fix(web): session-generation guards for the non-message stores (F38)#22
Conversation
…tores (F38) Only messageStore guarded against a request settling after an identity boundary. serverStore, dmStore, unreadStore, and permissionStore all awaited an API call then wrote state with no guard, so a request that RESOLVED at the logout boundary (which abortInFlightRequests cannot cancel) or a bit after the AppPage effect cleanup could write the previous account's data into the next session. Add a shared session-lifecycle module (src/api/session.ts) with a narrow API -- captureSessionGeneration / isSessionGenerationCurrent / invalidateSession -- rather than exported mutable state. invalidateSession() advances the generation synchronously at every identity boundary (logout and account replacement in authStore) BEFORE aborting requests, disconnecting the socket, or resetting stores; token refresh (same identity) does not advance it. client.ts's token-refresh guard now uses this shared generation instead of its own private epoch. Every async action in the four stores captures the generation at entry and, after each await (and in catch), bails before touching state -- so a stale continuation cannot write the store, set/clear an error, clear a new session's loading, fire a second request in a multi-await action, or return an old result as success (createDM now returns undefined in that case). messageStore is intentionally left on its own local epoch (its reset semantics are entangled with fetch ownership). Tests (mutation-verified): held read/mutation/rejection cannot touch the new session; a new-session request commits while an older one settles after and the old one cannot clear the new loading state; a multi-await action stops after the first await; createDM returns undefined; and logout advances the generation before abort and reset run. 156 web tests. test/typecheck/build/lint all green.
|
Reviewed Bastion PR #22 at The store-side generation checks are directionally right, but I found three blockers. 1. Login/registration completions do not own the generation they started under
The same omission allows an older concurrent login to replace a newer one or an older rejected login to write an error into the newer session. Capture the generation at auth-action entry, check it after the await and in
2. A stale refresh rejection can log out the newer accountThe refresh success path checks
Generation ownership must cover the refresh failure continuation before it clears tokens, invokes auth failure, or affects queued work. Add a regression using a held old refresh and a newer authenticated session. 3. Two of the four store integrations are not regression-pinnedThe new tests exercise server-store and DM-store guards, but never import Add held old-session success regressions for both stores (at minimum Validation
PR #22 is not ready to merge. The shared generation is the right authority, but the auth operations that create/end identities must obey it too. Otherwise the gate is sound and the gatekeeper occasionally arrives from yesterday. |
…38 round 2) The shared generation was the right authority, but the operations that create and end identities did not fully obey it: 1. login/register now advance the generation at ENTRY (a new-identity boundary), so a later-started auth op supersedes an earlier one, and capture it to bail after the await -- a login/register resolving after a newer boundary (logout or a concurrent attempt) no longer resurrects the dead session. Registration previously never invalidated the prior generation at all. 2. The token-refresh REJECTION path now checks the captured generation too (the success path already did): a held account-A refresh that 401s after account B logs in no longer clears B's tokens or fires auth failure. 3. Regression coverage that was missing: held-old-session tests for unreadStore.fetchReadStates/ackChannel and permissionStore.fetchPermissions (removing their guards previously left the suite green), plus login-superseded, register-invalidates-at-entry, and the stale-refresh-rejection interceptor test. All mutation-verified. 162 web tests. test/typecheck/build/lint all green.
|
Reviewed Bastion PR #22 round two at The five reported changes are present and mutation-sensitive, but I found three remaining session-boundary blockers. 1.
|
…und 3) 1. loadFromStorage now captures the generation and guards its post-apiGetMe continuation: a validation that resolves after an identity boundary no longer repopulates the user, and one that rejects no longer logs out a newer session. 2. login/register no longer resolve as success when superseded -- they reject with a recognized SessionSupersededError, so the form's await throws and it does not navigate to /app despite discarded auth. logout also clears isLoading so a login pending at logout does not spin forever. 3. Refresh ownership is now generation-scoped. The request interceptor stamps each request with the generation it was issued under; the 401 handler rejects a stale request (so an old request cannot refresh/retry with the new account's creds), and if the in-flight refresh belongs to an ended session it is dropped rather than let current requests queue behind it and inherit its rejection. The refresh finally only clears isRefreshing if it is still the current refresh. Tests (mutation-verified): loadFromStorage repopulate/logout guards; login rejects on supersede; a stale request is not refreshed/retried; a current-session request does not inherit a stale in-flight refresh's rejection. 166 web tests. test/typecheck/build/lint all green.
|
Reviewed Bastion PR #22 round three at The three reported mechanisms are present and their committed cases pass, but I found four remaining blockers. 1. Request ownership is stamped after the request is issued
I reproduced this deterministically:
The committed stale-request regression waits until the adapter has started, which guarantees the interceptor has already stamped the request and misses this boundary. Capture ownership synchronously at request creation—e.g. a synchronous request interceptor or an explicit request wrapper/config stamp—and pin the immediate call → boundary sequence. 2. A stale refresh can still settle the current refresh's queue
I reproduced:
The same cross-owner drain exists in the stale-success branch at lines 214-216. Scope queues to a refresh generation/owner ID, and allow a refresh settlement to process only its own queue. The 3. F38 still omits
|
…ry (F38 round 4)
1. The request interceptor now runs synchronously ({ synchronous: true }), so a
request's generation stamp + auth token are captured at CREATION time, not a
microtask later. A request issued just before an identity boundary can no longer
be re-stamped as the new session's and refreshed with its credentials.
2. failedQueue is shared, so a superseded refresh settling must not drain the queue
that now belongs to its replacement: the stale-refresh success branch no longer
touches the queue, and the catch only drains it if this is still the current
refresh. (The finally already only clears isRefreshing when current.)
3. commandStore.fetchCommands is now generation-guarded too -- its serverId match is
not enough since two accounts can share a server id.
4. Registration's post-await supersession guard is now regression-pinned (a register
response resolving after a newer boundary rejects and does not authenticate).
Tests (mutation-verified): a request is stamped at creation time not later; a stale
refresh settling does not drain the current refresh's queue; commandStore ignores a
stale response on the same server id; register rejects on supersede.
170 web tests. test/typecheck/build/lint all green.
The round-4 "a stale refresh settling does not drain the current refresh queue" test rejects refresh A (r1's request) partway through, but did not await r1's rejection until the end -- with two macrotask (setTimeout 0) flushes in between. During that window r1 was rejected with no handler attached, which Node reports as an unhandled rejection: vitest then fails the whole run with "1 error" even though all 170 assertions pass. (Timing-sensitive, so it slipped a local run and only bit in CI.) Attach r1's settlement handler synchronously, before the macrotask gaps, and assert it rejected at the end. In production this window never exists -- the caller awaits the request inside a guarded try/catch immediately. Product code unchanged. Mutation-verified: removing the client.ts stale-refresh queue-drain guard still fails this test (stale A drains r3). 170 web tests, no unhandled errors; test/typecheck/lint/build all green.
|
Reviewed Bastion PR #22 round four at The four reported round-three fixes are present: request interception is synchronous, stale refresh A no longer drains replacement refresh B's queue, 1. Old-session requests already queued behind a refresh can remain pending forever
That avoids corrupting refresh B's queue, but it abandons refresh A's waiters. A deterministic probe performed this sequence:
The queue needs refresh/session ownership rather than one unowned global array. On an identity boundary or stale refresh settlement, reject only the entries belonging to that ended refresh/generation; never touch a replacement refresh's entries. Add a regression that queues an A request, ends A with no replacement 401, settles refresh A, and proves the queued request rejects promptly and the next session starts with clean refresh state. 2. Production DM creation bypasses the guarded store action and can still write across sessionsThe new
Each calls A rendered Route these production paths through the guarded store action and treat its ValidationPassed:
Both deterministic review probes were removed, and the exact checkout is clean. PR #22 is not ready to merge. The session generation now owns ordinary store continuations, but refresh waiters and the actual DM creation UI still have routes around the boundary. |
…ough the guard (F38 round 5) Blocker 1 -- a request queued behind a refresh could hang forever. failedQueue is shared across refreshes; round 4 made a superseded refresh avoid draining it (to protect a replacement's waiters), but with no replacement to reset the queue, an old-session request queued behind the stale refresh was never settled. Fix: generation-tag each queued waiter with the refresh it waits behind, and make processQueue settle only the entries for a given generation. Every settlement path (reset, no-token, stale-success, success, failure) now drains ITS OWN generation's waiters -- so a stale refresh rejects its waiters instead of stranding them, while a replacement refresh's waiters (a different generation) are left untouched. The finally clears isRefreshing when this refresh still owns it (refreshGeneration unchanged), including the stale-with-no-replacement case, so the flag is never left stuck true. Blocker 2 -- NewDMDialog, UserContextMenu and UserProfileCard called apiCreateDM directly and selected the result, bypassing the session-guarded dmStore.createDM. A create held across a session change resolved into the new session and selected a DM built for the previous account. Fix: NewDMDialog uses the guarded store action and skips selection when it returns undefined; the two server-context callers use a new shared openDirectMessage() helper (guarded create -> leave server view -> select), which returns undefined on a session change. Tests (all mutation-verified): a request queued behind a refresh that goes stale rejects rather than hanging; openDirectMessage does not select/switch on a mid-create session change; NewDMDialog does not select a DM created for the previous account. 175 web tests, no unhandled errors; test/typecheck/lint/build all green.
|
Reviewed Bastion PR #22 round five at The generation-tagged queue fixes the reported stale-refresh-vs-replacement-refresh collision, and the production DM entry points now route through 1. Session invalidation still does not settle queued refresh waiters
A deterministic probe did the following:
The committed regression explicitly rejects the refresh after invalidation, so it proves eventual stale-settlement drainage, not invalidation drainage. Every waiter belonging to the ended generation must be rejected at the identity boundary even if its refresh never settles. Do that without touching replacement-generation waiters. 2. A queued old-session request can still retry with the new account's credentialsA queue entry is generation-tagged only for I reproduced this ordering:
The request's original generation must remain immutable across retries, and the queued continuation must recheck that ownership immediately before retrying. A settled refresh does not grant its waiters immunity from a boundary occurring between queue resolution and promise continuation. 3. DM workflows guard the create await, not the complete operationThe production routes no longer call raw
|
…, own DM workflows end-to-end (F38 round 6) Blocker 1 -- invalidateSession() could not reach the refresh queue. Generation scoping drains waiters only when their refresh settles; the refresh is a bare axios.post that abortInFlightRequests() cannot cancel, so a hung refresh stranded its queued requests forever. session.ts now exposes onSessionInvalidated(); invalidateSession() runs listeners synchronously after advancing the generation, and client.ts registers one that rejects every queued waiter whose generation is no longer current. The boundary itself settles prior-session machinery. Blocker 2 -- a queued request could retry with the new account's credentials. The queue continuation ran without rechecking its generation, so a boundary landing between the queue being resolved (with the old session's token) and the waiter's .then() running re-entered the interceptor, which re-stamped the old request with the NEW session's generation and bearer token. The continuation now rechecks the generation it was queued under and rejects with the original 401 if it moved. Blocker 3 -- DM workflow ownership ended at the create. NewDMDialog captures one generation for its whole create -> fetchDMs -> select/close sequence and checks it after every await (a boundary during the fetch no longer selects/closes with the old DM); openDirectMessage() captures its own generation and rechecks it after createDM settles, closing the window between the store action's internal check and the helper's side effects. Tests (all mutation-verified): invalidateSession immediately rejects requests queued behind a hung refresh; a queued request whose boundary lands before its continuation is not retried (and never re-sent) with the new credentials; the helper does not select/switch when the session changes after the create settles; the dialog does not select/close when the session changes during the post-create fetch. 179 web tests, no unhandled errors; test/typecheck/lint/build all green.
|
Reviewed PR #22 round six at 1. Session invalidation still cannot settle the request that owns a hung refresh
I reproduced this deterministically:
The new boundary fixes followers but still leaves one prior-session request/workflow parked forever for every hung refresh. Race the refresh owner against a generation-invalidation promise or otherwise explicitly settle the leader at the boundary, while retaining the late-result generation guards. Add a regression for leader starts hung refresh -> invalidate -> leader rejects promptly. 2. Guarded store mutations still fulfill as successful when superseded
A rendered
3. Other production workflows still bypass generation ownership and can mutate the new sessionThe DM bypass is fixed, but the same class remains in direct component API paths. Two deterministic examples:
There are additional direct channel/category workflows with post-await global-store writes in Validation otherwise passed:
The PR body still reports 156 tests and should be refreshed, non-blocking. PR #22 is not ready to merge. The shared generation is sound, but ownership still ends at store boundaries in places where the actual workflow keeps walking. |
…d mutations, guard remaining workflows (F38 round 7) Blocker 1 -- invalidation could not settle the request LEADING a hung refresh (draining the queue settles only followers; the leader awaits the bare axios.post directly). The refresh await is now raced against a boundary promise wired to a one-shot onSessionInvalidated listener (unsubscribed in finally): invalidation rejects the race, the leader flows through its existing stale-guarded catch, and nothing stays pending. Blocker 2 -- superseded store mutations fulfilled as successes, so callers ran their success UI for an operation belonging to a previous account. createServer/createChannel/leaveServer/deleteServer now reject with SessionSupersededError when the session changed mid-flight (both the success and failure arms). dmStore.closeDM deliberately keeps the silent return -- its callers are fire-and-forget with no success UI, so a typed rejection would only surface as an unhandled rejection (documented inline). Blocker 3 -- remaining workflows now capture a generation at entry and check it after every await, before side effects: - ChannelItem rename/delete/move: ids are stable across sessions; a stale completion no longer rewrites/removes a same-ID channel the new session loaded. - CreateServerDialog create (swallows SessionSupersededError; no close/clear on stale) and invite-join (stale join no longer drives the new session's fetch/selection or closes the dialog). - InvitePage auto-join: stale join no longer fetches/selects/navigates. - UnifiedSidebar + ChannelList: category create/rename/delete, category fetch, sidebar channel-create (was writing the old server's channel list into the new session's store), and the drag-reorder failure revert. - ServerSettingsDialog: server save/delete and channel create/edit/delete. Tests (each guard mutation-verified): leader settles at invalidation with a hung refresh; createServer and createChannel reject with SessionSupersededError when superseded; a stale dialog create does not close/clear; a stale invite join does not fetch/select/close (dialog) or fetch/select/navigate (InvitePage); a stale channel delete does not remove a same-ID channel. Two prior tests updated: the queued-retry timing gains one microtask hop (the race), and held leaders attach settlement handlers at creation since the boundary now settles them early. 185 web tests, no unhandled errors; test/typecheck/lint/build all green.
|
Reviewed Bastion PR #22 round seven at The reported refresh-leader race is fixed: invalidation now settles the leader directly, the listener is removed in 1. User-account mutations can still overwrite the next session
A deterministic rendered probe reproduced the cross-account write:
Profile save, avatar upload, and email change need one workflow generation captured at entry and checked after the await before store/storage/UI writes; stale catch/finally paths must not affect the current session either. This is the exact F38 failure, now against the authentication store itself. 2. An old member-list read can repopulate global presence after reset
I held the member request, advanced the session, reset presence, and then resolved the old response. Actual: the new session ended with: { "old-member": "away" }Unmounting the component does not cancel that promise callback or its direct global-store writes. Own this fetch with the session generation and guard its success, error, and loading continuations. The production workflow audit should include other component-level reads that write shared per-user stores, not only mutations. 3. The superseded-mutation rejection contract still has holes
await get().selectServer(server.id)
The dialog's additional generation check currently prevents its success UI, but the guarded store action no longer provides the explicit non-success contract claimed by this fix. Check ownership after the nested await and reject stale completion. The stated “success and failure arms” behavior is also absent from ValidationPassed on the exact head after removing all review probes:
The newly added component tests emit React PR #22 is not ready to merge. The shared generation now reaches much farther, but old account work can still write the auth and presence stores, and one guarded mutation can still report stale completion as success. |
…e rejection-contract holes (F38 round 8) Blocker 1 -- profile save, avatar upload, and email change wrote the response straight into authStore AND persistent storage with no session ownership: one held under account A and resolving after account B logged in overwrote B's user everywhere and closed the dialog as a success. All three handlers now capture a generation at entry and check it after the await, before the store/storage writes, the close, and the error UI. Blocker 2 -- a held apiGetMembers() response repopulated the NEW session's global presence store (and the member list) with the old account's members. MemberList's fetch is now generation-owned; MessageInput's two member fetches (mention list) get the same guard. Blocker 3 -- two holes in the superseded-mutation rejection contract: - createServer() rechecks ownership after its nested selectServer() await. selectServer never rejects (it guards internally), so a boundary during that second await let the mutation fulfill. - leaveServer()/deleteServer() now translate a stale rejection into SessionSupersededError (failure arm), matching the stated both-arms contract. Tests (each guard mutation-verified): stale profile save / avatar upload / email change do not overwrite the new session's user, storage, or close the dialog; a stale members response writes no presence; createServer superseded during its nested await rejects; leaveServer/deleteServer stale rejections surface SessionSupersededError, not the raw transport error. 192 web tests, no unhandled errors; test/typecheck/lint/build all green.
|
Reviewed Bastion PR #22 round eight at The three reported round-seven fixes are present and their new tests are mutation-sensitive: profile/avatar/email writes are generation-gated; MemberList and MessageInput member reads reject stale results; and the nested 1. Starting login/register does not tear down the identity it supersedes
This produces two concrete failures:
The old WebSocket remains active through the same interval and successful replacement never synchronously clears its handlers/data. A new-identity attempt needs an atomic transition: invalidate first, then synchronously abort/disconnect/reset and remove the old credentials before any new-generation work can be admitted. If failed login is intended to leave A logged in, then it is not an identity boundary and needs attempt ownership separate from the active-session generation; the current hybrid grants the new generation to the old identity. 2. Account deletion still belongs to no session
A rendered held-response probe reproduced:
Its stale rejection and 3. Account-operation continuation coverage remains incompleteThe round-eight change guards profile/avatar/email store writes and errors, but not all continuations:
The approved F38 model was explicit: check ownership after every await and in catch/finally before every UI side effect. These paths still violate it. Add held-success and held-failure regressions for password/delete, plus a loading-ownership regression where newer work is active when older 4.
|
…nt-op path, request-recency for member loading (F38 round 9) Blocker 1 -- login/register advanced the generation without tearing down the superseded identity: the previous account's cached stores survived into the new session, requests issued while the login was pending carried the OLD bearer token under the NEW generation (so their responses committed as current-session data), and the old WebSocket stayed live. login() and register() now run the full logout() teardown at entry -- invalidate, abort in-flight transport, clear tokens, reset every per-user store (including the synchronous socket teardown) -- before starting the new identity's request. Blocker 2 -- account deletion was not session-owned: a deletion held under account A and resolving after account B logged in cleared B's credentials and redirected. The handler now checks its generation before clearing tokens/redirecting, before the error UI, and in finally. Blocker 3 -- the account-operation guards now cover every continuation: profile and email finally paths (a stale settlement no longer flips the old form back to ready), the avatar FileReader.onload callback (A's preview is never published into B's UI), and the password change owns its whole workflow (success/failure/loading and the delayed success-dismiss timer). Blocker 4 -- MemberList owns its loading state by request recency: a monotonic fetch sequence means only the LATEST fetch may write members/presence or clear the spinner, so an old response settling while a newer request is in flight no longer hides the loading state. Tests (each guard mutation-verified): login and register tear down the previous identity at entry before the request resolves; a stale account deletion does not end the new session; a stale password change surfaces no success UI and does not collapse the form; a cross-boundary avatar preview is not published; stale profile and email settlements leave the form in its in-flight state; an old members response neither clears a newer fetch's loading state nor writes, while the latest fetch commits. 198 web tests, no unhandled errors; test/typecheck/lint/build all green.
|
Reviewed Bastion PR #22 round nine at The four reported fixes are present: login/register now perform the full teardown before creating the auth request; stale account-operation continuations are generation-gated; and 1. Invalid login is misclassified as session supersessionThe server deliberately returns
I reproduced this through the real interceptor with an adapter returning the server's login Exclude unauthenticated auth routes such as The new teardown tests mock 2. Successful account deletion does not perform an identity transition
A deterministic component probe completed a successful deletion and observed all of the following still true before navigation:
A hard navigation is not a substitute for the synchronous identity boundary this PR establishes, especially if navigation fails or under the Tauri ValidationPassed:
Both deterministic review probes failed as described. Probe files were removed; the exact checkout is clean. PR #22 is not ready to merge. |
…entity transition (F38 round 10) Blocker 1 -- an invalid login was misclassified as session supersession. The shared response interceptor routed the login 401 into the refresh path; login's entry teardown had already removed the refresh token, so the no-token branch fired the auth-failure cascade (another logout, another generation advance) and login() threw SessionSupersededError instead of surfacing "invalid credentials". A 401 from /auth/* is a credential failure, not an expired session: the interceptor now rejects it straight through, so the caller's error handling runs under an unchanged generation. Blocker 2 -- successful account deletion cleared tokens and redirected but never performed the identity transition: the deleted user stayed authenticated with cached per-user data (and a live socket) behind the redirect. The success path now runs the full logout() teardown -- invalidate, abort, clear tokens + auth state, socket disconnect, store resets -- with the hard redirect as a final belt. Also adds the requested real-interceptor order regressions: login and register requests are created only AFTER the previous identity is torn down (the synchronous request interceptor attaches the token at creation, so the request must carry no stale header and observe already-cleared storage at send time). Tests (each mutation-verified): an invalid login rejects with the credential error, sets the visible error, attempts no refresh, and fires no auth-failure cascade; login/register requests carry no stale Authorization and see storage already cleared (real interceptor, both directions of the teardown order); a current-session deletion leaves the user unauthenticated with per-user stores reset. 202 web tests, no unhandled errors; test/typecheck/lint/build all green.
|
Reviewed Bastion PR #22 round ten at The two reported round-nine fixes are present: auth-endpoint Three blockers remain. 1.
|
…secret-revealing workflows (F38 round 11) Blocker 1 -- overlapping loadFromStorage() calls share one session generation (React StrictMode double-effects make this operationally real), so generation checks alone cannot order them: an OLDER validation failing after a newer one succeeded logged the freshly-validated session out. A monotonic sequence now accompanies the generation; only the LATEST invocation may commit its outcome (set the user or log out). Blocker 2 -- closeDM() could produce an unhandled rejection during logout: the teardown aborts its in-flight request, which rejected the unguarded await while every production caller discards the promise. closeDM is now TOTAL: a rejection after the session ended is swallowed (not this session's concern), and a same-session failure is recorded as store error state instead of being thrown at nobody. Blocker 3 -- the secret-returning server-settings workflows (webhook create and token rotation, bot create and token regeneration) published the OLD session's one-time plaintext token into the NEW session's UI when held across an identity boundary. All four handlers (plus the sibling deletes) capture a generation at entry and check it before revealing tokens or writing lists. IntegrationsTab is exported for direct testing, matching WebhooksTab. Tests (each guard mutation-verified): an older validation failure cannot log out a newer success; a teardown-aborted closeDM resolves silently and a same-session failure sets error state without rejecting; stale webhook create/rotation and stale bot create/regeneration reveal no token and write no rows. 209 web tests, no unhandled errors; test/typecheck/lint/build all green.
|
Reviewed Bastion PR #22 round eleven at The three reported fixes are present, and their committed regressions pass. The review found four remaining blockers:
Validation otherwise passed:
PR #22 is not ready to merge. |
…ership, rotation ordering, visible closeDM failures (F38 round 12) Blocker 1 -- startup validation is now FULLY recency-owned. Every loadFromStorage() invocation claims the sequence at ENTRY (including the no-token/corrupt-token paths, which previously returned before claiming and left an older held validation free to commit afterward). loadFromStorage() now resolves an owned-boolean: true only when this invocation owned the outcome. App completes startup (isInitialized) only on the OWNING settle -- under StrictMode's double effect the superseded first call resolves false, so protected routing no longer mounts while the owning validation is still in flight. Blocker 2 -- the webhook/channel and bot list effects are ownership-guarded (sequence + generation, like MemberList): a held old-session response neither renders rows nor completes loading in the still-mounted integration UI. Blocker 3 -- bot token rotation is recency-ordered within a session: each rotation invalidates the previous token server-side, so when two settle out of order only the LATEST may present its one-time token. (Webhook rotation already serializes via regeneratingId.) Blocker 4 -- closeDM() failures are user-visible: a same-session failure now raises a toast (the app-level ToastContainer renders it; nothing ever rendered dmStore.error), while a post-boundary rejection stays silently swallowed. Tests (mutation-verified; the list-effect pins assert BOTH no-rows and still-loading, sensitive to the then+finally ownership pair): a no-token invocation supersedes an older held validation; a superseded loadFromStorage resolves false and the owner true; stale webhook/bot list responses render nothing and never complete loading; an older rotation settling last does not overwrite the newer token; a same-session closeDM failure raises the toast. 214 web tests, no unhandled errors; test/typecheck/lint/build all green.
|
Reviewed Bastion PR #22 round 12 at The list-effect ownership guards and visible 1. An owning no-token invocation leaves a superseded provisional identity authenticated
Deterministic interleaving:
A review probe expected the owner to leave The owning no-token/corrupt-token path must perform in-memory identity teardown as well as persistent-token cleanup, while the superseded invocation remains unable to restore anything. Add the held-A/no-token-B regression with the unauthenticated state assertion. 2.
|
…loadFromStorage, serialized bot rotation (F38 round 13) Blocker 1 -- an owning no-token startup left the superseded provisional identity authenticated in memory: an earlier overlapped validation optimistically publishes the stored user before its fetch, and the later no-token owner only cleared persistent storage. The owner now also clears the in-memory auth state (user, tokens, isAuthenticated) -- "no session" is true in memory, not just on disk. The prior test that codified the unsafe result is rewritten to pin the safe one. Blocker 2 -- App bypassed startup ownership on rejection: its catch-belt set isInitialized on ANY rejection, so a superseded invocation failing could mount routing while the owner was still validating. loadFromStorage is now TOTAL by construction (its whole body is wrapped; unexpected throws resolve the owned-boolean instead of rejecting), and App's catch-belt is removed -- only the owning settle completes startup, with no bypass path. Blocker 3 -- bot rotation ordering assumed request-start order equals server commit order. Rotations are now SERIALIZED like webhook regeneration: one at a time (guard + disabled control with a Rotating state), making commit order unambiguous; the generation guard still stops a cross-boundary reveal. Tests (mutation-verified; the serialization pin is sensitive to the guard+disabled pair, mutated together since the disabled control masks the guard alone): the owning no-token invocation clears the provisional identity and the superseded validation commits nothing; loadFromStorage resolves a boolean even when validation throws synchronously; concurrent rotation clicks start exactly one request, show its token, and re-enable the control. 215 web tests, no unhandled errors; test/typecheck/lint/build all green.
|
Reviewed exact head The three reported changes are present: the owning no-token path clears in-memory auth fields, 1. The owning no-token outcome clears auth state without ending the provisional session
That leaves work started under the provisional identity generation-authoritative even though the owning startup result is now “no session.” This is not theoretical: I reproduced a provisional A held generation-guarded store request likewise still sees its captured generation as current and can commit after the no-token owner returns. The owning no-session result needs the same identity teardown contract as logout—generation advance first, then abort/disconnect/reset/clear—while still returning the owning startup result. Add a real-interceptor refresh regression and a held-store regression. The committed no-token test currently verifies only auth fields and that the superseded validation does not call 2. The new totalizing outer catch can initialize routing while leaving a provisional identity authenticated
A deterministic probe made “Total” must mean the owning failure produces a safe, explicit unauthenticated teardown, not merely a boolean fulfillment. A superseded failure should still return 3. The production API-call ownership sweep remains incompleteSeveral still-mounted identity-owned workflows still call the API directly without any generation/recency ownership. A concrete reproduction is
I held the initial member/role request, advanced the session generation, then resolved it. The old member rendered and the old request cleared loading in the surviving tab. Similar unguarded identity-owned paths remain in the same dialog’s roles, bans, and audit tabs, and in Please finish the direct-call audit rather than relying on component unmount timing. At minimum pin a held old-session success against each shared workflow family, plus a multi-await action proving no second request or local write occurs after the boundary. ValidationPassed locally:
The three deterministic review probes failed as described. They were removed afterward, and the exact checkout is clean. Non-blocking: the PR body still reports 198 tests; the head has 215. PR #22 is not ready to merge. The startup owner now clears the visible identity, but it has not yet killed the session that identity’s work still belongs to. |
… full tab/dialog ownership sweep (F38 round 14) Blocker 1 -- the owning no-token outcome now performs a real identity END via logout(), not a field clear: without advancing the generation, an in-flight refresh started under the provisional identity could still restore the old credentials afterward. logout() invalidates first (settling that refresh via the boundary), aborts transport, clears storage AND memory, disconnects the socket, and resets per-user stores. A superseded no-token invocation commits nothing. Blocker 2 -- an OWNING invocation hitting the totalizing catch no longer completes startup with a provisional identity authenticated: it tears down via logout() and resolves true; a superseded failure resolves false without committing. Blocker 3 -- the ownership sweep now covers every remaining server-settings surface: MembersTab (list fetch, kick/ban/timeout/role mutations, and the shared-recency refreshMembers), RolesTab + RoleEditor (list, create/delete/save with its delayed saved-reset), BansTab (list + unban), AuditTab (list, where recency also orders filter changes), and InviteDialog (list, create, revoke). All list effects use the sequence+generation owns() pair on then and finally; all mutations capture a generation at entry and check before side effects. MembersTab/RolesTab/BansTab/AuditTab are exported for direct testing. Tests (mutation-verified; list pins assert BOTH no-rows and still-loading, sensitive to the ownership pair): the owning no-token invocation advances the generation and leaves memory unauthenticated while the superseded validation commits nothing; an owning startup failure ends the provisional identity; stale members/roles/bans/audit responses render nothing and never complete loading; a stale invites response does not populate the dialog. 221 web tests, no unhandled errors; test/typecheck/lint/build all green.
|
Reviewed round 14 at exact head The owning no-token and owning-catch paths now perform the full teardown, and the newly guarded Roles/Members/Bans/Audit/Invite list cases work. The production ownership sweep is still incomplete, however. I found four deterministic blockers:
I pinned all four with deterministic review-only component probes; all four failed. The probes were removed and the exact checkout is clean. Validation otherwise passed:
PR #22 is not ready to merge. The central generation authority is now coherent, but several ordinary production reads can still carry the previous account across the boundary. The dead continue to send HTTP responses. Very on-brand for distributed systems, less so for authentication. |
…er (F38 round 15) The four surfaces the round-14 sweep missed, plus one of the same class: - ChannelsTab: the channel list fetch now uses the sequence+generation ownership pair on then and finally, like every other server-settings tab (exported for direct testing). - NewDMDialog user search: the debounced search is session+query owned -- an old-account response no longer renders after invalidation, and a slower OLDER query can no longer overwrite newer results. - SearchDialog (message search): same session+query ownership on the debounced apiSearch, covering results, the empty-on-error write, and loading. - PinnedMessages: the initial fetch and the pin-update event refresh share one recency counter (so either supersedes the other correctly, and a channel switch supersedes both), and the unpin continuation is owned by the session AND the list it started for -- a stale completion cannot edit the current list. - GifPicker (same query-recency class, swept to close it): trending and search requests share an ownership helper; only the latest request populates the grid. Tests (mutation-verified; neutralizing each ownership pair fails its pins): a stale channels response renders nothing and never completes loading; stale-session DM/message search responses are not rendered; a slower older search cannot overwrite newer results (both dialogs); a stale pins response renders nothing and never completes loading; a stale unpin completion does not edit the current list. 228 web tests across 34 files, no unhandled errors; all four gates green.
Round 27: closes the five blockers from review round 26 by removing the inference the review named: the client no longer derives committed state from event arrival order. Server commit order and client arrival order are different clocks, and every arithmetic bridge between them (additive bumps, convergent max, entry-relative deltas -- including the interim event-epoch refinement in the previous commit) had a losing interleaving. 1/2. Mention counts are SERVER-OWNED. A mention event or an ack response applies an optimistic direct update for instant UI and then TRIGGERS an authoritative read-state fetch through the lineage. The trigger supersedes every older in-flight snapshot, and the triggered snapshot is server truth minted after the write reached the server (a mention is committed before broadcast; an ack before its response). Nothing journals claims on this lineage anymore -- supersession alone orders the world, and no committed count is ever client-computed. The ack's optimistic zero is gated on nothing having moved since entry (no new activity, no authoritative rebase); otherwise the count is left for the follow-up fetch to settle. 3. ackChannel carries a reset epoch: held across unreadStore.reset() it neither repopulates the cleared store nor fires its follow-up fetch. 4. A close racing a reopen ASKS THE SERVER: when proof of life arrived during the close's flight, the response installs neither removal nor tombstone and triggers fetchDMs -- the authoritative list either contains the id (reopen won) or omits it (close won; the row drops out honestly). 5. The channel-lineage proof-of-life assertion is WITHDRAWN as unsound: a message inserted concurrently with a channel delete can be broadcast after the delete broadcast (different request goroutines), so a message is not proof a delete failed -- clearing the tombstone reopened the resurrection window. Only the DM lineage keeps the assertion, where reopening is a real server behavior. Recovery for a genuinely failed delete-after-broadcast belongs to the server-side ordering fix (broadcast after commit), tracked as a follow-up. Pins reshaped to the trigger design plus new ones for each blocker (91 in sessionGuards, 313 total). Mutations MT1-MT5, with the optimistic- zero gate (MT5) verified in isolation from the missing-follow-up mutation (MT2) that otherwise masks it.
Round 27 review — changes requestedReviewed exact head The round-26 arithmetic bridges are gone: mention and ack writes now trigger newer authoritative read-state fetches, held acks are reset-owned, a close/reopen collision asks the server, and a late message no longer clears a guild-channel deletion tombstone. Those changes fix the five reported orderings. Four blocking cases remain. 1. A later reopen can be erased by the close/reopen deciding fetchWhen proof of life arrives during A deterministic probe produced: Proof arriving during the deciding fetch must supersede/restart that decision, or the fetch must validate the channel's alive epoch before committing. 2. If the close wins, the deciding fetch leaves a dangling DM selectionThe direct close path clears The probe ended with:
3. A delayed pre-ack notification permanently resurrects the local unread flagCounts are corrected by the new authoritative follow-up, but
The deterministic probe ended with 4. A failed server-side channel delete now hides a live channel for the sessionThe backend still broadcasts Tracking broadcast-after-commit as a follow-up does not make this reviewed head safe: this PR introduces the persistent tombstone while the pre-commit broadcaster remains live. Land the server ordering fix before/with this client behavior, or provide a reliable failed-delete recovery path. Regression coverageThe store-level DM tests do not exercise the changed production ValidationPassed on the exact reviewed commit:
Three deterministic review-only probes failed as described in blockers 1–3. They were removed, and the exact checkout is clean. PR #22 is not ready to merge. The counts finally ask the server what happened. The DM resolver and unread flag still occasionally accept testimony from the wrong moment in time. |
broadcast-after-commit Round 28: closes the four blockers and the coverage gap from review round 27. 1. Message-only reopen evidence is DURABLE: when a MESSAGE_CREATE lands in a locally-known DM, noteChannelAlive journals the known row as an upsert (and moves it to the front -- a new message makes it the most recent). A close/reopen deciding fetch -- or any in-flight fetch -- whose snapshot was read before that reopen committed now re-applies the row instead of erasing an open conversation. 2. The fetchDMs commit reconciles selectedDMId with the committed list, the same rule the server selection follows: when the close won its race and the deciding fetch drops the row, the selection no longer dangles over a conversation that does not exist. 3. The unread FLAG reconciles with server truth on one clock. markUnread takes the message's server-minted createdAt: a delayed notification whose message predates the committed server-minted lastReadAt is dropped (the user already read it), and a flag that was raised records its message time so a later committed lastReadAt retires it. The ack's optimistic write keeps the previous server-minted lastReadAt instead of fabricating one from the client clock -- mixing clocks would corrupt exactly this comparison. 4. Server: CHANNEL_DELETE is broadcast AFTER the delete commits, never before. Broadcasting first had two failure modes: a fetch racing the broadcast-to-commit window could read the channel back into existence, and a delete that failed after broadcasting left every client having removed a channel that still exists with no correcting event -- the exact gap the withdrawn client-side recovery could not soundly cover. Go regression test over a real WebSocket: a failed delete (404) broadcasts nothing; a successful one broadcasts exactly once. Mutation-verified (broadcast-first fails the test). Coverage: the production MESSAGE_CREATE handler is now pinned directly -- wsStore tests capture the handlers registered by connect() and drive them with raw payloads (assert-before-refetch order for unknown DMs, known-DM journaling, createdAt pass-through, active-channel exemption). 321 web tests + the new Go test. Mutations MW1-MW5 and MG1; the createdAt wiring (MW5) verified in isolation from the store-side clock guard (MW3) it feeds.
Self-found while reviewing round 28's own change: the known-row aliveness journal used the merge-upsert, so replaying it onto a snapshot carrying a FRESHER version of the DM (the snapshot was read after the message committed) overwrote new fields with the captured older ones -- the lastMessage preview regressed. The journaled apply now keeps the list's own version when the row is present and only fills absence with the captured copy; either way the DM moves to the front. Pinned (322 total) and mutation-verified: reverting to the merge-upsert fails exactly the new pin.
Round 28 review — changes requestedReviewed exact head The durable DM-presence evidence, DM-selection reconciliation, server-side delete ordering, and production handler coverage are present. Three blocking cases remain. 1. Post-commit channel-delete fanout can miss clients or deliver duplicates
I reproduced both failure modes with the real PostgreSQL/Redis/WebSocket harness:
The committed regression has exactly one surviving subscribed channel, so it happens to observe one event and masks both cases. Fan out once per unique server member/client, or capture and deduplicate the relevant recipients before deletion and broadcast only after the delete succeeds. Add both topologies to the real-WebSocket regression. 2. Durable DM evidence overwrites fresher fetched metadata with a stale local rowFor a known DM, A deterministic probe started a DM fetch, delivered known-DM evidence while the cached preview was Journal presence/order without replacing a snapshot row's fields—for example, retain the fetched row when present and insert the known row only when absent. The current production-handler pin checks only the ID, so extend it to prove fresher fetched metadata survives replay. 3. The delayed
|
Round 29: closes the remaining two blockers from review round 28 (the third -- stale evidence overwriting fresher fetched metadata -- was self-found and committed as the preceding commit before the verdict arrived). 1. broadcastToServer delivers server-scoped events exactly once per MEMBER, not once per channel. Channel fanout delivered duplicates to anyone subscribed to several of the server's channels, and -- once deletes broadcast after commit -- a CHANNEL_DELETE fanned out through the SURVIVING channels missed clients subscribed only to the deleted one (the fanout targets were queried after the row was gone). Membership is the stable recipient set regardless of which rows the event is about. Go regression: a member of a multi-channel server receives CHANNEL_DELETE exactly once; the per-channel-fanout mutation fails it. 2. NOTIFICATION carries the message's server-minted createdAt (the mention path now threads msg.CreatedAt through processMentions), and the client passes it to markUnread -- the same one-clock contract MESSAGE_CREATE follows, so a delayed notification whose message an acknowledgment already covered can no longer permanently restore isUnread. Go regression asserts the payload field (RFC3339); a handler-level web pin drives the production NOTIFICATION handler both ways; the payload-dropped and wiring-dropped mutations each fail exactly their pin. 323 web tests + 3 Go. Mutations MG2, MG3, MN1, each isolated.
Round 29 review — changes requestedReviewed exact head The round-28 duplicate/missed-delete reproductions are fixed for ordinary members, the DM aliveness replay now preserves fresher fetched content, and 1. Per-member channel fanout bypasses
|
Round 30: closes the two blockers from review round 29. 1. Channel events go to the deduplicated AUTHORIZED recipient set, not raw membership: authorizedMemberIDs resolves the owner plus members whose role union carries ViewChannel (or Administrator) in one query, and broadcastToServer delivers to that set exactly once per member. A member whose channel access was revoked receives no CHANNEL_CREATE / UPDATE / DELETE -- create and update payloads carry channel metadata that would otherwise leak, and a create would install the hidden channel into client state. Deletion captures the recipient set BEFORE the row is removed and broadcasts to it after the commit; with today's server-scoped visibility the set happens to be row- independent, so the capture order is structural discipline for the day per-channel overwrites exist rather than an observable -- stated here so the pin inventory is honest. Go regression: a ViewChannel- stripped member receives none of the three events while the owner receives each exactly once (act-then-assert per event: the harness CountEvents drains the queue, so each window must contain only its own event); the raw-membership mutation fails it. 2. Event times are server-emitted and non-overridable. Bots may backdate message.createdAt (a presentation timestamp, capped only against the future), so it is NOT a causal watermark: a backdated mention would read as pre-acknowledgment and the client would swallow a genuinely new event. MESSAGE_CREATE broadcasts now wrap the message with an eventAt stamped at emission (both the live path and bulk import), the client prefers eventAt over message.createdAt for the unread clock (falling back for older servers), and NOTIFICATION's createdAt is the notification's own emission time rather than the message's. Go regression: an hour-backdated bot mention notifies with a fresh timestamp; web pin: a backdated createdAt with a fresh eventAt still raises the flag; the backdated-stamp and envelope-ignored mutations each fail exactly their pin. 324 web tests + 6 Go. Mutations MV1, MV3, MV4, each isolated.
|
Reviewed Bastion PR #22 round thirty at The raw-membership leak is fixed in the ordinary non-racing path, and bot-supplied presentation timestamps no longer directly drive the web client's unread clock. I found three blockers. 1. Creating a channel still does not subscribe already-connected authorized clients to it
I reproduced through the real HTTP/PostgreSQL/Redis/WebSocket harness:
The direct event makes the channel visible in client state while its realtime stream is dead until reconnect. Channel creation needs to synchronously reconcile/subscribe every currently authorized connected user to the new channel, with a regression proving the subsequent channel broadcast arrives. That subscription path also needs to compose safely with a concurrent permission revocation rather than reinstall revoked access. 2. Delete fanout uses authorization captured too early and bypasses a later revocation
A deterministic integration probe held the channel row lock so deletion stopped after its recipient query, revoked the member's With today's server-scoped visibility, there is no row-dependent reason to preserve that stale audience: resolve current authorization after the successful delete and immediately before fanout. If future per-channel overrides require pre-delete state, preserve that state transactionally but still intersect it with current membership/role authorization at delivery, or use an equivalent authorization epoch. Add the revocation-during-delete regression; the current test covers only a member revoked before the operation starts. 3. Emission timestamps are not causal read watermarksThe new Two histories are indistinguishable to the client:
The notification path has an especially direct window: mention count is committed, an ack can commit, then Use a stable server-owned message/read identity or causal watermark generated with the message write and recorded from the acked message when acknowledging it; carry that same non-overridable value in both Validation
PR #22 is not ready to merge. The recipient query now asks who is authorized, but one path asks too early and another never installs the subscription. The timestamp fix answers when a packet left, not whether its message had already been read. |
Round 31: closes the three blockers from review round 30 -- and, this time, the whole invariant class each belongs to, adjacent unnamed transitions included. INVARIANT: a client's subscription set tracks authorized visibility through EVERY lifecycle transition. - Channel create subscribes already-connected authorized clients (the named blocker: members got the announcement but never the messages). - Server create subscribes the already-connected OWNER to the new server's channels -- the same gap on an unnamed path (joins already subscribed via subscribeViewable; creation did not). - Channel delete evaluates its recipient set at DELIVERY time, after the commit: a member revoked inside the request window receives nothing. A test seam (AfterChannelDeleteExecForTest, nil in production) makes the locked-row interleaving deterministic. - The hub forgets deleted channels (RemoveChannel): a dead channel's subscriber set must not pin client pointers until disconnect. - Kick/leave/ban and role changes were already reconciled (PR #6); verified rather than assumed. INVARIANT: unread/read comparisons use ONE server-owned total order tied to the write and the acknowledgment. - Migration 014: messages.seq (database-assigned, backfilled in pagination-keyset order; not part of any request surface) and read_states.last_read_seq (backfilled from last_message_id). - The ack records the acked message's seq as the read watermark -- GREATEST-guarded, so a stale ack response (two devices racing) can never move the watermark backwards. - MESSAGE_CREATE (live and bulk import) and NOTIFICATION carry seq; read-states return lastReadSeq. - The client's unread guard is two-tier: the seq axis decides when both sides have it (a pre-ack message broadcast LATE still carries its pre-ack seq and is dropped; a post-ack write with a backdated presentation timestamp still raises the flag); the server-minted time axis survives only as a fallback for pre-seq servers. Flag retirement reconciles on the same axes. Six new Go regressions (subscribe-on-create x2, delivery-time authorization via the seam, ack watermark end-to-end against a real backdated bot mention, watermark non-regression, hub cleanup) and two new web pins; 326 web + 12 Go. Mutations MA1-MA4, MB1/2, MC1/MC2 -- each isolated, including re-proving MA3 against the seam after the original test could not distinguish capture order.
|
Reviewed Bastion PR #22 round thirty-one at exact head The round-30 directions are materially better: new-channel subscriptions are installed, delete authorization is queried after commit, and a database watermark is the right replacement for wall-clock inference. The implementation still does not establish the claimed invariants. Changes requested1.
|
Closes review round 31's five blockers, then the findings of an
adversarial pre-review pass over the whole diff (two independent
reviews, server and web) so they never reach the reviewer.
Round-31 blockers:
1. messages.seq now matches COMMIT order, not allocation order: every
message insert (create, bulk import, uploads, webhooks, interactions
-- grep-exhaustive) runs inside a per-channel advisory-lock
transaction (insertMessageTx). A transaction can no longer allocate a
low seq, stall uncommitted while a later message is acked, and then
surface as already-read. Pinned by blocking: hold the lock
externally, the insert must block until release.
2. The three unlocked insert paths (uploads, webhooks, interactions)
also return seq and broadcast the {message, eventAt} envelope.
3. The ENTIRE ack update is watermark-gated (strict <): a stale or
duplicate ack is a complete no-op -- mention count, last_message_id,
and last_read_at can no longer be clobbered backwards.
4. The client's optimistic ack preserves the known lastReadSeq: a
delayed covered event cannot re-flag the channel while the follow-up
fetch is pending (or failed).
5. Channel create post-checks authorization and unsubscribes anyone
revoked inside the window (seam-tested); RemoveChannel bumps the
reconcile generation so a connecting client's stale snapshot cannot
resurrect a deleted channel's subscriptions; the ineffective
server-create regression now uses the in-transaction default channel
and fails when the fix is removed.
Pre-review findings, server:
- The watermark-gated ack turned the mention-increment race into a
permanently wedged badge: processMentions runs after commit and
broadcast, so an ack of that very message can land first, and
re-acking is now a deliberate no-op. The increment is gated on the
member's watermark; a seam (BeforeMentionIncrementForTest) makes the
interleaving deterministic and the production statement is what the
test drives -- the first draft of this pin replayed its own SQL and
its mutation run exposed that.
- Uploads no longer hold the channel insert lock across file I/O: files
persist first, then a short locked transaction for the message and
attachment rows.
- RemoveChannel bumps the generation BEFORE mutating (the ordering
UnsubscribeUser documents as load-bearing).
- Server deletion now follows the lifecycle invariants it was exempt
from: recipient and channel sets captured while rows exist, broadcast
only after a RowsAffected-checked commit, exactly once per member, and
every cascaded channel's hub subscriber set is removed.
- The ephemeral interaction broadcast uses the shared envelope and UTC.
- Three comments contradicting code fixed.
Pre-review findings, web:
- incrementMention no longer fabricates a client-clock lastReadAt (the
one cross-clock comparison left; reproduced swallowing genuinely new
messages inside clock skew on the fallback tier). '' means no
watermark.
- ackChannel dedupes: re-acking the already-recorded message with
nothing new is a local no-op, so scroll handlers no longer emit a
POST + read-states fetch per wheel tick.
- Covered delayed events are complete no-ops (activity epoch untouched):
they must not suppress an in-flight ack's flag clear.
Disclosed, not fixed here: a fresh ack still zeroes mentions for
strictly newer messages whose increment landed first (pre-existing;
needs seq-scoped mention accounting -- follow-up with
ack-returns-ReadState), and the speculative older-ack-clears-newer-flag
edge shares the same structural fix.
330 web tests + 18 Go. Mutations MX1-MX11 plus the earlier MA/MB/MC
batches, each isolated; two wiring-true rewrites where a first pin
survived its own mutation.
Round 32 review — changes requestedReviewed exact head The five round-31 fixes are materially present: every persisted message writer now uses the per-channel insert lock and emits 1. A fresh older ack still erases mentions strictly above its watermarkThis is the disclosed residual, and the real harness confirms it. The strict gate in Deterministic reproduction:
This is not merely a stale-retry case; the new read watermark and mention count disagree about server truth. 2. A covered delayed
|
…structurally Round 33: closes review round 32's five blockers by fixing the MODELS the previous rounds patched, then the findings of a second adversarial pre-review pass (two independent reviews). Round-32 blockers: 1+2. The mention badge is now a PROJECTION of (mentions, read watermark), not mutable running state. migration 015 adds a mentions table keyed by (user_id, message_id) carrying the message seq and DROPS read_states.mention_count. processMentions records a row (and ensures a read_states row so never-acked channels surface); the ack only advances the watermark; ListReadStates computes the badge as COUNT(mentions with seq > last_read_seq). Acking an older message no longer clears a newer mention, the increment-vs-ack wedge is gone, and the previously-DISCLOSED fresh-ack-zeroes-newer bug dissolves -- a derived count is never rewritten. Client: markUnread returns whether it raised; the NOTIFICATION handler drops the badge bump and browser notification for a covered event. 3+4. Channel-create installs subscriptions through a new hub.SubscribeAuthorizedStable -- the generation-stability loop the connect path uses (sample revGen, read authorized+existence, subscribe, re-check; roll back and retry if it moved; bail if the channel is gone). This closes revocation-after-read and create-racing-delete by construction rather than by post-check. 5. Server delete captures its recipient set inside a transaction holding SELECT ... FOR UPDATE on the server row, so a concurrent join's FK KEY-SHARE lock blocks until commit -- no member joins then gets cascade-removed without SERVER_DELETE. Pre-review findings: - A committed test still wrote the dropped mention_count column, failing the Go suite on any migrated DB (I had FAIL-filtered it out of a grep -- corrected: grep FAIL, never just filter ok). Rewritten to the mentions model. - RemoveChannel (which bumps the reconcile generation) now runs BEFORE the delete broadcast in both channel and server delete, so a create's stability re-check inside the delete's post-commit window observes the bump instead of confirming a phantom CHANNEL_CREATE; the overclaiming "impossible" comment is corrected to name the residual inherent gap. - SubscribeAuthorizedStable's rollback can strip a subscription a concurrent connect/join legitimately installed for the committed channel, so the create's abort path now calls RemoveChannel to bump the generation and force those clients to re-read; the comment no longer claims exclusive ownership. - authorizedMemberIDs propagates query/scan errors instead of confirming an empty recipient set as success. - Two stale comments corrected. Two web-reviewer hardening notes applied: markUnread ignores a non-positive seq (defense for third-party servers) and, with no seq watermark yet, defers to a covering server read time. 333 web tests + Go suite (all mention/subscription/delete pins, seam-driven where the interleaving needs it). Mutations MY1-MY6, MZ1 -- each isolated; two pins rewritten after surviving their own mutation (create-race seam position, server-delete lock discriminator).
Round 33 review — changes requestedReviewed exact head 1.
|
…-sub delivery Round 34: closes review round 33's three blockers on the theme Odin named -- stop guessing what the database committed -- plus the findings of a two-review adversarial pre-review pass. Round-33 blockers: 1. The ack RETURNS the committed read state (watermark + mentions-table computed count) in one transaction, so the client commits server truth instead of an optimistic guess chased by a follow-up fetch. On a quiet flight the client claims the lineage with the committed state (a held pre-ack fetch reconciles it in, seq-ordered, rather than clobbering it); a mid-flight event makes the response stale, so the client settles via a fresh fetch instead. No pending/failed follow-up window remains. 2. A NOTIFICATION is emitted only if the mention row committed: processMentions skips the notify on any persistence failure, so a failed mention cannot produce a phantom badge/popup for state that does not exist. 3. CHANNEL_CREATE and single-channel CHANNEL_UPDATE deliver to the LIVE channel subscription set (BroadcastToChannel) under the hub lock, so a concurrent ViewChannel revocation -- which synchronously unsubscribes the member -- excludes them; a captured recipient slice leaked to a just-revoked member. A real grant during create bumps the reconcile generation (SubscribeUser now reports whether it added, so only an actual new subscription bumps -- no spurious create-loop churn on unrelated role edits), and the client refetches the selected server's channels on a message for an unknown channel as a grant/create-race safety net. Pre-review findings, fixed before reaching Odin: - (web, blocker) a mid-flight NON-mention message bumps the activity epoch but triggers no fetch, so the stale ack response was discarded and the count left permanently wrong; the mid-flight bail now settles via an authoritative fetch. - (web) an ack that raises the flag for a nonzero committed count recorded no flagRaised, so a fetch could never retire it; the fetch reconciliation now clears a flag once the local raise is covered AND the server shows no unread mentions -- a channel stays flagged only while it has unread mentions or an uncovered local raise. - (server) the ack upsert + read-back run in one transaction so a concurrent cascade cannot 500 the re-read. - stale header/inline comments corrected to the new models. 336 web tests + the Go suite. Mutations MW1-MW4 (each rewritten where the first pin was masked -- user-delete dropped the WS, BroadcastToChannel masked the grant bump) plus MV1/MV2 for the web-review fixes, each isolated.
|
Reviewed Bastion PR #22 round 34 at The three round-33 changes are present, but four blockers remain:
Confirmed improvements:
Validation:
PR #22 is not ready to merge. The database is finally returning authoritative state. The client and hub still occasionally replace “authoritative” with “whichever continuation got scheduled last,” because apparently causality needed another on-call rotation. |
… best-effort delivery Odin round-34 blockers, fixed at the model level (stop guessing what the DB committed), plus this round's two-review pre-review sweep findings: 1. Ack applies the server-returned ReadState MONOTONICALLY (higher watermark wins regardless of completion order); the unread flag derives from the WINNING watermark's count, not the last-arriving response. Out-of-order acks regress neither the watermark nor the flag. 2. A revocation emits CHANNELS_STALE so the client refetches and reconciles away any CHANNEL_CREATE leaked in the create-vs-revoke window; granted/revoked are tracked from the real hub-mutation results. 3. Mention row + read_states anchor commit in ONE transaction; the NOTIFICATION is contingent on that committed, visible state (no phantom badge on a partial failure). 4. Non-convergence best-effort delivery no longer fails closed (silence) NOR leaks: it applies the stability loop's generation guard once more — broadcast to the channel only if nothing raced, else roll the speculative subscribe back and degrade to one-time per-user delivery. Re-checks channel existence before the degraded floor so a delete in the guard window emits no phantom create. Pre-review sweep (two adversarial reviewers on the full diff) caught, before Odin: the best-effort persistent-subscription leak (blocker 2 back door); the degraded-floor phantom-create-on-delete; and a weak pin (the out-of-order ack test did not exercise the flag-derivation half). All new pins mutation-verified. 339 web tests; full Go integration/race suite; both stacks' gates green.
|
Reviewed Bastion PR #22 round 35 at exact head The atomic mention/anchor transaction is correct, and higher-watermark ack responses no longer regress lower ones. The new best-effort path also closes the specific persistent-subscription leak covered by its committed test. Four blockers remain. 1. Equal-watermark ack responses can still regress the authoritative mention projection
existing.lastReadSeq > committed.lastReadSeq ? existing : committedTwo concurrent duplicate acks can both return A deterministic store probe reproduced exactly that:
The accepted watermark + count tuple needs lineage. Use per-channel ack request ownership/in-flight deduplication for equal watermarks, or return a server-owned projection/version that orders equal-watermark snapshots. Watermark comparison alone cannot order them. 2. The degraded floor can deliver stale
|
…ewrite
Odin round-35 blockers, addressed at the model level rather than patched:
Blocker 1 (equal-watermark ack regresses mention count): the client apply
tie-break is now >= (not >), so on an equal watermark the EXISTING state wins --
an ack carries no authority to regress mention count at a watermark it did not
advance. Also stops a replayed stale equal-watermark ack from clobbering an
authoritative fetch snapshot. Pinned + mutation-verified.
Blockers 2/3/4 (degraded per-user floor leaks stale creates, phantoms deleted
channels, and gives no live delivery): the floor is DELETED. SubscribeAuthorizedStable
now reconciles the hub subscription to the authorized set, re-reading until
consistent -- generation fast-path, else two agreeing authorized reads
(set-stable), else bounded best-effort -- and KEEPS the subscription (no rollback).
The caller ALWAYS delivers via BroadcastToChannel (the live hub set under the
lock). This collapses 2/3/4 into the stable-path guarantee already accepted:
- No leak: a revocation's synchronous UnsubscribeUser excludes the member; a
member subscribed-then-revoked is cleaned by their own reconcile, which reads
the now-committed channel list. The persistent-leak interleaving is impossible
(happens-before: the channel commit precedes every create authz read, so any
revoke that misses the new channel also precedes -- and excludes -- that read).
- No phantom: a deleted channel's live set is empty; the existence read
short-circuits the broadcast.
- Live delivery: the subscription persists, so later messages arrive.
SubscribeAuthorizedStable returns no recipient set -- delivery is the live hub
set, never a captured slice, so a caller cannot reintroduce the leak.
New pins, mutation-verified: TestChurnConvergesWithLiveDelivery,
TestRevokedDuringReconcileNotSubscribed, TestDeletedDuringReconcileNoCreate, and
the SubscribeAuthorizedStable converge/best-effort unit tests.
Two adversarial pre-review sweeps on the full diff (server + client) before
review; all findings addressed. Full Go race suite + web 340 + all gates green.
|
Reviewed round 36 at The round-36 rewrite fixed the degraded-floor failure class, but one remaining blocker in each model still needed a server-owned ordering primitive:
Added regression coverage for the equal-watermark projection revision and exact removal of pre-existing unauthorized subscriptions. Validation at
I am leaving final merge approval to your review of the pushed changes, as requested. The loop has now been broken with database ordering rather than another round of interpretive archaeology. |
fix(server): subscription hardening — read-error subscription preservation + owner_id fence (PR #22 follow-ups)
web/src changed across PR #22 (unread/mention correctness + CHANNELS_STALE) so all clients need a new release. Version-string bumps only; the tauri-action reads the binary version from tauri.conf.json while the release tag is the pushed git tag. Pre-release step for tags desktop-{linux,windows,macos}-v0.1.4 + android-v0.1.7.
Finding F38 — a settled request can write the previous account's data into the next session
Only
messageStoreguarded its post-await continuations against an identity boundary.serverStore,dmStore,unreadStore, andpermissionStoreeachawaitan API call and thenset(...)with no guard.logout()aborts in-flight requests via the sharedsessionAbortsignal, but a request that already resolved at the boundary cannot be aborted, and a resolution that lands after the AppPage effect-cleanup reset writes the old account's data into the new session.Odin design-reviewed the approach before implementation (chose the shared-generation option).
Fix
New
src/api/session.ts— a narrow session-lifecycle API, not exported mutable state:captureSessionGeneration()/isSessionGenerationCurrent(gen)/invalidateSession()invalidateSession()advances the generation synchronously at every identity boundary (logout and account replacement inauthStore) before aborting requests, disconnecting the socket, or resetting stores. Token refresh (same identity) does not advance it.client.ts's token-refresh guard now uses this one generation instead of its own private epoch.Every async action in the four stores captures the generation at entry and, after every await (and in
catch), bails before any side effect — so a stale continuation cannot write the store, set/clear an error, clear a new session's loading state, fire a second request in a multi-await action, or return an old result as success (createDMreturnsundefinedin that case).messageStoreis intentionally not migrated: its local epoch is entangled with fetch ownership, journal protection, and reset — a store-local reset epoch and an auth-session generation solve related-but-different problems. (Can be reconciled in a later, separate change.)Tests
session.test.ts(generation semantics) andsessionGuards.test.tscover Odin's required cases, all mutation-verified: held read / held mutation / held rejection cannot touch the new session; a new-session request commits while an older one settles afterward and the old one cannot clear the new loading state; a multi-await action stops after the first await;createDMreturnsundefined; andlogoutadvances the generation before abort and reset run.340 web tests across 37 files, plus the Go regression suite and migrations 014/015 for the server-side invariants this PR carries: commit-ordered message sequence, a mention badge computed as a projection of (mentions, read watermark), generation-stable channel-create subscription, and lock-captured server deletion.seq / read_states.last_read_seq) as the unread watermark tied to write and acknowledgment. test / typecheck / build / lint all green.
Round-35 addendum — the concurrency-correctness arc (client + server)
Later review rounds evolved this PR from client session guards into a full client+server correctness effort under one theme: stop guessing what the database actually committed. Round-35 resolves the four round-34 blockers:
ReadState(watermark + computed mention count); the client applies it monotonically (higherlastReadSeqwins regardless of completion order) and derives the unread flag from the winning watermark's count — so two acks completing out of order regress neither the watermark nor the flag.CHANNELS_STALE; the client refetches the affected server's channels and the lineage reconciles away anyCHANNEL_CREATEleaked in the create-vs-revoke window.granted/revokedare tracked from real hub-mutation results, so the event fires iff a subscription was actually removed.read_statesanchor commit in one transaction; theNOTIFICATIONis contingent on that committed, visible state — a partial failure emits nothing.Round-36 update — subscription model rewrite + equal-watermark ack
Round 35's blocker 4 was patched as a "degraded per-user floor"; round 36 replaced it wholesale after that floor accreted holes (stale-create-after-
CHANNELS_STALE, phantom-after-delete, and no live delivery). The floor is deleted.SubscribeAuthorizedStablenow reconciles the hub subscription to the authorized set, re-reading until consistent (generation fast-path → two agreeing authorized reads → bounded best-effort), keeps the subscription (no rollback), and the caller always delivers viaBroadcastToChannel(the live hub set under the hub lock). This collapses the three failure modes into the stable-path guarantee already accepted: no leak (a revocation's synchronous unsubscribe excludes the member; the persistent-leak interleaving is impossible by happens-before — the channel commit precedes every create authz-read), no phantom (a deleted channel's live set is empty and the existence read short-circuits the broadcast), and real live delivery (the subscription persists). The primitive returns no recipient set, so no caller can deliver to a stale slice.Round-36 blocker 1 (equal-watermark ack regressing the mention count): the client apply tie-break is now
>=, so on an equal watermark the existing state wins — an ack cannot regress the count at a watermark it did not advance.Every round ran a two-reviewer adversarial pre-review sweep on the full diff before Odin; the server sweep proved the persistent-leak interleaving impossible, and both cleared. Mutation-verified Go pins:
TestChurnConvergesWithLiveDelivery,TestRevokedDuringReconcileNotSubscribed,TestDeletedDuringReconcileNoCreate,TestSubscribeAuthorizedStableConvergesUnderGenChurn,TestRevocationEmitsChannelsStale,TestMentionAnchorFailureCommitsNoMention.