From cc24e79cd2b0650880886143a5bf6abdb5840d88 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Thu, 9 Jul 2026 03:01:11 +0800
Subject: [PATCH] docs: agent-scope RFC, CONTEXT.md glossary, architecture
scope section, README sync
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The agent-scope-contexts RFC (implemented) records the decision tree:
the dsh-scope primitive over cordis extend/Context.filter/no-op fibers,
two-level flat scope with shadowing, restriction/grant semantics, the
scoped-dispatch rule with fused helpers, the setup window, and the
alternatives (explicit scope params, isolate, event-filtering-only,
vendored support) with why each lost. CONTEXT.md pins the glossary.
architecture.md gains the Agent Scope section, the dsh-scope spine row,
the scoped turn-flow line, and an extension-table row (ceiling 1640→1790:
the two-layer registration model is a new architectural axis; additions
are condensed to pointers). READMEs of every touched package re-state
their scoped facts; the stale structured-runtime README section is
replaced by the scoped-registration description.
---
CONTEXT.md | 15 ++++++++
docs/architecture.md | 8 ++++-
docs/rfc/INDEX.md | 1 +
.../2026-07-08-agent-scope-contexts.md | 35 +++++++++++++++++++
packages/core/README.md | 3 ++
packages/core/agent-loop/README.md | 2 ++
packages/core/agent/README.md | 2 ++
packages/core/session/README.md | 3 +-
packages/core/system-prompt/README.md | 12 +++----
packages/core/tools/README.md | 13 ++++---
.../subagent/subagent-inprocess/README.md | 16 ++++-----
packages/subagent/subagent/README.md | 2 +-
scripts/doc-budgets.manifest.json | 2 +-
13 files changed, 90 insertions(+), 24 deletions(-)
create mode 100644 CONTEXT.md
create mode 100644 docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md
diff --git a/CONTEXT.md b/CONTEXT.md
new file mode 100644
index 0000000000..7fd11ae59e
--- /dev/null
+++ b/CONTEXT.md
@@ -0,0 +1,15 @@
+# Context glossary
+
+Domain vocabulary for the DeepSeek Harness SDK — one canonical term per concept. Terms link with `[[name]]`; implementation detail stays in the package READMEs and RFCs.
+
+## agent-scope
+
+- **scope** — the unit of per-agent registration: a contribution (tool, prompt section, variable, restriction, listener) is either *global* (visible to every agent) or *scoped* (owned by exactly one [[scope-key]]). Two levels, flat: nothing inherits down to subagents; subtree behavior is expressed with [[lineage]] data, never structure.
+- **scope key** — the opaque identity a scope is keyed by, compared by object identity. The harness convention: a live agent is the key of its own scope.
+- **agent context (`agent.ctx`)** — the agent's scoped context; registrations through it are scope-visible AND scope-lifetime (one fact drives both), and listeners on it hear only that agent's dispatches.
+- **scope carrier** — the `thisArg` a scope-filtered dispatch carries (built by `scopeTarget`); its filter admits untagged listeners plus the subject's own. A *subject-less* carrier (no key) admits untagged listeners only.
+- **scoped dispatch** — the rule: an event about one agent's activity dispatches with that agent's carrier. Events about a registry itself (a tool was added) are *registry-subject* and stay unfiltered.
+- **shadowing** — most-specific-wins name resolution: a scoped tool/section/variable replaces its same-named global twin for that scope alone. The per-agent persona and per-agent tool-variant mechanism.
+- **restriction / grant** — a restriction (`tools.restrict`) masks the GLOBAL tool surface for one scope (compose by intersection); a scoped registration is an explicit grant that bypasses restrictions. A restricted-away tool is absent from the prompt AND refuses execution, indistinguishably from a nonexistent one.
+- **setup window** — the creation slot where a creator composes an agent's scoped world (`CreateAgentOptions.setup`): after the scope exists and the agent is registered, before `agent/session-start` and the first prompt assembly. Setup registers; it never drives the agent.
+- **lineage** — parent/child facts carried as data (`parentSession`, `subagentDepth`); never affects visibility.
diff --git a/docs/architecture.md b/docs/architecture.md
index 02dac4a2dd..b668edfefd 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -14,6 +14,7 @@ The default distribution is a composition, not a hierarchy. `packages/core/` is
| ctx key | Package | Role |
|---|---|---|
+| — | [`dsh-scope`](../packages/core/scope/README.md) | scoped-context registration primitive (library) |
| `ctx.sessions` | `dsh-session` | in-memory event-sourced sessions |
| `ctx.systemPrompt` | `dsh-system-prompt` | ordered prompt sections, tool schemas, and prompt variables |
| `ctx.tools` | `dsh-tools` | tool registry and [execution pipeline](tool-execution-pipeline.md) |
@@ -58,7 +59,7 @@ A **session** is one agent's append-only event log. A **turn** drains one queued
### Turn Flow
```text
-create agent -> emit agent/session-start(source)
+create agent -> mint agent scope (agent.ctx) -> run creation setup -> emit agent/session-start(source)
forever:
wait for queued messages
emit agent/status(running)
@@ -103,6 +104,10 @@ Every session event is turn-enclosed. Reloading a crashed session preserves the
`ctx.agents` owns live agents and returns an `AgentHandle { agent, dispose() }`. `Agent` is the surface other plugins drive: `send()` queues work, `steer()` injects mid-turn content, `inject()` appends context and opens a one-shot injection turn when idle, `cancel()` is the public stop primitive, and `whenIdle()` observes quiescence. Lifecycle owners tear down with `await dispose()`.
+### Agent Scope
+
+Every live agent owns a scope context, `agent.ctx` ([`dsh-scope`](../packages/core/scope/README.md), key = the agent). Registrations through it — tools, prompt sections/variables, listeners, `tools.restrict()` masks — are visible to that agent alone, SHADOW same-named global contributions for it (per-agent personas and tool variants), and unwind with the agent; an `agent.ctx` listener hears only that agent's dispatches, while events about one agent dispatch with its scope carrier. `CreateAgentOptions.setup(agentCtx)` composes a child's scoped world at creation (the subagent seam's `persona`/`toolFilter`) — setup registers, never drives. Dev invariants enforce carrier/subject identity; `verify-scoped-dispatch` pins enforced ⇔ documented. Rationale: [agent-scope RFC](rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md).
+
## State And Model Surface
### Session Log
@@ -145,5 +150,6 @@ New behavior should attach to a documented seam; changing the shipped loop requi
| Add UI or editor integration | drive `ctx.agents` and render from `session/event` |
| Add durable session state | add a `SessionEventMap` member and render/replay from the log |
| Fork a live session | use `ctx.sessions.fork(source, boundary?, childSessionId?)` |
+| Scope a tool, prompt section, or listener to ONE agent | register it through that agent's `agent.ctx` (see Agent Scope) |
The [extension cookbook](cookbook/extension-cookbook.md) carries plugin skeletons and the feature-to-seam map; step-by-step guides cover [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), and [vendored packages](cookbook/adding-a-vendored-package.md).
diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md
index d6d0ce747b..677e4eafb5 100644
--- a/docs/rfc/INDEX.md
+++ b/docs/rfc/INDEX.md
@@ -123,6 +123,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
| [Prompt variables and tool-guidance ownership](implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md) | 2026-07-05 |
| [Every LLM request is reconstructable from the session log](implemented/architecture/2026-07-05-reconstructable-requests.md) | 2026-07-05 |
| [Subagent provider-lifecycle events — `subagent/provider-added` / `subagent/provider-removed`](implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md) | 2026-07-05 |
+| [The agent is a registration scope](implemented/architecture/2026-07-08-agent-scope-contexts.md) | 2026-07-08 |
### Process
diff --git a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md
new file mode 100644
index 0000000000..7c140f1ec6
--- /dev/null
+++ b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md
@@ -0,0 +1,35 @@
+# RFC: The agent is a registration scope
+
+Status: implemented
+
+## Problem
+
+The runtime is multi-agent — configuration can declare several agents, the ACP bridge creates one agent per client session, and the in-process subagent backends spawn/fork children as sibling agents on the same Cordis context — yet every extension surface was context-global. One tool registry fed every agent's prompt (a child spawned to summarize a file was offered bash, file-write, and the delegation tool itself, unbounded); one section list rendered the same persona for everyone (`SubagentStartRequest` could not express a per-child persona at all); every `agent/*`, `session/*`, and `tools/*` listener fired for every agent, so a decider waterfall written for one agent silently governed all of them unless its author remembered to self-filter. The gap was visible in the API: `SubagentCapabilities.toolFilter` was public vocabulary, yet every real provider declared `toolFilter: false` because per-agent tool visibility was unimplementable, and `structured.ts` carried a FIXME documenting the placeholder-schema/final-assembly-swap/refcount dance forced by global registration.
+
+## Decision
+
+Make the agent a registration scope, using the framework's own machinery rather than per-registry bolt-ons:
+
+- **`dsh-scope`** (`packages/core/scope`, peer-deps cordis only, below `dsh-session`/`dsh-system-prompt` in the module-graph DAG): `createScope(ctx, key)` mints a tagged context over a synchronously-usable no-op-plugin fiber; `scopeOf(ctx)` reads the tag through the prototype chain; `scopeTarget(base, key)` builds the scope-filtered dispatch carrier over cordis `Context.filter`, composing the base's own filter, branded `Scoped` and runtime-marked for the dev invariants; `Scope.rawDispose` exposes the exact cordis disposer so a composite effect nests the scope's teardown at its yield position; `scopeHost` is the fail-loud test-side minter.
+- **Ownership and visibility derive from ONE fact** — which context a registration went through: the scope's fiber owns the disposal, and the tag decides who sees it. An explicit `{ scope }` registration parameter could express "visible to X, disposed with Y", which is almost always a bug; the scoped context makes it unrepresentable.
+- **`Agent.ctx`**: every live agent owns a scope context (key = the agent), minted inside the loop's composite lifecycle effect. Yield order gives teardown stop/drain → unregister → detach session → unwind scope; detach before the (async) scope unwind keeps store/registry rollback synchronous on every failure path, so a caller catching a throwing `create()` observes no half-created agent or session. `CreateAgentOptions.setup(agentCtx)` runs after the scope is minted and the agent registered, before `agent/session-start` and the loop start — setup REGISTERS the scoped world, it never drives (a dev invariant makes a pre-session-start turn a teaching error).
+- **Two registration layers with shadowing**: `ctx.tools` and `ctx.systemPrompt` file a registration by the calling context's tag; a scoped tool/section/variable is visible to that agent alone, unwinds with it, and SHADOWS a same-named global contribution for that agent (most-specific-wins; within one layer duplicates still throw). Shadowing is the per-agent persona mechanism (a scoped `deployment:persona`) and the per-agent tool-variant mechanism (a scoped `bash` with the same model-facing name).
+- **`tools.restrict({allow?, deny?})`**: a scoped, snapshot-at-registration mask over the GLOBAL tool surface with loud unknown-name validation; multiple restrictions intersect; scoped registrations are explicit grants that bypass restriction (what keeps a structured capture tool alive under an allow-list). One visibility function feeds prompt assembly, `get(name, scope?)`, and `execute`, so what the model is shown, what a presenter renders, and what dispatches can never disagree; out-of-view execution is `UNKNOWN_TOOL`, indistinguishable from nonexistent.
+- **Scoped dispatch by rule**: an event about one agent's activity dispatches with that agent's carrier — all `agent/*` (via the fused `agentEvents(ctx, agent)`, which injects carrier and subject in one move so the correct dispatch is the shortest spelling), `session/created|event|flush` (carrier captured at `SessionStore.enter` from the entering context; `ctx.sessions.flush(session)` owns the awaited checkpoint dispatch), `tools/pre|post-execute` (by `exec.agent`), `system-prompt/assemble` (by `context.scope`; `assembleContextFor(agent)` builds the context), and `subagent/start|end` (by the delegating parent). Registry-subject notifications (`tools/change`, `system-prompt/change`, `subagent/provider-*`) stay deliberately unfiltered. A listener registered through `agent.ctx` hears only its agent; plain plugin listeners keep hearing everything; `{ global: true }` bypasses filtering.
+- **Enforcement**: dev-invariants assert at cordis's `internal/dispatch` seam that every scoped-family dispatch carries a carrier keyed to the same subject its arguments name, and that an assembly context never carries `agent` without `scope`; the `verify-scoped-dispatch` gate pins the invariant table against the declaration docs so the two cannot drift.
+- **The seam becomes honest**: spawn/fork advertise `{ outputSchema, depthLimit, toolFilter, persona }` all true (ACP all false); the driver composes the child's scoped world in the setup window; a parent-scope teardown effect links each child to its parent through the memoized handle (structured concurrency — a disposed parent reaches its subtree even if the delegating tool's `finally` never runs); `structured.ts` collapses to scoped registrations with a call-keyed two-phase commit and one scoped prepend re-assert listener.
+
+## Alternatives considered
+
+- **Explicit scope parameters on every registration API** (`tools.register(def, {agent})`): forgettable — omitting the option is global, so leak-by-default survives; no lifecycle coupling; and it can express visible-to-X-disposed-with-Y, which is almost always a bug.
+- **Per-agent `ctx.isolate()` service instances**: isolation is a bulkhead for co-hosting independent applications, not intra-app scoping. Resolution picks exactly one instance per name — "deployment tools plus my tools" needs a hand-built delegating merge registry per service — and single-subscription observers (persistence, the ACP bridge) would have to discover and subscribe per agent.
+- **Event-filtering only** (scoped listeners, global registries): leaves the model-visible surfaces — tool schemas, personas — unscoped, which is the half that makes `toolFilter` and per-child personas impossible.
+- **Vendored-cordis support** (a first-class scope concept in the framework): more invasive vendor drift for no additional capability; `extend` + `Context.filter` + a no-op plugin fiber already compose the same semantics from public primitives.
+
+## Consequences
+
+- Plugin authors get one new concept: register through `agent.ctx` for one agent, through your plugin context for everyone. The registration APIs are unchanged; scope-filtered events document themselves in the catalog.
+- The loop's dispatch discipline is enforced three ways: `Scoped` `this`-types make a bare subject a compile error, the fused helpers make the correct spelling the shortest, and the dev invariants throw on a mis-keyed or missing carrier at the dispatching call site.
+- `toolOrder` validates against the providers' pre-restriction `knownNames` universe, so a deployment order listing a global tool stays compatible with children that `restrict()` it away (a typo still fails every assembly loudly).
+- A scoped listener's own disposer runs after the session leaves the store on teardown (detach precedes the scope unwind); it heard the final stop/drain flush while attached, so nothing durable is lost.
+- Deliberately out of scope, buildable on the primitive with no core change: named profile registries (`agentCtx.plugin(...)` already works), per-agent `fs/*` policy, `llm/*` scoping, and background subagents (the parent-scope teardown effect is already shaped for them).
diff --git a/packages/core/README.md b/packages/core/README.md
index eee8e3eed0..4e61293034 100644
--- a/packages/core/README.md
+++ b/packages/core/README.md
@@ -4,6 +4,7 @@ The packages every harness build is assembled from: the session log, the system-
| Package | Role | ctx key |
|---|---|---|
+| `scope/` | Scoped-context registration primitive (scope tags, scope-filtered dispatch) | (library — no ctx key) |
| `session/` | Event-sourced session log + in-memory store | `ctx.sessions` |
| `system-prompt/` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` |
| `tools/` | Tool registry + `tools/pre-execute`/`tools/post-execute` pipeline | `ctx.tools` |
@@ -11,6 +12,8 @@ The packages every harness build is assembled from: the session log, the system-
| `agent-loop/` | The concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` |
| `agent-core/` | Bundle plugin: the providerless/executor-less/UI-less spine as code | (loads the spine) |
+`scope/` is the one non-service package here: a dependency-free library (`createScope`/`scopeOf`/`scopeTarget`) the registries and the loop build per-agent scoping on — it sits below `session/` and `system-prompt/` in the module graph precisely so they can consume it without a cycle.
+
`agent-loop` is the one concrete implementation of the `agent` seam and lives here because it is the harness's default product loop; everything else in `core/` is interface/vocabulary. Plugins depend on the `agent` vocabulary, never on `agent-loop` directly, so the loop stays swappable.
`agent-core` is the composition counterpart: one bundle plugin that loads the whole providerless spine (`timer` + `llm` + sessions + system-prompt + tools + agents + invariants + `tool-bash` + `agent-loop`) and forwards `agent-loop`'s `agents` list as its own config. App packages (`ui/stdio-agent`, `ui/acp-agent`) consume it and add only a front door; a leaf adds the swappable backends plus any optional product tools it wants to expose. It lives in `core/` because it composes exclusively `core/` + interface packages and ships no provider, executor, or UI of its own.
diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md
index ed651ca7f0..b00ad6aad1 100644
--- a/packages/core/agent-loop/README.md
+++ b/packages/core/agent-loop/README.md
@@ -8,6 +8,8 @@ This is the only package in the harness that contains concrete loop logic. Every
### Public API
+Lifecycle (scoped): the composite creation effect mints the agent's scope (`agent.ctx`), enters the session through it (the session's dispatch carrier), registers the agent, runs `CreateAgentOptions.setup`, emits `agent/session-start`, then starts the loop; teardown runs stop/drain → unregister → detach session → unwind scope, keeping store/registry rollback synchronous on every failure path. All `agent/*` dispatches go through `agentEvents(ctx, agent)`; per-step assembly through `assembleContextFor(agent)`; the turn-end durability checkpoint through `ctx.sessions.flush(session)`.
+
- `ctx.agentLoop.create(id: string, options?: AgentOptions): ReactLoopAgent` — config-driven create: an agent on a fresh per-run session id `${id}-session-` (no cwd). Used for `cordis.yml`-configured agents. The per-run uuid avoids colliding with the on-disk log a prior run materialized once a durable persistence backend is loaded; each run is a new session (a deliberate demo simplification — a real resume-or-create policy is a TODO). Disposed with the calling fiber.
`AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface):
diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md
index 9e15356ed0..e0668ef761 100644
--- a/packages/core/agent/README.md
+++ b/packages/core/agent/README.md
@@ -8,6 +8,8 @@ Tracks live agents so UI, hook, and orchestrator plugins can find them without i
### Public API
+The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher every agent-subject event goes through (carrier + injected subject in one move); `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `CreateAgentOptions.setup(agentCtx)` composes a child's scoped world at creation — setup registers, it never drives.
+
- `ctx.agents.register(agent: Agent): () => void` — record an **already-constructed** agent. Disposed with the calling fiber.
- `ctx.agents.get(id: AgentId): Agent | undefined`
- `ctx.agents.list(): Agent[]`
diff --git a/packages/core/session/README.md b/packages/core/session/README.md
index 0ae5bbf39e..767008d8ee 100644
--- a/packages/core/session/README.md
+++ b/packages/core/session/README.md
@@ -9,6 +9,7 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
### Public API
- `ctx.sessions.create(id?: SessionId, options?: { seed?: SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number } }): Session` — Create a session. `options.seed` replays/forks an existing event log; `options.meta` attaches creation metadata (validated absolute `cwd`, `parentSession` lineage, seed boundary) as the immutable `SessionHeader`. The store fills `version`/`id` and defaults `createdAt` to now; a caller reconstructing a persisted session passes the original `createdAt` and persisted `seedLength` to preserve them. Disposed with the calling fiber.
+- `ctx.sessions.flush(session: Session): Promise` Dispatch the awaited `session/flush` durability checkpoint with the carrier captured at enter — THE flush entry point (the loop's turn-end checkpoint and idle injection call it; never dispatch a raw `ctx.parallel`).
- `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that boundary to be `turn/end`, and create a live child session with lineage metadata.
- `ctx.sessions.get(id: SessionId): Session | undefined`
- `ctx.sessions.list(): Session[]`
@@ -28,7 +29,7 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
| Event | Mode | Purpose |
|---|---|---|
| `session/created` | emit | A session was created |
-| `session/event` | emit | An event was appended (sync, fire-and-forget) |
+| `session/event` | emit (scope-filtered by the owning session's scope) | An event was appended (sync, fire-and-forget) |
| `session/flush` | parallel | Awaited durability checkpoint (persistence plugins drain buffers here) |
### Class: `Session`
diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md
index 704cd8e80f..b890ad3cd5 100644
--- a/packages/core/system-prompt/README.md
+++ b/packages/core/system-prompt/README.md
@@ -13,21 +13,21 @@ System prompt assembly registry. Plugins contribute ordered text sections, tool-
### Public API
-- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. Duplicate names throw. Disposed with the calling fiber.
-- `ctx.systemPrompt.tools(provider: () => ToolSchema[]): () => void` Contribute tool schemas (evaluated at each assembly). A provider must not return a schema named `TOOL_ORDER_REST`; that name is reserved for `toolOrder`'s rest entry. Disposed with the calling fiber.
-- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void` Contribute a prompt variable, referenced from section text as `{{name}}`. Duplicate or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber.
-- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise` Assemble the prompt for one caller. Runs through the `system-prompt/assemble` waterfall. Rejects when a configured `toolOrder` names a tool no provider contributed, or when a provider returns the reserved rest-entry name.
+- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. The layer is the CALLING context's scope: `agent.ctx` contributes to that agent alone, SHADOWING a same-named global section there (the per-agent persona mechanism — a scoped `deployment:persona`). Duplicate names within one layer throw. Disposed with the calling fiber.
+- `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => void` Contribute tool schemas, evaluated at each assembly with that assembly's context. `ToolProviderResult` = `{ schemas, knownNames? }`: `schemas` is the post-restriction visible set for `context.scope`; `knownNames` (defaulting to the schemas' names) is the pre-restriction universe `toolOrder` validates against. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber.
+- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void` Contribute a prompt variable, referenced from section text as `{{name}}`. Scoped variables (via `agent.ctx`) shadow a same-named global for that agent. Duplicate-in-layer or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber.
+- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer (scoped shadows global). Runs through the `system-prompt/assemble` waterfall (scope-filtered by `context.scope`). Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe (a restricted-away KNOWN tool is a normal absence), or when a provider returns the reserved rest-entry name.
### Events
| Event | Mode | Purpose |
|---|---|---|
| `system-prompt/assemble` | waterfall | Mutate/extend the assembly (with the caller's context) before it reaches the model |
-| `system-prompt/change` | emit | A section, tool provider, or variable was registered or unregistered |
+| `system-prompt/change` | emit | A section, tool provider, or variable was registered or unregistered (possibly for one scope); deliberately unfiltered |
### Key types
-- `AssembleContext` — what one `assemble()` call is FOR. Declared empty here and merge-extensible; `dsh-agent` declares `agent?: Agent`, so providers project per-agent facts. Providers must tolerate absent fields (a bare `assemble()` carries an empty context).
+- `AssembleContext` — what one `assemble()` call is FOR. Merge-extensible; declares `scope?: ScopeKey` (the layer selector) here, and `dsh-agent` declares `agent?: Agent` (the typed DX field — never set without `scope`; use `assembleContextFor(agent)`). Providers must tolerate absent fields (a bare `assemble()` carries an empty, scope-less context).
- `PromptSection` — `{ name, order, text: string | ((context) => string) }`. Sections are concatenated in ascending `order`. Order bands: `-100` is the harness identity, `0` the deployment persona (both registered by this plugin), tool guidance uses `100–199`; other negative orders also render before the persona.
- `PromptAssembly` — `{ sections: AssembledSection[], tools: ToolSchema[], variables: Record }`. Section texts arrive resolved but not yet interpolated; `variables` holds every registered variable resolved against the context. Tool schemas are part of the assembly by design: "what the model is told it can do" is one coherent thing, even though adapters transmit schemas as a separate wire field.
- `renderPrompt(assembly)` — interpolates `{{variable}}` references in each section, drops empty sections, joins with blank lines. STRICT: an unknown reference (`Object.hasOwn` lookup — prototype names like `{{constructor}}` are unknown), a registered-but-valueless reference, a malformed complete `{{…}}` group, or a `{{` that opens no complete group while a `}}` still follows (`{{{model}}}`) throws — fail loud beats shipping a malformed prompt. A lone `{{` with no `}}` anywhere after it passes through verbatim; substituted values are never re-scanned.
diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md
index aea87ad76c..b42087b5d6 100644
--- a/packages/core/tools/README.md
+++ b/packages/core/tools/README.md
@@ -6,9 +6,12 @@ Tool registry and execution pipeline. Tool plugins register their schemas and ex
### Public API
-- `ctx.tools.register(definition: ToolDefinition): () => void` Register a tool. Disposed with the calling fiber.
-- `ctx.tools.get(name: string): ToolDefinition | undefined`
-- `ctx.tools.schemas(): ToolSchema[]` Schemas of all registered tools (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)).
+- `ctx.tools.register(definition: ToolDefinition): () => void` Register a tool. The layer is the CALLING context's scope (`dsh-scope`): a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, SHADOWING a same-named global tool there (per-agent tool variants). Duplicate names within one layer throw. Disposed with the calling fiber (= the agent, for scoped registrations).
+- `ctx.tools.restrict(filter: ToolRestriction): () => void` Scoped-only (throws on a plain context): mask the GLOBAL tool surface for the calling agent — `allow` keeps only the listed tools, `deny` removes them; multiple restrictions intersect; scoped registrations bypass restriction as explicit grants. Snapshot-at-registration, loud unknown-name validation, `restrict({})` rejects (the materialized-empty-config trap).
+- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed.
+- `ctx.tools.visible(scope?: ScopeKey): ToolDefinition[]` THE visibility function — restricted global layer ∪ the scope's own layer — feeding prompt assembly, `get`, and `execute`, so what the model sees and what dispatches can never disagree.
+- `ctx.tools.knownNames(scope?: ScopeKey): string[]` The PRE-restriction name universe configuration (`toolOrder`, `restrict`) validates against: a typo fails loud while a restricted-away tool stays a normal absence.
+- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)).
- `ctx.tools.execute(exec: ToolExecution): Promise` Execute one tool call through the `tools/pre-execute` → dispatch → `tools/post-execute` pipeline.
### Injected services
@@ -19,9 +22,9 @@ Tool registry and execution pipeline. Tool plugins register their schemas and ex
| Event | Mode | Purpose |
|---|---|---|
-| `tools/pre-execute` | waterfall | Allow/deny gate BEFORE a tool runs (sandbox, permission, hooks); returns `PreToolDecision` |
+| `tools/pre-execute` | waterfall | Allow/deny gate BEFORE a tool runs (sandbox, permission, hooks); returns `PreToolDecision`. Scope-filtered by `exec.agent`: an `agent.ctx` listener gates only its own agent |
| `tools/post-execute` | waterfall | Inspect/replace the result AFTER a tool runs, attach context; returns `PostToolDecision` |
-| `tools/change` | emit | A tool was registered or unregistered |
+| `tools/change` | emit | A tool or restriction was registered or unregistered (possibly for one scope); deliberately unfiltered |
### Key types
diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md
index f6870929a8..a5c293c7da 100644
--- a/packages/subagent/subagent-inprocess/README.md
+++ b/packages/subagent/subagent-inprocess/README.md
@@ -21,16 +21,14 @@ Runs a child as a child [`Agent`](../../core/agent) on the same cordis context (
### Structured output (package-internal runtime)
-The mechanism behind `outputSchema` for in-process children — acquired per structured RUN inside `startInProcessRun` (nothing is registered on a context that never runs a structured child; only the model-facing constants `STRUCTURED_OUTPUT_TOOL`/`STRUCTURED_OUTPUT_INSTRUCTION` are exported). One globally registered `structured_output` capture tool (its registered parameters are a placeholder) plus four listeners:
+`attachStructuredRuntime(childCtx, schema)` registers the run's whole enforcement surface as SCOPED registrations on the child's `agent.ctx` — riding the child's fiber (a backend hot-reload mid-run cannot unregister anything; a disposed child leaves no residue) and visible to that child alone (two concurrent structured runs never interact; no placeholder schema, no strip-for-everyone-else, no refcounted global state):
-- a `system-prompt/assemble` waterfall listener registered `prepend: true` that post-processes `await next()` — **final-assembly enforcement**: the assembly the loop renders never carries `structured_output` for an agent without a structured run, and for one that has it always carries the run's OWN schema (as the tool's `parameters`) plus the calling instruction as a trailing prompt section (the demand travels with the tool — `AgentOptions` has no per-agent prompt field to carry it). The loop logs the rendered assembly as the step's `request/header`, so the injection is reconstructable log state, never a wire-only mutation. Per-agent shaping lives here because the tool registry and prompt assembly are context-global while schemas differ per concurrent child (FIXME in the module doc: per-agent/per-session scoping would dissolve this); cooperative mutate-then-`next()` would not survive a downstream listener returning a replacement assembly.
-- a `tools/post-execute` listener (`prepend: true` = outermost, so `await next()` yields the composed final decision) that COMMITS the capture: the tool body only stages the validated value, and it becomes the run's result only when the final decision accepts the call — a downstream block (a PostToolUse hook) turns the logged result into `isError`, and the run must not report `structured` success for a call the model and session log saw fail.
-- a `tools/pre-execute` deny for any call arriving after the agent's capture — terminal means terminal WITHIN the step: a response listing `structured_output` before further tool calls cannot run side effects after the final answer was accepted.
-- an `agent/turn-continuation` listener (also `prepend: true` — an earlier-registered force-continue listener returning without `next()` must not decide the turn before the veto runs) that stops a child's turn once its output is captured, so a successful capture doesn't buy a wasted extra model step.
-
-The capture tool validates each call against the run's schema (`validateStructuredValue`) — violations become an `INVALID_ARGS` isError result the model retries in-turn; a valid call stages the value for the post-execute commit.
-
-Lifetime is refcounted by live structured runs: each acquires at start and releases at settle, so the registrations exist exactly while at least one structured child is live, a backend hot-reload mid-run cannot unregister the capture tool under a live child, and the last settle disposes everything. `release()` is idempotent per acquisition.
+- the `structured_output` capture tool with the run's REAL schema as its registered `parameters`, validating each call (`validateStructuredValue`) — violations become an `INVALID_ARGS` isError the model retries in-turn; a valid call STAGES the value keyed by its call id;
+- the calling instruction as an ordinary order-190 scoped prompt section (the demand travels with the tool, as prompt state of exactly one agent);
+- a scoped `system-prompt/assemble` re-assert (`prepend: true` = outermost): whatever downstream listeners mutate or replace, the child's assembly always carries its capture tool and instruction — the loop logs the rendered assembly as the step's `request/header`, so the demand is reconstructable log state;
+- a scoped `tools/post-execute` COMMIT (`prepend: true`): the staged value becomes the run's result only when the final decision accepts THE SAME CALL that staged it — call-keyed, so a stale stage orphaned by an outer short-circuiting listener is dropped, never promoted on a later call's acceptance;
+- a scoped `tools/pre-execute` deny for any call arriving after the capture — terminal means terminal WITHIN the step;
+- a scoped `agent/turn-continuation` veto (`prepend: true`) stopping the child's turn once its output is captured, so a successful capture doesn't buy a wasted extra model step.
### `depthOf(agent): number`
diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md
index 8bab4c61c9..695bd8f952 100644
--- a/packages/subagent/subagent/README.md
+++ b/packages/subagent/subagent/README.md
@@ -25,7 +25,7 @@ Unlike the bash seam (one executor per context, second load throws), **multiple
## Capabilities: two kinds, discovered two ways
-- **Start-time features** (`outputSchema`, `depthLimit`, `toolFilter`) are a static `provider.capabilities` descriptor, checked by the service BEFORE a run exists. A request that needs one the provider lacks is **rejected loud** (`UNSUPPORTED_CAPABILITY`), never accepted-then-ignored.
+- **Start-time features** (`outputSchema`, `depthLimit`, `toolFilter/persona`) are a static `provider.capabilities` descriptor, checked by the service BEFORE a run exists. A request that needs one the provider lacks is **rejected loud** (`UNSUPPORTED_CAPABILITY`), never accepted-then-ignored.
- **Runtime features** (steering, resume) are **optional methods** on `SubagentRun` (`sendMessage?`, `resume?`). The method's presence IS the capability; TS narrowing is the discovery mechanism — a consumer cannot call an absent method without narrowing first, so there is no silent degradation path.
Beside `capabilities` sits one DESCRIPTIVE fact, not validated by the service: `provider.inheritsParentContext` — whether a child sees the parent conversation (`fork`: true — seeded with the completed-turn prefix; `spawn`/`acp`: false). The model-facing consumer (`dsh-tool-subagent`) derives truthful tool wording from it.
diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json
index fc2b9d12c2..33217097b2 100644
--- a/scripts/doc-budgets.manifest.json
+++ b/scripts/doc-budgets.manifest.json
@@ -1,7 +1,7 @@
{
"AGENTS.md": 1691,
"docs/AGENTS.md": 1315,
- "docs/architecture.md": 1640,
+ "docs/architecture.md": 1790,
"docs/cordis-primer.md": 550,
"docs/defensive-patterns.md": 550,
"docs/testing.md": 800,