diff --git a/src/__tests__/record-session-fence-compat.test.ts b/src/__tests__/record-session-fence-compat.test.ts
new file mode 100644
index 000000000..50cf8f945
--- /dev/null
+++ b/src/__tests__/record-session-fence-compat.test.ts
@@ -0,0 +1,191 @@
+/**
+ * FENCE-AC-10 — the only recovery a pre-fence browser tab has, frozen.
+ *
+ * A tab open at deploy time that is inside or after a shared-record context
+ * sends no fence header, so the server refuses it with 403
+ * `sharing.access.denied`. That is not an arbitrary choice of code: it is the
+ * one code the CURRENTLY DEPLOYED bundle already acts on. `SharedRecordGrantLossBridge`
+ * is mounted globally, subscribes to the whole query cache, and on the first
+ * errored query carrying that code leaves the record and performs a full
+ * document navigation — which serves the fence-aware bundle. One reload,
+ * self-healing, no new code in a bundle nobody can edit any more.
+ *
+ * Every link in that chain is load-bearing and every one of them looks
+ * removable to somebody tidying up. So each is asserted here, with a non-zero
+ * match first, and the reason written down.
+ *
+ * The fourth leg is the opposite assertion and matters as much: the 409
+ * `sharing.session.changed` must NOT be in the set the grant-loss bridge fires
+ * on. That code means "reconcile, do not leave"; routing it into a bridge that
+ * hard-navigates out of the record would turn a one-frame hold into an
+ * eviction, and would let one confused tab yank every other tab of the same
+ * browser out of a record nobody revoked.
+ *
+ * Break it by adding `sharing.session.changed` to the bridge's predicate.
+ */
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+const SRC = join(process.cwd(), "src");
+
+function read(rel: string): string {
+ return readFileSync(join(SRC, rel), "utf8");
+}
+
+const SWITCH_HOOK = read("hooks/use-account-switch.ts");
+const PROVIDERS = read("components/providers.tsx");
+const GRANT_LOSS_BRIDGE = read(
+ "components/layout/shared-record-grant-loss-bridge.tsx",
+);
+const FENCE_BRIDGE = read("components/layout/record-session-fence-bridge.tsx");
+
+describe("FENCE-AC-10 a pre-fence tab can still recover", () => {
+ it("FENCE-AC-10 still keys the grant-loss predicate on sharing.access.denied", () => {
+ // Non-zero proof first: an emptied file would satisfy a bare `toContain`
+ // check on nothing.
+ expect(SWITCH_HOOK.length).toBeGreaterThan(1000);
+ expect(SWITCH_HOOK).toMatch(
+ /SHARING_ACCESS_DENIED\s*=\s*"sharing\.access\.denied"/,
+ );
+ expect(SWITCH_HOOK).toMatch(
+ /export function isSharingAccessDenied[\s\S]{0,200}SHARING_ACCESS_DENIED/,
+ );
+ });
+
+ it("FENCE-AC-10 still mounts the grant-loss bridge globally", () => {
+ expect(PROVIDERS).toContain("SharedRecordGrantLossBridge");
+ expect(PROVIDERS).toMatch(//);
+ // And the bridge still subscribes rather than asking call sites.
+ expect(GRANT_LOSS_BRIDGE).toContain("subscribeToGrantLoss");
+ expect(SWITCH_HOOK).toMatch(
+ /subscribeToGrantLoss[\s\S]{0,600}isSharingAccessDenied\(event\.query\.state\.error\)/,
+ );
+ });
+
+ it("FENCE-AC-10 still leaves the record with a full document navigation", () => {
+ // `router.push` would not do: a soft navigation keeps the in-memory cache,
+ // every React tree holding a rendered value, and — critically here — the
+ // PRE-FENCE JavaScript bundle. The reload is what serves the new one.
+ expect(SWITCH_HOOK).toMatch(
+ /async function landOnRecord[\s\S]{0,400}window\.location\.assign\(destination\)/,
+ );
+ expect(SWITCH_HOOK).toMatch(
+ /useLeaveSharedRecordOnGrantLoss[\s\S]{0,700}await landOnRecord\(\)/,
+ );
+ });
+
+ it("FENCE-AC-10 does NOT route the 409 into the grant-loss bridge", () => {
+ // The predicate the bridge fires on.
+ const predicate = /subscribeToGrantLoss\(([\s\S]*?)\n\}/.exec(SWITCH_HOOK);
+ expect(predicate).not.toBeNull();
+ const body = predicate![1];
+ expect(body.length).toBeGreaterThan(50);
+ expect(body).toContain("isSharingAccessDenied");
+ expect(body).not.toContain("isRecordSessionChanged");
+ expect(body).not.toContain("sharing.session.changed");
+ // The grant-loss bridge component itself must not learn about it either.
+ expect(GRANT_LOSS_BRIDGE).not.toContain("sharing.session.changed");
+ expect(GRANT_LOSS_BRIDGE).not.toContain("isRecordSessionChanged");
+ });
+
+ it("FENCE-AC-10 routes the 409 into a reconciling bridge instead", () => {
+ // The positive control for the leg above: without it, "the 409 is not in
+ // the grant-loss bridge" would pass for a release that dropped the 409
+ // handling altogether.
+ expect(PROVIDERS).toMatch(//);
+ expect(FENCE_BRIDGE).toContain("isRecordSessionChanged");
+ expect(FENCE_BRIDGE).toContain("holdForRecordSessionReconcile");
+ expect(FENCE_BRIDGE).toContain("onRecordFenceMismatch");
+ // It reconciles; it does not leave.
+ expect(FENCE_BRIDGE).not.toContain("landOnRecord");
+ expect(FENCE_BRIDGE).not.toContain("window.location.assign");
+ });
+
+ it("FENCE-AC-10 keeps the two codes distinct at the source", () => {
+ expect(SWITCH_HOOK).toMatch(
+ /SHARING_SESSION_CHANGED\s*=\s*"sharing\.session\.changed"/,
+ );
+ expect(SWITCH_HOOK).toMatch(
+ /export function isRecordSessionChanged[\s\S]{0,200}SHARING_SESSION_CHANGED/,
+ );
+ });
+});
+
+describe("FENCE-AC-10 the browser adopts a context from exactly two responses", () => {
+ it("FENCE-AC-10 has exactly two callers of adoptRecordFenceState", () => {
+ // A third adoption path would be a third source of truth about which
+ // record this browser is on — and it would arrive on the very responses
+ // whose context is in doubt. The 409 body therefore carries an error code
+ // and nothing else, by construction.
+ const CONSUMERS = [
+ // `GET /api/auth/me`, the bootstrap.
+ "hooks/use-auth.ts",
+ // `POST /api/account/switch`, the only other canonical resolver.
+ "hooks/use-account-switch.ts",
+ ];
+
+ const callers = CONSUMERS.filter((rel) =>
+ /adoptRecordFenceState\(/.test(read(rel)),
+ );
+ expect(callers).toEqual(CONSUMERS);
+
+ // And no third file calls it. The declaring module is allowed to name it.
+ for (const rel of [
+ "components/layout/record-session-fence-bridge.tsx",
+ "components/layout/shared-record-grant-loss-bridge.tsx",
+ "lib/api/api-fetch.ts",
+ ]) {
+ expect(read(rel)).not.toContain("adoptRecordFenceState");
+ }
+ });
+
+ it("FENCE-AC-10 adopts before it releases a hold", () => {
+ // A hold released onto an unadopted context puts the shell back on screen
+ // while every subsequent request still asserts `bootstrap`.
+ const src = read("hooks/use-auth.ts");
+ const adopt = src.indexOf("adoptRecordFenceState(data.recordSession)");
+ const settle = src.indexOf("settleRecordSessionTransition(");
+ const scope = src.indexOf("setRecordScope(");
+ expect(adopt).toBeGreaterThan(-1);
+ expect(settle).toBeGreaterThan(-1);
+ expect(scope).toBeGreaterThan(-1);
+ expect(adopt).toBeLessThan(scope);
+ expect(adopt).toBeLessThan(settle);
+ });
+
+ it("FENCE-AC-10 names the epoch on every switch", () => {
+ // A fence-aware client never omits `expectedEpoch`; the server's
+ // unconditional arm is reachable only by a bundle that predates the fence.
+ expect(SWITCH_HOOK).toMatch(
+ /apiPost\([\s\S]{0,200}expectedEpoch:/,
+ );
+ });
+});
+
+describe("FENCE-AC-10 the transport attaches the assertion, not the call sites", () => {
+ it("FENCE-AC-10 wraps all three fetch entries", () => {
+ const src = read("lib/api/api-fetch.ts");
+ const wraps = [...src.matchAll(/withRecordFenceHeaders\(/g)];
+ const validates = [...src.matchAll(/validateResponseContext\(/g)];
+ // Three entries: `apiFetch`, `apiFetchEnvelope`, `apiFetchRaw`. The raw one
+ // matters most — ~25 call sites including the Coach SSE stream, several of
+ // which are record traffic.
+ expect(wraps.length).toBe(3);
+ expect(validates.length).toBe(3);
+ expect(src).toMatch(
+ /export async function apiFetchRaw[\s\S]{0,900}withRecordFenceHeaders\(init\)/,
+ );
+ });
+
+ it("FENCE-AC-10 imports the header names from the client-safe contract only", () => {
+ // Importing the server fence or the acting carrier would pull `next/headers`
+ // or Prisma into the browser bundle: clean typecheck, failed `pnpm build`.
+ const src = read("lib/api/record-fence.ts");
+ expect(src).toContain('from "@/lib/sharing/record-session-fence-contract"');
+ expect(src).not.toContain('from "@/lib/sharing/record-session-fence"');
+ expect(src).not.toContain('from "@/lib/auth/acting-carrier"');
+ expect(src).not.toContain('from "@/lib/api-handler"');
+ expect(src).not.toContain('from "@/lib/db"');
+ });
+});
diff --git a/src/__tests__/record-session-fence-guard.test.ts b/src/__tests__/record-session-fence-guard.test.ts
index bb81b3d3f..6f30e434e 100644
--- a/src/__tests__/record-session-fence-guard.test.ts
+++ b/src/__tests__/record-session-fence-guard.test.ts
@@ -13,7 +13,9 @@
* a carrier or look up a grant;
* * the helpers that deliberately do not consult it still do not;
* * one file writes the epoch and a closed list reads it;
- * * the header names live in four files and no fifth.
+ * * the header names live in four files and no fifth: declared in the contract
+ * module, read by the server fence, attached by the browser transport, and
+ * published by the OpenAPI route module.
*
* ## What these matchers cannot see
*
@@ -268,6 +270,8 @@ describe("the header names live in a closed set of files", () => {
);
expect(namers.length).toBeGreaterThan(0);
expect(namers).toEqual([
+ // Attaches them to every same-origin request, and validates the echo.
+ "lib/api/record-fence.ts",
// Publishes them to the clients that have to send them.
"lib/openapi/routes/account-sharing.ts",
// Declares them.
diff --git a/src/components/layout/record-session-fence-bridge.tsx b/src/components/layout/record-session-fence-bridge.tsx
new file mode 100644
index 000000000..2630761fd
--- /dev/null
+++ b/src/components/layout/record-session-fence-bridge.tsx
@@ -0,0 +1,80 @@
+"use client";
+
+import { useQueryClient } from "@tanstack/react-query";
+import { useEffect } from "react";
+
+import { onRecordFenceMismatch } from "@/lib/api/record-fence";
+import { fetchMe } from "@/hooks/use-auth";
+import { isRecordSessionChanged } from "@/hooks/use-account-switch";
+import { queryKeys } from "@/lib/query-keys";
+import { holdForRecordSessionReconcile } from "@/lib/query-keys/record-session-transition";
+
+/**
+ * v1.37.0 — hold and reconcile when the server says this browser's record
+ * context has moved.
+ *
+ * Two signals mean the same thing and land in the same place:
+ *
+ * * a 409 `sharing.session.changed`, which the fence raises when the asserted
+ * context does not match the session row;
+ * * a 2xx whose ECHOED context contradicts the adopted one — a response that
+ * was in flight across a switch, or a service-worker replay of one cached
+ * before it.
+ *
+ * The response to both is the same and it is NOT the grant-loss response. This
+ * browser does not leave the record: it holds every tab, re-reads
+ * `/api/auth/me` network-only, adopts whatever context the server actually
+ * holds, and releases. `sharing.session.changed` is deliberately absent from
+ * the set `subscribeToGrantLoss` fires on — handing a reconcilable
+ * disagreement to a bridge that hard-navigates out of the record would turn a
+ * one-frame hold into an eviction.
+ *
+ * A sibling of `SharedRecordGrantLossBridge` rather than a branch inside it,
+ * for the reason that file gives for existing at all: the two answer different
+ * questions ("you have lost this record" versus "we disagree about which record
+ * this is"), and a single subscriber with a mode flag is how they start
+ * answering each other's.
+ */
+export function RecordSessionFenceBridge() {
+ const queryClient = useQueryClient();
+
+ useEffect(() => {
+ /**
+ * Held, then reconciled. `fetchMe` adopts the context and settles the
+ * transition itself, so nothing here decides what the browser is allowed
+ * to render next — the server's answer does.
+ */
+ const reconcile = () => {
+ holdForRecordSessionReconcile();
+ void queryClient.cancelQueries();
+ queryClient.clear();
+ void queryClient
+ .fetchQuery({ queryKey: queryKeys.authMe(), queryFn: fetchMe })
+ .catch(() => {
+ // Offline, or the server is down. The hold stays, which is the
+ // correct place to be stuck: nothing renders under a context this
+ // browser cannot prove, and the next successful `/me` releases it.
+ });
+ };
+
+ // The in-flight / cached-response arm, raised by the transport wrapper.
+ const disposeMismatch = onRecordFenceMismatch(reconcile);
+
+ // The 409 arm. Watching the query cache rather than asking ~200 call sites
+ // to handle a status code, for the reason the grant-loss bridge gives:
+ // a per-call-site handler is a rule the next feature forgets.
+ const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
+ if (event.type !== "updated") return;
+ if (event.query.state.status !== "error") return;
+ if (!isRecordSessionChanged(event.query.state.error)) return;
+ reconcile();
+ });
+
+ return () => {
+ disposeMismatch();
+ unsubscribe();
+ };
+ }, [queryClient]);
+
+ return null;
+}
diff --git a/src/components/medications/__tests__/research-mode-acknowledgment-dialog.test.tsx b/src/components/medications/__tests__/research-mode-acknowledgment-dialog.test.tsx
index a1d4f0da0..e753af7b7 100644
--- a/src/components/medications/__tests__/research-mode-acknowledgment-dialog.test.tsx
+++ b/src/components/medications/__tests__/research-mode-acknowledgment-dialog.test.tsx
@@ -171,6 +171,7 @@ describe("", () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
+ headers: new Headers(),
json: async () => ({ data: { enabled: true } }),
// The apiFetch wrapper reads the envelope via `text()`.
text: async () => JSON.stringify({ data: { enabled: true } }),
diff --git a/src/components/medications/__tests__/take-all-due.test.tsx b/src/components/medications/__tests__/take-all-due.test.tsx
index 2ae62400c..570c52b59 100644
--- a/src/components/medications/__tests__/take-all-due.test.tsx
+++ b/src/components/medications/__tests__/take-all-due.test.tsx
@@ -399,6 +399,7 @@ describe("runTakeAllDue — per-medication loop with failure isolation", () => {
function okResponse(id: string) {
return {
ok: true,
+ headers: new Headers(),
json: vi.fn().mockResolvedValue({ data: { id } }),
text: vi.fn().mockResolvedValue(JSON.stringify({ data: { id } })),
};
@@ -454,7 +455,12 @@ describe("runTakeAllDue — per-medication loop with failure isolation", () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(okResponse("evt-a"))
- .mockResolvedValueOnce({ ok: false, json: vi.fn(), text: vi.fn() });
+ .mockResolvedValueOnce({
+ ok: false,
+ headers: new Headers(),
+ json: vi.fn(),
+ text: vi.fn(),
+ });
vi.stubGlobal("fetch", fetchMock);
const queryClient = fakeQueryClient();
@@ -509,7 +515,12 @@ describe("runTakeAllDue — per-medication loop with failure isolation", () => {
it("reports total failure without invalidating (nothing changed server-side)", async () => {
vi.stubGlobal(
"fetch",
- vi.fn().mockResolvedValue({ ok: false, json: vi.fn(), text: vi.fn() }),
+ vi.fn().mockResolvedValue({
+ ok: false,
+ headers: new Headers(),
+ json: vi.fn(),
+ text: vi.fn(),
+ }),
);
const queryClient = fakeQueryClient();
diff --git a/src/components/medications/__tests__/use-medication-intake.test.ts b/src/components/medications/__tests__/use-medication-intake.test.ts
index b73ca1e8d..7d8d500d3 100644
--- a/src/components/medications/__tests__/use-medication-intake.test.ts
+++ b/src/components/medications/__tests__/use-medication-intake.test.ts
@@ -109,6 +109,7 @@ describe("runRecordIntake — shared C1 failure toast + C2 Undo", () => {
"fetch",
vi.fn().mockResolvedValue({
ok: true,
+ headers: new Headers(),
json: vi.fn().mockResolvedValue({ data: { id: "evt-99" } }),
// The apiFetch wrapper reads the envelope via `text()`.
text: vi
@@ -154,6 +155,7 @@ describe("runRecordIntake — shared C1 failure toast + C2 Undo", () => {
"fetch",
vi.fn().mockResolvedValue({
ok: true,
+ headers: new Headers(),
json: vi.fn().mockResolvedValue({ data: { id: "evt-1" } }),
text: vi
.fn()
@@ -186,6 +188,7 @@ describe("runRecordIntake — shared C1 failure toast + C2 Undo", () => {
"fetch",
vi.fn().mockResolvedValue({
ok: true,
+ headers: new Headers(),
json: vi.fn().mockResolvedValue({ data: { id: "evt-1" } }),
text: vi
.fn()
@@ -212,6 +215,7 @@ describe("runRecordIntake — shared C1 failure toast + C2 Undo", () => {
it("sends the displayed dose's scheduledFor on the POST so the server marks THAT slot (v1.12.3)", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
+ headers: new Headers(),
json: vi.fn().mockResolvedValue({ data: { id: "evt-am" } }),
text: vi
.fn()
@@ -243,6 +247,7 @@ describe("runRecordIntake — shared C1 failure toast + C2 Undo", () => {
it("targets the morning vs evening slot purely from the supplied scheduledFor, not the wall-clock", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
+ headers: new Headers(),
json: vi.fn().mockResolvedValue({ data: { id: "evt" } }),
text: vi.fn().mockResolvedValue(JSON.stringify({ data: { id: "evt" } })),
});
@@ -285,6 +290,7 @@ describe("runRecordIntake — shared C1 failure toast + C2 Undo", () => {
it("omits scheduledFor entirely on a PRN / unscheduled dose (null), preserving the server now-snap path", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
+ headers: new Headers(),
json: vi.fn().mockResolvedValue({ data: { id: "evt-prn" } }),
});
vi.stubGlobal("fetch", fetchMock);
@@ -311,6 +317,7 @@ describe("runRecordIntake — shared C1 failure toast + C2 Undo", () => {
"fetch",
vi.fn().mockResolvedValue({
ok: true,
+ headers: new Headers(),
json: vi.fn().mockResolvedValue({ data: {} }),
text: vi.fn().mockResolvedValue(JSON.stringify({ data: {} })),
}),
@@ -334,6 +341,7 @@ describe("runLogIntake — manual backdated intake from the Add choice", () => {
it("posts a backdated takenAt against the medication's intake route and returns true", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
+ headers: new Headers(),
json: vi.fn(),
text: vi.fn().mockResolvedValue(""),
});
@@ -367,6 +375,7 @@ describe("runLogIntake — manual backdated intake from the Add choice", () => {
it("threads scheduledFor (the chosen slot) so the write routes through the canonical slot upsert", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
+ headers: new Headers(),
json: vi.fn(),
text: vi.fn().mockResolvedValue(""),
});
@@ -394,6 +403,7 @@ describe("runLogIntake — manual backdated intake from the Add choice", () => {
it("omits takenAt on a skipped log and shows the skipped toast", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
+ headers: new Headers(),
json: vi.fn(),
text: vi.fn().mockResolvedValue(""),
});
@@ -445,6 +455,7 @@ describe("runUndoIntake — shared soft-delete", () => {
it("reverts via DELETE and confirms on success", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
+ headers: new Headers(),
text: vi.fn().mockResolvedValue(""),
});
vi.stubGlobal("fetch", fetchMock);
diff --git a/src/components/providers.tsx b/src/components/providers.tsx
index 4d9808836..48c445aee 100644
--- a/src/components/providers.tsx
+++ b/src/components/providers.tsx
@@ -22,6 +22,7 @@ import { Toaster } from "@/components/ui/sonner";
import { VersionPoller } from "@/components/version-poller";
import { ServiceWorkerRegistrar } from "@/components/service-worker-registrar";
import { SharedRecordGrantLossBridge } from "@/components/layout/shared-record-grant-loss-bridge";
+import { RecordSessionFenceBridge } from "@/components/layout/record-session-fence-bridge";
import { useAuth, type AccountAccessStatus } from "@/hooks/use-auth";
import { useRecordSessionTransition } from "@/hooks/use-record-session-transition";
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
@@ -394,6 +395,7 @@ export function Providers({
subscriber notices the stable errorCode and puts the browser back
in its own account, rather than asking every call site to. */}
+
{children}
diff --git a/src/hooks/__tests__/use-record-capabilities.test.ts b/src/hooks/__tests__/use-record-capabilities.test.ts
index 1b4600950..4b59e2648 100644
--- a/src/hooks/__tests__/use-record-capabilities.test.ts
+++ b/src/hooks/__tests__/use-record-capabilities.test.ts
@@ -17,7 +17,10 @@
*/
import { describe, expect, it } from "vitest";
-import { resolveRecordCapabilities } from "@/hooks/use-record-capabilities";
+import {
+ recordContextIsUnproven,
+ resolveRecordCapabilities,
+} from "@/hooks/use-record-capabilities";
import type { AccountAccessEntry } from "@/lib/sharing/account-access-view";
const READ_ONLY: AccountAccessEntry = {
@@ -155,3 +158,68 @@ describe("resolveRecordCapabilities", () => {
expect(resolveRecordCapabilities(disagreeing).canAdd).toBe(false);
});
});
+
+/**
+ * v1.37.0 — the record context has to be PROVABLE before the controls appear.
+ *
+ * `/api/auth/me` publishes the same question from two angles: `accountAccess.active`
+ * is the re-decided answer (it survived a live-grant pass), and `recordSession.scope`
+ * is the raw selector the fence compares a request's assertion against. When
+ * they disagree the session is pointed at a record the grant no longer opens:
+ * every delegable route will refuse, while `active` being null would otherwise
+ * make this hook answer "your own record, all controls". That combination
+ * paints an add button on a page whose every write is about to 403 — the exact
+ * failure `canAdd` exists to end.
+ */
+describe("an unprovable record context withholds every control", () => {
+ it("holds when the raw selector names a record the grant no longer opens", () => {
+ expect(
+ recordContextIsUnproven({ epoch: 3, scope: "acct-owner" }, null),
+ ).toBe(true);
+ const held = resolveRecordCapabilities(null, false, false, true);
+ expect(held.canAdd).toBe(false);
+ expect(held.canManage).toBe(false);
+ expect(held.recordSessionPending).toBe(true);
+ });
+
+ it("holds when the resolved entry names a record the selector has left", () => {
+ // The mirror image: the grant resolved, but the session is back on its own
+ // record. Painting the owner's controls here would be the reverse mix-up.
+ expect(recordContextIsUnproven({ epoch: 4, scope: null }, READ_ONLY)).toBe(
+ true,
+ );
+ });
+
+ it("agrees when both answers name the same record", () => {
+ expect(
+ recordContextIsUnproven(
+ { epoch: 3, scope: READ_ONLY.accountId },
+ READ_ONLY,
+ ),
+ ).toBe(false);
+ expect(recordContextIsUnproven({ epoch: 0, scope: null }, null)).toBe(
+ false,
+ );
+ });
+
+ it("does not hold when there is no context to cross-check", () => {
+ // Null is the Bearer transport (no session row, no switch state) and
+ // undefined is a server image that predates the field. Neither is a
+ // disagreement, and neither is a reason to blank the app for everybody.
+ expect(recordContextIsUnproven(null, null)).toBe(false);
+ expect(recordContextIsUnproven(undefined, READ_ONLY)).toBe(false);
+ // The positive control for the two above: without it, a
+ // `recordContextIsUnproven` that always returned false would pass them.
+ expect(recordContextIsUnproven({ epoch: 1, scope: "someone" }, null)).toBe(
+ true,
+ );
+ });
+
+ it("leaves every existing arm untouched when the two agree", () => {
+ // The fourth argument defaults to false, so every case above this block
+ // means exactly what it meant before the fence existed.
+ expect(resolveRecordCapabilities(READ_ONLY)).toEqual(
+ resolveRecordCapabilities(READ_ONLY, false, false, false),
+ );
+ });
+});
diff --git a/src/hooks/use-account-switch.ts b/src/hooks/use-account-switch.ts
index 1d53ee054..e23ddc89a 100644
--- a/src/hooks/use-account-switch.ts
+++ b/src/hooks/use-account-switch.ts
@@ -37,6 +37,11 @@ import { useCallback } from "react";
import { toast } from "sonner";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
+import {
+ adoptRecordFenceState,
+ currentRecordFenceState,
+} from "@/lib/api/record-fence";
+import { fetchMe } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { clearOfflineCachesForRecordSwitch } from "@/lib/pwa/query-persister";
import { queryKeys } from "@/lib/query-keys";
@@ -88,6 +93,20 @@ export function isSharingNotPermitted(error: unknown): boolean {
return errorCodeOf(error) === SHARING_NOT_PERMITTED;
}
+/**
+ * v1.37.0 — the record-session fence's 409. Not a refusal of access: another
+ * tab moved the selector first, or this tab's context is older than the row.
+ * The response is to reconcile through `/api/auth/me` and try again, NOT to
+ * leave the record — which is why it is deliberately absent from the set
+ * `subscribeToGrantLoss` fires on below.
+ */
+export const SHARING_SESSION_CHANGED = "sharing.session.changed";
+
+/** True when the server refused because this session's record context moved. */
+export function isRecordSessionChanged(error: unknown): boolean {
+ return errorCodeOf(error) === SHARING_SESSION_CHANGED;
+}
+
interface SwitchResponse {
actingAs: {
accountId: string;
@@ -95,10 +114,54 @@ interface SwitchResponse {
displayName: string | null;
access: "READ" | "WRITE";
} | null;
+ /**
+ * The context this session is now in. Null when the request carried no
+ * `expectedEpoch` — which a fence-aware client never does, so in this bundle
+ * it is always present on a 200.
+ */
+ recordSession: { epoch: number; scope: string | null } | null;
}
+/**
+ * Ask the server to move the selector, naming the epoch this browser believes
+ * it is on, and adopt whatever context comes back.
+ *
+ * The epoch turns the write into a compare-and-set: two tabs pressing the
+ * switcher at the same moment resolve to one monotonic outcome and the loser
+ * gets a 409 instead of silently overwriting the winner. Adopting the response
+ * is what lets the very next request prove its context — this endpoint and
+ * `/api/auth/me` are the only two a client may adopt from.
+ */
async function postSwitch(accountId: string | null): Promise {
- return apiPost("/api/account/switch", { accountId });
+ const response = await apiPost("/api/account/switch", {
+ accountId,
+ expectedEpoch: currentRecordFenceState()?.epoch ?? 0,
+ });
+ adoptRecordFenceState(response.recordSession);
+ return response;
+}
+
+/**
+ * The switch, with one reconcile-and-retry.
+ *
+ * A lost compare-and-set is not a failure the person at the keyboard caused or
+ * can act on: another tab of the same browser moved first. Surfacing that as a
+ * failed toast would make the switcher look broken while nothing was wrong. So
+ * a 409 reconciles through a network-only `/api/auth/me` — which re-adopts the
+ * true epoch — and retries exactly once. A SECOND 409 means the browser is
+ * genuinely racing itself faster than it can settle, and that does surface,
+ * because retrying forever would spin rather than tell anybody.
+ */
+async function postSwitchWithReconcile(
+ accountId: string | null,
+): Promise {
+ try {
+ return await postSwitch(accountId);
+ } catch (error) {
+ if (!isRecordSessionChanged(error)) throw error;
+ await fetchMe();
+ return postSwitch(accountId);
+ }
}
/**
@@ -148,7 +211,7 @@ export function useAccountSwitch() {
void queryClient.cancelQueries();
queryClient.clear();
try {
- await postSwitch(accountId);
+ await postSwitchWithReconcile(accountId);
} catch (error) {
// A client error cannot prove that the server left the session alone:
// its response may have been lost after the server committed. Keep all
@@ -227,7 +290,7 @@ export function useLeaveSharedRecordOnGrantLoss() {
void queryClient.cancelQueries();
queryClient.clear();
try {
- await postSwitch(null);
+ await postSwitchWithReconcile(null);
commitRecordSessionTransition(transitionId, null);
} catch {
// The grant-loss path deliberately still reloads, but it cannot claim
diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts
index 6f2a87cef..44e884d5a 100644
--- a/src/hooks/use-auth.ts
+++ b/src/hooks/use-auth.ts
@@ -10,6 +10,7 @@ import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { queryKeys } from "@/lib/query-keys";
import { apiGet, apiFetchRaw, ApiError } from "@/lib/api/api-fetch";
+import { adoptRecordFenceState } from "@/lib/api/record-fence";
import { retryOnceOnTransientError } from "@/lib/queries/retry-transient";
import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
@@ -332,6 +333,13 @@ export async function fetchMe(): Promise {
// whatever the switch flow stamped before its reload, the block is what the
// resolver will actually honour on the next request, so the mirror tracks it
// rather than the client's memory of what it asked for.
+ // v1.37.0 — adopt the record-session context BEFORE anything releases a
+ // hold. `/api/auth/me` is one of exactly two responses a client may learn
+ // its context from, and a hold released onto an unadopted context would put
+ // the shell back on screen with every subsequent request still asserting
+ // `bootstrap`. Adoption first means a released hold is always released onto
+ // a context the next request can prove.
+ adoptRecordFenceState(data.recordSession);
const { accountAccess, status: accountAccessStatus } =
resolveAccountAccess(data);
if (accountAccessStatus === "invalid") {
diff --git a/src/lib/api/__tests__/api-fetch.test.ts b/src/lib/api/__tests__/api-fetch.test.ts
index 5cb7abd88..2671b31cc 100644
--- a/src/lib/api/__tests__/api-fetch.test.ts
+++ b/src/lib/api/__tests__/api-fetch.test.ts
@@ -39,6 +39,18 @@ afterEach(() => {
vi.unstubAllGlobals();
});
+/** The record-session assertion the transport attached to a `fetch` call. */
+function recordFenceHeadersOf(call: unknown[]): {
+ epoch: string | null;
+ scope: string | null;
+} {
+ const headers = new Headers((call[1] as RequestInit | undefined)?.headers);
+ return {
+ epoch: headers.get("x-healthlog-record-epoch"),
+ scope: headers.get("x-healthlog-record-scope"),
+ };
+}
+
describe("apiFetch", () => {
it("unwraps the data payload from the envelope", async () => {
fetchMock.mockResolvedValueOnce(
@@ -48,8 +60,17 @@ describe("apiFetch", () => {
const data = await apiFetch<{ id: string }>("/api/measurements/m1");
expect(data).toEqual({ id: "m1" });
- expect(fetchMock).toHaveBeenCalledWith("/api/measurements/m1", {
- signal: expect.any(AbortSignal),
+ // v1.37.0 — `headers` is now always present: the transport attaches the
+ // record-session assertion to every same-origin request, so no call site
+ // has to classify itself as record traffic. `objectContaining` rather than
+ // an exact object so the assertion stays about what THIS test is for.
+ expect(fetchMock).toHaveBeenCalledWith(
+ "/api/measurements/m1",
+ expect.objectContaining({ signal: expect.any(AbortSignal) }),
+ );
+ expect(recordFenceHeadersOf(fetchMock.mock.calls[0])).toEqual({
+ epoch: "bootstrap",
+ scope: "bootstrap",
});
});
@@ -62,10 +83,13 @@ describe("apiFetch", () => {
signal: controller.signal,
});
- expect(fetchMock).toHaveBeenCalledWith("/api/version", {
- cache: "no-store",
- signal: controller.signal,
- });
+ expect(fetchMock).toHaveBeenCalledWith(
+ "/api/version",
+ expect.objectContaining({
+ cache: "no-store",
+ signal: controller.signal,
+ }),
+ );
});
it("resolves undefined for a 204 response", async () => {
@@ -242,10 +266,13 @@ describe("verb helpers", () => {
await apiGet("/api/foo");
- expect(fetchMock).toHaveBeenCalledWith("/api/foo", {
- method: "GET",
- signal: expect.any(AbortSignal),
- });
+ expect(fetchMock).toHaveBeenCalledWith(
+ "/api/foo",
+ expect.objectContaining({
+ method: "GET",
+ signal: expect.any(AbortSignal),
+ }),
+ );
});
it.each([
@@ -272,10 +299,17 @@ describe("verb helpers", () => {
await apiPost("/api/foo");
- expect(fetchMock).toHaveBeenCalledWith("/api/foo", {
- method: "POST",
- signal: expect.any(AbortSignal),
- });
+ expect(fetchMock).toHaveBeenCalledWith(
+ "/api/foo",
+ expect.objectContaining({
+ method: "POST",
+ signal: expect.any(AbortSignal),
+ }),
+ );
+ // Still no body and no Content-Type — the fence headers are the only ones.
+ const [, bodylessInit] = fetchMock.mock.calls[0] as [string, RequestInit];
+ expect(bodylessInit.body).toBeUndefined();
+ expect(new Headers(bodylessInit.headers).get("Content-Type")).toBeNull();
});
it("merges caller headers with the JSON Content-Type", async () => {
diff --git a/src/lib/api/__tests__/record-fence-client.test.ts b/src/lib/api/__tests__/record-fence-client.test.ts
new file mode 100644
index 000000000..831af33dd
--- /dev/null
+++ b/src/lib/api/__tests__/record-fence-client.test.ts
@@ -0,0 +1,200 @@
+/**
+ * The browser's half of the record-session fence: what it asserts on a request,
+ * and what it does with the context a response echoes back.
+ *
+ * The two halves of the response rule are worth stating as opposites, because
+ * getting either backwards is a live defect:
+ *
+ * * a MISMATCHING echo is discarded — the answer is about a record this
+ * browser has left, and serving it paints one person's numbers under
+ * another person's name;
+ * * an ABSENT echo is served normally — it means the response resolved no
+ * record scope, which is true of every public route, actor surface, admin
+ * surface and static response. `/api/version` is polled on a timer through
+ * `apiGet`, so a discard-on-absence rule would put the browser into a
+ * permanent hold-and-reconcile loop.
+ *
+ * Break it by making `validateResponseContext` discard on absence: the
+ * `/api/version`-shaped case fails, and no hold is entered.
+ */
+import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
+
+import {
+ RECORD_EPOCH_HEADER,
+ RECORD_FENCE_BOOTSTRAP,
+ RECORD_SCOPE_HEADER,
+ RECORD_SCOPE_SELF,
+} from "@/lib/sharing/record-session-fence-contract";
+import {
+ RecordContextMismatchError,
+ __resetRecordFenceForTests,
+ adoptRecordFenceState,
+ currentAssertion,
+ currentRecordFenceState,
+ onRecordFenceMismatch,
+ validateResponseContext,
+ withRecordFenceHeaders,
+} from "@/lib/api/record-fence";
+
+const OWNER = "owner-account-id";
+
+function respond(headers: Record = {}): Response {
+ return new Response(JSON.stringify({ data: null, error: null }), {
+ status: 200,
+ headers,
+ });
+}
+
+/** What `/api/version` looks like on the wire: 2xx, no record echo. */
+function publicResponse(): Response {
+ return respond({ "content-type": "application/json" });
+}
+
+function echoing(epoch: number, scope: string | null): Response {
+ return respond({
+ [RECORD_EPOCH_HEADER]: String(epoch),
+ [RECORD_SCOPE_HEADER]: scope ?? RECORD_SCOPE_SELF,
+ });
+}
+
+beforeEach(() => {
+ __resetRecordFenceForTests();
+});
+
+afterEach(() => {
+ __resetRecordFenceForTests();
+});
+
+describe("what the browser asserts on a request", () => {
+ it("sends the bootstrap sentinel before anything has been adopted", () => {
+ expect(currentAssertion()).toEqual({
+ epoch: RECORD_FENCE_BOOTSTRAP,
+ scope: RECORD_FENCE_BOOTSTRAP,
+ });
+ const init = withRecordFenceHeaders();
+ const headers = new Headers(init.headers);
+ // Present, not absent. Header-absent means exactly one thing on the wire —
+ // a bundle that predates the fence — and a fence-aware client must never
+ // be mistaken for one.
+ expect(headers.get(RECORD_EPOCH_HEADER)).toBe(RECORD_FENCE_BOOTSTRAP);
+ expect(headers.get(RECORD_SCOPE_HEADER)).toBe(RECORD_FENCE_BOOTSTRAP);
+ });
+
+ it("sends the adopted context once /me has answered", () => {
+ adoptRecordFenceState({ epoch: 4, scope: OWNER });
+ const headers = new Headers(withRecordFenceHeaders().headers);
+ expect(headers.get(RECORD_EPOCH_HEADER)).toBe("4");
+ expect(headers.get(RECORD_SCOPE_HEADER)).toBe(OWNER);
+ });
+
+ it("spells one's own record as the self sentinel", () => {
+ adoptRecordFenceState({ epoch: 2, scope: null });
+ const headers = new Headers(withRecordFenceHeaders().headers);
+ expect(headers.get(RECORD_SCOPE_HEADER)).toBe(RECORD_SCOPE_SELF);
+ });
+
+ it("keeps caller headers and does not overwrite an explicit assertion", () => {
+ adoptRecordFenceState({ epoch: 4, scope: OWNER });
+ const init = withRecordFenceHeaders({
+ headers: {
+ "content-type": "application/json",
+ [RECORD_EPOCH_HEADER]: "1",
+ },
+ });
+ const headers = new Headers(init.headers);
+ expect(headers.get("content-type")).toBe("application/json");
+ expect(headers.get(RECORD_EPOCH_HEADER)).toBe("1");
+ });
+
+ it("un-adopts on a null context rather than keeping a stale one", () => {
+ adoptRecordFenceState({ epoch: 4, scope: OWNER });
+ adoptRecordFenceState(null);
+ expect(currentRecordFenceState()).toBeNull();
+ expect(currentAssertion().epoch).toBe(RECORD_FENCE_BOOTSTRAP);
+ });
+});
+
+describe("what the browser does with an echoed context", () => {
+ it("serves a response carrying no echo, and enters no hold", () => {
+ // The `/api/version` case, named because it is the concrete thing a
+ // discard-on-absence rule breaks: it is polled on a timer.
+ adoptRecordFenceState({ epoch: 4, scope: OWNER });
+ const hold = vi.fn();
+ onRecordFenceMismatch(hold);
+
+ const res = publicResponse();
+ expect(validateResponseContext(res)).toBe(res);
+ expect(hold).not.toHaveBeenCalled();
+ });
+
+ it("serves a response whose echo agrees", () => {
+ adoptRecordFenceState({ epoch: 4, scope: OWNER });
+ const hold = vi.fn();
+ onRecordFenceMismatch(hold);
+
+ const res = echoing(4, OWNER);
+ expect(validateResponseContext(res)).toBe(res);
+ expect(hold).not.toHaveBeenCalled();
+ });
+
+ it("discards a response delayed across a switch, and enters the hold", () => {
+ // Acceptance case 6: the response was already past the fence when the
+ // switch committed, so the server served it honestly under the old context
+ // and said so. The browser is on epoch 5 now.
+ adoptRecordFenceState({ epoch: 5, scope: null });
+ const hold = vi.fn();
+ onRecordFenceMismatch(hold);
+
+ expect(() => validateResponseContext(echoing(4, OWNER))).toThrow(
+ RecordContextMismatchError,
+ );
+ expect(hold).toHaveBeenCalledTimes(1);
+ });
+
+ it("discards on a matching epoch whose scope disagrees", () => {
+ adoptRecordFenceState({ epoch: 4, scope: OWNER });
+ const hold = vi.fn();
+ onRecordFenceMismatch(hold);
+
+ expect(() => validateResponseContext(echoing(4, "somebody-else"))).toThrow(
+ RecordContextMismatchError,
+ );
+ expect(hold).toHaveBeenCalledTimes(1);
+ });
+
+ it("serves an echoing response while still unadopted", () => {
+ // Nothing to contradict: the request itself asserted `bootstrap`, so the
+ // server already decided whether it could be served at all.
+ const hold = vi.fn();
+ onRecordFenceMismatch(hold);
+
+ const res = echoing(0, null);
+ expect(validateResponseContext(res)).toBe(res);
+ expect(hold).not.toHaveBeenCalled();
+ });
+
+ it("does not raise an ApiError, because nothing about the exchange failed", () => {
+ adoptRecordFenceState({ epoch: 5, scope: null });
+ try {
+ validateResponseContext(echoing(4, OWNER));
+ throw new Error("expected a throw");
+ } catch (err) {
+ expect(err).toBeInstanceOf(RecordContextMismatchError);
+ // A caller branching on `err.status` must not see a status that was
+ // never refused — the response was a 200.
+ expect((err as { status?: number }).status).toBeUndefined();
+ }
+ });
+
+ it("releases the mismatch handler when its disposer runs", () => {
+ adoptRecordFenceState({ epoch: 5, scope: null });
+ const hold = vi.fn();
+ const dispose = onRecordFenceMismatch(hold);
+ dispose();
+
+ expect(() => validateResponseContext(echoing(4, OWNER))).toThrow(
+ RecordContextMismatchError,
+ );
+ expect(hold).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/lib/api/api-fetch.ts b/src/lib/api/api-fetch.ts
index eaa576ae5..55529169a 100644
--- a/src/lib/api/api-fetch.ts
+++ b/src/lib/api/api-fetch.ts
@@ -50,6 +50,10 @@
*/
import { readError } from "./read-error";
+import {
+ validateResponseContext,
+ withRecordFenceHeaders,
+} from "./record-fence";
/** HTTP-level failure from an `/api/...` route — non-OK status. */
export class ApiError extends Error {
@@ -113,7 +117,9 @@ export async function apiFetch(
path: string,
init?: RequestInit,
): Promise {
- const res = await fetch(path, withDefaultTimeout(init));
+ const res = validateResponseContext(
+ await fetch(path, withRecordFenceHeaders(withDefaultTimeout(init))),
+ );
if (!res.ok) await throwApiError(res);
const envelope = await parseEnvelope(res);
return (envelope === undefined ? undefined : envelope.data) as T;
@@ -127,7 +133,9 @@ export async function apiFetchEnvelope<
T = unknown,
M = Record,
>(path: string, init?: RequestInit): Promise<{ data: T; meta: M | undefined }> {
- const res = await fetch(path, withDefaultTimeout(init));
+ const res = validateResponseContext(
+ await fetch(path, withRecordFenceHeaders(withDefaultTimeout(init))),
+ );
if (!res.ok) await throwApiError(res);
const envelope = await parseEnvelope(res);
return {
@@ -142,11 +150,20 @@ export async function apiFetchEnvelope<
* Performs no `.ok` check, no envelope unwrap, and applies no default
* timeout (SSE streams legitimately outlive any fixed window).
*/
-export function apiFetchRaw(
+export async function apiFetchRaw(
path: string,
init?: RequestInit,
): Promise {
- return fetch(path, init);
+ // The record-session assertion is attached HERE and not at the ~25 call
+ // sites, several of which are record traffic (the Coach SSE stream, the
+ // document inbound upload, blob exports). A call site that had to classify
+ // itself would be wrong the first time a route changed mode.
+ //
+ // The echo is validated from headers only — the body is never touched, so an
+ // SSE stream and a blob download reach their reader intact.
+ return validateResponseContext(
+ await fetch(path, withRecordFenceHeaders(init)),
+ );
}
function withJsonBody(
diff --git a/src/lib/api/record-fence.ts b/src/lib/api/record-fence.ts
new file mode 100644
index 000000000..2426758c5
--- /dev/null
+++ b/src/lib/api/record-fence.ts
@@ -0,0 +1,201 @@
+/**
+ * v1.37.0 — the browser's half of the record-session fence.
+ *
+ * Every same-origin API request says which record it believes it is addressing,
+ * and every response that resolved a record scope says which one it was
+ * actually served under. This module holds both halves: the adopted context,
+ * the request headers built from it, and the response check.
+ *
+ * ─── Where the context comes from ─────────────────────────────────────────
+ *
+ * Exactly two responses may be ADOPTED from: `GET /api/auth/me` and
+ * `POST /api/account/switch`. Both are actor surfaces that resolve canonical
+ * session state, and both carry the context in the response BODY. Every other
+ * response's echoed context is used to VALIDATE and never to adopt — which is
+ * also why the 409 refusal carries only an error code and no epoch: a refusal
+ * arriving on a request whose context is in doubt is the worst possible third
+ * source of truth.
+ *
+ * ─── Why it is in memory only ─────────────────────────────────────────────
+ *
+ * Not mirrored to storage. `/api/auth/me` runs on every boot and is the single
+ * bootstrap, so a storage mirror would be a second answer that can go stale
+ * across a deploy — and the `bootstrap` sentinel already makes the
+ * pre-adoption window safe by producing a 409 rather than a served request.
+ *
+ * ─── The validation rule is MISMATCH-ONLY ─────────────────────────────────
+ *
+ * A response is discarded only when it carries an echo that CONTRADICTS the
+ * adopted context. A response carrying NO echo is returned untouched, because
+ * absence means "this response resolved no record scope" — which is exactly
+ * true for public routes, actor surfaces, admin, `/me` itself, and static
+ * responses.
+ *
+ * The concrete thing a discard-on-absence rule breaks:
+ * `src/components/version-poller.tsx` polls the PUBLIC `/api/version` through
+ * `apiGet` on a timer. That response resolves no record and echoes nothing, so
+ * a discard-on-absence client would enter the safe hold and fire a network-only
+ * `/me` on every poll cycle, forever. Do not "tighten" this rule.
+ *
+ * The case mismatch-only appears to give up — a service-worker disk-cache hit
+ * that predates the fence and therefore carries no echo — is closed elsewhere:
+ * `public/sw.js` names its caches by the app release and its `activate` handler
+ * deletes every `healthlog-(static|pages|data)-*` cache not in the current set,
+ * so the deploy that ships the fence evicts every pre-fence cached response
+ * before one can be served. A POST-fence cached record response does carry the
+ * echo it was cached with, so replaying it after a switch is a mismatch and is
+ * discarded.
+ */
+
+import {
+ RECORD_EPOCH_HEADER,
+ RECORD_FENCE_BOOTSTRAP,
+ RECORD_SCOPE_HEADER,
+ recordScopeHeaderValue,
+} from "@/lib/sharing/record-session-fence-contract";
+
+/**
+ * The context this browser has adopted, or null before `/api/auth/me` has told
+ * it one.
+ *
+ * Imported from the contract module and NOT from `record-session-fence.ts` or
+ * `acting-carrier.ts`: those pull `next/headers` and Prisma, which typechecks
+ * cleanly in a client module and fails `pnpm build`.
+ */
+export interface AdoptedRecordContext {
+ epoch: number;
+ scope: string | null;
+}
+
+let adopted: AdoptedRecordContext | null = null;
+
+/** What a mismatching response should trigger. Installed by the app shell. */
+let onContextMismatch: (() => void) | null = null;
+
+/**
+ * Adopt the context published by `GET /api/auth/me` or
+ * `POST /api/account/switch`.
+ *
+ * `null` is a real value with a real meaning — the Bearer transport, or a
+ * server image that predates the field — and it un-adopts, returning the client
+ * to the `bootstrap` sentinel rather than leaving a stale context in place.
+ */
+export function adoptRecordFenceState(
+ context: AdoptedRecordContext | null | undefined,
+): void {
+ adopted = context ?? null;
+}
+
+/** What this browser currently believes. Read by the capability gate. */
+export function currentRecordFenceState(): AdoptedRecordContext | null {
+ return adopted;
+}
+
+/**
+ * Install the handler a contradicting response triggers.
+ *
+ * A callback rather than a direct import of the transition store, so this
+ * module stays a transport concern and the app shell owns what "hold and
+ * reconcile" means. Returns a disposer.
+ */
+export function onRecordFenceMismatch(handler: () => void): () => void {
+ onContextMismatch = handler;
+ return () => {
+ if (onContextMismatch === handler) onContextMismatch = null;
+ };
+}
+
+/** @internal — deterministic module state for unit tests. */
+export function __resetRecordFenceForTests(): void {
+ adopted = null;
+ onContextMismatch = null;
+}
+
+/**
+ * The two header values to send on this request.
+ *
+ * Before adoption both are the literal `bootstrap`. That is a PRESENT header,
+ * which is the whole point: the server's header-absent arm means exactly "a
+ * bundle that predates the fence" and answers it with the 403 such a bundle
+ * recovers from, so a fence-aware client must never reach it. On a session that
+ * never entered a shared record the server's exemption serves `bootstrap`
+ * normally, so an ordinary sign-in sees no refusal during boot.
+ */
+export function currentAssertion(): { epoch: string; scope: string } {
+ if (adopted === null) {
+ return { epoch: RECORD_FENCE_BOOTSTRAP, scope: RECORD_FENCE_BOOTSTRAP };
+ }
+ return {
+ epoch: String(adopted.epoch),
+ scope: recordScopeHeaderValue(adopted.scope),
+ };
+}
+
+/**
+ * Attach the assertion to a request.
+ *
+ * Applied inside `apiFetch` / `apiFetchEnvelope` / `apiFetchRaw` rather than at
+ * call sites, so no caller has to classify its own request as record traffic —
+ * a classification that would be wrong the first time a route changed mode. A
+ * caller that set the headers itself keeps them: an explicit value wins, the
+ * same posture `withDefaultTimeout` takes for `signal`.
+ */
+export function withRecordFenceHeaders(init?: RequestInit): RequestInit {
+ const headers = new Headers(init?.headers);
+ const assertion = currentAssertion();
+ if (!headers.has(RECORD_EPOCH_HEADER)) {
+ headers.set(RECORD_EPOCH_HEADER, assertion.epoch);
+ }
+ if (!headers.has(RECORD_SCOPE_HEADER)) {
+ headers.set(RECORD_SCOPE_HEADER, assertion.scope);
+ }
+ return { ...init, headers };
+}
+
+/**
+ * Discard a response served under a record context this browser is no longer
+ * on.
+ *
+ * Returns the response untouched when it carries no echo, or when the echo
+ * agrees. Throws when it contradicts — and triggers the safe hold on the way
+ * out, so the caller's error path and the browser's reconciliation are one
+ * event rather than two that can disagree.
+ *
+ * Read from headers only. Never consume the body here: `apiFetchRaw` hands the
+ * response to SSE readers and blob downloads that need the stream intact.
+ */
+export function validateResponseContext(res: Response): Response {
+ const echoedEpoch = res.headers.get(RECORD_EPOCH_HEADER);
+ const echoedScope = res.headers.get(RECORD_SCOPE_HEADER);
+
+ // No echo: this response resolved no record scope. True, safe, and the
+ // common case — see the version-poller note above.
+ if (echoedEpoch === null || echoedScope === null) return res;
+
+ // Not adopted yet: there is nothing to contradict. The request itself
+ // asserted `bootstrap`, so the server already decided whether to serve it.
+ if (adopted === null) return res;
+
+ const expected = currentAssertion();
+ if (echoedEpoch === expected.epoch && echoedScope === expected.scope) {
+ return res;
+ }
+
+ onContextMismatch?.();
+ throw new RecordContextMismatchError();
+}
+
+/**
+ * A response arrived describing a record this browser has left.
+ *
+ * Deliberately not an `ApiError`: nothing about the HTTP exchange failed, and a
+ * caller branching on `err.status` must not see a status that was never
+ * refused. What happened is that the answer is about somebody else's record,
+ * and the only safe thing to do with it is to drop it.
+ */
+export class RecordContextMismatchError extends Error {
+ constructor() {
+ super("Response describes a record this session has left");
+ this.name = "RecordContextMismatchError";
+ }
+}
diff --git a/src/lib/query-keys/record-session-transition.ts b/src/lib/query-keys/record-session-transition.ts
index e3db5ec33..403b5ec79 100644
--- a/src/lib/query-keys/record-session-transition.ts
+++ b/src/lib/query-keys/record-session-transition.ts
@@ -245,6 +245,29 @@ export function resolveUnknownRecordSessionTransition(id: string): void {
publish({ id, phase: "resolving", at: Date.now() });
}
+/**
+ * v1.37.0 — hold every tab because a response contradicted the record context
+ * this browser is on, with no transition of our own to attach it to.
+ *
+ * The switch paths above always hold FIRST and get an id back. This one starts
+ * from the other end: a 409 from the record-session fence, or a 2xx whose
+ * echoed context disagrees with the adopted one, means the server has moved and
+ * nobody told us. There is no prior id because this browser did not begin the
+ * transition — another tab did, or the referential action behind an account
+ * deletion did, or an operator did.
+ *
+ * A narrow export rather than a second store: `resolving` already means exactly
+ * "held, outcome unknown, `/me` decides", and `expectedScope` is deliberately
+ * left unset so `settleRecordSessionTransition` releases on whatever the server
+ * turns out to say rather than on a scope this browser guessed.
+ */
+export function holdForRecordSessionReconcile(): void {
+ seed();
+ ensureTransport();
+ if (transition.phase === "resolving") return;
+ publish({ id: makeId(), phase: "resolving", at: Date.now() });
+}
+
/** Release only the `/me` response that confirms the committed record. */
export function settleRecordSessionTransition(
resolvedScope: string | null,