fix: moderation audit trail — system users, MCP identity, date validation (#442)#443
Conversation
…tion (#442) Auto-published items had moderated_by=NULL (no audit trail), MCP admin tools passed null as userId (destroying approval records), and parseDate accepted year 62025 (mangled AI output bypassing recency filters). - Add system user rows: Auto-Publisher (id=-1) and MCP Admin (id=-2) - processItem: set moderated_by=-1 when auto-publishing - MCP_ADMIN_USER_ID: -2 instead of null - parseDate: reject years outside 2000-2100 - Backfill 508 existing NULL moderated_by items Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces system user accounts (Auto-Publisher and MCP Admin) to establish an audit trail for automated moderation actions, updates the date extractor to enforce a year range boundary (2000-2100), and tracks moderation metadata in the database. The review feedback identifies two critical logic issues in the moderation service where human moderator IDs could be overwritten by the system user or stale moderator IDs could be retained on non-published statuses. Additionally, the feedback suggests a fallback to collection_date in the SQL migration backfill to prevent null values in moderated_at.
| if (rescoredDate) { | ||
| await pool.query( | ||
| `UPDATE ${table} SET moderation_processed = true, moderation_status = $1, | ||
| publication_date = $2, date_consensus_score = $3, | ||
| ai_reasoning = $4, relevance_signals = $5, moderation_date = CURRENT_TIMESTAMP | ||
| ai_reasoning = $4, relevance_signals = $5, moderation_date = CURRENT_TIMESTAMP, | ||
| moderated_by = COALESCE($7, moderated_by), moderated_at = CASE WHEN $7 IS NOT NULL THEN CURRENT_TIMESTAMP ELSE moderated_at END | ||
| WHERE id = $6`, | ||
| [resolvedStatus, newDate, newScore, reasoning, | ||
| relevanceVotes.length > 0 ? JSON.stringify(relevanceVotes) : null, | ||
| contentId] | ||
| contentId, autoModeratedBy] | ||
| ); | ||
| } else { | ||
| await pool.query( | ||
| `UPDATE ${table} SET moderation_processed = true, moderation_status = $1, | ||
| date_consensus_score = $2, | ||
| ai_reasoning = $3, relevance_signals = $4, moderation_date = CURRENT_TIMESTAMP | ||
| ai_reasoning = $3, relevance_signals = $4, moderation_date = CURRENT_TIMESTAMP, | ||
| moderated_by = COALESCE($6, moderated_by), moderated_at = CASE WHEN $6 IS NOT NULL THEN CURRENT_TIMESTAMP ELSE moderated_at END | ||
| WHERE id = $5`, | ||
| [resolvedStatus, newScore, reasoning, | ||
| relevanceVotes.length > 0 ? JSON.stringify(relevanceVotes) : null, | ||
| contentId] | ||
| contentId, autoModeratedBy] | ||
| ); |
There was a problem hiding this comment.
There are two logic issues with the current moderated_by and moderated_at update logic:
- Overwriting Human Moderators: If an item was previously approved by a human admin (and thus has a valid human user ID in
moderated_by), and it is later re-processed byprocessItem(e.g., due to a re-score or force status) and still resolves to'published', the current code will overwrite the human moderator's ID with-1(Auto-Publisher) becauseCOALESCE(-1, moderated_by)always evaluates to-1. - Retaining Moderators on Non-Published Statuses: If an item was previously
'published'(withmoderated_byandmoderated_atset), and is later re-processed and resolves to'pending'or'rejected', the current code will retain the oldmoderated_byandmoderated_atvalues becauseCOALESCE(null, moderated_by)evaluates to the existingmoderated_by. A pending or rejected item should not have a moderator ID or moderation timestamp associated with it.
We can fix both issues by conditionally setting these fields based on whether the resolved status is 'published' and using COALESCE to preserve any existing human moderator.
await pool.query(
`UPDATE ${table} SET moderation_processed = true, moderation_status = $1,
publication_date = $2, date_consensus_score = $3,
ai_reasoning = $4, relevance_signals = $5, moderation_date = CURRENT_TIMESTAMP,
moderated_by = CASE WHEN $1 = 'published' THEN COALESCE(moderated_by, $7) ELSE NULL END,
moderated_at = CASE WHEN $1 = 'published' THEN COALESCE(moderated_at, CURRENT_TIMESTAMP) ELSE NULL END
WHERE id = $6`,
[resolvedStatus, newDate, newScore, reasoning,
relevanceVotes.length > 0 ? JSON.stringify(relevanceVotes) : null,
contentId, autoModeratedBy]
);
} else {
await pool.query(
`UPDATE ${table} SET moderation_processed = true, moderation_status = $1,
date_consensus_score = $2,
ai_reasoning = $3, relevance_signals = $4, moderation_date = CURRENT_TIMESTAMP,
moderated_by = CASE WHEN $1 = 'published' THEN COALESCE(moderated_by, $6) ELSE NULL END,
moderated_at = CASE WHEN $1 = 'published' THEN COALESCE(moderated_at, CURRENT_TIMESTAMP) ELSE NULL END
WHERE id = $5`,
[resolvedStatus, newScore, reasoning,
relevanceVotes.length > 0 ? JSON.stringify(relevanceVotes) : null,
contentId, autoModeratedBy]
);
}| UPDATE poi_news SET moderated_by = -1, moderated_at = COALESCE(moderated_at, moderation_date) | ||
| WHERE moderation_status = 'published' AND moderated_by IS NULL AND content_source = 'ai'; | ||
|
|
||
| UPDATE poi_events SET moderated_by = -1, moderated_at = COALESCE(moderated_at, moderation_date) | ||
| WHERE moderation_status = 'published' AND moderated_by IS NULL AND content_source = 'ai'; |
There was a problem hiding this comment.
If both moderated_at and moderation_date are NULL (which can happen for older items published before the moderation_date column was introduced), moderated_at will remain NULL after the backfill. Coalescing with collection_date provides a safe fallback timestamp to ensure moderated_at is populated for all published items.
UPDATE poi_news SET moderated_by = -1, moderated_at = COALESCE(moderated_at, moderation_date, collection_date)
WHERE moderation_status = 'published' AND moderated_by IS NULL AND content_source = 'ai';
UPDATE poi_events SET moderated_by = -1, moderated_at = COALESCE(moderated_at, moderation_date, collection_date)
WHERE moderation_status = 'published' AND moderated_by IS NULL AND content_source = 'ai';
Summary
moderated_by=NULL— add system user "Auto-Publisher" (id=-1) and set it on auto-publishnullas userId, destroying approval records — setMCP_ADMIN_USER_ID = -2("MCP Admin" system user)parseDateaccepted year 62025 from mangled AI output, creating far-future dates that permanently passed the recency filter — add 2000-2100 year range checkCloses #442
Test plan
./run.sh buildpassesSELECT COUNT(*) FROM poi_news WHERE moderation_status='published' AND moderated_by IS NULLreturns 0🤖 Generated with Claude Code