Files
callstack__agent-device/docs/adr/0016-active-session-script-publication.md
Michał Pierzchała 62001cf210 refactor(record): derive session recording from the publication lifecycle (#1719)
* refactor(record): derive session recording from the publication lifecycle

`SessionState.recordSession` stored an answer the script-publication
aggregate already contained. Every writer set both, but nothing made them
agree, and #1533 was the consequence: a `--save-script` ingress re-armed
the flag behind an ABORTED status, and a bare `close` published a
recording the caller had been told was aborted.

That fix routed every write through one rule, which made the two agree
without making disagreement unrepresentable. The field remained a second
source of truth, and its doc comments had to carry the invariant that a
type could enforce.

Remove the field and derive the answer. `isRecordingPublication` reads
recording off the lifecycle: ordinary authoring records only while ARMED;
a repair transaction records for its whole lifetime, terminal statuses
included. That last clause is deliberately exact rather than merely safe —
`armRepairStep` armed the old flag and neither `abortRepair` nor
`commitRepair` ever cleared it, so narrowing it would silently stop
evidence capture for a committed repair. Whether it should is a real
question, and a behavior change, so it is left alone here.

What this buys, beyond one less field:

- `buildNextOpenSession` and `finalizeOrdinaryCloseScript` make no
  recording decision at all now, so no surface can arm recording without
  moving the lifecycle that authorizes it.
- The writer's publication gate is answered entirely by the aggregate. Its
  separate ABORTED check is gone: a terminal authoring lifecycle is
  already not recording, so one question replaces two that could disagree.
- The R7 ownership ratchet drops from 23 writer-owned fields / 29 owner
  claims to 22 / 26, and the layering manifest loses the entry whose
  comment documented the smell ("deliberately set on its own by paths that
  record without arming a publication").

Behavior-preserving: the derivation reproduces what the flag held at every
transition. The test fixtures that armed `recordSession` with no
publication state described a shape production stopped producing at #1478;
they now carry the lifecycle that causes recording.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PFW9gJqz1wEHoowkdd1nFW

* test(close-script): flush queued event-log writes before removing the tmp root

CI failed the Coverage lane with ENOTEMPTY removing the test's tmp root,
in `afterEach` rather than in an assertion.

`SessionStore.recordAction` QUEUES its event-log append
(`queueEventLogWrite`) instead of writing it, and every close path in this
file records an action. Nothing awaited that write, so `fs.rmSync(root,
{recursive: true})` could race it: the pending append recreates
`<root>/sessions/<name>/` while rmSync is walking, and the final rmdir
fails ENOTEMPTY. It needs CI's parallel load to lose the race — the file
passes 12/12 in isolation locally.

Await `flushSessionEventLogWrites()` before removing. The hazard is latent
in any test that records actions and then removes its tmp root; this fixes
the file that failed rather than sweeping the pattern, which deserves its
own change.

Not added to the #1419 contention-retry list: that list requires a
concrete spawn/wait mechanism named per entry, and this file has none. The
race was a real teardown bug, not lane contention.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PFW9gJqz1wEHoowkdd1nFW

* docs: correct ADR 0016 on recording vs publication for repair

Review caught a real overstatement. The amendment claimed evidence capture
and publication authorization are "the same question asked of the same
state". That holds for ordinary authoring — ARMED both records and
publishes, ABORTED and PUBLISHED do neither — but not for repair:
`isRecordingPublication` is true for every repair status including
`committed` and `aborted`, while the writer additionally applies
`isRepairArmedWriteBlocked`, refusing a committed transaction and one that
is not yet committable.

State it as it is: both decisions derive from the same aggregate, but they
remain distinct predicates, and collapsing them would republish a committed
repair or commit an incomplete prefix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PFW9gJqz1wEHoowkdd1nFW

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-11 10:58:03 +02:00

342 lines
24 KiB
Markdown

# 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](#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 plain `open`) 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-`wait` destination
guard after the last descriptor-classified mutating action — since #1349 the guard's `target-v1`
annotation must be `verification: "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 `saveScriptBoundary` set 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 `fill` inputs must be recorded as placeholders via `fill --record-as <VAR>`
(ADR 0017, shipped for #1348); unparameterized `fill`/`type` values persist literally into the
artifact, so a secret entered without `--record-as` is published.
## 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](https://github.com/callstack/agent-device/issues/1346) and the throwaway
[`session-save-script` prototype](https://github.com/callstack/agent-device/tree/agent/prototype-session-save-replay/scripts/prototypes/session-save-script)
record the motivating workflow and API trial. Composable lifecycle-free fragments remain a separate
decision in [#1336](https://github.com/callstack/agent-device/issues/1336).
## Decision
Add an explicit publication action for an already-armed ordinary script recording:
```sh
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 `open` succeeds while ARMED. The app operation may continue,
but the recording is no longer a single-open bootstrap and close-time publication is disarmed. The
successful `open` response warns that a fresh session is required to author another script.
- **PUBLISHED**: reached only after `session save-script` atomically commits the complete history from
the sole recorded `open` through 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-script` at 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-script` on 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-time
> `target-v1` evidence, with no signal to the caller that evidence capture never ran — degraded
> replay verification is worse than a loud refusal. `close --save-script` on a never-armed session
> is now rejected before any teardown or filesystem work, the same way ABORTED/PUBLISHED are,
> naming `open --save-script` as the recovery; a plain `close` still tears the session down
> without writing. This is distinct from
> [#1533](https://github.com/callstack/agent-device/issues/1533), which is about an
> already-ARMED-then-ABORTED session whose flag ingress re-enabled recording and let a
> *bare* `close` (no `--save-script` on the close itself) publish; that case is resolved by the
> amendments below.
> **Amendment (#1533, shipped).** ABORTED terminality above was enforced only by
> `abortAuthoringOnSecondOpen` clearing `session.recordSession` — inert by ordering, not by
> construction. `recordSession` is an evidence-capture flag that several surfaces set directly, so
> a later `--save-script` re-armed it while the status stayed ABORTED, and a *bare* `close` then
> published the full session log through a writer that only knew how to refuse repair
> transactions. The refusal this ADR specifies for `close --save-script` in ABORTED promises the
> caller that plain `close` "tears down without writing", so the gap also made an existing error
> message untrue.
>
> ABORTED is now terminal by construction. `--save-script` arms 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 (bare `close`, 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.recordSession` that 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. `isRecordingPublication` reads 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. `buildNextOpenSession` and 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 — `isRecordingPublication` is true for every repair status, `committed` and `aborted`
> included, while publication additionally requires `isRepairArmedWriteBlocked` to 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-v1` evidence for the matched
> element (ADR 0012's read-only-step amendment), and a qualifying destination guard is exactly a
> selector wait whose annotation is `verification: "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 an
> `identity-mismatch` `REPLAY_DIVERGENCE` before 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).
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](https://github.com/callstack/agent-device/issues/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.
### Artifact contract
The published `.ad`:
- contains exactly one recorded `open` as 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-script` or `close`;
- uses the ordinary session context header, selector-chain optimization, and canonical `target-v1`
annotations captured while ARMED;
- fails loudly rather than emitting an unresolved session-local `@ref` or 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
`--force` authorizes 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 `replay` whose script has no terminal `close` leaves a live daemon and
app session behind — including the ordinary one-shot `replay` invocation's own owned/ephemeral daemon,
which the client keeps alive and reports a `--state-dir` address 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 explicit `close`, 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 with `close` so `test` runs 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-close` or `--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/stop` or history marks:** rejected because the accepted v1 boundary is exactly
one recorded `open` through one destination. Arbitrary slices require entry-state semantics and belong
with fragment design.
- **`replay save`:** rejected because `replay <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-script` as the
recovery.
- An armed session without a destination guard refuses publication before filesystem work and names a
selector-targeted `wait` as the recovery; `wait @ref`, duration waits, and `wait stable` are covered
refusal cases.
- A second successful plain `open` transitions ARMED to ABORTED and disables all publication, while
`open --save-script` on an existing session is rejected before app dispatch.
- An armed session publishes `open` plus target-annotated actions without `close`, 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 `@ref` reaches 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; `--force` replaces atomically; a failed publish
remains retryable.
- After PUBLISHED, later ordinary actions remain usable, repeated `session save-script` fails, plain
`open --relaunch` cannot re-arm, and `open --save-script` is rejected before app dispatch.
- In ABORTED/PUBLISHED, `close --save-script[=<other>]` is rejected before platform close and plain
`close` tears 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-script` is likewise rejected before
platform close or filesystem work, naming `open --save-script` as the recovery; plain `close` still
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-as` workflow 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.