* refactor(daemon): extract the repair-tombstone reader below store and client `findUnrecoveredRepairCommitFailure` reads session artifacts off disk and is reached from the daemon client, which had to import `session-store.ts` — the daemon's largest server module — for it. Move the tombstone shape, its file reader and the unrecovered-commit scan into `session-repair-tombstone.ts`, a leaf below both, and give the tombstone file name a single owner. No behavior change; both consumers keep their existing tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CZkJjeEhLmyGpGtcwY8pqc * refactor(daemon): relocate the daemon client out of src/daemon `src/daemon/client/` is the daemon's client, not the daemon: no daemon file imports it, and its consumers are the CLI, the Node client, the proxy command and the injected dispatch type. Move it to `src/daemon-client/` as renames so `src/daemon` is server code plus the shared kernel the client still needs — `config.ts`, `daemon-process.ts`, `request-progress-protocol.ts`, `daemon-request.ts` and the extracted `session-repair-tombstone.ts`. Zone name and rank are unchanged (`daemon-client`, 5); the zone now falls out of the folder instead of a `src/daemon/client/` prefix. Tests move unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CZkJjeEhLmyGpGtcwY8pqc * refactor(daemon): move the session artifact path helpers out of session-store `src/cli.ts` and `src/remote/remote-request-diagnostics.ts` reach into `session-store.ts` for one pure path function, `resolveRemoteRequestDiagnosticsPath`, which made every CLI process eagerly evaluate the daemon's session store and its whole subtree — the script writer, the event log, the action recorder and the replay transaction vocabulary. The four artifact path helpers name files; they hold no store state. Move them to `src/daemon/session-artifact-paths.ts`, a leaf over `session-paths.ts`, and point all ten consumers at it. `src/cli.ts`'s eager closure drops from 379 modules to 365 and no longer contains `session-store.ts`; the store itself is 464 -> 341 lines. AGENTS.md's declaration-site pointer follows. No behavior change: the helpers are unmodified. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CZkJjeEhLmyGpGtcwY8pqc * chore(gates): re-key the daemon-client gate paths onto src/daemon-client Path-keyed enforcement follows the relocated files: the fallow health baseline entries, the oxlint per-file override, the wire-compat surface/ledger/mutation paths, and the layering zone derivation (the `src/daemon/client/` prefix is dead now that the folder itself names the zone). R10's external daemon request/session-state importer list gains the five client modules. The edges are unchanged by this PR — the client has always built `DaemonRequest` and read `DaemonResponse`; it sat inside `src/daemon/` and so fell under the prefix skip. Naming the files keeps the dependency enumerated and shrink-only, so a new `src/daemon-client/` module reaching `session-state` still fails. Its size assertion now reads the recorded list instead of a literal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CZkJjeEhLmyGpGtcwY8pqc --------- Co-authored-by: Claude <noreply@anthropic.com>
25 KiB
ADR 0016: Active-Session Script Publication
Status
Accepted; implemented on main (src/daemon/handlers/session-script-publication.ts,
src/daemon/session-script-active-publication.ts), including the #1349 identity-level
destination-guard amendment.
Rules at a glance
Normative summary; the binding contracts and refusal cases are in Decision.
session save-script [path] [--force]publishes the current ordinary script recording without closing the app or the session; the session stays live at the destination.- Recording lifecycle: ARMED (only by a new session's initial
open --save-script) -> ABORTED (a second successful plainopen) or PUBLISHED (successful save). ABORTED and PUBLISHED are terminal until the session is destroyed; a filesystem/target failure leaves the recording ARMED and retryable. - Publication requires exactly one initial recorded
open, a portable selector-waitdestination guard after the last descriptor-classified mutating action — since #1349 the guard'starget-v1annotation must beverification: "verified"— and no unresolved session-local@ref. Every refusal happens before filesystem work and names its recovery. - Repair transactions (ADR 0012) are disjoint: a session with
saveScriptBoundaryset refuses this action without changing repair state. ReplayCommandResult.sessionActive(from the daemon's session store, never script re-parsing) is what lets a one-shot client keep the daemon alive on a close-less handoff.- Sensitive
fillinputs must be recorded as placeholders viafill --record-as <VAR>(ADR 0017, shipped for #1348); unparameterizedfill/typevalues persist literally into the artifact, so a secret entered without--record-asis published. The protection is recording-session-scoped (ADR 0017's #1398 amendment): a later action's own recorded evidence can never re-serialize an app-rendered echo of an already-parameterized value either.
Context
The replay repair study found re-recording a drifted journey cheaper than repairing it in two of the
three measured drift classes. The immediate reusable unit is an open-to-destination script: a
self-contained .ad script that opens the app, performs the complete journey to screen X, verifies a
destination landmark, and leaves the app session active there so an agent can continue with new work.
Ordinary script recording currently combines two concerns. open --save-script[=<path>] arms
recording before the first interaction so selector chains and ADR 0012 target-v1 identity evidence
are captured at action time. close publishes the accumulated script, records a terminal close, and
tears down the session. That artifact is suitable as a closed test flow, but not as a live starting
state.
Publishing only at the end of an ordinary unarmed session is insufficient. Session history retains the
commands, but the resolved target tree needed for target-v1 evidence is deliberately discarded after
each action. Late publication can serialize selector text; it cannot reconstruct which element was
actually acted on. Making identity capture unconditional for every session would change normal
interaction execution: recording disables direct selector fast paths when a capture-backed route is
required for evidence. That performance and data-retention change has not been measured.
Issue #1346 and the throwaway
session-save-script prototype
record the motivating workflow and API trial. Composable lifecycle-free fragments remain a separate
decision in #1336.
Decision
Add an explicit publication action for an already-armed ordinary script recording:
agent-device open com.example.app --relaunch --save-script=screen-x.ad
# perform the complete journey to screen X
agent-device wait 'role="heading" label="Screen X"'
agent-device session save-script
session save-script [path] [--force] publishes the current ordinary script recording without
closing the app or deleting the session. An explicit path retargets the recording using the existing
target/force authorization rules. Without it, publication uses the path armed by open --save-script
or the existing generated default.
Recording lifecycle
An ordinary recording eligible for active-session publication has three states:
- ARMED: established only when a new session's initial successful
open --save-script[=<path>]is recorded as action zero. The session records portable action inputs and fresh target identity evidence. - ABORTED: reached when another plain
opensucceeds while ARMED. The app operation may continue, but the recording is no longer a single-open bootstrap and close-time publication is disarmed. The successfulopenresponse warns that a fresh session is required to author another script. - PUBLISHED: reached only after
session save-scriptatomically commits the complete history from the sole recordedopenthrough the current action. The session remains active at the destination, but close-time script publication is disarmed.
A filesystem or target-collision failure leaves the recording ARMED, including its path and same-target
--force authorization, so the caller can correct the target or permissions and retry. ABORTED and
PUBLISHED are terminal until the session is destroyed. session save-script in either state fails
loudly and never writes.
Every existing arming entry point respects terminality. open --save-script on any existing session —
unarmed, ARMED, ABORTED, or PUBLISHED — is rejected before app dispatch; a plain later open is allowed,
causing ARMED to become ABORTED while leaving ABORTED/PUBLISHED unchanged.
close --save-script[=<path>] in ABORTED or PUBLISHED is rejected before platform close or filesystem
work, so the caller can retry with plain close. Plain close tears down ABORTED/PUBLISHED without
writing; closing an unpublished ARMED recording retains the existing close-time publication behavior. A
fresh session is the only re-arming boundary.
Amendment (2026-08-02, shipped). A session that was never armed — no recorded
open --save-scriptat all — has no fourth pre-ARMED state name here, but it previously fell through the same close-time write path as an ARMED recording:close --save-scripton it folded the request into the authoring lifecycle at record time and published anyway. Live evidence showed this produces a script whose actions carry selector fallback chains but no recording-timetarget-v1evidence, with no signal to the caller that evidence capture never ran — degraded replay verification is worse than a loud refusal.close --save-scripton a never-armed session is now rejected before any teardown or filesystem work, the same way ABORTED/PUBLISHED are, namingopen --save-scriptas the recovery; a plainclosestill tears the session down without writing. This is distinct from #1533, which is about an already-ARMED-then-ABORTED session whose flag ingress re-enabled recording and let a bareclose(no--save-scripton the close itself) publish; that case is resolved by the amendments below.
Amendment (#1533, shipped). ABORTED terminality above was enforced only by
abortAuthoringOnSecondOpenclearingsession.recordSession— inert by ordering, not by construction.recordSessionis an evidence-capture flag that several surfaces set directly, so a later--save-scriptre-armed it while the status stayed ABORTED, and a bareclosethen published the full session log through a writer that only knew how to refuse repair transactions. The refusal this ADR specifies forclose --save-scriptin ABORTED promises the caller that plainclose"tears down without writing", so the gap also made an existing error message untrue.ABORTED is now terminal by construction.
--save-scriptarms recording through one rule owned by the publication projection, which answers "not recording" for an ABORTED lifecycle on every surface that handles the flag — the re-open builder, the close finalizer, and the recorded-action ingress — so the flag can no longer contradict the status. Publication authorization is likewise the aggregate's to answer: the writer asks one publication-blocked question covering not-recording, an uncommittable or committed repair, and an ABORTED authoring lifecycle alike, so every path that reaches it (bareclose, teardown, idle-reap, active publication) refuses. ARMED and PUBLISHED lifecycles and every repair transaction are unchanged; no state name, transition, or entry point in the lifecycle above is added or altered.
Amendment (#1533 follow-up, shipped). The rule above still relied on a stored
SessionState.recordSessionthat every writer had to set in step with the lifecycle. Routing the writes through one place made the two agree; it did not make disagreement unrepresentable, and the field remained a second source of truth for a question the aggregate already answered.Recording is now derived, not stored.
isRecordingPublicationreads it off the lifecycle — ordinary authoring records only while ARMED; a repair transaction records for its whole lifetime, terminal statuses included — and the field is gone. Three consequences worth stating, because they are what the derivation buys:
- No surface can arm recording without moving the lifecycle that authorizes it, so #1533's drift is unrepresentable rather than guarded against.
buildNextOpenSessionand the close finalizer make no recording decision at all now.- The writer's publication gate is answered entirely by the aggregate. Its separate ABORTED check is gone, because a terminal authoring lifecycle is already not recording — no second gate that could disagree with the first about authoring.
- Evidence capture and publication authorization now derive from the same aggregate, but they remain distinct predicates, and deliberately so. They coincide for ordinary authoring: ARMED both records and publishes, ABORTED and PUBLISHED do neither. They do NOT coincide for repair —
isRecordingPublicationis true for every repair status,committedandabortedincluded, while publication additionally requiresisRepairArmedWriteBlockedto pass, which refuses a committed transaction (idempotent no-op) and one that is not yet committable (ADR 0012 decision 6, C2). A repair that captures evidence is therefore not necessarily a repair that may publish, and collapsing the two would silently republish or commit a prefix.This is behavior-preserving: the derivation reproduces exactly what the flag held at every transition. Whether a committed repair should still capture recording-time evidence is a real question this deliberately does not answer — the old flag said yes, and changing that is a behavior change, not a derivation.
This lifecycle is distinct from ADR 0012's repair transaction. session save-script rejects a session
with saveScriptBoundary set and directs the caller to finish or abort the repair through its existing
replay --from and teardown commit protocol. Active-session publication never marks a repair COMPLETE,
commits a healed slice, writes # agent-device:heal-complete, or changes repair tombstone semantics.
Destination readiness and replay handoff
The destination is an authored postcondition, not the last navigation action. Before publication, the
recorded suffix after the last mutating action must contain a wait whose target is a portable selector
or selector chain identifying a landmark on the ready destination screen. A duration wait, wait stable,
or wait @ref does not qualify, though wait stable may follow the landmark wait. The publisher validates
the serialized guard before filesystem work and never relies on repair-only bare-ref rejection.
session save-script refuses publication without this destination guard and tells the author to
record one. V1 does not infer a screen identity from a snapshot or synthesize an implicit guard.
Amendment (#1349, shipped). The guard is now identity-level, not merely selector-level. A selector wait recorded while ARMED captures landmark-mode
target-v1evidence for the matched element (ADR 0012's read-only-step amendment), and a qualifying destination guard is exactly a selector wait whose annotation isverification: "verified"— a selector wait on an identity-empty element (no id and no label) records no annotation and does not qualify, so publication refuses it with a recovery hint naming a labeled/id-bearing landmark. On replay, polling is preserved: the wait keeps polling until a selector match carries the recorded identity (local identity + leaf-anchored ancestry prefix), and a deadline reached with only same-selector impostors fails closed as anidentity-mismatchREPLAY_DIVERGENCEbefore the wait reports success. The reshuffled-screen false-pass below is covered by a provider-scenario regression (record → publish → replay against a reshuffled tree whose same-label node sits under a different id/ancestry).Amendment (#1398, ADR 0017). An identity-empty landmark is not the only case that fails to qualify: a selector wait whose only identity is an app-rendered echo of a literal the recording session already parameterized via
fill --record-asalso records no annotation (ADR 0017's session-scoped echo protection), so it does not qualify as a destination guard either. Publication refuses it with the same recovery hint, directing the author to a stable, non-value-bearing landmark — an enforced version of exactly the fix issue #1398's motivating scenario applied by hand.
The original selector-level caveat, retained as context: the guard proved that an element matching its
selector exists, not that it is the same landmark element observed while authoring, so a reshuffled
screen containing the same weak label elsewhere could false-pass.
#1349 owned the identity design for waits and
remaining read-only steps. It preserves polling: ADR 0012's pre-action target-v1
verification cannot be attached to a wait unchanged because a not-yet-present landmark is the expected
starting condition, so the identity check runs after the wait's selector resolves and before the wait
reports success (see ADR 0012's #1349 amendment for the mechanism and the read-only-step coverage
classification).
The phrase "last mutating action" is derived from a request-sensitive recording-effect trait on the
central CommandDescriptor, required for every command that records session actions and guarded by a
completeness test. It is not a publisher-local command-name set. The trait distinguishes app-state
mutation from observation for subcommands such as read-only versus mutating find, keyboard, and
alert actions; the existing conservative refFrameEffect: may-invalidate classification is not precise
enough for this boundary.
On consumption, a script without close preserves the existing replay behavior: the named session stays
active and the successful ReplayCommandResult returns its session id. The caller binds subsequent
commands to that returned id. Replay reports success only after the destination guard completes; the
absence of close changes neither action dispatch nor when replay reports success.
Response shape (issue #1384). ReplayCommandResult carries a required sessionActive: boolean,
computed from whether session still exists in the daemon's own store when the response is built — never
by re-parsing the script for close. This is what lets the real CLI/IPC client (not just the in-process
handler) actually honor "the session stays active": without an explicit signal in the response, the
client's one-shot replay/test teardown had no way to distinguish a still-active handoff from a
finished one, and tore the owning daemon down regardless (src/daemon-client/daemon-client-lifecycle.ts).
sessionActive is true for every close-less run (including a --from resume) and false once the
script's terminal close — or a repair-armed run's deferred equivalent — has executed.
Sensitive inputs
Executable .ad artifacts serialize ordinary action inputs literally; diagnostic and event-log
redaction cannot protect an input that replay must later execute. ADR 0017 resolves #1348 for explicit
sensitive fills: fill ... --record-as PASSWORD sends the live text to the app while recording only
${PASSWORD}. Replay receives the value through AD_VAR_PASSWORD or --env PASSWORD=<value>.
This is opt-in and fill-only. Unparameterized fill/type inputs remain literal artifact content, so
authors must use ADR 0017 for each sensitive fill and avoid secret-bearing type steps. CLI help states
both the safe workflow and the remaining literal-input warning next to publication guidance.
ADR 0017's #1398 amendment extends this protection to the whole recording session: a later, unrelated
recorded action can no longer re-serialize an app-rendered echo of an already-parameterized literal into
its own result or target-v1 evidence, so a value protected once by --record-as stays protected for
every action recorded after it in the same session, not only its own originating fill.
Artifact contract
The published .ad:
- contains exactly one recorded
openas its first action and every recordable action through the publication request; - contains a portable selector/selector-chain destination guard after its last descriptor-classified mutating action;
- does not append or serialize
session save-scriptorclose; - uses the ordinary session context header, selector-chain optimization, and canonical
target-v1annotations captured while ARMED; - fails loudly rather than emitting an unresolved session-local
@refor dropping target evidence that ADR 0012 requires for an element-targeting recorded action; and - uses the existing same-directory atomic publication primitive, refusing every existing target unless
--forceauthorizes atomic replacement.
The success response identifies the final path and session and reports the number of serialized actions.
The command must fail before writing when there is no active session, recording was not armed, the
recording is ABORTED or PUBLISHED, the history does not contain exactly one initial open, no portable
destination guard exists, or a repair transaction owns the session. Every failure explains the recovery
action; none degrades to { written: false } success.
Surface and naming
V1 extends the existing session command and typed session client surface. It does not introduce
script start/stop, marks, or a second replay engine. CLI help makes the two phases explicit: the
existing --save-script flag arms evidence capture, while session save-script publishes without
teardown.
This ADR does not rename or deprecate --save-script. The flag and session action name the same persisted
artifact: open --save-script configures the armed recording's eventual target, while
session save-script requests publication now. save-replay is rejected because replay is the act of
executing that script, not the artifact being saved.
Consequences
- Agents can record onboarding or deep navigation as one self-contained starting state, replay it from scratch, and continue from the resulting live session.
- The workflow has two explicit moments because evidence must be armed before the first target action and the destination is known only when the caller publishes.
- Normal unarmed interactions keep their current fast paths and retention behavior.
- A successful active-session publication cannot collide with a later close-time auto-save.
- A second successful open abandons the in-flight artifact instead of silently publishing a multi-open bootstrap; authoring resumes only in a fresh session.
- Intermediate lifecycle-free fragments, entry guards, include semantics, composed digests, and shared fragment pinning remain entirely under #1336.
- Secret-bearing fill authoring uses ADR 0017's explicit
--record-as <VAR>contract. Unparameterized fill/type inputs remain literal script content. Arbitrary history ranges remain out of scope. - On consumption (issue #1384), a
replaywhose script has no terminalcloseleaves a live daemon and app session behind — including the ordinary one-shotreplayinvocation's own owned/ephemeral daemon, which the client keeps alive and reports a--state-diraddress hint for instead of tearing down. An unattended close-less replay (e.g. in CI) therefore leaks a session exactly like one opened interactively: bounded by ordinary idle-reap or an explicitclose, never by the one-shot command's own process lifetime. This is the intended shape of "the caller owns close," not an accident, and recorded test flows already end withclosesotestruns are unaffected.
Alternatives Considered
- Save any session history at the end: rejected because target identity evidence cannot be reconstructed after the interaction and the resulting artifact would undercut ADR 0012's provenance model.
- Capture full target evidence in every session: rejected until its direct-path latency, capture count, memory, and event-log costs are measured. It would alter ordinary interaction behavior to make one authoring command shorter.
close --no-closeor--save-script --no-close: rejected because a command named for teardown would conditionally preserve the session and because it would not solve late arming.- General
script start/stopor history marks: rejected because the accepted v1 boundary is exactly one recordedopenthrough one destination. Arbitrary slices require entry-state semantics and belong with fragment design. replay save: rejected becausereplay <path>consumes an artifact while publication consumes a live session; the session owns the source data and lifecycle.- Infer a destination fingerprint at publication: rejected for v1 because screen identity and readiness are app semantics. A caller-authored target wait is explicit, already recordable, and fails at the correct point during cold replay.
Validation Required for Implementation
- An unarmed session refuses publication before filesystem work and names
open --save-scriptas the recovery. - An armed session without a destination guard refuses publication before filesystem work and names a
selector-targeted
waitas the recovery;wait @ref, duration waits, andwait stableare covered refusal cases. - A second successful plain
opentransitions ARMED to ABORTED and disables all publication, whileopen --save-scripton an existing session is rejected before app dispatch. - An armed session publishes
openplus target-annotated actions withoutclose, returns the final path, remains active, and can continue accepting commands. - The artifact replays from a cold start, completes its destination guard, returns the live session id, and accepts a subsequent command on that session.
- Every action in ADR 0012's existing target-binding command set has canonical identity evidence and no
unresolved
@refreaches disk; since #1349 the destination guard additionally requires verified recorded landmark identity, with a reshuffled-screen false-pass regression proving replay fails closed. - Existing-target refusal preserves the original bytes;
--forcereplaces atomically; a failed publish remains retryable. - After PUBLISHED, later ordinary actions remain usable, repeated
session save-scriptfails, plainopen --relaunchcannot re-arm, andopen --save-scriptis rejected before app dispatch. - In ABORTED/PUBLISHED,
close --save-script[=<other>]is rejected before platform close and plainclosetears down without writing; closing an unpublished ARMED recording preserves current close-time publication behavior. - On a never-armed session (2026-08-02 amendment),
close --save-scriptis likewise rejected before platform close or filesystem work, namingopen --save-scriptas the recovery; plainclosestill tears down without writing, and the session is not deleted by the rejected request. - Descriptor completeness tests classify every recordable request's mutation effect, including request-sensitive read-only/mutating subcommands, and destination-guard ordering consumes only that trait.
- Repair-armed sessions refuse this action without changing repair state.
- CLI help documents ADR 0017's
--record-asworkflow and warns that unparameterized fill/type inputs remain literal script content. - Provider-backed integration scenarios cover the public daemon route, and live iOS and Android runs prove the saved artifact and post-save session behavior on real backends.