From e6fad266a66c0511235dfc11d54e0485e7f78217 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Sun, 5 Jul 2026 22:58:25 +0800
Subject: [PATCH 1/3] docs(rfc): define and enforce a uniform RFC format; adopt
it across the corpus
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Define the in-file RFC contract in docs/rfc/README.md § The file format:
the header block (`# RFC:
` plus a dateless Status enum
cross-checked against the lifecycle folder), the per-lifecycle body
skeleton (a Problem opener everywhere; Proposal/Alternatives considered/
Acceptance criteria/Risks in proposed/; present-tense Decision/
Consequences with proposal-era headings banned in implemented/; the
frozen proposal shape in rejected/), and a mandatory Alternatives
considered section with a date-fenced grandfather comment for pre-format
RFCs whose alternatives are not reconstructible from the record.
Enforce it with a new doc-sync gate, scripts/verify-rfc-format.ts, and
normalize all 112 RFCs to it: ~15 Status-line spellings collapse to the
enum, 29 Context openers become Problem, the 39 legacy-format XXX debt
markers are resolved and banned from reappearing, proposal-era sections
in implemented RFCs are rewritten to shipped reality (including the
web/fs/subagent seam RFCs' migration plans and test checklists, closing
the doc-tiers deferred-work item on the web seam), every RFC gains an
Alternatives considered section or the grandfather comment, and the
bilingual pair is re-mirrored and re-recorded.
Move the generated index tables out of README.md into a fully generated
docs/rfc/INDEX.md — gen-rfc-index now writes the whole file, and
verify-rfc-classification checks its freshness and rejects index-shaped
rows in the curated README — which makes room for the format contract to
live in the README front door instead of a separate FORMAT.md.
The decision record, and the first RFC written in the new format, is
docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md.
---
.agents/skills/dsh-doc-standards/SKILL.md | 4 +-
docs/rfc/INDEX.md | 181 +++++++++++++++
docs/rfc/README.md | 206 ++++--------------
docs/rfc/implemented/AGENTS.md | 2 +-
.../2026-06-11-content-block-vocabulary.md | 15 +-
.../2026-06-11-custom-schema-dsl.md | 10 +-
...06-11-dev-invariants-over-deep-readonly.md | 10 +-
.../2026-06-11-event-sourced-sessions.md | 14 +-
.../2026-06-11-microkernel-event-taxonomy.md | 12 +-
.../2026-06-11-runtime-arg-validation.md | 10 +-
.../2026-06-11-structured-error-taxonomy.md | 8 +-
...6-06-11-tool-schemas-in-prompt-assembly.md | 12 +-
.../2026-06-13-capability-seams.md | 13 +-
.../2026-06-13-twin-llm-adapters.md | 11 +-
.../2026-06-14-session-persistence.md | 10 +-
.../2026-06-15-turn-enclosure-invariant.md | 12 +-
.../2026-06-17-filesystem-capability-seam.md | 72 ++----
...-18-agent-lifecycle-and-ownership-seams.md | 16 +-
.../2026-06-18-session-surface.md | 14 +-
...18-shared-persistence-write-coordinator.md | 9 +-
.../architecture/2026-06-20-branded-ids.md | 19 +-
...2026-06-20-extract-example-app-packages.md | 8 +-
.../2026-06-20-package-hierarchy.md | 10 +-
...06-21-mandatory-app-attribution-headers.md | 6 +-
.../2026-06-24-web-capability-seam.md | 128 ++++-------
.../2026-06-26-file-context-as-event-gate.md | 22 +-
...0-bash-stdin-env-trusted-plugin-surface.md | 8 +-
.../2026-06-30-event-domain-semantics.md | 6 +-
.../2026-07-02-fs-per-session-cwd.md | 4 +-
...26-07-02-result-time-applied-hunk-diffs.md | 8 +-
.../2026-07-02-tool-render-intent-union.md | 10 +
...07-03-filesystem-directory-listing-seam.md | 10 +-
.../2026-06-17-filesystem-tool-schemas.md | 23 +-
...6-06-18-acp-terminal-and-tool-rendering.md | 9 +-
.../2026-06-18-compaction-capability-seam.md | 13 +-
.../2026-06-21-subagent-capability-seam.md | 30 +--
.../2026-06-22-acp-subagent-backend.md | 22 +-
.../feature/2026-06-29-todo-write-tool.md | 6 +-
.../feature/2026-06-30-hook-bridges.md | 10 +-
.../feature/2026-06-30-hook-protocol-lib.md | 10 +-
.../feature/2026-06-30-interception-seams.md | 11 +-
.../2026-06-30-subagent-observe-enrich.md | 19 +-
.../2026-06-11-doc-sync-enforcement.md | 15 +-
.../process/2026-06-11-quality-gates.md | 8 +-
.../process/2026-06-11-tsdown-over-dumble.md | 12 +-
.../2026-06-11-vendor-cordis-as-source.md | 11 +-
.../process/2026-06-16-pnpm-over-yarn.md | 12 +-
.../process/2026-06-17-ts-build-config.md | 11 +-
.../2026-06-18-markdown-cross-link-lint.md | 11 +-
...2026-06-20-core-data-structures-catalog.md | 12 +-
.../2026-06-20-generated-cordis-catalog.md | 14 +-
.../process/2026-06-20-rfc-classification.md | 10 +-
...-bilingual-docs-and-pairing-gate.i18n.yaml | 4 +-
...6-07-02-bilingual-docs-and-pairing-gate.md | 6 +-
...7-02-bilingual-docs-and-pairing-gate.zh.md | 6 +-
.../process/2026-07-02-tool-schema-catalog.md | 10 +-
.../2026-07-03-documentation-graph-atlas.md | 10 +-
...26-07-04-cordis-jsdoc-completeness-gate.md | 12 +-
.../2026-07-04-doc-tiers-and-budgets.md | 7 +-
.../2026-07-04-generate-rfc-index-tables.md | 20 +-
.../2026-07-04-persistence-log-catalog.md | 11 +-
.../process/2026-07-05-uniform-rfc-format.md | 28 +++
...2026-06-19-drop-mutable-session-summary.md | 8 +-
...6-20-collapse-trace-only-session-events.md | 31 ++-
...rop-unconsumed-llm-adapter-change-event.md | 25 +--
...-drop-unconsumed-llm-assembled-surfaces.md | 26 +--
.../2026-06-20-prune-dead-seam-methods.md | 23 +-
.../2026-06-20-public-agent-stop-surface.md | 21 +-
...-20-remove-agent-boundary-mirror-events.md | 9 +-
.../2026-06-26-fsspec-style-fs-seam.md | 22 +-
.../2026-07-02-remove-stream-chunk-mirror.md | 8 +-
.../2026-07-04-drop-image-content-block.md | 14 +-
.../2026-07-04-drop-inert-request-knobs.md | 16 +-
...drop-unconsumed-web-observation-surface.md | 20 +-
.../2026-07-04-fold-stdio-ui-helper.md | 4 +-
...-prune-producerless-vocabulary-variants.md | 16 +-
.../2026-07-04-prune-write-only-fs-surface.md | 15 +-
...2026-07-04-remove-agent-steering-mirror.md | 15 +-
.../2026-07-04-share-app-bin-boot-glue.md | 4 +-
...26-07-04-tighten-hook-protocol-contract.md | 17 +-
...-04-trim-acp-bridge-unreachable-surface.md | 10 +-
.../2026-06-11-property-based-testing.md | 8 +-
.../testing/2026-06-19-acp-snapshot-tests.md | 12 +-
.../testing/2026-06-19-real-api-e2e-ci.md | 11 +-
...0-remove-redundant-snapshot-log-goldens.md | 20 +-
.../2026-06-22-fork-snapshot-scenarios.md | 2 +
.../2026-06-22-subagent-snapshot-replay.md | 2 +
.../2026-07-04-hook-snapshot-matrix.md | 2 +
...6-07-04-single-source-acp-replay-config.md | 4 +-
.../2026-06-16-typed-event-schemas.md | 16 +-
...06-20-generic-long-running-tool-runtime.md | 4 +-
.../2026-06-14-acp-agent-client-protocol.md | 12 +-
.../feature/2026-06-14-acp-multi-session.md | 11 +-
.../feature/2026-06-15-optional-code-mode.md | 12 +-
.../2026-06-30-pre-tool-input-rewrite.md | 25 ++-
.../2026-06-11-api-extractor-reports.md | 17 +-
.../2026-06-11-architectural-conformance.md | 9 +-
...026-06-11-supply-chain-and-vendor-drift.md | 13 +-
.../2026-06-20-discover-package-inventory.md | 4 +-
.../2026-06-20-unify-agent-and-session-id.md | 4 +-
...026-07-04-prune-dead-core-spine-surface.md | 4 +-
...-06-11-deterministic-and-stress-testing.md | 10 +-
.../testing/2026-06-11-mutation-testing.md | 10 +-
.../2026-06-11-immutable-public-surfaces.md | 4 +-
.../2026-06-20-providerless-example-base.md | 2 +
...06-20-assembled-assistant-messages-only.md | 2 +
.../2026-06-20-drop-acp-session-load.md | 2 +
.../2026-06-20-drop-acp-terminal-meta.md | 2 +
...2026-06-20-drop-bash-output-spill-files.md | 2 +
...2026-06-20-drop-durable-step-boundaries.md | 2 +
.../2026-06-20-drop-unused-session-lineage.md | 2 +
...6-20-fold-session-persistence-interface.md | 2 +
.../2026-06-20-generic-tool-rendering.md | 2 +
.../2026-06-20-retire-mid-turn-steering.md | 2 +
.../2026-06-20-single-session-acp-bridge.md | 2 +
.../2026-06-20-truncate-interrupted-turns.md | 2 +
...prune-unimplemented-subagent-vocabulary.md | 4 +-
package.json | 3 +-
packages/fs/fs-local/README.md | 2 +-
scripts/gen-rfc-index.ts | 34 +--
scripts/rfc-index.ts | 73 ++-----
scripts/verify-rfc-classification.ts | 39 ++--
scripts/verify-rfc-format.ts | 106 +++++++++
123 files changed, 1221 insertions(+), 847 deletions(-)
create mode 100644 docs/rfc/INDEX.md
create mode 100644 docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md
create mode 100644 scripts/verify-rfc-format.ts
diff --git a/.agents/skills/dsh-doc-standards/SKILL.md b/.agents/skills/dsh-doc-standards/SKILL.md
index 7506ca5eb0..7c396ed93c 100644
--- a/.agents/skills/dsh-doc-standards/SKILL.md
+++ b/.agents/skills/dsh-doc-standards/SKILL.md
@@ -10,7 +10,7 @@ The contract lives in [docs/AGENTS.md](../../../docs/AGENTS.md) — the tier tax
## Sources of truth (read, don't re-summarize)
- [docs/AGENTS.md](../../../docs/AGENTS.md) — the taxonomy ("one home per fact"), budgets, slop checklist.
-- [docs/rfc/README.md](../../../docs/rfc/README.md) — when a decision earns an RFC and how to file it; [docs/postmortem/README.md](../../../docs/postmortem/README.md) — when an incident earns a postmortem.
+- [docs/rfc/README.md](../../../docs/rfc/README.md) — when a decision earns an RFC, how to file it, and what goes inside one (the header block, per-lifecycle skeleton, and Alternatives-considered mandate, gated by `verify-rfc-format`); [docs/postmortem/README.md](../../../docs/postmortem/README.md) — when an incident earns a postmortem.
- [docs/i18n/README.md](../../../docs/i18n/README.md) — the bilingual pairing contract; editing either side of a pair obligates the counterpart in the same change.
- Root [AGENTS.md](../../../AGENTS.md) — the standing orders whose budget discipline this skill protects.
@@ -31,7 +31,7 @@ The audit is a hunt for the standard's slop checklist, cheapest probes first:
2. Hunt narrated history: `rg -n -g '!vendor' -t md "no longer|used to|previously|was moved|renamed"` — judge each hit; some are legitimate (quoting a contrast against a live alternative), most are drift.
3. Hunt duplication: take each standing-doc rule, grep one distinctive phrase from it across all Markdown; more than one home means all but one become links.
4. Hunt catalog restatement: compare README event/tool tables against the generated catalogs and JSDoc; hand copies get replaced by links.
-5. Hunt spec-speak in `implemented/` RFCs: migration plans, test checklists, future-tense "should" — an implemented RFC describes what is.
+5. Hunt spec-speak in `implemented/` RFCs: migration plans, test checklists, future-tense "should" — an implemented RFC describes what is. The heading-level cases (`## Plan`, `## Acceptance criteria`, …) are mechanically gated by `verify-rfc-format`; the prose-level "should" hunt remains manual.
6. Classify each finding: a mechanical trim lands as a small PR; a restructure or removal that changes what a doc promises gets a proposed RFC first (follow [dsh-find-simplifications](../dsh-find-simplifications/SKILL.md) for the RFC shape).
Compression discipline: every load-bearing rule survives — as one to three lines plus a link to the home that carries its why. Cut stories, duplicates, and status annotations; never silently drop a rule. If a cut rule has no durable home to link, create it (usually an RFC or postmortem) in the same change.
diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md
new file mode 100644
index 0000000000..fa53e1f7c6
--- /dev/null
+++ b/docs/rfc/INDEX.md
@@ -0,0 +1,181 @@
+# RFC index
+
+Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; `verify-rfc-classification` fails when this file is stale. The curated front door — layout, classification, when to write one, and the in-file format — is [README.md](README.md).
+
+## Proposed
+
+### Feature
+
+| Title | First proposed |
+|---|---|
+| [Agent Client Protocol (ACP) support — drive the coding agent from external editors](proposed/feature/2026-06-14-acp-agent-client-protocol.md) | 2026-06-14 |
+| [Multiplex concurrent ACP sessions over one connection](proposed/feature/2026-06-14-acp-multi-session.md) | 2026-06-14 |
+| [Optional Code Mode — model writes TypeScript against an SDK of all tools](proposed/feature/2026-06-15-optional-code-mode.md) | 2026-06-15 |
+| [Pre-tool input rewrite — a consistent design](proposed/feature/2026-06-30-pre-tool-input-rewrite.md) | 2026-06-30 |
+
+### Simplification
+
+| Title | First proposed |
+|---|---|
+| [Unify the agent id and the session id](proposed/simplification/2026-06-20-unify-agent-and-session-id.md) | 2026-06-20 |
+| [Prune dead core-spine surface — `SurfaceManager.invalidate()`, the loop-internal exports, `ToolExecutionResult.callId`](proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md) | 2026-07-04 |
+
+### Architecture
+
+| Title | First proposed |
+|---|---|
+| [Runtime schemas for the event vocabulary (Zod vs the merge-extensible-map pattern)](proposed/architecture/2026-06-16-typed-event-schemas.md) | 2026-06-16 |
+| [Extract a generic long-running tool runtime](proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md) | 2026-06-20 |
+
+### Process
+
+| Title | First proposed |
+|---|---|
+| [API extractor reports](proposed/process/2026-06-11-api-extractor-reports.md) | 2026-06-11 |
+| [Architectural conformance — dependency rules and the adapter kit](proposed/process/2026-06-11-architectural-conformance.md) | 2026-06-11 |
+| [Supply chain checks and vendor drift verification](proposed/process/2026-06-11-supply-chain-and-vendor-drift.md) | 2026-06-11 |
+| [Discover package inventories instead of maintaining static lists](proposed/process/2026-06-20-discover-package-inventory.md) | 2026-06-20 |
+
+### Testing
+
+| Title | First proposed |
+|---|---|
+| [Deterministic tests, the replay invariant fixture, and race stress](proposed/testing/2026-06-11-deterministic-and-stress-testing.md) | 2026-06-11 |
+| [Mutation testing as the coverage counterweight](proposed/testing/2026-06-11-mutation-testing.md) | 2026-06-11 |
+
+## Implemented
+
+### Feature
+
+| Title | First proposed |
+|---|---|
+| [Filesystem tool schemas — model-facing read/write/edit shapes](implemented/feature/2026-06-17-filesystem-tool-schemas.md) | 2026-06-17 |
+| [Rich ACP bash rendering — the terminal card via the `_meta` convention](implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) | 2026-06-18 |
+| [Compaction as a capability seam (abstract contract + basic backend)](implemented/feature/2026-06-18-compaction-capability-seam.md) | 2026-06-18 |
+| [Subagent capability seam](implemented/feature/2026-06-21-subagent-capability-seam.md) | 2026-06-21 |
+| [ACP subagent backend (out-of-process delegation)](implemented/feature/2026-06-22-acp-subagent-backend.md) | 2026-06-22 |
+| [The `todo_write` tool — model task list as event-sourced session state](implemented/feature/2026-06-29-todo-write-tool.md) | 2026-06-29 |
+| [dsh-hooks-claude + dsh-hooks-codex — the Claude Code / Codex hook bridges](implemented/feature/2026-06-30-hook-bridges.md) | 2026-06-30 |
+| [dsh-hook-protocol — the shared Claude Code / Codex hook wire-protocol core](implemented/feature/2026-06-30-hook-protocol-lib.md) | 2026-06-30 |
+| [Interception seams — the typed-Decision surface a hook programs against](implemented/feature/2026-06-30-interception-seams.md) | 2026-06-30 |
+| [Subagent lifecycle enrichment — lastAssistantMessage (observe-only)](implemented/feature/2026-06-30-subagent-observe-enrich.md) | 2026-06-30 |
+
+### Simplification
+
+| Title | First proposed |
+|---|---|
+| [Drop the mutable session summary](implemented/simplification/2026-06-19-drop-mutable-session-summary.md) | 2026-06-19 |
+| [Fold trace-only session facts into load-bearing events](implemented/simplification/2026-06-20-collapse-trace-only-session-events.md) | 2026-06-20 |
+| [Drop the unconsumed `llm/adapter-change` event](implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md) | 2026-06-20 |
+| [Drop unconsumed assembled LLM convenience surfaces](implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md) | 2026-06-20 |
+| [Prune dead methods from the persistence seam](implemented/simplification/2026-06-20-prune-dead-seam-methods.md) | 2026-06-20 |
+| [Keep one public stop primitive](implemented/simplification/2026-06-20-public-agent-stop-surface.md) | 2026-06-20 |
+| [Stop mirroring durable boundaries as agent events](implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md) | 2026-06-20 |
+| [Split the filesystem seam — provider text mutations plus the `dsh-fs-policy` plugin](implemented/simplification/2026-06-26-fsspec-style-fs-seam.md) | 2026-06-26 |
+| [Stop mirroring the token stream as an agent event](implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md) | 2026-07-02 |
+| [Drop the `image` content block until a path can honor it](implemented/simplification/2026-07-04-drop-image-content-block.md) | 2026-07-04 |
+| [Drop `GenerateOptions.prefill` and `ToolSchema.strict` — request knobs with no working end-to-end path](implemented/simplification/2026-07-04-drop-inert-request-knobs.md) | 2026-07-04 |
+| [Drop the unconsumed web observation surface — the `providers-change` event and the status methods](implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) | 2026-07-04 |
+| [Fold the stdio UI helper into the stdio app](implemented/simplification/2026-07-04-fold-stdio-ui-helper.md) | 2026-07-04 |
+| [Prune producer-less vocabulary variants (block cache hints, the `agent` message source, the `continuation` turn trigger)](implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md) | 2026-07-04 |
+| [Prune write-only fields and a dead routing knob from the fs seam](implemented/simplification/2026-07-04-prune-write-only-fs-surface.md) | 2026-07-04 |
+| [Remove the `agent/steering` mirror emit](implemented/simplification/2026-07-04-remove-agent-steering-mirror.md) | 2026-07-04 |
+| [Share the app bins' boot glue instead of maintaining twin copies](implemented/simplification/2026-07-04-share-app-bin-boot-glue.md) | 2026-07-04 |
+| [Tighten the hook-protocol contract — dialect, discarded fields, double defaults, and lib-owned `hook/result` semantics](implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md) | 2026-07-04 |
+| [Trim unreachable ACP bridge surface — the branding knobs and the kind-sniffing fallback](implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md) | 2026-07-04 |
+
+### Architecture
+
+| Title | First proposed |
+|---|---|
+| [Provider-neutral content-block vocabulary owned by dsh-llm](implemented/architecture/2026-06-11-content-block-vocabulary.md) | 2026-06-11 |
+| [Custom typed tool-schema DSL instead of schemastery](implemented/architecture/2026-06-11-custom-schema-dsl.md) | 2026-06-11 |
+| [Dev-mode invariants over compile-time deep-readonly](implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md) | 2026-06-11 |
+| [Event-sourced sessions with derived message history](implemented/architecture/2026-06-11-event-sourced-sessions.md) | 2026-06-11 |
+| [Microkernel — extension via Cordis event taxonomy, one concrete loop](implemented/architecture/2026-06-11-microkernel-event-taxonomy.md) | 2026-06-11 |
+| [Runtime arg validation at the model boundary](implemented/architecture/2026-06-11-runtime-arg-validation.md) | 2026-06-11 |
+| [Structured error taxonomy](implemented/architecture/2026-06-11-structured-error-taxonomy.md) | 2026-06-11 |
+| [Tool schemas are part of the system-prompt assembly](implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.md) | 2026-06-11 |
+| [Capability seams — interface / implementation / consumer split](implemented/architecture/2026-06-13-capability-seams.md) | 2026-06-13 |
+| [Two LLM adapters as a design-verification twin](implemented/architecture/2026-06-13-twin-llm-adapters.md) | 2026-06-13 |
+| [Session persistence as an abstract service over the existing `SessionEvent`](implemented/architecture/2026-06-14-session-persistence.md) | 2026-06-14 |
+| [Every session event is enclosed in a turn](implemented/architecture/2026-06-15-turn-enclosure-invariant.md) | 2026-06-15 |
+| [Filesystem capability seam — ctx.fs, local backend, and model-facing filesystem tools](implemented/architecture/2026-06-17-filesystem-capability-seam.md) | 2026-06-17 |
+| [Agent lifecycle and ownership seams](implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md) | 2026-06-18 |
+| [Session surface — a linked list over the event log for LLM message derivation](implemented/architecture/2026-06-18-session-surface.md) | 2026-06-18 |
+| [Shared persistence write coordinator](implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) | 2026-06-18 |
+| [Branded IDs everywhere they belong](implemented/architecture/2026-06-20-branded-ids.md) | 2026-06-20 |
+| [Extract example apps into packages](implemented/architecture/2026-06-20-extract-example-app-packages.md) | 2026-06-20 |
+| [Reorganize packages into a modular hierarchy](implemented/architecture/2026-06-20-package-hierarchy.md) | 2026-06-20 |
+| [Mandatory `User-Agent` attribution for provider requests](implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md) | 2026-06-21 |
+| [Web capability seam - stable tools over multiple providers](implemented/architecture/2026-06-24-web-capability-seam.md) | 2026-06-24 |
+| [Make `dsh-fs-policy` an event-gate plugin, not a method interface](implemented/architecture/2026-06-26-file-context-as-event-gate.md) | 2026-06-26 |
+| [stdin + extra env on the bash seam](implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) | 2026-06-30 |
+| [Event-domain semantics — session is the fact log, agent is the live surface](implemented/architecture/2026-06-30-event-domain-semantics.md) | 2026-06-30 |
+| [Resolve filesystem paths against the caller's session cwd](implemented/architecture/2026-07-02-fs-per-session-cwd.md) | 2026-07-02 |
+| [Result-time applied-hunk diffs for file mutations](implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md) | 2026-07-02 |
+| [Tagged render-intent union for tool-call presentation](implemented/architecture/2026-07-02-tool-render-intent-union.md) | 2026-07-02 |
+| [Add direct directory listing to the filesystem seam](implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md) | 2026-07-03 |
+
+### Process
+
+| Title | First proposed |
+|---|---|
+| [Doc-sync enforcement](implemented/process/2026-06-11-doc-sync-enforcement.md) | 2026-06-11 |
+| [Mechanical quality gates over prose guidelines](implemented/process/2026-06-11-quality-gates.md) | 2026-06-11 |
+| [tsdown for JS bundling instead of dumble](implemented/process/2026-06-11-tsdown-over-dumble.md) | 2026-06-11 |
+| [Vendor Cordis as source, not npm dependencies](implemented/process/2026-06-11-vendor-cordis-as-source.md) | 2026-06-11 |
+| [pnpm as the package manager instead of Yarn 4](implemented/process/2026-06-16-pnpm-over-yarn.md) | 2026-06-16 |
+| [TSC-first build and one tsconfig](implemented/process/2026-06-17-ts-build-config.md) | 2026-06-17 |
+| [Markdown cross-link validity linting](implemented/process/2026-06-18-markdown-cross-link-lint.md) | 2026-06-18 |
+| [Core-data-structures catalog and the `ts type-equiv` drift gate](implemented/process/2026-06-20-core-data-structures-catalog.md) | 2026-06-20 |
+| [Generated cordis events + services catalog](implemented/process/2026-06-20-generated-cordis-catalog.md) | 2026-06-20 |
+| [Classify RFCs by kind via path-encoded subdirectories](implemented/process/2026-06-20-rfc-classification.md) | 2026-06-20 |
+| [Bilingual documentation via paired sibling files and a pairing gate](implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md) | 2026-07-02 |
+| [Generated tool-schema catalog (boot-and-harvest)](implemented/process/2026-07-02-tool-schema-catalog.md) | 2026-07-02 |
+| [Documentation graph index for maintainers and SDK users](implemented/process/2026-07-03-documentation-graph-atlas.md) | 2026-07-03 |
+| [JSDoc completeness gate for the cordis surface](implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md) | 2026-07-04 |
+| [Documentation tiers, budgets, and the ceiling gate](implemented/process/2026-07-04-doc-tiers-and-budgets.md) | 2026-07-04 |
+| [Generate the RFC index tables](implemented/process/2026-07-04-generate-rfc-index-tables.md) | 2026-07-04 |
+| [Generated persistence log event catalog](implemented/process/2026-07-04-persistence-log-catalog.md) | 2026-07-04 |
+| [One gated in-file format for RFCs](implemented/process/2026-07-05-uniform-rfc-format.md) | 2026-07-05 |
+
+### Testing
+
+| Title | First proposed |
+|---|---|
+| [Property-based testing for protocol-shaped code](implemented/testing/2026-06-11-property-based-testing.md) | 2026-06-11 |
+| [ACP snapshot tests — record-once / replay-deterministic](implemented/testing/2026-06-19-acp-snapshot-tests.md) | 2026-06-19 |
+| [Real-API e2e in CI against the external DeepSeek API](implemented/testing/2026-06-19-real-api-e2e-ci.md) | 2026-06-19 |
+| [Use `session.jsonl` as the only snapshot session-log artifact](implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md) | 2026-06-20 |
+| [Persist the seed boundary so fork-child replay routes correctly](implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md) | 2026-06-22 |
+| [Record fork and mixed spawn+fork snapshot scenarios](implemented/testing/2026-06-22-fork-snapshot-scenarios.md) | 2026-06-22 |
+| [Per-session snapshot replay for nested agents](implemented/testing/2026-06-22-subagent-snapshot-replay.md) | 2026-06-22 |
+| [Hook snapshot matrix — end-to-end goldens for both bridges](implemented/testing/2026-07-04-hook-snapshot-matrix.md) | 2026-07-04 |
+| [Single-source the acp-agent replay config](implemented/testing/2026-07-04-single-source-acp-replay-config.md) | 2026-07-04 |
+
+## Rejected
+
+### Simplification
+
+| Title | First proposed |
+|---|---|
+| [Persist assembled assistant messages, not stream chunks](rejected/simplification/2026-06-20-assembled-assistant-messages-only.md) | 2026-06-20 |
+| [Drop ACP session/load until resume has a product shape](rejected/simplification/2026-06-20-drop-acp-session-load.md) | 2026-06-20 |
+| [Drop ACP terminal `_meta` rendering](rejected/simplification/2026-06-20-drop-acp-terminal-meta.md) | 2026-06-20 |
+| [Drop bash full-output spill files](rejected/simplification/2026-06-20-drop-bash-output-spill-files.md) | 2026-06-20 |
+| [Drop durable step boundary events](rejected/simplification/2026-06-20-drop-durable-step-boundaries.md) | 2026-06-20 |
+| [Drop unused session lineage metadata](rejected/simplification/2026-06-20-drop-unused-session-lineage.md) | 2026-06-20 |
+| [Fold the persistence interface into dsh-session](rejected/simplification/2026-06-20-fold-session-persistence-interface.md) | 2026-06-20 |
+| [Collapse tool-owned UI presentation](rejected/simplification/2026-06-20-generic-tool-rendering.md) | 2026-06-20 |
+| [Retire mid-turn steering](rejected/simplification/2026-06-20-retire-mid-turn-steering.md) | 2026-06-20 |
+| [Return the ACP bridge to one live session per connection](rejected/simplification/2026-06-20-single-session-acp-bridge.md) | 2026-06-20 |
+| [Truncate interrupted final turns on load](rejected/simplification/2026-06-20-truncate-interrupted-turns.md) | 2026-06-20 |
+| [Prune the unimplemented subagent seam vocabulary](rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md) | 2026-07-04 |
+
+### Architecture
+
+| Title | First proposed |
+|---|---|
+| [Deep-readonly public surfaces](rejected/architecture/2026-06-11-immutable-public-surfaces.md) | 2026-06-11 |
+| [Make the shared example base providerless](rejected/architecture/2026-06-20-providerless-example-base.md) | 2026-06-20 |
diff --git a/docs/rfc/README.md b/docs/rfc/README.md
index 9633691cf7..fcb077e0a5 100644
--- a/docs/rfc/README.md
+++ b/docs/rfc/README.md
@@ -1,6 +1,6 @@
# RFCs
-One kind of design doc lives here. An **RFC** records a decision or proposal that shapes this codebase — the *why* and *what we gave up*, the parts code and docs can't carry.
+One kind of design doc lives here. An **RFC** records a decision or proposal that shapes this codebase — the *why* and *what we gave up*, the parts code and docs can't carry. The full list is the generated [INDEX.md](INDEX.md); this file is the contract — where RFCs live, when to write one, and [the in-file format](#the-file-format).
## Layout and naming
@@ -16,7 +16,7 @@ The date in the filename is when the topic was **first proposed** (per git histo
## Classification
-Each RFC is filed under exactly one **class** — the kind of decision it records. The class is encoded in the path (the folder *is* the label, so a file's location declares its class) and the set is **closed**: `scripts/rfc-index.ts` owns the canonical set, `scripts/verify-rfc-classification.ts` rejects any folder outside it, and the index tables below are **generated** from the tree (`pnpm run gen-rfc-index` rewrites the marker-delimited regions from each RFC's path, H1 title, and filename date; the gate fails when they are stale). Adding a new class means amending that `const` and this section, not just dropping a new folder. See [the classification RFC](implemented/process/2026-06-20-rfc-classification.md) for why the taxonomy is path-encoded and gated, and [the index-generation RFC](implemented/process/2026-07-04-generate-rfc-index-tables.md) for why the tables are generated while this prose stays curated.
+Each RFC is filed under exactly one **class** — the kind of decision it records. The class is encoded in the path (the folder *is* the label, so a file's location declares its class) and the set is **closed**: `scripts/rfc-index.ts` owns the canonical set, `scripts/verify-rfc-classification.ts` rejects any folder outside it, and [INDEX.md](INDEX.md) is **generated** from the tree in full (`pnpm run gen-rfc-index` rewrites it from each RFC's path, H1 title, and filename date; the gate fails when it is stale, and rejects an index-shaped row in this file). Adding a new class means amending that `const` and this section, not just dropping a new folder. See [the classification RFC](implemented/process/2026-06-20-rfc-classification.md) for why the taxonomy is path-encoded and gated, and [the index-generation RFC](implemented/process/2026-07-04-generate-rfc-index-tables.md) for why the index is generated while this prose stays curated.
| Class | What it covers |
|---|---|
@@ -35,185 +35,75 @@ Write an RFC when a decision is **durable** (it shapes the codebase beyond a sin
Do NOT write one for a mechanical or local choice (a variable name, a one-file refactor), for anything already enforced and explained by a gate or a convention in AGENTS.md, or for a still-provisional decision tagged `TODO(...)` in the code — record those as TODOs and promote to an RFC only once they settle. An RFC is never edited into a *different decision*: supersede it with a new one and cross-link. (Editing an `implemented/` RFC to track where its already-made decision now *lives* — a moved file, a renamed package — is not a different decision and is required, not forbidden; see [implemented/AGENTS.md](implemented/AGENTS.md).)
-## Proposed
+## The file format
-
-### Feature
+Every RFC follows one in-file format, enforced by `pnpm run verify-rfc-format` ([scripts/verify-rfc-format.ts](../../scripts/verify-rfc-format.ts), part of `doc-sync`); the rationale for the format — and the alternatives it rejected — is [the uniform-format RFC](implemented/process/2026-07-05-uniform-rfc-format.md).
-| Title | First proposed |
-|---|---|
-| [Agent Client Protocol (ACP) support — drive the coding agent from external editors](proposed/feature/2026-06-14-acp-agent-client-protocol.md) | 2026-06-14 |
-| [Multiplex concurrent ACP sessions over one connection](proposed/feature/2026-06-14-acp-multi-session.md) | 2026-06-14 |
-| [Optional Code Mode — model writes TypeScript against an SDK of all tools](proposed/feature/2026-06-15-optional-code-mode.md) | 2026-06-15 |
-| [Pre-tool input rewrite — a consistent design](proposed/feature/2026-06-30-pre-tool-input-rewrite.md) | 2026-06-30 |
+### The header block
-### Simplification
+The first three lines of every RFC are exactly:
-| Title | First proposed |
-|---|---|
-| [Unify the agent id and the session id](proposed/simplification/2026-06-20-unify-agent-and-session-id.md) | 2026-06-20 |
-| [Prune dead core-spine surface — `SurfaceManager.invalidate()`, the loop-internal exports, `ToolExecutionResult.callId`](proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md) | 2026-07-04 |
+```markdown
+# RFC:
-### Architecture
+Status:
+```
-| Title | First proposed |
-|---|---|
-| [Runtime schemas for the event vocabulary (Zod vs the merge-extensible-map pattern)](proposed/architecture/2026-06-16-typed-event-schemas.md) | 2026-06-16 |
-| [Extract a generic long-running tool runtime](proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md) | 2026-06-20 |
+followed by a blank line. The `Status:` value is one of three forms, and must agree with the lifecycle folder the file sits in — the gate cross-checks them:
-### Process
+- `Status: proposed`
+- `Status: implemented`
+- `Status: rejected — `
-| Title | First proposed |
-|---|---|
-| [API extractor reports](proposed/process/2026-06-11-api-extractor-reports.md) | 2026-06-11 |
-| [Architectural conformance — dependency rules and the adapter kit](proposed/process/2026-06-11-architectural-conformance.md) | 2026-06-11 |
-| [Supply chain checks and vendor drift verification](proposed/process/2026-06-11-supply-chain-and-vendor-drift.md) | 2026-06-11 |
-| [Discover package inventories instead of maintaining static lists](proposed/process/2026-06-20-discover-package-inventory.md) | 2026-06-20 |
+The status carries no dates and no parentheticals: the filename holds the first-proposed date, git holds everything else, and an "accepted in amended form" note is body content (state the amendment where the decision is stated). The rejection reason is the one status with content, because a rejected RFC's verdict is the fact readers come for.
-### Testing
+### The body skeleton
-| Title | First proposed |
-|---|---|
-| [Deterministic tests, the replay invariant fixture, and race stress](proposed/testing/2026-06-11-deterministic-and-stress-testing.md) | 2026-06-11 |
-| [Mutation testing as the coverage counterweight](proposed/testing/2026-06-11-mutation-testing.md) | 2026-06-11 |
-
+Every RFC opens its body with `## Problem` — the motivation, written to stand without the solution. What follows depends on the lifecycle; recurring sections use these canonical names and nothing else, while genuinely bespoke technical sections (package topology, wire contracts, schemas) remain free-form between the required ones.
-## Implemented
+#### `proposed/`
-
-### Feature
+```markdown
+## Problem
+## Proposal
+…bespoke sections…
+## Alternatives considered
+## Acceptance criteria
+## Risks
+```
-| Title | First proposed |
-|---|---|
-| [Filesystem tool schemas — model-facing read/write/edit shapes](implemented/feature/2026-06-17-filesystem-tool-schemas.md) | 2026-06-17 |
-| [Rich ACP bash rendering — the terminal card via the `_meta` convention](implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) | 2026-06-18 |
-| [Compaction as a capability seam (abstract contract + basic backend)](implemented/feature/2026-06-18-compaction-capability-seam.md) | 2026-06-18 |
-| [Subagent capability seam](implemented/feature/2026-06-21-subagent-capability-seam.md) | 2026-06-21 |
-| [ACP subagent backend (out-of-process delegation)](implemented/feature/2026-06-22-acp-subagent-backend.md) | 2026-06-22 |
-| [The `todo_write` tool — model task list as event-sourced session state](implemented/feature/2026-06-29-todo-write-tool.md) | 2026-06-29 |
-| [dsh-hooks-claude + dsh-hooks-codex — the Claude Code / Codex hook bridges](implemented/feature/2026-06-30-hook-bridges.md) | 2026-06-30 |
-| [dsh-hook-protocol — the shared Claude Code / Codex hook wire-protocol core](implemented/feature/2026-06-30-hook-protocol-lib.md) | 2026-06-30 |
-| [Interception seams — the typed-Decision surface a hook programs against](implemented/feature/2026-06-30-interception-seams.md) | 2026-06-30 |
-| [Subagent lifecycle enrichment — lastAssistantMessage (observe-only)](implemented/feature/2026-06-30-subagent-observe-enrich.md) | 2026-06-30 |
+`## Proposal` is the intended change and may legitimately speak in the future tense — plans, migration steps, and open questions belong here while the work is unbuilt. `## Acceptance criteria` says what observable state means done. `## Risks` covers both what could go wrong and what the change knowingly gives up.
-### Simplification
+#### `implemented/`
-| Title | First proposed |
-|---|---|
-| [Drop the mutable session summary](implemented/simplification/2026-06-19-drop-mutable-session-summary.md) | 2026-06-19 |
-| [Fold trace-only session facts into load-bearing events](implemented/simplification/2026-06-20-collapse-trace-only-session-events.md) | 2026-06-20 |
-| [Drop the unconsumed `llm/adapter-change` event](implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md) | 2026-06-20 |
-| [Drop unconsumed assembled LLM convenience surfaces](implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md) | 2026-06-20 |
-| [Prune dead methods from the persistence seam](implemented/simplification/2026-06-20-prune-dead-seam-methods.md) | 2026-06-20 |
-| [Keep one public stop primitive](implemented/simplification/2026-06-20-public-agent-stop-surface.md) | 2026-06-20 |
-| [Stop mirroring durable boundaries as agent events](implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md) | 2026-06-20 |
-| [Split the filesystem seam — provider text mutations plus the `dsh-fs-policy` plugin](implemented/simplification/2026-06-26-fsspec-style-fs-seam.md) | 2026-06-26 |
-| [Stop mirroring the token stream as an agent event](implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md) | 2026-07-02 |
-| [Drop the `image` content block until a path can honor it](implemented/simplification/2026-07-04-drop-image-content-block.md) | 2026-07-04 |
-| [Drop `GenerateOptions.prefill` and `ToolSchema.strict` — request knobs with no working end-to-end path](implemented/simplification/2026-07-04-drop-inert-request-knobs.md) | 2026-07-04 |
-| [Drop the unconsumed web observation surface — the `providers-change` event and the status methods](implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) | 2026-07-04 |
-| [Fold the stdio UI helper into the stdio app](implemented/simplification/2026-07-04-fold-stdio-ui-helper.md) | 2026-07-04 |
-| [Prune producer-less vocabulary variants (block cache hints, the `agent` message source, the `continuation` turn trigger)](implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md) | 2026-07-04 |
-| [Prune write-only fields and a dead routing knob from the fs seam](implemented/simplification/2026-07-04-prune-write-only-fs-surface.md) | 2026-07-04 |
-| [Remove the `agent/steering` mirror emit](implemented/simplification/2026-07-04-remove-agent-steering-mirror.md) | 2026-07-04 |
-| [Share the app bins' boot glue instead of maintaining twin copies](implemented/simplification/2026-07-04-share-app-bin-boot-glue.md) | 2026-07-04 |
-| [Tighten the hook-protocol contract — dialect, discarded fields, double defaults, and lib-owned `hook/result` semantics](implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md) | 2026-07-04 |
-| [Trim unreachable ACP bridge surface — the branding knobs and the kind-sniffing fallback](implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md) | 2026-07-04 |
+```markdown
+## Problem
+## Decision
+…bespoke sections…
+## Alternatives considered
+## Consequences
+```
-### Architecture
+`## Decision` describes shipped reality in the present tense, and the whole file is kept current with it per [implemented/AGENTS.md](implemented/AGENTS.md). `## Consequences` records what the trade-off cost **and** bought. Proposal-era headings are spec-speak here and the gate rejects them: `## Proposal`, `## Plan`, `## Migration plan`, and `## Acceptance criteria` may not appear in an implemented RFC (the [slop checklist](../AGENTS.md) names why). A `## Testing`, `## Deferred`, or `## Related` section is fine where it states present-tense fact.
-| Title | First proposed |
-|---|---|
-| [Provider-neutral content-block vocabulary owned by dsh-llm](implemented/architecture/2026-06-11-content-block-vocabulary.md) | 2026-06-11 |
-| [Custom typed tool-schema DSL instead of schemastery](implemented/architecture/2026-06-11-custom-schema-dsl.md) | 2026-06-11 |
-| [Dev-mode invariants over compile-time deep-readonly](implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md) | 2026-06-11 |
-| [Event-sourced sessions with derived message history](implemented/architecture/2026-06-11-event-sourced-sessions.md) | 2026-06-11 |
-| [Microkernel — extension via Cordis event taxonomy, one concrete loop](implemented/architecture/2026-06-11-microkernel-event-taxonomy.md) | 2026-06-11 |
-| [Runtime arg validation at the model boundary](implemented/architecture/2026-06-11-runtime-arg-validation.md) | 2026-06-11 |
-| [Structured error taxonomy](implemented/architecture/2026-06-11-structured-error-taxonomy.md) | 2026-06-11 |
-| [Tool schemas are part of the system-prompt assembly](implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.md) | 2026-06-11 |
-| [Capability seams — interface / implementation / consumer split](implemented/architecture/2026-06-13-capability-seams.md) | 2026-06-13 |
-| [Two LLM adapters as a design-verification twin](implemented/architecture/2026-06-13-twin-llm-adapters.md) | 2026-06-13 |
-| [Session persistence as an abstract service over the existing `SessionEvent`](implemented/architecture/2026-06-14-session-persistence.md) | 2026-06-14 |
-| [Every session event is enclosed in a turn](implemented/architecture/2026-06-15-turn-enclosure-invariant.md) | 2026-06-15 |
-| [Filesystem capability seam — ctx.fs, local backend, and model-facing filesystem tools](implemented/architecture/2026-06-17-filesystem-capability-seam.md) | 2026-06-17 |
-| [Agent lifecycle and ownership seams](implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md) | 2026-06-18 |
-| [Session surface — a linked list over the event log for LLM message derivation](implemented/architecture/2026-06-18-session-surface.md) | 2026-06-18 |
-| [Shared persistence write coordinator](implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) | 2026-06-18 |
-| [Branded IDs everywhere they belong](implemented/architecture/2026-06-20-branded-ids.md) | 2026-06-20 |
-| [Extract example apps into packages](implemented/architecture/2026-06-20-extract-example-app-packages.md) | 2026-06-20 |
-| [Reorganize packages into a modular hierarchy](implemented/architecture/2026-06-20-package-hierarchy.md) | 2026-06-20 |
-| [Mandatory `User-Agent` attribution for provider requests](implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md) | 2026-06-21 |
-| [Web capability seam - stable tools over multiple providers](implemented/architecture/2026-06-24-web-capability-seam.md) | 2026-06-24 |
-| [Make `dsh-fs-policy` an event-gate plugin, not a method interface](implemented/architecture/2026-06-26-file-context-as-event-gate.md) | 2026-06-26 |
-| [stdin + extra env on the bash seam](implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) | 2026-06-30 |
-| [Event-domain semantics — session is the fact log, agent is the live surface](implemented/architecture/2026-06-30-event-domain-semantics.md) | 2026-06-30 |
-| [Resolve filesystem paths against the caller's session cwd](implemented/architecture/2026-07-02-fs-per-session-cwd.md) | 2026-07-02 |
-| [Result-time applied-hunk diffs for file mutations](implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md) | 2026-07-02 |
-| [Tagged render-intent union for tool-call presentation](implemented/architecture/2026-07-02-tool-render-intent-union.md) | 2026-07-02 |
-| [Add direct directory listing to the filesystem seam](implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md) | 2026-07-03 |
+#### `rejected/`
-### Process
+A rejected RFC is the proposal, frozen: it keeps whatever proposal-time sections it had (including `## Acceptance criteria` or `## Plan`), and the verdict lives on the `Status:` line. Only the header block, the `## Problem` opener, a `## Proposal` section, and the Alternatives-considered mandate below apply.
-| Title | First proposed |
-|---|---|
-| [Doc-sync enforcement](implemented/process/2026-06-11-doc-sync-enforcement.md) | 2026-06-11 |
-| [Mechanical quality gates over prose guidelines](implemented/process/2026-06-11-quality-gates.md) | 2026-06-11 |
-| [tsdown for JS bundling instead of dumble](implemented/process/2026-06-11-tsdown-over-dumble.md) | 2026-06-11 |
-| [Vendor Cordis as source, not npm dependencies](implemented/process/2026-06-11-vendor-cordis-as-source.md) | 2026-06-11 |
-| [pnpm as the package manager instead of Yarn 4](implemented/process/2026-06-16-pnpm-over-yarn.md) | 2026-06-16 |
-| [TSC-first build and one tsconfig](implemented/process/2026-06-17-ts-build-config.md) | 2026-06-17 |
-| [Markdown cross-link validity linting](implemented/process/2026-06-18-markdown-cross-link-lint.md) | 2026-06-18 |
-| [Core-data-structures catalog and the `ts type-equiv` drift gate](implemented/process/2026-06-20-core-data-structures-catalog.md) | 2026-06-20 |
-| [Generated cordis events + services catalog](implemented/process/2026-06-20-generated-cordis-catalog.md) | 2026-06-20 |
-| [Classify RFCs by kind via path-encoded subdirectories](implemented/process/2026-06-20-rfc-classification.md) | 2026-06-20 |
-| [Bilingual documentation via paired sibling files and a pairing gate](implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md) | 2026-07-02 |
-| [Generated tool-schema catalog (boot-and-harvest)](implemented/process/2026-07-02-tool-schema-catalog.md) | 2026-07-02 |
-| [Documentation graph index for maintainers and SDK users](implemented/process/2026-07-03-documentation-graph-atlas.md) | 2026-07-03 |
-| [JSDoc completeness gate for the cordis surface](implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md) | 2026-07-04 |
-| [Documentation tiers, budgets, and the ceiling gate](implemented/process/2026-07-04-doc-tiers-and-budgets.md) | 2026-07-04 |
-| [Generate the RFC index tables](implemented/process/2026-07-04-generate-rfc-index-tables.md) | 2026-07-04 |
-| [Generated persistence log event catalog](implemented/process/2026-07-04-persistence-log-catalog.md) | 2026-07-04 |
+### Alternatives considered — mandatory
-### Testing
+Every RFC carries an `## Alternatives considered` section: each genuine alternative and why it lost, one bold-led paragraph per alternative or a `### Why not ?` subsection per contested one. A decision recorded without what it beat invites re-litigation — the failure RFCs exist to prevent.
-| Title | First proposed |
-|---|---|
-| [Property-based testing for protocol-shaped code](implemented/testing/2026-06-11-property-based-testing.md) | 2026-06-11 |
-| [ACP snapshot tests — record-once / replay-deterministic](implemented/testing/2026-06-19-acp-snapshot-tests.md) | 2026-06-19 |
-| [Real-API e2e in CI against the external DeepSeek API](implemented/testing/2026-06-19-real-api-e2e-ci.md) | 2026-06-19 |
-| [Use `session.jsonl` as the only snapshot session-log artifact](implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md) | 2026-06-20 |
-| [Persist the seed boundary so fork-child replay routes correctly](implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md) | 2026-06-22 |
-| [Record fork and mixed spawn+fork snapshot scenarios](implemented/testing/2026-06-22-fork-snapshot-scenarios.md) | 2026-06-22 |
-| [Per-session snapshot replay for nested agents](implemented/testing/2026-06-22-subagent-snapshot-replay.md) | 2026-06-22 |
-| [Hook snapshot matrix — end-to-end goldens for both bridges](implemented/testing/2026-07-04-hook-snapshot-matrix.md) | 2026-07-04 |
-| [Single-source the acp-agent replay config](implemented/testing/2026-07-04-single-source-acp-replay-config.md) | 2026-07-04 |
-
+Alternatives are recorded, never invented. An RFC dated before 2026-07-05 whose alternatives are not reconstructible from the record carries this exact comment in place of the section, which the gate accepts for pre-format files only:
-## Rejected
+```markdown
+
+```
-
-### Simplification
+### Moving between lifecycles
-| Title | First proposed |
-|---|---|
-| [Persist assembled assistant messages, not stream chunks](rejected/simplification/2026-06-20-assembled-assistant-messages-only.md) | 2026-06-20 |
-| [Drop ACP session/load until resume has a product shape](rejected/simplification/2026-06-20-drop-acp-session-load.md) | 2026-06-20 |
-| [Drop ACP terminal `_meta` rendering](rejected/simplification/2026-06-20-drop-acp-terminal-meta.md) | 2026-06-20 |
-| [Drop bash full-output spill files](rejected/simplification/2026-06-20-drop-bash-output-spill-files.md) | 2026-06-20 |
-| [Drop durable step boundary events](rejected/simplification/2026-06-20-drop-durable-step-boundaries.md) | 2026-06-20 |
-| [Drop unused session lineage metadata](rejected/simplification/2026-06-20-drop-unused-session-lineage.md) | 2026-06-20 |
-| [Fold the persistence interface into dsh-session](rejected/simplification/2026-06-20-fold-session-persistence-interface.md) | 2026-06-20 |
-| [Collapse tool-owned UI presentation](rejected/simplification/2026-06-20-generic-tool-rendering.md) | 2026-06-20 |
-| [Retire mid-turn steering](rejected/simplification/2026-06-20-retire-mid-turn-steering.md) | 2026-06-20 |
-| [Return the ACP bridge to one live session per connection](rejected/simplification/2026-06-20-single-session-acp-bridge.md) | 2026-06-20 |
-| [Truncate interrupted final turns on load](rejected/simplification/2026-06-20-truncate-interrupted-turns.md) | 2026-06-20 |
-| [Prune the unimplemented subagent seam vocabulary](rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md) | 2026-07-04 |
+Moving a file between lifecycle folders means updating the `Status:` line and re-satisfying that folder's skeleton in the same change — the gate fails the move otherwise. Concretely, `proposed/` → `implemented/` rewrites `## Proposal` into a present-tense `## Decision`, folds `## Acceptance criteria` and `## Risks` into `## Consequences` (or a present-tense `## Testing`/`## Verification` section for what now pins the behavior), and drops plans in favor of what shipped — the rewrite [implemented/AGENTS.md](implemented/AGENTS.md) requires, made mechanical. `proposed/` → `rejected/` only adds the reason to the `Status:` line and freezes the file.
-### Architecture
+### Chinese counterparts
-| Title | First proposed |
-|---|---|
-| [Deep-readonly public surfaces](rejected/architecture/2026-06-11-immutable-public-surfaces.md) | 2026-06-11 |
-| [Make the shared example base providerless](rejected/architecture/2026-06-20-providerless-example-base.md) | 2026-06-20 |
-
+A `.zh.md` counterpart mirrors its English sibling's structure section-for-section under the [i18n contract](../i18n/README.md); the machine-checked header tokens (`# RFC: ` and the `Status:` line) stay in English verbatim. The format gate skips `.zh.md` files — the pairing gate owns their consistency.
diff --git a/docs/rfc/implemented/AGENTS.md b/docs/rfc/implemented/AGENTS.md
index c23e9069e8..b4ee7d83d3 100644
--- a/docs/rfc/implemented/AGENTS.md
+++ b/docs/rfc/implemented/AGENTS.md
@@ -1,6 +1,6 @@
# AGENTS.md — Implemented RFCs
-These are RFCs whose decision has **shipped**. The repo-wide and docs-wide rules still apply ([root AGENTS.md](../../../AGENTS.md) § "Type safety and documentation", [docs/AGENTS.md](../../AGENTS.md)); this file adds one rule specific to this folder.
+These are RFCs whose decision has **shipped**. The repo-wide and docs-wide rules still apply ([root AGENTS.md](../../../AGENTS.md) § "Type safety and documentation", [docs/AGENTS.md](../../AGENTS.md)), and the in-file skeleton — including the proposal→implemented rewrite a lifecycle move owes — is [README.md § The file format](../README.md#the-file-format), gated by `verify-rfc-format`; this file adds one rule specific to this folder.
## Keep an implemented RFC current with what actually shipped
diff --git a/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md
index bc6d71e6e9..42efb34774 100644
--- a/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md
+++ b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md
@@ -1,19 +1,22 @@
# RFC: Provider-neutral content-block vocabulary owned by dsh-llm
-Status: implemented (accepted 2026-06-11)
+Status: implemented
-
+## Problem
-## Context
-
-The harness needs one internal language for messages that the loop, session log, and all plugins speak. Options: mirror the DeepSeek/OpenAI chat-completions shape (zero mapping for the first provider, awkward for rich content), adopt Anthropic's Messages block structure verbatim (battle-tested, but our canonical types would mirror a third-party API we don't target first), or own a vocabulary.
+The harness needs one internal language for messages that the loop, session log, and all plugins speak.
## Decision
-Own it: messages are arrays of typed content blocks (`text`, `reasoning`, `tool-call`, `tool-result`), with the union derived from the merge-extensible `ContentBlockMap` so plugins add block types via declaration merging. The same merge-extensible-map pattern types every "stringly" field (`MessageSource`, `FinishReason`, `TurnTrigger`, `TurnEndReason`). Streaming is a raw chunk protocol; `BlockAssembler` is the single shared assembly implementation. Adapters translate to provider wire formats — mapping cost lives in adapters, where it belongs.
+Own the vocabulary: messages are arrays of typed content blocks (`text`, `reasoning`, `tool-call`, `tool-result`), with the union derived from the merge-extensible `ContentBlockMap` so plugins add block types via declaration merging. The same merge-extensible-map pattern types every "stringly" field (`MessageSource`, `FinishReason`, `TurnTrigger`, `TurnEndReason`). Streaming is a raw chunk protocol; `BlockAssembler` is the single shared assembly implementation. Adapters translate to provider wire formats — mapping cost lives in adapters, where it belongs.
In-session context injection (`context/message`, `steering/message`) renders as tagged user-role envelopes (the system-reminder pattern) rather than a new role, so adapters carry zero burden. Live-adapter review has since validated the tagged-envelope rendering against current DeepSeek behavior; a future provider-specific mismatch should be handled in that adapter rather than by adding a new role to the canonical content vocabulary.
+## Alternatives considered
+
+- **Mirror the DeepSeek/OpenAI chat-completions shape** — zero mapping cost for the first provider, but awkward for rich content (reasoning, tool results as structured blocks).
+- **Adopt Anthropic's Messages block structure verbatim** — battle-tested, but the canonical types would mirror a third-party API the harness does not target first.
+
## Consequences
- Reasoning has a home without provider contortions. Multimodal content deliberately has NO core block type: the core set is limited to blocks every shipping path honors, and a multimodal feature adds its block type through the merge-extensible map in the same coordinated change that maps it in the adapters, surfaces it in the UI bridges, and prices it in compaction — see [the drop-image RFC](../simplification/2026-07-04-drop-image-content-block.md). Block cache hints likewise have no core field: DeepSeek prompt caching is automatic, so no shipping adapter can transmit a hint; a caching feature adds a `cache` field together with the adapter that honors it — see [the producer-less-variants RFC](../simplification/2026-07-04-prune-producerless-vocabulary-variants.md). Assistant-prefix continuation (prefill) likewise has no request field: DeepSeek's chat-prefix completion is a Beta feature on a base URL neither shipping adapter targets, so a prefill feature adds `GenerateOptions.prefill` together with the adapter that honors it — see [the inert-request-knobs RFC](../simplification/2026-07-04-drop-inert-request-knobs.md).
diff --git a/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.md b/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.md
index 22330f4747..c9f07e463e 100644
--- a/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.md
+++ b/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.md
@@ -1,10 +1,8 @@
# RFC: Custom typed tool-schema DSL instead of schemastery
-Status: implemented (accepted 2026-06-11)
+Status: implemented
-
-
-## Context
+## Problem
Tool parameters must reach the model as standard JSON Schema (the wire format), and tool authors deserve typed `execute(args)` without casts. The repo already vendors schemastery (used for plugin Config), so reusing it was the obvious candidate. The user also explicitly preferred per-property `required: true` booleans over JSON Schema's separate `required` array.
@@ -12,7 +10,9 @@ Tool parameters must reach the model as standard JSON Schema (the wire format),
A small custom DSL in dsh-tools: `SchemaSpec` (per-property specs with `required: true` booleans), type-level `InferArgs` mapping a spec to the argument type (required keys non-optional, others genuinely optional via `?`), a runtime `schemaSpecToJsonSchema()` converter, and `defineTool()` tying them together. Raw JSON-Schema `ToolDefinition`s remain accepted by `ToolRegistry.register()` — that's how MCP-sourced tools arrive.
-Schemastery was evaluated and rejected for this use: it targets validation / transformation against StandardSchema, not JSON Schema *generation*, so it would add indirection without producing the wire format cleanly.
+## Alternatives considered
+
+**Schemastery** (already vendored, used for plugin Config) was evaluated and rejected for this use: it targets validation / transformation against StandardSchema, not JSON Schema *generation*, so it would add indirection without producing the wire format cleanly.
## Consequences
diff --git a/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md b/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md
index 212d2e82b7..a3240153d0 100644
--- a/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md
+++ b/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md
@@ -1,10 +1,8 @@
# RFC: Dev-mode invariants over compile-time deep-readonly
-Status: implemented (accepted 2026-06-13)
+Status: implemented
-
-
-## Context
+## Problem
The session log is append-only by contract, but the types don't enforce it: `session.events` returns `readonly SessionEvent[]` whose *elements* are mutable, and `deriveMessages()` handed the logged `content` arrays/blocks out by reference. The loop then passes those derived messages into the `agent/request` waterfall and on to adapters, where mutating the request is sanctioned — so a request middleware could reach back and rewrite history, silently breaking replay equivalence and the derived-history guarantee. Separately, the event taxonomy (turn/step nesting, seq monotonicity, tool-call/result pairing, legal status transitions) was asserted only where individual tests happened to look.
@@ -19,7 +17,9 @@ Reject the pervasive `DeepReadonly` type flip. Instead:
The invariants encode the *real* contract, not an idealized one: a `tool/call` may have no `tool/result` (a thrown tool-execution pipeline step ends the turn), and both `idle→disposed` and `running→disposed` are legal.
-`DeepReadonly` was rejected because it is compile-time only (a plugin casts straight through it), high type-noise across every log/message consumer and adapter, and would force readonly types through code where mutation is the sanctioned API. The clone draws the mutable/immutable boundary exactly at "logged vs in-flight" without any of that noise.
+## Alternatives considered
+
+**The pervasive `DeepReadonly` type flip** ([the rejected proposal](../../rejected/architecture/2026-06-11-immutable-public-surfaces.md)) — compile-time only (a plugin casts straight through it), high type-noise across every log/message consumer and adapter, and it would force readonly types through code where mutation is the sanctioned API. The clone draws the mutable/immutable boundary exactly at "logged vs in-flight" without any of that noise.
## Consequences
diff --git a/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.md b/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.md
index 3b20a19ef3..aeea55103d 100644
--- a/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.md
+++ b/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.md
@@ -1,12 +1,10 @@
# RFC: Event-sourced sessions with derived message history
-Status: implemented (accepted 2026-06-11)
+Status: implemented
-
+## Problem
-## Context
-
-The MVP requires strict event-based tracing with fully replayable sessions (严格的基于事件的trace、logging系统,session完全可回放). Two models were considered: a mutable message array with events fired as notifications (simpler, but state and log can diverge), or event-sourcing where the log IS the state.
+The MVP requires strict event-based tracing with fully replayable sessions (严格的基于事件的trace、logging系统,session完全可回放).
## Decision
@@ -16,9 +14,13 @@ Appends are synchronous (the hot path never blocks on I/O); `session/event` is a
Ordering contract: the loop appends to the session *before* emitting the corresponding Cordis event, and the `agent/step-result` waterfall runs before the `assistant/message` append so the log records what tool dispatch actually used (post-review fix; regression-tested).
+## Alternatives considered
+
+**A mutable message array with events fired as notifications** — simpler, but state and log can diverge; with event-sourcing the log IS the state, so divergence is structurally impossible.
+
## Consequences
- Replay, trace, and telemetry are structurally guaranteed, not bolted on.
- Persistence stays a plugin concern; the in-memory store ships in dsh-session.
-- The event vocabulary is merge-extensible (plugins add e.g. compaction events); it carries a TODO(review) marker until the first persistence plugin and real adapter exercise it.
+- The event vocabulary is merge-extensible (plugins add e.g. compaction events); [session persistence](2026-06-14-session-persistence.md) froze its shape once the log became durable.
- Derivation cost grows with log length — compaction (future plugin) is the intended mitigation, not log mutation.
diff --git a/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md b/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md
index 9dc91ae6da..501f8c7331 100644
--- a/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md
+++ b/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md
@@ -1,12 +1,10 @@
# RFC: Microkernel — extension via Cordis event taxonomy, one concrete loop
-Status: implemented (accepted 2026-06-11)
+Status: implemented
-
+## Problem
-## Context
-
-The product principle is "everything is a plugin": hooks, /goal, /loop, dynamic workflows, compaction, sandboxing, permissions, UI, persistence, MCP, skills must all be writable as plugins without modifying the core. Candidate mechanisms considered: a purpose-built middleware stack (koa-compose style), an explicit phase state machine plugins can insert into, or Cordis's native event system.
+The product principle is "everything is a plugin": hooks, /goal, /loop, dynamic workflows, compaction, sandboxing, permissions, UI, persistence, MCP, skills must all be writable as plugins without modifying the core.
## Decision
@@ -18,6 +16,10 @@ Pure Cordis event taxonomy. The loop's extension seams are typed events with del
The event vocabulary lives in interface packages (dsh-agent declares the agent/* events); `@deepseek-ai/dsh-agent-loop` is the only concrete loop plugin and is itself swappable — nothing outside it may depend on it.
+## Alternatives considered
+
+**A purpose-built middleware stack (koa-compose style)** and **an explicit phase state machine plugins insert into** — both would re-implement dispatch, disposal, and reload semantics that Cordis's native event system already provides; as Cordis effects, listeners get HMR and disposal for free.
+
## Consequences
- Every MVP feature maps to a listener (the [feature → mechanism map](../../../cookbook/extension-cookbook.md#the-feature--mechanism-map) is the proof obligation, kept current).
diff --git a/docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.md b/docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.md
index 5c0aad9eb2..33bf241c74 100644
--- a/docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.md
+++ b/docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.md
@@ -1,10 +1,8 @@
# RFC: Runtime arg validation at the model boundary
-Status: implemented (accepted 2026-06-13)
+Status: implemented
-
-
-## Context
+## Problem
`defineTool` ([the custom schema DSL](2026-06-11-custom-schema-dsl.md)) gives tool authors a typed `execute(args)` via the `InferArgs` mapping. But that type is a compile-time claim about a value that arrives at runtime as model-generated JSON: nothing forced the model to honor the schema, so a malformed call — missing a required key, a string where a number was declared, an enum value outside the set — reached `execute` typed-in-name-only. The tool body then either crashed on the bad shape (a generic stack trace the model can't act on) or, worse, silently misbehaved. Meanwhile the converter already encodes the exact structure a validator would need to walk.
@@ -17,6 +15,8 @@ The validator mirrors `schemaSpecToJsonSchema` semantics exactly — same struct
## Consequences
- The model gets actionable feedback on its own malformed calls instead of an opaque crash, closing the gap between `InferArgs`'s promise and runtime reality.
-- The validator and `InferArgs` must stay in agreement; that drift risk is to be closed by a property test ([property-based testing](../testing/2026-06-11-property-based-testing.md), not yet landed) generating args that satisfy `InferArgs` and asserting they pass `validateArgs`. Until then the agreement rests on the example tests and the shared converter structure.
+- The validator and `InferArgs` must stay in agreement; [a property test](../testing/2026-06-11-property-based-testing.md) generates args satisfying a spec and asserts they pass `validateArgs` (with targeted corruptions rejected), closing that drift risk mechanically.
- `ToolArgsError` is a plain `Error` with a `code` field for now; if a harness-wide error taxonomy lands it becomes a subclass without changing callers that read `.message`.
- Validation cost is negligible next to a model call.
+
+
diff --git a/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md b/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md
index 9e4eb0f2fc..35c1917926 100644
--- a/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md
+++ b/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md
@@ -1,10 +1,8 @@
# RFC: Structured error taxonomy
-Status: implemented (accepted 2026-06-14)
+Status: implemented
-
-
-## Context
+## Problem
Failures crossed seams as bare strings. A tool error flattened to a text block — name, code, and stack lost — so a future sandbox/retry plugin couldn't tell ENOENT from EACCES, and the model got less actionable feedback than it could. A non-Error throw degraded further: the loop wrapped it in `new Error(String(x))`, dropping any code. And `LlmError` was the only typed error in the system, with no shared base, so there was nothing for a consumer to `instanceof` against generically.
@@ -24,3 +22,5 @@ A single `HarnessError extends Error` base in `dsh-llm` (the leaf package every
- One base class is imported widely, but it lives in the package everyone already depends on, so the cost is a single import, not a new edge.
- `deriveMessages` does not surface `error` into model history — the model still sees the text block; the structured field is for code and replay.
- Reverting this PR returns the earlier errors to plain `Error`+`code` form; nothing else in the stack depends on the shared base.
+
+
diff --git a/docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.md b/docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.md
index b78aa622ed..5c78ef4280 100644
--- a/docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.md
+++ b/docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.md
@@ -1,17 +1,19 @@
# RFC: Tool schemas are part of the system-prompt assembly
-Status: implemented (accepted 2026-06-11)
+Status: implemented
-
+## Problem
-## Context
-
-On the wire, tool schemas travel in a dedicated `tools` field of the model request, not in prompt text. Architecturally, though, "what the model is told it can do" is one coherent concern: prompt sections and the tool list are assembled from the same plugin contributions and consumed at the same moment. The alternative — the loop querying the tool registry separately from the prompt service — splits one concern across two seams.
+On the wire, tool schemas travel in a dedicated `tools` field of the model request, not in prompt text. Architecturally, though, "what the model is told it can do" is one coherent concern: prompt sections and the tool list are assembled from the same plugin contributions and consumed at the same moment.
## Decision
`PromptAssembly { sections, tools }`: the system-prompt service collects ordered text sections AND tool schemas (the tool registry auto-contributes a provider). The loop consumes one assembly per step; adapters map `sections` to the provider's system slot and `tools` to the wire `tools` field. The `system-prompt/assemble` waterfall is therefore a single interception point for everything the model is told up front — tool filtering (ToolSearch / progressive disclosure) is an assembly rewrite, same as prompt edits.
+## Alternatives considered
+
+**The loop queries the tool registry separately from the prompt service** — splits one coherent concern across two seams, and every interception that wants to shape "what the model is told" (tool filtering, plan mode) would need two listeners on two surfaces instead of one assembly rewrite.
+
## Consequences
- One waterfall governs the model's standing context; plugins like plan mode can swap prompt text and visible tools in one listener.
diff --git a/docs/rfc/implemented/architecture/2026-06-13-capability-seams.md b/docs/rfc/implemented/architecture/2026-06-13-capability-seams.md
index 46bfe88d50..907e4cd86b 100644
--- a/docs/rfc/implemented/architecture/2026-06-13-capability-seams.md
+++ b/docs/rfc/implemented/architecture/2026-06-13-capability-seams.md
@@ -1,10 +1,8 @@
# RFC: Capability seams — interface / implementation / consumer split
-Status: implemented (accepted 2026-06-13)
+Status: implemented
-
-
-## Context
+## Problem
The harness has swappable capabilities — bash execution today, sandboxed/remote executors and alternative model providers tomorrow. A capability has three concerns that change at different rates and for different reasons: the *contract* (what the capability is), the *implementation* (how it runs), and the *consumer surface* (what the model and other plugins program against). Bundling them in one package couples those rates of change — swapping a local executor for a sandboxed one would churn the tool schemas the model sees, even though the model-facing contract never changed.
@@ -20,10 +18,13 @@ A swappable capability is **three packages**:
Implementation and consumer then evolve independently: a sandboxed executor replaces `dsh-bash-local` without touching a tool schema.
-Alternatives considered: **one combined package** — rejected because it recouples the three rates of change the split exists to separate (the whole point). **`@cordisjs/plugin-capability`** — a different axis entirely: it is a permission/capability-*security* service (named permissions with inheritance, tested against a session via `ctx.capability.test`), a candidate for the deferred permissions/sandbox work on the `tools/pre-execute` deny/ask seam, NOT a mechanism for swapping implementations. Confusing the two ("capability") is the trap this RFC names.
-
The split is not mandatory when the parts are genuinely one concern: the LLM seam folds interface + consumer into `dsh-llm` (the consumer is the loop itself, not a swappable schema surface) with adapters as the implementation packages. Don't split preemptively — a capability with one conceivable implementation and one consumer stays one package until a second appears.
+## Alternatives considered
+
+- **One combined package** — rejected because it recouples the three rates of change the split exists to separate (the whole point).
+- **`@cordisjs/plugin-capability`** — a different axis entirely: it is a permission/capability-*security* service (named permissions with inheritance, tested against a session via `ctx.capability.test`), a candidate for the deferred permissions/sandbox work on the `tools/pre-execute` deny/ask seam, NOT a mechanism for swapping implementations. Confusing the two ("capability") is the trap this RFC names.
+
## Consequences
More packages and more boilerplate per capability (a `package.json`/`tsconfig`/README trio, the inject wiring). Bought: implementations and consumers ship and version independently, and a new backend never risks the model-facing contract. The rule is documented in [AGENTS.md](../../../../AGENTS.md) § Conventions ("Capability seams are three packages") and [architecture.md](../../../architecture.md) § "Capability seams"; the bash trio is the reference template. When to fold vs. split is a judgment call the architecture doc spells out — this RFC records *why* the default is to split.
diff --git a/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md b/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md
index ecd21b5cc3..7f30240ffd 100644
--- a/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md
+++ b/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md
@@ -1,10 +1,8 @@
# RFC: Two LLM adapters as a design-verification twin
-Status: implemented (accepted 2026-06-13)
+Status: implemented
-
-
-## Context
+## Problem
`dsh-llm` owns a provider-neutral streaming vocabulary — the `StreamChunk` protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`) and the content-block types ([the content-block vocabulary](2026-06-11-content-block-vocabulary.md)). A vocabulary defined against a single adapter risks baking that adapter's quirks into the "neutral" contract: anything the one implementation happens to do becomes the de-facto spec, and the abstraction is unverified until a second provider arrives — by which point the leak is expensive to fix.
@@ -17,7 +15,10 @@ Ship **two** adapters against the one contract from the start, deliberately buil
The rule they enforce: **anything the StreamChunk vocabulary cannot express for BOTH implementations is a core-vocabulary bug**, caught immediately rather than at the next provider. The pair pinned down conventions now documented on `StreamChunk` in `dsh-llm/src/types.ts`: usage emitted before finish, nothing after finish, tool-call `arguments` as raw JSON strings end-to-end, and the two sanctioned error paths (throw from `stream()` *or* end with `finish {kind:'error'|'aborted'}`) that a consumer must handle on both sides — a divergence the library-backed adapter surfaced that a single hand-rolled adapter would have hidden.
-Alternatives considered: **a single adapter** — less code and half the e2e cost, but leaves the "provider-neutral" claim unverified; the vocabulary would encode DeepSeek-via-fetch assumptions silently. **A mock second adapter** — cheaper but doesn't exercise a real provider's wire quirks, so it proves little. The twin is real-on-real.
+## Alternatives considered
+
+- **A single adapter** — less code and half the e2e cost, but leaves the "provider-neutral" claim unverified; the vocabulary would encode DeepSeek-via-fetch assumptions silently.
+- **A mock second adapter** — cheaper but doesn't exercise a real provider's wire quirks, so it proves little. The twin is real-on-real.
## Consequences
diff --git a/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md
index 7abe7ec8ed..135c1671f6 100644
--- a/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md
+++ b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md
@@ -1,12 +1,10 @@
# RFC: Session persistence as an abstract service over the existing `SessionEvent`
-Status: implemented (proposed 2026-06-14, accepted 2026-06-15)
-
-
+Status: implemented
> Merges the original proposal and the decision record for one topic. The proposal's full method-surface and write-path detail lives in git history; this records the decision and the durable, contested choices.
-## Context
+## Problem
Sessions lived only in memory. The example `session-jsonl.ts` plugin (duplicated byte-for-byte in both examples) was write-only telemetry: it buffered `session/event` and appended JSON lines, with no read/replay path, no crash-safety (no fsync, no atomic write, a fire-and-forget dispose drain), no listing, and no format versioning. Nothing could rehydrate a past session from disk into a live agent, so durable resume ("continue yesterday's task"), durable forking, and the ACP `session/load` method ([ACP support](../../proposed/feature/2026-06-14-acp-agent-client-protocol.md)) were all impossible.
@@ -27,6 +25,10 @@ Key choices recorded here because they are durable, contested, and surprising:
- **Metadata is out-of-log.** Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a `SessionHeader` owned by `dsh-session` and attached to a `Session` via a new readonly `session.header` — never in `SessionEventMap`, never reaching `deriveMessages()`. The alternative (a merge-extensible `session/meta` event as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header seam is the cleaner cost. (The header was originally split into an immutable `SessionHeader` plus a mutable `SessionSummary` whose union was `SessionMeta`; the mutable summary was later removed as dead state — see [Drop the mutable session summary](../simplification/2026-06-19-drop-mutable-session-summary.md).)
- **Resume is an async factory, not a change to synchronous create.** `ctx.agents.resume({ resumeSessionId })` awaits `ctx.sessionPersistence.load`, recreates the live session with the loaded events (so `lastTurnNumber`/`deriveMessages` continue), and starts a fresh agent on the resumed id (NOT `${agentId}-session`). The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever); `resume` rejects with a clear error when it is absent.
+## Alternatives considered
+
+Each key choice above records its rejected alternative where the choice is stated: a **chunk-filtered canonical log** (Codex's `policy.rs` shape) — breaks the contiguous-seq contract; **truncating a crashed turn** — silently destroys a long autonomous run's real work; an **in-log `session/meta` event as line 0** — metadata is not replayable state; **hard-injecting `sessionPersistence` into the loop** — would pend non-persistent demos forever.
+
Format versioning: the header carries a `version`; `load` rejects any non-current version (no migration — the pre-release session format is pinned at `SESSION_FORMAT_VERSION = 0` and absorbs shape churn, per the AGENTS.md pre-release stance). Stated honestly: append-only + flush is robust to partial trailing writes (tolerated on load) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option later.
## Consequences
diff --git a/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md
index 63ebc1c875..10a6209f70 100644
--- a/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md
+++ b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md
@@ -1,10 +1,8 @@
# RFC: Every session event is enclosed in a turn
-Status: implemented (accepted 2026-06-15)
+Status: implemented
-
-
-## Context
+## Problem
A durable session-persistence backend (added in a companion change) uses the **turn** as its crash-recovery boundary: a crash can leave an unclosed final turn, which `load` closes with a synthetic `turn/end {kind:'interrupted'}` while preserving the turn's real events (see [session persistence](2026-06-14-session-persistence.md)). This recovery is only well-defined if nothing *legitimately* durable sits OUTSIDE a turn — between the last `turn/end` and the next `turn/start` — since such an event would be swept into the next turn's interrupted close.
@@ -15,8 +13,6 @@ That assumption did not hold. Two paths recorded events outside any turn:
In case 2, if the injected `context/message` is the last event before a flush/dispose (no later turn appends a `turn/end`), `scanLog` treats it as crash debris and **drops it on resume** — the injected context is durably on disk but silently lost on reload. Case 1 was benign in isolation (a `user/message` is always followed by the turn it triggered) but made the "what may appear outside a turn" rule fuzzy.
-Two ways to fix it: relax the *reader* (let `scanLog` commit events that sit outside an open turn), or constrain the *producer* (make every event turn-enclosed so the reader's simple "last `turn/end`" rule is both correct and complete). We chose the producer-side invariant: a single, checkable rule beats a more permissive boundary scan that has to reason about partial turns *and* loose between-turn events.
-
## Decision
**Every session event lives inside a turn** — between a `turn/start` and its matching `turn/end`. Concretely:
@@ -29,6 +25,10 @@ Two ways to fix it: relax the *reader* (let `scanLog` commit events that sit out
The serializability invariant is enforced at the same source boundary (`Session.append` throws on non-JSON-serializable data), so "what may enter the log" is now governed in one place rather than discovered downstream by whichever backend happens to be watching.
+## Alternatives considered
+
+**Relax the reader instead of constraining the producer** — let `scanLog` commit events that sit outside an open turn. Rejected: a single, checkable producer-side rule beats a more permissive boundary scan that has to reason about partial turns *and* loose between-turn events.
+
## Consequences
The turn is now the *single* durability/replay boundary, so [session persistence](2026-06-14-session-persistence.md)'s crash-recovery rule is complete, not merely sufficient: an interrupted final turn is closed (with a synthetic `turn/end {interrupted}`) and its real events preserved, with zero risk of conflating between-turn context into it, because there is no between-turn context. `scanLog` stays simple (one possibly-open final turn, never a loose between-turn event), and an idle background-task notice survives persist + resume.
diff --git a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md
index 50925b49cb..0ecf3e0e78 100644
--- a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md
+++ b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md
@@ -16,9 +16,9 @@ Without a `ctx.fs` interface, swapping local filesystem access for a sandboxed o
We need the filesystem tools to land in the same capability-seam shape as bash before they become a public package surface.
-## Proposal
+## Decision
-Introduce filesystem access as a first-class capability seam following [the capability-seam RFC](../../implemented/architecture/2026-06-13-capability-seams.md):
+Filesystem access is a first-class capability seam following [the capability-seam RFC](../../implemented/architecture/2026-06-13-capability-seams.md):
1. `@deepseek-ai/dsh-fs` (`packages/fs/fs`) owns the abstract `ctx.fs` service, the filesystem vocabulary types, and the `fs/*` policy event vocabulary.
2. `@deepseek-ai/dsh-fs-local` (`packages/fs/fs-local`) provides the first implementation, backed by the local filesystem.
@@ -26,7 +26,7 @@ Introduce filesystem access as a first-class capability seam following [the capa
The consumer package depends only on the interface package, never on `dsh-fs-local`. A deployment that wants a different backend loads a different provider for `ctx.fs` without changing the tool schemas or model-facing prompt guidance.
-The read-before-write/edit and observed-state policy is a fourth package, `@deepseek-ai/dsh-fs-policy` (`packages/fs/fs-policy`), contributed through the `fs/*` event gate rather than living on `ctx.fs`. This RFC established the three-package seam; the split of policy off the provider base class is decided by [the split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md), and its realization as an event-gate plugin (not a method service) by [the event-gate RFC](2026-06-26-file-context-as-event-gate.md). This document is updated to describe that landed four-package shape.
+The read-before-write/edit and observed-state policy is a fourth package, `@deepseek-ai/dsh-fs-policy` (`packages/fs/fs-policy`), contributed through the `fs/*` event gate rather than living on `ctx.fs`; a deployment loading `dsh-tool-fs` also loads `dsh-fs-policy` to get read-before-write/edit. This RFC established the three-package seam; the split of policy off the provider base class is decided by [the split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md), and its realization as an event-gate plugin (not a method service) by [the event-gate RFC](2026-06-26-file-context-as-event-gate.md). This document is updated to describe that landed four-package shape.
The first backend is deliberately local-only: `dsh-fs-local` implements `ctx.fs` against the host filesystem. Future sibling backends can provide sandboxed, remote, virtual, or project-scoped filesystems behind the same interface.
@@ -57,7 +57,7 @@ The root `tool-fs` plugin registers the full filesystem tool suite (`read`, `wri
`@deepseek-ai/dsh-fs` owns a semantic filesystem service. It is higher-level than `readFile` / `writeFile` so `tool-fs` does not reimplement path resolution, versioning, text decoding, binary rejection, pagination, atomic replacement, symlink behavior, or literal edit semantics.
-The exact TypeScript signatures are implementation details for the PR, but the interface must cover four semantic operations:
+The interface covers these semantic operations:
- Resolve a model/plugin-supplied path into a backend-defined target.
- Stat target metadata without reading file contents.
@@ -73,7 +73,7 @@ The provider seam also carries the freshness hooks that policy builds on — but
Authorization is version freshness, not a full/partial view distinction: any read records the target's version, and a later write/edit is authorized as long as the file is still at that version — so a windowed read of lines 100-150 authorizes an edit of line 120. The observed-state store is a `WeakMap>` inside `dsh-fs-policy`; `dsh-fs` holds none of it and treats the actor as opaque. (This RFC first modeled a `FileState` cache with `full`/`partial` views on `ctx.fs`; the split-fs-seam and event-gate RFCs replaced that with the freshness-based policy plugin described here.)
-Path resolution should be explicit and allowed to be async. Local resolution may only normalize a path, but sandboxed/remote/project-scoped backends may need I/O to resolve a user-supplied path into a stable target identity.
+Path resolution is explicit and allowed to be async. Local resolution may only normalize a path, but sandboxed/remote/project-scoped backends may need I/O to resolve a user-supplied path into a stable target identity.
Resolved targets must expose at least three concepts:
@@ -81,7 +81,7 @@ Resolved targets must expose at least three concepts:
- An opaque `targetKey`, used for stale guards and file-state lookup. The local backend might use a realpath-like key; a remote backend might use a workspace URI or file id. Consumers must not parse or assume this is a local absolute path.
- A `displayPath`, used for model/UI-facing output. It may be a local absolute path, workspace-relative path, or remote URI depending on the backend.
-Read and mutation results must include an opaque file `version`. A local backend can use mtime/size or a hash-like token; a remote backend can use a revision id. `ctx.fs` records versions in its file-state store for stale checks; consumers may display related metadata but must not interpret the version token.
+Read and mutation results must include an opaque file `version`. A local backend can use mtime/size or a hash-like token; a remote backend can use a revision id. The `dsh-fs-policy` plugin records versions for stale checks; consumers may display related metadata but must not interpret the version token.
The provider hands back decoded text: `readText` returns a whole regular text file, `streamText` streams the same text semantics for large files. Both own regular-file checks, bounded line/output handling is NOT theirs — line windowing, numbered-line rendering, and total-line accounting live in the executor (`dsh-tool-fs`), which reads through `ctx.fs` and renders the model-facing window. The provider owns UTF-8 decoding and binary/NUL rejection; it does not know about line windows or views.
@@ -89,7 +89,7 @@ Observed-state recording is not on `ctx.fs`: after a successful read the executo
Full-file writes create or replace UTF-8 text files. Backends may create parent directories when that behavior is supported and documented. Existing non-regular targets are rejected. `writeText` takes an optional expectation: `createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED` (the path the policy uses for an unobserved owner); `replaceIfVersion` replaces only when the target exists at the observed version, else `FS_STALE_VERSION`; omitting the expectation is the unconditional bare-provider create-or-overwrite. The policy plugin chooses which expectation to supply from the owner's observed state.
-Literal edit is a provider primitive (`editText`), not composed in `tool-fs` from a read plus write. Literal matching, duplicate-match rejection, CRLF preservation, binary rejection, optional stale-version checking, and atomic read-modify-write must stay together inside the backend's mutation critical section. `editText` takes the same optional version expectation; the stale check runs before literal matching so an edit against an old read reports `FS_STALE_VERSION`. A remote backend may implement edit as a native compare-and-edit operation; the consumer should not force local-style composition.
+Literal edit is a provider primitive (`editText`), not composed in `tool-fs` from a read plus write. Literal matching, duplicate-match rejection, CRLF preservation, binary rejection, optional stale-version checking, and atomic read-modify-write must stay together inside the backend's mutation critical section. `editText` takes the same optional version expectation; the stale check runs before literal matching so an edit against an old read reports `FS_STALE_VERSION`. A remote backend may implement edit as a native compare-and-edit operation; the consumer does not force local-style composition.
The policy plugin, not `ctx.fs`, gates on prior observation: an `edit` requires a prior observation by the owner (else `FS_NOT_OBSERVED`), and the recorded version is passed to `editText` as the CAS basis. With the policy plugin absent, `ctx.fs` alone is a complete unconstrained seam (unconditional write/edit); the tool is never method-coupled to the policy.
@@ -114,56 +114,30 @@ Each tool follows the same execution shape:
The package registers prompt guidance through `ctx.systemPrompt.section(...)` and registers schemas through `ctx.tools.register(...)`. Tool schemas still flow into the normal prompt assembly path via `SystemPrompt.assemble()` and `ToolRegistry.schemas()`; no agent-loop changes are required.
-The tool package must keep model-facing contracts stable when backends change. A local backend and a remote backend may resolve paths differently internally, but the `read` / `write` / `edit` schemas should not change solely because the backend changes.
+The tool package keeps model-facing contracts stable when backends change: a local backend and a remote backend may resolve paths differently internally, but the `read` / `write` / `edit` schemas do not change solely because the backend changes.
The default deployment requires a prior `read` before updating an existing file with `write` or `edit`. `tool-fs` does not implement this by checking whether a tool named `read` ran: it dispatches the `fs/write-intent`/`fs/edit-intent` events (passing the execution context as the opaque actor), and the `dsh-fs-policy` plugin derives the owner, gates on prior observation, and supplies the version expectation. Any windowed read authorizes a later write/edit as long as the file is unchanged. Creating a new file with `write` does not require prior observation.
The root plugin registers the full suite by composing the per-tool registration helpers. It injects `fs`, `tools`, and `systemPrompt`.
-## Migration plan
+## Testing
-This RFC starts from `origin/master`, where no filesystem tool package exists yet. The landed implementation adds the new three-package topology directly:
+Tests follow the package boundary, not only the user-visible tools: the service seam in `dsh-fs`; real filesystem behavior through the `ctx.fs` interface in `dsh-fs-local` (resolution, symlinks, streaming, binary/UTF-8 rejection, unconditional and version-guarded writes, literal-edit semantics, line-ending preservation, structured `FsError` codes); the consumer surface in `dsh-tool-fs` against the real local provider (mock only the model/clock, never the collaborator); and integration through `ctx.tools.execute()` with and without `dsh-fs-policy`, world-verified by reading files back from disk rather than trusting the returned `ContentBlock[]`. The observed-state/owner-derivation policy is tested in `dsh-fs-policy`, not here.
-1. Add `packages/fs/fs` with the `ctx.fs` abstract service and vocabulary types.
-2. Add `packages/fs/fs-local` with the local backend implementation and backend-level tests.
-3. Add `packages/fs/tool-fs` with the model-facing `read`, `write`, and `edit` tools over `ctx.fs`.
-4. Update `docs/architecture.md`, `packages/README.md`, package READMEs, build/typecheck config, and aggregate maintenance scripts such as `scripts/publint-all.ts`.
+The defensive-pattern classes this repo has been bitten by are pinned directly:
-This RFC's first landing kept the observed-state store behind `ctx.fs`. The split-fs-seam and event-gate RFCs then moved it into the standalone `@deepseek-ai/dsh-fs-policy` plugin on the `fs/*` event gate, which is the shipped shape; a deployment loading `dsh-tool-fs` also loads `dsh-fs-policy` to get read-before-write/edit.
+- **Atomic-write temp-file safety.** Write/edit stage through a private random `0700` directory next to the target with an exclusive owner-only (`'wx'`, `0o600`) temp file, cleanup on failure, and a final atomic rename — mirroring the bash spill-file rules, because predictable world-readable temp paths invite symlink races and disclosure. Tests assert the permissions and that a pre-existing temp path is not clobbered; this primitive is a standing requirement of the seam.
+- **`targetKey` identity through symlinks.** Two input paths resolving to the same realpath share one observed-state entry: a `read` via path A satisfies the read-before-edit guard for an `edit` via symlink path B, and a stale write through one path is detected through the other.
+- **Concurrency / stale races.** Two concurrent write/edit operations against the same target settle deterministically — one succeeds, the other is rejected with `FS_STALE_VERSION` — and a successful edit refreshes recorded state so the same owner's next edit proceeds.
+- **HMR safety and disposal.** Disposing the backend's fiber withdraws the `ctx.fs` provider; a later provider starts with no inherited state.
-Example leaf configs stay bash-only in this landing. Wiring `examples/coding-agent` or `examples/acp-agent` to `dsh-fs-local` + `dsh-tool-fs` changes the model prompt, visible tool schemas, and ACP snapshot transcript, so it should land as a follow-up UX/example change with prompt and snapshot updates in the same PR.
+## Alternatives considered
-If this work is split into multiple PRs, they should follow the seam order:
+- **Model-facing tools directly over `node:fs`** — the tool package would own execution policy, path resolution, atomic writes, text decoding, and edit semantics at once, coupling the three independently-changing concerns the Problem names and churning schemas on any backend swap.
+- **One combined `dsh-fs-tools` package** — the pre-seam shape; rejected for the same interface/implementation/consumer split as bash, and the combined name never became public surface.
+- **Observed-state on `ctx.fs`** — the shape this RFC first landed; superseded by [the split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md) and [the event-gate RFC](2026-06-26-file-context-as-event-gate.md): a sandboxed/remote backend must not inherit model-facing observation policy, so the provider keeps only the version token and the optional version-guarded mutation.
-1. Interface PR: `dsh-fs` only, with service registration and contract tests.
-2. Implementation PR: `dsh-fs-local`, with real filesystem behavior tests.
-3. Consumer PR: `dsh-tool-fs`, docs, and integration tests; example wiring follows in a separate prompt/snapshot PR.
-
-The earlier combined package name `@deepseek-ai/dsh-fs-tools` should not become part of the new public surface.
-
-## Tests
-
-Tests should follow the package boundary, not only the user-visible tools.
-
-`dsh-fs` tests cover the service seam itself: a provider registers `ctx.fs`, duplicate providers follow Cordis service behavior, disposal removes the service, and any shared contract helpers or type-level utilities behave as documented.
-
-`dsh-fs-local` tests cover real filesystem behavior through the `ctx.fs` interface, not through model tools. They should include path resolution, absolute paths, `..` segments, symlinks inside and outside the configured base directory, reading small and large text files, streaming, binary-file rejection, invalid-UTF-8 rejection, abort handling, unconditional and version-guarded full-file writes, `createIfAbsent`/`replaceIfVersion` semantics, parent-directory creation, non-regular target rejection, literal edit success/failure, unique-match enforcement, replace-all behavior, line-ending preservation, stale-version rejection (guarded edit against an old version), and structured `FsError` codes. The observed-state/owner-derivation policy is NOT here — it lives in `dsh-fs-policy` and is tested there.
-
-Beyond the happy/sad paths above, `dsh-fs-local` tests must cover the defensive-pattern classes this repo has been bitten by:
-
-- **Atomic-write temp-file safety**, not just cleanup. The atomic replace must write its temp file into a private (`0700`) directory, with a random name and an exclusive owner-only (`'wx'`, `0o600`) open, mirroring the bash spill-file rules — predictable world-readable temp paths invite symlink races and disclosure. Assert the temp file's permissions and that a pre-existing temp path does not get clobbered, alongside the existing cleanup-on-failure path.
-- **Implementation requirement:** `dsh-fs-local` write/edit use the same private-temp primitive: a random `0700` staging directory next to the target, an exclusive `0o600` temp file, cleanup on failure, and a final atomic rename. Do not move this RFC to `implemented/` if that primitive regresses or is deliberately revised.
-- **`targetKey` identity through symlinks.** Two different input paths that resolve to the same realpath must share one file-state entry: a `read` via path A must satisfy the read-before-edit guard for an `edit` via symlink path B, and a stale write through one path must be detected through the other. This is the contract that makes the stale guard correct, so test it directly.
-- **Concurrency / stale races.** The RFC names edit as race-prone (see Risks). Test that two concurrent write/edit operations against the same target settle deterministically: one succeeds and the other is rejected with `FS_STALE_VERSION` rather than silently overwriting, and that a successful edit refreshes recorded state so an immediately-following edit by the same owner proceeds.
-- **HMR safety and disposal.** `dsh-fs-local` registers `ctx.fs` and owns the in-memory file-state store, so it needs its own HMR-safety test (register the backend on a fiber, dispose it, assert the `ctx.fs` provider is withdrawn and the file-state store is released — a later provider starts with no inherited state).
-
-`dsh-tool-fs` tests cover the consumer surface against the real `dsh-fs-local` provider (mock only the model/clock, not the collaborator). They should verify tool schemas, argument validation, prompt-section registration, formatting of successful results, propagation of backend `FsError` codes into `isError` tool results through `ctx.tools.execute()`, that read/write/edit dispatch the `fs/*` events (passing the execution context as the actor), root-plugin suite registration, and HMR cleanup of both tool schemas and prompt sections.
-
-Integration tests should load `dsh-fs-local` plus `dsh-tool-fs` (and, for the default deployment, `dsh-fs-policy`) and execute `read`, `write`, and `edit` through `ctx.tools.execute()` to prove the packages work together without bypassing the tool registry — including a bare-provider path (no `dsh-fs-policy`) where an unread edit/overwrite succeeds. They must verify the world, not the tool's self-report: after a `write`/`edit`, read the file back from disk and assert byte-identical content (and that untouched files are unchanged), rather than trusting the returned `ContentBlock[]`. Each integration/e2e test owns its resources — create the harness in the test, run against a per-test temporary directory, and dispose the harness and remove the directory in `afterEach` even on failure or timeout.
-
-Repo gates for the implementation include the focused vitest suites, `pnpm run typecheck`, `pnpm run test:coverage` for runtime code, and build/publint coverage after adding package entrypoints.
-
-## Risks
+## Consequences
**`cwd` can be mistaken for a sandbox.** The local backend's base directory is a resolution default, not automatically a containment boundary. If containment is required, it must be enforced by the backend contract or by a permission/sandbox plugin on `tools/execute`.
@@ -171,14 +145,14 @@ Repo gates for the implementation include the focused vitest suites, `pnpm run t
**The interface can become too thin.** If `ctx.fs` only mirrors `node:fs` primitives, `tool-fs` will reimplement binary detection, pagination, atomic writes, and edit semantics. That recreates the coupling this RFC is trying to avoid.
-**Edit semantics are race-prone.** Literal edit is a read-modify-write operation. Without a stale-content guard or backend-level atomic edit primitive, concurrent edits can overwrite each other. The first implementation should document its guarantees clearly; stronger compare-and-swap semantics can be added later if needed.
+**Edit semantics are race-prone by nature.** Literal edit is a read-modify-write operation; the guard is the backend's atomic mutation critical section plus the optional version expectation, so concurrent edits settle deterministically — one wins, the other gets `FS_STALE_VERSION`.
**Observed state does not belong on `ctx.fs`.** Recording what an execution context has seen is workflow policy, not raw filesystem I/O. This RFC first placed it inside the filesystem seam; the split-fs-seam RFC then established that a sandboxed/remote backend should not inherit model-facing observation policy, and moved it into the `dsh-fs-policy` plugin. The provider seam keeps only what write/edit safety genuinely needs at the storage layer — a backend-minted version token and an optional version-guarded mutation — while the policy plugin owns owner derivation, observed-state, and read-before-edit gating over the `fs/*` events.
**The `resolve`-then-operate shape costs an extra round-trip per call.** Each tool may resolve a path to an `FsTarget` and then issue the read/write/edit as a separate `ctx.fs` call. For the local backend this is negligible (resolution is in-memory path normalization), but a remote/sandboxed backend may turn each step into its own request, so a single `read` can become two network round-trips. Backends where the round-trip matters can cache or fold resolution internally while preserving the observable contract.
-**File-state persistence is deferred.** The first implementation can keep file state in memory. Resumed sessions should conservatively require files to be read again before write/edit tools accept updates until a future session-event or persistence mechanism makes file state replayable.
+**Observed-state persistence is deferred.** Observed state lives in memory (the `WeakMap` inside `dsh-fs-policy`), so a resumed session conservatively requires files to be read again before write/edit until a future session-event or persistence mechanism makes observation replayable.
-**Error codes become part of the seam.** `FsError` codes make stale-version and observation failures machine-routable through the existing structured error taxonomy. The cost is that `dsh-fs` imports the shared `HarnessError` base from `dsh-llm`; that dependency is intentional and should stay limited to the error vocabulary.
+**Error codes become part of the seam.** `FsError` codes make stale-version and observation failures machine-routable through the existing structured error taxonomy. The cost is that `dsh-fs` imports the shared `HarnessError` base from `dsh-llm`; that dependency is intentional and stays limited to the error vocabulary.
**Package churn is front-loaded.** The three-package split adds boilerplate before there is more than one backend. This is intentional: filesystem access is a likely sandbox/remote boundary, and changing the package surface after shipping model-facing tools would be more expensive.
diff --git a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md
index 35cb2eb6b3..e786adbfe8 100644
--- a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md
+++ b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md
@@ -6,9 +6,9 @@ Status: implemented
Several ACP and tool-bash limitations were symptoms of the same missing seam: plugins could create or resume agents through `ctx.agents`, but they could not own and dispose one agent independently, and long-running bash tasks carried no stable owner in the executor itself. ACP aborted and awaited agents on disconnect but could not unregister just that session's agent; `session/cancel` could not cancel queued-but-not-yet-started work; and `tool-bash` kept task ownership in a plugin-local `Map`, so an HMR reload could make an old task look unowned.
-## What was implemented
+## Decision
-The three seams shipped across a stacked chain of PRs (the queue-aware cancel, the `AgentHandle` disposer, and the bash owner token), each converged independently.
+Three seams: the queue-aware cancel, the `AgentHandle` disposer, and the bash owner token.
### 1. Queue-aware `Agent.cancel(reason?)`
@@ -24,7 +24,9 @@ A new `cancel()` verb on the `Agent` interface — the single public stop primit
Background-task ownership moved from a `tool-bash` plugin-local `Map` into the executor. `BashExecRequest` gains an optional `owner?: string`; the resolved `BashExecSpec` carries it as required-but-nullable `owner: string | undefined` (a forgotten owner is a visible `undefined`, never a silently-absent property). The executor stores the token on its task and exposes it via a new `BashExecutor.ownerOf(id): string | undefined` seam (NOT on the public `BashTask` — one read path, no redundant API). `tool-bash` deletes its `Map` entirely: it stamps `exec.agent?.session.header.id` as the owner at `start`, and `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token with `!== undefined` semantics (an empty-string token is still a real owner). The completion notice finds the live agent by scanning `ctx.get('agents')?.list()` for `agent.session.header.id === ownerToken` (read via `ctx.get` — `onTaskDone` runs on the bash fiber, a foreign fiber, where the `ctx.agents` proxy would throw). Because ownership now lives on the task in the executor (disposed with the `dsh-bash` fiber), it SURVIVES a `tool-bash` HMR reload — closing the old `XXX(tool-bash-owner-hmr)` gap. (The `onTaskDone` listener is still effect-scoped to `tool-bash`'s `apply`, so a completion landing during the reload gap still drops its one notice — the pre-existing reload-gap drop — but the ownership fence itself is HMR-proof.)
-## Acceptance Criteria (met)
+## Verification
+
+These invariants hold and are pinned by tests:
- ACP disconnect/session close leaves no registered agent AND no session-store entry for that session, even when `session/load` races teardown.
- `session/cancel` before a queued prompt starts prevents that prompt from running and cannot batch the next prompt into the cancelled turn.
@@ -37,6 +39,12 @@ The bash owner-token comparison relies on `session.header.id` being unique among
The planned resolution is to remove the precondition by construction — see [unify the agent id and the session id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md): once an agent IS its session (one id), the registry's existing unique-`agentId` check is a unique-session-id guarantee and no two live agents can share a session token.
-## Notes
+## Alternatives considered
+
+- **A public `BashTask.owner` field** instead of the `BashExecutor.ownerOf(id)` seam — rejected: one read path, no redundant API.
+- **Sibling cordis effects for the agent's session lifecycle** — rejected: a fiber unload disposes sibling effects concurrently (`Promise.all`), racing the session's `onAppend` detach against the loop's closing `session/flush`; the single composite effect's ordered LIFO chain is what captures the closing `turn/end` on both disposal paths.
+- **A separate step-only `abort()` beside `cancel()`** — shipped originally, then removed as unused; `cancel()` is the single public stop primitive ([the public-stop-surface RFC](../simplification/2026-06-20-public-agent-stop-surface.md)).
+
+## Consequences
This touched public interfaces (`Agent`, `AgentFactory`, the bash seam) deliberately, not as a local ACP patch. The simple synchronous `Agent.send()` ergonomics were preserved; the async lifecycle path is additive, for owners that need it.
diff --git a/docs/rfc/implemented/architecture/2026-06-18-session-surface.md b/docs/rfc/implemented/architecture/2026-06-18-session-surface.md
index 3f4419b5ca..4c8b5a81e3 100644
--- a/docs/rfc/implemented/architecture/2026-06-18-session-surface.md
+++ b/docs/rfc/implemented/architecture/2026-06-18-session-surface.md
@@ -1,8 +1,8 @@
# RFC: Session surface — a linked list over the event log for LLM message derivation
-Status: implemented (accepted 2026-06-18)
+Status: implemented
-## Context
+## Problem
The `Session` event log is the single source of truth ([event-sourced sessions](2026-06-11-event-sourced-sessions.md)), but the only view over it was `deriveMessages()` — a linear scan that filtered and transformed raw events into `Message[]`. This creates problems for session-history-manipulating plugins (compaction, tool-call result pruning, etc.). Without a central mechanism, each plugin would need to wrap `agent/request` to rewrite the message list — a pattern that suffers from listener-ordering fragility, provides no durable record of what was changed, and forces repeated changes to the core `deriveMessages()` whenever a new manipulation is added. A central hub in the `session` package, with a provenance-recording mechanism and enough flexibility for future plugins to manipulate session history through a stable API, lays a solid foundation for plugin development.
@@ -29,13 +29,11 @@ export type SurfaceOp =
2. **Replace** — remove nodes from `start` through `end` (both inclusive) and insert a new node in their place. Both `start` and `end` must be valid surface node seqs in the current surface; `start === end` replaces a single node. The node's `sourceEventSeqs` must contain every shadowed surface node. The shadowed events remain in the log but are no longer on the surface.
-The both-ends-inclusive design was chosen over half-open `[start, endExclusive)` because the surface is a doubly-linked list — both ends are naturally named by node seqs, and single-node replacement (`start === end`) is a common case that reads naturally with inclusive semantics.
-
### SurfaceManager: delta-based, not full rebuild
A `SurfaceManager` class (private to `Session`) maintains the cached linked list. It tracks `_lastProcessedSeq` and processes only the **delta** (new events since the last access) rather than rescanning the entire log. Because the log is append-only, prior events never change — full rebuild is only needed after a wholesale log replacement (e.g., seeding).
-Why delta processing? The naive approach (a dirty flag + full rebuild on every access) would be O(N²) over a session's lifetime — every single-event append triggers a complete scan of all prior events. Delta processing is O(1) when no new events and O(new events) when new events arrive.
+Delta processing is O(1) when no new events and O(new events) when new events arrive.
`deriveMessages()` uses the surface when surface markers exist, falling back to the existing linear scan for sessions without markers (backward compatibility).
@@ -53,6 +51,12 @@ The dev-mode invariants plugin validates: `sourceEventSeqs` references (non-empt
Because the surface is the SOLE derivation path, a surface-eligible event that carries no `surfaceOp` marker is invisible to `deriveMessages()` — it would land in the log yet silently drop from history on resume/fork. `append`'s typed overload makes the marker mandatory for `SurfaceEventType` events at compile time, but only when the type argument is a SPECIFIC literal; when it widens to the `SessionEventType` union (a caller iterating raw events, e.g. `for (const e of log) append(e.type, e.data)`) the conditional rest collapses to optional and the compiler stops enforcing it. The marker requirement is therefore ALSO checked at runtime in two places: `append` itself throws on a marker-less surface-eligible event (covering the union-widening loophole), and the `Session` seed constructor re-checks the same invariant (alongside its seq-contiguity and JSON-serializability checks) so a seed/load/fork — which arrives as raw `SessionEvent[]`, bypassing `append` — is REJECTED rather than constructing a session that resumes with missing history. (No backward-compat path for surface-less logs: per the pre-release stance there is no persisted user data to preserve, so such a log is rejected, not upgraded.)
+## Alternatives considered
+
+- **Per-plugin `agent/request` wrapping** (the pre-surface pattern for history manipulation) — listener-ordering fragility, no durable record of what was changed, and every new manipulation forces another change to core `deriveMessages()`.
+- **Half-open `[start, endExclusive)` replace ranges** — rejected: the surface is a doubly-linked list whose ends are naturally named by node seqs, and single-node replacement (`start === end`) reads naturally with inclusive semantics.
+- **Full rebuild behind a dirty flag** instead of delta processing — O(N²) over a session's lifetime: every single-event append would rescan all prior events.
+
## Consequences
- **`packages/core/session`**: New `surface.ts` (`SurfaceManager`), new types (`SurfaceOp`, `SurfaceIntent`), new fields on `SessionEvent`, modified `append()` (third required `SurfaceIntent` param), refactored `deriveMessages()` (walks the surface as the sole derivation path), surface-aware `repair.ts`. The seed constructor rejects a surface-eligible seed event missing its `surfaceOp` marker (see § Invariants).
diff --git a/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md b/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md
index 44ae67f872..5923ff434f 100644
--- a/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md
+++ b/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md
@@ -1,6 +1,6 @@
# RFC: Shared persistence write coordinator
-Status: implemented (proposed and accepted 2026-06-18, implemented 2026-06-20)
+Status: implemented
## Problem
@@ -32,6 +32,11 @@ The single design choice that keeps the seam clean: the crash-repair "where is t
The shared `runPersistenceContract` (public-API contract) keeps running for every backend. A new `runCoordinatorContract` (`tests/coordinator-contract.ts`) holds the write-path orchestration — adoption, HMR, collision, dispose-drain, crash-tail repair — and runs once per backend through a `CoordinatorFixture` (an in-memory reference + jsonl + sqlite). The per-backend specs shrank to storage mechanics only (JSONL: path safety, fsync rollback, bucket listing; SQLite: schema version, `scanRows`, transaction rollback). A through-coordinator torn-tail→load→`commitRepair` test per real backend (via a `corruptTail` fixture hook) keeps the coordinator's torn-marker repair branch covered under the 100% per-file gate — the contract crash test only produces synthetic closers, never a torn marker, so it could not reach that branch.
-## Risks and what we gave up
+## Alternatives considered
+
+- **A base class the backends extend** — rejected for composition: a backend exposes only the hooks, cannot reach the coordinator's private orchestration state, and a third-party backend may still implement the abstract service directly without the coordinator at all.
+- **A wider hook surface** — each candidate hook folded away: there is no separate `materialize` hook (the materialize-write must commit atomically with the first event batch inside `appendBatch`), no separate create-collision probe (it is `loadStored(id) !== undefined`), and no coordinator pass-through for `list()` (listing needs none of the orchestration).
+
+## Consequences
The pre-extraction duplication was verbose but explicit — each backend read top-to-bottom. The coordinator adds one indirection (the hook seam) and one new concept (the opaque torn marker). This clears the bar because the centralized logic is the correctness-heavy part that was already being fixed twice, and the hook set is narrow (six methods, no inheritance). The hook surface was deliberately held to the minimum: the create-collision probe is NOT a separate hook — it folds into `loadStored(id) !== undefined`; there is no separate `materialize` hook (folded into `appendBatch` for atomicity); `list()` stays a backend method with no coordinator pass-through (listing needs none of the orchestration). The net effect is a reduction: one orchestration copy instead of two, the backends shrank by ~1200 lines of duplicated churn, and a future backend implements a handful of small primitives instead of copying the entire `session/event` → buffer → flush machinery.
diff --git a/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md
index 0b4975089d..8171a05d30 100644
--- a/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md
+++ b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md
@@ -1,6 +1,6 @@
# RFC: Branded IDs everywhere they belong
-Status: implemented (proposed and accepted 2026-06-20)
+Status: implemented
## Problem
@@ -12,7 +12,7 @@ The bash **owner token** is the related sub-case: `BashExecRequest.owner?: strin
**Gap 2 — brand erosion at the seams of the *already-branded* IDs.** Even `CallId`/`SessionId`/`AgentId` decay back to bare `string` at exactly the places confusion is most likely: the registry/store `Map` key types and most public method params. Representative sites: `SessionStore.store = new Map()` and `create`/`prepare(id?: string)`/`get(id: string)` (`packages/core/session/src/index.ts`); `AgentRegistry.store = new Map()` and `register`/`get(id: string)` (`packages/core/agent/src/index.ts`); `ToolPresenter.pending = new Map()` keyed by call id and `call(callId: string)`/`result(callId: string)` (`packages/ui/acp/src/index.ts`); the ACP session-id surface beyond the store map — `SessionRecord.sessionId: string`, `bySession = new WeakMap()`, `loadingIds = new Set()`, `requireSession(sessionId: string)`, and the exported `streamSessionEventUpdate(sessionId: string, …)` (`packages/ui/acp/src/index.ts`); and the persistence coordinator's `Map` keyed by session id (`packages/session-persistence/session-persistence/src/coordinator.ts`). A brand that is dropped at the `Map` key buys nothing on lookups — the value of the existing brands is partly unrealized.
-## Proposal
+## Decision
A type-only change. Brands are zero-cost casts; nothing about runtime behavior, serialization, comparison, or the wire format changes. The work is in three parts, all honoring the existing "not every string" policy.
@@ -40,7 +40,9 @@ export function OwnerToken(id: string): OwnerToken {
}
```
-## Why a distinct OwnerToken brand (not SessionId)
+## Alternatives considered
+
+### Why not typing `owner` as `SessionId`?
The obvious shortcut is to type `owner` as `SessionId` directly — it always *is* one. We reject that. The bash executor seam is a capability seam (interface `dsh-bash`, implementation `dsh-bash-local`, consumer `dsh-tool-bash`) and its owner token is *documented as deliberately opaque*: the executor "never interprets it (no access policy lives in the seam — that is the consumer's job)" (`packages/bash/bash/src/types.ts`). Typing the seam's field as `SessionId` would import `dsh-session`'s vocabulary into a package that must not know what an owner token *means* — it would couple a generic execution backend to the session model and contradict the opaque-token design. A sandboxed or remote executor that replaces `dsh-bash-local` should not inherit a session dependency. The distinct `OwnerToken` brand keeps the seam decoupled: `dsh-bash` knows only "an owner is some opaque branded token," and the `dsh-tool-bash` consumer — which already decides the access policy — is the single boundary that casts its `SessionId` into an `OwnerToken`. The brand still delivers the safety win (you cannot pass a `BashTaskId` or a raw string where an owner is expected) without the coupling.
@@ -54,15 +56,12 @@ Kept deliberately narrow per the "not every string needs a brand" policy. Each o
- **Numeric ordinals** — turn number, step number, and the event `seq` are `number`, not `string`, so `Branded` does not apply; a parallel `number & { readonly [BRAND]: B }` variant could brand them, but they are positional ordinals rarely passed across boundaries, so the payoff is low.
- **Validated construction** — the brand factories are pure casts with no runtime check, and every boundary (ACP `sessionId`, provider-issued `call.id`, the empty-string fallback in `dsh-llm-deepseek`) trusts the raw string today. A `SessionId.parse()` / `isValid()` companion that throws on malformed input at boundaries is a genuine gap, but it is a *runtime-behavior* change with its own design (what is "malformed"? what do we do on failure?) and belongs in its own RFC, not bundled into this type-only pass.
-## Acceptance criteria
+## Verification
-- `BashTaskId` and `OwnerToken` are defined in `dsh-bash` and threaded end-to-end: the executor seam, the `dsh-bash-local` generation site, and the `dsh-tool-bash` model-facing surface all speak the brands; `dsh-bash` gains no dependency on `dsh-session`.
-- No collection keyed by an in-scope branded id (`CallId`/`SessionId`/`AgentId`/`BashTaskId`) is keyed by bare `string` — this covers `Map`, `WeakMap` value slots, and `Set` membership (e.g. the ACP `bySession`/`loadingIds`), not just `Map`; the corresponding public method params and exported function signatures (e.g. `streamSessionEventUpdate`) take the brand, not `string`.
-- Brands are constructed via the cast factory at each boundary where a raw string enters (provider call id, ACP session id, model-supplied `task_id`); no `as` casts scattered at call sites.
-- `pnpm run typecheck` and `pnpm run doc-sync` are green; the change is observably type-only (no snapshot, no e2e behavioral diff).
+The landed invariants: `BashTaskId` and `OwnerToken` are defined in `dsh-bash` and threaded end-to-end (executor seam, the `dsh-bash-local` generation site, the `dsh-tool-bash` model-facing surface) with no `dsh-bash` dependency on `dsh-session`; no collection keyed by an in-scope branded id (`CallId`/`SessionId`/`AgentId`/`BashTaskId`) is keyed by bare `string` — `Map` keys, `WeakMap` value slots, `Set` membership (the ACP `bySession`/`loadingIds`), public method params, and exported signatures (`streamSessionEventUpdate`) all take the brand; and brands are constructed via the cast factory at each boundary where a raw string enters (provider call id, ACP session id, model-supplied `task_id`), never as scattered `as` casts.
-## Risks / what we give up
+## Consequences
-- **Mechanical churn across two surfaces.** Propagating brands touches the bash seam (interface + impl + consumer) and the ACP session-id surface plus the persistence coordinator. The risk is broad but low-severity: a missed site is a compile error, not a silent bug. It ships as its own PR, converged with Codex, and stacks naturally near the [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) work (both touch the session-id / owner-token boundary; if that proposal lands first, `OwnerToken` still stays distinct from the unified id for the decoupling reason above).
+- **Mechanical churn across two surfaces.** Propagating brands touches the bash seam (interface + impl + consumer) and the ACP session-id surface plus the persistence coordinator. The churn is broad but low-severity: a missed site is a compile error, not a silent bug. The change is observably type-only — no snapshot or e2e behavioral diff. It sits next to the [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) proposal (both touch the session-id / owner-token boundary); if that proposal lands, `OwnerToken` still stays distinct from the unified id for the decoupling reason above.
- **Brands do not validate.** A brand is a confusability guard, not a correctness proof: a *wrong* session id that is still a well-formed string passes the type checker exactly as before. This RFC does not close that gap (see Out of scope) — it only stops the *category* error of passing the wrong *kind* of id.
- **The "where to stop" line stays a judgment call.** Branding `BashTaskId` but not `ToolName`, `OwnerToken` but not `ModelId`, is a taste call about which strings "could plausibly be confused." Reasonable reviewers may want more or fewer; the policy in `brand.ts` is the tie-breaker, and this RFC errs toward the ids that are model-facing or used for access control.
diff --git a/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md b/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md
index b0d78bdc84..0d8d6c02b4 100644
--- a/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md
+++ b/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md
@@ -8,7 +8,7 @@ An example folder is supposed to be *thin* — the variable wiring of a demo, no
The deeper problem was a **coupled front-door cluster** that lived at the leaf with nothing enforcing it. Choosing the ACP bridge over `ui-stdio` was not one swappable line: an ACP server must **drop the stdout console logger** (stdout is the JSON-RPC channel — a stray log corrupts the frames) and pre-create **no** agents (ACP `session/new` creates them on demand), whereas the stdio app needs a console logger and a pre-created `main`. (`timer` is the one infra plugin common to both — it writes nothing to stdout — so it belongs in the shared spine, not the cluster.) That coupling was enforced only by prose warnings in the leaf YAML. A leaf that wired a console logger into the ACP config was a one-line, comment-only mistake away — exactly the [stdout-purity footgun](../feature/2026-06-18-acp-terminal-and-tool-rendering.md) the examples guarded by hand. The three `start.ts` files also duplicated the Loader-boot tail, the `.env` loader, and (for ACP) snapshot-mode branching and the stdin-dispose lifecycle.
-## What shipped
+## Decision
Each example is now **mostly an invocation of an app package**, splitting the wiring along the existing [interface / implementation / consumer seam](2026-06-13-capability-seams.md): the **app package owns the composition**, the leaf `cordis.yml` owns only the **swappable choices** (which LLM adapter, which bash executor, model, prompt, persistence root).
@@ -30,7 +30,9 @@ The proposal listed `hmr` among the stdio app's baked-in front-door cluster. Val
Crucially, `hmr` is **not** a stdout-purity footgun the way the console logger is — a stray `hmr` in the ACP config would not corrupt the JSON-RPC frames — so leaving it at the leaf costs none of the safety the coupling argument is about. The **logger** (the real coupling) stays baked in: the stdio app includes it, the ACP app omits it.
-## Why not keep the wiring in shared YAML includes?
+## Alternatives considered
+
+### Why not keep the wiring in shared YAML includes?
The old `base*.yml`/`acp-tail.yml` includes already deduped the *config*, but a YAML include cannot **encapsulate** the front-door coupling — it can only describe it in a comment and trust every leaf to obey. It also cannot own a `bin`, so the boot glue stayed copied across three `start.ts` files. A package turns "the ACP app never logs to stdout" from a prose warning into a property of the artifact: there is no logger entry in the leaf to get wrong.
@@ -41,7 +43,7 @@ The old `base*.yml`/`acp-tail.yml` includes already deduped the *config*, but a
- The new packages carry the per-file 100% coverage gate and a README like every `@deepseek-ai/dsh-*`. Each app package has a keyless **real-load-path** smoke that boots it through its `bin` + the cordis Loader (not a hand-built `ctx.plugin({...})` mount), guarding the `unwrapExports` export-shape bug class ([postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md)).
- The ACP snapshot **replay** transcript is unchanged: the boot restructuring preserved the plugin set + load order, so `pnpm run test:snapshot` stays green against the committed goldens with no re-record.
-## What we give up
+## Consequences
- **The bare-plugin-tree pedagogy.** echo-agent's inlined `cordis.yml` showed every plugin at once; the spine now lives behind a bundle, so seeing the whole tree means opening `dsh-agent-core`. The app package's README carries that teaching weight.
- **A layer of indirection.** "What does this demo load?" becomes a package read, not a single YAML scan.
diff --git a/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.md b/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.md
index 8b198ed9c6..89b50eb3df 100644
--- a/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.md
+++ b/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.md
@@ -8,7 +8,7 @@ Status: implemented
This was not just cosmetic. Because every top-level package looked like part of the same public surface, future removal was harder, and publish/lint/doc scripts had to encode intent through comments or hand-maintained static lists rather than reading it off the layout.
-## What landed
+## Decision
Packages are grouped by modular role at a uniform `packages///` depth. Group directories are pure containers (no `package.json`); every package keeps its `@deepseek-ai/dsh-` name — this is repo structure and maintenance policy, not package renaming.
@@ -62,6 +62,12 @@ Two doc-sync/hygiene gates keep the structure and its references honest, so the
- `scripts/verify-package-paths.ts` flags a `packages/` reference (in Markdown or a `.ts` comment/string) that does not resolve **and** names a real package in a segment — i.e. a stale path to a moved package. A path naming a package that exists nowhere (a forward-looking proposal) is left alone, so the gate applies uniformly across proposed/implemented/rejected.
- `scripts/check-workspace-constraints.ts` asserts the `packages//` shape: group dirs carry no `package.json`, and no package sits flat at the root or nests deeper. Group names stay open — a new group may be added without editing the gate; only the depth-2 shape is fixed.
-## What we gave up
+## Alternatives considered
+
+- **A third tier (`adapters/` / `impls/` under each family)** — rejected: uniform depth 2 keeps the workspace glob a clean `packages/*/*` and lets one `@deepseek-ai/dsh-*` tsconfig wildcard resolve every package.
+- **Nesting persistence under `core/session/`** — rejected: the storage backends form a parallel capability family mirroring `llm/` and `bash/`, while the session log itself stays core product API.
+- **`ui-stdio` under `ui/`** — rejected: it is example-coupled dev support, not a product surface; `acp` is the only `ui/` member because an editor actually drives it.
+
+## Consequences
The restructure churned imports, workspace globs, doc links, build references, and package paths in one coordinated move. That churn is acceptable pre-release (per the AGENTS.md foundation-over-blast-radius stance) because it stops the flat layout from fossilizing support packages as product contracts, and it is a one-time cost: the wildcard `paths`, the glob-derived publint list, and the shape gate mean a new package needs no further structural edits.
diff --git a/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md b/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md
index 5fbac8f663..df4914f105 100644
--- a/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md
+++ b/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md
@@ -45,7 +45,9 @@ Wire mapping (`attributionHeaders`; header names lowercase in code - HTTP field
Endpoint detection is not part of this RFC because no endpoint-specific mapping is accepted here. If OpenRouter support lands later, detection must be explicit: either a dedicated OpenRouter provider package or an explicit `provider: 'openrouter'` / `attributionTarget: 'openrouter'` config, not arbitrary path fragments or model names.
-## Acceptance criteria (all landed)
+## Verification
+
+The landed contract:
- `dsh-llm` documents the mandatory `User-Agent` attribution contract for `LlmAdapter` authors (`LlmAdapter` JSDoc, package README, and the adapter-contract section of `docs/core-data-structures/llm-streaming.md`).
- A shared helper (`attributionHeaders` / `userAgent`) constructs the app identity and the standard `User-Agent` value from package metadata, so adapters do not hand-copy version constants.
@@ -69,7 +71,7 @@ Endpoint detection is not part of this RFC because no endpoint-specific mapping
**Product-named token (`deepseek-harness-sdk`).** Considered for the `User-Agent` token, since the product name is DeepSeek Harness SDK. `deepseek-harness` won on continuity: it is the identity providers already see from this codebase, it matches the org/repo identity and package scope, and it keeps wire attribution stable while display copy carries the product name.
-## Risks / what we give up
+## Consequences
**Providers see that traffic comes from the harness.** That is the point, but it means deployments that previously blended into generic SDK traffic become identifiable. Mitigation: send only static public product data and let forks/white-label deployments pass their own `AppIdentity`.
diff --git a/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md b/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md
index 6dc07221fa..a03df16c8c 100644
--- a/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md
+++ b/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md
@@ -4,17 +4,17 @@ Status: implemented
## Problem
-The harness needs model-facing web tools without binding the model contract to one vendor's API shape. Search is the immediate pressure point: the first version should support at least Exa search and Perplexity search — two deliberately different provider shapes (Exa returns a flat `results[]` of `{title, url, highlights, publishedDate}`; Perplexity returns a generated answer plus citations), which is what proves the normalized seam does not just mirror one vendor. Fetch is a separate capability: an anonymous public HTTP(S) fetch backend has transport, security, redirect, decoding, and size-limit concerns that are not the same as provider-backed search.
+The harness needs model-facing web tools without binding the model contract to one vendor's API shape. Search is the immediate pressure point: supporting both Exa search and Perplexity search from the start — two deliberately different provider shapes (Exa returns a flat `results[]` of `{title, url, highlights, publishedDate}`; Perplexity returns a generated answer plus citations) — is what proves the normalized seam does not just mirror one vendor. Fetch is a separate capability: an anonymous public HTTP(S) fetch backend has transport, security, redirect, decoding, and size-limit concerns that are not the same as provider-backed search.
-The model-facing surface should stay stable while backends change. A search provider swap should not change how the model asks for a query, and a fetch implementation swap should not change how the model asks for a URL. Conversely, a provider package should not expose its own model-facing tool schema just because it has extra provider-specific knobs.
+The model-facing surface must stay stable while backends change. A search provider swap should not change how the model asks for a query, and a fetch implementation swap should not change how the model asks for a URL. Conversely, a provider package should not expose its own model-facing tool schema just because it has extra provider-specific knobs.
Putting search and fetch directly in `dsh-tool-web` would make the model-facing tool own provider selection, backend request mapping, transport policy, result normalization, prompt guidance, presentation, and schema registration at once. Letting each provider register its own tool has the opposite problem: tool availability, names, descriptions, and parameters would depend on whichever provider packages happen to load, and provider-specific fields would leak into the model contract.
There is also a provider-selection question. Existing `tool-bash` and `tool-fs` can rely on Cordis `inject` because there is one backend service key. Web has two independent capabilities (`search` and `fetch`) and potentially multiple providers per capability. `inject: ['web']` proves the seam exists; it does not prove a usable search or fetch provider exists, and it does not define which provider should win when several are registered.
-## Proposal
+## Decision
-Introduce web access as a first-class capability seam following [the capability-seam RFC](../../implemented/architecture/2026-06-13-capability-seams.md):
+Web access is a first-class capability seam following [the capability-seam RFC](../../implemented/architecture/2026-06-13-capability-seams.md):
1. `@deepseek-ai/dsh-web` (`packages/web/web`) owns `ctx.web`, provider registration, provider selection, shared request/result vocabulary, and web-specific errors.
2. Provider packages implement concrete backends and register capabilities with `ctx.web`, for example `@deepseek-ai/dsh-web-search-exa`, `@deepseek-ai/dsh-web-search-perplexity`, `@deepseek-ai/dsh-web-search-deepseek`, and `@deepseek-ai/dsh-web-fetch-local`.
@@ -24,12 +24,11 @@ Providers do not register tools. Providers register capabilities. `dsh-tool-web`
Search and fetch are separate capabilities and separate model-facing tools, but they are deliberately one seam. `ctx.web` is a single web-access middle layer between provider packages on one side and the tool consumer on the other: one service to inject, one provider-selection policy owner, one abort/error vocabulary, one place a product configures "how this harness reaches the web." The two halves do not share a request schema and have no shared business logic — search normalizes provider-backed discovery into a portable result with optional answer text and citeable sources, while fetch retrieves a concrete public HTTP(S) URL and returns a status code plus bounded decoded content — but they are parallel registries on one capability surface, not two surfaces. The cost is a `WebService` whose registry/exec methods come in `Search`/`Fetch` pairs; that parallelism is intentional, not a missed extraction. Splitting into `dsh-search` and `dsh-fetch` is the rejected alternative below.
-`dsh-tool-web` should register model-facing web tools when the product has enabled those tools and the `ctx.web` seam is present. Backend availability is an execution-time concern, not a schema-registration concern:
+`dsh-tool-web` registers model-facing web tools when the product has enabled those tools and the `ctx.web` seam is present. Backend availability is an execution-time concern, not a schema-registration concern:
-- Register `web_search` when web search is enabled for the product/app.
-- Register `web_fetch` when web fetch is enabled for the product/app.
-- Do not unregister a tool merely because its selected provider is missing, misconfigured, missing credentials, ambiguous, or temporarily unavailable.
-- Resolve the provider at execution time, and return a structured `WebError` when the selected capability cannot run.
+- `web_search` is registered when web search is enabled for the product/app, `web_fetch` when web fetch is.
+- A tool is never unregistered merely because its selected provider is missing, misconfigured, missing credentials, ambiguous, or temporarily unavailable.
+- The provider is resolved at execution time, and a structured `WebError` is returned when the selected capability cannot run.
This keeps the model schema stable without making plugin load order, credential state, or HMR timing part of the model-facing contract. If web search is enabled but no usable search provider exists, `web_search` remains visible and execution fails with a structured `WebError` such as `WEB_PROVIDER_UNAVAILABLE` or `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`. If a provider appears after `dsh-tool-web`, the next execution can use it without changing the schema. If a provider disappears mid-call, execution fails with a structured `WebError` instead of silently choosing another provider or falling through to `UNKNOWN_TOOL`.
@@ -73,7 +72,7 @@ Provider packages depend on `@deepseek-ai/dsh-web` and Cordis. They own credenti
## `ctx.web` contract
-`ctx.web` is a provider registry plus a provider-selecting execution surface. The registry half should stay close to `LlmService`: a `Map` per capability kind, `registerSearchProvider` / `registerFetchProvider` methods that return disposers, duplicate ids that throw `WebError`, and execution-time resolution that throws when the selected provider is absent or unusable. The exact TypeScript signatures belong to the implementation PR, but the seam should expose this shape:
+`ctx.web` is a provider registry plus a provider-selecting execution surface. The registry half stays close to `LlmService`: a `Map` per capability kind, `registerSearchProvider` / `registerFetchProvider` methods that return disposers, duplicate ids that throw `WebError`, and execution-time resolution that throws when the selected provider is absent or unusable. The authoritative signatures live in `packages/web/web/src/types.ts`; the seam's shape:
```ts
interface WebSearchProvider {
@@ -101,9 +100,9 @@ interface WebExecContext {
}
```
-`WebExecContext` is execution control, not business input. The first version should carry only `signal` so `tool-web` can propagate turn cancellation, tool timeout, and agent disposal into provider network requests, SSE readers, and expensive decoding. It should not pass `ToolExecution` through the seam, because that would make `dsh-web` depend on `dsh-tools`.
+`WebExecContext` is execution control, not business input. It carries only `signal`, so `tool-web` propagates turn cancellation, tool timeout, and agent disposal into provider network requests, SSE readers, and expensive decoding. It does not pass `ToolExecution` through the seam — that would make `dsh-web` depend on `dsh-tools`.
-Provider ids are stable strings and unique within their capability kind. Registering a duplicate search provider id or duplicate fetch provider id should fail rather than silently replace the old provider. Provider registration returns a disposer and follows the existing `ctx.tools.register()` / `ctx.systemPrompt.section()` pattern: wrap the mutation in `ctx.effect()` so the registration is torn down with the contributing fiber.
+Provider ids are stable strings and unique within their capability kind. Registering a duplicate search provider id or duplicate fetch provider id fails rather than silently replacing the old provider. Provider registration returns a disposer and follows the existing `ctx.tools.register()` / `ctx.systemPrompt.section()` pattern: the mutation is wrapped in `ctx.effect()` so the registration is torn down with the contributing fiber.
## Provider status and selection
@@ -131,7 +130,7 @@ Selection must not depend on registration order. Cordis load order, config order
| No provider id is configured and multiple usable providers for that kind are registered | fails with `WEB_PROVIDER_AMBIGUOUS` rather than choosing by registration order |
| No provider id is configured and providers exist but none are usable | fails with `WEB_PROVIDER_UNAVAILABLE` |
-The "single provider auto-selects" rule is for tests, demos, and simple deployments. Product configs should set explicit provider ids:
+The "single provider auto-selects" rule is for tests, demos, and simple deployments. Product configs set explicit provider ids:
```yaml
- id: web
@@ -156,26 +155,26 @@ The "single provider auto-selects" rule is for tests, demos, and simple deployme
name: '@deepseek-ai/dsh-tool-web'
```
-Operational overrides such as environment variables may exist, but they must feed the same explicit selection path. For example, `DSH_WEB_SEARCH_PROVIDER=perplexity` is equivalent to config `searchProvider: perplexity`; it is not a hidden priority chain inside `dsh-tool-web`.
+Operational overrides feed the same explicit selection path: `DSH_WEB_SEARCH_PROVIDER=perplexity` is equivalent to config `searchProvider: perplexity`, not a hidden priority chain inside `dsh-tool-web`.
-`ctx.web.search()` and `ctx.web.fetch()` resolve the provider at execution time using the selection rules above. If the selected capability is unavailable, they throw `WebError` with a structured code such as `WEB_PROVIDER_UNAVAILABLE`, `WEB_PROVIDER_CONFIGURED_MISSING`, `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`, or `WEB_PROVIDER_AMBIGUOUS`. If no provider is explicitly configured and no usable provider exists, the execution error is the generic `WEB_PROVIDER_UNAVAILABLE` case; the first version should not add a diagnostic summary of every unavailable provider.
+`ctx.web.search()` and `ctx.web.fetch()` resolve the provider at execution time using the selection rules above. If the selected capability is unavailable, they throw `WebError` with a structured code such as `WEB_PROVIDER_UNAVAILABLE`, `WEB_PROVIDER_CONFIGURED_MISSING`, `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`, or `WEB_PROVIDER_AMBIGUOUS`. If no provider is explicitly configured and no usable provider exists, the execution error is the generic `WEB_PROVIDER_UNAVAILABLE` case; there is deliberately no diagnostic summary of every unavailable provider.
## Search request and result schema
-The first `web_search` model-facing tool should be small. The only model-facing argument is:
+The `web_search` model-facing tool is small. The only model-facing argument is:
- `query`: required string.
-`max_results` is NOT exposed to the model in the first version. It is a `dsh-tool-web`-layer decision: the tool sets the result bound — the `searchMaxResults` plugin config, default `8` (aligning with OpenCode's Exa default), mirroring `dsh-tool-fs`'s `readLimit` — and passes it to the seam as `maxResults` on the `WebSearchRequest`. Keeping it off the model schema means the model just asks a question and the product controls how much context comes back; the field can be promoted to a model-facing argument later without breaking the seam.
+`max_results` is NOT exposed to the model. It is a `dsh-tool-web`-layer decision: the tool sets the result bound — the `searchMaxResults` plugin config, default `8` (aligning with OpenCode's Exa default), mirroring `dsh-tool-fs`'s `readLimit` — and passes it to the seam as `maxResults` on the `WebSearchRequest`. Keeping it off the model schema means the model just asks a question and the product controls how much context comes back; the field can be promoted to a model-facing argument later without breaking the seam.
`maxResults` flows tool → seam → provider, and the bound is enforced on the way back:
- `dsh-tool-web` owns the value and puts it on `WebSearchRequest.maxResults`.
- `ctx.web` passes the request through to the selected provider unchanged.
-- A provider should apply `maxResults` at the request layer when its API supports it (Exa's `numResults`), as a cost/latency optimization.
+- A provider applies `maxResults` at the request layer when its API supports it (Exa's `numResults`), as a cost/latency optimization.
- `ctx.web` enforces the bound on the result: if a provider returns more than `maxResults` sources — because its API has no result-count control (Perplexity) or ignored the hint — the seam truncates `sources[]` to `maxResults` and sets `WebSearchResult.truncated` to `true` before returning. This makes the bound a single cross-provider guarantee the model-facing layer can rely on, rather than something each provider must remember to honor.
-The seam request should not include provider-specific controls such as Perplexity model selection, search recency, domain filters, Exa `livecrawl`, Exa `type`, regional hints, generated-answer budgets, or search depth in the first version. Those fields should be added only when they have provider-neutral semantics that both the tool schema and selected providers can honor honestly.
+The seam request carries no provider-specific controls — no Perplexity model selection, search recency, domain filters, Exa `livecrawl`, Exa `type`, regional hints, generated-answer budgets, or search depth. Such a field is added only when it has provider-neutral semantics that both the tool schema and selected providers can honor honestly.
```ts
interface WebSearchRequest {
@@ -200,24 +199,24 @@ interface WebSearchSource {
}
```
-`content` is optional provider-generated answer text, search context, or summary. `sources[]` is the portable citation surface. A source always has a URL; title, snippet, and `publishedAt` are optional because not every provider returns them. `title` should not be required: Perplexity-style citations may provide only URLs, and forcing adapters to invent titles would make the seam lie. `dsh-tool-web` can render `title ?? hostname(url)` for display. `publishedAt` is an optional publication/crawl timestamp as an ISO-8601 string — Exa returns it as `publishedDate` on each result and Perplexity returns a `date` on search results, so it is real provider data, not derived; the seam carries it as a string and leaves date parsing to the consumer.
+`content` is optional provider-generated answer text, search context, or summary. `sources[]` is the portable citation surface. A source always has a URL; title, snippet, and `publishedAt` are optional because not every provider returns them. `title` is not required: Perplexity-style citations may provide only URLs, and forcing adapters to invent titles would make the seam lie. `dsh-tool-web` renders a `title ?? hostname(url)`-style fallback label for display. `publishedAt` is an optional publication/crawl timestamp as an ISO-8601 string — Exa returns it as `publishedDate` on each result and Perplexity returns a `date` on search results, so it is real provider data, not derived; the seam carries it as a string and leaves date parsing to the consumer.
-Exa search should map each entry of the provider's flat `results[]` into a `WebSearchSource`: `url` ← `url`, `title` ← `title`, `snippet` ← the first `highlights[]` entry (an entry with no highlight has no portable snippet and is dropped), `publishedAt` ← `publishedDate`. Exa returns no provider-generated answer, so `content` is omitted. Perplexity search should map `choices[0].message.content` to `content` and prefer the structured top-level `search_results[]` for `sources[]` — `url` ← `url`, `title` ← `title`, `snippet` ← `snippet` (often empty), `publishedAt` ← `date` — falling back to the URL-only `citations[]` array only when `search_results` is absent (those sources carry just a `url`). If a provider returns fewer structured fields than the seam supports, the adapter omits those optional fields.
+Exa search maps each entry of the provider's flat `results[]` into a `WebSearchSource`: `url` ← `url`, `title` ← `title`, `snippet` ← the first `highlights[]` entry (an entry with no highlight has no portable snippet and is dropped), `publishedAt` ← `publishedDate`. Exa returns no provider-generated answer, so `content` is omitted. Perplexity search maps `choices[0].message.content` to `content` and prefers the structured top-level `search_results[]` for `sources[]` — `url` ← `url`, `title` ← `title`, `snippet` ← `snippet` (often empty), `publishedAt` ← `date` — falling back to the URL-only `citations[]` array only when `search_results` is absent (those sources carry just a `url`). If a provider returns fewer structured fields than the seam supports, the adapter omits those optional fields.
Full page retrieval remains the job of `web_fetch(url)`. Search snippets are discovery context, not fetched page bodies.
## Fetch request and result schema
-The first `web_fetch` implementation should be an anonymous public HTTP(S) fetch provider, likely `local-http`. It should fetch bytes from a concrete URL, apply the basic transport hygiene below (http/https-only, credential rejection, byte/time caps, cross-origin redirect blocking), decode textual content, and return only the minimal model-useful result: final URL, status code, body, and truncation. It should not carry browser cookies, editor credentials, git credentials, internal auth tokens, or implicit access to private services. (Full SSRF / private-network blocking is deferred — see [Deferred work](#deferred-work).)
+The `web_fetch` implementation is an anonymous public HTTP(S) fetch provider, `local-http`. It fetches bytes from a concrete URL, applies the basic transport hygiene below (http/https-only, credential rejection, byte/time caps, cross-origin redirect blocking), decodes textual content, and returns only the minimal model-useful result: final URL, status code, body, and truncation. It carries no browser cookies, editor credentials, git credentials, internal auth tokens, or implicit access to private services. (Full SSRF / private-network blocking is deferred — see [Deferred work](#deferred-work).)
-The first seam request should stay smaller than OpenCode's model-facing tool:
+The seam request stays smaller than OpenCode's model-facing tool:
- `url`: required HTTP(S) URL.
- `timeoutMs`: optional positive number capped by the provider.
-The seam request deliberately does not include `format`, `prompt`, or provider-specific extraction controls. `format` is a presentation decision over a fetched resource; `prompt` is a higher-level LLM summarization instruction; extraction APIs such as Firecrawl, Exa, Tavily, or Parallel may not expose a concrete HTTP response. If the product later needs provider-backed page extraction, add a separate `web_extract` capability or explicitly widen this RFC before implementation. Do not smuggle extract semantics into `web_fetch` by making every HTTP field optional.
+The seam request deliberately does not include `format`, `prompt`, or provider-specific extraction controls. `format` is a presentation decision over a fetched resource; `prompt` is a higher-level LLM summarization instruction; extraction APIs such as Firecrawl, Exa, Tavily, or Parallel may not expose a concrete HTTP response. If the product later needs provider-backed page extraction, that is a separate `web_extract` capability or a deliberate widening of this seam — extract semantics are never smuggled into `web_fetch` by making every HTTP field optional.
-HTTP status is part of the fetched resource state, not automatically a tool failure. A successful network fetch of a `404` or `500` response should return `WebFetchResult` with the status code and a bounded decoded body when the content type is supported. `WebError` is for failures to safely retrieve or represent the resource: invalid or blocked URL, redirect policy violation, timeout, abort, response too large, unsupported content type, provider failure, or network failure.
+HTTP status is part of the fetched resource state, not automatically a tool failure. A successful network fetch of a `404` or `500` response returns `WebFetchResult` with the status code and a bounded decoded body when the content type is supported. `WebError` is for failures to safely retrieve or represent the resource: invalid or blocked URL, redirect policy violation, timeout, abort, response too large, unsupported content type, provider failure, or network failure.
```ts
interface WebFetchRequest {
@@ -238,21 +237,19 @@ type WebFetchBody =
| { readonly kind: 'text'; readonly content: string }
```
-`WebFetchResult.url` is the final URL after allowed redirects. The request URL is already present in `WebFetchRequest`, so the first version should not add separate `requestedUrl` and `finalUrl` fields.
+`WebFetchResult.url` is the final URL after allowed redirects. The request URL is already present in `WebFetchRequest`, so there is no separate `requestedUrl`/`finalUrl` pair.
`WebFetchBody` is a CLOSED discriminated union owned by `dsh-web`, not a merge-extensible map. The merge-extensible pattern (`ContentBlockMap`) exists for variants that independent plugins introduce and the seam cannot foresee; body kinds are not that — `dsh-web` declares the kind, the fetch provider decodes it, and `dsh-tool-web` renders it, so a new kind is a coordinated change across three known packages, not a plugin extension. Keeping it closed buys compile-time exhaustiveness: consumers `switch` on `kind` ending in `default: assertNever(body, …)`, so adding a kind breaks compilation at every consumer that must render it (e.g. `tool-web`'s `html`→markdown vs `text` passthrough) until that arm is written. Each arm stays its own object literal even when the fields coincide today, leaving room for arm-specific fields (a future `pdf` body's `pageCount`, a `json` body's parsed value) without reshaping the type. Since the harness is unreleased, extending this closed union later is free (no migration, no compat shim).
The provider owns safe resource retrieval: URL validation, HTTP transport, redirect policy, timeout, abort propagation, byte caps, charset decoding, content-type classification, and binary rejection. `dsh-tool-web` owns presentation: HTML-to-markdown, HTML-to-text, truncation formatting for the model, and future summaries.
-The fetch provider must define resource controls before the tool ships:
+The fetch provider's resource controls:
-- Accept only `http:` and `https:` URLs.
-- Reject credentials in URLs.
-- Enforce maximum URL length, response byte cap, decoded body character cap, timeout, and redirect hop cap.
-- Propagate abort signals through network fetches and expensive decoding.
-- Automatically follow only same-origin redirects.
-- Fail cross-origin redirects with `WEB_REDIRECT_BLOCKED`, requiring a fresh tool call and therefore a fresh provider/permission decision. (Claude Code's WebFetch uses this same model — it does not auto-follow a cross-host redirect; it returns the redirect target to the model for a fresh call.)
-- Use an explicit product user agent rather than silently impersonating a browser by default.
+- Only `http:` and `https:` URLs are accepted; credentials in URLs are rejected.
+- Maximum URL length, response byte cap, decoded body character cap, timeout, and redirect hop cap are enforced.
+- Abort signals propagate through network fetches and expensive decoding.
+- Only same-origin redirects are followed automatically; a cross-origin redirect fails with `WEB_REDIRECT_BLOCKED`, requiring a fresh tool call and therefore a fresh provider/permission decision. (Claude Code's WebFetch uses this same model — it does not auto-follow a cross-host redirect; it returns the redirect target to the model for a fresh call.)
+- Requests carry an explicit product user agent rather than silently impersonating a browser.
SSRF / private-network protection (blocking private, loopback, link-local, multicast, and otherwise non-public destinations, with DNS-resolve-then-validate to defeat rebinding and per-hop re-validation on redirects) is **deferred** — see [Deferred work](#deferred-work). Until it lands, `web_fetch` is an SSRF primitive and must not be enabled in a deployment that can reach sensitive internal network targets.
@@ -262,23 +259,17 @@ SSRF / private-network protection (blocking private, loopback, link-local, multi
`dsh-tool-web` must not enumerate providers or call provider `status()` directly. Its only path into the seam is `ctx.web.search()` / `ctx.web.fetch()`. That keeps provider selection in one layer; otherwise the tool package could decide one provider is usable while execution resolves a different state.
-Tool registration in the first version is a minimal stable sync:
-
-1. On plugin startup, read the `dsh-tool-web` `Config` (`search?: boolean`, `fetch?: boolean`, both default `true`) that enables or disables each web tool.
-2. If web search is enabled, register `web_search` (its disposer is fiber-scoped via the effect-based registry).
-3. If web fetch is enabled, register `web_fetch` (likewise fiber-scoped).
-4. Do not dispose either tool merely because its selected provider is missing, unusable, or ambiguous.
-5. Disposing the `tool-web` fiber tears down its registrations automatically.
+Tool registration is a minimal stable sync: on plugin startup the `dsh-tool-web` `Config` (`search?: boolean`, `fetch?: boolean`, both default `true`) enables or disables each web tool; an enabled tool is registered with a fiber-scoped disposer via the effect-based registry; neither tool is disposed merely because its selected provider is missing, unusable, or ambiguous; disposing the `tool-web` fiber tears down its registrations automatically.
Provider status changes affect execution results and diagnostics, not whether the model-facing schema exists. If a product wants no web tools at all, it disables `dsh-tool-web` or the individual web tool in config; if it wants web tools but the backend is misconfigured, the model sees a structured tool error at execution time.
-Prompt guidance should explain the semantic split: use `web_search` for discovery and current information, then use `web_fetch` when the model needs the content of a specific URL. The prompt and tool result should tell the model to cite relevant URLs with markdown links.
+The prompt guidance explains the semantic split — `web_search` for discovery and current information, `web_fetch` when the model needs the content of a specific URL — and the prompt and tool result tell the model to cite relevant URLs with markdown links.
-The model-facing output should be text-first because current tool results are `ContentBlock[]`, but the seam outcome should stay structured so UI presentation and future adapters do not have to scrape rendered text.
+The model-facing output is text-first because tool results are `ContentBlock[]`, but the seam outcome stays structured so UI presentation and future adapters do not have to scrape rendered text.
## Errors
-`dsh-web` should define `WebError extends HarnessError` with stable codes. Initial codes should include only states that callers may reasonably branch on:
+`dsh-web` defines `WebError extends HarnessError` with stable codes, covering only states that callers may reasonably branch on:
- `WEB_PROVIDER_UNAVAILABLE`
- `WEB_PROVIDER_CONFIGURED_MISSING`
@@ -294,40 +285,13 @@ The model-facing output should be text-first because current tool results are `C
- `WEB_UNSUPPORTED_CONTENT_TYPE`
- `WEB_PROVIDER_ERROR`
-`WEB_DUPLICATE_PROVIDER` is thrown synchronously from `registerSearchProvider` / `registerFetchProvider` when an id is already registered for that capability kind (the analogue of `LlmService`'s `DUPLICATE_ADAPTER`); it is a registration-time programming error, not an execution outcome, but shares the `WebError` code space so callers see one taxonomy. `WEB_PROVIDER_ERROR` is the catch-all for a provider's own failure surfaced through the seam, including network/transport failure in `web-fetch-local` (DNS, connection refused, TLS); the first version does not split out a separate `WEB_NETWORK` code, but the provider should set a descriptive message so the model and logs can tell a network failure from a provider API failure.
+`WEB_DUPLICATE_PROVIDER` is thrown synchronously from `registerSearchProvider` / `registerFetchProvider` when an id is already registered for that capability kind (the analogue of `LlmService`'s `DUPLICATE_ADAPTER`); it is a registration-time programming error, not an execution outcome, but shares the `WebError` code space so callers see one taxonomy. `WEB_PROVIDER_ERROR` is the catch-all for a provider's own failure surfaced through the seam, including network/transport failure in `web-fetch-local` (DNS, connection refused, TLS); there is deliberately no separate `WEB_NETWORK` code — the provider sets a descriptive message so the model and logs can tell a network failure from a provider API failure.
-Tool execution should let these errors flow through `ToolRegistry.execute()`, which already converts `HarnessError` into an error tool result with structured metadata. The model gets a readable error message; hooks, tests, and UI code can route on the stable code.
+Tool execution lets these errors flow through `ToolRegistry.execute()`, which already converts `HarnessError` into an error tool result with structured metadata. The model gets a readable error message; hooks, tests, and UI code can route on the stable code.
-## Tests
+## Testing
-Tests should prove the seam contract without turning this RFC into an implementation checklist.
-
-`dsh-web` tests cover provider registration and disposal (proved through execution behavior — a registered provider serves `search()`/`fetch()`, a disposed one no longer resolves), duplicate provider ids, the selection table above exercised through execution-time provider resolution, `maxResults` truncation of `sources[]` with `truncated` set when a provider over-returns, abort propagation through `WebExecContext.signal`, and structured `WebError` codes.
-
-Search provider tests cover request mapping, response parsing into `content` plus `sources[]`, missing credentials, provider errors, timeout/abort, truncation, and a self-skipping with-key smoke test for each real provider. Perplexity fixtures must include URL-only citations so the optional source fields stay honest.
-
-`dsh-web-fetch-local` tests cover real HTTP behavior using a local test server: valid text and HTML fetches, non-2xx HTTP responses returned as results, byte/decoded-body caps, timeout, abort, invalid URLs, credential-in-URL rejection, cross-origin redirect blocking, unsupported content types, and product user agent. (Private-destination/SSRF blocking tests come with that deferred work.)
-
-`dsh-tool-web` tests execute through the real tool registry. They verify schema registration follows product/app tool enablement rather than provider availability, unavailable or ambiguous providers produce structured execution errors, argument validation, formatting of successful search/fetch results, structured error propagation, and cleanup on disposal.
-
-Integration tests should load the real seam, provider, and tool packages together and execute through `ctx.tools.execute()` rather than calling providers directly. If wiring the tools into an ACP-facing example changes editor-visible transcripts, add or update the relevant snapshot scenario in the same change.
-
-At least one test must drive these packages through their REAL cordis Loader/export path, not a hand-built `ctx.plugin({...})` mount, so a broken export shape is caught (see [docs/postmortem/0001](../../../postmortem/0001-acp-default-export-drops-inject.md) and `packages/AGENTS.md` § plugin-export-shape). The two shapes need different guards: `dsh-web` is a **service** (`export default` the class) and a stray extra export would surface as a missing service; the provider packages and `dsh-tool-web` are **namespace plugins** (named `name`/`inject`/`apply`, NO default), and because each has `inject`, a stray `export default apply` makes `unwrapExports` drop the `inject` and the plugin throws `cannot get property … without inject` the moment it loads — so a Loader smoke that boots tool-web over `ctx.web` catches it (and each provider's registration test mounts it the real way and asserts no default export). Prove the guard bites: add `export default apply` to `tool-web`, watch the smoke go red, revert.
-
-## Migration plan
-
-This is new capability work, so no compatibility migration is required while the harness is unreleased.
-
-Land the work in seam order:
-
-1. Add `packages/web/web` with `ctx.web`, provider registration, provider status, selection, request/result/error types, and contract tests.
-2. Add `packages/web/web-search-exa` with parser/unit tests and a self-skipping real-provider smoke test.
-3. Add `packages/web/web-search-perplexity` with parser/unit tests and a self-skipping real-provider smoke test.
-4. Add `packages/web/web-search-deepseek` with parser/unit tests and a self-skipping real-provider smoke test.
-5. Add `packages/web/web-fetch-local` with local HTTP behavior tests.
-6. Add `packages/web/tool-web` with config-driven tool registration, prompt sections, model formatting, presentation, and tool-registry tests.
-7. Wire product app/example configs only after package behavior is stable, because tool schemas and prompt sections affect agent behavior and snapshots.
-8. Update `docs/architecture.md`, `packages/README.md`, package READMEs, generated Cordis catalogs if new events/services are added, and maintenance scripts.
+Each layer is pinned at its own seam: the registry/selection/truncation/abort contract and the `WebError` codes in `dsh-web`; per-provider request/response mapping over recorded fixtures (Perplexity fixtures include URL-only citations so the optional source fields stay honest) plus a self-skipping with-key smoke per real provider; real local-HTTP behavior in `web-fetch-local`; and enablement-driven registration, structured execution errors, and result formatting through the real tool registry in `dsh-tool-web`. A real-Loader smoke guards the two export shapes ([postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md)): `dsh-web` is a default-exported service, while the providers and `tool-web` are namespace plugins where a stray `export default` would drop `inject`.
## Alternatives considered
@@ -355,19 +319,19 @@ Rejected for the first version. Those providers often return extracted or summar
Rejected for the seam. `prompt` turns fetch into LLM summarization and couples public-web retrieval to a model provider. The harness seam should fetch and decode deterministically; `dsh-tool-web` can later offer summaries as a presentation mode without making `ctx.web` depend on `ctx.llm`.
-## Risks
+## Consequences
-**The search schema may be too thin.** Exa and Perplexity both expose useful provider-specific controls. The first version should resist adding them until they can be defined provider-neutrally and enforced honestly by both tool registration and provider execution.
+**The search schema is deliberately thin.** Exa and Perplexity both expose useful provider-specific controls; a control is added only once it can be defined provider-neutrally and enforced honestly by both tool registration and provider execution.
-**Perplexity citations may be sparse.** A citation may be only a URL. Making `title` and `snippet` optional keeps the seam truthful but means `tool-web` must render useful fallback labels.
+**Perplexity citations can be sparse.** A citation may be only a URL. Making `title` and `snippet` optional keeps the seam truthful but means `tool-web` renders fallback labels.
-**Stable tool registration can defer misconfiguration to execution.** Keeping the tool visible is correct when the product enabled web access, but product apps that expect web search should surface the structured `WEB_PROVIDER_CONFIGURED_MISSING` / `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` / `WEB_PROVIDER_AMBIGUOUS` failures loudly so users do not discover setup problems only after the model calls the tool.
+**Stable tool registration defers misconfiguration to execution.** Keeping the tool visible is correct when the product enabled web access, but product apps that expect web search should surface the structured `WEB_PROVIDER_CONFIGURED_MISSING` / `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` / `WEB_PROVIDER_AMBIGUOUS` failures loudly so users do not discover setup problems only after the model calls the tool.
-**Provider state can change after startup.** A tool can be visible in the request assembled at step start and lose its provider before execution. The execution path must resolve again and fail with a structured error.
+**Provider state can change after startup.** A tool can be visible in the request assembled at step start and lose its provider before execution. The execution path resolves again and fails with a structured error.
-**Fetch is a network boundary, not just a read-only tool.** `web_fetch` can still reach sensitive network targets or exfiltrate data through URLs. The first version ships only the basic transport hygiene (http/https-only, credential rejection, byte/time caps, cross-origin redirect blocking); SSRF / private-network blocking is deferred (see [Deferred work](#deferred-work)), so until it lands `web_fetch` must not be enabled where it can reach internal targets.
+**Fetch is a network boundary, not just a read-only tool.** `web_fetch` can reach sensitive network targets or exfiltrate data through URLs. Only the basic transport hygiene ships (http/https-only, credential rejection, byte/time caps, cross-origin redirect blocking); SSRF / private-network blocking is deferred (see [Deferred work](#deferred-work)), so until it lands `web_fetch` must not be enabled where it can reach internal targets.
-**Large web content can damage context quality.** Providers must enforce byte/character caps and report `truncated`; `tool-web` must format bounded model output with clear continuation or follow-up guidance.
+**Large web content can damage context quality.** Providers enforce byte/character caps and report `truncated`; `tool-web` formats bounded model output with clear continuation or follow-up guidance.
## Deferred work
diff --git a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md
index 9f7b8a48c6..c890acb5c0 100644
--- a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md
+++ b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md
@@ -152,21 +152,17 @@ Both mutations are still atomic (the backend's per-target lock is unconditional)
This amends — does not reverse — [the split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md). The four-layer split, the provider contract, and the freshness *policy* are all kept. What changes is the **coupling between the tool and the policy layer**: a mandatory method service became a plugin-owned event gate, and the fs I/O + read windowing moved from `fileContext` up into `dsh-tool-fs`. The split-fs-seam RFC's description of `dsh-tool-fs` injecting `fileContext` and of `fileContext` owning `read`/`write`/`edit` was updated to match in the same change.
-## Acceptance Criteria
+## Verification
-- The `dsh-tool-fs` root plugin injects `fs` (+ `tools`/`systemPrompt`), not `fileContext`; it calls `ctx.fs` directly and dispatches the `fs/write-intent`/`fs/edit-intent` waterfalls (passing `exec` as the actor) and the `fs/observed` emit. Read rendering lives in `dsh-tool-fs`. (No subpath plugins — see the Tool contract above.)
-- `dsh-fs` declares the three events with `@mode` tags and an opaque `object` actor argument (no agent/session structure leaks into the provider vocabulary); the generated cordis catalog is regenerated.
-- `dsh-fs-policy` is a plugin, not a service: it does not register `ctx.fileContext`, has no public `read`/`write`/`edit`/`resolve` methods, and does not inject `fs`; it registers the three listeners, keeps observed-state, and has HMR/disposal coverage (dispose the fiber, assert the gate no longer rewrites).
-- **Bare-provider test**: a config WITHOUT `dsh-fs-policy` boots the `dsh-tool-fs` root plugin, and `read`/`write`(create AND overwrite)/`edit` work against the real `dsh-fs-local`; an `edit` of an unread existing file and an overwrite of an existing unread file both succeed (unconditional bare-provider behavior), proving the tool carries no `fileContext` dependency. A bare-provider edit of a missing target reports `FS_STALE_VERSION`. With `dsh-fs-policy` present, the same unread `edit` is rejected `FS_NOT_OBSERVED` and the same unread overwrite uses `createIfAbsent` (rejected on an existing file).
-- **Single-slot semantics**: a test registers a second `fs/edit-intent` listener AFTER `dsh-fs-policy` and asserts it is NOT reached (first-wins short-circuit), and documents in a comment that a decider registered before/`prepend`ed would instead win — the slot is first-wins by convention, not an enforced invariant.
-- **Fire-and-forget recording**: `fs/observed` is emitted via a plain `ctx.emit` after the mutation succeeds; a listener is contractually synchronous and side-effect-only, so the tool does not guard it.
-- `dsh-fs` `writeText`/`editText` make `expected` optional (omit ⇒ unconditional); the `FsWriteIntent` union is unchanged, and `dsh-fs-policy`'s guarded paths (`createIfAbsent`/`replaceIfVersion`/`{ version }`) behave exactly as today. A bare-provider test exercises an unconditional overwrite, an unconditional edit, and a missing-target edit reporting `FS_STALE_VERSION`.
-- Freshness is enforced by provider CAS when guarded: an edit after a stale read reports `FS_STALE_VERSION` (regression test); `dsh-fs-policy` performs no `stat`.
-- `stat` budget: read = 1, write = 0, edit = 0 — in the tool, with or without `dsh-fs-policy` (the bare default returns `undefined`, never stats). A test asserts neither write nor edit stats in the tool on either path.
-- Model-facing schemas stay byte-for-byte unchanged; snapshot transcript goldens are unaffected (or the diff is reviewed and re-recorded with justification).
-- Docs/artifacts updated in the same change: `docs/architecture.md`, fs package READMEs, `docs/core-data-structures/filesystem.md`, the split-fs-seam RFC's now-amended description, type-equiv blocks + manifest, cordis catalog, module graph. Gates green: `doc-sync`, `knip`, `test:coverage` (100% per-file).
+The decoupling and its semantics are pinned by tests: a bare-provider config (no `dsh-fs-policy`) boots the `dsh-tool-fs` root plugin and `read`/`write` (create and overwrite)/`edit` work against the real `dsh-fs-local` — an unread edit and an unread overwrite both succeed, proving the tool carries no `fileContext` dependency, while the same operations with `dsh-fs-policy` present are rejected `FS_NOT_OBSERVED` / gated `createIfAbsent`. A second `fs/edit-intent` listener registered after `dsh-fs-policy` is asserted NOT reached (first-wins short-circuit). A stale-read edit reports `FS_STALE_VERSION` through provider CAS, with `dsh-fs-policy` performing no `stat`; the tool's `stat` budget (read = 1, write = 0, edit = 0, on both paths) is asserted directly. Model-facing schemas stayed byte-for-byte unchanged, so snapshot transcript goldens are unaffected.
-## Risks
+## Alternatives considered
+
+- **Keep `ctx.fileContext` as an in-path method service** — the shape [the split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md) first landed; rejected because the tool could not run without the policy layer, making policy load-bearing for basic operation instead of an opt-in tightening.
+- **Policy-side version checking** (`dsh-fs-policy` stats and compares in its waterfall handler) — rejected for the TOCTOU gap between that check and the tool's actual write; the provider's mutation critical section is the only race-free place, so the policy only chooses the CAS basis and gates on prior observation.
+- **Per-tool `/read`/`/write`/`/edit` subpath plugins** — dropped on implementation: no consumer needed a single-tool deployment, and subpath publishing forced bespoke `tsdown`/`tsconfig`/`files`/workspace-constraint handling no sibling tool package carries; the per-tool registration helpers remain internal modules the root plugin composes.
+
+## Consequences
- **Event indirection over a method call.** A waterfall + emit is less direct than `await ctx.fileContext.edit(...)`. The payoff is removing the tool-to-policy method dependency while keeping the default policy plugin; the cost is one more event vocabulary to learn. Mitigated by keeping the three events narrow and documenting the default-thunk semantics on each.
- **Policy events in the storage seam.** `dsh-fs` gains two version-decision events plus a recording event though it is "just storage". This is the price of decoupling (the emitter cannot depend on the policy plugin). The events carry only `dsh-fs` vocabulary plus an opaque `object` actor and no model-facing concepts, so the seam stays free of line-window/observation policy types and of the agent/session owner structure.
diff --git a/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md b/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md
index 0d797fa8a2..e6ecc974d7 100644
--- a/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md
+++ b/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md
@@ -1,10 +1,8 @@
# RFC: stdin + extra env on the bash seam
-Status: implemented (accepted 2026-06-30)
+Status: implemented
-
-
-## Context
+## Problem
The hooks subsystem runs external hook commands the way Claude Code and Codex do: a hook is a shell command that receives its event payload as **JSON on stdin** and reads context from a handful of **environment variables** (`CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, `PLUGIN_ROOT`, …). The harness already has a perfectly good command runner behind the `ctx.bash` capability seam ([dsh-bash](../../../../packages/bash/bash) → [dsh-bash-local](../../../../packages/bash/bash-local)), with process-group kills, output truncation/spill, and a credential scrub. Reusing it for hook execution means a hook bridge does not re-implement subprocess plumbing — but the seam had no way to write stdin or set extra env. This RFC adds those two inputs.
@@ -24,7 +22,7 @@ Three deliberate choices:
`dsh-bash-local` spawns stdin as a `'pipe'` (writing the supplied bytes, then closing) ONLY when a caller set `stdin`; with none supplied it uses `'ignore'` — fd 0 → `/dev/null` — the exact pre-seam default. This distinction is observable and deliberate: a closed empty pipe and `/dev/null` are NOT the same file type (node's spawn pipe is an `AF_UNIX` socket, so `test -c /dev/stdin` holds for `/dev/null` but not for an empty pipe), so the no-stdin path — every model-driven call — must keep `/dev/null` rather than regress to an always-open pipe. Each branch's `stdio` tuple is a literal, which preserves the typed `spawn` overload that guarantees non-null `stdout`/`stderr`. When stdin IS written, a child that exits without reading makes the write fail EPIPE; that error is swallowed (the command's outcome rides on its exit code/output, not the write) so it never crashes the host or rejects `done`.
-## Scope: configurable scrub pattern is NOT included
+## Alternatives considered
An earlier sketch of this work also proposed making `SENSITIVE_ENV_PATTERN` configurable. Validating against the code, that is **speculative and already subsumed**: `run.ts` documents a configurable whitelist as future work, and the new explicit `env` field — merged after the scrub — already gives a caller full control, including over credential-shaped vars. There is no current caller that needs to *broaden* the ambient scrub (the hazard runs the other way). Adding a config knob now would be a speculative surface with no consumer. If a real workflow ever needs to forward a specific ambient credential, the explicit `env` field is the supported path; a configurable scrub can be reconsidered then.
diff --git a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md
index eff6164dbf..9375a6e248 100644
--- a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md
+++ b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md
@@ -1,8 +1,8 @@
# RFC: Event-domain semantics — session is the fact log, agent is the live surface
-Status: implemented (accepted 2026-06-30)
+Status: implemented
-## Context
+## Problem
The harness extends the agent loop through a Cordis event taxonomy (see [the microkernel event-taxonomy RFC](2026-06-11-microkernel-event-taxonomy.md)). As that taxonomy grew, the line between the three event domains blurred:
@@ -33,3 +33,5 @@ This is the foundational change in a stack that adds a Hooks subsystem; it estab
- The loop marks the step open (`stepOpen = true`) BEFORE appending `step/start`, because `Session.append` pushes the event to the log before notifying `session/event` listeners (validation throws happen earlier, before the push — see [the session append contract](../../../core-data-structures/session.md)). So a throwing `step/start` session-event listener runs with the step already open and the event already in the log: the loop's outer catch then calls `closeStep()`, which appends the balancing `step/end`, and the turn closes balanced with an error (`turn/start → step/start → step/end → turn/end` — verified by the invariants oracle in the regression test). Closing the open step is owed precisely because the marker is set first.
- The full realization of this is [the simplification RFC "Stop mirroring durable boundaries as agent events"](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md): all four boundary mirrors are removed and every consumer reads boundaries off `session/event`. `agent/steering` (not a boundary mirror) stayed outside that RFC's scope and was removed by its own follow-up, [Remove the `agent/steering` mirror emit](../simplification/2026-07-04-remove-agent-steering-mirror.md) — it mirrored the durable `steering/message`.
- The cordis events catalog (`docs/cordis-catalog/events.md`) is regenerated to drop the mirror events.
+
+
diff --git a/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md b/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md
index 5669ecd98f..d0656c9327 100644
--- a/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md
+++ b/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md
@@ -16,7 +16,9 @@ Thread the caller's session cwd into path resolution, exactly as `dsh-tool-bash`
- `dsh-fs-local.resolve` uses `resolveLocalTarget(opts?.cwd ?? this.config.cwd, path)`. `config.cwd` stays the default for a caller that supplies none (non-ACP / no-session use, and the single-session stdio demo where `process.cwd()` IS the workspace).
- `dsh-tool-fs`'s `read`/`write`/`edit` derive the session cwd through a shared `sessionCwd(exec)` helper (`exec.agent?.session.header.cwd`, mirroring bash's `resolveWorkdir`) and pass it to `resolve`. A non-agent / headerless caller yields `undefined`, so the backend applies its default.
-## Why the caller supplies the cwd (not the provider)
+## Alternatives considered
+
+### Why the caller supplies the cwd (not the provider)
The provider seam must not depend on `dsh-agent` / `dsh-session` — it is a text-storage backend that a sandboxed or remote implementation also satisfies, and those have no notion of an "agent session". The tool already receives the `ToolExecution` (`exec`), which carries the agent, so the tool is the right place to project `exec → cwd` and hand the provider a plain string. This is the "explicit > implicit at package seams" convention: the base directory arrives as an explicit argument the provider acts on, not smuggled in by having the provider reach into a session it should not know about. It also matches `dsh-tool-bash` one-to-one, so the two model-facing file surfaces resolve paths identically.
diff --git a/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md b/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md
index 9c95dff773..45d5a77e46 100644
--- a/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md
+++ b/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md
@@ -37,9 +37,13 @@ Per the [capability-seam split](2026-06-13-capability-seams.md), the storage bac
`ToolResultView` gains a `DiffResultView { card:'diff'; title?; diffs: FileDiff[] }`; the bridge's result-side `switch (view.card)` gets a `diff` arm emitting the `{type:'diff'}` `ToolCallContent` blocks (mirroring the call-side arm). An ACP `tool_call_update.content` REPLACES the call's content in an editor, so the result diff **supersedes** the call-time snippet (and keeps the model-facing result text from clobbering it) — the two-update sequence (call snippet, then result diff) matches `claude-agent-acp` exactly.
-### The diff algorithm — a third-party runtime dependency over vendoring
+## Alternatives considered
-Computing hunks-with-context is a solved problem with sharp edge cases (grouping, context coalescing, the trailing-newline marker). Rather than hand-roll it, `dsh-tool-fs` takes a runtime dependency on the npm [`diff`](https://www.npmjs.com/package/diff) package (a `^9.0.0` range, exact-pinned by the lockfile; it ships its own types) and uses its `structuredPatch`. The repo's default is to vendor Cordis-framework source, but that policy is about the *framework*; a leaf tool package taking a small, well-known, self-typed utility dependency is the same shape as `dsh-acp` depending on `@agentclientprotocol/sdk`. Vendoring a diff algorithm would be re-implementing a battle-tested one for no benefit — the [pre-release "foundation over blast radius"](../../../../AGENTS.md) reasoning does not argue for re-deriving standard algorithms. The dependency's output is normalized in one small module (`packages/fs/tool-fs/src/diff.ts`).
+**Hand-rolling or vendoring the diff algorithm.** Computing hunks-with-context is a solved problem with sharp edge cases (grouping, context coalescing, the trailing-newline marker). Rather than hand-roll it, `dsh-tool-fs` takes a runtime dependency on the npm [`diff`](https://www.npmjs.com/package/diff) package (a `^9.0.0` range, exact-pinned by the lockfile; it ships its own types) and uses its `structuredPatch`. The repo's default is to vendor Cordis-framework source, but that policy is about the *framework*; a leaf tool package taking a small, well-known, self-typed utility dependency is the same shape as `dsh-acp` depending on `@agentclientprotocol/sdk`. Vendoring a diff algorithm would be re-implementing a battle-tested one for no benefit — the [pre-release "foundation over blast radius"](../../../../AGENTS.md) reasoning does not argue for re-deriving standard algorithms. The dependency's output is normalized in one small module (`packages/fs/tool-fs/src/diff.ts`).
+
+## Consequences
+
+`tool/result` events may now carry a tool-private `meta` payload — part of the on-disk vocabulary, runtime-gated to JSON by `Session.append` — and any tool can attach durable result presentation without another core change. The diff card reproduces on session reload and snapshot replay for free: it is read back from the log, never recomputed. The costs: an overwrite holds both the prior and new text in memory to compute a UI-only hunk (`TODO(overwrite-diff-bound)`), and `dsh-tool-fs` carries a small, well-known runtime dependency.
## Non-goals
diff --git a/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md b/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md
index d574f50bba..cdaf152d73 100644
--- a/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md
+++ b/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md
@@ -58,6 +58,16 @@ interface TerminalResultView { card: 'terminal'; title?: string; output?: string
`claude-agent-acp` relativizes a file card's title path against the session cwd (`toDisplayPath`) — `Read src/foo.ts`, not `/abs/proj/src/foo.ts` — while keeping `locations[]`/`diff.path` **raw** (the editor opens the real path). Our `presentCall` is pure/args-only and cannot see the session cwd, so this relativization happens at the **bridge**, which already threads the session cwd into tool-call rendering (the same cwd it uses to resolve a terminal card's header). The bridge relativizes the title only, by an exact structured replace of the known `locations[0].path`/`diffs[0].path` substring — generic over the file-card kinds, never special-casing tool names.
+## Alternatives considered
+
+- **Delete tool-owned presentation entirely** — [the rejected collapse proposal](../../rejected/simplification/2026-06-20-generic-tool-rendering.md); its own verdict deferred to exactly this union once two real tools and two real consumers existed, and that bar is now met.
+- **A merge-extensible union** (the `ContentBlockMap` pattern) — rejected: a new render intent needs new bridge code to render it anyway, so a plugin-added variant the bridge silently drops would be worse than the compile error the closed union raises at the bridge's `assertNever` switch.
+- **Keeping the optional-field bag** — the status quo the Problem dissects: invalid states representable, undocumented field interactions, and no way to ask for a diff card at all.
+
+## Consequences
+
+A new render intent is a compile-breaking change at the bridge switch — deliberately: rendering code must exist before a card kind does. Invalid card/field combinations are now unrepresentable, and the bash fallback derivation lives in the bridge, so a tool returns one structured shape. The bar for a fourth card (a table, a chart) is writing its bridge arm in the same change.
+
## Non-goals
- **Live incremental `terminal_output_delta` streaming** and **command classification** — the terminal-rendering RFC's own deferred follow-ups, untouched here.
diff --git a/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md b/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md
index a02cfeb5bb..050c0f3590 100644
--- a/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md
+++ b/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md
@@ -1,10 +1,8 @@
-# Add direct directory listing to the filesystem seam
+# RFC: Add direct directory listing to the filesystem seam
-## Status
+Status: implemented
-Implemented.
-
-## Context
+## Problem
`@deepseek-ai/dsh-fs` is the provider seam for filesystem access, with local and future non-local backends behind the same `ctx.fs` contract. Before this change it could resolve paths, stat targets, read text, stream text, write text, and edit text. That was enough for model-facing file tools, but not for non-model-facing consumers that need to enumerate directories without importing `node:fs`.
@@ -36,7 +34,7 @@ Broken or disappeared children may be represented as `type: 'other'` without `ve
- `FS_IO_ERROR` for other backend I/O failures.
- `FS_ABORTED` for aborted calls.
-## Rejected alternatives
+## Alternatives considered
**Add a model-facing list tool now.** Rejected for this PR. The immediate request is the provider seam, and the user explicitly asked not to change skill loading or other upper layers in this branch. A model-facing tool needs prompt/schema/rendering decisions that should be reviewed separately.
diff --git a/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md b/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md
index 217c60cf63..adad17472d 100644
--- a/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md
+++ b/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md
@@ -8,7 +8,7 @@ Status: implemented
The schema should be small enough to implement in the first `dsh-tool-fs` pass, but stable enough that future local/remote/sandboxed filesystem backends do not require model-facing churn. It should also avoid importing every option from reference systems. Claude Code and OpenCode expose similar core file tools but differ in naming style and extra flags; this RFC chooses the minimal shared surface for the prototype.
-## Proposal
+## Decision
`@deepseek-ai/dsh-tool-fs` exposes these three model-facing tools in the first filesystem suite:
@@ -78,7 +78,7 @@ Default native projections:
| `write` | create/update operation, target display path, new file version | concise create/update success text |
| `edit` | replacement count, replace-all flag, target display path, new file version | concise edit success text |
-The structured outcome should not restate model arguments such as `file_path`, `old_string`, or `content` unless the backend has resolved them into new information such as `displayPath`, `targetKey`, or a new version. Token-conscious truncation is part of the model projection, not the backend's canonical result.
+The structured outcome does not restate model arguments such as `file_path`, `old_string`, or `content` unless the backend has resolved them into new information such as `displayPath`, `targetKey`, or a new version. Token-conscious truncation is part of the model projection, not the backend's canonical result.
## Deferred
@@ -91,22 +91,19 @@ The following are deliberately out of scope for the first filesystem schema pass
- Code Mode projection values for filesystem tools.
- A canonical edit diff format.
-## Tests
+## Testing
-`dsh-tool-fs` schema tests should assert:
+Schema tests pin the required/optional argument set per tool, empty-`old_string` rejection, the `replace_all` default, the snake_case field names, description prose that states the observation policy, and root-plugin suite registration; integration tests execute all three tools through `ctx.tools.execute()` against the real `dsh-fs-local` provider and verify the model arguments translate into the expected `ctx.fs` calls and `fs/*` dispatches.
-- `read` requires `file_path` and accepts optional positive integer `offset` / `limit`.
-- `write` requires `file_path` and `content`.
-- `edit` requires `file_path`, `old_string`, and `new_string`, accepts optional boolean `replace_all`, rejects empty `old_string`, and defaults `replace_all` to false.
-- The registered JSON schemas use the snake_case field names in this RFC.
-- The tool descriptions accurately describe that, under the default fs-policy, existing-file `write` and `edit` require a prior observation (any windowed read counts) in the same execution context, while new-file `write` does not.
-- The `tool-fs` root plugin registers all three schemas.
+## Alternatives considered
-Integration tests should execute `read`, `write`, and `edit` through `ctx.tools.execute()` against the real `dsh-fs-local` provider and verify that model arguments are translated into the expected `ctx.fs` calls and `fs/*` dispatches.
+- **A Codex-style patch grammar or multi-mode edit API** — rejected: one strict literal replacement mode keeps the model-facing contract simple and lets the backend own exact-match, duplicate-match, line-ending, and stale-version semantics.
+- **camelCase argument names (OpenCode's style)** — snake_case aligns with Claude Code and the existing harness tool-schema examples, and naming is public surface once shipped.
+- **Model-facing `expected_hash` / `expected_version` / `create_only` parameters** — rejected: stale checks are driven by backend-minted versions and the policy plugin's observed state, never by fragile model-copied tokens.
-## Risks
+## Consequences
-**The first schema is intentionally smaller than Claude Code's.** Dropping PDF pages, multimodal read, rich grep/list flags, and expected hash fields keeps the first implementation focused, but users may ask for those quickly. They should be added as separate RFCs or focused follow-ups rather than overloaded into the initial schema.
+**The first schema is intentionally smaller than Claude Code's.** Dropping PDF pages, multimodal read, rich grep/list flags, and expected hash fields keeps the implementation focused, but users may ask for those quickly. They arrive as separate RFCs or focused follow-ups rather than overloads of the initial schema.
**No explicit model-facing stale guard in v1.** The schema does not ask the model to provide an expected hash/version. That is intentional: stale checks come from backend-produced versions and the `dsh-fs-policy` plugin's observed state, not from fragile model-copied tokens. Filesystem safety failures surface through structured `FsError` codes owned by `dsh-fs`, not through model-supplied version fields.
diff --git a/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md b/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md
index 6e108dc4c1..bca351e906 100644
--- a/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md
+++ b/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md
@@ -28,7 +28,12 @@ Keep `dsh-bash` agent-side execution; render the terminal card via the `_meta` c
3. **Bridge mapping.** When the client advertised the capability, the bridge maps that presentation to: on `tool_call`, `content:[…, {type:'terminal', terminalId}]` (any tool `content`, e.g. the description, rendered BEFORE the terminal block) + `_meta.terminal_info.{terminal_id,cwd}`; on `tool_call_update`, `_meta.terminal_output.{terminal_id,data}` (the captured output) + `_meta.terminal_exit.{terminal_id, exit_code|signal}` (the parsed exit), with the update's text `content` OMITTED (an ACP `tool_call_update.content` REPLACES the call's content collection, so re-sending the fenced block would clobber the terminal content block). `terminalId` is derived from the harness `callId` (stable, unique per call). When the capability is absent, the bridge sends the description content block on the call and the existing ` ```console ` text content on the update — unchanged.
4. **The exit pill is parsed from the rendered output; no new execution path, no live streaming.** Output is attached at completion (from the agent's own `tool/result`), not streamed token-by-token. The exit-status pill (`_meta.terminal_exit.{exit_code,signal}`) IS emitted: the pure `presentResult(args, result)` seam sees only content blocks, so `dsh-tool-bash` recovers the structured exit by parsing the status markers (`[exit code: N]` / `[killed by signal: …]`) that `renderResult` appended — the parse is the exact inverse of the marker emission, the two co-evolve in one file, and a round-trip test guards the pair. Disposal is unaffected: nothing new to tear down, since the bridge never creates a client-side terminal.
-## Risks / trade-offs
+## Alternatives considered
+
+- **The ACP client-side terminal sub-protocol (`terminal/create`)** — explicitly rejected: the editor would execute the process, bypassing `dsh-bash`'s env scrub, background-task ownership, and per-session cwd, and forking execution into two backends. Both reference agents reject it the same way (the key finding above); agent-side execution plus the `_meta` convention is the only shape that yields the terminal card while keeping the harness's execution policy.
+- **Threading a structured exit through the event schema** — rejected in favor of the marker round-trip: the pure `presentResult(args, result)` seam sees only content blocks, and the parse is the exact inverse of the marker emission, co-evolving in one file under a round-trip test.
+
+## Consequences
- **Zed-convention `_meta` keys.** The terminal card rides on Zed-specific keys (`terminal_info`/`terminal_output`/`terminal_exit`) inside ACP's spec-blessed `_meta` extensibility point, NOT on the ACP terminal sub-protocol. A client that doesn't recognize the keys still gets the text fallback (the capability gate ensures we only emit them when the client opted in via `_meta.terminal_output`), so a non-Zed client is never worse off. If ACP later standardizes agent-executed terminals, migrate to that and drop the convention keys.
- **Capability honesty.** Emit terminal metadata ONLY when the client advertised `_meta.terminal_output`; the text fallback is the contract for everyone else and must never regress. Covered by a no-capability test asserting the ` ```console ` path.
@@ -38,4 +43,4 @@ Keep `dsh-bash` agent-side execution; render the terminal card via the `_meta` c
## Out of scope / non-goals
-The text-block baseline stays the no-capability default. Client-side `terminal/create` execution is explicitly rejected (it bypasses `dsh-bash`). Two follow-ups are deliberately NOT built here and would each warrant their own RFC when someone takes them on: **live incremental streaming** (`_meta.terminal_output_delta` as chunks arrive, which needs an incremental-output seam on `dsh-bash`), and **command classification** (parsing a `cat`/`sed` as a `read` card with a file location, a `grep` as a `search`, etc., falling back to the terminal card — display-only, must never change what executes).
+The text-block baseline stays the no-capability default. Two follow-ups are deliberately NOT built here and would each warrant their own RFC when someone takes them on: **live incremental streaming** (`_meta.terminal_output_delta` as chunks arrive, which needs an incremental-output seam on `dsh-bash`), and **command classification** (parsing a `cat`/`sed` as a `read` card with a file location, a `grep` as a `search`, etc., falling back to the terminal card — display-only, must never change what executes).
diff --git a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md
index f09dab5cd1..bba12727f7 100644
--- a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md
+++ b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md
@@ -1,14 +1,14 @@
# RFC: Compaction as a capability seam (abstract contract + basic backend)
-Status: implemented (2026-06-18; retention/seam reform 2026-06-26)
+Status: implemented
-## Context
+## Problem
A long-running agent conversation grows without bound. As the event log accumulates turns, the derived message history eventually approaches the model's context window — the model then truncates mid-response (`max-tokens`) or degrades. **Compaction** is the mitigation: replace a run of older history with a concise summary, keeping recent context intact.
The [session surface](../../implemented/architecture/2026-06-18-session-surface.md) was built as the foundation for exactly this — a linked list over the event log with a `surfaceOp: { op: 'replace', start, end }` operation purpose-built to shadow a range of nodes and insert a replacement, with `sourceEventSeqs` recording provenance so the decision replays deterministically. What remained was the plugin that *decides what to compact and produces the summary*.
-Two forces shape the design. First, compaction is **swappable**: token counting can be a char/4 heuristic or a real tokenizer, and summarization can be a model call, a template, or a remote service — these vary independently of *when* and *which range* to compact. Second, a later commit (`ce43c25`) closed `SurfaceEventType` to five event types (`user/message`, `assistant/message`, `tool/result`, `context/message`, `steering/message`); only those may carry `surfaceOp`. A bespoke `compaction/*` event therefore **cannot** itself appear on the surface — the compiler rejects `surfaceOp` on it and the invariants plugin rejects it at runtime.
+Two forces shape the design. First, compaction is **swappable**: token counting can be a char/4 heuristic or a real tokenizer, and summarization can be a model call, a template, or a remote service — these vary independently of *when* and *which range* to compact. Second, `SurfaceEventType` is closed to five event types (`user/message`, `assistant/message`, `tool/result`, `context/message`, `steering/message`); only those may carry `surfaceOp`. A bespoke `compaction/*` event therefore **cannot** itself appear on the surface — the compiler rejects `surfaceOp` on it and the invariants plugin rejects it at runtime.
## Decision
@@ -103,6 +103,13 @@ Two failure paths, both documented:
**Core session repair stays compaction-agnostic — deliberately.** `interruptedTurnClosers` is never taught about `compact/*`. Teaching it would force every future `xxx/start … xxx/end` plugin pair to patch a core module — exactly the coupling the capability-seam architecture exists to avoid. Because the log-only orphan is inert, no special repair is needed: generic turn-repair plus the inertness of an un-landed surface mutation is sufficient.
+## Alternatives considered
+
+- **The full algorithm as concrete interface methods** (only estimation/summarization abstract) — the earlier draft; rejected because it recouples the contract to one retention strategy. Both core methods are abstract; the `protected` estimation/summarization hooks are the backend's private factoring, not the contract's.
+- **Compaction on the `agent/request` waterfall** — the earlier cut; rejected for the double-derive it forced and for handing the listener context it structurally cannot compact. The dedicated `agent/pre-step` seam makes the layering correct by construction.
+- **A separate `compact/error` event** — rejected: `compact/end` keeps an `error?` field, mirroring `tool/result`'s self-contained error — one event tells success from failure without correlating a sibling.
+- **Teaching core turn-repair about `compact/*`** — rejected: the log-only orphan is inert, and a core module patched for every future `xxx/start … xxx/end` plugin pair is exactly the coupling the capability-seam architecture exists to avoid.
+
## Consequences
- **New packages**: `packages/compact/compact` (interface) and a sibling `compact-basic` (backend) under `packages/compact/`, wired into the root tsconfigs. The consumer tier is deferred.
diff --git a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md
index 77a838b299..28daff14ff 100644
--- a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md
+++ b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md
@@ -2,11 +2,11 @@
Status: implemented
-> **Implementation status:** shipped across four PRs. PR1 landed this proposal + the `dsh-subagent` interface, the `dsh-subagent-mock` test backend, and the `dsh-tool-subagent` consumer; PR2 the two in-process backends (`dsh-subagent-spawn`, `dsh-subagent-fork`); PR2.5 the nested-agent snapshot infrastructure (see [Per-session snapshot replay for nested agents](../testing/2026-06-22-subagent-snapshot-replay.md)); PR3 the out-of-process `dsh-subagent-acp` backend (see [ACP subagent backend](2026-06-22-acp-subagent-backend.md)). The design below is amended to describe what actually landed.
+> The full seam is shipped: the `dsh-subagent` interface, the `dsh-subagent-mock` test backend, and the `dsh-tool-subagent` consumer; the two in-process backends (`dsh-subagent-spawn`, `dsh-subagent-fork`); the nested-agent snapshot infrastructure ([per-session snapshot replay](../testing/2026-06-22-subagent-snapshot-replay.md)); and the out-of-process `dsh-subagent-acp` backend ([its RFC](2026-06-22-acp-subagent-backend.md)).
## Problem
-The harness has a long-deferred seam for **subagents** — an agent delegating work to another agent. The intent was sketched in the `Agent`/`AgentLoop` interfaces ([packages/core/agent/src/types.ts](../../../../packages/core/agent/src/types.ts), [packages/core/agent-loop/src/index.ts](../../../../packages/core/agent-loop/src/index.ts)): a creation option referencing a parent agent (fork = seed the child session with the parent's event log; spawn = fresh session), with the child returned as an `Agent` handle so steering and event subscription work uniformly. This RFC realizes that seam (see the implementation-status banner above for what has landed); the design below is the proposal it was argued from, when no service, vocabulary, or implementation yet existed.
+The harness has a long-deferred seam for **subagents** — an agent delegating work to another agent. The intent was sketched in the `Agent`/`AgentLoop` interfaces ([packages/core/agent/src/types.ts](../../../../packages/core/agent/src/types.ts), [packages/core/agent-loop/src/index.ts](../../../../packages/core/agent-loop/src/index.ts)): a creation option referencing a parent agent (fork = seed the child session with the parent's event log; spawn = fresh session), with the child returned as an `Agent` handle so steering and event subscription work uniformly. This RFC realizes that seam; the banner above lists what shipped.
The distinctive requirement — the one that shapes the whole design — is that **multiple subagent implementations must coexist at runtime**. A parent may want a cheap in-process child for a scoped subtask AND an isolated out-of-process child (over ACP) in the same session. The transports we foresee:
@@ -14,11 +14,13 @@ The distinctive requirement — the one that shapes the whole design — is that
- **ACP** — act as an ACP *client* driving another agent process (which can be another instance of ourselves);
- later: **A2A**, the **Codex app-server**, and the **Claude Code Agent SDK** — each the same out-of-process "start a child, prompt it, stream updates, cancel" shape as the ACP backend.
-## Why not the bash seam shape
+## Alternatives considered
+
+### Why not the bash seam shape
The bash seam ([capability seams](../../implemented/architecture/2026-06-13-capability-seams.md)) registers exactly one `BashExecutor` per context; loading a second throws. That is correct for bash (one machine, one way to run a command) but wrong here: coexistence is the requirement. So the subagent service is a **named-provider registry** — each implementation registers under a unique name and a caller picks one by name — mirroring the **LLM adapter registry** (`LlmService.registerAdapter`), not the single-service bash executor. The seam is still three-package (interface / implementation / consumer); only the "one vs. many implementations" axis differs.
-## Proposal
+## Decision
### The three-package seam
@@ -27,11 +29,11 @@ A new package group `packages/subagent/`:
| Package | Role |
|---|---|
| `@deepseek-ai/dsh-subagent` | interface: `SubagentService` (`ctx.subagents`), `SubagentProvider`, `SubagentRun`, the request/result/capability vocabulary, the `subagent/*` events |
-| `@deepseek-ai/dsh-subagent-spawn` | implementation: a fresh in-process child via `ctx.agents.create` (PR2) |
-| `@deepseek-ai/dsh-subagent-fork` | implementation: an in-process child seeded with a snapshot of the parent's log (PR2) |
-| `@deepseek-ai/dsh-subagent-acp` | implementation: an ACP client driving a configured child process (PR3) |
-| `@deepseek-ai/dsh-subagent-mock` | support: a scripted provider for testing the seam through the real load path (PR1) |
-| `@deepseek-ai/dsh-tool-subagent` | consumer: the model-facing `subagent` tool over `ctx.subagents` (PR1) |
+| `@deepseek-ai/dsh-subagent-spawn` | implementation: a fresh in-process child via `ctx.agents.create` |
+| `@deepseek-ai/dsh-subagent-fork` | implementation: an in-process child seeded with a snapshot of the parent's log |
+| `@deepseek-ai/dsh-subagent-acp` | implementation: an ACP client driving a configured child process |
+| `@deepseek-ai/dsh-subagent-mock` | support: a scripted provider for testing the seam through the real load path |
+| `@deepseek-ai/dsh-tool-subagent` | consumer: the model-facing `subagent` tool over `ctx.subagents` |
### The primitive: `start → SubagentRun`
@@ -44,7 +46,7 @@ A provider exposes `start(request) → SubagentRun`. The run carries a `result`
### Fork vs. fresh are separate backends, not a flag
-Rather than a `context: 'fresh' | 'fork'` request field, the distinction is the provider's identity: `dsh-subagent-spawn` (fresh, isolated, own system prompt) and `dsh-subagent-fork` (seeded from the parent's log) are two registered providers. You pick behavior by picking a provider — consistent with the registry being the selection mechanism.
+Rather than a `context: 'fresh' | 'fork'` request field, the distinction is the provider's identity: `dsh-subagent-spawn` (fresh, isolated, own system prompt) and `dsh-subagent-fork` (seeded from the parent's log) are two registered providers. You pick behavior by picking a provider — consistent with the registry being the selection mechanism. The fork backend seeds only a **balanced, completed-turn prefix** of the parent log: at tool-execute time the parent's turn is open (it holds the `assistant/message` and the dangling spawn `tool/call` with no `tool/result`), and seeding that raw prefix would give the child an unbalanced turn the [invariants](../../../../packages/support/invariants/src/index.ts) freeze-check rejects.
### Child isolation and the parent log
@@ -58,13 +60,11 @@ The `dsh-tool-subagent` consumer awaits `run.result` and returns the child's fin
`dsh-tool-subagent` binds to exactly one provider name (`Config.provider`); the model sees only `{ description, prompt }`. To expose more than one transport, load the tool plugin more than once, each bound to a different provider and a distinct `toolName` (the tool registry rejects a duplicate name). The *service* holds the multi-provider registry; the *tool* picks one — no provider/type parameter in the schema this cut.
-## Plan (three PRs, each converged with Codex separately)
+## Testing
-1. **PR1 — interface + tool + mock.** This RFC, `dsh-subagent` (service, registry, vocabulary, `subagent/*` events), `dsh-subagent-mock` (scripted provider), `dsh-tool-subagent`. Wire the new `packages/subagent/` group into the tsconfigs, the build references, the package hierarchy docs, and the module graph. Tests: registry HMR-safety, duplicate-name rejection, start-time capability rejection, and at least one test driving the tool through the **real cordis Loader / export path** (a hand-built `ctx.plugin` mount bypasses `unwrapExports` and cannot catch a broken export shape — see [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md)).
-2. **PR2 — in-process backends.** `dsh-subagent-spawn` and `dsh-subagent-fork` over `ctx.agents.create` + `AgentHandle.dispose`. The fork backend must seed only a **balanced, completed-turn prefix** of the parent log: at tool-execute time the parent's turn is open (it holds the `assistant/message` and the dangling spawn `tool/call` with no `tool/result`), and seeding that raw prefix gives the child an unbalanced turn the [invariants](../../../../packages/support/invariants/src/index.ts) freeze-check rejects. Depth tracking (parent depth + 1, refused past `maxDepth`) and its exact storage are settled in PR2.
-3. **PR3 — ACP backend.** `dsh-subagent-acp` as an ACP client over a configured spawn command (stdio); point it at our own `acp-agent` example to "talk to our own process". Minimal client stub: advertise no optional client capabilities, auto-resolve `session/request_permission` via a configured default, consume `session/update` without surfacing it this cut. Decide the `@agentclientprotocol/sdk` version (recommended: bump to 0.28.x for the fluent client API; the bump is shared with the existing `dsh-acp` bridge, so re-run its snapshot + e2e).
+The seam is tested through the real cordis Loader / export path, not a hand-built `ctx.plugin` mount (which bypasses `unwrapExports` and cannot catch a broken export shape — [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md)); the registry pins HMR-safety, duplicate-name rejection, and start-time capability rejection; the nested-agent snapshot scenarios replay keyless in the default gate ([per-session snapshot replay](../testing/2026-06-22-subagent-snapshot-replay.md)); in-process backends carry real-loop unit tests plus a with-key e2e.
-## Risks and deferrals
+## Consequences
- **Recursion.** Without a guard, an in-process child inherits the spawn tool and can spawn unboundedly. Depth-limit is an optional capability (the in-process backends enforce it; ACP advertises it off and rejects a `maxDepth` request); tool-filtering is likewise optional. Tool-filtering, when implemented, needs a `tools/pre-execute` deny in the child context — schema filtering alone is insufficient because a model can hallucinate a denied tool name.
- **Blocking the parent turn.** Synchronous collect holds the parent's `runStep` open for the child's full duration. This is acceptable for the first cut; **background / poll / spill semantics are deferred to a future redesign that unifies long-running-tool handling across subagents AND bash** (a sub-agent and a long `bash` background task pose the same "the model started something slow, how does it collect later" problem, and should share one mechanism rather than each inventing its own).
diff --git a/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md b/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md
index aa56483f32..ec608177b4 100644
--- a/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md
+++ b/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md
@@ -12,7 +12,7 @@ The subagent seam ([the seam RFC](2026-06-21-subagent-capability-seam.md)) was b
### Fresh process per run
-Each `start` spawns a new child, runs exactly one ACP session (`initialize` → `newSession` → `prompt`), and `dispose` kills the subprocess and awaits its exit. This is the simplest lifecycle and mirrors the in-process one-child-per-run shape. Persistent-process pooling (reuse a warm child across runs) is a performance optimization deferred to future work — it adds session-lifecycle and crash-recovery complexity the first cut does not need.
+Each `start` spawns a new child, runs exactly one ACP session (`initialize` → `newSession` → `prompt`), and `dispose` kills the subprocess and awaits its exit. This is the simplest lifecycle and mirrors the in-process one-child-per-run shape.
### Minimal client stub
@@ -26,10 +26,6 @@ The provider's `capabilities` are all `false`. An out-of-process child cannot ho
ACP `StopReason` → harness `SubagentStopReason`: `end_turn`→`completed`, `max_tokens`→`max-tokens`, `refusal`→`refusal`, `cancelled`→`aborted`, `max_turn_requests`→`error` (no clean equivalent — the task did not finish), unknown→`error`. A spawn/transport/RPC failure resolves `error` (or `aborted` if a cancel was requested); `result` never rejects on a child-level failure, per the seam contract.
-### SDK version: stayed on 0.25.1
-
-The plan proposed bumping `@agentclientprotocol/sdk` 0.25.1 → 0.28.x for the new fluent `acp.client()` / `ActiveSession.nextUpdate()` API. Validating that against the code (the AGENTS.md "RFC is a proposal, not golden truth" discipline) reversed the decision: the backend only needs `ClientSideConnection` + `ndJsonStream` + `PROTOCOL_VERSION` + the `Client`/`Agent`/`StopReason` types, **all present and non-deprecated in 0.25.1**. The fluent API and `unstable_forkSession` that motivated the bump are never used here, so the "cleaner client code" benefit did not materialize. Worse, 0.28.x **deprecates both** `ClientSideConnection` AND `AgentSideConnection` (it wants all callers on the fluent builders), which turns the `no-deprecated` lint red across the entire existing ACP layer — 33 usages including the server bridge this PR has no business rewriting. That cross-cutting connection-API migration is its own PR, not baggage for "add an ACP subagent backend". So the bump was reverted and the backend is written against 0.25.1 (the plan's own fallback clause: "if the bump proves disruptive, fall back to `ClientSideConnection` (0.25.1), which is sufficient"). Migrating the whole ACP layer to the fluent API on a later 0.28.x bump is a worthwhile standalone follow-up.
-
### Security: scrubbed child environment
The child is a separate process, so it inherits an environment. Credential-shaped ambient vars (`/KEY|SECRET|TOKEN/i`) are NOT forwarded by default — the parent harness's own secrets must not leak into a spawned process implicitly (the same policy the bash executor applies). The child's OWN credentials (it needs a model key) are supplied EXPLICITLY via `config.env`, layered AFTER the scrub, so an intended `DEEPSEEK_API_KEY` survives while an incidental `AWS_SECRET_ACCESS_KEY` does not. Child stderr is inherited to the parent's stderr (diagnostics surface naturally); a spawn-level `error` event (e.g. ENOENT for a bad command) is captured and raced against the ACP drive, so a bad command settles `error` instead of crashing the parent with an unhandled error.
@@ -40,7 +36,21 @@ Designed at every tier the backend touches, per the root AGENTS.md rule that a n
- **Keyless unit/integration** (`subagent-acp.spec.ts`): spawns a scripted mock ACP server subprocess (`tests/mock-acp-server.ts`) and drives it through the real backend over real ACP stdio. Covers: the prompt round-trip + output accumulation; every StopReason mapping; cancellation via `run.cancel()` and via the request signal; the already-aborted-before-start case; the cancel-races-ahead-of-newSession case; a torn-pipe-after-cancel (child crashes on cancel) settling `aborted`; permission auto-answer under both policies (including the allow-policy-no-allow-option fallback); a non-message update consumed but not accumulated; a nonexistent-command spawn failure settling `error`; HMR provider cleanup; and the namespace export shape. 100% per-file coverage.
- **With-key e2e** (`subagent-acp.e2e.ts`): the harness drives ITSELF — the backend spawns the real `acp-agent` example process and a real model in that child answers a prompt (PONG) and does real file work (writes `proof.txt`, verified on disk). Self-skips without `DEEPSEEK_API_KEY`. This is the "talk to our own process" smoke and the out-of-process analogue of the in-process spawn e2e.
-- **Snapshot**: deferred as `TODO(acp-subagent-replay)`. An ACP child is a distinct replay shape — each child is its own PROCESS with its own single-agent replay (booted under `DSH_SNAPSHOT=replay` with its own sessions-root + fixture), unlike the in-process per-session keying that [PR2.5](../testing/2026-06-22-subagent-snapshot-replay.md) added. The keyless mock-server tests give deterministic coverage of the backend in the meantime; the snapshot follow-up would record the parent driving a real-but-replayed ACP child.
+- **Snapshot**: deferred as `TODO(acp-subagent-replay)`. An ACP child is a distinct replay shape — each child is its own PROCESS with its own single-agent replay (booted under `DSH_SNAPSHOT=replay` with its own sessions-root + fixture), unlike the in-process per-session keying that [the per-session replay RFC](../testing/2026-06-22-subagent-snapshot-replay.md) added. The keyless mock-server tests give deterministic coverage of the backend in the meantime; the snapshot follow-up would record the parent driving a real-but-replayed ACP child.
+
+## Alternatives considered
+
+### Why not the 0.28.x SDK bump?
+
+The plan proposed bumping `@agentclientprotocol/sdk` 0.25.1 → 0.28.x for the new fluent `acp.client()` / `ActiveSession.nextUpdate()` API. Validating that against the code (the AGENTS.md "RFC is a proposal, not golden truth" discipline) reversed the decision: the backend only needs `ClientSideConnection` + `ndJsonStream` + `PROTOCOL_VERSION` + the `Client`/`Agent`/`StopReason` types, **all present and non-deprecated in 0.25.1**. The fluent API and `unstable_forkSession` that motivated the bump are never used here, so the "cleaner client code" benefit did not materialize. Worse, 0.28.x **deprecates both** `ClientSideConnection` AND `AgentSideConnection` (it wants all callers on the fluent builders), which turns the `no-deprecated` lint red across the entire existing ACP layer — 33 usages including the server bridge this backend has no business rewriting. That cross-cutting connection-API migration is its own change, not baggage for "add an ACP subagent backend". So the bump was reverted and the backend is written against 0.25.1 (the plan's own fallback clause: "if the bump proves disruptive, fall back to `ClientSideConnection` (0.25.1), which is sufficient"). Migrating the whole ACP layer to the fluent API on a later 0.28.x bump is a worthwhile standalone follow-up.
+
+### Why not a persistent child process?
+
+Persistent-process pooling (reuse a warm child across runs) is a performance optimization deferred to future work — it adds session-lifecycle and crash-recovery complexity the first cut does not need; each `start` spawning a fresh child mirrors the in-process one-child-per-run shape.
+
+## Consequences
+
+Every run pays a fresh subprocess (spawn + `initialize` + `newSession`). The parent surfaces only the child's final answer: `session/update` thoughts and tool-call cards are consumed and dropped, and permission prompts never reach a human — the configured policy answers them. The child's environment is credential-scrubbed by default, so its own model key is supplied explicitly via `config.env`.
## Future providers
diff --git a/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.md b/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.md
index a2287918f9..90bd274eb3 100644
--- a/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.md
+++ b/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.md
@@ -51,8 +51,12 @@ Four tiers, designed up front:
- **`session/load` replay** — a persisted `todo/write` re-emits the `plan` update when a fresh ACP bridge loads the session.
- **With-key e2e + snapshot** — a real prompt induces a `todo_write`; the snapshot golden gains the `plan` notification and the log event.
-## Alternatives rejected
+## Alternatives considered
- **In-memory `ctx.todos` service** — would reinvent durability, replay, and `session/load` reconstruction the log gives for free.
- **Per-item delta protocol** — only needed for a shared multi-owner list, which is out of scope; whole-list replace is simpler and matches the references.
- **Tool in `core/`** — `todo_write` is an extension tool registering on `ctx.tools`, not part of the spine; it lives in its own `packages/todo/` group like other tool families.
+
+## Consequences
+
+The todo list is durable, replayable session state: a persisted `todo/write` re-emits the editor's `plan` update on `session/load`, and the log — not plugin memory — is the single source of truth. Whole-list replace means one tool call per update with last-write-wins; there is no delta protocol to reconcile. The event stays off the surface, so a todo update never perturbs the derived model history — the model sees only its own tool call and result.
diff --git a/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
index 3bedd73b34..630d4dd3ed 100644
--- a/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
+++ b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
@@ -1,10 +1,8 @@
# RFC: dsh-hooks-claude + dsh-hooks-codex — the Claude Code / Codex hook bridges
-Status: implemented (accepted 2026-06-30)
+Status: implemented
-
-
-## Context
+## Problem
The harness's extension surface is its typed interception seams ([the interception-seams RFC](2026-06-30-interception-seams.md)): a "native hook" is just an ordinary cordis plugin subscribing to `agent/session-start`, `agent/prompt-submit`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation`, `subagent/start`, `subagent/end`. But users arrive with **existing** Claude Code (CC) and Codex hook configs — a `hooks.json` (or a settings file's `hooks` key) full of shell-command hooks — and want those to run unmodified. This RFC introduces the two **bridge plugins** that translate that external shell-hook protocol onto the typed seams, built on the shared wire-protocol library ([the hook-protocol-lib RFC](2026-06-30-hook-protocol-lib.md)).
@@ -60,9 +58,9 @@ Two different cwds, kept distinct on purpose. The hooks **themselves** run in th
- **Config discovery.** The path is explicit in `cordis.yml` and process-level (see above); the full multi-layer CC/Codex precedence walk, per-session project-local discovery, and the trust/hash model are not reimplemented (`TODO(per-session-hook-config)`).
- **Session-start / subagent-start context is best-effort, not gated (`TODO(session-start-gating)`).** `agent/session-start` is a synchronous emit and the bridge runs its hook on a detached `.then`, so the injected `additionalContext` is not guaranteed to land before the first turn reaches the model — a slow hook can miss the first request (the context then arrives as a later injection). `subagent/start` is sharper: an in-process provider may have already queued the child's prompt before the listener runs, and a short-lived child can finish before the detached inject fires. Making startup context a gated/awaited primitive is a loop-level change deferred to the interception seams; today the contract is "injected as soon as the hook resolves", not "before the first request". The bridge tests do NOT wait on the injection where they assert the guaranteed-timing behavior, so they document the real (best-effort) timing rather than masking it.
-### Multiple hooks on one point run serially, not concurrently
+## Alternatives considered
-The reference engines run a point's matched hooks concurrently and fold the results. These bridges run them **serially** (`await` per hook inside the match loop) and fold with the same most-restrictive merge. Serial is deliberate: it keeps each hook's `hook/invoked`/`hook/result` pair adjacent and in a deterministic order in the session log, and the fold is order-independent for the decision (`deny > ask > allow`) so the outcome matches. The cost is latency (hook *N* waits for hook *N−1*) and that per-hook timeouts are not overlapped — acceptable for the hook counts real configs use; revisit if a config ever fans out enough for the wall-clock to matter.
+**Concurrent per-point hook execution.** The reference engines run a point's matched hooks concurrently and fold the results. These bridges run them **serially** (`await` per hook inside the match loop) and fold with the same most-restrictive merge. Serial is deliberate: it keeps each hook's `hook/invoked`/`hook/result` pair adjacent and in a deterministic order in the session log, and the fold is order-independent for the decision (`deny > ask > allow`) so the outcome matches. The cost is latency (hook *N* waits for hook *N−1*) and that per-hook timeouts are not overlapped — acceptable for the hook counts real configs use; revisit if a config ever fans out enough for the wall-clock to matter.
## Consequences
diff --git a/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.md b/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.md
index 12a47ddcf1..fa83dbd779 100644
--- a/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.md
+++ b/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.md
@@ -1,10 +1,8 @@
# RFC: dsh-hook-protocol — the shared Claude Code / Codex hook wire-protocol core
-Status: implemented (accepted 2026-06-30)
+Status: implemented
-
-
-## Context
+## Problem
The hooks subsystem ships two bridge plugins: one that runs a user's existing Claude Code (CC) hooks, one for Codex hooks. Studying the reference implementations (`~/repos/refs/claude-code`, `~/repos/refs/codex`) surfaced a decisive fact: **Codex deliberately reimplements a SUBSET of the CC hook protocol.** Its engine reads the same `hooks.json`, uses the same matcher-group shape, the same exit-code/structured-stdout output contract, and the same command-hook execution model — Codex's source even names the engine after Claude's and comments where it "intentionally diverges." So the two bridges would otherwise duplicate the bulk of the protocol.
@@ -23,9 +21,9 @@ A new `packages/hooks/` group with `hook-protocol` as a pure library. It owns fo
**Per-dialect (the bridge plugins):** building each event's stdin payload (CC's base+per-event field sets vs Codex's snake_case with `turn_id`/`model` extras), the dialect's env + `${CLAUDE_PLUGIN_ROOT}` substitution (CC) vs none (Codex), and mapping the neutral `HookOutput`/`MergedHookOutcome` onto the harness's seam-specific typed Decisions (`PreToolDecision`, `PromptDecision`, `ContinuationDecision`, `PostToolDecision`).
-### Why "shared core + per-dialect adapters", not "one parameterized engine"
+## Alternatives considered
-A single engine parameterized by a full `dialect` descriptor was considered and rejected. The payload construction and decision mapping are where the dialects genuinely diverge (different field names, different supported outputs, CC's env/substitution); folding those into a data-driven descriptor would make the *bridge* logic indirect — a reader of `dsh-hooks-claude` would have to chase a descriptor to see what payload it sends. Keeping the truly-identical primitives shared (matcher, codec, runner, merge, events) and letting each bridge write its own straightforward payload+mapping keeps each bridge readable standalone, at the cost of a little duplication in the payload shape. The primitives are the part where duplication would actually be dangerous (a divergent matcher or exit-code rule is a correctness bug); the payload is the part where explicitness beats sharing.
+**One parameterized engine.** A single engine parameterized by a full `dialect` descriptor was considered and rejected. The payload construction and decision mapping are where the dialects genuinely diverge (different field names, different supported outputs, CC's env/substitution); folding those into a data-driven descriptor would make the *bridge* logic indirect — a reader of `dsh-hooks-claude` would have to chase a descriptor to see what payload it sends. Keeping the truly-identical primitives shared (matcher, codec, runner, merge, events) and letting each bridge write its own straightforward payload+mapping keeps each bridge readable standalone, at the cost of a little duplication in the payload shape. The primitives are the part where duplication would actually be dangerous (a divergent matcher or exit-code rule is a correctness bug); the payload is the part where explicitness beats sharing.
## Consequences
diff --git a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md
index 138fe975a1..7795b044f6 100644
--- a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md
+++ b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md
@@ -1,10 +1,8 @@
# RFC: Interception seams — the typed-Decision surface a hook programs against
-Status: implemented (accepted 2026-06-30)
+Status: implemented
-
-
-## Context
+## Problem
The harness needs a hooks subsystem: users extend or gate the agent at lifecycle points the way Claude Code (CC) and Codex do. The key reframe driving this design is that **"native hooks" are not a package** — a native hook is just an ordinary Cordis plugin subscribing to the canonical lifecycle events. So the real product is a *powerful, well-typed canonical event surface*; the CC/Codex bridges (the `dsh-hooks-claude` / `dsh-hooks-codex` packages) are merely translators that map an external shell-hook protocol onto that same surface. Anything a bridge can do, a plain plugin can do directly — more powerfully (no serialization boundary, full `ctx`, typed returns).
@@ -40,6 +38,11 @@ Add/reshape the interception seams so every one returns a small, seam-specifi
It does **not** declare `hook/*` SessionEvents (the durable hook-invocation log) — those belong to the `dsh-hook-protocol` library, because a native plugin can already use the typed Decisions without a durable hook log. A worked native-plugin example/test in this PR (`packages/core/agent-loop/tests/interception.spec.ts`) proves all the seams compose end-to-end through the REAL loop with NO `hook/*` involved — the concrete proof that "native hooks are just a plugin". Compaction (`PreCompact`/`PostCompact`), the Notification hook, Codex `PermissionRequest`, the permission/`ask` system, and the Stop loop-guard remain deferred (`FIXME(permissions)` marks the `ask`→deny degrade).
+## Alternatives considered
+
+- **Shipping pre-tool INPUT rewrite as part of this seam set** — deferred as the over-reach signal; the section above carries the consistency problem (audit, history, and presentation all read `tool/call.arguments` logged before execution), and [the pre-tool input-rewrite proposal](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md) owns the design.
+- **Declaring the durable `hook/*` SessionEvents alongside the seams** — rejected: a native plugin uses the typed Decisions with no hook log at all (the worked example proves it), so the durable log belongs to [the hook-protocol library](2026-06-30-hook-protocol-lib.md), not the seam surface.
+
## Consequences
The canonical interception surface is now complete and uniformly typed: a native plugin returns typed decisions directly, and a CC/Codex bridge maps its protocol fields onto the same unions. The loop gained four firing points (session-start emit, prompt-submit waterfall, the post-tool context buffer, the continuation reshape) and the `dsh-tools` registry runs a two-waterfall pipeline; both are documented in [architecture.md](../../../architecture.md) and the package READMEs, and the decision types in [core-data-structures](../../../core-data-structures/core.md#interception-decisions) + [tools.md](../../../core-data-structures/tools.md). All existing `tools/execute` and `turn-continuation` listeners (tests, docs) migrated to the new seams. The ACP bridge maps the new `rejected` reason to `cancelled` (its codec). A pure internal change with no editor-visible transcript shift for the existing scenarios — the new behavior only fires when a hook is registered — so the snapshot goldens are unchanged; a hook-driven snapshot scenario lands with the `dsh-hooks-claude` bridge, which is what makes a hook observable end-to-end through ACP.
diff --git a/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md b/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md
index b67ede3ab6..f3b1c9bfb6 100644
--- a/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md
+++ b/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md
@@ -1,17 +1,8 @@
# RFC: Subagent lifecycle enrichment — lastAssistantMessage (observe-only)
-Status: implemented (accepted 2026-06-30)
+Status: implemented
-
-
-
-## Context
+## Problem
The hooks subsystem ([interception seams RFC](2026-06-30-interception-seams.md)) lets a plugin observe and gate the agent at lifecycle points. Claude Code and Codex both expose **SubagentStart / SubagentStop** hooks, and CC's carry the subagent's final message. The harness already emits `subagent/start` and `subagent/end` lifecycle events ([the subagent capability-seam](2026-06-21-subagent-capability-seam.md)), but their payloads were minimal (`provider`, `id`, and on end `stopReason`) — not enough for a hooks bridge to report WHAT a subagent produced without separately reaching for the live run.
@@ -23,6 +14,12 @@ This RFC enriches the end payload. It is deliberately **observe-only**: no contr
Both events stay plain **`emit`s**. `subagent/end` fires from a detached `.then` on `run.result` and awaits no listener, so it is genuinely observe-only by construction — a `subagent/start` listener can still reach the live child via `ctx.agents.get(info.id)` and `inject()` into it; a `subagent/end` listener can only observe (the run has settled). Per-listener containment (already in place) keeps one bad subscriber from stranding a live run or surfacing as an unhandled rejection on the detached settle hook.
+## Alternatives considered
+
+**An `agentType` subagent-kind label** (the harness analogue of CC's `subagent_type`) on the request + both lifecycle payloads — an earlier draft shipped it; dropped in review because it is a Claude-Code concept that does not fit our own seam (nothing here interprets it, and the only consumer was a CC-dialect bridge). The CC bridge instead feeds Claude Code's own default matcher value `"general-purpose"` for its SubagentStart/Stop `agent_type` matcher, so this RFC ships ONE enrichment: `lastAssistantMessage`.
+
+**A control-flow `subagent/end`** — deferred; see below.
+
## Why observe-only, and what is deferred
A control-flow `subagent/end` (an awaited waterfall returning a stop/continue decision, like the other interception seams) would require: reshaping `subagent/end` from emit to waterfall, restructuring `SubagentService.start` to await listeners before settling, and implementing the `resume` capability in the in-process provider so a "continue" can actually re-run the child. That belongs to the background/steering subagent redesign the [capability-seam RFC](2026-06-21-subagent-capability-seam.md) already defers (the same redesign that unifies long-running-tool handling across subagents and bash). This RFC ships the observe-only enrichment a hooks bridge needs today; `FIXME(subagent-continuation)` / `TODO` anchors mark where the control-flow version would land if and when that redesign happens.
diff --git a/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.md b/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.md
index ba44b61373..8fd37a1656 100644
--- a/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.md
+++ b/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.md
@@ -1,10 +1,8 @@
# RFC: Doc-sync enforcement
-Status: implemented (accepted 2026-06-14)
+Status: implemented
-
-
-## Context
+## Problem
AGENTS.md promises that docs and code stay strictly in sync, but the promise was verified by eyeball. Review caught drift twice — a cookbook example contradicting the type policy, and a README citing the wrong `registerAdapter` call. Out-of-sync docs are worse than no docs, and this codebase is built primarily by agents that follow gates far more reliably than prose (mechanical quality gates). Two classes of doc drift are mechanically checkable: code blocks that no longer compile, and the event-taxonomy table that duplicates the `interface Events` declarations.
@@ -15,13 +13,18 @@ Two gates, mirroring the existing `scripts/` style (tsx ESM, one job each):
1. **`doc-typecheck`** extracts every fenced ` ```ts ` block from `README.md`, `docs/**`, and `packages/*/README.md`, writes them to a temp project extending the root `tsconfig.json`, and compiles it with `tsc -b`. The temp project reuses the source `paths` map and the root project references, so documentation examples see source while vendored code remains checked under its own tsconfig settings. A block that is a deliberate sketch opts out with an explicit ` ```ts ignore-check ` info string; the script reports the opt-out ratio and fails if it exceeds half, so the escape hatch can't quietly become the norm.
2. **`verify-event-taxonomy`** extracts the event names from the `interface Events` blocks across `packages/*/src` and from the taxonomy table in `docs/architecture.md`, and asserts the two sets match exactly. Verify, don't generate: the table keeps its hand-written Mode/Purpose columns; only the set of names is checked. (Landing this surfaced three events the table had been missing — `tools/change`, `llm/adapter-change`, `system-prompt/change`.) **Superseded** by [the generated cordis catalog](2026-06-20-generated-cordis-catalog.md): this gate and its `architecture.md` table are retired in favor of the fully-generated `docs/cordis-catalog/events.md` + `docs/cordis-catalog/services.md` and their `verify-cordis-catalog` freshness gate. The other gates here (`doc-typecheck`, and the `verify-md-wrap` amendment below) are unaffected.
-Both run via a shared `doc-sync` package.json script that the lefthook pre-push hook and CI both invoke ([mechanical quality gates](2026-06-11-quality-gates.md): hooks and CI call the same scripts, so the gate fires locally before a push — not only after it). They run after `pnpm run typecheck`, which validates the package/vendor build graph that doc-typecheck references. API-extractor golden reports ([the deferred API-extractor-reports proposal](../../proposed/process/2026-06-11-api-extractor-reports.md)) were deliberately **deferred** — low value for an internal monorepo where reviewers already see the source diff, and a heavy, finicky dependency.
+Both run via a shared `doc-sync` package.json script that the lefthook pre-push hook and CI both invoke ([mechanical quality gates](2026-06-11-quality-gates.md): hooks and CI call the same scripts, so the gate fires locally before a push — not only after it). They run after `pnpm run typecheck`, which validates the package/vendor build graph that doc-typecheck references.
**Amendment (2026-06-17):** a third gate, **`verify-md-wrap`**, was later folded into `doc-sync`. It parses each in-scope Markdown file (`README.md`, `docs/**`, `packages/*/README.md`, plus `AGENTS.md` / `packages/AGENTS.md`) with `mdast-util-from-markdown` + GFM and fails on any `paragraph` node spanning more than one source line, enforcing the docs/AGENTS.md "one physical line per paragraph" writing rule. Same verify-don't-generate principle: it reports hard-wraps and never rewrites, so it adds no formatting churn. `doc-sync` is now three gates.
+## Alternatives considered
+
+- **API-extractor golden reports** ([the deferred proposal](../../proposed/process/2026-06-11-api-extractor-reports.md)) — deliberately deferred: low value for an internal monorepo where reviewers already see the source diff, and a heavy, finicky dependency.
+- **Generating the taxonomy table from source** instead of verifying names — rejected as more machinery than the problem warranted; the table kept its hand-written Mode/Purpose columns until [the generated cordis catalog](2026-06-20-generated-cordis-catalog.md) superseded the check entirely.
+
## Consequences
- Doc drift in the checkable classes now fails the pre-push hook and CI instead of waiting for a reviewer to notice. This is an instance of the "mechanical gates over prose" principle.
- Making doc snippets compile costs a few stub imports/`declare`s; the `ignore-check` ratio must stay low or the gate is theater (the ratio guard enforces this).
-- The taxonomy check is name-only — a wrong Mode or Purpose column still needs human review. Generating the table from source was considered and rejected as more machinery than the problem warrants.
+- The taxonomy check is name-only — a wrong Mode or Purpose column still needs human review.
- API reports remain available to revisit if the packages are ever published externally.
diff --git a/docs/rfc/implemented/process/2026-06-11-quality-gates.md b/docs/rfc/implemented/process/2026-06-11-quality-gates.md
index 277a56fa2b..69b1beb554 100644
--- a/docs/rfc/implemented/process/2026-06-11-quality-gates.md
+++ b/docs/rfc/implemented/process/2026-06-11-quality-gates.md
@@ -1,10 +1,8 @@
# RFC: Mechanical quality gates over prose guidelines
-Status: implemented (accepted 2026-06-11)
+Status: implemented
-
-
-## Context
+## Problem
This codebase is developed primarily by coding agents. Agents follow enforced gates far more reliably than prose conventions, and "a lot of work" is not a cost argument when agents do the labor. Early evidence: tests that didn't typecheck shipped (vitest doesn't typecheck) and were only caught by a review.
@@ -23,3 +21,5 @@ Every AGENTS.md promise gets a command that exits non-zero, wired into git hooks
- Conventions survive agent turnover; violations fail fast and locally.
- The gates themselves are code to maintain; config changes are reviewed like any change.
- 100%-coverage pressure can produce assertion-free tests — mutation testing is the planned counterweight (see [the mutation-testing proposal](../../proposed/testing/2026-06-11-mutation-testing.md)).
+
+
diff --git a/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.md b/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.md
index dc028d7e9b..fd5f01590c 100644
--- a/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.md
+++ b/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.md
@@ -1,10 +1,8 @@
# RFC: tsdown for JS bundling instead of dumble
-Status: implemented (accepted 2026-06-11)
+Status: implemented
-
-
-## Context
+## Problem
The initial build used **dumble**, the cordiverse zero-config esbuild wrapper that upstream Cordis itself builds with — maximum alignment with the vendored packages' conventions (it reads each package.json and infers entries/formats from the `exports` field). But dumble is a liability as a load-bearing tool in this repo: v0.2.x, ~530 npm downloads/week, effectively one maintainer, and we were invoking it through a custom orchestration script (`scripts/build.ts`) because it has no workspace mode.
@@ -19,7 +17,11 @@ Replace dumble with **tsdown** (rolldown-based, ~2.5M downloads/week, VoidZero-b
- Two per-package overrides in vendor/ (ours, like the regenerated tsconfigs; logged in vendor/README.md): schemastery (dual `.mjs`/`.cjs` via `outExtensions`), logger-console (two single-entry passes so the shared base class is inlined into each entry instead of a hash-named chunk, matching upstream's published shape).
- `scripts/build.ts` deleted; `pnpm run build` = `tsc -b tsconfig.build.json && tsdown`.
-Alternatives considered: **direct esbuild script** (most established engine, zero wrapper risk, but hand-maintains the per-package spec table tsdown's workspace mode gives us); **pkgroll** (closest drop-in philosophically, but 78k dl/wk and Rollup-based — strictly weaker maintenance story than tsdown); **keep dumble** (perfect upstream alignment, unacceptable bus factor).
+## Alternatives considered
+
+- **A direct esbuild script** — the most established engine and zero wrapper risk, but hand-maintains the per-package spec table tsdown's workspace mode gives us.
+- **pkgroll** — the closest drop-in philosophically, but 78k downloads/week and Rollup-based: strictly weaker maintenance story than tsdown.
+- **Keep dumble** — perfect upstream alignment, unacceptable bus factor.
## Consequences
diff --git a/docs/rfc/implemented/process/2026-06-11-vendor-cordis-as-source.md b/docs/rfc/implemented/process/2026-06-11-vendor-cordis-as-source.md
index da457688af..2aa24907d5 100644
--- a/docs/rfc/implemented/process/2026-06-11-vendor-cordis-as-source.md
+++ b/docs/rfc/implemented/process/2026-06-11-vendor-cordis-as-source.md
@@ -1,10 +1,8 @@
# RFC: Vendor Cordis as source, not npm dependencies
-Status: implemented (accepted 2026-06-11)
+Status: implemented
-
-
-## Context
+## Problem
DeepSeek Harness SDK is built on the Cordis framework. Cordis core was at 4.0.0-rc.6 (a release candidate) when this repo started; the harness depends on framework internals (fiber lifecycle, effect disposal, waterfall dispatch) whose exact behavior matters to the agent loop's correctness guarantees.
@@ -14,6 +12,11 @@ Copy the needed Cordis packages (core, loader, include, group, timer, hmr, logge
`vendor/README.md` is the manifest: upstream repo + commit SHA per package and an exhaustive local-modification log. A pre-commit guard (`scripts/check-vendor-manifest.sh`) rejects vendored-source changes that don't update the manifest in the same commit.
+## Alternatives considered
+
+- **Depend on the npm packages** — rejected: core was at a release candidate, and the harness leans on framework internals (fiber lifecycle, effect disposal, waterfall dispatch) whose exact behavior the agent loop's correctness guarantees depend on; an upstream RC bump could break them without a local fix path.
+- **Vendor everything transitively** — rejected: truly third-party dependencies (js-yaml, chokidar, @standard-schema/spec, …) stay on npm; only the framework layer whose internals matter is owned.
+
## Consequences
- The harness fully owns its framework layer: auditable, patchable, pinned — an RC upstream can't break us, and we can fix framework bugs in-tree.
diff --git a/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.md b/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.md
index 2d549fa9b9..6ea62b2c4e 100644
--- a/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.md
+++ b/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.md
@@ -1,10 +1,8 @@
# RFC: pnpm as the package manager instead of Yarn 4
-Status: implemented (accepted 2026-06-16)
+Status: implemented
-
-
-## Context
+## Problem
The repo shipped on **Yarn 4** with the `node-modules` linker — a deliberately conservative choice that behaves like npm's flat layout while giving us Yarn's workspaces and `yarn constraints`. It worked. But Yarn 4's Plug'n'Play heritage makes the `node-modules` linker the off-the-beaten-path mode, and the broader JS ecosystem — tooling defaults, CI actions, Corepack examples, contributor familiarity — increasingly centers on pnpm. For a repo that is built primarily by agents and read by occasional human contributors, "the package manager most tools and people expect" has real value: fewer surprises, better-trodden failure paths, more copy-pasteable answers.
@@ -20,7 +18,11 @@ Adopt **pnpm 11.7.0**, pinned via the `packageManager` field and installed throu
- **Constraints become package-manager-independent.** `yarn.config.cjs` (which imported `@yarnpkg/types` and used `Yarn.workspaces()` / `workspace.set()`) is replaced by `scripts/check-workspace-constraints.ts`, a plain tsx script run as `pnpm run constraints`. It enforces the identical invariants — every package `private: true`; `@deepseek-ai/dsh-*` packages declare `cordis` as both a peer- and dev-dependency with matching ranges, `version: 0.0.1`, `type: module`; vendored packages checked for privacy only — over the same `vendor` + `packages` scope.
- All `yarn …` verbs across CI, lefthook hooks, `package.json` scripts, and docs become `pnpm …` / `pnpm run …`. `yarn.lock` → `pnpm-lock.yaml` (lockfile v9). `.gitignore` swaps `.yarn/` for `.pnpm-store/`. Vendored READMEs (e.g. `vendor/cordis/README.md`) keep their upstream `yarn` examples untouched per the Vendoring Policy.
-Alternatives considered: **keep Yarn 4** (zero churn, but bets on the less-traveled linker mode and a constraints engine tied to one package manager); **npm workspaces** (ubiquitous, but no constraints story and weaker monorepo ergonomics); **pnpm with hoisted linker** (smoother migration, but throws away the phantom-dependency safety that is the main correctness reason to move).
+## Alternatives considered
+
+- **Keep Yarn 4** — zero churn, but bets on the less-traveled linker mode and a constraints engine tied to one package manager.
+- **npm workspaces** — ubiquitous, but no constraints story and weaker monorepo ergonomics.
+- **pnpm with the hoisted linker** — smoother migration, but throws away the phantom-dependency safety that is the main correctness reason to move.
## Consequences
diff --git a/docs/rfc/implemented/process/2026-06-17-ts-build-config.md b/docs/rfc/implemented/process/2026-06-17-ts-build-config.md
index a45962c70e..0687df250c 100644
--- a/docs/rfc/implemented/process/2026-06-17-ts-build-config.md
+++ b/docs/rfc/implemented/process/2026-06-17-ts-build-config.md
@@ -1,10 +1,8 @@
# RFC: TSC-first build and one tsconfig
-Status: implemented (accepted 2026-06-20)
+Status: implemented
-
-
-## Context
+## Problem
The current TypeScript build and typecheck setup had these issues:
@@ -57,6 +55,11 @@ tsc -b tsconfig.json
`pnpm run demo:*` still runs `src` directly through tsx and root paths, without a compile step.
+## Alternatives considered
+
+- **Keep `tsdown`/oxc as the TypeScript transformer** — oxc's transform is not `tsc` behavior (decorator transform differs, bundled JS differs from per-file emit), and its bundled `.d.ts` conflicts with Cordis' internal relative module augmentation shape.
+- **One root strict program over packages, vendor, examples, tests, and scripts** — vendor source triggers type errors outside this project's ownership under the root strict flags; project references with per-project strictness are the boundary that works.
+
## Consequences
Build responsibilities are clearer:
diff --git a/docs/rfc/implemented/process/2026-06-18-markdown-cross-link-lint.md b/docs/rfc/implemented/process/2026-06-18-markdown-cross-link-lint.md
index c4c0ca5afb..db98c2fb7d 100644
--- a/docs/rfc/implemented/process/2026-06-18-markdown-cross-link-lint.md
+++ b/docs/rfc/implemented/process/2026-06-18-markdown-cross-link-lint.md
@@ -1,8 +1,8 @@
# RFC: Markdown cross-link validity linting
-Status: implemented (proposed 2026-06-18, accepted 2026-06-18)
+Status: implemented
-## Context
+## Problem
Docs in this repo link to each other by relative path — `[topic](../implemented/2026-…-….md)`, `[the cookbook](adding-a-tool.md)`, `[architecture.md](../../architecture.md)`. Nothing verified those targets exist. A rename or a move silently breaks every inbound link, and the break is invisible until a reader clicks it. [Doc-sync enforcement](2026-06-11-doc-sync-enforcement.md) already mechanized two classes of doc drift (uncompilable code blocks, a stale event-taxonomy table) and [verify-md-wrap](2026-06-11-doc-sync-enforcement.md) a third (hard-wrapped prose) — but a dead cross-link is a fourth, equally mechanical class that was still verified by eyeball.
@@ -18,11 +18,14 @@ A fourth `doc-sync` gate, `verify-md-links` (`scripts/verify-md-links.ts`), mirr
Scope matches the other gates plus the AGENTS.md pair and the repo-authored agent-skill Markdown under `.agents/skills/` (those skill files cross-link into the docs tree, so this reorg rewrote links in them too): `README.md`, `docs/**/*.md`, `packages/*/README.md`, `AGENTS.md`, `packages/AGENTS.md`, `.agents/skills/**/*.md`, deduped by real path (the `CLAUDE.md` symlinks resolve onto the AGENTS.md files). It is wired into the `doc-sync` script that the lefthook pre-push hook and CI both run, so a broken link fails locally before a push — consistent with [mechanical quality gates](2026-06-11-quality-gates.md).
-This gate checks *existence*, not anchor validity: a link to a real file with a `#wrong-heading` fragment still passes (the file resolves; the fragment is stripped). Anchor-level checking is a heavier, lower-value follow-up — file-level dead links are the failure that actually bit us.
+This gate checks *existence*, not anchor validity: a link to a real file with a `#wrong-heading` fragment still passes (the file resolves; the fragment is stripped).
+
+## Alternatives considered
+
+**Anchor-level validity checking** — heavier and lower-value; file-level dead links are the failure that actually bit. The scope cut is deliberate: authors verify `#fragment` anchors themselves when linking to one.
## Consequences
- Renames and moves that orphan a cross-link now fail the pre-push hook and CI instead of waiting for a reader to click a dead link. This made the RFC reorganization that introduced the gate self-verifying: the same PR that rewrote forty links also added the check that proves none dangle.
- One more fast tsx script in the `doc-sync` chain; no new dependency (the mdast/GFM stack is already in devDependencies for `verify-md-wrap`).
-- Fragment/anchor validity remains unchecked — a known, deliberate scope cut.
- The convention this enforces — cross-reference docs by machine-checkable relative link, never by bare prose or a number — is documented in [docs/AGENTS.md](../../../AGENTS.md) so authors know the gate exists and why.
diff --git a/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md b/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md
index d578d8091c..37678096ab 100644
--- a/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md
+++ b/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md
@@ -1,10 +1,8 @@
# RFC: Core-data-structures catalog and the `ts type-equiv` drift gate
-Status: implemented (accepted 2026-06-20)
+Status: implemented
-
-
-## Context
+## Problem
A reader trying to understand the harness could find its *behavior* in [architecture.md](../../../architecture.md) (the service map, the session/turn/step lifecycle, the event taxonomy) but had no single place describing its *vocabulary* — the data structures that behavior moves around. The type shapes lived only in source, scattered across `packages/*/src/types.ts`, so understanding "what is a `Message`, a `SessionEvent`, a `StreamChunk`" meant reading the declarations directly. A prose catalog would help, but a catalog that paraphrases or paste-copies type definitions rots the instant a field changes — and an out-of-sync type doc is worse than none, because a reader trusts it.
@@ -40,6 +38,12 @@ The durability requirement was specific: the doc should show the **literal** cur
`verify-type-equiv` catches a *drifted paste* of an already-documented type, but it cannot tell you a brand-new core type went undocumented. So AGENTS.md and the `dsh-code-review` skill were updated to require keeping the catalog in sync when a change adds or reshapes a documented type — the gate handles drift, the human handles new surface.
+## Alternatives considered
+
+- **A flat dump of all cross-package vocabulary** — the `BashExecRequest` test case killed it: if seam vocabulary is "core", the catalog helps no one; the tiered spine-vs-seam structure won.
+- **A compiled `_Check` assignability assertion** instead of the verbatim source match — rejected because byte-equality, not assignability, is the property we want: a renamed field with the same type would pass assignability.
+- **Provenance as directive comments in the prose** — rejected for the central manifest, whose enforced 1:1 correspondence means a block can never be silently unchecked and an entry can never rot.
+
## Process
The design was driven entirely by a one-question-at-a-time grilling that walked the scoping decision tree through concrete examples (`BashExecRequest`, `ToolSchema`, `ToolDefinition`, the schema DSL, the presentation types, the session/persistence split) before committing to the spine-vs-seam rule — the rule was the *output* of the examples, not an a-priori axiom. The implementation landed as four commits mirroring the structure of the work: the gate (`e97f94b`), the catalog (`7e33c7b`), the maintenance-guard updates (`53e01a0`), and a review-fix commit (`6da7a0f`).
diff --git a/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md b/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md
index a53b1f40f6..f0b175303c 100644
--- a/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md
+++ b/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md
@@ -1,10 +1,8 @@
# RFC: Generated cordis events + services catalog
-Status: implemented (accepted 2026-06-20)
+Status: implemented
-
-
-## Context
+## Problem
A plugin author needs two reference surfaces that no single document gave them: every cordis **event** they can listen to (with its exact signature and dispatch mode) and every `ctx.` **service** they can call (with its exact interface). The pieces existed but were scattered — a hand-maintained event-taxonomy *table* in `docs/architecture.md` (names + prose Mode/Purpose, name-set-checked by `verify-event-taxonomy`), a Service-map table (8 rows of role prose), and the `interface Events` / `interface Context` declarations themselves. The taxonomy table also could not catch a brand-new *undocumented* event: a name-set verifier only checks the names that are already in the table on both sides.
@@ -25,7 +23,13 @@ Specific choices:
- **Cross-links to the data-structure catalog.** A type name in a signature (`GenerateOptions`, `StreamChunk`, `ToolDefinition`, …) links to the core-data-structures page that documents it. The map is a small hand-curated const in the generator — NOT `type-equiv.manifest.json`, which documents the `…Map` symbols while signatures reference the derived union names, and lists a few symbols on two pages.
- **A dedicated fence.** Signature blocks use a ` ```ts cordis-catalog ` info string that `doc-typecheck` recognizes and skips (a bare signature fragment is not standalone-compilable), excluded from the opt-out ratio — the same treatment `type-equiv` blocks get.
-This **supersedes the event-taxonomy half** of [doc-sync enforcement](2026-06-11-doc-sync-enforcement.md): `verify-event-taxonomy` and its `docs/architecture.md` table are retired (the architecture.md heading stays, its body now points at the catalog; the Service-map role table stays as curated prose). The verify-don't-generate principle that RFC chose for the taxonomy is reversed *for this surface only* — the data here is mechanically complete, so generation is strictly stronger (full signatures, cannot drift, catches undocumented events) than a name-set check of a hand-table. doc-typecheck, verify-md-wrap, verify-md-links, and verify-type-equiv are unchanged.
+This **supersedes the event-taxonomy half** of [doc-sync enforcement](2026-06-11-doc-sync-enforcement.md): `verify-event-taxonomy` and its `docs/architecture.md` table are retired (the architecture.md heading stays, its body now points at the catalog; the Service-map role table stays as curated prose). doc-typecheck, verify-md-wrap, verify-md-links, and verify-type-equiv are unchanged.
+
+## Alternatives considered
+
+- **Verify-don't-generate, as the retired taxonomy check did** — reversed *for this surface only*: the data here is mechanically complete, so generation is strictly stronger (full signatures, cannot drift, catches undocumented events) than a name-set check of a hand-maintained table.
+- **Walking the vendor AST for the inherited tier** — rejected for the curated table: the cordis-core `Context` mixes true ctx members with non-service fields, and the pinned vendor surface changes only on a deliberate sync.
+- **Reusing `type-equiv.manifest.json` as the signature cross-link map** — rejected for a small hand-curated const: the manifest documents the `…Map` symbols while signatures reference the derived union names, and it lists a few symbols on two pages.
## Consequences
diff --git a/docs/rfc/implemented/process/2026-06-20-rfc-classification.md b/docs/rfc/implemented/process/2026-06-20-rfc-classification.md
index 8fcc101b7d..2162687225 100644
--- a/docs/rfc/implemented/process/2026-06-20-rfc-classification.md
+++ b/docs/rfc/implemented/process/2026-06-20-rfc-classification.md
@@ -1,8 +1,8 @@
# RFC: Classify RFCs by kind via path-encoded subdirectories
-Status: implemented (proposed 2026-06-20, accepted 2026-06-20)
+Status: implemented
-## Context
+## Problem
`docs/rfc/` grouped RFCs by **lifecycle** only — `proposed/` / `implemented/` / `rejected/`. Nothing recorded what *kind* of decision each RFC was. The index was one flat list per lifecycle, with no way to scan "show me every simplification" or "every testing-strategy decision." A wave of simplification RFCs landing on the same day made the gap concrete: a reader skimming `proposed/` could not tell a new capability from a removal from a tooling-policy change without opening each file.
@@ -29,14 +29,14 @@ The `architecture` / `process` line: **architecture** is about the source we shi
Both are `doc-sync` members, in the `verify-md-wrap` style (tsx ESM, verify-don't-generate, exit non-zero on the first violation):
-- **`scripts/verify-rfc-classification.ts`** — the closed set and index freshness. It asserts every file under a lifecycle folder lives in a class folder from the canonical set (a loose `.md` at a lifecycle root, or an unknown class folder, fails), and that the README's marker-delimited index regions byte-match a fresh render from the tree (see [generate the RFC index tables](2026-07-04-generate-rfc-index-tables.md)). The canonical class set lives as a `const` in `scripts/rfc-index.ts` — the machine source of truth shared with the generator — and [the index](../../README.md) documents it in prose; the README's class *descriptions* stay hand-written, its tables are generated.
+- **`scripts/verify-rfc-classification.ts`** — the closed set and index freshness. It asserts every file under a lifecycle folder lives in a class folder from the canonical set (a loose `.md` at a lifecycle root, or an unknown class folder, fails), and that the generated [INDEX.md](../../INDEX.md) byte-matches a fresh render from the tree (see [generate the RFC index tables](2026-07-04-generate-rfc-index-tables.md)). The canonical class set lives as a `const` in `scripts/rfc-index.ts` — the machine source of truth shared with the generator — and [the README](../../README.md) documents it in prose; the class *descriptions* stay hand-written, the index is generated.
- **`scripts/verify-doc-refs.ts`** — source comments that cite docs. RFC paths are referenced not only from Markdown but from TypeScript doc comments (root-relative prose like `docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md`). `verify-md-links` never saw those, so the reorg could have silently orphaned them. This gate scans repo-authored `.ts` under `packages/**` and `examples/**` (excluding built `lib/` and `vendor/`) for `docs/….md` tokens, resolves each root-relative, and asserts it exists. It requires the `.md` extension so extensionless prose (`docs/postmortem/0001`, `docs/architecture.md § Extending The Harness`) is left alone.
-### Rejected alternatives
+## Alternatives considered
- **A `Classification:` prose line** in each file (next to `Status:`), parsed by the gate. Workable, but it duplicates into the file a fact the path can already carry, and a line can disagree with its folder. Path-encoding makes the label and its storage the same thing — there is nothing to keep in sync.
- **A `refactor` class.** It overlaps `simplification` almost entirely; the only discriminator anyone reached for was "does observable behavior change?", which `simplification` already encodes (it does not). One class, not two.
-- **Auto-generating the README index** from the filesystem. Rejected here to keep the index hand-written; superseded by [generate the RFC index tables](2026-07-04-generate-rfc-index-tables.md) once stacked proposal waves made the hand-written tables the repo's most conflict-prone docs region — the tables are now generated between markers while the surrounding prose stays curated.
+- **Auto-generating the index** from the filesystem. Rejected here to keep the index hand-written; superseded by [generate the RFC index tables](2026-07-04-generate-rfc-index-tables.md) once stacked proposal waves made the hand-written tables the repo's most conflict-prone docs region — the list is now the fully generated [INDEX.md](../../INDEX.md) while the README prose stays curated.
## Consequences
diff --git a/docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml b/docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml
index bc9a1cd466..ecbd6f196b 100644
--- a/docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml
+++ b/docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
-2026-07-02-bilingual-docs-and-pairing-gate.md: 517a6371eca5d747313c7efdb2756a50257701e4
-2026-07-02-bilingual-docs-and-pairing-gate.zh.md: f8f68bf5d4d7e6795318d9dd435a525f20a4f407
+2026-07-02-bilingual-docs-and-pairing-gate.md: 8731fef46b16cfa20d223575c70774cff780a6aa
+2026-07-02-bilingual-docs-and-pairing-gate.zh.md: ce2589498ab16cf5ca2f5cdb3f58d031aeb8298f
diff --git a/docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md b/docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md
index 517a6371ec..8731fef46b 100644
--- a/docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md
+++ b/docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md
@@ -1,8 +1,10 @@
-# Bilingual documentation via paired sibling files and a pairing gate
+# RFC: Bilingual documentation via paired sibling files and a pairing gate
+
+Status: implemented
English | [中文](2026-07-02-bilingual-docs-and-pairing-gate.zh.md)
-## Context
+## Problem
This repo's README and docs tree are read by people and agents inside and outside the company, in both English and Chinese. Maintaining a second language by hand, with no mechanism, is how translations rot: one side moves on, the other silently lies, and no gate notices. The repo's standing answer to invariants of this kind is to encode them as a mechanical check (see [quality gates](2026-06-11-quality-gates.md) and [doc-sync enforcement](2026-06-11-doc-sync-enforcement.md)), so the bilingual policy ships with one.
diff --git a/docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md b/docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md
index f8f68bf5d4..ce2589498a 100644
--- a/docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md
+++ b/docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md
@@ -1,8 +1,10 @@
-# 通过配对兄弟文件与配对门禁实现双语文档
+# RFC: 通过配对兄弟文件与配对门禁实现双语文档
+
+Status: implemented
[English](2026-07-02-bilingual-docs-and-pairing-gate.md) | 中文
-## 背景
+## 问题
本仓库的 README 与 docs 目录树会被公司内外的人和 agent(智能体)以中英两种语言阅读。没有机制、纯靠手工维护第二语言,正是译文腐烂的方式:一侧继续演进,另一侧默默地说谎,而没有门禁会注意到。对这类不变式,本仓库一贯的答案是把它编码成机械检查(见[质量门禁](2026-06-11-quality-gates.md)与 [doc-sync 强制](2026-06-11-doc-sync-enforcement.md)),因此双语政策随附一道门禁一起交付。
diff --git a/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md b/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md
index 479d93c2df..e13192c640 100644
--- a/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md
+++ b/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md
@@ -1,8 +1,8 @@
# RFC: Generated tool-schema catalog (boot-and-harvest)
-Status: implemented (accepted 2026-07-02)
+Status: implemented
-## Context
+## Problem
A reader — a plugin author, a prompt engineer, someone auditing what the agent can do — has no single place that lists the model-facing tools the harness ships. The `name` / `description` / JSON-Schema `parameters` a tool contributes are what the model actually receives (via `ctx.systemPrompt.tools()` off `ctx.tools.schemas()`), but they are scattered across each `defineTool` call in each `packages/*/tool-*` package, buried in string concatenation and runtime spreads. The cordis [events](../../../cordis-catalog/events.md) & [services](../../../cordis-catalog/services.md) catalogs ([their RFC](2026-06-20-generated-cordis-catalog.md)) document the *wiring* a plugin works against and the [core-data-structures catalog](../../../core-data-structures/core.md) documents the *vocabulary* those signatures move — but neither documents the *tools* the agent is offered. This RFC adds that third reference surface, `docs/tool-catalog/tools.md`, and a freshness gate so it cannot drift.
@@ -39,6 +39,12 @@ The unit is the PACKAGE, not the deployed tool instance. A package's registered
Schema blocks use ` ```json `, not a bespoke `ts`-family fence. `doc-typecheck` only extracts `ts*` fences, so a JSON block is invisible to it — no `BlockKind` wiring is needed (unlike the cordis catalog's `ts cordis-catalog` fence, which had to be allowlisted so a bare signature fragment isn't compiled).
+## Alternatives considered
+
+- **A pure TypeScript-AST pass, like the cordis catalog** — tool schemas are not statically knowable (the crux above): runtime spreads, string concatenation, config-chosen names, and raw `ctx.tools.register()` registrations all make an AST-derived doc lie.
+- **Inferring each package's boot recipe from its injects** — the "too clever" path [the discover-package-inventory proposal](../../proposed/process/2026-06-20-discover-package-inventory.md) warns against; the recipe stays hand-written policy while the inventory is discovered and completeness-guarded.
+- **A bespoke `ts`-family fence for schema blocks** — unnecessary: a plain ` ```json ` fence is invisible to `doc-typecheck`, so no `BlockKind` allowlisting is needed.
+
## Consequences
- The catalog cannot drift: a tool schema change the committed file doesn't reflect fails `verify-tool-catalog` in the pre-push hook and CI. A new `tool-*` package not added to the manifest fails the completeness guard outright.
diff --git a/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.md b/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.md
index c8d46c6fd2..0cf93a8f6e 100644
--- a/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.md
+++ b/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.md
@@ -1,14 +1,14 @@
# RFC: Documentation graph index for maintainers and SDK users
-Status: implemented (accepted 2026-07-03)
+Status: implemented
-## Context
+## Problem
The repo already had several high-trust documentation surfaces, each on a different axis: [module-graph.md](../../../module-graph.md) is generated from package `peerDependencies`, the generated [Cordis events](../../../cordis-catalog/events.md) and [services](../../../cordis-catalog/services.md) catalogs are generated from Cordis `Events` and `Context` declarations, [tool-catalog/tools.md](../../../tool-catalog/tools.md) is generated by booting shipped tool plugins, and [core-data-structures/](../../../core-data-structures/core.md) uses `ts type-equiv` blocks to keep pasted type definitions synchronized with source.
Those references are accurate, but they are mostly catalogs. A maintainer still has to synthesize the relationships: which packages form a capability seam, which app bundles a concrete spine, which event is durable vs live, where a hook or policy plugin can intercept work, and which model-facing tool depends on which service. An SDK user has the same problem from another angle: "Which package do I install or load for the behavior I want, and which event/service/tool do I extend?"
-The pressure is already visible in the open stacks even though this implementation is based on `origin/master`: the hooks stack through PR #129 makes event producer/consumer topology and interception points much more important, while the filesystem stack through PR #128 makes capability seams, policy vetoes, tool presentation, and SDK assembly paths much more important. Graphs based only on today's small bash/todo/subagent surface would become obsolete as soon as those stacks land.
+The hooks subsystem makes event producer/consumer topology and interception points much more important, and the filesystem seam makes capability seams, policy vetoes, tool presentation, and SDK assembly paths much more important — relationship graphs scoped to a small bash/todo/subagent surface would have gone stale immediately.
## Decision
@@ -55,9 +55,9 @@ The hybrid pages must fail loud when their manifests are stale:
- The event producer/consumer matrix labels itself hybrid because subagent lifecycle events deliberately use `ctx.events.dispatch` for per-listener containment; those dynamic edges are explicit overrides rather than invisible omissions.
- `verify-mermaid` parses every repo-authored ` ```mermaid ` fence with Mermaid's own parser, so syntax errors fail `doc-sync` locally and in CI instead of showing up as broken GitHub-rendered diagrams.
-## Format choices
+## Alternatives considered
-Use Mermaid for committed diagrams because GitHub renders it in Markdown and it adds no new docs build dependency. Use Markdown tables for dense many-to-many data such as event producer/consumer relationships. Do not adopt PlantUML, hosted diagram services, or generated SVGs until Mermaid becomes the limiting factor.
+Committed diagrams use Mermaid because GitHub renders it in Markdown and it adds no new docs build dependency; dense many-to-many data such as event producer/consumer relationships uses Markdown tables instead. **PlantUML, hosted diagram services, and generated SVGs** were considered and deliberately not adopted until Mermaid becomes the limiting factor.
## Consequences
diff --git a/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md b/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md
index c86fe5533f..9db59e1138 100644
--- a/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md
+++ b/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md
@@ -1,8 +1,8 @@
# RFC: JSDoc completeness gate for the cordis surface
-Status: implemented (accepted 2026-07-04)
+Status: implemented
-## Context
+## Problem
The [generated cordis catalog](2026-06-20-generated-cordis-catalog.md) already walks every harness `interface Events` member and every `ctx.` service class with the TypeScript compiler API, and already hard-errors on a missing `@mode` tag — a forcing function that made dispatch modes impossible to leave undocumented. Nothing equivalent guarded the rest of the JSDoc: a service method could ship with no doc at all, and no event or method documented its parameters or return value individually. A survey at adoption found 5 public service methods with no JSDoc and roughly 139 missing `@param`/`@returns` entries across 15 files — on the product API spine (`ctx.bash`, `ctx.fs`, `ctx.sessions`, …) and the cross-plugin event payload contracts, exactly the surface where "what does this argument mean" is the question a plugin author asks the IDE.
@@ -20,10 +20,16 @@ The contract:
- **Explicitness the walk can check**: the gate is a pure-AST pass (no type checker), so a service method must annotate its return type (an inferred return cannot be classified) and surface parameters must be simple identifiers (a binding pattern has no name for `@param` to match).
- **Violations aggregate** into one error listing every offender — a remediation pass sees the whole list at once. The previously fail-fast `@mode` checks moved into the same aggregated report, with their message texts unchanged.
-The tags are **enforcement-only**: `parseJsDoc` now ends description prose at the first block tag (standard JSDoc semantics, which also stops multi-line tag descriptions from leaking into the catalog as prose), so `@param`/`@returns` never change the rendered catalog. Rendering them — restructuring the services section into per-method entries — was considered and deliberately deferred: source JSDoc plus IDE hover is where method docs are consumed, and the catalog stays an index. No escape-hatch tag exists; the surface is small and curated (12 services, 57 methods, 27 events at adoption), and the point is that the check cannot be waved off.
+The tags are **enforcement-only**: `parseJsDoc` now ends description prose at the first block tag (standard JSDoc semantics, which also stops multi-line tag descriptions from leaking into the catalog as prose), so `@param`/`@returns` never change the rendered catalog.
Negative-path tests in `packages/core/agent/tests/gen-cordis-catalog.spec.ts` drive `collectEvents`/`collectServices` against synthetic fixtures to prove each guard fires and that the exemptions hold. The authoring rule lives in the root [AGENTS.md](../../../../AGENTS.md) conventions bullet alongside the `@mode` rule.
+## Alternatives considered
+
+- **An ESLint rule** — cannot see the scope's machine definition (which `interface Events` members and which `ctx.` classes are the cordis surface); the catalog generator computes exactly that mapping on every run, so the gate lives there.
+- **Rendering the tags into the catalog** — restructuring the services section into per-method entries was considered and deliberately deferred: source JSDoc plus IDE hover is where method docs are consumed, and the catalog stays an index.
+- **An escape-hatch tag** — none exists; the surface is small and curated (12 services, 57 methods, 27 events at adoption), and the point is that the check cannot be waved off.
+
## Consequences
- A new event or service method cannot land with an undocumented parameter or result: the generator refuses to regenerate and `verify-cordis-catalog` fails pre-push and in CI. The ~139 gaps found at adoption were filled in the same change, so the gate landed green.
diff --git a/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.md b/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.md
index c33c917112..5d886fa4bd 100644
--- a/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.md
+++ b/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.md
@@ -1,6 +1,8 @@
-# Documentation tiers, budgets, and the ceiling gate
+# RFC: Documentation tiers, budgets, and the ceiling gate
-## Context
+Status: implemented
+
+## Problem
The repo's standing docs accrete. Root `AGENTS.md` reached 8,130 words through 50 commits in two and a half weeks — each PR appending its own lesson, none displacing anything — until the same rule was stated two or three times inside one file (the pushed-branch rewrite ban ~600 words across two sections; the with-key e2e policy ~400 words across two), an incident already recorded in [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md) was retold inline at ~750 words, and the per-package one-liner map existed in five places. [architecture.md](../../../architecture.md) grew the same way: paragraph walls re-narrating RFCs it already links, plus implementation-status annotations that were stale the week after they were written. The writing rules that forbid this (document current state, never history) predate the drift and sat in the very file violating them — prose rules alone do not hold against accretion pressure. The repo's standing answer to an invariant of this kind is a mechanical check ([quality gates](2026-06-11-quality-gates.md), [doc-sync enforcement](2026-06-11-doc-sync-enforcement.md)).
@@ -28,6 +30,5 @@ The repo's standing docs accrete. Root `AGENTS.md` reached 8,130 words through 5
The first audit cycle under the standard, in rough priority order (evidence gathered in the survey that motivated this RFC):
- Package README trims where generated catalogs or JSDoc are restated or history is narrated: `packages/ui/acp`, `packages/core/tools`, `packages/bash/tool-bash`, `packages/core/session`, `packages/compact/compact-basic`, `packages/session-persistence/session-persistence`.
-- [The web capability seam RFC](../architecture/2026-06-24-web-capability-seam.md) converted from spec-speak to shipped reality (drop the migration plan and test enumeration, "should" → "is").
- `docs/core-data-structures/core.md`: drop the JSDoc walls from the `Agent`/`GenerateOptions` type-equiv pastes per that page's own stated rule.
- [Postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md): merge the overlapping Executive summary and Summary sections.
diff --git a/docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.md b/docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.md
index 8039dd50f4..d854991ad9 100644
--- a/docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.md
+++ b/docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.md
@@ -4,23 +4,29 @@ Status: implemented
## Problem
-`docs/rfc/README.md`'s per-lifecycle/per-class tables list facts that are fully derivable: an RFC's path encodes lifecycle and class, its filename encodes the first-proposed date, and its H1 carries the title. A hand-maintained copy of those facts is also the repo's highest-contention docs hotspot: every proposal wave appends rows to the same few lines, so concurrent RFC branches conflict precisely there while agreeing everywhere else, and each conflict is resolved by hand-merging rows whose content the filesystem already knows. [The classification RFC](2026-06-20-rfc-classification.md) originally kept the index hand-written for curation's sake — but the curated part of the README is the prose, and the prose never conflicts; only the mechanical tables do.
+The RFC index's per-lifecycle/per-class tables list facts that are fully derivable: an RFC's path encodes lifecycle and class, its filename encodes the first-proposed date, and its H1 carries the title. A hand-maintained copy of those facts is also the repo's highest-contention docs hotspot: every proposal wave appends rows to the same few lines, so concurrent RFC branches conflict precisely there while agreeing everywhere else, and each conflict is resolved by hand-merging rows whose content the filesystem already knows. [The classification RFC](2026-06-20-rfc-classification.md) originally kept the index hand-written for curation's sake — but the curated part of the README is the prose, and the prose never conflicts; only the mechanical tables do.
## Decision
-Keep the curated prose; generate the tables. [`scripts/rfc-index.ts`](../../../../scripts/rfc-index.ts) is the shared source of truth — the tree walker (owning the closed lifecycle/class sets and the structure rules, including a parseable-H1 requirement) and the renderer (rows from H1 title with any `RFC: ` prefix stripped, plus the filename date, sorted by date then filename, grouped as `### {Class}` sections in canonical class order). Two thin consumers share it:
+Keep the curated prose; generate the list. The tables live in [`docs/rfc/INDEX.md`](../../INDEX.md), a **fully generated file** — the curated prose stays in README.md, which carries no index rows at all. [`scripts/rfc-index.ts`](../../../../scripts/rfc-index.ts) is the shared source of truth — the tree walker (owning the closed lifecycle/class sets and the structure rules, including a parseable-H1 requirement) and the renderer (rows from H1 title with any `RFC: ` prefix stripped, plus the filename date, sorted by date then filename, grouped as `### {Class}` sections in canonical class order). Two thin consumers share it:
-- [`scripts/gen-rfc-index.ts`](../../../../scripts/gen-rfc-index.ts) (`pnpm run gen-rfc-index`) rewrites the three marker-delimited regions in the README (`` … `end`), one per `## {Lifecycle}` section, leaving everything outside the markers untouched.
-- [`scripts/verify-rfc-classification.ts`](../../../../scripts/verify-rfc-classification.ts) (a `doc-sync` member) checks structure and asserts the committed regions byte-match a fresh render — the `gen-cordis-catalog`/`verify-cordis-catalog` pattern. Freshness subsumes the index-completeness check: a generated-from-disk table is definitionally complete and correctly headed.
+- [`scripts/gen-rfc-index.ts`](../../../../scripts/gen-rfc-index.ts) (`pnpm run gen-rfc-index`) rewrites INDEX.md in full from the tree.
+- [`scripts/verify-rfc-classification.ts`](../../../../scripts/verify-rfc-classification.ts) (a `doc-sync` member) checks structure, asserts the committed INDEX.md byte-matches a fresh render — the `gen-cordis-catalog`/`verify-cordis-catalog` pattern — and rejects an index-shaped row in the curated README. Freshness subsumes the index-completeness check: a generated-from-disk table is definitionally complete and correctly headed.
Adding, moving, or deleting an RFC means editing only the RFC file and running the generator; the classification RFC's rejected-alternatives record carries the supersession cross-link.
-## Why not the verifier-only model?
+## Alternatives considered
-It catches mistakes but still makes every proposal edit a shared hotspot, and a failed verifier is strictly more annoying than a generator for a purely mechanical row: the author has already named and placed the file; the index copy adds no information. This is the same hand-list-versus-derivation judgment the [package-inventory proposal](../../proposed/process/2026-06-20-discover-package-inventory.md) applies to tsconfig references and knip stanzas — applied to the one list that demonstrably conflicts.
+### Why not marker-delimited regions inside README.md?
+
+The first landed shape: the generator spliced the tables into README.md between `gen-rfc-index` marker comments, under each `## {Lifecycle}` heading. Superseded by the whole-file INDEX.md once the README also absorbed the in-file format contract ([the uniform-format RFC](2026-07-05-uniform-rfc-format.md)): a front-door README hosting hundreds of generated rows dwarfed its curated prose, and splice mechanics (marker pairs, heading checks, outside-region row detection) exist only to protect curated text that a dedicated generated file simply doesn't contain.
+
+### Why not the verifier-only model?
+
+It catches mistakes but still makes every proposal edit a shared hotspot in a hand-maintained table, and a failed verifier is strictly more annoying than a generator for a purely mechanical row: the author has already named and placed the file; the index copy adds no information. This is the same hand-list-versus-derivation judgment the [package-inventory proposal](../../proposed/process/2026-06-20-discover-package-inventory.md) applies to tsconfig references and knip stanzas — applied to the one list that demonstrably conflicts.
## Consequences
-- The generated regions are explicit: marker comments make script ownership obvious to reviewers, and the generator refuses to run on a structurally invalid tree.
+- The generated file is explicit: its banner names the generator, there is no curated region to protect inside it, and the generator refuses to run on a structurally invalid tree.
- A malformed or missing H1 is a hard error in both the generator and the gate — the H1 is now load-bearing as the index title source.
- Concurrent RFC branches resolve index conflicts by rerunning the generator, never by hand-merging rows.
diff --git a/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md b/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md
index b03c79e84a..9bb7b7d624 100644
--- a/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md
+++ b/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md
@@ -1,8 +1,8 @@
# RFC: Generated persistence log event catalog
-Status: implemented (accepted 2026-07-04)
+Status: implemented
-## Context
+## Problem
The session event log is the harness's on-disk contract: every `SessionEventMap` member is a record a persistence backend writes verbatim and a replay reconstructs from, and adding one that breaks the durability rules is a breaking change to the on-disk format. Yet the vocabulary had no single reference. The declarations are split across three files — the owning interface in `@deepseek-ai/dsh-session` plus declaration merges in `@deepseek-ai/dsh-compact` and `@deepseek-ai/dsh-hook-protocol` — and the doc surfaces covered it with hand-copies: a `hook/*` payload table in [session.md](../../../core-data-structures/session.md), a `compact/*` payload table in the compact README, payload bullets in the hook-protocol README, and a name-list in the session README. The name-list's merge note had already drifted (it named the compaction merge and omitted the hook merge entirely), and nothing could catch the next merge going undocumented: a hand-copy only checks the names someone already wrote down. This is the same gap the [cordis catalog](2026-06-20-generated-cordis-catalog.md) closed for bus events and the [tool catalog](2026-07-02-tool-schema-catalog.md) closed for model-facing tools — and log events are covered by neither: a `SessionEventMap` member is not a cordis `Events` declaration (it reaches listeners via the single `session/event` emit), so it has no cordis-catalog row by design.
@@ -10,7 +10,7 @@ The session event log is the harness's on-disk contract: every `SessionEventMap`
Generate `docs/persistence-catalog/log-events.md` from source, with a freshness gate, as the fourth reference surface: the *records* a persisted session log can contain, complementing the cordis catalog (wiring), core-data-structures (vocabulary), and the tool catalog (tools).
-`scripts/gen-persistence-catalog.ts` is a pure TypeScript-AST pass, like `gen-cordis-catalog.ts` and unlike the boot-based tool catalog — the right technique because log events ARE statically knowable: every member is a string-literal-named property with a static type annotation, so the AST is the whole truth. The walk collects every `interface SessionEventMap` declaration under `packages/*/*/src` — the owning top-level interface and every `declare module '@deepseek-ai/dsh-session'` merge — so a brand-new event, core or merged, appears in the next regenerate and an un-regenerated file fails `--check` (`verify-persistence-catalog`, a `doc-sync` member, so pre-push and CI both run it). Each entry renders the member's JSDoc prose, its payload (printed through the TypeScript printer, so a newline-separated multi-line type literal still yields a valid one-line fragment), a surface badge, cross-links into core-data-structures, and the declaration's source pointer, grouped by scope.
+`scripts/gen-persistence-catalog.ts` is a pure TypeScript-AST pass, like `gen-cordis-catalog.ts` — log events ARE statically knowable: every member is a string-literal-named property with a static type annotation, so the AST is the whole truth. The walk collects every `interface SessionEventMap` declaration under `packages/*/*/src` — the owning top-level interface and every `declare module '@deepseek-ai/dsh-session'` merge — so a brand-new event, core or merged, appears in the next regenerate and an un-regenerated file fails `--check` (`verify-persistence-catalog`, a `doc-sync` member, so pre-push and CI both run it). Each entry renders the member's JSDoc prose, its payload (printed through the TypeScript printer, so a newline-separated multi-line type literal still yields a valid one-line fragment), a surface badge, cross-links into core-data-structures, and the declaration's source pointer, grouped by scope.
Specific choices:
@@ -21,6 +21,11 @@ Specific choices:
This supersedes the hand-copies: the session.md `hook/*` table, the compact README's event table, the hook-protocol README's payload bullets, and the session README's name-list now link the catalog instead of restating payloads (the surrounding semantics prose stays where it was). The two stray `@mode emit` tags on the hook-protocol merge members are removed — the new gate rejects them as the category error they were.
+## Alternatives considered
+
+- **A boot-based generator, like the tool catalog's** — the log vocabulary is fully static, so the AST pass reads the whole truth without booting anything.
+- **Keeping the hand-copies** — a hand-copy only checks the names someone already wrote down; the session README's merge note had already drifted when the catalog landed.
+
## Consequences
- The catalog cannot drift: a vocabulary change the committed file doesn't reflect fails `verify-persistence-catalog` in the pre-push hook and CI, and a new merged event with no JSDoc fails the generator outright — a plugin can no longer add an undocumented on-disk record type.
diff --git a/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md b/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md
new file mode 100644
index 0000000000..f8da02905f
--- /dev/null
+++ b/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md
@@ -0,0 +1,28 @@
+# RFC: One gated in-file format for RFCs
+
+Status: implemented
+
+## Problem
+
+The tree's layout is uniform — [the classification scheme](2026-06-20-rfc-classification.md) path-encodes lifecycle and class and gates both — but the file insides never were. The corpus the format decision faced had two H1 spellings; some fifteen `Status:` line spellings, from bare enums through dated parentheticals duplicating what the filename and git already carry, to four files with no status at all; two body genres side by side (ADR-style `Context`/`Decision`/`Consequences` beside proposal-style `Problem`/`Proposal`/`Risks`), so every new RFC guessed its shape from whichever neighbor its author opened; thirty-nine files carrying a debt comment that flagged them as "legacy ADR/RFC body format" awaiting a unified template that was never actually defined; and over twenty implemented RFCs still carrying the proposal-era headings (`Acceptance criteria`, `Plan`, `Migration plan`) that the [documentation standard's slop checklist](../../../AGENTS.md) outlaws for `implemented/` — outlawed, but enforced by nothing, so the `proposed/` → `implemented/` move could silently skip the rewrite [implemented/AGENTS.md](../AGENTS.md) requires.
+
+## Decision
+
+[README.md § The file format](../../README.md#the-file-format) is the in-file contract — the header block (`# RFC: ` plus a dateless, folder-agreeing `Status:` enum whose only content is the rejection reason), the per-lifecycle body skeleton (`Problem` opener everywhere; `Proposal`/`Acceptance criteria`/`Risks` in `proposed/`; present-tense `Decision`/`Consequences` with proposal-era headings banned in `implemented/`; frozen proposal shape in `rejected/`), a mandatory `Alternatives considered` section, and the canonical section vocabulary between which bespoke technical sections stay free-form. `pnpm run verify-rfc-format` ([scripts/verify-rfc-format.ts](../../../../scripts/verify-rfc-format.ts)) enforces every mechanical clause as part of `doc-sync`, so a lifecycle move that skips its rewrite now fails CI instead of review memory.
+
+The whole corpus was normalized in the same change that defined the format — the pre-release stance: no transition period, no dual-format tolerance. The one grandfather is content, not format: alternatives are recorded, never invented, so a pre-format RFC whose alternatives are not reconstructible from the record carries the exact `rfc-format: alternatives-not-recorded` comment, which the gate accepts only for files dated before this RFC.
+
+## Alternatives considered
+
+- **A full rigid template** (one fixed section sequence per lifecycle, every RFC restructured to fit) — rejected: the big design RFCs carry eight to fifteen bespoke technical sections (package topology, wire contracts, schemas) that are load-bearing content, not drift; a rigid sequence would force destructive rewrites now and template-fighting forever.
+- **Header-only normalization** (H1 and Status, bodies untouched) — rejected: the debt markers flagged the *body* genre split, and leaving `Context`/`Decision` beside `Problem`/`Proposal` indefinitely resolves nothing.
+- **No Status line** (the folder already is the status; the four newest pre-format RFCs omitted the line) — rejected in favor of keeping a self-describing file: the drift risk that motivated dropping it is neutralized by gating the line against the folder instead.
+- **Dated status** (`Status: implemented (accepted YYYY-MM-DD)`) — rejected: the acceptance date is narrated history the writing rules keep out of docs; the filename carries first-proposed, git carries the rest, and the gate could check a date's format but never its truth.
+- **A bare `# ` H1** — rejected: the `RFC: ` prefix is the corpus-majority form and self-describes the genre when a file is read outside its tree; the index generator strips it, so index rows are identical either way.
+- **`## What we give up` as the implemented closer** (the README's own phrase for what an RFC records) — rejected: it names only costs, and an honest consequences section records what the trade-off bought as well.
+- **Convention without a gate** (write the contract down, enforce by review) — rejected: the slop checklist already outlawed spec-speak in `implemented/` by convention, and over twenty files show what convention alone achieves here.
+- **A standalone `FORMAT.md` contract file** — the first landed home; folded into README.md once the generated index moved out to [INDEX.md](../../INDEX.md): with the tables gone the README regained the room, and one front door carrying layout, classification, and format beats splitting the contract across two files.
+
+## Consequences
+
+Every RFC now costs slightly more structure, and the mandatory `Alternatives considered` section is deliberate friction: a decision recorded without what it beat invites the re-litigation RFCs exist to prevent. Pre-format RFCs whose alternatives were not reconstructible carry the grandfather comment permanently — an honest gap on the record rather than fabricated rationale. `doc-sync` gains one gate, and moving an RFC between lifecycle folders is now real work at move time (the body rewrite the move always owed) instead of deferred cleanup nothing tracked. The thirty-nine debt markers are gone, resolved by the template they were waiting for.
diff --git a/docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.md b/docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.md
index 24f693e6b6..54f8397fc9 100644
--- a/docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.md
+++ b/docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.md
@@ -1,8 +1,8 @@
# RFC: Drop the mutable session summary
-Status: implemented (proposed and accepted 2026-06-19)
+Status: implemented
-## Context
+## Problem
The [session-persistence seam](../architecture/2026-06-14-session-persistence.md) split a session's out-of-log metadata into two types owned by `dsh-session`: an immutable `SessionHeader` (`version`, `id`, `createdAt`, `cwd?`, `parentSession?`) written once at creation, and a mutable `SessionSummary` (`updatedAt`, `title?`, `firstPrompt?`) "updateable without touching the append-only log". Their union was `SessionMeta = SessionHeader & SessionSummary`, and the abstract `SessionPersistence` service carried a seventh method — `update(id, summary)` — for rewriting the summary. Each backend implemented the mutable store its own way: JSONL wrote a separate atomic `.summary.json` **sidecar** beside the log (temp-write + rename, best-effort), SQLite kept `updated_at`/`title`/`first_prompt` **columns** bumped inside the append transaction.
@@ -26,6 +26,8 @@ This is recorded as a decision because it is **durable** (it narrows a public se
This is unreleased software (see [root AGENTS.md](../../../../AGENTS.md) § "Pre-release stance: foundation over blast radius"), so there are no on-disk databases or logs to preserve. SQLite does not migrate a v1 database: the `openDatabase` guard now rejects any non-current on-disk `user_version` (`onDisk !== 0 && onDisk !== SCHEMA_VERSION`) — older *or* newer — so a stale v1 DB is cleanly rejected rather than half-read against the new column set. A fresh database stamps the current version; that is the only path that needs to work.
-## What we gave up
+## Consequences
A future session picker now has to derive its preview/ordering from the log (or reintroduce a typed field) rather than reading a ready-made summary row. That is the correct cost: a cache for a feature that does not exist is dead weight that every backend pays to maintain and every contract test pays to assert. The principle — **a passing test pins current behavior, not necessarily correct behavior; behavior can be an artifact of a past compromise** — is now recorded as a standalone convention in [root AGENTS.md](../../../../AGENTS.md), with this change as its worked example.
+
+
diff --git a/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md b/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md
index 4ec39bcce4..0a80ec5a41 100644
--- a/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md
+++ b/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md
@@ -1,6 +1,6 @@
# RFC: Fold trace-only session facts into load-bearing events
-Status: implemented (proposed and accepted 2026-06-20)
+Status: implemented
## Problem
@@ -8,27 +8,26 @@ The session event vocabulary includes first-class events that are not part of re
These events make the canonical transcript look more useful as telemetry than it currently is. They add event variants, invariants, tests, snapshots, and persistence cases, but they are not load-bearing as separate records. The facts they carry can still be useful: token usage should remain available for accounting, and an error's step number should not silently disappear. The simplification is to fold those facts into nearby events consumers already must understand, not to record less information.
-## Proposal
+## Decision
-Remove standalone trace-only events only where their information can be preserved without a parallel record:
+Standalone trace-only events are removed exactly where their information is preserved without a parallel record:
-- Fold successful-step usage into the matching `assistant/message`, e.g. `assistant/message { turn, step, content, usage? }`, so the assembled model output and its accounting travel together.
-- For a failed or aborted step that has usage but no `assistant/message`, carry the usage on the terminal turn reason or another load-bearing failure record in the same turn. The implementing design must prove no usage chunk that is currently persisted becomes unrepresented.
-- Fold the step number from the standalone `error` event into `turn/end.reason` for `kind: 'error'`, e.g. `{ kind: 'error', step, message, code? }`. `turn/end` is the durable turn outcome ACP and resume already consume.
-- Keep `agent/error` and logging for live diagnostics; do not add a second session-log error record after `turn/end`.
+- Successful-step usage folds into the matching `assistant/message` (`assistant/message { turn, step, content, usage? }`), so the assembled model output and its accounting travel together.
+- A failed or aborted step that has usage but no assistant content carries the usage on an empty-content `assistant/message` (the implementation note below carries the no-information-loss proof) — no persisted usage chunk goes unrepresented.
+- The step number from the standalone `error` event folds into `turn/end.reason` for `kind: 'error'` (`{ kind: 'error', step, message, code? }`) — `turn/end` is the durable turn outcome ACP and resume already consume.
+- `agent/error` and logging stay for live diagnostics; there is no second session-log error record after `turn/end`.
-If analytics become real, add a projection helper or a dedicated telemetry store with its own retention policy. The user conversation log should contain what is needed to render, resume, audit, and account for the interaction without requiring consumers to reconcile duplicate trace rows.
+The user conversation log contains what is needed to render, resume, audit, and account for the interaction without consumers reconciling duplicate trace rows.
-## Acceptance criteria
+## Alternatives considered
-- `SessionEventMap` drops standalone `usage` and `error` only after their fields are represented on load-bearing session events.
-- The loop no longer appends a separate `usage` event for a usage chunk.
-- The loop records durable failures through `turn/end { kind: 'error', step, message, code? }` or an equivalent no-information-loss shape and reports live diagnostics through `agent/error`.
-- ACP snapshots and persistence tests stop asserting trace-only lines.
-- Documentation explains exactly where token usage and operational errors are observed.
-- Recorded fixtures are refreshed for the new event shape; the session format version stays pinned at `0` (unstable/pre-release) and backends reject any non-`0` stored log per the pre-release format policy.
+**Keep the standalone rows as telemetry** — the events made the canonical transcript look more useful as telemetry than it was, at the cost of event variants, invariants, tests, snapshots, and persistence cases nothing consumed. If analytics become real, the shape is a projection helper or a dedicated telemetry store with its own retention policy — not duplicate trace rows in the conversation log.
-## What we give up
+## Verification
+
+`SessionEventMap` carries no standalone `usage` or `error`; the loop appends no separate usage event and records durable failures through `turn/end { kind: 'error', step, message, code? }`; ACP snapshots and persistence tests assert no trace-only lines; recorded fixtures are on the new event shape with the session format version pinned at `0` (backends reject any non-`0` stored log per the pre-release format policy); and the docs state where token usage and operational errors are observed.
+
+## Consequences
A consumer can no longer filter the canonical log for standalone `usage` or step-level `error` rows. It must read those facts from the assistant/failure events that carry them. That is a reasonable simplification only if the implementing PR proves the same facts remain present; otherwise the standalone events should stay.
diff --git a/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md
index e35e090dbf..ab921dd62c 100644
--- a/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md
+++ b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md
@@ -1,6 +1,6 @@
# RFC: Drop the unconsumed `llm/adapter-change` event
-Status: implemented (proposed and accepted 2026-06-20)
+Status: implemented
## Problem
@@ -10,32 +10,23 @@ This differs from `tools/change` and `system-prompt/change`. Those two events ar
The event is not free. `registerAdapter()` yields its rollback disposer before emitting `llm/adapter-change` so a throwing listener unwinds the mutation instead of leaking an adapter entry, and the package carries tests for that listener-throw path. That defensive ordering protects a failure mode only tests can trigger.
-## Proposal
+## Decision
-Remove only `llm/adapter-change`:
+Only `llm/adapter-change` is removed: the declaration in `dsh-llm`'s `interface Events`, the `ctx.emit('llm/adapter-change')` calls, and the "Emits `llm/adapter-change` on registration and disposal" sentence in `LlmService.registerAdapter`'s JSDoc. `registerAdapter()`'s effect generator keeps the mutation and rollback disposer for HMR/disposal but sheds the listener-throw rollback ordering that existed only for the removed event. The adapter-disposer test asserts the returned disposer removes the adapter without subscribing to the event; the listener-throw rollback test is gone with its subject. The event taxonomy in [docs/architecture.md](../../../architecture.md) and [packages/llm/llm/README.md](../../../../packages/llm/llm/README.md) is updated in the same change.
-- Delete the `llm/adapter-change` declaration from `dsh-llm`'s `interface Events`.
-- Delete the `ctx.emit('llm/adapter-change')` calls.
-- Simplify `registerAdapter()`'s effect generator: keep the mutation and rollback disposer for HMR/disposal, but drop the listener-throw rollback ordering that exists only for the removed event.
-- Remove the "Emits `llm/adapter-change` on registration and disposal" sentence from `LlmService.registerAdapter`'s JSDoc.
-- Rewrite the adapter-disposer test to assert the returned disposer removes the adapter without subscribing to `llm/adapter-change`; delete the listener-throw rollback test that exists solely for the removed event.
-- Update the event taxonomy table in [docs/architecture.md](../../../architecture.md) and [packages/llm/llm/README.md](../../../../packages/llm/llm/README.md). The [doc-sync-enforcement RFC](../../implemented/process/2026-06-11-doc-sync-enforcement.md) should avoid using `llm/adapter-change` as an example once the event is gone.
+## Alternatives considered
-## Why not remove every registry change event?
+### Why not remove every registry change event?
A microkernel where registries announce mutations is a coherent convention. `tools/change` and `system-prompt/change` may become useful when a UI can live-refresh available tools or prompt sections. This RFC leaves that convention intact where it has a plausible user-facing consumer and cuts only the adapter-change event whose current and likely future consumer is unclear.
If an LLM adapter browser or dynamic model-picker needs this signal later, reintroduce it with that consumer and a clearer payload than "something changed."
-## Acceptance criteria
+## Verification
-- `llm/adapter-change` and its emits are gone; `pnpm run verify-cordis-catalog` passes against the regenerated catalog.
-- HMR-safety tests still pass: disposing a contributing fiber still removes the adapter.
-- `tools/change` and `system-prompt/change` remain documented and tested.
-- `pnpm run test:coverage` stays 100% per-file.
-- No production code path changes observable behavior (verified by unchanged ACP snapshot goldens and the echo-agent smoke test).
+`llm/adapter-change` and its emits are gone and the regenerated cordis catalog is fresh; HMR-safety holds (disposing a contributing fiber removes the adapter); `tools/change` and `system-prompt/change` remain documented and tested; and no production path changed observable behavior — the ACP snapshot goldens and the echo-agent smoke are byte-unchanged.
-## Risks
+## Consequences
- **Removing a documented emit event is a public-surface change.** It is in the taxonomy table, so it reads as deliberate API. But "declared and emitted" is not "consumed" — the same distinction that justified dropping the mutable summary. The taxonomy table is updated in the same change, so the docs do not drift.
- **The registry-change convention becomes uneven.** That is acceptable because LLM adapter registration is not the same user-facing concept as tools or prompt sections. Uneven but honest beats uniform but dead.
diff --git a/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md
index 74d37bf75a..585c611262 100644
--- a/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md
+++ b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md
@@ -1,6 +1,6 @@
# RFC: Drop unconsumed assembled LLM convenience surfaces
-Status: implemented (proposed and accepted 2026-06-20)
+Status: implemented
## Problem
@@ -16,27 +16,19 @@ This is the [drop-mutable-session-summary](../../implemented/simplification/2026
`streamBlocks()` drags a dedicated slice of `BlockAssembler` behind it: `flushReady()` and `flushRemaining()` ([packages/llm/llm/src/assembler.ts:138-168](../../../../packages/llm/llm/src/assembler.ts)) plus the `flushed` cursor field exist only to support incremental in-order yield. `generate()` drags `GenerateResult`, `BlockAssembler.result()`, and the `llm/generate` waterfall as a second interception surface over the same underlying stream. The loop's assembler usage is `push()` / `message()` / `usage` / `finish` — not streaming flush or one-shot service assembly.
-## Proposal
+## Decision
-Make `stream()` the only public LLM call surface:
+`stream()` is the only public LLM call surface. Removed with their JSDoc and doc references: `LlmService.streamBlocks()`; `LlmService.generate()`, the `llm/generate` waterfall event, and `GenerateResult`; `BlockAssembler.flushReady()`/`flushRemaining()` and the `flushed` cursor field; and `BlockAssembler.result()`, which only served the deleted `generate()` path. Adapter tests drive `ctx.llm.stream()` through a small helper that pushes chunks into `BlockAssembler` and returns the assembled message, usage, and finish reason — keeping the [twin-adapter design](../../implemented/architecture/2026-06-13-twin-llm-adapters.md) intact without a public method whose only callers are tests. The assembler invariants that apply to `push()` / `blocks()` / `message()` keep their tests; the flush-API pins went with the API. The `ctx.llm` service-map row in [docs/architecture.md](../../../architecture.md) is `stream()` only, the event taxonomy carries no `llm/generate`, and the [property-based-testing RFC](../../implemented/testing/2026-06-11-property-based-testing.md) names block-assembly invariants without the removed convenience methods.
-- Remove `LlmService.streamBlocks()` and its JSDoc.
-- Remove `LlmService.generate()`, the `llm/generate` waterfall event, and `GenerateResult` if no surviving API needs that named result shape.
-- Remove `BlockAssembler.flushReady()`, `BlockAssembler.flushRemaining()`, and the `flushed` cursor field.
-- Remove `BlockAssembler.result()` if it is only a helper for the deleted `generate()` service path and tests.
-- Replace adapter-test use of `ctx.llm.generate()` with a small test helper that calls `ctx.llm.stream()`, pushes chunks into `BlockAssembler`, and returns the assembled message, usage, and finish reason needed by that test. That keeps the [twin-adapter design](../../implemented/architecture/2026-06-13-twin-llm-adapters.md) intact while avoiding a public method whose only callers are tests.
-- Remove or rework the `flushReady`/`flushRemaining`-dependent tests. Keep assembler invariants that still apply to `push()` / `blocks()` / `message()`; delete behavior that only pins the removed flush API.
-- Update every doc/comment reference to `streamBlocks`, `generate`, `GenerateResult`, and `llm/generate` across `docs/`, package READMEs, and source comments. The `ctx.llm` service-map row in [docs/architecture.md](../../../architecture.md) becomes `stream()` only, the event taxonomy drops `llm/generate`, and the [property-based-testing RFC](../../implemented/testing/2026-06-11-property-based-testing.md) names block-assembly invariants without referring to removed convenience methods.
+## Alternatives considered
-## Acceptance criteria
+**Keep `generate()` as a test-only convenience** — rejected: adapter tests hand-draining `stream()` through the shared assembler exercise the same streaming path production uses, and a public method whose only callers are tests is exactly the dead-surface shape [the drop-mutable-summary precedent](2026-06-19-drop-mutable-session-summary.md) retired. A future consumer that wants assembled blocks without deltas reintroduces a focused helper with that consumer.
-- `streamBlocks`, `generate`, `llm/generate`, and the assembler helpers they alone require are gone; `pnpm run knip` reports no new dead exports.
-- `pnpm run test:coverage` stays at 100% per-file (the deleted methods take their dedicated tests with them; no remaining line goes uncovered).
-- Adapter tests still exercise both real adapters through `stream()` and the shared assembler, not through a test-only public shortcut.
-- The loop behaves identically — verified by unchanged ACP snapshot goldens.
-- `packages/llm/llm/README.md`, [docs/architecture.md](../../../architecture.md), and module docs no longer mention the removed convenience surfaces.
+## Verification
-## Risks
+`streamBlocks`, `generate`, `llm/generate`, and the assembler helpers they alone required are gone with no new dead exports; both real adapters are exercised through `stream()` and the shared assembler; the loop behaves identically (ACP snapshot goldens unchanged); and the README, architecture doc, and module docs carry no mention of the removed surfaces.
+
+## Consequences
- **It removes public methods from a core vocabulary package.** A future plugin that wants assembled blocks without deltas would need to call `stream()` and use `BlockAssembler` directly or reintroduce a focused helper with a real consumer. Given the pre-release "foundation over speculative future" stance ([AGENTS.md](../../../../AGENTS.md)), this is the right time to cut test-only public shape.
- **Adapter tests get a little more explicit.** They lose the ergonomic `generate()` wrapper, but that is useful pressure: tests exercise the same streaming path production uses.
diff --git a/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md b/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md
index 9a385a01a9..765cadd573 100644
--- a/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md
+++ b/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md
@@ -1,6 +1,6 @@
# RFC: Prune dead methods from the persistence seam
-Status: implemented (proposed and accepted 2026-06-20)
+Status: implemented
> **Implementation note (scope narrowed from the original proposal).** This RFC proposed pruning dead methods from BOTH the persistence seam (`SessionPersistence.has()`/`.delete()`) and the bash seam (`BashExecutor.get()`/`.list()`). Only the **persistence** removal shipped. The bash `get()`/`.list()` removal was reverted before merge: each is a one-line accessor over the executor's already-tracked `tasks` map, and removing them forced `dsh-tool-bash`'s tests onto a ~35-line `onTaskDone`-based completion-tracking harness to replace the one-line `ctx.bash.get(id)` lookup — the migration cost dwarfed the surface removed. Per the [AGENTS.md "RFCs are proposals, not golden truth"](../../../../AGENTS.md) principle, that friction is evidence the method earns its keep (a test harness IS a consumer that programs against the seam), so `get()`/`list()` stay. The bash-seam analysis below is retained for the record but was NOT acted on; `BashTaskId`-branding those methods lands in the [branded-ids RFC](../architecture/2026-06-20-branded-ids.md) instead. The persistence removal stands: `has()`/`delete()` had only contract-test callers and no test-ergonomics cost to remove.
@@ -14,27 +14,26 @@ The abstract service declared its operations beyond create/append: `load`, `list
`has()` was not just unused — it was the most intricate branch in the shared coordinator: a tracked-vs-untracked dual-probe (`loadLive(id, cwd)` for a live-tracked session vs `loadStored(id)` for an untracked one) with a multi-line rationale. `delete()` dragged the `deleteStored` backend hook that every backend had to implement. This is the [drop-mutable-session-summary](../../implemented/simplification/2026-06-19-drop-mutable-session-summary.md) pattern: a contract test exercised both, but no shipping code asks "is this session persisted?" or removes one.
-## Proposal
+## Decision
-Remove the methods nothing consumes, from the abstract seam, the implementation, and the contract/spec suites that exist only to exercise them:
+The methods nothing consumes are removed — from the abstract seam, the implementation, and the contract/spec suites that existed only to exercise them:
-- `SessionPersistence.has()` / `.delete()`: delete the abstract declarations, the coordinator's `has`/`delete`/`deleteCore`, and the `PersistenceBackend.deleteStored` hook. Remove the `has`/`delete` rows from the contract suite and the per-backend specs (jsonl + sqlite each implemented `deleteStored` only to satisfy the hook — that implementation goes too). The backends are the [dual-backend](../../implemented/architecture/2026-06-14-session-persistence.md) design and otherwise out of scope, but removing a hook they implement for no consumer is part of removing the hook, not a backend redesign.
-- Update every doc and source-comment reference to the removed methods — not only literal `has(`/`delete(`/`deleteStored` call spellings, but also `{@link has}`/`{@link delete}` JSDoc links and prose that counts the methods (removing 2 of the persistence service's 6 public methods makes any "six public methods" phrasing wrong). The implementing PR greps `has`/`delete`/`deleteStored`/`{@link `/`six ` across `docs/`, `packages/*/README.md`, and source comments, and fixes each. The known doc sites: the seam README ([packages/session-persistence/session-persistence/README.md](../../../../packages/session-persistence/session-persistence/README.md)'s `has(id)`/`delete(id)` API row and its "delegates its six public service methods" prose → four), the backend READMEs that describe `has`/`list` semantics ([packages/session-persistence/session-persistence-sqlite/README.md](../../../../packages/session-persistence/session-persistence-sqlite/README.md), [packages/session-persistence/session-persistence-jsonl/README.md](../../../../packages/session-persistence/session-persistence-jsonl/README.md) — reword "absent from `has()`/`list()`" to just `list()`), the service-map / seam docs in [docs/architecture.md](../../../architecture.md), and the persistence prose in the [session-persistence RFC](../../implemented/architecture/2026-06-14-session-persistence.md) and [shared write-coordinator RFC](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). The known source-comment sites: the abstract `create()` JSDoc's `{@link has}/{@link list}` link ([packages/session-persistence/session-persistence/src/index.ts](../../../../packages/session-persistence/session-persistence/src/index.ts) — drop the `has` link), the coordinator's "six public methods"/"six public service methods" module + class JSDoc and its lazy-materialization JSDoc justifying the `materialized` flag by "the signal `has`/`list` rely on" ([packages/session-persistence/session-persistence/src/coordinator.ts](../../../../packages/session-persistence/session-persistence/src/coordinator.ts)), the JSONL backend's `loadStored`/`deleteStored` comment, and the SQLite backend's `schema.ts` and `index.ts` comments that mention "absent from `has`/`list`" — all reworded to the surviving four-method, `list()`-only contract.
+- `SessionPersistence.has()` / `.delete()` are gone: the abstract declarations, the coordinator's `has`/`delete`/`deleteCore`, and the `PersistenceBackend.deleteStored` hook (jsonl + sqlite each implemented `deleteStored` only to satisfy the hook — those implementations went too). The backends are the [dual-backend](../../implemented/architecture/2026-06-14-session-persistence.md) design and otherwise out of scope; removing a hook they implemented for no consumer is part of removing the hook, not a backend redesign.
+- Every doc and source-comment reference is updated to the surviving four-method, `list()`-only contract — not only literal `has(`/`delete(`/`deleteStored` spellings but `{@link has}`/`{@link delete}` JSDoc links and "six public methods" counts — across the seam and backend READMEs, [docs/architecture.md](../../../architecture.md), the [session-persistence](../../implemented/architecture/2026-06-14-session-persistence.md) and [write-coordinator](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) RFCs, and the coordinator/backends JSDoc.
-## Why not keep them as "the seam should be complete"?
+## Alternatives considered
+
+### Why not keep them as "the seam should be complete"?
The instinct that a persistence seam "should" offer delete is real — and it is exactly the speculative-completeness the pre-release stance warns against ([AGENTS.md](../../../../AGENTS.md): optimize for the correct foundation, not for hypothetical callers you do not have). `delete()` is one method to re-add the day a consumer needs it: a session-management UI that deletes old sessions will want it — add it then, designed against that UI's real needs (soft-delete? cascade? confirmation?), not guessed now.
Re-adding a seam method with a live consumer is cheap and better-designed than the speculative version, because the consumer pins the contract. Carrying it unused means every implementation (and every future backend) must implement and test a method that does nothing.
-## Acceptance criteria
+## Verification
-- `has`/`delete`/`deleteStored` are gone from the persistence seam, impl, and contract suites; `pnpm run knip` reports no new dead exports.
-- The remaining persistence operations (`create`/`append`/`load`/`list`) are untouched; ACP `session/list` and crash-recovery behave identically.
-- `pnpm run test:coverage` stays 100% per-file (the contract/spec rows for the removed persistence methods are deleted with them).
-- The persistence seam README and `docs/architecture.md` no longer list the removed `has`/`delete` methods.
+`has`/`delete`/`deleteStored` are gone from the persistence seam, impl, and contract suites with no new dead exports; the remaining operations (`create`/`append`/`load`/`list`) are untouched, with ACP `session/list` and crash-recovery behaving identically; and the seam README and `docs/architecture.md` list only the surviving methods.
-## Risks
+## Consequences
- **`delete()` is the kind of operation a product eventually wants.** True — but "eventually" is the point. Deleting it now and re-adding it against a real consumer is strictly better than shipping a guessed contract. The dual backends each shed a `deleteStored` impl, which is a bounded edit in otherwise-out-of-scope packages.
- **Low coupling.** The removal is confined to the persistence seam + impl + tests; no cross-package consumer references the removed methods, so there is no ripple beyond the docs.
diff --git a/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md b/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md
index 60ad056f18..b97d0abcee 100644
--- a/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md
+++ b/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md
@@ -1,6 +1,6 @@
# RFC: Keep one public stop primitive
-Status: implemented (proposed 2026-06-20; accepted in amended form — `whenIdle()` retained)
+Status: implemented
> **Implementation note (scope narrowed from the original proposal).** This RFC proposed removing BOTH `abort()` and `whenIdle()` from the public `Agent` handle. Only `abort()` was removed. Validating the premise against the code ([AGENTS.md "RFCs are proposals, not golden truth"](../../../../AGENTS.md)) found `whenIdle()` to be a **load-bearing quiescence primitive**, not dead surface: it is the settle signal in several ACP tests (`packages/ui/acp/tests/{edges,turns,dispose}.spec.ts`) and is backed by a deliberate loop contract (settle waiters without a status transition; handle the replacement-turn race). The RFC's suggested migration — have consumers observe the `running`→`idle` transition by hand — is exactly the brittle hand-rolled path [the defensive patterns](../../../defensive-patterns.md) warns against ("Async state is not synchronous state"). Deleting a clean primitive to push every consumer onto that is a net loss, so `whenIdle()` stays. `abort()` was genuinely dead public surface (no production caller; the loop aborts its own `AbortController` directly), so it was removed as proposed. The text below is amended to describe what shipped.
@@ -12,22 +12,23 @@ The `abort()`/`cancel()` distinction is real — `abort()` preserves queued prom
The extra surface area made the loop carry a public verb that is mostly a teardown internal: `abort()` had to be documented as distinct from queue-aware cancellation even though a UI cancellation almost always wants the broader operation.
-## Proposal
+## Decision
-Keep `cancel()` as the only public *stop* primitive on `Agent`. Lifecycle owners use `AgentHandle.dispose()` to stop and unregister an agent; non-owners use `cancel()` to abandon current and queued work. The implementation keeps a private abort controller, but it is not part of the plugin-facing `Agent` contract.
+`cancel()` is the only public *stop* primitive on `Agent`. Lifecycle owners use `AgentHandle.dispose()` to stop and unregister an agent; non-owners use `cancel()` to abandon current and queued work. The implementation keeps a private abort controller, but it is not part of the plugin-facing `Agent` contract.
`whenIdle()` is **retained** as the public quiescence-observation primitive (resolve once the agent settles out of `running`, resolve immediately when already idle, await the loop exit when disposed). It is not a stop verb; it is how a non-owner observes the stop *completing* without disposing the agent. Its live consumers are ACP and agent tests that await settlement through this public seam (`packages/ui/acp/tests`, `packages/core/agent-loop/tests`); the production ACP bridge owns its agents and tears them down through `AgentHandle.dispose()`, so `packages/ui/acp/src` itself has no `whenIdle()` call.
-Delete public `abort()`, the tests that exercise it as standalone API, and the docs that describe step-only abort as an embedding feature. Empty-queue abort tests migrate to `cancel(reason)` where they still prove cancellation behavior; tests whose subject is the loop's internal `AbortController` behavior drive that controller directly via an in-package typed cast to the private field; tests that only pin the removed no-arg `abort()` default go away with the method. The disposer remains async and still waits for the loop to stop.
+Public `abort()` is deleted, with the tests that exercised it as standalone API and the docs that described step-only abort as an embedding feature. Empty-queue abort tests migrated to `cancel(reason)` where they still prove cancellation behavior; tests whose subject is the loop's internal `AbortController` drive that controller directly via an in-package typed cast to the private field; tests that only pinned the removed no-arg `abort()` default went with the method. The disposer remains async and still waits for the loop to stop.
-## Acceptance criteria
+## Alternatives considered
-- `Agent` exposes no public `abort()`; `cancel()`, `whenIdle()`, and `steer()` remain part of the surface.
-- ACP cancellation continues to call `cancel()`.
-- Agent teardown continues to await quiescence through handle disposal, and `whenIdle()` still resolves on quiescence for non-owner observers.
-- Tests cover cancellation and disposal as the two supported stop paths.
+**Removing `whenIdle()` too** — the original proposal's shape, reversed on validating the premise against the code (the implementation note above carries the full record): it is a load-bearing quiescence primitive, and pushing consumers onto hand-observed `running`→`idle` transitions is exactly the brittle path the defensive patterns warn against.
-## What we give up
+## Verification
+
+`Agent` exposes no public `abort()` while `cancel()`, `whenIdle()`, and `steer()` remain; ACP cancellation calls `cancel()`; teardown awaits quiescence through handle disposal, with `whenIdle()` resolving on quiescence for non-owner observers; and the suites cover cancellation and disposal as the two supported stop paths.
+
+## Consequences
A future plugin cannot abort only the current model/tool step while preserving queued prompts through the public interface. If that use case becomes real, it should return with a named consumer and a narrower contract. Today it is latent generality that keeps a private loop mechanic public.
diff --git a/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md
index f198ff9ec6..e4b72ab5d1 100644
--- a/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md
+++ b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md
@@ -1,6 +1,6 @@
# RFC: Stop mirroring durable boundaries as agent events
-Status: implemented (accepted 2026-07-01)
+Status: implemented
+Status: implemented
> Merges the original proposal and the decision record for one topic. It found a real BlockAssembler duplicate-`block-end` bug on first run.
-## Context
+## Problem
Example-based tests pin the cases we thought of. The harness's core is protocol-shaped — chunk streams, event logs, schema conversion, inbox scheduling — where the input space is combinatorial and the interesting bugs live in interleavings nobody wrote an example for. The motivating evidence: a block-assembly ordering bug once survived 100% line coverage of the happy paths. Per-file 100% coverage proves every line ran, not that every interleaving is correct.
@@ -25,3 +23,5 @@ Adopt `fast-check` (a root devDependency) with one `tests/properties.spec.ts` pe
- **It already paid off:** the BlockAssembler stream found a real bug — a duplicate `block-end` at the same index overwrote an already-flushed block, so the streamed prefix disagreed with final `blocks()`. Fixed (first close wins, matching the existing straggler rule) with a dedicated regression test.
- A property flake from a timeout is a finding, not something to retry away. The loop properties are deterministic by construction (settle on `agent/status`), so a hang is a real defect.
- Property tests supplement, not replace, the example tests that pin specific branches for the 100%-coverage gate.
+
+
diff --git a/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md
index cd7058487f..d587206569 100644
--- a/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md
+++ b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md
@@ -1,10 +1,8 @@
# RFC: ACP snapshot tests — record-once / replay-deterministic
-Status: implemented (accepted 2026-06-19)
+Status: implemented
-
-
-## Context
+## Problem
The harness has two test tiers: keyless unit `.spec.ts` (the 100%-per-file coverage gate) and real-API `.e2e.ts` (key-gated, self-skipping in CI). Neither continuously verifies the **complete output transcript** an ACP editor (Zed) sees on its stdin/stdout. The existing ACP e2e ([examples/acp-agent/tests/acp.e2e.ts](../../../../examples/acp-agent/tests/acp.e2e.ts)) is the closest end-to-end check, but it is key-gated and asserts on a handful of *structured fields* (`stopReason`, a `tool_call` title), not the byte-for-byte stream of `session/update` frames. That leaves the "green units, broken product" gap: every unit test can pass while the actual editor-facing protocol output regresses — the same class of failure that shipped the inject bug ([docs/postmortem/0001](../../../postmortem/0001-acp-default-export-drops-inject.md)), where 178 hand-mounted tests stayed green while a real Zed session crashed instantly.
@@ -72,6 +70,12 @@ The replay plugin lives in its own package, `@deepseek-ai/dsh-llm-replay` (`pack
`pnpm run test:snapshot` runs replay (keyless) and is composed into the default `pnpm run test` gate so every PR gets the regression check (the main `vitest.config.ts` include stays narrow; the gate is `test && test:snapshot`). `pnpm run test:snapshot:record` requires `DEEPSEEK_API_KEY` (loaded from repo `.env` first), hits the real API, harvests the produced `session.jsonl` (the replay source AND the expected-log artifact), and `--update`s the stdout golden in one pass. Both forward a scenario filter. A missing fixture in replay **fails loud** with a "record first" message rather than self-skipping (the e2e self-skip rule is a CI-secret accommodation, not appropriate here — a committed-fixture test that silently vanishes is a coverage hole). A no-model scenario's `session.jsonl` simply has no `assistant/chunk` events (empty derived script); fail-loud still applies if a model call happens with no entry. An orphan-fixture guard test fails on a golden/fixture not referenced by any scenario (Vitest does not prune orphaned raw goldens), and a per-kind required-fixture guard asserts each scenario ships exactly the files its kind needs (`input.json` + `stdout.golden.jsonl` + `session.jsonl` for ALL scenarios — the harness passes `/session.jsonl` to `llm-replay` unconditionally, so even a no-model scenario needs its header-only fixture or `loadReplayScript()` fails; `replay.override.json` additionally for authored model scenarios).
+## Alternatives considered
+
+- **A hand-authored `llm.json` of model chunks** — the earlier draft; reusing the real session log makes the fixture a genuine product of the system rather than a hand-built mock, and doubles it as a behavioral golden.
+- **A byte-level HTTP-record library (Polly/nock/MSW)** — rejected: adapter-specific, awkward with streaming SSE, and lower-level than the thing under test.
+- **Synthesizing throw/cancel entries from `turn/end {kind:'error'|'aborted'}`** — rejected: it couples `llm-replay` to loop-internal turn-closing semantics, and the `turn/end` reason is lossy (it cannot distinguish a thrown 401 from a finish-error); the explicit `replay.override.json` sidecar is the cleaner seam.
+
## Consequences
A new test tier and its fixtures to maintain: each scenario is a directory of `input.json` (the client stdin script) + `session.jsonl` (the recorded log, which doubles as the expected re-persisted log) + an optional `replay.override.json` + an optional `workspace/` seed dir + the `stdout.golden.jsonl`, committed and reviewed. A scenario that needs the agent to operate on existing files (read, edit, grep) ships a `/workspace/` directory; the harness copies its contents into the temp cwd before the run, so the seeded files are present for both record and replay (the cwd is normalized in the goldens, so the seeded paths stay stable). Re-recording when the model's phrasing changes churns the fixture and the stdout golden — visible in review, which is the point of committing them. Bought: deterministic, keyless, full-transcript regression coverage that boots the real Loader (so it still guards the export-shape bug class), exercises the real bash executor, and gives a one-command accept-the-diff loop. The tier is ACP-first but the harness (subprocess + tee + input-DSL + workspace seeding + normalization + JSONL-derived replay) is example-agnostic and extends to other examples.
diff --git a/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md b/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md
index 42b2e55ffd..d23483eb88 100644
--- a/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md
+++ b/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md
@@ -1,10 +1,8 @@
# RFC: Real-API e2e in CI against the external DeepSeek API
-Status: implemented (accepted 2026-06-19)
+Status: implemented
-
-
-## Context
+## Problem
The harness leans hard on real-API tests by policy: [docs/testing.md](../../../testing.md) argues that a no-key suite proves the plumbing but not the product, and the [ACP inject postmortem](../../../postmortem/0001-acp-default-export-drops-inject.md) is the standing proof — 178 keyless tests stayed green while a real editor session crashed instantly. The real-API e2e suite (`pnpm run test:e2e`, the `*.e2e.ts` files) exists precisely to close that gap: it drives the agent against the live DeepSeek API — real model calls, real bash tools, multi-turn, resume, ACP-over-stdio.
@@ -86,6 +84,11 @@ What gets worse is the *surrounding* model, and these are the things to address
None of these require changing the workflow to go public; they are operational steps plus the already-added `pull_request_target` guard comment.
+## Alternatives considered
+
+- **A secret-consuming job inside ci.yml** — rejected: it would couple the keyless, forkable, always-green gate to credential availability and a different trigger/concurrency policy; different lifecycles, different files.
+- **Omitting the `pull_request` trigger** (the smaller key-exposure surface) — rejected for the pre-merge signal; the Security section carries the accepted exposure analysis.
+
## Consequences
A second CI workflow and the first repo secret to maintain. The real-API suite now gates merges (pre-merge on trusted PRs, post-merge on the main branch) and runs nightly, so a real break in the agent's interaction with the external API surfaces in CI rather than only in a developer's local run — at the cost of real (but internally free) API calls on every trusted PR and merge. The preflight makes secret misconfiguration self-announcing instead of silently disabling the net.
diff --git a/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md b/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md
index af7cd59d06..07a1cfd731 100644
--- a/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md
+++ b/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md
@@ -1,6 +1,6 @@
# RFC: Use `session.jsonl` as the only snapshot session-log artifact
-Status: implemented (proposed and accepted 2026-06-20)
+Status: implemented
## Problem
@@ -8,9 +8,9 @@ Model-driving ACP snapshot scenarios ship both `session.jsonl` and `session.gold
Authored override scenarios (`error-finish`, `cancel`) currently use `replay.override.json` to drive model behavior and keep `session.jsonl` as a minimal dummy fixture, while `session.golden.jsonl` holds the expected persisted log. The override file is a JSON array of `ReplayEntry` objects: `{ "kind": "chunks", "chunks": StreamChunk[] }`, `{ "kind": "throw", "chunks": StreamChunk[], "message": string, "code": string, "status"?: number }`, or `{ "kind": "hang" }`. That split is also unnecessary: when an override sidecar exists, `llm-replay` replaces the derived script and does not need `session.jsonl` for model chunks, so `session.jsonl` can still be the expected session-log artifact for the scenario.
-## Proposal
+## Decision
-Remove the `session.golden.jsonl` concept entirely. Every scenario has at most one committed session-log artifact, `session.jsonl`:
+The `session.golden.jsonl` concept is removed entirely. Every scenario has at most one committed session-log artifact, `session.jsonl`:
- For recorded scenarios, `session.jsonl` remains the raw harvested log. Replay still derives model chunks from it, and the snapshot test compares the replay run's normalized persisted log against normalized `session.jsonl`.
- For authored override scenarios, `replay.override.json` drives model behavior and `session.jsonl` holds the expected produced session log. The replay adapter ignores the fixture for model chunks when the override exists, so the same file can be the expected log without affecting replay behavior.
@@ -18,15 +18,15 @@ Remove the `session.golden.jsonl` concept entirely. Every scenario has at most o
Stdout goldens remain unchanged; they are the editor-facing projection and are not redundant with the session fixture.
-## Acceptance criteria
+## Alternatives considered
-- `session.golden.jsonl` disappears from the snapshot harness, fixtures, orphan guards, and docs.
-- The snapshot test derives the expected session log from `session.jsonl` for every model scenario.
-- Authored sidecar scenarios commit their expected produced log in `session.jsonl`; `replay.override.json` remains the model-behavior override.
-- Orphan-fixture guards understand which files are required by scenario kind.
-- The [ACP snapshot tests RFC](../../implemented/testing/2026-06-19-acp-snapshot-tests.md) is updated to describe the reduced fixture set.
+**Normalizing both sides against a shared (replay-run) context** — rejected: `normalizeSessionLog` scrubs cwd by exact string match, so the fixture's recorded cwd would survive unscrubbed and every compare would fail. Each side normalizes against its own header-derived context — the implementation note below carries the mechanics.
-## What we give up
+## Verification
+
+`session.golden.jsonl` appears nowhere in the snapshot harness, fixtures, orphan guards, or docs; the snapshot test derives the expected session log from `session.jsonl` for every model scenario; authored sidecar scenarios commit their expected produced log as `session.jsonl` with `replay.override.json` as the model-behavior override; and the orphan-fixture guards know which files each scenario kind requires. The [ACP snapshot tests RFC](../../implemented/testing/2026-06-19-acp-snapshot-tests.md) describes the reduced fixture set.
+
+## Consequences
Reviewers lose one artifact name that made the expected persisted log visually separate from the replay fixture. The stdout golden still protects the editor transcript, and comparing replay output to `session.jsonl` preserves the loop/persistence regression check without duplicating files.
diff --git a/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.md b/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.md
index 2ad44e2040..b2c39047b9 100644
--- a/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.md
+++ b/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.md
@@ -25,3 +25,5 @@ The fork backend seeds the child with the parent's **balanced completed-turn pre
- `subagent-mixed` is the first snapshot scenario to drive two *different* subagent backends in one transcript, exercising the per-session replay keying across a spawn and a fork child simultaneously.
- Out-of-process (ACP) subagent replay remains a different shape (each child is its own process with its own replay) and is still tracked as `TODO(acp-subagent-replay)` — these scenarios are in-process only.
- Re-recording (`pnpm run test:snapshot:record`) regenerates all four fork/spawn fixtures from the live API; the two new scenarios self-skip without a key like every recorded scenario.
+
+
diff --git a/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md b/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md
index e72175e544..d114b25176 100644
--- a/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md
+++ b/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md
@@ -31,6 +31,8 @@ This keys by WHO calls, not by global call order — so it stays correct even if
The ordering key is the session header `createdAt`. In the current synchronous cut this is sound because sibling children are created **strictly sequentially** — the subagent tool awaits one child's result and disposes it before the parent's next tool call starts the next child — so their `createdAt` values are strictly ordered and match first-call order exactly. A same-millisecond sibling tie is therefore unreachable; the `recordedId` tiebreak only keeps such a degenerate collision deterministic, it does not recover first-call order. A future cut that runs siblings concurrently/backgrounded WOULD be able to create two children in the same millisecond, and must then thread a real first-call ordinal (the order live sessions first stream) rather than leaning on `createdAt` — flagged with `XXX(concurrent-subagents)` at the sort site.
+## Alternatives considered
+
The alternative considered and rejected was a **call-ordered merge of the parent and child logs** into one global script (sound only because in-process subagent execution is strictly nested — the parent blocks on the child). It is simpler for today's synchronous cut but bakes in the parent-blocks-on-child invariant that a future backgrounded/concurrent subagent would break; per-session keying does not.
### 3. The harness harvests every log, primary-first
diff --git a/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md b/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md
index ad61bd3b84..47db2cd793 100644
--- a/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md
+++ b/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md
@@ -44,3 +44,5 @@ The matrix therefore covers every hook point that has a DETERMINISTIC, OBSERVABL
- The block scenarios are keyless (no model turn); the rest replay keyless from recorded fixtures. `pnpm run test:snapshot:record` regenerates the recorded fixtures from the live API and self-skips without a key like every recorded scenario.
- The prove-red discipline holds: tampering a hook config's output (e.g. changing a deny reason) turns its scenario red on replay — the hook process runs FOR REAL during replay (only the model is replayed), so the golden guards the actual hook→seam→loop path, not a mock of it.
- The `acp-agent` demo now loads a Codex bridge it will usually no-op (no `codex-hooks.json` in a typical project), which is the intended fail-soft behavior, not a cost.
+
+
diff --git a/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md b/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md
index c5f836b9f7..358aa64147 100644
--- a/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md
+++ b/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md
@@ -12,7 +12,9 @@ Status: implemented
One vendored-plugin fact the overlay depends on, deliberately: the include applies `patches` when it loads the file — its `refresh()`/`internal/update` paths re-read without re-patching — which is exactly enough for a one-shot replay boot (the replay app loads no `hmr` and nothing rewrites the config mid-run). The snapshot suite is the proof: all scenarios pass unchanged on the overlay, byte-identical goldens included.
-## Why not the alternatives?
+## Alternatives considered
+
+### Why not the alternatives?
Keeping the full twin with a symmetry verify-gate was the recorded fallback — it would have removed the silent-drift class but kept a 125-line near-copy whose only content was one entry's difference, growing with every plugin the app gains. A bin-side swap (parse the config, replace the entry, delete the file) would have put YAML surgery inside a published artifact and moved the replay delta out of sight; the overlay keeps the delta declarative, readable, and next to the base config — the teaching value the twin's defenders actually wanted.
diff --git a/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md b/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md
index a48caf11d4..e1792d3ca3 100644
--- a/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md
+++ b/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md
@@ -2,8 +2,6 @@
Status: proposed
-
-
## Problem
The harness models its core vocabulary — content blocks, message sources, finish reasons, turn triggers, turn-end reasons, and session events — as **merge-extensible maps**: a TypeScript `interface` (e.g. `SessionEventMap`, `ContentBlockMap`) that plugins augment via declaration merging, with the public union derived as `Map[keyof Map]`. This is the repo's universal extension pattern, documented in [docs/architecture.md](../../../architecture.md) ("The same merge-extensible-map pattern is used for `MessageSource`, `FinishReason`, `TurnTrigger`, and `TurnEndReason`") and relied on by the `defineTool` `InferArgs` DSL and the `assertNever` exhaustiveness convention.
@@ -36,7 +34,7 @@ A migration of the event/vocabulary surface to runtime schemas touches, at minim
This is a HUGE change. It is not in scope for the RFC-009 session-persistence work and must not be smuggled in through it.
-## Options
+## Alternatives considered
### A. Status quo — merge-extensible types + `isJsonValue` at the durable boundary
Keep the compile-time pattern. Persistence stays opaque-JSON + serializability guard. Plugins extend via declaration merging; correctness of event *shape* is the producer's responsibility, enforced by TypeScript at compile time and by the `dsh-invariants` plugin's structural checks in dev.
@@ -56,10 +54,20 @@ Replace the merge-extensible maps with a runtime registry the producers contribu
- **Pros**: real runtime validation at the durable boundary and at plugin seams; one source of truth; enables generic tooling (auto-generated docs, fuzzing, wire-format checks).
- **Cons**: the full blast radius above; **Zod is not currently a direct dependency** (only a transitive dep of `@earendil-works/pi-ai`) and the repo's chosen schema lib is **schemastery** — adopting Zod broadly is itself a dependency decision; declaration-merge ergonomics (one-line plugin extension, full inference) are replaced by runtime registration + manual type wiring; the `assertNever` exhaustiveness guarantee weakens (runtime variants aren't statically exhaustive).
-## Recommendation
+## Proposal
Defer. Do **not** change #33. If runtime validation is wanted at the durable boundary in the near term, **Option B** (schemastery on the closed header/metadata shapes) is the proportionate step and stays within the existing convention. **Option C** is a genuine architecture decision that should be evaluated on its own merits — including whether the chosen library is Zod or schemastery — and, if accepted, land as its own change with its own RFC, not as a side effect of persistence serialization.
+## Acceptance criteria
+
+- The decision state is explicit: Option C proceeds only as its own change with its own implementation RFC — never as a side effect of a persistence PR.
+- If Option B is taken up, the closed header/metadata shapes (the JSONL `isHeaderLine` guard and kin) validate through schemastery in place of hand-rolled guards, with the merge-extensible maps untouched.
+
+## Risks
+
+- The deferral leaves event `data` structurally unvalidated at the durable boundary: a malformed-but-JSON datum is caught late, by a consumer's `switch` — the status-quo cost, accepted deliberately.
+- If Option C is ever adopted, the ergonomic loss is real: one-line declaration merging becomes runtime registration plus manual type wiring, and the `assertNever` static-exhaustiveness guarantee weakens.
+
## Open questions
- If a registry is adopted, is the library **schemastery** (already in the tree, already the config schema lib) or **Zod** (richer ecosystem, currently only transitive)? Adopting two schema libraries is a cost in itself.
diff --git a/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md b/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md
index 7f7c5ed007..f4ebe921b5 100644
--- a/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md
+++ b/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md
@@ -34,6 +34,8 @@ A consumer census of the surface the runtime would carve up. Production has two
- ACP and snapshot fixtures render background bash through the shared task vocabulary, not through bash-only lifecycle semantics.
- The [tool cookbook](../../../cookbook/adding-a-tool.md) points long-running tools at the shared runtime instead of telling each tool to invent its own task protocol.
-## What we give up
+## Risks
The bash package loses local ownership of an already-working background-task implementation, and the implementing PR may temporarily churn model-facing tool names or transcript presentation. That churn is worthwhile if it leaves one background-task contract instead of making every future long-running tool clone bash's private protocol.
+
+
diff --git a/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md b/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md
index 36396e233d..c321c56d53 100644
--- a/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md
+++ b/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md
@@ -2,7 +2,6 @@
Status: proposed
-
> **Implementation status (MVP landed):** steps 1, 2, 3, 4, 6, 7, 8 are implemented in `packages/ui/acp` + `examples/acp-agent`. **Step 5 (the `session/request_permission` permission gate) is deferred** — the bridge ships a pass-through (tools run with the executor's full authority) marked `TODO(rfc010-permission-gate)`, and lays down only the `WeakMap` ownership seam the gate will build on. Status stays `proposed` until the gate lands. `session/cancel` is the queue-aware `agent.cancel()`: it aborts a running step, clears queued + steering work, and drops a turn that is about to start, so a queued-but-not-yet-started prompt never runs and a later prompt cannot be batched into the cancelled turn. **Per-session `cwd` is now honored** (lifting the original "launch the server in the workspace root" restriction — see § Deferred): `session/new` accepts any absolute `cwd`, and `session/load` requires the request `cwd` to match the persisted session `cwd` so the editor and bash executor agree on the workspace.
## Problem
@@ -59,6 +58,17 @@ Deferred (each names its owning future work):
- Client `terminal/*` proxying (a live editor terminal) and `fs/*` (editor-rendered diffs) — a future `BashExecutor` over the [capability seams](../../implemented/architecture/2026-06-13-capability-seams.md) bash seam, gated on `clientCapabilities.terminal`.
- Image/audio prompts (blocked on the DeepSeek adapter, which skips `image` blocks today), modes, auth, `available_commands`/slash-commands, `plan`, and `usage_update`.
+## Alternatives considered
+
+- **A process-wide stdout hijack inside `dsh-acp`** (defensively monkey-patching `console.log` / `process.stdout.write`) — rejected: it lives outside Cordis' effect-scoped, HMR-friendly plugin model, races the connection's own stdout handoff, and fights the logger. The stdout guarantee is config-only.
+- **Injecting `agentLoop` directly instead of the abstract create/resume factory** — the recorded fallback, taken only if the factory seam is judged not worth it, with the architecture-rule exception recorded in `docs/architecture.md`.
+
+## Acceptance criteria
+
+- The `acp-agent` example speaks ACP over stdio end-to-end: `initialize`, `session/new` with a validated absolute `cwd` honored as the session workspace, streamed `session/update` frames per prompt turn, `session/load` re-deriving identical history, and `session/prompt` resolving with the correct wire `stopReason`.
+- stdout carries only framed JSON-RPC (asserted by test); the permission gate settles every `session/request_permission` exactly once — on outcome, cancel, or connection close.
+- The plan's test set runs green: the property-based protocol invariants, the codec unit tests over an in-memory duplex pair, the HMR-safety test, the failure-path matrix, and the self-skipping real-API e2e that verifies the world.
+
## Risks
stdout is the protocol — guaranteed by config, not by monkey-patching. The console logger writes through `console.log` to stdout, so any stdout UI/logger plugin corrupts JSON-RPC. The guarantee is config-only: the `acp-agent` example loads no stdout plugin (no console logger, no `stdio-chat`) and, if logging is wanted, uses a stderr exporter. A defensive process-wide `process.stdout.write`/`console.log` hijack inside `dsh-acp` is explicitly rejected — it lives outside Cordis' effect-scoped, HMR-friendly plugin model, races the connection's own stdout handoff, and fights the logger. A test asserts the example emits only framed JSON-RPC on stdout.
diff --git a/docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md b/docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md
index ed51a2ded8..a65f6b67eb 100644
--- a/docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md
+++ b/docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md
@@ -2,7 +2,6 @@
Status: proposed
-
> **Implementation status:** the multi-session bridge (steps 1, 3, 4) and the bash task-ownership isolation are implemented in `packages/ui/acp` + `packages/bash/tool-bash`. **Per-session *permission* ownership is deferred** — it depends on [the ACP support permission gate](2026-06-14-acp-agent-client-protocol.md) (`TODO(rfc010-permission-gate)`), which is itself deferred; the `agent→sessionId` reverse map the gate will route through is in place. Step 2's per-session disposer scope is now implemented (see [agent lifecycle & ownership seams](../../implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md)): the factory returns a per-agent `AgentHandle` whose `dispose()` stops the loop, awaits quiescence, unregisters the agent, and removes its session, so a bare client disconnect leaves no registered agent or session-store entry. Status stays `proposed` until per-session permission ownership lands.
> **Target-client note:** Zed is the current target ACP client, and its ACP client maintains a `HashMap` plus `pending_sessions` for concurrent `session/load` calls. The competing simplification to return to one live session per connection was rejected after checking that target-client shape; this RFC remains the path for finishing multiplexing and per-session permission ownership. See [the rejected simplification](../../rejected/simplification/2026-06-20-single-session-acp-bridge.md).
@@ -30,6 +29,16 @@ The harness core already supports many agents (`AgentRegistry.list()` and `Agent
3. Lift the `session/new` guard; keep `session/load` ([from ACP support](2026-06-14-acp-agent-client-protocol.md)) working per session.
4. Tests for cross-session isolation: two sessions streaming and permission-prompting concurrently never interleave; a cancel/abort in one session leaves the other's stream and pending permission untouched; per-session in-flight-prompt enforcement holds independently; disposing one session leaves the others running.
+## Alternatives considered
+
+**A per-session `ctx.extend()` scope** — rejected: in Cordis, `ctx.extend()` only creates a child context/prototype, and `ctx.on()` registered on it is still owned by the current plugin fiber, so disposing it would not remove that session's listeners. A genuine child fiber (or a per-session collection of disposers) is required.
+
+## Acceptance criteria
+
+- N concurrent sessions stream and permission-prompt without interleaving their `session/update` notifications; a cancel in one session leaves every other session's stream, queued prompts, and pending permissions untouched.
+- Disposing one session removes exactly its own listeners; connection teardown reaches quiescence across all sessions.
+- One session's agent cannot read or kill another session's background bash task.
+
## Risks
Listener fan-out cost: each session adds listeners; ensure disposal of one session removes exactly its own and the connection teardown ([from ACP support](2026-06-14-acp-agent-client-protocol.md)) still reaches quiescence across all sessions.
diff --git a/docs/rfc/proposed/feature/2026-06-15-optional-code-mode.md b/docs/rfc/proposed/feature/2026-06-15-optional-code-mode.md
index f5da38c14c..39c907a1c2 100644
--- a/docs/rfc/proposed/feature/2026-06-15-optional-code-mode.md
+++ b/docs/rfc/proposed/feature/2026-06-15-optional-code-mode.md
@@ -2,8 +2,6 @@
Status: proposed
-
-
## Problem
Today the agent loop advertises every registered tool to the model as a native JSON-schema function definition. `ToolRegistry` feeds its schemas into `ctx.systemPrompt`, the loop puts them on `GenerateOptions.tools`, and the adapter serializes them to the provider's function-calling wire format. The model then invokes one `tool-call` block per step, the loop dispatches each call through `ctx.tools.execute()` **sequentially** (parallel tool execution is an explicit open TODO in `dsh-tools` and [docs/architecture.md](../../../architecture.md)), and **every** intermediate `tool-result` re-enters the model's context on the next request.
@@ -73,7 +71,7 @@ These are illustrations of the seam's reach, **not commitments** — the MVP shi
**Optionality / toggle.** Loading the `code-mode` plugin enables Code Mode for that context; not loading it leaves today's native tool-calling untouched. The two are mutually exclusive within one ctx, because Code Mode rewrites the wire tool list down to `[run_code]`. Per-agent selection via ctx forks, and the visibility tiers above, are future work; the MVP toggle is plugin presence.
-## Alternatives
+## Alternatives considered
**Result elision / summarization over native tool-calling (the narrower route).** The Problem has two halves — context bloat (every intermediate `tool-result` re-enters context) and serial composition (one tool call per round-trip). The context-bloat half can be addressed *without* any code-execution runtime: keep provider tool-calling exactly as it is, and add a plugin on the `agent/request` waterfall (or a compaction pass akin to [the session-persistence work](../../implemented/architecture/2026-06-14-session-persistence.md)) that elides or summarizes older `tool-result` blocks before they re-enter the model's context — drop them past a window, replace large payloads with a digest, or keep only the blocks the model still references. This is strictly less invasive than Code Mode: no new runtime seam, no model-written programs, no new safety surface. It is the right tool if context growth is the only pain.
@@ -90,6 +88,14 @@ It is insufficient for the **composition / round-trip** half, which is the decis
5. Wire an example: `examples/coding-agent-code-mode` (or a config flag on the existing example) loading the trio. Running it against the node:vm stub requires both opt-ins (`VmCodeRuntime({ unsafe: true })` and `code-mode`'s `allowUnsafeRuntime`); the example sets them explicitly and comments why, or uses a mock model — a real model never reaches the unsandboxed stub without those deliberate flags. Add a `pnpm run demo:*` entry.
6. Docs: update [docs/architecture.md](../../../architecture.md) (a `ctx.codeRuntime` row in the service map, a Code Mode note under the tool pipeline / capability seams sections); add a [cookbook](../../../cookbook) note on writing a `CodeRuntime` backend; and **file the follow-up RFC for the hardened execution substrate** (the isolate/sandboxed-process design, the additional-language backends sketched in §1 — AssemblyScript/WASM, Python — with their per-language SDK generators, plus the tool-visibility-tier design skipped here). On landing, move this file to `implemented/` and update its row in [the RFC index](../../README.md).
+## Acceptance criteria
+
+- The three packages exist and pass their suites: `dsh-code-runtime` (the abstract seam), `dsh-code-runtime-vm` (the reference stub whose constructor throws without `{ unsafe: true }`), and `dsh-code-mode` (SDK codegen, the lazy prompt section, the `agent/request` collapse, the `run_code` tool).
+- The wire tool list is exactly `[run_code]` under the plugin (spy-adapter test); the generated SDK covers every registered tool, with non-identifier names reachable via quoted access.
+- A program calling two tools returns only its curated output; `code/dispatch` events land in the session log and never enter derived history.
+- `Promise.all` over SDK calls does not overlap the underlying `ctx.tools.execute` invocations (the per-run serialization queue holds); an abort stops further dispatches.
+- With `allowUnsafeRuntime` unset over an unsafe runtime, `run_code` is not registered and the wire tool list is unchanged from native.
+
## Risks
node:vm is not a sandbox. This is the single biggest caveat. Withholding `require`/`process` is not a boundary; the MVP runs at harness trust only; the hardened substrate is a hard prerequisite before any untrusted use and is the explicit subject of a follow-up RFC. The guard is enforceable, not just documented: the runtime exposes `safe: boolean`, the VM stub throws unless constructed with `{ unsafe: true }`, and `code-mode` refuses to register `run_code` over an unsafe runtime unless separately acknowledged (`allowUnsafeRuntime`) — production misuse requires two deliberate, greppable flags, and the refusal path is tested.
diff --git a/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md b/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md
index a3bb6d4718..b3e6ab3cd7 100644
--- a/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md
+++ b/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md
@@ -1,10 +1,8 @@
# RFC: Pre-tool input rewrite — a consistent design
-Status: proposed (2026-06-30)
+Status: proposed
-
-
-## Context
+## Problem
The [interception-seams RFC](../../implemented/feature/2026-06-30-interception-seams.md) added `tools/pre-execute` returning a `PreToolDecision` (allow/deny/ask) — but deliberately NOT input rewrite (a hook changing a tool call's `arguments` before it runs). Claude Code's `PreToolUse` hook offers an `updatedInput`, so a faithful CC bridge wants the same. This RFC designs that, separately, because doing it consistently is a real problem — not a field to bolt onto the allow decision.
@@ -18,9 +16,9 @@ In the loop, a tool call's arguments are committed to the log and read by live c
So an "input rewrite" that changes ONLY what executes would make the UI show one command while another RAN, and render result state against the wrong arguments — a real inconsistency, not a documentable gap. (The existing low-level capability to mutate `exec.arguments` in a listener has exactly this latent inconsistency; it is unadvertised precisely because of this — yet not unused: a tool-bash integration test rewrites a scripted call's arguments through it (`packages/bash/tool-bash/tests/integration.spec.ts`), so this design must either sanction that path with the consistency unit below or seal it — `readonly` arguments at the seam, with the test shim moved onto a behavior-level helper.)
-## Proposed design (sketch — to validate against the code when built)
+## Proposal
-Treat input rewrite as a consistency unit: when a `pre-execute` hook supplies `updatedInput`, the rewrite must be reflected in ALL three readers, atomically, before execution:
+A sketch, to validate against the code when built. Treat input rewrite as a consistency unit: when a `pre-execute` hook supplies `updatedInput`, the rewrite must be reflected in ALL three readers, atomically, before execution:
- The `tool/call` audit event records the REWRITTEN arguments (with the original retained in a sidecar field for the audit trail — a hook changed the call, and both the original and the effective arguments are facts worth keeping).
- The `assistant/message` in derived history must agree with what executed — options to evaluate: rewrite the assistant message's tool-call block in place (changes what the model "sees it said"), or record a separate correction the next request carries. The CC model is that the model sees the rewrite took effect.
@@ -28,10 +26,23 @@ Treat input rewrite as a consistency unit: when a `pre-execute` hook supplies `u
The shape would extend `PreToolDecision` with an allow-variant `arguments` (or a dedicated `{kind:'rewrite', arguments}`), and the loop would thread the rewrite through the three readers above rather than only into `ctx.tools.execute()`.
-## Why not now
+## Alternatives considered
+
+### Why not now
The interception-seams RFC notes input rewrite "fought the code across two review rounds" — the signal AGENTS.md names for an over-reaching change. Shipping allow/deny/ask first keeps the seam honest (no advertised contract that silently desyncs the UI), and a CC/Codex bridge that receives an `updatedInput` logs it and surfaces a faithful-but-degraded warning (like `ask`→deny) until this lands. This RFC is the home for the consistent design; `TODO(pre-tool-input-rewrite)` in the loop's pre-execute call site anchors it.
+## Acceptance criteria
+
+- A `pre-execute` rewrite is reflected in all three readers atomically before execution: the `tool/call` audit records the rewritten arguments (the original retained in a sidecar field), derived history agrees with what executed, and presentation renders the rewritten arguments.
+- The unadvertised `exec.arguments` mutation path is either sanctioned by this consistency unit or sealed (`readonly` arguments at the seam, the test shim moved onto a behavior-level helper).
+- The CC/Codex bridges honor `updatedInput` instead of logging the faithful-but-degraded warning.
+
+## Risks
+
+- Rewriting the `assistant/message` tool-call block changes what the model "sees it said"; whether any provider rejects that on replay is the open question that must be settled empirically before the decision shape freezes.
+- Until this lands, the unadvertised mutation path keeps its latent UI-desync inconsistency.
+
## Open questions
- Does rewriting the `assistant/message` tool-call block corrupt any provider's expectation on replay, or is a separate correction safer?
diff --git a/docs/rfc/proposed/process/2026-06-11-api-extractor-reports.md b/docs/rfc/proposed/process/2026-06-11-api-extractor-reports.md
index c1099a6cce..a6f3bfb14c 100644
--- a/docs/rfc/proposed/process/2026-06-11-api-extractor-reports.md
+++ b/docs/rfc/proposed/process/2026-06-11-api-extractor-reports.md
@@ -2,8 +2,6 @@
Status: proposed
-
-
> Split out from the original "Doc-sync and API reports" RFC (2026-06-11). Parts 1-2 (doc-block typechecking, event-taxonomy verification) shipped — see [doc-sync enforcement](../../implemented/process/2026-06-11-doc-sync-enforcement.md). This is the deferred part 3, kept as a standalone proposal.
## Problem
@@ -14,6 +12,19 @@ Public API changes are invisible — nothing makes "this commit changed the publ
api-extractor (or `tsc --emitDeclarationOnly` + a normalized public-surface dump) producing a checked-in `etc/.api.md` per package; CI fails if regeneration differs. Every public-API change becomes a diff line a reviewer (or review agent) must see.
-## Status / why deferred
+## Alternatives considered
+
+**`tsc --emitDeclarationOnly` plus a normalized public-surface dump** — the lighter mechanism if api-extractor proves too heavy; either satisfies the checked-in, diffable report shape the proposal needs.
+
+## Acceptance criteria
+
+- Every package has a checked-in `etc/.api.md`; CI fails when regeneration differs from the committed report.
+- A public-API change (a new export, a widened field, a shifted signature) is visible as a report diff line in review.
+
+## Risks
+
+The dependency is heavy and finicky — the reason this was deferred — and the report format churns with compiler upgrades, adding a maintenance surface that buys little while the packages stay unpublished.
+
+## Why deferred
Deferred when doc-sync landed: low value for an internal monorepo where reviewers already see the source diff, and a heavy, finicky dependency. Revisit if the packages are ever published externally — at that point a stable, diffable public surface earns its keep.
diff --git a/docs/rfc/proposed/process/2026-06-11-architectural-conformance.md b/docs/rfc/proposed/process/2026-06-11-architectural-conformance.md
index 6eaf8b03a7..e0d16b455d 100644
--- a/docs/rfc/proposed/process/2026-06-11-architectural-conformance.md
+++ b/docs/rfc/proposed/process/2026-06-11-architectural-conformance.md
@@ -2,8 +2,6 @@
Status: proposed
-
-
## Problem
Two architectural guarantees currently live only in prose: (1) nothing depends on the concrete loop package ([the microkernel promise](../../implemented/architecture/2026-06-11-microkernel-event-taxonomy.md)), and (2) every LlmAdapter speaks the chunk protocol correctly. Both should be mechanical ([the quality-gates principle](../../implemented/process/2026-06-11-quality-gates.md)).
@@ -24,6 +22,13 @@ Two architectural guarantees currently live only in prose: (1) nothing depends o
dependency-cruiser config + CI step first (an hour of work, permanent guarantee); the conformance kit lands with its first consumer test against MockAdapter, and is a prerequisite for the V4 adapter phase.
+## Acceptance criteria
+
+- dependency-cruiser runs in CI with the rule families above; a violating import fails the build.
+- The conformance kit runs against the mock adapter and both shipping adapters, and a new adapter package inherits the suite by invoking it with its factory.
+
## Risks
Dep-cruiser rule maintenance as packages are added — keep rules pattern-based (`dsh-*`) rather than enumerated.
+
+
diff --git a/docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.md b/docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.md
index e35e79f0a5..e787133026 100644
--- a/docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.md
+++ b/docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.md
@@ -2,8 +2,6 @@
Status: proposed
-
-
## Problem
The vendor manifest ([the vendoring decision](../../implemented/process/2026-06-11-vendor-cordis-as-source.md)) is enforced at commit time in the *forward* direction (vendored change ⇒ manifest update) but nothing verifies the manifest's *claims*: that vendor/ actually equals upstream-at-SHA plus exactly the logged modifications. And the handful of true npm dependencies have no advisory monitoring or update cadence.
@@ -19,6 +17,17 @@ The vendor manifest ([the vendoring decision](../../implemented/process/2026-06-
3 is trivial — do first. 1 requires network access from CI to the upstream repos (private — needs a token) and converting the two existing logged modifications into patch files. 2 and 4 are config.
+## Alternatives considered
+
+- **`pnpm audit` instead of osv-scanner** — either satisfies the advisory-scanning shape; the choice is deferred to implementation.
+- **A scheduled agent task instead of Renovate** — equivalent for proposing small update PRs that ride the full gate suite; vendored packages stay excluded either way (their updates follow the manifest sync procedure).
+
+## Acceptance criteria
+
+- The license inventory script runs in CI and fails on a missing LICENSE or a `license` field that contradicts the inventory in `vendor/README.md`.
+- The nightly drift job reconstructs `vendor/` from the manifest SHAs plus checked-in patch files and fails on any unexplained diff.
+- Advisory scanning runs on the lockfile on schedule and on lockfile-touching PRs.
+
## Risks
Upstream repos are private mirrors; CI credentials and availability are the main friction for the drift check. If blocked, run it as a local scheduled agent task instead of CI.
diff --git a/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md b/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md
index a85e196ffd..3587393efe 100644
--- a/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md
+++ b/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md
@@ -27,6 +27,8 @@ Two of the cataloged items need no generator at all: folding the e2e entry glob
- `knip.json` carries a per-package override only where it encodes real information (an extra entry file, an ignored dependency), never a restatement of the default stanza.
- Snapshot scenarios declare policy, not facts discoverable from their fixture directories.
-## What we give up
+## Risks
Discovery scripts can become too clever. The implementation should stay boring: read manifests, filter on explicit fields, print the resolved list, and fail loud. The payoff is removing manual inventory drift, not inventing a build system.
+
+
diff --git a/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md
index c7978183ef..19dee0ed56 100644
--- a/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md
+++ b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md
@@ -28,7 +28,9 @@ Make an agent BE its session: one id. An agent's registry handle IS its `session
- The config path (`AgentLoop.create`) uses its configured `id` directly as the session id, applying whatever resume-or-create policy it adopts (today it appends a per-run uuid to avoid colliding with an on-disk log; that policy moves onto the single id, e.g. the config id IS the session and a durable backend resumes it — to be settled in the implementing PR).
- The registry's existing unique-`agentId` check becomes, by construction, a unique-session-id guarantee — the bash alias hole is closed with NO new defensive invariant: two agents cannot share a session id because the session id is the agent id.
-## Why not just enforce session-id uniqueness in `AgentRegistry.register()`?
+## Alternatives considered
+
+### Why not just enforce session-id uniqueness in `AgentRegistry.register()`?
That was the review's first suggestion. It would couple the generic registry to a session-uniqueness assumption (the registry tracks *agents*, not sessions) and entrench the very separation this RFC removes. Unifying the ids closes the hole more cleanly — there is nothing left to enforce.
diff --git a/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md b/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md
index 85121f3293..a6f932b1ed 100644
--- a/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md
+++ b/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md
@@ -16,7 +16,9 @@ Delete the method and its test; delete the three export lines and their `package
Sequencing: the in-flight surface-cache work (tool-pairing balance caching) neither uses nor touches `invalidate`, so that removal lands after or alongside it mechanically. The execute pipeline is `tools/pre-execute` → dispatch → `tools/post-execute`, and post-execute listeners receive the execution object alongside the result — nothing needs the result's own id.
-## Why not keep them?
+## Alternatives considered
+
+### Why not keep them?
A future consumer that swaps a session's log in place would want a reset primitive — it re-adds `invalidate` with itself. A replacement-loop author might want to reuse the inbox or the driver — the architecture already answers that a replacement loop is a different bundle. An isolated result-logging listener might want self-contained correlation on the result — the execution object is in scope at every listener, and a field that exists only to be ignored is worse than absent: it invites exactly the orphaned-pairing bug the loop comment warns about.
diff --git a/docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.md b/docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.md
index 6337d6b82f..2a30969ba4 100644
--- a/docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.md
+++ b/docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.md
@@ -2,8 +2,6 @@
Status: proposed
-
-
## Problem
Several loop tests synchronize with `setTimeout(30)` sleeps — flakiness debt that wastes agent cycles on retries and can mask ordering bugs. Separately, our core architectural promise (any session log replays to identical derived history) is asserted in two tests but is cheap to assert *everywhere*. And the inbox wakeup race was verified by hand exactly once; nothing re-verifies it continuously.
@@ -20,6 +18,14 @@ Three measures:
Land 1 and 2 together (they touch the same helpers); add the nightly job after the suite is sleep-free so repeats are fast.
+## Acceptance criteria
+
+- No `setTimeout` remains in `packages/*/tests` outside the allowlisted helper module, enforced by the lint rule.
+- The shared harness replays every test's session log into a fresh `Session` and asserts `deriveMessages()` equality automatically, across the whole suite.
+- The nightly job runs the agent-loop and inbox suites with `--repeat` and `--shuffle`; a flake it finds is triaged as a bug, never retried away.
+
## Risks
Fake timers interact subtly with Promise scheduling in the loop — prefer event-driven waits; reserve fake timers for timer-service behavior itself.
+
+
diff --git a/docs/rfc/proposed/testing/2026-06-11-mutation-testing.md b/docs/rfc/proposed/testing/2026-06-11-mutation-testing.md
index 209b0cdbd0..8c68ccf09f 100644
--- a/docs/rfc/proposed/testing/2026-06-11-mutation-testing.md
+++ b/docs/rfc/proposed/testing/2026-06-11-mutation-testing.md
@@ -2,8 +2,6 @@
Status: proposed
-
-
## Problem
The per-file 100% coverage gate ([the quality-gates decision](../../implemented/process/2026-06-11-quality-gates.md)) proves every line *executes* under test — not that any assertion would notice if the line were wrong. Under agent-written tests, coverage pressure can produce execution-without-assertion. Mutation testing measures what coverage cannot: whether the suite *kills* deliberately injected bugs.
@@ -23,6 +21,14 @@ Stryker (`@stryker-mutator/vitest-runner`) over `packages/*/src`:
2. Expand to all packages; record baseline scores in the config.
3. Wire the nightly job; add the incremental PR job once runtime is acceptable.
+## Acceptance criteria
+
+- A Stryker config runs over `packages/*/src` with the vitest runner; a nightly job records the mutation score, and a ratcheting threshold fails the run when the score drops below the recorded baseline.
+- PR-scoped incremental runs gate merges once runtime is acceptable — or are explicitly kept nightly-only, with that outcome recorded here.
+- Equivalent mutants carry annotated exclusions with reasons, mirroring the `/* v8 ignore */` policy.
+
## Risks
Runtime: mutation testing is expensive; per-file 100% coverage helps (every mutant is at least reached). If PR-scoped runs stay too slow, keep them nightly-only and rely on the score ratchet.
+
+
diff --git a/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.md b/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.md
index 0e791ef689..6c404a05a9 100644
--- a/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.md
+++ b/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.md
@@ -2,8 +2,6 @@
Status: rejected — the pervasive `DeepReadonly` type flip was rejected in favor of an always-on `deriveMessages` clone plus dev-mode `Object.freeze` + invariants. The immutability *goal* shipped via that alternative; see [dev-mode invariants](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md).
-
-
## Problem
The session log is append-only by contract, but `session.events` returns `readonly SessionEvent[]` whose *elements* are mutable: a plugin can reach in and rewrite history (`events[0].data.content.push(...)`), silently breaking replay equivalence and the derived-history guarantee. The same applies to derived messages and prompt assemblies passed through waterfalls — mutation is sometimes the intended idiom (waterfall middleware mutates the request) and sometimes corruption (mutating a *logged* event), and the types don't distinguish.
@@ -26,3 +24,5 @@ Introduce `DeepReadonly`, flip the session read paths, fix resulting compile err
## Risks
`DeepReadonly` types can produce noisy errors at waterfall boundaries where mutation IS the API — keep the mutable/readonly boundary exactly at "logged vs in-flight" and document it in the session README.
+
+
diff --git a/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.md b/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.md
index 7856c04b81..9746d4ac07 100644
--- a/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.md
+++ b/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.md
@@ -25,3 +25,5 @@ The shared base should contain only provider-neutral services and tools: `llm`,
## What we give up
Real demos lose one layer of convenience: each must opt into the adapter. That is the right default for examples, because adapter choice is the variable part and providerless wiring is the shared product core.
+
+
diff --git a/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.md b/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.md
index 56f20c04ef..088fa8d25d 100644
--- a/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.md
+++ b/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.md
@@ -30,3 +30,5 @@ The canonical user session no longer reconstructs the exact token stream of an o
## Related
This supersedes the chunk-persistence choice in [session persistence](../../implemented/architecture/2026-06-14-session-persistence.md) and affects [ACP snapshot tests](../../implemented/testing/2026-06-19-acp-snapshot-tests.md), whose current replay plugin derives its script from `assistant/chunk` events.
+
+
diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.md b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.md
index f6cc3a3236..18cd6e981d 100644
--- a/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.md
+++ b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.md
@@ -23,3 +23,5 @@ For now, ACP starts fresh sessions only. `initialize` advertises `loadSession: f
## What we give up
An editor cannot reopen a prior persisted session through ACP. That is a real product feature, but the current implementation is ahead of the UX and ties the bridge to token-level log replay. Keeping persistence while dropping editor load narrows the bridge to the workflow it can currently present cleanly.
+
+
diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.md b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.md
index 4b7ca91a0c..2f1408dc4c 100644
--- a/docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.md
+++ b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.md
@@ -25,3 +25,5 @@ This proposal is narrower than [collapsing tool-owned UI presentation](2026-06-2
## What we give up
Zed users lose the dedicated terminal card: no cwd header, terminal display, or exit pill. They still see the command and output as plain content. That is a reasonable simplification while the ACP bridge is still unreleased and the `_meta` keys are a convention rather than a standard.
+
+
diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.md b/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.md
index 8f971bb15a..0a13d90f1b 100644
--- a/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.md
+++ b/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.md
@@ -25,3 +25,5 @@ This proposal can land independently of [a generic long-running tool runtime](..
## What we give up
A model or user cannot recover the omitted prefix of a huge command output from a temp file. That is acceptable until there is a real artifact service. The current spill path is too much bespoke machinery for a feature whose lifecycle and permissions are not designed.
+
+
diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.md b/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.md
index 833fae5bc6..94313fd0ad 100644
--- a/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.md
+++ b/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.md
@@ -26,3 +26,5 @@ The invariants plugin should enforce that step-scoped events have valid positive
## What we give up
The log no longer records "a model request started but produced no event before the process died" as a durable fact, and no longer has an explicit "this step completed" marker. That loss is not acceptable while the session log is the durable replay and audit surface.
+
+
diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.md b/docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.md
index f32b0f3461..fc06cc76c9 100644
--- a/docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.md
+++ b/docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.md
@@ -25,3 +25,5 @@ If lineage returns, decide then whether it belongs in the immutable header, a se
## What we give up
The codebase loses a ready-made lineage hook for future fork/sub-agent UX. That is intentional. The field is easy to reintroduce when the feature exists, and the unreleased stance lets the format change without migrations.
+
+
diff --git a/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.md b/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.md
index 229e2810fc..a59c992b0d 100644
--- a/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.md
+++ b/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.md
@@ -25,3 +25,5 @@ The implementing PR should update the [capability seams](../../implemented/archi
## What we give up
`dsh-session` becomes heavier: it owns both the in-memory log and the persistence interface. That is the trade. If third-party persistence backends were already a public ecosystem, the separate interface package would be a cleaner SDK boundary; pre-release, the extra package looks like abstraction before there is an external consumer.
+
+
diff --git a/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.md b/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.md
index 5b80920a6a..6125feaa8b 100644
--- a/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.md
+++ b/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.md
@@ -12,6 +12,8 @@ The real first-party use is bash presentation for ACP. That is too little eviden
Remove tool-owned UI presentation callbacks for now. The canonical tool events already carry the tool name, raw argument string, result content, and error state. UIs render a generic tool card from those fields. Tool-specific rich rendering can return later as a tagged render-intent union after there are at least two real tools and two real consumers to validate the vocabulary.
+## Alternatives considered
+
As a smaller alternative, replace the current optional-field bag with one explicit union in a single PR; but if the goal is simplification, the stronger move is to delete the callbacks and keep the generic path.
## Acceptance criteria
diff --git a/docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.md b/docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.md
index 97c7d5dc61..fd1a4687b3 100644
--- a/docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.md
+++ b/docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.md
@@ -31,3 +31,5 @@ A user cannot add same-turn steering content while a model is between tool steps
## Related
This pairs naturally with [dropping durable step boundaries](2026-06-20-drop-durable-step-boundaries.md), because removing same-turn steering and `agent/turn-continuation` leaves tool calls as the only reason a turn contains multiple model steps.
+
+
diff --git a/docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.md b/docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.md
index b2a2c3b84f..e68cd24346 100644
--- a/docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.md
+++ b/docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.md
@@ -25,3 +25,5 @@ Remove the multi-session maps and demux where a single `SessionRecord | undefine
## What we give up
An ACP client cannot host several concurrent conversations on one server process. That is a meaningful capability cut. The simpler model is still reasonable for an unreleased harness: one editor conversation maps to one agent process, and cross-session permission/background-task isolation stops being a live correctness burden.
+
+
diff --git a/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.md b/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.md
index 90e2b3f7a4..1ed26c66f8 100644
--- a/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.md
+++ b/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.md
@@ -30,3 +30,5 @@ A crash can lose real work from the final turn: assistant text, tool calls, and
## Related
This is a direct simplification of [session persistence](../../implemented/architecture/2026-06-14-session-persistence.md) and [turn enclosure](../../implemented/architecture/2026-06-15-turn-enclosure-invariant.md). It also removes much of the motivation for durable step boundary events, making [drop durable step boundary events](2026-06-20-drop-durable-step-boundaries.md) smaller.
+
+
diff --git a/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md b/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md
index 9eaa1a5a8d..1af873731c 100644
--- a/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md
+++ b/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md
@@ -21,7 +21,9 @@ Adjacent surface examined and deliberately left alone: `SubagentService.getProvi
This is the seam-vocabulary echo of [prune dead methods from the persistence seam](../../implemented/simplification/2026-06-20-prune-dead-seam-methods.md): members every implementation must declare for nobody — weaker even, since here zero implementations exist.
-## Why not keep it?
+## Alternatives considered
+
+### Why not keep it?
The two-kinds-of-capability design is the seam RFC's headline, and re-adding `outputSchema` later touches several files. But the design survives with `depthLimit` as its live example and the RFCs as its record, and the seam RFC itself concedes the shipped `toolFilter` shape is wrong (real enforcement needs a `tools/pre-execute` deny in the child's context, not schema filtering) — that deny primitive exists on the interception seams, so re-adding against a real implementing provider will pin a better contract than the current speculative one.
diff --git a/package.json b/package.json
index 8a0218f1e7..7453a022e9 100644
--- a/package.json
+++ b/package.json
@@ -31,6 +31,7 @@
"verify-package-paths": "tsx scripts/verify-package-paths.ts",
"verify-mermaid": "tsx scripts/verify-mermaid.ts",
"verify-rfc-classification": "tsx scripts/verify-rfc-classification.ts",
+ "verify-rfc-format": "tsx scripts/verify-rfc-format.ts",
"verify-type-equiv": "tsx scripts/verify-type-equiv.ts",
"verify-translation-pairing": "tsx scripts/verify-translation-pairing.ts",
"verify-doc-budgets": "tsx scripts/verify-doc-budgets.ts",
@@ -47,7 +48,7 @@
"gen-module-graph": "tsx scripts/gen-module-graph.ts",
"verify-module-graph": "tsx scripts/gen-module-graph.ts --check",
"constraints": "tsx scripts/check-workspace-constraints.ts",
- "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-tool-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-mermaid && pnpm run verify-rfc-classification && pnpm run verify-type-equiv && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets",
+ "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-tool-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-mermaid && pnpm run verify-rfc-classification && pnpm run verify-rfc-format && pnpm run verify-type-equiv && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets",
"hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types",
"demo:echo": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/echo-agent/cordis.yml",
"demo:repl": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/coding-agent/cordis.yml",
diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md
index d7bce1d3a7..26524fad24 100644
--- a/packages/fs/fs-local/README.md
+++ b/packages/fs/fs-local/README.md
@@ -21,6 +21,6 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
## `cwd` is not a sandbox
-`config.cwd` is a resolution default, not a containment boundary — absolute paths and `..` escape it. Enforce containment with a stricter `ctx.fs` backend or a permission plugin on the `tools/execute` waterfall. See [the filesystem capability-seam RFC's Risks section](../../../docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md#risks).
+`config.cwd` is a resolution default, not a containment boundary — absolute paths and `..` escape it. Enforce containment with a stricter `ctx.fs` backend or a permission plugin on the `tools/execute` waterfall. See [the filesystem capability-seam RFC's Consequences section](../../../docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md#consequences).
The raw I/O lives in `src/fsio.ts` (Cordis-free, independently unit-tested); `src/index.ts` is the thin service wiring.
diff --git a/scripts/gen-rfc-index.ts b/scripts/gen-rfc-index.ts
index ad7aeb6588..37ad96d73a 100644
--- a/scripts/gen-rfc-index.ts
+++ b/scripts/gen-rfc-index.ts
@@ -1,16 +1,17 @@
/**
- * Regenerate the RFC index tables in `docs/rfc/README.md` from the RFC tree
- * (see [rfc-index.ts](./rfc-index.ts) for the layout contract and rendering
- * rules). Rewrites ONLY the marker-delimited regions; the curated prose is
- * untouched. Freshness is asserted by `verify-rfc-classification.ts` (a
- * `doc-sync` member), so a stale committed index fails CI.
+ * Regenerate `docs/rfc/INDEX.md` — the fully generated RFC index — from the
+ * RFC tree (see [rfc-index.ts](./rfc-index.ts) for the layout contract and
+ * rendering rules). The whole file is generated state; the curated prose lives
+ * in `docs/rfc/README.md`. Freshness is asserted by
+ * `verify-rfc-classification.ts` (a `doc-sync` member), so a stale committed
+ * index fails CI.
*
* Run: `pnpm run gen-rfc-index`.
*/
import { readFileSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
-import { rfcRoot, spliceReadme, walkRfcTree } from './rfc-index.ts'
+import { renderIndex, rfcRoot, walkRfcTree } from './rfc-index.ts'
const { rfcs, errors } = walkRfcTree()
if (errors.length > 0) {
@@ -19,12 +20,17 @@ if (errors.length > 0) {
process.exit(1)
}
-const readmePath = resolve(rfcRoot, 'README.md')
-const readme = readFileSync(readmePath, 'utf8')
-const next = spliceReadme(readme, rfcs)
-if (next === readme) {
- console.log(`gen-rfc-index: docs/rfc/README.md is up to date (${rfcs.length} RFCs).`)
-} else {
- writeFileSync(readmePath, next)
- console.log(`gen-rfc-index: docs/rfc/README.md regenerated (${rfcs.length} RFCs).`)
+const indexPath = resolve(rfcRoot, 'INDEX.md')
+const next = renderIndex(rfcs)
+let current: string | undefined
+try {
+ current = readFileSync(indexPath, 'utf8')
+} catch {
+ // Missing INDEX.md is the fresh-generation case, not an error: fall through and write it.
+}
+if (next === current) {
+ console.log(`gen-rfc-index: docs/rfc/INDEX.md is up to date (${rfcs.length} RFCs).`)
+} else {
+ writeFileSync(indexPath, next)
+ console.log(`gen-rfc-index: docs/rfc/INDEX.md regenerated (${rfcs.length} RFCs).`)
}
diff --git a/scripts/rfc-index.ts b/scripts/rfc-index.ts
index e14eea5c59..f8cb1d763b 100644
--- a/scripts/rfc-index.ts
+++ b/scripts/rfc-index.ts
@@ -9,11 +9,11 @@
* folder IS the label, and both sets are CLOSED — extending either means
* amending this module AND the README's Classification prose.
*
- * The README's per-lifecycle tables are GENERATED between marker comments
- * (`` … `end`): section headings and
- * rows are derived from each RFC's path (lifecycle/class), H1 (title, with an
- * optional `RFC: ` prefix stripped), and filename date, sorted by date then
- * filename. Prose outside the markers is curated by hand and never touched.
+ * The index (`docs/rfc/INDEX.md`) is GENERATED in full: per-lifecycle sections
+ * whose rows are derived from each RFC's path (lifecycle/class), H1 (title,
+ * with an optional `RFC: ` prefix stripped), and filename date, sorted by date
+ * then filename. The curated prose lives in README.md, which carries no index
+ * rows at all.
*/
import { readFileSync, readdirSync } from 'node:fs'
@@ -101,14 +101,8 @@ export function walkRfcTree(): { rfcs: Rfc[]; errors: string[] } {
return { rfcs, errors }
}
-/** The begin/end marker lines that delimit one lifecycle's generated region. */
-const markers = (lifecycle: string): { begin: string; end: string } => ({
- begin: ``,
- end: ``,
-})
-
/**
- * Render one lifecycle's generated region body: a `### {Class}` heading plus a
+ * Render one lifecycle's section body: a `### {Class}` heading plus a
* `| Title | First proposed |` table for every non-empty class, in CLASSES
* order, rows sorted by date then filename.
*/
@@ -126,48 +120,21 @@ function renderLifecycle(rfcs: Rfc[], lifecycle: string): string {
}
/**
- * Splice freshly rendered regions into the README text. Throws when a marker
- * pair is missing, duplicated, or out of order, when a region does not sit
- * under its own `## {Lifecycle}` heading, or when an index-shaped table row
- * (a `| [title](lifecycle/…)` line) appears OUTSIDE the generated regions —
- * the markers are part of the curated prose, the heading above each region is
- * the one its lifecycle names, and index rows live only inside the regions
- * (prose links to RFCs remain fine anywhere).
+ * Render the complete `docs/rfc/INDEX.md` content: a generated-file banner
+ * followed by one `## {Lifecycle}` section per lifecycle in canonical order.
+ * The whole file is generated state — there is no curated region to preserve.
*/
-export function spliceReadme(readme: string, rfcs: Rfc[]): string {
- let out = readme
- const regions: Array<{ from: number; to: number }> = []
+export function renderIndex(rfcs: Rfc[]): string {
+ const parts = [
+ '# RFC index',
+ '',
+ 'Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; `verify-rfc-classification` fails when this file is stale. The curated front door — layout, classification, when to write one, and the in-file format — is [README.md](README.md).',
+ ]
for (const lifecycle of LIFECYCLES) {
- const { begin, end } = markers(lifecycle)
- const beginAt = out.indexOf(begin)
- const endAt = out.indexOf(end)
- if (beginAt === -1 || endAt === -1 || endAt < beginAt) {
- throw new Error(`README.md is missing the ${JSON.stringify(begin)} … ${JSON.stringify(end)} marker pair`)
- }
- if (out.indexOf(begin, beginAt + 1) !== -1 || out.indexOf(end, endAt + 1) !== -1) {
- throw new Error(`README.md has a duplicated ${lifecycle} index marker`)
- }
- // The region must sit directly under its own lifecycle heading: the last
- // H2 above the begin marker is `## {Heading(lifecycle)}`, or the heading
- // itself has drifted while the generated table stayed put.
- const before = out.slice(0, beginAt)
- const lastH2 = [...before.matchAll(/^##\s+(.+?)\s*$/gm)].at(-1)?.[1]
- if (lastH2 !== heading(lifecycle)) {
- throw new Error(`README.md: the ${lifecycle} index region is not under a "## ${heading(lifecycle)}" heading (found "## ${lastH2 ?? ''}")`)
- }
- out = `${out.slice(0, beginAt + begin.length)}\n${renderLifecycle(rfcs, lifecycle)}\n${out.slice(endAt)}`
- regions.push({ from: out.indexOf(begin), to: out.indexOf(markers(lifecycle).end) + markers(lifecycle).end.length })
+ parts.push('', `## ${heading(lifecycle)}`, '', renderLifecycle(rfcs, lifecycle))
}
- // Index rows are generated state: a table row linking into a lifecycle
- // folder anywhere OUTSIDE the regions is a hand-added index entry the
- // generator would never reconcile.
- let offset = 0
- for (const line of out.split('\n')) {
- const inRegion = regions.some(r => offset >= r.from && offset < r.to)
- if (!inRegion && /^\|\s*\[[^\]]+\]\((?:proposed|implemented|rejected)\//.test(line)) {
- throw new Error(`README.md: index-shaped row outside the generated regions: ${JSON.stringify(line.slice(0, 80))}`)
- }
- offset += line.length + 1
- }
- return out
+ return `${parts.join('\n')}\n`
}
+
+/** Matches an index-shaped table row (a `| [title](lifecycle/…) |` line) — generated state that must not appear in curated prose. */
+export const INDEX_ROW = /^\|\s*\[[^\]]+\]\((?:proposed|implemented|rejected)\//
diff --git a/scripts/verify-rfc-classification.ts b/scripts/verify-rfc-classification.ts
index b6f1ed2eb5..2e270c17f8 100644
--- a/scripts/verify-rfc-classification.ts
+++ b/scripts/verify-rfc-classification.ts
@@ -1,13 +1,13 @@
/**
* Doc-sync gate: enforce the RFC classification scheme
* ([the classification RFC](../docs/rfc/implemented/process/2026-06-20-rfc-classification.md))
- * and the freshness of the generated index tables
+ * and the freshness of the generated index
* ([the index-generation RFC](../docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.md)).
* Every RFC is filed at `docs/rfc/{lifecycle}/{class}/yyyy-mm-dd-topic.md`; the
* folder IS the label. This gate is the machine source of truth for the closed
- * class set and keeps the README index honest.
+ * class set and keeps the generated index honest.
*
- * Two checks (both against [rfc-index.ts](./rfc-index.ts), the shared walker
+ * Three checks (all against [rfc-index.ts](./rfc-index.ts), the shared walker
* and renderer):
*
* 1. STRUCTURE — every `.md` under a lifecycle folder lives in a class folder
@@ -17,31 +17,38 @@
* file at an unexpected depth fails. This is what makes the set CLOSED: a
* new class folder can't appear without amending CLASSES (and the README's
* Classification section, per the RFC).
- *
- * 2. FRESHNESS — the marker-delimited index regions in `docs/rfc/README.md`
- * byte-match a fresh render from the tree, so every RFC is listed exactly
- * once, under the heading matching its path, with its H1 title and filename
- * date. The fix for a stale index is `pnpm run gen-rfc-index`, never a hand
- * edit. This is checker, not fixer: it reports and never rewrites.
+ * 2. FRESHNESS — the committed `docs/rfc/INDEX.md` byte-matches a fresh render
+ * from the tree, so every RFC is listed exactly once, under the heading
+ * matching its path, with its H1 title and filename date. The fix for a
+ * stale index is `pnpm run gen-rfc-index`, never a hand edit. This is
+ * checker, not fixer: it reports and never rewrites.
+ * 3. NO STRAY ROWS — `docs/rfc/README.md` (the curated front door) carries no
+ * index-shaped table rows; the list lives only in the generated INDEX.md.
*
* Run: `tsx scripts/verify-rfc-classification.ts`.
*/
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
-import { rfcRoot, spliceReadme, walkRfcTree } from './rfc-index.ts'
+import { INDEX_ROW, renderIndex, rfcRoot, walkRfcTree } from './rfc-index.ts'
const { rfcs, errors } = walkRfcTree()
-const readmePath = resolve(rfcRoot, 'README.md')
-const readme = readFileSync(readmePath, 'utf8')
if (errors.length === 0) {
+ let index: string | undefined
try {
- if (spliceReadme(readme, rfcs) !== readme) {
- errors.push('index: docs/rfc/README.md is stale — run `pnpm run gen-rfc-index` and commit the result')
+ index = readFileSync(resolve(rfcRoot, 'INDEX.md'), 'utf8')
+ } catch {
+ // A missing INDEX.md is reported below as staleness, exactly like a drifted one.
+ }
+ if (renderIndex(rfcs) !== index) {
+ errors.push('index: docs/rfc/INDEX.md is stale or missing — run `pnpm run gen-rfc-index` and commit the result')
+ }
+ const readme = readFileSync(resolve(rfcRoot, 'README.md'), 'utf8')
+ for (const line of readme.split('\n')) {
+ if (INDEX_ROW.test(line)) {
+ errors.push(`readme: index-shaped row in the curated README (the list lives in INDEX.md): ${JSON.stringify(line.slice(0, 80))}`)
}
- } catch (error) {
- errors.push(`index: ${error instanceof Error ? error.message : String(error)}`)
}
}
diff --git a/scripts/verify-rfc-format.ts b/scripts/verify-rfc-format.ts
new file mode 100644
index 0000000000..1b22f349b3
--- /dev/null
+++ b/scripts/verify-rfc-format.ts
@@ -0,0 +1,106 @@
+/**
+ * Doc-sync gate: enforce the RFC in-file format
+ * ([README.md § The file format](../docs/rfc/README.md), the contract; rationale in
+ * [the uniform-format RFC](../docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md)).
+ * The classification gate owns WHERE a file sits and how it is named; this gate
+ * owns what is INSIDE: the header block, the per-lifecycle body skeleton, and
+ * the Alternatives-considered mandate.
+ *
+ * Per English RFC (`.zh.md` counterparts are the pairing gate's concern):
+ *
+ * 1. HEADER — line 1 is `# RFC: `, line 2 blank, line 3 the one
+ * `Status:` line in the file, line 4 blank. The status is the dateless enum
+ * matching the lifecycle folder: `Status: proposed`, `Status: implemented`,
+ * or `Status: rejected — `.
+ * 2. SKELETON — the first `##` section is `## Problem`; the lifecycle's
+ * required sections are present under their canonical names (`proposed/`:
+ * Proposal, Acceptance criteria, Risks; `implemented/`: Decision,
+ * Consequences; `rejected/`: Proposal); `implemented/` must not carry the
+ * proposal-era headings (Proposal, Plan, Migration plan, Acceptance
+ * criteria) that the docs standard's slop checklist outlaws there.
+ * 3. ALTERNATIVES — `## Alternatives considered` is present, or the file is a
+ * pre-format RFC (dated before the format landed) carrying the exact
+ * grandfather comment instead. Carrying both, or grandfathering a
+ * post-format RFC, fails.
+ * 4. DEBT MARKER — the retired legacy-format debt comment may not reappear.
+ *
+ * Checker, not fixer: it reports and never rewrites.
+ * Run: `tsx scripts/verify-rfc-format.ts`.
+ */
+
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { rfcRoot, walkRfcTree } from './rfc-index.ts'
+
+/** The date the format contract landed; the grandfather comment is valid only before it. */
+const FORMAT_ADOPTED = '2026-07-05'
+
+/** The exact comment a pre-format RFC carries in place of `## Alternatives considered`. */
+const GRANDFATHER = ''
+
+/** The retired debt marker that flagged pre-format bodies; banned so it cannot creep back. */
+const LEGACY_MARKER = 'XXX: legacy ADR/RFC body format'
+
+/** Status-line grammar per lifecycle folder. */
+const STATUS: Record = {
+ proposed: /^Status: proposed$/,
+ implemented: /^Status: implemented$/,
+ rejected: /^Status: rejected — .+$/,
+}
+
+/** Required `##` headings per lifecycle, beyond the universal `## Problem` opener. */
+const REQUIRED: Record = {
+ proposed: ['## Proposal', '## Acceptance criteria', '## Risks'],
+ implemented: ['## Decision', '## Consequences'],
+ rejected: ['## Proposal'],
+}
+
+/** Headings banned in `implemented/` — proposal-era spec-speak per the slop checklist. */
+const BANNED_IMPLEMENTED = /^## (?:Proposal\b|Plan\b|Migration plan\b|Acceptance criteria\b)/i
+
+const { rfcs, errors } = walkRfcTree()
+
+for (const rfc of rfcs) {
+ const fail = (msg: string): void => {
+ errors.push(`format: ${rfc.rel} — ${msg}`)
+ }
+ const lines = readFileSync(resolve(rfcRoot, rfc.rel), 'utf8').split('\n')
+
+ if (!/^# RFC: \S/.test(lines[0] ?? '')) fail('line 1 must be `# RFC: `')
+ if (lines[1] !== '') fail('line 2 must be blank')
+ const status = STATUS[rfc.lifecycle]
+ if (status !== undefined && !status.test(lines[2] ?? '')) {
+ fail(`line 3 must match the ${rfc.lifecycle} status grammar (${String(status)})`)
+ }
+ if (lines[3] !== '') fail('line 4 must be blank')
+ const statusLines = lines.filter((l, i) => i !== 2 && l.startsWith('Status:'))
+ if (statusLines.length > 0) fail('the line-3 `Status:` line must be the only one in the file')
+
+ const h2s = lines.filter(l => l.startsWith('## ')).map(l => l.trimEnd())
+ if (h2s[0] !== '## Problem') fail(`the first section must be \`## Problem\` (got ${JSON.stringify(h2s[0] ?? '')})`)
+ for (const required of REQUIRED[rfc.lifecycle] ?? []) {
+ if (!h2s.includes(required)) fail(`missing the required \`${required}\` section`)
+ }
+ if (rfc.lifecycle === 'implemented') {
+ for (const h2 of h2s.filter(h => BANNED_IMPLEMENTED.test(h))) {
+ fail(`\`${h2}\` is a proposal-era heading; an implemented RFC states what is (fold it into Decision/Consequences/Testing)`)
+ }
+ }
+
+ const hasSection = h2s.includes('## Alternatives considered')
+ const hasGrandfather = lines.includes(GRANDFATHER)
+ if (hasSection && hasGrandfather) fail('carries both `## Alternatives considered` and the grandfather comment — drop the comment')
+ if (!hasSection && !hasGrandfather) fail('missing `## Alternatives considered` (a pre-format RFC whose alternatives are not reconstructible carries the grandfather comment instead — see docs/rfc/README.md § The file format)')
+ if (hasGrandfather && rfc.date >= FORMAT_ADOPTED) fail(`the grandfather comment is only valid for RFCs dated before ${FORMAT_ADOPTED}`)
+
+ if (lines.some(l => l.includes(LEGACY_MARKER))) fail('carries the retired legacy-format debt marker')
+}
+
+if (errors.length === 0) {
+ console.log(`verify-rfc-format: ${rfcs.length} RFC(s) checked, all conform to docs/rfc/README.md § The file format.`)
+ process.exit(0)
+}
+
+console.error('verify-rfc-format: violations found:')
+for (const e of errors) console.error(` ${e}`)
+process.exit(1)
From 0e29de459e7f1db998eb2c68640ffc1afd779cc3 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Sun, 5 Jul 2026 23:45:34 +0800
Subject: [PATCH 2/3] =?UTF-8?q?docs(rfc):=20address=20Codex=20review=20?=
=?UTF-8?q?=E2=80=94=20fence-aware=20format=20gate,=20exact=20corpus=20cou?=
=?UTF-8?q?nts?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two findings from the pre-ready review, both verified:
- verify-rfc-format scanned raw lines, so an RFC quoting a Status line,
a banned heading, or the grandfather comment inside a fenced example
would false-positive. The content scans (duplicate Status, H2 headings,
banned headings, grandfather, legacy marker) now ignore fenced blocks;
the positional header-block checks stay raw. Verified: a fenced
'Status: implemented' + '## Plan' + grandfather quote inside a walked
RFC no longer trips the gate.
- The companion RFC's pre-format corpus counts were imprecise: 27
distinct Status spellings (reasons collapsed), not 'some fifteen';
nineteen implemented files carrying thirty proposal-era heading
occurrences, not 'over twenty files'; three English files (plus one zh
counterpart) with no status, not four.
---
.../process/2026-07-05-uniform-rfc-format.md | 4 ++--
scripts/verify-rfc-format.ts | 23 +++++++++++++++----
2 files changed, 20 insertions(+), 7 deletions(-)
diff --git a/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md b/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md
index f8da02905f..71b720eacc 100644
--- a/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md
+++ b/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md
@@ -4,7 +4,7 @@ Status: implemented
## Problem
-The tree's layout is uniform — [the classification scheme](2026-06-20-rfc-classification.md) path-encodes lifecycle and class and gates both — but the file insides never were. The corpus the format decision faced had two H1 spellings; some fifteen `Status:` line spellings, from bare enums through dated parentheticals duplicating what the filename and git already carry, to four files with no status at all; two body genres side by side (ADR-style `Context`/`Decision`/`Consequences` beside proposal-style `Problem`/`Proposal`/`Risks`), so every new RFC guessed its shape from whichever neighbor its author opened; thirty-nine files carrying a debt comment that flagged them as "legacy ADR/RFC body format" awaiting a unified template that was never actually defined; and over twenty implemented RFCs still carrying the proposal-era headings (`Acceptance criteria`, `Plan`, `Migration plan`) that the [documentation standard's slop checklist](../../../AGENTS.md) outlaws for `implemented/` — outlawed, but enforced by nothing, so the `proposed/` → `implemented/` move could silently skip the rewrite [implemented/AGENTS.md](../AGENTS.md) requires.
+The tree's layout is uniform — [the classification scheme](2026-06-20-rfc-classification.md) path-encodes lifecycle and class and gates both — but the file insides never were. The corpus the format decision faced had two H1 spellings; some twenty-seven `Status:` line spellings once free-text rejection reasons are collapsed — bare enums, dated parentheticals duplicating what the filename and git already carry — plus three English files (and the zh counterpart of one of them) with no status at all; two body genres side by side (ADR-style `Context`/`Decision`/`Consequences` beside proposal-style `Problem`/`Proposal`/`Risks`), so every new RFC guessed its shape from whichever neighbor its author opened; thirty-nine files carrying a debt comment that flagged them as "legacy ADR/RFC body format" awaiting a unified template that was never actually defined; and nineteen implemented RFCs still carrying thirty occurrences of the proposal-era headings (`Acceptance criteria`, `Plan`, `Migration plan`, `Proposal`) that the [documentation standard's slop checklist](../../../AGENTS.md) outlaws for `implemented/` — outlawed, but enforced by nothing, so the `proposed/` → `implemented/` move could silently skip the rewrite [implemented/AGENTS.md](../AGENTS.md) requires.
## Decision
@@ -20,7 +20,7 @@ The whole corpus was normalized in the same change that defined the format — t
- **Dated status** (`Status: implemented (accepted YYYY-MM-DD)`) — rejected: the acceptance date is narrated history the writing rules keep out of docs; the filename carries first-proposed, git carries the rest, and the gate could check a date's format but never its truth.
- **A bare `# ` H1** — rejected: the `RFC: ` prefix is the corpus-majority form and self-describes the genre when a file is read outside its tree; the index generator strips it, so index rows are identical either way.
- **`## What we give up` as the implemented closer** (the README's own phrase for what an RFC records) — rejected: it names only costs, and an honest consequences section records what the trade-off bought as well.
-- **Convention without a gate** (write the contract down, enforce by review) — rejected: the slop checklist already outlawed spec-speak in `implemented/` by convention, and over twenty files show what convention alone achieves here.
+- **Convention without a gate** (write the contract down, enforce by review) — rejected: the slop checklist already outlawed spec-speak in `implemented/` by convention, and nineteen files show what convention alone achieves here.
- **A standalone `FORMAT.md` contract file** — the first landed home; folded into README.md once the generated index moved out to [INDEX.md](../../INDEX.md): with the tables gone the README regained the room, and one front door carrying layout, classification, and format beats splitting the contract across two files.
## Consequences
diff --git a/scripts/verify-rfc-format.ts b/scripts/verify-rfc-format.ts
index 1b22f349b3..99347e4764 100644
--- a/scripts/verify-rfc-format.ts
+++ b/scripts/verify-rfc-format.ts
@@ -65,6 +65,17 @@ for (const rfc of rfcs) {
errors.push(`format: ${rfc.rel} — ${msg}`)
}
const lines = readFileSync(resolve(rfcRoot, rfc.rel), 'utf8').split('\n')
+ // Content scans ignore fenced code blocks: an RFC may legitimately QUOTE a
+ // status line, a banned heading, or the grandfather comment inside a fence
+ // (the README's own format section does), and only real prose counts.
+ let inFence = false
+ const prose = lines.filter((l) => {
+ if (l.startsWith('```')) {
+ inFence = !inFence
+ return false
+ }
+ return !inFence
+ })
if (!/^# RFC: \S/.test(lines[0] ?? '')) fail('line 1 must be `# RFC: `')
if (lines[1] !== '') fail('line 2 must be blank')
@@ -73,10 +84,12 @@ for (const rfc of rfcs) {
fail(`line 3 must match the ${rfc.lifecycle} status grammar (${String(status)})`)
}
if (lines[3] !== '') fail('line 4 must be blank')
- const statusLines = lines.filter((l, i) => i !== 2 && l.startsWith('Status:'))
- if (statusLines.length > 0) fail('the line-3 `Status:` line must be the only one in the file')
+ const statusLines = prose.filter(l => l.startsWith('Status:') && l !== lines[2])
+ if (statusLines.length > 0 || prose.filter(l => l === lines[2]).length > 1) {
+ fail('the line-3 `Status:` line must be the only one in the file')
+ }
- const h2s = lines.filter(l => l.startsWith('## ')).map(l => l.trimEnd())
+ const h2s = prose.filter(l => l.startsWith('## ')).map(l => l.trimEnd())
if (h2s[0] !== '## Problem') fail(`the first section must be \`## Problem\` (got ${JSON.stringify(h2s[0] ?? '')})`)
for (const required of REQUIRED[rfc.lifecycle] ?? []) {
if (!h2s.includes(required)) fail(`missing the required \`${required}\` section`)
@@ -88,12 +101,12 @@ for (const rfc of rfcs) {
}
const hasSection = h2s.includes('## Alternatives considered')
- const hasGrandfather = lines.includes(GRANDFATHER)
+ const hasGrandfather = prose.includes(GRANDFATHER)
if (hasSection && hasGrandfather) fail('carries both `## Alternatives considered` and the grandfather comment — drop the comment')
if (!hasSection && !hasGrandfather) fail('missing `## Alternatives considered` (a pre-format RFC whose alternatives are not reconstructible carries the grandfather comment instead — see docs/rfc/README.md § The file format)')
if (hasGrandfather && rfc.date >= FORMAT_ADOPTED) fail(`the grandfather comment is only valid for RFCs dated before ${FORMAT_ADOPTED}`)
- if (lines.some(l => l.includes(LEGACY_MARKER))) fail('carries the retired legacy-format debt marker')
+ if (prose.some(l => l.includes(LEGACY_MARKER))) fail('carries the retired legacy-format debt marker')
}
if (errors.length === 0) {
From b38d751e4229ba41763ec7df43d68ca9222303fd Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Sun, 5 Jul 2026 23:56:11 +0800
Subject: [PATCH 3/3] docs(rfc): align the no-Status-line alternative's file
count with the measured one
---
docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md b/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md
index 71b720eacc..696d46cb20 100644
--- a/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md
+++ b/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md
@@ -16,7 +16,7 @@ The whole corpus was normalized in the same change that defined the format — t
- **A full rigid template** (one fixed section sequence per lifecycle, every RFC restructured to fit) — rejected: the big design RFCs carry eight to fifteen bespoke technical sections (package topology, wire contracts, schemas) that are load-bearing content, not drift; a rigid sequence would force destructive rewrites now and template-fighting forever.
- **Header-only normalization** (H1 and Status, bodies untouched) — rejected: the debt markers flagged the *body* genre split, and leaving `Context`/`Decision` beside `Problem`/`Proposal` indefinitely resolves nothing.
-- **No Status line** (the folder already is the status; the four newest pre-format RFCs omitted the line) — rejected in favor of keeping a self-describing file: the drift risk that motivated dropping it is neutralized by gating the line against the folder instead.
+- **No Status line** (the folder already is the status; the three newest pre-format RFCs (and the zh counterpart of one) omitted the line) — rejected in favor of keeping a self-describing file: the drift risk that motivated dropping it is neutralized by gating the line against the folder instead.
- **Dated status** (`Status: implemented (accepted YYYY-MM-DD)`) — rejected: the acceptance date is narrated history the writing rules keep out of docs; the filename carries first-proposed, git carries the rest, and the gate could check a date's format but never its truth.
- **A bare `# ` H1** — rejected: the `RFC: ` prefix is the corpus-majority form and self-describes the genre when a file is read outside its tree; the index generator strips it, so index rows are identical either way.
- **`## What we give up` as the implemented closer** (the README's own phrase for what an RFC records) — rejected: it names only costs, and an honest consequences section records what the trade-off bought as well.