fix: deduplicateAccounts fixpoint loop and identity property tests - #579
Conversation
…operty suite fast-check found the bug on its 11th generated pool: a newest-wins merge in the email tier can install an account that duplicates an earlier survivor through a different tier (accountId + refresh token), so a single dedup pass could return a list still containing two records for the same identity. deduplicateAccountsByIdentity now re-runs the pass until the length stabilizes; every merge strictly shrinks the array, so the loop terminates after at most accounts.length passes. The new test/property/account-identity.property.test.ts pins: - normalizeEmailKey idempotence and case/whitespace insensitivity - findMatchingAccountIndex order-invariance under pool permutation, candidate-spelling invariance, facet-sharing soundness, foreign candidates never matching, and the email-ambiguity veto - deduplicateAccounts subset + idempotence properties, plus a deterministic replay of the discovered counterexample https://claude.ai/code/session_01XNtnkLbBiXZxfQQYLMpucB
|
Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits. |
📝 WalkthroughWalkthroughaccount deduplication now uses fixpoint iteration to eliminate duplicates introduced by newest-wins merges across identity tiers. a single-pass helper repeatedly applies until the array size stabilizes. comprehensive property-based tests verify idempotence, facet matching, and convergence. ChangesAccount Deduplication Fixpoint Convergence
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Suggested labels
review flagsthe fixpoint loop in
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
✨ Simplify code
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Review follow-up: the original replay only needed two shrinking passes; this chain exercises one merge per matching tier (email, then refresh, then unique-id) so the fixpoint loop is pinned as a real loop rather than a hardcoded second pass. https://claude.ai/code/session_01XNtnkLbBiXZxfQQYLMpucB
…racle (#593) * test: property-check the auth rate limiter against a sliding-window oracle Three fast-check properties over the real module under fake timers (maxAttempts 3 / 1s window so sequences cross the expiry boundary): - model-based: for any record/reset/advance interleaving across three accounts with decorated id spellings (case/whitespace variants), getAttemptsRemaining and canAttemptAuth match a trivial timestamps-in-window oracle keyed by canonical id - pinning both the sliding window and the trim+lowercase bucket mapping at once - checkAuthRateLimit throws AuthRateLimitError exactly when blocked, carrying the canonical accountId, zero attemptsRemaining, and a resetAfterMs that agrees with the live getTimeUntilReset - over-recording past maxAttempts can never wedge a bucket: any burst unblocks fully after one quiet window, with getTimeUntilReset bounded by windowMs throughout Config and registry are module state, so each property restores the documented defaults and clears buckets, and ids are namespaced per iteration. Companion to #574/#575/#579/#592; same conventions. https://claude.ai/code/session_01XNtnkLbBiXZxfQQYLMpucB * test: exhaustive event narrowing and constant-derived gaps length Greptile flagged the bare else branches (a future event kind would silently model as a reset) and the literal 11 coupled to MAX_ATTEMPTS * 4 - 1. https://claude.ai/code/session_01XNtnkLbBiXZxfQQYLMpucB --------- Co-authored-by: Claude <[email protected]>
…contracts (#594) * test: property-check SessionAffinityStore TTL, eviction, and reindex contracts Five fast-check properties over the real store using its injectable now parameters (1s TTL floor so sequences cross expiry often): - model-based TTL/upsert equivalence: for any remember/update/forget/ advance interleaving through whitespace-decorated key spellings, getPreferredAccountIndex and getLastResponseId match a trivial TTL map (remember preserves the continuation id, response-id writes refresh expiry and never create entries) - capacity: size() never exceeds maxEntries, and LRU eviction can never evict the entry just written - write-version conflicts: a stale version loses to a live entry on both the index and response-id channels, but may rebind once the entry expires - forgetAccount + reindexAfterRemoval mirror an account-array splice, with both return counts pinned against the model - prune removes exactly the expired entries; lazily-reaped sessions (touched while expired) correctly do not count as prunable The prune model initially missed that updateLastResponseId deletes an expired entry outright; fast-check found the 8-event counterexample and the model now mirrors the lazy reap. Companion to #574/#575/#579/#592/#593; same conventions. https://claude.ai/code/session_01XNtnkLbBiXZxfQQYLMpucB * test: fix remember model after lazy reap; cover clearAll; decorate prune keys Greptile P1: the model carried a responseId across expiry, but the assertion block's reads lazily reap expired entries from the store, so a remember after expiry finds no existing entry and the id is gone - the model now inherits the id only from a live entry (verified at FAST_CHECK_NUM_RUNS=1000, where the original 4-event counterexample sequence reproduces without the fix). P2s: a sixth property pins clearAll (#474 invalidation) - size drops to zero, every read goes null, and the store stays usable - and the prune property now routes remember/forget/updateLastResponseId through decorated key spellings like the model property does. https://claude.ai/code/session_01XNtnkLbBiXZxfQQYLMpucB --------- Co-authored-by: Claude <[email protected]>
* test: property-check the circuit breaker's availability contract Five fast-check properties over the real CircuitBreaker under fake timers (small windows so sequences cross every boundary): - isAvailable/getTimeUntilAvailable agree with the very next canExecute outcome after ANY event sequence - the polling view callers route on is a faithful prediction, never stale - an open circuit rejects strictly before resetTimeoutMs and admits the probe exactly at it, from any prior history (fresh forced open) - opens exactly at failureThreshold inside one window, never below - failures spaced beyond failureWindowMs never accumulate - the half-open slot admits exactly one probe; success closes with a clean failure count, failure reopens for a full timeout Companion to the property suites in #574/#575/#579; same conventions. https://claude.ai/code/session_01XNtnkLbBiXZxfQQYLMpucB * test: pin getTimeUntilReset alongside the injectable-now twin https://claude.ai/code/session_01XNtnkLbBiXZxfQQYLMpucB --------- Co-authored-by: Claude <[email protected]>
Summary
deduplicateAccountswas not idempotent — one pass could return a list still containing two records for the same account. This PR fixes the implementation and lands the suite that caught it.What Changed
The bug (
lib/storage.ts):deduplicateAccountsByIdentitymade a single pass, and a newest-wins merge in one matching tier can install an account that duplicates an earlier survivor through a different tier. Minimal reproduction (now a deterministic test):{accountId: acc-1, refreshToken: rt-1}(no email) is kept.{email: [email protected], refreshToken: rt-2}(no accountId) is kept.{accountId: acc-1, email: [email protected], refreshToken: rt-1}arrives — the email tier matches record 2 first and replaces that slot.The result keeps both record 1 and record 3, which are the same account by accountId + refresh token; a second pass would have merged them. The fix: re-run the dedup pass until the length stabilizes. Every merge strictly shrinks the array, so the loop terminates after at most
accounts.lengthpasses; the kept entry is still the most-recently-used one, exactly as the function's contract documents.The suite (
test/property/account-identity.property.test.ts, 9 tests):normalizeEmailKey: idempotence, case/whitespace insensitivity, undefined on blank inputfindMatchingAccountIndex: the matched account is invariant under pool permutation (pools generated with strictly distinctlastUsedso newest-wins has a unique answer); candidate email respelling never changes the result; any match shares at least one identity facet with the candidate; candidates with all-foreign facets never match; email-only candidates are vetoed when the email maps to multiple accountIdsdeduplicateAccounts: output is a subset of input (object identity — dedup never synthesizes), idempotence, and a deterministic replay of the discovered counterexampleValidation
npm run typechecknpx eslint lib/storage.ts test/property/account-identity.property.test.ts --max-warnings=0npm test -- test/property/account-identity.property.test.ts— 9/9storage,storage-async,import-export,account-pool,account-port,rotation-integration,refresh-guardian,runtime-auth-facade,codex-manager-login-workspace-512): only the 23 known environment failures instorage.test.ts, name-diffed exactly againstdocs/audits/evidence/test-baseline-2026-06-10.txtmain(this branch is based onmainwithout it); zero failures attributable to this changeDocs and Governance Checklist
Risk and Rollback
deduplicateAccountsruns on the storage load path, so the fix can merge account records that previously (incorrectly) survived as duplicates. The kept record is always the most-recently-used of the merged set, which is the documented contract; pools without the multi-tier corner are byte-identical to before (the first pass is unchanged, and a length-stable pass is a pure copy).Additional Notes
fc.assert, explicit vitest imports, small identity alphabets so generated pools actually collide on facets).https://claude.ai/code/session_01XNtnkLbBiXZxfQQYLMpucB
Generated by Claude Code
note: greptile review for oc-chatgpt-multi-auth. cite files like
lib/foo.ts:123. confirm regression tests + windows concurrency/token redaction coverage.Greptile Summary
fixes
deduplicateAccountsByIdentityso it re-runs until the output length stabilises, addressing a real idempotence failure where a newest-wins email-tier merge could leave an earlier survivor still matching a second record through a different tier. the property suite that caught the bug is also landed, including a new deterministic 3-shrinking-pass test that pins the full convergence chain.lib/storage.ts: split the inner loop intodeduplicateAccountsByIdentityPass(readonly T[]), wrapped by a newdeduplicateAccountsByIdentitythat loops untilnext.length === current.length; the length check is a sound fixpoint condition because every merge reduces the count by exactly one, so length-stable ↔ zero merges ↔ content-stable.test/property/account-identity.property.test.ts: 9 fast-check/deterministic tests fornormalizeEmailKey,findMatchingAccountIndex, anddeduplicateAccounts, including explicit replay of the discovered counterexample and a new 3-pass convergence pin.Confidence Score: 5/5
the change is safe to merge; the fixpoint loop is a pure in-memory operation on small arrays, the termination argument is airtight, and pools without the multi-tier corner case are byte-identical to the previous output.
the fix is narrowly scoped to
deduplicateAccountsByIdentityand does not touch storage I/O, token handling, or any concurrent path. the length-stable fixpoint condition is provably correct, the property suite fully exercises the affected code paths, and the deterministic regression pins the exact counterexample.no files require special attention; both changed files are clean.
Important Files Changed
deduplicateAccountsByIdentityPass(now takesreadonly T[]), then wrapped it in a newdeduplicateAccountsByIdentitythat loops to the length-stable fixpoint — termination is sound because every merge strictly shrinks the array by exactly one entryFlowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD A["deduplicateAccounts(accounts: T[])"] --> B["deduplicateAccountsByIdentity(accounts)"] B --> C["current = deduplicateAccountsByIdentityPass(accounts)"] C --> D{"next = deduplicateAccountsByIdentityPass(current)\nnext.length === current.length?"} D -- "yes (fixpoint reached)" --> E["return next"] D -- "no (merged ≥1 account)" --> F["current = next"] F --> D G["deduplicateAccountsByIdentityPass(accounts: readonly T[])"] --> H["for each account"] H --> I{"findMatchingAccountIndex\nin deduplicated?"} I -- "no" --> J["push account"] I -- "yes" --> K["deduplicated[i] = selectNewestAccount(existing, account)"] J --> H K --> HReviews (2): Last reviewed commit: "test: pin three-pass dedup convergence c..." | Re-trigger Greptile