Most messenger accounts are single-account by design: you sign in, the session
lives in one token slot, and every local cache is global. That works...
For further actions, you may consider blocking this person and/or reporting abuse
The captured-segment rule is the heart of this, and the test that proves it has to assert an absence, which is the part most suites skip. Fetch under A, switch to B mid-flight, then don't just check A's cache got the write - enumerate B's namespace and assert nothing landed there. A leak is a write that shouldn't exist, and "A still works" can pass while B is quietly poisoned. Same for the epoch stamp: the late profile fetch finishing under B is only a real test if B's UI and stores are inspected for A's data, not merely left unrefreshed. One more fixture for the eviction path, because it combines your two hardest mechanisms: six saved accounts, account F being activated, and the true LRU has a token refresh in flight at the moment it's evicted. The rotated token arrives after eviction. Assert it doesn't resurrect the evicted slot - the account id is read from the refresh token itself, which is exactly the information that would write into a deleted namespace. That's the case where "persist into the requesting account's slot" and "the slot no longer exists" collide, and whichever behavior you choose (drop silently, queue for re-add) deserves a pinned test rather than an accident.
the absence assertion is the right shape — a leak is a write that shouldn’t exist, and checking that A still works can pass while B is quietly poisoned.
the capture primitive is already pinned: account_scope_isolation_test switches the live segment between awaits and asserts the operation keeps its original account, and the text-cache/freshness stores are checked for cross-account visibility. what isn’t covered yet is the end-to-end fixture you describe — switch mid-fetch, then enumerate B’s namespace and assert nothing landed there.
the eviction + in-flight refresh case is handled in code: the rotated token is persisted under the account that started the refresh, and persistence is skipped if that account was removed, so it can’t resurrect an evicted slot. but you’re right that it deserves a pinned test instead of staying an implementation detail. both cases belong in the suite.
Both pins write themselves from the failure shapes. Mid-fetch: start a fetch under A, switch to B between the await and the write, let it land, then assert two directions - B's namespace is empty AND A's data is intact. Checking only the first can pass while the write clobbered A's slot instead. Eviction: start a refresh under A, evict A mid-flight, complete the refresh, then assert no persistence happened and no slot resurrected - and enumerate B too, because "skipped the write" and "wrote it to the wrong account" are the same test from the runner's seat unless you look in both places. The rotated-token path is exactly the one where "handled in code" rots quietly, since nothing failing looks wrong when the account is gone. Thanks for checking what's actually pinned instead of defending the list.
thx 4 attention I am sorry I have no time now to answer properly now, I do it later!!
life got in the way of the proper reply — sorry for the wait.
you're right about the two directions, and the current mid-fetch pin is weaker than it looks on the A side.
chat_initial_load_testnow covers both the foreground first load and the debounced prefetch: start under A, switch to B before the result lands, then enumerate both stores — B empty, and A empty because the late result is dropped instead of written into its captured slot. but A starts empty in that test, so "A's data intact" can pass by accident. seeding A with a row before the fetch is the missing half, and I'm not calling that direction pinned until it's in.eviction is still not pinned. the code drops the rotated token when the account slot is gone — persisting it would resurrect the eviction and undo the LRU guarantee — but that decision lives only in the refresh path, which is exactly the thing that rots quietly. the fixture you described (six accounts, activate F, evict A mid-refresh, let the rotation land, assert no write, no resurrected slot, and enumerate B) is on the list as its own test rather than folded into the switch test.
thanks for pushing on the difference between "skipped the write" and "wrote it somewhere else"; that's the assertion that makes the test worth having.
Worth the wait. The gap you caught in your own test is the instructive part: "A's data intact" over an empty store is a vacuous assertion - it passes no matter what the code does. The rule I'd pin next to it: every "X is unchanged" assertion needs a pre-state that would fail if the code touched it. Seed, then assert. And "the decision lives only in the refresh path, which is exactly the thing that rots quietly" is the reason the eviction fixture can't stay on the list - a guarantee that exists only as the absence of a write in one path is invisible to code review, which sees presence. The six-account test is what makes the negative guarantee ("no write, no resurrected slot") something that fails loudly the day someone persists that token for a plausible reason. "Skipped the write" vs "wrote it somewhere else" was your framing to keep - it is the difference between a guard and a hope.
“Validate the target, then tear down, then publish” is the right order, and capturing the account before the first await is a detail most apps miss.
Two more places where account A can leak into account B that aren’t local storage:
1. Push notifications. If the device’s FCM token is registered on the server under account A, A’s messages keep arriving while B is active, and tapping one opens a chat B can’t see. On a switch, the token has to move to the new account on the server (or be kept per account, with notifications routed by account id).
2. Purchases. With a billing SDK like RevenueCat, the customer identity is cached on the device. Without an explicit logIn with the new user id on every switch, one account’s Pro status can show up on the other until the next refresh.
Does tonGues have paid features tied to the account, or is everything free for now?
fair — sign-out/sign-in is where the billing identity bites hardest, and it's the same ordering rule: logIn the new identity before anything scoped reads it. thanks for the honest answer on the prefetch race; that's the useful kind of comment. on FCM, payload-side account id plus drop-on-mismatch is the shape I want — the payload just doesn't carry the recipient id yet, only the sender's.
Building on the push-notification point — the FCM token is a two-sided trap, and the nastier half lives on the device.
Server side is what you described: one token per app install, so on a switch the token has to move atomically from A to B. If re-registering under B isn't a single transactional detach-then-attach call, there's a window where A keeps getting pushes to a device that's now B's — or B's messages silently never arrive. Registering only at app start misses the problem; the re-map belongs inside the switch sequence, after validation and before publish, like everything else here.
The other half is the local handler. A push for A can arrive between the tap and the switch completing, so onMessage can fire with A's payload while B is the active segment. Unless the handler resolves the sender's account from the payload before touching any scoped store, A's message lands in B's database — or A's sender name shows up in B's drawer, the tap-a-chat-B-can't-see scenario above. Tag the payload with the account id and drop it on mismatch with the active epoch, the same way late async work gets dropped.
One more: dismiss A's pending notifications during teardown, not after. A notification posted two minutes before the switch stays in the drawer after B is active.
the mapping itself isn't detach-then-attach: register-token reassigns user_id on the existing row in a single UPDATE, so there's no half-moved row. the window is the client ordering. it does re-register on every session setup, including a switch, but session setup runs after the session is published — and it does a fleet lookup and a WS connect before the token POST. so between publish and that POST the device is B while the token still resolves to A. moving the re-map into the switch, before publish, is the fix. (the endpoint also returns success when the commit fails, so a stale mapping can survive silently.)
one correction on the handler: it doesn't write message rows. the background path marks the conversation as having advanced and posts a local notification; the foreground path only posts the notification. so a stale A payload under B leaves an activity marker in B's store and A's notification in the drawer, not A's message in B's database. the mismatch check is still the right shape, but the payload only carries the sender's user_id, not the recipient account, so there's nothing to compare until the server adds that field.
on dismissal, agreed — teardown cancels prefetchers and the socket but doesn't clear the drawer. but dismissing there only covers what already arrived: a push that lands in the gap between teardown and the token re-map will still post. so the three points are one ordering problem — re-map before publish, carry the recipient account in the payload, and sweep the drawer during teardown. the sweep without the other two just shortens the window.
Fair point — a single UPDATE means the mapping itself is atomic, so there's no half-moved row at the DB layer. The window I had in mind is exactly the ordering one you describe: publish lands while the token still resolves to A. Moving the token POST before publish (or gating publish on the new token's epoch) closes it without needing a lock.
yep — that was one of the three bugs the review pass caught. a prefetch that started under A finished after the switch and wrote into B’s message database. the fix is the pattern you describe: capture AccountScope.segment before the first await, bail if the live segment changed, and pass the captured segment into the cache write instead of letting it re-read the current account at completion time.
teardown also cancels queued prefetchers, and the upload outbox + chunked-upload dirs are per-account, so a send can’t resume under the new session. the FCM background isolate is the exception — it has no AccountScope, so it reads the persisted active account id. did you hit this in an isolate or in ordinary in-flight async work?
Isolation is the right thing to focus on. One place that leaks often: background work. A sync for account A can finish after the switch and write into B's cache. Tag every job with its account id. Drop any result that doesn't match the active account. Did you run into it?
yep — that was one of the three bugs the review pass caught. a prefetch that started under A finished after the switch and wrote into B’s message database. the fix is the pattern you describe: capture AccountScope.segment before the first await, bail if the live segment changed, and pass the captured segment into the cache write instead of letting it re-read the current account at completion time.
teardown also cancels queued prefetchers, and the upload outbox + chunked-upload dirs are per-account, so a send can’t resume under the new session. the FCM background isolate is the exception — it has no AccountScope, so it reads the persisted active account id. did you hit this in an isolate or in ordinary in-flight async work?
Ordinary in-flight async work is the common case. A sync starts before the switch and finishes after it. Capturing the scope before the first await is the clean fix. On the FCM path, a switch can still happen between receipt and the write. If the payload carries the target account id, the isolate writes there directly. Then the active id does not matter.
agreed on ordinary in-flight work being the common case. on FCM: the payload's user_id is the sender, not the recipient account, so the isolate can't route by it today — it falls back to the persisted active id for the activity marker (the push itself doesn't write message rows). adding the recipient account id to the payload is the version that removes the race: write to the named account's namespace, or drop on mismatch, and the active id stops mattering. the server already knows the recipient, so it's a payload field, not a lookup.