fix(02-12.1): give an unprovable record context a way out

A selector pointed at a record whose grant has lapsed reports the same
disagreement on every /api/auth/me, so routing it to the hydration gate
rendered a permanent spinner with no controls. Expiry reaches this state
on its own: revocation clears the selector inside its transaction and
expiry does not.

- treat an unprovable context as a refusal, which paints the door with
  the button back to one's own record
- check the refusal ahead of the hydration gate, so neither trigger can
  render behind a spinner again
This commit is contained in:
Marc-André Bombeck committed 2026-08-06 20:40:31 +02:00
1 parent ffe38c9bb6
commit 0fee005683
4 files changed
+111 -21

No files matched your search

@@ -179,7 +179,30 @@ describe("an unprovable record context withholds every control", () => {
const held = resolveRecordCapabilities(null, false, false, true);
expect(held.canAdd).toBe(false);
expect(held.canManage).toBe(false);
expect(held.recordSessionPending).toBe(true);
// REFUSED, not pending. `recordSessionPending` renders a bare spinner with
// no controls, which is right for a switch in flight and a wedge here: this
// state reports the same thing on every `/api/auth/me`, so it never ends on
// its own. `accessRefused` renders the door with the way back out.
expect(held.accessRefused).toBe(true);
expect(held.recordSessionPending).toBeUndefined();
});
it("routes an EXPIRED grant to the refusal door, not the spinner", () => {
// The second trigger, and the one that arrives without anybody doing
// anything: revocation clears the selector inside its transaction, expiry
// does NOT. So the session stays pointed at a record whose grant has
// lapsed, `/api/auth/me` resolves `active: null` because the entry no
// longer survives the live-grant pass, and `recordSession.scope` still
// names the owner. Every delegable read 403s from here.
const expired = recordContextIsUnproven(
{ epoch: 3, scope: "acct-owner" },
null,
);
expect(expired).toBe(true);
const capabilities = resolveRecordCapabilities(null, false, false, expired);
expect(capabilities.accessRefused).toBe(true);
expect(capabilities.recordSessionPending).toBeUndefined();
expect(capabilities.inSharedRecord).toBe(true);
});
it("holds when the resolved entry names a record the selector has left", () => {
+10 -3
View File
@@ -122,9 +122,16 @@ export function resolveRecordCapabilities(
): RecordCapabilities {
if (accessRefused || recordSessionPending || contextUnproven) {
return {
accessRefused: accessRefused || undefined,
recordSessionPending:
recordSessionPending || contextUnproven || undefined,
// An unprovable context is a REFUSAL, not a transient hold, and the
// difference is whether there is a way out. `recordSessionPending`
// renders `RecordScopeHydrationGate` — a bare spinner with no controls,
// correct while a switch is genuinely in flight because it ends on its
// own. This state does not end on its own: `/api/auth/me` reports the
// same disagreement on every boot, so a spinner here is a wedge with no
// exit. `accessRefused` renders `SharedRecordUnavailable`, which carries
// a "leave this record" button that posts `switch(null)`.
accessRefused: accessRefused || contextUnproven || undefined,
recordSessionPending: recordSessionPending || undefined,
inSharedRecord: true,
canWrite: false,
canAdd: false,