Skip to content

Commit a2b3800

Browse files
committed
feat(daemon): wire pre-side-effect frame expiration at the seams (ADR 0014 step 3)
Route device mutations through the idempotent ref-frame transition. A leaf expires the current frame synchronously, immediately before awaiting the device operation, so success, timeout, cancellation, or connection loss all leave it expired — there is no success-only rollback. Seams wired: - interaction runtime backend closures (tap/click, fill, longPress, native web clickRef/fillRef, gesture, type) — post-resolution, pre-dispatch, so a resolution failure before the seam preserves the frame; - the generic daemon leaf (back/home/rotate/scroll/tv-remote/app-switcher/ viewport/focus, ...), gated by the daemon `refFrameEffect` classification via `resolveRefFrameEffect`, which is that resolver's first production consumer. Re-authorization: issuing a complete namespace re-activates the frame — `markSessionSnapshotRefsIssued` and the snapshot command's `buildNextSnapshotSession` — so a fresh capture between mutations restores usability. A diff or kept tree preserves the prior authorization state; internal read captures never re-authorize. Enforcement of the new expired-frame rejection is intentionally deferred to step 7, which the ADR gates on fresh live device evidence per platform. The iOS #1239 guard therefore stays armed-but-not-enforced here: it consults the admission matrix but still rejects only on the pre-existing conditions (pinned generation mismatch, coarse plain-ref stale marker), so behavior is unchanged. Tests prove the transition is wired (a press expires the frame; a re-issue re-activates it) alongside the idempotency and re-authorization unit tests. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa
1 parent fa65521 commit a2b3800

8 files changed

Lines changed: 163 additions & 27 deletions

File tree

‎src/daemon/__tests__/ref-frame.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import assert from 'node:assert/strict';
22
import { test } from 'vitest';
33
import {
4+
activateRefFrame,
45
admitRefMutation,
6+
expireRefFrame,
57
refFrameEpoch,
68
refFrameScope,
79
refFrameState,
@@ -90,3 +92,26 @@ test('generation mismatch is evaluated before issuance scope', () => {
9092
'ref_generation_mismatch',
9193
);
9294
});
95+
96+
test('expireRefFrame is idempotent and rejects all refs while expired', () => {
97+
const s = session({ snapshotGeneration: 42 });
98+
expireRefFrame(s);
99+
assert.equal(refFrameState(s), 'expired');
100+
expireRefFrame(s); // idempotent
101+
assert.equal(refFrameState(s), 'expired');
102+
assert.equal(
103+
reason(admitRefMutation({ session: s, refBody: 'e1', mintedGeneration: 42 })),
104+
'ref_frame_expired',
105+
);
106+
});
107+
108+
test('activateRefFrame re-authorizes a complete frame after expiry', () => {
109+
const s = session({ snapshotGeneration: 42, refFrameScope: new Set(['e1']) });
110+
expireRefFrame(s);
111+
activateRefFrame(s);
112+
assert.equal(refFrameState(s), 'active');
113+
assert.equal(refFrameScope(s), 'all');
114+
assert.deepEqual(admitRefMutation({ session: s, refBody: 'e9', mintedGeneration: undefined }), {
115+
admitted: true,
116+
});
117+
});

‎src/daemon/handlers/__tests__/interaction.test.ts‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ import type { CommandFlags } from '../../../core/dispatch.ts';
66
import { attachRefs, type SnapshotBackend } from '../../../kernel/snapshot.ts';
77
import { AppError } from '../../../kernel/errors.ts';
88
import { buildSnapshotState } from '../snapshot-capture.ts';
9-
import { setSessionSnapshot, STALE_SNAPSHOT_REFS_WARNING } from '../../session-snapshot.ts';
9+
import {
10+
markSessionSnapshotRefsIssued,
11+
setSessionSnapshot,
12+
STALE_SNAPSHOT_REFS_WARNING,
13+
} from '../../session-snapshot.ts';
1014
import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts';
1115
import {
1216
makeIosSession,
@@ -3293,6 +3297,29 @@ test('press selector then press @ref rejects refs that outlived the stored snaps
32933297
expect(mockDispatch).toHaveBeenCalledTimes(dispatchCallsBeforeStaleRef);
32943298
});
32953299

3300+
test('a ref press crosses the ADR 0014 side-effect seam and expires the ref frame', async () => {
3301+
const sessionStore = makeSessionStore();
3302+
const sessionName = 'seam-expiry';
3303+
const session = makeStaleRefSession(sessionName);
3304+
sessionStore.set(sessionName, session);
3305+
mockDispatch.mockImplementation(async (_device, command) =>
3306+
command === 'snapshot' ? { nodes: makeTwoButtonNodes(), backend: 'xctest' } : {},
3307+
);
3308+
3309+
// A freshly issued frame is active (undefined === active).
3310+
expect(sessionStore.get(sessionName)?.refFrameState).toBeUndefined();
3311+
3312+
const press = await runInteraction(sessionStore, sessionName, 'press', ['@e1']);
3313+
expect(press?.ok).toBe(true);
3314+
// The transition is wired at the leaf seam even though iOS enforcement of the
3315+
// expired-frame rejection is deferred to a later migration step.
3316+
expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired');
3317+
3318+
// Re-issuing a complete snapshot re-authorizes the frame.
3319+
markSessionSnapshotRefsIssued(sessionStore.get(sessionName)!);
3320+
expect(sessionStore.get(sessionName)?.refFrameState).toBe('active');
3321+
});
3322+
32963323
test('press @ref directly after refs were issued does not warn', async () => {
32973324
const sessionStore = makeSessionStore();
32983325
const sessionName = 'fresh-ref-no-warning';

‎src/daemon/handlers/interaction-ref-policy.ts‎

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,21 @@
11
import { publicPlatformString } from '../../kernel/device.ts';
2-
import { admitRefMutation } from '../ref-frame.ts';
2+
import { admitRefMutation, refFrameEpoch } from '../ref-frame.ts';
33
import { STALE_SNAPSHOT_REFS_WARNING } from '../session-snapshot.ts';
44
import type { DaemonResponse, SessionState } from '../types.ts';
55
import { errorResponse } from './response.ts';
66

77
/**
88
* Mutating through a ref from an older client-visible tree is never safe on iOS
9-
* (#1239). The decision now flows through the ADR 0014 ref-frame admission
10-
* matrix (`admitRefMutation`): a pinned ref whose generation no longer matches
11-
* the frame epoch, an expired frame, or a ref outside a partial issuance scope
12-
* is refused.
9+
* (#1239). The decision consults the ADR 0014 ref-frame admission matrix
10+
* (`admitRefMutation`).
1311
*
14-
* Transitional: until frame expiration at the device side-effect seam replaces
15-
* the coarse `snapshotRefsStale` marker (ADR 0014 step 3), a plain ref whose
16-
* operational observation was reindexed by an internal capture is also refused
17-
* on iOS. The external error contract (code, message, hint) is unchanged.
12+
* Migration status: ADR 0014 step 3 wires frame expiration at the device
13+
* side-effect seam, but ENFORCING the new expired-frame rejection is deferred to
14+
* step 7, which the ADR gates on fresh live device evidence per platform. Until
15+
* then this guard enforces exactly the pre-existing conditions — a pinned
16+
* generation mismatch and the coarse plain-ref stale marker — so a pinned
17+
* mismatch currently masked by an (unenforced) expiry is still rejected. The
18+
* external error contract (code, message, hint) is unchanged.
1819
*/
1920
export function staleIosRefGuardResponse(params: {
2021
session: SessionState;
@@ -30,9 +31,20 @@ export function staleIosRefGuardResponse(params: {
3031
refBody,
3132
mintedGeneration: params.mintedGeneration,
3233
});
34+
35+
const pinnedMismatch =
36+
params.mintedGeneration !== undefined &&
37+
params.mintedGeneration !== refFrameEpoch(params.session);
38+
// Issuance-scope rejections (partial frames) are enforced now; they are inert
39+
// until a later step publishes non-`all` scopes.
40+
const scopeRejected =
41+
!admission.admitted &&
42+
(admission.reason === 'plain_ref_requires_complete_frame' ||
43+
admission.reason === 'ref_not_issued');
3344
const coarsePlainStale =
3445
params.mintedGeneration === undefined && params.session.snapshotRefsStale === true;
35-
if (admission.admitted && !coarsePlainStale) return null;
46+
47+
if (!pinnedMismatch && !scopeRejected && !coarsePlainStale) return null;
3648

3749
return errorResponse('COMMAND_FAILED', `Ref ${params.ref} not found or has no bounds`, {
3850
hint: params.staleRefsWarning ?? STALE_SNAPSHOT_REFS_WARNING,

‎src/daemon/handlers/interaction-runtime.ts‎

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { createAgentDevice } from '../../runtime.ts';
1313
import { AppError } from '../../kernel/errors.ts';
1414
import type { SessionState } from '../types.ts';
1515
import { setSessionSnapshot } from '../session-snapshot.ts';
16+
import { expireRefFrame } from '../ref-frame.ts';
1617
import type { InteractionHandlerParams } from './interaction-common.ts';
1718
import type { CaptureSnapshotForSession } from './interaction-snapshot.ts';
1819
import { createDaemonRuntimePolicy } from '../runtime-policy.ts';
@@ -70,34 +71,42 @@ function createInteractionBackend(
7071
session.device,
7172
params.contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath),
7273
),
73-
tap: async (_context, point): Promise<BackendActionResult> =>
74-
toBackendActionResult(
74+
tap: async (_context, point): Promise<BackendActionResult> => {
75+
// ADR 0014 side-effect seam: the point is resolved; expire the ref frame
76+
// synchronously before dispatching so a later step cannot reuse it.
77+
expireRefFrame(session);
78+
return toBackendActionResult(
7579
await dispatchCommand(
7680
session.device,
7781
'press',
7882
[String(point.x), String(point.y)],
7983
req.flags?.out,
8084
params.contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath),
8185
),
82-
),
86+
);
87+
},
8388
tapTarget: webProvider?.clickRef
8489
? async (_context, target): Promise<BackendActionResult> => {
90+
expireRefFrame(session);
8591
await webProvider.clickRef?.(target.ref);
8692
return { ref: stripAtPrefix(target.ref) };
8793
}
8894
: undefined,
89-
fill: async (_context, point, text): Promise<BackendActionResult> =>
90-
toBackendActionResult(
95+
fill: async (_context, point, text): Promise<BackendActionResult> => {
96+
expireRefFrame(session);
97+
return toBackendActionResult(
9198
await dispatchCommand(
9299
session.device,
93100
'fill',
94101
[String(point.x), String(point.y), text],
95102
req.flags?.out,
96103
params.contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath),
97104
),
98-
),
105+
);
106+
},
99107
fillTarget: webProvider?.fillRef
100108
? async (_context, target, text, options): Promise<BackendActionResult> => {
109+
expireRefFrame(session);
101110
await webProvider.fillRef?.(target.ref, text, options);
102111
return {
103112
ref: stripAtPrefix(target.ref),
@@ -106,8 +115,9 @@ function createInteractionBackend(
106115
};
107116
}
108117
: undefined,
109-
longPress: async (_context, point, options): Promise<BackendActionResult> =>
110-
toBackendActionResult(
118+
longPress: async (_context, point, options): Promise<BackendActionResult> => {
119+
expireRefFrame(session);
120+
return toBackendActionResult(
111121
await dispatchCommand(
112122
session.device,
113123
'longpress',
@@ -119,25 +129,30 @@ function createInteractionBackend(
119129
req.flags?.out,
120130
params.contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath),
121131
),
122-
),
123-
performGesture: async (_context, plan): Promise<BackendActionResult> =>
124-
toBackendActionResult(
132+
);
133+
},
134+
performGesture: async (_context, plan): Promise<BackendActionResult> => {
135+
expireRefFrame(session);
136+
return toBackendActionResult(
125137
await dispatchGesturePlan(
126138
session.device,
127139
plan,
128140
params.contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath),
129141
),
130-
),
131-
typeText: async (_context, text): Promise<BackendActionResult> =>
132-
toBackendActionResult(
142+
);
143+
},
144+
typeText: async (_context, text): Promise<BackendActionResult> => {
145+
expireRefFrame(session);
146+
return toBackendActionResult(
133147
await dispatchCommand(
134148
session.device,
135149
'type',
136150
[text],
137151
req.flags?.out,
138152
params.contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath),
139153
),
140-
),
154+
);
155+
},
141156
};
142157
}
143158

‎src/daemon/ref-frame.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,29 @@ export function refFrameEpoch(session: SessionState): number | undefined {
4848
return session.snapshotGeneration;
4949
}
5050

51+
/**
52+
* Expire the current frame at a device side-effect seam (ADR 0014). Idempotent:
53+
* additional effects while already expired are a no-op. Call this SYNCHRONOUSLY,
54+
* immediately before awaiting the operation that may change device-visible
55+
* element identity, so that a post-dispatch failure (timeout, connection loss,
56+
* ambiguous error) still leaves the frame expired — there is no success-only
57+
* rollback.
58+
*/
59+
export function expireRefFrame(session: SessionState): void {
60+
session.refFrameState = 'expired';
61+
}
62+
63+
/**
64+
* Re-authorize a complete frame when a response issues its refs to the client
65+
* (ADR 0014). This is the only transition that restores plain-ref mutation after
66+
* an expiry; internal read captures never call it. The complete namespace has
67+
* scope `all`.
68+
*/
69+
export function activateRefFrame(session: SessionState): void {
70+
session.refFrameState = 'active';
71+
session.refFrameScope = undefined;
72+
}
73+
5174
export function refFrameState(session: SessionState): RefFrameState {
5275
return session.refFrameState ?? 'active';
5376
}

‎src/daemon/request-generic-dispatch.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@ import {
2424
} from './recording-gestures.ts';
2525
import { markPostGestureStabilization } from './post-gesture-stabilization.ts';
2626
import { normalizeError } from '../kernel/errors.ts';
27-
import { shouldGuardAndroidBlockingDialog } from './daemon-command-registry.ts';
27+
import { expireRefFrame } from './ref-frame.ts';
28+
import {
29+
resolveRefFrameEffect,
30+
shouldGuardAndroidBlockingDialog,
31+
} from './daemon-command-registry.ts';
2832
import { isActiveProviderDevice } from '../provider-device-runtime.ts';
2933
import {
3034
assertSupportedScreenshotPixelDensity,
@@ -59,6 +63,14 @@ export async function dispatchGenericCommand(params: {
5963
...contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath),
6064
surface: session.surface,
6165
};
66+
// ADR 0014 side-effect seam for generic-route leaves (back/home/rotate/scroll/
67+
// tv-remote/app-switcher/viewport/focus, ...). The daemon effect classification
68+
// is the honesty guard that decides which of these mutate; expire the frame
69+
// before dispatching so a later ref cannot reuse it. Read-only generic leaves
70+
// (screenshot) are classified `preserve` and leave the frame untouched.
71+
if (resolveRefFrameEffect(req) === 'may-invalidate') {
72+
expireRefFrame(session);
73+
}
6274
let data = await executeGenericPlatformCommand({
6375
session,
6476
sessionName: params.sessionName,

‎src/daemon/session-snapshot.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { randomInt } from 'node:crypto';
22
import type { SnapshotState } from '../kernel/snapshot.ts';
3+
import { activateRefFrame } from './ref-frame.ts';
34
import type { SessionState } from './types.ts';
45

56
/**
@@ -73,6 +74,9 @@ export function nextSnapshotGeneration(current: number | undefined): number {
7374
/** The response being returned hands the stored snapshot's refs to the client. */
7475
export function markSessionSnapshotRefsIssued(session: SessionState): void {
7576
session.snapshotRefsStale = false;
77+
// ADR 0014: issuing refs to the client (re-)authorizes a complete frame, so a
78+
// fresh capture between mutations restores usability after a side-effect expiry.
79+
activateRefFrame(session);
7680
}
7781

7882
/**

‎src/daemon/snapshot-runtime.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,11 @@ function buildNextSnapshotSession(params: {
264264
nextSession.snapshotGeneration = keepCurrentSnapshot
265265
? current?.snapshotGeneration
266266
: nextSnapshotGeneration(current?.snapshotGeneration);
267+
nextSession.refFrameState = resolveNextRefFrameState({
268+
current,
269+
keepCurrentSnapshot,
270+
issuesRefsToClient: params.issuesRefsToClient,
271+
});
267272
if (record.appName) nextSession.appName = record.appName;
268273
return nextSession;
269274
}
@@ -282,6 +287,19 @@ function shouldKeepCurrentSnapshot(
282287
);
283288
}
284289

290+
// ADR 0014: a snapshot command hands the client the complete ref namespace, so
291+
// it re-authorizes a complete frame (recovering usability after a side-effect
292+
// expiry). A diff (summary response) or a kept tree preserves the prior
293+
// authorization state that buildSnapshotSession carried over.
294+
function resolveNextRefFrameState(params: {
295+
current: SessionState | undefined;
296+
keepCurrentSnapshot: boolean;
297+
issuesRefsToClient: boolean;
298+
}): SessionState['refFrameState'] {
299+
if (!params.keepCurrentSnapshot && params.issuesRefsToClient) return 'active';
300+
return params.current?.refFrameState;
301+
}
302+
285303
function resolveNextSnapshotScopeSource(params: {
286304
current: SessionState | undefined;
287305
keepCurrentSnapshot: boolean;

0 commit comments

Comments
 (0)