Fix/chat moderation expiry - #134
Conversation
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
left a comment
There was a problem hiding this comment.
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:
-
Scope.
chat_room_muteandchat_room_banboth carryexpiresAttoo (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. -
Worker no-ops silently. In
plugin.tsthe handler doesif (!expiryRef) return;. In a process that starts workers without building routers, the sweep never runs and nothing tells us. Alogger.warninstead of the bare return costs one line. -
unmute vs. sweep race.
unmutefilters onliftedAt IS NULLonly, not on expiry. If an admin "lifts" a mute that has already lapsed after the sweep claimed it, we end up with bothchat.mute.expiredandchat.mute.liftedon the trail for the same row. Narrow window, and the test only covers the opposite ordering. -
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 onelogger.errorat boot and no sweep, with no other signal. -
Audit chain contention.
recordInTransactiontakes the globalpg_advisory_xact_lock('audit_log'), so a full batch is 500 sequential acquisitions of that lock per tick, competing with ledger writes. Offsetting off*/15only 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. -
No deadlock risk, I checked:
unmutedoes its UPDATE outside a transaction and audits separately, so there's no reverse lock ordering against the sweep.
|
I dont like the idea of saving audit log after the event had happened but i dont see any viable solution for that either. 👍 |
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
|
Thanks — all six looked at. 1. Scope — 2. Worker no-ops silently. Fixed — 3. 4. Cron in two places, unvalidated. Both fixed. The default is now 5. Audit chain contention. Agreed on the ceiling, no change. Worth recording why it stays: 6. Deadlock. Matches what I read, and #3 doesn't change it: the new predicate is another condition on |
Summary
Fixes two chat-moderation defects: BF-511, where
listMutesreturned mutes whoseduration 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.
listMutesfiltered onliftedAtonly, whilelistBanshas always carriedexpiresAt IS NULL OR expiresAt > now. Enforcement was never wrong —assertCanSendapplies 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
createdand then nothing at all. An auditor couldsee 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_muteandchat_platform_ban, the tables thebackoffice moderation surface drives.
chat_room_muteandchat_room_bancarryexpiresAttoo and have the identical audit gap, but they are the per-room moderatortools 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_atNULL onupgrade 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 onwhat the entry says — only on how soon it appears.
Trusting the scan's
expiry_recorded_at IS NULLguard. Two sweeps can select thesame row, so the guard is re-applied as an
IS NULL-conditioned UPDATE that claims therow and returns it. That is what makes "exactly one entry per lapse" hold rather than
"usually one".
Quarter-hourly on the
*/15tick. That is minute-for-minute the wallet custodysweep's schedule; the offset to
7,22,37,52keeps the in-process driver from runningthis alongside a money path.
Risks
Schema. Additive only: a nullable
expiry_recorded_atonchat_muteandchat_platform_ban, plus anexpires_atindex on the latter to match the onechat_mutealready had. The migration's backfill touches only rows that are unliftedand already lapsed.
No behavioural change to enforcement.
expiry_recorded_atis written by the sweepand read nowhere else;
assertCanSendstill readsexpiresAtandliftedAtonly. Whocan 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_URLisset; 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/tagreplacePlayerTag,fx/exchange-ratesingle-flightand others, each passing in isolation. A control run of the full gate on clean
devwasgreen, and this branch's gate passed green on the commit; the affected tests were not
touched here.
tag.service.int.test.tsdocuments the fragile assumption in its owncomment ("the seeds above leave a single warm connection in the pool"), which is what
gives way under the extra load. Worth hardening separately.