Skip to content

Fix/chat moderation expiry - #134

Open
mp-blurify wants to merge 5 commits into
devfrom
fix/chat-moderation-expiry
Open

Fix/chat moderation expiry#134
mp-blurify wants to merge 5 commits into
devfrom
fix/chat-moderation-expiry

Conversation

@mp-blurify

@mp-blurify mp-blurify commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes two chat-moderation defects: BF-511, where listMutes returned mutes whose
duration had already run out, and BF-512, where nothing recorded an audit entry when
a chat mute or platform ban lapsed on its own.

Why

BF-511. listMutes filtered on liftedAt only, while listBans has always carried
expiresAt IS NULL OR expiresAt > now. Enforcement was never wrong — assertCanSend
applies that predicate in all three of its checks — so the player could already post
again while the admin listing went on reporting them as muted, through a hard reload.
Only the listing disagreed with the rest of the system.

BF-512. Expiry in this domain is purely a read-time predicate: no sweep, no job, no
domain event. Audit entries were written only inside the actor-initiated methods, so a
timed ban appeared on the trail as created and then nothing at all. An auditor could
see moderation start and never see it end, with no way to establish when it stopped
applying — a gap that matters under a licence where the audit trail is the record.

Scope

Platform-level moderation only: chat_mute and chat_platform_ban, the tables the
backoffice moderation surface drives. chat_room_mute and chat_room_ban carry
expiresAt too and have the identical audit gap, but they are the per-room moderator
tools rather than BF-512's subject, and covering them means two more columns and a
second migration. Deliberate cut, tracked separately - not an oversight.

Alternatives considered

Recording history instead of backfilling. Leaving expiry_recorded_at NULL on
upgrade would give a complete trail, but the first tick would emit one entry per lapse
the operator had ever issued. Those lapses are already untraceable and nothing makes
them less so, so the migration stamps them as recorded and the trail starts at this
release.

A timer or event per row rather than a cron sweep. Nothing observes the moment a
row lapses, so any such mechanism would have to be invented and kept durable. Since the
audit entry is dated from the row's own expiresAt, cron cadence has no bearing on
what the entry says — only on how soon it appears.

Trusting the scan's expiry_recorded_at IS NULL guard. Two sweeps can select the
same row, so the guard is re-applied as an IS NULL-conditioned UPDATE that claims the
row and returns it. That is what makes "exactly one entry per lapse" hold rather than
"usually one".

Quarter-hourly on the */15 tick. That is minute-for-minute the wallet custody
sweep's schedule; the offset to 7,22,37,52 keeps the in-process driver from running
this alongside a money path.

Risks

Schema. Additive only: a nullable expiry_recorded_at on chat_mute and
chat_platform_ban, plus an expires_at index on the latter to match the one
chat_mute already had. The migration's backfill touches only rows that are unlifted
and already lapsed.

No behavioural change to enforcement. expiry_recorded_at is written by the sweep
and read nowhere else; assertCanSend still reads expiresAt and liftedAt only. Who
can post is unchanged. BF-511 changes only what the admin listing reports, bringing it
into line with what enforcement already did.

Rollout. Every app boot now registers one more BullMQ worker and repeating
schedule. Registration is idempotent by scheduleId and durable wherever REDIS_URL is
set; the in-process driver still ticks in dev.

Deferred — test flakiness. The two new integration files raise the parallelism of
the integration run, and a few pre-existing concurrency-sensitive tests fail
intermittently under it — pam/tag replacePlayerTag, fx/exchange-rate single-flight
and others, each passing in isolation. A control run of the full gate on clean dev was
green, and this branch's gate passed green on the commit; the affected tests were not
touched here. tag.service.int.test.ts documents the fragile assumption in its own
comment ("the seeds above leave a single warm connection in the pool"), which is what
gives way under the extra load. Worth hardening separately.

Mateusz Paś and others added 2 commits September 2, 2026 11:50
listMutes filtered on liftedAt only, so a mute whose duration had run out
was still returned by adminListMutes. Enforcement was never wrong -
assertCanSend applies the expiry predicate in all three of its checks, and
listBans has carried it all along - so the player could already post again
while the backoffice went on showing them as muted, through a hard reload.

Adds the same `expiresAt IS NULL OR expiresAt > now` arm listBans uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017horQY1Q63dWM7JLVEaVfP
Chat moderation expiry was purely a read-time predicate: no sweep, no job,
no event. Only the actor-initiated paths wrote audit entries, so a timed ban
appeared on the trail as `created` and then nothing - an auditor could see it
start and never see it end, with no way to tell when it stopped applying.

Adds a sweep that records `chat.mute.expired` / `chat.platform_ban.expired`
as actorType `system`. The entry's `before` carries the row's own expiresAt,
so it states when the moderation actually lapsed - the instant the player
could post again - rather than when the cron happened to run. A new
`expiryRecordedAt` column on both tables is the sweep's bookmark, never an
enforcement input; it is claimed with an `IS NULL`-guarded UPDATE in the same
transaction as the audit write, so concurrent sweeps still yield exactly one
entry per lapse.

The schedule is offset off the quarter-hourly tick the wallet custody sweep
owns, so the in-process driver never runs this alongside a money path.

The migration backfills the bookmark for entries that had already lapsed on
upgrade, so the trail starts at this release instead of the first tick
emitting one entry per lapse the operator has ever issued.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017horQY1Q63dWM7JLVEaVfP

@jakubfilinger-b jakubfilinger-b left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Went through this. The core of both fixes looks right to me, so this is a GO from my side. BF-511 is the same predicate listBans and all three assertCanSend checks already use, and the sweep's claim-then-audit in one transaction is the correct way to get "exactly one entry per lapse". Migration is additive and the backfill is properly scoped. A few things I'd like you to look at, none of them blocking:

  1. Scope. chat_room_mute and chat_room_ban both carry expiresAt too (schema/index.ts:185 and :210) and have exactly the same audit gap. The sweep doesn't cover them. If that's a deliberate cut to platform-level moderation only, please say so in the PR description or the ticket, otherwise it reads as an oversight.

  2. Worker no-ops silently. In plugin.ts the handler does if (!expiryRef) return;. In a process that starts workers without building routers, the sweep never runs and nothing tells us. A logger.warn instead of the bare return costs one line.

  3. unmute vs. sweep race. unmute filters on liftedAt IS NULL only, not on expiry. If an admin "lifts" a mute that has already lapsed after the sweep claimed it, we end up with both chat.mute.expired and chat.mute.lifted on the trail for the same row. Narrow window, and the test only covers the opposite ordering.

  4. Default cron lives in two places. .default('7,22,37,52 * * * *') in platform-config.ts and the constant in plugin.ts. The comment acknowledges it but nothing enforces it. Also the cron string isn't validated - a typo in an operator's config gives us one logger.error at boot and no sweep, with no other signal.

  5. Audit chain contention. recordInTransaction takes the global pg_advisory_xact_lock('audit_log'), so a full batch is 500 sequential acquisitions of that lock per tick, competing with ledger writes. Offsetting off */15 only avoids the custody sweep, not regular transaction traffic. With the backfill in place the first tick has no backlog so in practice this is fine, but 500 is the ceiling worth being aware of.

  6. No deadlock risk, I checked: unmute does its UPDATE outside a transaction and audits separately, so there's no reverse lock ordering against the sweep.

Comment thread packages/core/src/engagement/chat/schema/index.ts Outdated
Comment thread packages/core/src/engagement/chat/schema/index.ts Outdated
@marek-chmielowski-blurify

marek-chmielowski-blurify commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

I dont like the idea of saving audit log after the event had happened but i dont see any viable solution for that either. 👍

Mateusz Paś and others added 2 commits September 3, 2026 08:23
Resolves packages/core/src/contracts/schemas/platform-config.ts: dev renamed
AttachmentHostSchema to HostAllowlistEntrySchema for the CMS banner-image host
allowlist; this branch's chat.moderationExpiry block is kept alongside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyLqsaeoprPoPXehsU5BbR
- Trim the comment volume on the sweep service and drop the two schema-column
  comments.
- Warn instead of returning in silence when the sweep worker runs in a process
  that never built the routers, so a no-op tick is visible.
- Single-source the sweep cadence as CHAT_MODERATION_EXPIRY_DEFAULT_CRON in the
  config contract and validate an operator override against CronExpressionSchema,
  so a typo fails config validation instead of costing one logger.error at boot
  and a job that never ticks.
- Close the unmute/unban vs. sweep race: both now apply the same 'not lapsed'
  predicate listMutes/listBans and assertCanSend use, so a row carries
  chat.*.expired or chat.*.lifted on the trail, never both.

Refs BF-511, BF-512

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyLqsaeoprPoPXehsU5BbR
@mp-blurify

Copy link
Copy Markdown
Collaborator Author

Thanks — all six looked at. dev is merged in (ef4e865, one conflict: your rename of AttachmentHostSchema to HostAllowlistEntrySchema), fixes in 5b46b07, gate green.

1. Scope — chat_room_mute / chat_room_ban. Deliberate, and you're right that it wasn't written down. Now in the PR description. BF-512 is about the moderation surface the backoffice drives (ChatModerationServicechat_mute / chat_platform_ban); the room tables are the per-room moderator tools and carry the identical gap. Extending is mechanical — two columns, a migration, two audit actions, the sweep already loops over a table pair — but it is a second ticket's worth of schema on a bugfix PR. Happy to fold it in here instead if you'd rather have it in one migration; say the word.

2. Worker no-ops silently. Fixed — logger.warn('chat-moderation-expiry sweep skipped - service not constructed') in place of the bare return.

3. unmute vs. sweep race. Fixed, and in both directions: unmute and unban now carry the same expiresAt IS NULL OR expiresAt > now predicate that listMutes/listBans and all three assertCanSend checks already apply. A lapsed row is not active, so there is nothing to lift — the trail gets chat.*.expired or chat.*.lifted for a given row, never both. No API-surface change (unmute/unban already returned { success: true } whether or not a row matched), and an admin cannot reach a lapsed row from the listing anyway now that BF-511 hides it. New test covers the ordering the old one didn't.

4. Cron in two places, unvalidated. Both fixed. The default is now CHAT_MODERATION_EXPIRY_DEFAULT_CRON exported from platform-config.ts — it is the zod .default() and the plugin's fallback, so there is one string. The operator's override goes through a new CronExpressionSchema (structural: 5 fields, or 6 with a leading seconds field), so a typo fails config validation at boot rather than costing one logger.error and a job that silently never ticks. Not a full parse — the driver owns that — but it catches the typo class you're describing. Note wallet.sweep.cron, wallet.reconciliation.cron and the rest have the same two-places-and-unvalidated shape; CronExpressionSchema is exported and ready for them, but I left them out of this diff rather than change boot-failure behaviour for wallet in a chat bugfix.

5. Audit chain contention. Agreed on the ceiling, no change. Worth recording why it stays: EXPIRY_SWEEP_BATCH_SIZE is 500 per table per tick, so the worst case is 1000 sequential pg_advisory_xact_lock('audit_log') acquisitions on a tick, not 500. That worst case needs 500 rows on one table to lapse inside a single 15-minute window with the backfill already drained, which is an operator issuing timed moderation at a rate we don't see. The steady state is a handful per tick. If it ever does bite, the fix is batching the chain write rather than lowering the cap — lowering it just moves the backlog to the next tick while holding the same lock the same number of times.

6. Deadlock. Matches what I read, and #3 doesn't change it: the new predicate is another condition on unmute's existing single UPDATE, still outside a transaction, still auditing separately.

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.

3 participants