Skip to content

Commit ba6c818

Browse files
authored
spike(daemon): give the ADR-0014 ref frame private ownership (#2296)
* 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(depgraph): note the ref frame outgrew its R7 row * refactor(daemon): make the ref frame nominal, not symbol-branded 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. * chore(gates): collapse the four ADR 0014 R7 rows into the owned refFrame value 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. * style: apply oxfmt * 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). * 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.
1 parent 96727a0 commit ba6c818

37 files changed

Lines changed: 361 additions & 270 deletions

docs/adr/0014-session-ref-frame-lifetime.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,12 @@ ref-frame vocabulary is promoted into `CONTEXT.md`.
1717

1818
- A session owns at most one **ref frame** — the authorization namespace for mutation refs (epoch
1919
exposed as `refsGeneration`, immutable source tree, `active`/`expired` state, `all` or bounded
20-
issuance scope) — owned by `src/daemon/ref-frame.ts` and kept separate from the latest
21-
operational observation (`session.snapshot`).
20+
issuance scope) — kept separate from the latest operational observation (`session.snapshot`).
21+
The frame is one value on `SessionState`, replaced whole by a transition and never edited in
22+
place; its type is nominal (`#`-private fields), so no module outside
23+
`src/daemon/ref-frame.ts` can construct a frame, edit one, or derive one from an existing
24+
frame. What the type cannot judge is a whole frame moved unchanged — clearing the field, or
25+
assigning another session's frame — which the field-owner gate still covers.
2226
- A complete snapshot activates an `all` frame; `find`, settled diffs, and replay divergence
2327
screens activate a bounded partial frame that supersedes the prior one; internal read-only
2428
captures never activate, reindex, or expire a frame.

docs/dependency-graph-findings.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,9 @@ of 27 fields already have exactly one writer**. The sharp case was ADR 0014's re
338338
`refFrameState`, `refFrameScope`, `refFrameTree`, `refFrameGeneration` must move together or the
339339
frame is incoherent, yet complete issuance wrote them in `ref-frame.ts` and partial issuance
340340
wrote the same four in `session-snapshot.ts`, even though `ref-frame.ts` claims in its header to
341-
be "the single owner of the frame's transitions". Both forms now go through `activateRefFrame`.
341+
be "the single owner of the frame's transitions". Both forms now go through `activateRefFrame`,
342+
and the four fields have since been replaced by one `refFrame` value whose type only
343+
`ref-frame.ts` can construct — so that ownership no longer rests on the R7 table alone.
342344

343345
`recordSession` deliberately moves alone in two paths (recording without arming a publication),
344346
so the save-script cluster got no invented abstraction. It got ownership: **R7** records every

scripts/__tests__/help-conformance-sample-producers.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { buildAmbiguousMatchError } from '../../src/daemon/selector-match-errors
2323
import { refMutationAdmissionResponse } from '../../src/daemon/interaction/index.ts';
2424
import { buildDeviceInUseBySessionError } from '../../src/daemon/session-recovery-hints.ts';
2525
import { buildDeviceClaimConflictError } from '../../src/daemon/device-claim-conflict.ts';
26-
import { readRefMutationFrame } from '../../src/daemon/ref-frame.ts';
26+
import { activateCompleteRefFrame, readRefMutationFrame } from '../../src/daemon/ref-frame.ts';
2727
import { resolveRefStalenessWarning } from '../../src/daemon/session-snapshot.ts';
2828
import type { SessionState } from '../../src/daemon/types.ts';
2929
import { buildAppNotInstalledError } from '@agent-device/platform-apple/app-resolution';
@@ -423,7 +423,10 @@ export const SAMPLE_PRODUCERS: SampleProducer[] = [
423423
producer: 'the real ADR 0014 admission rejection and staleness hint',
424424
sample: STALE_REF_SAMPLE,
425425
render: () => {
426-
const session = { refFrameGeneration: 7 } as SessionState;
426+
const session = { snapshotGeneration: 7 } as SessionState;
427+
// Pin the epoch in the frame itself, as a real published namespace does, so the
428+
// sample exercises the frame epoch rather than the pre-frame fallback.
429+
activateCompleteRefFrame(session);
427430
const response = refMutationAdmissionResponse({
428431
session,
429432
ref: '@e12',

scripts/layering/model.test.ts

Lines changed: 27 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -293,79 +293,71 @@ test('SessionState field names come from the declaration, not a hand-kept list',
293293
" kind: 'cwd';",
294294
' id: string;',
295295
' };',
296-
' refFrameState?: RefFrameState;',
296+
' refFrame?: RefFrame;',
297297
'};',
298298
'',
299299
'export type Other = { notAField: string };',
300300
].join('\n'),
301301
);
302302
// Nested object members are not session fields, and neighbouring types are not scanned.
303-
assert.deepEqual(fields, ['name', 'sessionScope', 'refFrameState']);
303+
assert.deepEqual(fields, ['name', 'sessionScope', 'refFrame']);
304304
});
305305

306306
test('session-state writes are found by field, and non-daemon or undeclared names are not', () => {
307307
const writes = findSessionStateWrites(
308308
new Map([
309-
['src/daemon/ref-frame.ts', "session.refFrameState = 'active';"],
309+
['src/daemon/ref-frame.ts', "session.refFrame = 'active';"],
310310
['src/daemon/session-snapshot.ts', 'session.snapshotGeneration += 1;'],
311311
// the store owns the record and may write anything on it
312-
['src/daemon/session-store.ts', "session.refFrameState = 'expired';"],
312+
['src/daemon/session-store.ts', "session.refFrame = 'expired';"],
313313
// a runner session outside the daemon is a different type that happens to share a name
314-
['src/platforms/apple/runner-session.ts', 'session.refFrameState = 1;'],
314+
['src/platforms/apple/runner-session.ts', 'session.refFrame = 1;'],
315315
// a local that is not a declared SessionState field
316316
['src/daemon/session-observability/internal/session-audio.ts', 'session.somethingElse = 1;'],
317317
// reads and comparisons are not writes
318-
[
319-
'src/daemon/interaction/internal/find.ts',
320-
"if (session.refFrameState === 'active') return;",
321-
],
318+
['src/daemon/interaction/internal/find.ts', "if (session.refFrame === 'active') return;"],
322319
// a write into a sub-object is not a write to the field itself
323-
['src/daemon/handlers/session-probe.ts', 'session.refFrameState.inner = 1;'],
320+
['src/daemon/handlers/session-probe.ts', 'session.refFrame.inner = 1;'],
324321
// a different binding that happens to have a matching property
325-
[
326-
'src/daemon/session-lifecycle/internal/session-close.ts',
327-
"other.refFrameState = 'expired';",
328-
],
322+
['src/daemon/session-lifecycle/internal/session-close.ts', "other.refFrame = 'expired';"],
329323
]),
330-
['refFrameState', 'snapshotGeneration'],
324+
['refFrame', 'snapshotGeneration'],
331325
);
332326

333327
assert.deepEqual(
334328
writes.map(({ file, field }) => `${file}:${field}`),
335-
['src/daemon/ref-frame.ts:refFrameState', 'src/daemon/session-snapshot.ts:snapshotGeneration'],
329+
['src/daemon/ref-frame.ts:refFrame', 'src/daemon/session-snapshot.ts:snapshotGeneration'],
336330
);
337331
});
338332

339333
test('every assignment form is a write, including the ones a regex forgets', () => {
340334
// A line-based matcher has to enumerate operators, and the ones it misses are the natural
341335
// ways to write these: `??=` for a default on an optional field, `||=`/`&&=` for a flag.
342336
const forms = [
343-
'session.refFrameState = 1;',
344-
'session.refFrameState ??= 1;',
345-
'session.refFrameState ||= 1;',
346-
'session.refFrameState &&= 1;',
347-
'session.refFrameState += 1;',
348-
'session.refFrameState -= 1;',
349-
'session.refFrameState++;',
350-
'--session.refFrameState;',
351-
'session\n .refFrameState = 1;',
337+
'session.refFrame = 1;',
338+
'session.refFrame ??= 1;',
339+
'session.refFrame ||= 1;',
340+
'session.refFrame &&= 1;',
341+
'session.refFrame += 1;',
342+
'session.refFrame -= 1;',
343+
'session.refFrame++;',
344+
'--session.refFrame;',
345+
'session\n .refFrame = 1;',
352346
];
353347
for (const form of forms) {
354-
const writes = findSessionStateWrites(new Map([['src/daemon/probe.ts', form]]), [
355-
'refFrameState',
356-
]);
348+
const writes = findSessionStateWrites(new Map([['src/daemon/probe.ts', form]]), ['refFrame']);
357349
assert.deepEqual(
358350
writes.map(({ field }) => field),
359-
['refFrameState'],
351+
['refFrame'],
360352
`expected ${JSON.stringify(form)} to count as a write`,
361353
);
362354
}
363355
});
364356

365357
test('a computed session write is reported rather than silently unattributed', () => {
366358
const writes = findSessionStateWrites(
367-
new Map([['src/daemon/probe.ts', 'session[key] = 1;\nsession[`refFrameState`] = 2;']]),
368-
['refFrameState'],
359+
new Map([['src/daemon/probe.ts', 'session[key] = 1;\nsession[`refFrame`] = 2;']]),
360+
['refFrame'],
369361
);
370362
// `[computed]` has no entry in SESSION_STATE_FIELD_OWNERS, so R7 fails on it by
371363
// construction — a computed write can never pass as an owned one.
@@ -401,19 +393,19 @@ test('a session write counts through an aliased binding, not only one named `ses
401393
'src/daemon/probe.ts',
402394
[
403395
'nextSession.snapshotGeneration = 3;',
404-
'preEntrySession.refFrameState = "active";',
396+
'preEntrySession.refFrame = "active";',
405397
'completedSession.saveScriptComplete = true;',
406398
// Not a session binding, and not a session write.
407399
'result.snapshotGeneration = 9;',
408-
'flags.refFrameState = "x";',
400+
'flags.refFrame = "x";',
409401
].join('\n'),
410402
],
411403
]),
412-
['snapshotGeneration', 'refFrameState', 'saveScriptComplete'],
404+
['snapshotGeneration', 'refFrame', 'saveScriptComplete'],
413405
);
414406
assert.deepEqual(
415407
writes.map(({ field, line }) => `${line}:${field}`),
416-
['1:snapshotGeneration', '2:refFrameState', '3:saveScriptComplete'],
408+
['1:snapshotGeneration', '2:refFrame', '3:saveScriptComplete'],
417409
);
418410
});
419411

scripts/layering/session-state.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,10 @@
2525
// the set of writers that exist, so the gate's job is to stop the set from growing quietly.
2626
// Adding a field to `SessionState` forces a deliberate owner; writing an existing field from
2727
// a new module fails until that module is either declared an owner or, better, calls the
28-
// owner instead. ADR 0014's ref frame is the worked example — its four fields moved together
29-
// across two modules until `activateRefFrame` took the transition.
28+
// owner instead. ADR 0014's ref frame is the worked example, and the one that has since been
29+
// taken further than this table can go: its four fields moved together across two modules
30+
// until `activateRefFrame` took the transition, and they are now a single value whose nominal
31+
// type no other module can construct, edit, or derive from an existing frame.
3032
//
3133
// Detection is AST-based (`oxc-parser`, already a devDependency) rather than a line regex. A
3234
// regex has to enumerate assignment operators, and the ones it forgets are exactly the ones
@@ -49,12 +51,13 @@ export type SessionStateWrite = {
4951
* owner list has one entry is a field only that module can get wrong.
5052
*/
5153
export const SESSION_STATE_FIELD_OWNERS: Readonly<Record<string, readonly string[]>> = {
52-
// ADR 0014 ref frame: the four frame fields move together or the frame is incoherent, so
53-
// both issuance forms go through ref-frame.ts.
54-
refFrameState: ['src/daemon/ref-frame.ts'],
55-
refFrameScope: ['src/daemon/ref-frame.ts'],
56-
refFrameTree: ['src/daemon/ref-frame.ts'],
57-
refFrameGeneration: ['src/daemon/ref-frame.ts'],
54+
// ADR 0014 ref frame. The four frame fields this row replaced moved together or the frame was
55+
// incoherent, and only this table said so; `RefFrame` is now a nominal type (`#`-private
56+
// fields) that no other module can construct, edit, or spread into a new frame, and the
57+
// transitions replace it whole. The row stays because the type cannot judge a whole frame
58+
// moved unchanged: assigning `undefined` (a reset to the pristine frame) and assigning a
59+
// frame read off another session.
60+
refFrame: ['src/daemon/ref-frame.ts'],
5861
// Scoped-snapshot lineage is cleared at two distinct events: crossing a device side-effect
5962
// seam (ref-frame.ts) and replacing the stored observation (session-snapshot.ts).
6063
snapshotScopeSource: ['src/daemon/ref-frame.ts', 'src/daemon/session-snapshot.ts'],

src/daemon/__tests__/android-system-dialog-ref-frame.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { androidObservation } from '../../platform-runtime.ts';
1313
import { makeAndroidSession } from '../../__tests__/test-utils/session-factories.ts';
1414
import { makeTestScreenRecordingResource } from '../../__tests__/test-utils/screen-recording-live-handle.ts';
1515
import { makeAndroidSnapshotCapture } from '../../__tests__/test-utils/android-snapshot-capture.ts';
16+
import { refFrameState } from '../ref-frame.ts';
1617

1718
const recoverAndroidBlockingSystemDialog = (
1819
params: Omit<Parameters<typeof recoverOwnedAndroidBlockingSystemDialog>[0], 'observation'>,
@@ -45,12 +46,12 @@ test('android blocking-dialog recovery expires the ref frame before its recovery
4546
outPath: '/tmp/anr.mp4',
4647
startedAt: 0,
4748
});
48-
expect(session.refFrameState).toBeUndefined(); // active
49+
expect(refFrameState(session)).toBe('active');
4950

5051
const result = await recoverAndroidBlockingSystemDialog({ session });
5152

5253
// The recovery tap was dispatched, and the frame is expired as a result.
5354
expect(vi.mocked(runAndroidAdb)).toHaveBeenCalled();
54-
expect(session.refFrameState).toBe('expired');
55+
expect(refFrameState(session)).toBe('expired');
5556
expect(result.status).not.toBe('absent');
5657
});

src/daemon/__tests__/back-runtime.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { makeSession } from '../../__tests__/test-utils/session-factories.ts';
1717
import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts';
1818
import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts';
1919
import { LeaseRegistry } from '../lease-registry.ts';
20-
import { activateCompleteRefFrame } from '../ref-frame.ts';
20+
import { activateCompleteRefFrame, refFrameState } from '../ref-frame.ts';
2121
import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts';
2222
import type { GenericPlatformExecutionParams } from '../request-generic-dispatch.ts';
2323
import { resolveBoundBackRuntime } from '../back-runtime.ts';
@@ -162,7 +162,7 @@ test('request router joins back admission to execution, recording, and ref inval
162162
ok: true,
163163
data: { action: 'back', mode: 'in-app', message: 'Back' },
164164
});
165-
expect(session.refFrameState).toBe('expired');
165+
expect(refFrameState(session)).toBe('expired');
166166
expect(session.actions.at(-1)).toMatchObject({ command: 'back' });
167167
expect(harness.inspectFacts).toHaveBeenCalledTimes(1);
168168
expect(harness.bind).toHaveBeenCalledTimes(1);

src/daemon/__tests__/focus-runtime.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { makeSession } from '../../__tests__/test-utils/session-factories.ts';
1717
import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts';
1818
import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts';
1919
import { LeaseRegistry } from '../lease-registry.ts';
20-
import { activateCompleteRefFrame } from '../ref-frame.ts';
20+
import { activateCompleteRefFrame, refFrameState } from '../ref-frame.ts';
2121
import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts';
2222
import type { GenericPlatformExecutionParams } from '../request-generic-dispatch.ts';
2323
import { readFocusPoint, resolveBoundFocusRuntime } from '../focus-runtime.ts';
@@ -213,7 +213,7 @@ test('request router joins focus admission to execution, recording, and ref inva
213213
ok: true,
214214
data: { x: 40, y: 90, message: 'Focused (40, 90)' },
215215
});
216-
expect(session.refFrameState).toBe('expired');
216+
expect(refFrameState(session)).toBe('expired');
217217
expect(session.actions.at(-1)).toMatchObject({
218218
command: 'focus',
219219
positionals: ['40', '90'],

src/daemon/__tests__/generic-settle.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { SnapshotBackend } from '@agent-device/kernel/snapshot';
33
import type { CommandFlags } from '@agent-device/contracts/command';
44
import { makeIosSession } from '../../__tests__/test-utils/session-factories.ts';
55
import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts';
6-
import { activateCompleteRefFrame } from '../ref-frame.ts';
6+
import { activateCompleteRefFrame, refFrameState } from '../ref-frame.ts';
77
import { setSessionSnapshot } from '../session-snapshot.ts';
88
import type { SessionStore } from '../session-store.ts';
99
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
215215
// The settled tree became the stored snapshot, and its refs were published:
216216
// a partial frame is active at the generation the payload reports.
217217
const stored = sessionStore.get(sessionName) as SessionState;
218-
expect(stored.refFrameState).toBe('active');
218+
expect(refFrameState(stored)).toBe('active');
219219
expect(settle.refsGeneration).toBe(stored.snapshotGeneration);
220220
expect(stored.snapshot?.nodes.some((node) => node.label === 'Load more')).toBe(true);
221221
});
@@ -288,7 +288,7 @@ test('scroll without --settle takes no observation captures and issues no refs',
288288
expect(captureObservations).toEqual([]);
289289
// ADR 0014: the leaf side-effect seam expired the frame and nothing
290290
// re-published it.
291-
expect((sessionStore.get(sessionName) as SessionState).refFrameState).toBe('expired');
291+
expect(refFrameState(sessionStore.get(sessionName) as SessionState)).toBe('expired');
292292
});
293293

294294
test('a settle observation that cannot build a runtime degrades instead of failing the action', async () => {

src/daemon/__tests__/home-runtime.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { makeSession } from '../../__tests__/test-utils/session-factories.ts';
1717
import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts';
1818
import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts';
1919
import { LeaseRegistry } from '../lease-registry.ts';
20-
import { activateCompleteRefFrame } from '../ref-frame.ts';
20+
import { activateCompleteRefFrame, refFrameState } from '../ref-frame.ts';
2121
import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts';
2222
import type { GenericPlatformExecutionParams } from '../request-generic-dispatch.ts';
2323
import { resolveBoundHomeRuntime } from '../home-runtime.ts';
@@ -135,7 +135,7 @@ test('request router joins home admission to execution, recording, and ref inval
135135
});
136136

137137
expect(response).toMatchObject({ ok: true, data: { action: 'home', message: 'Home' } });
138-
expect(session.refFrameState).toBe('expired');
138+
expect(refFrameState(session)).toBe('expired');
139139
expect(harness.inspectFacts).toHaveBeenCalledTimes(1);
140140
expect(harness.bind).toHaveBeenCalledTimes(1);
141141
expect(harness.home).toHaveBeenCalledTimes(1);

0 commit comments

Comments
 (0)