mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
ba6c818d81
* refactor(daemon): make the ADR 0014 ref frame one owned value
The four `refFrame*` fields on `SessionState` were policed only by the R7
ownership table: any daemon module could write them, and only a full-graph AST
scan could say whose write it was. They are now one `RefFrame` value whose brand
key is private to `src/daemon/ref-frame.ts`, so a module outside that file cannot
construct one and cannot edit the one a session holds; the transitions replace it
whole. Every transition, rejection reason and epoch rule is unchanged.
Readers moved to the accessors ref-frame.ts exports (`refFrameState`,
`refFrameScope`, `refFrameEpoch`, plus a new `refFrameTree` and `refFrame`).
`internal-observation.ts` drops its four-field lineage copy and its field-by-field
comparison: frame identity is now one `===`.
Seen red: with the empty-result early return removed from
`markSessionPartialRefsIssued`, the new frame-identity assertion in
session-snapshot.test.ts fails; restored, it passes. A planted foreign writer
module was rejected by tsc (TS2741 missing brand, TS2540 read-only property)
before deletion.
* docs(depgraph): note the ref frame outgrew its R7 row
* refactor(daemon): make the ref frame nominal, not symbol-branded
A symbol brand on a plain object type stops construction from nothing, but not
`{ ...refFrame(session), state: 'active' }`: object spread copies the symbol key,
so any daemon module could mint an incoherent frame (active state, stale tree)
out of a coherent one and it type-checked. Proven before the fix with a throwaway
module doing exactly that write: tsc reported nothing.
The frame is now a class with `#`-private fields behind getters. That makes the
type nominal, so no object literal is assignable to it — the same probe now fails
with TS2739 (`missing #fields, scope, generation, expired`). Construction stays
inside ref-frame.ts, and the four claim sites (ADR 0014, the SessionState field
doc, and the two in the R7 owner table) now say what the type does and does not
judge: it cannot see a whole frame moved unchanged, which is why the R7 row stays.
Expiry is idempotent by identity again. `expired()` returns THIS frame when the
frame is already expired, rather than an equal copy, which is what the lineage
check in internal-observation.ts compares with `===`. Seen red: with that early
return removed, the tightened ref-frame test fails with "Values have same
structure but are not reference-equal"; green with it.
Also: the ADR 0014 stale-ref help sample seeds its epoch through a real frame
activation again, instead of leaning on the pre-frame snapshotGeneration
fallback, and a find test drops a `?? []` that can no longer be reached.
Behavior is unchanged: same frame contents, same transitions, same admission.
* chore(gates): collapse the four ADR 0014 R7 rows into the owned refFrame value
R7's owner table listed `refFrameState`, `refFrameScope`, `refFrameTree` and
`refFrameGeneration` as four fields that had to be written together by one
module; the code now carries them as one nominal value, so the table carries one
row. R10 follows: 19 writer-owned fields to 16, 22 owner claims to 19.
The row itself stays. The type stops construction, editing and spread-derivation
of a frame outside ref-frame.ts, but it cannot judge a whole frame moved
unchanged — clearing the field, or assigning another session's frame — and the
table can. The comments say that rather than claiming full enforcement.
Seen red: a planted `session.refFrame = undefined` in snapshot-session.ts fails
R7 with "owned by src/daemon/ref-frame.ts"; green once reverted.
* style: apply oxfmt
* refactor(daemon): keep ref-frame expiry module-private
`RefFrame` exposed a public `expired()` method, so any module holding a
frame could derive a new valid one and install it through a reconstructed
session record, past the R7 field scan. Expiry is now a static on the
unexported class, reachable only inside ref-frame.ts; the frame's surface is
four getters. A type-level regression pins that no outside module can
construct, spread, edit, or derive a frame (tsc covers src tests, so a
directive that stops erroring fails typecheck).
* test(daemon): hold the three accessor migrations within the size ratchet
Each file grew by exactly its new ref-frame import; one blank line between
mock blocks goes so the files stay at their merge-base length.
476 lines
33 KiB
Markdown
476 lines
33 KiB
Markdown
# ADR 0014: Session Ref-Frame Lifetime
|
|
|
|
## Status
|
|
|
|
Accepted; implemented and enforced on `main` across all platforms. Fresh live evidence exercised
|
|
every production seam except Android blocking-dialog recovery, which shipped as a documented,
|
|
accepted gap: no deterministic repro exists to raise the system dialog on demand, and the seam's
|
|
transition and abort logic are pinned by fixture regressions
|
|
(`android-system-dialog-ref-frame.test.ts`, `interaction-touch-android-readiness.test.ts`). The
|
|
staged migration plan lives in this file's git history; PR #1241 was an earlier compatible
|
|
transitional fix, not this architecture migration.
|
|
|
|
## Rules at a glance
|
|
|
|
Normative summary; the binding contracts and edge cases are in [Decision](#decision), and the
|
|
ref-frame vocabulary is promoted into `CONTEXT.md`.
|
|
|
|
- A session owns at most one **ref frame** — the authorization namespace for mutation refs (epoch
|
|
exposed as `refsGeneration`, immutable source tree, `active`/`expired` state, `all` or bounded
|
|
issuance scope) — kept separate from the latest operational observation (`session.snapshot`).
|
|
The frame is one value on `SessionState`, replaced whole by a transition and never edited in
|
|
place; its type is nominal (`#`-private fields), so no module outside
|
|
`src/daemon/ref-frame.ts` can construct a frame, edit one, or derive one from an existing
|
|
frame. What the type cannot judge is a whole frame moved unchanged — clearing the field, or
|
|
assigning another session's frame — which the field-owner gate still covers.
|
|
- A complete snapshot activates an `all` frame; `find`, settled diffs, and replay divergence
|
|
screens activate a bounded partial frame that supersedes the prior one; internal read-only
|
|
captures never activate, reindex, or expire a frame.
|
|
- Every mutating leaf expires the frame at the side-effect seam — after all pre-action guards,
|
|
immediately before the device op — with no success-only rollback; a post-dispatch failure still
|
|
leaves it expired.
|
|
- Mutation admission requires an active frame whose epoch and issuance scope authorize the ref;
|
|
rejections carry ordered typed reasons `ref_frame_expired` -> `ref_generation_mismatch` ->
|
|
`plain_ref_requires_complete_frame` -> `ref_not_issued`.
|
|
- Read-only ref consumers stay fail-open with a structured staleness warning only while the frame
|
|
retains the ref's evidence; missing evidence fails rather than resolving by positional
|
|
coincidence against a newer observation.
|
|
- Every daemon command declares a `RefFrameEffect` (`preserve` | `may-invalidate` | `delegated`) in
|
|
its descriptor daemon facet; the registry classification is completeness-gated.
|
|
|
|
## Context
|
|
|
|
Snapshot refs such as `@e12` are positional names inside one accessibility-tree namespace. They are
|
|
cheap and effective within that namespace, but they do not identify an element across navigation,
|
|
layout changes, or another capture whose positional ordering differs. A generation pin such as
|
|
`@e12~s42` identifies the namespace that issued the ref; it does not make the element stable.
|
|
|
|
The current session model conflates two different concepts in `session.snapshot`:
|
|
|
|
1. the latest operational observation used by selector matching, verification, settling, Android
|
|
freshness, overlays, and diagnostics; and
|
|
2. the caller-authorized tree whose refs may be used for a later mutation.
|
|
|
|
That conflation creates failures in both directions. A mutation can change the device without
|
|
capturing another tree, leaving the old positional refs apparently current. Conversely, an internal
|
|
read-only capture can replace `session.snapshot` and invalidate refs even though the caller performed
|
|
no mutation. Partial outputs such as `find`, settled diffs, and replay divergence screens can then
|
|
clear the coarse stale marker for the whole replacement tree even though they exposed only a few
|
|
refs.
|
|
|
|
Issue [#1239](https://github.com/callstack/agent-device/issues/1239) and PR
|
|
[#1241](https://github.com/callstack/agent-device/pull/1241) demonstrate the first failure on iOS:
|
|
`snapshot -> press @e1 -> press @e2` can dispatch both presses when the first press performs no
|
|
internal capture. The second ref is resolved against pre-action coordinates even if the first press
|
|
navigated. A false successful tap on the new screen is worse than a clear stale-ref failure.
|
|
|
|
The lifetime contract must be consistent across platforms and dispatch paths, preserve fast paths,
|
|
remain compatible with selector-based replay, and add no automatic capture or per-node output bytes.
|
|
|
|
## Decision
|
|
|
|
The terms introduced below are now the implemented model; the ref-frame vocabulary is promoted into
|
|
`CONTEXT.md`. The superseded coarse `snapshotRefsStale` marker has been removed:
|
|
read-only ref staleness is now derived from frame state — a plain ref warns once the frame has expired,
|
|
and a read-only capture no longer marks refs stale because it does not expire the frame.
|
|
|
|
### Ref frames are separate from operational observations
|
|
|
|
A session owns at most one **ref frame**. The frame is the authorization namespace for mutation refs
|
|
and contains:
|
|
|
|
- a seeded, monotonic **ref-frame epoch**, exposed through the existing `refsGeneration` field and
|
|
accepted through the existing `~s<n>` input suffix;
|
|
- the immutable source tree needed to resolve and guard the refs it issued;
|
|
- a state of `active` or `expired`; and
|
|
- an issuance scope of `all` or a bounded set of issued ref bodies.
|
|
|
|
Operational captures update the latest observation used by selector and diagnostic machinery. They
|
|
do not silently replace, reactivate, or reindex the authorized ref frame. The implementation may
|
|
share immutable tree objects between the observation and frame, but it must not make their lifetimes
|
|
the same by aliasing mutable session state.
|
|
|
|
Only one frame is retained. Activating a new frame supersedes the previous frame and advances the
|
|
epoch; historical generation-to-tree caches are not introduced. Complete and partial frames retain
|
|
the immutable source tree because interaction guards and replay identity can depend on context outside
|
|
the emitted subset, including ancestors, siblings, overlays, and the viewport. The issuance set, not
|
|
the evidence tree, bounds partial authority. The frame and latest observation share immutable capture
|
|
data when they originate from the same capture; neither transition deep-copies the tree.
|
|
|
|
The existing `snapshotGeneration` implementation evolves behind one ref-frame module; the
|
|
`snapshotRefsStale` marker it originally paired with has since been removed. The
|
|
public name `refsGeneration` and the `@e12~s42` grammar remain unchanged for wire 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 |
|
|
|
|
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
|
|
scoped snapshot replaces authorization with exactly its returned namespace; it neither authorizes
|
|
matching ref bodies outside that scope nor retains the old `all` scope.
|
|
|
|
A frame-activating capture scoped by `@ref` must first admit that scope ref against the current active
|
|
frame. To preserve the existing repeated scoped-snapshot contract after scoped output reindexes refs,
|
|
the session retains one bounded **scope lineage** for consecutive read-only repetitions of that exact
|
|
scope. The lineage stores the admitted semantic scope (label/selector chain), not the old positional
|
|
tree or mutation authority. A repeated `snapshot -s @ref` may resolve through that lineage only when
|
|
the immediately preceding frame publication was the same scoped capture. The first device-side effect,
|
|
an unrelated frame publication, or another scope clears the lineage. An arbitrary stale or unissued
|
|
scope ref still fails closed; it cannot borrow lineage or authorize a different subtree. Empty, sparse,
|
|
failed, or unusable repeated captures preserve the last useful frame and its still-consecutive lineage.
|
|
Selector-scoped captures follow their normal selector rules.
|
|
|
|
Snapshot diffs, digests that omit the namespace, `find`, settled diffs/tails, and replay divergence
|
|
screens are partial external publications. They activate a partial frame only when at least one ref
|
|
and its epoch are visible to the caller or orchestrated workflow. An empty partial result does not
|
|
supersede useful existing authority.
|
|
|
|
Publishing a partial frame intentionally supersedes the prior frame. This avoids retaining an
|
|
unbounded set of generation-specific trees and makes its limited authority explicit: only refs named
|
|
in that partial response can be used for mutation, and only with their response-level epoch.
|
|
|
|
An explicit, non-diff complete snapshot leaf may activate a frame inside replay or batch before the
|
|
outer orchestrator returns, even when the outer response level later elides or digests that tree. This
|
|
execution-local activation is a deliberate compatibility exception for deterministic
|
|
`snapshot -> @ref` scripts. It authorizes only predeclared later bare-ref steps; it does not publish
|
|
pins to the outer caller or MCP, make the outer response ref-issuing, or add general result
|
|
interpolation. Internally injected, cached, freshness, and recovery captures never receive this
|
|
exception. A nested non-empty partial publication may supersede scope, but batch and replay have no
|
|
dynamic generation binding with which a later step could consume it.
|
|
|
|
### Mutation admission and read behavior
|
|
|
|
A ref-targeting mutation is admitted only when all of the following hold:
|
|
|
|
1. the frame is active;
|
|
2. a pinned ref carries the current epoch, or a plain ref is used against a complete `all` frame;
|
|
3. the ref body belongs to the frame's issuance scope; and
|
|
4. normal target guards and replay target-binding verification pass.
|
|
|
|
The target is resolved against the authorized frame, never against a newer operational observation
|
|
by positional coincidence. Android freshness captures may verify current identity or provide
|
|
comparison evidence, but they must not redefine which node an already-admitted `@eN` authorizes.
|
|
|
|
Read-only ref consumers remain fail-open with a structured staleness warning only when the retained
|
|
frame contains the evidence needed to resolve that ref. A stale read is observable and recoverable;
|
|
a stale mutation can act on the wrong element. Missing frame evidence fails rather than falling
|
|
through to a newer operational observation by positional coincidence. Reads never reactivate a frame
|
|
or convert an unissued ref into mutation authority.
|
|
|
|
Rejected mutations use the existing error system. Admission failures are evaluated in the order
|
|
below and have typed reasons so callers can distinguish "capture a complete snapshot" from "use the
|
|
emitted pinned ref":
|
|
|
|
- `ref_frame_expired` when a side effect invalidated the current frame;
|
|
- `ref_generation_mismatch` when a pin names another epoch;
|
|
- `plain_ref_requires_complete_frame` when a plain ref targets a partial frame; and
|
|
- `ref_not_issued` when a pinned ref is outside a partial frame's issuance set.
|
|
|
|
Each failure includes `details.ref`, `details.reason`, `details.currentGeneration`, the frame scope,
|
|
and `details.mintedGeneration` when the input is pinned. For example:
|
|
|
|
```text
|
|
code: COMMAND_FAILED
|
|
message: Ref @e12 belongs to an expired ref frame
|
|
details.reason: ref_frame_expired
|
|
details.ref: @e12
|
|
details.currentGeneration: 43
|
|
details.mintedGeneration: 42 # present only when the input was pinned
|
|
hint: Capture a fresh interactive snapshot or use a stable selector.
|
|
```
|
|
|
|
The daemon cannot truthfully report `mintedGeneration` for a plain ref. Error messages must not claim
|
|
that a found ref was missing or lacked bounds when the actual failure is lifetime expiration.
|
|
|
|
### Expiration occurs at the side-effect seam
|
|
|
|
Expiration occurs after syntax, capability, freshness, target resolution, replay identity,
|
|
occlusion, visibility, bounds, and other pre-action guards pass, but immediately before the first
|
|
operation that may change device-visible element identity. Persist the in-memory transition before
|
|
awaiting that operation.
|
|
|
|
Failure before this seam preserves the frame. Once the seam is crossed, success, timeout,
|
|
cancellation, connection loss, and ambiguous platform errors all leave it expired because the device
|
|
may already have acted. Crossing the seam also clears scoped-snapshot lineage. There is no success-only
|
|
rollback.
|
|
|
|
This rule applies to every execution shape:
|
|
|
|
- runtime ref/selector/coordinate paths expire before `tap`, `fill`, `longPress`, text input, or
|
|
gesture execution;
|
|
- native web ref paths run their existing ref preflight first, then expire before `clickRef` or
|
|
`fillRef`;
|
|
- direct iOS selector paths consume no ref but expire any existing frame before the fused runner
|
|
resolve-and-act request;
|
|
- platform-generic, app lifecycle, keyboard, alert, settings, and provider actions expire at their
|
|
actual leaf side-effect seam, not at generic request admission;
|
|
- `open`/relaunch against an existing session expires that session's frame before the first
|
|
close/launch dispatch. A failure or timeout after dispatch leaves it expired; a successful `close`
|
|
deletes the session and therefore retains no frame to restore;
|
|
- a fallback or retry may proceed after an error only when the error proves no prior side effect;
|
|
ambiguous post-dispatch errors neither restore the frame nor authorize an automatic second action;
|
|
- Android blocking-dialog recovery is itself device-mutating. Its recovery tap/relaunch must cross
|
|
the same idempotent seam before acting, even when invoked from apparent readiness work. If recovery
|
|
mutates before a requested ref action dispatches, that ref action aborts with
|
|
`ref_frame_expired`; it cannot continue against the recovered UI. Selector and coordinate actions
|
|
may re-resolve and continue under their existing policies; and
|
|
- automatic no-change retries assert that the originating action already expired the frame before
|
|
any retry coordinate is sent.
|
|
|
|
The seam is deliberately inside leaf execution. Expiring at router entry would reject the ref needed
|
|
by the current command, invalidate on ordinary validation failures, and mishandle multiplexed and
|
|
orchestrating commands.
|
|
|
|
Some platform operations fuse their final status/target check and mutation in one request, including
|
|
direct iOS selectors and current native alert or keyboard actions. Until a lower-level "will act"
|
|
callback exists, dispatching that fused request is the conservative seam. A later `not found`,
|
|
`already hidden`, timeout, or no-alert result is post-seam and does not restore the frame. A command
|
|
with a separate definitive read-only status request preserves the frame when that status proves no
|
|
mutation request was dispatched.
|
|
|
|
### Command effects are daemon-owned and completely classified
|
|
|
|
The command descriptor's daemon facet declares a request policy:
|
|
|
|
```ts
|
|
type RefFrameEffect = 'preserve' | 'may-invalidate' | 'delegated';
|
|
|
|
type DaemonRefFrameEffect = RefFrameEffect | ((request: DaemonRequest) => RefFrameEffect);
|
|
```
|
|
|
|
The daemon registry exposes a named resolver. Every daemon command is classified, but the
|
|
classification is an honesty/completeness guard rather than the transition site. `may-invalidate`
|
|
means that some successful path can cross a device side effect; the leaf implementation still calls
|
|
the ref-frame module only when that path is selected. Request-sensitive commands use a focused
|
|
resolver rather than pretending all subcommands behave alike.
|
|
|
|
The registry is the exhaustive source of truth. The following table is non-exhaustive guidance for
|
|
classifying representative actions; it must not become a second prose registry:
|
|
|
|
| Effect | Commands/actions |
|
|
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| `may-invalidate` | press, click, fill, longpress, type, focus, scroll, swipe, gesture, back, home, `tv-remote`, orientation, open/relaunch, trigger/push delivery, settings changes, install/reinstall, React Native overlay dismissal, and lifecycle operations that can replace the visible surface |
|
|
| Conditional resolver | keyboard status preserves while dismiss/return/input invalidate; alert get/wait preserve while accept/dismiss invalidate; find reads preserve while click/fill/focus/type delegate to their leaf mutation |
|
|
| `delegated` | batch, replay, and test/suite orchestrators; each nested leaf owns its transition |
|
|
| `preserve` | snapshots and other observation, assertion, screenshot, recording, trace, logs, events, network inspection, performance, inventory, capability, lease, and transport-management operations unless a selected subaction directly manipulates the visible surface |
|
|
|
|
Clipboard reads and writes preserve the frame because pasteboard state alone does not change element
|
|
identity. A later paste/type action is independently invalidating.
|
|
|
|
Generic routing is not an exception to the policy. `back`, `home`, `orientation`, `scroll`, `tv-remote`,
|
|
and `app-switcher` all reach the generic daemon leaf and must cross the same transition there when
|
|
they act. `app-switcher` is currently projected to the daemon by a direct writer but, unlike its
|
|
generic-routed siblings, omits an explicit daemon descriptor facet and relies on the registry's
|
|
generic fallback. Migration adds that facet and its effect classification; it does not invent a
|
|
specialized route. The completeness gate covers every command projected to the daemon, including
|
|
generic fallbacks, so a missing facet cannot hide an unclassified mutation. Mutations performed by
|
|
unrelated external tools remain outside this session guarantee.
|
|
|
|
This policy is not derived from Apple runner `readOnly`. Runner traits govern retry, liveness,
|
|
readiness probes, and preflight skipping at a lower wire-command seam. `refFrameEffect` governs
|
|
daemon session authorization and includes commands that never reach the Apple runner. Narrow
|
|
consistency tests may cover direct mappings, but blanket parity would couple different concepts.
|
|
|
|
Frame admission and transitions are serialized by the existing per-session request lock. The frame
|
|
is shared session state, not per-client or per-lease history. Generation pins make the rejected epoch
|
|
and reason precise; they do not reserve a frame or prevent another caller's mutation from expiring it.
|
|
No second ref-specific mutex is introduced.
|
|
|
|
### Batch, replay, recording, and repeated actions
|
|
|
|
Batch and replay invoke their steps sequentially through ordinary daemon dispatch and are
|
|
`delegated`. They never expire a frame merely because the outer command began. Frame state naturally
|
|
carries between leaf steps:
|
|
|
|
- `snapshot -> press @e1 -> press @e2` executes the first press and rejects the second;
|
|
- `snapshot -> press @e1 -> press @e1 -> press @e2` also stops on the second press, before `@e2`;
|
|
- `snapshot -> press @e1 -> snapshot -> press @e2` is admitted;
|
|
- selector-only and coordinate-only batches retain their request-round-trip advantage; and
|
|
- a stale-ref failure stops batch through its existing policy and preserves `step`, `command`,
|
|
`executed`, `total`, `partialResults`, diagnostic fields, and `details.reason`.
|
|
|
|
Batch does not gain nested MCP pin traversal, intermediate-result interpolation, an unsafe
|
|
"assume stable refs" override, or a second static lifecycle interpreter. Normal leaf dispatch is the
|
|
single enforcement path.
|
|
|
|
Recorded `.ad` scripts remain selector-first. The recorder continues converting successfully
|
|
resolved refs to selector chains and refuses to persist an unresolved session-bound bare ref.
|
|
Generation suffixes remain runtime evidence and are stripped from durable recordings because seeded
|
|
epochs are not portable across sessions. Legacy hand-written `.ad` scripts that mutate through
|
|
several bare refs from one snapshot become intentionally incompatible; they must capture between
|
|
mutations or use selectors. Replay target-binding verification completes before the side-effect seam;
|
|
identity divergence preserves the frame, while an action failure after dispatch leaves it expired.
|
|
|
|
`press --count`, double-tap, hold, jitter, `swipe --count`, and platform sequence chunks are one
|
|
logical command. They resolve or plan once, expire once before the first repetition, and execute the
|
|
original physical point/plan for every repetition. They do not re-resolve the target or reactivate the
|
|
frame between repetitions. Failure in any later repetition or chunk leaves the frame expired. This
|
|
preserves the explicit repeated-physical-interaction contract while preventing later commands from
|
|
reusing the frame.
|
|
|
|
### Partial issuance and MCP
|
|
|
|
`find`, settled diffs/tails, and replay divergence screens publish only the refs they actually return.
|
|
They must not call a whole-frame "refs issued" marker.
|
|
|
|
Read-only `find` may partially publish its returned ref. A mutating `find` returns the acted ref only
|
|
as diagnostic pre-action identity: it must omit `refsGeneration`, must not publish or activate a
|
|
frame, and cannot make that ref valid after the nested action.
|
|
|
|
The MCP executor continues storing per-ref pins by state-directory/session scope. Issuance handling
|
|
must distinguish the selected `find` action, not infer issuance from the command name alone. A
|
|
read-only `find` with `refsGeneration` merges its partial pin. A mutating `find` without
|
|
`refsGeneration` is explicitly non-issuing and leaves every remembered pin unchanged; it must not
|
|
enter a generic missing-generation branch that clears the session's pin scope. The executor must carry
|
|
enough normalized command/action context to make that distinction. Remembered pins are not cleared on
|
|
mutation: sending the remembered old epoch is how the daemon produces a precise stale rejection. MCP
|
|
currently neither traverses refs nested inside batch inputs nor learns refs from nested batch results;
|
|
this remains an explicit non-goal.
|
|
|
|
The response-level `refsGeneration` field remains the token-economical authority. Snapshot nodes never
|
|
gain per-node generation bytes. Every reusable ref in a partial CLI text result must render in
|
|
ready-to-copy `@eN~s<refsGeneration>` form. JSON and Node.js responses retain plain ref bodies plus one
|
|
response-level generation; callers must pair them before a mutation. MCP may render a plain ref because
|
|
it stores and forwards the pin internally. Complete snapshot trees remain plain to protect the token
|
|
budget. A mutating `find` diagnostic ref is never rendered or stored as pinned because it carries no
|
|
`refsGeneration`.
|
|
|
|
## Performance and compatibility
|
|
|
|
Expiration is O(1): one monotonic epoch transition and bounded in-memory state. It adds no capture,
|
|
platform request, daemon round trip, or per-node output. The model retains at most one authorized frame
|
|
and one latest operational observation, sharing immutable capture objects rather than deep copies; it
|
|
never caches an unbounded history of full trees. Existing capture size limits remain the bound. Memory
|
|
pressure must not silently expire or swap a frame because that would turn deterministic admission into
|
|
environment-dependent behavior.
|
|
|
|
The real cost is workflow-level and intentional. A ref-oriented sequence that performs several
|
|
mutations may need an explicit observation between them. Callers can avoid unnecessary captures by
|
|
using selectors, direct selector fast paths, a settled partial result whose emitted ref is pinned, or
|
|
`--count` for a repeated physical action at one resolved target. The daemon never auto-captures merely
|
|
to renew refs.
|
|
|
|
Older clients remain wire-parse-compatible: command grammar, `refsGeneration`, and `~s<n>` remain
|
|
unchanged, and structured error details are additive, so ADR 0006 requires no protocol bump. They are
|
|
not fully behaviorally compatible. Unsafe plain-ref mutation chains newly fail. A fused command can
|
|
also expire the frame when its eventual result is a no-op, such as `keyboard dismiss` reporting that
|
|
the keyboard was already hidden or an alert action reporting no alert. Read-only refs that lack
|
|
retained frame evidence newly fail instead of warning and resolving by positional coincidence against
|
|
a newer observation. These changes require CLI/help, changelog, `.ad` migration, and batch guidance.
|
|
|
|
It composes with existing ADRs as follows:
|
|
|
|
- ADR 0003/0008: daemon-owned effect policy is composed in the descriptor daemon facet and projected
|
|
only through the daemon registry;
|
|
- ADR 0004: capture plans and sparse-quality rules are unchanged, and no capture is added;
|
|
- ADR 0005: runner liveness/preflight state remains independent from session ref authorization;
|
|
- ADR 0006/0010: no RPC bump; typed `details.reason` and actionable hints follow the error contract;
|
|
- ADR 0007: the frame is session-scoped under the existing session/request lock, not client- or
|
|
lease-local;
|
|
- ADR 0009: one target model applies to Apple, Android, Linux, and web while public platform output
|
|
remains projected;
|
|
- ADR 0011: lifetime is a sibling session-sequence invariant, not another path-by-element-guarantee
|
|
cell; every mutating path still crosses the shared seam;
|
|
- ADR 0012: durable target identity remains selector/evidence based, and divergence refs become
|
|
honest partial issuance; and
|
|
- ADR 0013: coordinate gestures expire frames but remain outside the element guarantee matrix.
|
|
|
|
## Consequences
|
|
|
|
- A stale ref cannot silently mutate a different element after navigation; the caller receives a
|
|
typed failure and must choose a fresh observation or a selector.
|
|
- Ref-oriented workflows that perform multiple logical mutations must capture again between them,
|
|
consume an honestly issued settled ref, use selectors, or use one repeated-action command such as
|
|
`--count`. Legacy hand-written `.ad` scripts that reuse several bare refs from one snapshot must
|
|
change.
|
|
- Conservative fused requests may consume ref authority even when the platform later reports that no
|
|
action was needed. This favors avoiding a wrong-screen action over preserving refs after an
|
|
ambiguous dispatch.
|
|
- A read without retained evidence fails rather than resolving the same positional ref body in a
|
|
newer tree. Reads with retained evidence keep the structured warning behavior.
|
|
- Consecutive repeated scoped snapshots retain their admitted semantic scope, while any mutation,
|
|
unrelated publication, or different scope breaks that lineage.
|
|
- The implementation pays bounded session-memory bookkeeping but adds no automatic capture, platform
|
|
call, round trip, or per-node response bytes.
|
|
|
|
## Required evidence
|
|
|
|
The implementation is not complete until contract and provider tests prove:
|
|
|
|
1. a bare and pinned second mutation reject after an unobserved first mutation;
|
|
2. definitive pre-seam validation, target, and replay-identity failures preserve the frame;
|
|
3. timeout, cancellation, connection loss, and other post-seam failures leave it expired;
|
|
4. runtime ref/selector/coordinate, native ref, direct selector, provider-backed interaction, and
|
|
provider-backed lifecycle/generic paths cross the same seam;
|
|
5. Android freshness cannot retarget an admitted ref through positional coincidence;
|
|
6. read-only operational captures do not replace the authorized frame;
|
|
7. partial publication admits only emitted pinned refs, reports each typed admission failure, cannot
|
|
bless unrelated plain refs, and CLI text renders every reusable partial ref with its pin while JSON
|
|
and Node.js retain the response-level representation;
|
|
8. complete publication admits its plain namespace while sparse/unusable captures do not;
|
|
9. mutating `find` omits `refsGeneration` and never pins or activates its pre-action ref as current
|
|
post-action state; an MCP sequence that first remembers a snapshot pin, then performs mutating
|
|
`find`, still forwards the remembered old pin on a later ref input so the daemon rejects it as stale;
|
|
10. batch and replay delegate per step, retain their existing failure/partial-result contracts, and
|
|
allow `snapshot -> @ref` with an intermediate digest without exposing pins or dynamic bindings;
|
|
11. recorded scripts contain selectors rather than portable generation claims;
|
|
12. press/swipe series and Apple chunks resolve and expire exactly once;
|
|
13. Android dialog recovery expires before its first recovery side effect and aborts an outstanding
|
|
ref action after recovery mutates;
|
|
14. consecutive repeated `snapshot -s @ref` calls resolve the same admitted semantic scope after
|
|
scoped output reindexes refs; a mutation, unrelated publication, or different scope breaks the
|
|
lineage; an arbitrary stale ref cannot use it; and empty/unusable repetition preserves the last
|
|
useful frame and still-consecutive lineage;
|
|
15. fused keyboard/alert requests and split status/action paths follow their documented conservative
|
|
seams, including already-hidden and no-alert outcomes;
|
|
16. failed or timed-out existing-session open/relaunch leaves the old frame expired after dispatch;
|
|
17. a read-only ref with retained stale evidence returns the structured warning, while missing frame
|
|
evidence fails without falling through to a newer observation by positional coincidence;
|
|
18. a large-tree fixture proves frame/observation separation introduces no deep copy and records the
|
|
bounded peak retained nodes/bytes; and
|
|
19. enforcement and error shapes are consistent across supported platforms.
|
|
|
|
Performance regression tests prove that admission, expiration, and frame/observation separation add
|
|
no implicit capture or provider/platform call. Device benchmarks are required only if those tests or
|
|
live evidence reveal a regression. No path may claim success until a test proves the relevant
|
|
direct/native/runtime/provider branch actually executed.
|
|
|
|
Before enforcement is enabled for a platform, fresh-build live evidence must exercise every supported
|
|
production seam affected there: Apple runtime-ref and direct/native paths where available, Android
|
|
helper freshness and recovery, generic/lifecycle mutation, and at least one real provider-backed
|
|
interaction plus lifecycle operation. Each run must prove a fresh ref succeeds, a second mutation with
|
|
the stale ref is rejected before dispatch, and a fresh observation restores usability. A seam not
|
|
exercised remains disabled or is recorded as an explicit release blocker. Fixture-backed tests and
|
|
registry claims are necessary but do not substitute for this live evidence.
|
|
|
|
## Alternatives considered
|
|
|
|
- **Stable refs across trees:** rejected because positional accessibility snapshots have no durable
|
|
cross-tree identity, and semantic IDs are neither universal nor unique.
|
|
- **Expire only after successful mutation:** rejected because a timeout or transport failure may occur
|
|
after the device acted.
|
|
- **Keep one snapshot for observation and authorization:** rejected because read-only internal captures
|
|
can reindex or invalidate caller refs.
|
|
- **Automatically capture after every mutation:** rejected because it adds the dominant platform cost,
|
|
can itself fail or be sparse, and hides rather than defines lifetime.
|
|
- **Retain every generation's full tree:** rejected as unbounded session memory and unnecessary for a
|
|
single-current-frame contract.
|
|
- **Add a per-ref historical ledger immediately:** rejected until evidence requires concurrent
|
|
generation support; one bounded current frame plus issuance scope is sufficient.
|
|
- **Derive the policy from runner read-only traits:** rejected because runner liveness and daemon ref
|
|
authorization classify different commands for different reasons.
|
|
- **Add batch interpolation or an unsafe ref-stability override:** rejected as a new orchestration
|
|
interface that bypasses the same safety rule.
|
|
- **Force lifetime into ADR 0011's element path matrix:** rejected because ref lifetime spans commands,
|
|
time, non-element invalidators, and orchestrators; the matrix remains the enforcement record for
|
|
per-path element guarantees.
|