* refactor(move): move remaining package-ready modules out of src/core - validation.ts -> @agent-device/kernel/validation - android-system-surface-disclosure.ts -> @agent-device/contracts/android-system-surface-disclosure - project-runtime.ts -> @agent-device/host-kit/project-runtime - runtime-transport-hints(.test).ts -> @agent-device/host-kit/runtime-transport-hints - app-events.ts (+tests) -> src/daemon/app-events.ts - dispatch-payload.ts (+test), payload-input.ts -> src/daemon/ - fill-backend-result.ts (+test) -> src/daemon/ interaction-outcome.ts stays in core: it correlates ResolvedInteractionTarget with error objects across the commands(rank3)/daemon(rank4) boundary, and contracts explicitly refuses mutable interaction-outcome lifecycle (R18). * chore(gates): pin the exports and boundary snapshots for the moved core modules
27 KiB
ADR 0018: Unified Request Event Journal
Status
Proposed (2026-07-24; revised same day after architecture review). Nothing here is implemented.
Scope reduction relative to the first draft, per review: progress streaming stays a permanent, transport-owned second channel (it is an output port, not telemetry); the journal absorbs diagnostics, agent-cost, the session event log, and the replay timing trace. Out-of-request session events get an explicit scope model, and the replay-trace redaction fix is declared as an intentional compatibility change rather than hidden under a byte-compatibility claim.
Rules at a glance
Normative summary of the proposal; contracts and rationale below.
- One event catalog (
packages/contracts/src/events.ts) is the single declaration site for every diagnostic/telemetry event kind: key, subsystem, default level, and derivation traits. Emitting a kind not in the catalog is a compile error; consumers derive kind sets from traits, never from parsing kind names. - Catalog keys are internal identities. Legacy wire/file discriminators (today's diagnostics
phasestrings, the replay trace'stypevalues) are preserved by sinks as output mappings; a wire name is never automatically the canonical cross-channel identity. - One journal per scope is the single append point (
emitDiagnosticevolves into it). Scopes are: the existing per-request scope, and explicit short-lived session-scoped teardown scopes for out-of-request work (idle reap, daemon shutdown), following the existing fatal-scope precedent. Emitters state facts; they never know who is listening. NoEventEmitter, no dynamic subscription. - Every consumer is a sink: explicitly registered at scope construction, invoked synchronously
in registration order, individually best-effort (a sink error is swallowed and must not affect
other sinks or the request). Concurrent sub-work (sharded test attempts run under
Promise.allSettled) runs in forked child scopes that clone every mutable binding — envelope, log path, routing — and share only concurrency-safe aggregation state; nothing mutable is ever shared across concurrent scopes. - Progress streaming is not journal-owned.
src/request/progress.tsand its typedRequestProgressEventunion remain the transport's channel, unchanged. - Existing formats do not change, with one declared exception.
events.ndjsonv1 entries,cost.runnerRoundTrips, per-request diagnostics ndjson, andreplay-timing.ndjsonshapes stay byte-compatible behind golden fixtures. The exception: replay-timing events written by the currently unredacted helper become redacted — an intentional security fix, called out as a behavior change, not smuggled in as "compatible." - Redaction happens once, at the journal boundary, for every journal-owned output.
- The journal is an observability spine, not a source of truth:
session.actionsand all other session state remain owned by their stores. No state is ever rebuilt from journal events. - Anything that leaves the machine goes through a closed, allowlist-by-construction schema
(the usage sink's
UsageRecord): every field drawn from a registry-enumerated vocabulary — command names, typed error codes, flag names, durations — with positionals, selectors, labels, and messages unrepresentable by type, not merely redacted.
Context
The codebase has grown four parallel event vocabularies, each with its own emit call, shape, redaction discipline, and sink (inventoried 2026-07-24):
- Diagnostics (
packages/host-kit/src/internal/diagnostics.ts). ~155 distinct stringly-typedphasevalues across ~70 files, anAsyncLocalStoragerequest scope entered in exactly three places (CLI pre-parse, daemon per-request inrequest-router.ts, daemon fatal catch-all), an in-memory buffer plus aphaseCountstally, and debug-mode live streaming to the per-request ndjson file (aftercreateRequestExecutionScoperebindslogPath),daemon.log, or stderr. ThetraceLogPathscope option is dead: no call site ever sets it. - Session event log (
@agent-device/session-journal/session-event-log). Append-only per-sessionevents.ndjsonwith kindsrequest.started/request.finished/action.recorded, written from three request-lifecycle points plusSessionStore.recordAction, read only by the publiceventscommand.action.recordedis already a projection ofsession.actionspushes — the model this ADR generalizes. Crucially, not every append happens inside a request:finalizeRepairTeardown(src/daemon/session-store.ts) records a synthesizedcloseaction during idle reap or daemon shutdown, with — per its own comment — "no live request here." - Progress streaming (
src/request/progress.ts,src/daemon/request-progress-protocol.ts). A closed, typedRequestProgressEventunion emitted through a single-sinkAsyncLocalStorageinstalled by the socket/HTTP transport, serialized as ndjson envelopes multiplexed onto the same stream as the final response, with client-disconnect-as-cancellation tracking. Opt-in per request viameta.requestProgress; disabled under--json. Events are written to the wire unredacted today. - Replay timing trace (
src/daemon/handlers/session-replay-trace.ts,session-test-runtime.ts, read bysrc/cli/replay-test/trace.ts). Per-attemptreplay-timing.ndjsonfiles whose paths are created dynamically inside each attempt — written by two different helpers, one of which redacts and one of which does not (appendReplayTraceEventvsappendReplayTestTimingEvent).
(src/upload-progress.ts is a fifth, local-only callback sink for artifact uploads; it never
crosses the request scope and is out of scope here. Production code contains no EventEmitter
usage at all — the codebase already avoids implicit pub/sub, which this design preserves.)
The channels overlap but share nothing: no common envelope, no shared kind vocabulary, and
consumers couple to emit sites by string. The sharpest instance: agent-cost's runnerRoundTrips
is countDiagnosticEventsByPhase(['ios_runner_command_send', 'ios_runner_readiness_preflight'])
(src/daemon/request-router.ts) — a hand-picked name list a new runner phase can silently drift
from. That violates two established repo rules: what enumerates N? (nothing enumerates
round-trip phases), and categories come from recorded fields, never parsed names.
The near-term consumer motivating the unification is opt-in usage analytics over agent behavior — command frequencies, failure codes, and outcome sequences that trip agents — under a hard privacy constraint: request metadata only, never request content (decision 4).
ADR 0008 solved the same shape of problem for commands: one declaration registry, behavior derived by parity-tested projection. This ADR applies that thesis to events — to the channels that are genuinely telemetry. Progress is not one of them (decision 3).
Decision
1. Event catalog: kinds are declared data
packages/contracts/src/events.ts exports one EVENT_CATALOG — a const object with one entry per event
kind, keyed by today's diagnostics phase strings (no renames in this ADR):
type EventDescriptor = {
/** Grouping for docs/filtering; not parsed from the kind name. */
subsystem: 'apple-runner' | 'android' | 'daemon' | 'request' | 'record' | 'replay'
| 'snapshot' | 'cli' | 'web' | 'compat' | 'util';
level: 'info' | 'warn' | 'error' | 'debug';
/** Derivation traits — the only way a consumer may select kind sets. */
traits?: readonly EventTrait[]; // e.g. 'runner-round-trip', 'session-lifecycle', 'replay-trace'
};
type EventKind = keyof typeof EVENT_CATALOG;
emitDiagnostic's phase parameter narrows from string to EventKind, making every
uncataloged emit a compile error — the same completeness mechanism as the ADR 0011 matrix. The
existing ~155 phases enter the catalog verbatim; per-kind data payloads stay
Record<string, unknown> in this ADR (typed payload schemas are a possible per-kind follow-up,
not a prerequisite — see Alternatives).
Kinds absorbed from other channels (the session event log's request.started/request.finished/
action.recorded, the replay trace's replay_action_start/replay_action_stop/attempt-lifecycle
events) get catalog entries whose internal keys need not equal their legacy wire discriminators;
the owning sink maps the internal kind to the exact legacy kind/type value on output. Wire and
file names stay frozen for consumers; catalog identities stay free to be consistent.
Consumers derive kind sets from traits: RUNNER_ROUND_TRIP_PHASES becomes
kindsWithTrait('runner-round-trip') (exactly ios_runner_command_send and
ios_runner_readiness_preflight carry the trait; their _skipped/_recovered variants
deliberately do not, preserving today's cost semantics). A parity test pins the derived sets so a
trait edit is a reviewed decision, not a drift.
The catalog lives in contracts (ranked, kernel-adjacent) so every zone may import it without a
layering back-edge. The journal runtime evolves in place in
packages/host-kit/src/internal/diagnostics.ts, keeping all existing import directions legal.
2. One journal, explicit sinks, defined scope model
The diagnostics scope becomes the journal: the single append point for diagnostics, agent-cost
tallies, session lifecycle events, and replay traces. emit(event) does exactly what
emitDiagnostic does today — stamp the scope envelope (ts, requestId?, session?,
command?), redact data, buffer, tally phaseCounts — and then offers the event to each
registered sink.
Scope model. Two scope shapes, both explicit:
- the existing per-request scope (CLI entry, daemon
handleRequest, fatal catch-all), unchanged; and - a session-scoped teardown scope for out-of-request work: idle reap and daemon-shutdown
finalizers (
finalizeRepairTeardown, session teardown cleanup) open a short-lived scope bound to the session (norequestId), following the existingemitFatalDiagnosticprecedent. This is what keeps "no sink writes without the journal" true for the teardown-recordedcloseaction — today's direct event-log append fromSessionStore.recordActionmust keep working when no request exists, and silently dropping it is unacceptable.
Sink contract. Sinks are constructed with the scope and listed in one place per entry point — statically enumerable, greppable, no dynamic subscription API. Semantics, normative:
- Ordering: sinks are invoked synchronously, in registration order, at emit time. A sink may queue its own I/O (the session event log keeps its per-path serialized write queue).
- Isolation: each sink invocation is individually best-effort — an error is swallowed (as
emitDiagnostic's stream writes are today) and must never affect other sinks, the buffer, or the request. - Flushing: flush remains journal-owned (
flushDiagnosticsToSessionFilesemantics unchanged); sinks that buffer expose flush hooks the journal calls on request finalization and fatal paths. - Concurrency — fork, never rebind; forks clone all mutable state: the journal gains a fork
primitive:
journal.fork(bindings, fn)runsfninside a new AsyncLocalStorage scope object that clones every mutable field of the parent scope — the envelope (session,command,debug),logPath, and all routing bindings — applies the fork's own frozenbindingson top, and owns its own event buffer, flushed against the fork's bindings when the fork ends. Shared with the parent are only the sink list and concurrency-safe aggregation state: thephaseCountstally stays request-global (single-threaded atomic increments; agent-cost must span shards). Shardedtestruns execute attempts concurrently (Promise.allSettledinrunReplayTestShards,src/daemon/handlers/session-test.ts) and their nested dispatch callscreateRequestExecutionScope(request-router.tschild scopes), which rebindssessionandlogPathmid-flight — so a shared mutable envelope would cross-route debug and session-log events between shards even with frozen trace bindings. Therefore:updateDiagnosticsScopemutates only the innermost (current) scope, each shard/attempt lifecycle wraps its work — including all nested replay dispatch — in a fork, and every mid-flight rebind lands on that shard's clone. The fork's trace binding (the attempt'sreplay-timing.ndjsonpath) dies with the fork, so no clearing step exists to race; the replay trace sink drops trace-kind events emitted with no bound destination. - Scope identity (exporter readiness): every scope — request, teardown, and fork — carries a
scopeId, and a fork records its parent's asparentScopeId; both ride the event envelope. This is deliberately minimal span plumbing: forks already form a tree, so a future exporter sink (e.g. OpenTelemetry) can emit real parent-child spans from envelope fields alone, without retrofitting the envelope or touching emit sites. Cross-process correlation staysrequestId(already stamped on both sides of the RPC); a W3Ctraceparent-style request-meta field is additive under ADR 0006 and deferred to whatever ADR introduces an exporter. No exporter is part of this ADR.
Registered sinks:
- debug stream sink — today's debug-mode live ndjson writer (per-request file /
daemon.log/ stderr fallback), unchanged behavior including theliveWrittenEventCountflush watermark; - session event log sink — consumes the
session-lifecycletrait kinds and writes today'sevents.ndjsonv1 entries, reusing the existing presentation builders (buildActionSummary,buildRequestSuccessEventPresentation), write queue, and retention window (session-event-log-window.ts, #1788: size-capped rotation to one retained generation,events.ndjson.1). Entry bytes are unchanged by the cap, but the window's sidecar (events.ndjson.window.json) is new journal-owned state that cursor identity depends on: it records each retained generation's first absolute line index, line count, and first-line digest, and the reader verifies those against the files before answering. A sink that ever owns this file owns that contract too — it is not a cache and cannot be regenerated from the entries; - replay trace sink — consumes
replay-tracetrait kinds, maps internal kinds to the legacytypevalues, writes the per-attempt file via bound routing context; replaces both existing append helpers; - agent-cost tally — already a projection (
phaseCounts); now reads its kind set from therunner-round-triptrait.
The request-lifecycle points that today call sessionStore.recordEvent/appendActionEvent
directly (request-execution-scope.ts, request-router.ts, session-store.ts) instead emit
the corresponding journal kinds; the sink owns the file. SessionAction recording itself is
untouched: recordActionEntry still pushes to session.actions first, and the action.recorded
journal event remains a projection of that push.
3. Progress streaming stays a separate, transport-owned channel
Progress is an output port of the transport, not telemetry: it has ordering and
interleaving guarantees relative to the final response on the same stream, client-disconnect
tracking that feeds request cancellation, a closed typed union (RequestProgressEvent) consumed
directly by the wire serializer, and a single-sink AsyncLocalStorage installed by the transport
outside handleRequest. Forcing it through the journal would either weaken its payload type to
Record<string, unknown> at the wire boundary or make the journal envelope part of the RPC
protocol — both worse than the status quo. It also writes unredacted today, so journal-boundary
redaction would silently alter wire bytes.
src/request/progress.ts, request-progress-protocol.ts, and both transports are therefore
unchanged by this ADR. If a future feature needs progress events in the journal (e.g. a request
timeline), the correct shape is a mirror emit — the progress emitter additionally emitting a
cataloged journal kind — never rerouting the wire path through the journal. That is a separate,
opt-in decision.
4. First planned consumer: the usage sink (privacy by construction)
The motivating consumer for this architecture is opt-in usage analytics over agent behavior: which commands run, how often, what fails with which error codes, and outcome sequences that trip agents (consecutive full snapshots, screenshot-after-snapshot, repeated same-command failures). The unit of observation is request metadata, never request content.
The privacy rule is therefore allowlist by construction, not redaction. Redaction is a
blocklist over rich data and one missed field leaks a selector or label; the usage sink instead
projects session-lifecycle events into a closed UsageRecord schema in which every field draws
from an enumerated vocabulary:
command/action — enumerated by the CommandDescriptor registry;- outcome —
okor the typed error code (ADR 0010's closed set); never the error message; durationMs, cost fields (runnerRoundTrips,nodeCount),platform/appleOs;- flag names present on the request — never flag values;
- a hashed session identity plus per-session sequence number, so command bigrams are computable downstream.
Positionals, selectors, labels, fill text, snapshot content, and error messages are unrepresentable
in the schema — there is nothing to scrub. Sequence/anti-pattern detection is downstream analysis
over the record stream (a local stats read first; opt-in export later), never emission-side logic.
The sink itself is a follow-up landing after migration step 3 (it consumes the
session-lifecycle kinds); this ADR fixes only its schema discipline so no richer interim export
gets built.
Invariants
- Byte compatibility, one declared exception.
events.ndjsonv1, per-request diagnostics ndjson,replay-timing.ndjsonshapes and legacytypediscriminators, andcost.runnerRoundTripsare produced identically by the sinks; golden fixtures undercontracts/fixtures/pin each format. The declared exception: replay-timing events from the previously unredacted helper become redacted (URL query stripping, sensitive-key masking, long string truncation may alter those lines). This is an intentional security fix shipped as such — changelog-visible, fixture-updated — not an unnoticed side effect. - Single redaction boundary for journal-owned outputs.
redactDiagnosticDataruns once, on append. The flush path's second whole-entry pass is retained only until sinks own their writes, then removed. No sink may write an event that did not pass through the journal. (Progress is not journal-owned and keeps its current unredacted wire behavior.) - Emitters never branch on consumers. An emit site must not know or care whether debug mode or a replay trace is active. Anything consumer-conditional lives in the sink.
- No state from events. The journal is write-only telemetry. Recovering session state, replay provenance, or repair watermarks from journal events is out of scope and rejected (see Alternatives).
- Traits over names. No production code may select events by matching kind-name substrings
(
startsWith('ios_runner_')etc.); a lint-style test enforces this for the derived-set helpers.
Consequences
- Traceability becomes "filter the journal by
requestId" (or bysessionfor teardown scopes): one envelope, one ordering, across what are today three telemetry files with different envelope shapes. Progress remains separately observable on the wire, as an operational channel should be. - New observability features (a timeline command, an OTel exporter, a perf-phase report) are new sinks — no new emit sites, no new vocabularies.
- Adding an event kind is a catalog entry plus emit sites; forgetting the catalog is a compile error. Renaming an internal kind is invisible on the wire when the owning sink maps it; changing a wire name remains a breaking change for log consumers and needs the same care as today.
- The dead
traceLogPathoption, the duplicate redaction pass, and the unredacted replay-timing helper are deleted; replay-timing redaction is the one visible output change. - Cost: a mechanical, wide migration (~70 files import the typed kind; the string values themselves do not change, so diffs are type-level). Runtime overhead is one synchronous sink-dispatch loop per event over today's buffer push — negligible against device I/O.
Alternatives considered
- Absorb progress streaming into the journal (the first draft's step 5): rejected after review. Progress is a transport-owned output port with ordering, cancellation, and closed-union type-safety requirements; rerouting it through a generic journal weakens a protocol contract to gain nothing operationally. Mirror emits remain available if journal visibility is ever needed.
- One closed discriminated union with typed payloads per kind: rejected for this ADR — 155 kinds × payload schemas is a large, low-yield migration, and payload types can be added per-kind incrementally once the catalog exists. The catalog is the enforcement point; payload typing is a follow-up, not a prerequisite. (Progress keeps its typed union precisely because it stays on its own channel.)
EventEmitter/pub-sub bus: rejected. Implicit subscription defeats both goals: deletability (who listens is not statically visible) and traceability (causality disappears into.on()). The codebase currently has zero productionEventEmitterusage; this design keeps it that way.- Event sourcing (rebuild session state from the journal): rejected. Session state has owners
(
SessionStore, ref frames, repair transactions) with their own invariants and gates; deriving it from telemetry would couple correctness to an observability channel and is a large behavioral rewrite with no user-facing benefit. - A process-global fallback scope instead of explicit teardown scopes: rejected — an ambient catch-all scope would make "which scope did this event land in" environment-dependent and hide missing-scope bugs; teardown entry points are few and known, so explicit scopes are cheap.
- Leave the channels, just type the phases: considered as a stopping point — step 1 alone fixes the stringly agent-cost coupling — but it leaves the duplicate envelopes, the redaction inconsistency, and three write paths. Kept as the migration's first independently useful step instead.
- Merge
upload-progressandapp-eventsin: rejected. Upload progress is a local byte-counter callback that never crosses the request scope;src/daemon/app-events.tsis a deep-link builder for thetrigger-app-eventcommand, not an event channel.
Validation required for implementation
- Type-level completeness: an emit with an uncataloged kind fails to compile. Orphan detection (declared-but-never-emitted kinds) is static: a source-scan check in the layering-lint style proves every catalog kind has at least one emit reference; runtime observation is explicitly not the mechanism (it conflates untested branches with dead declarations).
- Golden byte-compatibility fixtures for
events.ndjson, the per-request diagnostics file, andreplay-timing.ndjson— the latter's fixture updated once, in the same change that declares the redaction fix. - Out-of-request coverage: an idle-reap/shutdown
finalizeRepairTeardownstill lands its synthesizedcloseas anaction.recordedentry inevents.ndjson, via a teardown scope, with no request active. - Retention: the size cap (#1788) is orthogonal to sink ownership but shares the file. A sink
migration keeps rotation inside the one serialized write path, keeps the sidecar written before
the rename it describes, and keeps reads failing typed
(
EVENT_LOG_CURSOR_EXPIRED/EVENT_LOG_WINDOW_UNVERIFIED) rather than answering a cursor it cannot place. - Per-attempt routing: a multi-attempt
testrun writes each attempt's trace to its own file with legacytypevalues; trace-kind events emitted with no bound destination are dropped, not misrouted. A concurrent nested-action regression runs sharded attempts in parallel — each performing nested dispatch that rebindssession/logPathviacreateRequestExecutionScope— and proves per-fork isolation across all three routed outputs: eachreplay-timing.ndjson, each per-request diagnostics ndjson, and each session'sevents.ndjsonplus its retained generation contain only their own shard's events — no cross-writes, no drops — including events emitted afterawaitpoints. - Sink isolation: a sink that throws does not affect the buffer, other sinks, or the response; ordering across sinks is registration order.
cost.runnerRoundTripsparity: the trait-derived set equals the current literal list; the existingrequest-router-costtest keeps passing unchanged.- Redaction: a fixture proves a sensitive value emitted in
datanever reaches any journal-owned sink output, including the replay timing trace. - Usage schema gate: a type/test check proves every
UsageRecordfield's vocabulary source — a field is either numeric, an enum imported from the owning registry (command names, error codes, flag keys), or a hash — and that no open-string field exists; adding one is a failing gate, not a review comment. - Layering:
scripts/layering/check.tsstays green — catalog incontracts, runtime inhost-kit, no new back-edges.
Migration plan
Each step lands green and independently useful:
- Catalog + typed kinds — introduce
EVENT_CATALOG, narrowemitDiagnostic'sphasetoEventKind, migrate all emit sites mechanically (string values unchanged), deriveRUNNER_ROUND_TRIP_PHASESfrom therunner-round-triptrait with a parity test, add the static orphan check. No runtime behavior change. - Sink seam — restructure
diagnostics.tsso the scope owns an explicit sink list with the ordering/isolation/flush contract; move the debug stream and agent-cost tally behind it; deletetraceLogPath; collapse to a single redaction pass. Byte-compat fixtures land here. - Session event log as sink + teardown scopes — request-lifecycle emit points route through
the journal; the
events.ndjsonwriter becomes a sink; idle-reap/shutdown finalizers open session-scoped teardown scopes so out-of-requestaction.recordedevents keep flowing; theeventscommand keeps its paging contract, including the typed cursor-expiry and window-verification errors the #1788 retention window added to it. - Replay trace as sink — the journal fork primitive lands with its concurrent-shard
regression; both trace helpers are replaced by one sink with fork-bound per-attempt routing and
legacy
typemapping; the declared redaction change ships here with its fixture and changelog entry.