diff --git a/CLAUDE.md b/CLAUDE.md
index 75116458..0ed008bc 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -38,7 +38,7 @@ A run executes in one of **three execution modes** behind the one `LLMProvider`
engine is identical across all three. See [ADR-0012](docs/decisions/0012-managed-inference-dual-mode.md) to [ADR-0015](docs/decisions/0015-managed-mode-data-handling-and-compliance.md)
and [docs/architecture/managed-inference.md](docs/architecture/managed-inference.md).
-**Status:** Phase 1 in progress — milestone M1 (LLM seam proven) reached (PR #9, 2026-06-07); the `FallbackChain` runner (1.K) landed, completing 1.m2 with the cost tracker (PR #13, 2026-06-11); the run loop (1.N — `WorkflowEngine` + `RunEventBus`) landed (PR #17, 2026-06-13) **completing 1.m3** (parse → DAG → run loop emits the canonical event stream), with the built-in `ToolRegistry` (1.T, a 1.m4 component) landing alongside it as the other `AgentRunner` (1.O) join prerequisite; the **`AgentRunner` (1.O) — per-node LLM execution behind the seam — landed (PR #18, 2026-06-14)**; and the **node-type handlers (1.P) — the six non-agent `NodeExecutor` arms (condition / transform / fan_out / fan_in / input / output) behind a dispatching executor — landed (PR #20, 2026-06-14)**; and **checkpoint/resume (1.R) + the human gate (1.Q) landed (PR #22, 2026-06-15)** — the derived `Checkpointer` + cross-process `resumeFromCheckpoint`, and the `human_in_the_loop` gate with the one-shot timeout port; and **node retry (1.S) — the above-chain whole-node retry budget ([ADR-0040](docs/decisions/0040-node-retry-budget-above-the-chain.md), amending ADR-0038) — landed (PR #24, 2026-06-15)**, re-dispatching a whole node on a retryable failure up to `retry.max` attempts (with `node:retrying`, abort-aware backoff, and `retry_on` filtering), with retry-from-node (ADR-0040 Part B) deferred to Phase-2; and the **pre-egress budget governor (1.AC, [ADR-0028](docs/decisions/0028-workflow-resource-governance.md)) + the `AgentSession` agent-first entry point (1.V, [ADR-0024](docs/decisions/0024-agent-first-entry-point-agentsession.md)) landed together (PR #26, 2026-06-16)** — 1.AC was the last 1.m4 component, so **1.m4 is complete** (the full engine stack: node handlers, gate, checkpoint/resume, retry, tools, sandbox, budget governor), and 1.V opens the Lane-C agent-first sub-spine (1.m5); then the **end-to-end Node harness (1.U) landed (PR #27, 2026-06-16), reaching 🎯 M2** — the engine runs end-to-end (live streaming + per-node-boundary checkpointing + cross-process resume + node retry + provider failover, gap-free), **completing the Phase-1 engine critical path**. The remaining Phase-1 work is additive and off the critical path (Lane C: the **`session:*` namespace (1.W) landed (PR #28, 2026-06-17)** — the `SessionEventSink`→`RunEventBus` adapter + per-session `sequenceNumber`, the `SessionHandle`, and the combined `RunOrSessionEventSchema` gate — and **session persistence (1.X) landed (PR #29, 2026-06-17)** — the `agent_sessions`/`session_messages` tables + migration, `SessionMessageSchema`/`AgentSessionSchema`, and the `SessionStore` + domain↔row mappers (data-layer only; the per-turn `AgentSession`→store wiring + resume are 1.Y/1.AA) — leaving 1.Y/1.Z/1.AA next ‖ the 1.m6 multimodal sub-spine); **Phase 2 (CLI, M3) is unblocked**.
+**Status:** Phase 1 in progress — milestone M1 (LLM seam proven) reached (PR #9, 2026-06-07); the `FallbackChain` runner (1.K) landed, completing 1.m2 with the cost tracker (PR #13, 2026-06-11); the run loop (1.N — `WorkflowEngine` + `RunEventBus`) landed (PR #17, 2026-06-13) **completing 1.m3** (parse → DAG → run loop emits the canonical event stream), with the built-in `ToolRegistry` (1.T, a 1.m4 component) landing alongside it as the other `AgentRunner` (1.O) join prerequisite; the **`AgentRunner` (1.O) — per-node LLM execution behind the seam — landed (PR #18, 2026-06-14)**; and the **node-type handlers (1.P) — the six non-agent `NodeExecutor` arms (condition / transform / fan_out / fan_in / input / output) behind a dispatching executor — landed (PR #20, 2026-06-14)**; and **checkpoint/resume (1.R) + the human gate (1.Q) landed (PR #22, 2026-06-15)** — the derived `Checkpointer` + cross-process `resumeFromCheckpoint`, and the `human_in_the_loop` gate with the one-shot timeout port; and **node retry (1.S) — the above-chain whole-node retry budget ([ADR-0040](docs/decisions/0040-node-retry-budget-above-the-chain.md), amending ADR-0038) — landed (PR #24, 2026-06-15)**, re-dispatching a whole node on a retryable failure up to `retry.max` attempts (with `node:retrying`, abort-aware backoff, and `retry_on` filtering), with retry-from-node (ADR-0040 Part B) deferred to Phase-2; and the **pre-egress budget governor (1.AC, [ADR-0028](docs/decisions/0028-workflow-resource-governance.md)) + the `AgentSession` agent-first entry point (1.V, [ADR-0024](docs/decisions/0024-agent-first-entry-point-agentsession.md)) landed together (PR #26, 2026-06-16)** — 1.AC was the last 1.m4 component, so **1.m4 is complete** (the full engine stack: node handlers, gate, checkpoint/resume, retry, tools, sandbox, budget governor), and 1.V opens the Lane-C agent-first sub-spine (1.m5); then the **end-to-end Node harness (1.U) landed (PR #27, 2026-06-16), reaching 🎯 M2** — the engine runs end-to-end (live streaming + per-node-boundary checkpointing + cross-process resume + node retry + provider failover, gap-free), **completing the Phase-1 engine critical path**. The remaining Phase-1 work is additive and off the critical path (Lane C: the **`session:*` namespace (1.W) landed (PR #28, 2026-06-17)** — the `SessionEventSink`→`RunEventBus` adapter + per-session `sequenceNumber`, the `SessionHandle`, and the combined `RunOrSessionEventSchema` gate — and **session persistence (1.X) landed (PR #29, 2026-06-17)** — the `agent_sessions`/`session_messages` tables + migration, `SessionMessageSchema`/`AgentSessionSchema`, and the `SessionStore` + domain↔row mappers (data-layer only); then **session checkpoint/resume (1.Y) + export-to-workflow (1.Z) landed (PR #30, 2026-06-17)** — `reconstructSessionState`/`AgentSession.resume` (reload-not-replay; preload the text-only transcript, re-seed turnCount/cost, no `session:started` re-emit) + the `serializeWorkflow`/`sessionToWorkflow` pair (one agent node per completed turn, transcript in `metadata`, secret/signature exclusion structural) — leaving only the **1.AA** chat-regression harness ‖ the 1.m6 multimodal sub-spine; **Phase 2 (CLI, M3) is unblocked**.
Phase 0 (M0, 2026-06-04) landed the monorepo, strict toolchain + CI, `@relavium/shared` (the
full Zod contract set), the no-vendor-type seam fence, and `@relavium/db`. Phase 1 has since
landed `@relavium/llm` — the `LLMProvider` seam + all three adapters (Anthropic, OpenAI/DeepSeek,
@@ -62,9 +62,9 @@ re-delivery and a `workflow_mismatch` identity guard, and the `human_in_the_loop
plus the one-shot `setTimer` timeout port — `approve` auto-resolves, `reject` fails with `run_timeout`).
The pre-egress budget governor (1.AC) + the agent-first `AgentSession` (1.V) landed together (PR #26)
**completing 1.m4**; then the end-to-end Node harness (1.U) landed (PR #27, 2026-06-16) **reaching M2** —
-the Phase-1 engine critical path is complete. Active work is now the additive Lane-C sub-spine (session
-events **1.W ✅ Done (PR #28)** + persistence **1.X ✅ Done (PR #29, 2026-06-17)**; 1.Y/1.Z/1.AA next) and
-the multimodal sub-spine; Phase 2 (CLI) is unblocked. See
+the Phase-1 engine critical path is complete. The additive Lane-C agent-first sub-spine is now **complete**
+(session events **1.W ✅ (PR #28)** + persistence **1.X ✅ (PR #29)** + checkpoint/resume **1.Y** & export **1.Z ✅ (PR #30, 2026-06-17)** + the **1.AA** chat-regression harness ✅ (2026-06-17), closing **1.m5**);
+remaining Phase-1 work is the 1.m6 multimodal sub-spine; Phase 2 (CLI) is unblocked. See
[docs/roadmap/current.md](docs/roadmap/current.md). See [README.md](README.md) for the public overview.
## Non-negotiable rules for AI agents
diff --git a/README.md b/README.md
index d1d88b2a..3b41e3b7 100644
--- a/README.md
+++ b/README.md
@@ -52,6 +52,6 @@ both landed in **PR #26 (2026-06-16)**. With the budget governor in, **milestone
**end-to-end Node harness** (1.U) landed (**PR #27, 2026-06-16**) — **🎯 reaching milestone M2**: the engine runs
end-to-end (live streaming + checkpoint + cross-process resume + retry + provider failover), completing the
Phase-1 engine critical path. The remaining Phase-1 work is additive (the agent-first sub-spine — **session
-events 1.W ✅ (PR #28)** + **persistence 1.X ✅ (PR #29, 2026-06-17)**, 1.Y/1.Z/1.AA next … — and the
+events 1.W ✅ (PR #28)** + **persistence 1.X ✅ (PR #29)** + **session checkpoint/resume 1.Y & export-to-workflow 1.Z ✅ (PR #30, 2026-06-17)** + **the 1.AA chat-regression harness ✅ (2026-06-17)**, completing **1.m5** — and the
multimodal sub-spine); **Phase 2 (the CLI) is unblocked**. See
[docs/roadmap/current.md](docs/roadmap/current.md) for live status.
diff --git a/docs/decisions/0041-external-action-governance-seam.md b/docs/decisions/0041-external-action-governance-seam.md
new file mode 100644
index 00000000..d8deb575
--- /dev/null
+++ b/docs/decisions/0041-external-action-governance-seam.md
@@ -0,0 +1,112 @@
+# ADR-0041: External action-governance seam — the optional, host-injected `ActionGuard` over side-effecting tool actions
+
+- **Status**: Proposed
+- **Date**: 2026-06-17
+- **Related**: [ADR-0003](0003-pure-ts-engine-not-langgraph-python.md), [ADR-0008](0008-local-first-phase-1-cloud-phase-2.md), [ADR-0012](0012-managed-inference-dual-mode.md), [ADR-0015](0015-managed-mode-data-handling-and-compliance.md), [ADR-0018](0018-desktop-execution-and-rust-egress.md), [ADR-0028](0028-workflow-resource-governance.md), [ADR-0029](0029-tool-policy-hardening.md), [ADR-0034](0034-mcp-client-sdk-dependency.md), [ADR-0036](0036-run-loop-substrate-event-bus-and-execution-host.md), [ADR-0037](0037-engine-tool-execution-boundary.md), [ADR-0039](0039-same-provider-reasoning-replay.md), [tool-registry.md](../reference/shared-core/tool-registry.md), [security-review.md](../standards/security-review.md), [error-handling.md](../standards/error-handling.md), [sse-event-schema.md](../reference/contracts/sse-event-schema.md), [shared-core-engine.md](../architecture/shared-core-engine.md), [product-constraints.md](../product-constraints.md), [architectural-principles.md](../standards/architectural-principles.md)
+
+
+
+## Context
+
+[ADR-0037](0037-engine-tool-execution-boundary.md) pinned the `ToolHost` boundary: the engine's `ToolRegistry` owns all tool **policy + dispatch** as pure code in `packages/core`, and every side-effecting **mechanism** is host-injected. [ADR-0029](0029-tool-policy-hardening.md) hardened the engine-side guardrails on that boundary — exact-command match, node-tools narrow-only, no secret interpolation (taint-tracked), one SSRF primitive over three egress paths, `git_commit` behind a human gate. Those guardrails are **static allow/deny + an injection-resistant data/instruction posture**: fail-closed, but coarse-grained and authored.
+
+A **distinct, deeper class of guarantee** is increasingly a precondition for the regulated/enterprise adopters who let agents **write to systems of record** (payments, ERP rows, ticket state, infra). It is four properties Relavium deliberately does **not** provide today, and should not, because they are a *different product's* concern, not this local-first runtime's identity:
+
+1. **Transactional action safety** — idempotency on a *semantic* effect key, a dry-run/preview before commit, and a *compensating rollback* of external side effects. Note the gap precisely: the derived `Checkpointer` ([ADR-0003](0003-pure-ts-engine-not-langgraph-python.md)) and `resumeFromCheckpoint` ([ADR-0036](0036-run-loop-substrate-event-bus-and-execution-host.md)) make a run *replay*; they do **not** *undo* an external `POST` that already committed. Node retry ([ADR-0040](0040-node-retry-budget-above-the-chain.md)) re-dispatches, it does not compensate.
+2. **Provable information-flow control** — beyond the untrusted-content-as-data structural boundary ([security-review.md §Prompt-injection](../standards/security-review.md), binding on workstreams 1.T/1.O per [ADR-0037](0037-engine-tool-execution-boundary.md)), capability/sink-policy IFC (the CaMeL/FIDES research class) that *proves* untrusted data cannot reach a sensitive sink.
+3. **Per-action, delegation/attenuation-aware authorization** — beyond the static `allowedCommands` / `allowedDomains` allowlists, a runtime PDP decision over `agent ∩ user ∩ delegation ∩ intent`.
+4. **Tamper-evident, regulator-grade audit** — beyond the `run_events` log and the Phase-2 SOC 2 audit trail ([cloud-phase-2.md](../architecture/cloud-phase-2.md)), hash-chained / Merkle-witnessed evidence mapped to external regimes (e.g. EU AI Act Art. 12/14).
+
+These four are the concern of an **external action-governance / assurance control plane** (working name **Provna**) that wraps *any* agent runtime, not only Relavium. The question this ADR settles is the **integration boundary**: *where and how does such a governor attach, without breaking the things that make Relavium Relavium?* The framing constraints are non-negotiable:
+
+- **Engine purity** (rule 5, [ADR-0003](0003-pure-ts-engine-not-langgraph-python.md)): calling an external governor is network/process I/O — it cannot live in `packages/core`.
+- **Local-first, no cloud dependency** ([product-constraints.md](../product-constraints.md), [ADR-0008](0008-local-first-phase-1-cloud-phase-2.md)): Relavium must not acquire a *required* external dependency; an external enterprise control plane can only ever be **opt-in**, in the spirit of managed inference ([ADR-0012](0012-managed-inference-dual-mode.md)) being an additive Phase-2 opt-in that is never a paywall in front of the BYOK/local path.
+- **Seam-abstraction discipline** (the `LLMProvider` and `ToolHost` precedent): the boundary must be **vendor-neutral** — no single governance vendor's type may define it.
+
+Getting this wrong has two failure modes: baking the four properties *into* the engine (scope creep that contradicts desktop-is-not-an-IDE / local-first and couples a local runtime to an enterprise plane), or letting each surface bolt on a different hook (the surface-drift risk [shared-core-engine.md](../architecture/shared-core-engine.md) exists to kill).
+
+## Decision
+
+**We will define an optional, host-injected `ActionGuard` seam at the side-effecting tool-execution boundary, OFF BY DEFAULT, through which an external action-governance control plane (Provna as the reference implementation) governs side-effecting tool actions — adding transactional safety, information-flow control, per-action authorization, and tamper-evident audit. The engine pins the *seam, the invocation point, and the composition rules* as pure types in `packages/core`; the *mechanism* (the call to the external governor and the wrapped side-effect execution) is host-injected exactly as the `ToolHost` ([ADR-0037](0037-engine-tool-execution-boundary.md)) and the Rust egress ([ADR-0018](0018-desktop-execution-and-rust-egress.md)) are. The `ActionGuard` COMPOSES WITH — never replaces — the [ADR-0029](0029-tool-policy-hardening.md) guardrails and the `human_in_the_loop` gate; it is a defense-in-depth, opt-in enterprise layer, not Relavium's baseline.**
+
+Naming note: this is the `ActionGuard` seam, deliberately **distinct from** [ADR-0028](0028-workflow-resource-governance.md)'s pre-egress *budget* governor (which caps token/cost before LLM egress). Different concern, different lifecycle — kept a sibling seam for the same reason [ADR-0037](0037-engine-tool-execution-boundary.md) keeps `ToolHost` separate from [ADR-0036](0036-run-loop-substrate-event-bus-and-execution-host.md)'s `ExecutionHost`.
+
+The split, by concern (the [ADR-0037](0037-engine-tool-execution-boundary.md) policy/mechanism precedent):
+
+- **Engine-pure (`packages/core`):** the `ActionGuard` **interface** (a typed port) and the **invocation point** in the `ToolRegistry` dispatch path — placed **after** the [ADR-0029](0029-tool-policy-hardening.md) guardrails pass and **around** the side-effecting `ToolHost` mechanism. The engine passes the governor the structured intent (tool id, the **effective validated args** — model args + `input_mapping` + config, [ADR-0037](0037-engine-tool-execution-boundary.md) — the existing **untrusted / secret taint markers**, node/agent identity, and the run/session correlation id) and interprets the governor's verdict — a discriminated union `allow | block | require-approval | transform` — into the engine's *existing* control flow:
+ - `block` → **`tool_denied`** (fatal, never retried — the [ADR-0037](0037-engine-tool-execution-boundary.md) error taxonomy, no new code).
+ - `require-approval` → reuse the durable **`human_in_the_loop`** suspend/resume (the [ADR-0036](0036-run-loop-substrate-event-bus-and-execution-host.md) `ExecutionHost` persistence + one-shot timeout port) — **not** a new suspend mechanism.
+ - `transform` → re-validate the narrowed args against the **same** Zod + [ADR-0029](0029-tool-policy-hardening.md) checks; a governor may only **narrow, never widen** (symmetry with node-tools narrow-only, [ADR-0029](0029-tool-policy-hardening.md)(b)).
+ - `allow` → proceed.
+- **Host-injected (the `ActionGuard` implementation):** the network/process I/O to the external control plane; the **idempotent, compensable execution wrap** of the side effect (when a governor is present it owns invoking the `ToolHost` mechanism, so it can bind an idempotency key, record a compensation, and emit the tamper-evident audit record); and, on failure or a later policy violation, **triggering compensation**. On the desktop this is a Tauri command ([ADR-0018](0018-desktop-execution-and-rust-egress.md) generalized beyond `llm_stream`); on the Node surfaces an in-process client; in Phase-2 cloud the relocated `ExecutionHost` provides it. **Absent (the default), the engine calls the `ToolHost` directly, exactly as today** — no external call, no behavior change.
+
+Composition rules this ADR pins:
+
+- **Off by default; never a required dependency.** With no `ActionGuard` injected, behavior is byte-identical to today — the [product-constraints.md](../product-constraints.md) / [ADR-0008](0008-local-first-phase-1-cloud-phase-2.md) local-first, zero-egress guarantee is untouched. The governor is an enterprise opt-in, the [ADR-0012](0012-managed-inference-dual-mode.md)/[ADR-0015](0015-managed-mode-data-handling-and-compliance.md) opt-in posture applied to governance.
+- **Side-effecting tools and egress — including read-only egress such as `web_search` — are governed.** Local read-only tools (`read_file`, `git_status`, clipboard, …) and `invoke_agent` (already *not* a `ToolHost` capability — [ADR-0037](0037-engine-tool-execution-boundary.md)) bypass the governor. Egress is governed *even when read-only* because it is an exfiltration sink for the information-flow pillar (the query is the lethal-trifecta channel). The governor sees every write / process-spawn / egress (classified by `ActionClass` — see [action-guard-seam.md](../reference/shared-core/action-guard-seam.md)), where transactionality, authz, audit, and — for egress — exfiltration control matter.
+- **`fs-write` needs an additive `ToolPolicyClass.fsWrite` flag.** The existing `fsScoped` ([tool-registry.md](../reference/shared-core/tool-registry.md)) is `true` for reads and writes alike, so it cannot key the `fs-write` class; this ADR proposes a minimal, backward-compatible additive `fsWrite?: boolean` that lands with the seam (until then, an implementation must not govern fs reads).
+- **Composes after, never replaces.** The [ADR-0029](0029-tool-policy-hardening.md) guardrails run **first** (fail-closed allowlists, secret-taint, SSRF); only calls the engine already permits reach the governor, which can **further restrict or wrap, never re-grant**. A hallucinated / injected `tool_call` for a tool the node was not granted is already dead at the registry ([ADR-0037](0037-engine-tool-execution-boundary.md)) before the governor is consulted.
+- **Host-internal spill mechanisms are orthogonal.** Mechanisms such as the `outputStore` spill-to-file path are internal `ToolHost` bookkeeping, not tool calls; they remain governed by the host and the existing [ADR-0029](0029-tool-policy-hardening.md) / [ADR-0037](0037-engine-tool-execution-boundary.md) boundaries, not by the optional `ActionGuard`.
+- **Deterministic replay.** The governor's verdict and the external side-effect result are journaled as side effects in `run_events`, keyed by the governor's idempotency key — the LLM-call journaling precedent ([ADR-0039](0039-same-provider-reasoning-replay.md) / [ADR-0003](0003-pure-ts-engine-not-langgraph-python.md) derived checkpointer) — so cross-process resume ([ADR-0036](0036-run-loop-substrate-event-bus-and-execution-host.md)) **re-delivers rather than re-executes**; a resumed run will never double-post.
+- **Taint handoff.** The governor **consumes** the engine's untrusted / secret markers ([ADR-0037](0037-engine-tool-execution-boundary.md) / [ADR-0029](0029-tool-policy-hardening.md)(c)) as inputs to its IFC decision and **returns** its result still marked untrusted — the unsafe-path-unrepresentable type boundary holds end to end.
+- **Vendor-neutral seam.** The interface names no vendor; Provna is *a* reference implementation, as Anthropic/OpenAI/Gemini are implementations behind `LLMProvider`. No external-governor SDK type crosses the seam.
+
+This ADR pins the principle + seam + invocation point + composition rules; the exhaustive `ActionGuard` interface (the verdict union, the intent payload, the compensation/audit handles) lands in its one canonical home, a new [action-guard-seam.md](../reference/shared-core/action-guard-seam.md) (rule 8), following the [ADR-0037](0037-engine-tool-execution-boundary.md) → [tool-registry.md](../reference/shared-core/tool-registry.md) precedent.
+
+```mermaid
+flowchart TD
+ Call["AgentRunner / AgentSession
selects a side-effecting tool"] --> Reg["ToolRegistry dispatch (pure)"]
+ Reg --> G29{"ADR-0029 guardrails
(allowlist · taint · SSRF)"}
+ G29 -->|"denied"| Denied["tool_denied (fatal)"]
+ G29 -->|"permitted"| HasG{"ActionGuard injected?"}
+ HasG -->|"no (default · local-first)"| TH["ToolHost mechanism (direct)"]
+ HasG -->|"yes (opt-in)"| Decide["PHASE 1 · ActionGuard.decide(intent + taint)"]
+ Decide --> V{"verdict"}
+ V -->|"block"| Denied
+ V -->|"require-approval"| HG["human_in_the_loop suspend/resume
(ADR-0036)"]
+ V -->|"transform"| Reval["re-validate (Zod + ADR-0029) · narrow-only"]
+ V -->|"allow"| Commit["PHASE 2 · ActionGuard.commit(plan, thunk)"]
+ HG -->|"approved"| Commit
+ HG -->|"reject / timeout-reject"| Denied
+ Reval --> Commit
+ Commit --> TH
+ TH --> Result["tool_result (untrusted-marked)"]
+ Commit -.->|"later saga fail / violation → compensate"| Comp["compensation (reverse saga)"]
+ Result --> Audit["journal verdict + receipt in run_events
(idempotency-keyed · ADR-0039)"]
+ Comp --> Audit
+```
+
+Considered alternatives:
+
+- **Build action-governance into `packages/core`** (rejected) — the governor is network I/O; it breaks engine purity (rule 5, [ADR-0003](0003-pure-ts-engine-not-langgraph-python.md)) and the ESLint / `tsconfig.purity.json` fences, and it bloats the local-first runtime with a different product's concern.
+- **Make the seam vendor-specific (a "Provna seam")** (rejected) — couples Relavium to one governance vendor, violating the abstraction discipline that governs the `LLMProvider` and `ToolHost` seams. The interface must be vendor-neutral with Provna as one implementation.
+- **On by default / required** (rejected) — breaks the no-cloud-dependency guarantee ([product-constraints.md](../product-constraints.md), [ADR-0008](0008-local-first-phase-1-cloud-phase-2.md)); an external VPC control plane is not something a solo BYOK-local user runs.
+- **Fold it into the existing `ToolHost`** ([ADR-0037](0037-engine-tool-execution-boundary.md)) (rejected) — the governor has a distinct lifecycle (a pre-execution *decision*, an execution *wrap* for idempotency + compensation, a post-hoc *compensation*, an async *approval* path); folding it into the per-tool I/O mechanism re-merges the policy/mechanism split [ADR-0037](0037-engine-tool-execution-boundary.md) keeps, and compensation/audit is cross-cutting, not per-mechanism.
+- **Express it only as authored `human_gate` nodes** (rejected) — the gate is coarse and authored per-workflow; the governor must transparently intercept *every* side-effecting action the agent chooses at runtime, including MCP tool calls ([ADR-0034](0034-mcp-client-sdk-dependency.md)), not only the gates an author placed.
+- **Reuse [ADR-0028](0028-workflow-resource-governance.md)'s budget governor** (rejected) — different concern and lifecycle (token/cost ceiling pre-LLM-egress vs. governance of side-effecting tool actions). Two minimal sibling seams beat one god-interface — the [ADR-0037](0037-engine-tool-execution-boundary.md) `ToolHost`-vs-`ExecutionHost` reasoning.
+
+## Consequences
+
+### Positive
+
+- **Local-first preserved.** Off by default, no external call, behavior identical to today — the guarantee stays a guarantee, and the seam is unit-testable against a stub `ActionGuard` with zero real I/O (the `ToolHost` testing posture).
+- **Enterprise adopters get the deep guarantees without forking the engine.** Transactional safety + IFC + per-action authz + tamper-evident audit attach at one injected seam; the engine stays pure, the surfaces wire it once (the [ADR-0018](0018-desktop-execution-and-rust-egress.md)/[ADR-0037](0037-engine-tool-execution-boundary.md) pattern).
+- **Vendor-neutral.** Provna is the reference implementation; the seam admits others — the `LLMProvider`/`ToolHost` discipline extended to governance.
+- **Defense-in-depth with no new machinery.** The governor composes *after* [ADR-0029](0029-tool-policy-hardening.md), reuses the durable `human_in_the_loop` gate ([ADR-0036](0036-run-loop-substrate-event-bus-and-execution-host.md)) for approvals and the `run_events` journal ([ADR-0003](0003-pure-ts-engine-not-langgraph-python.md)/[ADR-0039](0039-same-provider-reasoning-replay.md)) for deterministic replay.
+- **Boundary pinned while [ADR-0037](0037-engine-tool-execution-boundary.md) is fresh**, so Phase-1 tool work doesn't foreclose it and no surface invents a divergent hook.
+- **Clean two-product story.** Relavium runs agents; the external governor governs their actions at this seam — adjacent layers, not a merge that would dilute either.
+
+### Negative
+
+- **Another host-injected seam to wire per surface** (Tauri command on desktop, in-process client on Node) — mitigated: it is the already-accepted [ADR-0018](0018-desktop-execution-and-rust-egress.md)/[ADR-0037](0037-engine-tool-execution-boundary.md) injection pattern, and the engine half is surface-agnostic and tested once against a stub.
+- **The governor sits in the hot path of side-effecting actions** — added latency and an availability dependency that, for a regulated action, must fail-closed. Mitigated: scoped to the side-effecting subset only, bounded/timed, and a fail-closed *indeterminate* verdict is precisely what the governor's own dry-run + compensation makes safe (the deferral/reversibility is the point, not an outage).
+- **Replay must journal the governor's verdict + external result as side effects** (idempotency-keyed) — added event-log/resume complexity, mitigated by reusing the LLM-call journaling precedent ([ADR-0039](0039-same-provider-reasoning-replay.md)/[ADR-0003](0003-pure-ts-engine-not-langgraph-python.md)) rather than inventing a mechanism.
+- **Cross-product version coupling** (the Relavium `ActionGuard` seam vs. the external governor's API) — a real maintenance surface; mitigated by versioning the seam in its canonical reference doc (rule 8) and keeping it vendor-neutral, so it evolves on Relavium's cadence, not one vendor's.
+- **The deepest guarantees are not Relavium's baseline** (they live in the external product) — recorded as a *deliberate boundary*, not a gap: baking provable IFC, transactional rollback, and regulator-grade audit into the local-first runtime would be scope creep against the desktop-is-not-an-IDE / local-first discipline. Relavium's own [ADR-0029](0029-tool-policy-hardening.md) guardrails remain the always-on, fail-closed floor.
+- **Session entry-point scope (Phase 1).** `require-approval` (durable suspend) and automatic `compensate` (saga unwind) rely on `WorkflowEngine` machinery an `AgentSession` ([ADR-0024](0024-agent-first-entry-point-agentsession.md)) lacks; in Phase 1 they are **run-only**. A session turn still gets IFC + authz + audit + idempotency; its transactional / approval guarantees land with the session unwinder + persistence (1.X/1.Y). A deliberate scope boundary, recorded so it is not mistaken for a seam gap.
+- **Proposed, not Accepted.** This pins a boundary for a product that does not yet exist; if the external-governor direction is abandoned, the ADR is marked **Deprecated** (it is not *Superseded* — no later ADR replaces it; the direction is simply dropped) and the seam never lands — no Phase-1 commitment is created by recording it.
diff --git a/docs/decisions/README.md b/docs/decisions/README.md
index b04681fa..3306a261 100644
--- a/docs/decisions/README.md
+++ b/docs/decisions/README.md
@@ -84,6 +84,7 @@ flowchart TD
| 0038 | [AgentRunner LLM-call boundary — host-injected provider resolution, the per-node-execution `FallbackChain`, and the credential discipline](0038-agentrunner-llm-call-boundary.md) | Accepted | 2026-06-14 |
| 0039 | [Same-provider signed-reasoning replay — a behavioral amendment to ADR-0030](0039-same-provider-reasoning-replay.md) | Accepted | 2026-06-14 |
| 0040 | [Node-level retry budget above the provider fallback chain (1.S) — amends ADR-0038](0040-node-retry-budget-above-the-chain.md) | Accepted | 2026-06-15 |
+| 0041 | [External action-governance seam — the optional, host-injected `ActionGuard` over side-effecting tool actions](0041-external-action-governance-seam.md) | Proposed | 2026-06-17 |
## Creating a new ADR
diff --git a/docs/reference/shared-core/README.md b/docs/reference/shared-core/README.md
index aae0af4c..34da8e94 100644
--- a/docs/reference/shared-core/README.md
+++ b/docs/reference/shared-core/README.md
@@ -21,3 +21,4 @@ Part of [reference/](../README.md).
| [built-in-tools.md](built-in-tools.md) | Built-in tools available to local agents (read_file, run_command, web_search, git, …). |
| [tool-registry.md](tool-registry.md) | The engine-side `ToolRegistry` + dispatch and the host-injected `ToolHost` capability seam — policy/mechanism split, dispatch lifecycle, guardrail enforcement, result bounding, untrusted-data taint (1.T / [ADR-0037](../../decisions/0037-engine-tool-execution-boundary.md)). |
| [mcp-integration.md](mcp-integration.md) | MCP: agents as MCP servers, and agents consuming MCP tools. |
+| [action-guard-seam.md](action-guard-seam.md) | **Draft** — the optional, host-injected `ActionGuard` seam an external action-governance control plane plugs into at the side-effecting tool boundary: decide/commit/compensate, verdict union, off-by-default ([ADR-0041](../../decisions/0041-external-action-governance-seam.md), Proposed). |
diff --git a/docs/reference/shared-core/action-guard-seam.md b/docs/reference/shared-core/action-guard-seam.md
new file mode 100644
index 00000000..89f91c56
--- /dev/null
+++ b/docs/reference/shared-core/action-guard-seam.md
@@ -0,0 +1,281 @@
+# The `ActionGuard` Seam — external action-governance
+
+- **Status**: Draft ([ADR-0041](../../decisions/0041-external-action-governance-seam.md) is Proposed; this reference lands only if/when 0041 is Accepted)
+- **Canonical home**: the contract for the **optional, host-injected `ActionGuard`** that an external action-governance control plane (reference implementation: **Provna**) plugs into at the side-effecting tool boundary — the verdict union, the `ActionIntent` payload, the decide/commit/compensate lifecycle, and the engine-opaque receipt/compensation handles
+- **Related**: [../../decisions/0041-external-action-governance-seam.md](../../decisions/0041-external-action-governance-seam.md) (the decision), [tool-registry.md](tool-registry.md) (the `ToolHost` seam + the dispatch lifecycle this composes into), [../../decisions/0037-engine-tool-execution-boundary.md](../../decisions/0037-engine-tool-execution-boundary.md) (the policy/mechanism split), [../../decisions/0029-tool-policy-hardening.md](../../decisions/0029-tool-policy-hardening.md) (the always-on guardrails that run first), [../../decisions/0036-run-loop-substrate-event-bus-and-execution-host.md](../../decisions/0036-run-loop-substrate-event-bus-and-execution-host.md) (the durable suspend/resume the approval verdict reuses), [../../decisions/0039-same-provider-reasoning-replay.md](../../decisions/0039-same-provider-reasoning-replay.md) (the side-effect journaling precedent), [../contracts/sse-event-schema.md](../contracts/sse-event-schema.md#error-code-taxonomy) (the `tool_denied` code an `ActionDecision` of kind `block` maps to), [../../standards/security-review.md](../../standards/security-review.md#prompt-injection-posture) (the untrusted-data taint this consumes), [../../product-constraints.md](../../product-constraints.md) (the local-first, no-cloud-dependency constraint the off-by-default rule preserves)
+
+This page is the **one canonical home** for the `ActionGuard` *contract* — the typed port the engine consults, what the engine passes it, the verdict it returns, and how the registry composes it into the [tool-registry.md](tool-registry.md) dispatch lifecycle. The *why* (the integration boundary for an external governor, the constraints it must not break) lives in [ADR-0041](../../decisions/0041-external-action-governance-seam.md). This file is the dry reference its consumers (the 1.T registry, each surface's host wiring, the external governor's adapter) bind to. Where any other doc names an `ActionGuard` rule it links here and never restates it.
+
+> **The two rules that shape everything here.** **(1) Off by default.** The `ActionGuard` is an **optional** host capability; absent (the default on every surface), the registry calls the `ToolHost` mechanism **directly** — no external call, behavior byte-identical to today, the [product-constraints.md](../../product-constraints.md) local-first / zero-egress guarantee untouched. It is an enterprise opt-in, the [ADR-0012](../../decisions/0012-managed-inference-dual-mode.md) posture applied to governance. **(2) Engine owns the seam, host owns the mechanism.** `@relavium/core` defines the `ActionGuard` *interface* and the *invocation point* (pure); calling the external governor is network/process I/O, so the implementation is **host-injected** — the same purity seam as the `ToolHost` ([ADR-0037](../../decisions/0037-engine-tool-execution-boundary.md)) and the Rust egress ([ADR-0018](../../decisions/0018-desktop-execution-and-rust-egress.md)). No external-governor SDK type crosses the seam (the `LLMProvider` discipline). The TypeScript shapes below are the canonical interface the implementation mirrors.
+
+## The `ActionGuard` seam
+
+A two-phase **decide → commit** protocol, plus an out-of-band **compensate**. The engine *decides* first (so a `require-approval` can durably suspend between the two phases via the existing human gate, and a `block` never touches the host); the guard *commits* second (so it owns running the side effect through a registry-supplied thunk, binding idempotency, recording compensation, and emitting its tamper-evident audit record). The engine never sees the governor's internals — compensation and audit are **engine-opaque handles**, exactly as a `credentialRef` is an opaque secret reference ([tool-registry.md](tool-registry.md)).
+
+```ts
+/**
+ * Optional capability on the host. Governs side-effecting tools AND ALL egress — a `policy.spawnsProcess`,
+ * any `policy.egress` (incl. read-only egress like `web_search`), or an fs WRITE (`policy.fsWrite`, below).
+ * Local read-only tools (fs read, `git_status`, clipboard) and `invoke_agent` (engine delegate, not a host
+ * capability) bypass it.
+ */
+interface ActionGuard {
+ /**
+ * PHASE 1 — decide. Called AFTER the ADR-0029 guardrails pass (the registry only consults the guard
+ * on actions it already permits) and BEFORE any host side effect. Pure-decision: the guard runs its
+ * information-flow check, per-action authorization, and risk classification, and returns a verdict.
+ * MUST NOT perform the side effect here.
+ */
+ decide(intent: ActionIntent, ctx: ActionGuardContext): Promise;
+
+ /**
+ * PHASE 2 — commit. Called only after `decide` returned `allow` (and, for `require-approval`, after the
+ * engine obtained approval via the durable human gate; for `transform`, after the engine re-validated
+ * the narrowed args). The guard OWNS running the effect: it invokes `thunk` (the registry-supplied
+ * thunk that performs the underlying `ToolHost` mechanism) exactly once for a given `plan.idempotencyKey`,
+ * binding idempotency, recording a compensation, and emitting its audit record. Returns the untrusted-
+ * marked result plus the engine-opaque `ActionReceipt` the engine journals for replay.
+ */
+ commit(plan: ActionPlan, thunk: ActionCommit, ctx: ActionGuardContext): Promise;
+
+ /**
+ * OUT-OF-BAND — compensate. Reverse an already-committed action on a later saga failure or post-hoc
+ * policy violation (the engine calls this when a downstream node fails and the run unwinds, or on an
+ * operator/kill-switch request). Idempotent: safe to retry. Optional — a guard with no reversible
+ * actions may omit it (then irreversible actions must have been gated at `decide`).
+ */
+ compensate?(receipt: ActionReceipt, reason: CompensationReason, ctx: ActionGuardContext): Promise;
+}
+
+/** The registry-supplied thunk that performs the underlying side effect (the same `ToolHost` call the
+ * registry would have made directly). The guard wraps — never replaces — it. */
+type ActionCommit = () => Promise>;
+```
+
+## `ActionIntent` — what the engine passes
+
+The engine hands the guard the **effective, validated** action (post-[ADR-0029](../../decisions/0029-tool-policy-hardening.md)) plus the taint markers and identity it already holds. It carries no raw secret: a secret-typed value is a host-resolved `credentialRef`, named in `secretArgKeys`, never an arg value ([tool-registry.md §Guardrail enforcement](tool-registry.md#guardrail-enforcement-policy--engine-pure-mechanism--host)).
+
+```ts
+interface ActionIntent {
+ readonly toolId: ToolId; // tool-registry.md
+ readonly action: ActionClass; // the side-effect kind (below) — read-only never reaches here
+ readonly effectiveArgs: Untrusted; // the COMPLETE validated effective set (step 3); model-derived parts already untrusted-marked
+ readonly secretArgKeys: ReadonlySet; // effective-arg keys carrying a host-resolved credentialRef (NEVER the secret value) — 0029(c)
+ readonly taint: ActionTaint; // integrity/confidentiality labels the guard's IFC consumes (below)
+ readonly principal: ActionPrincipal; // who/what: the agent, the on-behalf-of user, the delegation chain
+ readonly correlation: ActionCorrelation; // the run-or-session origin (discriminated union) — for audit + the idempotency key
+}
+
+/**
+ * The governed side-effecting kinds, derived from `ToolPolicyClass` (tool-registry.md): `spawnsProcess`
+ * → `process`; `egress: 'http' | 'search' | 'mcp'` → `egress-http` | `egress-search` | `egress-mcp`; an
+ * fs WRITE → `fs-write`. The fs-write split needs a discriminator the current `ToolPolicyClass` LACKS —
+ * `fsScoped` is `true` for reads AND writes alike (`read_file` / `write_file` / `list_directory` share it),
+ * so ADR-0041 proposes an additive **`fsWrite?: boolean`** on `ToolPolicyClass` (canonical in tool-registry.md;
+ * lands when 0041 is Accepted). Until it lands, an implementation MUST NOT govern fs reads as `fs-write`.
+ * Local read-only tools (fs read, `git_status`, clipboard) are NOT governed. EGRESS IS governed even when
+ * read-only (e.g. `web_search`): egress is an exfiltration *sink* for the IFC pillar (the query is the
+ * lethal-trifecta channel), so the guard sees every egress for IFC/authz/audit — a read-only egress simply
+ * carries no compensation.
+ */
+type ActionClass = 'process' | 'egress-http' | 'egress-search' | 'egress-mcp' | 'fs-write';
+
+/** The taint the registry already computed (tool-registry.md §Untrusted-data taint / 0029(c) secret taint), surfaced for IFC.
+ * The guard's information-flow decision CONSUMES this and its result stays untrusted-marked (taint handoff, below). */
+interface ActionTaint {
+ readonly untrustedArgKeys: ReadonlySet; // effective-arg keys derived from an untrusted source (a tool result / web / file)
+ readonly hasSecretRefs: boolean; // any secretArgKeys present
+}
+
+interface ActionPrincipal {
+ readonly agentId: string; // the .agent.yaml identity taking the action
+ readonly onBehalfOf?: string; // the human/service the agent acts for (an enterprise host may inject this; absent on a solo BYOK-local run)
+ readonly delegation?: readonly string[]; // the delegation chain (sub-agent hops), if the host supplies one
+}
+
+/**
+ * Correlates the action to its origin — a `WorkflowEngine` run OR an `AgentSession` turn (the two entry
+ * points, [ADR-0024](../../decisions/0024-agent-first-entry-point-agentsession.md)). A discriminated UNION,
+ * not an optional-`runId` record: a session has NO `runId` (ADR-0024 keeps run and session distinct), so
+ * neither entry point fabricates the other's id, and node-retry `attempt` vs session `turn` stay typed to
+ * their own world.
+ */
+type ActionCorrelation =
+ | {
+ readonly kind: 'run';
+ readonly runId: string;
+ readonly nodeId: string;
+ readonly attempt: number; // the node-retry attempt (ADR-0040) — part of replay correlation, NOT the idempotency key
+ }
+ | {
+ readonly kind: 'session';
+ readonly sessionId: string; // [agent-session-spec.md](../contracts/agent-session-spec.md)
+ readonly turn: number; // the session turn — the session-side counterpart of `attempt`
+ };
+```
+
+## `ActionDecision` — the verdict the engine interprets
+
+A discriminated union the registry lowers into its **existing** control flow — no new failure path, suspend mechanism, or validator.
+
+```ts
+type ActionDecision =
+ | { readonly verdict: 'allow'; readonly plan: ActionPlan }
+ | { readonly verdict: 'block'; readonly reason: ToolPolicyDenyReason } // → tool_denied (fatal); stable reason code from tool-registry.md §Error taxonomy
+ | { readonly verdict: 'require-approval'; readonly approval: ApprovalRequest; readonly plan: ActionPlan }
+ | { readonly verdict: 'transform'; readonly narrowedArgs: Untrusted; readonly plan: ActionPlan };
+
+/** Opaque to the engine: the guard's idempotency key + whatever it needs to commit/compensate this action.
+ * The engine journals it (replay) and hands it back to `commit`; it never inspects the internals. */
+interface ActionPlan {
+ readonly idempotencyKey: string; // the guard's SEMANTIC effect key (NOT the request body hash) — replay-stable
+ readonly opaque: OpaqueGuardState; // engine-opaque (Readonly>) — compensation descriptor, audit ref, etc.
+}
+
+/** The approval ask — surfaced through the SAME durable human gate as a `human_in_the_loop` node (ADR-0036). */
+interface ApprovalRequest {
+ readonly summary: string; // human-facing: what is about to happen (the dry-run preview)
+ readonly riskTier: 'low' | 'elevated' | 'high' | 'irreversible';
+ readonly timeoutPolicy?: HumanGateTimeout; // the `human_in_the_loop` gate's timeout shape ([node-types.md](node-types.md)) — reused, not redefined; absent ⇒ host default
+}
+```
+
+`ToolPolicyDenyReason` is the stable, closed reason-code union used by `ToolPolicyError` in [tool-registry.md §Error taxonomy](tool-registry.md#error-taxonomy); reusing it for the `block` verdict keeps the governor denial in the same taxonomy as the engine's own guardrail denials.
+
+Engine interpretation of each verdict:
+
+| Verdict | Engine action (all reuse existing machinery) |
+|---------|----------------------------------------------|
+| `allow` | proceed to **PHASE 2 commit** (`guard.commit(plan, thunk, ctx)`). |
+| `block` | raise `ActionDeniedError` → **`tool_denied`** (fatal, never retried — the [tool-registry.md §Error taxonomy](tool-registry.md#error-taxonomy) class, no new code). |
+| `require-approval` | **suspend** the run on the durable `human_in_the_loop` gate ([ADR-0036](../../decisions/0036-run-loop-substrate-event-bus-and-execution-host.md)); on resume → `approve` proceeds to commit, `reject` raises `ActionDeniedError` → `tool_denied`, timeout follows the gate's `approve`/`reject` fallback (the human-gate timeout — [node-types.md](node-types.md)). **No new suspend/resume mechanism. Run-only in Phase 1 — see Entry-point scope.** |
+| `transform` | **re-validate** `narrowedArgs` against the **same** `tool.parseArgs` (Zod) + [ADR-0029](../../decisions/0029-tool-policy-hardening.md) guardrails; the guard may only **narrow, never widen** (symmetry with node-tools narrow-only, 0029(b)) — a widening attempt fails `ToolArgsInvalidError`. On success → commit. |
+
+## Supporting types
+
+The remaining types the seam references. `ToolId`, `JsonValue`, and `AbortSignalLike` are imported from [tool-registry.md](tool-registry.md) / `@relavium/shared`, not redefined; `Untrusted` is the engine's branded untrusted marker, canonical in [tool-registry.md §Untrusted-data taint](tool-registry.md#untrusted-data-taint-1t-marks-1o-places); `HumanGateTimeout` is the `human_in_the_loop` node's timeout shape, canonical in [node-types.md](node-types.md) (reused, not redefined — rule 8).
+
+```ts
+/** What `commit` returns: the untrusted-marked tool result + the engine-opaque receipt the registry journals. */
+interface GovernedResult {
+ readonly result: Untrusted; // stays untrusted end to end (tool-registry.md §Untrusted-data taint)
+ readonly receipt: ActionReceipt;
+}
+
+/** Engine-OPAQUE proof-of-commit. The registry journals it in `run_events` (keyed by `idempotencyKey`) for
+ * replay and hands it back to `compensate`; the engine never inspects `opaque`. */
+interface ActionReceipt {
+ readonly idempotencyKey: string; // the SAME key as `ActionPlan.idempotencyKey` — the replay / dedup key
+ readonly opaque: OpaqueGuardState; // the guard's compensation descriptor, audit ref, commit time, … (engine never reads it)
+}
+
+/** Engine-opaque carrier the engine journals but never interprets — the governor's internals live here.
+ * Must be JSON-serializable because it is persisted in `run_events` for replay/resume. */
+type OpaqueGuardState = Readonly>;
+
+/** JSON-serializable values only — `OpaqueGuardState` is journaled, so non-serializable data must not enter. */
+type JsonValue =
+ | null
+ | boolean
+ | number
+ | string
+ | readonly JsonValue[]
+ | Readonly>;
+
+/** Why the engine is asking for a reverse — the `WorkflowEngine` run loop (ADR-0036) supplies it. */
+type CompensationReason = 'saga_unwind' | 'policy_violation' | 'operator_killswitch';
+
+/** The outcome of a reverse. `irreversible` is the honest "no compensation exists" answer (such an action
+ * should have been gated at `decide` — see Composition rules); `failed` raises `CompensationError`. */
+interface CompensationOutcome {
+ readonly status: 'reversed' | 'not_needed' | 'irreversible' | 'failed';
+ readonly detail?: string; // secret-free human note for the audit / operator
+}
+
+/** The per-call context shared by all three methods — minimal by design (the rich payload is in `ActionIntent`). */
+interface ActionGuardContext {
+ readonly correlation: ActionCorrelation; // the run-or-session origin (discriminated union), so `commit`/`compensate` locate the action
+ readonly approval?: ApprovalResolution; // present on `commit` ONLY when `decide` returned `require-approval` and the gate APPROVED
+ readonly signal?: AbortSignalLike; // cooperative cancel — an abort routes to the ADR-0036 cancel path, never `tool_failed`
+}
+
+/** The approved resolution the durable human gate (ADR-0036) hands forward to `commit`. A `reject` never reaches `commit`. */
+interface ApprovalResolution {
+ readonly approver?: string; // who approved, if the host has an identity to attribute; absent on a solo run
+}
+```
+
+## Where it sits in the dispatch lifecycle
+
+The [tool-registry.md](tool-registry.md#resolution--the-dispatch-lifecycle) pipeline is unchanged through step 4. The `ActionGuard` inserts **between step 4 (guardrails pass) and step 5 (the host side effect)**, for side-effecting tools only, and **only when a guard is injected**:
+
+- **4 → (default, no guard):** step 5 calls the `ToolHost` capability directly — today's behavior, no external call.
+- **4 → (guard injected, side-effecting tool):**
+ - **4a. decide.** `guard.decide(intent, ctx)` → `ActionDecision`.
+ - **4b. interpret** the verdict (table above): `block` → `tool_denied`; `require-approval` → durable suspend (ADR-0036), resume re-enters at 4c; `transform` → re-validate (back through steps 3–4 on the narrowed args), then 4c; `allow` → 4c.
+ - **4c. commit.** `guard.commit(plan, thunk, ctx)`, where `thunk` is the **exact** step-5 host call. The guard runs it once per `plan.idempotencyKey`, returns `GovernedResult { result: Untrusted<...>, receipt }`.
+- **The rest of the [tool-registry.md](tool-registry.md#resolution--the-dispatch-lifecycle) pipeline proceeds unchanged** — `output_mapping` on the full result, model-facing result bounding, the untrusted-mark, and bus emission — *plus* the registry **journals the `ActionReceipt`** as a side effect in `run_events` (see Determinism, below).
+
+Read-only tools, `invoke_agent`, and any dispatch with no injected guard **skip 4a–4c entirely**.
+
+## Composition rules
+
+| Rule | How it holds |
+|------|--------------|
+| **Off by default** | no guard ⇒ no `decide`/`commit`, direct `ToolHost` call, zero external egress — the local-first guarantee ([product-constraints.md](../../product-constraints.md)). |
+| **Governed classes (side-effecting + egress)** | the registry consults the guard only for `policy.spawnsProcess`, any `policy.egress` (incl. read-only `web_search`), or an fs write (`policy.fsWrite` — the additive flag ADR-0041 proposes, see `ActionClass`); local read-only tools and `invoke_agent` bypass it. |
+| **Composes after, never replaces** | [ADR-0029](../../decisions/0029-tool-policy-hardening.md) runs first (steps 1–4); a not-granted / disallowed / SSRF-blocked call is already `tool_denied` **before** `decide`. The guard can only further restrict (`block` / `require-approval`) or narrow (`transform`) — never re-grant. |
+| **Narrow-only transform** | a `transform` verdict's `narrowedArgs` is re-validated through the same Zod + 0029 path; widening fails (0029(b) symmetry). |
+| **Taint handoff** | the guard consumes `intent.taint` (the registry's untrusted/secret markers) for its IFC decision and returns an `Untrusted<...>` result — the unsafe-path-unrepresentable boundary ([tool-registry.md §Untrusted-data taint](tool-registry.md#untrusted-data-taint-1t-marks-1o-places)) holds end to end. |
+| **No raw secret crosses the seam** | `secretArgKeys` names credential-bearing keys; the value stays a host-resolved `credentialRef` (0029(c)) — the guard governs *which* credential ref, never *the secret*. |
+| **Vendor-neutral** | the interface names no governor; Provna is one implementation. No governor SDK type crosses the seam. |
+| **Irreversible ⇒ gated at `decide`** | the engine has no reverse of its own to fall back on, so for `riskTier: 'irreversible'` (or any action whose guard implements no `compensate`) `decide` MUST return `require-approval` or `block`, never `allow`. The seam states the contract; the **guard implementation** enforces it (the engine cannot). |
+
+## Determinism, idempotency & replay
+
+Side effects must survive the derived `Checkpointer` ([ADR-0003](../../decisions/0003-pure-ts-engine-not-langgraph-python.md)) + cross-process `resumeFromCheckpoint` ([ADR-0036](../../decisions/0036-run-loop-substrate-event-bus-and-execution-host.md)) **without re-executing**, exactly as an LLM call does ([ADR-0039](../../decisions/0039-same-provider-reasoning-replay.md)):
+
+- The registry **journals the `ActionReceipt`** (and the verdict) as a side-effect record in `run_events`, keyed by `plan.idempotencyKey`; for a `require-approval`, the verdict's `ActionPlan` is journaled at decide-time so the suspend/resume carries it across the checkpoint. On resume, a present receipt is **re-delivered, not re-committed** — a resumed run never double-posts a payment / re-spawns a process.
+- The `idempotencyKey` is the guard's **semantic effect key** (a function of principal + resource + normalized intent), *not* the request-body hash and *not* the run-retry `attempt` (nor the session `turn`) — so a node-retry ([ADR-0040](../../decisions/0040-node-retry-budget-above-the-chain.md)) of the *same* logical action reuses the key and the guard de-duplicates, while a genuinely new action gets a new key.
+- For a **`WorkflowEngine` run**, the run loop ([ADR-0036](../../decisions/0036-run-loop-substrate-event-bus-and-execution-host.md)) is what calls `compensate` — when a downstream node fails and the run unwinds, or on an operator / kill-switch via the cancel flow ([ADR-0036](../../decisions/0036-run-loop-substrate-event-bus-and-execution-host.md)). (For `AgentSession` turns, automatic compensation is **out of scope in Phase 1** — see Entry-point scope.) `compensate` is itself idempotent and journaled, so an unwind that is interrupted and resumed does not double-reverse.
+
+## Entry-point scope — run vs session (Phase 1)
+
+Two of the seam's behaviors lean on `WorkflowEngine` machinery an `AgentSession` ([ADR-0024](../../decisions/0024-agent-first-entry-point-agentsession.md)) does not (yet) have — a node-level run loop, the `human_in_the_loop` gate, and (until 1.X/1.Y) durable session persistence:
+
+- **`require-approval` is run-only in Phase 1.** It reuses the durable `human_in_the_loop` gate, a `WorkflowEngine` node mechanism. A guard governing an `AgentSession` turn MUST resolve to `allow` / `block` / `transform` (synchronous) and MUST NOT return `require-approval` — or the host must surface an interactive approval out-of-band *before* calling `commit`. (Durable session approval lands with session persistence, 1.X/1.Y.)
+- **Automatic `compensate` (saga unwind) is run-only in Phase 1.** A session has no run-loop unwinder, so a session turn gets `decide` / `commit` / per-action audit / idempotency, but **no automatic compensation**. Its irreversible / high-risk actions must therefore be **gated at `decide`** (which, given the run-only `require-approval` rule above, means `block` for a session — or an out-of-band host approval); any reversal of a committed session action is operator-triggered, not automatic.
+
+Both are deliberate Phase-1 scope, not seam limitations: the **run** entry point (the FS-back-office target) gets the full guarantee today; the **session** entry point gets IFC + authz + audit + idempotency now, and the transactional + approval guarantees when the session unwinder + persistence land.
+
+## Error taxonomy (additions to [tool-registry.md](tool-registry.md#error-taxonomy))
+
+All typed, discriminant-narrowed, **secret-free** ([error-handling.md](../../standards/error-handling.md)):
+
+| Error | When | Run `ErrorCode` | Class |
+|-------|------|-----------------|-------|
+| `ActionDeniedError` | `decide` returned `block`, or `require-approval` resolved to `reject`/timeout-reject | `tool_denied` | **fatal** (never retried — a denied action re-asked just burns budget; same class as `ToolPolicyError`) |
+| `ActionGuardError` | `guard.decide` threw, or the guard's control plane was unreachable in the **decision** phase — the verdict is **indeterminate**, so the registry **fails closed** (the action does NOT execute). Safe to retry — nothing committed. | `tool_failed` | retryable (fail-closed; node budget) |
+| `ActionCommitError` | `guard.commit` threw a non-cancel error (the underlying side effect or the governor failed) | `tool_failed` *(default)* | retryable **by default** — the idempotency key makes the retry *safe*. But a retry is not always *correct* (an upstream 4xx / unauthorized won't fix on retry), so the **guard MAY throw a classified error** the registry maps to `tool_denied` / `validation` (fatal) instead; absent a classification, the default is retryable. |
+| `CompensationError` | `guard.compensate` failed to reverse a committed action | `internal` | fatal — surfaced for operator escalation; the audit shows the system *attempted* the unwind |
+
+An **undefined** `actionGuard` is the **default-off** path (no `decide`/`commit`, direct `ToolHost` call) — **not** an error. `ActionGuardError` is the *opposite* case: a guard IS injected but cannot render a decision (its control plane errored / timed out), so the registry fails closed rather than letting an ungoverned action through.
+
+## Instantiation
+
+```ts
+// Engine-pure: the registry takes the SAME shape as today, plus an OPTIONAL guard. Omit it ⇒ unchanged behavior.
+const registry = createToolRegistry({
+ tools: BUILTIN_TOOLS, // tool-registry.md
+ host: toolHost, // the surface's ToolHost (Node fs/process/fetch; desktop Rust commands)
+ actionGuard, // OPTIONAL — undefined on the local-first default; an enterprise host injects one
+});
+// Dispatch is unchanged for read-only tools and when actionGuard is undefined; for a side-effecting tool
+// with a guard present, the registry runs decide → (interpret) → commit around the step-5 host call.
+const result = await registry.dispatch(toolCall, ctx);
+```
+
+`createToolRegistry` still performs **no** I/O and reads **no** ambient state; a stub `ActionGuard` (like the stub `ToolHost`) keeps the whole registry unit-testable with zero real side effects. Adding a guard never widens what a node may call — `ctx.grantedToolIds` + [ADR-0029](../../decisions/0029-tool-policy-hardening.md) still gate the call first; the guard can only restrict further.
+
+> **Engine-opaque by design.** `OpaqueGuardState`, `ActionReceipt`, and the compensation/audit internals are `Readonly>` the engine carries and journals but never interprets — the governor's IFC engine, compensation library, per-action authorization, and tamper-evident audit live entirely on the **external** side of the seam (see [ADR-0041](../../decisions/0041-external-action-governance-seam.md)). Relavium owns the boundary; the governor owns the guarantees.
diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md
index 51ca8a75..7d468293 100644
--- a/docs/roadmap/current.md
+++ b/docs/roadmap/current.md
@@ -169,8 +169,9 @@ emission via an injected `SessionEventSink`) **then landed together — ✅ Done
milestone)**, now unblocked; in parallel, **Lane C** (the 1.m5 sub-spine) continued from 1.V through **1.W**
(the `SessionEventSink`→`RunEventBus` wiring + per-session `sequenceNumber`/`SessionHandle` — ✅ Done, PR #28)
and **1.X** (session persistence — the `agent_sessions`/`session_messages` tables + `SessionMessageSchema` +
-`SessionStore`, data-layer only — ✅ Done, PR #29), leaving **1.Y/1.Z/1.AA** next on Lane C, with cost-event
-persistence still a tracked deferral.
+`SessionStore`, data-layer only — ✅ Done, PR #29); then **session checkpoint/resume (1.Y) +
+export-to-workflow (1.Z) landed together — ✅ Done (PR #30, 2026-06-17)**. The **1.AA** chat-regression
+harness is now ✅ Done (2026-06-17), completing **1.m5**; cost-event persistence remains a tracked deferral.
> **Multimodal I/O — the shape is landed (1.AD ✅ Done, PR #11, 2026-06-10).** First-class
> image/audio/video I/O (input **and** output, incl. generate-media-by-rule) was decided on 2026-06-08:
@@ -208,10 +209,12 @@ persistence still a tracked deferral.
> `sequenceNumber`, `next`/`emit` overloads keeping the engine run-precise), the `SessionHandle`, and the
> combined `RunOrSessionEventSchema` gate — and **session persistence (1.X) is ✅ Done (PR #29, 2026-06-17)** —
> the `agent_sessions`/`session_messages` tables + migration, `SessionMessageSchema`/`AgentSessionSchema`, and
-> the `SessionStore` + domain↔row mappers (data-layer only; engine wiring is 1.Y/1.AA). The remaining Phase-1
-> work is **additive and off the critical path**: Lane C (1.Y/1.Z/1.AA) and the 1.m6 multimodal sub-spine
-> (1.AE–1.AH). **Phase 2
-> (CLI, milestone M3) is unblocked.**
+> the `SessionStore` + domain↔row mappers (data-layer only); then **session checkpoint/resume (1.Y) +
+> export-to-workflow (1.Z) are ✅ Done (PR #30, 2026-06-17)** — `reconstructSessionState`/`AgentSession.resume`
+> + the `serializeWorkflow`/`sessionToWorkflow` pair (the per-turn `AgentSession`→store wiring stays with 1.AA).
+> **1.AA is now ✅ Done (2026-06-17)**, completing **1.m5**. The remaining Phase-1 work is **additive and
+> off the critical path**: only the 1.m6 multimodal sub-spine (1.AE–1.AH) remains before Phase 1 closes.
+> **Phase 2 (CLI, milestone M3) is unblocked.**
Carry-over hardening is tracked in [deferred-tasks.md](deferred-tasks.md) — pick items up as Phase 1
first touches each file.
diff --git a/docs/roadmap/phases/phase-1-engine-and-llm.md b/docs/roadmap/phases/phase-1-engine-and-llm.md
index 68b16734..17c29be9 100644
--- a/docs/roadmap/phases/phase-1-engine-and-llm.md
+++ b/docs/roadmap/phases/phase-1-engine-and-llm.md
@@ -35,7 +35,8 @@
> runs end-to-end** (live streaming + per-node-boundary checkpointing + cross-process resume + node retry +
> provider failover, per-attempt cost, gap-free `sequenceNumber`), **completing the Phase-1 critical path**. The
> remaining Phase-1 work is **additive and off the critical path**: Lane C (the 1.m5 agent-first sub-spine —
-> 1.W session events ‖ 1.X persistence ‖ 1.Y/1.Z/1.AA) and the 1.m6 multimodal sub-spine (1.AE–1.AH). **Phase 2
+> 1.W/1.X/1.Y/1.Z all ✅ Done, plus the 1.AA chat-regression harness ✅) is **complete**, leaving only the 1.m6
+> multimodal sub-spine (1.AE–1.AH). **Phase 2
> (CLI, milestone M3) is unblocked.** *(Session persistence, 1.X/1.Z, must exclude the reasoning signature —
> non-persisting.)*
>
@@ -877,9 +878,11 @@ These build the `AgentSession` entry point ([ADR-0024](../../decisions/0024-agen
- *Landed mechanism (PR #28):* the combined **`RunOrSessionEventSchema`** gate (`@relavium/shared`); `next`/`emit` **overloads** (run draft → `RunEvent`, session draft → `SessionEvent`, either → `RunOrSessionEvent`) keep the engine **run-precise** rather than widening `RunEventDraft`; `createSessionEventSink` attaches the `sessionId` — defensively dropping the run-only `agent:file_patch_proposed` at the seam — and the bus stamps the per-session `sequenceNumber`; `createSessionHandle` mirrors `createRunHandle` (terminal **only** on `session:cancelled`) over the shared `BoundedEventStream` (extracted from `RunHandle`, with an `onClose` hook that unsubscribes on early consumer abandon); `correlationKey` enforces the runId-XOR-sessionId invariant, and the bus/stream "can never happen" asserts are the typed `RunLoopInvariantError`. See [sse-event-schema.md](../../reference/contracts/sse-event-schema.md) §"The session stream".
- **1.X — Session persistence — ✅ Done (PR #29, 2026-06-17).** `agent_sessions` + `session_messages` via `@relavium/db` into `history.db` ([database-schema.md](../../reference/desktop/database-schema.md)). **Authors the durable `SessionMessageSchema`** in `@relavium/shared` (deferred from 1.V, which runs on the in-flight `LlmMessage` form): `{ id, sessionId, sequenceNumber, role, content: DurableContentPart[], modelId?, timestamp }` — the persisted transcript type these tables store. *Acceptance:* a session round-trips to the DB and resumes. **Note:** adding these two tables requires a regenerated Drizzle migration snapshot (the schema-migration drift CI gate). **ADR-0030 ephemerality:** a `reasoning` part's `signature`/`redacted` continuity token must **not** be persisted to `session_messages` — strip it (keep reasoning *text* if a transcript needs it, drop the opaque signature). *Acceptance also asserts:* a round-tripped session row carries no reasoning `signature`.
- *Landed mechanism (PR #29):* **data-layer only** (no engine change — `packages/core` never imports `@relavium/db`; the per-turn `AgentSession`→store wiring + cross-restart resume are 1.Y/1.AA). `@relavium/shared` gained `session.ts` (`SessionMessageSchema` / `AgentSessionSchema` (inferred `AgentSessionRecord`, to avoid clashing with the `AgentSession` engine class) / `SessionStatusSchema`), reusing the shared `DurableContentPart` so a reasoning `signature` + inline media are **structurally impossible** to persist; `modelId` is a `model_catalog` id reference (host-resolved). `@relavium/db` added the two tables (migration `0001_pale_scorpion.sql` + snapshot + journal; the drift gate re-generates clean; a 0001 fidelity snapshot test pins the DDL), the `SessionStore`, and the pure domain↔row mappers — the **single validation boundary** (parse on write AND read); `content_parts` is the canonical body (the scalar columns are optional denormalized metadata), and `updateSession` freezes `created_at`. Verified by a multi-dimensional adversarial review (8 dimensions); all confirmed findings folded in.
-- **1.Y — Session checkpoint/resume.** Reuse the idempotency-key logic so a session resumes after a restart.
-- **1.Z — Export-to-workflow serializer.** Session → `.relavium.yaml` **linear-chain scaffold + transcript** ([ADR-0026](../../decisions/0026-session-export-to-workflow.md)). Includes a **`WorkflowDefinition` → YAML emitter** (deterministic key ordering, the `metadata` transcript block, secret exclusion) — 1.L is parse-only, so this workstream owns serialization. *Acceptance:* an exported session parses as a valid workflow whose agent nodes mirror the turns; **parse → serialize round-trips** (including `metadata`); no `secret` value is serialized; and **no reasoning `signature` is serialized** (ADR-0030 ephemerality — the signature is a transient same-provider token, never written to a committable artifact, same exclusion as `secret`).
-- **1.AA — Node-harness chat regression.** The session counterpart of 1.U: a multi-turn chat with a tool call and an export, run green in CI.
+- **1.Y — Session checkpoint/resume.** ✅ — **Done (PR #30, 2026-06-17).** Reuse the idempotency-key logic so a session resumes after a restart.
+ - *Landed (PR #30):* `reconstructSessionState` (reload-not-replay per [ADR-0003](../../decisions/0003-pure-ts-engine-not-langgraph-python.md) — sessions are directly stored, not event-sourced) projects the persisted transcript to the **text-only** in-flight `LlmMessage[]` and **rolls back an incomplete trailing turn** (the `sessionId+sequenceNumber` analog of re-running the run-side incomplete node); `AgentSession.resume` is a static factory that preloads the transcript + re-seeds `turnCount`/cost (syncing a host-wired budget governor so the first resumed turn's pre-egress check sees the carried spend) and lands at `idle` **without** re-emitting `session:started`. Core-only (the host loads via the `@relavium/db` `SessionStore`). Verified by an 8-dimension adversarial review; all findings folded.
+- **1.Z — Export-to-workflow serializer.** ✅ — **Done (PR #30, 2026-06-17).** Session → `.relavium.yaml` **linear-chain scaffold + transcript** ([ADR-0026](../../decisions/0026-session-export-to-workflow.md)). Includes a **`WorkflowDefinition` → YAML emitter** (deterministic key ordering, the `metadata` transcript block, secret exclusion) — 1.L is parse-only, so this workstream owns serialization. *Acceptance:* an exported session parses as a valid workflow whose agent nodes mirror the turns; **parse → serialize round-trips** (including `metadata`); no `secret` value is serialized; and **no reasoning `signature` is serialized** (ADR-0030 ephemerality — the signature is a transient same-provider token, never written to a committable artifact, same exclusion as `secret`).
+ - *Landed (PR #30):* `packages/core/src/export/` — `serializeWorkflow` (`yaml.stringify` with sorted keys → byte-stable round-trip) + `sessionToWorkflow` (one `agent` node per **completed logical turn**, `input → turn-n → output`, the bound agent inline, full transcript under `metadata.relaviumExport`). Both pure + platform-free. Interpolation openers in copied chat text are **neutralized** so a user's literal `{{ secrets.X }}` can't trip the parse-time taint gate; secret/signature exclusion is structural (`DurableContentPart`). The mapping contract is owned by [agent-session-spec.md](../../reference/contracts/agent-session-spec.md) §"Export to workflow" §"Precise mapping". Verified by an 8-dimension adversarial review; all findings folded.
+- **1.AA — Node-harness chat regression.** ✅ — **Done (2026-06-17).** The session counterpart of 1.U: a multi-turn chat with a tool call and an export, run green in CI via `packages/core/src/engine/m5-chat-harness.e2e.test.ts`.
### 1.AB — Expression sandbox (QuickJS-wasm) — *critical path*, folds into 1.P · ✅ **Done (PR #16, 2026-06-13)**
@@ -991,7 +994,7 @@ the latter being the critical-path milestone for the whole product.
| 1.m3 ✅ | Shared-schema reconciliation + interpolation engine, parse → DAG → run loop emits the canonical event stream (**all components landed — 1.N closed it, PR #17, 2026-06-13**) | **1.L.0**, 1.L, **1.L2**, 1.M, 1.N |
| 1.m4 ✅ | Agent + non-agent node handlers, gate, checkpoint/resume, retry, tools, **expression sandbox** + pre-egress budget (**all components landed — 1.AC closed it, PR #26, 2026-06-16**) | 1.O, 1.P, 1.Q, 1.R, 1.S, 1.T, **1.AB**, **1.AC** |
| **M2 ✅** | **Engine end-to-end from a Node harness (stream + checkpoint + retry + fallback) — CRITICAL-PATH MILESTONE** (**reached — 1.U landed, PR #27, 2026-06-16**) | **1.U** |
-| 1.m5 | Agent-first sub-spine: `AgentSession` + session events + persistence + checkpoint/resume + export, proven by its own harness (**additive, parallel — does NOT gate M2**) | 1.V, 1.W, 1.X, 1.Y, 1.Z, 1.AA |
+| **1.m5 ✅** | Agent-first sub-spine: `AgentSession` + session events + persistence + checkpoint/resume + export, proven by its own harness (**additive, parallel — does NOT gate M2**) | 1.V, 1.W, 1.X, 1.Y, 1.Z, **1.AA** |
| 1.m6 | Multimodal I/O: seam amendment (**1.AD ✅ Done, PR #11 — landed before 1.K/1.O so the union members are non-breaking**), then media input/engine/output behavior (**additive — does NOT gate M2**) + surfaces threaded into Phases 2–6 ([ADR-0031](../../decisions/0031-llm-seam-shape-amendment-multimodal-io.md)/[0032](../../decisions/0032-desktop-rust-media-de-inline-amends-0018.md)) | **1.AD ✅**, 1.AE, 1.AF, 1.AG, 1.AH |
## Sequencing & parallelization
@@ -1145,12 +1148,12 @@ flowchart LR
| 1.Q | B | 1.P, 1.R | 1.AC, 1.U | ✅ — **Done (PR #22)** |
| 1.AC | B | 1.O, 1.Q | 1.U | ✅ folds into 1.O — **Done (PR #26)** |
| 1.U | B | 1.P, 1.S, 1.Q, 1.R, 1.T, 1.AC | **M2** | ✅ — **Done (PR #27) — M2 reached** |
-| 1.V | C | 1.O | 1.W, 1.X, 1.Z | ◇ — **Done (PR #26)** |
+| 1.V | C | 1.O | 1.W, 1.X, 1.Z | ✅ — **Done (PR #26)** |
| 1.W | C | 1.V, 1.N, 1.L.0 | 1.AA | ✅ — **Done (PR #28, 2026-06-17)** |
| 1.X | C | 1.V, `@relavium/db` (new migration) | 1.Y, 1.AA | ✅ — **Done (PR #29, 2026-06-17)** |
-| 1.Y | C | 1.X, 1.R | 1.AA | ◇ |
-| 1.Z | C | 1.V, 1.L | 1.AA | ◇ |
-| 1.AA | C | 1.V, 1.W, 1.X, 1.Y, 1.Z | **1.m5** | ◇ |
+| 1.Y | C | 1.X, 1.R | 1.AA | ✅ — **Done (PR #30, 2026-06-17)** |
+| 1.Z | C | 1.V, 1.L | 1.AA | ✅ — **Done (PR #30, 2026-06-17)** |
+| **1.AA** | C | 1.V, 1.W, 1.X, 1.Y, 1.Z | **1.m5** | ✅ — **Done (2026-06-17)** |
| 1.AD | D | 1.A (seam types) | **must precede 1.K, 1.O** (non-breaking union members); 1.AE | ⬤ shape-only — ✅ **Done (PR #11)** |
| 1.AE | D | 1.AD, 1.G/1.H (adapters) | 1.AF | ◇ |
| 1.AF | D | 1.AE, 1.K, 1.N, 1.R | 1.AG | ◇ |
diff --git a/packages/core/src/engine/m5-chat-harness.e2e.test.ts b/packages/core/src/engine/m5-chat-harness.e2e.test.ts
new file mode 100644
index 00000000..9fc5ed13
--- /dev/null
+++ b/packages/core/src/engine/m5-chat-harness.e2e.test.ts
@@ -0,0 +1,472 @@
+/**
+ * 1.AA — Node-harness chat regression (1.m5). The session counterpart of 1.U: proves the
+ * agent-first sub-spine end-to-end before any surface exists. Composes 1.V (AgentSession) /
+ * 1.W (session:* event namespace + SessionHandle) / 1.Y (resume) / 1.Z (export-to-workflow)
+ * behind the @relavium/llm seam and the shared RunEventBus, using only already-exported
+ * @relavium/core symbols — zero platform imports, no live network/keys, deterministic.
+ *
+ * Members:
+ * • multi-turn chat — two turns, the first with an echo tool round-trip; events validate
+ * against the canonical RunOrSessionEventSchema and sequenceNumbers are gap-free.
+ * • export — the same transcript maps to a linear-chain workflow scaffold that parses and
+ * round-trips byte-stably.
+ * • resume — a persisted transcript reconstructs into a resumed session that continues the
+ * conversation, carrying prior context into the next provider call.
+ * • determinism — the same scenario produces an identical event signature on re-run.
+ */
+
+import type { CapabilityFlags, LlmProvider, ProviderId, StreamChunk } from '@relavium/llm';
+import {
+ AgentSchema,
+ RunOrSessionEventSchema,
+ SessionContextSchema,
+ type Agent,
+ type AgentSessionRecord,
+ type RunOrSessionEvent,
+ type SessionContext,
+ type SessionMessage,
+} from '@relavium/shared';
+import { describe, expect, it } from 'vitest';
+
+import { AgentSession, type SessionDeps, type SessionStreamEvent } from './agent-session.js';
+import { RunEventBus } from './event-bus.js';
+import { createAbortController } from './execution-host.js';
+import {
+ createSessionEventSink,
+ createSessionHandle,
+ type SessionStreamHandleEvent,
+} from './session-handle.js';
+import { parseWorkflow } from '../parser.js';
+import { serializeWorkflow, sessionToWorkflow } from '../export/serializer.js';
+import { reconstructSessionState, type SessionResumeState } from './session-resume.js';
+import type { ToolDef as CoreToolDef, ToolRegistry, ToolResultPart } from '../tools/types.js';
+import { markUntrusted } from '../tools/untrusted.js';
+
+// --- LLM-provider stubs (mirror agent-session.test.ts) -----------------------------------------------
+
+const CAPS: CapabilityFlags = {
+ tools: true,
+ streaming: true,
+ parallelToolCalls: true,
+ vision: false,
+ promptCache: false,
+ reasoning: true,
+ media: {
+ input: { image: false, audio: false, video: false, document: false },
+ outputCombinations: [],
+ },
+};
+
+async function* streamOf(chunks: readonly StreamChunk[]): AsyncGenerator {
+ await Promise.resolve();
+ for (const c of chunks) yield c;
+}
+
+/** A provider that replays a different chunk list per stream() call (call N → scripts[N]). */
+function scriptedProvider(scripts: StreamChunk[][], id: ProviderId = 'anthropic'): LlmProvider {
+ let call = 0;
+ return {
+ id,
+ supports: CAPS,
+ generate: () => {
+ throw new Error('generate not used in the harness');
+ },
+ stream: () => {
+ // Fail fast on an UNSCRIPTED call — an unintended extra LLM invocation is a harness bug, not a
+ // silent empty turn (which would mask, e.g., a retry/failover that re-dispatched more than expected).
+ const chunks = scripts[call];
+ call += 1;
+ if (chunks === undefined) {
+ throw new Error(
+ `scriptedProvider: unexpected stream call #${call} (only ${scripts.length} scripted)`,
+ );
+ }
+ return streamOf(chunks);
+ },
+ };
+}
+
+const usage = { inputTokens: 10, outputTokens: 5 };
+const STOP = (reason: 'stop' | 'tool_use' = 'stop'): StreamChunk => ({
+ type: 'stop',
+ stopReason: reason,
+ usage,
+});
+const textTurn = (text: string): StreamChunk[] => [{ type: 'text_delta', text }, STOP('stop')];
+const toolUseTurn = (id: string): StreamChunk[] => [
+ { type: 'tool_call_start', id, name: 'echo' },
+ { type: 'tool_call_end', id },
+ STOP('tool_use'),
+];
+
+// --- Tool stubs: a sanitized echo registry + its LLM-visible def (mirror agent-runner.e2e.test.ts) ----
+
+const echoRegistry: ToolRegistry = {
+ has: (id) => id === 'echo',
+ list: () => ['echo'],
+ dispatch: (call) => {
+ if (call.name !== 'echo') {
+ return Promise.reject(new Error(`unexpected tool call: ${call.name}`));
+ }
+ const result: ToolResultPart = { type: 'tool_result', toolCallId: call.id, result: 'TOOL-OK' };
+ return Promise.resolve({
+ output: 'TOOL-OK',
+ toolResult: markUntrusted(result),
+ truncated: false,
+ events: {
+ call: { toolId: call.name, toolInput: {} },
+ result: { toolId: call.name, success: true, outputSummary: 'TOOL-OK' },
+ },
+ });
+ },
+};
+
+const echoToolDef: CoreToolDef = {
+ id: 'echo',
+ source: 'builtin',
+ description: 'echo',
+ parseArgs: (raw) => raw,
+ llmVisibleParams: { type: 'object' },
+ policy: { fsScoped: false, spawnsProcess: false, requiresGateApproval: false },
+ dispatch: () => Promise.reject(new Error('echoToolDef dispatch is not used directly')),
+};
+
+// --- Session fixture ---------------------------------------------------------------------------------
+
+const AGENT: Agent = AgentSchema.parse({
+ id: 'chatter',
+ model: 'claude-opus-4-8',
+ provider: 'anthropic',
+ system_prompt: 'You are a concise chat agent.',
+ tools: ['echo'],
+});
+
+const CONTEXT: SessionContext = SessionContextSchema.parse({
+ workingDir: '/workspace/chat',
+ fsScopeTier: 'sandboxed',
+});
+
+const TS = '2026-06-17T08:00:00.000Z';
+
+// --- Harness helpers ---------------------------------------------------------------------------------
+
+function createBus(): RunEventBus {
+ let tick = Date.parse('2026-06-17T00:00:00.000Z');
+ return new RunEventBus({ now: () => new Date(tick++).toISOString() });
+}
+
+function buildSession(
+ bus: RunEventBus,
+ scripts: StreamChunk[][],
+ sessionId = 'sess-aa-1',
+): { session: AgentSession; events: SessionStreamEvent[] } {
+ const collected: SessionStreamEvent[] = [];
+ const provider = scriptedProvider(scripts);
+ const sink = createSessionEventSink(bus, sessionId);
+ const deps: SessionDeps = {
+ resolveProvider: () => provider,
+ registry: echoRegistry,
+ tools: [echoToolDef],
+ keyFor: () => 'key',
+ sleep: () => Promise.resolve(),
+ newAbortController: createAbortController,
+ emit: (event) => {
+ collected.push(event);
+ sink(event);
+ },
+ };
+ const session = new AgentSession({
+ sessionId,
+ agentRef: AGENT.id,
+ agent: AGENT,
+ context: CONTEXT,
+ deps,
+ });
+ return { session, events: collected };
+}
+
+async function drainSession(
+ events: AsyncIterable,
+): Promise {
+ const collected: SessionStreamHandleEvent[] = [];
+ for await (const event of events) {
+ collected.push(event);
+ }
+ return collected;
+}
+
+/** Assert every event validates against the canonical RunOrSessionEventSchema. */
+function assertCanonicalSchema(events: readonly RunOrSessionEvent[]): void {
+ for (const event of events) {
+ const parsed = RunOrSessionEventSchema.safeParse(event);
+ if (!parsed.success) {
+ throw new Error(`event ${event.type}#${String(event.sequenceNumber)} is not canonical`);
+ }
+ }
+}
+
+/** Assert sequenceNumbers are exactly 0..n-1 — the bus's gap-free guarantee. */
+function assertGapFreeSeq(events: readonly { sequenceNumber: number }[]): void {
+ const seqs = events.map((e) => e.sequenceNumber).sort((a, b) => a - b);
+ seqs.forEach((seq, index) => expect(seq).toBe(index));
+}
+
+const tokensOf = (events: readonly SessionStreamHandleEvent[]): string[] =>
+ events.flatMap((e) => (e.type === 'agent:token' ? [e.token] : []));
+
+const costsOf = (
+ events: readonly SessionStreamHandleEvent[],
+): Extract[] =>
+ events.filter(
+ (e): e is Extract =>
+ e.type === 'cost:updated',
+ );
+
+// --- The scenarios -----------------------------------------------------------------------------------
+
+describe('1.AA — chat harness (1.m5 agent-first sub-spine)', () => {
+ it('multi-turn chat with a tool call: session events stream through the bus, gap-free + canonical', async () => {
+ const bus = createBus();
+ const handle = createSessionHandle(bus, 'sess-aa-1', () => undefined);
+ const { session } = buildSession(
+ bus,
+ [toolUseTurn('c1'), textTurn('echo received'), textTurn('plain answer')],
+ 'sess-aa-1',
+ );
+
+ session.start();
+ await session.sendMessage('call echo');
+ await session.sendMessage('anything else?');
+ session.cancel();
+
+ const events = await drainSession(handle.events);
+
+ // Lifecycle shape: started, two turn brackets, cancelled terminal.
+ const types = events.map((e) => e.type);
+ expect(types[0]).toBe('session:started');
+ expect(types.at(-1)).toBe('session:cancelled');
+ expect(types.filter((t) => t === 'session:turn_started')).toHaveLength(2);
+ expect(types.filter((t) => t === 'session:turn_completed')).toHaveLength(2);
+
+ // The first turn performed a tool round-trip through the shared turn core.
+ expect(types).toContain('agent:tool_call');
+ expect(types).toContain('agent:tool_result');
+
+ // Live tokens from both turns reached the stream.
+ expect(tokensOf(events)).toEqual(['echo received', 'plain answer']);
+
+ // Per-attempt cost: tool turn has two cost events, plain turn has one; cumulative is monotonic.
+ const costs = costsOf(events);
+ expect(costs.length).toBeGreaterThanOrEqual(3);
+ let running = 0;
+ for (const c of costs) {
+ expect(c.costMicrocents).toBeGreaterThan(0);
+ running += c.costMicrocents;
+ expect(c.cumulativeCostMicrocents).toBe(running);
+ }
+
+ assertGapFreeSeq(events);
+ assertCanonicalSchema(events);
+ });
+
+ it('exports the session transcript to a round-trippable workflow scaffold', () => {
+ const record: AgentSessionRecord = {
+ id: 'sess-aa-1',
+ agentSlug: AGENT.id,
+ agentSnapshot: AGENT,
+ title: 'Chat Harness Export',
+ context: CONTEXT,
+ status: 'idle',
+ totalInputTokens: 25,
+ totalOutputTokens: 15,
+ totalCostMicrocents: 4200,
+ createdAt: TS,
+ updatedAt: TS,
+ };
+ const messages: SessionMessage[] = [
+ {
+ id: 'm-0',
+ sessionId: 'sess-aa-1',
+ sequenceNumber: 0,
+ role: 'user',
+ content: [{ type: 'text', text: 'call echo' }],
+ timestamp: TS,
+ },
+ {
+ id: 'm-1',
+ sessionId: 'sess-aa-1',
+ sequenceNumber: 1,
+ role: 'assistant',
+ content: [
+ { type: 'tool_call', id: 'c1', name: 'echo', args: {} },
+ { type: 'text', text: 'echo received' },
+ ],
+ modelId: 'claude-opus-4-8',
+ timestamp: TS,
+ },
+ {
+ id: 'm-2',
+ sessionId: 'sess-aa-1',
+ sequenceNumber: 2,
+ role: 'user',
+ content: [{ type: 'text', text: 'anything else?' }],
+ timestamp: TS,
+ },
+ {
+ id: 'm-3',
+ sessionId: 'sess-aa-1',
+ sequenceNumber: 3,
+ role: 'assistant',
+ content: [{ type: 'text', text: 'plain answer' }],
+ modelId: 'claude-opus-4-8',
+ timestamp: TS,
+ },
+ ];
+
+ const def = sessionToWorkflow(record, messages);
+ const yaml1 = serializeWorkflow(def);
+ const parsed = parseWorkflow(yaml1);
+ const yaml2 = serializeWorkflow(parsed);
+
+ // Byte-stable round-trip: the export is a valid workflow and re-emits identically.
+ expect(yaml2).toBe(yaml1);
+
+ // Linear chain: input → turn-1 (tool) → turn-2 (text) → output.
+ expect(def.workflow.nodes.map((n) => n.id)).toEqual(['input', 'turn-1', 'turn-2', 'output']);
+ expect(def.workflow.nodes.map((n) => n.type)).toEqual(['input', 'agent', 'agent', 'output']);
+ expect(def.workflow.edges).toEqual([
+ { from: 'input', to: 'turn-1' },
+ { from: 'turn-1', to: 'turn-2' },
+ { from: 'turn-2', to: 'output' },
+ ]);
+
+ const turn1 = def.workflow.nodes[1];
+ expect(turn1?.type === 'agent' && turn1.prompt_template).toBe('call echo');
+ expect(turn1?.type === 'agent' && turn1.tools).toEqual(['echo']);
+
+ const turn2 = def.workflow.nodes[2];
+ expect(turn2?.type === 'agent' && turn2.prompt_template).toBe('anything else?');
+ expect(turn2?.type === 'agent' && turn2.tools).toBeUndefined();
+
+ // Full transcript preserved under metadata.
+ const serialized = JSON.stringify(def.workflow.metadata);
+ expect(serialized).toContain('"source":"session"');
+ expect(serialized).toContain('"sessionId":"sess-aa-1"');
+ expect(serialized).toContain('"sequenceNumber":0');
+ expect(serialized).toContain('"sequenceNumber":3');
+ });
+
+ it('resumes a persisted session and continues the conversation', async () => {
+ const bus = createBus();
+ const seen: { role: string; content: unknown }[][] = [];
+ const capturingProvider: LlmProvider = {
+ id: 'anthropic',
+ supports: CAPS,
+ generate: () => {
+ throw new Error('generate not used in the harness');
+ },
+ stream: (req) => {
+ seen.push(req.messages.map((m) => ({ role: m.role, content: [...m.content] })));
+ return streamOf(textTurn('resumed answer'));
+ },
+ };
+
+ const events: SessionStreamEvent[] = [];
+ const sink = createSessionEventSink(bus, 'sess-aa-resume');
+ const deps: SessionDeps = {
+ resolveProvider: () => capturingProvider,
+ registry: echoRegistry,
+ tools: [echoToolDef],
+ keyFor: () => 'key',
+ sleep: () => Promise.resolve(),
+ newAbortController: createAbortController,
+ emit: (event) => {
+ events.push(event);
+ sink(event);
+ },
+ };
+
+ const state: SessionResumeState = reconstructSessionState(
+ {
+ id: 'sess-aa-resume',
+ agentSlug: AGENT.id,
+ context: CONTEXT,
+ status: 'idle',
+ totalInputTokens: 10,
+ totalOutputTokens: 5,
+ totalCostMicrocents: 2100,
+ createdAt: TS,
+ updatedAt: TS,
+ },
+ [
+ {
+ id: 'm-0',
+ sessionId: 'sess-aa-resume',
+ sequenceNumber: 0,
+ role: 'user',
+ content: [{ type: 'text', text: 'hello' }],
+ timestamp: TS,
+ },
+ {
+ id: 'm-1',
+ sessionId: 'sess-aa-resume',
+ sequenceNumber: 1,
+ role: 'assistant',
+ content: [{ type: 'text', text: 'hi there' }],
+ modelId: 'claude-opus-4-8',
+ timestamp: TS,
+ },
+ ],
+ );
+
+ const resumed = AgentSession.resume(
+ {
+ sessionId: 'sess-aa-resume',
+ agentRef: AGENT.id,
+ agent: AGENT,
+ context: CONTEXT,
+ deps,
+ },
+ state,
+ );
+
+ await resumed.sendMessage('again');
+
+ // The provider saw the prior transcript plus the new user message.
+ expect(seen[0]).toEqual([
+ { role: 'user', content: [{ type: 'text', text: 'hello' }] },
+ { role: 'assistant', content: [{ type: 'text', text: 'hi there' }] },
+ { role: 'user', content: [{ type: 'text', text: 'again' }] },
+ ]);
+
+ // Resume does not re-emit session:started.
+ expect(events.map((e) => e.type)).not.toContain('session:started');
+ expect(events.some((e) => e.type === 'session:turn_completed')).toBe(true);
+ });
+
+ it('determinism: re-running the chat yields an identical event signature', async () => {
+ const runOnce = async (): Promise<{ sig: string; tokens: string[] }> => {
+ const bus = createBus();
+ const handle = createSessionHandle(bus, 'sess-aa-det', () => undefined);
+ const { session } = buildSession(
+ bus,
+ [toolUseTurn('c1'), textTurn('echo received'), textTurn('plain answer')],
+ 'sess-aa-det',
+ );
+ session.start();
+ await session.sendMessage('call echo');
+ await session.sendMessage('anything else?');
+ session.cancel();
+ const events = await drainSession(handle.events);
+ return {
+ sig: events.map((e) => `${String(e.sequenceNumber)}:${e.type}`).join('|'),
+ tokens: tokensOf(events),
+ };
+ };
+
+ const first = await runOnce();
+ const second = await runOnce();
+ expect(second.sig).toBe(first.sig);
+ expect(second.tokens).toEqual(first.tokens);
+ });
+});