Skip to content

fix: moderation audit trail — system users, MCP identity, date validation (#442)#443

Merged
fatherlinux merged 2 commits into
masterfrom
fix/442-moderation-audit
May 29, 2026
Merged

fix: moderation audit trail — system users, MCP identity, date validation (#442)#443
fatherlinux merged 2 commits into
masterfrom
fix/442-moderation-audit

Conversation

@fatherlinux
Copy link
Copy Markdown
Member

Summary

  • Auto-published items had moderated_by=NULL — add system user "Auto-Publisher" (id=-1) and set it on auto-publish
  • MCP admin tools passed null as userId, destroying approval records — set MCP_ADMIN_USER_ID = -2 ("MCP Admin" system user)
  • parseDate accepted year 62025 from mangled AI output, creating far-future dates that permanently passed the recency filter — add 2000-2100 year range check
  • Migration creates both system users and backfills 508 existing NULL moderated_by items

Closes #442

Test plan

  • ./run.sh build passes
  • Tests pass (3 pre-existing flaky UI failures unrelated)
  • Run migration 068 on production
  • Verify SELECT COUNT(*) FROM poi_news WHERE moderation_status='published' AND moderated_by IS NULL returns 0
  • Approve an item via MCP, verify moderated_by=-2

🤖 Generated with Claude Code

fatherlinux and others added 2 commits May 28, 2026 23:22
…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>
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 405 to 426
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]
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There are two logic issues with the current moderated_by and moderated_at update logic:

  1. 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 by processItem (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) because COALESCE(-1, moderated_by) always evaluates to -1.
  2. Retaining Moderators on Non-Published Statuses: If an item was previously 'published' (with moderated_by and moderated_at set), and is later re-processed and resolves to 'pending' or 'rejected', the current code will retain the old moderated_by and moderated_at values because COALESCE(null, moderated_by) evaluates to the existing moderated_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]
      );
    }

Comment on lines +12 to +16
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';
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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';

@fatherlinux fatherlinux merged commit 30a1f25 into master May 29, 2026
3 checks passed
@fatherlinux fatherlinux deleted the fix/442-moderation-audit branch May 29, 2026 08:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Moderation audit trail gaps — auto-publish, MCP, and date validation

1 participant