From 71761fd6846551329d74344eefc41661054dcd17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 5 Sep 2026 08:37:55 +0200 Subject: [PATCH 1/7] refactor(daemon): make the ADR 0014 ref frame one owned value The four `refFrame*` fields on `SessionState` were policed only by the R7 ownership table: any daemon module could write them, and only a full-graph AST scan could say whose write it was. They are now one `RefFrame` value whose brand key is private to `src/daemon/ref-frame.ts`, so a module outside that file cannot construct one and cannot edit the one a session holds; the transitions replace it whole. Every transition, rejection reason and epoch rule is unchanged. Readers moved to the accessors ref-frame.ts exports (`refFrameState`, `refFrameScope`, `refFrameEpoch`, plus a new `refFrameTree` and `refFrame`). `internal-observation.ts` drops its four-field lineage copy and its field-by-field comparison: frame identity is now one `===`. Seen red: with the empty-result early return removed from `markSessionPartialRefsIssued`, the new frame-identity assertion in session-snapshot.test.ts fails; restored, it passes. A planted foreign writer module was rejected by tsc (TS2741 missing brand, TS2540 read-only property) before deletion. --- docs/adr/0014-session-ref-frame-lifetime.md | 6 +- .../help-conformance-sample-producers.ts | 2 +- .../android-system-dialog-ref-frame.test.ts | 5 +- src/daemon/__tests__/back-runtime.test.ts | 4 +- src/daemon/__tests__/focus-runtime.test.ts | 4 +- src/daemon/__tests__/generic-settle.test.ts | 6 +- src/daemon/__tests__/home-runtime.test.ts | 4 +- .../__tests__/internal-observation.test.ts | 48 +++++----- .../__tests__/orientation-runtime.test.ts | 4 +- src/daemon/__tests__/ref-frame.test.ts | 28 ++++-- src/daemon/__tests__/session-snapshot.test.ts | 42 +++++---- .../__tests__/tv-remote-runtime.test.ts | 4 +- src/daemon/__tests__/viewport-runtime.test.ts | 4 +- .../handlers/__tests__/react-native.test.ts | 3 +- .../session-install-source-ref-frame.test.ts | 14 +-- .../__tests__/session-push-ref-frame.test.ts | 10 ++- .../__tests__/session-relaunch-close.test.ts | 5 +- .../__tests__/session-runtime-command.test.ts | 5 +- .../session-selector-dispatch.test.ts | 9 +- .../__tests__/snapshot-handler.test.ts | 15 ++-- .../internal/__tests__/find.test.ts | 7 +- .../interaction-ambiguity-publication.test.ts | 2 +- .../__tests__/interaction-settle.test.ts | 10 +-- ...nteraction-touch-android-freshness.test.ts | 3 +- .../interaction-touch-direct-ios.test.ts | 3 +- .../interaction-touch-press-admission.test.ts | 16 ++-- src/daemon/internal-observation.ts | 33 +------ src/daemon/ref-frame.ts | 90 +++++++++++++++---- ...sion-replay-divergence-publication.test.ts | 36 ++++---- .../session-replay-divergence.test.ts | 5 +- .../session-replay-maestro-failure.test.ts | 3 +- src/daemon/runtime-session.ts | 6 +- .../session-close-lifecycle-runtime.test.ts | 12 ++- src/daemon/types.ts | 47 +++------- 34 files changed, 275 insertions(+), 220 deletions(-) diff --git a/docs/adr/0014-session-ref-frame-lifetime.md b/docs/adr/0014-session-ref-frame-lifetime.md index 23ff21443e..943de73005 100644 --- a/docs/adr/0014-session-ref-frame-lifetime.md +++ b/docs/adr/0014-session-ref-frame-lifetime.md @@ -17,8 +17,10 @@ ref-frame vocabulary is promoted into `CONTEXT.md`. - A session owns at most one **ref frame** — the authorization namespace for mutation refs (epoch exposed as `refsGeneration`, immutable source tree, `active`/`expired` state, `all` or bounded - issuance scope) — owned by `src/daemon/ref-frame.ts` and kept separate from the latest - operational observation (`session.snapshot`). + issuance scope) — kept separate from the latest operational observation (`session.snapshot`). + The frame is one value on `SessionState`, replaced whole by a transition and never edited in + place; its type is constructible only inside `src/daemon/ref-frame.ts`, so that module's + ownership is enforced by the compiler rather than by convention. - A complete snapshot activates an `all` frame; `find`, settled diffs, and replay divergence screens activate a bounded partial frame that supersedes the prior one; internal read-only captures never activate, reindex, or expire a frame. diff --git a/scripts/__tests__/help-conformance-sample-producers.ts b/scripts/__tests__/help-conformance-sample-producers.ts index 29ab5c8655..27f02fa0f6 100644 --- a/scripts/__tests__/help-conformance-sample-producers.ts +++ b/scripts/__tests__/help-conformance-sample-producers.ts @@ -423,7 +423,7 @@ export const SAMPLE_PRODUCERS: SampleProducer[] = [ producer: 'the real ADR 0014 admission rejection and staleness hint', sample: STALE_REF_SAMPLE, render: () => { - const session = { refFrameGeneration: 7 } as SessionState; + const session = { snapshotGeneration: 7 } as SessionState; const response = refMutationAdmissionResponse({ session, ref: '@e12', diff --git a/src/daemon/__tests__/android-system-dialog-ref-frame.test.ts b/src/daemon/__tests__/android-system-dialog-ref-frame.test.ts index b07b0ed74f..c345c5f352 100644 --- a/src/daemon/__tests__/android-system-dialog-ref-frame.test.ts +++ b/src/daemon/__tests__/android-system-dialog-ref-frame.test.ts @@ -13,6 +13,7 @@ import { androidObservation } from '../../platform-runtime.ts'; import { makeAndroidSession } from '../../__tests__/test-utils/session-factories.ts'; import { makeTestScreenRecordingResource } from '../../__tests__/test-utils/screen-recording-live-handle.ts'; import { makeAndroidSnapshotCapture } from '../../__tests__/test-utils/android-snapshot-capture.ts'; +import { refFrameState } from '../ref-frame.ts'; const recoverAndroidBlockingSystemDialog = ( params: Omit[0], 'observation'>, @@ -45,12 +46,12 @@ test('android blocking-dialog recovery expires the ref frame before its recovery outPath: '/tmp/anr.mp4', startedAt: 0, }); - expect(session.refFrameState).toBeUndefined(); // active + expect(refFrameState(session)).toBe('active'); const result = await recoverAndroidBlockingSystemDialog({ session }); // The recovery tap was dispatched, and the frame is expired as a result. expect(vi.mocked(runAndroidAdb)).toHaveBeenCalled(); - expect(session.refFrameState).toBe('expired'); + expect(refFrameState(session)).toBe('expired'); expect(result.status).not.toBe('absent'); }); diff --git a/src/daemon/__tests__/back-runtime.test.ts b/src/daemon/__tests__/back-runtime.test.ts index cbd9b59021..e2946824c3 100644 --- a/src/daemon/__tests__/back-runtime.test.ts +++ b/src/daemon/__tests__/back-runtime.test.ts @@ -17,7 +17,7 @@ import { makeSession } from '../../__tests__/test-utils/session-factories.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; import { LeaseRegistry } from '../lease-registry.ts'; -import { activateCompleteRefFrame } from '../ref-frame.ts'; +import { activateCompleteRefFrame, refFrameState } from '../ref-frame.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; import type { GenericPlatformExecutionParams } from '../request-generic-dispatch.ts'; import { resolveBoundBackRuntime } from '../back-runtime.ts'; @@ -162,7 +162,7 @@ test('request router joins back admission to execution, recording, and ref inval ok: true, data: { action: 'back', mode: 'in-app', message: 'Back' }, }); - expect(session.refFrameState).toBe('expired'); + expect(refFrameState(session)).toBe('expired'); expect(session.actions.at(-1)).toMatchObject({ command: 'back' }); expect(harness.inspectFacts).toHaveBeenCalledTimes(1); expect(harness.bind).toHaveBeenCalledTimes(1); diff --git a/src/daemon/__tests__/focus-runtime.test.ts b/src/daemon/__tests__/focus-runtime.test.ts index fa328af308..1d7f736966 100644 --- a/src/daemon/__tests__/focus-runtime.test.ts +++ b/src/daemon/__tests__/focus-runtime.test.ts @@ -17,7 +17,7 @@ import { makeSession } from '../../__tests__/test-utils/session-factories.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; import { LeaseRegistry } from '../lease-registry.ts'; -import { activateCompleteRefFrame } from '../ref-frame.ts'; +import { activateCompleteRefFrame, refFrameState } from '../ref-frame.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; import type { GenericPlatformExecutionParams } from '../request-generic-dispatch.ts'; import { readFocusPoint, resolveBoundFocusRuntime } from '../focus-runtime.ts'; @@ -213,7 +213,7 @@ test('request router joins focus admission to execution, recording, and ref inva ok: true, data: { x: 40, y: 90, message: 'Focused (40, 90)' }, }); - expect(session.refFrameState).toBe('expired'); + expect(refFrameState(session)).toBe('expired'); expect(session.actions.at(-1)).toMatchObject({ command: 'focus', positionals: ['40', '90'], diff --git a/src/daemon/__tests__/generic-settle.test.ts b/src/daemon/__tests__/generic-settle.test.ts index 0ce19cdf4b..71f14008a3 100644 --- a/src/daemon/__tests__/generic-settle.test.ts +++ b/src/daemon/__tests__/generic-settle.test.ts @@ -3,7 +3,7 @@ import type { SnapshotBackend } from '@agent-device/kernel/snapshot'; import type { CommandFlags } from '@agent-device/contracts/command'; import { makeIosSession } from '../../__tests__/test-utils/session-factories.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; -import { activateCompleteRefFrame } from '../ref-frame.ts'; +import { activateCompleteRefFrame, refFrameState } from '../ref-frame.ts'; import { setSessionSnapshot } from '../session-snapshot.ts'; import type { SessionStore } from '../session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; @@ -215,7 +215,7 @@ test('scroll --settle answers with the settled diff against the stored pre-actio // The settled tree became the stored snapshot, and its refs were published: // a partial frame is active at the generation the payload reports. const stored = sessionStore.get(sessionName) as SessionState; - expect(stored.refFrameState).toBe('active'); + expect(refFrameState(stored)).toBe('active'); expect(settle.refsGeneration).toBe(stored.snapshotGeneration); expect(stored.snapshot?.nodes.some((node) => node.label === 'Load more')).toBe(true); }); @@ -288,7 +288,7 @@ test('scroll without --settle takes no observation captures and issues no refs', expect(captureObservations).toEqual([]); // ADR 0014: the leaf side-effect seam expired the frame and nothing // re-published it. - expect((sessionStore.get(sessionName) as SessionState).refFrameState).toBe('expired'); + expect(refFrameState(sessionStore.get(sessionName) as SessionState)).toBe('expired'); }); test('a settle observation that cannot build a runtime degrades instead of failing the action', async () => { diff --git a/src/daemon/__tests__/home-runtime.test.ts b/src/daemon/__tests__/home-runtime.test.ts index e3710cecd2..a852e01dee 100644 --- a/src/daemon/__tests__/home-runtime.test.ts +++ b/src/daemon/__tests__/home-runtime.test.ts @@ -17,7 +17,7 @@ import { makeSession } from '../../__tests__/test-utils/session-factories.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; import { LeaseRegistry } from '../lease-registry.ts'; -import { activateCompleteRefFrame } from '../ref-frame.ts'; +import { activateCompleteRefFrame, refFrameState } from '../ref-frame.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; import type { GenericPlatformExecutionParams } from '../request-generic-dispatch.ts'; import { resolveBoundHomeRuntime } from '../home-runtime.ts'; @@ -135,7 +135,7 @@ test('request router joins home admission to execution, recording, and ref inval }); expect(response).toMatchObject({ ok: true, data: { action: 'home', message: 'Home' } }); - expect(session.refFrameState).toBe('expired'); + expect(refFrameState(session)).toBe('expired'); expect(harness.inspectFacts).toHaveBeenCalledTimes(1); expect(harness.bind).toHaveBeenCalledTimes(1); expect(harness.home).toHaveBeenCalledTimes(1); diff --git a/src/daemon/__tests__/internal-observation.test.ts b/src/daemon/__tests__/internal-observation.test.ts index 5dd6263692..0066b72753 100644 --- a/src/daemon/__tests__/internal-observation.test.ts +++ b/src/daemon/__tests__/internal-observation.test.ts @@ -4,7 +4,13 @@ import { expect, test } from 'vitest'; import { makeIosSession } from '../../__tests__/test-utils/session-factories.ts'; import type { SnapshotState } from '@agent-device/kernel/snapshot'; import { bindInternalObservationAuthority } from '../internal-observation.ts'; -import { expireRefFrame } from '../ref-frame.ts'; +import { + expireRefFrame, + refFrame, + refFrameScope, + refFrameState, + refFrameTree, +} from '../ref-frame.ts'; import { markSessionPartialRefsIssued, setSessionSnapshot } from '../session-snapshot.ts'; import { SessionStore } from '../session-store.ts'; @@ -73,10 +79,10 @@ test('publishes exactly the validated outward refs from a current internal captu refsGeneration: input.refsGeneration, refCount: 1, }); - expect(input.session.refFrameState).toBe('active'); - expect(input.session.refFrameScope).toEqual(new Set(['e2'])); - expect(input.session.refFrameTree).toBe(input.captured); - expect(input.session.refFrameGeneration).toBe(input.refsGeneration); + expect(refFrameState(input.session)).toBe('active'); + expect(refFrameScope(input.session)).toEqual(new Set(['e2'])); + expect(refFrameTree(input.session)).toBe(input.captured); + expect(refFrame(input.session).generation).toBe(input.refsGeneration); expect(publishCurrent(input)).toEqual({ published: false, reason: 'stale-capture' }); }); @@ -89,8 +95,8 @@ test('empty publication never supersedes prior client authority', () => { }); expect(result).toEqual({ published: false, reason: 'empty' }); - expect(input.session.refFrameScope).toEqual(new Set(['e1'])); - expect(input.session.refFrameTree).toBe(input.prior); + expect(refFrameScope(input.session)).toEqual(new Set(['e1'])); + expect(refFrameTree(input.session)).toBe(input.prior); }); test('an empty finalization consumes its evidence and cannot later publish', () => { @@ -103,7 +109,7 @@ test('an empty finalization consumes its evidence and cannot later publish', () expect(first).toEqual({ published: false, reason: 'empty' }); expect(publishCurrent(input)).toEqual({ published: false, reason: 'stale-capture' }); - expect(input.session.refFrameScope).toEqual(new Set(['e1'])); + expect(refFrameScope(input.session)).toEqual(new Set(['e1'])); }); test('cancelled publication leaves prior client authority intact', () => { @@ -116,8 +122,8 @@ test('cancelled publication leaves prior client authority intact', () => { reason: 'cancelled', }); expect(publishCurrent(input)).toEqual({ published: false, reason: 'stale-capture' }); - expect(input.session.refFrameScope).toEqual(new Set(['e1'])); - expect(input.session.refFrameTree).toBe(input.prior); + expect(refFrameScope(input.session)).toEqual(new Set(['e1'])); + expect(refFrameTree(input.session)).toBe(input.prior); }); test('a newer capture makes older capture evidence stale', () => { @@ -125,16 +131,16 @@ test('a newer capture makes older capture evidence stale', () => { setSessionSnapshot(input.session, snapshot('e3', 'Later capture')); expect(publishCurrent(input)).toEqual({ published: false, reason: 'stale-capture' }); - expect(input.session.refFrameScope).toEqual(new Set(['e1'])); + expect(refFrameScope(input.session)).toEqual(new Set(['e1'])); }); test('a later ref publication prevents an older capture from superseding it', () => { const input = scenario(); markSessionPartialRefsIssued(input.session, ['e2']); - const laterTree = input.session.refFrameTree; + const laterTree = refFrameTree(input.session); expect(publishCurrent(input)).toEqual({ published: false, reason: 'stale-capture' }); - expect(input.session.refFrameTree).toBe(laterTree); + expect(refFrameTree(input.session)).toBe(laterTree); }); test('a runtime side effect invalidates capture evidence without rolling authority back', () => { @@ -142,8 +148,8 @@ test('a runtime side effect invalidates capture evidence without rolling authori expireRefFrame(input.session); expect(publishCurrent(input)).toEqual({ published: false, reason: 'stale-capture' }); - expect(input.session.refFrameState).toBe('expired'); - expect(input.session.refFrameTree).toBe(input.prior); + expect(refFrameState(input.session)).toBe('expired'); + expect(refFrameTree(input.session)).toBe(input.prior); }); test('runtime revision invalidates evidence even when the ref frame was already expired', () => { @@ -158,8 +164,8 @@ test('runtime revision invalidates evidence even when the ref frame was already }); expect(result).toEqual({ published: false, reason: 'stale-capture' }); - expect(input.session.refFrameState).toBe('expired'); - expect(input.session.refFrameTree).toBe(input.prior); + expect(refFrameState(input.session)).toBe('expired'); + expect(refFrameTree(input.session)).toBe(input.prior); }); test('session close invalidates capture evidence', () => { @@ -171,7 +177,7 @@ test('session close invalidates capture evidence', () => { // a stale finalization attempt already consumed. input.sessionStore.set(input.sessionName, input.session); expect(publishCurrent(input)).toEqual({ published: false, reason: 'stale-capture' }); - expect(input.session.refFrameScope).toEqual(new Set(['e1'])); + expect(refFrameScope(input.session)).toEqual(new Set(['e1'])); }); test('same-name session replacement cannot inherit capture evidence', () => { @@ -180,7 +186,7 @@ test('same-name session replacement cannot inherit capture evidence', () => { input.sessionStore.set(input.sessionName, replacement); expect(publishCurrent(input)).toEqual({ published: false, reason: 'stale-capture' }); - expect(replacement.refFrameTree).toBeUndefined(); + expect(refFrameTree(replacement)).toBeUndefined(); }); test('generation and ref projection must match the exact captured tree', () => { @@ -202,6 +208,6 @@ test('generation and ref projection must match the exact captured tree', () => { }); expect(refResult).toEqual({ published: false, reason: 'invalid-projection' }); expect(publishCurrent(wrongRef)).toEqual({ published: false, reason: 'stale-capture' }); - expect(wrongGeneration.session.refFrameScope).toEqual(new Set(['e1'])); - expect(wrongRef.session.refFrameScope).toEqual(new Set(['e1'])); + expect(refFrameScope(wrongGeneration.session)).toEqual(new Set(['e1'])); + expect(refFrameScope(wrongRef.session)).toEqual(new Set(['e1'])); }); diff --git a/src/daemon/__tests__/orientation-runtime.test.ts b/src/daemon/__tests__/orientation-runtime.test.ts index 00fed20421..e4e16ff4bb 100644 --- a/src/daemon/__tests__/orientation-runtime.test.ts +++ b/src/daemon/__tests__/orientation-runtime.test.ts @@ -39,7 +39,7 @@ import { makeSession } from '../../__tests__/test-utils/session-factories.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; import { LeaseRegistry } from '../lease-registry.ts'; -import { activateCompleteRefFrame } from '../ref-frame.ts'; +import { activateCompleteRefFrame, refFrameState } from '../ref-frame.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; import type { GenericPlatformExecutionParams } from '../request-generic-dispatch.ts'; import { @@ -221,7 +221,7 @@ test('request router joins orientation admission to execution and ref invalidati message: 'Rotated to landscape-left', }, }); - expect(session.refFrameState).toBe('expired'); + expect(refFrameState(session)).toBe('expired'); expect(harness.bind).toHaveBeenCalledTimes(1); expect(setOrientation).toHaveBeenCalledTimes(1); }); diff --git a/src/daemon/__tests__/ref-frame.test.ts b/src/daemon/__tests__/ref-frame.test.ts index 50266e1c5f..152530d8fd 100644 --- a/src/daemon/__tests__/ref-frame.test.ts +++ b/src/daemon/__tests__/ref-frame.test.ts @@ -2,8 +2,10 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import { activateCompleteRefFrame, + activatePartialRefFrame, admitRefMutation, expireRefFrame, + refFrame, refFrameEpoch, refFrameScope, refFrameState, @@ -24,8 +26,19 @@ function reason(admission: RefFrameAdmission): string | undefined { return admission.admitted ? undefined : admission.reason; } -test('defaults: no frame fields set reads as active / all / undefined epoch', () => { +/** + * A partial frame can only be reached through its transition: the frame value is + * constructible only inside `ref-frame.ts`, so a test cannot seed one by hand. + */ +function partialSession(generation: number, scope: ReadonlySet): SessionState { + const state = session({ snapshotGeneration: generation }); + activatePartialRefFrame(state, scope); + return state; +} + +test('defaults: a session with no frame reads as active / all / undefined epoch', () => { const s = session(); + assert.equal(s.refFrame, undefined); assert.equal(refFrameState(s), 'active'); assert.equal(refFrameScope(s), 'all'); assert.equal(refFrameEpoch(s), undefined); @@ -54,7 +67,8 @@ test('a pinned ref at another epoch is a generation mismatch', () => { }); test('an expired frame rejects every ref, and expiry wins over a matching pin', () => { - const s = session({ snapshotGeneration: 42, refFrameState: 'expired' }); + const s = session({ snapshotGeneration: 42 }); + expireRefFrame(s); assert.equal( reason(admitRefMutation({ session: s, refBody: 'e1', mintedGeneration: undefined })), 'ref_frame_expired', @@ -66,7 +80,7 @@ test('an expired frame rejects every ref, and expiry wins over a matching pin', }); test('a partial frame rejects a plain ref: it requires a complete frame', () => { - const s = session({ snapshotGeneration: 42, refFrameScope: new Set(['e1']) }); + const s = partialSession(42, new Set(['e1'])); assert.equal( reason(admitRefMutation({ session: s, refBody: 'e1', mintedGeneration: undefined })), 'plain_ref_requires_complete_frame', @@ -74,7 +88,7 @@ test('a partial frame rejects a plain ref: it requires a complete frame', () => }); test('a partial frame admits only pinned refs it issued, at the current epoch', () => { - const s = session({ snapshotGeneration: 42, refFrameScope: new Set(['e1']) }); + const s = partialSession(42, new Set(['e1'])); assert.deepEqual(admitRefMutation({ session: s, refBody: 'e1', mintedGeneration: 42 }), { admitted: true, }); @@ -86,7 +100,7 @@ test('a partial frame admits only pinned refs it issued, at the current epoch', test('generation mismatch is evaluated before issuance scope', () => { // A pin at the wrong epoch is a mismatch even if the body was in scope. - const s = session({ snapshotGeneration: 43, refFrameScope: new Set(['e1']) }); + const s = partialSession(43, new Set(['e1'])); assert.equal( reason(admitRefMutation({ session: s, refBody: 'e1', mintedGeneration: 42 })), 'ref_generation_mismatch', @@ -97,8 +111,10 @@ test('expireRefFrame is idempotent and rejects all refs while expired', () => { const s = session({ snapshotGeneration: 42 }); expireRefFrame(s); assert.equal(refFrameState(s), 'expired'); + const expired = refFrame(s); expireRefFrame(s); // idempotent assert.equal(refFrameState(s), 'expired'); + assert.deepEqual(refFrame(s), expired); assert.equal( reason(admitRefMutation({ session: s, refBody: 'e1', mintedGeneration: 42 })), 'ref_frame_expired', @@ -106,7 +122,7 @@ test('expireRefFrame is idempotent and rejects all refs while expired', () => { }); test('activateCompleteRefFrame re-authorizes a complete frame after expiry', () => { - const s = session({ snapshotGeneration: 42, refFrameScope: new Set(['e1']) }); + const s = partialSession(42, new Set(['e1'])); expireRefFrame(s); activateCompleteRefFrame(s); assert.equal(refFrameState(s), 'active'); diff --git a/src/daemon/__tests__/session-snapshot.test.ts b/src/daemon/__tests__/session-snapshot.test.ts index 9115644bbd..c3c2bd7b28 100644 --- a/src/daemon/__tests__/session-snapshot.test.ts +++ b/src/daemon/__tests__/session-snapshot.test.ts @@ -8,7 +8,15 @@ import { setSnapshotLineage, STALE_SNAPSHOT_REFS_WARNING, } from '../session-snapshot.ts'; -import { activateCompleteRefFrame, refFrameEpoch } from '../ref-frame.ts'; +import { + activateCompleteRefFrame, + activatePartialRefFrame, + expireRefFrame, + refFrame, + refFrameEpoch, + refFrameScope, + refFrameState, +} from '../ref-frame.ts'; function makeSession(): SessionState { return { @@ -35,7 +43,7 @@ test('setSessionSnapshot advances the generation on every tree replacement (#107 expect(seeded).toBeGreaterThanOrEqual(100_000); expect(seeded).toBeLessThan(1_000_000); // ADR 0014: replacing the observation does NOT touch the ref frame. - expect(session.refFrameState).toBeUndefined(); + expect(session.refFrame).toBeUndefined(); // Storing the SAME snapshot object again is not a replacement. setSessionSnapshot(session, first); @@ -71,11 +79,11 @@ test('a reopened session reseeds so pins from a previous lifetime do not silentl test('resolveRefStalenessWarning: frame expiry is checked before the epoch (ADR 0014 evidence #17)', () => { const session = makeSession(); session.snapshotGeneration = 15; - session.refFrameGeneration = 15; + activateCompleteRefFrame(session); // Expired frame: ANY read is stale, even a pin matching the epoch — a matching // pin proves identity within the retained frame, not that the UI is current. - session.refFrameState = 'expired'; + expireRefFrame(session); expect(resolveRefStalenessWarning({ session, ref: '@e37', mintedGeneration: 15 })).toBe( STALE_SNAPSHOT_REFS_WARNING, ); @@ -85,7 +93,7 @@ test('resolveRefStalenessWarning: frame expiry is checked before the epoch (ADR // Active frame: a pin matching the epoch and a plain ref are both clean; a pin // from another epoch gets the precise generation warning. - session.refFrameState = 'active'; + activateCompleteRefFrame(session); expect( resolveRefStalenessWarning({ session, ref: '@e37', mintedGeneration: 15 }), ).toBeUndefined(); @@ -101,14 +109,13 @@ test('resolveRefStalenessWarning names the frozen frame epoch, not the bumped ob const session = makeSession(); // A frame was issued at generation 15. session.snapshotGeneration = 15; - session.refFrameGeneration = 15; - session.refFrameState = 'active'; + activateCompleteRefFrame(session); // A read-only capture replaces the observation and advances the observation // counter WITHOUT re-issuing the frame — the frame epoch stays frozen at 15. setSessionSnapshot(session, makeSnapshot()); expect(session.snapshotGeneration).toBe(16); - expect(session.refFrameGeneration).toBe(15); + expect(refFrame(session).generation).toBe(15); // A pin matching the FROZEN frame epoch is clean, even though the observation // generation has since advanced past it. @@ -134,21 +141,24 @@ test('resolveRefStalenessWarning treats a missing stored generation as s0', () = test('markSessionPartialRefsIssued: an empty result leaves all frame state untouched (ADR 0014)', () => { const session = makeSession(); // A useful prior frame exists. - session.refFrameState = 'active'; - session.refFrameScope = new Set(['e1']); - session.refFrameGeneration = 7; + session.snapshotGeneration = 7; + activatePartialRefFrame(session, new Set(['e1'])); + const priorFrame = refFrame(session); // An empty partial publication (no refs) must not supersede that authority. markSessionPartialRefsIssued(session, []); - expect(session.refFrameState).toBe('active'); - expect(session.refFrameScope).toEqual(new Set(['e1'])); - expect(session.refFrameGeneration).toBe(7); + // The frame is one value, so "untouched" is one identity check rather than a + // field-by-field comparison that can miss the field nobody thought to assert. + expect(refFrame(session)).toBe(priorFrame); + expect(refFrameState(session)).toBe('active'); + expect(refFrameScope(session)).toEqual(new Set(['e1'])); + expect(refFrame(session).generation).toBe(7); // A non-empty result supersedes with exactly its bodies. session.snapshotGeneration = 9; markSessionPartialRefsIssued(session, ['@e5~s7', 'e6']); - expect(session.refFrameScope).toEqual(new Set(['e5', 'e6'])); - expect(session.refFrameGeneration).toBe(9); + expect(refFrameScope(session)).toEqual(new Set(['e5', 'e6'])); + expect(refFrame(session).generation).toBe(9); }); // The observation counter and the authorization epoch are different clocks, and conflating them diff --git a/src/daemon/__tests__/tv-remote-runtime.test.ts b/src/daemon/__tests__/tv-remote-runtime.test.ts index 727974449a..17fdb7713c 100644 --- a/src/daemon/__tests__/tv-remote-runtime.test.ts +++ b/src/daemon/__tests__/tv-remote-runtime.test.ts @@ -17,7 +17,7 @@ import { makeSession } from '../../__tests__/test-utils/session-factories.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; import { LeaseRegistry } from '../lease-registry.ts'; -import { activateCompleteRefFrame } from '../ref-frame.ts'; +import { activateCompleteRefFrame, refFrameState } from '../ref-frame.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; import type { GenericPlatformExecutionParams } from '../request-generic-dispatch.ts'; import { resolveBoundTvRemoteRuntime } from '../tv-remote-runtime.ts'; @@ -238,7 +238,7 @@ test('request router joins tv-remote admission to execution and ref invalidation ok: true, data: { action: 'tv-remote', button: 'down', message: 'Pressed TV remote down' }, }); - expect(session.refFrameState).toBe('expired'); + expect(refFrameState(session)).toBe('expired'); expect(harness.bind).toHaveBeenCalledTimes(1); expect(harness.tvRemote).toHaveBeenCalledTimes(1); }); diff --git a/src/daemon/__tests__/viewport-runtime.test.ts b/src/daemon/__tests__/viewport-runtime.test.ts index 985a742dcc..08235b3961 100644 --- a/src/daemon/__tests__/viewport-runtime.test.ts +++ b/src/daemon/__tests__/viewport-runtime.test.ts @@ -17,7 +17,7 @@ import { makeSession } from '../../__tests__/test-utils/session-factories.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; import { LeaseRegistry } from '../lease-registry.ts'; -import { activateCompleteRefFrame } from '../ref-frame.ts'; +import { activateCompleteRefFrame, refFrameState } from '../ref-frame.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; import type { GenericPlatformExecutionParams } from '../request-generic-dispatch.ts'; import { resolveBoundViewportRuntime } from '../viewport-runtime.ts'; @@ -189,7 +189,7 @@ test('request router joins viewport admission to execution, recording, and ref i ok: true, data: { width: 1280, height: 900, message: 'Viewport set: 1280x900' }, }); - expect(session.refFrameState).toBe('expired'); + expect(refFrameState(session)).toBe('expired'); expect(session.actions.at(-1)).toMatchObject({ command: 'viewport', positionals: ['1280', '900'], diff --git a/src/daemon/handlers/__tests__/react-native.test.ts b/src/daemon/handlers/__tests__/react-native.test.ts index 8cba0bfd53..077ad999a1 100644 --- a/src/daemon/handlers/__tests__/react-native.test.ts +++ b/src/daemon/handlers/__tests__/react-native.test.ts @@ -10,6 +10,7 @@ import { mockTapPoint, resetGetRuntimeFixture, } from '../../__tests__/interaction-get-runtime-fixture.ts'; +import { refFrameState } from '../../ref-frame.ts'; vi.mock('../../snapshot-capture.ts', () => ({ captureSnapshot: vi.fn(), @@ -79,7 +80,7 @@ test('react-native dismiss-overlay taps collapsed warning close affordance inste expect(response?.ok).toBe(true); // ADR 0014 side-effect seam: overlay dismissal taps the device, so it expires // the ref frame. - expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); + expect(refFrameState(sessionStore.get(sessionName)!)).toBe('expired'); expect(mockDismissTap).toHaveBeenCalledWith( expect.objectContaining({ point: { x: 379, y: 820 } }), ); diff --git a/src/daemon/handlers/__tests__/session-install-source-ref-frame.test.ts b/src/daemon/handlers/__tests__/session-install-source-ref-frame.test.ts index f5b242219e..714190288d 100644 --- a/src/daemon/handlers/__tests__/session-install-source-ref-frame.test.ts +++ b/src/daemon/handlers/__tests__/session-install-source-ref-frame.test.ts @@ -9,6 +9,7 @@ import { mockInspectDeviceRuntimeFacts, mockMaterializeAppSourceRuntime, } from './session-command-harness.ts'; +import { activateCompleteRefFrame, refFrameState } from '../../ref-frame.ts'; const invoke = async (): Promise => { throw new Error('install_source ref-frame tests must stay on the runtime route'); @@ -37,7 +38,7 @@ test('install_source admission failure preserves an active ref frame', async () expect(response).toMatchObject({ ok: false, error: { code: 'UNSUPPORTED_OPERATION' } }); expect(inspectFacts).toHaveBeenCalledOnce(); expect(mockBindDeviceRuntime).not.toHaveBeenCalled(); - expect(session.refFrameState).toBe('active'); + expect(refFrameState(session)).toBe('active'); expect(session.snapshotScopeSource).toBe(session.snapshot); }); @@ -54,7 +55,7 @@ test('install_source materialization failure preserves an active ref frame', asy expect(mockBindDeviceRuntime).toHaveBeenCalledOnce(); expect(mockMaterializeAppSourceRuntime).toHaveBeenCalledOnce(); expect(mockDeployMaterializedAppRuntime).not.toHaveBeenCalled(); - expect(session.refFrameState).toBe('active'); + expect(refFrameState(session)).toBe('active'); expect(session.snapshotScopeSource).toBe(session.snapshot); }); @@ -63,7 +64,7 @@ test('install_source deploy attempt expires an active ref frame when the bound o const session = activeSession(); sessionStore.set('default', session); mockDeployMaterializedAppRuntime.mockImplementationOnce(async () => { - expect(session.refFrameState).toBe('expired'); + expect(refFrameState(session)).toBe('expired'); expect(session.snapshotScopeSource).toBeUndefined(); throw new Error('provider deployment failed'); }); @@ -75,13 +76,13 @@ test('install_source deploy attempt expires an active ref frame when the bound o expect(mockBindDeviceRuntime).toHaveBeenCalledOnce(); expect(mockMaterializeAppSourceRuntime).toHaveBeenCalledOnce(); expect(mockDeployMaterializedAppRuntime).toHaveBeenCalledOnce(); - expect(session.refFrameState).toBe('expired'); + expect(refFrameState(session)).toBe('expired'); expect(session.snapshotScopeSource).toBeUndefined(); }); function activeSession(): SessionState { const snapshot = { createdAt: Date.now(), nodes: [] }; - return { + const session: SessionState = { name: 'default', createdAt: Date.now(), actions: [], @@ -94,8 +95,9 @@ function activeSession(): SessionState { }, snapshot, snapshotScopeSource: snapshot, - refFrameState: 'active', }; + activateCompleteRefFrame(session); + return session; } async function dispatchInstallSource(params: { diff --git a/src/daemon/handlers/__tests__/session-push-ref-frame.test.ts b/src/daemon/handlers/__tests__/session-push-ref-frame.test.ts index beb71b59d8..7e3d17b816 100644 --- a/src/daemon/handlers/__tests__/session-push-ref-frame.test.ts +++ b/src/daemon/handlers/__tests__/session-push-ref-frame.test.ts @@ -8,6 +8,7 @@ import { mockInspectDeviceRuntimeFacts, mockPushNotificationRuntime, } from './session-command-harness.ts'; +import { activateCompleteRefFrame, refFrameState } from '../../ref-frame.ts'; const invoke = async (): Promise => { throw new Error('push ref-frame tests must stay on the runtime route'); @@ -36,7 +37,7 @@ test('push admission failure preserves an active ref frame', async () => { expect(response).toMatchObject({ ok: false, error: { code: 'UNSUPPORTED_OPERATION' } }); expect(inspectFacts).toHaveBeenCalledOnce(); expect(mockBindDeviceRuntime).not.toHaveBeenCalled(); - expect(session.refFrameState).toBe('active'); + expect(refFrameState(session)).toBe('active'); expect(session.snapshotScopeSource).toBe(session.snapshot); }); @@ -51,13 +52,13 @@ test('push dispatch attempt expires an active ref frame when the bound operation expect(mockInspectDeviceRuntimeFacts).toHaveBeenCalledOnce(); expect(mockBindDeviceRuntime).toHaveBeenCalledOnce(); expect(mockPushNotificationRuntime).toHaveBeenCalledOnce(); - expect(session.refFrameState).toBe('expired'); + expect(refFrameState(session)).toBe('expired'); expect(session.snapshotScopeSource).toBeUndefined(); }); function activeSession(): SessionState { const snapshot = { createdAt: Date.now(), nodes: [] }; - return { + const session: SessionState = { name: 'default', createdAt: Date.now(), actions: [], @@ -70,8 +71,9 @@ function activeSession(): SessionState { }, snapshot, snapshotScopeSource: snapshot, - refFrameState: 'active', }; + activateCompleteRefFrame(session); + return session; } async function dispatchPush(params: { diff --git a/src/daemon/handlers/__tests__/session-relaunch-close.test.ts b/src/daemon/handlers/__tests__/session-relaunch-close.test.ts index 3866045f35..2b8be533ab 100644 --- a/src/daemon/handlers/__tests__/session-relaunch-close.test.ts +++ b/src/daemon/handlers/__tests__/session-relaunch-close.test.ts @@ -66,6 +66,7 @@ import { scheduleIosRunnerIdleStop, } from '@agent-device/platform-apple/runner/operations'; import { runMacOsAlertAction } from '@agent-device/platform-apple/macos'; +import { refFrameState } from '../../ref-frame.ts'; const mockResolveTargetDevice = vi.mocked(getResolveTargetDeviceMock()); const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady); @@ -195,7 +196,7 @@ test('open --relaunch leaves the old frame expired when the close dispatch fails }), ); // A freshly issued frame is active before the relaunch. - expect(sessionStore.get(sessionName)?.refFrameState).toBeUndefined(); + expect(refFrameState(sessionStore.get(sessionName)!)).toBe('active'); // The relaunch close dispatches and then fails/times out AFTER the app may // already have been torn down. @@ -212,7 +213,7 @@ test('open --relaunch leaves the old frame expired when the close dispatch fails // session's frame was expired BEFORE the close dispatch and stays expired — a // post-dispatch close failure never restores it (there is no rollback). expect(response?.ok ?? false).toBe(false); - expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); + expect(refFrameState(sessionStore.get(sessionName)!)).toBe('expired'); }); test('open --relaunch does not let an ambient provider claim suppress a local pre-close', async () => { diff --git a/src/daemon/handlers/__tests__/session-runtime-command.test.ts b/src/daemon/handlers/__tests__/session-runtime-command.test.ts index 0a76b8a0f4..1c560d5614 100644 --- a/src/daemon/handlers/__tests__/session-runtime-command.test.ts +++ b/src/daemon/handlers/__tests__/session-runtime-command.test.ts @@ -19,6 +19,7 @@ import { gestureInspectFacts, gestureRuntimeSpies, } from '../../__tests__/test-device-runtime-gateway.ts'; +import { refFrameState } from '../../ref-frame.ts'; test('runtime set/show/clear manages session-scoped runtime hints before open', async () => { const sessionStore = makeSessionStore(); @@ -151,7 +152,7 @@ test('runtime clear expires the ref frame at the admitted hint mutation boundary }); mockClearRuntimeHints.mockImplementationOnce(async () => { // Planted route witness: deleting the handler's pre-mutation expiry leaves this frame active. - expect(session.refFrameState).toBe('expired'); + expect(refFrameState(session)).toBe('expired'); }); const response = await handleSessionCommands({ @@ -170,7 +171,7 @@ test('runtime clear expires the ref frame at the admitted hint mutation boundary expect(response?.ok).toBe(true); expect(mockClearRuntimeHints).toHaveBeenCalledOnce(); - expect(session.refFrameState).toBe('expired'); + expect(refFrameState(session)).toBe('expired'); }); test('runtime clear rejects a false runtime-hints fact before its one implementation bind', async () => { diff --git a/src/daemon/handlers/__tests__/session-selector-dispatch.test.ts b/src/daemon/handlers/__tests__/session-selector-dispatch.test.ts index c4b640bc60..df1e71091f 100644 --- a/src/daemon/handlers/__tests__/session-selector-dispatch.test.ts +++ b/src/daemon/handlers/__tests__/session-selector-dispatch.test.ts @@ -27,6 +27,7 @@ import { } from './session-test-harness.ts'; import type { SessionState } from '../../types.ts'; import { handleSessionCommands } from './session-command-harness.ts'; +import { refFrameState } from '../../ref-frame.ts'; const available = Object.freeze({ available: true } as const); @@ -97,7 +98,7 @@ test('keyboard dismiss crosses the ADR 0014 seam while keyboard status preserves inspectFacts, bindDevice, }); - expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); + expect(refFrameState(sessionStore.get(sessionName)!)).toBe('expired'); // status is a read-only probe → frame preserved (undefined === active). sessionStore.set(sessionName, makeSession(sessionName, device)); @@ -116,7 +117,7 @@ test('keyboard dismiss crosses the ADR 0014 seam while keyboard status preserves inspectFacts, bindDevice, }); - expect(sessionStore.get(sessionName)?.refFrameState).toBeUndefined(); + expect(refFrameState(sessionStore.get(sessionName)!)).toBe('active'); }); // ADR 0014 requires the frame to expire immediately before the mutating call, with no @@ -137,7 +138,7 @@ test('keyboard dismiss expires the frame before the invocation runs, even when i const logPath = path.join(os.tmpdir(), 'daemon.log'); const { inspectFacts, bindDevice } = keyboardCapableRuntime(device, { keyboardDismiss: () => { - expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); + expect(refFrameState(sessionStore.get(sessionName)!)).toBe('expired'); return Promise.reject(new AppError('COMMAND_FAILED', 'runner timed out')); }, }); @@ -160,7 +161,7 @@ test('keyboard dismiss expires the frame before the invocation runs, even when i bindDevice, }), ).rejects.toMatchObject({ code: 'COMMAND_FAILED' }); - expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); + expect(refFrameState(sessionStore.get(sessionName)!)).toBe('expired'); }); test('keyboard requires an active session or explicit device selector', async () => { diff --git a/src/daemon/handlers/__tests__/snapshot-handler.test.ts b/src/daemon/handlers/__tests__/snapshot-handler.test.ts index 3257ece49f..c1ab318e41 100644 --- a/src/daemon/handlers/__tests__/snapshot-handler.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-handler.test.ts @@ -64,6 +64,7 @@ vi.mock('../../ios-app-session-hint.ts', () => ({ import { runAppleRunnerCommand } from '@agent-device/platform-apple/runner/operations'; import { buildIosOpenCommandHint } from '../../ios-app-session-hint.ts'; +import { expireRefFrame, refFrame, refFrameState, refFrameTree } from '../../ref-frame.ts'; const mockRunnerCommand = vi.mocked(runAppleRunnerCommand); const mockBuildIosOpenCommandHint = vi.mocked(buildIosOpenCommandHint); @@ -445,7 +446,7 @@ test('snapshot re-activates a complete frame; diff preserves it (ADR 0014)', asy backend: 'android', }; // A prior device action expired the frame. - session.refFrameState = 'expired'; + expireRefFrame(session); sessionStore.set(sessionName, session); legacyDispatchCapture.mockResolvedValue({ nodes: [{ index: 0, depth: 0, type: 'android.widget.Button', label: 'Fresh' }], @@ -463,7 +464,7 @@ test('snapshot re-activates a complete frame; diff preserves it (ADR 0014)', asy // The snapshot response hands every stored node's ref to the client: it // re-activates a complete frame, so refs are current again. expect(snapshotResponse?.ok).toBe(true); - expect(sessionStore.get(sessionName)?.refFrameState).toBe('active'); + expect(refFrameState(sessionStore.get(sessionName)!)).toBe('active'); const diffResponse = await handleSnapshotCommands({ req: snapshotRequest(sessionName, 'diff', { positionals: ['snapshot'] }), @@ -475,7 +476,7 @@ test('snapshot re-activates a complete frame; diff preserves it (ADR 0014)', asy // diff replaces the observation but is a read (summary response): it preserves // the authorized frame rather than expiring it. expect(diffResponse?.ok).toBe(true); - expect(sessionStore.get(sessionName)?.refFrameState).toBe('active'); + expect(refFrameState(sessionStore.get(sessionName)!)).toBe('active'); }); // #1076 versioned refs — shared harness for the refsGeneration tests below. @@ -517,8 +518,8 @@ function expectInternalObservationResult(params: { expect(params.response?.ok ? params.response.data?.refsGeneration : undefined).toBeUndefined(); expect(params.session?.snapshotGeneration).toBe((params.publishedGeneration as number) + 1); expect(params.session?.snapshot).not.toBe(params.publishedTree); - expect(params.session?.refFrameGeneration).toBe(params.publishedGeneration); - expect(params.session?.refFrameTree).toBe(params.publishedTree); + expect(refFrame(params.session!).generation).toBe(params.publishedGeneration); + expect(refFrameTree(params.session!)).toBe(params.publishedTree); } test('snapshot responses carry refsGeneration and advance it per capture (#1076 versioned refs)', async () => { @@ -560,8 +561,8 @@ test('daemon-private snapshot observation advances capture state without publish await runVersionedRefsCommand({ sessionStore, sessionName, command: 'snapshot' }); const published = sessionStore.get(sessionName); - const publishedGeneration = published?.refFrameGeneration; - const publishedTree = published?.refFrameTree; + const publishedGeneration = refFrame(published!).generation; + const publishedTree = refFrameTree(published!); legacyDispatchCapture.mockResolvedValue({ nodes: [{ index: 0, depth: 0, type: 'android.widget.Button', label: 'Internal' }], diff --git a/src/daemon/interaction/internal/__tests__/find.test.ts b/src/daemon/interaction/internal/__tests__/find.test.ts index c674256da4..805670a043 100644 --- a/src/daemon/interaction/internal/__tests__/find.test.ts +++ b/src/daemon/interaction/internal/__tests__/find.test.ts @@ -30,6 +30,7 @@ import { resetFindTouchRuntimeFixture, } from './find-touch-runtime-fixture.ts'; import { invokeFindHandler } from './find-handler-fixture.ts'; +import { refFrameScope, refFrameState } from '../../../ref-frame.ts'; beforeEach(() => { resetFindTouchRuntimeFixture(); @@ -92,7 +93,7 @@ test('mutating find focus crosses the ADR 0014 side-effect seam and expires the nodes: [node], }); expect(response.ok).toBe(true); - expect(session.refFrameState).toBe('expired'); + expect(refFrameState(session)).toBe('expired'); }); test('handleFindCommands click returns deterministic metadata across locator variants', async () => { @@ -618,8 +619,8 @@ test('handleFindCommands list returns every match without acting', async () => { // next command (a plain `@eN` still requires a complete frame by design — // the MCP/CLI layers pin from `matches` + `refsGeneration`). expect(typeof response.data?.refsGeneration).toBe('number'); - expect(session.refFrameState).toBe('active'); - expect([...(session.refFrameScope ?? [])].sort()).toEqual(['e2', 'e3', 'e4']); + expect(refFrameState(session)).toBe('active'); + expect([...(refFrameScope(session) ?? [])].sort()).toEqual(['e2', 'e3', 'e4']); }); test('handleFindCommands list on a unique match still lists instead of tapping', async () => { diff --git a/src/daemon/interaction/internal/__tests__/interaction-ambiguity-publication.test.ts b/src/daemon/interaction/internal/__tests__/interaction-ambiguity-publication.test.ts index 7085c428cd..c10ca3ab2a 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-ambiguity-publication.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-ambiguity-publication.test.ts @@ -57,5 +57,5 @@ test('non-ambiguity errors do not change ref authority', () => { }), original, ); - assert.equal(state.refFrameScope, undefined); + assert.equal(state.refFrame, undefined); }); diff --git a/src/daemon/interaction/internal/__tests__/interaction-settle.test.ts b/src/daemon/interaction/internal/__tests__/interaction-settle.test.ts index cffbe001b9..d9ad3853d2 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-settle.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-settle.test.ts @@ -7,7 +7,7 @@ import type { SessionState } from '../../../types.ts'; import type { SnapshotBackend } from '@agent-device/kernel/snapshot'; import { buildSnapshotState } from '../../../../core/snapshot-state.ts'; import { setSessionSnapshot } from '../../../session-snapshot.ts'; -import { activateCompleteRefFrame } from '../../../ref-frame.ts'; +import { activateCompleteRefFrame, expireRefFrame, refFrameState } from '../../../ref-frame.ts'; import { makeSessionStore } from '../../../../__tests__/test-utils/store-factory.ts'; import { makeIosSession } from '../../../../__tests__/test-utils/session-factories.ts'; import { @@ -229,7 +229,7 @@ test('press --settle responds with the settled diff, refsGeneration, and activat const session = sessionStore.get(sessionName) as SessionState; // The settle response handed the settled tree's refs to the client: it // activated a partial frame and the payload carries the stored generation. - expect(session.refFrameState).toBe('active'); + expect(refFrameState(session)).toBe('active'); expect(settle.refsGeneration).toBe(session.snapshotGeneration); // The settled tree became the stored session snapshot. expect(session.snapshot?.nodes.some((node) => node.label === 'Welcome!')).toBe(true); @@ -308,7 +308,7 @@ test('press --settle rejects an expired-frame ref before dispatch or observation const sessionName = 'settle-stale-ref'; const session = seedSession(sessionName, sessionStore); // ADR 0014: a device action since the snapshot expired the ref frame. - session.refFrameState = 'expired'; + expireRefFrame(session); sessionStore.set(sessionName, session); legacyDispatchCapture.mockRejectedValue( new Error('dispatch should not be called for an expired-frame ref'), @@ -337,7 +337,7 @@ test('press --settle rejects an expired-frame ref before dispatch or observation expect(String(response.error.details?.hint)).toMatch(/refs were issued/); } expect(mockCaptureSnapshotForSession).not.toHaveBeenCalled(); - expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); + expect(refFrameState(sessionStore.get(sessionName)!)).toBe('expired'); }); test('a settle observation without a diff leaves ref staleness untouched', async () => { @@ -377,7 +377,7 @@ test('a settle observation without a diff leaves ref staleness untouched', async expect(settle.hint).toMatch(/Settle observation unavailable/); // The press mutated (expiring the frame) and no partial frame was published, // so the frame stays expired — refs are stale until a fresh snapshot. - expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); + expect(refFrameState(sessionStore.get(sessionName)!)).toBe('expired'); }); test('a stalled settle capture receives its deadline signal and leaves the interaction responsive', async () => { diff --git a/src/daemon/interaction/internal/__tests__/interaction-touch-android-freshness.test.ts b/src/daemon/interaction/internal/__tests__/interaction-touch-android-freshness.test.ts index f4af1438c0..7a1296b902 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-touch-android-freshness.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-touch-android-freshness.test.ts @@ -43,6 +43,7 @@ import { getAndroidScreenSize, } from '@agent-device/platform-android/mechanics'; import { captureSnapshotWithInteractor } from '../../../snapshot-interactor-capture.ts'; +import { activateCompleteRefFrame } from '../../../ref-frame.ts'; const mockGetAndroidAppState = vi.mocked(getAndroidAppState); const mockGetAndroidBlockingDialogObservation = vi.mocked(getAndroidBlockingDialogObservation); const mockGetAndroidScreenSize = vi.mocked(getAndroidScreenSize); @@ -151,7 +152,7 @@ test('ADR 0014: Android freshness cannot retarget an admitted ref by positional }; session.snapshot = frameTree; // ADR 0014: the authorized frame tree names WHICH node @e1 authorizes. - session.refFrameTree = frameTree; + activateCompleteRefFrame(session); session.androidSnapshotFreshness = { action: 'press', markedAt: Date.now(), diff --git a/src/daemon/interaction/internal/__tests__/interaction-touch-direct-ios.test.ts b/src/daemon/interaction/internal/__tests__/interaction-touch-direct-ios.test.ts index e9aec8195f..c5d5d554b7 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-touch-direct-ios.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-touch-direct-ios.test.ts @@ -13,6 +13,7 @@ import { makeStaleRefSession, runInteraction, } from './interaction-touch-fixtures.ts'; +import { refFrameState } from '../../../ref-frame.ts'; vi.mock('@agent-device/platform-android/mechanics', async (importOriginal) => { const actual = await importOriginal(); @@ -71,5 +72,5 @@ test('Maestro selector click crosses the ADR 0014 fused seam and expires the ref expect(click?.ok).toBe(true); expect(mockTapElementSelector).toHaveBeenCalledOnce(); - expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); + expect(refFrameState(sessionStore.get(sessionName)!)).toBe('expired'); }); diff --git a/src/daemon/interaction/internal/__tests__/interaction-touch-press-admission.test.ts b/src/daemon/interaction/internal/__tests__/interaction-touch-press-admission.test.ts index 1b11e2395d..ea0c48158c 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-touch-press-admission.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-touch-press-admission.test.ts @@ -1,7 +1,7 @@ import { test, expect, vi, beforeEach } from 'vitest'; import { attachRefs } from '@agent-device/kernel/snapshot'; import { makeSessionStore } from '../../../../__tests__/test-utils/store-factory.ts'; -import { activateCompleteRefFrame } from '../../../ref-frame.ts'; +import { activateCompleteRefFrame, refFrameState } from '../../../ref-frame.ts'; import { setSessionSnapshot, STALE_SNAPSHOT_REFS_WARNING } from '../../../session-snapshot.ts'; import { handleInteractionCommands } from '../../index.ts'; import { @@ -179,7 +179,7 @@ test('press selector then press @ref rejects refs that outlived the stored snaps // ADR 0014: the selector press crossed the side-effect seam and expired the // frame, so the ref that outlived it is rejected before dispatch. - expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); + expect(refFrameState(sessionStore.get(sessionName)!)).toBe('expired'); const touchCallsBeforeStaleRef = mockTapPoint.mock.calls.length; const refPress = await runInteraction(sessionStore, sessionName, 'press', ['@e1']); expect(refPress?.ok).toBe(false); @@ -204,12 +204,12 @@ test('a ref press crosses the ADR 0014 side-effect seam and expires the ref fram }); // A freshly issued complete frame is active. - expect(sessionStore.get(sessionName)?.refFrameState).toBe('active'); + expect(refFrameState(sessionStore.get(sessionName)!)).toBe('active'); const press = await runInteraction(sessionStore, sessionName, 'press', ['@e1']); expect(press?.ok).toBe(true); // The transition is wired at the leaf seam. - expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); + expect(refFrameState(sessionStore.get(sessionName)!)).toBe('expired'); }); test('ADR 0014 evidence #1: a second ref mutation rejects (bare and pinned) until a fresh observation re-authorizes', async () => { @@ -229,7 +229,7 @@ test('ADR 0014 evidence #1: a second ref mutation rejects (bare and pinned) unti // `snapshot -> press @e1`: admitted; crosses the seam and expires the frame. const first = await runInteraction(sessionStore, sessionName, 'press', ['@e1']); expect(first?.ok).toBe(true); - expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); + expect(refFrameState(sessionStore.get(sessionName)!)).toBe('expired'); // `-> press @e2`: the unobserved second mutation rejects (bare). const bare = await runInteraction(sessionStore, sessionName, 'press', ['@e2']); @@ -276,7 +276,7 @@ test('re-issuing a complete frame lets press @ref succeed again without warning' ]); expect(selectorPress?.ok).toBe(true); // The selector press expired the frame (ADR 0014 seam). - expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); + expect(refFrameState(sessionStore.get(sessionName)!)).toBe('expired'); // Simulate the snapshot command re-issuing the complete ref namespace: it // re-activates a complete frame (through buildNextSnapshotSession; covered in @@ -342,7 +342,7 @@ test('ADR 0014 evidence #6: a read-only capture does not invalidate a mutation r createdAt: Date.now(), backend: 'xctest', }); - expect(session.refFrameState).toBe('active'); + expect(refFrameState(session)).toBe('active'); sessionStore.set(sessionName, session); mockTapPoint.mockResolvedValue({ pressed: true }); @@ -350,7 +350,7 @@ test('ADR 0014 evidence #6: a read-only capture does not invalidate a mutation r expect(response?.ok).toBe(true); expect(mockTapPoint).toHaveBeenCalled(); // Crossing the seam expired the frame, so a SECOND plain ref is now rejected. - expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); + expect(refFrameState(sessionStore.get(sessionName)!)).toBe('expired'); const second = await runInteraction(sessionStore, sessionName, 'press', ['@e1']); expect(second?.ok).toBe(false); if (second && !second.ok) { diff --git a/src/daemon/internal-observation.ts b/src/daemon/internal-observation.ts index 8ac278d5ac..c2965bc0cf 100644 --- a/src/daemon/internal-observation.ts +++ b/src/daemon/internal-observation.ts @@ -1,5 +1,5 @@ import type { SnapshotState } from '@agent-device/kernel/snapshot'; -import { readSessionRuntimeRevision } from './ref-frame.ts'; +import { readSessionRuntimeRevision, refFrame, type RefFrame } from './ref-frame.ts'; import { markSessionPartialRefsIssued, setSessionSnapshot } from './session-snapshot.ts'; import type { SessionState } from './types.ts'; @@ -13,13 +13,6 @@ export type InternalObservationEvidence = { readonly [INTERNAL_OBSERVATION_EVIDENCE]: true; }; -type RefFrameLineage = Readonly<{ - state: SessionState['refFrameState']; - scope: SessionState['refFrameScope']; - tree: SessionState['refFrameTree']; - generation: SessionState['refFrameGeneration']; -}>; - type InternalObservationLineage = Readonly<{ sessionName: string; session: SessionState; @@ -27,7 +20,7 @@ type InternalObservationLineage = Readonly<{ snapshot: SnapshotState; snapshotGeneration: number; runtimeRevision: number; - refFrame: RefFrameLineage; + refFrame: RefFrame; }>; const evidenceLineage = new WeakMap(); @@ -120,7 +113,7 @@ function storeInternalObservation( snapshot, snapshotGeneration, runtimeRevision: readSessionRuntimeRevision(session), - refFrame: readRefFrameLineage(session), + refFrame: refFrame(session), }); return { evidence, refsGeneration: snapshotGeneration }; } @@ -179,25 +172,7 @@ function isCurrentLineage( current.snapshot === lineage.snapshot && current.snapshotGeneration === lineage.snapshotGeneration && readSessionRuntimeRevision(current) === lineage.runtimeRevision && - sameRefFrameLineage(readRefFrameLineage(current), lineage.refFrame) - ); -} - -function readRefFrameLineage(session: SessionState): RefFrameLineage { - return { - state: session.refFrameState, - scope: session.refFrameScope, - tree: session.refFrameTree, - generation: session.refFrameGeneration, - }; -} - -function sameRefFrameLineage(left: RefFrameLineage, right: RefFrameLineage): boolean { - return ( - left.state === right.state && - left.scope === right.scope && - left.tree === right.tree && - left.generation === right.generation + refFrame(current) === lineage.refFrame ); } diff --git a/src/daemon/ref-frame.ts b/src/daemon/ref-frame.ts index 5166c24d60..8112fa89ca 100644 --- a/src/daemon/ref-frame.ts +++ b/src/daemon/ref-frame.ts @@ -1,3 +1,4 @@ +import type { SnapshotState } from '@agent-device/kernel/snapshot'; import type { SessionState } from './types.ts'; const runtimeRevisions = new WeakMap(); @@ -44,15 +45,64 @@ export type RefFrameAdmission = | { admitted: true } | { admitted: false; reason: RefFrameRejectReason }; +const REF_FRAME = Symbol('ref-frame'); + +/** + * The frame as one value. `state`, `scope`, `tree` and `generation` move + * together or the frame is incoherent, so they are not four fields a caller can + * step out of agreement: `SessionState.refFrame` is REPLACED whole by this + * module's transitions and never mutated. The brand key is module-private, so + * no module outside this file can construct or edit one — the accessors below + * are the whole read surface. + */ +export type RefFrame = { + readonly [REF_FRAME]: true; + readonly state: RefFrameState; + readonly scope: RefFrameScope; + readonly tree: SnapshotState | undefined; + readonly generation: number | undefined; +}; + +/** + * The frame a session has before anything issues refs: complete authority over + * an empty namespace, deferring tree and epoch to the operational observation. + * A shared constant, so lineage identity is stable for a session that never + * reached a transition. + */ +const PRISTINE_FRAME: RefFrame = { + [REF_FRAME]: true, + state: 'active', + scope: 'all', + tree: undefined, + generation: undefined, +}; + /** - * The frame epoch exposed to clients as `refsGeneration`. Frozen at issuance - * (`refFrameGeneration`) so a later read-only capture that advances the - * observation counter (`snapshotGeneration`) does not shift the epoch a valid - * pin is compared against. Falls back to `snapshotGeneration` for pre-frame - * sessions. + * The session's current frame, as one comparable value. Identity changes on + * every transition and only on a transition, so a caller holding an earlier + * frame can tell whether authority moved with a single `===`. + */ +export function refFrame(session: SessionState): RefFrame { + return session.refFrame ?? PRISTINE_FRAME; +} + +/** + * The frame epoch exposed to clients as `refsGeneration`. Frozen at issuance so + * a later read-only capture that advances the observation counter + * (`snapshotGeneration`) does not shift the epoch a valid pin is compared + * against. Falls back to `snapshotGeneration` for pre-frame sessions. */ export function refFrameEpoch(session: SessionState): number | undefined { - return session.refFrameGeneration ?? session.snapshotGeneration; + return refFrame(session).generation ?? session.snapshotGeneration; +} + +/** + * The tree that minted the frame's refs, retained so a ref resolves to the node + * the caller was authorized against rather than to whatever now sits at that + * index in a newer observation. Undefined before any issuance. + */ +export function refFrameTree(session: SessionState): SnapshotState | undefined { + return refFrame(session).tree; } /** @@ -70,7 +120,7 @@ export function refFrameEpoch(session: SessionState): number | undefined { */ export function expireRefFrame(session: SessionState): void { advanceSessionRuntimeRevision(session); - session.refFrameState = 'expired'; + session.refFrame = { ...refFrame(session), state: 'expired' }; session.snapshotScopeSource = undefined; } @@ -102,7 +152,7 @@ export function readSessionRuntimeRevision(session: SessionState): number { * {@link activateRefFrame}. */ export function activateCompleteRefFrame(session: SessionState): void { - activateRefFrame(session, undefined); + activateRefFrame(session, 'all'); } /** @@ -119,29 +169,31 @@ export function activatePartialRefFrame(session: SessionState, scope: ReadonlySe } /** - * The frame's four fields move together or the frame is incoherent: an `active` state with a - * stale `refFrameTree` resolves refs against a namespace nobody authorized, and a frame - * pinned to the wrong `refFrameGeneration` invalidates correct pins. Both issuance forms - * therefore land here rather than each writing the four fields itself. + * Both issuance forms land here because they differ only in scope, and because the frame is + * one value: an `active` state paired with a stale tree resolves refs against a namespace + * nobody authorized, and a frame pinned to the wrong generation invalidates correct pins. * * Retains the just-published tree (`session.snapshot`) as the frame's immutable source by * SHARED reference — no deep copy (ADR 0014 performance). A later read-only capture advances * `session.snapshot` without disturbing this tree, so a ref keeps resolving against the * namespace that authorized it. */ -function activateRefFrame(session: SessionState, scope: ReadonlySet | undefined): void { - session.refFrameState = 'active'; - session.refFrameScope = scope; - session.refFrameTree = session.snapshot; - session.refFrameGeneration = session.snapshotGeneration; +function activateRefFrame(session: SessionState, scope: RefFrameScope): void { + session.refFrame = { + [REF_FRAME]: true, + state: 'active', + scope, + tree: session.snapshot, + generation: session.snapshotGeneration, + }; } export function refFrameState(session: SessionState): RefFrameState { - return session.refFrameState ?? 'active'; + return refFrame(session).state; } export function refFrameScope(session: SessionState): RefFrameScope { - return session.refFrameScope ?? 'all'; + return refFrame(session).scope; } export type RefMutationFrame = { diff --git a/src/daemon/replay/internal/__tests__/session-replay-divergence-publication.test.ts b/src/daemon/replay/internal/__tests__/session-replay-divergence-publication.test.ts index 6250795818..c0ce839aae 100644 --- a/src/daemon/replay/internal/__tests__/session-replay-divergence-publication.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-divergence-publication.test.ts @@ -12,7 +12,7 @@ import { makeIosSession } from '../../../../__tests__/test-utils/session-factori import type { SnapshotState } from '@agent-device/kernel/snapshot'; import type { ReplayDivergence } from '@agent-device/contracts/divergence'; import { bindInternalObservationAuthority } from '../../../internal-observation.ts'; -import { expireRefFrame } from '../../../ref-frame.ts'; +import { expireRefFrame, refFrameScope, refFrameState, refFrameTree } from '../../../ref-frame.ts'; import { markSessionPartialRefsIssued, setSessionSnapshot } from '../../../session-snapshot.ts'; import { SessionStore } from '../../../session-store.ts'; import { captureDivergenceObservation } from '../session-replay-divergence.ts'; @@ -134,8 +134,8 @@ test('internal divergence capture updates observation without publishing client expect(observation.state).toBe('available'); expect(input.session.snapshot?.nodes[0]?.label).toBe('Captured again'); - expect(input.session.refFrameScope).toEqual(new Set(['old'])); - expect(input.session.refFrameTree).toBe(input.prior); + expect(refFrameScope(input.session)).toEqual(new Set(['old'])); + expect(refFrameTree(input.session)).toBe(input.prior); }); test.each([ @@ -158,8 +158,8 @@ test.each([ const refs = result.screen.state === 'available' ? result.screen.refs.map((entry) => entry.ref) : []; expect(refs).toHaveLength(expected); - expect(input.session.refFrameScope).toEqual(new Set(refs)); - expect(input.session.refFrameTree).toBe(input.snapshot); + expect(refFrameScope(input.session)).toEqual(new Set(refs)); + expect(refFrameTree(input.session)).toBe(input.snapshot); }); test('missing capture evidence fails closed instead of exposing unauthorized refs', () => { @@ -180,8 +180,8 @@ test('missing capture evidence fails closed instead of exposing unauthorized ref reason: 'ref-publication-missing-evidence', }), ); - expect(input.session.refFrameScope).toEqual(new Set(['old'])); - expect(input.session.refFrameTree).toBe(input.prior); + expect(refFrameScope(input.session)).toEqual(new Set(['old'])); + expect(refFrameTree(input.session)).toBe(input.prior); }); test('degenerate projected refs fail closed when publication normalizes to empty', () => { @@ -219,8 +219,8 @@ test('degenerate projected refs fail closed when publication normalizes to empty { selector: 'label="Empty ref"', basis: 'label' }, { selector: 'label="Empty scoped ref"', basis: 'label' }, ]); - expect(input.session.refFrameScope).toEqual(new Set(['old'])); - expect(input.session.refFrameTree).toBe(input.prior); + expect(refFrameScope(input.session)).toEqual(new Set(['old'])); + expect(refFrameTree(input.session)).toBe(input.prior); }); test('an empty outward projection consumes its one-shot capture evidence', () => { @@ -257,8 +257,8 @@ test('an empty outward projection consumes its one-shot capture evidence', () => reason: 'ref-publication-stale-capture', }), ); - expect(input.session.refFrameScope).toEqual(new Set(['old'])); - expect(input.session.refFrameTree).toBe(input.prior); + expect(refFrameScope(input.session)).toEqual(new Set(['old'])); + expect(refFrameTree(input.session)).toBe(input.prior); }); test('successful overflow publishes the refs exposed by the exact artifact projection', () => { @@ -280,8 +280,8 @@ test('successful overflow publishes the refs exposed by the exact artifact proje screen: { refs: Array<{ ref: string }> }; }; const artifactRefs = artifact.screen.refs.map((entry) => entry.ref); - expect(input.session.refFrameScope).toEqual(new Set(artifactRefs)); - expect(input.session.refFrameTree).toBe(input.snapshot); + expect(refFrameScope(input.session)).toEqual(new Set(artifactRefs)); + expect(refFrameTree(input.session)).toBe(input.snapshot); }); test('failed overflow artifact with no inline refs publishes nothing', () => { @@ -305,8 +305,8 @@ test('failed overflow artifact with no inline refs publishes nothing', () => { expect(result.artifactUnavailable).toBe(true); expect(result.screen.state).toBe('unavailable'); - expect(input.session.refFrameScope).toEqual(new Set(['old'])); - expect(input.session.refFrameTree).toBe(input.prior); + expect(refFrameScope(input.session)).toEqual(new Set(['old'])); + expect(refFrameTree(input.session)).toBe(input.prior); }); test('stale capture suppresses outward refs and preserves newer authority', () => { @@ -326,7 +326,7 @@ test('stale capture suppresses outward refs and preserves newer authority', () = }); expect(result.screen.state).toBe('unavailable'); - expect(input.session.refFrameScope).toEqual(new Set(['old'])); + expect(refFrameScope(input.session)).toEqual(new Set(['old'])); }); test('cancellation after capture suppresses outward refs without reactivating an expired frame', () => { @@ -346,6 +346,6 @@ test('cancellation after capture suppresses outward refs without reactivating an }); expect(result.screen.state).toBe('unavailable'); - expect(input.session.refFrameState).toBe('expired'); - expect(input.session.refFrameTree).toBe(input.prior); + expect(refFrameState(input.session)).toBe('expired'); + expect(refFrameTree(input.session)).toBe(input.prior); }); diff --git a/src/daemon/replay/internal/__tests__/session-replay-divergence.test.ts b/src/daemon/replay/internal/__tests__/session-replay-divergence.test.ts index 74cfb2cdc1..38dafd1b93 100644 --- a/src/daemon/replay/internal/__tests__/session-replay-divergence.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-divergence.test.ts @@ -43,6 +43,7 @@ import { legacyDispatchCapture, resetLegacySnapshotCapture, } from '../../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { refFrameScope, refFrameState } from '../../../ref-frame.ts'; const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); @@ -871,8 +872,8 @@ test('buildReplayFailureDivergence: the partial ref frame authorizes exactly the // screen refs — no more (no over-pin surface), no less (every advertised ref // is usable), even though every one of them is a `covered` node here. const stored = sessionStore.get(sessionName); - expect(stored?.refFrameState).toBe('active'); - expect(stored?.refFrameScope).toEqual(screenRefBodies); + expect(refFrameState(stored!)).toBe('active'); + expect(refFrameScope(stored!)).toEqual(screenRefBodies); }); // #1264 (capture parity, point 1): the divergence capture must go through the diff --git a/src/daemon/replay/internal/__tests__/session-replay-maestro-failure.test.ts b/src/daemon/replay/internal/__tests__/session-replay-maestro-failure.test.ts index 605b991330..fc6abbf834 100644 --- a/src/daemon/replay/internal/__tests__/session-replay-maestro-failure.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-maestro-failure.test.ts @@ -34,6 +34,7 @@ import { captureSnapshotThroughLegacyDispatchFixture, legacyDispatchCapture, } from '../../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { refFrameScope } from '../../../ref-frame.ts'; const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); @@ -368,7 +369,7 @@ test('typed Maestro failure publishes exactly the refs exposed by its divergence expect(divergence.screen.state).toBe('available'); expect(exposedRefs).toEqual(['e2']); - expect(scenario.sessionStore.get(scenario.sessionName)?.refFrameScope).toEqual( + expect(refFrameScope(scenario.sessionStore.get(scenario.sessionName)!)).toEqual( new Set(exposedRefs), ); }); diff --git a/src/daemon/runtime-session.ts b/src/daemon/runtime-session.ts index 80ab3fbcd9..5d494faf0e 100644 --- a/src/daemon/runtime-session.ts +++ b/src/daemon/runtime-session.ts @@ -1,5 +1,6 @@ import type { CommandSessionRecord, CommandSessionStore } from '../runtime-contract.ts'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; +import { refFrameTree } from './ref-frame.ts'; import type { SessionState } from './types.ts'; export type RuntimeSessionRecordOptions = { @@ -20,6 +21,7 @@ function toRuntimeSessionRecord( options: RuntimeSessionRecordOptions = {}, ): CommandSessionRecord | undefined { if (!session) return undefined; + const frameTree = refFrameTree(session); return { name, appBundleId: session.appBundleId, @@ -30,8 +32,8 @@ function toRuntimeSessionRecord( // ADR 0014: expose the authorized frame tree so ref resolution binds a // `@eN` to the node the caller was authorized against, not to whatever // now sits at that index in a newer observation. - ...(session.refFrameTree && options.omitRefFrameSnapshot !== true - ? { refFrameSnapshot: session.refFrameTree } + ...(frameTree && options.omitRefFrameSnapshot !== true + ? { refFrameSnapshot: frameTree } : {}), } : {}), diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts index 77e5ea3268..a63f9e9481 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts @@ -3,7 +3,11 @@ import os from 'node:os'; import path from 'node:path'; import { SessionStore } from '../../../session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../../../types.ts'; -import { readSessionRuntimeRevision } from '../../../ref-frame.ts'; +import { + activateCompleteRefFrame, + readSessionRuntimeRevision, + refFrameState, +} from '../../../ref-frame.ts'; import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; const runtimeHintsModule = vi.hoisted(() => ({ @@ -83,7 +87,7 @@ test('an app-only close without a target fails before admission or ref-frame exp booted: true, }; const session = makeSession(sessionName, device); - session.refFrameState = 'active'; + activateCompleteRefFrame(session); sessionStore.set(sessionName, session); const revisionBeforeClose = readSessionRuntimeRevision(session); @@ -101,7 +105,7 @@ test('an app-only close without a target fails before admission or ref-frame exp expect(mockBindDeviceRuntime).not.toHaveBeenCalled(); expect(mockDispatch).not.toHaveBeenCalled(); expect(readSessionRuntimeRevision(session)).toBe(revisionBeforeClose); - expect(session.refFrameState).toBe('active'); + expect(refFrameState(session)).toBe('active'); expect(sessionStore.get(sessionName)).toBe(session); }); @@ -329,7 +333,7 @@ test('close expires the ref frame immediately before its admitted platform mutat session.appBundleId = 'com.example.app'; sessionStore.set(sessionName, session); mockDispatch.mockImplementationOnce(async () => { - expect(session.refFrameState).toBe('expired'); + expect(refFrameState(session)).toBe('expired'); }); const response = await close({ diff --git a/src/daemon/types.ts b/src/daemon/types.ts index 7e78f05e69..af9967266f 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -32,7 +32,7 @@ import type { ReplayTargetGuardDenotation, TargetAnnotationV1, } from '@agent-device/contracts/replay'; -import type { RefFrameScope, RefFrameState } from './ref-frame.ts'; +import type { RefFrame } from './ref-frame.ts'; export type DaemonInstallSource = PublicDaemonInstallSource; export type SessionRuntimeHints = PublicSessionRuntimeHints; export type DaemonArtifact = PublicDaemonArtifact; @@ -302,43 +302,16 @@ export type SessionState = { /** Source snapshot used to resolve repeated `snapshot -s @ref` after scoped output replaces refs. */ snapshotScopeSource?: SnapshotState; /** - * ADR 0014 ref-frame lifecycle state. Undefined is treated as `active`. A - * device side effect transitions the frame to `expired` (wired at the - * side-effect seam in a later migration step); an expired frame admits no ref - * mutation. Managed only through `src/daemon/ref-frame.ts`. + * ADR 0014 ref frame: the namespace whose refs a caller may currently use to + * target a mutation, as ONE opaque value carrying lifecycle state, issuance + * scope, the tree that minted the refs, and the epoch the frame was issued + * at. `RefFrame` cannot be constructed or edited outside + * `src/daemon/ref-frame.ts`, so that module owns the frame by construction + * rather than by convention; read it through the accessors it exports. + * Undefined is the pristine frame — complete authority over a namespace + * nothing has issued yet. */ - refFrameState?: RefFrameState; - /** - * ADR 0014 issuance scope of the current ref frame. Undefined is treated as - * `all` (a complete namespace). A bounded set names the ref bodies a partial - * publication (`find`, settled diff, replay divergence) actually issued, which - * are the only bodies a pinned mutation may target. Managed only through - * `src/daemon/ref-frame.ts`. - */ - refFrameScope?: RefFrameScope; - /** - * ADR 0014 immutable source tree of the current ref frame: the tree that - * minted the frame's refs, retained so a ref resolves to the node the caller - * was authorized against — never to a different element by positional - * coincidence in a newer operational observation (`snapshot`). Shares the - * capture object with `snapshot` at activation (no deep copy); an Android - * freshness or other read-only capture advances `snapshot` WITHOUT touching - * this, so the two intentionally diverge. Managed only through - * `src/daemon/ref-frame.ts` and the partial-issuance writer. Undefined falls - * back to `snapshot` (pre-frame sessions). - */ - refFrameTree?: SnapshotState; - /** - * ADR 0014 ref-frame epoch, frozen at the generation the frame was issued at - * (the `refsGeneration` the client received). A later read-only capture - * advances `snapshotGeneration` (the observation counter) WITHOUT reissuing - * refs, so admission and pin comparisons use this frame-pinned epoch — a - * correct pin from the issuing frame is not falsely rejected because an - * intervening read bumped the observation counter. Undefined falls back to - * `snapshotGeneration`. Managed only through `src/daemon/ref-frame.ts` and the - * partial-issuance writer. - */ - refFrameGeneration?: number; + refFrame?: RefFrame; /** Last broad snapshot safe for Android route-freshness comparisons after interactive snapshots. */ lastComparisonSafeSnapshot?: SnapshotState; androidSnapshotFreshness?: SnapshotFreshnessWindow; From 88b085d14cc4b23adfd95ab1898a3e5889a2a9a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 5 Sep 2026 08:42:25 +0200 Subject: [PATCH 2/7] docs(depgraph): note the ref frame outgrew its R7 row --- docs/dependency-graph-findings.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/dependency-graph-findings.md b/docs/dependency-graph-findings.md index 0e88848902..5890e98888 100644 --- a/docs/dependency-graph-findings.md +++ b/docs/dependency-graph-findings.md @@ -338,7 +338,9 @@ of 27 fields already have exactly one writer**. The sharp case was ADR 0014's re `refFrameState`, `refFrameScope`, `refFrameTree`, `refFrameGeneration` must move together or the frame is incoherent, yet complete issuance wrote them in `ref-frame.ts` and partial issuance wrote the same four in `session-snapshot.ts`, even though `ref-frame.ts` claims in its header to -be "the single owner of the frame's transitions". Both forms now go through `activateRefFrame`. +be "the single owner of the frame's transitions". Both forms now go through `activateRefFrame`, +and the four fields have since been replaced by one `refFrame` value whose type only +`ref-frame.ts` can construct — so that ownership no longer rests on the R7 table alone. `recordSession` deliberately moves alone in two paths (recording without arming a publication), so the save-script cluster got no invented abstraction. It got ownership: **R7** records every From 0545e9dd0a8ec1883ea2d71451a27966861f727d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 5 Sep 2026 09:17:52 +0200 Subject: [PATCH 3/7] refactor(daemon): make the ref frame nominal, not symbol-branded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A symbol brand on a plain object type stops construction from nothing, but not `{ ...refFrame(session), state: 'active' }`: object spread copies the symbol key, so any daemon module could mint an incoherent frame (active state, stale tree) out of a coherent one and it type-checked. Proven before the fix with a throwaway module doing exactly that write: tsc reported nothing. The frame is now a class with `#`-private fields behind getters. That makes the type nominal, so no object literal is assignable to it — the same probe now fails with TS2739 (`missing #fields, scope, generation, expired`). Construction stays inside ref-frame.ts, and the four claim sites (ADR 0014, the SessionState field doc, and the two in the R7 owner table) now say what the type does and does not judge: it cannot see a whole frame moved unchanged, which is why the R7 row stays. Expiry is idempotent by identity again. `expired()` returns THIS frame when the frame is already expired, rather than an equal copy, which is what the lineage check in internal-observation.ts compares with `===`. Seen red: with that early return removed, the tightened ref-frame test fails with "Values have same structure but are not reference-equal"; green with it. Also: the ADR 0014 stale-ref help sample seeds its epoch through a real frame activation again, instead of leaning on the pre-frame snapshotGeneration fallback, and a find test drops a `?? []` that can no longer be reached. Behavior is unchanged: same frame contents, same transitions, same admission. --- docs/adr/0014-session-ref-frame-lifetime.md | 6 +- .../help-conformance-sample-producers.ts | 5 +- src/daemon/__tests__/ref-frame.test.ts | 4 +- .../internal/__tests__/find.test.ts | 2 +- src/daemon/ref-frame.ts | 79 ++++++++++++++----- src/daemon/types.ts | 7 +- 6 files changed, 75 insertions(+), 28 deletions(-) diff --git a/docs/adr/0014-session-ref-frame-lifetime.md b/docs/adr/0014-session-ref-frame-lifetime.md index 943de73005..6205c867c8 100644 --- a/docs/adr/0014-session-ref-frame-lifetime.md +++ b/docs/adr/0014-session-ref-frame-lifetime.md @@ -19,8 +19,10 @@ ref-frame vocabulary is promoted into `CONTEXT.md`. exposed as `refsGeneration`, immutable source tree, `active`/`expired` state, `all` or bounded issuance scope) — kept separate from the latest operational observation (`session.snapshot`). The frame is one value on `SessionState`, replaced whole by a transition and never edited in - place; its type is constructible only inside `src/daemon/ref-frame.ts`, so that module's - ownership is enforced by the compiler rather than by convention. + place; its type is nominal (`#`-private fields), so no module outside + `src/daemon/ref-frame.ts` can construct a frame, edit one, or derive one from an existing + frame. What the type cannot judge is a whole frame moved unchanged — clearing the field, or + assigning another session's frame — which the field-owner gate still covers. - A complete snapshot activates an `all` frame; `find`, settled diffs, and replay divergence screens activate a bounded partial frame that supersedes the prior one; internal read-only captures never activate, reindex, or expire a frame. diff --git a/scripts/__tests__/help-conformance-sample-producers.ts b/scripts/__tests__/help-conformance-sample-producers.ts index 27f02fa0f6..74d6496c57 100644 --- a/scripts/__tests__/help-conformance-sample-producers.ts +++ b/scripts/__tests__/help-conformance-sample-producers.ts @@ -23,7 +23,7 @@ import { buildAmbiguousMatchError } from '../../src/daemon/selector-match-errors import { refMutationAdmissionResponse } from '../../src/daemon/interaction/index.ts'; import { buildDeviceInUseBySessionError } from '../../src/daemon/session-recovery-hints.ts'; import { buildDeviceClaimConflictError } from '../../src/daemon/device-claim-conflict.ts'; -import { readRefMutationFrame } from '../../src/daemon/ref-frame.ts'; +import { activateCompleteRefFrame, readRefMutationFrame } from '../../src/daemon/ref-frame.ts'; import { resolveRefStalenessWarning } from '../../src/daemon/session-snapshot.ts'; import type { SessionState } from '../../src/daemon/types.ts'; import { buildAppNotInstalledError } from '@agent-device/platform-apple/app-resolution'; @@ -424,6 +424,9 @@ export const SAMPLE_PRODUCERS: SampleProducer[] = [ sample: STALE_REF_SAMPLE, render: () => { const session = { snapshotGeneration: 7 } as SessionState; + // Pin the epoch in the frame itself, as a real published namespace does, so the + // sample exercises the frame epoch rather than the pre-frame fallback. + activateCompleteRefFrame(session); const response = refMutationAdmissionResponse({ session, ref: '@e12', diff --git a/src/daemon/__tests__/ref-frame.test.ts b/src/daemon/__tests__/ref-frame.test.ts index 152530d8fd..2baff97ce8 100644 --- a/src/daemon/__tests__/ref-frame.test.ts +++ b/src/daemon/__tests__/ref-frame.test.ts @@ -114,7 +114,9 @@ test('expireRefFrame is idempotent and rejects all refs while expired', () => { const expired = refFrame(s); expireRefFrame(s); // idempotent assert.equal(refFrameState(s), 'expired'); - assert.deepEqual(refFrame(s), expired); + // Identity, not shape: lineage holders compare frames with `===`, so a repeat + // expiry must leave the same frame rather than an equal one. + assert.equal(refFrame(s), expired); assert.equal( reason(admitRefMutation({ session: s, refBody: 'e1', mintedGeneration: 42 })), 'ref_frame_expired', diff --git a/src/daemon/interaction/internal/__tests__/find.test.ts b/src/daemon/interaction/internal/__tests__/find.test.ts index 805670a043..8910b6a21d 100644 --- a/src/daemon/interaction/internal/__tests__/find.test.ts +++ b/src/daemon/interaction/internal/__tests__/find.test.ts @@ -620,7 +620,7 @@ test('handleFindCommands list returns every match without acting', async () => { // the MCP/CLI layers pin from `matches` + `refsGeneration`). expect(typeof response.data?.refsGeneration).toBe('number'); expect(refFrameState(session)).toBe('active'); - expect([...(refFrameScope(session) ?? [])].sort()).toEqual(['e2', 'e3', 'e4']); + expect([...refFrameScope(session)].sort()).toEqual(['e2', 'e3', 'e4']); }); test('handleFindCommands list on a unique match still lists instead of tapping', async () => { diff --git a/src/daemon/ref-frame.ts b/src/daemon/ref-frame.ts index 8112fa89ca..78bc0d1708 100644 --- a/src/daemon/ref-frame.ts +++ b/src/daemon/ref-frame.ts @@ -45,23 +45,62 @@ export type RefFrameAdmission = | { admitted: true } | { admitted: false; reason: RefFrameRejectReason }; -const REF_FRAME = Symbol('ref-frame'); +type RefFrameFields = Readonly<{ + state: RefFrameState; + scope: RefFrameScope; + tree: SnapshotState | undefined; + generation: number | undefined; +}>; /** * The frame as one value. `state`, `scope`, `tree` and `generation` move * together or the frame is incoherent, so they are not four fields a caller can * step out of agreement: `SessionState.refFrame` is REPLACED whole by this - * module's transitions and never mutated. The brand key is module-private, so - * no module outside this file can construct or edit one — the accessors below - * are the whole read surface. + * module's transitions and never mutated. + * + * The fields are `#`-private behind getters, which buys what a plain object of + * `readonly` fields does not: the type is NOMINAL, so no object literal is + * assignable to it — including `{ ...refFrame(session), state: 'expired' }`, the + * one shape that could otherwise mint an incoherent frame out of a coherent one. + * This constructor is the only source of a frame, and it is private to this file; + * the accessors below are the whole read surface. */ -export type RefFrame = { - readonly [REF_FRAME]: true; - readonly state: RefFrameState; - readonly scope: RefFrameScope; - readonly tree: SnapshotState | undefined; - readonly generation: number | undefined; -}; +class SessionRefFrame { + readonly #fields: RefFrameFields; + + constructor(fields: RefFrameFields) { + this.#fields = fields; + } + + get state(): RefFrameState { + return this.#fields.state; + } + + get scope(): RefFrameScope { + return this.#fields.scope; + } + + get tree(): SnapshotState | undefined { + return this.#fields.tree; + } + + get generation(): number | undefined { + return this.#fields.generation; + } + + /** + * The same frame with authority withdrawn, keeping the scope, tree and epoch an + * expiry does not change. An already-expired frame returns ITSELF rather than an + * equal copy, so a repeated expiry is a no-op by identity — which is the property + * lineage holders test, with `===`. + */ + expired(): SessionRefFrame { + if (this.#fields.state === 'expired') return this; + return new SessionRefFrame({ ...this.#fields, state: 'expired' }); + } +} + +export type RefFrame = SessionRefFrame; /** * The frame a session has before anything issues refs: complete authority over @@ -69,13 +108,12 @@ export type RefFrame = { * A shared constant, so lineage identity is stable for a session that never * reached a transition. */ -const PRISTINE_FRAME: RefFrame = { - [REF_FRAME]: true, +const PRISTINE_FRAME = new SessionRefFrame({ state: 'active', scope: 'all', tree: undefined, generation: undefined, -}; +}); /** * The session's current frame, as one comparable value. Identity changes on @@ -106,8 +144,10 @@ export function refFrameTree(session: SessionState): SnapshotState | undefined { } /** - * Expire the current frame at a device side-effect seam (ADR 0014). Idempotent: - * additional effects while already expired are a no-op. Call this SYNCHRONOUSLY, + * Expire the current frame at a device side-effect seam (ADR 0014). The frame + * transition is idempotent by identity: an effect crossed while already expired + * leaves the SAME frame in place (the runtime revision below still advances, one + * per effect, because that is what tracks effects). Call this SYNCHRONOUSLY, * immediately before awaiting the operation that may change device-visible * element identity, so that a post-dispatch failure (timeout, connection loss, * ambiguous error) still leaves the frame expired — there is no success-only @@ -120,7 +160,7 @@ export function refFrameTree(session: SessionState): SnapshotState | undefined { */ export function expireRefFrame(session: SessionState): void { advanceSessionRuntimeRevision(session); - session.refFrame = { ...refFrame(session), state: 'expired' }; + session.refFrame = refFrame(session).expired(); session.snapshotScopeSource = undefined; } @@ -179,13 +219,12 @@ export function activatePartialRefFrame(session: SessionState, scope: ReadonlySe * namespace that authorized it. */ function activateRefFrame(session: SessionState, scope: RefFrameScope): void { - session.refFrame = { - [REF_FRAME]: true, + session.refFrame = new SessionRefFrame({ state: 'active', scope, tree: session.snapshot, generation: session.snapshotGeneration, - }; + }); } export function refFrameState(session: SessionState): RefFrameState { diff --git a/src/daemon/types.ts b/src/daemon/types.ts index af9967266f..4d4dad5cc8 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -305,9 +305,10 @@ export type SessionState = { * ADR 0014 ref frame: the namespace whose refs a caller may currently use to * target a mutation, as ONE opaque value carrying lifecycle state, issuance * scope, the tree that minted the refs, and the epoch the frame was issued - * at. `RefFrame` cannot be constructed or edited outside - * `src/daemon/ref-frame.ts`, so that module owns the frame by construction - * rather than by convention; read it through the accessors it exports. + * at. `RefFrame` is nominal, so it cannot be constructed, edited, or derived + * from an existing frame outside `src/daemon/ref-frame.ts`; that module owns + * the frame by construction rather than by convention. Read it through the + * accessors it exports — never by reaching into the value. * Undefined is the pristine frame — complete authority over a namespace * nothing has issued yet. */ From 4aefc914e93f17fd3bcaf40dd8a35207f7383991 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 5 Sep 2026 09:18:02 +0200 Subject: [PATCH 4/7] chore(gates): collapse the four ADR 0014 R7 rows into the owned refFrame value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R7's owner table listed `refFrameState`, `refFrameScope`, `refFrameTree` and `refFrameGeneration` as four fields that had to be written together by one module; the code now carries them as one nominal value, so the table carries one row. R10 follows: 19 writer-owned fields to 16, 22 owner claims to 19. The row itself stays. The type stops construction, editing and spread-derivation of a frame outside ref-frame.ts, but it cannot judge a whole frame moved unchanged — clearing the field, or assigning another session's frame — and the table can. The comments say that rather than claiming full enforcement. Seen red: a planted `session.refFrame = undefined` in snapshot-session.ts fails R7 with "owned by src/daemon/ref-frame.ts"; green once reverted. --- scripts/layering/model.test.ts | 54 +++++++++++++++---------------- scripts/layering/session-state.ts | 19 ++++++----- 2 files changed, 38 insertions(+), 35 deletions(-) diff --git a/scripts/layering/model.test.ts b/scripts/layering/model.test.ts index 888436e0e6..be46f7d21c 100644 --- a/scripts/layering/model.test.ts +++ b/scripts/layering/model.test.ts @@ -293,46 +293,46 @@ test('SessionState field names come from the declaration, not a hand-kept list', " kind: 'cwd';", ' id: string;', ' };', - ' refFrameState?: RefFrameState;', + ' refFrame?: RefFrame;', '};', '', 'export type Other = { notAField: string };', ].join('\n'), ); // Nested object members are not session fields, and neighbouring types are not scanned. - assert.deepEqual(fields, ['name', 'sessionScope', 'refFrameState']); + assert.deepEqual(fields, ['name', 'sessionScope', 'refFrame']); }); test('session-state writes are found by field, and non-daemon or undeclared names are not', () => { const writes = findSessionStateWrites( new Map([ - ['src/daemon/ref-frame.ts', "session.refFrameState = 'active';"], + ['src/daemon/ref-frame.ts', "session.refFrame = 'active';"], ['src/daemon/session-snapshot.ts', 'session.snapshotGeneration += 1;'], // the store owns the record and may write anything on it - ['src/daemon/session-store.ts', "session.refFrameState = 'expired';"], + ['src/daemon/session-store.ts', "session.refFrame = 'expired';"], // a runner session outside the daemon is a different type that happens to share a name - ['src/platforms/apple/runner-session.ts', 'session.refFrameState = 1;'], + ['src/platforms/apple/runner-session.ts', 'session.refFrame = 1;'], // a local that is not a declared SessionState field ['src/daemon/session-observability/internal/session-audio.ts', 'session.somethingElse = 1;'], // reads and comparisons are not writes [ 'src/daemon/interaction/internal/find.ts', - "if (session.refFrameState === 'active') return;", + "if (session.refFrame === 'active') return;", ], // a write into a sub-object is not a write to the field itself - ['src/daemon/handlers/session-probe.ts', 'session.refFrameState.inner = 1;'], + ['src/daemon/handlers/session-probe.ts', 'session.refFrame.inner = 1;'], // a different binding that happens to have a matching property [ 'src/daemon/session-lifecycle/internal/session-close.ts', - "other.refFrameState = 'expired';", + "other.refFrame = 'expired';", ], ]), - ['refFrameState', 'snapshotGeneration'], + ['refFrame', 'snapshotGeneration'], ); assert.deepEqual( writes.map(({ file, field }) => `${file}:${field}`), - ['src/daemon/ref-frame.ts:refFrameState', 'src/daemon/session-snapshot.ts:snapshotGeneration'], + ['src/daemon/ref-frame.ts:refFrame', 'src/daemon/session-snapshot.ts:snapshotGeneration'], ); }); @@ -340,23 +340,23 @@ test('every assignment form is a write, including the ones a regex forgets', () // A line-based matcher has to enumerate operators, and the ones it misses are the natural // ways to write these: `??=` for a default on an optional field, `||=`/`&&=` for a flag. const forms = [ - 'session.refFrameState = 1;', - 'session.refFrameState ??= 1;', - 'session.refFrameState ||= 1;', - 'session.refFrameState &&= 1;', - 'session.refFrameState += 1;', - 'session.refFrameState -= 1;', - 'session.refFrameState++;', - '--session.refFrameState;', - 'session\n .refFrameState = 1;', + 'session.refFrame = 1;', + 'session.refFrame ??= 1;', + 'session.refFrame ||= 1;', + 'session.refFrame &&= 1;', + 'session.refFrame += 1;', + 'session.refFrame -= 1;', + 'session.refFrame++;', + '--session.refFrame;', + 'session\n .refFrame = 1;', ]; for (const form of forms) { const writes = findSessionStateWrites(new Map([['src/daemon/probe.ts', form]]), [ - 'refFrameState', + 'refFrame', ]); assert.deepEqual( writes.map(({ field }) => field), - ['refFrameState'], + ['refFrame'], `expected ${JSON.stringify(form)} to count as a write`, ); } @@ -364,8 +364,8 @@ test('every assignment form is a write, including the ones a regex forgets', () test('a computed session write is reported rather than silently unattributed', () => { const writes = findSessionStateWrites( - new Map([['src/daemon/probe.ts', 'session[key] = 1;\nsession[`refFrameState`] = 2;']]), - ['refFrameState'], + new Map([['src/daemon/probe.ts', 'session[key] = 1;\nsession[`refFrame`] = 2;']]), + ['refFrame'], ); // `[computed]` has no entry in SESSION_STATE_FIELD_OWNERS, so R7 fails on it by // construction — a computed write can never pass as an owned one. @@ -401,19 +401,19 @@ test('a session write counts through an aliased binding, not only one named `ses 'src/daemon/probe.ts', [ 'nextSession.snapshotGeneration = 3;', - 'preEntrySession.refFrameState = "active";', + 'preEntrySession.refFrame = "active";', 'completedSession.saveScriptComplete = true;', // Not a session binding, and not a session write. 'result.snapshotGeneration = 9;', - 'flags.refFrameState = "x";', + 'flags.refFrame = "x";', ].join('\n'), ], ]), - ['snapshotGeneration', 'refFrameState', 'saveScriptComplete'], + ['snapshotGeneration', 'refFrame', 'saveScriptComplete'], ); assert.deepEqual( writes.map(({ field, line }) => `${line}:${field}`), - ['1:snapshotGeneration', '2:refFrameState', '3:saveScriptComplete'], + ['1:snapshotGeneration', '2:refFrame', '3:saveScriptComplete'], ); }); diff --git a/scripts/layering/session-state.ts b/scripts/layering/session-state.ts index a51199bbc5..1c1f14528f 100644 --- a/scripts/layering/session-state.ts +++ b/scripts/layering/session-state.ts @@ -25,8 +25,10 @@ // the set of writers that exist, so the gate's job is to stop the set from growing quietly. // Adding a field to `SessionState` forces a deliberate owner; writing an existing field from // a new module fails until that module is either declared an owner or, better, calls the -// owner instead. ADR 0014's ref frame is the worked example — its four fields moved together -// across two modules until `activateRefFrame` took the transition. +// owner instead. ADR 0014's ref frame is the worked example, and the one that has since been +// taken further than this table can go: its four fields moved together across two modules +// until `activateRefFrame` took the transition, and they are now a single value whose nominal +// type no other module can construct, edit, or derive from an existing frame. // // Detection is AST-based (`oxc-parser`, already a devDependency) rather than a line regex. A // regex has to enumerate assignment operators, and the ones it forgets are exactly the ones @@ -49,12 +51,13 @@ export type SessionStateWrite = { * owner list has one entry is a field only that module can get wrong. */ export const SESSION_STATE_FIELD_OWNERS: Readonly> = { - // ADR 0014 ref frame: the four frame fields move together or the frame is incoherent, so - // both issuance forms go through ref-frame.ts. - refFrameState: ['src/daemon/ref-frame.ts'], - refFrameScope: ['src/daemon/ref-frame.ts'], - refFrameTree: ['src/daemon/ref-frame.ts'], - refFrameGeneration: ['src/daemon/ref-frame.ts'], + // ADR 0014 ref frame. The four frame fields this row replaced moved together or the frame was + // incoherent, and only this table said so; `RefFrame` is now a nominal type (`#`-private + // fields) that no other module can construct, edit, or spread into a new frame, and the + // transitions replace it whole. The row stays because the type cannot judge a whole frame + // moved unchanged: assigning `undefined` (a reset to the pristine frame) and assigning a + // frame read off another session. + refFrame: ['src/daemon/ref-frame.ts'], // Scoped-snapshot lineage is cleared at two distinct events: crossing a device side-effect // seam (ref-frame.ts) and replacing the stored observation (session-snapshot.ts). snapshotScopeSource: ['src/daemon/ref-frame.ts', 'src/daemon/session-snapshot.ts'], From 0033ab041e55580364632edb23fff7fd6e03b9d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 5 Sep 2026 18:32:31 +0200 Subject: [PATCH 5/7] style: apply oxfmt --- scripts/layering/model.test.ts | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/scripts/layering/model.test.ts b/scripts/layering/model.test.ts index be46f7d21c..c32594644a 100644 --- a/scripts/layering/model.test.ts +++ b/scripts/layering/model.test.ts @@ -315,17 +315,11 @@ test('session-state writes are found by field, and non-daemon or undeclared name // a local that is not a declared SessionState field ['src/daemon/session-observability/internal/session-audio.ts', 'session.somethingElse = 1;'], // reads and comparisons are not writes - [ - 'src/daemon/interaction/internal/find.ts', - "if (session.refFrame === 'active') return;", - ], + ['src/daemon/interaction/internal/find.ts', "if (session.refFrame === 'active') return;"], // a write into a sub-object is not a write to the field itself ['src/daemon/handlers/session-probe.ts', 'session.refFrame.inner = 1;'], // a different binding that happens to have a matching property - [ - 'src/daemon/session-lifecycle/internal/session-close.ts', - "other.refFrame = 'expired';", - ], + ['src/daemon/session-lifecycle/internal/session-close.ts', "other.refFrame = 'expired';"], ]), ['refFrame', 'snapshotGeneration'], ); @@ -351,9 +345,7 @@ test('every assignment form is a write, including the ones a regex forgets', () 'session\n .refFrame = 1;', ]; for (const form of forms) { - const writes = findSessionStateWrites(new Map([['src/daemon/probe.ts', form]]), [ - 'refFrame', - ]); + const writes = findSessionStateWrites(new Map([['src/daemon/probe.ts', form]]), ['refFrame']); assert.deepEqual( writes.map(({ field }) => field), ['refFrame'], From 642ec60b940e36c33465b076cd3f37d0d85adbac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 5 Sep 2026 20:38:14 +0200 Subject: [PATCH 6/7] refactor(daemon): keep ref-frame expiry module-private `RefFrame` exposed a public `expired()` method, so any module holding a frame could derive a new valid one and install it through a reconstructed session record, past the R7 field scan. Expiry is now a static on the unexported class, reachable only inside ref-frame.ts; the frame's surface is four getters. A type-level regression pins that no outside module can construct, spread, edit, or derive a frame (tsc covers src tests, so a directive that stops erroring fails typecheck). --- src/daemon/__tests__/ref-frame.test.ts | 16 +++++++++++++++ src/daemon/ref-frame.ts | 28 +++++++------------------- src/daemon/types.ts | 11 ++-------- 3 files changed, 25 insertions(+), 30 deletions(-) diff --git a/src/daemon/__tests__/ref-frame.test.ts b/src/daemon/__tests__/ref-frame.test.ts index 2baff97ce8..0fe1549042 100644 --- a/src/daemon/__tests__/ref-frame.test.ts +++ b/src/daemon/__tests__/ref-frame.test.ts @@ -9,6 +9,7 @@ import { refFrameEpoch, refFrameScope, refFrameState, + type RefFrame, type RefFrameAdmission, } from '../ref-frame.ts'; import type { SessionState } from '../types.ts'; @@ -146,3 +147,18 @@ test('expireRefFrame clears scoped-snapshot lineage at the seam (ADR 0014)', () // repeated scoped snapshot cannot borrow stale lineage across the side effect. assert.equal(s.snapshotScopeSource, undefined); }); + +test('a frame cannot be constructed, edited, or derived outside ref-frame.ts', () => { + const frame = refFrame(session()); + const rejectedByTsc = [ + // @ts-expect-error the class value is not exported, so no literal is a frame + (): RefFrame => ({ state: 'expired', scope: 'all', tree: undefined, generation: 1 }), + // @ts-expect-error a spread drops the #private field + (): RefFrame => ({ ...frame, state: 'expired' }), + // @ts-expect-error every field is a getter + () => void (frame.state = 'expired'), + // @ts-expect-error expiry is not on the frame's surface + () => frame.expired(), + ]; + assert.equal(rejectedByTsc.length, 4); +}); diff --git a/src/daemon/ref-frame.ts b/src/daemon/ref-frame.ts index 78bc0d1708..ddb018e457 100644 --- a/src/daemon/ref-frame.ts +++ b/src/daemon/ref-frame.ts @@ -53,17 +53,8 @@ type RefFrameFields = Readonly<{ }>; /** - * The frame as one value. `state`, `scope`, `tree` and `generation` move - * together or the frame is incoherent, so they are not four fields a caller can - * step out of agreement: `SessionState.refFrame` is REPLACED whole by this - * module's transitions and never mutated. - * - * The fields are `#`-private behind getters, which buys what a plain object of - * `readonly` fields does not: the type is NOMINAL, so no object literal is - * assignable to it — including `{ ...refFrame(session), state: 'expired' }`, the - * one shape that could otherwise mint an incoherent frame out of a coherent one. - * This constructor is the only source of a frame, and it is private to this file; - * the accessors below are the whole read surface. + * One value, replaced whole by this module's transitions. The class is not exported, so nothing + * outside this file can construct, edit, or derive a frame. */ class SessionRefFrame { readonly #fields: RefFrameFields; @@ -88,15 +79,10 @@ class SessionRefFrame { return this.#fields.generation; } - /** - * The same frame with authority withdrawn, keeping the scope, tree and epoch an - * expiry does not change. An already-expired frame returns ITSELF rather than an - * equal copy, so a repeated expiry is a no-op by identity — which is the property - * lineage holders test, with `===`. - */ - expired(): SessionRefFrame { - if (this.#fields.state === 'expired') return this; - return new SessionRefFrame({ ...this.#fields, state: 'expired' }); + /** Idempotent by identity: an expired frame is returned as is, so repeated expiry compares `===`. */ + static expire(frame: SessionRefFrame): SessionRefFrame { + if (frame.#fields.state === 'expired') return frame; + return new SessionRefFrame({ ...frame.#fields, state: 'expired' }); } } @@ -160,7 +146,7 @@ export function refFrameTree(session: SessionState): SnapshotState | undefined { */ export function expireRefFrame(session: SessionState): void { advanceSessionRuntimeRevision(session); - session.refFrame = refFrame(session).expired(); + session.refFrame = SessionRefFrame.expire(refFrame(session)); session.snapshotScopeSource = undefined; } diff --git a/src/daemon/types.ts b/src/daemon/types.ts index 4d4dad5cc8..3a5cf134d0 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -302,15 +302,8 @@ export type SessionState = { /** Source snapshot used to resolve repeated `snapshot -s @ref` after scoped output replaces refs. */ snapshotScopeSource?: SnapshotState; /** - * ADR 0014 ref frame: the namespace whose refs a caller may currently use to - * target a mutation, as ONE opaque value carrying lifecycle state, issuance - * scope, the tree that minted the refs, and the epoch the frame was issued - * at. `RefFrame` is nominal, so it cannot be constructed, edited, or derived - * from an existing frame outside `src/daemon/ref-frame.ts`; that module owns - * the frame by construction rather than by convention. Read it through the - * accessors it exports — never by reaching into the value. - * Undefined is the pristine frame — complete authority over a namespace - * nothing has issued yet. + * ADR 0014 ref frame, owned by `ref-frame.ts` by construction: read it through that module's + * accessors. Undefined is the pristine frame. */ refFrame?: RefFrame; /** Last broad snapshot safe for Android route-freshness comparisons after interactive snapshots. */ From 838af72bd9f8fc12d203f5d383d5e50f039150c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 5 Sep 2026 20:38:14 +0200 Subject: [PATCH 7/7] test(daemon): hold the three accessor migrations within the size ratchet Each file grew by exactly its new ref-frame import; one blank line between mock blocks goes so the files stay at their merge-base length. --- src/daemon/handlers/__tests__/snapshot-handler.test.ts | 1 - src/daemon/interaction/internal/__tests__/find.test.ts | 1 - .../replay/internal/__tests__/session-replay-divergence.test.ts | 1 - 3 files changed, 3 deletions(-) diff --git a/src/daemon/handlers/__tests__/snapshot-handler.test.ts b/src/daemon/handlers/__tests__/snapshot-handler.test.ts index c1ab318e41..522da36e59 100644 --- a/src/daemon/handlers/__tests__/snapshot-handler.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-handler.test.ts @@ -47,7 +47,6 @@ vi.mock('../../snapshot-interactor-capture.ts', async () => { const fixture = await import('../../__tests__/legacy-snapshot-capture-fixture.ts'); return { captureSnapshotWithInteractor: fixture.captureSnapshotThroughLegacyDispatchFixture }; }); - vi.mock('@agent-device/platform-apple/runner/operations', async (importOriginal) => { const actual = await importOriginal(); diff --git a/src/daemon/interaction/internal/__tests__/find.test.ts b/src/daemon/interaction/internal/__tests__/find.test.ts index 8910b6a21d..5dc2419455 100644 --- a/src/daemon/interaction/internal/__tests__/find.test.ts +++ b/src/daemon/interaction/internal/__tests__/find.test.ts @@ -17,7 +17,6 @@ vi.mock('../../../../core/dispatch-resolve.ts', async (importOriginal) => { resolveTargetDevice: actual.resolveTargetDevice, }; }); - vi.mock('../../../snapshot-interactor-capture.ts', async () => { const fixture = await import('../../../__tests__/legacy-snapshot-capture-fixture.ts'); return { captureSnapshotWithInteractor: fixture.captureSnapshotThroughLegacyDispatchFixture }; diff --git a/src/daemon/replay/internal/__tests__/session-replay-divergence.test.ts b/src/daemon/replay/internal/__tests__/session-replay-divergence.test.ts index 38dafd1b93..1626d11181 100644 --- a/src/daemon/replay/internal/__tests__/session-replay-divergence.test.ts +++ b/src/daemon/replay/internal/__tests__/session-replay-divergence.test.ts @@ -1,7 +1,6 @@ import path from 'node:path'; import { beforeEach, expect, test, vi } from 'vitest'; import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; - vi.mock('../../../../core/dispatch-resolve.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, resolveTargetDevice: vi.fn() };