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
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: carol@example.com, refreshToken: rt-2}(no accountId) is kept.{accountId: acc-1, email: carol@example.com, 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