Guest cleanup: batch candidate IDs and re-assert the delete predicate (closes #155) - #156
Conversation
…closes #155) run() passed the full candidate list to IN(...) queries; the two-column game lookup emits 2xN binds, so past ~16k candidates it exceeded the driver bind-parameter ceiling, threw, and (swallowed by the scheduler) stopped guest cleanup permanently. Now processes candidates in batches of batchSize so every IN(...) stays under the ceiling regardless of candidate count. Also re-asserts appleUserID == null inside the user delete, so an account that links Sign in with Apple between the candidate read and the delete is spared (L1 TOCTOU). The existing AuthAbuseTests cleanup cases still guard correctness for small candidate sets. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E9dkcpbwWWHrdGzpvWZd1T
…no-space) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E9dkcpbwWWHrdGzpvWZd1T
|
Security review (rule 5) — BLOCK @ Classification: security-adjacent — account lifecycle / Apple-linking in Fixes: both correct.
Blocker — missing regression for the load-bearing bug. Required before APPROVE: make Staying draft; no — orchestrator/reviewer, 2026-07-12 |
#155) Security review (rule 5) asked for a regression that exercises the load-bearing M3 fix. batchSize is now an injectable parameter (default unchanged), and a new test seeds 5 deletable guests, runs with batchSize 2, and asserts all 5 are reaped across the 2+2+1 batch boundaries — the behavior the pre-batch single-IN(...) query couldn't do past the bind ceiling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E9dkcpbwWWHrdGzpvWZd1T
|
Added the required batch-boundary regression at Generated by Claude Code |
Security review (rule 5) accepted the batch-boundary regression at e7d51ee but left the L1 mid-pass-link re-assertion untested; the prior pass judged it unreachable without instrumenting run(). It's reachable with a small seam. - run() gains `afterCandidateSnapshot`, a no-op-by-default async closure that fires once inside the TOCTOU window between the candidate read and the reaps. testCleanupSparesGuestLinkedMidPass uses it to commit an Apple link on a candidate mid-pass and asserts the DELETE's `appleUserID == null` re-assertion spares it while two controls are still reaped. Removing the re-assert makes the test fail (verified). - Fix a contract violation the test surfaced: `removed` counted `deletable.count`, so a guest spared by the re-assert inflated the "accounts deleted" total the scheduler logs. Now count rows actually reaped (deletable minus survivors), so the returned/logged count is truthful. The batch test's removed==5 is unchanged. Full server suite green (87 tests). Still draft; no self-approval. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Re-review requested @ Following up on the BLOCK's optional item (b), the mid-pass link TOCTOU. The prior pass called it unreachable "without instrumenting the function's internals" — it's reachable with a small, contained test seam, so I added the coverage rather than leaving L1 untested. What changed since
Unchanged & still holding: the M3 batch-boundary regression at Full server suite green locally (87 tests, 0 failures); Staying draft; not self-approving. Awaiting |
|
Both catches are fair and appreciated. You're right that (b) was reachable with a seam — I punted on it too early rather than reaching for the same Author-side confirmation: freezing this branch at Generated by Claude Code |
|
Security review: APPROVE @ 2510253 Root-agent review (CLAUDE.md rule 5). L1 TOCTOU fix (delete-time |
Summary
Closes #155 (audit findings M3 + L1).
M3 — cleanup stopped reaping forever past the bind-param ceiling.
run()passed the whole candidate list to~~(IN) queries; the game-history lookup filters two columns (whiteIDORblackID), so it emits2×Nbinds. Past ~16k candidates that exceeds the driver ceiling (SQLite 32,766 / Postgres 65,535),run()throws, andGuestCleanupSchedulerlogs-and-swallows it — so once the candidate set crossed the limit, no guest was ever reaped again and the users table grew without bound. Now the candidate IDs are processed in batches ofbatchSize(1000), so everyIN(…)stays well under the ceiling regardless of candidate count.L1 — delete TOCTOU. A candidate could link Sign in with Apple (or refresh) between the read and the
DELETE, and get reaped anyway. The user delete now re-assertsappleUserID == null, so an account that became recoverable mid-pass is spared — closing the irreversible-data-loss case. (The narrow refresh-token re-activation window remains a documented low-severity residual, noted in the issue.)Planned on the main model; the mechanical edit applied by a fast model against an exact spec, verified by grep.
Test plan
swift testinChessKit/passes (untouched)swift testinchess-server/passes — the server lane is the verifier (this session can't build Swift locally). The existingAuthAbuseTestscleanup cases (testCleanupDeletesOnlyAbandonedGuests,…KeepsGuestWhoPlayedAsBlack,…IsIdempotent…) still guard correctness; the batched loop is behavior-identical for small candidate sets.No dedicated bind-ceiling test — reproducing it needs 16k+ seeded accounts, which isn't practical in the suite; the batching is a straightforward loop over the same per-batch logic the existing tests already cover.
🤖 Generated with Claude Code
https://claude.ai/code/session_01E9dkcpbwWWHrdGzpvWZd1T
Generated by Claude Code