Skip to content

feat(2.6.5): W1 — the eight P0 blockers, closing with consent before a local MCP spawn (ADR-0084) - #83

Merged
cemililik merged 114 commits into
mainfrom
development
Aug 24, 2026
Merged

feat(2.6.5): W1 — the eight P0 blockers, closing with consent before a local MCP spawn (ADR-0084)#83
cemililik merged 114 commits into
mainfrom
development

Conversation

@cemililik

@cemililik cemililik commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Closes W1 of Phase 2.6.5 — the eight P0 blockers plus CR-92, each with an accepted ADR landing before its implementation. pnpm run ci and pnpm coverage both exit 0.

The bulk of this branch is CR-14/CR-92/CR-15/CR-17 (ADR-0082, ADR-0083); the newest work is CR-16 — consent before a local MCP spawn (ADR-0084), which is where I'd point your review first.


What CR-16 decides

A stdio MCP server is a local program the artifact chooses. Before ADR-0084, opening an agent that declared one ran it — no tool call needed, ask mode did not apply, and shell: false did not stop the declaration from naming sh or node.

The gate is a host decision at one chokepoint, per machine and per resolved declaration. Four things the original register item did not anticipate, each settled in the ADR after you rejected two earlier drafts:

  1. Provenance was the wrong axis. The item said "untrusted-provenance import". A git pull changes a committed artifact with no import step, so the gate covers every stdio server on every path.
  2. cwd is in the identity. A command may be relative — node server.js in two directories is two programs. (I asked you about this once with an incomplete premise and re-asked with the correct one.)
  3. The environment is in it too, values type-tagged. NODE_OPTIONS is a name: a digest over names alone let one grant match every value of it. A sole {{secrets.NAME}} reference contributes only the name, so no credential enters the digest — and the tag is what stops a literal secret:acme from colliding with a real reference.
  4. Resolve before the decision, spawn the resolved path after it, so a PATH change in between cannot substitute a binary under an approved fingerprint. The declared-environment denylist run_command already had is now shared with this host — that was a gap, not a decision.

ADR-0084 §10 — the 19 acceptance items, and which test satisfies each

# Item Test
1 Nothing spawns without consent, proven by a counter at the process boundary mcp-servers.test.ts — "a REFUSING gate means nothing is ever handed to the client"
2 Approve → spawn once; decline → refuse, no spawn mcp-servers.test.ts "…an APPROVING gate hands them over exactly once" + mcp-consent-gate.test.ts "declining refuses the run, and records NOTHING"
3 Second invocation, same directory, no prompt mcp-consent-gate.test.ts "approving records a grant, and a second invocation does not ask again"
4 Command / args / env-name / env-value / secret-swap / cwd each re-prompt; a rotated secret behind an unchanged reference does not mcp-consent.test.ts "changes with the resolved command, the args, an env NAME, an env VALUE, and the cwd" + the secret-reference invariance cases
5 A literal secret:x and {{secrets.x}} differ mcp-consent.test.ts "a LITERAL … and a … reference DIFFER"
6 Digest over the resolved executable; the spawn uses that path mcp-consent.test.ts (PATH walk) + mcp-servers.test.ts "spawns the CONSENTED absolute executable, not the authored word" + find-on-path.test.ts
7 Two symlinked routes are one grant; a repointed link is not mcp-consent.test.ts "canonicalizes the cwd…" and "a REPOINTED symlink is a different program"
8 Denylist rejected at parse, both entry points, case-insensitive, drift-tested declared-env.test.ts + process.test.ts "refuses EVERY member of the shared list — the drift test"
9 The printed digest is exactly what --allow-mcp-stdio accepts mcp-consent-gate.test.ts, same-named case
10 No prompt unless all four signals mcp-consent-gate.test.ts, the four-case table
11 --allow-mcp-stdio writes no grant mcp-consent-gate.test.ts "--allow-mcp-stdio writes NO grant"
12 Store 0600, directory 0700, no env value in the written bytes mcp-consent.test.ts mode/repair cases, "creates a MISSING parent directory 0700", and "the store never holds an env VALUE — scanned in the written BYTES"
13 Two real concurrent child processes, not two calls in one mcp-consent.test.ts + fixtures/concurrent-granter.mjs — genuine OS processes, via a module-resolution hook
14 A grant followed by a truncated tombstone spawns nothing, and is reported mcp-consent.test.ts "ANY unparseable line folds the WHOLE store closed" + mcp-consent-gate.test.ts "REPORTS an unreadable grant store"
15 Stable digest, golden vectors, v2: fails closed, lone surrogate refused mcp-consent.test.ts golden-vector table + mcp-consent-gate.test.ts "refuses a declaration carrying a LONE SURROGATE"
16 Hostile characters stripped from every displayed field, asserted on the composition; args as separate fields consent-prompt.test.ts (9 cases) + mcp-consent-gate.test.ts "SANITIZES every displayed field"
17 Env values and the artifact source shown; a secret never rendered mcp-consent-gate.test.ts "shows the resolved executable, each argument separately, and the env with authored values" + createConsentGate "FORWARDS the artifact" + session-host.test.ts "names the RESOLVED agent file at the consent gate"
18 The same server twice in one artifact prompts once mcp-consent-gate.test.ts "two ids naming the SAME declaration prompt ONCE and record ONE grant"
19 Network transports unaffected, still SSRF-gated mcp-consent-gate.test.ts "a network-only declaration needs no consent" + the existing SSRF suite

Every new guarantee above was mutation-verified: the guard was broken and exactly the named test failed. Item 15's v2: half is the one corollary I did not write a separate test for — it follows from ConsentGrantSchema's z.literal(1).

Where the reviews found real bugs

Three review rounds (Opus, then Sonnet, on top of two rounds you ran yourself on the ADR). What they caught is the honest part of this PR:

  • The gate covered only relavium run. chat, chat-resume, Home and agent run all spawned ungated — an imported agent is opened by chat --agent, not by run. Fixed, then made structurally impossible: connectAgentMcp/connectWorkflowMcp now refuse a stdio declaration when no gate was wired, so a sixth surface cannot forget.
  • --allow-mcp-stdio was registered and never forwarded. The action's opts was typed { input?: … }, so the documented CI escape hatch did nothing.
  • The artifact field died twice, at two different layers. First it was threaded through three types and never assigned. Then createConsentGate returned a two-parameter function where StdioConsentGate declares three — TypeScript accepts a shorter parameter list, so both callers passed an artifact that vanished at runtime, and two of the five call sites carried comments claiming it worked. Nothing caught it because every test called assertStdioConsent directly; the adapter the surfaces actually wire had no test at all. It has one now.
  • canonicalJson did not implement the lone-surrogate refusal the ADR claimed.

Documentation (ADR-0084 §11)

The gate had shipped with its specification living only in an ADR and in the code. mcp-integration.md is now its canonical home — the fingerprint's five inputs, the canonicalization rules, the env type-tagging, the golden vectors, the grant-file format — stated as a cross-surface contract, because §1 places the desktop's Rust spawner outside this gate and it owes the same one. Also: ipc-contract.md (the language-neutral invariant), commands.md (--allow-mcp-stdio, the exit-2 refusal, the CI pattern), both schema specs (the denylist as an authored error), security-review.md (the shared declared-environment rule and the local-spawn floor), and the six cross-phase pointers that still routed this gate to "2.6.B".

Landed after this PR was opened: the CI failure and the Sonar pass

Three of these are real defects the tooling surfaced, not metric noise.

A "flaky" test was reporting a real open race. runMigrations — the two-process race had been failing intermittently and reading as noise. Converting a database to WAL takes an EXCLUSIVE lock, and SQLite returns SQLITE_BUSY for it without invoking the busy handler — waiting there could deadlock, so it refuses. Two Relavium processes opening one fresh history.db at the same moment raced and the loser's open failed outright: 18 failures in 30 paired spawns, 0 in 30 with the WAL switch routed through the withBusyRetry this package already had. What a user saw was relavium run refusing to start because another Relavium happened to be starting. busy_timeout does not help — I measured that too before assuming it would (15/25 with it first, 17/25 with it second). A repeated open-race test pins it; removing the retry fails it.

A quadratic regex on authored YAML. /\{\{[\s\S]*?\}\}/ retries its lazy scan from every {{, each attempt running to end of string: 1033ms on a 120KB default of '{{' repeated, against 0.011ms for the indexOf form. It matters more since ADR-0084 settled that an artifact is often not the user's — a parse-time stall is now something a shared file can cause.

A vacuous assertion in the AuthoredSystemPrompt fence. expect(typeof forged).toBe('string') on a value assigned from a string — true of every string ever written. The compile-time @ts-expect-error was always the real check; the runtime line now pins the property worth having (the brand is erased, so it costs nothing at run time).

I got the ReDoS test wrong twice before getting it right, and the PR history shows it. < 500ms was a machine-speed assertion (33ms locally, 610ms on a loaded runner). A growth ratio across two input sizes is the textbook answer and is genuinely discriminating in isolation — 1.98 for this function, 3.97 for a known quadratic one — but under whole-monorepo contention the per-round ratios scattered from 0.98 to 10.17, and it blew the default 5s budget by measuring seventeen redactions. The threat is catastrophic backtracking, not a merely-quadratic scan: measured, a nested quantifier ran the input past 120 seconds. The bound now sits where it separates those two worlds, with an explicit test timeout so the assertion reports a failure rather than the runner.

The rest of the Sonar pass: nine complexity refactors where the extracted piece had a name (assertStdioConsent, buildChatSession, resolveAndValidateWorkflowInputs, verifyResumeIdentity, deepStructuralEquals, violatesInputContract, validateValidationBlock, parseWorkflow, runCommand, plus dispatchprepareEffect and the audit's shared order walk); three nested ternaries; nine duplicate imports; two void operators; two optional chains.

Five findings are rejected, each with the reason in a comment at the code rather than in a dashboard:

Finding Why not
charCodeAtcodePointAt The function's job is to find a surrogate code unit standing alone. codePointAt combines a well-formed pair, which makes the paired/unpaired distinction unexpressible.
JSON.parse(JSON.stringify(…))structuredClone That round trip normalises to JSON shape and throws on what JSON cannot represent — which is what the catch around it exists for, and what stopped a RangeError escaping resumeFromCheckpoint and stranding a lease for a full TTL. structuredClone clones a Date, a Map and a cycle happily.
EffectSlot = number is redundant The docblock above it is where "what a slot is" lives; inlining number deletes the concept from the type surface.
#emitDurable's closure is too complex Every branch is an ordering guarantee relative to await prior and the persist, and the fast path exists to avoid a microtask hop the comments record as having reordered the log once already.
resumeFromCheckpoint is too complex (by one) The branches are the ordering — lease before any read, identity before checkpoint — and each comment states which line it must precede.

Two things I want your call on

  1. 69e6a86 mixes ADR-0083's text with CR-14/CR-21 code. I raised rebase-splitting it three times and proceeded without an answer rather than block. If you want the ADR extracted into its own commit, say so and I will rebase.
  2. Two pre-existing broken doc links I found while sweeping (docs/decisions/0069-… → a renamed ADR-0047 slug, and database-schema.mdkeychain-and-secrets.md missing its ../desktop/ prefix). Unrelated to this work, so I left them out rather than widen the diff.

Not closed here

  • Lazy connect — split out with ADR-0052 §3 named as its blocker: deferring the spawn against today's immutable registry would delete the agent's MCP tool grant outright. Tracked in deferred-tasks.md with the tool-list cache as its unblocker.
  • npx version/integrity pinning — the other half of the old register item. Consent answers "may this program run?", not "is this the same code as yesterday?": an approved npx -y @acme/server re-resolves on every spawn under a byte-identical declaration.
  • W2–W9 of the phase.

🤖 Generated with Claude Code

Summary by Sourcery

Close the Phase 2.6.5 W1 reliability and security blockers by hardening durable execution, resume identity, provider liveness, input handling, and local MCP process consent across every CLI surface.

New Features:

  • Require explicit consent before spawning local stdio MCP servers across all CLI entry points, with persistent per-machine grants and a one-shot CI authorization flag.
  • Add stdin-based secret re-supply for cross-process gate resumes without exposing credentials in arguments or durable logs.

Bug Fixes:

  • Prevent ungated MCP spawns, stale resume identity, duplicate external effects, uncertain terminal durability, and cross-process run ownership races.
  • Harden input handling, environment validation, terminal rendering, stream completion, and concurrent history database startup.

Enhancements:

  • Add durable run leases, fencing, terminal outbox handling, effect journaling, resume verification, and per-attempt provider deadlines.
  • Move compaction summaries out of trusted system prompts and enforce authored system prompts with branded types.
  • Share executable resolution, environment denylisting, canonical serialization, and interactive-terminal checks across hosts.

Build:

  • Update database migrations and schemas for run leases and durable effect journals.

CI:

  • Strengthen regression, concurrency, mutation, source-wiring, and real-process integration coverage for reliability and security guarantees.

Documentation:

  • Document the local MCP consent contract, grant fingerprints, CI authorization, secret resume flow, effect durability, exit codes, and updated reliability decisions.

Tests:

  • Add comprehensive tests for MCP consent, grant storage, executable resolution, input admission, resume identity, effect journaling, stream grammar, deadlines, leases, terminal outboxes, and real SQLite/process races.

Chores:

  • Update roadmap and deferred-task tracking to close the consent portion of CR-16 while separating lazy MCP connection and npx integrity pinning follow-ups.

cemililik and others added 30 commits August 11, 2026 11:12
Phase 2.6.5 status moves to in progress with a Progress block naming the six
closed items, their closure dates, and the three gaps carried forward rather
than implied: ADR-0077's unbuilt required regression (without it `#runAttempt`'s
money-durability arm is unreached), `CR-10`'s property being inexpressible from
the durable log alone, and the oracle's three remaining debts to `CR-92`.

`current.md`: the Wave 1 closure is now merged on both PRs (#81 on 2026-08-09,
`#W15-1` + the first 2.6.5 batch via #82 on 2026-08-11), the ledger node is
marked complete in the graph, and the 2.6.5 section carries a live 6-of-46 count
pointing at `CR-10` as next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…fore the ADRs

A seven-agent audit re-verified every W1 Evidence claim against the tree before
any ADR is written. Most held; seven did not, and two would have produced an ADR
that was wrong on its first pass.

**CR-14's Failure paragraph is FALSE and inverts where the defect is.** All three
shipped adapters already detect a no-terminal EOF and emit a classified
`transport` error — anthropic.ts:843, openai.ts:1301, gemini.ts:935. The gap is a
missing trust boundary at `FallbackChain` for FOREIGN providers (cassette,
scripted, Phase-2's gateway) plus the chain's own `usage === undefined =>
succeeded`. The enforcement point moves to the chain; the adapters become defence
in depth.

**CR-12's key cannot work as written.** `nodeAttempt` + `toolCallId` in the
identity means every retry and resume mints a new key, so tier 1's "safe retry
under the same key" is unreachable — and it contradicts action-guard-seam.md:109,
which already says the node-retry attempt is NOT the idempotency key. Two
identities are needed. Three of the five components are also unobtainable today
(`runId` is absent from NodeExecContext and from AgentSession by design; the
attemptNumber that reaches dispatch is the within-chain counter). Two scope
claims were wrong: the `tool` NODE type is unimplemented (three dispatch sites,
not four), and an MCP tool cannot be assigned a tier at all — no annotations are
parsed, and they would be attacker-controlled if they were.

**CR-13: half of ADR-0062 §1's rejection ground was already false when written.**
Anthropic's `mergeAdjacentSameRole` landed 2026-06-14, three weeks before
ADR-0062, and closes the "two consecutive user messages" hazard at the seam. The
superseding ADR does not merely offer a better alternative.

**CR-16's lazy connect is structurally blocked** by ADR-0052 §3 — the registry has
no mutation API and MCP ToolDefs exist only as a product of connect-time
`listTools()`, so deferring the spawn deletes the tool grant. Split out with its
blocker named; consent-before-spawn alone satisfies the written Acceptance.

**CR-17's "key reference plus version" is not implementable** — ADR-0006 defines
no version concept and `maskInputs` emits a self-reference, not a keychain ref.
`sse-event-schema.md:247` already states the false version.

**CR-10**: the quoted code comment does not exist verbatim, and out-of-order
commit is NOT reachable on the CLI's synchronous store on the happy path — it
needs a SQLITE_BUSY yield or an async store. Also recorded: this item does NOT
retire ADR-0074's sum-vs-max fold, which is driven by seq-ASSIGNMENT order.

**CR-92**: "handle resolution" does not locate on the terminal path; only the
media reclaim does. `#emitDurable`'s totality must survive for non-terminal
events or ADR-0077's barrier argument rots. `reconcile()` is a second write path
that bypasses the choke point entirely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n helper

`scripts[call] ?? []` handed an overrun call a SILENT EMPTY stream, which the
chain reads today as a successful zero-usage attempt — so a test that invoked the
provider more times than it scripted passed for a reason it never stated.

This lands before `CR-14` deliberately. Under that item's rules 5 and 6 the same
shape becomes a classified error, so leaving the fallback in place would have
shown a wall of unrelated red inside CR-14's own PR with real regressions hidden
in it.

Matches the existing precedent verbatim — `m2-e2e-harness.e2e.test.ts:102` and
`m5-chat-harness.e2e.test.ts:77` already index directly and throw. All 46 tests
still pass, so nothing was relying on the fallback.

Refs: CR-14
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…al outbox

Closes CR-10 and CR-92 of phase 2.6.5, and establishes the durable-write seam
CR-11 and CR-12 extend rather than re-break.

Eight sections. The ones that carry weight:

The engine serializes each run's appends into one tail, and the mechanism is a
one-line move — `await prior` goes ABOVE the persist, so the single existing tail
serializes ask, write and delivery in that order. A second `#persistTail` is
rejected: two chains over one ordering property is how they drift.

The media de-inline stays OUTSIDE that region (an unbounded host round-trip
inside it would block every later write including the terminal); the media
reclaim moves AFTER a successful terminal persist (a terminal whose write failed
must not have already released the run's references). Two calls twenty lines
apart, stated separately so a reviewer does not conflate them.

`persistEvent` takes one extensible `DurableWriteContext`. The store's guard is a
`SELECT max(seq)` inside the existing IMMEDIATE transaction through the `tx`
handle — no migration, no snapshot regen.

The terminal outbox lives outside the store behind a required host port, per the
maintainer's ruling: a row in the same history.db is unavailable for most of the
fault class it exists to survive, and no outbox at all means reconcile() writes
`run:failed{internal}` for a run that completed. It is drained BEFORE
reconciliation — across processes that order is what stops the same divergence
being reintroduced — and a drained entry whose run already has a terminal is
dropped, not appended.

`durability: 'durable' | 'uncertain'` is minted once, at the handle and never on
the RunEvent (the store persists the delivered event verbatim, so a live-only
field either lands on disk or forces the two forms to diverge). CR-11 and CR-14
reuse it.

Totality is preserved for non-terminal events — ADR-0077's B1/B2/B3 argument
rests on it and both money events are non-terminal.

Two limits stated rather than papered over: "durable" here means process-crash,
not power-loss (`synchronous = NORMAL`); and CR-10's headline property is not
provable from the log alone, because streamed events consume sequence numbers and
are never persisted — it needs the store harness that lands before the
implementation.

And what this does NOT change: ADR-0074's sum-vs-last-wins and the Math.max
checkpoint fold survive untouched. They are driven by seq-ASSIGNMENT order among
concurrent emitters, which an ordered append tail cannot change.

Refs: ADR-0078
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…oven with

Lands BEFORE CR-10's implementation, exactly as CR-91's durable-truth oracle
preceded the spine. Asserting the spine with an instrument the reviews already
found insufficient is the failure this ordering exists to prevent.

**Why a log assertion cannot do this.** CR-10's property is "no persisted event
is missing from the middle", and that is not expressible from the durable log
alone: the run's sequence numbers are shared with streamed events (`agent:token`,
`cost:updated`, …) which take a number and are never persisted, so a healthy
completed run reads [0,1,2,3,5,10,…] and a lost event is byte-identical to one
that was never meant to land. The witness has to come from the ask side, which
only a store decorator holds.

`createAppendAudit` wraps a RunStore and records, per run, the ask order, the
commit order and each ask's outcome. Three predicates, deliberately separate:

- **PREFIX** — committed events in sequence order must be an unbroken leading run
  of the asked events. A cut tail is legitimate (a crash truncates); a hole is
  the defect.
- **ASK ORDER**, independently — a synchronous store commits in order even when
  the engine issued its writes concurrently, which is exactly the pre-CR-10 state.
  Without this predicate the whole item would look green on `better-sqlite3`.
- **COMMIT ORDER** — what an async store actually did with the asks it accepted.

Vacuity checked as ADR-0078 requires: mutating the prefix predicate to a SET
comparison turns exactly the three hole-detecting tests red, so the prefix is
what does the work. Restored after measuring.

Exported from packages/core on the same terms as `checkDurableTruth` — CR-92's
acceptance has to be certified in apps/cli against the real history.db store.

Scoped per run SEGMENT, not per runId for all time: a resume re-seeds the bus
from the durable maximum, so a resumed leg legitimately starts far above 0 and a
caller wraps the store again for the second leg.

`pnpm run ci` and `pnpm coverage` green.

Refs: ADR-0078, CR-10
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…defect it exists for

41 agents, six lenses, every finding adversarially verified before it was acted
on; 17 stood, the rest were refuted. Two mattered.

**The harness had no predicate for the property ADR-0078 §1 establishes — and its
docblock claimed one did.** §1's property is "ask N+1 is not ISSUED until N has
SETTLED". `askOrderViolations` cannot express that: the engine assigns the
sequence number and starts the persist with no await between them, so its asks go
out in sequence order whether or not they overlap. Measured: run the pre-CR-10
emit shape against a synchronous store — which is what `better-sqlite3` is, and
which this ADR itself established commits in order — and the prefix, ask-order
and commit-order predicates ALL verdict HOLDS. CR-10's own acceptance clause,
"break-verify by restoring the concurrent start", would have gone green against
the instrument built to prove it. That is the exact failure the
oracle-before-the-spine ordering exists to prevent, reproduced one level up.

`overlapViolations` now states the property directly: an ask issued for a run
while a prior ask for that run is still in flight, captured at ask time because a
settled record looks identical either way. Break-verified — disabling it reddens
the concurrent-start test and nothing else. Three tests added, including the
negative one (two runs writing concurrently is legitimate; the append is ordered
per RUN, and scoping the check globally would fail every parallel run).

**A test was hollow and a second asserted nothing about its own title.** The
dual-event test survived deleting the guard it names — its assertions observed
the per-run filter, not the guard. The inner-rejection test asserted only
`holds`/`holes`, which are byte-identical whether the rejection was recorded or
dropped. Both now assert the thing they claim.

Docs: ADR-0036 gained the dated `> Amended` back-pointer every prior amender of
it carries. ADR-0078's Related said "Closes CR-10 and CR-92" on an ADR whose
every mechanism is unimplemented — the precise false-completion shape the phase
document warns about twice; it now says "Decides … implementation staged". §1's
"the awaited total is unchanged" was false for a concurrent emitter and
contradicted the ADR's own first Negative; scoped. §8 now names the
`run-history-store.ts` comment CR-10 must rewrite — it justifies the telescoping
delta by out-of-order commit, the very thing §1 removes, so landing the tail
would leave the money write's stated reason contradicted by the code beside it.

Also: `durable-truth.ts` claimed CR-10 for itself and carried no pointer to the
harness the append-audit docblock says it points to; corrected to the ORDER half
only. `records`/`mutable` were byte-identical duplicate arrays whose hand-copied
type literal was the only reason two widening `as` existed — one array now, with
the mutable view derived by a mapped type.

`pnpm run ci` and `pnpm coverage` green.

Refs: ADR-0078, CR-10
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… own predicate

Second pass over the same step, aimed at what the first round missed and at the
fold itself. Five findings stood, one refuted. The first round of 41 agents did
not find the one that mattered.

**A fault hook that THROWS orphaned its record at `pending` forever.**
`AppendFault` is typed to RETURN an Error, but a store double that throws is the
obvious way to write one — and `better-sqlite3` throws synchronously for real.
The hook sat outside the try/catch, so such a throw escaped before `outcome` was
set. Every LATER ask on that run then read the orphan as still-in-flight and
reported a false overlap: the predicate this harness exists for would have fired
on a correctly ordered engine, failing the very implementation it is meant to
certify. One try/catch now covers the hook and the write. Break-verified —
restoring the old shape reddens exactly the new regression and nothing else.

**The docblock overclaimed, and this round measured it.** It said the pre-CR-10
engine overlaps "on every event". It does not: every `#emitDurable` call site
awaits and `#emitDurable` awaits its own region, so an ordinary sequential run
overlaps NOTHING — asked and committed both `[0,1,2,3,6,7,8,9,10]`, zero
overlaps. It fires under genuine concurrency: a `max_parallel: 2` fan-out
produced exactly one, "sequence 10 (node:completed) was asked while [9] was still
in flight". That is clause 4 — record rather than assert — and the corrected
paragraph now states what was measured.

**The measurement is now a test, not a claim in a comment.** Two e2e cases drive
a live WorkflowEngine over an audited store: the sequential baseline (zero
overlaps, `committed === asked`) and the fan-out baseline. The fan-out assertion
is written to FLIP when CR-10 lands, and says so — flipping it from
`toBeGreaterThan(0)` to `toEqual([])` IS the acceptance the phase document asks
for ("break-verify by restoring the concurrent start"), and deleting it instead
would remove the only end-to-end evidence the ordered tail changed anything.

ADR-0042 gets the dated back-pointer its §4 was owed — ADR-0078 §1 re-times the
terminal media sweep to after a successful terminal persist, and the previous
fold gave that note to ADR-0036 only. ADR-0078's title and index row now name
both. Also renumbered two predicate comment blocks that both read "3." after the
overlap predicate was inserted between them.

`pnpm run ci` and `pnpm coverage` green.

Refs: ADR-0078, CR-10
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Implements ADR-0078 §1–§3. The engine's asks for one run are now serialized, the
store refuses an append that would leave a hole, and `reconcile()` — the second
write path — carries the same guard.

**§1 is one line, as the ADR said.** `await prior` moves ABOVE the persist inside
`#emitDurable`, so the single existing tail serializes the ask, the write and the
delivery instead of only the delivery. Below it the previous event's write had
been STARTED but not joined, so two events for one run overlapped in flight and
nothing but the store's timing kept the log a prefix.

**§2's guard is `DurableWriteContext`, one extensible object.** It lives in
`@relavium/shared` rather than beside the port, because `@relavium/db` implements
that port and depends only on shared — the dependency runs one way. `CR-11`'s
fencing token and `CR-12`'s journal correlation extend the same object rather
than breaking `persistEvent` twice more.

`expectedLastSequenceNumber` is the last sequence ASKED, not the last committed,
and that difference is the guard. After a lost write the engine keeps running
(§6 totality), so reporting the last SUCCESSFUL sequence would make an append
that skips the lost one match — creating exactly the hole. The store evaluates it
with `max(seq)` INSIDE the existing IMMEDIATE transaction through `tx`: no
migration, no snapshot regen, and — verified — a refusal rolls the derived rows
back with it. `InMemoryRunStore` enforces the identical guard, because a
reference that accepts what the real store rejects makes every core test prove
nothing.

The terminal is exempt, and that is a named hole, not an oversight:
exactly-one-terminal (ADR-0036) outranks the guard, and a terminal the store will
not take is CR-92's outbox to own. Resume seeds the guard from the checkpoint's
`lastSequenceNumber` — left at `-1` a resumed leg's first append would claim the
log was empty and be refused, breaking resume outright.

**The acceptance flipped a test rather than adding one.** The fan-out e2e case
landed one commit ago asserting `overlapViolations.length > 0` — the measured
pre-CR-10 baseline — and said in its own comment that flipping it IS the
acceptance. It went red on this change and is now `toEqual([])`. Break-verified
the way the phase document words it: putting `await prior` back below the persist
reddens it again. Exactly one test moved; the other 1154 in `packages/core`
stayed green, which is the evidence that serializing the append perturbed no
interleaving a test legitimately pins.

Also folds ADR-0078 §8's doc obligation: the telescoping `run_costs` comment
justified itself by out-of-order COMMIT — the thing this commit removes — so it
would have read as obsolete the moment the tail landed. The real reason is
stamp-time capture (ADR-0077), which the ordered tail does not touch.

`pnpm run ci` and `pnpm coverage` green.

Refs: ADR-0078, CR-10
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…uard switched off

41 agents, six lenses, every finding adversarially verified; 18 stood. The
reorder itself survived — measured end to end on a max_parallel:3 fan-out, asks
came out 0..15 with the belief exactly the previous durable seq every time, no
deadlock on any path, and ADR-0077's totality argument intact. What did not
survive is the scaffolding around it.

**The append-audit harness dropped `ctx`.** Its decorator re-spelled
`persistEvent`'s signature by hand, so when ADR-0078 §2 added the parameter it
silently kept compiling and forwarded only the event — every store driven through
the instrument built one commit earlier to certify CR-10 ran with CR-10's guard
OFF. Now typed as the port's own member, so CR-11's fencing token and CR-12's
journal correlation cannot be dropped the same way. The belief is recorded on
`AppendAskRecord` too: a WRONG belief is now visible from the ask side rather
than only as a store rejection.

**Nothing observed the engine side of the guard.** Both mutations passed the
whole suite: dropping the ctx entirely (the guard disconnected from the durable
write path) and guarding the terminal too (ADR-0036's exemption removed). A
recording-double test now pins that every non-terminal ask carries the previous
ask's sequence, the first believes `-1`, and the terminal is unguarded.
Break-verified.

**CR-10's acceptance clause was undischarged.** "A crash injected between the two
must leave a prefix with no hole" now has an end-to-end test: one `node:completed`
write is dropped mid-fan-out and the sibling's append is REFUSED rather than
landing past the gap. Also added: `reconcile()`'s guard — the second write path
this item exists to close — had no test at all, and the store's per-run scoping
was untested (dropping `where(runId)` passed all 307 db tests, while its
in-memory twin had exactly that case).

**Two claims of mine were wrong and are corrected in place.** The db test said
the guard is "unreachable through the engine once the ordered tail is in place";
measurement says the opposite — after any lost write the engine's very next
guarded ask is a holed one, because §6 keeps it emitting. And the port comment
presented the optional `ctx` as ADR-0078 §2, which specifies a required one; it
now records the deviation, why it differs from §4's required outbox port, and the
residual risk it leaves.

The `#emitDurable` docblock still said "Persists stay concurrent; only delivery
is serialized" — the exact sentence ADR-0078's Context quotes as the defect.
ADR-0078 §8's two §6 re-derivations landed. `database-schema.md`
§"Concurrency & transaction behavior" — the canonical home the ADR names — now
describes the compare-and-append, including that it is deliberately NOT in the
retryable set.

Also recorded rather than left implied: the ordered tail made ADR-0074 §2's
ordering assertion hold with or without the money barrier, so that test no longer
pins the barrier. It is kept for the tail, and the note says where the barrier's
own half is pinned instead — which matters, because this commit's predecessor
offered "the other 1154 tests stayed green" as evidence.

CR-10 is marked closed in the phase document with the code that closes it, and
with the two things it does NOT close: the terminal exemption (CR-92's outbox),
and end-to-end certification against the real history.db (rides with CR-92).

`pnpm run ci` and `pnpm coverage` green.

Refs: ADR-0078, CR-10
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t test what I said

Second pass over CR-10 including the Opus fold. The reorder and the store guard
survived a sixth independent attempt to break them — six properties mutated, each
caught by an existing test. What did not survive is two tests the previous commit
message specifically credited, and a claim about the type system.

**The `reconcile()` guard test was hollow, and its own comment named a scenario it
did not build.** It called `reconcile()` twice and asserted the second produced
nothing — which it does, but because `listInterruptedRuns` already excludes a run
carrying a terminal, so the second call never reached the guarded write at all.
Two reviewers independently verified by mutation: removing ADR-0078 §3's guard
from `reconcile()` entirely left all 1165 `packages/core` tests green, this one
included. It now drives two CONCURRENT reconciles against one still-interrupted
run — both read the same belief, only one can be right — and asserts exactly one
repair landed. Break-verified: the mutation reddens it.

**The acceptance test asserted something the injected fault satisfied by itself.**
"A lost non-terminal write makes the engine's next ask fail closed" checked
`rejected.length >= 1`, and the deliberately-dropped write IS a rejection — so it
passed whether or not the guard refused anything downstream. It now requires at
least two, and asserts the prefix property directly over the segment CR-10 covers:
every non-terminal committed before the first miss, none after it. The terminal
landing past the miss is asserted too, rather than hidden — that is precisely the
residual CR-92's outbox closes.

**And a claim about TypeScript that is not true.** The previous commit said typing
the harness decorator as the port's own member means CR-11's and CR-12's context
fields "cannot be dropped the same way". Structural assignability lets a
fewer-parameter function satisfy a wider signature, so a decorator that ignores
them would still compile — contextually typed or not. What actually closes the
hole is the forwarding regression that asserts on what the inner store RECEIVED.
Corrected in place under clause 4.

`pnpm run ci` and `pnpm coverage` green.

Refs: ADR-0078, CR-10
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… says so

Implements ADR-0078 §4 and §5, and closes the two things CR-10 deliberately left
open.

**The defect.** `#emitDurable` is total for store faults, so a terminal whose
write failed was still delivered — a caller drained `run:completed` with outputs
while the durable log had no terminal at all, and nothing in the API could tell
the two apart. Reconciliation would later write `run:failed{internal}` for that
run, relabelling the divergence rather than closing it and losing the outputs.

**The outbox lives outside the store, in a separate file**, per the maintainer's
ruling. The store that must hold a refused terminal is the store that just
refused it: a full disk, a corrupt history.db or an exhausted busy-retry fails an
outbox row for exactly the reason it failed the terminal. `apps/cli` writes NDJSON
beside history.db at 0600, appending rather than rewriting — the process writing
there has already demonstrated a write can fail, and a rewrite that dies
mid-truncate would lose every other run's entry. Compaction happens on `remove`,
when the process is healthy again by definition.

The port is REQUIRED on ExecutionHost, and it broke the CLI wiring on the first
typecheck — which is the behaviour that makes it worth requiring. The optional
precedent there (`mediaStore?`) is absent-tolerant because a text-only host has
no media; there is no legitimate host with no terminal durability.

**`RunHandle.durability`** is `'pending' | 'durable' | 'uncertain'`, at the
handle and never on the RunEvent — the store persists the delivered event
verbatim, so a live-only field would either land on disk (self-contradictory, the
row existing IS the durability) or force delivered and persisted to diverge.
CR-11 and CR-14 reuse this vocabulary rather than minting their own.

**The drain runs BEFORE reconciliation**, and the order is the point: a crashed
process leaves its terminal in the outbox, and reconciling first would see a run
with no durable terminal, conclude it needs repair, and write `run:failed` for a
run that completed. A drained entry whose run is no longer in
`listInterruptedRuns` is DROPPED, never appended — the original may have
committed with only its acknowledgement lost, and replaying would break
exactly-one-terminal from the path that exists to restore it. The drain uses only
the three methods `RunStore` already declares, so a Phase-2 cloud store can
implement it.

**The media reclaim moved after a successful terminal persist** (re-timing
ADR-0042 §4). It ran at the emit, so a terminal whose write then failed had
already released the run's media references — the outbox could retry the terminal
into a log whose media was gone.

Four tests, break-verified: mutating `'uncertain'` to `'durable'` reddens two of
them. The negative control is there too — a terminal that lands reports `durable`
and holds nothing — because without it the headline assertion passes for a handle
that reports `uncertain` unconditionally.

`pnpm run ci` and `pnpm coverage` green.

Refs: ADR-0078, CR-92
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…chable on the shipping surface

51 agents. The engine wiring survived every attempt to break it — measured, not
read: the reclaim runs exactly once on success and never on the refused path;
`#emitDurable` stays TOTAL even with BOTH the store and the outbox throwing, with
zero unhandled rejections; a cancelled run whose terminal fails reports
`uncertain` and is held exactly like a completed one; and the drain's
already-terminal DROP is what stops a blind replay.

The gate-parked question I flagged has a reassuring answer, and it is worth
recording: an outbox entry only exists because a terminal was EMITTED, so the run
demonstrably settled — `resumable` describes only what the durable log looks
like, and after a failed terminal write it lies. Skipping such a run the way
`reconcile()` skips resumable ones would leave it resumable forever, and a later
`gate approve` would re-execute it into a second, DIFFERENT terminal plus
duplicate effects. Appending is correct, and the drain running before the
resumable skip is what makes it hold.

**What the review actually found is that none of it was reachable.**
`createFileTerminalOutbox` had ZERO production call sites — every real CLI run got
the in-memory reference that, by its own docblock, survives nothing, in a
one-shot process. The maintainer chose a separate file over an in-database row
precisely for fault isolation, and that choice was unrealized. Both `run` and
`gate` now wire the real path (`~/.relavium/terminal-outbox.ndjson`, exposed from
the history opener so the two commands cannot drift onto different files).

**And the file implementation had no test — the half specifically chosen.** Eleven
now, and the first one written found a real bug: a process killed mid-append
leaves a partial line with no terminator, and appending straight onto it
CONCATENATES — one corrupt line that swallows the NEXT entry as well as the
truncated one. So a crash during the very write this outbox exists to survive
would have cost the following terminal. Each entry is now written with a leading
newline as well as a trailing one; the reader already skips blank lines, so it
costs a byte and needs no read of the existing file, which matters on a path that
has just seen I/O fail.

The other cases pin what the implementation claims: newest-wins is last-wins on
READ (both lines verifiably on disk), a valid-JSON-but-invalid-RunEvent line is
REJECTED rather than becoming a fabricated terminal, `remove` compacts without
touching other runs, the file is created 0600 like history.db, nothing throws
when the directory is gone or the file unreadable, and a C1/bidi payload
round-trips — the line is re-read by a later process that then persists what it
finds.

Still open from this review and carried to the Sonnet round: nothing reads
`handle.durability()` yet, so `relavium run` still exits 0 on an uncertain
terminal; the terminal remains exempt from the append guard while a comment says
that hole closes here; and the end-to-end certification against the real
history.db is not written.

`pnpm run ci` and `pnpm coverage` green.

Refs: ADR-0078, CR-92
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…exemption is decided

Closes the three items the Opus review left open on CR-92.

**`relavium run` no longer exits 0 on a terminal that was not recorded.** Nothing
read `handle.durability()`, so the headline sentence of CR-92's acceptance — "if
durability is uncertain the API must not say completed" — was unmet on the only
shipping surface. `outcomeToExitCode` now takes the disposition and lets
`'uncertain'` OUTRANK the outcome, on both the `run` and `gate` paths.

Exit code 5, and deliberately neither 0 nor 1: the run may well have COMPLETED —
the outputs are in the delivered terminal — and only its durable record is
missing, so reporting failure would be as wrong as reporting success. A script
seeing 5 should treat the run as done-but-unrecorded and re-check `relavium
status` after a later invocation, by which point the outbox drain has retried it.
The taxonomy extension landed in `commands.md`'s exit-code table, which an
existing test pins byte-for-byte — that test caught the omission before I did.

**The terminal exemption is now DECIDED, not deferred.** The code said the hole
"closes when CR-92 lands"; CR-92 has landed and the exemption stays, so that
sentence had become false rather than pending. The original reason is genuinely
gone — §4's outbox gives a refused terminal a home — but guarding it would
convert the COMMON case (a non-terminal write was lost, so the belief no longer
matches the log) into a run whose terminal is refused and only lands at the next
`reconcile()`. That trades a run that ends correctly-but-with-a-hole for one that
does not durably end at all, on the failure path. The residual is stated rather
than hidden, including that `checkDurableTruth` and `createAppendAudit`
deliberately disagree about such a log — which is the honest pair, since the run
really did end and really did lose an event.

Five tests on the exit mapping, including the negative control and the
absent/pending cases — without them the headline assertions pass for an
implementation that returns 5 unconditionally.

Still open and carried to the Sonnet round: the end-to-end certification against
the real history.db.

`pnpm run ci` and `pnpm coverage` green.

Refs: ADR-0078, ADR-0049, CR-92
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last item the CR-92 Opus review left open. Both were proven in
`packages/core` against `InMemoryRunStore` — which the engine ships as a
reference precisely so a core test needs no filesystem — but the guard that
matters is the one inside the SQLite store's IMMEDIATE transaction, and the
outbox that matters is the one that writes a real file beside a real database. A
reference implementation that agrees with the real one is a claim until someone
runs both. The phase document says in terms that this certification belongs here.

Four cases, all over an on-disk `better-sqlite3` database and an on-disk outbox
file — the same two objects `relavium run` wires:

- A full run commits a clean prefix with no overlapping asks, measured through
  `createAppendAudit` wrapped around the real store.
- A stale append is refused with a typed `AppendConflictError` and writes
  NOTHING — the rollback half matters as much as the refusal, since a partial
  write would leave derived `runs`/`step_executions` rows with no event.
- A refused terminal is held in the real FILE outbox, the handle reports
  `uncertain`, and the database really does lack the terminal.
- The drain retries it into the real store — read back through a FRESH outbox
  object over the same file, so the handoff is proven to be the file rather than
  in-process state — and the run ends `run:completed`, NOT relabelled `failed` by
  reconciliation. Exactly one terminal, and the entry is forgotten once it lands.

That last one is CR-92's acceptance sentence ("live, history, resume and reconcile
agree") across a process-like boundary.

`pnpm run ci` and `pnpm coverage` green.

Refs: ADR-0078, CR-10, CR-92
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…aller, and remove raced a concurrent put

Second pass over CR-92 and its three folds. The in-process half held up under
adversarial reading and measurement. Two real defects, and the first was mine.

**The whole retry path was unreachable from the shipping binary.** `reconcile()`
— the only thing that drained the outbox — has zero production call sites
anywhere in the monorepo. So the outbox, the drain, and the real-history.db
certification I had just written were all dead code from `relavium`'s point of
view: a user who saw exit code 5 had no command that would ever move their run to
`durable`, while that exit code's documentation — added in this same range —
asserted one would. That sentence was false as shipped, and it is exactly the
class of claim this phase exists to remove.

`drainTerminalOutbox()` is now public and both `run` and `gate` call it at start.
Draining rather than wiring `reconcile()`: the drain writes only terminals the
engine itself already produced, for runs whose log still lacks one, and switches
on nothing else — whereas `reconcile()` also repairs every interrupted run, a far
larger behaviour to turn on inside a CR-92 fold. That it remains unwired is now a
known, separable thing rather than an accident hiding this one.

**`remove` truncated the file and destroyed a concurrent `put`.** Reproduced
against the shipped code: an append landing inside another process's
truncate-then-write window vanished silently, with no error on either side —
precisely the unrecoverable loss §4 exists to close, reintroduced by the
compaction. Concurrent processes over one `~/.relavium` are designed for here
(ADR-0073, ADR-0064 §5), not an edge case. Removal now appends a tombstone, so
the file is append-only in BOTH operations — which is what its docblock always
claimed; I had only broken it in `remove`. Three tests, including that a run put
AGAIN after its tombstone is held again, which is what a second failed retry must
produce.

Also: the drain now runs the D11 media sweep its sibling `reconcile()` arm
already did — the crashed process never ran its in-process reclaim, which is why
the terminal is in the outbox at all, so without it the run's media references
survive forever. And `gate` repeated the outbox path literal instead of consuming
the opener's, contradicting an earlier commit message of mine that claimed the
two commands "cannot drift"; there is now one exported `terminalOutboxPath`.

Two gate tests caught the new wiring through a stub that lacked the method — the
wiring did not find them.

The review also settled the question I had flagged: a gate-parked run can never
have an outbox entry, because an entry exists only if a terminal draft was built,
which never happens for a currently-parked run. Better than the answer the first
round gave.

`pnpm run ci` and `pnpm coverage` green.

Refs: ADR-0078, CR-92
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…se and fencing token

Decides CR-11, the third link of phase 2.6.5's durability spine. Implementation
staged behind it.

The gap: `resumeFromCheckpoint`'s cross-process check is an in-memory
`this.#runs.has(runId)`, which is per-process by construction. Two `relavium`
processes can resume the same paused run and become two independent side-effect
producers. ADR-0078's compare-and-append stops the second one WRITING the same
row; it does not stop either DOING the work, because the effect happens before
anything is written. That is why CR-12's effect journal cannot mean anything
until this closes — an idempotency key is worthless when two owners each believe
they are alone.

A `run_leases` row with a monotonic `generation`, not a column on `runs`: that
row is a derived projection the event fold rewrites, so authoritative ownership
state in it invites a fold to clobber it. The fence rides `DurableWriteContext` —
which ADR-0078 §2 introduced as one extensible object precisely so the two items
after it would extend rather than re-break `persistEvent`. Checked inside the
same IMMEDIATE transaction as the append guard, through the same `tx` handle.

A fresh run's lease is created in the same transaction as `run:started`, which
sidesteps `start()` being synchronous: a fresh runId comes from `ids.newId()`, so
the start-side lease is uncontended by construction and all contention is on the
already-async resume path.

**The maintainer's three rulings, recorded with their reasoning.** A process
fenced out MID-RUN emits no terminal at all — writing `run:failed` would be a
durable lie about a run another process may be finishing, and the fence would
reject it anyway; it closes its local stream and reports `uncertain`, reusing
exactly the vocabulary ADR-0078 §5 said CR-11 would. TTL 60s with a 20s
heartbeat: three missed beats permit takeover, wide enough not to mistake a long
provider call for death, narrow enough not to lock a crashed run for long.
Expiry is compared store-side against the epoch-ms clock the store already has,
so every process on the box uses one clock and the platform-free engine gains no
second notion of time. And a lease loss gets exit code 6, distinct from the
blanket EngineStateError → exit 2, because it is transient and retryable while
every other one is a permanent invocation fault.

`reconcile()` becomes lease-aware here rather than later: it writes a terminal
for every non-resumable interrupted run, from a process that may not own it.
Latent today, cheap now, a data-loss bug once any surface wires it.

Scope stated rather than inferred: runs only (AgentSession keeps ADR-0070's
recorded drift, and the asymmetry is named honestly); local only (Phase-2
Postgres gets its own mechanism, mirroring ADR-0073's scoping); ADR-0073 is
precedent, not a component; ADR-0075 is cited, not superseded; ADR-0050's
durability-first posture is unchanged because a stale-fence rejection is an
expected refusal, not data loss.

Two things deferred with their triggers: the observer handle (a typed refusal is
in scope, tailing another process's log is not), and the fact that the
two-process race cannot be proven in one Node process — `better-sqlite3` is
synchronous, so the regression follows `migrate-lock.e2e.test.ts` and is visibly
SKIPPED rather than silently passing when the build output is absent.

ADR-0036 carries the dated back-pointer.

Refs: ADR-0079
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All three stand and all three are cheap.

The Related field was the longest in the corpus as one inline `·` run; it is a
bulleted list now, following ADR-0078 rather than ADR-0075/0076. The review
suggested this for "the next ADR of this complexity" — but the improvement lands
directly on this one, so it lands here.

§1 said `generation` increments "on every successful acquire" without naming
`reconcile()`'s expired-lease takeover as one of them, which §7 introduces. It is
consistent in practice — reconcile IS acquiring — but the reader had to connect
two sections to see it. Named inline.

The phase document's CR-11 acceptance says the loser "degrades to observer with a
typed, actionable error", which reads as requiring the full observer now. ADR-0079
§4 splits that into two deliverables and scopes only the typed refusal in. The
phase doc now carries a note pointing at that scoping, so the acceptance cannot be
read as demanding a capability the decision deliberately deferred.

While there: §4 identified the in-scope half positionally ("only the second"),
which is correct but invites a misread of the very distinction the paragraph
exists to draw. Stated explicitly instead.

Refs: ADR-0079, CR-11
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ADR-0079 §1 and §6, db-side only. No engine change, so this lands on its own with
the drizzle snapshot regenerated and `db:sync-check` green.

A table keyed by `run_id`, not columns on `runs`: that row is a derived
projection the event fold rewrites, so authoritative ownership state in it mixes
two lifetimes in one row and invites a fold to clobber it.

`generation` is the fence — monotonic, never reset, bumped on EVERY successful
acquire including a renewal by the same owner. That last part is deliberate and
tested: it means a caller must carry the token it was just handed rather than a
remembered one, which keeps the fence strictly ordered.

Four operations, each one `BEGIN IMMEDIATE`. A DEFERRED read-then-write would
take a read lock first and lose the upgrade race under exactly the contention
this exists for. `acquire` refuses only a DIFFERENT owner holding a LIVE lease —
same owner is a renewal, expired is a takeover. `heartbeat` matches on (owner,
generation), so a fenced-out owner discovers it lost from the update matching
nothing, with no second query. `release` is scoped the same way, so a process
that has already been fenced out cannot free the new holder's lease on its way
down — a quiet failure that would otherwise let a third process in while the real
owner is mid-run.

Expiry is evaluated against the store's own injected clock (§6), never a
caller-supplied time: a caller passes a TTL and nothing else, so it cannot widen
its own lease by lying about `now`, and every process on the machine compares
against one clock. `read` returns the store's verdict on liveness rather than
data for the caller to re-derive.

Nine tests. Break-verified — and the first attempt did NOT apply (prettier had
collapsed the anchored condition onto one line), so the green it produced was
meaningless; re-anchored, removing the live-lease refusal reddens exactly the
test that names it.

Refs: ADR-0079, CR-11
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ADR-0079 §2. `DurableWriteContext` gains `fence: { ownerId, generation }` and the
store checks it inside the SAME IMMEDIATE transaction as the append guard,
through the same `tx` handle. A check outside would be two statements another
process could interleave — the race it exists to close.

This is the field that justifies `DurableWriteContext` being an object at all:
ADR-0078 §2 introduced it as one extensible parameter precisely so the items
after it would extend rather than re-break `persistEvent`. This is the first of
them; CR-12's journal correlation is the second.

Two decisions worth stating. The fence is checked AFTER the append guard: both
refuse the same write, but a stale belief about the log is the more specific
diagnosis when a writer has both problems — and a fenced writer's belief is stale
precisely BECAUSE it was fenced. And a MISSING lease row is a rejection, not a
pass: the run was taken over and released, or the row was never created; either
way the writer cannot prove ownership and ADR-0079 fails closed.

`LeaseFencedError` is distinct from `AppendConflictError`, and the distinction is
what a caller acts on. An append conflict says "the log moved under you, your
belief is stale". A fence rejection says "you are not the owner any more, stop" —
including stopping short of the terminal, because the run's real outcome now
belongs to somebody else (§5).

Five tests, break-verified: disabling the fence check reddens exactly the three
that name it. The accept case and the no-fence case are the controls — the two
halves of the context are independent, so a caller holding no lease still gets
its ordering checked and vice versa.

Refs: ADR-0079, CR-11
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ires before it reads

ADR-0079 §4. `RunLeasePort` is REQUIRED on `ExecutionHost` — same reasoning as
`TerminalOutbox`: the optional media ports are absent-tolerant because a
text-only host legitimately has no media, and there is no legitimate host with no
run ownership.

`resumeFromCheckpoint` acquires BEFORE it reads anything — not after the
checkpoint, not after the identity guard. Every line below the acquire is work
only the owner is entitled to do, so a loser must never become a second producer
even briefly. The refusal names the current holder, which is what makes it
actionable rather than "something else has it", and it carries the new
`run_owned_elsewhere` code.

That code is the first TRANSIENT `EngineStateError`, and the distinction now has
a name in the type: `run_owned_elsewhere` means "somebody else is running this
right now", which resolves on its own; every other code is a mistake in the call
that will fail identically forever. A surface uses that to tell "try again
shortly" from "never call this again".

The engine's owner id comes from `host.ids.newId()`, not a process id: the engine
is platform-free and has no notion of a process, and two engines in ONE process
must still be distinguishable — otherwise the second would silently RENEW the
first one's lease instead of being refused, which is the whole failure this
closes.

**Implementation found a real gap in §4 and the ADR now says so.** The lease is
released when the process stops WORKING on the run — which includes a re-pause,
not only a terminal. A sequential multi-gate test caught it: the run resumes,
re-pauses at the next gate, and the very next `relavium gate` was refused for the
full TTL by a lease nobody was using. A parked run has no process executing it,
and ownership exists to stop two processes ACTING. Every refusal path releases
what it just took, too, or a run that does not exist would lock its own id.

`createRunLeasePort` adapts the store's synchronous operations to the
`Promise`-typed seam — wrapping at the boundary rather than making the store
async, so nothing in `run-history-store.ts` pretends to await. Both `run` and
`gate` wire the DURABLE lease built from the same store the run persists to; the
in-memory reference the host defaults to guards nothing across processes, which
is correct for a fixture and wrong for a surface.

Refs: ADR-0079, CR-11
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…and a loser that stops

A process that loses its run mid-flight now stops without claiming an outcome it
does not know (ADR-0079 §5), and keeps its claim alive while it works (§6).

The heartbeat rides `ExecutionHost.setTimer` as ADR-0079 §6 chose, but the seam
now carries a `TimerKind`. The distinction is load-bearing, not bookkeeping: a
work timer (gate deadline, retry backoff, media poll) ADVANCES the run when it
fires, while the beat advances nothing and re-arms itself forever. Conflating
them broke twenty-one tests in ways that were all the same bug — a
drive-to-quiescence loop that never terminates, an `armedCount()` that stops
answering "is this run waiting on anything", and a counted-fault harness whose
second arm was no longer the one it meant. In production the same split is why
the CLI `unref`s a liveness timer and only a liveness timer: a work timer SHOULD
hold the event loop open, and a perpetual beat must never be what keeps a
process from exiting.

Four defects found while proving it, each by a test that now pins it:

- The beat was never disarmed on a NORMAL terminal — only on the fenced path —
  so every finished run left a timer re-arming itself and renewing the lease of
  a completed run for the life of the process. Ownership now ends with the run,
  after the terminal is durable (the order is forced: the terminal is itself
  fence-checked). The lease is released there too, so `run_leases` stops growing
  a permanent row per run.
- `resumeFromCheckpoint` released the lease it had just taken, immediately, via
  a `#releaseIfIdle` that checked nothing — and `beginResume` returns when the
  resume is KICKED, not when it finishes. Every cross-process gate resume handed
  its lease back mid-run and was fenced out by its own release, stopping with no
  terminal and hanging its caller. Ownership is now given up at the two moments
  the process actually stops working on the run, both inside the execution where
  that is observable.
- A fenced run was torn down via `#schedule()`, but `#step()` returns early only
  on `#settled` — so a fenced run with nothing runnable never reached `#settle`
  and its consumer's `for await` hung forever, which is the opposite of what §5
  promises. `#settleFenced()` is now called directly at each discovery point.
- `resume()` re-acquired unconditionally, bumping the generation out from under
  a cross-process resume's own in-flight claim — the hazard
  `resumeFromCheckpoint`'s own comment named. It now re-acquires only when the
  park actually released. Arming the beat is idempotent for the same reason.

Refs: ADR-0079

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… runs, and the fence is proven across processes

Closes CR-11. `reconcile()` writes a terminal for every non-resumable interrupted
run, from a process that may own none of them — latent today because nothing
called it, a data-loss bug the moment something did. It now takes the run over
first, and takes it over ATOMICALLY: `acquire` already refuses when a different
owner holds a live lease, so the acquire IS the check. A `read` followed by an
`acquire` would be redundant with that refusal and, worse, not atomic with it —
the lease can change hands between the two. An expired lease is a takeover that
bumps the generation, which is what fences the dead owner if it ever wakes.

The outbox drain gets the same guard, and it is sharper there: the held event is
a terminal a CRASHED process built from its own view of the run, so writing it
into a run a live owner has since resumed would durably contradict the run that
owner is finishing right now.

The CLI maps the one transient refusal to exit code 6 (ADR-0079 §7), documented
in the exit-code taxonomy's canonical home. Every other engine-state refusal is
a mistake in the call that will fail identically forever; `run_owned_elsewhere`
resolves on its own within a TTL. An automation loop has to be able to tell
"retry shortly" from "never call this again", and one blanket code cannot say it.

And the property that cannot be shown in one Node process is now shown in two.
`better-sqlite3` is synchronous, so two in-process owners are serialized by
construction and the state that matters — one process still BELIEVING it owns a
run another has taken over — never arises. That belief is the entire reason
ADR-0079 chose a fencing token over a bare CAS. Two spawned children over one
real `history.db`, deterministic by handshake rather than by racing spawns:
the second is refused while the first holds, and a fenced-out holder is refused
its write while the new owner's lands. With the store's fence check removed, the
stale owner writes — measured, which is what makes the test worth having.

Refs: ADR-0079

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… at each caller

The Opus review round found a blocker three agents reached independently, and
proving it exposed two more defects and one build hazard.

**The blocker.** A gate park hands the lease back (ADR-0079 §4), but a parked
run is not inert: its gate deadline, the run-level `timeout_ms` and cooperative
cancel all stay armed by design, and all of them end in durable writes. Since a
terminal is exempt from the append guard (ADR-0078 §2) and an ABSENT fence is a
pass rather than a refusal, a parked process could write `run:cancelled` into a
run another process was finishing — two terminals in one log, the exact
divergence ADR-0079 exists to prevent, reintroduced by §4 itself.

**The mirror of it, on the happy path.** `#emitDurable` delivers to consumers,
and an inline prompter resumes synchronously on `run:paused`. Dropping the claim
after the emit let that resume observe itself as still owning, skip its
re-acquire, and then have the row deleted out from under it — a healthy run died
`uncertain`. The claim now drops as part of the write that creates the pause.

**The fix is one choke point, not a guard per caller.** `#emitDurable` is
provably the run's only durable writer, so ownership is reconciled there:
re-take a claim a park handed back, pass one already held, refuse once it is
lost. Guarding call sites instead would have needed edits in six methods and
still missed `#onRunTimeout`, which had the identical bug and no test at all —
it is fixed here without a line changing in it. Two booleans became one
five-state field because `!owned` could not tell "before the first acquire"
(where `run:started` must write unfenced, the FK deviation) from "parked, must
re-take", and that confusion WAS the bug: the run fenced itself out against the
row it had released one event earlier.

The hot path deliberately does not await. Making it unconditional inserted a
microtask between `await prior` and the persist, which reordered ADR-0077's
money barrier so a rejected ledger write was attributed to "a durable write
failed" instead of the cancellation that actually stopped the run.

**The reference store now enforces the fence**, which is what let any of this be
caught: deleting the fence from the engine's write choke point previously left
all 3,568 tests green. The lease port binds to the STORE, so two hosts over one
store share one lease table — modelling one `run_leases` per `history.db` — and
an injected port is bound too, since binding only the defaulted one left a
fixture that supplied its own port running with enforcement silently off.

`run_leases` also reached its canonical home in database-schema.md: the ER
diagram, a table section, the count, and a concurrency-policy bullet for the
fence beside the compare-and-append one. Two reviewers filed its absence as a
CLAUDE.md rule 8 blocker.

Refs: ADR-0079

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d reconcile stops killing its own runs

Three verified findings from the Opus round, each now pinned by a test that dies
under mutation.

`reconcile()` could terminate a run THIS engine is executing. `acquire`'s refusal
rule is "a different owner holding a live lease", so for our own runs it is not a
refusal at all — it is a renewal that bumps the generation, which fences the live
execution out at its next write and then deletes its row in the caller's
`finally`. The lease cannot tell the two apart because both claimants share an
`ownerId`; the in-memory run table can, and it is the authority on what this
process is running. Latent today (no surface calls `reconcile()` directly), which
is exactly why it was cheap to fix now.

`resumeFromCheckpoint` acquires before it reads the checkpoint, so every exit
between there and `adoptLease` owns that claim — including the ones that return
rather than throw. The already-terminal no-op held it, converting the documented
idempotent re-delivery into a transient refusal (exit 6) for a full TTL over a
run that finished hours ago, and leaving a `run_leases` row per re-delivery.
`buildRunPlan` on an edited workflow and the checkpoint rehydration both throw
outside the existing try and stranded it the same way.

Refs: ADR-0079

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… success

Two surfaces reported a run taken over by another process as an ordinary,
successful outcome. Both are one discriminator away from correct, and it costs
nothing: ADR-0079 §5 closes a fenced run's stream WITHOUT a terminal, while
ADR-0078 §5's outbox case delivers one.

`relavium gate` printed "run X already settled; nothing to resume" and exited 0
for a resume that was fenced mid-flight. That is the worst available answer on
the one path §5 was built for: the run is executing elsewhere, this process's
gate decision never became durable, and an automation loop records success. It
now raises the transient refusal, naming what happened and where the truth is.

`relavium run` mapped a mid-flight fence to exit 5, whose documented remedy —
"held in the outbox and retried on the next start" — is false for it: §5
deliberately writes nothing to the outbox, so a script following that advice
waits for a drain that never comes. ADR-0079's own Consequences say exit 6 is
what makes this case actionable; until now 6 was reachable only from a
pre-resume refusal, never from the mid-run loss the sentence is about.

Both exit codes' docs are corrected rather than left to be inferred: 5 is now
scoped to "a terminal was PRODUCED and its write did not land", 6 covers both
ways ownership is lost, and the canonical table says what to do differently for
each.

Refs: ADR-0079

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tops describing what did not ship

Three engine fixes, each pinned by a test that dies under mutation, plus the
ADR corrections the conformance review asked for.

The heartbeat's tolerance for a failed write was unbounded, which hid exactly
the failure §6 names as its reason for existing: a persistently unwritable store
means the lease provably expires, another process takes the run over, and this
one keeps dispatching nodes and calling tools with no beat ever telling it. The
tolerance is now bounded by the TTL itself — once the misses cover it, the claim
is one this process can no longer prove, and §5's rule is that an unprovable
claim stops. A single blip still costs nothing.

`#beat` also re-checked the wrong thing after its await. A gate park during that
suspension hands the claim back WITHOUT setting `lost`, so the beat read this
process's own deliberate release as somebody else's takeover and killed a
healthy parked run. It now re-checks that it still holds the lease.

`#settleFenced` never released the budget governor's legacy media holds, which
`#settle` discharges deliberately — a `checkPreEgress` awaiting a job that will
now never settle would hang forever. A teardown whose whole job is to leave
nothing behind was leaking on the newest failure path.

And a fresh run whose acquire is refused now says what happened. That acquire is
uncontended by construction, so a refusal means the host is misconfigured — a
locked, unmigrated or read-only history.db — and settling on the generic default
gave the user `internal: "the run failed"` pointing at nothing.

ADR-0079 gains three dated amendments rather than silent drift. §1's "generation
never resets" is not what shipped: `release` deletes the row, so what keeps a
stale owner out is (ownerId, generation) pair-equality plus fail-closed-on-a-
missing-row. The residual risk is named with its trigger — a long-lived host
keeping one engine across many park/resume cycles cannot use the token alone to
tell a straggler from the current leg — along with what fixing it would cost, so
the trade can be re-taken with evidence. §3 records the FK-forced ordering and
the unfenced `run:started` that follows from it. §4's post-Accepted paragraph is
marked as an amendment and carries the two corrections implementation forced.

Refs: ADR-0079

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t stops drifting from src

Two build-graph defects, both of which cost real time this branch.

`@relavium/db`'s two-process regressions spawn children that import the BUILT
package — a child cannot use vitest's source resolution. `test` depends on
`^build`, which builds only UPSTREAM deps, and the required CI lane runs `test`
before `build`. So on a clean checkout this package's own `dist` was absent and
both files skipped: visible in the log, green to the job. ADR-0079's headline
property — the one the ADR states cannot be proven in a single process — was
therefore unproven in the gate that guards it, and the same was true of
ADR-0073's migrate-lock race. Confirmed by the task graph: `@relavium/db#build`
was not in it and now is.

`*.tsbuildinfo` was not declared a build output. With `incremental: true`, tsc
skips emit when that file says the outputs are current — so a cache restore that
brought back `dist/**` without it, or a stale one left by a build that ran while
files were mid-edit, yields a `dist` that silently does not match `src`, and
`--force` does not defeat it. Measured here: `dist/engine/engine.js` was missing
a field `src` had, across repeated forced rebuilds, and several hours went into
chasing product bugs that were this. The CLI ships as a bundle of these outputs,
so a desynced one is shipped code that never existed in source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ooled by a stale pause

The Sonnet round found that commit 636f0c3 reopened the bug it fixed, and
reproduced it against a real `driveRun`. `outcomeToExitCode` discriminated "was
a terminal delivered" with `outcome === undefined` — but `run:paused` sets
`outcome` too, and the engine buffers that event before it discovers a fence. An
inline gate prompt therefore delivers a stale `run:paused` AFTER the loss, so
the flagship two-terminal race reported exit 5 — whose documented remedy ("held
in the outbox, retried on the next start") is false for a fenced run, since §5
deliberately queues nothing. `isTerminalOutcome`, defined two functions away and
already saying a pause is not terminal, is the right test.

`relavium gate` had the same defect and needed the opposite correction: keyed on
the outcome it is wrong in BOTH directions — `undefined` misses the buffered
case, and `!isTerminalOutcome` sweeps up a legitimate re-pause at a later gate
that must still exit 3. There the durability is the discriminator, and the
outcome decides everything else.

Two more things a user actually sees. `relavium run` hitting a takeover printed
nothing at all — the renderer falls through to a bare "run ended" because there
is no terminal to summarise — while `relavium gate` explained it; both now do.
And both the CLI message and the canonical exit-code table named
`relavium status <runId>` as the remedy, which is not a command: `status` takes
no argument and lists only ACTIVE runs, so it cannot show a run another process
has already finished. The remedy is `relavium logs <runId>`.

Finally, `schema.ts` and database-schema.md still asserted the generation is
"monotonic, per run, never reset" — the claim ADR-0079's own 2026-08-17
amendment retracts. Amending the ADR without them left the canonical schema doc
contradicting the ADR that governs it.

Refs: ADR-0079

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nd the claims the docs were overstating

The Sonnet round's test-integrity lens showed the whole `run_owned_elsewhere`
pathway was untested end to end: forcing `isTransientEngineStateError` to return
`false` left every test in the repo green, because the CLI's own tests prove
only the CliErrorCode→exit-code table and never that anything PRODUCES the code.
The classification is now pinned directly, positive and negative.

`createCliHost`'s durable-store-without-a-lease-port guard was in the same
position — its own docblock says that wiring "silently fences every run" and
"looks like a hung run", and replacing the condition with `false` left all 2,396
CLI tests passing. An untested throw is a guard that can be deleted in silence.

ADR-0079 §1's amendment turns on a numeric claim nothing asserted: that a
release-then-reacquire by the same owner restarts the generation at 1. It is now
pinned, deliberately, as a documented LIMITATION — if a future change makes
`release` a tombstone, this is the test that should fail and be updated together
with the amendment.

Two comments of mine claimed more than they could support, and the state-machine
lens disproved both by mutation. The park-ordering comment read as though the
placement alone prevents the inline-resume race; moving the hand-off back AND
injecting a delay to force the race left the suite green, because
`#emitDurable` re-reads ownership at write time and `#schedule`'s single-flight
guard keeps a post-gate dispatch from starting until the pause has unwound. The
ordering stays — it makes the invariant true by construction rather than by two
coincidences — but it is now described as defence in depth. Likewise the "no
test dies when this arm is made permissive" note was scoped to the `done`
ownership arm when the same is true of `lost` and of `#settle`'s guard, for the
same deliberate reason: `#fence` is retained after ownership ends, so the store's
transactional check refuses the stale token independently.

Finally, `durability.e2e.test.ts` now says that `apps/cli` resolves the engine
from `dist` — a bare `vitest` run there tests the last build, which was measured
to hide a real break.

Refs: ADR-0079

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… effect contract

Decides CR-12 and CR-95's short-term fix. An effect can complete at its target,
the process can die before the result persists, and resume re-runs the node —
duplicate ticket, deploy, payment, commit. ADR-0079 closed the precondition; this
closes the effect itself.

The phase document's proposed key cannot work: with the retry attempt and the
tool-call id in it, every retry and resume mints a new value, so the journal can
never dedup and "safe retry under the same key" is unimplementable. It also
contradicts two canonical sentences that name the attempt as replay correlation
and explicitly not the idempotency key.

So this ADR does NOT claim a universal replay-stable identity — claiming one is
the trap. Five concepts are named separately, because collapsing any two is where
the design goes wrong, and the primary guarantee is a resume gate at node
granularity whose lookup key is the correlation with the ATTEMPT DROPPED: the
node-retry attempt resets to 1 on both a crash-resume and a budget approval, so
an attempt-scoped lookup would miss the row it exists to find.

The gate examines every prior effect record, not only unresolved ones. An earlier
draft left the main crash window open: settle succeeds, the process dies before
`node:completed` persists, and a gate that only checked unresolved rows waves the
re-run through. A `committed` row is not a green light — if the journal did not
retain enough to re-deliver the result, it blocks the node exactly as an
unresolved one does.

Every effect that ships today is tier 3: at-most-once dispatch attempt, never
auto-retried. Tiers 1 and 2 are specified and occupied by nothing, and saying so
is the point — the headline is a NARROWING of the product claim, not a new
capability. MCP is permanently tier 3, not pending better metadata: an annotation
from the server the hostile-MCP class defends against may never raise trust.

Five ADRs are amended, none reversed. ADR-0077 is the addition: it predicted this
journal belongs at its own per-TURN barrier, "rather than inside
ToolRegistry.dispatch". That is right for the money ledger and wrong for the
journal — a turn dispatches a LOOP of tool calls, so a checkpoint running once
per turn cannot record which individual effect was prepared or settled.

The canonical home is new: effect-journal.md carries the identities, the gate,
the predicate, the state machine, the ordering-and-failure matrix, the
needs_attention contract, retention, and the crash matrix the implementation must
be tested against. The DDL stays in database-schema.md. Four documents that
promised a blanket idempotency guarantee now describe the tiered one, including
architectural-principles.md §11, which CR-12 named explicitly.

Refs: ADR-0080

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cemililik and others added 3 commits August 20, 2026 11:07
…d this time a test says so

The Sonnet review found the artifact field dead for a SECOND time, one layer
above where the Opus fold fixed it. `createConsentGate` returned a two-parameter
function where `StdioConsentGate` declares three, and TypeScript accepts a shorter
parameter list wherever a longer one is expected — so both callers passed an
artifact that vanished at runtime, `deps.artifact` was always `undefined`, and the
"declared in <file>" line never rendered on any surface. Two of the five call sites
carry comments claiming the property works.

Nothing caught it because every gate test called `assertStdioConsent` directly:
the production adapter the five surfaces actually wire had no test at all.

- The adapter forwards `artifact`, and its return type is the shared
  `StdioConsentGate` alias, so a missing parameter is now a type error.
- `chat`, `chat-resume` and Home never computed an artifact either.
  `resolveChatAgentSource` returns the resolved path beside the agent — beside,
  not folded into `AgentDefinition`, which is the parsed artifact, is persisted
  into a session snapshot, and comes from the pure core parser. A RESUMED session
  names the session rather than a file: it runs the agent snapshot frozen at start,
  so the original file may have changed or be gone, and naming it would send the
  user to review bytes that are not what is about to run.
- A duplicated spread in `session-host.ts` — harmless, but it sat on the exact line
  the artifact wiring was supposed to have fixed.

Three tests, each mutation-verified against the guarantee it pins:

- `createConsentGate` forwards the artifact (and still refuses, and still records
  nothing on a refusal) — the adapter, not the function under it.
- `buildChatSession` names the RESOLVED agent file at the gate, asserted through
  the surface that computes it rather than at the gate that receives it.
- A repointed symlink is a different program (ADR-0084 §10.7's other half — the
  first half was tested, this one held only by inference), and the grant store
  never holds an env VALUE, asserted by scanning the written bytes for a seeded
  credential as §10.12 asks, rather than by re-reading the record's shape.

Refs: ADR-0084

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…the stale routes are retired

§11's landing obligations. The gate had shipped with its specification living only
in an ADR and in the code, which is precisely what CLAUDE.md rule 8 forbids: a
reader consulting `commands.md` could not discover `--allow-mcp-stdio` exists, and
the desktop's Rust spawner — which ADR-0084 §1 places OUTSIDE this gate and which
owes the same contract — had nothing to conform to.

- `mcp-integration.md` gains the cross-surface contract: the fingerprint's five
  inputs, the canonicalization rules a second implementation must reproduce, the
  env type-tagging (with the `secret:` collision it exists to close), the golden
  vectors, the grant-file format and its fold-closed rule, and the four-way
  interactivity precondition. The file's own line naming the Rust backend as the
  owner of stdio children now points at it.
- `ipc-contract.md` states the language-neutral invariant: the Rust backend must
  not spawn a declared stdio MCP server without a matching grant.
- `commands.md` documents `--allow-mcp-stdio` on both commands that take it, the
  exit-2 refusal with its digest listing, why the chat family has no flag, and the
  CI pattern.
- `agent-yaml-spec.md` and `config-spec.md` each state §4's denylist as an authored
  error at parse — and the config one adds the thing a committed file makes easy to
  assume: registering a server does not authorize spawning it.
- `security-review.md` gains both the shared declared-environment rule (one rule,
  now true of BOTH process hosts — it was a floor on one and not the other) and the
  local-spawn floor, plus a mandatory-review trigger.

The routing is retired rather than left to rot: `deferred-tasks.md` splits the old
"import-trust/consent gate + npx pinning" item — consent closed here and re-scoped,
because provenance was the wrong axis (a `git pull` changes a committed artifact
with no import step) — leaves `npx` pinning open with its own scheduling, and adds
lazy connect with ADR-0052 §3 named as its blocker and the tool-list cache as its
unblocker. CR-16 is closed in phase 2.6.5 with the four corrections its own review
notes asked for recorded, and the six cross-phase pointers that still routed this
gate to "2.6.B" now point where the work actually landed.

Also fixes a stale ADR link in `config-spec.md` (`0059-in-place-model-reseat.md`
never existed under that slug).

Refs: ADR-0084

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… still marked open

Exit criterion 7 asks for a per-item register naming the code that closes each W1
item, "verified by reading the code, not by trusting the mark". Writing it caught
the failure it exists to catch, for the third time: `CR-14` and `CR-92` were both
implemented — `stream-grammar.ts` under ADR-0082, and the durable-truth hold plus
the terminal outbox — and both still carried an open heading ("needs an ADR", "in
the durability spine"). Corrected.

The table names, per item, the module that closes it and the test that would fail
if it were reverted. Two entries record why the obvious test would not have worked:
`CR-10`'s property is not expressible from the durable log at all (streamed events
take sequence numbers and are never persisted, so a lost event reads identically to
a skipped one — hence the append audit's separate witness), and `CR-11`'s fencing
token cannot be produced by a test that only does compare-and-swap.

It deliberately does not claim the phase is closed — W2–W9 remain — and it records
that CR-16's lazy-connect half was split out rather than closed, since deferring
the spawn against today's immutable registry would delete the agent's MCP tool
grant outright.

Refs: ADR-0082, ADR-0083, ADR-0084

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @cemililik, your pull request is larger than the review limit of 150000 diff characters

@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown

Important

Review skipped

Too many files!

This PR contains 211 files, which is 61 over the limit of 150.

To get a review, reduce the PR to 150 files or fewer by splitting it into smaller PRs or changing its base branch.

Upgrade to Pro+ to raise the limit.

This review couldn't start because sufficient usage credits or metered capacity aren't available. Add credits or update usage-based reviews in the billing tab, then retry.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bbf93f0d-a1dc-48a5-b430-7f84cca1f282

📥 Commits

Reviewing files that changed from the base of the PR and between 3148034 and 1f92cd9.

📒 Files selected for processing (211)
  • apps/cli/src/chat/agent-source.ts
  • apps/cli/src/chat/persister.test.ts
  • apps/cli/src/chat/session-host.test.ts
  • apps/cli/src/chat/session-host.ts
  • apps/cli/src/commands/agent-run.test.ts
  • apps/cli/src/commands/agent-run.ts
  • apps/cli/src/commands/chat.test.ts
  • apps/cli/src/commands/chat.ts
  • apps/cli/src/commands/create.test.ts
  • apps/cli/src/commands/create.ts
  • apps/cli/src/commands/dispatch.test.ts
  • apps/cli/src/commands/dispatch.ts
  • apps/cli/src/commands/drive.test.ts
  • apps/cli/src/commands/drive.ts
  • apps/cli/src/commands/gate.test.ts
  • apps/cli/src/commands/gate.ts
  • apps/cli/src/commands/inputs.test.ts
  • apps/cli/src/commands/inputs.ts
  • apps/cli/src/commands/list.test.ts
  • apps/cli/src/commands/list.ts
  • apps/cli/src/commands/manifest.test.ts
  • apps/cli/src/commands/manifest.ts
  • apps/cli/src/commands/provider.test.ts
  • apps/cli/src/commands/provider.ts
  • apps/cli/src/commands/run.test.ts
  • apps/cli/src/commands/run.ts
  • apps/cli/src/commands/specs-forwarding.test.ts
  • apps/cli/src/commands/specs.ts
  • apps/cli/src/commands/status.test.ts
  • apps/cli/src/commands/status.ts
  • apps/cli/src/engine/build-engine.ts
  • apps/cli/src/engine/effect-journal-wiring.test.ts
  • apps/cli/src/engine/effect-retention.ts
  • apps/cli/src/engine/find-on-path.test.ts
  • apps/cli/src/engine/find-on-path.ts
  • apps/cli/src/engine/fixtures/concurrent-granter.mjs
  • apps/cli/src/engine/fixtures/ts-resolve-hook.mjs
  • apps/cli/src/engine/host.test.ts
  • apps/cli/src/engine/host.ts
  • apps/cli/src/engine/mcp-consent-gate.test.ts
  • apps/cli/src/engine/mcp-consent-gate.ts
  • apps/cli/src/engine/mcp-consent.test.ts
  • apps/cli/src/engine/mcp-consent.ts
  • apps/cli/src/engine/mcp-servers.test.ts
  • apps/cli/src/engine/mcp-servers.ts
  • apps/cli/src/engine/terminal-outbox.test.ts
  • apps/cli/src/engine/terminal-outbox.ts
  • apps/cli/src/engine/tool-host/process.test.ts
  • apps/cli/src/engine/tool-host/process.ts
  • apps/cli/src/gate/select-prompter.test.ts
  • apps/cli/src/gate/select-prompter.ts
  • apps/cli/src/harness/concurrency.e2e.test.ts
  • apps/cli/src/harness/durability.e2e.test.ts
  • apps/cli/src/harness/generative-media.e2e.test.ts
  • apps/cli/src/harness/mcp-stdio.e2e.test.ts
  • apps/cli/src/harness/regression.e2e.test.ts
  • apps/cli/src/history/open.e2e.test.ts
  • apps/cli/src/history/open.ts
  • apps/cli/src/home/drive-home.tsx
  • apps/cli/src/home/should-open-home.ts
  • apps/cli/src/mcp/consent-prompt.test.ts
  • apps/cli/src/mcp/consent-prompt.ts
  • apps/cli/src/process/attempt-timer.test.ts
  • apps/cli/src/process/errors.test.ts
  • apps/cli/src/process/errors.ts
  • apps/cli/src/process/exit-codes.ts
  • apps/cli/src/process/output-mode.ts
  • apps/cli/src/process/sleep.ts
  • apps/cli/src/render/tui/chat-projection.ts
  • apps/cli/src/secrets/read-secret.ts
  • docs/architecture/shared-core-engine.md
  • docs/decisions/0011-internal-llm-abstraction.md
  • docs/decisions/0023-strict-authored-yaml-validation.md
  • docs/decisions/0027-expression-sandbox.md
  • docs/decisions/0034-mcp-client-sdk-dependency.md
  • docs/decisions/0036-run-loop-substrate-event-bus-and-execution-host.md
  • docs/decisions/0037-engine-tool-execution-boundary.md
  • docs/decisions/0040-node-retry-budget-above-the-chain.md
  • docs/decisions/0041-external-action-governance-seam.md
  • docs/decisions/0042-engine-media-storage-substrate-mediastore-deinline-retention.md
  • docs/decisions/0052-inbound-mcp-client-package-lifecycle-registration.md
  • docs/decisions/0059-cli-mid-session-model-reseat.md
  • docs/decisions/0062-context-compaction-and-cli-history-commands.md
  • docs/decisions/0077-realized-cost-ledger-uses-the-conservative-commitment-barrier.md
  • docs/decisions/0078-ordered-durable-append-and-the-terminal-outbox.md
  • docs/decisions/0079-cross-process-run-ownership-lease-and-fencing-token.md
  • docs/decisions/0080-durable-effect-journal-and-the-tiered-effect-contract.md
  • docs/decisions/0081-the-compaction-summary-is-untrusted-and-the-system-prompt-is-branded.md
  • docs/decisions/0082-the-stream-grammar-is-a-seam-obligation-and-every-attempt-has-a-deadline.md
  • docs/decisions/0083-input-admission-and-a-resume-that-verifies-its-own-identity.md
  • docs/decisions/0084-consent-before-a-local-mcp-spawn.md
  • docs/decisions/README.md
  • docs/reference/cli/chat-session.md
  • docs/reference/cli/commands.md
  • docs/reference/contracts/agent-yaml-spec.md
  • docs/reference/contracts/config-spec.md
  • docs/reference/contracts/ipc-contract.md
  • docs/reference/contracts/sse-event-schema.md
  • docs/reference/contracts/workflow-yaml-spec.md
  • docs/reference/shared-core/action-guard-seam.md
  • docs/reference/shared-core/database-schema.md
  • docs/reference/shared-core/effect-journal.md
  • docs/reference/shared-core/expression-sandbox-spec.md
  • docs/reference/shared-core/llm-provider-seam.md
  • docs/reference/shared-core/mcp-integration.md
  • docs/roadmap/current.md
  • docs/roadmap/deferred-tasks.md
  • docs/roadmap/phases/phase-2-cli.md
  • docs/roadmap/phases/phase-2.5-cli-consolidation.md
  • docs/roadmap/phases/phase-2.6-conversational-authoring.md
  • docs/roadmap/phases/phase-2.6.5-core-reliability-remediation.md
  • docs/roadmap/phases/phase-7-hub-marketplace.md
  • docs/standards/architectural-principles.md
  • docs/standards/error-handling.md
  • docs/standards/security-review.md
  • eslint.config.mjs
  • packages/core/src/engine/agent-runner.e2e.test.ts
  • packages/core/src/engine/agent-runner.ts
  • packages/core/src/engine/agent-session.test.ts
  • packages/core/src/engine/agent-session.ts
  • packages/core/src/engine/agent-turn.test.ts
  • packages/core/src/engine/agent-turn.ts
  • packages/core/src/engine/append-audit.test.ts
  • packages/core/src/engine/append-audit.ts
  • packages/core/src/engine/authored-system-prompt.test.ts
  • packages/core/src/engine/authored-system-prompt.ts
  • packages/core/src/engine/checkpoint.test.ts
  • packages/core/src/engine/checkpoint.ts
  • packages/core/src/engine/deep-equal.ts
  • packages/core/src/engine/durable-truth.ts
  • packages/core/src/engine/effect-resume-gate.test.ts
  • packages/core/src/engine/effect-turn-wiring.test.ts
  • packages/core/src/engine/engine.test.ts
  • packages/core/src/engine/engine.ts
  • packages/core/src/engine/errors.ts
  • packages/core/src/engine/execution-host.test.ts
  • packages/core/src/engine/execution-host.ts
  • packages/core/src/engine/input-admission.test.ts
  • packages/core/src/engine/input-admission.ts
  • packages/core/src/engine/m2-e2e-harness.e2e.test.ts
  • packages/core/src/engine/money-durability.ts
  • packages/core/src/engine/node-executor.ts
  • packages/core/src/engine/resume-identity.test.ts
  • packages/core/src/engine/resume-identity.ts
  • packages/core/src/engine/run-handle-terminal.test.ts
  • packages/core/src/engine/run-handle.ts
  • packages/core/src/engine/run-lease.test.ts
  • packages/core/src/engine/session-resume.test.ts
  • packages/core/src/engine/session-resume.ts
  • packages/core/src/engine/turn-messages.ts
  • packages/core/src/index.ts
  • packages/core/src/interpolation/analyze.test.ts
  • packages/core/src/interpolation/analyze.ts
  • packages/core/src/interpolation/collect.test.ts
  • packages/core/src/interpolation/collect.ts
  • packages/core/src/parser.ts
  • packages/core/src/tools/bounding.test.ts
  • packages/core/src/tools/bounding.ts
  • packages/core/src/tools/builtins.test.ts
  • packages/core/src/tools/builtins.ts
  • packages/core/src/tools/effect-bracket.test.ts
  • packages/core/src/tools/effect-predicate.test.ts
  • packages/core/src/tools/effect-predicate.ts
  • packages/core/src/tools/errors.ts
  • packages/core/src/tools/registry.test.ts
  • packages/core/src/tools/registry.ts
  • packages/core/src/tools/types.ts
  • packages/db/drizzle/0014_wandering_xavin.sql
  • packages/db/drizzle/0015_modern_roland_deschain.sql
  • packages/db/drizzle/meta/0014_snapshot.json
  • packages/db/drizzle/meta/0015_snapshot.json
  • packages/db/drizzle/meta/_journal.json
  • packages/db/src/client.ts
  • packages/db/src/effect-journal-store.test.ts
  • packages/db/src/effect-journal-store.ts
  • packages/db/src/fixtures/lease-holder.mjs
  • packages/db/src/index.ts
  • packages/db/src/migrate-lock.e2e.test.ts
  • packages/db/src/run-history-store.test.ts
  • packages/db/src/run-history-store.ts
  • packages/db/src/run-lease.e2e.test.ts
  • packages/db/src/schema.ts
  • packages/llm/src/attempt-deadline.test.ts
  • packages/llm/src/attempt-deadline.ts
  • packages/llm/src/fallback-chain.test.ts
  • packages/llm/src/fallback-chain.ts
  • packages/llm/src/stream-grammar.test.ts
  • packages/llm/src/stream-grammar.ts
  • packages/llm/src/types.test.ts
  • packages/llm/src/types.ts
  • packages/mcp/src/tool-mapping.test.ts
  • packages/mcp/src/tool-mapping.ts
  • packages/shared/src/agent.ts
  • packages/shared/src/canonical.test.ts
  • packages/shared/src/canonical.ts
  • packages/shared/src/common.ts
  • packages/shared/src/config.ts
  • packages/shared/src/constants.ts
  • packages/shared/src/declared-env.test.ts
  • packages/shared/src/declared-env.ts
  • packages/shared/src/effect-journal.test.ts
  • packages/shared/src/format-source.test.ts
  • packages/shared/src/index.ts
  • packages/shared/src/run-event.test.ts
  • packages/shared/src/run-event.ts
  • packages/shared/src/run.ts
  • packages/shared/src/workflow.test.ts
  • packages/shared/src/workflow.ts
  • tools/lint-fixtures/assert-fence.mjs
  • tools/lint-fixtures/forged-authored-prompt.ts
  • turbo.json

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.


Comment @coderabbitai help to get the list of available commands.

@sourcery-ai

sourcery-ai Bot commented Aug 20, 2026

Copy link
Copy Markdown

Reviewer's Guide

Implements CR-10/CR-11/CR-12/CR-14/CR-15/CR-16/CR-17 durability and MCP-consent work plus related admission, lease, effect journal, deadline, grammar, and CLI wiring changes across core engine, LLM chain, shared contracts, DB schema/store, CLI commands, and chat/agent runtimes, including new safety checks, outbox/journal/lease infrastructure, input validation, effect deduplication, and consent-before-spawn for local MCP servers.

Sequence diagram for consent-before-stdio-MCP spawn

sequenceDiagram
  actor User
  participant RunCommand
  participant ConnectAgentMcp
  participant ConsentGate
  participant StartMcpClient

  User->>RunCommand: runCommand
  RunCommand->>ConnectAgentMcp: connectAgentMcp
  ConnectAgentMcp->>ConsentGate: consentGate
  ConsentGate-->>ConnectAgentMcp: Map<serverId,ResolvedStdioSpawn>
  ConnectAgentMcp->>StartMcpClient: startMcpClient
  StartMcpClient-->>ConnectAgentMcp: McpClient
  ConnectAgentMcp-->>RunCommand: McpClient
Loading

File-Level Changes

Change Details Files
Strengthen run durability and ordered append semantics with append audit, last-sequence guard, outbox, and durability reporting on RunHandle, including reconcile and terminal drain behavior.
  • Add DurableWriteContext, AppendConflictError/LeaseFencedError, TerminalOutbox, RunDurability, RunLeasePort, and effect journal types in @relavium/shared
  • Guard RunStore.persistEvent in both in-memory and SQLite stores with compare-and-append and lease checks
  • Serialize ask/write/deliver in RunExecution.#emitDurable using per-run tail and track #lastAskedSequenceNumber
  • Implement terminal outbox, durability disposition, and drainTerminalOutbox in WorkflowEngine, with reconciliation using the same guard and fencing
  • Expose durability and terminalError on RunHandle and use outcomeToExitCode to map durability/effect errors to new CLI exit codes
packages/shared/src/run.ts
packages/core/src/engine/engine.ts
packages/core/src/engine/run-handle.ts
packages/db/src/run-history-store.ts
packages/core/src/engine/durable-truth.ts
apps/cli/src/process/exit-codes.ts
apps/cli/src/commands/drive.ts
apps/cli/src/history/open.ts
apps/cli/src/engine/terminal-outbox.ts
packages/core/src/engine/append-audit.ts
packages/db/src/run-history-store.test.ts
packages/core/src/engine/m2-e2e-harness.e2e.test.ts
Introduce cross-process run ownership leases and fencing with heartbeat, park/release semantics, and CLI run-leases wiring.
  • Define RunLeasePort, RunLeaseInfo, RUN_LEASE_TTL_MS/HEARTBEAT_MS in shared contract
  • Add RunOwnership state, #fence, heartbeat (#startHeartbeat/#beat), park/release, and write authorization (#authorizeWrite/#reclaim) in RunExecution
  • Implement durable run_leases table and RunLeaseStore in DB schema/store plus async RunLeasePort adapter
  • Wire runLeases through ExecutionHost, createInMemoryRunLeases/resolveInMemoryLeases, and createCliHost, including timer kind separation and lease unref behavior
  • Update WorkflowEngine.resumeFromCheckpoint and reconcile to acquire/release leases early and handle fenced cases via EngineStateError
packages/shared/src/run.ts
packages/db/src/schema.ts
packages/db/src/run-history-store.ts
packages/core/src/engine/engine.ts
packages/core/src/engine/execution-host.ts
apps/cli/src/engine/host.ts
apps/cli/src/commands/gate.ts
apps/cli/src/process/exit-codes.ts
apps/cli/src/process/errors.ts
packages/core/src/engine/run-lease.test.ts
packages/db/src/run-history-store.test.ts
Add durable effect journal (CR-12) with dispatch and resume semantics, effect tiers, conflict handling, and wiring through engine, tools, CLI, and chat/agent runtimes.
  • Specify EffectCorrelation/EffectIdentity/EffectAttemptId, EffectTier/EffectState, EffectRecord, EffectDispatchPort/EffectResumePort, EffectPrepareVerdict, EffectConflictError/API in shared
  • Implement in-memory effect journal store and ports in execution-host, including unwiredEffectJournal default
  • Add engine-side effectJournal/effectResume deps and use them to journal tool dispatches and enforce resume gates via #effectResumeGateOrFail
  • Extend ToolDef/ToolDispatchContext to carry effect and effectSlot; implement journaledTier and integrate into registry dispatch with replay, settle, and ToolEffect* errors
  • Wire effect journal ports into WorkflowEngine, CLI run/gate, chat build/resume, agent-run, and Home, and implement effect retention/sweep plus unresolved-effect notices
packages/shared/src/run.ts
packages/core/src/engine/execution-host.ts
packages/core/src/engine/engine.ts
packages/core/src/tools/types.ts
packages/core/src/tools/registry.ts
packages/core/src/engine/effect-journal.md
packages/core/src/engine/effect-predicate.ts
apps/cli/src/engine/build-engine.ts
apps/cli/src/commands/run.ts
apps/cli/src/commands/gate.ts
apps/cli/src/chat/session-host.ts
apps/cli/src/commands/chat.ts
apps/cli/src/commands/agent-run.ts
apps/cli/src/home/drive-home.tsx
apps/cli/src/engine/effect-retention.ts
Implement input admission and resume identity verification (CR-15/CR-17) with workflow input validation rules, defaults, and resume mismatch errors.
  • Add Input formats, pattern anchoring, interpolation detection, validation semantics, and type-matching helpers in WorkflowInputSchema and helpers
  • Introduce resolveAndValidateWorkflowInputs and InputAdmissionResult/Issue types, and use them in WorkflowEngine.start before runId allocation
  • Extend CheckpointState with admittedInputs/executionMode reconstructed from run:started
  • Add verifyResumeIdentity and workflow content verification helpers wired into resumeFromCheckpoint, with new EngineStateError codes
  • Update CLI gate to parse inputs from snapshot, resolve secret inputs from stdin with --secret-stdin, and map EngineStateErrors to invocation faults
packages/shared/src/workflow.ts
packages/core/src/engine/input-admission.ts
packages/core/src/engine/resume-identity.ts
packages/core/src/engine/engine.ts
packages/core/src/engine/checkpoint.ts
packages/core/src/engine/errors.ts
apps/cli/src/commands/gate.ts
apps/cli/src/commands/inputs.ts
apps/cli/src/secrets/read-secret.ts
Enforce stream grammar and per-attempt deadlines in FallbackChain (CR-14/CR-21) for both stream and generate, including committed content semantics and retry decisions.
  • Extend FallbackChainOptions with attemptTimeoutMs/newAbortController/setTimer and implement DeadlineScope via attempt-deadline module
  • Change FallbackChain.stream to verifyStreamGrammar, track committed state, race next() calls against deadlines, and stamp contentCommitted on surfaced errors
  • Ensure generate() attempts are also covered by deadlines and non-committed failures can fail over
  • Strip provider-forged contentCommitted on ingress and fold retryability at node layer via foldRetryable, differentiating committed vs pre-content failures
  • Update m2 harness and fallback-chain tests to reflect grammar, timeout, commit, and deadline semantics, including provider behavior and liveness cleanup
packages/llm/src/fallback-chain.ts
packages/llm/src/attempt-deadline.ts
packages/llm/src/stream-grammar.ts
packages/llm/src/fallback-chain.test.ts
packages/core/src/engine/agent-turn.ts
packages/core/src/engine/agent-runner.ts
Add consent-before-stdio-MCP-spawn gate (CR-16) with fingerprinting, grant store, denylist sharing, and CLI wiring across run, agent-run, chat, and Home.
  • Define StdioConsentGate, ResolvedServerRef, ResolvedStdioSpawn, and requireGateForStdio; extend resolveServerConfigs to consult consented spawns and re-assert env denylist
  • Implement createConsentGate, grant file (mcp-consent.ndjson), fingerprint canonicalization, golden vectors, and consent prompt sanitization
  • Wire consentGate into connectAgentMcp/connectWorkflowMcp, runCommand, agentRunCommand, chat build/resume, and Home, and add --allow-mcp-stdio CLI flag
  • Share declared-env denylist via isForbiddenDeclaredEnvKey so both run_command and MCP stdio use same forbidden env names
  • Update docs for MCP integration, CLI commands, security review, and phase roadmap to describe consent contract and CI usage
apps/cli/src/engine/mcp-consent-gate.ts
apps/cli/src/engine/mcp-consent.ts
apps/cli/src/mcp/consent-prompt.ts
apps/cli/src/engine/mcp-servers.ts
apps/cli/src/commands/run.ts
apps/cli/src/commands/agent-run.ts
apps/cli/src/chat/session-host.ts
apps/cli/src/commands/chat.ts
apps/cli/src/home/drive-home.tsx
packages/shared/src/declared-env.ts
packages/core/src/engine/execution-host.ts
docs/reference/shared-core/mcp-integration.md
docs/reference/cli/commands.md
docs/standards/security-review.md
docs/roadmap/phases/phase-2.6.5-core-reliability-remediation.md
Adjust CLI, host, and chat/agent wiring for durability, leases, effects, and consent, including new exit codes, interactive detection, and NDJSON floors.
  • Extend EXIT_CODES with durabilityUncertain/runOwnedElsewhere/effectNeedsAttention and outcomeToExitCode to account for durability and effect_needs_attention
  • Map EngineStateError and new CliErrorCode to exit codes; adjust toUserFacing for admission and ownership errors
  • Add isInteractiveTerminal helper and use in createCommand/consent Gate to respect CI/TTY/--json signals
  • Wire terminalOutboxPath from history.open into hosts and use createFileTerminalOutbox; update run/gate commands to drain outbox and sweep effects on terminal
  • Update agent-run, chat, Home, and specs forwarding to propagate new flags and attach effect journals and durability probes
apps/cli/src/process/exit-codes.ts
apps/cli/src/process/errors.ts
apps/cli/src/process/output-mode.ts
apps/cli/src/commands/create.ts
apps/cli/src/commands/run.ts
apps/cli/src/commands/gate.ts
apps/cli/src/commands/chat.ts
apps/cli/src/chat/session-host.ts
apps/cli/src/home/drive-home.tsx
apps/cli/src/engine/terminal-outbox.ts
apps/cli/src/engine/effect-retention.ts
apps/cli/src/commands/specs.ts
apps/cli/src/commands/specs-forwarding.test.ts
apps/cli/src/engine/host.ts
apps/cli/src/commands/agent-run.ts
Tighten workflow input and secret handling plus context compaction prompt placement to avoid untrusted content in system prompts.
  • Enhance WorkflowInputSchema with format/pattern validation, interpolation bans in defaults, secret default/enum bans, and per-type matching; add tests
  • Export isReferenceableInputName and matchesDeclaredType; use violations in admission and engine errors
  • Move COMPACTION_SYSTEM_PROMPT into authored-system-prompt with branded AuthoredSystemPrompt type, and route compaction summaries as Untrusted data in user messages
  • Add ESLint fences to prevent asserting AuthoredSystemPrompt outside constructor and enforce JSON stringification floors
  • Update run-event MaskedSecretSchema docs and keep proto keys by preservingUnknownRecord
packages/shared/src/workflow.ts
packages/core/src/engine/agent-session.ts
packages/core/src/engine/authored-system-prompt.ts
packages/core/src/engine/turn-messages.ts
eslint.config.mjs
packages/shared/src/run-event.ts
packages/core/src/interpolation/collect.test.ts
packages/shared/src/workflow.test.ts

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

cemililik and others added 21 commits August 20, 2026 12:16
…ine running it

CI failed this at 610ms against a `< 500ms` bound. The same input takes 33ms here,
and measuring it at 25k/50k/100k/200k/400k gives 4.8/9.0/18.3/36.2/69.9ms — dead
linear, doubling with the input at every step. There is no backtracking; there is
an 18x machine-speed gap on a loaded shared runner.

An absolute wall-clock bound never measured the claim. ReDoS is *super-linear
growth*, and the old assertion would have gone on failing on the slow machine and
passing on the fast one without either outcome saying anything about the regex. So
the test now measures what it is named after: doubling the input doubles a linear
scan and quadruples a quadratic one, and that ratio is the same on any hardware.
The threshold is 3 — between linear's 2 and quadratic's 4 — with a median of five
warmed runs so one descheduled run cannot decide the verdict, and a floor on the
denominator so a sub-millisecond timing measures the algorithm rather than the
clock.

Verified against known implementations rather than by mutating the regex: a
deliberately quadratic loop reads 3.62 and a linear one reads far below 1, while
the real function reads 1.98. (A genuinely catastrophic pattern was also tried —
it hangs rather than returning a ratio, which proves the vitest timeout works and
says nothing about the assertion, so it is not the evidence to rely on.)

Both correctness assertions are unchanged. They are the half that catches the
regression a timing bound structurally cannot: a narrowed quantifier that leaks
the token tail runs FASTER, not slower.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…he reasons the rest are not

**The one actual defect.** `authored-system-prompt.test.ts` asserted
`typeof forged === 'string'` on a value assigned from a `string` — true of every
string ever written. Sonar was right: the line proved nothing. The compile-time
half (`@ts-expect-error`) was always the real assertion; the runtime line now
pins the other property worth having — the brand is erased, so a branded value is
byte-identical to the string it came from and costs nothing at run time.

**Under load, the ReDoS test was still measuring the machine.** The growth ratio
landed at 4.8 on a provably linear function when the whole monorepo's suites ran
concurrently: measuring the small size and the large size in separate groups put
them in different contention windows. They are interleaved now — one ratio per
round, median across rounds — so both sizes share whatever the scheduler is doing.
Eight consecutive full-turbo runs pass; the instrument still reads 2.00 for a known
linear implementation and 3.97 for a known quadratic one, with the threshold of 3
between them.

Also fixed: a nested ternary in `canonical.ts`'s sort (now a named
`compareCodeUnits`, which is where "never `localeCompare`" can be written down)
and in `resume-identity.ts` (now `inputMismatchMessage`, with the reason each of
its three cases exists); repeated `Array#push` in the consent prompt; two `void`
operators; and nine duplicate imports.

**Two findings are rejected, in comments, at the code:**

- `charCodeAt` → `codePointAt` in `canonical.ts`. The function's entire job is to
  find a surrogate CODE UNIT standing alone. `codePointAt` combines a well-formed
  pair into one code point, which makes the paired/unpaired distinction — the only
  thing being asked — unexpressible. Same call, same reason, as the existing
  NOSONAR in `render/tui/chat-projection.ts`.
- `JSON.parse(JSON.stringify(…))` → `structuredClone` in `resume-identity.ts`.
  That round trip does two jobs a clone does not: it normalises to JSON shape, so
  two values compare equal exactly when they would serialise equal — the property
  the comparison is defined on — and it THROWS on input JSON cannot represent or
  cannot walk, which is what the `catch` around it exists for and what stopped a
  RangeError escaping `resumeFromCheckpoint` and stranding a lease for a full TTL.
  `structuredClone` clones a Date, a Map and a cycle happily, deleting both.

`pnpm run ci` exits 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… that never closes a pair

`/\{\{[\s\S]*?\}\}/` retries its lazy scan from every `{{`, each attempt running to
the end of the string. On a `default` of `'{{'` repeated 60,000 times — a 120KB
authored value — that measures **1033ms**, against **0.011ms** for the `indexOf`
form, and the cost grows with the square.

It matters more than it did when the rule was written. ADR-0084 settled that an
artifact is often not the user's, so a parse-time stall is something a shared file
can cause rather than something an author only does to themselves.

The rewrite is exactly equivalent: a terminated pair exists iff some `}}` follows
the FIRST `{{`, and where the only `}}` sits before it (`}}{{`) neither form
matches. Both tests are new — one pins the linear cost on the hostile input, the
other walks the eleven shapes that would diverge if the equivalence were wrong,
including the overlapping-brace and close-before-open cases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… complex, where the split had a name

Cognitive complexity is a maintainability metric, not a defect, so each of these
was taken only where the extracted piece is a coherent decision with a name — not
to move a number. Every one is behaviour-preserving; the suites pass unchanged, and
no test was edited.

- `assertStdioConsent` (18) → four phases: resolve, `undecided` (the digest dedupe,
  which is a rule with a §10.18 acceptance item behind it), `refuseWithDigests`,
  `askAndRecord` + `recordGrant`.
- `buildChatSession` (20) → `bindChatAgent` (the `/clear`-rebind vs resolve-from-disk
  decision) and `mcpOptionsFor` (the omit-vs-explicit-undefined shape
  `exactOptionalPropertyTypes` forces).
- `resolveAndValidateWorkflowInputs` (27) → `unknownKeyIssues` and a per-input
  `admitOne` returning a typed outcome, with `absentOutcome` holding the one rule
  that differs between `admit` and `verify`.
- `verifyResumeIdentity` (31) → `executionModeRefusal`, `reconcileRecorded`,
  `resuppliedSecret` (§6's "re-supplied or refused"), `unexpectedSuppliedRefusal`.
- `deepStructuralEquals` (17) → `alreadyComparing` (the cycle guard, which reads as
  a question and is called for its answer), `arraysEqual`, `objectsEqual`.
- `violatesInputContract` (22) → `violatesNumericBounds` and `violatesStringRules`,
  the latter carrying the length-before-pattern ordering that is the only ReDoS
  mitigation the contract offers.
- `validateValidationBlock` (27) → `validatePattern`.

Also: a nested ternary in `violatesDeclaredType` became a `DECLARED_TYPE_NOUN`
table, which is where "number says finite because that is the part a caller gets
wrong" can be written down.

`EffectSlot = number` is rejected as a redundant alias, in a comment at the code:
the docblock above it is where "what a slot is" lives, and replacing every
occurrence with `number` would delete the concept from the type surface with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…eDoS bound I got wrong twice

**The flake was a defect.** `runMigrations — the two-process race` had been failing
intermittently and reading as test noise. It was not: converting a database to WAL
takes an EXCLUSIVE lock, and SQLite returns `SQLITE_BUSY` for it WITHOUT invoking
the busy handler — waiting there could deadlock, so it refuses instead. Two
Relavium processes opening one fresh `history.db` at the same moment therefore
raced, and the loser's OPEN failed outright. Measured with paired spawns: **18
failures in 30**, and **0 in 30** with the WAL switch routed through the
`withBusyRetry` this package already had. `busy_timeout` does not help — measured
that too, before assuming it would: 15/25 with it first, 17/25 with it second.

What a user saw was `relavium run` refusing to start because another Relavium
happened to be starting. A new repeated open-race test pins it (one pair caught the
defect only ~60% of the time, so it runs six); removing the retry fails both it and
the original test.

**The ReDoS test: two wrong instruments before the right one.** `< 500ms` was a
machine-speed assertion — 33ms here, 610ms on a loaded runner, and neither says
anything about backtracking. A growth RATIO across two input sizes is the textbook
answer and is genuinely discriminating in isolation (1.98 for this function, 3.97
for a known quadratic one), but under whole-monorepo contention the per-round
ratios scattered from 0.98 to 10.17: each measurement is milliseconds, and one
descheduled window decides it. It also blew the default 5s test budget by measuring
seventeen redactions on a machine where each costs 610ms.

The threat is CATASTROPHIC backtracking, not a merely-quadratic scan: a quadratic
pattern on 250KB is slow, an exponential one does not return — measured, a nested
quantifier ran this input past 120 seconds. So the bound is now set where it
separates those two worlds (~8x the slowest honest observation, orders of magnitude
below a real regression), with an explicit test timeout so the assertion reports a
failure rather than the runner. Eight consecutive full-turbo runs pass.

Also in this batch, the remaining Sonar complexity findings where the split had a
name: `parseWorkflow` → `safeParseGuarded`, `runCommand` → `parseOrRefuse`, and the
three byte-identical attempt-failure tails in `#runStreamAttempt` → one
`#failAttempt` spliced with `yield*` — which removes the duplication that let the
`committed()` stamp go missing from one of them once already, and is
mutation-verified to still be pinned. Plus two optional chains.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…eclined at the code

Split, because the piece had a name:

- `dispatch` (38) → `prepareEffect`, ADR-0080 §7 step 2. It is the line the whole
  mechanism draws — everything above it is a refusal that journals nothing,
  everything below may have reached a target — so it reads better as a named step
  than as a nested try inside the ladder.
- `verdict` (21) in the append audit → `outOfOrderPairs`, shared by checks 3 and 4.
  They ask the same question of two different lists (what the engine ISSUED vs what
  the store COMMITTED) and are genuinely different properties, but the pair walk is
  one walk, and the two copies had already drifted on the `undefined` guard
  `noUncheckedIndexedAccess` requires.

Declined, with the reason in a NOSONAR at the code rather than in a dashboard:

- `#emitDurable`'s settled closure (23). Every branch in it is an ordering
  guarantee relative to `await prior` and the persist, and the `held`/`unclaimed`
  fast path exists specifically to avoid a microtask hop that the comments record
  as having reordered the log once already. Extracting any branch reinserts exactly
  that hop. The metric is not worth reopening the race CR-10/CR-92 closed.
- `resumeFromCheckpoint` (16, one point over). The branches ARE the ordering — the
  lease before any read, the identity guard before the checkpoint, the checkpoint
  before the workflow — and each comment states which line it must precede. A
  helper hiding one step turns a sequence the reader can see into one they have to
  reconstruct.

Also marked the standalone-credential alternation in `bounding.ts` NOSONAR, whose
deliberate-exception rationale was already written above it: a per-family split is
not equivalent, because an earlier pass inserting `[redacted]` truncates a later
family's greedy match and can leave a trailing secret-shaped substring exposed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The fixture EXISTS to contain the forms the fence must catch: the one-hop type
alias and the type-predicate return annotation are the cases `assert-fence.mjs`
asserts on, so "cleaning them up" would delete the coverage rather than improve it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…onal chain

`withBusyRetry(() => sqlite?.pragma(…))` expressed a real invariant as an optional
one: TypeScript cannot narrow the outer `let` inside a closure, so the `?.` was
there to satisfy the compiler — and it would silently SKIP the WAL switch rather
than fail if the binding ever were undefined, which is the opposite of what a
fail-closed open path wants.

A local `const opened` carries the connection through the pragmas and the Drizzle
bind; the outer `let` stays because it is what the `catch` closes.

Also records what was measured and rejected: setting `busy_timeout` before
`journal_mode` does NOT help this race (15/25 against 17/25), so the pragma order
is left as it was rather than changed on a plausible-sounding theory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ect's actual Node floor

`attempt-timer.test.ts` spawns a child that imports the REAL `hostAttemptTimer`
from a `.ts` file — which is the point of the test, since an earlier version
inlined a hand-written `setTimeout` and stayed green when `unref()` was put back
into the function it was supposed to be measuring.

Node unflagged type stripping in 22.18, and this project's floor is `>=22.13.0`.
On the one CI leg that runs the exact floor the child died with
`ERR_UNKNOWN_FILE_EXTENSION` — a red advisory check I introduced two steps ago and
did not notice, because every other leg runs a newer Node where the flag is on by
default. The flag has existed since 22.6, so passing it explicitly works on the
floor and on every newer runtime alike.

The consent gate's own two-process fixture already passes it; this was the one
child that did not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… pass — five suppressions that never applied

Checked the SonarCloud API rather than assuming the last pass landed, and it had
not: **five of nine `NOSONAR` comments were on the line ABOVE the issue**, and
SonarCloud only honours one on the SAME line. They were doing nothing. Moved to
end-of-line, with the explanation left as prose above.

Two of them cannot be suppressed that way at all — prettier relocates a trailing
comment after `{` into the block — so instead:

- `resumeFromCheckpoint` (16, one over) → `#ownedElsewhereMessage`. The ordering
  the rest of the method defends is untouched: this is a message the ordering does
  not depend on.
- `dispatch` (33) → `resolveGrantedTool`, the three refusals that reach no target.
  REGISTERED IS NOT AUTHORIZED, which is why the grant check sits beside the lookup.
- `#runStreamAttempt` (16) → `#settleUsage`, which sits outside the attempt's
  `try`/`finally` already and so touches no teardown ordering.

`#emitDurable`'s closure (23) is the one that stays: its branches ARE the ordering
relative to `await prior` and the persist. It needs an Accepted resolution in the
SonarCloud UI, which is a maintainer action, not a code one.

**And one of my "fixes" last pass did not fix what was reported.** Sonar still
flagged `authored-system-prompt.test.ts` — correctly. I had replaced
`expect(typeof forged).toBe('string')` with `expect(forged).toBe(HOSTILE)`, which
is *also* vacuous: `forged` is assigned from `dynamic`, which is assigned from
`HOSTILE`. This property only exists at the type level, so the assertion is
`expectTypeOf` now — already this repo's idiom in `llm/src/types.test.ts`.

**The extraction work surfaced a real coverage gap.** Mutating the fold-failure
path to drop its `committed()` stamp left all 773 tests in `@relavium/llm` green,
even though the code's own comment claims the stamp keeps "every error the chain
surfaces past content carries `contentCommitted`" true. That is the third stamp
site and the only one without a test; it has one now, and it fails when the stamp
is removed. `kind: 'unknown'` already derives `retryable: false`, so nothing
changes today — which is exactly why the invariant needed a test rather than a
second, unrelated mechanism holding it up.

Also: `indexOf(…) !== -1` → `includes` in the interpolation scan, and
`[A-Za-z0-9_]` → `\w` in the env echo-safety gate (exactly equivalent in JS; the
lead class cannot fold, since it excludes digits).

`pnpm run ci` exits 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… regex declines on their own lines

Sonar went 17 → 9 after the previous pass. Of the nine:

- `dispatch` (30) → `admitArgs`, steps 2-4. They belong together for a reason worth
  writing down: the effective set is assembled and VALIDATED before the guardrail
  check, so a `tool` node — whose args come entirely from `input_mapping`, with no
  model args at all — cannot bypass the allowlist by being checked before its args
  exist. Removing the policy call from the extracted helper fails 16 tests.
- `isSecretishFlag` and the RFC-3339 `date-time` check are declined, each on its own
  line so the suppression actually applies. The first is one anchored alternation of
  literal flag names — the metric is counting NAMES, and a Set would trade that for
  hand-expanding `api[_-]?key` into three members apiece, which is where a real
  omission would hide. The second's complexity IS the per-component calendar and
  clock bounds; "simplifying" it means deleting bounds and reinstating the defect its
  own comment describes (`0000-99-99T99:99:99Z` was once accepted as an instant).

**I attempted the five `String.raw` findings and reverted them.** Converting
`'[^\\s\\u0000-\\u001f\\u007f]'` to its raw form is a genuine readability win, but
the escapes were mangled twice on the way through the editing layer and landed as
literal control bytes in the character class — silently WIDENING what `format:`
accepts, which is the exact failure the surrounding comment warns about. A cosmetic
MINOR is not worth a change I could not write reliably, so the doubled form stays.

`#emitDurable` (23) also stays: its branches are the ordering relative to
`await prior` and the persist. It needs an Accepted resolution in the SonarCloud UI
— a maintainer action, not a code one.

`pnpm run ci` exits 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nts, so the suppression lands

Both `NOSONAR` markers were sitting three lines above their issue, inside a
multi-line comment — SonarCloud honours one only on the issue's OWN line, so
neither did anything. Two earlier placements failed the same way.

Hoisting each regex to a named `const` fixes it for a reason worth stating: with
the literal on its own line, prettier's own formatting puts the trailing comment
exactly where Sonar wants it. The names are the better half of the change anyway —
`RFC_3339` and `SECRETISH_FLAG` say what an inline literal made the reader derive,
and each now carries its rationale in a docblock rather than a comment wedged into
a `.test(` call.

Neither is a simplification Sonar could get: the first's complexity IS the
per-component calendar and clock bounds, and "simplifying" it means deleting bounds
and reinstating the defect its own docblock describes; the second's is the length
of a flag-name list, where a Set would trade one anchored alternation for
hand-expanding `api[_-]?key` into three members apiece.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…what they exclude

The three `format:` character classes were written as JS string literals with
doubled backslashes, so every read cost a step of mental arithmetic in a file where
getting a class wrong silently WIDENS what `format:` accepts. `String.raw` makes
them read as the regex source they are.

I abandoned this conversion once already: the escape sequences were mangled twice
in transit and landed as literal control bytes in the character class — the exact
silent widening the code warns about. Done here by constructing the bytes
explicitly (`chr(92)`, never a typed escape) and proving byte-equality of the
compiled sources before touching the file.

The test is the part worth reading. Its FIRST draft re-declared the three classes
locally and asserted against those — so it tested its own copy and would have
passed against any drift in `workflow.ts` at all. A mutation exposed it: widening
the real class was caught by a neighbouring suite and NOT by the new test. It goes
through `violatesInputContract` now — the exported entry point admission itself
calls — and each of the three classes was mutated in turn to confirm it fails.

Closes the last five Sonar findings that were mine to close. The two remaining
CRITICALs are declines: `#emitDurable`'s branches ARE the ordering, and both need
an Accepted resolution in the SonarCloud UI rather than a code change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…erely expected (PR83-01)

The compare-and-append guard checked only that the store's `max(seq)` equalled
`expectedLastSequenceNumber`. It never checked that the incoming event's own
sequence was GREATER than that maximum — and equality alone does not order a log.

Sequence gaps are legitimate: a transient or streamed event consumes a number
without becoming a durable row. So `(run_id, seq)` uniqueness cannot establish
order either — a stale event's number is both unique and lower.

Reproduced against the real SQLite store: `run:started` at 0, durable work at 5,
then `run:failed` at 3 with a TRUTHFUL `expectedLastSequenceNumber: 5`. The append
was accepted. Durable order became [0, 3, 5], `applyDerived` marked the run
terminal, and `listInterruptedRuns()` stopped reporting it.

The terminal-outbox drain is the reachable path, and it is where this bites: it
passes the run's current maximum as its belief — truthfully — so the equality half
always passes, and the held terminal was built by a crashed process from an older
view. On that false success the drain deletes the outbox entry: the only evidence
that the terminal was ever uncertain.

Both stores now require `event.sequenceNumber > actual` whenever the guard is
supplied, including the in-memory reference — a reference that accepted what SQLite
rejects would hide this from every `packages/core` test. `AppendConflictError`
carries the incoming sequence so a caller distinguishes "your belief is stale" from
"your event is behind" without parsing a message. The drain needs no change: the
store's refusal reaches its existing catch, which retains the entry.

Four regression tests, each verified to fail with the guard removed: SQLite rejects
behind/equal and keeps a legitimate gap legal; the reference store mirrors it; and
the drain holds a stale terminal instead of writing it behind newer work.

The `DurableWriteContext` contract claimed the equality check detected "an
out-of-order commit, and a replayed append". It did not. It says what is enforced.

Refs: ADR-0078 §2
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…efined` (PR83-02)

`JSON.stringify` DELETES a property whose value is `undefined`. The registry goes
out of its way to treat `undefined` as a legitimate tool result — that is what the
`NOT_REPLAYED` sentinel exists for — and then handed the replay envelope to a store
that dropped exactly that key.

Reproduced against real SQLite: settling
`{ value: undefined, truncated: false, summary: '', hadMapping: false }` stored
`{"truncated":false,"summary":"","hadMapping":false}`. On reload the reader's
`'value' in stored` guard failed, the compatibility fallback treated the whole
METADATA OBJECT as an old bare result, and the replay delivered that object as the
tool's return value. First run and resumed run produce different values — only
after a crash, and only for a tool that returns nothing.

The stored form is versioned now (`v: 1`) with an explicit `hasValue` bit, so
presence is stated rather than inferred from whether JSON happened to keep a
property. An unknown or malformed `v` fails CLOSED to "cannot re-deliver" instead
of falling through to the bare-value arm — falling through is the defect itself.
Both legacy shapes still read: the pre-`v` envelope and a bare stored value. The
pre-`v` ambiguity is inherited, not introduced: the byte that would tell an old
`undefined` from a bare value was never written.

`mapped` needs no parallel tag — `hadMapping` already records whether a mapping was
CONFIGURED, so a mapping projecting to `undefined` reads back correctly. That
distinction was already deliberate; this one was missed.

**The reference journal is why no test caught it.** It held the retained result BY
REFERENCE, making it strictly more capable than the store it stands for — so every
core test over the replay gate passed while SQLite corrupted the value. It
serializes and parses like the real store now, which is the same principle already
written into its `prepare`: a reference that accepts what SQLite refuses makes the
tests over it vacuous. That change alone surfaced a second latent coupling —
`blocksResume` reads `{ state, result }`, so the parsed value has to be threaded
into it rather than the row.

The regression test dispatches a tier-3 tool that genuinely returns nothing, then
resumes; it fails when the envelope is written unversioned.

Refs: ADR-0080 §4, §7
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…eadline (PR83-03)

`openDeadline` woke active race waiters when a caller abort FIRED, but a caller
that had already aborted at construction only got the provider-facing controller
aborted. Nothing recorded the cancellation for a `race` that had not been called
yet, and a native signal does not re-emit `abort` to a listener attached later.

So `race()` saw `expired === false`, registered a waiter nothing would ever wake,
and waited on the provider. Against a provider that ignores its signal — the exact
case the hard race exists for — it stayed pending until the ABSOLUTE timer fired:
120 seconds on the shipped default, during which Ctrl-C looks ignored. `classify()`
reported `caller` the whole time, so the label was right and the liveness was not.

The gap is reachable, not theoretical: the outer loops do check cancellation, but
`preAttempt` and credential resolution both run after that check and before
`openDeadline`. A cancel landing there arrives already-aborted.

Caller cancellation is latched now, and `race` consumes the latch on entry —
sharing the abandoned-step arm, because the disposal is identical (abandon the
step, handle its late rejection) and only the label differs, which `classify()`
already owns.

Five regression tests, all verified to fail with the latch removed: an immediate
settle for a pre-aborted caller WITHOUT firing the timer, an abort landing between
races, no unhandled rejection from the abandoned step, and the chain-level gap on
both the generate and stream paths.

Writing those chain tests turned up something worth stating: my first version wired
no timer port, so `#openDeadline` returned undefined, the chain plain-`await`ed the
provider, and both tests hung on a code path with no deadline in it at all. They
wire the port and never fire the timer — settling is what proves the caller latch
woke the race rather than the clock.

Refs: ADR-0082 §5, §7, §12
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… success (PR83-05)

The SQLite `settle` ran an update constrained by exact identity and
`state = 'prepared'`, then discarded the statement's `changes` count. A missing
identity and an already-terminal row therefore both resolved as success, and the
registry proceeded believing a real external effect had been durably journaled.

Reproduced against the real store: `settle` on an identity that had never been
prepared returned normally, having changed zero rows.

The code's own comment had the first half right — a settle against an
already-terminal row is a caller bug, and the honest response is to leave the
durable answer alone. Leaving truth alone is correct. Reporting that the requested
transition happened is not: corruption, an accidental delete, or a state-machine
race was converted from a loud fail-closed condition into silent success, past the
`ToolEffectNeedsAttentionError` path the registry keeps for exactly this case.

`EffectTransitionError` is typed distinctly from `EffectConflictError` because a
caller acts differently on each: a conflict means another attempt legitimately
holds the identity and this dispatch must not proceed; a transition failure means
the durable record is not what the caller believed while the effect it describes
may well have landed.

Mirrored in the in-memory reference for the reason PR83-02 just proved the hard
way — a reference more permissive than the real store makes every test over it
vacuous.

The existing terminal-settle test asserted the silent no-op, so it is updated
rather than added to: same intent (the durable answer is not overwritten), now with
the refusal that intent always implied.

Refs: ADR-0080 §7
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ly unresolved effect (PR83-04)

For an effectful tool the registry writes `prepared` before entering the dispatch
arm. When dispatch throws `ToolUnavailableError` — a missing host capability, which
throws synchronously before the host is ever touched — `neverLeftTheProcess`
correctly concludes the effect could not have reached a target, so the catch
declines to settle `ambiguous`.

Declining was right. Doing nothing else was not. The row stayed `prepared`, which
the state machine reads as UNRESOLVED: it blocks workflow resume, is disclosed on
session resume as an effect that may have landed, and is exempt from every age
sweep because unresolved rows are the record an operator needs. A wiring error
therefore produced a permanent, misleading, operator-facing claim of uncertainty
about a call that provably never happened.

The existing test asserted exactly that — `state === 'prepared'` — directly under a
comment saying the point was to avoid a permanently unresolved row. The assertion
and the stated intent contradicted each other, and the assertion won.

`EffectDispatchPort.discard` releases a `prepared` claim, in both the SQLite store
and the in-memory reference. Constrained to `prepared` in both, so it can never
erase a terminal row recording something that DID happen. A missing row is not an
error — it releases a claim, and a claim already gone is the outcome it wanted.

Chose this over the two alternatives deliberately. A new terminal `not_dispatched`
state would need a schema change, a CHECK-constraint migration and a snapshot
regen, to record a call that did not occur. A capability preflight derived from
`ToolPolicyClass` looked cleaner until I checked the mapping: `egress: 'mcp'`
routes to `requireMcp`, not `requireEgress`, and `read_media` has its own — so a
policy-derived preflight could refuse a dispatch that would have worked, which is
worse than the bug.

Both sides are now pinned: a missing capability leaves no row, and a network
failure still settles `ambiguous` and still blocks. The second test exists so the
release cannot quietly widen into "any dispatch throw clears the row".

Refs: ADR-0080 §7
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The CLI reference said a durability-uncertain terminal is retried "on the next
`relavium` start" and told scripts to re-check `relavium status <runId>` after a
subsequent invocation. Neither half held. Only `run` and `gate` drain the outbox,
and `status` never did — so a user who got exit 5 and did the natural thing, asking
for status repeatedly, saw the same stale truth indefinitely. Recovery only
happened as a side effect of running some unrelated workflow.

The narrow constraint is real and worth stating: `drainTerminalOutbox` is a
`WorkflowEngine` method, and `run`/`gate` are the only commands that construct one.
`chat`, `agent run` and the bare-invocation Home run on `AgentSession` and have no
engine to drain with, so "every start drains" is not a one-line change — it is a
core refactor to lift the drain off the engine and onto its host ports.

So `status` now NAMES the state instead of silently misreporting it. Such a run
reads `running` in the derived projection because its terminal never became
durable, which made it indistinguishable from a run still working; it is marked in
the human listing and carries `terminalHeld` in `--json`.

READ-ONLY, deliberately, and this is the part I would not trade away: draining
claims a run lease, and a status read must not take ownership of a run another
process may be finishing right now. An unreadable outbox degrades `status` to
exactly what it printed before rather than failing it — pinned by its own test.

The docs now say which two commands drain and why, rather than implying all of them
do.

`statusCommand` becomes async; its executor was already Promise-typed, so the
change stops at the dispatcher.

The engine-side lift — a drain callable from any surface — is the better long-term
answer and is recorded as a follow-up rather than smuggled into this diff.

Refs: ADR-0078 §4, §5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ion (PR83-07)

`database-schema.md` said run-effect retention "is not implemented yet — no sweep
touches this table today". The same PR ships and wires both committed-effect sweeps
(`effect-retention.ts`), and `effect-journal.md` §9 correctly says both ship. The
canonical schema reference was telling maintainers and future store authors the
opposite of what the code does.

Corrected to state the actual contract, which is an asymmetry rather than a gap:
`committed` rows are swept once their correlation can no longer be resumed;
unresolved rows are never swept by age, because an unresolved row is the record an
operator needs and outlives its run deliberately — the same reason the table
carries no foreign key to `runs`.

The ER diagram also drew `runs ||--|| run_leases`, implying every run has exactly
one lease. A lease row is created on acquire and DELETED on release, so most runs
have none for most of their lifetime and a finished run has none at all. Now
`||--o|`, with a note saying why — the mandatory reading is precisely what would
mislead someone porting this schema.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e of them protects

My PR83-02 and PR83-04 fixes pushed `dispatch`'s cognitive complexity from 29 to
33 — a regression I introduced while fixing the review findings, and Sonar caught
it. Two cohesive steps move out:

- `journalDispatchFailure` — the whole of §7 step 4, where the three outcomes and
  the reasons they differ now sit together instead of nested in a catch.
- `settleCommitted` — §7 step 8, including the `ToolEffectNeedsAttentionError`
  window where the effect provably happened and the record does not say so.

Mutation-checking the extractions turned up a real coverage gap rather than just
confirming the move. Storing `mapped` UNCONDITIONALLY — dropping the guard that
omits it when the node configured no `output_mapping` — passed all 239 tool tests.
That guard is load-bearing: without a mapping, `outputMapped` IS the full unbounded
result, so persisting it would put the very thing "settle after bounding" exists to
keep out of `history.db` straight back into it, with no cap and no sweep.

The new test pairs both directions — no mapping means no `mapped` key, a
configured mapping means there is one — because the negative alone would also pass
for a row that stored nothing at all. It fails under the mutation.

Its first draft asserted the stored envelope was smaller than a 50 KB body, which
was wrong about the subject: 50 KB is within the bounding limit and is legitimately
retained whole. Bounding has its own tests; this one is about the projection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@cemililik
cemililik merged commit 6194a99 into main Aug 24, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant