From 9d310ecc27954f412e709fe8a4dd891406ff2e1d Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Sun, 19 Jul 2026 19:19:57 +0800
Subject: [PATCH 01/30] feat(invariants): add package-owned service seam
---
docs/architecture.md | 7 +-
docs/capability-seams.md | 14 +-
docs/config-catalog.md | 26 +-
docs/cordis-catalog/services.md | 18 +
docs/core-data-structures/session.md | 4 +-
docs/event-producer-consumer.md | 10 +-
docs/module-graph.md | 21 +-
docs/rfc/INDEX.md | 1 +
...06-11-dev-invariants-over-deep-readonly.md | 10 +-
.../2026-06-11-structured-error-taxonomy.md | 4 +-
.../2026-06-15-turn-enclosure-invariant.md | 2 +-
.../2026-06-18-session-surface.md | 3 +-
.../2026-07-05-reconstructable-requests.md | 2 +-
.../2026-07-12-agent-scope-runtime-design.md | 2 +-
...-package-owned-invariant-service.i18n.yaml | 6 +
...6-07-19-package-owned-invariant-service.md | 97 ++
...7-19-package-owned-invariant-service.zh.md | 97 ++
.../2026-06-18-compaction-capability-seam.md | 4 +-
.../feature/2026-07-07-session-prefix.md | 2 +-
...pt-program-backed-semantic-gates.i18n.yaml | 4 +-
...ypescript-program-backed-semantic-gates.md | 8 +-
...script-program-backed-semantic-gates.zh.md | 8 +-
.../2026-06-16-typed-event-schemas.md | 4 +-
.../tests/compact-loop-repro.spec.ts | 16 +-
.../cordis/tool-cordis/src/api-catalog.ts | 18 +
packages/core/agent-loop/README.md | 4 +
packages/core/agent-loop/package.json | 6 +
packages/core/agent-loop/src/invariant.ts | 73 ++
.../tests/contract-regressions.spec.ts | 30 +-
.../core/agent-loop/tests/invariant.spec.ts | 103 +++
.../core/agent-loop/tests/turn-stop.spec.ts | 14 +-
packages/core/agent-loop/tsconfig.json | 3 +
packages/core/agent-loop/tsdown.config.ts | 25 +
packages/core/agent/README.md | 2 +
packages/core/agent/package.json | 7 +
packages/core/agent/src/invariant.ts | 35 +
packages/core/agent/tests/invariant.spec.ts | 58 ++
packages/core/agent/tsconfig.json | 3 +
packages/core/agent/tsdown.config.ts | 25 +
packages/core/scope/README.md | 2 +
packages/core/scope/package.json | 7 +
packages/core/scope/src/invariant.ts | 41 +
.../core/scope/src/scoped-events.generated.ts | 50 +
packages/core/scope/tests/invariant.spec.ts | 81 ++
packages/core/scope/tsconfig.json | 3 +
packages/core/scope/tsdown.config.ts | 27 +
packages/core/session/README.md | 4 +-
packages/core/session/package.json | 7 +
packages/core/session/src/invariant.ts | 230 +++++
packages/core/session/tests/invariant.spec.ts | 268 ++++++
packages/core/session/tsconfig.json | 3 +
packages/core/session/tsdown.config.ts | 25 +
packages/examples/agent-spine-demo/README.md | 15 +-
.../examples/agent-spine-demo/package.json | 2 +
.../examples/agent-spine-demo/src/index.ts | 21 +-
.../agent-spine-demo/tests/agent-core.spec.ts | 41 +
.../stdio-demo/tests/built-bin.e2e.ts | 2 +-
packages/llm/llm/README.md | 2 +-
.../helper/src/features/builtin/helpers.ts | 15 +-
.../sdk/helper/src/features/builtin/spine.ts | 6 +-
packages/sdk/helper/tests/project.spec.ts | 11 +
.../tests/multi-subagent.spec.ts | 14 +-
.../subagent-fork/tests/subagent-fork.spec.ts | 16 +-
.../tests/structured.spec.ts | 14 +-
.../tests/subagent-inprocess.spec.ts | 14 +-
.../tests/subagent-spawn.spec.ts | 18 +-
packages/support/invariants/README.md | 83 +-
packages/support/invariants/package.json | 17 +-
packages/support/invariants/src/index.ts | 544 ++++-------
.../invariants/src/scoped-events.generated.ts | 71 --
.../invariants/tests/invariants.spec.ts | 851 ------------------
.../support/invariants/tests/service.spec.ts | 266 ++++++
packages/support/invariants/tsconfig.json | 23 +-
packages/ui/acp/tests/config-options.spec.ts | 14 +-
.../tests/integration.spec.ts | 14 +-
pnpm-lock.yaml | 40 +-
scripts/check-workspace-constraints.ts | 21 +
scripts/gen-cordis-catalog.ts | 2 +
scripts/gen-doc-graphs.ts | 12 +-
scripts/gen-scoped-events.ts | 68 +-
tsconfig.base.json | 5 +
website/.vitepress/config/api-sidebar.json | 4 +
website/zh-CN/api/harness/invariants.md | 32 +
83 files changed, 2225 insertions(+), 1557 deletions(-)
create mode 100644 docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml
create mode 100644 docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.md
create mode 100644 docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md
create mode 100644 packages/core/agent-loop/src/invariant.ts
create mode 100644 packages/core/agent-loop/tests/invariant.spec.ts
create mode 100644 packages/core/agent-loop/tsdown.config.ts
create mode 100644 packages/core/agent/src/invariant.ts
create mode 100644 packages/core/agent/tests/invariant.spec.ts
create mode 100644 packages/core/agent/tsdown.config.ts
create mode 100644 packages/core/scope/src/invariant.ts
create mode 100644 packages/core/scope/src/scoped-events.generated.ts
create mode 100644 packages/core/scope/tests/invariant.spec.ts
create mode 100644 packages/core/scope/tsdown.config.ts
create mode 100644 packages/core/session/src/invariant.ts
create mode 100644 packages/core/session/tests/invariant.spec.ts
create mode 100644 packages/core/session/tsdown.config.ts
delete mode 100644 packages/support/invariants/src/scoped-events.generated.ts
delete mode 100644 packages/support/invariants/tests/invariants.spec.ts
create mode 100644 packages/support/invariants/tests/service.spec.ts
create mode 100644 website/zh-CN/api/harness/invariants.md
diff --git a/docs/architecture.md b/docs/architecture.md
index fd21648dd2..1035407375 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -1,10 +1,10 @@
# DeepSeek Harness Architecture
-The **DeepSeek Harness SDK** builds agent harnesses on Cordis. The principle is simple: **everything is a plugin**. The shipped loop is one plugin, not a privileged kernel.
+The **DeepSeek Harness SDK** builds on Cordis: **everything is a plugin**, including the shipped loop.
## Overview
-A harness is one [Cordis](cordis-primer.md) context. Packages add services (`ctx.llm`, `ctx.tools`, `ctx.sessions`), typed events (`agent/request`, `tools/pre-execute`, `session/event`), and disposable prompt, tool, provider, adapter, and listener registrations.
+A harness is a [Cordis](cordis-primer.md) context with services (`ctx.llm`, `ctx.tools`, `ctx.sessions`), typed events (`agent/request`, `tools/pre-execute`, `session/event`), and prompt/tool/provider/adapter/listener registrations.
`packages/core/` groups the default agent flow; surrounding capabilities are equally first-class Cordis plugins.
@@ -37,6 +37,7 @@ A harness is one [Cordis](cordis-primer.md) context. Packages add services (`ctx
| `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration |
| `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | durable storage for session logs |
| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live-preferred logical-corpus exact reads and relationship traces |
+| `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | registry and package-name selection for package-owned runtime checks |
## Event
@@ -134,7 +135,7 @@ Every live agent owns a scoped `agent.ctx`. Its registrations shadow globals, re
The session log is the source of truth. `deriveMessages()` projects session events into the `Message[]` sent to the model; raw `assistant/chunk` events stay in the log for replay and UI fidelity. Replay, fork, resume, transcript rendering, telemetry, and persistence all derive from the same event stream.
-**Model-visible ⟺ logged**: the log reconstructs every request — messages at `step/start` fronted by the header's session prefix, headers by folding `request/header` — and dev invariants assert this ([reconstructability RFC](rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)).
+**Model-visible ⟺ logged**: the log reconstructs every request — messages at `step/start` plus the header's prefix, and headers by folding `request/header` — and the package-owned `dsh-agent-loop/invariant` can assert it through `ctx.invariants` ([RFC](rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)).
Durability is a plugin concern. Persistence backends buffer synchronous `session/event` notifications and the loop awaits a turn-end checkpoint before moving on. The `SessionPersistence` seam stores `SessionEvent` directly, with metadata in `SessionHeader`; JSONL and SQLite share one contract suite.
diff --git a/docs/capability-seams.md b/docs/capability-seams.md
index 58641456b1..883e747723 100644
--- a/docs/capability-seams.md
+++ b/docs/capability-seams.md
@@ -24,6 +24,8 @@ flowchart LR
pkg_session_query["session-query"]
pkg_subagent_inprocess["subagent-inprocess"]
pkg_invariants["invariants"]
+ svc_invariants["ctx.invariants
Package-owned invariant registry"]
+ pkg_scope["scope"]
svc_sessionPersistence["ctx.sessionPersistence
Durable session persistence seam"]
pkg_session_persistence_jsonl["session-persistence-jsonl"]
pkg_session_persistence_sqlite["session-persistence-sqlite"]
@@ -109,6 +111,7 @@ flowchart LR
pkg_compact_basic --> svc_compact
pkg_fs --> svc_fs
pkg_fs_local --> svc_fs
+ pkg_invariants --> svc_invariants
pkg_llm --> svc_llm
pkg_llm_deepseek --> svc_llm
pkg_llm_pi_ai --> svc_llm
@@ -147,7 +150,6 @@ flowchart LR
svc_agents --> pkg_acp
svc_agents --> pkg_agent_loop
svc_agents --> pkg_cli_demo
- svc_agents --> pkg_invariants
svc_agents --> pkg_stdio_demo
svc_agents --> pkg_subagent_inprocess
svc_approval --> pkg_tool_bash
@@ -158,6 +160,10 @@ flowchart LR
svc_codeRuntime --> pkg_tools
svc_compact --> pkg_compact_basic
svc_fs --> pkg_tool_fs
+ svc_invariants --> pkg_agent
+ svc_invariants --> pkg_agent_loop
+ svc_invariants --> pkg_scope
+ svc_invariants --> pkg_session
svc_llm --> pkg_agent_loop
svc_llm --> pkg_compact_basic
svc_permission --> pkg_acp
@@ -171,7 +177,6 @@ flowchart LR
svc_sessions --> pkg_agent
svc_sessions --> pkg_agent_loop
svc_sessions --> pkg_cli_demo
- svc_sessions --> pkg_invariants
svc_sessions --> pkg_session_persistence
svc_sessions --> pkg_session_query
svc_sessions --> pkg_subagent_inprocess
@@ -208,14 +213,15 @@ flowchart LR
| --- | --- | --- | --- | --- | --- | --- |
| `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compact-basic`](../packages/compact/compact-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. |
| `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements. |
-| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. |
+| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | - | Owns append-only Session instances and emits the durable session event feed. |
+| `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. |
| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`acp`](../packages/ui/acp), [`session-query`](../packages/session-query/session-query) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. |
| `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | - | - | - | Resolves live and optional persisted logs into one logical corpus for exact reads and relationship traces. |
| `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. |
| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. |
| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`stdio-demo`](../packages/examples/stdio-demo), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`stdio-demo`](../packages/examples/stdio-demo), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. |
| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. |
-| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-demo`](../packages/examples/stdio-demo), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. |
+| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-demo`](../packages/examples/stdio-demo) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. |
| `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. |
| `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. |
| `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. |
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index 225098e851..76663117b8 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -115,7 +115,8 @@ Source: [`packages/core/agent-loop/src/index.ts:369`](../packages/core/agent-loo
* `dshHome` to bash environment and local skill discovery, `skills` to the
* skill registry/local provider/tool consumer, `workspaceContext` to the
* workspace-context loader, and `toolBash`/`toolTasks` to the model-facing tool
- * plugins this bundle owns. Owner schemas supply defaults for optional input;
+ * plugins this bundle owns. `invariants` configures global and package-filtered
+ * relational checks. Owner schemas supply defaults for optional input;
* workspace context instead requires an explicit byte budget or `false` because
* it changes model-visible input. Producer opt-in stays producer-local:
* `toolBash` configures bash only; independently composed producers keep their
@@ -142,6 +143,8 @@ export interface Config {
toolBash?: toolBash.Config
/** Generic background-task control-tool wait bounds. */
toolTasks?: toolTasks.Config
+ /** Global enablement and package-name filters for invariant companions. */
+ invariants?: InvariantConfig
}
/** Skill bundle config forwarded to the registry, local provider, and model-facing consumer. */
@@ -155,9 +158,9 @@ export interface SkillConfig {
}
```
-Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts)
+Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts)
-Source: [`packages/examples/agent-spine-demo/src/index.ts:57`](../packages/examples/agent-spine-demo/src/index.ts)
+Source: [`packages/examples/agent-spine-demo/src/index.ts:62`](../packages/examples/agent-spine-demo/src/index.ts)
## `@deepseek-ai/dsh-bash-local`
@@ -379,6 +382,22 @@ export interface Config {
Source: [`packages/hooks/hooks-codex/src/index.ts:42`](../packages/hooks/hooks-codex/src/index.ts)
+## `@deepseek-ai/dsh-invariants`
+
+```ts config-catalog
+/** Runtime invariant selection configured on the service plugin. */
+export interface Config {
+ /** Global switch; defaults to `true`. */
+ readonly enabled?: boolean
+ /** Case-sensitive JavaScript regex sources that admit package names; empty admits all. */
+ readonly package_allowlist?: string[]
+ /** Case-sensitive JavaScript regex sources that exclude package names after allowlist matching. */
+ readonly package_blocklist?: string[]
+}
+```
+
+Source: [`packages/support/invariants/src/index.ts:15`](../packages/support/invariants/src/index.ts)
+
## `@deepseek-ai/dsh-jsonrpc`
Requires: `agents`
@@ -1458,7 +1477,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts))
- `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts))
-- `@deepseek-ai/dsh-invariants` — requires `sessions` ([`packages/support/invariants/src/index.ts`](../packages/support/invariants/src/index.ts))
- `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts))
- `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts))
- `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts))
diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md
index 10cd0bbf05..5a868e1eff 100644
--- a/docs/cordis-catalog/services.md
+++ b/docs/cordis-catalog/services.md
@@ -479,6 +479,24 @@ Types: [FsDirEntry](../core-data-structures/filesystem.md) · [FsEditOutcome](..
Source: [`packages/fs/fs/src/index.ts:80`](../../packages/fs/fs/src/index.ts)
+## `ctx.invariants` — `InvariantService`
+
+Package-owned invariant registry with global and regex-based selection.
+
+```ts cordis-catalog
+/**
+ * Register one package's invariant installer. The package name is reserved
+ * even when filtering disables its checks. Enabled installers run in a child
+ * fiber; failure disposes that fiber and releases the reservation.
+ * @param packageName - full npm package name that owns the contribution.
+ * @param installer - synchronous listener installer for the child context.
+ * @returns an effect-scoped disposer for the registration.
+ */
+register(packageName: string, installer: InvariantInstaller): () => void
+```
+
+Source: [`packages/support/invariants/src/index.ts:94`](../../packages/support/invariants/src/index.ts)
+
## `ctx.llm` — `LlmService`
The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall.
diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md
index 3668191fb2..2dc5a1c34e 100644
--- a/docs/core-data-structures/session.md
+++ b/docs/core-data-structures/session.md
@@ -502,7 +502,7 @@ interface TurnEndReasonMap {
## The turn-enclosure invariant
-Every session event lives **inside** a turn (between a `turn/start` and its `turn/end`). The loop appends queued `user/message` events *after* `turn/start`, and an idle `agent.inject()` wraps its `context/message` in a one-shot `injection` turn. This makes the turn the single durability/replay boundary: a backend can treat anything after the last `turn/end` as an interrupted-crash tail without risking the loss of legitimately-recorded between-turn context. The `dsh-invariants` plugin enforces it in dev (a message event outside an open turn throws). See [the turn-enclosure invariant RFC](../rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md).
+Every session event lives **inside** a turn (between a `turn/start` and its `turn/end`). The loop appends queued `user/message` events *after* `turn/start`, and an idle `agent.inject()` wraps its `context/message` in a one-shot `injection` turn. This makes the turn the single durability/replay boundary: a backend can treat anything after the last `turn/end` as an interrupted-crash tail without risking the loss of legitimately-recorded between-turn context. The optional `dsh-session/invariant` companion enforces it in dev through `ctx.invariants` (a message event outside an open turn throws). See [the turn-enclosure invariant RFC](../rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md).
## Plugin-contributed log-only events
@@ -512,6 +512,6 @@ The hook bridges' `hook/invoked` / `hook/result` provenance pairs (from `@deepse
## Durability contract
-What a persistence backend relies on: the durable log persists every event verbatim, **including** `assistant/chunk` — `seq` must stay contiguous, so chunks cannot be filtered out of the canonical log. All `event.data` must be JSON-serializable; `Session.append` enforces this at the source (throwing on non-serializable data), so a bad event never enters the log and `session.events` always equals what a backend can persist. Adding an event type that carries non-serializable data, or that breaks the turn/step nesting the invariants plugin checks, is a breaking change to the on-disk format.
+What a persistence backend relies on: the durable log persists every event verbatim, **including** `assistant/chunk` — `seq` must stay contiguous, so chunks cannot be filtered out of the canonical log. All `event.data` must be JSON-serializable; `Session.append` enforces this at the source (throwing on non-serializable data), so a bad event never enters the log and `session.events` always equals what a backend can persist. Adding an event type that carries non-serializable data, or that breaks the turn/step nesting checked by the session invariant companion, is a breaking change to the on-disk format.
The backends that consume this contract are on [persistence.md](persistence.md).
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index 4f6e6daff0..29f8763460 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -19,7 +19,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:278`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic) |
| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:241`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) |
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:188`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`stdio`](../packages/ui/stdio) |
-| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:165`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) |
+| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:165`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) |
| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:252`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:288`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:298`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
@@ -27,10 +27,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:61`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:53`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
-| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:43`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) |
-| `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) |
+| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:43`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm-replay`](../packages/support/llm-replay) |
+| `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`session-persistence`](../packages/session-persistence/session-persistence) |
-| `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`workspace-context`](../packages/context/workspace-context) |
+| `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`workspace-context`](../packages/context/workspace-context) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:112`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) |
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:86`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
@@ -54,7 +54,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| Event string | Dispatchers | Listeners |
| --- | --- | --- |
-| `internal/dispatch` | - | [`invariants`](../packages/support/invariants) |
+| `internal/dispatch` | - | [`scope`](../packages/core/scope), [`session`](../packages/core/session) |
| `internal/status` | - | [`agent`](../packages/core/agent) |
Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program.
diff --git a/docs/module-graph.md b/docs/module-graph.md
index 0886877f0e..af8504be18 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -151,12 +151,14 @@ flowchart TD
pkg_workflow_workerthread["workflow-workerthread"]
end
pkg_llm --> pkg_brand
+ pkg_scope --> pkg_invariants
pkg_code_runtime_worker --> pkg_code_runtime
pkg_helper --> pkg_brand
pkg_scripts --> pkg_app_boot
pkg_llm_deepseek --> pkg_llm
pkg_llm_pi_ai --> pkg_llm
pkg_session --> pkg_brand
+ pkg_session --> pkg_invariants
pkg_session --> pkg_llm
pkg_session --> pkg_scope
pkg_system_prompt --> pkg_llm
@@ -168,6 +170,7 @@ flowchart TD
pkg_token_meter --> pkg_llm
pkg_token_meter --> pkg_session
pkg_agent --> pkg_brand
+ pkg_agent --> pkg_invariants
pkg_agent --> pkg_llm
pkg_agent --> pkg_scope
pkg_agent --> pkg_session
@@ -211,10 +214,6 @@ flowchart TD
pkg_session_query --> pkg_llm
pkg_session_query --> pkg_session
pkg_session_query --> pkg_session_persistence
- pkg_invariants --> pkg_agent
- pkg_invariants --> pkg_llm
- pkg_invariants --> pkg_scope
- pkg_invariants --> pkg_session
pkg_user_approval --> pkg_agent
pkg_user_approval --> pkg_brand
pkg_user_approval --> pkg_llm
@@ -247,6 +246,7 @@ flowchart TD
pkg_permission --> pkg_session
pkg_permission --> pkg_user_approval
pkg_agent_loop --> pkg_agent
+ pkg_agent_loop --> pkg_invariants
pkg_agent_loop --> pkg_llm
pkg_agent_loop --> pkg_scope
pkg_agent_loop --> pkg_session
@@ -391,6 +391,7 @@ flowchart TD
pkg_agent_spine_demo --> pkg_home
pkg_agent_spine_demo --> pkg_invariants
pkg_agent_spine_demo --> pkg_llm
+ pkg_agent_spine_demo --> pkg_scope
pkg_agent_spine_demo --> pkg_session
pkg_agent_spine_demo --> pkg_skill
pkg_agent_spine_demo --> pkg_skill_local
@@ -451,27 +452,28 @@ flowchart TD
| [`paths`](../packages/util/paths) | `util` | — |
| [`retention`](../packages/util/retention) | `util` | — |
| [`timeout`](../packages/util/timeout) | `util` | — |
-| [`scope`](../packages/core/scope) | `core` | — |
| [`skill`](../packages/skill/skill) | `skill` | — |
| [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | `subagent` | — |
| [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | — |
+| [`invariants`](../packages/support/invariants) | `support` | — |
| [`loader-smoke`](../packages/support/loader-smoke) | `support` | — |
| [`app-boot`](../packages/ui/app-boot) | `ui` | — |
| [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | — |
| [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | — |
| [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand) |
+| [`scope`](../packages/core/scope) | `core` | [`invariants`](../packages/support/invariants) |
| [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime) |
| [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand) |
| [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot) |
| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`llm`](../packages/llm/llm) |
| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`llm`](../packages/llm/llm) |
-| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
+| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
| [`system-prompt`](../packages/core/system-prompt) | `core` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) |
| [`web`](../packages/web/web) | `web` | [`llm`](../packages/llm/llm) |
| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm) |
| [`token-meter`](../packages/llm/token-meter) | `llm` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
-| [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
+| [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
| [`bash`](../packages/bash/bash) | `bash` | [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs) |
| [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs) |
@@ -492,7 +494,6 @@ flowchart TD
| [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) |
| [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) |
| [`session-query`](../packages/session-query/session-query) | `session-query` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) |
-| [`invariants`](../packages/support/invariants) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session) |
| [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
| [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) |
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent) |
@@ -501,7 +502,7 @@ flowchart TD
| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) |
| [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox) |
| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) |
-| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
+| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`home`](../packages/util/home), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
@@ -528,7 +529,7 @@ flowchart TD
| [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
| [`stdio`](../packages/ui/stdio) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-interaction`](../packages/ui/user-interaction) |
| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
-| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) |
+| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) |
| [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
| [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md
index 4476c47a32..c7e542276e 100644
--- a/docs/rfc/INDEX.md
+++ b/docs/rfc/INDEX.md
@@ -167,6 +167,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
| [Initiating Agent scope over AsyncLocalStorage](implemented/architecture/2026-07-15-agent-initiator-scope.md) | 2026-07-15 |
| [Advisory LLM catalogs and per-session ACP model selection](implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md) | 2026-07-15 |
| [Replay token meter service](implemented/architecture/2026-07-15-replay-token-meter-service.md) | 2026-07-15 |
+| [Package-owned invariant service seam](implemented/architecture/2026-07-19-package-owned-invariant-service.md) | 2026-07-19 |
### Process
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 aac3991f46..9e272456fc 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
@@ -28,11 +28,11 @@ This guarantee belongs in `Session`, not in an optional listener, because every
`deriveMessages()` projects logged surface events into detached, deep-frozen `Message` objects and returns a fresh array snapshot. Request assembly can therefore combine derived history with other inputs without exposing a path back into the log. The cache reuses safe immutable projections rather than recloning the complete history for each model call.
-### The invariants plugin checks relationships
+### Package-owned invariant companions check relationships
-`dsh-invariants` is a pure-listener development plugin. It does not freeze records and has no configuration; disposal removes only its assertions. It checks rules that require trace state or observation of another seam, including monotonic sequence numbers, turn and step nesting, tool-call/result pairing, legal agent-status transitions, subject-correct scoped dispatch, and equality between a loop-built request and the request reconstructed from its session-log prefix.
+`dsh-invariants` registers the configurable `ctx.invariants` service and contains no product checks. Optional `./invariant` companions from `dsh-session`, `dsh-agent`, `dsh-scope`, and `dsh-agent-loop` own the rules that require trace state or observation of another seam: monotonic sequence numbers, turn and step nesting, tool-call/result pairing, legal agent-status transitions, subject-correct scoped dispatch, and equality between a loop-built request and the request reconstructed from its session-log prefix. Global enablement and package-name regex filters belong to the service ([package-owned invariant service](2026-07-19-package-owned-invariant-service.md)).
-When the plugin attaches to an existing or seeded session, it replays the immutable log to rebuild trace state. This makes hot reload safe in the middle of a turn without giving the plugin ownership of session storage.
+When the session companion attaches to an existing or seeded session, it replays the immutable log to rebuild trace state. The service gives each contribution a disposable child fiber, so hot reload is safe in the middle of a turn without giving diagnostics ownership of session storage.
## Alternatives considered
@@ -53,6 +53,6 @@ Detaching `deriveMessages()` would protect the most common request path but leav
- Every accepted live or seeded session event is detached from caller-owned inputs and deeply immutable before any observer can receive it.
- `session.events` exposes stable immutable snapshots instead of the private growing array.
- Request-side mutation cannot reach stored history through derived messages.
-- Development builds can enable relational assertions without changing storage behavior, and disposing or omitting the plugin does not weaken log immutability.
-- `dsh-invariants` has no `Config` surface because it has no behavior to tune.
+- Development builds can enable relational assertions without changing storage behavior, and disposing or filtering a companion does not weaken log immutability.
+- `dsh-invariants` configures global enablement plus package allow/block regex lists; each check remains owned and tested by its product package.
- The runtime boundary carries a recursive snapshot-and-freeze cost once per accepted event; later readers and cached projections reuse the owned immutable records.
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 01e50da2ff..f1114c62de 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
@@ -10,7 +10,7 @@ Failures crossed seams as bare strings. A tool error flattened to a text block
A single `HarnessError extends Error` base in `dsh-llm` (the leaf package every other imports — no new dependency edge): a stable `code` distinct from `message`, `cause` chaining via `ErrorOptions`, and `name` defaulting to the subclass. `isHarnessError` narrows at seams.
-- `LlmError`, `ToolArgsError` (dsh-tools), and `InvariantError` (dsh-invariants) now extend it, keeping their existing codes.
+- `LlmError` and `ToolArgsError` (dsh-tools) extend it, keeping their existing codes.
- `ToolExecutionResult` gains optional `error: { name, code }`, populated in the registry's catch when the thrown value is a `HarnessError`. The agent loop forwards it onto the `tool/result` session event (which gained the same optional field), so the structured failure survives into the log for retry/sandbox plugins and replay. The model-facing text block is unchanged.
- The loop's `toError` wraps a non-Error throw in a `HarnessError` (`code: 'UNKNOWN'`, original chained as `cause`) instead of a bare `Error`, so even a bad throw carries a routable code into the session `error` event (which already surfaced `code`).
@@ -19,6 +19,6 @@ A single `HarnessError extends Error` base in `dsh-llm` (the leaf package every
- Errors are machine-routable end-to-end: a plugin can branch on `error.code` rather than substring-matching a message.
- 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.
-- Argument validation and dev invariants retain their existing codes and behavior; the shared base adds cross-seam routing metadata without changing model-facing text.
+- Argument validation retains its existing code and behavior; package-owned diagnostic invariants carry their stable code independently so the invariant registry does not import a product package. The shared base adds cross-seam routing metadata without changing model-facing text.
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 a54f735219..81b1bd25df 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
@@ -21,7 +21,7 @@ In case 2, if the injected `context/message` is the last event before a flush/di
- An `agent.inject()` made while the agent is **running** joins the already-open turn. While the current step executes assistant tool calls, accepted context waits in arrival order until that batch settles, then appends after every recorded result and before the turn closes even when execution is interrupted.
- An `agent.inject()` made while **idle** wraps its `context/message` in a one-shot turn: `turn/start{trigger:{kind:'injection'}}` → `context/message` → `turn/end{completed}`. A new `injection` variant joins the merge-extensible `TurnTriggerMap`.
- The loop derives the next turn number from the log each iteration (`lastTurnNumber(session) + 1`) instead of keeping a private counter, so an idle injection's one-shot turn cannot collide with the next real turn's number.
-- The `dsh-invariants` plugin **enforces** the invariant in dev: a `user/message` / `context/message` / `steering/message` appended while no turn is open throws an `InvariantError`.
+- The optional `dsh-session/invariant` companion registers the dev check with `ctx.invariants`: a `user/message` / `context/message` / `steering/message` appended while no turn is open throws an `InvariantError` attributed to `@deepseek-ai/dsh-session`.
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.
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 d639fe2917..55ba8cce4a 100644
--- a/docs/rfc/implemented/architecture/2026-06-18-session-surface.md
+++ b/docs/rfc/implemented/architecture/2026-06-18-session-surface.md
@@ -47,7 +47,7 @@ The `repair.ts` module synthesizes `tool/result` closers for orphaned tool calls
### Invariants
-The dev-mode invariants plugin validates: `sourceEventSeqs` references (only `assistant/message` may use an empty list; otherwise no duplicates, references earlier events, and references known seqs) and `surfaceOp` (replace `start ≤ end`, both endpoints are on the tracked surface, the range is non-reversed in surface position, and `sourceEventSeqs` includes every node the range shadows).
+`Session` validates `sourceEventSeqs` and `surfaceOp` at the always-on seed/append boundary: only `assistant/message` may use an empty provenance list; references are unique, earlier, and known; replacement endpoints exist in surface order; and provenance covers every shadowed node. These are single-record acceptance and storage-projection rules, not optional invariant-service contributions.
Every surface-eligible event must carry `surfaceOp` or it would disappear from derived history. Typed `append` overloads enforce this for literal event types; runtime checks in `append` and the seed constructor cover widened unions and loaded logs. Invalid seeds are rejected rather than upgraded under the pre-release format policy.
@@ -63,7 +63,6 @@ Every surface-eligible event must carry `surfaceOp` or it would disappear from d
- **`packages/core/session`**: `surface.ts` (`SurfaceManager`) maintains one ordered seq array for candidate acceptance and live projection; `SessionSurface` is its readonly public view. `SurfaceOp`/`SurfaceIntent` and the top-level session-event fields record how entries join it. `append()` requires a `SurfaceIntent` for surface events, `deriveMessages()` walks the surface as the sole derivation path, and `repair.ts` emits surface-aware closers. The seed constructor rejects a surface-eligible seed event missing its `surfaceOp` marker (see § Invariants).
- **`packages/core/agent-loop`**: All surface-capable appends pass surface opts. Chunk seqs are collected for `assistant/message` provenance; `tool/call` seqs are captured for `tool/result` provenance.
- **`packages/session-persistence/session-persistence-sqlite`**: Two new nullable TEXT columns (`source_event_seqs`, `surface_op`) on the `events` table; `SCHEMA_VERSION` bumped (bump-and-reject, no migration).
-- **`packages/support/invariants`**: Surface-related validation rules.
- **`packages/session-persistence/session-persistence-jsonl`**: No changes required.
- **`packages/session-persistence/session-persistence`**: Abstract interface unchanged.
diff --git a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md
index 9b53369086..e86d6b9610 100644
--- a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md
+++ b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md
@@ -26,7 +26,7 @@ Each step rebuilds prompt assembly. On the instance's first step, `agent/session
**`step/start` is the reconstruction boundary.** A step derives messages from events before that sequence. Injection after the snapshot joins the next request, and reentrant appends are rejected during event publication. `agent/pre-step(agent, turn, step, signal)` remains the generic seam for content needed by the current request. Header reconstruction selects the step's `request/header`, or carries the prior snapshot when no new header is written.
-**Enforcement.** In development, `dsh-invariants` independently rebuilds each loop request through a fresh `Session`, so the live cache cannot vouch for itself, then compares messages and folded header fields at `llm/stream`. Loop requests are identified by their frozen shape and session id; direct one-shots are excluded. Correctness depends on sequence-bounded reconstruction rather than listener order. A with-key e2e requires positive cache-read tokens after the first request; per-step usage is the production signal, and a header change or compaction appears as a cache-read drop on the next step.
+**Enforcement.** In development, the optional `dsh-agent-loop/invariant` companion registers with `ctx.invariants` and independently rebuilds each loop request through a fresh `Session`, so the live cache cannot vouch for itself, then compares messages and folded header fields at `llm/stream`. Loop requests are identified by their frozen shape and session id; direct one-shots are excluded. Correctness depends on sequence-bounded reconstruction rather than listener order. A with-key e2e requires positive cache-read tokens after the first request; per-step usage is the production signal, and a header change or compaction appears as a cache-read drop on the next step.
### The MiniCode shape: adopted, with the provenance arrow inverted
diff --git a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md
index 2c3126793f..e7e43cc1eb 100644
--- a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md
+++ b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md
@@ -322,7 +322,7 @@ TypeScript cannot govern JavaScript casts, direct Cordis dispatch, process messa
### Runtime invariants cover cross-service facts
-The invariants plugin verifies that every declared scoped event uses a marked carrier and that event families exposing a subject use the matching key. Session trace validation stages before append commit and advances after the same event commits.
+The optional `dsh-scope/invariant` companion verifies that every declared scoped event uses a marked carrier and that event families exposing a subject use the matching key. The separate `dsh-session/invariant` contribution stages trace validation before append commit and advances after the same event commits; both register through `ctx.invariants`.
The plugin does not police trusted setup by scanning registries or reject prompt assembly objects fabricated through casts. Those checks would turn composition contracts into speculative runtime machinery without protecting a real external boundary.
diff --git a/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml
new file mode 100644
index 0000000000..ce8fabdc40
--- /dev/null
+++ b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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-19-package-owned-invariant-service.md: f147cd91134509e17df39d1acbb28ae8b521c2f8
+2026-07-19-package-owned-invariant-service.zh.md: a106b57137c36009d0d4b4d39f109b7abf857f42
diff --git a/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.md b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.md
new file mode 100644
index 0000000000..f147cd9113
--- /dev/null
+++ b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.md
@@ -0,0 +1,97 @@
+# RFC: Package-owned invariant service seam
+
+Status: implemented
+
+English | [中文](2026-07-19-package-owned-invariant-service.zh.md)
+
+## Problem
+
+Runtime invariant checks span session traces, agent state, scoped dispatch, and request reconstruction. Putting all checks in one diagnostics package makes that package import product vocabularies from unrelated domains, centralizes tests away from their owners, and requires the central package to change whenever a product package adds or removes a check.
+
+Deployments also need more than presence or absence of one plugin. A standard composition should carry the known invariant contributions while permitting a global off switch and package-selective diagnostics. Selection must remain stable when a package loads later or reloads under HMR, and disabled contributions must not allow two plugins to claim the same package name silently.
+
+## Decision
+
+### One registry service, package-owned contributions
+
+`@deepseek-ai/dsh-invariants` is a product-independent Cordis service plugin that registers `ctx.invariants`. It owns configuration, registration uniqueness, child-fiber lifecycle, and package-attributed failures. It imports no session, agent, scope, or agent-loop package and contains none of their checks.
+
+Packages expose diagnostics through optional `./invariant` companion plugins. Their root entrypoints do not import or register diagnostics implicitly, so loading a product package does not change runtime checking or require the invariant service.
+
+### Configuration and selection
+
+```ts
+interface Config {
+ enabled?: boolean
+ package_allowlist?: string[]
+ package_blocklist?: string[]
+}
+```
+
+Defaults are `enabled: true`, `package_allowlist: []`, and `package_blocklist: []`. For a full registration name, selection is:
+
+```ts
+export function selected(enabled: boolean, package_allowlist: RegExp[], package_blocklist: RegExp[], packageName: string): boolean {
+ return enabled
+ && (
+ package_allowlist.length === 0
+ || package_allowlist.some(pattern => pattern.test(packageName))
+ )
+ && !package_blocklist.some(pattern => pattern.test(packageName))
+}
+```
+
+Blocklist matches override allowlist matches. Each list entry is a case-sensitive JavaScript regex source compiled by `new RegExp(pattern)`. Matching is unanchored unless callers supply `^` and `$`; slash-delimited syntax and flags are not interpreted. Startup rejects blank, whitespace-padded, invalid, or duplicate sources within either list. A source that matches no loaded package remains valid because registration order, later loading, and HMR must not change config validity.
+
+### Registration and failure ownership
+
+The public registration boundary is `ctx.invariants.register(packageName, installer)`. It reserves one active registration per full npm package name even when filters disable installation, and returns the effect disposer. Disposing the companion or service releases the reservation and all contribution state.
+
+An enabled installer runs in a dedicated child Cordis fiber owned by the service. `InvariantInstaller.inject` declares the child fiber's service surface explicitly; the registry carries no product-specific dependency metadata. The installer receives a bound `fail(message)` reporter. Calling it throws an `Error` subclass named `InvariantError` with stable code `INVARIANT` and the registering `packageName`; it does not extend a product-package error base.
+
+Registration setup is transactional. If an installer fails after registering listeners, the child fiber is disposed completely and the name reservation is released before the failure escapes. Filtered registrations create no child but retain their reservation until disposal. Reloading a companion therefore begins with one clean installer state; stateful contributions rebuild baselines from their owning services.
+
+The former functional-plugin entrypoint and one-argument `InvariantError` constructor are not retained as compatibility surfaces. The repository is pre-release and all call sites move to the service and package-attributed error together.
+
+### Shipped companions
+
+| Companion entry | Registration name | Owned checks |
+|---|---|---|
+| `@deepseek-ai/dsh-session/invariant` | `@deepseek-ai/dsh-session` | session sequence, turn/step enclosure, and same-step call/result trace |
+| `@deepseek-ai/dsh-agent/invariant` | `@deepseek-ai/dsh-agent` | agent-status transitions |
+| `@deepseek-ai/dsh-scope/invariant` | `@deepseek-ai/dsh-scope` | scoped-event carrier presence and subject consistency |
+| `@deepseek-ai/dsh-agent-loop/invariant` | `@deepseek-ai/dsh-agent-loop` | model-request reconstruction |
+
+Each owner contains its source and focused tests. Every companion is a separately bundled `./invariant` export with its own declarations and Loader-safe namespace plugin shape. The service package retains only registry tests.
+
+### Scoped-event semantic map
+
+The generated scoped-event subject resolver lives in `dsh-scope`, beside the contract and invariant that consume it. `gen-scoped-events` uses the root TypeScript Program to enumerate `this: Scoped` declarations, infer routing-key types from real `scopeTarget(base, key)` calls, and require one unambiguous payload subject or an explicit unsupported marker. The committed runtime map imports no event-owner package, so semantic completeness does not expand either the service or scope package's runtime closure.
+
+### Standard composition and SDK output
+
+The standard agent spine mounts the service and all four companion subpaths, forwarding `enabled`, `package_allowlist`, and `package_blocklist` to the service. Generated SDK Cordis composition emits the same entries. A subpath entry adds its installable root npm package rather than treating the subpath as a package name.
+
+Workspace constraints recognize the separate invariant bundle, and package exports, project references, build configuration, dependency declarations, and the lockfile describe the same publication surface. Generated config catalogs, module graphs, and API documentation derive from those sources.
+
+## Testing
+
+Service tests cover defaults, global disablement, allow/block selection, blocklist precedence, anchoring, unanchored matching, case sensitivity, invalid configuration, zero-match patterns, late registration, duplicate ownership, disposal, rollback, and HMR re-registration. Owner tests keep each invariant's positive and negative behavior beside its source.
+
+Composition tests cover standard-spine forwarding and generated SDK entries. Loader tests preserve each companion namespace, while built plain-Node smokes exercise the compiled subpath exports. The scoped-event freshness gate reruns its semantic Program analysis.
+
+## Alternatives considered
+
+- **Keep all checks in `dsh-invariants`.** Rejected because the registry would continue importing every checked product domain, owner changes would require central edits, and package tests would remain detached from the contracts they protect.
+- **Let root package entrypoints register checks implicitly when `ctx.invariants` happens to exist.** Rejected because root behavior would depend on composition order and optional service presence, diagnostics could not be selected independently, and package loading would hide a registration effect outside an explicit companion.
+- **Discover every `invariant.ts` file automatically.** Rejected because filesystem/package discovery is not a runtime ownership contract, makes bundled publication ambiguous, and cannot express explicit Cordis load order or dependency installation.
+- **Validate allow/block entries against the currently loaded package set.** Rejected because a zero-match pattern can intentionally target a later or HMR-loaded contribution; current load order must not determine config validity.
+
+## Consequences
+
+- Product packages own and test their relational assertions while the service stays product-independent.
+- Standard compositions can disable all checks or select package names without changing their plugin tree.
+- Explicit companion entries make diagnostic cost and ownership visible in Cordis config and package exports.
+- One selected contribution adds one child fiber and its listener/state cost; filtered registrations retain only name ownership.
+- Regex sources are deployment configuration and remain fixed until the service reloads.
+- Session storage validation, snapshotting, freezing, provenance, and surface acceptance remain always on and are not affected by invariant selection.
diff --git a/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md
new file mode 100644
index 0000000000..a106b57137
--- /dev/null
+++ b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md
@@ -0,0 +1,97 @@
+# RFC: 包拥有的不变式服务接缝
+
+Status: implemented
+
+[English](2026-07-19-package-owned-invariant-service.md) | 中文
+
+## 问题
+
+运行时不变式检查跨越会话轨迹、agent 状态、作用域 dispatch 和请求重建。如果所有检查都放在一个诊断包中,该包就必须导入彼此无关的产品领域词汇,测试也会离开真正的所有者;任何产品包新增或移除检查时,都要修改中央包。
+
+部署还需要比“是否加载一个插件”更细的控制。标准组合应携带已知的不变式贡献,同时允许全局关闭或按包选择诊断。包稍后加载或在 HMR 下重载时,选择结果必须保持稳定;被过滤的贡献也不能让两个插件静默占用同一个包名。
+
+## 决策
+
+### 一个注册服务,贡献归包所有
+
+`@deepseek-ai/dsh-invariants` 是与产品无关的 Cordis 服务插件,注册 `ctx.invariants`。它只负责配置、注册唯一性、子 fiber 生命周期和带包归属的失败;不导入 session、agent、scope 或 agent-loop 包,也不包含这些包的检查。
+
+各包通过可选的 `./invariant` 伴随插件公开诊断。根入口不会隐式导入或注册诊断,因此加载产品包本身不会改变运行时检查,也不要求不变式服务存在。
+
+### 配置与选择
+
+```ts
+interface Config {
+ enabled?: boolean
+ package_allowlist?: string[]
+ package_blocklist?: string[]
+}
+```
+
+默认值为 `enabled: true`、`package_allowlist: []` 和 `package_blocklist: []`。对完整注册名的选择规则为:
+
+```ts
+export function selected(enabled: boolean, package_allowlist: RegExp[], package_blocklist: RegExp[], packageName: string): boolean {
+ return enabled
+ && (
+ package_allowlist.length === 0
+ || package_allowlist.some(pattern => pattern.test(packageName))
+ )
+ && !package_blocklist.some(pattern => pattern.test(packageName))
+}
+```
+
+blocklist 匹配优先于 allowlist 匹配。每个条目都是区分大小写的 JavaScript 正则表达式源,通过 `new RegExp(pattern)` 编译。除非调用方提供 `^` 与 `$`,否则匹配不锚定;系统不会解析斜杠包围语法或 flags。服务启动会拒绝空白、首尾带空白、无效或同一列表内重复的源。没有匹配当前已加载包的有效源仍然合法,因为注册顺序、稍后加载和 HMR 不应改变配置有效性。
+
+### 注册与失败归属
+
+公开注册边界是 `ctx.invariants.register(packageName, installer)`。即使过滤器禁止安装,它也会为每个完整 npm 包名保留唯一的活跃注册,并返回 effect disposer。卸载伴随插件或服务都会释放注册名及全部贡献状态。
+
+启用的 installer 在服务拥有的独立 Cordis 子 fiber 中运行。`InvariantInstaller.inject` 显式声明该子 fiber 的服务表面;注册服务不携带产品专用依赖元数据。installer 接收绑定后的 `fail(message)` 报告器。调用它会抛出名为 `InvariantError` 的 `Error` 子类,保留稳定代码 `INVARIANT` 并记录注册方 `packageName`;该错误不继承产品包中的错误基类。
+
+注册启动是事务性的。如果 installer 在注册监听器后失败,子 fiber 会完整释放,并在失败向外传播前解除包名占用。被过滤的注册不创建子 fiber,但会保留占用直到 dispose。伴随插件重载时总会从干净的 installer 状态开始;有状态贡献从其所属服务重建基线。
+
+原有函数式插件入口与单参数 `InvariantError` 构造函数不作为兼容表面保留。仓库尚未发布,所有调用方会一起迁移到服务和带包归属的错误。
+
+### 已提供的伴随插件
+
+| 伴随入口 | 注册名 | 所属检查 |
+|---|---|---|
+| `@deepseek-ai/dsh-session/invariant` | `@deepseek-ai/dsh-session` | 会话序号、turn/step 包围关系和同 step 的 call/result 轨迹 |
+| `@deepseek-ai/dsh-agent/invariant` | `@deepseek-ai/dsh-agent` | agent 状态转换 |
+| `@deepseek-ai/dsh-scope/invariant` | `@deepseek-ai/dsh-scope` | scoped event carrier 存在性与主体一致性 |
+| `@deepseek-ai/dsh-agent-loop/invariant` | `@deepseek-ai/dsh-agent-loop` | 模型请求重建 |
+
+每个所有者都保存自己的源码与聚焦测试。每个伴随入口都是单独打包的 `./invariant` export,具有独立声明和对 Loader 安全的命名空间插件形态。服务包只保留注册服务测试。
+
+### Scoped event 语义映射
+
+生成的 scoped event 主体解析表位于 `dsh-scope`,与消费它的契约和不变式相邻。`gen-scoped-events` 使用根 TypeScript Program 枚举 `this: Scoped` 声明,从真实 `scopeTarget(base, key)` 调用推断路由键类型,并要求唯一、无歧义的 payload 主体或显式 unsupported 标记。提交的运行时映射不导入事件所有者包,因此语义完整性不会扩大服务包或 scope 包的运行时依赖闭包。
+
+### 标准组合与 SDK 输出
+
+标准 agent spine 会挂载服务和四个伴随子路径,并把 `enabled`、`package_allowlist` 与 `package_blocklist` 转发给服务。生成的 SDK Cordis 组合输出相同条目。子路径条目添加可安装的根 npm 包,而不会把子路径误当成包名。
+
+Workspace 约束识别独立的不变式 bundle;包 exports、项目引用、构建配置、依赖声明和 lockfile 描述同一发布表面。生成的配置目录、模块图和 API 文档都从这些源派生。
+
+## 测试
+
+服务测试覆盖默认值、全局关闭、allow/block 选择、blocklist 优先级、锚定与非锚定匹配、大小写敏感、无效配置、零匹配模式、延迟注册、重复所有权、dispose、回滚和 HMR 重新注册。所有者测试把各不变式的正向与负向行为保留在其源码旁边。
+
+组合测试覆盖标准 spine 转发和生成的 SDK 条目。Loader 测试固定每个伴随命名空间,构建后的纯 Node smoke 覆盖编译子路径 export。scoped event 新鲜度门禁会重新执行语义 Program 分析。
+
+## 考虑过的替代方案
+
+- **把所有检查保留在 `dsh-invariants`。** 不予采纳,因为注册包仍要导入所有被检查的产品领域,所有者变更仍需中央编辑,测试也继续远离被保护的契约。
+- **当 `ctx.invariants` 恰好存在时,让根包入口隐式注册检查。** 不予采纳,因为根入口行为会依赖组合顺序与可选服务是否存在,诊断无法独立选择,而且包加载会隐藏一个不在显式伴随插件中的注册 effect。
+- **自动发现所有 `invariant.ts` 文件。** 不予采纳,因为文件系统或包发现不是运行时所有权契约,会让 bundle 发布含义不清,也无法表达显式 Cordis 加载顺序或依赖安装。
+- **根据当前已加载包集合验证 allow/block 条目。** 不予采纳,因为零匹配模式可能有意指向稍后加载或 HMR 加载的贡献;当前加载顺序不能决定配置有效性。
+
+## 后果
+
+- 产品包拥有并测试自己的关系断言,服务保持与产品无关。
+- 标准组合无需改变插件树即可关闭全部检查或按包名选择。
+- 显式伴随条目让诊断成本和所有权在 Cordis 配置与包 export 中可见。
+- 每个选中贡献增加一个子 fiber 及其监听器和状态成本;被过滤注册只保留包名占用。
+- 正则表达式源属于部署配置,在服务重载前保持固定。
+- 会话存储验证、快照、冻结、provenance 与 surface 接受规则始终启用,不受不变式选择影响。
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 1d767d49fc..8e20795e40 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
@@ -8,7 +8,7 @@ A long-running agent conversation grows without bound. As the event log accumula
The [session surface](../../implemented/architecture/2026-06-18-session-surface.md) was built as the foundation for exactly this — an ordered projection over the event log with a `surfaceOp: { op: 'replace', start, end }` operation purpose-built to shadow a range of entries 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 policy and reusable token measurement vary independently: measurement belongs to the LLM-family [`ctx.tokenMeter` service](../../implemented/architecture/2026-07-15-replay-token-meter-service.md), while summarization can be a model call, a template, or a remote service. 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.
+Two forces shape the design. First, compaction policy and reusable token measurement vary independently: measurement belongs to the LLM-family [`ctx.tokenMeter` service](../../implemented/architecture/2026-07-15-replay-token-meter-service.md), while summarization can be a model call, a template, or a remote service. 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 and Session's always-on append/seed boundary reject `surfaceOp` on it.
## Decision
@@ -114,7 +114,7 @@ Two failure paths, both documented:
- **Automatic seams**: `agent/post-step` (`@mode serial`) handles successful-call pressure and `agent/request-error` (`@mode waterfall`) handles final request failures after the failed step closes. Generic `agent/pre-step` remains a four-argument checkpoint with no compaction-only prompt/prefix payload.
- **`SessionEventMap`** gains `compact/start` / `compact/summary` / `compact/end` by declaration merging (merge-extensible); `SurfaceEventType` is **not** touched. These are session events, not cordis `Events`, so the event-taxonomy gate needs no entry.
- **`dsh-compact`** owns `toolPairingBalancedBefore(session, seq)` and `toolPairingBalancedAfter(session, seq)`, the cached surface-edge checks that `compactRegion` and `compactIfNeeded` use to avoid splitting a tool-call/result pair. The cache validates current membership by seq and answers both edges from one per-cut balance sequence; stale or missing seqs and orphan results reject. `dsh-session` continues to own the surface `replace` operation, ordered event sequences, and rewrite generation.
-- **`dsh-invariants`** drops its `surface replace: start must be <= end` assertion: a head-anchored compaction lands a high-seq replacement entry at an older range's *position*, so `start > end` numerically is normal and valid (the range is positional, validated by the surface's `indexOf` checks that remain). The turn-enclosure invariant is reused unchanged.
+- **`dsh-session`** accepts `start > end` numerically when the named entries appear in valid surface position order: a head-anchored compaction can place a high-seq replacement entry at an older range's position. The optional session companion reuses turn enclosure unchanged.
- **Wiring**: `examples/repl-agent/cordis.yml` loads zero-config `dsh-token-meter` before `dsh-compact-basic`; the service-wide window and compact defaults make the pair usable without repeated numeric policy.
## Testing
diff --git a/docs/rfc/implemented/feature/2026-07-07-session-prefix.md b/docs/rfc/implemented/feature/2026-07-07-session-prefix.md
index 0c642e7c68..8fefb6726b 100644
--- a/docs/rfc/implemented/feature/2026-07-07-session-prefix.md
+++ b/docs/rfc/implemented/feature/2026-07-07-session-prefix.md
@@ -14,7 +14,7 @@ The obvious third option — let a plugin edit the request's `messages` on the w
Three properties carry the design:
-- **Request-only, header-logged.** `deriveMessages()` never returns the prefix; its one durable record is `EpochHeader.messagePrefix` on the instance's anchoring `request/header` snapshot — the channel the reconstructable-requests RFC already owns for the request's non-history half, so no new session event exists. The dev invariant ([dsh-invariants](../../../../packages/support/invariants/src/index.ts)) recomputes `messagePrefix + boundary derivation` against every loop-built request; an unlogged prefix cannot reach the wire.
+- **Request-only, header-logged.** `deriveMessages()` never returns the prefix; its one durable record is `EpochHeader.messagePrefix` on the instance's anchoring `request/header` snapshot — the channel the reconstructable-requests RFC already owns for the request's non-history half, so no new session event exists. The [`dsh-agent-loop/invariant`](../../../../packages/core/agent-loop/src/invariant.ts) companion recomputes `messagePrefix + boundary derivation` against every loop-built request; an unlogged prefix cannot reach the wire when that contribution is enabled.
- **Frozen per instance.** Reuse is structural, not disciplined: the cached product cannot change mid-session, so the provider's prompt cache holds by construction and the prefix extends the cacheable region at zero marginal cost per step. A process restart or `ctx.agents.resume()` is a new instance: it recomposes, and any drift lands attributably on the `'resume'` header snapshot. This is the routing rule the seam creates: session-frozen openers ride the prefix; content that changes mid-session rides the append-only history channels (`agent.inject()` or tool/prompt-submit `additionalContexts` — [the interception-seams RFC](2026-06-30-interception-seams.md)), each a durable `context/message` paid once and prefix-cached thereafter.
- **Exact in the durable request envelope.** Composition precedes the instance's first `agent/pre-step` and request boundary. The first routed request logs the current prefix on its header, so post-step token pressure reads the exact prefix together with the actual prompt, tools, and routed model; no compaction-only parameter is carried through the generic pre-step seam. A composition interrupted by cancel/dispose is discarded, never cached: an abort-aware listener's degraded fallback cannot leak into later requests, and the next turn recomposes under a live signal.
diff --git a/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.i18n.yaml b/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.i18n.yaml
index eb411f1158..72f9163933 100644
--- a/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.i18n.yaml
+++ b/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.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-14-typescript-program-backed-semantic-gates.md: 3e7a76e86d83080ae1a4f91ca97cc9749c90ef29
-2026-07-14-typescript-program-backed-semantic-gates.zh.md: 0a13452012e7f6cbd3ad7994845ba1985355c089
+2026-07-14-typescript-program-backed-semantic-gates.md: 427ffbf93a67a49664115f2376351c8da5afb9d1
+2026-07-14-typescript-program-backed-semantic-gates.zh.md: 56cba0141fdba98e0186093dca565674dd47cc9f
diff --git a/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md b/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md
index 3e7a76e86d..427ffbf93a 100644
--- a/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md
+++ b/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md
@@ -38,9 +38,9 @@ Every declared harness event must have a discovered producer. A missing producer
Exactly one match generates a resolver. Multiple matches are ambiguous and fail. Zero matches require `@dshScopeScan unsupported`, which is reserved for events whose routing key intentionally stays outside the payload, such as owner-keyed session events and parent-keyed subagent lifecycle events. The annotation records an unsupported scan; it does not encode an event name, parameter index, property path, or replacement type.
-The committed [`scoped-events.generated.ts`](../../../../packages/support/invariants/src/scoped-events.generated.ts) imports every scoped-event owner for its type-side `Events` contributions. Each generated lambda accepts `Parameters`, and the complete object satisfies a `Record` over the derived `ScopedEventName` union. Ordinary TypeScript compilation therefore checks event existence, parameter position, property access, and scoped-event completeness. The only cast adapts Cordis's runtime `unknown[]` dispatch boundary to the already type-checked resolver.
+The committed [`scoped-events.generated.ts`](../../../../packages/core/scope/src/scoped-events.generated.ts) is a runtime-only map in the package that owns scoped dispatch and imports no event-owner package. Semantic completeness lives in the generator: its root Program enumerates every scoped `Events` declaration and real `scopeTarget` contract, resolves the unique payload path with the checker, and refuses missing, stale, or ambiguous entries before rendering the `unknown[]` runtime boundary.
-The invariants plugin consumes this generated runtime map instead of maintaining its own table. Additional event-owner packages are dev dependencies and project references of `dsh-invariants`, not peer dependencies, so the compile-time aggregation does not expand the plugin's runtime closure.
+The `dsh-scope/invariant` companion consumes this map instead of maintaining a handwritten table. Because Program analysis happens in the repository gate rather than through generated type imports, neither `dsh-scope` nor `dsh-invariants` acquires dependencies on every event owner.
### Semantic gaps fail explicitly
@@ -48,7 +48,7 @@ The generators reject missing declarations, config diagnostics, widened or gener
## Verification
-`verify-doc-graphs` freshness-checks semantic producer/listener discovery, and `verify-scoped-events` freshness-checks the generated resolver map. The root TypeScript build compiles the resolver against merged `Events`; workspace constraints and runtime-closure checks ensure its type-only aggregation does not become a deployment dependency.
+`verify-doc-graphs` freshness-checks semantic producer/listener discovery, and `verify-scoped-events` reruns the Program analysis while freshness-checking the generated resolver map. The root TypeScript build compiles its runtime adapter; workspace constraints and runtime-closure checks keep event-owner aggregation out of deployment dependencies.
## Alternatives considered
@@ -58,6 +58,6 @@ The generators reject missing declarations, config diagnostics, widened or gener
- Event relation generation follows semantic receiver identity and closed event values instead of local naming conventions.
- Scoped-event membership, subject extraction, and runtime invariant coverage come from event declarations and real dispatch contracts rather than handwritten tables.
-- Refactors that change event names, parameter positions, subject properties, or routing-key types fail generation or compilation at the owning contract.
+- Refactors that change event names, parameter positions, subject properties, or routing-key types fail generation at the owning contract.
- Building a flattened Program costs more startup time and memory than parsing isolated files, and semantic gates depend on a valid root project graph.
- Generated TypeScript remains committed source: changes to event owners or dispatch shapes must regenerate it and the affected documentation.
diff --git a/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.zh.md b/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.zh.md
index 0a13452012..56cba0141f 100644
--- a/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.zh.md
+++ b/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.zh.md
@@ -38,9 +38,9 @@ Context 与 AgentEventDispatch 调用只贡献有限的字符串字面量事件
恰好一个匹配项会生成解析函数。存在多个匹配项时,含义不明确,生成器会失败。没有匹配项时,事件必须标记 `@dshScopeScan unsupported`;该标记只用于路由键有意留在事件参数之外的情况,例如按所属 agent(智能体)路由的会话事件和按父 agent 路由的 subagent 生命周期事件。此标记只表示扫描不受支持,不编码事件名、参数下标、属性路径或替代类型。
-仓库提交的 [`scoped-events.generated.ts`](../../../../packages/support/invariants/src/scoped-events.generated.ts) 会导入每个带作用域的事件声明方,使它们从类型侧合并进 `Events`。每个生成函数都接收 `Parameters`,完整对象则满足基于 `ScopedEventName` 联合类型派生出的 `Record`。因此,常规 TypeScript 编译会检查事件是否存在、参数位置、属性访问和带作用域的事件集合完整性。唯一的类型断言只负责将 Cordis 运行时的 `unknown[]` dispatch 边界适配到已经通过类型检查的解析函数。
+仓库提交的 [`scoped-events.generated.ts`](../../../../packages/core/scope/src/scoped-events.generated.ts) 是位于 scoped dispatch 所属包中的纯运行时映射,不导入任何事件声明方包。语义完整性由生成器自身保证:根 Program 枚举所有 scoped `Events` 声明与真实 `scopeTarget` 契约,通过 checker 解析唯一的 payload 路径,并在渲染 `unknown[]` 运行时边界前拒绝缺失、陈旧或含义不明确的条目。
-不变式插件消费这份生成的运行时表,不再维护自己的事件表。新增的事件声明方包只作为 `dsh-invariants` 的开发依赖和项目引用存在,不进入对等依赖,因此编译期聚合不会扩大插件的运行时依赖闭包。
+`dsh-scope/invariant` companion 消费这份映射,不再维护手写事件表。Program 分析发生在仓库门禁内,而不是依赖生成的类型导入,因此 `dsh-scope` 和 `dsh-invariants` 都不需要依赖所有事件声明方。
### 语义缺口必须显式失败
@@ -48,7 +48,7 @@ Context 与 AgentEventDispatch 调用只贡献有限的字符串字面量事件
## 验证
-`verify-doc-graphs` 对语义生产方/监听方扫描执行新鲜度检查,`verify-scoped-events` 对生成的解析函数表执行新鲜度检查。根 TypeScript 构建会将解析函数与合并后的 `Events` 一起编译;workspace 约束和运行时依赖闭包检查则确保仅参与类型聚合的依赖不会变成部署依赖。
+`verify-doc-graphs` 对语义生产方/监听方扫描执行新鲜度检查;`verify-scoped-events` 会重新运行 Program 分析,并检查生成映射的新鲜度。根 TypeScript 构建编译其运行时适配器;workspace 约束与运行时依赖闭包检查确保事件声明方聚合不会进入部署依赖。
## 考虑过的替代方案
@@ -58,6 +58,6 @@ Context 与 AgentEventDispatch 调用只贡献有限的字符串字面量事件
- 事件关系生成依据语义接收者身份和封闭事件值,不再依赖局部命名约定;
- 带作用域的事件成员关系、主体提取和运行时不变式覆盖来自事件声明与真实 dispatch 契约,不再来自手写表;
-- 修改事件名、参数位置、主体属性或路由键类型时,会在其所属契约处触发生成或编译失败;
+- 修改事件名、参数位置、主体属性或路由键类型时,会在其所属契约处触发生成失败;
- 构建扁平化 Program 比解析孤立文件消耗更多启动时间和内存,语义门禁也依赖有效的根项目图;
- 生成的 TypeScript 仍属于提交到仓库的源码:事件声明方或 dispatch 形态发生变化后,必须重新生成该文件和受影响的文档。
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 9eb4c585a4..5db00851ae 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
@@ -28,7 +28,7 @@ A migration of the event/vocabulary surface to runtime schemas touches, at minim
- **Six merge-extensible maps** (~370 LOC of core types): `ContentBlockMap`, `MessageSourceMap`, `FinishReasonMap` (in `dsh-llm`); `TurnTriggerMap`, `TurnEndReasonMap`, `SessionEventMap` (in `dsh-session`).
- **~10 `declare module` augmentation sites** across `dsh-agent`, `dsh-agent-loop`, `dsh-bash`, `dsh-llm`, `dsh-session`, `dsh-session-persistence`, `dsh-system-prompt`, `dsh-tools` — each would move from declaration merging to a runtime `register()` call.
- **The event producers** — 16 `session.append(...)` call sites in the loop — unchanged in shape but now validated at the boundary.
-- **~7 switch-consumers** that branch on these unions: `deriveMessages` (`dsh-session`), `BlockAssembler` (`dsh-llm`), the `dsh-invariants` plugin, both LLM adapters (`dsh-llm-deepseek`, `dsh-llm-pi-ai`), and the tool schema layer (`dsh-tools`). The `assertNever`-on-closed-unions vs fall-through-on-extensible-unions convention (a documented lint rule) would need rethinking — runtime variants are not statically exhaustive.
+- **~7 switch-consumers** that branch on these unions: `deriveMessages` and the optional invariant companion (`dsh-session`), `BlockAssembler` (`dsh-llm`), both LLM adapters (`dsh-llm-deepseek`, `dsh-llm-pi-ai`), and the tool schema layer (`dsh-tools`). The `assertNever`-on-closed-unions vs fall-through-on-extensible-unions convention (a documented lint rule) would need rethinking — runtime variants are not statically exhaustive.
- **The `defineTool` `InferArgs` DSL** (`dsh-tools`), which derives zero-cast `execute` arg types from a compile-time schema spec — the showcase of the current approach.
- **Docs**: architecture.md (the pattern is described as foundational), [dev-mode invariants](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md), and any RFC that references the pattern.
@@ -37,7 +37,7 @@ This is a repository-wide vocabulary redesign, not a persistence implementation
## 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.
+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 and is enforced by TypeScript at compile time. Package-owned invariant companions check selected cross-record relationships in dev but do not provide general runtime shape schemas.
- **Pros**: zero churn; plugin extension is a one-line `interface` augmentation with full type inference and no runtime registration ceremony; no new runtime dependency; the `defineTool` DSL and `assertNever` exhaustiveness keep working.
- **Cons**: no runtime structural validation at the persistence boundary or at plugin seams; a malformed-but-JSON datum is caught late.
diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts
index 1327f073c5..0410940127 100644
--- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts
+++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts
@@ -8,7 +8,10 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
-import * as Invariants from '@deepseek-ai/dsh-invariants'
+import InvariantService from '@deepseek-ai/dsh-invariants'
+import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
+import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
+import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
import { SessionId, type SurfaceEvent } from '@deepseek-ai/dsh-session'
@@ -95,10 +98,17 @@ class OverflowRecoveryAdapter extends LlmAdapter {
}
}
+async function mountInvariants(ctx: Context): Promise {
+ await ctx.plugin(InvariantService)
+ await ctx.plugin(SessionInvariant)
+ await ctx.plugin(AgentInvariant)
+ await ctx.plugin(AgentLoopInvariant)
+}
+
async function harness(toolSteps: number): Promise<{ ctx: Context; compact: ReproCompactService }> {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
- await ctx.plugin(Invariants)
+ await mountInvariants(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(TokenMeterService, { contextWindow: 400 })
ctx.llm.registerAdapter(['mock'], new StepwiseToolAdapter(toolSteps))
@@ -223,7 +233,7 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
const ctx = new Context()
const adapter = new OverflowRecoveryAdapter(delivery)
await mountAgentLoopTestDependencies(ctx)
- await ctx.plugin(Invariants)
+ await mountInvariants(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(TokenMeterService, { contextWindow: 128 })
ctx.llm.registerAdapter(['mock'], adapter)
diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts
index 52f8477318..9cbfd2a59b 100644
--- a/packages/cordis/tool-cordis/src/api-catalog.ts
+++ b/packages/cordis/tool-cordis/src/api-catalog.ts
@@ -250,6 +250,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
+ {
+ key: 'invariants',
+ summary: 'Package-owned invariant registry with global and regex-based selection.',
+ methods: [
+ {
+ signature: 'register(packageName: string, installer: InvariantInstaller): () => void',
+ jsDoc: '/**\n * Register one package\'s invariant installer. The package name is reserved\n * even when filtering disables its checks. Enabled installers run in a child\n * fiber; failure disposes that fiber and releases the reservation.\n * @param packageName - full npm package name that owns the contribution.\n * @param installer - synchronous listener installer for the child context.\n * @returns an effect-scoped disposer for the registration.\n */',
+ },
+ ],
+ },
{
key: 'llm',
summary: 'The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall.',
@@ -1176,6 +1186,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'InjectOptions',
declaration: 'export interface InjectOptions extends SendOptions {\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n}',
},
+ {
+ name: 'InvariantFailure',
+ declaration: 'export type InvariantFailure = (message: string) => never;',
+ },
+ {
+ name: 'InvariantInstaller',
+ declaration: 'export interface InvariantInstaller {\n (ctx: Context, fail: InvariantFailure): void;\n readonly inject?: Inject;\n}',
+ },
{
name: 'JsonValue',
declaration: 'export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n};',
diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md
index d604e2b16f..a015230ce0 100644
--- a/packages/core/agent-loop/README.md
+++ b/packages/core/agent-loop/README.md
@@ -27,6 +27,10 @@ The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loo
`agents`, `sessions`, `llm`, `tools`, `systemPrompt` — all five interface services.
+### Invariant companion
+
+The optional `@deepseek-ai/dsh-agent-loop/invariant` companion registers request reconstruction with `ctx.invariants`. For each frozen loop-built request carrying a live session id, it independently rebuilds the message boundary and folded request header from the session log; direct one-shot calls remain outside this marker contract.
+
### Configuration (schemastery)
```ts
diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json
index 7e2fb235a2..8e9a2b93bb 100644
--- a/packages/core/agent-loop/package.json
+++ b/packages/core/agent-loop/package.json
@@ -11,10 +11,15 @@
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
+ "./invariant": {
+ "types": "./lib/types/invariant.d.ts",
+ "default": "./lib/invariant.js"
+ },
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
+ "lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
@@ -22,6 +27,7 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
+ "@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
diff --git a/packages/core/agent-loop/src/invariant.ts b/packages/core/agent-loop/src/invariant.ts
new file mode 100644
index 0000000000..41b9112a4e
--- /dev/null
+++ b/packages/core/agent-loop/src/invariant.ts
@@ -0,0 +1,73 @@
+/**
+ * Package-owned request-reconstruction invariant for loop-built LLM calls.
+ * @module @deepseek-ai/dsh-agent-loop/invariant
+ */
+
+import type { Context } from 'cordis'
+import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
+import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+import { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-agent-loop'
+
+/** Cordis companion plugin name. */
+export const name = 'agent-loop-invariant'
+/** Services required before the companion can register. */
+export const inject = ['invariants', 'sessions']
+
+/** Install the request-reconstruction contribution into its child registration fiber. */
+const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
+ // Prepend prevents a short-circuiting replay listener from silencing the
+ // check; correctness itself comes from the sequence-bounded reconstruction.
+ ctx.on('llm/stream', (options: GenerateOptions, next) => {
+ if (options.sessionId === undefined || !Object.isFrozen(options)) return next()
+ const session = ctx.sessions.get(options.sessionId)
+ if (!session) return next()
+ if (!Object.isFrozen(options.messages)) {
+ fail('a loop-built request must carry a frozen messages array')
+ }
+
+ const events = session.events
+ let boundary = -1
+ for (let index = events.length - 1; index >= 0; index -= 1) {
+ if (events[index]?.type === 'step/start') {
+ boundary = index
+ break
+ }
+ }
+ if (boundary === -1) {
+ return fail('a loop-built request with no step/start in its session log')
+ }
+ const header = foldRequestHeader(events)
+ if (header === undefined) {
+ return fail('a loop-built request with no request/header event in its session log')
+ }
+ const rebuilt = new Session(
+ SessionId(`${String(session.id)}-invariant-rebuild`),
+ structuredClone(events.slice(0, boundary)),
+ )
+ const expected = [...header.messagePrefix ?? [], ...rebuilt.deriveMessages()]
+ if (JSON.stringify(options.messages) !== JSON.stringify(expected)) {
+ fail(`llm request for session "${String(session.id)}" diverges from the boundary derivation (log-reconstruction desync)`)
+ }
+
+ const headerMatches = options.model === header.config.model
+ && options.system === header.system
+ && options.temperature === header.config.temperature
+ && options.maxTokens === header.config.maxTokens
+ && JSON.stringify(options.stop) === JSON.stringify(header.config.stop)
+ && JSON.stringify(options.tools ?? []) === JSON.stringify(header.tools ?? [])
+ if (!headerMatches) {
+ fail(`llm request for session "${String(session.id)}" diverges from the folded request header`)
+ }
+ return next()
+ }, { global: true, prepend: true })
+}, { inject: ['sessions'] })
+
+/**
+ * Register the agent-loop invariant companion.
+ * @param ctx - Cordis context carrying the invariant and session services.
+ * @returns the installed registration's disposer after setup succeeds.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+ Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts
index 80d085c32b..0146efbd1b 100644
--- a/packages/core/agent-loop/tests/contract-regressions.spec.ts
+++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts
@@ -7,9 +7,19 @@ import ToolRegistry, { defineTool, type PostToolDecision } from '@deepseek-ai/ds
import AgentRegistry, { type Agent, type ContinuationDecision } from '@deepseek-ai/dsh-agent'
import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop'
import { prepareReactLoopAgent } from '../src/agent.ts'
-import * as Invariants from '@deepseek-ai/dsh-invariants'
+import InvariantService from '@deepseek-ai/dsh-invariants'
+import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
+import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
+import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
import { maxTokensResponse, MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
+async function mountInvariants(ctx: Context): Promise {
+ await ctx.plugin(InvariantService)
+ await ctx.plugin(SessionInvariant)
+ await ctx.plugin(AgentInvariant)
+ await ctx.plugin(AgentLoopInvariant)
+}
+
function driverDone(agent: Agent): Promise {
return (agent as Agent & { done: Promise }).done
}
@@ -144,7 +154,7 @@ describe('successful provider completion survives agent/step-result failure', ()
): Promise {
const adapter = new MockAdapter([response])
const ctx = await harness(adapter)
- await ctx.plugin(Invariants)
+ await mountInvariants(ctx)
const agent = ctx.agentLoop.create(SessionId(id), { provider: 'mock', model: 'mock' })
const failure = new Error(`${id} result processing failed`)
const reported: Error[] = []
@@ -1014,7 +1024,7 @@ describe('step boundary publication order', () => {
})
describe('turn and step boundary recovery', () => {
- // The invariants plugin makes an unbalanced log fail the test.
+ // The session invariant companion makes an unbalanced log fail the test.
async function balancedHarness(adapter: MockAdapter) {
const ctx = new Context()
await ctx.plugin(LlmService)
@@ -1023,7 +1033,7 @@ describe('turn and step boundary recovery', () => {
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
- await ctx.plugin(Invariants)
+ await mountInvariants(ctx)
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
}
@@ -1437,7 +1447,7 @@ describe('surface: assistant/message records exact empty provenance when no chun
// stream from legacy events whose provenance was not recorded.
const adapter = new MockAdapter([[]])
const ctx = await harness(adapter)
- await ctx.plugin(Invariants)
+ await mountInvariants(ctx)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/step-result', async (_agent, _turn, _step, _message, _next) => ({
@@ -1474,7 +1484,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
- await ctx.plugin(Invariants)
+ await mountInvariants(ctx)
ctx.llm.registerAdapter(['mock'], adapter)
// Parent-owned listener survives agent-fiber disposal.
@@ -1525,7 +1535,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
- await ctx.plugin(Invariants)
+ await mountInvariants(ctx)
ctx.llm.registerAdapter(['mock'], adapter)
const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, _context, next) {
@@ -1580,7 +1590,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
- await ctx.plugin(Invariants)
+ await mountInvariants(ctx)
ctx.llm.registerAdapter(['mock'], adapter)
ctx.on('agent/pre-step', async () => {
@@ -1631,7 +1641,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
- await ctx.plugin(Invariants)
+ await mountInvariants(ctx)
ctx.llm.registerAdapter(['mock'], adapter)
ctx.on('agent/pre-step', async () => {
@@ -1680,7 +1690,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
- await ctx.plugin(Invariants)
+ await mountInvariants(ctx)
ctx.llm.registerAdapter(['mock'], adapter)
ctx.on('system-prompt/assemble', async function (_assembly, _context, next) {
diff --git a/packages/core/agent-loop/tests/invariant.spec.ts b/packages/core/agent-loop/tests/invariant.spec.ts
new file mode 100644
index 0000000000..a61cda6f99
--- /dev/null
+++ b/packages/core/agent-loop/tests/invariant.spec.ts
@@ -0,0 +1,103 @@
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import InvariantService from '@deepseek-ai/dsh-invariants'
+import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
+
+async function setup(): Promise {
+ const ctx = new Context()
+ await ctx.plugin(SessionStore)
+ await ctx.plugin(InvariantService)
+ await ctx.plugin(AgentLoopInvariant)
+ return ctx
+}
+
+function dispatch(ctx: Context, options: unknown): void {
+ void ctx.waterfall('llm/stream', options as never, () => (async function* () {})() as never)
+}
+
+async function requestSetup() {
+ const ctx = await setup()
+ const session = ctx.sessions.create(SessionId('req-check'))
+ session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+ session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
+ const boundary = session.deriveMessages()
+ session.append('step/start', { turn: 1, step: 1 })
+ session.append('request/header', { header: { config: { provider: 'mock', model: 'm' } }, reason: 'initial' })
+ return { ctx, session, boundary }
+}
+
+describe('request-reconstruction invariant', () => {
+ it('accepts a frozen request equal to the boundary derivation and folded header', async () => {
+ const { ctx, session, boundary } = await requestSetup()
+ const options = Object.freeze({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id })
+ expect(() => { dispatch(ctx, options) }).not.toThrow()
+ })
+
+ it('uses the step boundary rather than content appended afterward', async () => {
+ const { ctx, session, boundary } = await requestSetup()
+ session.append('context/message', { content: [{ type: 'text', text: '[late]' }], source: { kind: 'plugin', plugin: 'x' } }, { surfaceOp: 'append' })
+ const options = Object.freeze({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id })
+ expect(() => { dispatch(ctx, options) }).not.toThrow()
+ })
+
+ it('requires the folded session prefix ahead of derived history', async () => {
+ const { ctx, session, boundary } = await requestSetup()
+ const prefix = { role: 'user' as const, content: [{ type: 'text' as const, text: 'catalog' }] }
+ session.append('request/header', { header: { config: { provider: 'mock', model: 'm' }, messagePrefix: [prefix] }, reason: 'change' })
+ expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([prefix, ...boundary]), sessionId: session.id })) })
+ .not.toThrow()
+ expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([...boundary]), sessionId: session.id })) })
+ .toThrow(/diverges from the boundary derivation/)
+ expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([...boundary, prefix]), sessionId: session.id })) })
+ .toThrow(/diverges from the boundary derivation/)
+ })
+
+ it('rejects message and header divergence', async () => {
+ const { ctx, session, boundary } = await requestSetup()
+ const divergent = [...boundary, { role: 'user', content: [{ type: 'text', text: 'phantom' }] }]
+ expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze(divergent), sessionId: session.id })) })
+ .toThrow(/diverges from the boundary derivation/)
+ expect(() => { dispatch(ctx, Object.freeze({ model: 'other', messages: Object.freeze(boundary), sessionId: session.id })) })
+ .toThrow(/diverges from the folded request header/)
+ })
+
+ it('rejects loop requests with no boundary or header', async () => {
+ const ctx = await setup()
+ const session = ctx.sessions.create(SessionId('req-bare'))
+ session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+ const bare = Object.freeze({ model: 'm', messages: Object.freeze([]), sessionId: session.id })
+ expect(() => { dispatch(ctx, bare) }).toThrow(/no step\/start/)
+ session.append('step/start', { turn: 1, step: 1 })
+ expect(() => { dispatch(ctx, bare) }).toThrow(/no request\/header event/)
+ })
+
+ it('rejects an unfrozen messages array but skips requests outside the loop contract', async () => {
+ const { ctx, session, boundary } = await requestSetup()
+ expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: [...boundary], sessionId: session.id })) })
+ .toThrow(/frozen messages array/)
+ expect(() => { dispatch(ctx, { model: 'summarizer', messages: [], sessionId: session.id }) }).not.toThrow()
+ expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([]) })) }).not.toThrow()
+ expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([]), sessionId: SessionId('ghost') })) })
+ .not.toThrow()
+ })
+
+ it('prepends ahead of a short-circuiting stream listener', async () => {
+ const ctx = new Context()
+ await ctx.plugin(SessionStore)
+ ctx.on('llm/stream', () => (async function* () {})() as never)
+ await ctx.plugin(InvariantService)
+ await ctx.plugin(AgentLoopInvariant)
+ const session = ctx.sessions.create(SessionId('prepend-check'))
+ session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+ session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
+ session.append('step/start', { turn: 1, step: 1 })
+ session.append('request/header', { header: { config: { provider: 'mock', model: 'm' } }, reason: 'initial' })
+ const divergent = Object.freeze({
+ model: 'm',
+ messages: Object.freeze([{ role: 'user', content: [{ type: 'text', text: 'phantom' }] }]),
+ sessionId: session.id,
+ })
+ expect(() => { dispatch(ctx, divergent) }).toThrow(/diverges from the boundary derivation/)
+ })
+})
diff --git a/packages/core/agent-loop/tests/turn-stop.spec.ts b/packages/core/agent-loop/tests/turn-stop.spec.ts
index 355e1e8e3d..736ada7013 100644
--- a/packages/core/agent-loop/tests/turn-stop.spec.ts
+++ b/packages/core/agent-loop/tests/turn-stop.spec.ts
@@ -7,9 +7,19 @@ import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent, type ContinuationStop } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
-import * as Invariants from '@deepseek-ai/dsh-invariants'
+import InvariantService from '@deepseek-ai/dsh-invariants'
+import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
+import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
+import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
+async function mountInvariants(ctx: Context): Promise {
+ await ctx.plugin(InvariantService)
+ await ctx.plugin(SessionInvariant)
+ await ctx.plugin(AgentInvariant)
+ await ctx.plugin(AgentLoopInvariant)
+}
+
async function harness(adapter: MockAdapter): Promise {
const ctx = new Context()
await ctx.plugin(LlmService)
@@ -17,7 +27,7 @@ async function harness(adapter: MockAdapter): Promise {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
- await ctx.plugin(Invariants)
+ await mountInvariants(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
diff --git a/packages/core/agent-loop/tsconfig.json b/packages/core/agent-loop/tsconfig.json
index 5d7cf98bb7..0949f18453 100644
--- a/packages/core/agent-loop/tsconfig.json
+++ b/packages/core/agent-loop/tsconfig.json
@@ -37,6 +37,9 @@
},
{
"path": "../../core/scope"
+ },
+ {
+ "path": "../../support/invariants"
}
]
}
diff --git a/packages/core/agent-loop/tsdown.config.ts b/packages/core/agent-loop/tsdown.config.ts
new file mode 100644
index 0000000000..e92275a7f5
--- /dev/null
+++ b/packages/core/agent-loop/tsdown.config.ts
@@ -0,0 +1,25 @@
+import { defineConfig } from 'tsdown'
+
+/** Build the package root and optional invariant companion as independent bundles. */
+export default defineConfig([
+ {
+ entry: ['lib/types/index.js'],
+ outDir: 'lib',
+ format: ['esm'],
+ platform: 'node',
+ target: 'es2024',
+ fixedExtension: false,
+ dts: false,
+ clean: false,
+ },
+ {
+ entry: ['lib/types/invariant.js'],
+ outDir: 'lib',
+ format: ['esm'],
+ platform: 'node',
+ target: 'es2024',
+ fixedExtension: false,
+ dts: false,
+ clean: false,
+ },
+])
diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md
index bf5e563443..574d865792 100644
--- a/packages/core/agent/README.md
+++ b/packages/core/agent/README.md
@@ -2,6 +2,8 @@
Agent interface, registry, process-local initiator scope, and `agent/*` event vocabulary. Every plugin (UI, hooks, orchestrators) programs against the `Agent` handle defined here — it has zero loop dependency, so the loop is swappable.
+The optional `@deepseek-ai/dsh-agent/invariant` companion registers this package's agent-status transition checks with `ctx.invariants`. The root agent service does not load diagnostics implicitly.
+
## Service: `AgentRegistry` (ctx key: `agents`)
Tracks live agents and carries the initiating Agent through asynchronous driver work without importing the concrete loop package.
diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json
index bcf75ea7ca..f40a5da441 100644
--- a/packages/core/agent/package.json
+++ b/packages/core/agent/package.json
@@ -11,11 +11,16 @@
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
+ "./invariant": {
+ "types": "./lib/types/invariant.d.ts",
+ "default": "./lib/invariant.js"
+ },
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
+ "lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
@@ -23,6 +28,7 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
+ "@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
@@ -31,6 +37,7 @@
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
+ "@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
diff --git a/packages/core/agent/src/invariant.ts b/packages/core/agent/src/invariant.ts
new file mode 100644
index 0000000000..1902f3e746
--- /dev/null
+++ b/packages/core/agent/src/invariant.ts
@@ -0,0 +1,35 @@
+/** Package-owned agent lifecycle invariants. @module @deepseek-ai/dsh-agent/invariant */
+
+import type { Context } from 'cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-agent'
+
+/** Cordis companion plugin name. */
+export const name = 'agent-invariant'
+/** Services required before the companion can register. */
+export const inject = ['invariants']
+
+/** Install the agent contribution into its child registration fiber. */
+const install: InvariantInstaller = (ctx, fail) => {
+ const lastStatus = new WeakMap()
+ ctx.on('agent/status', (agent, status) => {
+ const previous = lastStatus.get(agent)
+ if (previous === status) {
+ fail(`agent/status repeated ${status} (no-op transition)`)
+ }
+ if (previous === 'disposed') {
+ fail(`agent/status left terminal state disposed → ${status}`)
+ }
+ lastStatus.set(agent, status)
+ }, { global: true })
+}
+
+/**
+ * Register the agent invariant companion.
+ * @param ctx - Cordis context carrying the invariant service.
+ * @returns the installed registration's disposer after setup succeeds.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+ Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
diff --git a/packages/core/agent/tests/invariant.spec.ts b/packages/core/agent/tests/invariant.spec.ts
new file mode 100644
index 0000000000..3c0d147b9a
--- /dev/null
+++ b/packages/core/agent/tests/invariant.spec.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
+import { scopeTarget } from '@deepseek-ai/dsh-scope'
+import InvariantService from '@deepseek-ai/dsh-invariants'
+
+async function setup(): Promise {
+ const ctx = new Context()
+ await ctx.plugin(InvariantService)
+ await ctx.plugin(AgentInvariant)
+ return ctx
+}
+
+function mockAgent(id: string): Agent {
+ return { id } as unknown as Agent
+}
+
+describe('agent status invariants', () => {
+ it('accepts lifecycle transitions through idle, running, and disposed', async () => {
+ const ctx = await setup()
+ const agent = mockAgent('a1')
+ expect(() => {
+ ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle')
+ ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running')
+ ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle')
+ ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'disposed')
+ }).not.toThrow()
+
+ const running = mockAgent('a2')
+ ctx.emit(scopeTarget(running, running), 'agent/status', running, 'running')
+ expect(() => { ctx.emit(scopeTarget(running, running), 'agent/status', running, 'disposed') }).not.toThrow()
+ })
+
+ it('rejects a no-op transition', async () => {
+ const ctx = await setup()
+ const agent = mockAgent('a3')
+ ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running')
+ expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') })
+ .toThrow(/no-op transition/)
+ })
+
+ it('rejects leaving the terminal disposed state', async () => {
+ const ctx = await setup()
+ const agent = mockAgent('a4')
+ ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'disposed')
+ expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') })
+ .toThrow(/left terminal state disposed/)
+ })
+
+ it('tracks agents independently', async () => {
+ const ctx = await setup()
+ const a = mockAgent('a5')
+ const b = mockAgent('b5')
+ ctx.emit(scopeTarget(a, a), 'agent/status', a, 'running')
+ expect(() => { ctx.emit(scopeTarget(b, b), 'agent/status', b, 'running') }).not.toThrow()
+ })
+})
diff --git a/packages/core/agent/tsconfig.json b/packages/core/agent/tsconfig.json
index 2692e1b7f7..b6d6c9e6bf 100644
--- a/packages/core/agent/tsconfig.json
+++ b/packages/core/agent/tsconfig.json
@@ -28,6 +28,9 @@
},
{
"path": "../../core/system-prompt"
+ },
+ {
+ "path": "../../support/invariants"
}
]
}
diff --git a/packages/core/agent/tsdown.config.ts b/packages/core/agent/tsdown.config.ts
new file mode 100644
index 0000000000..e92275a7f5
--- /dev/null
+++ b/packages/core/agent/tsdown.config.ts
@@ -0,0 +1,25 @@
+import { defineConfig } from 'tsdown'
+
+/** Build the package root and optional invariant companion as independent bundles. */
+export default defineConfig([
+ {
+ entry: ['lib/types/index.js'],
+ outDir: 'lib',
+ format: ['esm'],
+ platform: 'node',
+ target: 'es2024',
+ fixedExtension: false,
+ dts: false,
+ clean: false,
+ },
+ {
+ entry: ['lib/types/invariant.js'],
+ outDir: 'lib',
+ format: ['esm'],
+ platform: 'node',
+ target: 'es2024',
+ fixedExtension: false,
+ dts: false,
+ clean: false,
+ },
+])
diff --git a/packages/core/scope/README.md b/packages/core/scope/README.md
index 36cc059bc4..3ee979bda0 100644
--- a/packages/core/scope/README.md
+++ b/packages/core/scope/README.md
@@ -13,6 +13,8 @@ Scoped registration primitive. `createScope(ctx, key)` creates a tagged Cordis c
- `Scoped` The compile-time opaque carrier brand: scope-filtered events demand it as their `this` type, so dispatching with a bare subject is a compile error. The type parameter records the subject type but does not expose its properties.
- `isScopeCarrier(value)` / `carrierKeyOf(value)` Runtime carrier marks, used by the dev invariants to assert every scope-filtered dispatch carries a carrier keyed to the subject its arguments name.
+The optional `@deepseek-ai/dsh-scope/invariant` companion owns that runtime assertion. It uses the generated `scoped-events.generated.ts` resolver map to require a carrier for every declared scoped event and, when the payload exposes its routing subject, require identity with the carrier key. The Program-backed generator derives the map from event declarations and real `scopeTarget(base, key)` calls.
+
## Design contract
The registration context determines both visibility and ownership, preventing a registration from being visible in one scope but disposed with another. Scopes route trusted same-process plugins; they are not sandboxes or authority boundaries. See the [agent-scope RFC](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals) for rationale and security non-goals.
diff --git a/packages/core/scope/package.json b/packages/core/scope/package.json
index 88d78ceb8b..4679e2755a 100644
--- a/packages/core/scope/package.json
+++ b/packages/core/scope/package.json
@@ -11,20 +11,27 @@
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
+ "./invariant": {
+ "types": "./lib/types/invariant.d.ts",
+ "default": "./lib/invariant.js"
+ },
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
+ "lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
+ "@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
+ "@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}
diff --git a/packages/core/scope/src/invariant.ts b/packages/core/scope/src/invariant.ts
new file mode 100644
index 0000000000..a5bd59f263
--- /dev/null
+++ b/packages/core/scope/src/invariant.ts
@@ -0,0 +1,41 @@
+/** Package-owned scoped-dispatch invariants. @module @deepseek-ai/dsh-scope/invariant */
+
+import type { Context } from 'cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+import { carrierKeyOf, isScopeCarrier } from '@deepseek-ai/dsh-scope'
+import { scopedSubjectResolverFor } from './scoped-events.generated.ts'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-scope'
+
+/** Cordis companion plugin name. */
+export const name = 'scope-invariant'
+/** Services required before the companion can register. */
+export const inject = ['invariants']
+
+/** Install the scoped-dispatch contribution into its child registration fiber. */
+const install: InvariantInstaller = (ctx, fail) => {
+ ctx.on('internal/dispatch', (_mode, eventName, args, thisArg) => {
+ const subjectOf = scopedSubjectResolverFor(eventName)
+ if (subjectOf === undefined) return
+ if (!isScopeCarrier(thisArg)) {
+ fail(
+ `"${eventName}" is a scope-filtered event but was dispatched without a scope carrier — `
+ + 'pass scopeTarget(base, subject) as the dispatch thisArg (agent events: use agentEvents(ctx, agent))',
+ )
+ }
+ if (subjectOf !== null && carrierKeyOf(thisArg) !== subjectOf(args)) {
+ fail(
+ `"${eventName}" was dispatched with a scope carrier keyed to a DIFFERENT subject than its arguments name — `
+ + 'the carrier key and the event\'s subject must be the same object (use agentEvents(ctx, agent))',
+ )
+ }
+ }, { global: true })
+}
+
+/**
+ * Register the scope invariant companion.
+ * @param ctx - Cordis context carrying the invariant service.
+ * @returns the installed registration's disposer after setup succeeds.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+ Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
diff --git a/packages/core/scope/src/scoped-events.generated.ts b/packages/core/scope/src/scoped-events.generated.ts
new file mode 100644
index 0000000000..4e23706fb3
--- /dev/null
+++ b/packages/core/scope/src/scoped-events.generated.ts
@@ -0,0 +1,50 @@
+/**
+ * Generated scoped-event routing-subject resolvers for dsh-scope invariants.
+ * Do not edit by hand; run `pnpm run gen-scoped-events`.
+ *
+ * @module @deepseek-ai/dsh-scope/scoped-events.generated
+ */
+
+type ScopedSubjectResolver = (args: readonly unknown[]) => unknown
+
+const scopedSubjectResolvers: Readonly> = Object.freeze({
+ 'agent/created': args => args[0],
+ 'agent/disposed': args => args[0],
+ 'agent/error': args => args[0],
+ 'agent/post-step': args => args[0],
+ 'agent/pre-step': args => args[0],
+ 'agent/prompt-submit': args => args[0],
+ 'agent/queued': args => args[0],
+ 'agent/request': args => args[0],
+ 'agent/request-error': args => args[0],
+ 'agent/session-prefix': args => args[0],
+ 'agent/session-start': args => args[0],
+ 'agent/status': args => args[0],
+ 'agent/step-result': args => args[0],
+ 'agent/turn-continuation': args => args[0],
+ 'agent/turn-stop': args => args[0],
+ 'approval/request': args => (args[0] as Record)['agent'],
+ 'session/created': null,
+ 'session/disposed': null,
+ 'session/event': null,
+ 'session/flush': null,
+ 'subagent/end': null,
+ 'subagent/start': null,
+ 'system-prompt/assemble': args => (args[1] as Record)['scope'],
+ 'tools/execute': args => (args[0] as Record)['agent'],
+ 'tools/post-execute': args => (args[0] as Record)['agent'],
+ 'tools/pre-execute': args => (args[0] as Record)['agent'],
+ 'tools/result': args => (args[0] as Record)['agent'],
+})
+
+/**
+ * Resolve the routing key named by one scoped event payload. A null
+ * resolver means the payload cannot expose its external routing key, so the
+ * invariant checks carrier presence only.
+ * @param event - runtime Cordis event name.
+ * @returns the generated subject resolver, null for presence-only,
+ * or undefined when the event is not scope-filtered.
+ */
+export function scopedSubjectResolverFor(event: string): ScopedSubjectResolver | null | undefined {
+ return scopedSubjectResolvers[event]
+}
diff --git a/packages/core/scope/tests/invariant.spec.ts b/packages/core/scope/tests/invariant.spec.ts
new file mode 100644
index 0000000000..6e39ab2025
--- /dev/null
+++ b/packages/core/scope/tests/invariant.spec.ts
@@ -0,0 +1,81 @@
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import { scopeTarget } from '@deepseek-ai/dsh-scope'
+import * as ScopeInvariant from '@deepseek-ai/dsh-scope/invariant'
+import InvariantService from '@deepseek-ai/dsh-invariants'
+
+async function setup(): Promise {
+ const ctx = new Context()
+ await ctx.plugin(InvariantService)
+ await ctx.plugin(ScopeInvariant)
+ return ctx
+}
+
+function emit(ctx: Context, receiver: object | undefined, event: string, args: unknown[]): void {
+ const dispatch = ctx.emit.bind(ctx) as (...values: unknown[]) => void
+ if (receiver === undefined) dispatch(event, ...args)
+ else dispatch(receiver, event, ...args)
+}
+
+describe('scoped-dispatch invariants', () => {
+ it('ignores ordinary events and rejects a scoped dispatch without a carrier', async () => {
+ const ctx = await setup()
+ expect(() => { emit(ctx, undefined, 'ordinary/event', []) }).not.toThrow()
+ const agent = { id: 'a1' }
+ expect(() => { emit(ctx, undefined, 'agent/error', [agent, 1, 0, new Error('x')]) })
+ .toThrow(/dispatched without a scope carrier/)
+ })
+
+ it('checks every generated subject resolver against the carrier key', async () => {
+ const ctx = await setup()
+ const agent = { id: 'a1' }
+ const other = { id: 'a2' }
+ const rows: Array<[string, unknown[]]> = [
+ ['agent/created', [agent]],
+ ['agent/disposed', [agent]],
+ ['agent/error', [agent, 1, 0, new Error('x')]],
+ ['agent/post-step', [agent, 1, 1]],
+ ['agent/pre-step', [agent, 1, 1, new AbortController().signal]],
+ ['agent/prompt-submit', [agent, [], { kind: 'user' }, () => Promise.resolve({ kind: 'allow' })]],
+ ['agent/queued', [agent, [], { source: { kind: 'user' }, steering: false }]],
+ ['agent/request', [agent, 1, 1, { model: 'm' }, () => Promise.resolve({ model: 'm' })]],
+ ['agent/request-error', [agent, 1, 1, new Error('x')]],
+ ['agent/session-prefix', [agent, [], new AbortController().signal, () => Promise.resolve([])]],
+ ['agent/session-start', [agent, 'startup']],
+ ['agent/status', [agent, 'idle']],
+ ['agent/step-result', [agent, 1, 1, { role: 'assistant', content: [] }, () => Promise.resolve({ role: 'assistant', content: [] })]],
+ ['agent/turn-continuation', [agent, 1, { action: 'stop' }, () => Promise.resolve({ action: 'stop' })]],
+ ['agent/turn-stop', [agent, 1]],
+ ['approval/request', [{ agent, toolName: 'echo' }, () => Promise.resolve('unavailable')]],
+ ['system-prompt/assemble', [[], { scope: agent }]],
+ ['tools/execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ content: [], isError: false })]],
+ ['tools/post-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }, () => Promise.resolve({ kind: 'accept' })]],
+ ['tools/pre-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ kind: 'allow' })]],
+ ['tools/result', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }]],
+ ]
+
+ for (const [event, args] of rows) {
+ expect(() => { emit(ctx, scopeTarget(agent, agent), event, args) }, `${event} matching`).not.toThrow()
+ expect(() => { emit(ctx, scopeTarget(agent, other), event, args) }, `${event} mismatched`)
+ .toThrow(/DIFFERENT subject/)
+ }
+ })
+
+ it('requires carriers for generated presence-only scoped events without comparing a payload subject', async () => {
+ const ctx = await setup()
+ const agent = { id: 'a1' }
+ const rows: Array<[string, unknown[]]> = [
+ ['session/created', [{}]],
+ ['session/disposed', [{}]],
+ ['session/event', [{}, {}]],
+ ['session/flush', [{}]],
+ ['subagent/end', [{}]],
+ ['subagent/start', [{}]],
+ ]
+ for (const [event, args] of rows) {
+ expect(() => { emit(ctx, scopeTarget(agent, agent), event, args) }, `${event} carrier`).not.toThrow()
+ expect(() => { emit(ctx, undefined, event, args) }, `${event} no carrier`)
+ .toThrow(/dispatched without a scope carrier/)
+ }
+ })
+})
diff --git a/packages/core/scope/tsconfig.json b/packages/core/scope/tsconfig.json
index 754725418e..9966c8ca8a 100644
--- a/packages/core/scope/tsconfig.json
+++ b/packages/core/scope/tsconfig.json
@@ -13,6 +13,9 @@
},
{
"path": "../../../vendor/cordis"
+ },
+ {
+ "path": "../../support/invariants"
}
]
}
diff --git a/packages/core/scope/tsdown.config.ts b/packages/core/scope/tsdown.config.ts
new file mode 100644
index 0000000000..1dbbf38372
--- /dev/null
+++ b/packages/core/scope/tsdown.config.ts
@@ -0,0 +1,27 @@
+import { defineConfig } from 'tsdown'
+
+/** Build the package root and optional invariant companion as independent bundles. */
+export default defineConfig([
+ {
+ entry: ['lib/types/index.js'],
+ outDir: 'lib',
+ format: ['esm'],
+ platform: 'node',
+ target: 'es2024',
+ fixedExtension: false,
+ dts: false,
+ clean: false,
+ },
+ {
+ entry: ['lib/types/invariant.js'],
+ outDir: 'lib',
+ format: ['esm'],
+ platform: 'node',
+ target: 'es2024',
+ fixedExtension: false,
+ dts: false,
+ clean: false,
+ // Preserve the root entry's carrier WeakMap identity across bundles.
+ deps: { neverBundle: ['@deepseek-ai/dsh-scope'] },
+ },
+])
diff --git a/packages/core/session/README.md b/packages/core/session/README.md
index ba4e8ea94a..ee46c17590 100644
--- a/packages/core/session/README.md
+++ b/packages/core/session/README.md
@@ -2,6 +2,8 @@
Event-sourced session log and in-memory store. A `Session` is the append-only source of truth for an agent's whole interaction history — the LLM message history is *derived* from it. A **surface** layer (an ordered projection of message-producing events) is maintained on top of the raw log for efficient derivation and compaction.
+The optional `@deepseek-ai/dsh-session/invariant` companion registers this package's relational trace checks with `ctx.invariants`: monotonic sequence numbers, turn/step enclosure, and same-step tool call/result pairing. It replays existing sessions when loaded or reloaded; storage validation, snapshotting, freezing, provenance, and surface acceptance remain always-on responsibilities of the root session package.
+
## Service: `SessionStore` (ctx key: `sessions`)
Creates and holds event-sourced `Session` instances. Persistence is intentionally not implemented here — plugins subscribe to `session/event`, flush on `session/flush`, and may mirror the paired `session/created`/`session/disposed` lifecycle.
@@ -34,7 +36,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
- `session.append(type, data, opts?)` snapshots and freezes durable data and surface metadata, validates marker shape, provenance, and complete replacement coverage, commits synchronously, then notifies observers with independent failure containment. Reentrant attached-session appends reject, and runtime checks cover widened unions and loaded logs.
- `session.deriveMessages()` incrementally projects each new surface entry once and returns a fresh array over shared frozen messages. Assistant projections preserve provider/model provenance and adapter-private replay state. A surface rewrite rebuilds the projection; there is no raw-log fallback.
-- `session.deriveEventMessage(event)` is the canonical per-event projection used by reconstruction and invariants.
+- `session.deriveEventMessage(event)` is the canonical per-event projection used by reconstruction and request checks.
- `session.surface` exposes the readonly `SessionSurface` view owned by the session's single incremental surface manager; `replaceGeneration` changes on every committed rewrite.
- `session.events` is a cached frozen snapshot invalidated by append; accepted events remain deeply frozen.
- `session.seq`, `session.id` — current sequence and readonly typed identity.
diff --git a/packages/core/session/package.json b/packages/core/session/package.json
index 540c5cdc75..314eb6e0b5 100644
--- a/packages/core/session/package.json
+++ b/packages/core/session/package.json
@@ -11,11 +11,16 @@
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
+ "./invariant": {
+ "types": "./lib/types/invariant.d.ts",
+ "default": "./lib/invariant.js"
+ },
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
+ "lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
@@ -23,12 +28,14 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
+ "@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
+ "@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"cordis": "^4.0.0-rc.7"
diff --git a/packages/core/session/src/invariant.ts b/packages/core/session/src/invariant.ts
new file mode 100644
index 0000000000..2887d6f9f0
--- /dev/null
+++ b/packages/core/session/src/invariant.ts
@@ -0,0 +1,230 @@
+/**
+ * Package-owned relational invariants for the session event log. Load this
+ * companion beside `@deepseek-ai/dsh-invariants` to enable the checks.
+ *
+ * @module @deepseek-ai/dsh-session/invariant
+ */
+
+import type { Context } from 'cordis'
+import { assertNever } from '@deepseek-ai/dsh-llm'
+import type { CallId } from '@deepseek-ai/dsh-llm'
+import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-session'
+
+/** Cordis companion plugin name. */
+export const name = 'session-invariant'
+/** Services required before the companion can register. */
+export const inject = ['invariants', 'sessions']
+
+/** Per-session bookkeeping for relational log checks. */
+interface SessionTrace {
+ lastSeq: number
+ openTurn: number | null
+ openStep: number | null
+ nextTurn: number
+ nextStep: number
+ pendingCalls: Set
+}
+
+/** One accepted event's deferred mutation of a committed session trace. */
+interface SessionTraceTransition {
+ scalars: Pick
+ pendingCalls:
+ | { kind: 'none' }
+ | { kind: 'add' | 'delete'; callId: CallId }
+ | { kind: 'clear' }
+}
+
+/** Assert that a step-scoped event names the currently open turn and step. */
+function requireOpenStep(
+ trace: SessionTrace,
+ kind: string,
+ turn: number,
+ step: number,
+ fail: InvariantFailure,
+): void {
+ if (trace.openTurn !== turn || trace.openStep !== step) {
+ fail(`${kind} names turn ${turn}/step ${step} but open is turn ${trace.openTurn}/step ${trace.openStep}`)
+ }
+}
+
+/** Validate one candidate event without mutating the committed trace. */
+function validateEvent(
+ trace: SessionTrace,
+ event: SessionEvent,
+ fail: InvariantFailure,
+): SessionTraceTransition {
+ if (event.seq <= trace.lastSeq) {
+ fail(`seq must strictly increase: saw ${event.seq} after ${trace.lastSeq}`)
+ }
+ let openTurn = trace.openTurn
+ let openStep = trace.openStep
+ let nextTurn = trace.nextTurn
+ let nextStep = trace.nextStep
+ let pendingCalls: SessionTraceTransition['pendingCalls'] = { kind: 'none' }
+
+ // SessionEventMap is merge-extensible, so the default enforces turn
+ // enclosure for package-added events as well as the built-in variants.
+ switch (event.type) {
+ case 'turn/start': {
+ if (trace.openTurn !== null) {
+ fail(`turn/start ${event.data.turn} while turn ${trace.openTurn} is still open`)
+ }
+ if (event.data.turn !== trace.nextTurn) {
+ fail(`turn/start expected turn ${trace.nextTurn}, got ${event.data.turn}`)
+ }
+ openTurn = event.data.turn
+ nextStep = 1
+ break
+ }
+ case 'turn/end': {
+ if (trace.openTurn !== event.data.turn) {
+ fail(`turn/end ${event.data.turn} does not match open turn ${trace.openTurn}`)
+ }
+ if (trace.openStep !== null) {
+ fail(`turn/end ${event.data.turn} while step ${trace.openStep} is still open`)
+ }
+ openTurn = null
+ nextTurn += 1
+ break
+ }
+ case 'step/start': {
+ if (trace.openTurn !== event.data.turn) {
+ fail(`step/start in turn ${event.data.turn} but open turn is ${trace.openTurn}`)
+ }
+ if (trace.openStep !== null) {
+ fail(`step/start ${event.data.step} while step ${trace.openStep} is still open`)
+ }
+ if (event.data.step !== trace.nextStep) {
+ fail(`step/start expected step ${trace.nextStep} in turn ${event.data.turn}, got ${event.data.step}`)
+ }
+ openStep = event.data.step
+ break
+ }
+ case 'step/end': {
+ requireOpenStep(trace, 'step/end', event.data.turn, event.data.step, fail)
+ pendingCalls = { kind: 'clear' }
+ openStep = null
+ nextStep += 1
+ break
+ }
+ case 'assistant/chunk': {
+ requireOpenStep(trace, 'assistant/chunk', event.data.turn, event.data.step, fail)
+ break
+ }
+ case 'assistant/message': {
+ requireOpenStep(trace, 'assistant/message', event.data.turn, event.data.step, fail)
+ break
+ }
+ case 'tool/call': {
+ requireOpenStep(trace, 'tool/call', event.data.turn, event.data.step, fail)
+ pendingCalls = { kind: 'add', callId: event.data.callId }
+ break
+ }
+ case 'tool/result': {
+ requireOpenStep(trace, 'tool/result', event.data.turn, event.data.step, fail)
+ const syntheticInterrupted = event.data.isError && event.data.error?.code === 'interrupted'
+ if (!trace.pendingCalls.has(event.data.callId) && !syntheticInterrupted) {
+ fail(`tool/result for ${event.data.callId} with no prior tool/call in this step`)
+ }
+ pendingCalls = { kind: 'delete', callId: event.data.callId }
+ break
+ }
+ default: {
+ if (trace.openTurn === null) {
+ fail(`${event.type} appended outside any open turn (every event must be turn-enclosed)`)
+ }
+ break
+ }
+ }
+ return {
+ scalars: { lastSeq: event.seq, openTurn, openStep, nextTurn, nextStep },
+ pendingCalls,
+ }
+}
+
+/** Apply one already-validated transition after its event commits. */
+function applyTransition(trace: SessionTrace, transition: SessionTraceTransition): void {
+ Object.assign(trace, transition.scalars)
+ switch (transition.pendingCalls.kind) {
+ case 'none':
+ break
+ case 'add':
+ trace.pendingCalls.add(transition.pendingCalls.callId)
+ break
+ case 'delete':
+ trace.pendingCalls.delete(transition.pendingCalls.callId)
+ break
+ case 'clear':
+ trace.pendingCalls.clear()
+ break
+ /* v8 ignore next -- validateEvent produces this closed transition union */
+ default:
+ assertNever(transition.pendingCalls, 'session trace pending-call transition')
+ }
+}
+
+/** Install the session contribution into its child registration fiber. */
+const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
+ const traces = new WeakMap()
+ const stagedTransitions = new WeakMap()
+
+ const freshTrace = (): SessionTrace => ({
+ lastSeq: -1,
+ openTurn: null,
+ openStep: null,
+ nextTurn: 1,
+ nextStep: 1,
+ pendingCalls: new Set(),
+ })
+
+ const seedSession = (session: Session): SessionTrace => {
+ const trace = freshTrace()
+ traces.set(session, trace)
+ for (const event of session.events) {
+ applyTransition(trace, validateEvent(trace, event, fail))
+ }
+ return trace
+ }
+
+ /* v8 ignore next -- session/event always follows list() or session/created seeding */
+ const traceFor = (session: Session): SessionTrace => traces.get(session) ?? seedSession(session)
+
+ for (const session of ctx.sessions.list()) seedSession(session)
+
+ ctx.on('session/created', (session) => { seedSession(session) }, { global: true })
+
+ ctx.on('session/event', (session, event) => {
+ const staged = stagedTransitions.get(event)
+ /* v8 ignore next 2 -- internal/dispatch stages the exact callback arguments */
+ if (staged === undefined || staged.session !== session) {
+ return fail('session/event reached publication without matching pre-commit validation')
+ }
+ stagedTransitions.delete(event)
+ applyTransition(staged.trace, staged.transition)
+ }, { global: true })
+
+ ctx.on('internal/dispatch', (_mode, eventName, args) => {
+ if (eventName !== 'session/event') return
+ const [session, event] = args as [Session, SessionEvent]
+ const trace = traceFor(session)
+ const transition = validateEvent(trace, event, fail)
+ // A later dispatch listener may veto. Validation is pure, so abandoning
+ // this weakly keyed transition does not advance or retain the session.
+ stagedTransitions.set(event, { session, trace, transition })
+ }, { global: true })
+}, { inject: ['sessions'] })
+
+/**
+ * Register the session invariant companion.
+ * @param ctx - Cordis context carrying the invariant and session services.
+ * @returns the installed registration's disposer after setup succeeds.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+ Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
diff --git a/packages/core/session/tests/invariant.spec.ts b/packages/core/session/tests/invariant.spec.ts
new file mode 100644
index 0000000000..71f6dc9ace
--- /dev/null
+++ b/packages/core/session/tests/invariant.spec.ts
@@ -0,0 +1,268 @@
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import { createScope, scopeTarget } from '@deepseek-ai/dsh-scope'
+import { CallId } from '@deepseek-ai/dsh-llm'
+import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
+import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants'
+
+async function setup(): Promise<{ ctx: Context; fiber: Awaited> }> {
+ const ctx = new Context()
+ await ctx.plugin(SessionStore)
+ await ctx.plugin(InvariantService)
+ const fiber = await ctx.plugin(SessionInvariant)
+ return { ctx, fiber }
+}
+
+describe('session-log invariants', () => {
+ it('keeps registration global when the companion is mounted under a scope', async () => {
+ const ctx = new Context()
+ await ctx.plugin(SessionStore)
+ await ctx.plugin(InvariantService)
+ let scopedCtx!: Context
+ await ctx.plugin(Object.assign((inner: Context) => {
+ scopedCtx = createScope(inner, {}).ctx
+ }, { inject: ['sessions', 'invariants'] }))
+ await scopedCtx.plugin(SessionInvariant)
+ const session = ctx.sessions.create(SessionId('global-under-scoped-invariants'))
+ expect(() => {
+ session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+ session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+ }).not.toThrow()
+ })
+
+ it('accepts a well-formed turn, step, and tool sequence', async () => {
+ const { ctx } = await setup()
+ const session = ctx.sessions.create()
+ expect(() => {
+ session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+ session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
+ session.append('step/start', { turn: 1, step: 1 })
+ session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } })
+ session.append('assistant/message', {
+ provenance: { provider: 'mock', model: 'mock' },
+ turn: 1,
+ step: 1,
+ content: [{ type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' }],
+ }, { surfaceOp: 'append' })
+ session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' })
+ session.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [], isError: false }, { surfaceOp: 'append' })
+ session.append('step/end', { turn: 1, step: 1 })
+ session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+ }).not.toThrow()
+ })
+
+ it('does not advance committed trace state when a later dispatch listener vetoes', async () => {
+ const { ctx } = await setup()
+ const session = ctx.sessions.create(SessionId('dispatch-veto-rollback'))
+ let veto = true
+ ctx.on('internal/dispatch', (_mode, name) => {
+ if (name !== 'session/event' || !veto) return
+ veto = false
+ throw new Error('later dispatch veto')
+ })
+ expect(() => session.append('turn/start', {
+ turn: 1,
+ trigger: { kind: 'message', source: { kind: 'user' } },
+ })).toThrow('later dispatch veto')
+ expect(session.events).toEqual([])
+ expect(() => {
+ session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+ session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+ }).not.toThrow()
+ })
+
+ it('applies the committed transition after another postcommit observer throws', async () => {
+ const { ctx } = await setup()
+ const warnings: string[] = []
+ ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
+ const session = ctx.sessions.create(SessionId('postcommit-peer'))
+ ctx.on('session/event', () => { throw new Error('hostile observer') }, { prepend: true })
+ expect(() => {
+ session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+ session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+ }).not.toThrow()
+ expect(warnings).toHaveLength(2)
+ })
+
+ it('rejects non-monotonic event sequence numbers', async () => {
+ const { ctx } = await setup()
+ const session = ctx.sessions.create()
+ ctx.emit(scopeTarget(session, undefined), 'session/event', session, {
+ type: 'turn/start',
+ seq: 0,
+ time: 1,
+ data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
+ } as never)
+ expect(() => { ctx.emit(scopeTarget(session, undefined), 'session/event', session, {
+ type: 'turn/end',
+ seq: 0,
+ time: 2,
+ data: { turn: 1, reason: { kind: 'completed' } },
+ } as never) }).toThrow(/seq must strictly increase/)
+ })
+
+ it('enforces turn numbering and enclosure', async () => {
+ const first = await setup()
+ const open = first.ctx.sessions.create()
+ open.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+ expect(() => open.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }))
+ .toThrow(/turn 1 is still open/)
+ expect(() => open.append('turn/end', { turn: 2, reason: { kind: 'completed' } }))
+ .toThrow(/does not match open turn 1/)
+
+ const second = (await setup()).ctx.sessions.create()
+ second.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+ second.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+ expect(() => second.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } }))
+ .toThrow(/expected turn 2, got 3/)
+
+ const outside = (await setup()).ctx.sessions.create()
+ expect(() => outside.append('user/message', {
+ content: [{ type: 'text', text: 'hi' }],
+ source: { kind: 'user' },
+ }, { surfaceOp: 'append' })).toThrow(/outside any open turn/)
+ expect(() => outside.append('steering/message', {
+ turn: 1,
+ content: [{ type: 'text', text: 'go' }],
+ source: { kind: 'user' },
+ }, { surfaceOp: 'append' })).toThrow(/outside any open turn/)
+ // Merge-extensible session events use the same default enclosure branch.
+ const appendUnknown = outside.append.bind(outside) as (type: string, data: unknown) => unknown
+ expect(() => { appendUnknown('plugin/marker', {}) }).toThrow(/outside any open turn/)
+ })
+
+ it('enforces open-step identity and numbering', async () => {
+ const wrongTurn = (await setup()).ctx.sessions.create()
+ wrongTurn.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+ expect(() => wrongTurn.append('step/start', { turn: 2, step: 1 })).toThrow(/open turn is 1/)
+
+ const nested = (await setup()).ctx.sessions.create()
+ nested.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+ nested.append('step/start', { turn: 1, step: 1 })
+ expect(() => nested.append('step/start', { turn: 1, step: 2 })).toThrow(/while step 1 is still open/)
+ expect(() => nested.append('turn/end', { turn: 1, reason: { kind: 'completed' } }))
+ .toThrow(/while step 1 is still open/)
+ expect(() => nested.append('step/end', { turn: 1, step: 2 })).toThrow(/open is turn 1\/step 1/)
+ expect(() => nested.append('assistant/message', {
+ provenance: { provider: 'mock', model: 'mock' },
+ turn: 1,
+ step: 2,
+ content: [],
+ }, { surfaceOp: 'append' })).toThrow(/open is turn 1\/step 1/)
+
+ const skipped = (await setup()).ctx.sessions.create()
+ skipped.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+ skipped.append('step/start', { turn: 1, step: 1 })
+ skipped.append('step/end', { turn: 1, step: 1 })
+ expect(() => skipped.append('step/start', { turn: 1, step: 3 }))
+ .toThrow(/expected step 2 in turn 1, got 3/)
+ })
+
+ it('requires step-scoped stream and tool events to name the open step', async () => {
+ const chunk = (await setup()).ctx.sessions.create()
+ chunk.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+ expect(() => chunk.append('assistant/chunk', {
+ turn: 1,
+ step: 1,
+ chunk: { type: 'text-delta', index: 0, text: 'x' },
+ })).toThrow(/open is turn 1\/step null/)
+
+ const tool = (await setup()).ctx.sessions.create()
+ tool.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+ tool.append('step/start', { turn: 1, step: 1 })
+ expect(() => tool.append('tool/result', {
+ turn: 1,
+ step: 1,
+ callId: CallId('ghost'),
+ content: [],
+ isError: false,
+ }, { surfaceOp: 'append' })).toThrow(/no prior tool\/call/)
+ })
+
+ it('allows interrupted repair results and unresolved calls at step end', async () => {
+ const repaired = (await setup()).ctx.sessions.create()
+ expect(() => {
+ repaired.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+ repaired.append('step/start', { turn: 1, step: 1 })
+ repaired.append('tool/result', {
+ turn: 1,
+ step: 1,
+ callId: CallId('crashed'),
+ content: [],
+ isError: true,
+ error: { name: 'InterruptedError', code: 'interrupted' },
+ }, { surfaceOp: 'append' })
+ repaired.append('step/end', { turn: 1, step: 1 })
+ repaired.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } })
+ }).not.toThrow()
+
+ const unresolved = (await setup()).ctx.sessions.create()
+ expect(() => {
+ unresolved.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+ unresolved.append('step/start', { turn: 1, step: 1 })
+ unresolved.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' })
+ unresolved.append('step/end', { turn: 1, step: 1 })
+ unresolved.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'boom' } })
+ }).not.toThrow()
+ })
+
+ it('does not let a result in a later step satisfy an earlier call', async () => {
+ const { ctx } = await setup()
+ const session = ctx.sessions.create()
+ session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+ session.append('step/start', { turn: 1, step: 1 })
+ session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' })
+ session.append('step/end', { turn: 1, step: 1 })
+ session.append('step/start', { turn: 1, step: 2 })
+ expect(() => session.append('tool/result', {
+ turn: 1,
+ step: 2,
+ callId: CallId('c1'),
+ content: [],
+ isError: false,
+ }, { surfaceOp: 'append' })).toThrow(/no prior tool\/call in this step/)
+ })
+
+ it('replays seeded sessions and tracks each session independently', async () => {
+ const { ctx } = await setup()
+ const badSeed = [
+ { type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
+ { type: 'turn/start' as const, seq: 1, time: 0, data: { turn: 2, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
+ ]
+ expect(() => ctx.sessions.create(undefined, { seed: badSeed })).toThrow(InvariantError)
+
+ const a = ctx.sessions.create(SessionId('a'))
+ const b = ctx.sessions.create(SessionId('b'))
+ a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+ expect(() => b.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }))
+ .not.toThrow()
+ })
+
+ it('rebuilds trace state for sessions that exist when the companion reloads', async () => {
+ const { ctx, fiber } = await setup()
+ const session = ctx.sessions.create()
+ session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+ session.append('step/start', { turn: 1, step: 1 })
+ await fiber.dispose()
+ await ctx.plugin(SessionInvariant)
+ expect(() => session.append('assistant/chunk', {
+ turn: 1,
+ step: 1,
+ chunk: { type: 'text-delta', index: 0, text: 'h' },
+ })).not.toThrow()
+ expect(() => session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }))
+ .toThrow(/turn 1 is still open/)
+ })
+
+ it('removes all listeners when the companion is disposed', async () => {
+ const { ctx, fiber } = await setup()
+ const session = ctx.sessions.create()
+ session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+ await fiber.dispose()
+ expect(() => session.append('turn/start', {
+ turn: 2,
+ trigger: { kind: 'message', source: { kind: 'user' } },
+ })).not.toThrow()
+ })
+})
diff --git a/packages/core/session/tsconfig.json b/packages/core/session/tsconfig.json
index b19b98c5ad..253a1c8793 100644
--- a/packages/core/session/tsconfig.json
+++ b/packages/core/session/tsconfig.json
@@ -22,6 +22,9 @@
},
{
"path": "../../core/scope"
+ },
+ {
+ "path": "../../support/invariants"
}
]
}
diff --git a/packages/core/session/tsdown.config.ts b/packages/core/session/tsdown.config.ts
new file mode 100644
index 0000000000..e92275a7f5
--- /dev/null
+++ b/packages/core/session/tsdown.config.ts
@@ -0,0 +1,25 @@
+import { defineConfig } from 'tsdown'
+
+/** Build the package root and optional invariant companion as independent bundles. */
+export default defineConfig([
+ {
+ entry: ['lib/types/index.js'],
+ outDir: 'lib',
+ format: ['esm'],
+ platform: 'node',
+ target: 'es2024',
+ fixedExtension: false,
+ dts: false,
+ clean: false,
+ },
+ {
+ entry: ['lib/types/invariant.js'],
+ outDir: 'lib',
+ format: ['esm'],
+ platform: 'node',
+ target: 'es2024',
+ fixedExtension: false,
+ dts: false,
+ clean: false,
+ },
+])
diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md
index 409aef5ef2..de24d81a00 100644
--- a/packages/examples/agent-spine-demo/README.md
+++ b/packages/examples/agent-spine-demo/README.md
@@ -18,7 +18,12 @@ Read this package for the whole plugin tree and its composition order.
@deepseek-ai/dsh-skill-local local filesystem skill provider
@deepseek-ai/dsh-agent agent registry + initiator scope + agent/* events
@deepseek-ai/dsh-tasks generic background-task registry
-@deepseek-ai/dsh-invariants dev-mode event-contract assertions
+@deepseek-ai/dsh-invariants configurable invariant registry service
+@deepseek-ai/dsh-session/invariant
+@deepseek-ai/dsh-agent/invariant
+@deepseek-ai/dsh-scope/invariant
+@deepseek-ai/dsh-agent-loop/invariant
+ package-owned relational checks
@deepseek-ai/dsh-tool-bash the model-facing bash schema
@deepseek-ai/dsh-workspace-context AGENTS.md/CLAUDE.md workspace context loader
@deepseek-ai/dsh-tool-skill session-prefix skill catalog + model-facing loader schema
@@ -42,11 +47,13 @@ This is the [interface/implementation/consumer seam](../../../docs/rfc/implement
```ts
import type { Config } from '@deepseek-ai/dsh-agent-spine-demo'
-// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, skills?, workspaceContext, toolBash?, toolTasks? }
+// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, skills?, workspaceContext, toolBash?, toolTasks?, invariants? }
// workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults.
```
-The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — a stdio app pre-creates `main`, while the ACP app creates agents on demand at `session/new`; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer, while `toolTasks` controls generic `task_output` wait bounds; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields.
+The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — a stdio app pre-creates `main`, while the ACP app creates agents on demand at `session/new`; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); `invariants` to the invariant service; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer, while `toolTasks` controls generic `task_output` wait bounds; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields.
+
+For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` keeps the package-owned companions mounted but suppresses the blocked owner. Blocklist matches override allowlist matches; see [`dsh-invariants`](../../support/invariants/README.md) for regex and lifecycle rules.
## Why a code bundle, not a shared YAML include
@@ -59,4 +66,4 @@ Indirectly, through `dsh-system-prompt`, `dsh-tool-skill`, `dsh-tool-bash`, and
## Known Limitations and Deferred Work
- **The spine set is fixed in code** — `apply()` mounts every child unconditionally (including `tool-bash`); no config excludes or replaces one, so swapping the loop or dropping a spine member means composing a different bundle.
-- **`dsh-invariants` mounts unconditionally** — this bundle has no toggle, so every composition using it pays the dev-mode relational assertions; Session's always-on validation and freezing are separate.
+- **The invariant seam and companions remain fixed members** — `invariants.enabled: false` or package filters suppress checks but do not remove the service or companion registrations; Session's always-on validation and freezing are separate.
diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json
index 772d6c059b..cae0aab6d4 100644
--- a/packages/examples/agent-spine-demo/package.json
+++ b/packages/examples/agent-spine-demo/package.json
@@ -30,6 +30,7 @@
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-workspace-context": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
+ "@deepseek-ai/dsh-scope": "^0.0.1",
"@deepseek-ai/dsh-skill": "^0.0.1",
"@deepseek-ai/dsh-skill-local": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
@@ -50,6 +51,7 @@
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-workspace-context": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
+ "@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-skill-local": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts
index a4965ca457..6849b21f2e 100644
--- a/packages/examples/agent-spine-demo/src/index.ts
+++ b/packages/examples/agent-spine-demo/src/index.ts
@@ -19,7 +19,11 @@ import SkillService, { type Config as SkillRegistryConfig } from '@deepseek-ai/d
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import TaskService from '@deepseek-ai/dsh-tasks'
-import * as invariants from '@deepseek-ai/dsh-invariants'
+import InvariantService, { type Config as InvariantConfig } from '@deepseek-ai/dsh-invariants'
+import * as sessionInvariant from '@deepseek-ai/dsh-session/invariant'
+import * as agentInvariant from '@deepseek-ai/dsh-agent/invariant'
+import * as scopeInvariant from '@deepseek-ai/dsh-scope/invariant'
+import * as agentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
import * as toolBash from '@deepseek-ai/dsh-tool-bash'
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
import * as toolSkill from '@deepseek-ai/dsh-tool-skill'
@@ -48,7 +52,8 @@ export interface SkillConfig {
* `dshHome` to bash environment and local skill discovery, `skills` to the
* skill registry/local provider/tool consumer, `workspaceContext` to the
* workspace-context loader, and `toolBash`/`toolTasks` to the model-facing tool
- * plugins this bundle owns. Owner schemas supply defaults for optional input;
+ * plugins this bundle owns. `invariants` configures global and package-filtered
+ * relational checks. Owner schemas supply defaults for optional input;
* workspace context instead requires an explicit byte budget or `false` because
* it changes model-visible input. Producer opt-in stays producer-local:
* `toolBash` configures bash only; independently composed producers keep their
@@ -75,6 +80,8 @@ export interface Config {
toolBash?: toolBash.Config
/** Generic background-task control-tool wait bounds. */
toolTasks?: toolTasks.Config
+ /** Global enablement and package-name filters for invariant companions. */
+ invariants?: InvariantConfig
}
/** The skill config schema exported for app packages that forward `skills`. */
@@ -101,7 +108,8 @@ export const Config = z.intersect([
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
toolBash: ToolBashConfigSchema,
toolTasks: ToolTasksConfigSchema,
- }) as unknown as z>,
+ invariants: InvariantService.Config,
+ }) as unknown as z>,
]) as unknown as z
/**
@@ -120,6 +128,7 @@ export function pickSpineConfig(config: Omit): Omit {
expect(ctx.get('skills')).toBeDefined()
expect(ctx.get('agents')).toBeDefined()
expect(ctx.get('tasks')).toBeDefined()
+ expect(ctx.get('invariants')).toBeDefined()
expect(ctx.get('agentLoop')).toBeDefined()
await ctx.fiber.dispose()
})
+ it('mounts package companions and forwards invariant selection config', async () => {
+ const nestedTurn = (ctx: Context): void => {
+ const session = ctx.sessions.create()
+ session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+ session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
+ }
+
+ const enabled = await mount({ workspaceContext: false })
+ expect(() => { nestedTurn(enabled) }).toThrow(/turn 1 is still open/)
+ await enabled.fiber.dispose()
+
+ for (const invariants of [
+ { enabled: false },
+ { package_allowlist: ['^@deepseek-ai/dsh-agent$'] },
+ { package_blocklist: ['^@deepseek-ai/dsh-session$'] },
+ ]) {
+ const filtered = await mount({ workspaceContext: false, invariants })
+ expect(() => { nestedTurn(filtered) }).not.toThrow()
+ await filtered.fiber.dispose()
+ }
+ })
+
it('includes the skill registry, local provider, and skill tool without builtin skills', async () => {
const ctx = await mount({ workspaceContext: false })
@@ -355,6 +382,7 @@ describe('dsh-agent-spine-demo bundle', () => {
skills: {},
toolBash: { enableRunInBackground: false },
toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 },
+ invariants: { enabled: false },
}
expect(agentCore.pickSpineConfig(appConfig)).toEqual({
@@ -366,6 +394,7 @@ describe('dsh-agent-spine-demo bundle', () => {
skills: {},
toolBash: appConfig.toolBash,
toolTasks: appConfig.toolTasks,
+ invariants: appConfig.invariants,
})
expect(agentCore.pickSpineConfig({ workspaceContext: false })).toEqual({ workspaceContext: false })
})
@@ -426,4 +455,16 @@ describe('dsh-agent-spine-demo bundle', () => {
expect(unwrapped.Config).toBeDefined()
expect(typeof unwrapped.apply).toBe('function')
})
+
+ it('keeps every invariant companion loadable through the real Loader unwrap path', () => {
+ const loader = Object.create(Loader.prototype) as Loader
+ for (const companion of [sessionInvariant, agentInvariant, scopeInvariant, agentLoopInvariant]) {
+ expect('default' in companion).toBe(false)
+ const unwrapped = loader.unwrapExports(companion) as Record
+ expect(unwrapped).toBe(companion)
+ expect(typeof unwrapped.name).toBe('string')
+ expect(unwrapped.inject).toContain('invariants')
+ expect(typeof unwrapped.apply).toBe('function')
+ }
+ })
})
diff --git a/packages/examples/stdio-demo/tests/built-bin.e2e.ts b/packages/examples/stdio-demo/tests/built-bin.e2e.ts
index d3e0a32d09..18b3b9b33c 100644
--- a/packages/examples/stdio-demo/tests/built-bin.e2e.ts
+++ b/packages/examples/stdio-demo/tests/built-bin.e2e.ts
@@ -19,7 +19,7 @@ const stdioBin = join(repoRoot, 'packages/examples/stdio-demo/lib/bin.js')
// Symlink each required workspace package by package name so plain Node resolves its built `main`,
// matching an installed dependency rather than tsconfig paths.
const dshPackages = [
- 'examples/agent-spine-demo', 'core/agent', 'core/session', 'core/system-prompt',
+ 'examples/agent-spine-demo', 'core/agent', 'core/session', 'core/scope', 'core/system-prompt',
'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', 'bash/bash-local',
'bash/tool-bash', 'context/workspace-context', 'support/invariants', 'ui/app-boot',
'session-persistence/session-persistence',
diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md
index 44e7d80a05..bd17fdc981 100644
--- a/packages/llm/llm/README.md
+++ b/packages/llm/llm/README.md
@@ -46,7 +46,7 @@ Every product adapter sends application identity on provider HTTP requests. `att
- `LlmAdapter` — abstract base class for provider adapters. The only required method is `stream()`.
- `BlockAssembler` — incrementally assembles raw chunks into complete content blocks and an assistant message. The agent loop feeds it raw chunks (logging them for replay) while reading the assembled blocks/message for history.
-- `HarnessError` — base class for the harness error taxonomy: a stable `code` string (distinct from the human `message`) plus `cause` chaining. Lives here, in the leaf package every other imports, so a single base is shared without a new dependency edge. Per-package errors (`LlmError`, `ToolArgsError`, `InvariantError`, …) extend it. `isHarnessError(value)` narrows at seams.
+- `HarnessError` — base class for the product error taxonomy: a stable `code` string (distinct from the human `message`) plus `cause` chaining. Lives here, in the leaf product package every other product package imports, so a single base is shared without a new dependency edge. Per-package errors such as `LlmError` and `ToolArgsError` extend it. `isHarnessError(value)` narrows at seams.
- `LlmError` — extends `HarnessError`; its stable `code` string (`NO_ADAPTER`, `DUPLICATE_ADAPTER`, and adapter codes like `AUTH`/`RATE_LIMIT`) is the programmatic failure contract.
- `CONTEXT_WINDOW_EXCEEDED_CODE` — the provider-neutral code both DeepSeek adapters use when a request exceeds the model context window, regardless of thrown-HTTP versus in-band finish delivery. `isContextWindowExceededError(detail)` is their shared conservative classifier for OpenAI-compatible provider detail.
diff --git a/packages/sdk/helper/src/features/builtin/helpers.ts b/packages/sdk/helper/src/features/builtin/helpers.ts
index 60f7530c61..9b21c7f64f 100644
--- a/packages/sdk/helper/src/features/builtin/helpers.ts
+++ b/packages/sdk/helper/src/features/builtin/helpers.ts
@@ -15,8 +15,19 @@ import type {
PackageScriptResource,
} from '../resources.ts'
+/** Return the installable package name for a bare package or package subpath. */
+function installablePackageName(specifier: string): string {
+ const segments = specifier.split('/')
+ const expectedSegments = specifier.startsWith('@') ? 2 : 1
+ if (segments.length < expectedSegments || segments.slice(0, expectedSegments).some(segment => segment.length === 0)) {
+ throw new Error(`invalid bare package specifier: ${JSON.stringify(specifier)}`)
+ }
+ return segments.slice(0, expectedSegments).join('/')
+}
+
/** Create a runtime NPM dependency resource. */
-function npmDependency(_owner: string, name: string): NpmDependencyResource {
+function npmDependency(_owner: string, specifier: string): NpmDependencyResource {
+ const name = installablePackageName(specifier)
return {
kind: 'npm-dependency',
key: resourceKey(`npm-dependency:${name}`),
@@ -52,7 +63,7 @@ export function cordisConfigEntry(
}
}
-/** Couple one bare-package Cordis config entry to its mandatory runtime NPM dependency. */
+/** Couple one bare-package or subpath Cordis entry to its installable NPM package. */
export function npmCordisConfigEntry(
owner: string,
value: CordisConfigEntry,
diff --git a/packages/sdk/helper/src/features/builtin/spine.ts b/packages/sdk/helper/src/features/builtin/spine.ts
index caf066865e..3e1acb98fb 100644
--- a/packages/sdk/helper/src/features/builtin/spine.ts
+++ b/packages/sdk/helper/src/features/builtin/spine.ts
@@ -9,7 +9,7 @@ import type { ProjectProfile } from '../../project/types.ts'
import { loadHelperTemplate } from '../../templates/template-assets.ts'
import { FeatureOption, FixedFeature } from '../feature.ts'
import { ProjectContribution } from '../resources.ts'
-import { npmCordisConfigEntry, requiredString } from './helpers.ts'
+import { cordisConfigEntry, npmCordisConfigEntry, requiredString } from './helpers.ts'
const ID = featureId('spine')
const PERSONA = loadHelperTemplate>('persona.txt.tpl').render({}).trimEnd()
@@ -37,6 +37,10 @@ class SpineOption extends FeatureOption {
...npmCordisConfigEntry(ID, { id: 'tools', name: '@deepseek-ai/dsh-tools' }, []),
...npmCordisConfigEntry(ID, { id: 'agent', name: '@deepseek-ai/dsh-agent' }),
...npmCordisConfigEntry(ID, { id: 'invariants', name: '@deepseek-ai/dsh-invariants' }),
+ cordisConfigEntry(ID, { id: 'session-invariant', name: '@deepseek-ai/dsh-session/invariant' }),
+ cordisConfigEntry(ID, { id: 'agent-invariant', name: '@deepseek-ai/dsh-agent/invariant' }),
+ ...npmCordisConfigEntry(ID, { id: 'scope-invariant', name: '@deepseek-ai/dsh-scope/invariant' }),
+ cordisConfigEntry(ID, { id: 'agent-loop-invariant', name: '@deepseek-ai/dsh-agent-loop/invariant' }),
...npmCordisConfigEntry(ID, {
id: 'agent-loop',
name: '@deepseek-ai/dsh-agent-loop',
diff --git a/packages/sdk/helper/tests/project.spec.ts b/packages/sdk/helper/tests/project.spec.ts
index 655b308911..94fd212a68 100644
--- a/packages/sdk/helper/tests/project.spec.ts
+++ b/packages/sdk/helper/tests/project.spec.ts
@@ -188,9 +188,15 @@ describe('SdkProject and ProjectEditSession', () => {
.toContain('sessionId: !!js process.env.DSH_SDK_SESSION_ID')
expect(project.cordis.entry('stdio')?.config).not.toHaveProperty('model')
expect(project.cordis.entry('agent-loop')?.config).toEqual({ agents: [] })
+ expect(project.cordis.entry('session-invariant')?.name).toBe('@deepseek-ai/dsh-session/invariant')
+ expect(project.cordis.entry('agent-invariant')?.name).toBe('@deepseek-ai/dsh-agent/invariant')
+ expect(project.cordis.entry('scope-invariant')?.name).toBe('@deepseek-ai/dsh-scope/invariant')
+ expect(project.cordis.entry('agent-loop-invariant')?.name).toBe('@deepseek-ai/dsh-agent-loop/invariant')
expect(project.cordis.entry('system-prompt')?.config?.persona).toContain('{{cwd}}')
expect(project.packageManifest().dependencies?.['@cordisjs/plugin-timer']).toBe('^1.1.2')
expect(project.packageManifest().dependencies?.['@cordisjs/plugin-hmr']).toBe('^1.0.15')
+ expect(project.packageManifest().dependencies?.['@deepseek-ai/dsh-scope']).toBe('^0.0.1')
+ expect(project.packageManifest().dependencies).not.toHaveProperty('@deepseek-ai/dsh-scope/invariant')
expect(project.packageManifest().dependencies).not.toHaveProperty('node-addon-require-builtin')
expect(project.cordis.entry('hmr')).toMatchObject({ name: '@cordisjs/plugin-hmr' })
expect(project.cordis.entry('llm-deepseek')?.config).not.toHaveProperty('baseURL')
@@ -865,6 +871,11 @@ describe('extension points', () => {
expect(stringArray({ value: [1] }, 'value')).toHaveLength(1)
expect(cordisConfigEntry('owner', { id: 'entry', name: 'pkg' }).ownedConfigKeys).toEqual([])
expect(npmCordisConfigEntry('owner', { id: 'entry', name: 'pkg' })[1].ownedConfigKeys).toEqual([])
+ expect(npmCordisConfigEntry('owner', { id: 'entry', name: '@scope/pkg/subpath' })[0].name).toBe('@scope/pkg')
+ expect(npmCordisConfigEntry('owner', { id: 'entry', name: 'pkg/subpath' })[0].name).toBe('pkg')
+ for (const invalid of ['', '@scope', '@scope/']) {
+ expect(() => npmCordisConfigEntry('owner', { id: 'entry', name: invalid })).toThrow('invalid bare package specifier')
+ }
expect(environmentResource('owner', 'EMPTY', undefined)).not.toHaveProperty('value')
const builtins = createBuiltinRegistry(profile)
expect(builtins.get(featureId('app')).defaultOptions(profile)).toEqual(['embed'])
diff --git a/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts
index df3b74d346..6f091c2bf6 100644
--- a/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts
+++ b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts
@@ -3,7 +3,10 @@ import { Context } from 'cordis'
import { SessionId } from '@deepseek-ai/dsh-session'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
-import * as Invariants from '@deepseek-ai/dsh-invariants'
+import InvariantService from '@deepseek-ai/dsh-invariants'
+import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
+import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
+import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import * as Spawn from '@deepseek-ai/dsh-subagent-spawn'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
@@ -11,6 +14,13 @@ import * as fork from '../src/index.ts'
type Script = ConstructorParameters[0]
+async function mountInvariants(ctx: Context): Promise {
+ await ctx.plugin(InvariantService)
+ await ctx.plugin(SessionInvariant)
+ await ctx.plugin(AgentInvariant)
+ await ctx.plugin(AgentLoopInvariant)
+}
+
function start(ctx: Context, provider: string, request: Omit & { signal?: AbortSignal }) {
return ctx.subagents.start(provider, { signal: request.signal ?? new AbortController().signal, ...request })
}
@@ -24,7 +34,7 @@ function start(ctx: Context, provider: string, request: Omit[0]
+async function mountInvariants(ctx: Context): Promise {
+ await ctx.plugin(InvariantService)
+ await ctx.plugin(SessionInvariant)
+ await ctx.plugin(AgentInvariant)
+ await ctx.plugin(AgentLoopInvariant)
+}
+
function start(ctx: Context, provider: string, request: Omit & { signal?: AbortSignal }) {
return ctx.subagents.start(provider, { signal: request.signal ?? new AbortController().signal, ...request })
}
@@ -24,14 +34,14 @@ const emptyStop: StreamChunk[] = [{ type: 'finish', reason: { kind: 'stop' } }]
/**
* Drives the REAL fork backend with a real loop + scripted mock MODEL + the
- * real dsh-invariants plugin. The plugin replays a seeded child log on
+ * real invariant service and package companions. The session contribution replays a seeded child log on
* `session/created`, so a malformed (unbalanced) fork seed makes these tests
* THROW — that is the regression guard for the completed-turn-prefix boundary.
*/
async function setup(script: Script) {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
- await ctx.plugin(Invariants)
+ await mountInvariants(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentService)
await ctx.plugin(fork, { providerName: 'fork' })
diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts
index e50457bcb4..58b83859c5 100644
--- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts
+++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts
@@ -5,7 +5,10 @@ import type { ContinuationDecision } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
-import * as Invariants from '@deepseek-ai/dsh-invariants'
+import InvariantService from '@deepseek-ai/dsh-invariants'
+import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
+import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
+import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import type { Config as ToolConfig, StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
import { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools'
@@ -18,6 +21,13 @@ import {
type Script = ConstructorParameters[0]
+async function mountInvariants(ctx: Context): Promise {
+ await ctx.plugin(InvariantService)
+ await ctx.plugin(SessionInvariant)
+ await ctx.plugin(AgentInvariant)
+ await ctx.plugin(AgentLoopInvariant)
+}
+
interface CodeRunRequestLike {
bindings: { global: string; functions: Record Promise> }[]
}
@@ -51,7 +61,7 @@ async function setup(script: Script, options: SetupOptions = {}) {
run: options.codeRun ?? (() => Promise.resolve({ logs: [] })),
} as never)
}
- await ctx.plugin(Invariants)
+ await mountInvariants(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentService)
const disposeProvider = ctx.subagents.registerProvider({
diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts
index 13029ef3e7..3cba1b3a91 100644
--- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts
+++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts
@@ -4,17 +4,27 @@ import { type Agent } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
-import * as Invariants from '@deepseek-ai/dsh-invariants'
+import InvariantService from '@deepseek-ai/dsh-invariants'
+import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
+import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
+import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
import SubagentService from '@deepseek-ai/dsh-subagent'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { startInProcessRun } from '../src/index.ts'
type Script = ConstructorParameters[0]
+async function mountInvariants(ctx: Context): Promise {
+ await ctx.plugin(InvariantService)
+ await ctx.plugin(SessionInvariant)
+ await ctx.plugin(AgentInvariant)
+ await ctx.plugin(AgentLoopInvariant)
+}
+
async function setup(script: Script) {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
- await ctx.plugin(Invariants)
+ await mountInvariants(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentService)
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts
index 7de5f6f4d6..7ba0551f34 100644
--- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts
+++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts
@@ -5,7 +5,10 @@ import AgentRegistry from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
-import * as Invariants from '@deepseek-ai/dsh-invariants'
+import InvariantService from '@deepseek-ai/dsh-invariants'
+import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
+import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
+import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import * as spawn from '../src/index.ts'
@@ -13,10 +16,17 @@ import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-inprocess'
type Script = ConstructorParameters[0]
+async function mountInvariants(ctx: Context): Promise {
+ await ctx.plugin(InvariantService)
+ await ctx.plugin(SessionInvariant)
+ await ctx.plugin(AgentInvariant)
+ await ctx.plugin(AgentLoopInvariant)
+}
+
/**
* Drives the REAL spawn backend end-to-end: a real agent loop + a scripted mock
* MODEL (the only mocked boundary) + the real SubagentService + the real
- * dsh-invariants plugin (so a malformed child session log would fail the test).
+ * invariant service plus package companions (so a malformed child session log would fail the test).
* The parent is a real config agent; the spawn provider creates a real child
* agent on the same context and we assert its output.
*/
@@ -24,7 +34,7 @@ async function setup(script: Script) {
const ctx = new Context()
const adapter = new MockAdapter(script)
await mountAgentLoopTestDependencies(ctx)
- await ctx.plugin(Invariants)
+ await mountInvariants(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentService)
await ctx.plugin(spawn, { providerName: 'spawn' })
@@ -295,7 +305,7 @@ describe('dsh-subagent-spawn', () => {
const ctx = new Context()
const adapter = new MockAdapter(['hang'])
await mountAgentLoopTestDependencies(ctx)
- await ctx.plugin(Invariants)
+ await mountInvariants(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentService)
const fiber = await ctx.plugin(spawn, { providerName: 'spawn' })
diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md
index 661e073d13..1cb0e57838 100644
--- a/packages/support/invariants/README.md
+++ b/packages/support/invariants/README.md
@@ -1,62 +1,63 @@
# dsh-invariants
-Runtime event-contract assertions intended for development diagnostics. This pure-listener plugin checks relationships among session events, agent states, scoped dispatches, and model requests; it does not own or change product behavior.
+Configurable registry service for package-owned runtime invariant checks. The root plugin registers `ctx.invariants`; it contains no product checks or product-package imports. Packages publish optional `./invariant` companion plugins that contribute their own assertions.
-The plugin has no environment guard: it is active wherever it is registered. The default [`dsh-agent-spine-demo`](../../examples/agent-spine-demo/README.md) bundle mounts it unconditionally; a custom composition can omit it when the runtime cost is undesirable. It doubles as executable documentation of the event taxonomy — the assertions *are* the contract.
+## Service: `InvariantService` (`ctx.invariants`)
-Session itself owns immutable, surface-valid log storage in every composition: it takes one lossless JSON snapshot of each candidate, validates the complete surface transition, deep-freezes the accepted record, and exposes the log through immutable array snapshots. The invariants plugin checks the remaining cross-record and cross-seam rules that Session does not own.
+```ts
+interface Config {
+ enabled?: boolean
+ package_allowlist?: string[]
+ package_blocklist?: string[]
+}
+```
-Session-log assertions run during Cordis `internal/dispatch`, while `Session.append()` is resolving the `session/event` callback snapshot but before it pushes the candidate into the log. A valid transition is staged by exact event identity and applied to the live trace only when that same committed event reaches the plugin's contained post-commit listener. A later internal dispatch check can therefore veto without advancing either the log or the invariant trace, while ordinary `session/event` observer failures remain observe-only.
+Defaults are `enabled: true`, `package_allowlist: []`, and `package_blocklist: []`. A package is selected only when the service is enabled, the empty allowlist or at least one allowlist pattern matches its full npm name, and no blocklist pattern matches. Blocklist matches therefore override allowlist matches.
-## Plugin
+Each entry is a case-sensitive JavaScript regular-expression source compiled with `new RegExp(pattern)`. Matching is unanchored unless the source supplies `^` and `$`; `/pattern/flags` syntax is not parsed. Blank, whitespace-padded, invalid, or duplicate entries within one list fail service startup. A valid pattern may match no currently loaded package so later loading and HMR remain deterministic.
-A functional plugin — register the module namespace (this is what loading by name in `cordis.yml` does):
+`ctx.invariants.register(packageName, installer)` reserves one active registration for the full npm package name, including when filters keep its installer inactive, and returns its disposer. An enabled contribution runs in a dedicated child Cordis fiber. The installer can declare its required service surface through `installer.inject` and receives `fail(message)`, which throws an `InvariantError` bound to the registering package. Installer failure disposes the child and releases ownership atomically.
+
+The service owns every registration fiber, while the returned disposer also belongs to the companion fiber. Unloading either side removes the listeners and reservation completely. A companion can therefore reload and register the same package name without retaining trace state or duplicate listeners; packages that need an existing baseline rebuild it during installation.
+
+`InvariantError` extends `Error`, carries stable `code: 'INVARIANT'`, and exposes the owning `packageName` without adding a product-package dependency to the service.
+
+## Package companions
+
+| Companion | Registration | Checks |
+|---|---|---|
+| `@deepseek-ai/dsh-session/invariant` | `@deepseek-ai/dsh-session` | sequence, turn/step enclosure, and same-step tool call/result trace |
+| `@deepseek-ai/dsh-agent/invariant` | `@deepseek-ai/dsh-agent` | agent-status transitions |
+| `@deepseek-ai/dsh-scope/invariant` | `@deepseek-ai/dsh-scope` | scoped-event carrier presence and subject consistency |
+| `@deepseek-ai/dsh-agent-loop/invariant` | `@deepseek-ai/dsh-agent-loop` | loop-built model-request reconstruction from the session log |
+
+The root entrypoint of each owner remains independent of diagnostics. Loading the service alone installs no checks; loading a companion without the service remains pending on its declared `invariants` dependency.
+
+## Composition
```ts
import type { Context } from 'cordis'
-import * as Invariants from '@deepseek-ai/dsh-invariants'
+import InvariantService from '@deepseek-ai/dsh-invariants'
+import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
declare const ctx: Context
-await ctx.plugin(Invariants)
+ctx.plugin(InvariantService, {
+ enabled: true,
+ package_allowlist: ['^@deepseek-ai/dsh-'],
+ package_blocklist: ['^@deepseek-ai/dsh-agent-loop$'],
+})
+ctx.plugin(SessionInvariant)
```
-`inject`: `['sessions']` — it reads `ctx.sessions.list()` at apply time to rebuild trace state for sessions that already exist, so a hot reload mid-turn does not falsely reject the next event. The oracle listeners are explicitly global so pre-commit staging and post-commit application keep the same audience even if the plugin is mounted under a scoped context; their cleanup still belongs to that mounting fiber. The plugin has no configuration.
-
-## Invariants asserted
-
-Session log (per session):
-
-- **`seq` strictly increases** — the spine of replay equivalence.
-- **turns pair and nest** — `turn/start` opens a turn, `turn/end` closes the matching one; no overlapping turns.
-- **steps nest in turns** — `step/start` opens a step in the open turn; `step/end` closes the matching step.
-- **chunks belong to an open step** — `step/start` precedes its `assistant/chunk`s.
-- **a `tool/result` needs a prior `tool/call`** — but NOT the converse: a `tool/call` may have no result (a thrown tool-execution pipeline step ends the turn with no `tool/result`, which is legal).
-- **provenance sources are valid and unambiguous** — `sourceEventSeqs` contains unique earlier known seqs; only `assistant/message` may carry an explicit empty list, which denotes a known empty provider stream rather than absent legacy provenance.
-
-Agent status (per agent):
-
-- **legal transitions only** — `idle↔running` and `(idle|running)→disposed`. A no-op transition (`setStatus` dedups, so it never fires) and leaving the terminal `disposed` state are violations.
-
-Model requests (on `llm/stream`):
-
-- **a loop-built request is exactly what the log reconstructs** — a frozen request with a live `sessionId` (the loop-built marker; hand-built one-shots like compaction's summarize are unfrozen and skipped) must carry frozen `messages` deep-equal to the derivation over the log prefix strictly before the in-flight step's `step/start` (rebuilt through a FRESH `Session`, so the live cache cannot vouch for itself — and boundary-correct: content logged after `step/start` legitimately belongs to the next request), and every non-content field must equal the latest logged `request/header` (see [the reconstructability RFC](../../../docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)). Registered with `prepend: true` so a short-circuiting `llm/stream` listener (the replay adapter) cannot silence it; prepend orders it against append-registered listeners only — correctness rests on the seq-bounded rebuild, never listener timing.
-
-On any violation it throws `InvariantError` (`code: 'INVARIANT'`).
-
-## Why runtime assertions remain useful
-
-Session enforces the per-record storage boundary at runtime, where a cast cannot bypass it. Pervasive `DeepReadonly` types would add noise across consumers without expressing relationships such as turn/step nesting, subject-correct scoped dispatch, or equality between a request and its log reconstruction. This plugin checks those relationships wherever it is mounted while `dsh-session` keeps history immutable in every composition. See [source-owned session immutability and dev-mode invariants](../../../docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md).
-
-## Seeded sessions
-
-A seeded or forked session arrives with events already in its log because construction does not emit `session/event` for each seed record. `Session` validates, snapshots, and freezes every seed record before accepting it; on `session/created`, this plugin replays the accepted log only to rebuild and check its relational trace state.
+The standard agent spine mounts the service and all four companions. Custom compositions choose the companions they want and may disable or filter them without changing package entrypoints.
## Model Experience
-None, as this observer only validates events and frozen requests and never rewrites prompts, schemas, messages, or streams.
+None, as the service and companions observe runtime events and requests but never alter prompts, messages, schemas, streams, or tool results.
## Known Limitations and Deferred Work
-- **The request-reconstructability assertion covers loop-built requests only** — hand-built one-shots (e.g. compaction's summarize call) carry no live `sessionId` marker and are skipped.
-- **Merge-extended event families get no family-specific assertions** — `compact/*` lock pairing and `hook/*` invoked/result pairing are not checked here; only the core turn/step/chunk/tool-result contract is.
+- The shipped checks cover only the four listed package contracts; a merge-extended event family has no family-specific assertion until its owner publishes one.
+- Request reconstruction covers frozen loop-built requests with a live session id; direct one-shot calls remain outside that companion's marker contract.
+- Regular-expression filters are fixed for the service lifetime; changing them requires ordinary Cordis plugin reload.
diff --git a/packages/support/invariants/package.json b/packages/support/invariants/package.json
index 59a425387b..e06e9a3db7 100644
--- a/packages/support/invariants/package.json
+++ b/packages/support/invariants/package.json
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-invariants",
- "description": "Runtime event-contract assertions for DeepSeek Harness development diagnostics",
+ "description": "Registry service for package-owned DeepSeek Harness runtime invariants",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -22,21 +22,12 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
- "@deepseek-ai/dsh-agent": "^0.0.1",
- "@deepseek-ai/dsh-llm": "^0.0.1",
- "@deepseek-ai/dsh-scope": "^0.0.1",
- "@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
+ "dependencies": {
+ "schemastery": "^3.18.0"
+ },
"devDependencies": {
- "@deepseek-ai/dsh-agent": "workspace:^",
- "@deepseek-ai/dsh-llm": "workspace:^",
- "@deepseek-ai/dsh-scope": "workspace:^",
- "@deepseek-ai/dsh-session": "workspace:^",
- "@deepseek-ai/dsh-subagent": "workspace:^",
- "@deepseek-ai/dsh-system-prompt": "workspace:^",
- "@deepseek-ai/dsh-tools": "workspace:^",
- "@deepseek-ai/dsh-user-approval": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}
diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts
index cdb06edbac..754cb46ab9 100644
--- a/packages/support/invariants/src/index.ts
+++ b/packages/support/invariants/src/index.ts
@@ -1,409 +1,199 @@
/**
- * Runtime listeners that fail loudly when cross-event contracts are broken:
- * turn and step nesting, scoped dispatch, status transitions, and request
- * reconstruction. The plugin has no environment guard and is active wherever
- * mounted, including the default `dsh-agent-spine-demo` bundle; custom compositions
- * may omit it. Sessions own immutable, surface-valid event storage; this plugin
- * checks only relationships that event acceptance cannot express.
+ * Configurable registry for package-owned runtime invariant contributions.
+ * Packages register checks from optional `./invariant` companion plugins;
+ * ordinary package entrypoints stay independent of diagnostics.
+ *
* @module @deepseek-ai/dsh-invariants
*/
-import type { Context } from 'cordis'
-import { carrierKeyOf, isScopeCarrier } from '@deepseek-ai/dsh-scope'
-import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
-import type { CallId, GenerateOptions } from '@deepseek-ai/dsh-llm'
-import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
-import { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
-import type { SessionEvent } from '@deepseek-ai/dsh-session'
-import { scopedSubjectResolverFor } from './scoped-events.generated.ts'
+import { Context, Service } from 'cordis'
+import type { Inject } from 'cordis'
+import z from 'schemastery'
+import type Schema from 'schemastery'
-export const name = 'invariants'
-export const inject = ['sessions']
-
-/**
- * Thrown when a harness event-contract invariant is violated. Extends
- * {@link HarnessError} (`code: 'INVARIANT'`) so a violation is routable like
- * any other harness failure.
- */
-export class InvariantError extends HarnessError {
- constructor(message: string) {
- super(`invariant violated: ${message}`, 'INVARIANT')
- this.name = 'InvariantError'
- }
+/** Runtime invariant selection configured on the service plugin. */
+export interface Config {
+ /** Global switch; defaults to `true`. */
+ readonly enabled?: boolean
+ /** Case-sensitive JavaScript regex sources that admit package names; empty admits all. */
+ readonly package_allowlist?: string[]
+ /** Case-sensitive JavaScript regex sources that exclude package names after allowlist matching. */
+ readonly package_blocklist?: string[]
}
-/** Per-session bookkeeping for the session-log invariants. */
-interface SessionTrace {
- /** Highest `seq` seen so far (must strictly increase). */
- lastSeq: number
- /** Open turn number, or null between turns. */
- openTurn: number | null
- /** Open step within the current turn, or null between steps. */
- openStep: number | null
- /** The next turn number expected in this session log. */
- nextTurn: number
- /** The next step number expected within the open turn. */
- nextStep: number
+/**
+ * Throw a package-attributed invariant failure.
+ * @param message - violated package contract without the standard prefix.
+ * @returns never because reporting a violation throws.
+ */
+export type InvariantFailure = (message: string) => never
+
+/** Install one package's listeners into the registration's child context. */
+export interface InvariantInstaller {
/**
- * Tool-call ids issued in the OPEN step awaiting a result. Cleared at
- * `step/end` — a result must arrive in the same step as its call.
+ * Install the package contribution.
+ * @param ctx - child context owned by this invariant registration.
+ * @param fail - reporter bound to the registering package name.
+ * @returns nothing after synchronous listener installation completes.
*/
- pendingCalls: Set
+ (ctx: Context, fail: InvariantFailure): void
+ /** Services the child installer fiber may access. */
+ readonly inject?: Inject
}
-/** One accepted event's deferred mutation of a live session trace. */
-interface SessionTraceTransition {
- /** Scalar state after the event commits. */
- scalars: Pick
- /** The event's mutation of the open step's pending call set. */
- pendingCalls:
- | { kind: 'none' }
- | { kind: 'add' | 'delete'; callId: CallId }
- | { kind: 'clear' }
+/** Internal effect shape used to join child startup before a companion loads. */
+interface PendingInvariantRegistration extends PromiseLike<() => void> {
+ (): void | Promise
}
-/** Assert that a step-scoped event names the currently open turn and step. */
-function requireOpenStep(trace: SessionTrace, kind: string, turn: number, step: number): void {
- if (trace.openTurn !== turn || trace.openStep !== step) {
- throw new InvariantError(
- `${kind} names turn ${turn}/step ${step} but open is turn ${trace.openTurn}/step ${trace.openStep}`,
- )
+/** Thrown when a package-owned runtime invariant is violated. */
+export class InvariantError extends Error {
+ /** Stable machine-readable invariant failure code. */
+ readonly code = 'INVARIANT' as const
+ /** Full npm package name that owns the violated invariant. */
+ readonly packageName: string
+
+ /**
+ * Construct a package-attributed invariant failure.
+ * @param packageName - full npm package name that registered the check.
+ * @param message - violated contract, without the standard error prefix.
+ */
+ constructor(packageName: string, message: string) {
+ super(`invariant violated by "${packageName}": ${message}`)
+ this.name = 'InvariantError'
+ this.packageName = packageName
}
}
-/** Validate one candidate event without mutating the committed session trace. */
-function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTransition {
- // seq is strictly monotonic — the spine of replay equivalence. lastSeq
- // starts at -1, so the first event (seq 0) passes.
- if (event.seq <= trace.lastSeq) {
- throw new InvariantError(`seq must strictly increase: saw ${event.seq} after ${trace.lastSeq}`)
- }
- let openTurn = trace.openTurn
- let openStep = trace.openStep
- let nextTurn = trace.nextTurn
- let nextStep = trace.nextStep
- let pendingCalls: SessionTraceTransition['pendingCalls'] = { kind: 'none' }
-
- // Boundary/step-scoped events have explicit cases; every OTHER event type —
- // including plugin-added (merge-extensible) SessionEventMap keys — is caught
- // by the `default` and must be turn-enclosed (the turn-enclosure RFC). No assertNever: an
- // unknown variant is valid, not a compile error.
- switch (event.type) {
- case 'turn/start': {
- if (trace.openTurn !== null) {
- throw new InvariantError(`turn/start ${event.data.turn} while turn ${trace.openTurn} is still open`)
- }
- // Current sessions replay full logs, so numbering starts at 1 and remains
- // contiguous. If a future compaction/fork stores a partial log, it must
- // seed `nextTurn` from retained metadata before this check runs.
- if (event.data.turn !== trace.nextTurn) {
- throw new InvariantError(`turn/start expected turn ${trace.nextTurn}, got ${event.data.turn}`)
- }
- openTurn = event.data.turn
- nextStep = 1
- break
- }
- case 'turn/end': {
- if (trace.openTurn !== event.data.turn) {
- throw new InvariantError(`turn/end ${event.data.turn} does not match open turn ${trace.openTurn}`)
- }
- if (trace.openStep !== null) {
- throw new InvariantError(`turn/end ${event.data.turn} while step ${trace.openStep} is still open`)
- }
- openTurn = null
- nextTurn += 1
- break
- }
- case 'step/start': {
- if (trace.openTurn !== event.data.turn) {
- throw new InvariantError(`step/start in turn ${event.data.turn} but open turn is ${trace.openTurn}`)
- }
- if (trace.openStep !== null) {
- throw new InvariantError(`step/start ${event.data.step} while step ${trace.openStep} is still open`)
- }
- // Steps are checked under the same full-log assumption as turns above.
- if (event.data.step !== trace.nextStep) {
- throw new InvariantError(`step/start expected step ${trace.nextStep} in turn ${event.data.turn}, got ${event.data.step}`)
- }
- openStep = event.data.step
- break
- }
- case 'step/end': {
- requireOpenStep(trace, 'step/end', event.data.turn, event.data.step)
- // A result must arrive in the step that issued the call; orphan calls
- // (a step that errored before its result) do not carry to the next step.
- pendingCalls = { kind: 'clear' }
- openStep = null
- nextStep += 1
- break
- }
- case 'assistant/chunk': {
- requireOpenStep(trace, 'assistant/chunk', event.data.turn, event.data.step)
- break
- }
- case 'assistant/message': {
- requireOpenStep(trace, 'assistant/message', event.data.turn, event.data.step)
- break
- }
- case 'tool/call': {
- requireOpenStep(trace, 'tool/call', event.data.turn, event.data.step)
- pendingCalls = { kind: 'add', callId: event.data.callId }
- break
- }
- case 'tool/result': {
- requireOpenStep(trace, 'tool/result', event.data.turn, event.data.step)
- // A result needs a prior matching call in the same step. (The converse
- // does NOT hold: a call may have no result — a throwing tool-execution
- // pipeline step ends the turn with no tool/result, which is legal.)
- const syntheticInterrupted = event.data.isError && event.data.error?.code === 'interrupted'
- if (!trace.pendingCalls.has(event.data.callId) && !syntheticInterrupted) {
- throw new InvariantError(`tool/result for ${event.data.callId} with no prior tool/call in this step`)
- }
- pendingCalls = { kind: 'delete', callId: event.data.callId }
- break
- }
- // Turn-enclosure (the turn-enclosure RFC): EVERY session event not handled by a boundary
- // case above must sit inside an open turn. The durable session log uses the
- // turn as its commit/replay boundary (the JSONL backend treats anything
- // after the last turn/end as a crash tail), so a bare event between turns is
- // silently dropped on reload. The loop records queued user messages after
- // turn/start, and an idle agent.inject() wraps its context/message in a
- // one-shot turn. A `default`
- // (not an enumerated list) is deliberate: SessionEventMap is
- // merge-extensible, so a PLUGIN-added event type appended while idle must
- // also fail here rather than fall through and be dropped on resume.
- default: {
- if (trace.openTurn === null) {
- throw new InvariantError(`${event.type} appended outside any open turn (every event must be turn-enclosed)`)
- }
- break
- }
- }
- return {
- scalars: { lastSeq: event.seq, openTurn, openStep, nextTurn, nextStep },
- pendingCalls,
+declare module 'cordis' {
+ interface Context {
+ invariants: InvariantService
}
}
-/** Apply one already-validated transition after its event commits. */
-function applyTransition(trace: SessionTrace, transition: SessionTraceTransition): void {
- Object.assign(trace, transition.scalars)
- switch (transition.pendingCalls.kind) {
- case 'none':
- break
- case 'add':
- trace.pendingCalls.add(transition.pendingCalls.callId)
- break
- case 'delete':
- trace.pendingCalls.delete(transition.pendingCalls.callId)
- break
- case 'clear':
- trace.pendingCalls.clear()
- break
- /* v8 ignore next -- validateEvent produces this closed transition union */
- default:
- assertNever(transition.pendingCalls, 'session trace pending-call transition')
- }
+/** Compile and validate one package-filter list. */
+function compilePatterns(field: 'package_allowlist' | 'package_blocklist', values: readonly string[]): RegExp[] {
+ const seen = new Set()
+ return values.map((value) => {
+ if (value.length === 0 || value.trim() !== value) {
+ throw new Error(`invariants: ${field} entries must be non-blank and have no surrounding whitespace`)
+ }
+ if (seen.has(value)) {
+ throw new Error(`invariants: ${field} contains duplicate regex ${JSON.stringify(value)}`)
+ }
+ seen.add(value)
+ try {
+ return new RegExp(value)
+ } catch (cause) {
+ throw new Error(`invariants: ${field} contains invalid regex ${JSON.stringify(value)}`, { cause })
+ }
+ })
}
-/** Validate and apply one event while rebuilding an already-committed log. */
-function replayEvent(trace: SessionTrace, event: SessionEvent): void {
- applyTransition(trace, validateEvent(trace, event))
-}
-
-/** Allow an initial observation, idle/running transitions, and terminal disposal; reject repeats and leaving disposed. */
-function checkTransition(from: AgentStatus | undefined, to: AgentStatus): void {
- if (from === undefined) return
- if (from === to) {
- throw new InvariantError(`agent/status repeated ${to} (no-op transition)`)
- }
- if (from === 'disposed') {
- throw new InvariantError(`agent/status left terminal state disposed → ${to}`)
- }
-}
-
-/**
- * Register the runtime invariants. Contributions are effect-scoped, so
- * disposing the plugin fiber removes all listeners (HMR-safe). On (re-)apply
- * the trace state is rebuilt by replaying each existing session's log, so a
- * hot reload mid-turn does not falsely reject the next event.
- *
- * @param ctx - Cordis context that receives the invariant listeners.
- */
-export function apply(ctx: Context): void {
- const traces = new WeakMap()
- const stagedTransitions = new WeakMap()
- // Agent status has no stored history to replay; the first observation after
- // (re-)apply seeds the baseline, so a reload never produces a false positive.
- const lastStatus = new WeakMap()
-
- const freshTrace = (): SessionTrace => ({
- lastSeq: -1,
- openTurn: null,
- openStep: null,
- nextTurn: 1,
- nextStep: 1,
- pendingCalls: new Set(),
+/** Package-owned invariant registry with global and regex-based selection. */
+export class InvariantService extends Service {
+ static Config: Schema = z.object({
+ enabled: z.boolean().default(true),
+ package_allowlist: z.array(z.string()).default([]),
+ package_blocklist: z.array(z.string()).default([]),
})
- /** Build (or rebuild) a session's trace by replaying its whole log. */
- const seedSession = (session: Session): SessionTrace => {
- const trace = freshTrace()
- traces.set(session, trace)
- for (const event of session.events) {
- replayEvent(trace, event)
- }
- return trace
+ private readonly enabled: boolean
+ private readonly ownerCtx: Context
+ private readonly packageAllowlist: readonly RegExp[]
+ private readonly packageBlocklist: readonly RegExp[]
+ private readonly registrations = new Set()
+
+ /**
+ * Create and install the invariant registry.
+ * @param ctx - Cordis context that owns the service.
+ * @param config - global enablement and package-name regex filters.
+ */
+ constructor(ctx: Context, config: Config = {}) {
+ super(ctx, 'invariants')
+ this.ownerCtx = ctx
+ this.enabled = config.enabled ?? true
+ this.packageAllowlist = compilePatterns('package_allowlist', config.package_allowlist ?? [])
+ this.packageBlocklist = compilePatterns('package_blocklist', config.package_blocklist ?? [])
}
- // Every store-created session (the only kind that emits session/event) is
- // seeded first — via ctx.sessions.list() at apply or session/created — so the
- // fallback is a defensive guard, never hit in practice.
- /* v8 ignore next -- traceFor's fallback: session/event always follows a seed */
- const traceFor = (session: Session): SessionTrace => traces.get(session) ?? seedSession(session)
+ /** Return whether one full package name passes the configured filters. */
+ private selected(packageName: string): boolean {
+ if (!this.enabled) return false
+ if (this.packageAllowlist.length > 0
+ && !this.packageAllowlist.some(pattern => pattern.test(packageName))) return false
+ return !this.packageBlocklist.some(pattern => pattern.test(packageName))
+ }
- // Rebuild state for sessions that already exist at (re-)apply time — HMR
- // reload starts a fresh fiber, and a mid-turn session would otherwise look
- // like it began with a stray chunk/step-end.
- for (const session of ctx.sessions.list()) seedSession(session)
-
- // A newly created session may arrive seeded/forked (the constructor copies
- // the seed WITHOUT emitting session/event), so replay its log here too.
- ctx.on('session/created', (session) => { seedSession(session) }, { global: true })
-
- ctx.on('session/event', (session, event) => {
- // Session resolves dispatch before committing, so internal/dispatch has
- // already staged this exact event. A later dispatch veto skips every
- // session/event callback and therefore leaves the live trace unchanged.
- const staged = stagedTransitions.get(event)
- /* v8 ignore next 2 -- internal/dispatch stages the exact callback arguments */
- if (staged === undefined || staged.session !== session) {
- throw new InvariantError('session/event reached publication without matching pre-commit validation')
+ /**
+ * Register one package's invariant installer. The package name is reserved
+ * even when filtering disables its checks. Enabled installers run in a child
+ * fiber; failure disposes that fiber and releases the reservation.
+ * @param packageName - full npm package name that owns the contribution.
+ * @param installer - synchronous listener installer for the child context.
+ * @returns an effect-scoped disposer for the registration.
+ */
+ register(packageName: string, installer: InvariantInstaller): () => void {
+ if (packageName.length === 0 || packageName.trim() !== packageName || /\s/.test(packageName)) {
+ throw new Error('invariants: packageName must be non-blank and contain no whitespace')
}
- stagedTransitions.delete(event)
- applyTransition(staged.trace, staged.transition)
- }, { global: true })
-
- ctx.on('agent/status', (agent, status) => {
- checkTransition(lastStatus.get(agent), status)
- lastStatus.set(agent, status)
- }, { global: true })
-
- // --- Scoped-dispatch invariants (the agent-scoping seam) ---------------
- //
- // Every scope-filtered event family must dispatch with a scope carrier
- // (scopeTarget) whose key IS the subject the event's arguments name —
- // a dispatch without one silently reverts that event to global delivery
- // (agent-scoped listeners over-hear foreign agents), and a mis-keyed one
- // delivers to the wrong agent's listeners. `internal/dispatch` fires
- // synchronously before listener delivery, so a violation throws at the
- // dispatching call site. The generated table maps each family to the unique
- // payload path whose Program type matches the real scopeTarget routing key;
- // `null` means the key is external to the payload, so only carrier presence
- // can be asserted.
- ctx.on('internal/dispatch', (_mode, name, args, thisArg) => {
- const subjectOf = scopedSubjectResolverFor(name)
- if (subjectOf === undefined) return
- if (!isScopeCarrier(thisArg)) {
- throw new InvariantError(
- `"${name}" is a scope-filtered event but was dispatched without a scope carrier — `
- + 'pass scopeTarget(base, subject) as the dispatch thisArg (agent events: use agentEvents(ctx, agent))')
- }
- if (subjectOf !== null && carrierKeyOf(thisArg) !== subjectOf(args)) {
- throw new InvariantError(
- `"${name}" was dispatched with a scope carrier keyed to a DIFFERENT subject than its arguments name — `
- + 'the carrier key and the event\'s subject must be the same object (use agentEvents(ctx, agent))')
- }
- if (name === 'session/event') {
- const [session, event] = args as [Session, SessionEvent]
- const trace = traceFor(session)
- const transition = validateEvent(trace, event)
- // The exact event identity reaches the contained post-commit listener.
- // A later internal/dispatch listener may still veto; because validation
- // is pure, abandoning this weakly keyed transition does not advance the
- // committed trace or retain the session.
- stagedTransitions.set(event, { session, trace, transition })
- }
- }, { global: true })
-
- // Request-reconstruction cross-check (the reconstructability RFC): a
- // loop-built request — frozen envelope + live sessionId is the marker; a
- // hand-built one-shot (compaction summarize) is unfrozen and skipped — must
- // be EXACTLY what the session log reconstructs:
- //
- // - messages: the folded header's session prefix (messagePrefix — the
- // `agent/session-prefix` product, logged on the header because no
- // session event carries it) followed by the
- // derivation over the log prefix strictly before the in-flight step's
- // `step/start` (the reconstruction boundary). The derivation is compared
- // against a FRESH Session built over that prefix — the same projection
- // code with zero shared state, so the live cache under test cannot vouch
- // for itself. Boundary-correct by construction: content appended after
- // the boundary (an `agent/request`-window inject) is legitimately absent
- // from this request, and a current-surface comparison would false-fire.
- // - header: every non-content field must equal the fold of the log's
- // `request/header` events — the loop logs the header event BEFORE
- // dispatch, so the fold already covers this request.
- //
- // Registered with `prepend: true` so a short-circuiting llm/stream listener
- // (the replay adapter returns its chunks without calling next()) cannot
- // silence the check by registering first. Prepend beats APPEND-registered
- // listeners only — two prepended listeners have no defined mutual order
- // (cordis unshift) — which is fine: correctness rests on the seq-bounded
- // fold below, never on listener timing.
- ctx.on('llm/stream', (options: GenerateOptions, next) => {
- if (options.sessionId === undefined || !Object.isFrozen(options)) return next()
- // GenerateOptions types sessionId as Branded<'SessionId'>, which IS
- // SessionId (dsh-llm cannot import it without a cycle) — no cast needed.
- const session = ctx.sessions.get(options.sessionId)
- if (!session) return next()
- if (!Object.isFrozen(options.messages)) {
- throw new InvariantError('a loop-built request must carry a frozen messages array')
+ if (this.registrations.has(packageName)) {
+ throw new Error(`invariants: package "${packageName}" is already registered`)
}
- const events = session.events
- // seq === index (checked above), so the last step/start's seq bounds the
- // prefix directly. The in-flight step's step/start is necessarily the
- // last one: the loop cannot open another step while this call streams.
- let boundary = -1
- for (let i = events.length - 1; i >= 0; i -= 1) {
- if (events[i]?.type === 'step/start') {
- boundary = i
- break
- }
- }
- if (boundary === -1) {
- throw new InvariantError('a loop-built request with no step/start in its session log')
- }
- const header = foldRequestHeader(events)
- if (header === undefined) {
- throw new InvariantError('a loop-built request with no request/header event in its session log')
- }
- const rebuilt = new Session(SessionId(`${String(session.id)}-invariant-rebuild`), structuredClone(events.slice(0, boundary)))
- // The reconstruction equation: the folded header's session prefix, then
- // the boundary derivation — the loop
- // logs the header event BEFORE dispatch, so the fold already covers this
- // request's prefix. JSON equality is sound here: both sides are
- // structuredClones produced by the same projection/build code path, so key
- // insertion order matches when the values do.
- const expected = [...header.messagePrefix ?? [], ...rebuilt.deriveMessages()]
- if (JSON.stringify(options.messages) !== JSON.stringify(expected)) {
- throw new InvariantError(`llm request for session "${String(session.id)}" diverges from the boundary derivation (log-reconstruction desync)`)
- }
+ // Service method tracing binds `this.ctx` to the caller. This explicit
+ // origin keeps registrations and their child fibers owned by the service;
+ // companion disposal is covered independently by the returned disposer.
+ const ctx = this.ownerCtx
+ const registrations = this.registrations
+ registrations.add(packageName)
- const headerMatches = options.model === header.config.model
- && options.system === header.system
- && options.temperature === header.config.temperature
- && options.maxTokens === header.config.maxTokens
- && JSON.stringify(options.stop) === JSON.stringify(header.config.stop)
- && JSON.stringify(options.tools ?? []) === JSON.stringify(header.tools ?? [])
- if (!headerMatches) {
- throw new InvariantError(`llm request for session "${String(session.id)}" diverges from the folded request header`)
+ let registration: PendingInvariantRegistration
+ try {
+ registration = ctx.effect(async () => {
+ if (!this.selected(packageName)) {
+ return () => {
+ registrations.delete(packageName)
+ }
+ }
+
+ const installInvariant = (childCtx: Context) => {
+ installer(childCtx, (message): never => {
+ throw new InvariantError(packageName, message)
+ })
+ }
+ const child = ctx.plugin(installer.inject === undefined
+ ? installInvariant
+ : Object.assign(installInvariant, { inject: installer.inject }))
+
+ try {
+ await child
+ } catch (error) {
+ try {
+ await child.dispose()
+ } finally {
+ registrations.delete(packageName)
+ }
+ throw error
+ }
+
+ return async () => {
+ try {
+ await child.dispose()
+ } finally {
+ registrations.delete(packageName)
+ }
+ }
+ }, `invariants.register(${JSON.stringify(packageName)})`)
+ } catch (error) {
+ registrations.delete(packageName)
+ throw error
}
- return next()
- }, { global: true, prepend: true })
+ // Cordis attaches setup thenability and async teardown to this callable;
+ // the service seam intentionally exposes only the conventional disposer.
+ // eslint-disable-next-line @typescript-eslint/no-misused-promises -- the extra runtime shape stays private.
+ return registration
+ }
}
+
+export default InvariantService
diff --git a/packages/support/invariants/src/scoped-events.generated.ts b/packages/support/invariants/src/scoped-events.generated.ts
deleted file mode 100644
index 36d1721945..0000000000
--- a/packages/support/invariants/src/scoped-events.generated.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-/**
- * Generated scoped-event routing-subject resolvers for dsh-invariants.
- * Do not edit by hand; run `pnpm run gen-scoped-events`.
- *
- * @module @deepseek-ai/dsh-invariants/scoped-events.generated
- */
-
-import type { Events } from 'cordis'
-import type { Scoped } from '@deepseek-ai/dsh-scope'
-import type {} from '@deepseek-ai/dsh-agent'
-import type {} from '@deepseek-ai/dsh-session'
-import type {} from '@deepseek-ai/dsh-subagent'
-import type {} from '@deepseek-ai/dsh-system-prompt'
-import type {} from '@deepseek-ai/dsh-tools'
-import type {} from '@deepseek-ai/dsh-user-approval'
-
-type ScopedEventName = {
- [K in keyof Events]: ThisParameterType extends Scoped