* 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.
33 KiB
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, 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/expiredstate,allor bounded issuance scope) — kept separate from the latest operational observation (session.snapshot). The frame is one value onSessionState, replaced whole by a transition and never edited in place; its type is nominal (#-private fields), so no module outsidesrc/daemon/ref-frame.tscan 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
allframe;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:
- the latest operational observation used by selector matching, verification, settling, Android freshness, overlays, and diagnostics; and
- 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 and PR
#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
refsGenerationfield 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
activeorexpired; and - an issuance scope of
allor 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:
- the frame is active;
- a pinned ref carries the current epoch, or a plain ref is used against a complete
allframe; - the ref body belongs to the frame's issuance scope; and
- 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_expiredwhen a side effect invalidated the current frame;ref_generation_mismatchwhen a pin names another epoch;plain_ref_requires_complete_framewhen a plain ref targets a partial frame; andref_not_issuedwhen 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:
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
clickReforfillRef; - 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 successfulclosedeletes 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:
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 @e2executes the first press and rejects the second;snapshot -> press @e1 -> press @e1 -> press @e2also stops on the second press, before@e2;snapshot -> press @e1 -> snapshot -> press @e2is 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, anddetails.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.reasonand 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.adscripts 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:
- a bare and pinned second mutation reject after an unobserved first mutation;
- definitive pre-seam validation, target, and replay-identity failures preserve the frame;
- timeout, cancellation, connection loss, and other post-seam failures leave it expired;
- runtime ref/selector/coordinate, native ref, direct selector, provider-backed interaction, and provider-backed lifecycle/generic paths cross the same seam;
- Android freshness cannot retarget an admitted ref through positional coincidence;
- read-only operational captures do not replace the authorized frame;
- 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;
- complete publication admits its plain namespace while sparse/unusable captures do not;
- mutating
findomitsrefsGenerationand never pins or activates its pre-action ref as current post-action state; an MCP sequence that first remembers a snapshot pin, then performs mutatingfind, still forwards the remembered old pin on a later ref input so the daemon rejects it as stale; - batch and replay delegate per step, retain their existing failure/partial-result contracts, and
allow
snapshot -> @refwith an intermediate digest without exposing pins or dynamic bindings; - recorded scripts contain selectors rather than portable generation claims;
- press/swipe series and Apple chunks resolve and expire exactly once;
- Android dialog recovery expires before its first recovery side effect and aborts an outstanding ref action after recovery mutates;
- consecutive repeated
snapshot -s @refcalls 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; - fused keyboard/alert requests and split status/action paths follow their documented conservative seams, including already-hidden and no-alert outcomes;
- failed or timed-out existing-session open/relaunch leaves the old frame expired after dispatch;
- 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;
- a large-tree fixture proves frame/observation separation introduces no deep copy and records the bounded peak retained nodes/bytes; and
- 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.