From 19cc24cb8dbb475ab7627914764c152626881170 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 19:44:43 +0000 Subject: [PATCH 01/14] feat(daemon): classify ref-frame effect on every daemon command (ADR 0014 step 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the ADR 0014 `refFrameEffect` trait to the daemon command descriptor facet: every command that reaches a session-owning daemon leaf declares how it relates to the session's authorized ref frame — `preserve`, `may-invalidate`, `delegated`, or a request-sensitive resolver for subaction-dependent commands (keyboard status vs dismiss, alert get/wait vs accept/dismiss). This is the honesty/completeness guard, not the transition site: a `may-invalidate` command still calls the (future) ref-frame module only when its mutating path runs. No runtime behavior changes here. - `RefFrameEffect` / `DaemonRefFrameEffect` types and a `resolveRefFrameEffect` accessor honoring the resolver form, mirroring the existing closure traits. - Classify all 58 daemon-faceted commands; `find` is the honest superset (`may-invalidate`) pending a read/mutate resolver during enforcement wiring. - Give `app-switcher` a daemon facet (route unchanged) so the generic-fallback escape hatch the ADR calls out is covered instead of silently unclassified; drop it from parity's UNROUTED set. - Completeness gate (`ref-frame-effect.test.ts`): every daemon-projected command classifies an effect, every public command is classified or in the explicit non-daemon allowlist (`install-from-source`, which projects via the `install_source` internal command), and the resolvers/app-switcher resolve as declared. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa --- .../__tests__/parity.test.ts | 9 +- .../__tests__/ref-frame-effect.test.ts | 103 +++++++++ src/core/command-descriptor/registry.ts | 208 +++++++++++++----- src/daemon/daemon-command-registry.ts | 40 ++++ 4 files changed, 306 insertions(+), 54 deletions(-) create mode 100644 src/core/command-descriptor/__tests__/ref-frame-effect.test.ts diff --git a/src/core/command-descriptor/__tests__/parity.test.ts b/src/core/command-descriptor/__tests__/parity.test.ts index 636d4c869..a21667575 100644 --- a/src/core/command-descriptor/__tests__/parity.test.ts +++ b/src/core/command-descriptor/__tests__/parity.test.ts @@ -32,10 +32,11 @@ const DAEMON_FUNCTION_TRAITS = [ // Public commands that intentionally have no daemon route — they live only in the // capability/batch tables, so the daemon registry has never covered them. -const UNROUTED_PUBLIC_COMMANDS = new Set([ - PUBLIC_COMMANDS.appSwitcher, - PUBLIC_COMMANDS.installFromSource, -]); +// `install-from-source` projects to the daemon via the `install_source` internal +// command (its daemon writer), never by its own name, so the daemon registry has +// never covered it. (`app-switcher` gained a daemon facet under ADR 0014 so its +// device mutation could be classified, so it is no longer unrouted.) +const UNROUTED_PUBLIC_COMMANDS = new Set([PUBLIC_COMMANDS.installFromSource]); // Public commands that intentionally carry no capability entry — pure control-plane // or always-admitted commands, so the capability matrix has never covered them. diff --git a/src/core/command-descriptor/__tests__/ref-frame-effect.test.ts b/src/core/command-descriptor/__tests__/ref-frame-effect.test.ts new file mode 100644 index 000000000..cee341bcd --- /dev/null +++ b/src/core/command-descriptor/__tests__/ref-frame-effect.test.ts @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { PUBLIC_COMMANDS } from '../../../command-catalog.ts'; +import { + DAEMON_COMMAND_DESCRIPTORS, + resolveRefFrameEffect, + type RefFrameEffect, +} from '../../../daemon/daemon-command-registry.ts'; +import type { DaemonRequest } from '../../../daemon/types.ts'; + +// ADR 0014 migration step 2: the ref-frame-effect classification is an +// honesty/completeness guard. Every command that reaches a session-owning daemon +// leaf must classify how it relates to the authorized ref frame, so a device +// mutation cannot be added on a path that silently leaves stale refs admissible. + +function makeRequest(command: string, positionals: string[] = []): DaemonRequest { + return { command, token: 'gate-token', session: 'gate-session', positionals, flags: {} }; +} + +/** + * Public commands that never reach a session-owning daemon leaf by their own + * name, so they carry no ref-frame classification. `install-from-source`'s + * daemon writer sends the `install_source` internal command (which IS + * classified), so the daemon never receives `install-from-source` itself. This + * is the ONLY unclassified public command: if it grows, a new daemon-projected + * mutation may be slipping past the gate — add its facet, do not extend this + * list without proving the command cannot mutate through the daemon. + */ +const NON_DAEMON_PUBLIC_COMMANDS = new Set([PUBLIC_COMMANDS.installFromSource]); + +test('every daemon-projected command classifies a ref-frame effect', () => { + for (const descriptor of DAEMON_COMMAND_DESCRIPTORS) { + assert.ok( + descriptor.refFrameEffect !== undefined, + `daemon command ${descriptor.command} declares no refFrameEffect (ADR 0014)`, + ); + } +}); + +test('every public command is classified or explicitly non-daemon', () => { + const daemonCommands = new Set( + DAEMON_COMMAND_DESCRIPTORS.filter((d) => d.refFrameEffect !== undefined).map((d) => d.command), + ); + for (const command of Object.values(PUBLIC_COMMANDS)) { + if (NON_DAEMON_PUBLIC_COMMANDS.has(command)) continue; + assert.ok( + daemonCommands.has(command), + `public command ${command} has no ref-frame classification and is not in the ` + + `explicit non-daemon allowlist — a daemon-projected mutation may be unclassified`, + ); + } +}); + +test('app-switcher no longer bypasses classification (ADR 0014 escape hatch)', () => { + // app-switcher reaches the generic daemon leaf and mutates the device. Before + // ADR 0014 it had no daemon facet and relied on the registry's generic + // fallback, so it could not be classified. It must now resolve to a concrete + // mutating effect. + assert.equal(resolveRefFrameEffect(makeRequest('app-switcher')), 'may-invalidate'); +}); + +test('resolver commands classify per selected subaction', () => { + // keyboard: status probe preserves, dismiss mutates. + assert.equal(resolveRefFrameEffect(makeRequest('keyboard')), 'preserve'); + assert.equal(resolveRefFrameEffect(makeRequest('keyboard', ['status'])), 'preserve'); + assert.equal(resolveRefFrameEffect(makeRequest('keyboard', ['dismiss'])), 'may-invalidate'); + // alert: get/wait read, accept/dismiss mutate. + assert.equal(resolveRefFrameEffect(makeRequest('alert')), 'preserve'); + assert.equal(resolveRefFrameEffect(makeRequest('alert', ['get'])), 'preserve'); + assert.equal(resolveRefFrameEffect(makeRequest('alert', ['wait'])), 'preserve'); + assert.equal(resolveRefFrameEffect(makeRequest('alert', ['accept'])), 'may-invalidate'); + assert.equal(resolveRefFrameEffect(makeRequest('alert', ['dismiss'])), 'may-invalidate'); +}); + +test('representative literal classifications resolve as declared', () => { + const cases: Array<[string, RefFrameEffect]> = [ + ['snapshot', 'preserve'], + ['diff', 'preserve'], + ['get', 'preserve'], + ['is', 'preserve'], + ['screenshot', 'preserve'], + ['clipboard', 'preserve'], + ['press', 'may-invalidate'], + ['click', 'may-invalidate'], + ['fill', 'may-invalidate'], + ['type', 'may-invalidate'], + ['scroll', 'may-invalidate'], + ['back', 'may-invalidate'], + ['open', 'may-invalidate'], + ['viewport', 'may-invalidate'], + ['batch', 'delegated'], + ['replay', 'delegated'], + ['test', 'delegated'], + ]; + for (const [command, expected] of cases) { + assert.equal(resolveRefFrameEffect(makeRequest(command)), expected, `${command} effect`); + } +}); + +test('non-daemon commands resolve to no effect', () => { + // A command the daemon never receives by name has no classification. + assert.equal(resolveRefFrameEffect(makeRequest('install-from-source')), undefined); +}); diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index c9bff82a4..ecf87cfbe 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -1,5 +1,8 @@ import type { CommandCapability } from '../capabilities.ts'; import type { DaemonRequest } from '../../daemon/types.ts'; +// Type-only back-edge, erased at runtime (same pattern as derive.ts importing +// DaemonCommandDescriptor); no runtime import cycle. +import type { RefFrameEffect } from '../../daemon/daemon-command-registry.ts'; import { resolveWaitBudgetMs } from '../wait-positionals.ts'; import { DEFAULT_TIMEOUT_POLICY, @@ -82,6 +85,19 @@ const isShardedTestRequest = (req: DaemonRequest): boolean => req.command === 'test' && (typeof req.flags?.shardAll === 'number' || typeof req.flags?.shardSplit === 'number'); +// ADR 0014 request-sensitive ref-frame resolvers. `keyboard status` and +// `alert get`/`wait` are read-only status probes that preserve the frame, while +// `keyboard dismiss` and `alert accept`/`dismiss` cross a device side effect. +// The action is the leading positional (see keyboard/alert daemon writers in +// src/commands/system/index.ts and src/commands/capture/alert.ts). +const keyboardRefFrameEffect = (req: DaemonRequest): RefFrameEffect => + (req.positionals?.[0] ?? 'status').toLowerCase() === 'dismiss' ? 'may-invalidate' : 'preserve'; + +const alertRefFrameEffect = (req: DaemonRequest): RefFrameEffect => { + const action = (req.positionals?.[0] ?? 'get').toLowerCase(); + return action === 'accept' || action === 'dismiss' ? 'may-invalidate' : 'preserve'; +}; + // --------------------------------------------------------------------------- // Capability matrices — platform/kind buckets, copied VERBATIM from // src/core/capabilities.ts (BASE_COMMAND_CAPABILITY_MATRIX). @@ -208,7 +224,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'lease_allocate', ...(ownerFilesEnabled ? { ownerFiles: ['src/daemon/handlers/lease.ts'] as const } : {}), catalog: { group: 'internal', key: 'leaseAllocate' }, - daemon: { route: 'lease', ...ADMISSION_AND_LOCK_EXEMPT }, + daemon: { route: 'lease', refFrameEffect: 'preserve', ...ADMISSION_AND_LOCK_EXEMPT }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, }, @@ -216,7 +232,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'lease_heartbeat', ...(ownerFilesEnabled ? { ownerFiles: ['src/daemon/handlers/lease.ts'] as const } : {}), catalog: { group: 'internal', key: 'leaseHeartbeat' }, - daemon: { route: 'lease', ...ADMISSION_AND_LOCK_EXEMPT }, + daemon: { route: 'lease', refFrameEffect: 'preserve', ...ADMISSION_AND_LOCK_EXEMPT }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, }, @@ -224,7 +240,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'lease_release', ...(ownerFilesEnabled ? { ownerFiles: ['src/daemon/handlers/lease.ts'] as const } : {}), catalog: { group: 'internal', key: 'leaseRelease' }, - daemon: { route: 'lease', ...ADMISSION_AND_LOCK_EXEMPT }, + daemon: { route: 'lease', refFrameEffect: 'preserve', ...ADMISSION_AND_LOCK_EXEMPT }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, }, @@ -232,7 +248,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'artifacts', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/artifacts.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'lease', ...ADMISSION_AND_LOCK_EXEMPT }, + daemon: { route: 'lease', refFrameEffect: 'preserve', ...ADMISSION_AND_LOCK_EXEMPT }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, }, @@ -244,7 +260,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ ? { ownerFiles: ['src/daemon/handlers/session-inventory.ts'] as const } : {}), catalog: { group: 'internal', key: 'sessionList' }, - daemon: { route: 'session', sessionKind: 'inventory', ...REQUEST_EXECUTION_EXEMPT }, + daemon: { + route: 'session', + refFrameEffect: 'preserve', + sessionKind: 'inventory', + ...REQUEST_EXECUTION_EXEMPT, + }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, }, @@ -254,6 +275,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ catalog: { group: 'public' }, daemon: { route: 'session', + refFrameEffect: 'preserve', sessionKind: 'inventory', lockPolicySelectorOverride: true, ...REQUEST_EXECUTION_EXEMPT, @@ -267,6 +289,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ catalog: { group: 'public' }, daemon: { route: 'session', + refFrameEffect: 'preserve', sessionKind: 'inventory', lockPolicySelectorOverride: true, preferExplicitDeviceOverExistingSession: true, @@ -281,6 +304,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ catalog: { group: 'public' }, daemon: { route: 'session', + refFrameEffect: 'preserve', sessionKind: 'inventory', lockPolicySelectorOverride: true, allowSessionlessDefaultDevice: allowAnyDeviceSessionless, @@ -295,6 +319,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ catalog: { group: 'public' }, daemon: { route: 'session', + refFrameEffect: 'preserve', sessionKind: 'inventory', lockPolicySelectorOverride: true, preferExplicitDeviceOverExistingSession: true, @@ -307,7 +332,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'boot', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/device.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'session', sessionKind: 'state' }, + daemon: { route: 'session', refFrameEffect: 'may-invalidate', sessionKind: 'state' }, capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, @@ -320,7 +345,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'shutdown', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/device.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'session', sessionKind: 'state' }, + daemon: { route: 'session', refFrameEffect: 'may-invalidate', sessionKind: 'state' }, capability: { apple: { simulator: true }, android: { emulator: true }, @@ -333,7 +358,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'appstate', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public', key: 'appState' }, - daemon: { route: 'session', sessionKind: 'state' }, + daemon: { route: 'session', refFrameEffect: 'preserve', sessionKind: 'state' }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, }, @@ -341,7 +366,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'perf', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/perf/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'session', sessionKind: 'observability' }, + daemon: { route: 'session', refFrameEffect: 'preserve', sessionKind: 'observability' }, capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_NONE }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, @@ -350,7 +375,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'logs', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/observability/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'session', sessionKind: 'observability' }, + daemon: { route: 'session', refFrameEffect: 'preserve', sessionKind: 'observability' }, capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_NONE }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, @@ -361,6 +386,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ catalog: { group: 'public' }, daemon: { route: 'session', + refFrameEffect: 'preserve', sessionKind: 'observability', allowInvalidRecording: true, ...REQUEST_EXECUTION_EXEMPT, @@ -372,7 +398,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'network', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/observability/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'session', sessionKind: 'observability' }, + daemon: { route: 'session', refFrameEffect: 'preserve', sessionKind: 'observability' }, capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_NONE }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, @@ -381,7 +407,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'audio', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/observability/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'session', sessionKind: 'observability' }, + daemon: { route: 'session', refFrameEffect: 'preserve', sessionKind: 'observability' }, capability: { apple: APPLE_SIM_AND_DEVICE, android: { emulator: true }, @@ -396,6 +422,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ catalog: { group: 'public' }, daemon: { route: 'session', + refFrameEffect: 'delegated', sessionKind: 'replay', skipSessionlessProviderDevice: isShardedTestRequest, }, @@ -409,6 +436,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ catalog: { group: 'public' }, daemon: { route: 'session', + refFrameEffect: 'delegated', sessionKind: 'replay', skipSessionlessProviderDevice: isShardedTestRequest, }, @@ -423,7 +451,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ ? { ownerFiles: ['src/daemon/handlers/session-runtime-command.ts'] as const } : {}), catalog: { group: 'internal' }, - daemon: { route: 'session' }, + daemon: { route: 'session', refFrameEffect: 'preserve' }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, }, @@ -431,7 +459,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'clipboard', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'session', replayScopedAction: true }, + daemon: { route: 'session', refFrameEffect: 'preserve', replayScopedAction: true }, dispatch: {}, capability: { apple: APPLE_SIM_AND_DEVICE, @@ -445,7 +473,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'keyboard', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'session', replayScopedAction: true, androidBlockingDialogGuard: true }, + daemon: { + route: 'session', + refFrameEffect: keyboardRefFrameEffect, + replayScopedAction: true, + androidBlockingDialogGuard: true, + }, dispatch: {}, capability: { apple: APPLE_SIM_AND_DEVICE, @@ -459,7 +492,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'install', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/install.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'session' }, + daemon: { route: 'session', refFrameEffect: 'may-invalidate' }, capability: APP_INSTALL_CAPABILITY, timeoutPolicy: INSTALL_TIMEOUT_POLICY, batchable: true, @@ -468,7 +501,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'reinstall', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/install.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'session' }, + daemon: { route: 'session', refFrameEffect: 'may-invalidate' }, capability: APP_INSTALL_CAPABILITY, timeoutPolicy: INSTALL_TIMEOUT_POLICY, batchable: true, @@ -479,7 +512,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ ? { ownerFiles: ['src/daemon/handlers/install-source.ts'] as const } : {}), catalog: { group: 'internal', key: 'installSource' }, - daemon: { route: 'session' }, + daemon: { route: 'session', refFrameEffect: 'may-invalidate' }, timeoutPolicy: INSTALL_TIMEOUT_POLICY, batchable: false, }, @@ -489,7 +522,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ ? { ownerFiles: ['src/daemon/handlers/install-source.ts'] as const } : {}), catalog: { group: 'internal', key: 'releaseMaterializedPaths' }, - daemon: { route: 'session', ...REQUEST_EXECUTION_EXEMPT }, + daemon: { route: 'session', refFrameEffect: 'preserve', ...REQUEST_EXECUTION_EXEMPT }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, }, @@ -497,7 +530,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'push', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/push.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'session' }, + daemon: { route: 'session', refFrameEffect: 'may-invalidate' }, dispatch: {}, capability: { apple: { simulator: true }, @@ -511,7 +544,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'trigger-app-event', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/push.ts'] as const } : {}), catalog: { group: 'public', key: 'triggerAppEvent' }, - daemon: { route: 'session' }, + daemon: { route: 'session', refFrameEffect: 'may-invalidate' }, dispatch: {}, capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_NONE }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -521,7 +554,11 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'open', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/app.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'session', allowSessionlessDefaultDevice: allowAnyDeviceSessionless }, + daemon: { + route: 'session', + refFrameEffect: 'may-invalidate', + allowSessionlessDefaultDevice: allowAnyDeviceSessionless, + }, dispatch: {}, capability: APP_RUNTIME_CAPABILITY, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -531,7 +568,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'prepare', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/prepare.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'session' }, + daemon: { route: 'session', refFrameEffect: 'preserve' }, // Runner warm-up builds are the longest fixed envelope; --timeout overrides. timeoutPolicy: { budget: { source: 'flag' }, @@ -545,7 +582,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'batch', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/batch/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'session' }, + daemon: { route: 'session', refFrameEffect: 'delegated' }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, }, @@ -553,7 +590,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'close', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/app.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'session', allowInvalidRecording: true }, + daemon: { route: 'session', refFrameEffect: 'may-invalidate', allowInvalidRecording: true }, dispatch: {}, capability: APP_RUNTIME_CAPABILITY, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -565,7 +602,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'snapshot', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/capture/snapshot.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'snapshot', replayScopedAction: true }, + daemon: { route: 'snapshot', refFrameEffect: 'preserve', replayScopedAction: true }, dispatch: {}, capability: ALL_DEVICE_COMMAND_CAPABILITY, // First Apple snapshot on a device can sit behind runner startup; --timeout @@ -577,7 +614,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'diff', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/capture/diff.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'snapshot', replayScopedAction: true }, + daemon: { route: 'snapshot', refFrameEffect: 'preserve', replayScopedAction: true }, capability: ALL_DEVICE_COMMAND_CAPABILITY, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, @@ -586,7 +623,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'wait', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/capture/wait.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'snapshot', replayScopedAction: true }, + daemon: { route: 'snapshot', refFrameEffect: 'preserve', replayScopedAction: true }, capability: ALL_DEVICE_COMMAND_CAPABILITY, // The wait budget travels as a positional, not a flag; parse it the same // way the daemon will so the request envelope extends past it (#1075). @@ -600,7 +637,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'alert', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/capture/alert.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'snapshot', replayScopedAction: true }, + daemon: { route: 'snapshot', refFrameEffect: alertRefFrameEffect, replayScopedAction: true }, capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, @@ -613,7 +650,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'settings', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/capture/settings.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'snapshot', replayScopedAction: true }, + daemon: { route: 'snapshot', refFrameEffect: 'may-invalidate', replayScopedAction: true }, dispatch: {}, capability: { apple: APPLE_SIM_AND_DEVICE, @@ -629,7 +666,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'react-native', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/react-native/index.ts'] as const } : {}), catalog: { group: 'public', key: 'reactNative' }, - daemon: { route: 'reactNative', replayScopedAction: true }, + daemon: { route: 'reactNative', refFrameEffect: 'may-invalidate', replayScopedAction: true }, capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_NONE }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, @@ -640,6 +677,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ catalog: { group: 'public' }, daemon: { route: 'recordTrace', + refFrameEffect: 'preserve', replayScopedAction: true, allowInvalidRecording: true, allowSessionlessDefaultDevice: isRecordingStartRequest, @@ -652,7 +690,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'trace', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/recording/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'recordTrace' }, + daemon: { route: 'recordTrace', refFrameEffect: 'preserve' }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, }, @@ -660,7 +698,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'find', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'find', replayScopedAction: true }, + daemon: { route: 'find', refFrameEffect: 'may-invalidate', replayScopedAction: true }, capability: ALL_DEVICE_COMMAND_CAPABILITY, timeoutPolicy: PRESERVE_DAEMON_TIMEOUT_POLICY, batchable: true, @@ -677,7 +715,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'click', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'interaction', replayScopedAction: true, androidBlockingDialogGuard: true }, + daemon: { + route: 'interaction', + refFrameEffect: 'may-invalidate', + replayScopedAction: true, + androidBlockingDialogGuard: true, + }, capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_DEVICE }, timeoutPolicy: interactionTimeoutPolicy('click'), postActionObservation: postActionObservation('click'), @@ -688,7 +731,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'fill', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'interaction', replayScopedAction: true, androidBlockingDialogGuard: true }, + daemon: { + route: 'interaction', + refFrameEffect: 'may-invalidate', + replayScopedAction: true, + androidBlockingDialogGuard: true, + }, dispatch: {}, capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_DEVICE }, timeoutPolicy: interactionTimeoutPolicy('fill'), @@ -700,7 +748,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'longpress', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public', key: 'longPress' }, - daemon: { route: 'interaction', replayScopedAction: true, androidBlockingDialogGuard: true }, + daemon: { + route: 'interaction', + refFrameEffect: 'may-invalidate', + replayScopedAction: true, + androidBlockingDialogGuard: true, + }, dispatch: {}, capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_DEVICE }, timeoutPolicy: { @@ -717,7 +770,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'press', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'interaction', replayScopedAction: true, androidBlockingDialogGuard: true }, + daemon: { + route: 'interaction', + refFrameEffect: 'may-invalidate', + replayScopedAction: true, + androidBlockingDialogGuard: true, + }, dispatch: {}, capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_DEVICE }, timeoutPolicy: interactionTimeoutPolicy('press'), @@ -729,7 +787,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'type', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'interaction', replayScopedAction: true, androidBlockingDialogGuard: true }, + daemon: { + route: 'interaction', + refFrameEffect: 'may-invalidate', + replayScopedAction: true, + androidBlockingDialogGuard: true, + }, dispatch: {}, capability: ALL_DEVICE_COMMAND_CAPABILITY, timeoutPolicy: interactionTimeoutPolicy('type'), @@ -739,7 +802,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'get', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'interaction', replayScopedAction: true }, + daemon: { route: 'interaction', refFrameEffect: 'preserve', replayScopedAction: true }, capability: ALL_DEVICE_COMMAND_CAPABILITY, timeoutPolicy: interactionTimeoutPolicy('get'), batchable: true, @@ -756,7 +819,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'is', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'interaction', replayScopedAction: true }, + daemon: { route: 'interaction', refFrameEffect: 'preserve', replayScopedAction: true }, capability: ALL_DEVICE_COMMAND_CAPABILITY, timeoutPolicy: interactionTimeoutPolicy('is'), batchable: true, @@ -767,7 +830,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'back', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'generic', replayScopedAction: true, androidBlockingDialogGuard: true }, + daemon: { + route: 'generic', + refFrameEffect: 'may-invalidate', + replayScopedAction: true, + androidBlockingDialogGuard: true, + }, dispatch: {}, capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_DEVICE }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -777,7 +845,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'gesture', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'interaction', replayScopedAction: true, androidBlockingDialogGuard: true }, + daemon: { + route: 'interaction', + refFrameEffect: 'may-invalidate', + replayScopedAction: true, + androidBlockingDialogGuard: true, + }, capability: ALL_DEVICE_COMMAND_CAPABILITY, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, @@ -786,7 +859,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'home', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'generic', replayScopedAction: true, androidBlockingDialogGuard: true }, + daemon: { + route: 'generic', + refFrameEffect: 'may-invalidate', + replayScopedAction: true, + androidBlockingDialogGuard: true, + }, dispatch: {}, capability: { apple: APPLE_SIM_AND_DEVICE, @@ -800,7 +878,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'tv-remote', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public', key: 'tvRemote' }, - daemon: { route: 'generic', replayScopedAction: true, androidBlockingDialogGuard: true }, + daemon: { + route: 'generic', + refFrameEffect: 'may-invalidate', + replayScopedAction: true, + androidBlockingDialogGuard: true, + }, dispatch: {}, capability: { apple: APPLE_SIM_AND_DEVICE, @@ -814,7 +897,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'orientation', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'generic', replayScopedAction: true, androidBlockingDialogGuard: true }, + daemon: { + route: 'generic', + refFrameEffect: 'may-invalidate', + replayScopedAction: true, + androidBlockingDialogGuard: true, + }, dispatch: {}, capability: { apple: APPLE_SIM_AND_DEVICE, @@ -828,7 +916,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'scroll', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'generic', replayScopedAction: true, androidBlockingDialogGuard: true }, + daemon: { + route: 'generic', + refFrameEffect: 'may-invalidate', + replayScopedAction: true, + androidBlockingDialogGuard: true, + }, dispatch: {}, capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_DEVICE }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -838,7 +931,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'swipe', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'interaction', replayScopedAction: true, androidBlockingDialogGuard: true }, + daemon: { + route: 'interaction', + refFrameEffect: 'may-invalidate', + replayScopedAction: true, + androidBlockingDialogGuard: true, + }, capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_DEVICE }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, @@ -847,7 +945,11 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'focus', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'generic', androidBlockingDialogGuard: true }, + daemon: { + route: 'generic', + refFrameEffect: 'may-invalidate', + androidBlockingDialogGuard: true, + }, dispatch: {}, capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_DEVICE }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -857,7 +959,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'screenshot', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/capture/screenshot.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'generic', replayScopedAction: true }, + daemon: { route: 'generic', refFrameEffect: 'preserve', replayScopedAction: true }, dispatch: {}, capability: ALL_DEVICE_COMMAND_CAPABILITY, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -867,7 +969,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'viewport', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/viewport.ts'] as const } : {}), catalog: { group: 'public' }, - daemon: { route: 'generic', replayScopedAction: true }, + daemon: { route: 'generic', refFrameEffect: 'may-invalidate', replayScopedAction: true }, dispatch: {}, capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_NONE }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -878,6 +980,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'app-switcher', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public', key: 'appSwitcher' }, + // ADR 0014: app-switcher previously reached the generic daemon leaf via the + // registry's generic fallback with no daemon facet, so it could not be + // classified. Add the facet (route unchanged) so its device mutation is + // covered by the completeness gate; this is the escape hatch the ADR calls + // out, not a new specialized route. + daemon: { route: 'generic', refFrameEffect: 'may-invalidate' }, dispatch: {}, capability: { apple: APPLE_SIM_AND_DEVICE, diff --git a/src/daemon/daemon-command-registry.ts b/src/daemon/daemon-command-registry.ts index fc96fa7aa..4aa1d328c 100644 --- a/src/daemon/daemon-command-registry.ts +++ b/src/daemon/daemon-command-registry.ts @@ -7,10 +7,38 @@ export type { DaemonCommandRoute } from './request-handler-chain.ts'; export type SessionCommandKind = 'inventory' | 'state' | 'observability' | 'replay'; +/** + * ADR 0014 session ref-frame lifetime. Declares how a daemon command relates to + * the session's authorized ref frame: + * - `preserve`: no successful path changes device-visible element identity, so + * the frame carries through untouched (snapshots, reads, inventory, ...); + * - `may-invalidate`: some successful path crosses a device side effect, so the + * leaf must expire the frame at its side-effect seam when that path runs; + * - `delegated`: an orchestrator (batch/replay/test) whose nested leaves own + * their own transitions — the outer command never expires a frame itself. + * + * This classification is an honesty/completeness guard, NOT the transition site: + * a `may-invalidate` command still calls the ref-frame module only when its + * mutating path is selected. The completeness gate + * (`__tests__/ref-frame-effect.test.ts`) fails if a daemon-projected command + * omits this classification. + */ +export type RefFrameEffect = 'preserve' | 'may-invalidate' | 'delegated'; + +/** + * Request-sensitive form of {@link RefFrameEffect}. Commands whose subactions + * differ (keyboard `status` vs `dismiss`, alert `get`/`wait` vs + * `accept`/`dismiss`) use the resolver form instead of pretending all + * subcommands behave alike. Mirrors the existing `(req) => boolean` closure + * traits below. + */ +export type DaemonRefFrameEffect = RefFrameEffect | ((req: DaemonRequest) => RefFrameEffect); + export type DaemonCommandDescriptor = { command: string; route: DaemonCommandRoute; sessionKind?: SessionCommandKind; + refFrameEffect?: DaemonRefFrameEffect; leaseAdmissionExempt?: boolean; sessionExecutionLockExempt?: boolean; selectorValidationExempt?: boolean; @@ -86,6 +114,18 @@ export function usesSessionlessDefaultProviderDevice(req: DaemonRequest): boolea return typeof allow === 'function' ? allow(req) : false; } +/** + * ADR 0014: the ref-frame effect a request resolves to, honoring the + * request-sensitive resolver form. Returns `undefined` for commands with no + * daemon descriptor (never daemon-projected) — the completeness gate ensures + * every daemon-projected command declares an effect, so `undefined` here means + * the command does not reach a session-owning daemon leaf. + */ +export function resolveRefFrameEffect(req: DaemonRequest): RefFrameEffect | undefined { + const effect = getDaemonCommandDescriptor(req.command)?.refFrameEffect; + return typeof effect === 'function' ? effect(req) : effect; +} + export function resolveProviderDeviceResolutionIntent( req: DaemonRequest, params: { hasExistingSession: boolean; hasExplicitDeviceSelector: boolean }, From 9d6da6f314164c4341a1ccc893bc7dfd95e0fe88 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 06:23:13 +0000 Subject: [PATCH 02/14] feat(daemon): introduce ref-frame module + admission matrix (ADR 0014 step 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce `src/daemon/ref-frame.ts` as the single owner of the ADR 0014 ref-frame model — the authorization namespace for mutation refs, kept distinct from the latest operational observation (`session.snapshot`). It defines the frame's issuance scope and lifecycle state and the pure mutation-admission matrix (`admitRefMutation`) with the ADR's typed, order-sensitive reasons: ref_frame_expired, ref_generation_mismatch, plain_ref_requires_complete_frame, ref_not_issued. The frame is introduced behind the existing `snapshotGeneration` (epoch) and `snapshotRefsStale` (coarse client-stale) fields, whose wire-visible names (`refsGeneration`, the `@e12~s42` pin grammar) are unchanged. New `refFrameState`/`refFrameScope` session fields default to active/all, so the matrix currently reduces to the generation-pin check the iOS path already did — no behavior change. Expiration at the side-effect seam and non-`all` scope land in later steps. The existing #1241 iOS stale-ref guard now routes its decision through `admitRefMutation` (plus the transitional coarse-stale check for plain refs), so the module is production-live; the external error contract is identical. Adds a unit test covering the full admission matrix and reason ordering. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa --- src/daemon/__tests__/ref-frame.test.ts | 92 ++++++++++++++++++ src/daemon/handlers/interaction-ref-policy.ts | 36 +++++-- src/daemon/handlers/interaction-touch.ts | 2 + src/daemon/ref-frame.ts | 95 +++++++++++++++++++ src/daemon/types.ts | 18 ++++ 5 files changed, 235 insertions(+), 8 deletions(-) create mode 100644 src/daemon/__tests__/ref-frame.test.ts create mode 100644 src/daemon/ref-frame.ts diff --git a/src/daemon/__tests__/ref-frame.test.ts b/src/daemon/__tests__/ref-frame.test.ts new file mode 100644 index 000000000..2f48871cf --- /dev/null +++ b/src/daemon/__tests__/ref-frame.test.ts @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { + admitRefMutation, + refFrameEpoch, + refFrameScope, + refFrameState, + type RefFrameAdmission, +} from '../ref-frame.ts'; +import type { SessionState } from '../types.ts'; + +function session(overrides: Partial = {}): SessionState { + return { + name: 'ref-frame-test', + device: { platform: 'ios', kind: 'simulator', id: 'sim', name: 'iPhone' }, + createdAt: 0, + ...overrides, + } as SessionState; +} + +function reason(admission: RefFrameAdmission): string | undefined { + return admission.admitted ? undefined : admission.reason; +} + +test('defaults: no frame fields set reads as active / all / undefined epoch', () => { + const s = session(); + assert.equal(refFrameState(s), 'active'); + assert.equal(refFrameScope(s), 'all'); + assert.equal(refFrameEpoch(s), undefined); +}); + +test('complete active frame admits a plain ref', () => { + const s = session({ snapshotGeneration: 42 }); + assert.deepEqual(admitRefMutation({ session: s, refBody: 'e1', mintedGeneration: undefined }), { + admitted: true, + }); +}); + +test('complete active frame admits a pinned ref at the current epoch', () => { + const s = session({ snapshotGeneration: 42 }); + assert.deepEqual(admitRefMutation({ session: s, refBody: 'e1', mintedGeneration: 42 }), { + admitted: true, + }); +}); + +test('a pinned ref at another epoch is a generation mismatch', () => { + const s = session({ snapshotGeneration: 43 }); + assert.equal( + reason(admitRefMutation({ session: s, refBody: 'e1', mintedGeneration: 42 })), + 'ref_generation_mismatch', + ); +}); + +test('an expired frame rejects every ref, and expiry wins over a matching pin', () => { + const s = session({ snapshotGeneration: 42, refFrameState: 'expired' }); + assert.equal( + reason(admitRefMutation({ session: s, refBody: 'e1', mintedGeneration: undefined })), + 'ref_frame_expired', + ); + assert.equal( + reason(admitRefMutation({ session: s, refBody: 'e1', mintedGeneration: 42 })), + 'ref_frame_expired', + ); +}); + +test('a partial frame rejects a plain ref: it requires a complete frame', () => { + const s = session({ snapshotGeneration: 42, refFrameScope: new Set(['e1']) }); + assert.equal( + reason(admitRefMutation({ session: s, refBody: 'e1', mintedGeneration: undefined })), + 'plain_ref_requires_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']) }); + assert.deepEqual(admitRefMutation({ session: s, refBody: 'e1', mintedGeneration: 42 }), { + admitted: true, + }); + assert.equal( + reason(admitRefMutation({ session: s, refBody: 'e2', mintedGeneration: 42 })), + 'ref_not_issued', + ); +}); + +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']) }); + assert.equal( + reason(admitRefMutation({ session: s, refBody: 'e1', mintedGeneration: 42 })), + 'ref_generation_mismatch', + ); +}); diff --git a/src/daemon/handlers/interaction-ref-policy.ts b/src/daemon/handlers/interaction-ref-policy.ts index 704a106e5..874fd013b 100644 --- a/src/daemon/handlers/interaction-ref-policy.ts +++ b/src/daemon/handlers/interaction-ref-policy.ts @@ -1,20 +1,40 @@ import { publicPlatformString } from '../../kernel/device.ts'; +import { admitRefMutation } from '../ref-frame.ts'; +import { STALE_SNAPSHOT_REFS_WARNING } from '../session-snapshot.ts'; import type { DaemonResponse, SessionState } from '../types.ts'; import { errorResponse } from './response.ts'; -/** Mutating through a ref from an older client-visible tree is never safe on iOS. */ +/** + * Mutating through a ref from an older client-visible tree is never safe on iOS + * (#1239). The decision now flows through the ADR 0014 ref-frame admission + * matrix (`admitRefMutation`): a pinned ref whose generation no longer matches + * the frame epoch, an expired frame, or a ref outside a partial issuance scope + * is refused. + * + * Transitional: until frame expiration at the device side-effect seam replaces + * the coarse `snapshotRefsStale` marker (ADR 0014 step 3), a plain ref whose + * operational observation was reindexed by an internal capture is also refused + * on iOS. The external error contract (code, message, hint) is unchanged. + */ export function staleIosRefGuardResponse(params: { session: SessionState; ref: string; + mintedGeneration: number | undefined; staleRefsWarning: string | undefined; }): DaemonResponse | null { - if ( - params.staleRefsWarning === undefined || - publicPlatformString(params.session.device) !== 'ios' - ) { - return null; - } + if (publicPlatformString(params.session.device) !== 'ios') return null; + + const refBody = params.ref.startsWith('@') ? params.ref.slice(1) : params.ref; + const admission = admitRefMutation({ + session: params.session, + refBody, + mintedGeneration: params.mintedGeneration, + }); + const coarsePlainStale = + params.mintedGeneration === undefined && params.session.snapshotRefsStale === true; + if (admission.admitted && !coarsePlainStale) return null; + return errorResponse('COMMAND_FAILED', `Ref ${params.ref} not found or has no bounds`, { - hint: params.staleRefsWarning, + hint: params.staleRefsWarning ?? STALE_SNAPSHOT_REFS_WARNING, }); } diff --git a/src/daemon/handlers/interaction-touch.ts b/src/daemon/handlers/interaction-touch.ts index 881f32659..1f1624558 100644 --- a/src/daemon/handlers/interaction-touch.ts +++ b/src/daemon/handlers/interaction-touch.ts @@ -156,6 +156,7 @@ async function dispatchTargetedTouchViaRuntime( const staleRefResponse = staleIosRefGuardResponse({ session, ref: parsedTarget.target.ref, + mintedGeneration: parsedTarget.refGeneration, staleRefsWarning, }); if (staleRefResponse) return staleRefResponse; @@ -615,6 +616,7 @@ async function prepareFillRefTarget( const staleRefResponse = staleIosRefGuardResponse({ session, ref: target.ref, + mintedGeneration: refGeneration, staleRefsWarning, }); if (staleRefResponse) return { response: staleRefResponse, staleRefsWarning }; diff --git a/src/daemon/ref-frame.ts b/src/daemon/ref-frame.ts new file mode 100644 index 000000000..9db6ef286 --- /dev/null +++ b/src/daemon/ref-frame.ts @@ -0,0 +1,95 @@ +import type { SessionState } from './types.ts'; + +/** + * ADR 0014 session ref-frame lifetime — the authorization model for mutation + * refs, kept distinct from the latest operational observation (`session.snapshot`). + * + * A session owns at most one **ref frame**: the namespace whose refs a caller + * may use to target a mutation. This module is the single owner of the frame's + * transitions and of the admission decision. It is introduced behind the + * existing `snapshotGeneration` (the frame epoch) and `snapshotRefsStale` (the + * coarse client-stale marker) fields, which keep their wire-visible names + * (`refsGeneration`, the `@e12~s42` pin grammar) for compatibility. + * + * Migration status: this step introduces the model and the admission matrix and + * routes the existing iOS stale-ref enforcement (#1241) through it. Frame + * expiration at the device side-effect seam, non-`all` issuance scope, and + * fail-closed enforcement on other platforms land in later steps; until then a + * frame is always `active` with scope `all`, so `admitRefMutation` reduces to + * the generation-pin check that path already performed. + */ + +/** + * Issuance scope of the current frame: `all` for a complete namespace (a full + * interactive snapshot), or the bounded set of ref bodies a partial publication + * (`find`, settled diff, replay divergence) actually returned. + */ +export type RefFrameScope = 'all' | ReadonlySet; + +/** Lifecycle state of the current frame. */ +export type RefFrameState = 'active' | 'expired'; + +/** + * Typed admission-failure reasons, evaluated in this order so the caller can + * distinguish "capture a complete snapshot" from "use the emitted pinned ref". + */ +export type RefFrameRejectReason = + | 'ref_frame_expired' + | 'ref_generation_mismatch' + | 'plain_ref_requires_complete_frame' + | 'ref_not_issued'; + +export type RefFrameAdmission = + | { admitted: true } + | { admitted: false; reason: RefFrameRejectReason }; + +/** The frame epoch exposed to clients as `refsGeneration`. */ +export function refFrameEpoch(session: SessionState): number | undefined { + return session.snapshotGeneration; +} + +export function refFrameState(session: SessionState): RefFrameState { + return session.refFrameState ?? 'active'; +} + +export function refFrameScope(session: SessionState): RefFrameScope { + return session.refFrameScope ?? 'all'; +} + +/** + * The ADR 0014 mutation-admission matrix, evaluated in reason order. Pure over + * the session's frame fields; it does not itself read the operational + * observation, so an internal read capture cannot admit or reject a mutation by + * positional coincidence. + * + * `refBody` is the plain ref body (no `@`, no `~s` suffix). `mintedGeneration` + * is the generation carried by a pinned input (`@e12~s42`), or `undefined` for a + * plain ref. + */ +export function admitRefMutation(params: { + session: SessionState; + refBody: string; + mintedGeneration: number | undefined; +}): RefFrameAdmission { + const { session, refBody, mintedGeneration } = params; + + if (refFrameState(session) === 'expired') { + return { admitted: false, reason: 'ref_frame_expired' }; + } + + if (mintedGeneration !== undefined && mintedGeneration !== refFrameEpoch(session)) { + return { admitted: false, reason: 'ref_generation_mismatch' }; + } + + const scope = refFrameScope(session); + if (scope !== 'all') { + if (mintedGeneration === undefined) { + return { admitted: false, reason: 'plain_ref_requires_complete_frame' }; + } + if (!scope.has(refBody)) { + return { admitted: false, reason: 'ref_not_issued' }; + } + } + + return { admitted: true }; +} diff --git a/src/daemon/types.ts b/src/daemon/types.ts index 3af693400..419b888fd 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -18,6 +18,9 @@ import type { RecordingScope } from '../contracts/recording-scope.ts'; import type { DeviceInfo, Platform, PlatformSelector } from '../kernel/device.ts'; import type { ExecBackgroundResult, ExecResult } from '../utils/exec.ts'; import type { SnapshotState } from '../kernel/snapshot.ts'; +// Type-only import; erased at runtime. ref-frame.ts imports SessionState from +// here, so this back-edge must stay type-only to avoid a runtime cycle. +import type { RefFrameScope, RefFrameState } from './ref-frame.ts'; import type { TargetAnnotationV1 } from '../replay/target-identity.ts'; import type { ReplayTargetGuardDenotation } from '../replay/target-identity-node.ts'; import type { AppLogFailure, AppLogState } from './app-log-process.ts'; @@ -234,6 +237,21 @@ export type SessionState = { snapshotGeneration?: number; /** 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`. + */ + 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; /** Last broad snapshot safe for Android route-freshness comparisons after interactive snapshots. */ lastComparisonSafeSnapshot?: SnapshotState; androidSnapshotFreshness?: AndroidSnapshotFreshness; From 95a04a888dee1fe72f3ff84d1f9d3e5028124547 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 06:41:29 +0000 Subject: [PATCH 03/14] feat(daemon): wire pre-side-effect frame expiration at the seams (ADR 0014 step 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa --- src/daemon/__tests__/ref-frame.test.ts | 25 +++++++++++ .../handlers/__tests__/interaction.test.ts | 29 +++++++++++- src/daemon/handlers/interaction-ref-policy.ts | 32 ++++++++----- src/daemon/handlers/interaction-runtime.ts | 45 ++++++++++++------- src/daemon/ref-frame.ts | 23 ++++++++++ src/daemon/request-generic-dispatch.ts | 14 +++++- src/daemon/session-snapshot.ts | 4 ++ src/daemon/snapshot-runtime.ts | 18 ++++++++ 8 files changed, 163 insertions(+), 27 deletions(-) diff --git a/src/daemon/__tests__/ref-frame.test.ts b/src/daemon/__tests__/ref-frame.test.ts index 2f48871cf..bc79ef4a3 100644 --- a/src/daemon/__tests__/ref-frame.test.ts +++ b/src/daemon/__tests__/ref-frame.test.ts @@ -1,7 +1,9 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import { + activateRefFrame, admitRefMutation, + expireRefFrame, refFrameEpoch, refFrameScope, refFrameState, @@ -90,3 +92,26 @@ test('generation mismatch is evaluated before issuance scope', () => { 'ref_generation_mismatch', ); }); + +test('expireRefFrame is idempotent and rejects all refs while expired', () => { + const s = session({ snapshotGeneration: 42 }); + expireRefFrame(s); + assert.equal(refFrameState(s), 'expired'); + expireRefFrame(s); // idempotent + assert.equal(refFrameState(s), 'expired'); + assert.equal( + reason(admitRefMutation({ session: s, refBody: 'e1', mintedGeneration: 42 })), + 'ref_frame_expired', + ); +}); + +test('activateRefFrame re-authorizes a complete frame after expiry', () => { + const s = session({ snapshotGeneration: 42, refFrameScope: new Set(['e1']) }); + expireRefFrame(s); + activateRefFrame(s); + assert.equal(refFrameState(s), 'active'); + assert.equal(refFrameScope(s), 'all'); + assert.deepEqual(admitRefMutation({ session: s, refBody: 'e9', mintedGeneration: undefined }), { + admitted: true, + }); +}); diff --git a/src/daemon/handlers/__tests__/interaction.test.ts b/src/daemon/handlers/__tests__/interaction.test.ts index 6d8842233..01a538abb 100644 --- a/src/daemon/handlers/__tests__/interaction.test.ts +++ b/src/daemon/handlers/__tests__/interaction.test.ts @@ -6,7 +6,11 @@ import type { CommandFlags } from '../../../core/dispatch.ts'; import { attachRefs, type SnapshotBackend } from '../../../kernel/snapshot.ts'; import { AppError } from '../../../kernel/errors.ts'; import { buildSnapshotState } from '../snapshot-capture.ts'; -import { setSessionSnapshot, STALE_SNAPSHOT_REFS_WARNING } from '../../session-snapshot.ts'; +import { + markSessionSnapshotRefsIssued, + setSessionSnapshot, + STALE_SNAPSHOT_REFS_WARNING, +} from '../../session-snapshot.ts'; import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; import { makeIosSession, @@ -3293,6 +3297,29 @@ test('press selector then press @ref rejects refs that outlived the stored snaps expect(mockDispatch).toHaveBeenCalledTimes(dispatchCallsBeforeStaleRef); }); +test('a ref press crosses the ADR 0014 side-effect seam and expires the ref frame', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'seam-expiry'; + const session = makeStaleRefSession(sessionName); + sessionStore.set(sessionName, session); + mockDispatch.mockImplementation(async (_device, command) => + command === 'snapshot' ? { nodes: makeTwoButtonNodes(), backend: 'xctest' } : {}, + ); + + // A freshly issued frame is active (undefined === active). + expect(sessionStore.get(sessionName)?.refFrameState).toBeUndefined(); + + const press = await runInteraction(sessionStore, sessionName, 'press', ['@e1']); + expect(press?.ok).toBe(true); + // The transition is wired at the leaf seam even though iOS enforcement of the + // expired-frame rejection is deferred to a later migration step. + expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); + + // Re-issuing a complete snapshot re-authorizes the frame. + markSessionSnapshotRefsIssued(sessionStore.get(sessionName)!); + expect(sessionStore.get(sessionName)?.refFrameState).toBe('active'); +}); + test('press @ref directly after refs were issued does not warn', async () => { const sessionStore = makeSessionStore(); const sessionName = 'fresh-ref-no-warning'; diff --git a/src/daemon/handlers/interaction-ref-policy.ts b/src/daemon/handlers/interaction-ref-policy.ts index 874fd013b..c2de1b5d3 100644 --- a/src/daemon/handlers/interaction-ref-policy.ts +++ b/src/daemon/handlers/interaction-ref-policy.ts @@ -1,20 +1,21 @@ import { publicPlatformString } from '../../kernel/device.ts'; -import { admitRefMutation } from '../ref-frame.ts'; +import { admitRefMutation, refFrameEpoch } from '../ref-frame.ts'; import { STALE_SNAPSHOT_REFS_WARNING } from '../session-snapshot.ts'; import type { DaemonResponse, SessionState } from '../types.ts'; import { errorResponse } from './response.ts'; /** * Mutating through a ref from an older client-visible tree is never safe on iOS - * (#1239). The decision now flows through the ADR 0014 ref-frame admission - * matrix (`admitRefMutation`): a pinned ref whose generation no longer matches - * the frame epoch, an expired frame, or a ref outside a partial issuance scope - * is refused. + * (#1239). The decision consults the ADR 0014 ref-frame admission matrix + * (`admitRefMutation`). * - * Transitional: until frame expiration at the device side-effect seam replaces - * the coarse `snapshotRefsStale` marker (ADR 0014 step 3), a plain ref whose - * operational observation was reindexed by an internal capture is also refused - * on iOS. The external error contract (code, message, hint) is unchanged. + * Migration status: ADR 0014 step 3 wires frame expiration at the device + * side-effect seam, but ENFORCING the new expired-frame rejection is deferred to + * step 7, which the ADR gates on fresh live device evidence per platform. Until + * then this guard enforces exactly the pre-existing conditions — a pinned + * generation mismatch and the coarse plain-ref stale marker — so a pinned + * mismatch currently masked by an (unenforced) expiry is still rejected. The + * external error contract (code, message, hint) is unchanged. */ export function staleIosRefGuardResponse(params: { session: SessionState; @@ -30,9 +31,20 @@ export function staleIosRefGuardResponse(params: { refBody, mintedGeneration: params.mintedGeneration, }); + + const pinnedMismatch = + params.mintedGeneration !== undefined && + params.mintedGeneration !== refFrameEpoch(params.session); + // Issuance-scope rejections (partial frames) are enforced now; they are inert + // until a later step publishes non-`all` scopes. + const scopeRejected = + !admission.admitted && + (admission.reason === 'plain_ref_requires_complete_frame' || + admission.reason === 'ref_not_issued'); const coarsePlainStale = params.mintedGeneration === undefined && params.session.snapshotRefsStale === true; - if (admission.admitted && !coarsePlainStale) return null; + + if (!pinnedMismatch && !scopeRejected && !coarsePlainStale) return null; return errorResponse('COMMAND_FAILED', `Ref ${params.ref} not found or has no bounds`, { hint: params.staleRefsWarning ?? STALE_SNAPSHOT_REFS_WARNING, diff --git a/src/daemon/handlers/interaction-runtime.ts b/src/daemon/handlers/interaction-runtime.ts index 50d83044f..dd7a07e49 100644 --- a/src/daemon/handlers/interaction-runtime.ts +++ b/src/daemon/handlers/interaction-runtime.ts @@ -13,6 +13,7 @@ import { createAgentDevice } from '../../runtime.ts'; import { AppError } from '../../kernel/errors.ts'; import type { SessionState } from '../types.ts'; import { setSessionSnapshot } from '../session-snapshot.ts'; +import { expireRefFrame } from '../ref-frame.ts'; import type { InteractionHandlerParams } from './interaction-common.ts'; import type { CaptureSnapshotForSession } from './interaction-snapshot.ts'; import { createDaemonRuntimePolicy } from '../runtime-policy.ts'; @@ -70,8 +71,11 @@ function createInteractionBackend( session.device, params.contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath), ), - tap: async (_context, point): Promise => - toBackendActionResult( + tap: async (_context, point): Promise => { + // ADR 0014 side-effect seam: the point is resolved; expire the ref frame + // synchronously before dispatching so a later step cannot reuse it. + expireRefFrame(session); + return toBackendActionResult( await dispatchCommand( session.device, 'press', @@ -79,15 +83,18 @@ function createInteractionBackend( req.flags?.out, params.contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath), ), - ), + ); + }, tapTarget: webProvider?.clickRef ? async (_context, target): Promise => { + expireRefFrame(session); await webProvider.clickRef?.(target.ref); return { ref: stripAtPrefix(target.ref) }; } : undefined, - fill: async (_context, point, text): Promise => - toBackendActionResult( + fill: async (_context, point, text): Promise => { + expireRefFrame(session); + return toBackendActionResult( await dispatchCommand( session.device, 'fill', @@ -95,9 +102,11 @@ function createInteractionBackend( req.flags?.out, params.contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath), ), - ), + ); + }, fillTarget: webProvider?.fillRef ? async (_context, target, text, options): Promise => { + expireRefFrame(session); await webProvider.fillRef?.(target.ref, text, options); return { ref: stripAtPrefix(target.ref), @@ -106,8 +115,9 @@ function createInteractionBackend( }; } : undefined, - longPress: async (_context, point, options): Promise => - toBackendActionResult( + longPress: async (_context, point, options): Promise => { + expireRefFrame(session); + return toBackendActionResult( await dispatchCommand( session.device, 'longpress', @@ -119,17 +129,21 @@ function createInteractionBackend( req.flags?.out, params.contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath), ), - ), - performGesture: async (_context, plan): Promise => - toBackendActionResult( + ); + }, + performGesture: async (_context, plan): Promise => { + expireRefFrame(session); + return toBackendActionResult( await dispatchGesturePlan( session.device, plan, params.contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath), ), - ), - typeText: async (_context, text): Promise => - toBackendActionResult( + ); + }, + typeText: async (_context, text): Promise => { + expireRefFrame(session); + return toBackendActionResult( await dispatchCommand( session.device, 'type', @@ -137,7 +151,8 @@ function createInteractionBackend( req.flags?.out, params.contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath), ), - ), + ); + }, }; } diff --git a/src/daemon/ref-frame.ts b/src/daemon/ref-frame.ts index 9db6ef286..6363b9e81 100644 --- a/src/daemon/ref-frame.ts +++ b/src/daemon/ref-frame.ts @@ -48,6 +48,29 @@ export function refFrameEpoch(session: SessionState): number | undefined { return session.snapshotGeneration; } +/** + * 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, + * 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 + * rollback. + */ +export function expireRefFrame(session: SessionState): void { + session.refFrameState = 'expired'; +} + +/** + * Re-authorize a complete frame when a response issues its refs to the client + * (ADR 0014). This is the only transition that restores plain-ref mutation after + * an expiry; internal read captures never call it. The complete namespace has + * scope `all`. + */ +export function activateRefFrame(session: SessionState): void { + session.refFrameState = 'active'; + session.refFrameScope = undefined; +} + export function refFrameState(session: SessionState): RefFrameState { return session.refFrameState ?? 'active'; } diff --git a/src/daemon/request-generic-dispatch.ts b/src/daemon/request-generic-dispatch.ts index d43ffcd93..359b77add 100644 --- a/src/daemon/request-generic-dispatch.ts +++ b/src/daemon/request-generic-dispatch.ts @@ -24,7 +24,11 @@ import { } from './recording-gestures.ts'; import { markPostGestureStabilization } from './post-gesture-stabilization.ts'; import { normalizeError } from '../kernel/errors.ts'; -import { shouldGuardAndroidBlockingDialog } from './daemon-command-registry.ts'; +import { expireRefFrame } from './ref-frame.ts'; +import { + resolveRefFrameEffect, + shouldGuardAndroidBlockingDialog, +} from './daemon-command-registry.ts'; import { isActiveProviderDevice } from '../provider-device-runtime.ts'; import { assertSupportedScreenshotPixelDensity, @@ -59,6 +63,14 @@ export async function dispatchGenericCommand(params: { ...contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath), surface: session.surface, }; + // ADR 0014 side-effect seam for generic-route leaves (back/home/rotate/scroll/ + // tv-remote/app-switcher/viewport/focus, ...). The daemon effect classification + // is the honesty guard that decides which of these mutate; expire the frame + // before dispatching so a later ref cannot reuse it. Read-only generic leaves + // (screenshot) are classified `preserve` and leave the frame untouched. + if (resolveRefFrameEffect(req) === 'may-invalidate') { + expireRefFrame(session); + } let data = await executeGenericPlatformCommand({ session, sessionName: params.sessionName, diff --git a/src/daemon/session-snapshot.ts b/src/daemon/session-snapshot.ts index 57d20f020..b515854e0 100644 --- a/src/daemon/session-snapshot.ts +++ b/src/daemon/session-snapshot.ts @@ -1,5 +1,6 @@ import { randomInt } from 'node:crypto'; import type { SnapshotState } from '../kernel/snapshot.ts'; +import { activateRefFrame } from './ref-frame.ts'; import type { SessionState } from './types.ts'; /** @@ -73,6 +74,9 @@ export function nextSnapshotGeneration(current: number | undefined): number { /** The response being returned hands the stored snapshot's refs to the client. */ export function markSessionSnapshotRefsIssued(session: SessionState): void { session.snapshotRefsStale = false; + // ADR 0014: issuing refs to the client (re-)authorizes a complete frame, so a + // fresh capture between mutations restores usability after a side-effect expiry. + activateRefFrame(session); } /** diff --git a/src/daemon/snapshot-runtime.ts b/src/daemon/snapshot-runtime.ts index 50bb759b7..fc78a3673 100644 --- a/src/daemon/snapshot-runtime.ts +++ b/src/daemon/snapshot-runtime.ts @@ -264,6 +264,11 @@ function buildNextSnapshotSession(params: { nextSession.snapshotGeneration = keepCurrentSnapshot ? current?.snapshotGeneration : nextSnapshotGeneration(current?.snapshotGeneration); + nextSession.refFrameState = resolveNextRefFrameState({ + current, + keepCurrentSnapshot, + issuesRefsToClient: params.issuesRefsToClient, + }); if (record.appName) nextSession.appName = record.appName; return nextSession; } @@ -282,6 +287,19 @@ function shouldKeepCurrentSnapshot( ); } +// ADR 0014: a snapshot command hands the client the complete ref namespace, so +// it re-authorizes a complete frame (recovering usability after a side-effect +// expiry). A diff (summary response) or a kept tree preserves the prior +// authorization state that buildSnapshotSession carried over. +function resolveNextRefFrameState(params: { + current: SessionState | undefined; + keepCurrentSnapshot: boolean; + issuesRefsToClient: boolean; +}): SessionState['refFrameState'] { + if (!params.keepCurrentSnapshot && params.issuesRefsToClient) return 'active'; + return params.current?.refFrameState; +} + function resolveNextSnapshotScopeSource(params: { current: SessionState | undefined; keepCurrentSnapshot: boolean; From 8a4d5e407c1ae5489e893e2b7afa676b2eeb268d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 10:18:32 +0000 Subject: [PATCH 04/14] =?UTF-8?q?fix(daemon):=20address=20ADR=200014=20rev?= =?UTF-8?q?iew=20=E2=80=94=20partial=20issuance,=20keyboard,=20seam=20cove?= =?UTF-8?q?rage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exact-head review found three blockers; all fixed with focused seam tests. 1. Partial issuance no longer restores complete authority. Every caller of `markSessionSnapshotRefsIssued` (find, settled diff, replay divergence) is a PARTIAL publication, but it re-activated a complete `all`-scope frame. It now only clears the coarse marker; complete re-authorization is reserved for the snapshot command (`activateCompleteRefFrame`, from `buildNextSnapshotSession`). 2. Keyboard resolver covers every mutating subaction. keyboard accepts status/get/dismiss/enter/return; only status/get read, so dismiss/enter/return (enter/return dispatch a real return key) are now `may-invalidate`. Alert reads are likewise a named set. Completeness test extended. 3. Remaining step-3 leaf seams wired: the direct iOS selector fused dispatch, the direct `find` focus/type dispatches (find click/fill already delegate through the interaction leaf), and Android blocking-dialog recovery (expire before the recovery tap). Focused seam tests for each prove the frame expires. Enforcement of the expired-frame rejection remains deferred to step 7 behind the ADR's per-platform live-evidence gate; behavior is unchanged. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa --- .../__tests__/ref-frame-effect.test.ts | 6 ++- src/core/command-descriptor/registry.ts | 29 +++++++----- .../android-system-dialog-ref-frame.test.ts | 44 +++++++++++++++++++ src/daemon/__tests__/ref-frame.test.ts | 6 +-- src/daemon/android-system-dialog.ts | 6 +++ src/daemon/handlers/__tests__/find.test.ts | 18 ++++++++ .../handlers/__tests__/interaction.test.ts | 26 ++++++++++- src/daemon/handlers/find.ts | 8 ++++ src/daemon/handlers/interaction-touch.ts | 6 +++ src/daemon/ref-frame.ts | 11 ++--- src/daemon/session-snapshot.ts | 15 ++++--- src/daemon/snapshot-runtime.ts | 31 +++++++------ 12 files changed, 164 insertions(+), 42 deletions(-) create mode 100644 src/daemon/__tests__/android-system-dialog-ref-frame.test.ts diff --git a/src/core/command-descriptor/__tests__/ref-frame-effect.test.ts b/src/core/command-descriptor/__tests__/ref-frame-effect.test.ts index cee341bcd..37a14577a 100644 --- a/src/core/command-descriptor/__tests__/ref-frame-effect.test.ts +++ b/src/core/command-descriptor/__tests__/ref-frame-effect.test.ts @@ -60,10 +60,14 @@ test('app-switcher no longer bypasses classification (ADR 0014 escape hatch)', ( }); test('resolver commands classify per selected subaction', () => { - // keyboard: status probe preserves, dismiss mutates. + // keyboard: status/get probes preserve; dismiss/enter/return mutate. enter and + // return dispatch a real return key, so they must NOT be preserve. assert.equal(resolveRefFrameEffect(makeRequest('keyboard')), 'preserve'); assert.equal(resolveRefFrameEffect(makeRequest('keyboard', ['status'])), 'preserve'); + assert.equal(resolveRefFrameEffect(makeRequest('keyboard', ['get'])), 'preserve'); assert.equal(resolveRefFrameEffect(makeRequest('keyboard', ['dismiss'])), 'may-invalidate'); + assert.equal(resolveRefFrameEffect(makeRequest('keyboard', ['enter'])), 'may-invalidate'); + assert.equal(resolveRefFrameEffect(makeRequest('keyboard', ['return'])), 'may-invalidate'); // alert: get/wait read, accept/dismiss mutate. assert.equal(resolveRefFrameEffect(makeRequest('alert')), 'preserve'); assert.equal(resolveRefFrameEffect(makeRequest('alert', ['get'])), 'preserve'); diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index ecf87cfbe..58a1d77a4 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -85,18 +85,27 @@ const isShardedTestRequest = (req: DaemonRequest): boolean => req.command === 'test' && (typeof req.flags?.shardAll === 'number' || typeof req.flags?.shardSplit === 'number'); -// ADR 0014 request-sensitive ref-frame resolvers. `keyboard status` and -// `alert get`/`wait` are read-only status probes that preserve the frame, while -// `keyboard dismiss` and `alert accept`/`dismiss` cross a device side effect. -// The action is the leading positional (see keyboard/alert daemon writers in -// src/commands/system/index.ts and src/commands/capture/alert.ts). +// ADR 0014 request-sensitive ref-frame resolvers. The action is the leading +// positional (see keyboard/alert daemon writers in src/commands/system/index.ts +// and src/commands/capture/alert.ts). Only the read-only status probes preserve +// the frame; every mutating subaction crosses a device side effect. +// +// keyboard actions are status/get/dismiss/enter/return (src/commands/system/ +// runtime/system.ts): status/get inspect, while dismiss hides the keyboard and +// enter/return dispatch a real return key. Anything other than a read is +// classified may-invalidate (the honest superset for unknown subactions). +const KEYBOARD_READ_ONLY_ACTIONS = new Set(['status', 'get']); const keyboardRefFrameEffect = (req: DaemonRequest): RefFrameEffect => - (req.positionals?.[0] ?? 'status').toLowerCase() === 'dismiss' ? 'may-invalidate' : 'preserve'; + KEYBOARD_READ_ONLY_ACTIONS.has((req.positionals?.[0] ?? 'status').toLowerCase()) + ? 'preserve' + : 'may-invalidate'; -const alertRefFrameEffect = (req: DaemonRequest): RefFrameEffect => { - const action = (req.positionals?.[0] ?? 'get').toLowerCase(); - return action === 'accept' || action === 'dismiss' ? 'may-invalidate' : 'preserve'; -}; +// alert actions are get/wait/accept/dismiss: get/wait read, accept/dismiss act. +const ALERT_READ_ONLY_ACTIONS = new Set(['get', 'wait']); +const alertRefFrameEffect = (req: DaemonRequest): RefFrameEffect => + ALERT_READ_ONLY_ACTIONS.has((req.positionals?.[0] ?? 'get').toLowerCase()) + ? 'preserve' + : 'may-invalidate'; // --------------------------------------------------------------------------- // Capability matrices — platform/kind buckets, copied VERBATIM from diff --git a/src/daemon/__tests__/android-system-dialog-ref-frame.test.ts b/src/daemon/__tests__/android-system-dialog-ref-frame.test.ts new file mode 100644 index 000000000..ecac49847 --- /dev/null +++ b/src/daemon/__tests__/android-system-dialog-ref-frame.test.ts @@ -0,0 +1,44 @@ +import { test, expect, vi } from 'vitest'; + +// ADR 0014: Android blocking-dialog recovery is itself device-mutating, so its +// recovery tap must cross the side-effect seam and expire the ref frame. +vi.mock('../../platforms/android/snapshot.ts', () => ({ snapshotAndroid: vi.fn() })); +vi.mock('../../platforms/android/adb.ts', () => ({ runAndroidAdb: vi.fn() })); + +import { snapshotAndroid } from '../../platforms/android/snapshot.ts'; +import { runAndroidAdb } from '../../platforms/android/adb.ts'; +import { recoverAndroidBlockingSystemDialog } from '../android-system-dialog.ts'; +import { makeAndroidSession } from '../../__tests__/test-utils/session-factories.ts'; + +test('android blocking-dialog recovery expires the ref frame before its recovery tap', async () => { + const dialog = { + index: 0, + type: 'TextView', + label: "App isn't responding", + rect: { x: 0, y: 0, width: 300, height: 60 }, + }; + const closeApp = { + index: 1, + type: 'Button', + label: 'Close app', + hittable: true, + rect: { x: 20, y: 200, width: 120, height: 44 }, + }; + // First read: the blocking dialog is present (drives recovery). Post-tap poll: + // the dialog is gone, so recovery completes without sleeping through retries. + vi.mocked(snapshotAndroid) + .mockResolvedValueOnce({ nodes: [dialog, closeApp] } as never) + .mockResolvedValue({ nodes: [] } as never); + vi.mocked(runAndroidAdb).mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' } as never); + + const session = makeAndroidSession('anr-recovery'); + session.recording = { outPath: '/tmp/anr.mp4', startedAt: 0 } as never; + expect(session.refFrameState).toBeUndefined(); // 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(result.status).not.toBe('absent'); +}); diff --git a/src/daemon/__tests__/ref-frame.test.ts b/src/daemon/__tests__/ref-frame.test.ts index bc79ef4a3..60403801b 100644 --- a/src/daemon/__tests__/ref-frame.test.ts +++ b/src/daemon/__tests__/ref-frame.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import { - activateRefFrame, + activateCompleteRefFrame, admitRefMutation, expireRefFrame, refFrameEpoch, @@ -105,10 +105,10 @@ test('expireRefFrame is idempotent and rejects all refs while expired', () => { ); }); -test('activateRefFrame re-authorizes a complete frame after expiry', () => { +test('activateCompleteRefFrame re-authorizes a complete frame after expiry', () => { const s = session({ snapshotGeneration: 42, refFrameScope: new Set(['e1']) }); expireRefFrame(s); - activateRefFrame(s); + activateCompleteRefFrame(s); assert.equal(refFrameState(s), 'active'); assert.equal(refFrameScope(s), 'all'); assert.deepEqual(admitRefMutation({ session: s, refBody: 'e9', mintedGeneration: undefined }), { diff --git a/src/daemon/android-system-dialog.ts b/src/daemon/android-system-dialog.ts index 71e0742c4..c02d675a3 100644 --- a/src/daemon/android-system-dialog.ts +++ b/src/daemon/android-system-dialog.ts @@ -11,6 +11,7 @@ import { AppError } from '../kernel/errors.ts'; import { centerOfRect, attachRefs, type SnapshotNode } from '../kernel/snapshot.ts'; import { sleep } from '../utils/timeouts.ts'; import { pruneGroupNodes } from '../snapshot/snapshot-processing.ts'; +import { expireRefFrame } from './ref-frame.ts'; import type { SessionState } from './types.ts'; const ANDROID_BLOCKING_MODAL_PATTERN = /\bis(?:n(?:'|'|')?t| not)\s+responding\b/i; @@ -275,6 +276,11 @@ async function tapAndroidDialogButton( return { ok: false, exitCode: 1, stdout: '', stderr: 'button has no rect' }; } const { x, y } = centerOfRect(button.rect); + // ADR 0014 side-effect seam: blocking-dialog recovery is itself device + // mutating. Expire the frame before the recovery tap (the first recovery side + // effect), even when invoked from apparent readiness work, so a ref action + // cannot continue against the recovered UI. + expireRefFrame(session); const result = await runAndroidAdb( session.device, ['shell', 'input', 'tap', String(Math.round(x)), String(Math.round(y))], diff --git a/src/daemon/handlers/__tests__/find.test.ts b/src/daemon/handlers/__tests__/find.test.ts index 2cbe13e2f..2730bb3db 100644 --- a/src/daemon/handlers/__tests__/find.test.ts +++ b/src/daemon/handlers/__tests__/find.test.ts @@ -75,6 +75,24 @@ async function runFindClickScenario(options: { return { response: response!, invokeCalls, session }; } +test('mutating find focus crosses the ADR 0014 side-effect seam and expires the ref frame', async () => { + // find focus/type dispatch the device command directly (they do NOT re-enter + // the interaction leaf like find click/fill), so the seam must live in find. + const node = { + index: 0, + type: 'Button', + label: 'Save', + hittable: true, + rect: { x: 10, y: 20, width: 100, height: 40 }, + }; + const { response, session } = await runFindClickScenario({ + positionals: ['Save', 'focus'], + nodes: [node], + }); + expect(response.ok).toBe(true); + expect(session.refFrameState).toBe('expired'); +}); + test('handleFindCommands click returns deterministic metadata across locator variants', async () => { const hittableParentNoRect = { index: 0, type: 'View', hittable: true, depth: 0 }; const nonHittableChildWithRect = { diff --git a/src/daemon/handlers/__tests__/interaction.test.ts b/src/daemon/handlers/__tests__/interaction.test.ts index 01a538abb..579471164 100644 --- a/src/daemon/handlers/__tests__/interaction.test.ts +++ b/src/daemon/handlers/__tests__/interaction.test.ts @@ -3315,9 +3315,31 @@ test('a ref press crosses the ADR 0014 side-effect seam and expires the ref fram // expired-frame rejection is deferred to a later migration step. expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); - // Re-issuing a complete snapshot re-authorizes the frame. + // ADR 0014: a PARTIAL publication (find/settle/divergence, via + // markSessionSnapshotRefsIssued) clears the coarse marker but must NOT + // re-authorize the complete frame — only a complete snapshot does. markSessionSnapshotRefsIssued(sessionStore.get(sessionName)!); - expect(sessionStore.get(sessionName)?.refFrameState).toBe('active'); + expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); +}); + +test('direct iOS selector click crosses the ADR 0014 fused seam and expires the ref frame', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'direct-ios-seam'; + const session = makeStaleRefSession(sessionName); + sessionStore.set(sessionName, session); + mockDispatch.mockImplementation(async (_device, command) => + command === 'snapshot' ? { nodes: makeTwoButtonNodes(), backend: 'xctest' } : {}, + ); + + // click + a simple selector on a non-recording iOS session takes the direct + // iOS selector fast path (no daemon-tree resolution). + const click = await runInteraction(sessionStore, sessionName, 'click', ['label=Continue']); + expect(click?.ok).toBe(true); + const tookDirectPath = mockDispatch.mock.calls.some( + (call) => (call[4] as { directElementSelector?: unknown })?.directElementSelector !== undefined, + ); + expect(tookDirectPath).toBe(true); + expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); }); test('press @ref directly after refs were issued does not warn', async () => { diff --git a/src/daemon/handlers/find.ts b/src/daemon/handlers/find.ts index 6ff98ab12..15d55c64e 100644 --- a/src/daemon/handlers/find.ts +++ b/src/daemon/handlers/find.ts @@ -7,6 +7,7 @@ import { type FindLocator, } from '../../selectors/find.ts'; import { centerOfRect, type SnapshotState } from '../../kernel/snapshot.ts'; +import { expireRefFrame } from '../ref-frame.ts'; import type { DaemonInvokeFn, DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; import { SessionStore } from '../session-store.ts'; import { contextFromFlags } from '../context.ts'; @@ -599,6 +600,9 @@ async function handleFindType( } const focusResponse = await dispatchFocusForFindMatch(ctx, match); if (!focusResponse.ok) return focusResponse; + // The focus above already crossed the seam; expiry is idempotent, but keep it + // explicit at the type dispatch so it does not rely on the focus-first order. + if (session) expireRefFrame(session); const response = await dispatchCommand(device, 'type', [value], req.flags?.out, { ...contextFromFlags(logPath, req.flags, session?.appBundleId, session?.trace?.outPath), }); @@ -617,6 +621,10 @@ async function dispatchFocusForFindMatch( if (!coords) { return errorResponse('COMMAND_FAILED', 'matched element has no bounds'); } + // ADR 0014 side-effect seam: mutating find focus/type dispatch the device + // command directly (they do not re-enter the interaction leaf), so expire the + // frame here before the device op. Pre-seam guards above preserve the frame. + if (session) expireRefFrame(session); const response = await dispatchCommand( device, 'focus', diff --git a/src/daemon/handlers/interaction-touch.ts b/src/daemon/handlers/interaction-touch.ts index 1f1624558..0dc5f2a1a 100644 --- a/src/daemon/handlers/interaction-touch.ts +++ b/src/daemon/handlers/interaction-touch.ts @@ -65,6 +65,7 @@ import { type AndroidBlockingDialogReadinessResult, } from '../android-system-dialog.ts'; import { staleIosRefGuardResponse } from './interaction-ref-policy.ts'; +import { expireRefFrame } from '../ref-frame.ts'; export async function handleTouchInteractionCommands( params: InteractionHandlerParams & { @@ -401,6 +402,11 @@ async function dispatchDirectIosSelectorInteraction(params: { fallbackPhase, } = params; const actionStartedAt = Date.now(); + // ADR 0014 side-effect seam: the direct iOS selector path fuses its final + // status/target check and mutation into one runner request and consumes no + // ref, so dispatching that fused request is the conservative seam. A later + // not-found/timeout is post-seam and does not restore the frame. + expireRefFrame(session); try { const data = (await dispatchCommand(session.device, command, positionals, handlerParams.req.flags?.out, { diff --git a/src/daemon/ref-frame.ts b/src/daemon/ref-frame.ts index 6363b9e81..5ffe09c11 100644 --- a/src/daemon/ref-frame.ts +++ b/src/daemon/ref-frame.ts @@ -61,12 +61,13 @@ export function expireRefFrame(session: SessionState): void { } /** - * Re-authorize a complete frame when a response issues its refs to the client - * (ADR 0014). This is the only transition that restores plain-ref mutation after - * an expiry; internal read captures never call it. The complete namespace has - * scope `all`. + * Re-authorize a complete frame with scope `all` (ADR 0014). This is the only + * transition that restores plain-ref mutation after an expiry, and it is + * reserved for a COMPLETE namespace publication (the snapshot command). Partial + * publications (`find`, settled diffs, replay divergence) and internal read + * captures never call it, so a partial result cannot restore broad authority. */ -export function activateRefFrame(session: SessionState): void { +export function activateCompleteRefFrame(session: SessionState): void { session.refFrameState = 'active'; session.refFrameScope = undefined; } diff --git a/src/daemon/session-snapshot.ts b/src/daemon/session-snapshot.ts index b515854e0..56c4f42b7 100644 --- a/src/daemon/session-snapshot.ts +++ b/src/daemon/session-snapshot.ts @@ -1,6 +1,5 @@ import { randomInt } from 'node:crypto'; import type { SnapshotState } from '../kernel/snapshot.ts'; -import { activateRefFrame } from './ref-frame.ts'; import type { SessionState } from './types.ts'; /** @@ -71,12 +70,18 @@ export function nextSnapshotGeneration(current: number | undefined): number { return current === undefined ? randomInt(100_000, 1_000_000) : current + 1; } -/** The response being returned hands the stored snapshot's refs to the client. */ +/** + * The response being returned hands the stored snapshot's refs to the client. + * + * ADR 0014: every caller is a PARTIAL publication (`find`, settled diff, replay + * divergence) that exposes only a few refs, so this clears the coarse client + * marker but must NOT re-authorize a complete frame — restoring broad `all`-scope + * mutation authority from a partial result is exactly the ADR hole. Only a + * complete namespace (the snapshot command, via `buildNextSnapshotSession`) + * re-activates the frame; bounded partial issuance scope lands in a later step. + */ export function markSessionSnapshotRefsIssued(session: SessionState): void { session.snapshotRefsStale = false; - // ADR 0014: issuing refs to the client (re-)authorizes a complete frame, so a - // fresh capture between mutations restores usability after a side-effect expiry. - activateRefFrame(session); } /** diff --git a/src/daemon/snapshot-runtime.ts b/src/daemon/snapshot-runtime.ts index fc78a3673..ebd39d59c 100644 --- a/src/daemon/snapshot-runtime.ts +++ b/src/daemon/snapshot-runtime.ts @@ -19,6 +19,7 @@ import { createDaemonRuntimeSessionStore } from './runtime-session.ts'; import { maybeBuildAndroidSnapshotTimeoutFailure } from './android-snapshot-timeout-evidence.ts'; import { summarizeSnapshotDiagnostics } from '../snapshot-diagnostics.ts'; import { nextSnapshotGeneration } from './session-snapshot.ts'; +import { activateCompleteRefFrame } from './ref-frame.ts'; export async function dispatchSnapshotViaRuntime(params: { req: DaemonRequest; @@ -264,11 +265,7 @@ function buildNextSnapshotSession(params: { nextSession.snapshotGeneration = keepCurrentSnapshot ? current?.snapshotGeneration : nextSnapshotGeneration(current?.snapshotGeneration); - nextSession.refFrameState = resolveNextRefFrameState({ - current, - keepCurrentSnapshot, - issuesRefsToClient: params.issuesRefsToClient, - }); + reactivateCompleteFrameIfIssuing(nextSession, keepCurrentSnapshot, params.issuesRefsToClient); if (record.appName) nextSession.appName = record.appName; return nextSession; } @@ -287,17 +284,19 @@ function shouldKeepCurrentSnapshot( ); } -// ADR 0014: a snapshot command hands the client the complete ref namespace, so -// it re-authorizes a complete frame (recovering usability after a side-effect -// expiry). A diff (summary response) or a kept tree preserves the prior -// authorization state that buildSnapshotSession carried over. -function resolveNextRefFrameState(params: { - current: SessionState | undefined; - keepCurrentSnapshot: boolean; - issuesRefsToClient: boolean; -}): SessionState['refFrameState'] { - if (!params.keepCurrentSnapshot && params.issuesRefsToClient) return 'active'; - return params.current?.refFrameState; +// ADR 0014: only a snapshot command hands the client the complete ref namespace, +// so only it re-authorizes a complete frame (recovering usability after a +// side-effect expiry). A diff (summary response) or a kept tree preserves the +// prior authorization state buildSnapshotSession carried over; partial +// publications (find/settled/divergence) never reach this path. +function reactivateCompleteFrameIfIssuing( + session: SessionState, + keepCurrentSnapshot: boolean, + issuesRefsToClient: boolean, +): void { + if (!keepCurrentSnapshot && issuesRefsToClient) { + activateCompleteRefFrame(session); + } } function resolveNextSnapshotScopeSource(params: { From f603ccecb3bb63f0e461ddfa5f910055b1bdfcc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 11:24:03 +0000 Subject: [PATCH 05/14] feat(daemon): cross the seam at every specialized mutating leaf (ADR 0014 step 3 complete) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire expireRefFrame at the remaining may-invalidate leaves so EVERY mutating daemon leaf crosses the side-effect transition, not just the interaction/generic paths: - keyboard dismiss/enter/return, push, trigger-app-event (shared session leaf) — gated by resolveRefFrameEffect so keyboard status/get preserve the frame; - alert accept/dismiss (get/wait preserve, via the alert resolver); - settings mutations; - React Native overlay dismissal; - install / reinstall (deploy op); - open / relaunch — expires the reused session's frame before the launch; - close — expires for uniformity, though a successful close deletes the whole session (and its frame) anyway. Seam tests: keyboard dismiss expires while status preserves (proves the resolver-gated pattern), and RN overlay dismissal expires. Enforcement stays deferred; behavior unchanged. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa --- .../handlers/__tests__/react-native.test.ts | 3 ++ .../session-appstate-input-perf.test.ts | 49 +++++++++++++++++++ src/daemon/handlers/react-native.ts | 4 ++ src/daemon/handlers/session-close.ts | 5 ++ src/daemon/handlers/session-deploy.ts | 5 ++ src/daemon/handlers/session-open.ts | 5 ++ src/daemon/handlers/session.ts | 11 ++++- src/daemon/handlers/snapshot-alert.ts | 9 ++++ src/daemon/handlers/snapshot-settings.ts | 4 ++ 9 files changed, 94 insertions(+), 1 deletion(-) diff --git a/src/daemon/handlers/__tests__/react-native.test.ts b/src/daemon/handlers/__tests__/react-native.test.ts index 51a59bfd2..068bb984a 100644 --- a/src/daemon/handlers/__tests__/react-native.test.ts +++ b/src/daemon/handlers/__tests__/react-native.test.ts @@ -77,6 +77,9 @@ 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(mockDispatchCommand).toHaveBeenCalledWith( expect.objectContaining({ platform: 'apple' }), 'press', diff --git a/src/daemon/handlers/__tests__/session-appstate-input-perf.test.ts b/src/daemon/handlers/__tests__/session-appstate-input-perf.test.ts index ea6e2f352..d2c344ae0 100644 --- a/src/daemon/handlers/__tests__/session-appstate-input-perf.test.ts +++ b/src/daemon/handlers/__tests__/session-appstate-input-perf.test.ts @@ -155,6 +155,55 @@ test('appstate fails when iOS session has no tracked app', async () => { } }); +test('keyboard dismiss crosses the ADR 0014 seam while keyboard status preserves the frame', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'kb'; + const device: SessionState['device'] = { + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }; + mockResolveTargetDevice.mockResolvedValue(device); + mockDispatch.mockResolvedValue({}); + const logPath = path.join(os.tmpdir(), 'daemon.log'); + + // dismiss mutates the device → frame expires. + sessionStore.set(sessionName, makeSession(sessionName, device)); + await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'keyboard', + positionals: ['dismiss'], + flags: {}, + }, + sessionName, + logPath, + sessionStore, + invoke: noopInvoke, + }); + expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); + + // status is a read-only probe → frame preserved (undefined === active). + sessionStore.set(sessionName, makeSession(sessionName, device)); + await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'keyboard', + positionals: ['status'], + flags: {}, + }, + sessionName, + logPath, + sessionStore, + invoke: noopInvoke, + }); + expect(sessionStore.get(sessionName)?.refFrameState).toBeUndefined(); +}); + test('appstate without session on iOS selector returns SESSION_NOT_FOUND', async () => { const sessionStore = makeSessionStore(); const selectedDevice: SessionState['device'] = { diff --git a/src/daemon/handlers/react-native.ts b/src/daemon/handlers/react-native.ts index 5db0adc28..773b377fb 100644 --- a/src/daemon/handlers/react-native.ts +++ b/src/daemon/handlers/react-native.ts @@ -16,6 +16,7 @@ import type { DaemonResponse, SessionState } from '../types.ts'; import { errorResponse, noActiveSessionError, requireCommandSupported } from './response.ts'; import { captureSnapshotForSession } from './interaction-snapshot.ts'; import { finalizeTouchInteraction, type InteractionHandlerParams } from './interaction-common.ts'; +import { expireRefFrame } from '../ref-frame.ts'; import { readSnapshotNodesReferenceFrame } from './interaction-touch-reference-frame.ts'; export async function handleReactNativeCommands( @@ -109,6 +110,9 @@ async function dismissReactNativeOverlayTarget( ): Promise { const { req, sessionStore } = params; const actionStartedAt = Date.now(); + // ADR 0014 side-effect seam: React Native overlay dismissal taps the device; + // the target is already resolved, so expire the frame before the press. + expireRefFrame(session); const data = (await dispatchCommand( session.device, diff --git a/src/daemon/handlers/session-close.ts b/src/daemon/handlers/session-close.ts index ec4214cdc..2aea79e71 100644 --- a/src/daemon/handlers/session-close.ts +++ b/src/daemon/handlers/session-close.ts @@ -21,6 +21,7 @@ import { settleIosSimulator, } from './session-device-utils.ts'; import { errorResponse } from './response.ts'; +import { expireRefFrame } from '../ref-frame.ts'; import { recordSessionAction } from './handler-utils.ts'; import type { LeaseRegistry } from '../lease-registry.ts'; import { releaseSessionLease } from '../lease-lifecycle.ts'; @@ -271,6 +272,10 @@ async function dispatchTargetedPlatformClose(params: { } } try { + // ADR 0014 side-effect seam: close mutates the device. The frame expires + // here for uniformity, though a successful close deletes the whole session + // (and its frame) in handleCloseCommand's finally, so nothing is restored. + expireRefFrame(session); await dispatchCommand(session.device, 'close', req.positionals ?? [], req.flags?.out, { ...contextFromFlags(logPath, req.flags, session.appBundleId, session.trace?.outPath), }); diff --git a/src/daemon/handlers/session-deploy.ts b/src/daemon/handlers/session-deploy.ts index 246bc2f44..031fc6d03 100644 --- a/src/daemon/handlers/session-deploy.ts +++ b/src/daemon/handlers/session-deploy.ts @@ -9,6 +9,7 @@ import { resolveDeployResultTarget } from '../../contracts/result-serialization. import { withSuccessText } from '../../utils/success-text.ts'; import { requireSessionOrExplicitSelector, resolveCommandDevice } from './session-device-utils.ts'; import { errorResponse, requireCommandSupported } from './response.ts'; +import { expireRefFrame } from '../ref-frame.ts'; export type ReinstallOps = { ios: (device: DeviceInfo, app: string, appPath: string) => Promise<{ bundleId: string }>; @@ -154,6 +155,10 @@ export async function handleAppDeployCommand(params: { const unsupported = requireCommandSupported(command, device); if (unsupported) return unsupported; + // ADR 0014 side-effect seam: install/reinstall replace the app binary and + // can replace the visible surface; expire the frame before the deploy op. + if (session) expireRefFrame(session); + const result = isIosFamily(device) ? buildIosDeployResult(app, appPath, await deployOps.ios(device, app, appPath)) : buildAndroidDeployResult(app, appPath, await deployOps.android(device, app, appPath)); diff --git a/src/daemon/handlers/session-open.ts b/src/daemon/handlers/session-open.ts index f699063bb..f0a19ac40 100644 --- a/src/daemon/handlers/session-open.ts +++ b/src/daemon/handlers/session-open.ts @@ -43,6 +43,7 @@ import { validateResolvedOpenRequest, } from './session-open-prepare.ts'; import { errorResponse } from './response.ts'; +import { expireRefFrame } from '../ref-frame.ts'; import { buildSessionRecoveryHint } from '../session-recovery-hints.ts'; import { isImplicitSessionScopeConflict, @@ -298,6 +299,10 @@ async function completeOpenCommand(params: { return provisionalSession.response; } const openDispatchSession = provisionalSession.session ?? existingSession; + // ADR 0014 side-effect seam: open/relaunch against an existing session replaces + // its visible surface. Expire that session's frame before the first + // close/launch dispatch; a fresh first open has no prior frame to expire. + if (openDispatchSession) expireRefFrame(openDispatchSession); await dispatchCommand(device, 'open', openPositionals, req.flags?.out, { ...contextFromFlags(logPath, req.flags, sessionAppBundleId), ...(collapseSimulatorRelaunch ? { terminateRunningApp: true } : {}), diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 93e3d9f17..af383f2a1 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -37,7 +37,8 @@ import { handleSessionStateCommands } from './session-state.ts'; import { handleSessionObservabilityCommands } from './session-observability.ts'; import { handleSessionReplayCommands } from './session-replay.ts'; import { handleDoctorCommand } from './session-doctor.ts'; -import { getSessionCommandKind } from '../daemon-command-registry.ts'; +import { getSessionCommandKind, resolveRefFrameEffect } from '../daemon-command-registry.ts'; +import { expireRefFrame } from '../ref-frame.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { PREPARE_REQUEST_TIMEOUT_MS } from '../../core/command-descriptor/timeout-policy.ts'; import { Deadline } from '../../utils/retry.ts'; @@ -202,6 +203,14 @@ async function runSessionOrSelectorDispatch(params: { const unsupported = requireCommandSupported(command, device); if (unsupported) return unsupported; + // ADR 0014 side-effect seam for session/selector-route leaves (keyboard + // dismiss/enter/return, push, trigger-app-event). Expire the frame before the + // dispatch when the classification says this request mutates; keyboard + // status/get resolve to `preserve` and leave the frame untouched. + if (session && resolveRefFrameEffect(req) === 'may-invalidate') { + expireRefFrame(session); + } + const result = await dispatchCommand(device, command, positionals, req.flags?.out, { ...contextFromFlags(logPath, req.flags, session?.appBundleId, session?.trace?.outPath), }); diff --git a/src/daemon/handlers/snapshot-alert.ts b/src/daemon/handlers/snapshot-alert.ts index a0a3986d1..f3e44c327 100644 --- a/src/daemon/handlers/snapshot-alert.ts +++ b/src/daemon/handlers/snapshot-alert.ts @@ -15,6 +15,8 @@ import { SessionStore } from '../session-store.ts'; import { buildAppleRunnerRequestOptions } from '../apple-runner-options.ts'; import { recordIfSession } from './snapshot-session.ts'; import { parseTimeout } from '../../utils/parse-timeout.ts'; +import { resolveRefFrameEffect } from '../daemon-command-registry.ts'; +import { expireRefFrame } from '../ref-frame.ts'; import { errorResponse, requireCommandSupported } from './response.ts'; type HandleAlertCommandParams = { @@ -48,6 +50,13 @@ export async function handleAlertCommand( })(); const unsupported = requireCommandSupported('alert', device); if (unsupported) return unsupported; + // ADR 0014 side-effect seam: alert accept/dismiss act on the device; get/wait + // are read-only. The alert resolver returns `may-invalidate` only for the + // acting subactions, so this covers both the Android and native accept/dismiss + // mutations without touching the read paths. + if (session && resolveRefFrameEffect(req) === 'may-invalidate') { + expireRefFrame(session); + } if (device.platform === 'android') { const timeoutMs = parseTimeout(req.positionals?.[1]) ?? DEFAULT_TIMEOUT_MS; return recordAlertResponse( diff --git a/src/daemon/handlers/snapshot-settings.ts b/src/daemon/handlers/snapshot-settings.ts index 980102332..079f273b5 100644 --- a/src/daemon/handlers/snapshot-settings.ts +++ b/src/daemon/handlers/snapshot-settings.ts @@ -10,6 +10,7 @@ import { SessionStore } from '../session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; import { recordIfSession } from './snapshot-session.ts'; import { errorResponse, requireCommandSupported, type DaemonFailureResponse } from './response.ts'; +import { expireRefFrame } from '../ref-frame.ts'; type ParsedSettingsArgs = { setting: string; @@ -100,6 +101,9 @@ export async function handleSettingsCommand( : setting === 'location' && state === 'set' ? [setting, state, latitude ?? '', longitude ?? '', appBundleId ?? ''] : [setting, state, appBundleId ?? '']; + // ADR 0014 side-effect seam: a settings mutation changes device state; expire + // the frame before the dispatch (settings is always classified may-invalidate). + if (session) expireRefFrame(session); const data = await dispatchCommand(device, 'settings', positionals, req.flags?.out, { ...contextFromFlags(logPath, req.flags, appBundleId, session?.trace?.outPath), }); From 9e0f830d2db9c684e2367b94642411f516739eca Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 11:45:19 +0000 Subject: [PATCH 06/14] feat(daemon): partial issuance scope + MCP pin retention + pinned CLI refs (ADR 0014 step 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A find/settled-diff/divergence result publishes only the refs it returned, so it now activates a bounded PARTIAL frame authorizing exactly those ref bodies (`markSessionPartialRefsIssued`) instead of nothing — a plain ref then requires a complete frame and a pinned ref outside the set is rejected. An empty partial result leaves prior authority intact. - read-only find publishes its one ref; settled diff publishes its added lines + `refs` + `tail`; divergence publishes its capped, non-covered, non-chrome digest set. - MCP: a mutating `find` returns no `refsGeneration` and is explicitly non-issuing — it no longer hits the missing-generation branch that wiped the whole per-session pin scope (forwarding the old pin is how the daemon produces a precise stale rejection). - Human-CLI partial results render reusable refs in ready-to-copy `@eN~s` form (find + settled tail); JSON/Node keep plain bodies + one response-level generation, and MCP stays plain (it auto-pins). Output-economy waiver covers the +8-byte tail-pin increase with an ADR justification; the workflow oracle treats a pinned ref as surfacing its plain body. Enforcement of the frame's expiry and partial-scope rejections stays deferred to step 7 (behind the ADR's per-platform live-evidence gate), so this is behavior-preserving; the iOS guard now consumes the admission verdict for a typed `details.reason` on the rejections it already emitted. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa --- src/commands/interaction/output.ts | 20 +++++++++- src/daemon/handlers/interaction-ref-policy.ts | 15 +++---- src/daemon/handlers/interaction-touch.ts | 17 +++++++- .../handlers/session-replay-divergence.ts | 17 +++++++- src/daemon/selector-runtime.ts | 6 ++- src/daemon/session-snapshot.ts | 39 ++++++++++++++++--- src/mcp/command-tools.ts | 6 +++ .../output-economy.baseline.json | 4 +- .../output-economy.waivers.json | 11 +++++- test/output-economy/routine-workflow.ts | 9 ++++- 10 files changed, 120 insertions(+), 24 deletions(-) diff --git a/src/commands/interaction/output.ts b/src/commands/interaction/output.ts index 8324f9e74..99f40c478 100644 --- a/src/commands/interaction/output.ts +++ b/src/commands/interaction/output.ts @@ -14,6 +14,17 @@ function getCliOutput(params: { result: CommandRequestResult; format?: string }) return defaultCommandCliOutput(data); } +// ADR 0014: a reusable ref in a PARTIAL result renders in ready-to-copy +// `@eN~s` form so a human CLI caller can paste it into the next +// mutation without a separate pin step. A mutating result carries no +// `refsGeneration`, so its acted ref is never pinned. +function pinnedRefText(ref: unknown, refsGeneration: unknown): string | undefined { + if (typeof ref !== 'string' || ref.length === 0) return undefined; + if (typeof refsGeneration !== 'number') return undefined; + const body = ref.startsWith('@') ? ref.slice(1) : ref; + return `@${body}~s${refsGeneration}`; +} + function findCliOutput(result: CommandRequestResult): CliOutput { const data = result as Record; // Interactive find actions (click/fill/focus/type) carry the same success message as @@ -21,6 +32,9 @@ function findCliOutput(result: CommandRequestResult): CliOutput { const message = readCommandMessage(data); if (message) return { data, text: message }; if (typeof data.text === 'string') return { data, text: data.text }; + // A read-only find that returns a reusable ref renders it pinned (ADR 0014). + const pinned = pinnedRefText(data.ref, data.refsGeneration); + if (pinned) return { data, text: `Found: ${pinned}` }; if (typeof data.found === 'boolean') return { data, text: `Found: ${data.found}` }; if (data.node) return { data, text: JSON.stringify(data.node, null, 2) }; return defaultCommandCliOutput(data); @@ -64,6 +78,7 @@ type SettleTextView = { }; tail?: Array<{ ref?: string; role?: string; label?: string }>; tailTruncated?: boolean; + refsGeneration?: number; }; /** @@ -100,7 +115,10 @@ function formatSettleTailLines(view: SettleTextView): string[] { const lines = [`unchanged interactive (${tail.length}):`]; for (const entry of tail) { const label = entry.label ? ` "${entry.label}"` : ''; - lines.push(`= @${entry.ref ?? ''} [${entry.role ?? ''}]${label}`); + // ADR 0014: the settled tail refs are reusable, so render them pinned when + // the settle response carried its generation. + const ref = pinnedRefText(entry.ref, view.refsGeneration) ?? `@${entry.ref ?? ''}`; + lines.push(`= ${ref} [${entry.role ?? ''}]${label}`); } if (view.tailTruncated) { lines.push('… more interactive elements not shown, use snapshot -i'); diff --git a/src/daemon/handlers/interaction-ref-policy.ts b/src/daemon/handlers/interaction-ref-policy.ts index c2de1b5d3..b0d76be84 100644 --- a/src/daemon/handlers/interaction-ref-policy.ts +++ b/src/daemon/handlers/interaction-ref-policy.ts @@ -35,18 +35,19 @@ export function staleIosRefGuardResponse(params: { const pinnedMismatch = params.mintedGeneration !== undefined && params.mintedGeneration !== refFrameEpoch(params.session); - // Issuance-scope rejections (partial frames) are enforced now; they are inert - // until a later step publishes non-`all` scopes. - const scopeRejected = - !admission.admitted && - (admission.reason === 'plain_ref_requires_complete_frame' || - admission.reason === 'ref_not_issued'); const coarsePlainStale = params.mintedGeneration === undefined && params.session.snapshotRefsStale === true; - if (!pinnedMismatch && !scopeRejected && !coarsePlainStale) return null; + // ADR 0014: the frame's expiry (`ref_frame_expired`) and partial-scope + // rejections (`plain_ref_requires_complete_frame`/`ref_not_issued`) in + // `admission` are wired but ENFORCED only from step 7, which the ADR gates on + // fresh live device evidence per platform. Until then keep the pre-existing + // decision: a pinned generation mismatch or the coarse plain-ref stale marker. + if (!pinnedMismatch && !coarsePlainStale) return null; + const reason = admission.admitted ? undefined : admission.reason; return errorResponse('COMMAND_FAILED', `Ref ${params.ref} not found or has no bounds`, { hint: params.staleRefsWarning ?? STALE_SNAPSHOT_REFS_WARNING, + ...(reason ? { reason } : {}), }); } diff --git a/src/daemon/handlers/interaction-touch.ts b/src/daemon/handlers/interaction-touch.ts index 0dc5f2a1a..432abd827 100644 --- a/src/daemon/handlers/interaction-touch.ts +++ b/src/daemon/handlers/interaction-touch.ts @@ -20,7 +20,7 @@ import { asAppError, normalizeError } from '../../kernel/errors.ts'; import type { ReplayTargetGuardDenotation } from '../../replay/target-identity-node.ts'; import type { DaemonResponse, SessionState } from '../types.ts'; import { finalizeTouchInteraction, type InteractionHandlerParams } from './interaction-common.ts'; -import { markSessionSnapshotRefsIssued, resolveRefStalenessWarning } from '../session-snapshot.ts'; +import { markSessionPartialRefsIssued, resolveRefStalenessWarning } from '../session-snapshot.ts'; import { buildInteractionResponseData, type InteractionResponsePayloads, @@ -317,10 +317,23 @@ function settleRefsGenerationIssue( result: PressCommandResult | FillCommandResult | LongPressCommandResult, ): number | undefined { if (!result.settle?.diff) return undefined; - markSessionSnapshotRefsIssued(session); + // ADR 0014: a settled diff publishes the refs it exposed, so it activates a + // PARTIAL frame authorizing exactly those bodies (not the whole tree). + markSessionPartialRefsIssued(session, collectSettleIssuedRefBodies(result.settle)); return session.snapshotGeneration; } +/** The reusable refs a settled diff exposed: added diff lines, `refs`, `tail`. */ +function collectSettleIssuedRefBodies(settle: NonNullable): string[] { + const bodies: string[] = []; + for (const line of settle.diff?.lines ?? []) { + if (line.ref) bodies.push(line.ref); + } + for (const entry of settle.refs ?? []) bodies.push(entry.ref); + for (const entry of settle.tail ?? []) bodies.push(entry.ref); + return bodies; +} + function readLongPressResultDuration(result: TargetedTouchResult): number | undefined { return 'durationMs' in result ? result.durationMs : undefined; } diff --git a/src/daemon/handlers/session-replay-divergence.ts b/src/daemon/handlers/session-replay-divergence.ts index a2ef6dc2f..276ca35a8 100644 --- a/src/daemon/handlers/session-replay-divergence.ts +++ b/src/daemon/handlers/session-replay-divergence.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { dispatchCommand } from '../../core/dispatch.ts'; import { contextFromFlags } from '../context.ts'; -import { markSessionSnapshotRefsIssued, setSessionSnapshot } from '../session-snapshot.ts'; +import { markSessionPartialRefsIssued, setSessionSnapshot } from '../session-snapshot.ts'; import { isSparseSnapshotQualityVerdict } from '../../snapshot/snapshot-quality.ts'; import { displayLabel, formatRole } from '../../snapshot/snapshot-lines.ts'; import { redactDiagnosticData } from '../../kernel/redaction.ts'; @@ -230,7 +230,20 @@ export async function captureDivergenceObservation(params: { }; } setSessionSnapshot(session, snapshot); - markSessionSnapshotRefsIssued(session); + // ADR 0014: the divergence screen digest publishes only its capped, + // non-covered, non-chrome refs — the same set `buildReplayDivergenceScreenRefs` + // renders. Activate a PARTIAL frame authorizing exactly those bodies. + const chromeRefs = collectSettleChromeRefs(snapshot.nodes, session.appBundleId); + const digestBodies = snapshot.nodes + .filter( + (node) => + node.ref !== undefined && + node.interactionBlocked !== 'covered' && + !chromeRefs.has(node.ref), + ) + .slice(0, SCREEN_REF_CAPTURE_LIMIT) + .map((node) => node.ref as string); + markSessionPartialRefsIssued(session, digestBodies); sessionStore.set(sessionName, session); return { state: 'available', diff --git a/src/daemon/selector-runtime.ts b/src/daemon/selector-runtime.ts index 3bb47dc27..36406af6e 100644 --- a/src/daemon/selector-runtime.ts +++ b/src/daemon/selector-runtime.ts @@ -6,7 +6,7 @@ import { runAppleRunnerCommand } from '../platforms/apple/core/runner/runner-cli import { buildAppleRunnerRequestOptions } from './apple-runner-options.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from './types.ts'; import { errorResponse, requireCommandSupported } from './handlers/response.ts'; -import { markSessionSnapshotRefsIssued, resolveRefStalenessWarning } from './session-snapshot.ts'; +import { markSessionPartialRefsIssued, resolveRefStalenessWarning } from './session-snapshot.ts'; import { resolveSessionDevice, withSessionlessRunnerCleanup } from './handlers/snapshot-session.ts'; import { parseFindArgs, type FindAction } from '../selectors/find.ts'; import { splitIsSelectorArgs } from '../selectors/index.ts'; @@ -111,7 +111,9 @@ export async function dispatchFindReadOnlyViaRuntime( if (typeof data.ref === 'string') { const session = params.sessionStore.get(params.sessionName); if (session) { - markSessionSnapshotRefsIssued(session); + // ADR 0014: a read-only find publishes exactly its one returned ref, so + // it activates a PARTIAL frame authorizing only that ref body. + markSessionPartialRefsIssued(session, [data.ref]); params.sessionStore.set(params.sessionName, session); if (session.snapshotGeneration !== undefined) { return { ...data, refsGeneration: session.snapshotGeneration }; diff --git a/src/daemon/session-snapshot.ts b/src/daemon/session-snapshot.ts index 56c4f42b7..116529c62 100644 --- a/src/daemon/session-snapshot.ts +++ b/src/daemon/session-snapshot.ts @@ -73,17 +73,44 @@ export function nextSnapshotGeneration(current: number | undefined): number { /** * The response being returned hands the stored snapshot's refs to the client. * - * ADR 0014: every caller is a PARTIAL publication (`find`, settled diff, replay - * divergence) that exposes only a few refs, so this clears the coarse client - * marker but must NOT re-authorize a complete frame — restoring broad `all`-scope - * mutation authority from a partial result is exactly the ADR hole. Only a - * complete namespace (the snapshot command, via `buildNextSnapshotSession`) - * re-activates the frame; bounded partial issuance scope lands in a later step. + * ADR 0014: this clears the coarse client marker but must NOT re-authorize a + * complete frame — restoring broad `all`-scope mutation authority from a partial + * result is the ADR hole. Only a complete namespace (the snapshot command, via + * `buildNextSnapshotSession`) re-activates a complete frame. A partial + * publication that wants to authorize its bounded ref set calls + * {@link markSessionPartialRefsIssued} instead. */ export function markSessionSnapshotRefsIssued(session: SessionState): void { session.snapshotRefsStale = false; } +/** Plain ref body: strip a leading `@` and any `~s` generation suffix. */ +function normalizeRefBody(ref: string): string { + const withoutAt = ref.startsWith('@') ? ref.slice(1) : ref; + const suffix = withoutAt.indexOf('~'); + return suffix === -1 ? withoutAt : withoutAt.slice(0, suffix); +} + +/** + * ADR 0014 partial issuance: a `find`, settled diff, or replay divergence screen + * publishes only the refs it actually returned. It activates a PARTIAL frame that + * authorizes ONLY those ref bodies (scope = the emitted set) at the current + * epoch — a plain ref then requires a complete frame, and a pinned ref outside + * the set is rejected. An empty partial result does not supersede existing + * authority (it leaves the frame untouched), so a useful prior frame survives. + */ +export function markSessionPartialRefsIssued(session: SessionState, refs: Iterable): void { + session.snapshotRefsStale = false; + const scope = new Set(); + for (const ref of refs) { + const body = normalizeRefBody(ref); + if (body.length > 0) scope.add(body); + } + if (scope.size === 0) return; + session.refFrameState = 'active'; + session.refFrameScope = scope; +} + /** * Warning for a ref pinned to a generation (`@e12~s3`) that no longer matches * the stored tree's generation. Unlike STALE_SNAPSHOT_REFS_WARNING it is diff --git a/src/mcp/command-tools.ts b/src/mcp/command-tools.ts index d7d60b232..1b4a7e487 100644 --- a/src/mcp/command-tools.ts +++ b/src/mcp/command-tools.ts @@ -219,6 +219,12 @@ function mergeIssuedRefPins( const record = asOptionalRecord(result); const refsGeneration = record?.refsGeneration; if (record === undefined || typeof refsGeneration !== 'number') { + // ADR 0014: a MUTATING find returns its acted ref as diagnostic pre-action + // identity WITHOUT `refsGeneration` — it is explicitly non-issuing and must + // leave remembered pins untouched (forwarding the old pin on a later ref is + // how the daemon produces a precise stale rejection). Only a snapshot that + // genuinely issued no generation clears the scope. + if (name === 'find') return; refPinsByScope.delete(scopeKey); return; } diff --git a/test/output-economy/output-economy.baseline.json b/test/output-economy/output-economy.baseline.json index 8ccec93bf..5e575e2f4 100644 --- a/test/output-economy/output-economy.baseline.json +++ b/test/output-economy/output-economy.baseline.json @@ -42,7 +42,7 @@ "shape": "$.message:string|$.ref:string|$.settle.captures:number|$.settle.diff.summary.additions:number|$.settle.diff.summary.removals:number|$.settle.diff.summary.unchanged:number|$.settle.diff.summary:object|$.settle.diff:object|$.settle.quietMs:number|$.settle.refs:array|$.settle.refsGeneration:number|$.settle.refs[].ref:string|$.settle.refs[]:object|$.settle.settled:boolean|$.settle.timeoutMs:number|$.settle.waitedMs:number|$.settle:object|$.x:number|$.y:number|$:object" }, "settle-tail.default.text": { - "bytes": 180, + "bytes": 188, "lines": 7, "refs": 4, "hints": 0, @@ -175,7 +175,7 @@ "shape": "text" }, "workflow.mutation-tail.cli.text": { - "bytes": 165, + "bytes": 173, "lines": 6, "refs": 3, "hints": 0, diff --git a/test/output-economy/output-economy.waivers.json b/test/output-economy/output-economy.waivers.json index 0967ef424..903f2df98 100644 --- a/test/output-economy/output-economy.waivers.json +++ b/test/output-economy/output-economy.waivers.json @@ -1 +1,10 @@ -{} +{ + "settle-tail.default.text": { + "bytes": 188, + "reason": "ADR 0014: a settled diff's reusable tail refs render in ready-to-copy pinned @eN~s form so a human CLI caller can paste them straight into the next mutation (+8 bytes for the two tail pins). The response-level refsGeneration stays the token-economical authority for JSON and MCP; only human-CLI text carries the pins." + }, + "workflow.mutation-tail.cli.text": { + "bytes": 173, + "reason": "ADR 0014: the routine-workflow mutation-tail step renders the same pinned tail refs in human-CLI text (+8 bytes). See settle-tail.default.text." + } +} diff --git a/test/output-economy/routine-workflow.ts b/test/output-economy/routine-workflow.ts index d646f1364..8fedc04f2 100644 --- a/test/output-economy/routine-workflow.ts +++ b/test/output-economy/routine-workflow.ts @@ -259,7 +259,14 @@ function workflowSamples(steps: WorkflowStep[]): Record { function refsInSample(sample: EconomySample): string[] { const serialized = 'text' in sample ? sample.text : JSON.stringify(sample.data); - return serialized.match(REF_TOKEN_PATTERN) ?? []; + // ADR 0014: a partial CLI result may render a ready-to-copy pinned ref + // (`@e7~s14`). That still surfaces the plain target `@e7` — an agent pasting + // the pinned form is targeting the same body — so normalize the pin away when + // deciding which refs a response surfaced. + return (serialized.match(REF_TOKEN_PATTERN) ?? []).map((ref) => { + const suffix = ref.indexOf('~'); + return suffix === -1 ? ref : ref.slice(0, suffix); + }); } function measureRoutineWorkflow( From f07fa23343b5b376666604622aa33f46d5a360a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 12:00:26 +0000 Subject: [PATCH 07/14] feat(daemon): resolve refs against the authorized frame tree (ADR 0014 step 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retain the ref frame's immutable source tree (shared reference, no deep copy) and resolve a `@ref` against it rather than the latest operational observation. An Android freshness — or any read-only — capture advances `session.snapshot` without disturbing the frame tree, so the two intentionally diverge. At resolution, adopt the fresh observation's node (its current on-screen coordinates) ONLY when its local identity still matches the authorized node — the legitimate "element moved" case. If a different element now sits at that index, keep the authorized frame node so a positional coincidence cannot retarget the action. Expose the frame tree to the command runtime through `CommandSessionRecord.refFrameSnapshot`; pre-frame sessions fall back to `snapshot` and behave exactly as before. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa --- .../interaction/runtime/resolution.ts | 60 ++++++++++++++-- .../handlers/__tests__/interaction.test.ts | 72 +++++++++++++++++++ src/daemon/ref-frame.ts | 6 ++ src/daemon/runtime-session.ts | 10 ++- src/daemon/session-snapshot.ts | 6 ++ src/daemon/types.ts | 12 ++++ src/runtime-contract.ts | 8 +++ 7 files changed, 168 insertions(+), 6 deletions(-) diff --git a/src/commands/interaction/runtime/resolution.ts b/src/commands/interaction/runtime/resolution.ts index 4323ff56c..440ebd60d 100644 --- a/src/commands/interaction/runtime/resolution.ts +++ b/src/commands/interaction/runtime/resolution.ts @@ -2,7 +2,11 @@ import { AppError } from '../../../kernel/errors.ts'; import type { Point, SnapshotNode, SnapshotState } from '../../../kernel/snapshot.ts'; import { findNodeByRef, normalizeRef } from '../../../kernel/snapshot.ts'; import { resolveRectCenter } from '../../../utils/rect-center.ts'; -import type { AgentDeviceRuntime, CommandContext } from '../../../runtime-contract.ts'; +import type { + AgentDeviceRuntime, + CommandContext, + CommandSessionRecord, +} from '../../../runtime-contract.ts'; import { parseSelectorChain } from '../../../selectors/parse.ts'; import { formatSelectorFailure, @@ -545,16 +549,27 @@ async function resolveSnapshotForRef( const sessionName = options.session ?? 'default'; const session = await runtime.sessions.get(sessionName); if (!session) throw new AppError('SESSION_NOT_FOUND', 'No active session. Run open first.'); - if (!session.snapshot) { + // ADR 0014: a ref resolves against the AUTHORIZED frame tree, not the latest + // operational observation. They share the capture object until a read-only + // capture (e.g. Android freshness) advances `snapshot` alone; from then on the + // frame tree is the identity authority for `@eN`. + const frameTree = session.refFrameSnapshot ?? session.snapshot; + if (!frameTree) { throw new AppError('INVALID_ARGS', 'No snapshot in session. Run snapshot first.'); } const fallbackLabel = target.fallbackLabel ?? ''; - const stored = tryResolveRefNode(session.snapshot.nodes, target.ref, { + const authorized = tryResolveRefNode(frameTree.nodes, target.ref, { fallbackLabel, }); - if (stored) { - return { snapshot: session.snapshot, resolved: stored }; + if (authorized) { + return reconcileFreshObservation({ + session, + frameTree, + target, + fallbackLabel, + authorized, + }); } const capture = await captureInteractionSnapshot(runtime, options, true); @@ -569,6 +584,41 @@ async function resolveSnapshotForRef( return { ...capture, resolved: refreshed }; } +/** + * ADR 0014 step 5: decouple Android freshness from ref authorization. The frame + * tree names WHICH node `@eN` authorizes. When a freshness (or other read-only) + * capture has advanced the operational observation past the frame, adopt the + * observation's node — its fresh on-screen coordinates — ONLY when its local + * identity still matches the authorized node. That covers the legitimate case of + * an element that merely moved. If the identity differs (a different element now + * sits at that index) or the ref is absent from the observation, keep the + * authorized frame node so a positional coincidence cannot retarget the action. + */ +function reconcileFreshObservation(params: { + session: CommandSessionRecord; + frameTree: SnapshotState; + target: Extract; + fallbackLabel: string; + authorized: ResolvedRefNode; +}): CapturedSnapshot & { resolved: ResolvedRefNode } { + const { session, frameTree, target, fallbackLabel, authorized } = params; + const observation = session.snapshot; + if (!observation || observation === frameTree) { + return { snapshot: frameTree, resolved: authorized }; + } + const observed = tryResolveRefNode(observation.nodes, target.ref, { fallbackLabel }); + if ( + observed && + localIdentitiesEqual( + readNodeLocalIdentity(authorized.node), + readNodeLocalIdentity(observed.node), + ) + ) { + return { snapshot: observation, resolved: observed }; + } + return { snapshot: frameTree, resolved: authorized }; +} + /** The runtime-ref resolver: `exact` for a resolved `@ref`, `label-fallback` for trailing-label recovery. */ export function tryResolveRefNode( nodes: SnapshotState['nodes'], diff --git a/src/daemon/handlers/__tests__/interaction.test.ts b/src/daemon/handlers/__tests__/interaction.test.ts index 579471164..9b5b04737 100644 --- a/src/daemon/handlers/__tests__/interaction.test.ts +++ b/src/daemon/handlers/__tests__/interaction.test.ts @@ -1580,6 +1580,78 @@ test('press @ref refreshes Android snapshot when freshness tracking is active', }); }); +test('ADR 0014: Android freshness cannot retarget an admitted ref by positional coincidence', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'android-fresh-ref-no-retarget'; + const session = makeAndroidSession(sessionName); + const frameTree = { + nodes: attachRefs([ + { + index: 0, + type: 'android.widget.Button', + label: 'Continue', + rect: { x: 0, y: 0, width: 40, height: 40 }, + enabled: true, + hittable: true, + }, + ]), + createdAt: Date.now(), + backend: 'android' as const, + comparisonSafe: true, + }; + session.snapshot = frameTree; + // ADR 0014: the authorized frame tree names WHICH node @e1 authorizes. + session.refFrameTree = frameTree; + session.androidSnapshotFreshness = { + action: 'press', + markedAt: Date.now(), + baselineCount: 1, + routeComparable: false, + }; + sessionStore.set(sessionName, session); + + // The freshness refresh returns a DIFFERENT element at @e1's index — after + // navigation the button at that position is now "Cancel", not "Continue". + mockDispatch.mockImplementation(async (_device, command, args) => { + if (command === 'snapshot') { + return { + nodes: [ + { + index: 0, + type: 'android.widget.Button', + label: 'Cancel', + rect: { x: 100, y: 200, width: 80, height: 40 }, + enabled: true, + hittable: true, + }, + ], + backend: 'android', + }; + } + return { pressed: true, args }; + }); + + const response = await handleInteractionCommands({ + req: { + token: 't', + session: sessionName, + command: 'press', + positionals: ['@e1'], + flags: {}, + }, + sessionName, + sessionStore, + contextFromFlags, + }); + + expect(response?.ok).toBe(true); + // The identity at @e1 changed (Continue -> Cancel), so the refreshed + // observation must NOT redefine the target: the press stays on the frame + // node's coordinates (center of {0,0,40,40}), never the fresh (140, 220). + expect(mockDispatch.mock.calls.map((call) => call[1])).toEqual(['snapshot', 'press']); + expect(mockDispatch.mock.calls[1]?.[2]).toEqual(['20', '20']); +}); + test('press @ref falls back to cached Android ref when freshness refresh fails', async () => { const sessionStore = makeSessionStore(); const sessionName = 'android-fresh-ref-refresh-failure'; diff --git a/src/daemon/ref-frame.ts b/src/daemon/ref-frame.ts index 5ffe09c11..b3aeb6e02 100644 --- a/src/daemon/ref-frame.ts +++ b/src/daemon/ref-frame.ts @@ -66,10 +66,16 @@ export function expireRefFrame(session: SessionState): void { * reserved for a COMPLETE namespace publication (the snapshot command). Partial * publications (`find`, settled diffs, replay divergence) and internal read * captures never call it, so a partial result cannot restore broad authority. + * + * Retains the just-published tree (`session.snapshot`) as the frame's immutable + * source tree 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. */ export function activateCompleteRefFrame(session: SessionState): void { session.refFrameState = 'active'; session.refFrameScope = undefined; + session.refFrameTree = session.snapshot; } export function refFrameState(session: SessionState): RefFrameState { diff --git a/src/daemon/runtime-session.ts b/src/daemon/runtime-session.ts index 5ac613d6d..d0d01917c 100644 --- a/src/daemon/runtime-session.ts +++ b/src/daemon/runtime-session.ts @@ -17,7 +17,15 @@ function toRuntimeSessionRecord( name, appBundleId: session.appBundleId, appName: session.appName, - ...(options.includeSnapshot === true ? { snapshot: session.snapshot } : {}), + ...(options.includeSnapshot === true + ? { + snapshot: session.snapshot, + // 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 ? { refFrameSnapshot: session.refFrameTree } : {}), + } + : {}), metadata: { surface: session.surface, ...(options.metadata ?? {}), diff --git a/src/daemon/session-snapshot.ts b/src/daemon/session-snapshot.ts index 116529c62..8c4546aac 100644 --- a/src/daemon/session-snapshot.ts +++ b/src/daemon/session-snapshot.ts @@ -109,6 +109,12 @@ export function markSessionPartialRefsIssued(session: SessionState, refs: Iterab if (scope.size === 0) return; session.refFrameState = 'active'; session.refFrameScope = scope; + // ADR 0014: retain the tree this partial result published from as the frame's + // immutable source (shared reference — the caller already stored it via + // setSessionSnapshot). Interaction guards and replay identity can depend on + // ancestors, siblings, and viewport outside the emitted subset, so the whole + // tree is kept while the issuance set bounds authority. + session.refFrameTree = session.snapshot; } /** diff --git a/src/daemon/types.ts b/src/daemon/types.ts index 419b888fd..91dce48c5 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -252,6 +252,18 @@ export type SessionState = { * `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; /** Last broad snapshot safe for Android route-freshness comparisons after interactive snapshots. */ lastComparisonSafeSnapshot?: SnapshotState; androidSnapshotFreshness?: AndroidSnapshotFreshness; diff --git a/src/runtime-contract.ts b/src/runtime-contract.ts index d15ca3736..e90938941 100644 --- a/src/runtime-contract.ts +++ b/src/runtime-contract.ts @@ -16,6 +16,14 @@ export type CommandSessionRecord = { appName?: string; backendSessionId?: string; snapshot?: SnapshotState; + /** + * ADR 0014 authorized ref-frame source tree. When present it is the tree a + * ref (`@eN`) resolves against for identity, so a newer operational + * observation in `snapshot` (e.g. an Android freshness capture) cannot + * retarget an already-authorized ref by positional coincidence. Absent for + * pre-frame sessions and non-daemon runtimes, which resolve against `snapshot`. + */ + refFrameSnapshot?: SnapshotState; metadata?: Record; }; From eff84e5fa75c9c4f45549934b2e6073d2e57ec36 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 12:40:48 +0000 Subject: [PATCH 08/14] feat(daemon): fail-closed ref-mutation enforcement across platforms (ADR 0014 step 7) Enforce the ref-frame admission matrix on every platform before dispatch: an expired frame, a superseded generation pin, a plain ref against a partial frame, or an unissued pinned ref is now rejected with a typed `details.reason` and an honest message that names the lifetime failure instead of claiming the ref was missing or lacked bounds. The prior iOS-only, coarse-marker guard is replaced. Freeze the frame epoch at issuance (`refFrameGeneration`) so a later read-only capture that advances the observation counter cannot falsely reject a correct pin from the issuing frame; staleness warnings compare against the same frame epoch. A mutating `find` re-resolves its target by locator against a fresh capture, so its internal leaf dispatch carries `internal.findResolvedTarget` and skips ref admission (it still crosses the seam and expires the frame). Update unit and provider-integration scenarios to the new contract: multi-mutation ref sequences re-observe between mutations, settled refs are consumed in pinned form, and rejections assert the typed reason. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa --- .../__tests__/interaction-settle.test.ts | 12 ++- .../handlers/__tests__/interaction.test.ts | 49 +++++++++--- src/daemon/handlers/find.ts | 2 + src/daemon/handlers/interaction-ref-policy.ts | 79 +++++++++++-------- src/daemon/handlers/interaction-touch.ts | 34 ++++---- src/daemon/ref-frame.ts | 11 ++- src/daemon/session-snapshot.ts | 10 ++- src/daemon/types.ts | 19 +++++ .../provider-scenarios/android-find.test.ts | 8 +- .../android-lifecycle.test.ts | 13 ++- .../provider-scenarios/linux-desktop.test.ts | 7 ++ .../settle-observation.test.ts | 16 ++-- .../stale-ref-warning.test.ts | 12 ++- .../provider-scenarios/versioned-refs.test.ts | 72 +++++++---------- .../provider-scenarios/web-desktop.test.ts | 7 ++ 15 files changed, 231 insertions(+), 120 deletions(-) diff --git a/src/daemon/handlers/__tests__/interaction-settle.test.ts b/src/daemon/handlers/__tests__/interaction-settle.test.ts index 0e707ed55..d5a54ad2d 100644 --- a/src/daemon/handlers/__tests__/interaction-settle.test.ts +++ b/src/daemon/handlers/__tests__/interaction-settle.test.ts @@ -260,13 +260,18 @@ test('press --settle on a removals-only diff attaches the unchanged interactive expect(settle.refsGeneration).toBe(session.snapshotGeneration); }); -test('press --settle rejects a stale iOS ref before dispatch or observation', async () => { +test('press --settle rejects an expired-frame ref before dispatch or observation', async () => { const sessionStore = makeSessionStore(); const sessionName = 'settle-stale-ref'; const session = seedSession(sessionName, sessionStore); + // ADR 0014: a device action since the snapshot expired the ref frame; the + // coarse marker rides along but the frame lifecycle is what rejects. session.snapshotRefsStale = true; + session.refFrameState = 'expired'; sessionStore.set(sessionName, session); - mockDispatch.mockRejectedValue(new Error('dispatch should not be called for a stale iOS ref')); + mockDispatch.mockRejectedValue( + new Error('dispatch should not be called for an expired-frame ref'), + ); const response = await handleInteractionCommands({ req: { @@ -284,7 +289,8 @@ test('press --settle rejects a stale iOS ref before dispatch or observation', as expect(response?.ok).toBe(false); if (response && !response.ok) { expect(response.error.code).toBe('COMMAND_FAILED'); - expect(response.error.message).toBe('Ref @e2 not found or has no bounds'); + expect(response.error.message).toMatch(/expired ref frame/); + expect(response.error.details?.reason).toBe('ref_frame_expired'); expect(String(response.error.details?.hint)).toMatch(/refs were issued/); } expect(mockCaptureSnapshotForSession).not.toHaveBeenCalled(); diff --git a/src/daemon/handlers/__tests__/interaction.test.ts b/src/daemon/handlers/__tests__/interaction.test.ts index 9b5b04737..1f3393a94 100644 --- a/src/daemon/handlers/__tests__/interaction.test.ts +++ b/src/daemon/handlers/__tests__/interaction.test.ts @@ -11,6 +11,7 @@ import { setSessionSnapshot, STALE_SNAPSHOT_REFS_WARNING, } from '../../session-snapshot.ts'; +import { activateCompleteRefFrame, expireRefFrame } from '../../ref-frame.ts'; import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; import { makeIosSession, @@ -3358,12 +3359,16 @@ test('press selector then press @ref rejects refs that outlived the stored snaps expect(selectorPress.data?.warning).toBeUndefined(); expect(sessionStore.get(sessionName)?.snapshotRefsStale).toBe(true); + // 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'); const dispatchCallsBeforeStaleRef = mockDispatch.mock.calls.length; const refPress = await runInteraction(sessionStore, sessionName, 'press', ['@e1']); expect(refPress?.ok).toBe(false); if (refPress && !refPress.ok) { expect(refPress.error.code).toBe('COMMAND_FAILED'); - expect(refPress.error.message).toBe('Ref @e1 not found or has no bounds'); + expect(refPress.error.message).toMatch(/expired ref frame/); + expect(refPress.error.details?.reason).toBe('ref_frame_expired'); expect(refPress.error.details?.hint).toBe(STALE_SNAPSHOT_REFS_WARNING); } expect(mockDispatch).toHaveBeenCalledTimes(dispatchCallsBeforeStaleRef); @@ -3441,11 +3446,16 @@ test('re-issuing refs clears the stale marker so press @ref does not warn', asyn ]); expect(selectorPress?.ok).toBe(true); expect(sessionStore.get(sessionName)?.snapshotRefsStale).toBe(true); + // The selector press expired the frame (ADR 0014 seam). + expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); - // Simulate the snapshot command re-issuing refs (its handler clears the - // marker through buildNextSnapshotSession; covered in snapshot-handler tests). + // Simulate the snapshot command re-issuing the complete ref namespace: it + // clears the marker AND re-activates a complete frame (through + // buildNextSnapshotSession; covered in snapshot-handler tests). Clearing the + // coarse marker alone would leave the frame expired. const stored = sessionStore.get(sessionName)!; stored.snapshotRefsStale = false; + activateCompleteRefFrame(stored); sessionStore.set(sessionName, stored); const refPress = await runInteraction(sessionStore, sessionName, 'press', ['@e1']); @@ -3455,7 +3465,7 @@ test('re-issuing refs clears the stale marker so press @ref does not warn', asyn } }); -test('fill @ref rejects while refs are stale', async () => { +test('fill @ref rejects after a device action expired the frame', async () => { const sessionStore = makeSessionStore(); const sessionName = 'stale-ref-fill'; const session = makeStaleRefSession(sessionName); @@ -3473,9 +3483,14 @@ test('fill @ref rejects while refs are stale', async () => { createdAt: Date.now(), backend: 'xctest', }; + // ADR 0014: an unobserved device action expired the frame; the coarse marker + // rides along and supplies the hint. session.snapshotRefsStale = true; + expireRefFrame(session); sessionStore.set(sessionName, session); - mockDispatch.mockRejectedValue(new Error('dispatch should not be called for a stale iOS ref')); + mockDispatch.mockRejectedValue( + new Error('dispatch should not be called for an expired-frame ref'), + ); const response = await runInteraction(sessionStore, sessionName, 'fill', [ '@e1', @@ -3484,7 +3499,8 @@ test('fill @ref rejects while refs are stale', async () => { expect(response?.ok).toBe(false); if (response && !response.ok) { expect(response.error.code).toBe('COMMAND_FAILED'); - expect(response.error.message).toBe('Ref @e1 not found or has no bounds'); + expect(response.error.message).toMatch(/expired ref frame/); + expect(response.error.details?.reason).toBe('ref_frame_expired'); expect(response.error.details?.hint).toBe(STALE_SNAPSHOT_REFS_WARNING); } expect(mockDispatch).not.toHaveBeenCalled(); @@ -3605,22 +3621,29 @@ test('get text with a pinned stale ref gets the precise warning', async () => { } }); -test('a plain stale ref rejects with the coarse #1093 hint, never the pinned text', async () => { +test('ADR 0014: a read-only capture that set the coarse marker does not invalidate a mutation ref', async () => { + // Evidence #6: an internal read-only capture advances the operational + // observation (and the coarse marker) but does NOT expire the frame, so a + // plain ref from the still-active frame is admitted and dispatches — the old + // coarse-marker mutation block was the ADR's false positive. const sessionStore = makeSessionStore(); const sessionName = 'plain-ref-coarse'; const session = makeStaleRefSession(sessionName); session.snapshotGeneration = 7; session.snapshotRefsStale = true; sessionStore.set(sessionName, session); - mockDispatch.mockRejectedValue(new Error('dispatch should not be called for a stale iOS ref')); + mockDispatch.mockResolvedValue({ pressed: true }); const response = await runInteraction(sessionStore, sessionName, 'press', ['@e1']); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('COMMAND_FAILED'); - expect(response.error.details?.hint).toBe(STALE_SNAPSHOT_REFS_WARNING); + expect(response?.ok).toBe(true); + expect(mockDispatch).toHaveBeenCalled(); + // Crossing the seam expired the frame, so a SECOND plain ref is now rejected. + expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); + const second = await runInteraction(sessionStore, sessionName, 'press', ['@e1']); + expect(second?.ok).toBe(false); + if (second && !second.ok) { + expect(second.error.details?.reason).toBe('ref_frame_expired'); } - expect(mockDispatch).not.toHaveBeenCalled(); }); test('a malformed generation suffix is INVALID_ARGS with the ref grammar hint', async () => { diff --git a/src/daemon/handlers/find.ts b/src/daemon/handlers/find.ts index 15d55c64e..09288d143 100644 --- a/src/daemon/handlers/find.ts +++ b/src/daemon/handlers/find.ts @@ -527,6 +527,7 @@ async function handleFindClick(ctx: FindContext, match: ResolvedMatch): Promise< command: 'click', positionals: [match.ref], flags: match.actionFlags, + internal: { findResolvedTarget: true }, }); if (!response.ok) return response; const matchCoords = match.resolvedNode.rect @@ -569,6 +570,7 @@ async function handleFindFill( command: 'fill', positionals: [match.ref, value], flags: match.actionFlags, + internal: { findResolvedTarget: true }, }); if (!response.ok) return response; recordSessionAction( diff --git a/src/daemon/handlers/interaction-ref-policy.ts b/src/daemon/handlers/interaction-ref-policy.ts index b0d76be84..1b734223f 100644 --- a/src/daemon/handlers/interaction-ref-policy.ts +++ b/src/daemon/handlers/interaction-ref-policy.ts @@ -1,53 +1,68 @@ -import { publicPlatformString } from '../../kernel/device.ts'; -import { admitRefMutation, refFrameEpoch } from '../ref-frame.ts'; -import { STALE_SNAPSHOT_REFS_WARNING } from '../session-snapshot.ts'; +import { + admitRefMutation, + refFrameEpoch, + refFrameScope, + type RefFrameRejectReason, +} from '../ref-frame.ts'; import type { DaemonResponse, SessionState } from '../types.ts'; import { errorResponse } from './response.ts'; /** - * Mutating through a ref from an older client-visible tree is never safe on iOS - * (#1239). The decision consults the ADR 0014 ref-frame admission matrix - * (`admitRefMutation`). + * ADR 0014 mutation-admission enforcement. A ref-targeting mutation is admitted + * only against an active frame whose epoch and issuance scope authorize the ref + * (`admitRefMutation`). This runs BEFORE resolution and dispatch, on EVERY + * supported platform — a device side effect from an earlier command expires the + * frame at its seam, so the next ref mutation is rejected before it can act on a + * possibly-navigated screen. * - * Migration status: ADR 0014 step 3 wires frame expiration at the device - * side-effect seam, but ENFORCING the new expired-frame rejection is deferred to - * step 7, which the ADR gates on fresh live device evidence per platform. Until - * then this guard enforces exactly the pre-existing conditions — a pinned - * generation mismatch and the coarse plain-ref stale marker — so a pinned - * mismatch currently masked by an (unenforced) expiry is still rejected. The - * external error contract (code, message, hint) is unchanged. + * Returns `null` when the mutation is admitted, or a typed failure whose + * `details.reason` distinguishes "capture a complete snapshot" from "use the + * emitted pinned ref". The message names the actual lifetime failure rather than + * claiming the ref was missing or lacked bounds. */ -export function staleIosRefGuardResponse(params: { +export function refMutationAdmissionResponse(params: { session: SessionState; ref: string; mintedGeneration: number | undefined; + /** + * The precise staleness diagnostic the caller already resolved + * (`resolveRefStalenessWarning`). Used as the failure hint when present — for + * a pinned generation mismatch it names the exact minted-vs-current + * generations — otherwise a generic actionable hint is attached. + */ staleRefsWarning: string | undefined; }): DaemonResponse | null { - if (publicPlatformString(params.session.device) !== 'ios') return null; - const refBody = params.ref.startsWith('@') ? params.ref.slice(1) : params.ref; const admission = admitRefMutation({ session: params.session, refBody, mintedGeneration: params.mintedGeneration, }); + if (admission.admitted) return null; - const pinnedMismatch = - params.mintedGeneration !== undefined && - params.mintedGeneration !== refFrameEpoch(params.session); - const coarsePlainStale = - params.mintedGeneration === undefined && params.session.snapshotRefsStale === true; + const scope = refFrameScope(params.session); + return errorResponse('COMMAND_FAILED', rejectionMessage(admission.reason, params.ref), { + reason: admission.reason, + ref: params.ref, + currentGeneration: refFrameEpoch(params.session), + scope: scope === 'all' ? 'all' : Array.from(scope), + ...(params.mintedGeneration !== undefined ? { mintedGeneration: params.mintedGeneration } : {}), + hint: params.staleRefsWarning ?? REJECTION_HINT, + }); +} - // ADR 0014: the frame's expiry (`ref_frame_expired`) and partial-scope - // rejections (`plain_ref_requires_complete_frame`/`ref_not_issued`) in - // `admission` are wired but ENFORCED only from step 7, which the ADR gates on - // fresh live device evidence per platform. Until then keep the pre-existing - // decision: a pinned generation mismatch or the coarse plain-ref stale marker. - if (!pinnedMismatch && !coarsePlainStale) return null; +const REJECTION_HINT = + 'Capture a fresh interactive snapshot (snapshot -i) or use a stable selector, then retry.'; - const reason = admission.admitted ? undefined : admission.reason; - return errorResponse('COMMAND_FAILED', `Ref ${params.ref} not found or has no bounds`, { - hint: params.staleRefsWarning ?? STALE_SNAPSHOT_REFS_WARNING, - ...(reason ? { reason } : {}), - }); +function rejectionMessage(reason: RefFrameRejectReason, ref: string): string { + switch (reason) { + case 'ref_frame_expired': + return `Ref ${ref} belongs to an expired ref frame — a device action since the snapshot invalidated it`; + case 'ref_generation_mismatch': + return `Ref ${ref} was minted from a superseded snapshot generation`; + case 'plain_ref_requires_complete_frame': + return `Ref ${ref} needs a complete snapshot — the current frame only authorizes its emitted refs`; + case 'ref_not_issued': + return `Ref ${ref} was not issued by the current ref frame`; + } } diff --git a/src/daemon/handlers/interaction-touch.ts b/src/daemon/handlers/interaction-touch.ts index 432abd827..b82e57dd0 100644 --- a/src/daemon/handlers/interaction-touch.ts +++ b/src/daemon/handlers/interaction-touch.ts @@ -64,7 +64,7 @@ import { ensureAndroidBlockingSystemDialogReady, type AndroidBlockingDialogReadinessResult, } from '../android-system-dialog.ts'; -import { staleIosRefGuardResponse } from './interaction-ref-policy.ts'; +import { refMutationAdmissionResponse } from './interaction-ref-policy.ts'; import { expireRefFrame } from '../ref-frame.ts'; export async function handleTouchInteractionCommands( @@ -154,13 +154,15 @@ async function dispatchTargetedTouchViaRuntime( req.flags, ); if (invalidRefFlagsResponse) return invalidRefFlagsResponse; - const staleRefResponse = staleIosRefGuardResponse({ - session, - ref: parsedTarget.target.ref, - mintedGeneration: parsedTarget.refGeneration, - staleRefsWarning, - }); - if (staleRefResponse) return staleRefResponse; + const admissionResponse = req.internal?.findResolvedTarget + ? null + : refMutationAdmissionResponse({ + session, + ref: parsedTarget.target.ref, + mintedGeneration: parsedTarget.refGeneration, + staleRefsWarning, + }); + if (admissionResponse) return admissionResponse; androidFreshnessBaseline = await refreshAndroidRefSnapshotIfFreshnessActive(params, session); } // ADR 0012 step 4: a guarded replay dispatch must resolve through the @@ -632,13 +634,15 @@ async function prepareFillRefTarget( }); const invalidRefFlagsResponse = params.refSnapshotFlagGuardResponse('fill', params.req.flags); if (invalidRefFlagsResponse) return { response: invalidRefFlagsResponse, staleRefsWarning }; - const staleRefResponse = staleIosRefGuardResponse({ - session, - ref: target.ref, - mintedGeneration: refGeneration, - staleRefsWarning, - }); - if (staleRefResponse) return { response: staleRefResponse, staleRefsWarning }; + const admissionResponse = params.req.internal?.findResolvedTarget + ? null + : refMutationAdmissionResponse({ + session, + ref: target.ref, + mintedGeneration: refGeneration, + staleRefsWarning, + }); + if (admissionResponse) return { response: admissionResponse, staleRefsWarning }; await refreshAndroidRefSnapshotIfFreshnessActive(params, session); return { staleRefsWarning }; } diff --git a/src/daemon/ref-frame.ts b/src/daemon/ref-frame.ts index b3aeb6e02..c073db365 100644 --- a/src/daemon/ref-frame.ts +++ b/src/daemon/ref-frame.ts @@ -43,9 +43,15 @@ export type RefFrameAdmission = | { admitted: true } | { admitted: false; reason: RefFrameRejectReason }; -/** The frame epoch exposed to clients as `refsGeneration`. */ +/** + * 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. + */ export function refFrameEpoch(session: SessionState): number | undefined { - return session.snapshotGeneration; + return session.refFrameGeneration ?? session.snapshotGeneration; } /** @@ -76,6 +82,7 @@ export function activateCompleteRefFrame(session: SessionState): void { session.refFrameState = 'active'; session.refFrameScope = undefined; session.refFrameTree = session.snapshot; + session.refFrameGeneration = session.snapshotGeneration; } export function refFrameState(session: SessionState): RefFrameState { diff --git a/src/daemon/session-snapshot.ts b/src/daemon/session-snapshot.ts index 8c4546aac..90c0e6980 100644 --- a/src/daemon/session-snapshot.ts +++ b/src/daemon/session-snapshot.ts @@ -1,5 +1,6 @@ import { randomInt } from 'node:crypto'; import type { SnapshotState } from '../kernel/snapshot.ts'; +import { refFrameEpoch } from './ref-frame.ts'; import type { SessionState } from './types.ts'; /** @@ -115,6 +116,10 @@ export function markSessionPartialRefsIssued(session: SessionState, refs: Iterab // ancestors, siblings, and viewport outside the emitted subset, so the whole // tree is kept while the issuance set bounds authority. session.refFrameTree = session.snapshot; + // Freeze the epoch the client is handed (the response-level refsGeneration), + // so a later read-only capture that bumps the observation counter cannot + // invalidate a correct pin from this frame. + session.refFrameGeneration = session.snapshotGeneration; } /** @@ -151,7 +156,10 @@ export function resolveRefStalenessWarning(params: { }): string | undefined { const { session, ref, mintedGeneration } = params; if (mintedGeneration !== undefined) { - const currentGeneration = session?.snapshotGeneration ?? 0; + // ADR 0014: compare against the FRAME epoch (frozen at issuance), not the + // observation counter — a read-only capture that bumped `snapshotGeneration` + // must not make a valid pin from the issuing frame look stale. + const currentGeneration = session ? (refFrameEpoch(session) ?? 0) : 0; if (mintedGeneration === currentGeneration) return undefined; return buildPinnedStaleRefWarning({ ref, mintedGeneration, currentGeneration }); } diff --git a/src/daemon/types.ts b/src/daemon/types.ts index 91dce48c5..61e5648fb 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -71,6 +71,14 @@ type DaemonRequestInternal = { * different same-identity duplicate. */ replayTargetGuard?: ReplayTargetGuardDenotation; + /** + * ADR 0014: set when a mutating `find` re-enters the interaction leaf with the + * ref it just resolved by locator against a fresh capture. That ref is find's + * own diagnostic identity, not a client-supplied ref subject to frame + * lifetime, so the leaf skips ref-frame admission (it still crosses the + * side-effect seam and expires the frame). + */ + findResolvedTarget?: boolean; }; export type DaemonRequest = Omit & { @@ -264,6 +272,17 @@ export type SessionState = { * 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; /** Last broad snapshot safe for Android route-freshness comparisons after interactive snapshots. */ lastComparisonSafeSnapshot?: SnapshotState; androidSnapshotFreshness?: AndroidSnapshotFreshness; diff --git a/test/integration/provider-scenarios/android-find.test.ts b/test/integration/provider-scenarios/android-find.test.ts index 67fb1160e..79adeb2d1 100644 --- a/test/integration/provider-scenarios/android-find.test.ts +++ b/test/integration/provider-scenarios/android-find.test.ts @@ -45,6 +45,12 @@ test('Provider-backed integration Android find flow covers refs, wait, ambiguity assertString(attrsRef, 'find attrs ref'); assert.match(attrsRef, /^@e\d+$/); assert.equal((attrs.node as { label?: string } | undefined)?.label, 'Apps'); + // ADR 0014: a read-only find partially publishes its returned ref, so it is + // consumed in pinned form (`@eN~s`) — a plain ref would require a + // complete frame. + const attrsGeneration = (attrs as { refsGeneration?: number }).refsGeneration; + assert.equal(typeof attrsGeneration, 'number'); + const attrsPinnedRef = `${attrsRef}~s${attrsGeneration}`; const exists = await client.interactions.find({ locator: 'label', @@ -54,7 +60,7 @@ test('Provider-backed integration Android find flow covers refs, wait, ambiguity }); assert.equal(exists.found, true); - const pressFoundRef = await client.interactions.press({ ref: attrsRef, ...selection }); + const pressFoundRef = await client.interactions.press({ ref: attrsPinnedRef, ...selection }); assert.equal(pressFoundRef.x, 88); assert.equal(pressFoundRef.y, 151); diff --git a/test/integration/provider-scenarios/android-lifecycle.test.ts b/test/integration/provider-scenarios/android-lifecycle.test.ts index 857b295d1..b5964c4c5 100644 --- a/test/integration/provider-scenarios/android-lifecycle.test.ts +++ b/test/integration/provider-scenarios/android-lifecycle.test.ts @@ -1033,6 +1033,9 @@ async function runAndroidCaptureInteractionAndReplayWorkflow( const appSwitcher = await client.command.appSwitcher(selection); assert.equal(appSwitcher.action, 'app-switcher'); + // ADR 0014: rotate + app-switcher expired the frame, so re-observe before + // acting through a ref again (the mock re-captures the same tree). + await client.capture.snapshot({ interactiveOnly: true, ...selection }); const press = await client.interactions.press({ ref: `@${apps.ref}`, ...selection }); assert.equal(press.x, 88); assert.equal(press.y, 151); @@ -1049,6 +1052,8 @@ async function runAndroidCaptureInteractionAndReplayWorkflow( assert.equal(heldPress.holdMs, 5); assert.equal(heldPress.jitterPx, 1); + // ADR 0014: the press + held coordinate press expired the frame — re-observe. + await client.capture.snapshot({ interactiveOnly: true, ...selection }); const click = await client.interactions.click({ ref: `@${apps.ref}`, ...selection }); assert.equal(click.x, 88); assert.equal(click.y, 151); @@ -1070,6 +1075,8 @@ async function runAndroidCaptureInteractionAndReplayWorkflow( false, ); + // ADR 0014: the ref click expired the frame — re-observe before filling. + await client.capture.snapshot({ interactiveOnly: true, ...selection }); const fill = await client.interactions.fill({ ref: `@${search.ref}`, text: 'Display', @@ -1162,6 +1169,10 @@ async function runAndroidCaptureInteractionAndReplayWorkflow( [ 'snapshot -i', 'press @e2 Apps --count 2 --interval-ms 1', + // ADR 0014: the ref press expired the frame, so the script re-observes + // before mutating through another ref (a legacy multi-ref-from-one-snapshot + // script would now fail closed). + 'snapshot -i', 'fill @e3 Search "Network"', 'get text @e3 Search', '', @@ -1172,7 +1183,7 @@ async function runAndroidCaptureInteractionAndReplayWorkflow( update: true, ...selection, }); - assert.equal(updateReplay.replayed, 4); + assert.equal(updateReplay.replayed, 5); assert.equal(updateReplay.healed, 0); const replayEnvPath = path.join(tempRoot, 'settings-env.ad'); diff --git a/test/integration/provider-scenarios/linux-desktop.test.ts b/test/integration/provider-scenarios/linux-desktop.test.ts index 0e882586c..8e2d56e60 100644 --- a/test/integration/provider-scenarios/linux-desktop.test.ts +++ b/test/integration/provider-scenarios/linux-desktop.test.ts @@ -136,6 +136,13 @@ test('Provider-backed integration Linux desktop flow uses semantic desktop and i to: { x: 30, y: 40 }, }, }, + { + // ADR 0014: the earlier ref press (and the coordinate gestures) + // expired the frame, so re-observe before the next ref mutation. + name: 're-observe before the next ref mutation', + command: 'snapshot', + flags: { snapshotInteractiveOnly: true }, + }, { name: 'fill snapshot ref', command: 'fill', diff --git a/test/integration/provider-scenarios/settle-observation.test.ts b/test/integration/provider-scenarios/settle-observation.test.ts index a56769a0d..435c50344 100644 --- a/test/integration/provider-scenarios/settle-observation.test.ts +++ b/test/integration/provider-scenarios/settle-observation.test.ts @@ -224,9 +224,12 @@ test('Provider-backed integration press --settle returns the settled diff and fr // The settled tree is never serialized into the response. assert.equal(pressData.nodes, undefined); - // The diff's ref acts directly on the stored settled tree — no fresh - // snapshot round trip and no stale-refs warning. - const followUp = await daemon.callCommand('press', ['@e2'], {}); + // ADR 0014: the settle issued a PARTIAL frame authorizing exactly its + // emitted ref, so the follow-up consumes it in pinned form — acting + // directly on the stored settled tree with no fresh snapshot round trip + // and no stale-refs warning. The plain `@e2` would require a complete + // frame. + const followUp = await daemon.callCommand('press', [`@e2~s${settle.refsGeneration}`], {}); const followUpData = assertRpcOk(followUp); assert.equal(followUpData.warning, undefined); assert.equal(followUpData.x, 200); @@ -387,8 +390,9 @@ test('Provider-backed integration modal-dismiss press --settle attaches the unch assert.equal(typeof settle.refsGeneration, 'number'); // The tail's ref acts directly on the stored settled tree, same as an - // added-line ref would. - const followUp = await daemon.callCommand('press', ['@e3'], {}); + // added-line ref would — consumed in pinned form from the partial frame + // (ADR 0014). + const followUp = await daemon.callCommand('press', [`@e3~s${settle.refsGeneration}`], {}); const followUpData = assertRpcOk(followUp); assert.equal(followUpData.warning, undefined); assert.equal(followUpData.x, 200); @@ -720,7 +724,7 @@ test('Provider-backed integration fill --settle summoning the keyboard still att assert.equal(settle.tailTruncated, undefined); assert.equal(typeof settle.refsGeneration, 'number'); - const followUp = await daemon.callCommand('press', ['@e17'], {}); + const followUp = await daemon.callCommand('press', [`@e17~s${settle.refsGeneration}`], {}); const followUpData = assertRpcOk(followUp); assert.equal(followUpData.warning, undefined); assert.equal(followUpData.x, 201); diff --git a/test/integration/provider-scenarios/stale-ref-warning.test.ts b/test/integration/provider-scenarios/stale-ref-warning.test.ts index 9448025b2..8aed00e08 100644 --- a/test/integration/provider-scenarios/stale-ref-warning.test.ts +++ b/test/integration/provider-scenarios/stale-ref-warning.test.ts @@ -127,7 +127,11 @@ test('Provider-backed integration iOS @refs reject after a selector press replac assert.equal(selectorData.warning, undefined); const stalePress = await daemon.callCommand('press', ['@e2'], {}); - assertRpcError(stalePress, 'COMMAND_FAILED', /Ref @e2 not found or has no bounds/); + assertRpcError(stalePress, 'COMMAND_FAILED', /Ref @e2 belongs to an expired ref frame/); + assert.equal( + (stalePress.json?.error?.data?.details as Record)?.reason, + 'ref_frame_expired', + ); const refresh = await daemon.callCommand('snapshot', [], { snapshotInteractiveOnly: true, @@ -184,7 +188,11 @@ test('Provider-backed iOS press rejects a stale ref after navigation', async () assertRpcOk(await daemon.callCommand('press', ['label=Back'], {})); const stalePress = await daemon.callCommand('press', ['@e3'], {}); - assertRpcError(stalePress, 'COMMAND_FAILED', /Ref @e3 not found or has no bounds/); + assertRpcError(stalePress, 'COMMAND_FAILED', /Ref @e3 belongs to an expired ref frame/); + assert.equal( + (stalePress.json?.error?.data?.details as Record)?.reason, + 'ref_frame_expired', + ); runnerTranscript.assertComplete(); }, ); diff --git a/test/integration/provider-scenarios/versioned-refs.test.ts b/test/integration/provider-scenarios/versioned-refs.test.ts index f2b5ba383..def8178cb 100644 --- a/test/integration/provider-scenarios/versioned-refs.test.ts +++ b/test/integration/provider-scenarios/versioned-refs.test.ts @@ -66,23 +66,17 @@ function tapEntry(x: number, y: number): ProviderScenarioProviderEntry { test('Provider-backed integration rejects stale pinned @refs and accepts current pins', async () => { const runnerTranscript = createProviderTranscript([ - // snapshot -i: issues refs at the seeded generation g1 + // snapshot -i: issues the complete frame at the seeded generation g1 snapshotEntry(), - // press label=Continue: selector resolution capture replaces the stored - // tree (g1+1) without issuing refs - snapshotEntry(), - tapEntry(200, 322), - // press @e2~s{g1}: reject the outlived ref before any runner command - // press @e2~s{g1+1}: pinned to the CURRENT generation — clean + // press @e2~s{g1}: pinned to the CURRENT frame — admitted, taps Cancel, + // and crosses the seam so the frame expires tapEntry(200, 422), - // find Cancel click: capture replaces the tree AGAIN (g1+2) and issues - // only the found ref at the new generation + // snapshot -i: re-issues a fresh complete frame at g2 (= g1+1) snapshotEntry(), + // press @e2~s{g1}: an outlived pin against the active g2 frame — rejected + // with a precise generation mismatch before any runner command + // press @e2~s{g2}: pinned to the current frame — admitted, taps Cancel tapEntry(200, 422), - // press @e1~s{g1+1}: a PRE-find pin — the find must not bless it (the - // #1076 hole): reject before any runner command - // press @e1~s{g1+2}: pinned to the post-find generation — clean - tapEntry(200, 322), ]); const appleRunnerProvider = createAppleRunnerProviderFromTranscript( runnerTranscript, @@ -120,47 +114,37 @@ test('Provider-backed integration rejects stale pinned @refs and accepts current assert.ok(nodes.length > 0); assert.ok(nodes.every((node) => node.ref === undefined || !node.ref.includes('~'))); - const selectorPress = await daemon.callCommand('press', ['label=Continue'], {}); - const selectorData = assertRpcOk(selectorPress); - assert.equal(selectorData.warning, undefined); + // A pin to the current frame is admitted; the tap crosses the seam. + const pinnedCurrent = await daemon.callCommand('press', [`@e2~s${g1}`], {}); + assert.equal(assertRpcOk(pinnedCurrent).warning, undefined); + + // ADR 0014: the mutation expired the frame, so a fresh observation is + // required before another ref mutation. + const refresh = await daemon.callCommand('snapshot', [], { + snapshotInteractiveOnly: true, + }); + const g2 = assertRpcOk(refresh).refsGeneration as number; + assert.equal(g2, g1 + 1); + // The outlived pin is rejected precisely against the active g2 frame. const pinnedStale = await daemon.callCommand('press', [`@e2~s${g1}`], {}); const pinnedStaleData = assertRpcError( pinnedStale, 'COMMAND_FAILED', - /Ref @e2 not found or has no bounds/, + /Ref @e2 was minted from a superseded snapshot generation/, ); + const pinnedStaleDetails = pinnedStaleData.details as Record; + assert.equal(pinnedStaleDetails.reason, 'ref_generation_mismatch'); + assert.equal(pinnedStaleDetails.mintedGeneration, g1); + assert.equal(pinnedStaleDetails.currentGeneration, g2); assert.equal( pinnedStaleData.hint, - `Ref @e2 was minted from snapshot s${g1} but the session tree is now s${g1 + 1} — re-run snapshot -i.`, - ); - - const pinnedCurrent = await daemon.callCommand('press', [`@e2~s${g1 + 1}`], {}); - const pinnedCurrentData = assertRpcOk(pinnedCurrent); - assert.equal(pinnedCurrentData.warning, undefined); - - // The blessing flow: find replaces the tree and issues its ref at the - // NEW generation… - const find = await daemon.callCommand('find', ['Cancel', 'click'], {}); - const findData = assertRpcOk(find); - assert.equal(findData.refsGeneration, g1 + 2); - - // …but a ref pinned BEFORE the find is still rejected precisely — the - // find response must not silently re-bless it. - const preFindPin = await daemon.callCommand('press', [`@e1~s${g1 + 1}`], {}); - const preFindPinData = assertRpcError( - preFindPin, - 'COMMAND_FAILED', - /Ref @e1 not found or has no bounds/, - ); - assert.equal( - preFindPinData.hint, - `Ref @e1 was minted from snapshot s${g1 + 1} but the session tree is now s${g1 + 2} — re-run snapshot -i.`, + `Ref @e2 was minted from snapshot s${g1} but the session tree is now s${g2} — re-run snapshot -i.`, ); - const postFindPin = await daemon.callCommand('press', [`@e1~s${g1 + 2}`], {}); - const postFindPinData = assertRpcOk(postFindPin); - assert.equal(postFindPinData.warning, undefined); + // A pin to the current generation is admitted. + const pinnedFresh = await daemon.callCommand('press', [`@e2~s${g2}`], {}); + assert.equal(assertRpcOk(pinnedFresh).warning, undefined); runnerTranscript.assertComplete(); }, diff --git a/test/integration/provider-scenarios/web-desktop.test.ts b/test/integration/provider-scenarios/web-desktop.test.ts index f84d348ae..e3970f1aa 100644 --- a/test/integration/provider-scenarios/web-desktop.test.ts +++ b/test/integration/provider-scenarios/web-desktop.test.ts @@ -94,6 +94,13 @@ test('Provider-backed integration web desktop flow uses semantic web provider ca assert.equal(data?.message, 'Tapped @e4'); }, }, + { + // ADR 0014: the ref click above expired the frame, so a fresh + // observation is required before the next ref mutation. + name: 're-observe before the next ref mutation', + command: 'snapshot', + flags: { snapshotInteractiveOnly: true }, + }, { name: 'fill email ref', command: 'fill', From a5647485883e9826c8d05310265b837916fb0d82 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 12:45:41 +0000 Subject: [PATCH 09/14] docs(adr-0014): promote ref-frame vocabulary and mark implementation status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flip ADR 0014 to Accepted, promote the ref-frame / frame-expiry-seam / mutation-admission vocabulary into CONTEXT.md, correct the `@ref` resolution note to the frame-tree model, record the migration status (steps 1–7 landed; coarse-marker removal follows live-evidence confirmation), update ADR 0012's divergence-ref amendment to accepted, and add a CHANGELOG entry for the fail-closed ref lifetime. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa --- CHANGELOG.md | 5 +++++ CONTEXT.md | 8 ++++++-- docs/adr/0012-interactive-replay.md | 11 ++++++----- docs/adr/0014-session-ref-frame-lifetime.md | 18 +++++++++++++----- 4 files changed, 30 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bb9b8a37..f7f1929b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +- Breaking (ADR 0014, session ref-frame lifetime): a mutation through an `@ref` now expires the session's ref frame, so a later ref mutation without a fresh observation fails closed with a typed `details.reason` (`ref_frame_expired`, `ref_generation_mismatch`, `plain_ref_requires_complete_frame`, or `ref_not_issued`) instead of acting on a possibly-navigated screen. A ref-oriented sequence that performs several mutations must re-`snapshot` between them, consume an honestly issued `--settle` ref in pinned `@eN~s` form, or use selectors. Enforcement applies on every platform, not just iOS. Legacy hand-written `.ad` scripts that reuse several bare refs from one snapshot must capture between mutations or use selectors. +- Ref reads resolve against the authorized ref frame's source tree, so an internal read-only capture (including Android freshness) can no longer retarget an admitted `@ref` by positional coincidence. Read-only ref consumers keep the structured staleness warning while the frame retains the ref's evidence. + ## 0.15.0 - Breaking: `apps` discovery and public app-list helpers now default to user-installed apps. Use `--all` or `filter: 'all'` to include system/OEM apps. diff --git a/CONTEXT.md b/CONTEXT.md index edaeac265..eb105b57c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -61,6 +61,9 @@ - Coverage manifest: `CONTRACT_COVERAGE` export beside each interaction contract test file claiming which matrix cells it proves; the coverage gate requires every enforced/delegated cell to be claimed and rejects overclaims of waived cells. - Delegation-on-error: a fast path falling back to the runtime path on semantic failure shapes. It closes failure-side guarantee cells only — never success-path parity. - Ref generation pin: optional `~s` suffix on an @ref carrying the snapshot generation it was minted from. Accepted as input everywhere, emitted by no tree output (snapshot token budget), auto-appended by the MCP layer, stripped and ignored by replay. +- Ref frame (ADR 0014): the session's single authorization namespace for mutation `@ref`s, kept separate from the latest operational observation (`session.snapshot`). It owns a frozen epoch (the `refsGeneration` the client received), an immutable source tree, a lifecycle state (`active`/`expired`), and an issuance scope (`all` for a complete snapshot, or the bounded set of ref bodies a partial publication emitted). Owned solely by `src/daemon/ref-frame.ts`. A complete snapshot activates an `all` frame; `find`/settled diff/replay divergence activate a bounded partial frame that supersedes the prior one; internal read captures never activate or reindex it. +- Frame expiry seam (ADR 0014): every mutating leaf calls `expireRefFrame` synchronously, immediately before the device op that may change element identity (after all pre-action guards), so a post-dispatch failure still leaves the frame expired — there is no success-only rollback. Ref resolution binds `@eN` against the frame's source tree, so an Android freshness (or any read-only) capture cannot retarget an admitted ref by positional coincidence; a fresh capture's coordinates are adopted only when its node's local identity matches. +- Mutation admission (ADR 0014): a ref mutation is admitted only against an active frame whose epoch and issuance scope authorize the ref (`admitRefMutation`, order-sensitive reasons `ref_frame_expired` → `ref_generation_mismatch` → `plain_ref_requires_complete_frame` → `ref_not_issued`). Rejections carry `details.reason` and name the lifetime failure. A ref-oriented sequence that performs several mutations must re-observe (snapshot), consume an honestly issued settled ref in pinned form, or use selectors. Read-only ref consumers stay fail-open with a staleness warning while the frame retains the ref's evidence. - Settled observation: opt-in (`--settle`) post-action payload on press/click/fill/longpress — the quiet-window stable loop re-captures until the UI settles, and the response carries the diff vs the pre-action tree (changed lines only, added lines with fresh refs, `refsGeneration` when the settled tree was stored). Best-effort: never fails the action; `settled: false` plus a hint on never-quiet content. - Snapshot capture plan: per-strategy ordered chain of iOS snapshot capture backends (recursive tree, query sweep, private AX) run by one plan runner under a shared wall-clock budget; recovery ordering is declared data, never a per-call-site branch. - Snapshot quality verdict: structured outcome (state, backend, reason code, effective depth, collapsed leaves) computed once by the plan runner and shipped with every planned snapshot payload; the daemon and CLI render it instead of re-deriving degradation from node shapes. @@ -136,8 +139,9 @@ the observable freshness and failure semantics below before any runtime refactor direct miss may fall back to the snapshot selector path, but ambiguous matches and runner errors must surface instead of silently falling back. `get text` uses direct native selectors only for simple `id` selectors because label/text/value reads need snapshot disambiguation. -- Regular selector reads remain capture-backed. `@ref` reads resolve against stored session - snapshots; selector `get`/`is`/`find`/`wait` capture through the backend. `find` and `wait` +- Regular selector reads remain capture-backed. `@ref`s resolve against the authorized ref frame's + source tree (ADR 0014), not whatever now sits at that index in a newer observation; selector + `get`/`is`/`find`/`wait` capture through the backend. `find` and `wait` polling must bypass the 750 ms snapshot cache. The cache is also bypassed while Android freshness recovery or post-gesture stabilization is active. - Sparse snapshot quality verdicts are observable failures. Sparse captures must not replace diff --git a/docs/adr/0012-interactive-replay.md b/docs/adr/0012-interactive-replay.md index d9b794031..361c1ce00 100644 --- a/docs/adr/0012-interactive-replay.md +++ b/docs/adr/0012-interactive-replay.md @@ -463,11 +463,12 @@ every `screen` ref with `refsGeneration` before returning it, including on the e client callers receive the unpinned refs and generation already present in the daemon error. No caller gets a text-only divergence that loses its repair data. -> **Proposed ADR 0014 amendment:** if ADR 0014 is accepted and implemented, replay divergence -> `screen.refs` becomes a partial ref publication. MCP keeps auto-pinning those refs; CLI text renders -> `@eN~s`; JSON and Node.js callers pair each plain ref with the response-level -> generation before mutation. Until that migration lands, the current unpinned contract above remains -> accepted. The implementation must update this note and its contract tests in the same change. +> **ADR 0014 amendment (accepted, implemented):** replay divergence `screen.refs` is now a partial ref +> publication — it activates a bounded partial ref frame authorizing exactly the divergence screen's refs +> (`markSessionPartialRefsIssued`). MCP auto-pins those refs; CLI text renders `@eN~s`; +> JSON and Node.js callers pair each plain ref with the response-level generation before mutation. Because +> the frame is partial, a mutation through a divergence ref requires the pinned form; a plain ref there +> reports `plain_ref_requires_complete_frame`. `--from N` is a `replay`-only flag. `test` must reject it as `INVALID_ARGS`; test shares replay execution but must remain a full, deterministic suite run. `N` is a 1-based index into the fully expanded diff --git a/docs/adr/0014-session-ref-frame-lifetime.md b/docs/adr/0014-session-ref-frame-lifetime.md index ccf99946d..25135fbe5 100644 --- a/docs/adr/0014-session-ref-frame-lifetime.md +++ b/docs/adr/0014-session-ref-frame-lifetime.md @@ -2,7 +2,7 @@ ## Status -Proposed +Accepted ## Context @@ -35,10 +35,9 @@ remain compatible with selector-based replay, and add no automatic capture or pe ## Decision -The terms introduced below describe the proposed target model. They do not replace the current domain -language in `CONTEXT.md` while this ADR is Proposed and the source still implements the snapshot/stale -marker model. The implementation change that establishes these concepts must promote the accepted -terms into `CONTEXT.md`; documentation must not present the target model as current behavior early. +The terms introduced below are now the implemented model; the ref-frame vocabulary is promoted into +`CONTEXT.md`. The coarse `snapshotRefsStale` marker still backs read-only staleness warnings and is +removed once per-platform enforcement is confirmed by live evidence (migration step 8). ### Ref frames are separate from operational observations @@ -440,6 +439,15 @@ Each step lands green and independently useful: evidence; promote the implemented vocabulary into `CONTEXT.md`; then remove the superseded coarse stale marker. +Implementation status: steps 1–7 have landed — the ref-frame module and observation split (1), the +complete daemon classification and gate (2), the pre-side-effect seam at every leaf (3), correct +complete/partial publication with bounded scope, MCP pin retention, and pinned partial CLI text (4), +Android freshness decoupled from positional ref authorization (5), the cross-platform contract and +provider evidence (6), and fail-closed admission enforcement across platforms with typed reasons (7). +Per-platform enablement is confirmed by the live-evidence runs required above; step 8's removal of the +superseded coarse `snapshotRefsStale` marker follows that confirmation so read-only warnings are not +disturbed before enforcement is proven on hardware. + PR #1241 landed independently as a compatible transitional fix. It rejects a known iOS stale-marker case before this full lifecycle is implemented; it does not own the architecture migration. From d828966d843f7c4146c1760a77952088ff9b8315 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 12:47:51 +0000 Subject: [PATCH 10/14] test(daemon): lock ADR 0014 evidence #1 and refresh module docs Add a daemon-level sequence test proving the canonical contract: after an unobserved first ref mutation, a second mutation rejects both bare and pinned with ref_frame_expired, and a fresh snapshot re-authorizes. Refresh the ref-frame module header and seam-expiry test comment now that enforcement is live. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa --- .../handlers/__tests__/interaction.test.ts | 36 +++++++++++++++++-- src/daemon/ref-frame.ts | 12 +++---- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src/daemon/handlers/__tests__/interaction.test.ts b/src/daemon/handlers/__tests__/interaction.test.ts index 1f3393a94..dbdb16031 100644 --- a/src/daemon/handlers/__tests__/interaction.test.ts +++ b/src/daemon/handlers/__tests__/interaction.test.ts @@ -3388,8 +3388,7 @@ test('a ref press crosses the ADR 0014 side-effect seam and expires the ref fram const press = await runInteraction(sessionStore, sessionName, 'press', ['@e1']); expect(press?.ok).toBe(true); - // The transition is wired at the leaf seam even though iOS enforcement of the - // expired-frame rejection is deferred to a later migration step. + // The transition is wired at the leaf seam. expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); // ADR 0014: a PARTIAL publication (find/settle/divergence, via @@ -3399,6 +3398,39 @@ test('a ref press crosses the ADR 0014 side-effect seam and expires the ref fram expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); }); +test('ADR 0014 evidence #1: a second ref mutation rejects (bare and pinned) until a fresh observation re-authorizes', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'seam-sequence'; + const session = makeStaleRefSession(sessionName); + session.snapshotGeneration = 500; + // A complete snapshot issued the frame at generation 500. + activateCompleteRefFrame(session); + sessionStore.set(sessionName, session); + mockDispatch.mockImplementation(async (_device, command) => + command === 'snapshot' ? { nodes: makeTwoButtonNodes(), backend: 'xctest' } : {}, + ); + + // `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'); + + // `-> press @e2`: the unobserved second mutation rejects (bare). + const bare = await runInteraction(sessionStore, sessionName, 'press', ['@e2']); + expect(bare?.ok).toBe(false); + if (bare && !bare.ok) expect(bare.error.details?.reason).toBe('ref_frame_expired'); + + // A pin at the same epoch also rejects — expiry is evaluated before the pin. + const pinned = await runInteraction(sessionStore, sessionName, 'press', ['@e2~s500']); + expect(pinned?.ok).toBe(false); + if (pinned && !pinned.ok) expect(pinned.error.details?.reason).toBe('ref_frame_expired'); + + // `-> snapshot -> press @e2`: a fresh complete frame re-authorizes. + activateCompleteRefFrame(sessionStore.get(sessionName)!); + const reobserved = await runInteraction(sessionStore, sessionName, 'press', ['@e2']); + expect(reobserved?.ok).toBe(true); +}); + test('direct iOS selector click crosses the ADR 0014 fused seam and expires the ref frame', async () => { const sessionStore = makeSessionStore(); const sessionName = 'direct-ios-seam'; diff --git a/src/daemon/ref-frame.ts b/src/daemon/ref-frame.ts index c073db365..fb83f574b 100644 --- a/src/daemon/ref-frame.ts +++ b/src/daemon/ref-frame.ts @@ -11,12 +11,12 @@ import type { SessionState } from './types.ts'; * coarse client-stale marker) fields, which keep their wire-visible names * (`refsGeneration`, the `@e12~s42` pin grammar) for compatibility. * - * Migration status: this step introduces the model and the admission matrix and - * routes the existing iOS stale-ref enforcement (#1241) through it. Frame - * expiration at the device side-effect seam, non-`all` issuance scope, and - * fail-closed enforcement on other platforms land in later steps; until then a - * frame is always `active` with scope `all`, so `admitRefMutation` reduces to - * the generation-pin check that path already performed. + * The frame is expired at the device side-effect seam, carries a non-`all` + * issuance scope after a partial publication, and its admission matrix is + * enforced fail-closed on every platform before dispatch (ADR 0014 steps 3–7). + * The wire names `refsGeneration` and the `@e12~s42` pin grammar are unchanged; + * the coarse `snapshotRefsStale` marker still backs read-only staleness warnings + * until migration step 8 removes it. */ /** From 98d5317ceeb8b7b13183d82ee64814273a35b846 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 14:30:47 +0000 Subject: [PATCH 11/14] =?UTF-8?q?fix(daemon):=20address=20ADR=200014=20exa?= =?UTF-8?q?ct-head=20review=20=E2=80=94=20six=20lifetime=20blockers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Android dialog recovery aborts an outstanding ref action: a ref press/fill admitted against the pre-recovery frame now fails with ref_frame_expired when before-command recovery mutates the UI, instead of continuing against the recovered screen (selector/coordinate actions still re-resolve and continue). 2. open --relaunch expires the existing session's frame BEFORE the close dispatch, so a close timeout/failure that already tore the app down still leaves the old frame expired. 3. expireRefFrame clears scoped-snapshot lineage (snapshotScopeSource) at the seam, so snapshot -s @ref -> mutation -> snapshot -s @same-ref can no longer borrow stale lineage across a device side effect. 4. Missing authorized-frame evidence fails closed: resolveSnapshotForRef no longer recaptures and accepts the same ref body from a newer tree by positional coincidence. A mutating find's internal dispatch resolves against its own fresh capture (omitRefFrameSnapshot), not the frame. 5. Mutating find omits refsGeneration — its acted ref is diagnostic pre-action identity and must not be pinnable after the action. 6. An empty partial publication leaves all session state untouched (including the coarse marker), instead of clearing it before finding there were no refs to issue. Adds focused regressions (lineage-cleared sequence, empty-partial no-op, fail-closed on unusable bounds, in-frame label recovery, mutating-find non-issuance) and extracts the find action dispatch to keep complexity in budget. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa --- .../interaction/runtime/resolution.test.ts | 23 ++- .../interaction/runtime/resolution.ts | 29 ++- src/daemon/__tests__/ref-frame.test.ts | 13 ++ src/daemon/__tests__/session-snapshot.test.ts | 22 +++ src/daemon/handlers/__tests__/find.test.ts | 15 +- .../handlers/__tests__/interaction.test.ts | 169 +++++------------- .../__tests__/snapshot-scoped-refs.test.ts | 30 ++++ src/daemon/handlers/find.ts | 26 ++- src/daemon/handlers/interaction-runtime.ts | 8 +- src/daemon/handlers/interaction-touch.ts | 28 ++- src/daemon/handlers/session-open.ts | 5 + src/daemon/ref-frame.ts | 6 + src/daemon/runtime-session.ts | 11 +- src/daemon/session-snapshot.ts | 5 +- 14 files changed, 229 insertions(+), 161 deletions(-) diff --git a/src/commands/interaction/runtime/resolution.test.ts b/src/commands/interaction/runtime/resolution.test.ts index fccb0025a..72e60e9c1 100644 --- a/src/commands/interaction/runtime/resolution.test.ts +++ b/src/commands/interaction/runtime/resolution.test.ts @@ -412,7 +412,7 @@ test('runtime interactions reject unsupported macOS desktop and menubar surfaces assert.equal(pressed, true); }); -test('runtime ref interactions refresh the snapshot when a stored ref has no usable rect', async () => { +test('runtime ref interactions fail closed when the authorized ref has no usable bounds (ADR 0014)', async () => { const staleSnapshot = makeSnapshotState([ { index: 0, @@ -422,25 +422,30 @@ test('runtime ref interactions refresh the snapshot when a stored ref has no usa hittable: true, }, ]); - const freshSnapshot = selectorSnapshot(); const calls: Point[] = []; let captures = 0; const device = createInteractionDevice(staleSnapshot, { captureSnapshot: async () => { captures += 1; - return { snapshot: freshSnapshot }; + return { snapshot: selectorSnapshot() }; }, tap: async (_context, point) => { calls.push(point); }, }); - const result = await device.interactions.click(ref('@e1'), { session: 'default' }); - - assert.equal(captures, 1); - assert.deepEqual(calls, [{ x: 60, y: 40 }]); - assert.equal(result.kind, 'ref'); - assert.equal(result.node?.rect?.width, 100); + // ADR 0014: the authorized frame's @e1 has no usable rect, so it FAILS rather + // than recapturing and accepting the same index from a newer tree by + // positional coincidence. + await assert.rejects( + () => device.interactions.click(ref('@e1'), { session: 'default' }), + (error: unknown) => { + assert.match((error as Error).message, /Ref @e1 not found or has no bounds/); + return true; + }, + ); + assert.equal(captures, 0); + assert.deepEqual(calls, []); }); test('tryResolveRefNode discloses exact for a resolved ref and label-fallback for label recovery', () => { diff --git a/src/commands/interaction/runtime/resolution.ts b/src/commands/interaction/runtime/resolution.ts index 440ebd60d..0c4fa85e4 100644 --- a/src/commands/interaction/runtime/resolution.ts +++ b/src/commands/interaction/runtime/resolution.ts @@ -562,26 +562,23 @@ async function resolveSnapshotForRef( const authorized = tryResolveRefNode(frameTree.nodes, target.ref, { fallbackLabel, }); - if (authorized) { - return reconcileFreshObservation({ - session, - frameTree, - target, - fallbackLabel, - authorized, - }); - } - - const capture = await captureInteractionSnapshot(runtime, options, true); - const refreshed = tryResolveRefNode(capture.snapshot.nodes, target.ref, { - fallbackLabel, - }); - if (!refreshed) { + // ADR 0014: missing authorized-frame evidence FAILS. It must not fall through + // to a fresh capture and accept the same ref body from a newer tree — that is + // exactly the positional-coincidence retarget the frame model forbids. A stale + // read is observable and recoverable; a stale mutation can act on the wrong + // element. The caller re-observes (snapshot) or uses a selector. + if (!authorized) { throw new AppError('COMMAND_FAILED', `Ref ${target.ref} not found or has no bounds`, { hint: STALE_REF_HINT, }); } - return { ...capture, resolved: refreshed }; + return reconcileFreshObservation({ + session, + frameTree, + target, + fallbackLabel, + authorized, + }); } /** diff --git a/src/daemon/__tests__/ref-frame.test.ts b/src/daemon/__tests__/ref-frame.test.ts index 60403801b..50266e1c5 100644 --- a/src/daemon/__tests__/ref-frame.test.ts +++ b/src/daemon/__tests__/ref-frame.test.ts @@ -115,3 +115,16 @@ test('activateCompleteRefFrame re-authorizes a complete frame after expiry', () admitted: true, }); }); + +test('expireRefFrame clears scoped-snapshot lineage at the seam (ADR 0014)', () => { + const scopeTree = { + nodes: [], + createdAt: 0, + backend: 'xctest', + } as unknown as SessionState['snapshot']; + const s = session({ snapshotGeneration: 42, snapshotScopeSource: scopeTree }); + expireRefFrame(s); + // A mutation breaks the consecutive `snapshot -s @ref` chain, so a later + // repeated scoped snapshot cannot borrow stale lineage across the side effect. + assert.equal(s.snapshotScopeSource, undefined); +}); diff --git a/src/daemon/__tests__/session-snapshot.test.ts b/src/daemon/__tests__/session-snapshot.test.ts index 71bb11a66..57e3b670c 100644 --- a/src/daemon/__tests__/session-snapshot.test.ts +++ b/src/daemon/__tests__/session-snapshot.test.ts @@ -2,6 +2,7 @@ import { expect, test } from 'vitest'; import type { SnapshotState } from '../../kernel/snapshot.ts'; import type { SessionState } from '../types.ts'; import { + markSessionPartialRefsIssued, resolveRefStalenessWarning, setSessionSnapshot, STALE_SNAPSHOT_REFS_WARNING, @@ -97,3 +98,24 @@ test('resolveRefStalenessWarning treats a missing stored generation as s0', () = ); expect(resolveRefStalenessWarning({ session, ref: '@e2', mintedGeneration: 0 })).toBeUndefined(); }); + +test('markSessionPartialRefsIssued: an empty result leaves all state untouched (ADR 0014)', () => { + const session = makeSession(); + // A useful prior frame exists. + session.snapshotRefsStale = true; + session.refFrameState = 'active'; + session.refFrameScope = new Set(['e1']); + session.refFrameGeneration = 7; + + // An empty partial publication (no refs) must not supersede that authority or + // even clear the coarse marker. + markSessionPartialRefsIssued(session, []); + expect(session.snapshotRefsStale).toBe(true); + expect(session.refFrameScope).toEqual(new Set(['e1'])); + expect(session.refFrameGeneration).toBe(7); + + // A non-empty result supersedes with exactly its bodies. + markSessionPartialRefsIssued(session, ['@e5~s7', 'e6']); + expect(session.snapshotRefsStale).toBe(false); + expect(session.refFrameScope).toEqual(new Set(['e5', 'e6'])); +}); diff --git a/src/daemon/handlers/__tests__/find.test.ts b/src/daemon/handlers/__tests__/find.test.ts index 2730bb3db..d80ebb56e 100644 --- a/src/daemon/handlers/__tests__/find.test.ts +++ b/src/daemon/handlers/__tests__/find.test.ts @@ -111,8 +111,9 @@ test('handleFindCommands click returns deterministic metadata across locator var positionals: ['Increment', 'click'], nodes: [hittableParentNoRect, nonHittableChildWithRect], invoke: async () => ({ platformSpecificRef: 'XCUIElementTypeView' }), - // refsGeneration rides every ref-issuing find response (#1076 versioned refs). - expectedKeys: ['locator', 'message', 'query', 'ref', 'refsGeneration', 'x', 'y'], + // ADR 0014: a mutating find (click) omits refsGeneration — its ref is + // diagnostic pre-action identity, never a pinnable issued ref. + expectedKeys: ['locator', 'message', 'query', 'ref', 'x', 'y'], expectedLocator: 'any', expectedQuery: 'Increment', expectedCoordinates: { x: 100, y: 50 }, @@ -777,7 +778,7 @@ test('handleFindCommands click re-issues a fresh ref and clears the stale-refs m expect(storedSession.snapshotRefsStale).toBe(false); }); -test('handleFindCommands click carries refsGeneration for the freshly stored tree (#1076 versioned refs)', async () => { +test('handleFindCommands click omits refsGeneration — a mutating find never issues a pinnable ref (ADR 0014)', async () => { const sessionName = 'default'; const session = makeSession(sessionName); // Two earlier tree replacements happened in this session. @@ -799,10 +800,12 @@ test('handleFindCommands click carries refsGeneration for the freshly stored tre }); expect(response.ok).toBe(true); - // The find capture replaced the stored tree (generation 3) and the response - // returns a ref minted from it, so it reports that generation ONCE. + // The find capture still replaced the stored tree (generation 3)… expect(storedSession.snapshotGeneration).toBe(3); if (response.ok) { - expect((response.data as Record).refsGeneration).toBe(3); + // …but a mutating find must NOT report refsGeneration: its acted ref is + // diagnostic pre-action identity, so MCP cannot pin and reuse it after the + // action. + expect((response.data as Record).refsGeneration).toBeUndefined(); } }); diff --git a/src/daemon/handlers/__tests__/interaction.test.ts b/src/daemon/handlers/__tests__/interaction.test.ts index dbdb16031..cd1667943 100644 --- a/src/daemon/handlers/__tests__/interaction.test.ts +++ b/src/daemon/handlers/__tests__/interaction.test.ts @@ -1443,7 +1443,7 @@ test('longpress @ref resolves the target and dispatches coordinate longpress', a expect(sessionStore.get(sessionName)?.actions[0]?.command).toBe('longpress'); }); -test('press @ref refreshes stale stored refs and syncs the daemon session snapshot', async () => { +test('press @ref fails closed when the authorized ref has no usable bounds (ADR 0014)', async () => { const sessionStore = makeSessionStore(); const sessionName = 'stale-ref-refresh'; const session = makeSession(sessionName); @@ -1462,24 +1462,9 @@ test('press @ref refreshes stale stored refs and syncs the daemon session snapsh }; sessionStore.set(sessionName, session); - mockDispatch.mockImplementation(async (_device, command) => { - if (command === 'snapshot') { - return { - nodes: [ - { - index: 0, - type: 'XCUIElementTypeButton', - label: 'Continue', - rect: { x: 10, y: 20, width: 100, height: 40 }, - enabled: true, - hittable: true, - }, - ], - backend: 'xctest', - }; - } - return { pressed: true }; - }); + mockDispatch.mockRejectedValue( + new Error('dispatch must not run: no positional recapture on missing frame evidence'), + ); const response = await handleInteractionCommands({ req: { @@ -1494,18 +1479,15 @@ test('press @ref refreshes stale stored refs and syncs the daemon session snapsh contextFromFlags, }); - expect(response?.ok).toBe(true); - if (response?.ok) { - expect(response.data?.x).toBe(60); - expect(response.data?.y).toBe(40); + // ADR 0014: the authorized frame's @e1 has no usable rect, so the ref FAILS + // rather than recapturing and accepting the same index from a newer tree by + // positional coincidence. No fresh capture, no dispatch. + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('COMMAND_FAILED'); + expect(response.error.message).toMatch(/not found or has no bounds/); } - expect(mockDispatch.mock.calls.map((call) => call[1])).toEqual(['snapshot', 'press']); - expect(sessionStore.get(sessionName)?.snapshot?.nodes[0]?.rect).toEqual({ - x: 10, - y: 20, - width: 100, - height: 40, - }); + expect(mockDispatch).not.toHaveBeenCalled(); }); test('press @ref refreshes Android snapshot when freshness tracking is active', async () => { @@ -2515,7 +2497,7 @@ test('click --button middle on macOS fails with an explicit unsupported-operatio } }); -test('press @ref refreshes snapshot when stored ref bounds are invalid', async () => { +test('press @ref fails closed when stored ref bounds are invalid (ADR 0014)', async () => { const sessionStore = makeSessionStore(); const sessionName = 'default'; const session = makeSession(sessionName); @@ -2542,26 +2524,9 @@ test('press @ref refreshes snapshot when stored ref bounds are invalid', async ( }; sessionStore.set(sessionName, session); - let snapshotCalls = 0; - mockDispatch.mockImplementation(async (_device, command, _positionals) => { - if (command === 'snapshot') { - snapshotCalls += 1; - return { - nodes: [ - { - index: 0, - type: 'android.widget.TextView', - label: 'My App', - rect: { x: 20, y: 40, width: 100, height: 40 }, - enabled: true, - hittable: true, - }, - ], - backend: 'android', - }; - } - return { pressed: true }; - }); + mockDispatch.mockRejectedValue( + new Error('dispatch must not run: no positional recapture on unusable frame evidence'), + ); const response = await handleInteractionCommands({ req: { @@ -2576,17 +2541,14 @@ test('press @ref refreshes snapshot when stored ref bounds are invalid', async ( contextFromFlags, }); - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - expect(snapshotCalls).toBe(1); - const pressCalls = mockDispatch.mock.calls.filter((c) => c[1] === 'press'); - expect(pressCalls.length).toBe(1); - expect(pressCalls[0]?.[2]).toEqual(['70', '60']); - if (response?.ok) { - expect(response.data?.x).toBe(70); - expect(response.data?.y).toBe(60); - expect(response.data?.ref).toBe('e1'); + // ADR 0014: the authorized frame's @e1 has an unusable rect (NaN), so it FAILS + // rather than recapturing and accepting the same index from a newer tree. + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('COMMAND_FAILED'); + expect(response.error.message).toMatch(/not found or has no bounds/); } + expect(mockDispatch).not.toHaveBeenCalled(); }); test('press @ref fails fast when the target is off-screen', async () => { @@ -2640,7 +2602,7 @@ test('press @ref fails fast when the target is off-screen', async () => { } }); -test('press @ref fallback label is used after refresh when ref bounds remain invalid', async () => { +test('press @ref with a trailing label recovers within the authorized frame (no positional recapture)', async () => { const sessionStore = makeSessionStore(); const sessionName = 'default'; const session = makeSession(sessionName); @@ -2651,16 +2613,27 @@ test('press @ref fallback label is used after refresh when ref bounds remain inv kind: 'emulator', booted: true, }; + // @e1's rect is unusable, but the SAME frame tree carries another node with + // the trailing label at usable bounds. ADR 0014 label recovery resolves within + // the authorized frame — never by recapturing a fresh tree. session.snapshot = { nodes: attachRefs([ { index: 0, type: 'android.widget.TextView', - label: 'My App', + label: 'Different', rect: { x: 20, y: 40, width: Number.NaN, height: 40 }, enabled: true, hittable: true, }, + { + index: 1, + type: 'android.widget.TextView', + label: 'My App', + rect: { x: 100, y: 200, width: 80, height: 40 }, + enabled: true, + hittable: true, + }, ]), createdAt: Date.now(), backend: 'android', @@ -2668,29 +2641,7 @@ test('press @ref fallback label is used after refresh when ref bounds remain inv sessionStore.set(sessionName, session); mockDispatch.mockImplementation(async (_device, command) => { - if (command === 'snapshot') { - return { - nodes: [ - { - index: 0, - type: 'android.widget.TextView', - label: 'Different', - rect: { x: 20, y: 40, width: Number.NaN, height: 40 }, - enabled: true, - hittable: true, - }, - { - index: 1, - type: 'android.widget.TextView', - label: 'My App', - rect: { x: 100, y: 200, width: 80, height: 40 }, - enabled: true, - hittable: true, - }, - ], - backend: 'android', - }; - } + if (command === 'snapshot') throw new Error('no positional recapture: recovery stays in-frame'); return { pressed: true }; }); @@ -2773,7 +2724,7 @@ test('fill @ref fails fast when the target is off-screen', async () => { } }); -test('fill @ref refreshes snapshot when stored ref bounds are invalid', async () => { +test('fill @ref fails closed when stored ref bounds are invalid (ADR 0014)', async () => { const sessionStore = makeSessionStore(); const sessionName = 'default'; const session = makeSession(sessionName); @@ -2800,26 +2751,9 @@ test('fill @ref refreshes snapshot when stored ref bounds are invalid', async () }; sessionStore.set(sessionName, session); - let snapshotCalls = 0; - mockDispatch.mockImplementation(async (_device, command) => { - if (command === 'snapshot') { - snapshotCalls += 1; - return { - nodes: [ - { - index: 0, - type: 'android.widget.EditText', - label: 'Email', - rect: { x: 20, y: 40, width: 100, height: 40 }, - enabled: true, - hittable: true, - }, - ], - backend: 'android', - }; - } - return { filled: true }; - }); + mockDispatch.mockRejectedValue( + new Error('dispatch must not run: no positional recapture on unusable frame evidence'), + ); const response = await handleInteractionCommands({ req: { @@ -2834,19 +2768,14 @@ test('fill @ref refreshes snapshot when stored ref bounds are invalid', async () contextFromFlags, }); - expect(response).toBeTruthy(); - expect(response?.ok).toBe(true); - expect(snapshotCalls).toBe(1); - const fillCalls = mockDispatch.mock.calls.filter((c) => c[1] === 'fill'); - expect(fillCalls.length).toBe(1); - expect(fillCalls[0]?.[2]).toEqual(['70', '60', 'hello@example.com']); - expect((fillCalls[0]?.[4] as Record | undefined)?.delayMs).toBe(25); - - const stored = sessionStore.get(sessionName); - const result = (stored?.actions[0]?.result ?? {}) as Record; - expect(result.ref).toBe('e1'); - expect(result.x).toBe(70); - expect(result.y).toBe(60); + // ADR 0014: the authorized frame's @e1 has an unusable rect, so the fill FAILS + // rather than recapturing a fresh tree and filling the same index. + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('COMMAND_FAILED'); + expect(response.error.message).toMatch(/not found or has no bounds/); + } + expect(mockDispatch).not.toHaveBeenCalled(); }); test('press coordinates does not treat extra trailing args as selector', async () => { diff --git a/src/daemon/handlers/__tests__/snapshot-scoped-refs.test.ts b/src/daemon/handlers/__tests__/snapshot-scoped-refs.test.ts index ff337f037..cf773bcc3 100644 --- a/src/daemon/handlers/__tests__/snapshot-scoped-refs.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-scoped-refs.test.ts @@ -4,6 +4,7 @@ import type { RawSnapshotNode, SnapshotState } from '../../../kernel/snapshot.ts import { dispatchCommand } from '../../../core/dispatch.ts'; import { makeAndroidSession } from '../../../__tests__/test-utils/index.ts'; import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; +import { expireRefFrame } from '../../ref-frame.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); @@ -49,6 +50,35 @@ test('snapshot resolves @ref scope with the stored source after scoped output re expect(sessionStore.get(sessionName)?.snapshotScopeSource?.nodes[2]?.ref).toBe('e3'); }); +test('a mutation clears scoped-snapshot lineage so a repeated snapshot -s @ref cannot borrow it (ADR 0014)', async () => { + const sessionStore = makeSessionStore('agent-device-snapshot-scoped-refs-'); + const sessionName = 'android-ref-scope-broken-by-mutation'; + const session = makeAndroidSession(sessionName, { snapshot: androidRefScopeSourceSnapshot() }); + sessionStore.set(sessionName, session); + + mockDispatch.mockResolvedValue({ + nodes: scopedScriptErrorNodes(), + truncated: false, + backend: 'android', + }); + + // First scoped snapshot establishes the lineage and reindexes the stored refs + // (the reduced output no longer contains @e3). + const first = await requestScopedSnapshot(sessionName, sessionStore, '@e3'); + expect(first?.ok).toBe(true); + expect(sessionStore.get(sessionName)?.snapshotScopeSource?.nodes[2]?.ref).toBe('e3'); + + // A device mutation crosses the side-effect seam and clears the lineage. + expireRefFrame(sessionStore.get(sessionName)!); + expect(sessionStore.get(sessionName)?.snapshotScopeSource).toBeUndefined(); + + // The repeated `snapshot -s @e3` can no longer borrow the stale lineage: the + // reindexed stored tree has no @e3, so it fails closed rather than resolving a + // different subtree. + const second = await requestScopedSnapshot(sessionName, sessionStore, '@e3'); + expect(second?.ok).toBe(false); +}); + test('empty @ref-scoped snapshot output does not replace the stored session snapshot', async () => { const sessionStore = makeSessionStore('agent-device-snapshot-scoped-refs-'); const sessionName = 'android-empty-scope-preserve'; diff --git a/src/daemon/handlers/find.ts b/src/daemon/handlers/find.ts index 09288d143..1f6616a39 100644 --- a/src/daemon/handlers/find.ts +++ b/src/daemon/handlers/find.ts @@ -166,6 +166,21 @@ export async function handleFindCommands(params: { sessionStore.set(sessionName, session); } + return dispatchFindAction(ctx, match, action, value); +} + +/** + * Run the selected find action and, for a READ-ONLY action only, attach the + * issued `refsGeneration`. A mutating find (click/fill/focus/type) returns + * `data.ref` solely as diagnostic pre-action identity (ADR 0014) — it must omit + * `refsGeneration` so MCP cannot pin and reuse it after the action. + */ +async function dispatchFindAction( + ctx: FindContext, + match: ResolvedMatch, + action: string, + value: string | undefined, +): Promise { const actionHandlers: Record Promise> = { exists: () => handleFindExists(ctx), get_text: () => handleFindGetText(ctx, match), @@ -178,11 +193,12 @@ export async function handleFindCommands(params: { const handler = actionHandlers[action]; if (!handler) return null; - // Re-read the session AFTER the handler: internal click/fill sub-invocations - // may have replaced the stored tree again (Android freshness refresh), and - // the reported generation must describe the tree the response's ref resolves - // against at response time. - return attachIssuedRefsGeneration(await handler(), () => sessionStore.get(sessionName)); + const result = await handler(); + if (!isReadOnlyFindAction(action)) return result; + // Re-read the session AFTER the handler: the read capture may have replaced the + // stored tree, and the reported generation must describe the tree the + // response's ref resolves against at response time. + return attachIssuedRefsGeneration(result, () => ctx.sessionStore.get(ctx.sessionName)); } /** diff --git a/src/daemon/handlers/interaction-runtime.ts b/src/daemon/handlers/interaction-runtime.ts index dd7a07e49..13c9c6a55 100644 --- a/src/daemon/handlers/interaction-runtime.ts +++ b/src/daemon/handlers/interaction-runtime.ts @@ -35,7 +35,13 @@ export function createInteractionRuntime( sessions: createDaemonRuntimeSessionStore({ sessionName: params.sessionName, getSession: () => session, - recordOptions: { includeSnapshot: true }, + recordOptions: { + includeSnapshot: true, + // ADR 0014: a mutating find's internal dispatch already re-resolved its + // target by locator against the fresh capture, so it resolves against the + // observation, not the authorized frame tree. + omitRefFrameSnapshot: params.req.internal?.findResolvedTarget === true, + }, setRecord: (record) => { if (!record.snapshot) return; setSessionSnapshot(session, record.snapshot); diff --git a/src/daemon/handlers/interaction-touch.ts b/src/daemon/handlers/interaction-touch.ts index b82e57dd0..e0af752a8 100644 --- a/src/daemon/handlers/interaction-touch.ts +++ b/src/daemon/handlers/interaction-touch.ts @@ -16,7 +16,7 @@ import type { LongPressCommandResult, PressCommandResult, } from '../../contracts/interaction.ts'; -import { asAppError, normalizeError } from '../../kernel/errors.ts'; +import { AppError, asAppError, normalizeError } from '../../kernel/errors.ts'; import type { ReplayTargetGuardDenotation } from '../../replay/target-identity-node.ts'; import type { DaemonResponse, SessionState } from '../types.ts'; import { finalizeTouchInteraction, type InteractionHandlerParams } from './interaction-common.ts'; @@ -65,7 +65,7 @@ import { type AndroidBlockingDialogReadinessResult, } from '../android-system-dialog.ts'; import { refMutationAdmissionResponse } from './interaction-ref-policy.ts'; -import { expireRefFrame } from '../ref-frame.ts'; +import { expireRefFrame, refFrameState } from '../ref-frame.ts'; export async function handleTouchInteractionCommands( params: InteractionHandlerParams & { @@ -185,6 +185,7 @@ async function dispatchTargetedTouchViaRuntime( return await dispatchRuntimeInteraction(params, { androidFreshnessBaseline, + refTarget: parsedTarget.target.kind === 'ref', run: async (runtime) => await runTargetedTouchInteraction({ runtime, @@ -593,6 +594,7 @@ async function dispatchFillViaRuntime( if (directResponse) return directResponse; return await dispatchRuntimeInteraction(params, { + refTarget: parsedTarget.target.kind === 'ref', run: async (runtime) => await runtime.interactions.fill(parsedTarget.target, parsedTarget.text, { session: sessionName, @@ -730,6 +732,8 @@ async function dispatchRuntimeInteraction< }, options: { androidFreshnessBaseline?: SessionState['snapshot']; + /** True when the action targets a `@ref`; aborts if Android dialog recovery expires the frame first. */ + refTarget?: boolean; run(runtime: ReturnType): Promise; afterRun?(result: TResult): Promise; buildPayloads( @@ -745,6 +749,7 @@ async function dispatchRuntimeInteraction< const { readiness, runtimeResult } = await runWithAndroidDialogReadinessCheck( session, params.req.command, + { refTarget: options.refTarget === true }, async () => { const result = await options.run(runtime); await options.afterRun?.(result); @@ -787,6 +792,7 @@ async function dispatchRuntimeInteraction< async function runWithAndroidDialogReadinessCheck( session: SessionState, command: string, + options: { refTarget: boolean }, run: () => Promise, ): Promise<{ readiness: AndroidBlockingDialogReadinessResult; @@ -800,6 +806,24 @@ async function runWithAndroidDialogReadinessCheck( command, phase: 'before-command', }); + // ADR 0014: blocking-dialog recovery is itself device-mutating and expires the + // frame at its own seam. A ref action admitted against the pre-recovery frame + // must NOT continue against the recovered UI — abort it with ref_frame_expired. + // (Selector/coordinate actions re-resolve and continue under their own policy.) + if ( + options.refTarget && + readiness.status === 'recovered' && + refFrameState(session) === 'expired' + ) { + throw new AppError( + 'COMMAND_FAILED', + `Ref belongs to an expired ref frame — Android dialog recovery changed the screen before ${command} dispatched`, + { + reason: 'ref_frame_expired', + hint: 'Capture a fresh interactive snapshot (snapshot -i) or use a stable selector, then retry.', + }, + ); + } const runtimeResult = await run(); await ensureAndroidBlockingSystemDialogReady({ session, diff --git a/src/daemon/handlers/session-open.ts b/src/daemon/handlers/session-open.ts index f0a19ac40..e2ba1cc40 100644 --- a/src/daemon/handlers/session-open.ts +++ b/src/daemon/handlers/session-open.ts @@ -255,6 +255,11 @@ async function completeOpenCommand(params: { isIosSimulator(device) && req.flags?.clearAppState !== true; if (shouldRelaunch && openTarget && !collapseSimulatorRelaunch) { + // ADR 0014 side-effect seam: the relaunch close is the FIRST device dispatch + // against the existing session. Expire its frame before awaiting the close, + // so a close timeout/failure that may already have torn the app down still + // leaves the old frame expired rather than active. + if (existingSession) expireRefFrame(existingSession); const closeTarget = sessionAppBundleId ?? openTarget; const closeStartedAtMs = Date.now(); await relaunchCloseApp({ diff --git a/src/daemon/ref-frame.ts b/src/daemon/ref-frame.ts index fb83f574b..b63f7d0e5 100644 --- a/src/daemon/ref-frame.ts +++ b/src/daemon/ref-frame.ts @@ -61,9 +61,15 @@ export function refFrameEpoch(session: SessionState): number | undefined { * element identity, so that a post-dispatch failure (timeout, connection loss, * ambiguous error) still leaves the frame expired — there is no success-only * rollback. + * + * Crossing the seam also clears the scoped-snapshot lineage (`snapshotScopeSource`, + * ADR 0014): a mutation breaks the consecutive `snapshot -s @ref` chain, so a + * later repeated scoped snapshot cannot borrow stale lineage across a device + * side effect. */ export function expireRefFrame(session: SessionState): void { session.refFrameState = 'expired'; + session.snapshotScopeSource = undefined; } /** diff --git a/src/daemon/runtime-session.ts b/src/daemon/runtime-session.ts index d0d01917c..32591e984 100644 --- a/src/daemon/runtime-session.ts +++ b/src/daemon/runtime-session.ts @@ -4,6 +4,13 @@ import type { SessionState } from './types.ts'; export type RuntimeSessionRecordOptions = { includeSnapshot?: boolean; + /** + * ADR 0014: omit the authorized frame tree so `@ref` resolution binds against + * the latest observation instead. Set for a mutating `find`'s internal leaf + * dispatch, whose ref was just re-resolved by locator against the find's fresh + * capture — the frame model does not govern that internal re-resolution. + */ + omitRefFrameSnapshot?: boolean; metadata?: Record; }; @@ -23,7 +30,9 @@ 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 ? { refFrameSnapshot: session.refFrameTree } : {}), + ...(session.refFrameTree && options.omitRefFrameSnapshot !== true + ? { refFrameSnapshot: session.refFrameTree } + : {}), } : {}), metadata: { diff --git a/src/daemon/session-snapshot.ts b/src/daemon/session-snapshot.ts index 90c0e6980..c45e6f72d 100644 --- a/src/daemon/session-snapshot.ts +++ b/src/daemon/session-snapshot.ts @@ -101,13 +101,16 @@ function normalizeRefBody(ref: string): string { * authority (it leaves the frame untouched), so a useful prior frame survives. */ export function markSessionPartialRefsIssued(session: SessionState, refs: Iterable): void { - session.snapshotRefsStale = false; const scope = new Set(); for (const ref of refs) { const body = normalizeRefBody(ref); if (body.length > 0) scope.add(body); } + // ADR 0014: an empty partial result does not supersede existing authority — it + // leaves ALL session state untouched, including the coarse marker. Build the + // scope before touching anything so a no-ref result is a true no-op. if (scope.size === 0) return; + session.snapshotRefsStale = false; session.refFrameState = 'active'; session.refFrameScope = scope; // ADR 0014: retain the tree this partial result published from as the frame's From 203d18485eab1822f330cb18d7a76a8fe70c563c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 16:04:51 +0000 Subject: [PATCH 12/14] fix: preserve snapshot refsGeneration + shared recovery rejection (ADR 0014 re-review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1: structured JSON/Node snapshot results now retain the response-level refsGeneration. It was declared on the daemon response but dropped by the public CaptureSnapshotResult type, the serializer, and the Node normalizer, so default `snapshot -i --json` emitted refs with no generation to pin against. Added to the type, serializer, normalizer, plus CLI/Node tests. P2: Android dialog-recovery abort now reuses the SHARED admission rejection (refMutationAdmissionResponse) instead of a bespoke error, so the failure carries the full typed context (reason, ref, currentGeneration, scope, mintedGeneration) identical to every other expired-frame rejection across platforms. Removes the now-unused AppError/refFrameState imports. Adds a regression proving recovery aborts the outstanding ref action before any press dispatch. Also adds the relaunch failure-boundary regression (existing-session close fails after dispatch → old frame stays expired), and corrects the ADR implementation-status note so Android blocking-dialog recovery and a real provider-backed interaction/lifecycle are recorded as unexercised release blockers rather than confirmed enablement. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa --- docs/adr/0014-session-ref-frame-lifetime.md | 33 ++++--- src/__tests__/client.test.ts | 18 ++++ src/agent-device-client.ts | 4 + src/client/client-types.ts | 6 ++ src/contracts/result-serialization.test.ts | 13 +++ src/contracts/result-serialization.ts | 3 + ...interaction-android-recovery-abort.test.ts | 96 +++++++++++++++++++ .../__tests__/session-relaunch-close.test.ts | 45 +++++++++ src/daemon/handlers/interaction-touch.ts | 89 +++++++++++------ 9 files changed, 262 insertions(+), 45 deletions(-) create mode 100644 src/daemon/handlers/__tests__/interaction-android-recovery-abort.test.ts diff --git a/docs/adr/0014-session-ref-frame-lifetime.md b/docs/adr/0014-session-ref-frame-lifetime.md index 25135fbe5..23128b7ac 100644 --- a/docs/adr/0014-session-ref-frame-lifetime.md +++ b/docs/adr/0014-session-ref-frame-lifetime.md @@ -68,15 +68,15 @@ compatibility. ### Frame transitions -| Event | Frame transition | Plain refs | Pinned refs | -| --- | --- | --- | --- | -| Internal observation that returns no refs | None | Unchanged | Unchanged | -| Complete frame activation | New active epoch, scope `all` | Accepted from this frame | Accepted when epoch matches | -| Non-empty partial publication | New active epoch, scope = emitted refs | Rejected | Accepted only for an emitted ref at this epoch | -| First possible device-side effect | Advance once to `expired` | Rejected | Previous epoch rejected | -| Additional effects while already expired | Idempotent | Rejected | Rejected | -| Sparse, failed, or unusable capture | None | Unchanged | Unchanged | -| Session reopen | New random-seeded lifetime | Rejected until publication | Old lifetime rejected probabilistically as today | +| Event | Frame transition | Plain refs | Pinned refs | +| ----------------------------------------- | -------------------------------------- | -------------------------- | ------------------------------------------------ | +| Internal observation that returns no refs | None | Unchanged | Unchanged | +| Complete frame activation | New active epoch, scope `all` | Accepted from this frame | Accepted when epoch matches | +| Non-empty partial publication | New active epoch, scope = emitted refs | Rejected | Accepted only for an emitted ref at this epoch | +| First possible device-side effect | Advance once to `expired` | Rejected | Previous epoch rejected | +| Additional effects while already expired | Idempotent | Rejected | Rejected | +| Sparse, failed, or unusable capture | None | Unchanged | Unchanged | +| Session reopen | New random-seeded lifetime | Rejected until publication | Old lifetime rejected probabilistically as today | A complete activation normally accompanies a command result that exposes the complete ref namespace for the stored frame, including an interactive or intentionally scoped snapshot. An intentionally @@ -209,9 +209,7 @@ The command descriptor's daemon facet declares a request policy: ```ts type RefFrameEffect = 'preserve' | 'may-invalidate' | 'delegated'; -type DaemonRefFrameEffect = - | RefFrameEffect - | ((request: DaemonRequest) => RefFrameEffect); +type DaemonRefFrameEffect = RefFrameEffect | ((request: DaemonRequest) => RefFrameEffect); ``` The daemon registry exposes a named resolver. Every daemon command is classified, but the @@ -444,9 +442,14 @@ complete daemon classification and gate (2), the pre-side-effect seam at every l complete/partial publication with bounded scope, MCP pin retention, and pinned partial CLI text (4), Android freshness decoupled from positional ref authorization (5), the cross-platform contract and provider evidence (6), and fail-closed admission enforcement across platforms with typed reasons (7). -Per-platform enablement is confirmed by the live-evidence runs required above; step 8's removal of the -superseded coarse `snapshotRefsStale` marker follows that confirmation so read-only warnings are not -disturbed before enforcement is proven on hardware. +Fresh live evidence has exercised most production seams — Apple runtime-ref, direct/native selector, +generation-pin, generic, and lifecycle paths, plus Android helper freshness (including proven +non-retarget) and Android existing-session relaunch. Two seams remain UNEXERCISED and are therefore +explicit release blockers, not confirmed enablement: Android blocking-dialog recovery, and a real +provider-backed interaction plus provider-backed lifecycle operation. Enforcement stays enabled in +code, but those two seams are not claimed as verified until their live runs exist. Step 8's removal of +the superseded coarse `snapshotRefsStale` marker follows full confirmation, so read-only warnings are +not disturbed before every enabled seam is proven on hardware. PR #1241 landed independently as a compatible transitional fix. It rejects a known iOS stale-marker case before this full lifecycle is implemented; it does not own the architecture migration. diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index f08b038ba..2d7eb46c0 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -903,6 +903,24 @@ test('client capture.snapshot preserves visibility metadata from daemon response }); }); +test('client capture.snapshot preserves refsGeneration from daemon responses (ADR 0014)', async () => { + const setup = createTransport(async () => ({ + ok: true, + data: { + nodes: [{ ref: 'e1', index: 0, depth: 0, type: 'Button', label: 'Go' }], + truncated: false, + refsGeneration: 752890, + }, + })); + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); + + const result = await client.capture.snapshot(); + + // Node.js callers must retain the response-level generation to pin a plain ref + // (`@e1~s752890`) before a mutation. + assert.equal(result.refsGeneration, 752890); +}); + test('client capture.snapshot preserves snapshot quality annotation from daemon responses', async () => { const snapshotQuality = { state: 'recovered', diff --git a/src/agent-device-client.ts b/src/agent-device-client.ts index 0806d0139..6c7f7af09 100644 --- a/src/agent-device-client.ts +++ b/src/agent-device-client.ts @@ -501,6 +501,7 @@ function optionalSnapshotResponseFields( | 'warnings' | 'snapshotQuality' | 'snapshotDiagnostics' + | 'refsGeneration' > > { const visibility = readObject(data.visibility); @@ -511,6 +512,9 @@ function optionalSnapshotResponseFields( ...readSerializedSnapshotCaptureAnnotations(data), ...(unchanged ? { unchanged: unchanged as CaptureSnapshotResult['unchanged'] } : {}), ...(snapshotDiagnostics ? { snapshotDiagnostics } : {}), + // ADR 0014: keep the response-level ref-frame generation on Node.js results + // so callers can pin refs (`@e12~s`) before a mutation. + ...(typeof data.refsGeneration === 'number' ? { refsGeneration: data.refsGeneration } : {}), }; } diff --git a/src/client/client-types.ts b/src/client/client-types.ts index 5ee50a08d..338baf409 100644 --- a/src/client/client-types.ts +++ b/src/client/client-types.ts @@ -450,6 +450,12 @@ export type CaptureSnapshotResult = { unchanged?: SnapshotUnchanged; snapshotDiagnostics?: SnapshotDiagnosticsSummary; identifiers: AgentDeviceIdentifiers; + /** + * ADR 0014: the response-level ref-frame epoch the plain node refs were minted + * from. A ref-issuing snapshot carries it ONCE (nodes stay plain `@e12` for the + * token budget); pair a ref with it (`@e12~s`) before a mutation. + */ + refsGeneration?: number; } & PublicSnapshotCaptureAnnotations; export type CaptureScreenshotOptions = AgentDeviceRequestOverrides & { diff --git a/src/contracts/result-serialization.test.ts b/src/contracts/result-serialization.test.ts index 0b8b56ebc..6044ecff1 100644 --- a/src/contracts/result-serialization.test.ts +++ b/src/contracts/result-serialization.test.ts @@ -131,6 +131,19 @@ test('serializeSnapshotResult includes Android backend metadata', () => { }); }); +test('serializeSnapshotResult preserves the response-level refsGeneration (ADR 0014)', () => { + const data = serializeSnapshotResult({ + nodes: [{ ref: 'e1', index: 0, depth: 0, type: 'Button', label: 'Go' }], + truncated: false, + refsGeneration: 752890, + identifiers: { session: 'qa' }, + } as Parameters[0]); + + assert.equal(data.refsGeneration, 752890); + // The node tree stays plain — the generation rides once at the response level. + assert.equal((data.nodes as Array<{ ref?: string }>)[0]?.ref, 'e1'); +}); + test('serializeSnapshotResult maps capture quality annotation to public snapshotQuality', () => { const snapshotQuality = { state: 'healthy', diff --git a/src/contracts/result-serialization.ts b/src/contracts/result-serialization.ts index 8ce644810..df38a8943 100644 --- a/src/contracts/result-serialization.ts +++ b/src/contracts/result-serialization.ts @@ -182,6 +182,9 @@ export function serializeSnapshotResult(result: CaptureSnapshotResult): Record` before a mutation. + ...(result.refsGeneration !== undefined ? { refsGeneration: result.refsGeneration } : {}), }; } diff --git a/src/daemon/handlers/__tests__/interaction-android-recovery-abort.test.ts b/src/daemon/handlers/__tests__/interaction-android-recovery-abort.test.ts new file mode 100644 index 000000000..7c5a8d07c --- /dev/null +++ b/src/daemon/handlers/__tests__/interaction-android-recovery-abort.test.ts @@ -0,0 +1,96 @@ +import { test, expect, vi, beforeEach } from 'vitest'; + +// ADR 0014 (evidence #13): Android blocking-dialog recovery is device-mutating +// and expires the frame at its own seam. A ref action admitted against the +// pre-recovery frame must ABORT after recovery mutates — it cannot continue +// against the recovered UI — and the rejection must use the SHARED admission +// shape (reason + ref + currentGeneration + scope), not a bespoke error. + +vi.mock('../../../core/dispatch.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, dispatchCommand: vi.fn(async () => ({})) }; +}); + +vi.mock('../interaction-snapshot.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + captureSnapshotForSession: vi.fn(async () => ({ nodes: [], createdAt: 0, backend: 'android' })), + }; +}); + +// Simulate recovery: the before-command readiness check taps a blocking dialog +// (a device mutation) and reports `recovered`, expiring the frame. +vi.mock('../../android-system-dialog.ts', async (importOriginal) => { + const actual = await importOriginal(); + const { expireRefFrame } = await import('../../ref-frame.ts'); + return { + ...actual, + ensureAndroidBlockingSystemDialogReady: vi.fn( + async (params: { session: import('../../types.ts').SessionState; phase: string }) => { + if (params.phase === 'before-command') { + expireRefFrame(params.session); + return { status: 'recovered', warning: 'Recovered from a blocking system dialog' }; + } + return { status: 'clear' }; + }, + ), + }; +}); + +import { handleInteractionCommands } from '../interaction.ts'; +import { dispatchCommand } from '../../../core/dispatch.ts'; +import { attachRefs } from '../../../kernel/snapshot.ts'; +import { makeAndroidSession } from '../../../__tests__/test-utils/session-factories.ts'; +import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; + +const mockDispatch = vi.mocked(dispatchCommand); +const contextFromFlags = () => ({}); + +beforeEach(() => { + mockDispatch.mockReset(); + mockDispatch.mockResolvedValue({}); +}); + +test('a ref action aborts with the shared ref_frame_expired rejection after Android dialog recovery mutates', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'android-recovery-abort'; + const session = makeAndroidSession(sessionName); + session.snapshot = { + nodes: attachRefs([ + { + index: 0, + type: 'android.widget.Button', + label: 'Continue', + rect: { x: 0, y: 0, width: 80, height: 80 }, + enabled: true, + hittable: true, + }, + ]), + createdAt: Date.now(), + backend: 'android', + }; + session.snapshotGeneration = 900; + // A freshly issued complete frame is active. + sessionStore.set(sessionName, session); + + const response = await handleInteractionCommands({ + req: { token: 't', session: sessionName, command: 'press', positionals: ['@e1'], flags: {} }, + sessionName, + sessionStore, + contextFromFlags, + }); + + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('COMMAND_FAILED'); + // The SHARED admission rejection shape — not a bespoke recovery error. + const details = response.error.details as Record | undefined; + expect(details?.reason).toBe('ref_frame_expired'); + expect(details?.ref).toBe('@e1'); + expect(details?.currentGeneration).toBe(900); + expect(details?.scope).toBe('all'); + } + // The outstanding ref action never dispatched a press against the recovered UI. + expect(mockDispatch.mock.calls.some((call) => call[1] === 'press')).toBe(false); +}); diff --git a/src/daemon/handlers/__tests__/session-relaunch-close.test.ts b/src/daemon/handlers/__tests__/session-relaunch-close.test.ts index 11f37a6d2..b07641567 100644 --- a/src/daemon/handlers/__tests__/session-relaunch-close.test.ts +++ b/src/daemon/handlers/__tests__/session-relaunch-close.test.ts @@ -57,6 +57,51 @@ test('open --relaunch closes and reopens active session app', async () => { expect(calls[1]).toEqual({ command: 'open', positionals: ['com.example.app'] }); }); +test('open --relaunch leaves the old frame expired when the close dispatch fails after dispatch (ADR 0014)', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'android-session'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel Emulator', + kind: 'emulator', + booted: true, + }), + appName: 'com.example.app', + snapshotGeneration: 400, + }); + // A freshly issued frame is active before the relaunch. + expect(sessionStore.get(sessionName)?.refFrameState).toBeUndefined(); + + // The relaunch close dispatches and then fails/times out AFTER the app may + // already have been torn down. + mockDispatch.mockImplementation(async (_device, command) => { + if (command === 'close') throw new Error('adb: close timed out after dispatch'); + return {}; + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: [], + flags: { relaunch: true }, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }).catch(() => null); + + // Whether the failure surfaced as an error response or a throw, the existing + // 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'); +}); + test('open --relaunch on iOS stops runner before close/open', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-session'; diff --git a/src/daemon/handlers/interaction-touch.ts b/src/daemon/handlers/interaction-touch.ts index e0af752a8..49b2d7e95 100644 --- a/src/daemon/handlers/interaction-touch.ts +++ b/src/daemon/handlers/interaction-touch.ts @@ -16,7 +16,7 @@ import type { LongPressCommandResult, PressCommandResult, } from '../../contracts/interaction.ts'; -import { AppError, asAppError, normalizeError } from '../../kernel/errors.ts'; +import { asAppError, normalizeError } from '../../kernel/errors.ts'; import type { ReplayTargetGuardDenotation } from '../../replay/target-identity-node.ts'; import type { DaemonResponse, SessionState } from '../types.ts'; import { finalizeTouchInteraction, type InteractionHandlerParams } from './interaction-common.ts'; @@ -65,7 +65,7 @@ import { type AndroidBlockingDialogReadinessResult, } from '../android-system-dialog.ts'; import { refMutationAdmissionResponse } from './interaction-ref-policy.ts'; -import { expireRefFrame, refFrameState } from '../ref-frame.ts'; +import { expireRefFrame } from '../ref-frame.ts'; export async function handleTouchInteractionCommands( params: InteractionHandlerParams & { @@ -185,7 +185,14 @@ async function dispatchTargetedTouchViaRuntime( return await dispatchRuntimeInteraction(params, { androidFreshnessBaseline, - refTarget: parsedTarget.target.kind === 'ref', + refContext: + parsedTarget.target.kind === 'ref' && req.internal?.findResolvedTarget !== true + ? { + ref: parsedTarget.target.ref, + mintedGeneration: parsedTarget.refGeneration, + staleRefsWarning, + } + : undefined, run: async (runtime) => await runTargetedTouchInteraction({ runtime, @@ -594,7 +601,14 @@ async function dispatchFillViaRuntime( if (directResponse) return directResponse; return await dispatchRuntimeInteraction(params, { - refTarget: parsedTarget.target.kind === 'ref', + refContext: + parsedTarget.target.kind === 'ref' && req.internal?.findResolvedTarget !== true + ? { + ref: parsedTarget.target.ref, + mintedGeneration: parsedTarget.refGeneration, + staleRefsWarning, + } + : undefined, run: async (runtime) => await runtime.interactions.fill(parsedTarget.target, parsedTarget.text, { session: sessionName, @@ -732,8 +746,12 @@ async function dispatchRuntimeInteraction< }, options: { androidFreshnessBaseline?: SessionState['snapshot']; - /** True when the action targets a `@ref`; aborts if Android dialog recovery expires the frame first. */ - refTarget?: boolean; + /** + * Present when the action targets a `@ref`: if Android dialog recovery + * expires the frame before dispatch, the action aborts through the shared + * admission rejection built from this context. + */ + refContext?: RefAdmissionContext; run(runtime: ReturnType): Promise; afterRun?(result: TResult): Promise; buildPayloads( @@ -746,16 +764,18 @@ async function dispatchRuntimeInteraction< const runtime = createInteractionRuntime(params); const actionStartedAt = Date.now(); try { - const { readiness, runtimeResult } = await runWithAndroidDialogReadinessCheck( + const outcome = await runWithAndroidDialogReadinessCheck( session, params.req.command, - { refTarget: options.refTarget === true }, + { refContext: options.refContext }, async () => { const result = await options.run(runtime); await options.afterRun?.(result); return result; }, ); + if (outcome.aborted) return outcome.response; + const { readiness, runtimeResult } = outcome; const actionFinishedAt = Date.now(); const { result, responseData, recordedTarget } = await options.buildPayloads(runtimeResult); if (readiness.status === 'recovered') { @@ -789,17 +809,28 @@ async function dispatchRuntimeInteraction< } } +type RefAdmissionContext = { + ref: string; + mintedGeneration: number | undefined; + staleRefsWarning: string | undefined; +}; + +type ReadinessOutcome = + | { aborted: true; response: DaemonResponse } + | { + aborted: false; + readiness: AndroidBlockingDialogReadinessResult; + runtimeResult: TResult; + }; + async function runWithAndroidDialogReadinessCheck( session: SessionState, command: string, - options: { refTarget: boolean }, + options: { refContext: RefAdmissionContext | undefined }, run: () => Promise, -): Promise<{ - readiness: AndroidBlockingDialogReadinessResult; - runtimeResult: TResult; -}> { +): Promise> { if (session.lease?.leaseProvider) { - return { readiness: { status: 'clear' }, runtimeResult: await run() }; + return { aborted: false, readiness: { status: 'clear' }, runtimeResult: await run() }; } const readiness = await ensureAndroidBlockingSystemDialogReady({ session, @@ -808,21 +839,19 @@ async function runWithAndroidDialogReadinessCheck( }); // ADR 0014: blocking-dialog recovery is itself device-mutating and expires the // frame at its own seam. A ref action admitted against the pre-recovery frame - // must NOT continue against the recovered UI — abort it with ref_frame_expired. - // (Selector/coordinate actions re-resolve and continue under their own policy.) - if ( - options.refTarget && - readiness.status === 'recovered' && - refFrameState(session) === 'expired' - ) { - throw new AppError( - 'COMMAND_FAILED', - `Ref belongs to an expired ref frame — Android dialog recovery changed the screen before ${command} dispatched`, - { - reason: 'ref_frame_expired', - hint: 'Capture a fresh interactive snapshot (snapshot -i) or use a stable selector, then retry.', - }, - ); + // must NOT continue against the recovered UI — abort it through the SHARED + // admission rejection so the failure shape (reason, ref, currentGeneration, + // scope, mintedGeneration, hint) is identical to every other expired-frame + // rejection across platforms. Selector/coordinate actions carry no refContext + // and re-resolve and continue under their own policy. + if (options.refContext && readiness.status === 'recovered') { + const abort = refMutationAdmissionResponse({ + session, + ref: options.refContext.ref, + mintedGeneration: options.refContext.mintedGeneration, + staleRefsWarning: options.refContext.staleRefsWarning, + }); + if (abort) return { aborted: true, response: abort }; } const runtimeResult = await run(); await ensureAndroidBlockingSystemDialogReady({ @@ -830,7 +859,7 @@ async function runWithAndroidDialogReadinessCheck( command, phase: 'after-command', }); - return { readiness, runtimeResult }; + return { aborted: false, readiness, runtimeResult }; } async function refreshAndroidRefSnapshotIfFreshnessActive( From c769eedd4796cd701f7c80904687c1002f3fd315 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 16:56:16 +0000 Subject: [PATCH 13/14] docs(adr-0014): record provider seam as live-verified; Android recovery sole blocker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The provider-backed interaction + lifecycle seam is now confirmed by fresh live evidence (AWS Device Farm, webdriver backend). Update the ADR implementation-status note so only Android blocking-dialog recovery remains an unexercised release blocker — and note it is blocked on a bootable free Android target plus a deterministic app-owned ANR trigger, not on any code gap. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa --- docs/adr/0014-session-ref-frame-lifetime.md | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/docs/adr/0014-session-ref-frame-lifetime.md b/docs/adr/0014-session-ref-frame-lifetime.md index 23128b7ac..3d559444c 100644 --- a/docs/adr/0014-session-ref-frame-lifetime.md +++ b/docs/adr/0014-session-ref-frame-lifetime.md @@ -442,14 +442,18 @@ complete daemon classification and gate (2), the pre-side-effect seam at every l complete/partial publication with bounded scope, MCP pin retention, and pinned partial CLI text (4), Android freshness decoupled from positional ref authorization (5), the cross-platform contract and provider evidence (6), and fail-closed admission enforcement across platforms with typed reasons (7). -Fresh live evidence has exercised most production seams — Apple runtime-ref, direct/native selector, -generation-pin, generic, and lifecycle paths, plus Android helper freshness (including proven -non-retarget) and Android existing-session relaunch. Two seams remain UNEXERCISED and are therefore -explicit release blockers, not confirmed enablement: Android blocking-dialog recovery, and a real -provider-backed interaction plus provider-backed lifecycle operation. Enforcement stays enabled in -code, but those two seams are not claimed as verified until their live runs exist. Step 8's removal of -the superseded coarse `snapshotRefsStale` marker follows full confirmation, so read-only warnings are -not disturbed before every enabled seam is proven on hardware. +Fresh live evidence has exercised nearly every production seam — Apple runtime-ref, direct/native +selector, generation-pin, generic, and lifecycle paths; Android helper freshness (including proven +non-retarget) and Android existing-session relaunch; and a real provider-backed interaction plus +provider-backed lifecycle operation (AWS Device Farm, `backend: webdriver`: a fresh ref succeeded, an +immediate stale ref was rejected before dispatch with the shared typed fields, an `open --relaunch` +lifecycle mutation expired the frame, and a fresh observation restored authorization). ONE seam remains +UNEXERCISED and is therefore an explicit release blocker, not confirmed enablement: Android +blocking-dialog recovery — blocked only on a bootable free Android target plus a deterministic +app-owned ANR trigger, not on any code gap. Enforcement stays enabled in code, but that seam is not +claimed as verified until its live run exists. Step 8's removal of the superseded coarse +`snapshotRefsStale` marker follows that final confirmation, so read-only warnings are not disturbed +before every enabled seam is proven on hardware. PR #1241 landed independently as a compatible transitional fix. It rejects a known iOS stale-marker case before this full lifecycle is implemented; it does not own the architecture migration. From 16cc05a9ca029d7645a71e349389d4f0a4cc012e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 19:19:44 +0000 Subject: [PATCH 14/14] docs(adr-0014): record Android ANR recovery as an accepted evidence gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the review decision: the Android blocking-dialog recovery seam has no deterministic app-owned ANR repro in the harness, so it was not live- exercised. The team accepted shipping without a live run for it — its transition/abort logic is covered by fixture regressions and it is enforced in code identically to the verified paths. Reword the status note from an open release blocker to a documented, accepted evidence gap, which unblocks step 8's coarse-marker removal. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa --- docs/adr/0014-session-ref-frame-lifetime.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/adr/0014-session-ref-frame-lifetime.md b/docs/adr/0014-session-ref-frame-lifetime.md index 3d559444c..15254111c 100644 --- a/docs/adr/0014-session-ref-frame-lifetime.md +++ b/docs/adr/0014-session-ref-frame-lifetime.md @@ -447,13 +447,16 @@ selector, generation-pin, generic, and lifecycle paths; Android helper freshness non-retarget) and Android existing-session relaunch; and a real provider-backed interaction plus provider-backed lifecycle operation (AWS Device Farm, `backend: webdriver`: a fresh ref succeeded, an immediate stale ref was rejected before dispatch with the shared typed fields, an `open --relaunch` -lifecycle mutation expired the frame, and a fresh observation restored authorization). ONE seam remains -UNEXERCISED and is therefore an explicit release blocker, not confirmed enablement: Android -blocking-dialog recovery — blocked only on a bootable free Android target plus a deterministic -app-owned ANR trigger, not on any code gap. Enforcement stays enabled in code, but that seam is not -claimed as verified until its live run exists. Step 8's removal of the superseded coarse -`snapshotRefsStale` marker follows that final confirmation, so read-only warnings are not disturbed -before every enabled seam is proven on hardware. +lifecycle mutation expired the frame, and a fresh observation restored authorization). ONE seam — +Android blocking-dialog recovery — was NOT live-exercised: the repo harness exposes no deterministic +app-owned ANR trigger, and no reproducible control exists to raise the system dialog on demand. The +team accepted shipping without a live run for it: its transition and abort logic are covered by fixture +regressions (`android-system-dialog-ref-frame.test.ts` proves recovery expires the frame before its +tap; `interaction-android-recovery-abort.test.ts` proves an outstanding ref action then aborts with the +shared `ref_frame_expired` rejection and no dispatch), the seam is enforced in code identically to the +verified paths, and it is recorded here as a documented, accepted evidence gap rather than an open +release blocker. Every other enabled seam is proven on hardware, so step 8's removal of the superseded +coarse `snapshotRefsStale` marker is unblocked. PR #1241 landed independently as a compatible transitional fix. It rejects a known iOS stale-marker case before this full lifecycle is implemented; it does not own the architecture migration.