docs: state control boundaries and add a self-hosting page (#579)

Reword the local-model claim in providers.md to the models behind runnable examples, add framework-versus-integrator boundaries to durable-approvals.md, external-agents.md, and run-journal.md, and add docs/self-hosting.md covering runtime footprint, network scope, state locations, redaction, and air-gapped deployment.
This commit is contained in:
Jack Chen authored and GitHub committed 2026-09-05 12:27:01 +08:00
1 parent ab3654db1f
commit 472bd7f4e3
6 files changed
+314 -1

No files matched your search

+1
View File
@@ -104,6 +104,7 @@ These constraints span multiple files and can cause behavioral or compatibility
| Evaluation, scorers, stores, reports, sampling, and gates | [docs/evaluation.md](docs/evaluation.md) |
| CLI commands and JSON schemas | [docs/cli.md](docs/cli.md) |
| Process and ACP backends | [docs/external-agents.md](docs/external-agents.md) |
| Runtime footprint, network scope, state locations, and air-gapped deployment | [docs/self-hosting.md](docs/self-hosting.md) |
| Routing, scheduling, consensus, recovery, and replay | [model-routing](docs/model-routing.md), [execution-routing](docs/execution-routing.md), [task-scheduling](docs/task-scheduling.md), [consensus](docs/consensus.md), [adaptive-recovery](docs/adaptive-recovery.md), [plan-replay](docs/plan-replay.md) |
## Adding an LLM adapter
+42
View File
@@ -161,6 +161,48 @@ exact. Use access controls and encryption appropriate for that data.
is lossy and would change the content hash; it remains available for ordinary
checkpoint and shared-memory redaction.
## What the framework provides, and what you build
The framework's side of an approval is the execution mechanism and the durable
record: the gates that can return `{ action: 'suspend' }`, the primary approval
row keyed under `__oma_approval__/<requestId>`, the exact-content binding, the
`getApprovalRecord` / `decideApproval` / `DurableApprovalLedger` reviewer
helpers, and `OpenMultiAgent.restore()`. That is the whole surface.
There is deliberately no reviewer product on top of it:
- **No approval UI.** Nothing in the package renders a request or collects a
decision.
- **No CLI command.** The `oma` CLI is a non-interactive wrapper. Its commands
are `run`, `task`, `dashboard`, `eval run`, `eval gate`, `provider`, and
`help`; there is no `approve` or `reject`.
- **No transport.** OMA never notifies a reviewer, routes a request to a queue,
or exposes an HTTP endpoint. A suspended run returns `pendingApprovals` to
the caller and stops.
- **The dashboard is read-only about approvals.** The Run Viewer maps the
`oma.approval.approved` and `oma.plan.approved` trace facts to display labels
and renders them alongside the rest of the run. It is a post-run HTML
artifact with no write path back into the ledger.
An integrator therefore supplies three things:
1. **A store.** A `MemoryStore` whose `compareAndSet` is atomic across every
writer that will decide, and that is reachable from both the suspended
process and the reviewer. Suspension fails closed with
`APPROVAL_ATOMIC_STORE_REQUIRED` when the store cannot decide atomically.
See [Store requirements](#store-requirements) for what the bundled stores
do and do not give you.
2. **A review surface.** Something that shows the reviewer `request.content`
and `request.requestHash`, and collects back a decision plus a reviewer
identity. The hash is the part that matters: a reviewer who submits a hash
they did not inspect defeats the exact-content binding, and OMA cannot tell
the difference. Authentication, authorization, and the audit trail of who
was shown what are entirely yours.
3. **A resume path.** A process that calls `decideApproval(store, { requestId,
requestHash, decision, reviewer })` and then rebuilds the team wiring and
calls `restore(team, { checkpoint: { store } })`. The two can be the same
process or different ones, subject to the store's cross-writer guarantees.
## Explicit limits
- Tool-call suspension is supported only inside checkpointed built-in LLM
+43
View File
@@ -205,6 +205,49 @@ at the run boundary, so an ACP usage delta can exhaust the remaining run budget.
The process backend continues to contribute `{0, 0}` because it has no token
signal.
## Control boundary
An external agent is a first-class team member at the orchestration layer and
an opaque subprocess below it. The split is structural rather than a policy
choice: when `AgentConfig.backend` is set, the agent resolves to the external
backend and never constructs an `AgentRunner`, and the runner is where the tool
registry, the tool executor, the filesystem sandbox, the context strategy, and
the per-turn journal emission all live. Everything the orchestrator does around
a task still happens; everything the runner does inside one does not.
**Still applies.** These are enforced above the backend and are
backend-agnostic:
| Control | Where it acts |
|---|---|
| Task DAG placement, dependency ordering, and failure cascade to dependents | Task queue and scheduler; a failed external task skips its dependents like any other |
| `onPlanReady`, `onApproval`, and `onTaskDispatch` gates | Orchestration level, so the assignee's backend kind is irrelevant to them. `onTaskDispatch` in particular runs immediately before dispatch and before the backend is resolved, so rejecting it means the subprocess is never spawned |
| Token budget | The backend's reported usage aggregates into the run and is checked against `maxTokenBudget`. Read the [accounting caveat](#acp-token-accounting-caveat) first: an ACP agent that emits no `usage_update`, and every process-backend agent, contributes `{0, 0}` and is therefore not budget-gated in practice |
| Shared memory | The orchestrator writes the completed task's output to shared memory under `task:<id>:result` after the task finishes |
| Run and task journal events | `run/start`, `run/end`, `plan/set`, `task/status`, `memory/set`, `approval/request`, `approval/decision`, and `checkpoint/saved` are emitted by the orchestrator and task-execution layers |
| Abort propagation | `RunOptions.abortSignal` reaches both backends. The process backend kills the child's process tree. The ACP backend sends `session/cancel` and relies on the agent to stop, so cancellation there is cooperative and the subprocess stays alive for the next turn |
| Process stderr redaction | A non-zero exit or signal builds its error message through the shared credential redactor, so stderr is scrubbed before it reaches the task result |
**Does not apply.** None of these reach an external backend, and no
configuration makes them:
| Control | Why not |
|---|---|
| `onToolCall` per-call gate | The gate is evaluated by the tool executor inside `AgentRunner`. An external agent's tool calls never pass through it. ACP `tool_call` updates still populate `result.toolCalls`, but that is reporting, not a gate |
| Filesystem sandbox | `AgentConfig.cwd` scopes the built-in filesystem tools through that same runner. A backend's `cwd` is a plain working directory the subprocess reads and writes directly, with your process's permissions |
| `egressPolicy` | Scoped to framework-owned LLM requests. External backends and tool code are outside it by design, and the child owns its own network behavior. See the [egress enforcement matrix](egress-policy.md#enforcement-matrix) |
| Tool-level journal events | `turn/start`, `turn/end`, `user/message`, `assistant/message`, `llm/request`, `tool/call`, `tool/result`, and `context/replace` are emitted only by the runner. Neither backend reads the journal recorder it is handed, so a journaled run records that an external task ran, not what happened inside it |
| Mid-task checkpoints | Neither backend calls the runner checkpoint hook, so an external task checkpoints at task boundaries only. A `suspend` tool decision fails closed there; see [durable approvals](durable-approvals.md#explicit-limits) |
**ACP permissions default to auto-approve.** `permission` defaults to
`'auto-approve'`, so unless the application sets it, every permission prompt
the agent raises is answered yes. OMA picks the least-privilege option offered
(`allow_once` before `allow_always`), which bounds the blast radius of one
decision but does not change the default answer. `'reject'` or a callback is
the only way to make a permission prompt a real gate. The process backend has
no protocol-level permission prompts at all, so the configured `command`,
`args`, `env`, and `cwd` are the entire control surface.
## Programmatic API
Most users only touch `backend`. To construct a backend directly, import from the
+3 -1
View File
@@ -173,7 +173,9 @@ Reasoning is streamed as `reasoning` events. Preserving reasoning across a provi
The framework supports tool-calling with local models served by Ollama, vLLM, LM Studio, or llama.cpp. Tool-calling is handled natively through the OpenAI-compatible API.
Verified local models include Gemma 4, Llama 3.1, Qwen 3, Mistral, and Phi-4. Ollama publishes its tool-capable models at [ollama.com/search?c=tools](https://ollama.com/search?c=tools).
The local models exercised so far are the ones behind the runnable examples in this repository: Gemma 4 on Ollama ([`providers/gemma4-local`](../packages/core/examples/providers/gemma4-local.ts)), Llama 3.1 on Ollama ([`providers/ollama`](../packages/core/examples/providers/ollama.ts)), and a quantized Qwen2.5 on vLLM or llama-server ([`providers/local-quantized`](../packages/core/examples/providers/local-quantized.ts)). Those examples are run by hand against a local server you start yourself.
The automated coverage underneath them is narrower: `packages/core/tests/text-tool-extractor.test.ts` unit-tests the text tool-call extractor against the formats it recognizes, including bare JSON, fenced code blocks, and Hermes-style `<tool_call>` tags. It pins those output shapes, not any particular model. **No local model is covered by the end-to-end suite.** `packages/core/tests/e2e/` contains only hosted-provider cases; the whole directory is excluded from `npm test` unless `RUN_E2E` is set, and each suite then skips itself without its provider's credentials. Treat any other local model, and any other quantization or serving stack, as unverified here and check it against your own workload. Ollama publishes its tool-capable models at [ollama.com/search?c=tools](https://ollama.com/search?c=tools).
If a local model returns tool calls as text instead of the `tool_calls` wire format, the framework automatically extracts them from the text output. This helps with thinking models or misconfigured local servers.
+21
View File
@@ -254,6 +254,27 @@ new OpenMultiAgent({
This holds at approval boundaries too, where ordinary checkpoint saves escalate. Durability there is the [durable approval ledger](durable-approvals.md)'s job; the journal only records that the boundary existed. Losing the audit trail must never roll back a run that actually happened.
## Identity and integrity
Two properties an audit reader tends to assume are not there, and both are worth stating before someone builds a compliance story on this file.
**Journal events identify agents, not people.** Every event carries `agentName` where one applies, alongside `runId`, `attempt`, and `taskId`. There is no operator, user, actor, or principal field on any event type, and nothing populates one. The journal answers which agent produced a block, never which person authorized it.
Human identity enters the record in exactly two places, both downstream of a [durable approval](durable-approvals.md):
- **`approval/decision` events**, which carry the `ApprovalDecisionRecord` verbatim, including its `reviewer.id` and optional `reviewer.displayName`. That identity is whatever the application passed to `decideApproval`; OMA validates that `id` is a non-empty string and does not authenticate it.
- **The execution receipt**, whose approval summary copies the same values as `reviewerId` and `reviewerDisplayName`.
An unapproved run therefore has no human name in it at all, which is the correct reading: nobody was asked.
**`verifyRun()` detects drift, not tampering.** It checks three things: sequence monotonicity, referential integrity of `sourceEventSeqs`, and per-block content hashes against the events a request names. What it does not check is the file's provenance:
- **No hash chain.** No event carries a digest of its predecessor. Hashes cover one content block each and are recorded on the citing `llm/request`, so rewriting an event in place, or dropping one and renumbering what follows, leaves nothing structurally inconsistent to find; only a raw reorder or a duplicated `seq` trips the sequence check.
- **No signature.** Nothing in the journal is signed. The subsystem uses SHA-256 for content identity only and holds no key material.
- **No WORM storage.** `JsonlRunJournal` appends to an ordinary file with no lock, which anything with write access can rewrite afterwards. `InMemoryRunJournal` is a ring buffer in process memory and is not durable at all. Neither backend makes a written event immutable.
A writer that can edit the journal can edit an event and recompute the `contentHash` that cites it in the same pass, and `verifyRun()` will return `ok`. This is the same trust boundary [durable approvals](durable-approvals.md#exact-content-binding) draws: integrity checking across OMA's own records, not a cryptographic claim against whoever controls the storage. If you need tamper-evidence, put the journal on storage that provides it, and treat `verifyRun()` as a check on what OMA wrote rather than on what is on disk now.
## Journal versus telemetry
[Trace records](observability.md) and journal events describe the same run and deliberately do not depend on each other. Traces are **telemetry**: losing them must never roll back durable state, and they may be sampled, batched, exported, or dropped. Journal events are **execution state**: they record what the run did and what the model saw. The `journal/` module does not import from `observability/`, so trace loss cannot imply journal loss and neither can the reverse. Events carry `traceId`/`spanId` when a trace runtime is active, which is enough to join the two streams without coupling them.
+204
View File
@@ -0,0 +1,204 @@
# Self-hosting and data residency
`@open-multi-agent/core` is a library, not a service. There is no OMA backend,
no account, and no hosted control plane: the framework runs entirely inside
your Node.js process, and everything it persists is written through a store you
supply. This page collects the facts a review needs in one place. Each claim is
about the framework itself; where your own configuration decides the answer,
that is said explicitly.
## Runtime footprint
Core's runtime dependencies are:
| Package | Purpose |
|---|---|
| `@anthropic-ai/sdk` | Anthropic provider adapter |
| `openai` | OpenAI adapter and every OpenAI-compatible provider, including local servers |
| `zod` | Tool input schemas and structured output validation |
Everything else is a peer dependency and optional: the ACP SDK, the MCP SDK,
the Google GenAI SDK, the AWS Bedrock client, and the Vercel AI SDK. Each is
loaded through a dynamic `import()` at the moment a feature needs it, so an
install that never uses ACP never resolves the ACP SDK. Provider adapter
modules are dynamically imported too, so the two bundled provider SDKs load
only when an adapter that needs them is constructed.
The supported runtime is Node.js 20 or newer. OpenTelemetry lives in the
separate `@open-multi-agent/otel` package and is never pulled in by the core
import.
## What connects to the network
The framework opens outbound HTTP for one purpose: talking to the LLM endpoint
your configuration names. `AgentConfig.baseURL` and the provider environment
variables decide where that is. Point them at a local server and the framework
has no remaining reason to leave the machine. The one provider that reaches
past its own model API is GitHub Copilot, whose adapter also calls
`api.github.com` for a token and, for interactive device login, `github.com`.
There is **no telemetry, no analytics, no license check, no update check, and
no phone-home of any kind** in the package. "Telemetry" inside this codebase
means the local [observability](observability.md) subsystem: trace records that
go to a sink you construct and a store you own. Nothing is transmitted unless
you wire an exporter yourself, and the OpenTelemetry adapter that would do so
is a separate optional package with its own lifecycle.
Four things start subprocesses rather than connections, and each is something
you configured:
- **MCP servers** connect over stdio only. The MCP client uses
`StdioClientTransport`, so a server is a child process on the same machine.
There is no HTTP or SSE MCP transport in core.
- **`process` and `acp` backends** spawn the command you name. See
[external agents](external-agents.md).
- **The `bash` tool** runs commands through a local shell.
- **Process-tree cleanup** shells out to `taskkill` on Windows to terminate a
child's descendants.
Each of those children has your process's permissions and its own network
access. The framework starts them and exchanges bytes; it does not contain
them.
## Bounding LLM egress
`egressPolicy` narrows where a framework-owned LLM request may go. Two modes:
- `offline` permits only loopback origins: `localhost`, `*.localhost`,
`127.0.0.0/8`, and `[::1]`.
- `allowlist` permits only the exact HTTP(S) origins you list.
Policies configured at the orchestrator, run, and agent levels are
intersected, so a narrower scope can tighten but never widen a broader one.
Enforcement means the origin is checked before the provider SDK loads, a
guarded fetch re-checks every request, and redirects are rejected. An adapter
OMA cannot enforce fails closed rather than proceeding unguarded.
**Read the scope statement carefully.** The policy covers LLM requests the
framework itself issues. It does not cover tool code, the `bash` tool, MCP
server internals, process and ACP children, application callbacks, or your own
trace exporters. `offline` is a statement about OMA's provider calls, not
evidence that the Node.js process and its descendants are offline. The full
per-surface table is the
[egress enforcement matrix](egress-policy.md#enforcement-matrix).
## Where state lives
Nothing is persisted anywhere you did not choose. Shared memory, checkpoints,
and durable approvals all go through the `MemoryStore` interface, and core
ships three implementations:
| Store | Durability | Notes |
|---|---|---|
| `InMemoryStore` | None; dies with the process | Process-local `compareAndSet` |
| `FileStore` | One JSON file, rewritten atomically per mutation | Single Node process at a time; no cross-process file lock |
| `RedactingStore` | Delegates to whatever it wraps | Scrubs values on write; deliberately exposes no `compareAndSet`, so it cannot back durable approvals. See [redaction](#redaction) |
**There is no bundled database store.** Redis, Postgres, and SQLite appear in
the source only as examples of what an application might implement behind
`MemoryStore`. Writing one is the supported path when you outgrow a file.
**Cross-process atomicity is not provided.** `FileStore.compareAndSet` is
atomic among callers sharing one `FileStore` instance, and that is the whole
guarantee. A cross-process reviewer should therefore decide only after the
suspended runner has exited, or the deployment needs a database-backed store
whose `compareAndSet` is atomic across all writers. This matters most for
[durable approvals](durable-approvals.md#store-requirements), where the
decision write is the correctness boundary.
Trace records and evaluation records have their own stores and the same shape
of choice: `InMemoryTraceStore` or `FileTraceStore`, `InMemoryEvalStore` or
`FileEvalStore`. Both file-backed variants are append-only local files.
## Filesystem scope
Built-in filesystem tools resolve every path, symlinks included, inside a
sandbox root. Unset, that root is `<process.cwd()>/.agent-workspace`, created
on first write. `OrchestratorConfig.defaultCwd` or `AgentConfig.cwd` moves it,
`process.cwd()` widens it to the whole project, and `null` disables it.
**The `bash` tool is not sandboxed.** It takes a `cwd` argument straight from
the model and runs the command through a shell with your process's
permissions. The sandbox root does not constrain it, and neither does the
per-call gate unless you configure one. Granting `bash` is granting shell
access to the host; see [tool configuration](tool-configuration.md) for the
grant model and the per-call `onToolCall` gate.
## Redaction
A shared credential redactor runs at several surfaces before content is
persisted or exported:
- trace attributes and the tool input/output an agent run records
- observability status messages and record processors
- task metadata
- `bash` tool output
- `process` backend stderr on a failed exit
- evaluation sampling and report payloads
- `RedactingStore` values, and `JsonlRunJournal` events when `redact` is set
Its built-in patterns are credential-shaped: key/secret/token/password-style
field names, `Authorization` and `Bearer` headers, PEM private key blocks, and
literal token formats such as `sk-`, `ghp_`, `AKIA`, and `xox`-prefixed keys.
**PII is not covered by default.** Emails, national IDs, account numbers, and
anything else domain-specific pass through unchanged unless you supply the
patterns yourself. The two surfaces that accept them are `RedactingStore` and
`JsonlRunJournal`, both through the `patterns` option (the underlying helper
calls the same list `extraPatterns`); the other surfaces above run the built-in
patterns only and take no caller additions. Redaction is also best-effort by
construction: it is a set of regular expressions over text, not a classifier.
Do not rely on it as the only control over what an agent may emit.
One boundary is easy to miss: telemetry redaction does not reach persisted run
state. Shared-memory writes and checkpoint saves store agent output verbatim
unless the store itself is wrapped in `RedactingStore`. See
[shared memory](shared-memory.md#redacting-persisted-secrets).
## Air-gapped deployment
Two pieces of configuration cover the framework's own behavior:
1. **An OpenAI-compatible endpoint.** Configure it as `provider: 'openai'` with
a `baseURL` pointing at the server, plus a non-empty placeholder `apiKey`
because the OpenAI SDK validates that the field is set even when the server
ignores it. See
[local model tool-calling](providers.md#local-model-tool-calling).
2. **An egress policy**, so a stale `baseURL` or an inherited environment
variable fails closed instead of reaching a hosted provider. Use
`{ mode: 'offline' }` when the endpoint is on the same host: it permits
loopback only, and deliberately rejects private LAN addresses. A server
elsewhere on the network needs `{ mode: 'allowlist', allowedOrigins:
['http://<host>:<port>'] }` instead, because `offline` would block it.
Everything else in the boundary is outside what the framework can enforce: an
MCP server that calls out, a `bash`-granted agent, an external backend whose
child has network access, or a trace exporter pointed off the machine. The
framework will not open a connection on its own, but it also cannot contain the
code it starts on your behalf. A network namespace, an egress proxy, or a host
firewall is the control that actually does.
## What is not provided
So that nothing here is read as more than it is, the framework does **not**
include:
- **Multi-tenancy.** There is no tenant concept. Isolating one customer's runs,
stores, and workspaces from another's is an application concern.
- **RBAC or any authorization model.** No roles, no permissions, no policy
engine. The tool grant model and `onToolCall` gate are per-agent
configuration, not an identity system.
- **SSO or authentication.** OMA never authenticates anyone. A durable approval
records the `reviewer.id` string you pass it and validates only that it is
non-empty.
- **An approval UI or workflow.** The suspend/decide/resume API and the durable
record exist; the reviewer-facing product does not. See
[durable approvals](durable-approvals.md#what-the-framework-provides-and-what-you-build).
- **Tamper-evident audit.** The run journal has no hash chain, no signature,
and no WORM storage. See
[identity and integrity](run-journal.md#identity-and-integrity).
- **A database-backed store.** Only in-memory and single-file implementations
ship. Anything with cross-process guarantees is yours to write.
- **Encryption at rest.** File-backed stores write plain JSON and
newline-delimited JSON. Use disk or filesystem encryption appropriate to the
data those files will hold.