# onepipeline contract
Composes oneagentgraph + onevcs, owns the task DAG, merges the three event streams.
Surface kinds are an open vocabulary. A kind matches `^[a-z][a-z0-9-]{0,63}$`.
The engine declares and acts on only `check-in` (queued with source `check-in`)
and `finding` (queued with source `proposal`), and raises one kind of its own:
`edit-applied`, queued non-blocking when an edit by any author other than the
planner is applied, with `source` that author's word and the message
`<author> applied an edit: <command>`; a `finding` raises none, having already
said its piece. Every other well-formed kind is relayed unchanged, including its
message, source, blocking flag and unread accounting; consuming any kind resets
the check-in clock. `SurfaceKind` parses, serializes and deserializes that open
word without narrowing it to a fixed list.
Channel authors are open words. An omitted author is `planner`; otherwise the
launch's `planner-channel` bus configuration declares the author and its granted
operations. An undeclared author, an ungranted operation, and `completion: true`
without the `complete` grant are refused before anything is appended or applied.
Recorded envelopes, journals, checkpoints and parks replay any author word
unchanged. Every non-planner author's applied edit is surfaced as `edit-applied` with that
author as its source, its findings use that source, and it may requeue only its own park.
Serving a channel member protocol, resolving schema links and interpreting codec
bindings belong to the host's `onemessagebus serve`, not this engine.
A plan is **one onetaskgraph project**, and a node is one task in it. Plan schema v3 = ai-orchestrator tracked-graph schema v7 node shapes (`agent` direct, lifecycle with `repo`, `kind: human`, nested `steps` on one branch, `expects_no_diff`, `context`, cross-DAG `run:<id>#<node>` refs, What/Why/Acceptance-criteria task prose, per-node `max_turns`), with: `repo` resolved through onevcs; new optional per-node `executor: NAME`; new optional `agent_graph: REF` overriding the default node-scope graph config; a **required** `title` on every lifecycle node and an optional `body` beside it, which are the subject and the prose its change request opens with, and an optional `draft: bool` beside those, default `false`, which leaves that change request as a draft at closeout for a person to lift; two optional placement overrides beside those, `pool: u32` and `overflow: N|unlimited`, which are `onevcs`'s own per-open overrides copied onto the session request exactly as written — `pool: 0` still counts against `overflow`, and `overflow: unlimited` opts the open out of the cap; onepipeline adds no vocabulary of its own — and refused by name below schema 3 exactly as `draft` is; and **no `done_when`** — v7's judge-only bar is retired here, because onejudge hands that field to the judge verbatim and the criterion belongs in the onejudge document the node-scope graph's worker already points at, written once. A per-node bar is the task's own `## Acceptance criteria`, which the judge reads as the first message of the transcript it is given. A plan still carrying `done_when` is refused **by name**, and the refusal says where the bar goes instead.
`schema_version: 3` is the change that made the change request the plan's to state, and it is **additive**: this build reads **3, 2, and 1**, and what each version added is keyed to the version the *document* declares. A lifecycle node needs a `title` at 3; a plan written before it states none, and its publication takes no subject of its own — which is onevcs deriving one from the branch's own conventional commits, a better subject than anything this layer could compose about work it did not do. A plan below 3 naming `body` is refused **by that field's name**, exactly as a field no schema ever had is: the field is what its author has to act on, not the number the document was written at. `done_when` is answered the same way at every version — it is not a field of this schema, and the refusal says where the review bar goes instead. `verify_via_ci` is retired on the same terms and refused by that field's own name at every version: it was accepted here and read by nothing, and what it asked for is now observed rather than declared — a `change-auto` publication watches the host's own required checks to their conclusion, so the refusal names that policy rather than leaving its author to guess what asks for CI verification now. The **only** version refusal is a number this build has never written, and it names the versions that are read. Every optional field is omitted from what this crate writes when it is empty, so a plan round-trips as the file wrote it.
Where a plan lives: a run is launched by naming a **qualified onetaskgraph project id** — `otg:plan-store` — which reaches `onepipeline start <qualified-project-id>`. A plan file is not a launch input at any schema version. The id is qualified by **source**, so a `local-md` project id is launchable directly, with no copy into a remote system first. Nothing special-cases a remote source. That is the flow: author locally, read it back, run it, and copy it up only when it should become durable.
**A plan is one onetaskgraph project; a node is one task in that project.**
* The plan-level fields — `schema_version`, `goal`, `name`, `concurrency` — are reserved metadata keys `onepipeline.<field>` on the **project**, carrying the same names and the same JSON types the schema-3 plan document uses. The project's own `title` is the plan's name when `onepipeline.name` is absent.
* A node's **id** is the reserved metadata key `onepipeline.id` on the task: required, and unique within the project. It is a metadata key rather than the task's native id because a native id is opaque in a remote system, and a node id has to survive a copy between two sources.
* A node's **task prose** is the onetaskgraph task's `content`.
* A node's **title** is the onetaskgraph task's `title`. Every source carries one, so a title that is blank is the store's way of carrying none, and a lifecycle node whose task's title is blank is refused for the `title` schema 3 requires of it.
* A node's **deps** are real onetaskgraph dependency edges — the `blocks` edges arriving at this task — resolved to node ids through each far task's own `onepipeline.id`. An edge whose far end carries no `onepipeline.id` is refused where the plan is read, naming both ends. A cross-DAG `run:<id>#<node>` reference is the one dependency no edge can carry — it names another run's DAG, which is an item of no source — so it is the reserved key `onepipeline.deps`, which carries those references and nothing else.
* A node's **repo** is the first entry of its task's `repositories`. A `onevcs` identity that is not a normalized origin — a local checkout, named by its absolute path — is not a value that list can hold, and travels on the reserved key `onepipeline.repo`; a task naming a repository both ways is refused.
* A node's **delivers** is its task's own `delivers`: the tickets its work delivers, as qualified task ids, a bare entry naming the task's own source. It is never a metadata key — a task stating `onepipeline.delivers` is refused as not a node field — and a node whose task delivers nothing delivers nothing. An entry that is not a qualified task id, and two nodes of one plan delivering the same ticket, are refused where the plan is read and where an edit introduces them, naming the node and the entry, or both nodes and the ticket.
* Every remaining schema-3 node field — `kind`, `persona`, `max_turns`, `expects_no_diff`, `context`, `parked`, `executor`, `agent_graph`, `repo_type`, `workflow`, `merge_policy`, `base_branch`, `branch`, `body`, `draft`, `execution_checkout`, `pool`, `overflow`, `steps`, `resume`, `adoption`, `consumes` — is a reserved `onepipeline.<field>` metadata key carrying the same JSON value the plan document carried under that name. A retired field is refused by its own name here exactly as it is in a plan document.
Every validation a plan document crossed is applied at the point a project is read, before any provider time is spent: the shape rules, the reference rules, acyclicity, the required title on a lifecycle node, a key no plan field answers to, and the named refusal for each retired field.
**onepipeline drives the binary, it does not link the crate.** That is onetaskgraph's own recorded decision for both of its SDKs, and it is what keeps a crates.io release ordering out of every onepipeline release. onepipeline resolves the binary from `ONETASKGRAPH_BIN` when that names an executable and from `onetaskgraph` on PATH otherwise. At launch, before anything is dispatched, it compares the binary's reported version against the minimum this build requires and refuses — naming the path it resolved, the version it found, the minimum it needs, and how to install one. An absent binary is refused the same way, and neither refusal creates a run root or dispatches a node.
**Status writes back; the journal does not move.** As a node's state changes, onepipeline writes that state onto its task's status category: `queued` while a driver drives the run and the node waits, `in-progress` while it is dispatched, `done` when it settles done, and `cancelled` when it is parked or dropped. Before its first dispatch, every driver — the one a launch starts and the one an adoption starts — waits for one attempt at projecting the whole plan, bounded by the store's command deadline, so every task the run has claimed reads `queued` before any of that work starts; an attempt that fails or times out is reported, and the dispatch goes ahead. At closeout, whether the run settled or was stopped, a node that never started is written `todo`, which releases the claim. Each node's `delivers` is written onto its task, and the tickets those tasks deliver move by the store's own relation between the two: onepipeline writes no ticket itself. The settlement detail goes to reserved metadata. The run journal and the run ledger stay onepipeline's, and the plan of record is still the graph the run is executing, projected from that journal under this run's ownership lock rather than re-read from the store.
**Live edits write through.** Every accepted graph edit updates the onetaskgraph project's tasks after the reconciler commits it to the journal. Write-back is best effort and retried off the reconcile loop: a slow or unavailable store is reported and never changes an edit ruling, a node settlement, or a scheduling decision.
A store is configured in onetaskgraph's own document, `onetaskgraph.yaml`, discovered from the directory a launch runs in — so which backend a plan lives in is the operator's, and this crate ships no configuration surface of its own for it. `examples/plan-store/` is one such store, holding the example plans this crate ships.
Per-node **controls** — what a node declares about how its dispatch runs, as against what it is asked to do — reach that dispatch as node-scope overrides on its agent-graph launch, applied after the run-wide `--node-set`s: `max_turns` is the worker member's own turn ceiling. A control this build accepts and cannot apply refuses the plan at validation, and refuses the launch again where it is composed; neither path may fall back to a default. A control a dispatch could not run under is not representable on the dispatch at all — `max_turns` is a `NonZeroU32` there, and the checked conversion from the plan's own `Option<u32>` happens at the trust boundary, so `max_turns: 0` is refused where the plan is read rather than by the member that could not start. A node with `steps` declares its persona, task, and turn budget on them, and a `kind: human` or `expects_no_diff` node — which has no dispatch — declares none of the three.
`resume` continues a node on the branch its previous attempt preserved: `{branch, checkpoint?, completed_steps?}`. `branch` is the preserved branch. `completed_steps` names the steps that branch already carries, and a continuation skips exactly those and re-runs the rest — an absent or empty list re-runs the whole workstream, which repeats work but never skips it. `checkpoint` must be a commit reachable on the remote; a local-only revision is not a checkpoint, because the machine that continues the node is not the machine that made it.
Cross-DAG edges: a `run:<id>#<node>` dep resolves by reading the referenced run's ledger, and only a `node-settled` of `done` satisfies it. An unknown run, a node that has not settled, and one that settled `failed` or `skipped` all leave the consumer **blocked, never failed** — the upstream may still arrive. Resolution is re-read on **every reconcile pass**, so an upstream that arrives after its consumer was blocked starts that consumer on the pass that observed it rather than parking the run. On first resolution the consumer records how far the upstream had got (`cross-dag-satisfied`, `{dependency, last_seq}`); if the upstream passes that point afterwards the consumer reports it once (`upstream-modified`, `{dependency, captured_last_seq, observed_last_seq}`) and is **reported, not re-run** — the work was correct when it was done, and repeating it is the planner's judgement. `last_seq` is the count of records in the upstream's merged store, because a run is written by several processes and no single stream's `seq` describes it.
Execution is **continuous and deterministic**. There are no rounds: `onepipeline start` runs the engine's reconcile loop itself, and a node — and each step within a lifecycle node — dispatches the moment its dependencies settle `done`, with settlement triggering integration and publication immediately. The loop drains the planner's durable command queue, re-resolves cross-DAG edges, and re-derives the frontier on every pass. **No agent is required to use onepipeline**: deterministic code drives the whole lifecycle.
The only thing that pauses anything is a **decision point**, and it pauses only the subtree that depends on it. A blocking surface — a ready `kind: human` node's attestation, a completion request, any surface declared blocking — holds its dependents back while independent branches proceed, and clearing it (`attest`, `reply`) auto-resumes the paused subtree inside the running loop, with no external driver action. Non-blocking surfaces never pause anything. `decision-pending` is emitted when a blocking surface begins holding dependents back and `decision-cleared` when it releases them. **A full repository identity holds, and pauses nothing.** Before a queued lifecycle node comes forward the driver asks `onevcs::workspace_capacity` with the same `SessionRequest` the dispatch would open with — the identity's pool of warm worktree slots and its overflow bound are `onevcs`'s, sized by the host — and a node the identity admits no session for now is **held under `workspace`** rather than dispatched into a refusal: skipped, not broken past, so a node of another identity behind it still starts in the same pass, and within one pass the driver counts its own dispatches per identity so two nodes of one identity do not both leave on one reading. The hold is a `workspace` entry on the node-held record (the divergence log's entry 55), carrying the `identity` and the reading it was held on — `pool`, `slots`, `idle`, `maintaining`, `overflow` (`"unlimited"` or a number) and `overflow_in_use` — beside the executor's host-wide capacity and never folded into it. A non-blocking `workspace-wait` surface, source proposal and workstream the node, is raised on the cadence a release wait is surfaced at, saying the identity admits no more sessions now, the numbers, that `onevcs pool status <identity>` names the holders, and that nothing times it out; while any node is held this way the driver re-reads the identity on a paced timer as well as on every pass, and the hold clears the pass the identity admits it. The read is advisory and `open` is authoritative: a read that fails — an unregistered identity, an unreadable record — holds nothing, and the dispatch goes ahead as it always did.
Driver contract: `onepipeline start PROJECT [--attach|--detach] [--dag-graph off|REF] [--pr-author-graph REF] [--heartbeat-interval 1800] [--set PATH=VALUE]... [--node-set PATH=VALUE]... [--acknowledge-concurrent] [--launch-config FILE] [--filter-agentgraph SPEC] [--filter-vcs SPEC] [--filter-profile NAME=SPEC]... [--success-hook COMMAND] [--failure-hook COMMAND] [--hook-timeout SECONDS] [--dispatch-env-hook COMMAND] [--dispatch-env-hook-timeout SECONDS] [--maintenance-config FILE]` executes the plan the qualified onetaskgraph project id names, holding the run ownership lock (single writer) in the process that drives it — attached, this one; detached, the driver it retains. `--dag-graph` defaults to `off`; given a REF it launches that dag-scope agent graph (the shipped example, `graphs/dag-scope.yaml`, is an observer member beside a resettable-cron `check-in` member; a host copies it or brings its own, and the engine names no member of it) as an **observer and channel author** — never as the driver, and no member of it runs an engine verb. Its channel role is supplied by the host through the configured message bus. `--pr-author-graph` names the agent graph a lifecycle node's change request **body** is drafted by, and defaults to no graph at all, exactly as `--dag-graph` defaults to `off`: this crate ships the flag, not the document. Each repeatable `--set` is forwarded opaquely and in order to that dag-scope launch; each repeatable `--node-set` is forwarded opaquely and in order to every node-scope launch. Both lists — and the pr-author graph — are retained in the launch record and replayed by `adopt`. Attach returns when the run settles — the graph completed, a blocking surface waits with nothing else able to move, or exit 3 = nothing is driving the run. `awaiting-planner` means exactly that conjunction: an outstanding decision **and** nothing else able to move. A node held under `workspace` **is** something able to move — the driver is re-reading its identity and dispatches it the pass the identity admits it, on its first attempt or on the re-dispatch a preserved failure earned, pinned as it was — so a run whose only remaining nodes are held that way is neither settled nor `awaiting-planner`: it is waiting on the host, and attach waits with it. An attached launch is running the loop, so it waits out every decision it can still make progress beside — independent branches keep going and a decision cleared while they do resumes its subtree inside that loop — and returns only when the run cannot advance without something arriving over the channel. A detached launch, and an attached one that has returned, hands the same state to `adopt`. What keeps driving after a launcher returns is **this executable**, never an installed sibling: a detached dag-scope launch retains `onepipeline drive GRAPH --task ... --dir ...`, which composes this build's own `oneagentgraph` so one build decides what a graph document may contain, and the detached run driver is `onepipeline drive-run RUN`, which takes the ownership lock, launches the observer graph the run declares — inside the process tree a `stop` reaps — and runs the same loop an attached launch runs in-process. Both are `hide = true`, absent from `--help` and from the surface list, and both are reached directly by `scripts/smoke-published.sh`, which is required of every hidden verb because a published artifact that cannot reach them cannot launch a detached run at all. `onepipeline adopt RUN` attaches a fresh driver to an intact ledger, resuming mid-decision from the folded state. It refuses a run something is genuinely driving; a run the liveness verdict has already called PARKED — or otherwise undriven — it may take over, ending that parked driver politely, waiting for it to go, and only then taking the lock, which it says on stderr and still has no `--force` for. Ownership: runs belong to the launching session; `runs --mine`; `stop` refuses another session's run and `--force` names the owner.
**Run-end hooks.** `onepipeline start` takes `--success-hook COMMAND`, `--failure-hook COMMAND` and `--hook-timeout SECONDS`, and the launch config takes `success_hook`, `failure_hook` and `hook_timeout` — keys of launch-config `schema_version: 6`, refused by name in a document declaring an earlier one — with the precedence every other launch-config field has: a flag beats the config. Naming no hook is the shipped default and does exactly what a launch did before these existed; a blank value, flag or key, is this launch saying it has none. `--hook-timeout` is a positive whole number of seconds, `600` when unnamed, and zero is refused wherever it is read, the way a zero `--writeback-item-budget` is. All three are retained in the launch record — the timeout beside the hook it bounds — omitted when absent, so a record written by an earlier build reads as naming no hooks, and replayed by `adopt`, which takes none of them. A run whose record names no hook fires none, whichever build adopts it, and an ending whose hook the record does not name fires nothing and journals nothing.
A hook is judged at the moment a driver **lets go of the run** — once it has released the ownership lock — on the graph as it then stands; a node a `retry` or `drop` superseded has left the graph and does not count. **The two hooks are disjoint, and together they cover every way a run ends**: complete, failed or skipped, unfinished with no decision outstanding, and stopped. **success** fires when every node in the graph is `done`, which covers every outcome that settles done, `no-changes` and `change-draft` among them. **failure** fires with reason kind `nodes` when the graph holds a `failed` or `skipped` node; with reason kind `unfinished` when it holds no failed or skipped node, holds any node that is not `done` — parked, cancelled, blocked or still pending — and no decision is outstanding (no unanswered blocking surface, no ready human action); and with reason kind `stopped` when `onepipeline stop RUN` established a clean teardown, which the stop verb fires itself, after it journals `run-stopped`. A stop whose teardown was refused fires nothing. **A run that has not ended fires neither hook.** A driver that lets go with a decision outstanding — `awaiting-planner` — has paused the run, not ended it: it journals `run-hook-withheld` with `{"settlement": "awaiting-planner"}` and says on stderr that no hook fired because the run is paused on a decision, and a driver that later adopts the run fires whichever hook the run then reaches. A driver that dies fires nothing, a launcher that detaches fires nothing — its retained driver judges instead — and a run holding a `complete-but-draft` node has not ended. That is the whole of what "every way a driver stops" means, and it is read no wider: hooks fire **at most once per ending**, so a hook fired on a paused run would leave success unreachable for every run that ever asks a blocking question or waits on a human node. `run-hook-fired` is journaled before the command starts, and it is the marker: once a run carries one, no later driver, `adopt` or `stop` fires either hook again for that ending, which is what makes the two disjoint by construction. **An accepted edit that makes the run live again, or that carries it from one ending to a different one, retires that marker**, and the most recent such edit is the run's idempotency **epoch**. An edit makes the run live again when the graph it leaves behind holds a node the run can still carry out — one `ready` or `running`, a ready human action, or a node complete-but-draft — and the graph in front of it did not, and **that is judged by what the edit left behind, not by which operation it carried**: an `add` whose node is skipped behind the very failure that ended the run has reopened nothing, and a `reparent` or a `drop` that frees a blocked node from what was holding it carries no node at all and has. An edit carries the run to a different ending when the graph in front of it and the graph it leaves behind have both ended, under different hooks: a settle that carries a failed ending to a complete one — the failed node settled `done`, its work having landed some other way — never makes the graph live, and it is an epoch all the same, because the ending the failure hook fired for is not the ending the run now has. A settle that changes why the run failed and not that it did — a parked node settled `failed` beside the failure — ends the run under the same hook and is not. So a run whose failure hook fired and whose node was then retried fires **success** when the replacement completes, and fires **failure a second time** when the replacement fails in its turn, and a run whose failure hook fired and whose failed node was then settled `done` fires **success** for the complete ending that settle carried it to; that is what makes it true that a run recovered from a failure still fires the hook it then reaches, rather than silently skipping its success-only automation. **An accepted command that leaves the run unable to advance, at the ending it found, leaves the marker standing** — a `note`, a `finding` and an amendment among them, and equally an edit that moved the graph without freeing any work — because none of them is a run that has started again, and treating one as an epoch would fire a second hook for the ending that already fired. Liveness alone is not the rule either: a run made live by something nobody edited, a cross-DAG upstream arriving among them, is not an epoch. The marker is read as of that epoch inside the same gated section it is appended in, so two drivers judging one run under one epoch fire exactly one hook between them. Attached and detached drivers judge and fire identically.
A hook is spawned the way a `--node-validator` command is — the command itself, with no shell and no arguments — with the launch record's `dir` as its working directory, and `ONEPIPELINE_HOOK` (`success` or `failure`), `ONEPIPELINE_RUN_ID` and `ONEPIPELINE_RUN_ROOT` (the run's own directory, absolute) in its environment. `ONEPIPELINE_LAUNCHER` and `ONEPIPELINE_LAUNCHER_SESSION` are set to the owner the launch record names when the hook fires, and removed from its environment where the record names no owner, so anything a hook launches belongs to the run's owner whichever process fired it. Its stdin carries one JSON document, then EOF: `{"version": 1, "hook": "success"|"failure", "run_id": string, "run_root": string, "reason": null | {"kind": "nodes"|"unfinished"|"stopped", "nodes": [{"id": string, "status": string, "outcome": string|null}]}}`, where `reason` is `null` for success and, for failure, `nodes` lists every node not `done` when the hook was judged, in plan order. The hook is **awaited** for up to the hook timeout, and a hook still running then has its process tree ended. Its stdout and stderr are kept, together, in `hooks/success.log` or `hooks/failure.log` under the run's own directory, and an attached driver also relays that output to its own stderr. The hook starts only after the driver has let go of the ownership lock, so while it runs the run reads as not driven — as a run whose driver has gone does — and a hook may launch or read runs, including with a verb that refuses a driven run; its ending is recorded without re-claiming the run. `run-hook-fired` carries `{"hook", "command", "reason"}` and `run-hook-finished` carries `{"hook", "exit": int|null, "ending": "succeeded"|"failed"|"could-not-start"|"timed-out", "log": string}`. **A hook never changes settlement.** Whatever its ending, these are exactly what they would be without the hook: the exit status of `start`, `adopt` and `stop`; the `{"run_id", "settlement"}` line; the run's `result.json`; every node status; and the write-back.
Run-end hooks, as a launch names them and as a hook and a reader meet them:
```json
{
"run_end_hooks": {
"flags": {"success": "--success-hook", "failure": "--failure-hook", "timeout": "--hook-timeout"},
"config_keys": {"success": "success_hook", "failure": "failure_hook", "timeout": "hook_timeout"},
"config_schema_version": 6,
"default_timeout_seconds": 600,
"environment": ["ONEPIPELINE_HOOK", "ONEPIPELINE_RUN_ID", "ONEPIPELINE_RUN_ROOT", "ONEPIPELINE_LAUNCHER", "ONEPIPELINE_LAUNCHER_SESSION"],
"log": "hooks/<hook>.log",
"results_output_lines": 20,
"reason_kinds": ["nodes", "unfinished", "stopped"],
"endings": ["succeeded", "failed", "could-not-start", "timed-out"],
"stdin": {
"success": {"version": 1, "hook": "success", "run_id": "demo", "run_root": "/runs/demo", "reason": null},
"failure": {"version": 1, "hook": "failure", "run_id": "demo", "run_root": "/runs/demo", "reason": {"kind": "nodes", "nodes": [{"id": "build", "status": "failed", "outcome": "task-failed"}, {"id": "ship", "status": "skipped", "outcome": null}]}}
},
"payloads": {
"run-hook-fired": {"hook": "failure", "command": "./scripts/report-failure.sh", "reason": {"kind": "nodes", "nodes": [{"id": "build", "status": "failed", "outcome": "task-failed"}, {"id": "ship", "status": "skipped", "outcome": null}]}},
"run-hook-finished": {"hook": "failure", "exit": 1, "ending": "failed", "log": "/runs/demo/hooks/failure.log"},
"run-hook-withheld": {"settlement": "awaiting-planner"}
}
}
}
```
**Dispatch-env hook.** A live driver re-reads harness configuration (`oneharness.*.toml`) from the checkout at dispatch time but keeps only the environment captured when it started, so a routing change merged while a run is live that adds an `env_from` indirection makes the next dispatch fail provider startup: the driver never inherited the new variable, and oneharness correctly refuses an absent `env_from` source. The **dispatch-env hook** is the generic seam a host refreshes that environment through, and the host's composition — which of its own helpers produce the variables — stays with the host: this crate never sources or names any host variable. `onepipeline start` takes `--dispatch-env-hook COMMAND` and `--dispatch-env-hook-timeout SECONDS`, and the launch config takes `dispatch_env_hook` and `dispatch_env_hook_timeout` — keys of launch-config `schema_version: 8`, refused by name in a document declaring an earlier one — with the precedence every other launch-config field has: a flag beats the config, and a blank value, flag or key, is this launch saying it has none. The timeout is a positive whole number of seconds, `60` when unnamed, and zero is refused wherever it is read. Both are retained in the launch record — the timeout beside the hook it bounds — omitted when absent, so a record written by an earlier build reads as naming no hook, and replayed by `adopt`, which takes neither flag. It runs **immediately before every node-scope dispatch attempt** — a first dispatch, a re-asked dispatch, a retry, a requeue, and a lifecycle node's worker launch alike, and the drafting dispatch a `--pr-author-graph` makes for a lifecycle node too, since it goes through the same seam and launches a harness that reads `env_from` the same way — and **never** for the dag-scope observer launch. It is spawned the way a run-end hook is: the command itself, no shell, no arguments, with the launch record's `dir` as its working directory, the driver's environment plus `ONEPIPELINE_HOOK=dispatch-env`, `ONEPIPELINE_RUN_ID`, `ONEPIPELINE_RUN_ROOT` (absolute) and `ONEPIPELINE_NODE_ID`, and nothing on its stdin. It is awaited for up to the timeout, and one still running then has its process tree ended. It prints exactly one JSON document on stdout, `{"version": 1, "env": {"NAME": "value", ...}}`, where every value is a string and every name a valid environment variable name; a document with any other top-level member, a non-string value, an invalid name, or anything that is not that one document is **malformed**. Its stderr is kept in the run's `hooks/dispatch-env.log`. The `env` members are overlaid on the driver's environment **for that one child launch only** — the driver's own environment is unchanged, and this crate's own per-dispatch variables are set beside them and are not a hook's to move. After overlaying, and before launching, the engine reads every oneharness config that launch will read — each member's `oneharness_config` after `--node-set` overrides, a two-party member's agent side and each harness judge side among them, as the file is on disk at that moment — and checks that every `env_from` source variable those configs name is present in the refreshed child environment. A hook that exits non-zero, cannot start, times out or prints a malformed document, or a missing `env_from` source, **refuses the launch**: nothing is dispatched — no session is opened for it — and the node settles under the existing outcome word `infrastructure-failure`, with its existing retry semantics; there is no new outcome word and no new event kind. A refused drafting dispatch takes that dispatch's existing ending instead, exactly as one the executor refuses today does: the change request is published with no drafted body, `body-not-drafted` says why, and the node's own settlement is untouched. The refusal's detail names **either** the hook's ending — the exit code, `timeout`, `could-not-start`, or `malformed` with what was malformed — **or** the config file, the harness variant and the missing key by name. A hook's `env` **values** are never written anywhere the run keeps — journal, events, logs, `result.json`, launch record, views or surfaces; anything recorded about a hook's output names variables only. A launch naming no hook behaves exactly as it did before the hook existed: no spawn, no added environment, no new validation.
The dispatch-env hook, as a launch names it and as the hook and the engine meet:
```json
{
"dispatch_env_hook": {
"flags": {"command": "--dispatch-env-hook", "timeout": "--dispatch-env-hook-timeout"},
"config_keys": {"command": "dispatch_env_hook", "timeout": "dispatch_env_hook_timeout"},
"config_schema_version": 8,
"default_timeout_seconds": 60,
"hook": "dispatch-env",
"environment": ["ONEPIPELINE_HOOK", "ONEPIPELINE_RUN_ID", "ONEPIPELINE_RUN_ROOT", "ONEPIPELINE_NODE_ID"],
"log": "hooks/dispatch-env.log",
"stdout": {"version": 1, "env": {"CLAUDE_HOME_PRIMARY": "/home/op/.claude-primary"}},
"endings": ["exit", "timeout", "could-not-start", "malformed"],
"refusal_outcome": "infrastructure-failure"
}
}
```
**Pool maintenance.** `onevcs` keeps warm worktree slots per identity and carries `pool_maintain`, which runs the host's own `maintain` command in each idle slot and stamps the attempt on the slot as `last_maintained`; that library holds **no schedule**, and this crate holds **no state**. `onepipeline start` takes `--maintenance-config FILE` and the launch config takes `maintenance_config` — a key of launch-config `schema_version: 9`, refused by name in a document declaring an earlier one — with the precedence every other launch-config field has: the flag beats the config, and a blank value, flag or key, is this launch saying it has none. The file is the **schedule**, a pure function of which is when each identity's slots are due: `version: 1`, a `default` carrying `every`, and `rules`, first match wins, each carrying `match` and `every`. `every` is the **only** key a rule or the default carries — no `at`, no cron shape — and is a `onevcs::Span` (digits then `s`, `m`, `h` or `d`, nothing else); `match` is `onevcs::rules::RuleMatch`, resolved through `onevcs::first_matching`, so the span grammar and the match vocabulary have one implementation each and this crate restates neither. It is external input, refused before a run is minted naming the key: an unknown key, a `version` other than 1, a rule naming no match field, a malformed span. The parsed document — `onepipeline::maintenance::MaintenanceConfig`, its `default` a `MaintenanceDefault` and each rule a `MaintenanceRule` — is retained in the launch record as `maintenance_config`, as `bus_config` is, omitted when absent so an older record still reads, and replayed by `adopt`, which takes no flag. A pass of the reconcile loop is **idle** when it dispatched nothing, nothing became ready, the run is below its own concurrency ceiling, and the local executor reports `slots_free > 0`. On an idle pass, when the launch named a schedule, no maintenance thread of this driver is running, and the driver's in-memory pacing interval — `ONEPIPELINE_MAINTENANCE_PACE_SECONDS`, six hundred seconds when unset, never persisted — has elapsed since it last started one, the driver starts **one** thread that, for every identity the host has registered in sorted order, resolves `every` (rule, else default) and calls `onevcs::pool_maintain(Scope::Repo(identity), Some(every))`. That is the whole of "due": a slot is due when `last_maintained < now − every`, and the sibling decides it — so late is fine, nothing runs twice, two drivers on one host are safe by the same fact, and between runs nothing runs and nothing needs to. A second idle tick while the thread runs does nothing. The thread is bounded by construction — every command runs under the identity's own `timeout` — and a driver closing out joins it. It hands its reports back over the loop's own channel, and the loop journals **one** record, `pool-maintenance`, carrying `started_at` and, per identity it records, the `every` it was maintained on and the sibling's own `outcome` — per slot, what ran and how it ended, or the `error` that stood in for an outcome — only when some slot ran or some identity was `claimed` or failed; nothing is written when every identity answered `no-maintain-command`, `no-slots` or `not-due`. `status` names a maintenance in progress while the thread is live; `results` names the last record. A launch naming no schedule runs no maintenance, spawns no thread, and behaves exactly as before.
A launch naming the schedule, and the schedule itself:
```yaml
schema_version: 9 # maintenance_config is a schema 9 key
maintenance_config: ./maintenance.yml # the schedule, by path; --maintenance-config overrides it
```
```yaml
version: 1
default:
every: 7d # a onevcs `Span`: digits then s|m|h|d, nothing else
rules: # first match wins; `match` is the onevcs `RuleMatch`
- match: {host: github.com, owner: nickderobertis, name: onevcs}
every: 3d
```
**Every agent a run launches is visible through oneharness's own run history, automatically, whatever the agent structure of the target repository.** On **every** dispatch the engine starts — a node-scope dispatch, each step of a lifecycle node, the dag-scope observer graph and the `pr-author` drafting graph — the engine overlays three variables on the environment the launch inherits: `ONEHARNESS_HISTORY=1`; `ONEHARNESS_HISTORY_POINTER_FILE`, naming the run's **pointer file**, `<run root>/oneharness-sessions.jsonl`, absolute — written into the launch record as `oneharness_sessions` at launch (a record that predates the field reads as none), reachable as `RunPaths::oneharness_sessions()`, and carried on `RunSummary.oneharness_sessions` from summary schema 7; and `ONEHARNESS_HISTORY_LABELS`, composed **key-wise**: the pairs already in the value the launch would otherwise carry — the driver's environment, then the dispatch-env hook's document, which runs before the engine's own pairs are composed and so is inherited input — with every key under the `onepipeline.` prefix removed, then the engine's own keys put in: `onepipeline.run_id`; `onepipeline.project` (the qualified project id off the launch record, absent when it names none); `onepipeline.scope` (`node` for a node-scope dispatch, a lifecycle step included, `observer` for the dag-scope graph, `pr-author` for the drafting graph); `onepipeline.node` (absent on the observer); `onepipeline.step` (lifecycle steps only); and `onepipeline.attempt` (the attempt number that dispatch's `node-dispatched` records, decimal — carried to the executor as `DispatchRequest.attempt` — absent on the observer). The value is rendered in oneharness's wire format, comma-separated `key=value` in key order, every value held to oneharness's label grammar — a 1–64-character key starting alphanumeric, a non-empty value of at most 256 code points with no control character, and no comma, because the wire format has no escape — through that library's own `HistoryLabels::new`; a value the grammar refuses **refuses the launch** naming the key, the way a missing `env_from` source does. Three things a repository owner can rely on. **A repository's own labels survive**: only the keys under the `onepipeline.` prefix are the engine's, so an `owner=ci` the launch inherits stands beside them, and a stale `onepipeline.node` a nested launch inherited is replaced. **A repository's history store is its own**: the engine sets no `ONEHARNESS_HISTORY_DIR` — an inherited one passes through untouched, and a `history_dir` in the repository's own configuration or environment is honoured — so every transcript stays in the store the operator already reads with `oneharness history`, and the pointer line says which. **A repository that wants no history for a turn opts out** on the oneharness CLI (`--no-history` outranks the environment) or in a layer of its own that oneharness ranks above the environment, in which case no pointer line is written for that turn and that is not an error. Nothing in the target repository has to be configured for any of this. The pointer line's shape and its reader are oneharness's (`oneharness-core`'s `HistoryPointer` and `io::history::read_pointers`, from 0.17.0), and this crate consumes them: `verbs::agents(&RunPaths, AgentScope::Run | AgentScope::Node(NODE)) -> Agents` and `verbs::project_agents(root, PROJECT) -> Agents` — the union over the runs whose summary names that project — read **only the run's pointer file** through that reader, never a store listing, never the locking indexed reader, never a spawned `oneharness`, and never a session file, keeping the lines whose `onepipeline.node` is the node or every line, **grouped by `history_session`**. `Agents` carries one entry per session — `history_session`, `name`, `history_dir`, `history_project`, `history_file`, the project directory it ran in, `started` (the earliest among its lines), its `labels`, and its `runs` in file order, each `{history_id, harness, variant, harness_id, started}` — and `skipped`, the torn or foreign lines the reader counted; a run with no pointer file is an empty list, not an error; and each entry carries exactly the three fields the existing `oneharness_session` reference resolves through — `history_dir`, `history_project`, `history_session` — so a reader opens it to its transcript the way it opens that reference kind, through the store the line names. The CLI is `onepipeline agents RUN [NODE]` and `onepipeline agents --project PROJECT`, rendered by `render_agents`, and `oneharness history pointers <run root>/oneharness-sessions.jsonl` reads the same file with that library's own verb. The constants below live in `onepipeline::agents`, and `tests/contract.rs` reconciles them against this block:
```json
{
"oneharness_history": {
"environment": {
"history": "ONEHARNESS_HISTORY",
"pointer_file": "ONEHARNESS_HISTORY_POINTER_FILE",
"labels": "ONEHARNESS_HISTORY_LABELS"
},
"never_set": "ONEHARNESS_HISTORY_DIR",
"pointer_file": "oneharness-sessions.jsonl",
"label_prefix": "onepipeline.",
"labels": {
"run_id": "onepipeline.run_id",
"project": "onepipeline.project",
"scope": "onepipeline.scope",
"node": "onepipeline.node",
"step": "onepipeline.step",
"attempt": "onepipeline.attempt"
},
"scopes": {"node": "node", "observer": "observer", "pr-author": "pr-author"},
"inherited": "owner=ci,onepipeline.node=other",
"stamp": {"run_id": "demo-1", "project": "plans:demo", "scope": "node", "node": "service", "step": "implement", "attempt": 1},
"composed": "onepipeline.attempt=1,onepipeline.node=service,onepipeline.project=plans:demo,onepipeline.run_id=demo-1,onepipeline.scope=node,onepipeline.step=implement,owner=ci",
"summary_schema_version": 7,
"opt_out": "--no-history"
}
}
```
**A plan is checkable without launching it, and the check is the engine's own.** `onepipeline plan check <SOURCE:PROJECT> [--check <PATH>]... [--json]` runs, in this order: first the engine's own plan loader — every refusal `onepipeline start` would make before dispatching anything, and no other rule — and then each registered consumer check, in the order its `--check` flags were given. A consumer registers a check with a repeatable `--check <PATH>` flag naming an executable, resolved against the working directory `plan check` was run from; naming none runs the loader alone. Each is spawned with the loaded plan as a single JSON document on its **stdin**, and with `ONEPIPELINE_PLAN_CHECK_SCHEMA=1` in its environment; its working directory is the one `plan check` was run from. The document is `{"schema_version": <int>, "name": <string|null>, "goal": <object|null>, "concurrency": <int>, "tasks": [<node>, ...]}`, where each node is the engine's own loaded node with every default already resolved — so a check sees exactly what the engine loaded and not the store's raw text. Each node additionally carries **`metadata`: the store's own metadata map for that task, verbatim**, including the keys outside the reserved `onepipeline.<field>` namespace, because a consumer's checks read keys the engine does not. On **stdout** a check answers with one JSON object, `{"refusals": [{"node": <string|null>, "field": <string|null>, "reason": <string>}, ...]}`; an empty list is an accept, `node` and `field` are always present and may be null, and `reason` is always a non-empty string. A check that ran answers with exit status **0**, whatever its `refusals` list holds; any **non-zero** exit status, an unparseable answer, or a missing or non-executable path means the check **could not be run**, which is reported separately from a refusal — naming the path, the exit status and the check's own stderr — and is never read as an accept. A check is handed the *loaded* plan, so when the loader itself refuses there is no loaded plan to hand it: the registered checks do **not** run, and each is reported as not run for that reason rather than as accepting. Engine refusals come first and carry `"source": "engine"`; each check's refusals follow in flag order and carry `"source": "<the path as given>"`, and the human output prints them in that order, one per line, each naming its source, its node and field where it has them, and its reason. `--json` prints exactly one object — **always**, a project this build could not read included, because a consumer parses this verb's stdout without first asking which failure it met, and the diagnosis for that one goes to stderr where every other refusal this binary makes goes: `{"project": <string>, "accepted": <bool>, "refusals": [{"source","node","field","reason"}, ...], "unrunnable": [{"check": <string>, "exit_code": <int|null>, "stderr": <string>}, ...]}`. The exit codes are chosen to match the consuming wrapper's own convention so it forwards rather than translates: **0** — the loader and every check accepted; **1** — at least one refusal, from either source; **2** — the project could not be read at all, or a registered check could not be run. `plan check` is a new verb: `onepipeline start` is unchanged, and a consumer built against an engine that does not have it keeps working.
Before launch, every targeted repository is checked through `onevcs session holders REPO --json`. A live holder refuses the launch unless `--acknowledge-concurrent` is passed; that override remains visible on stderr and emits `concurrent-acknowledged` with the shared identities and runs. A stale holder is reported and does not refuse.
Channel (public contract): `onepipeline next RUN [--filter NAME|SPEC] [--all]`, `reply RUN [FILE] [--correlation C]`, `surface RUN --kind KIND --message TEXT`, `attest RUN REF`, `stop RUN`. Surfaces and events are emitted as they occur. Reply envelope: legacy verdicts plus `{"version": 1, "commands": [...]}` with ops `add | drop | reparent | retry | cancel | requeue | attest | complete | context` — required fields and validation semantics exactly as ai-orchestrator's live-edit protocol (docs/orchestration.md#live-graph-edits): applied-or-rejected-with-reason, durable command queue, reply exit 0 = applied, 1 = accepted-not-yet-reconciled, 2 = refused/malformed. **A reply is routed by the halves it carries, never by which reader reaches the queue first.** It answers a pending surface only when it carries a **verdict half** — `completion`, `message`, or `reason` — and a **commands-only** envelope (a version and commands, no verdict) belongs to the command path alone: it leaves the pending surface, and any reader waiting there for a verdict, untouched. One carrying both is delivered to both — its commands to the command path, and the envelope itself to the pending surface, out of which that reader reads the verdict. Neither reader advances the other's cursor: the command path has its own durable queue behind its own cursor, so an envelope one reader passes over is still waiting, exactly once, for the reader it was addressed to. **A verdict is addressed to one question.** A question the configured bus raises carries a correlation, and a verdict is bound to one question and carries that question's correlation: `--correlation C` binds it to the question `C` names and refuses, naming it, a correlation no pending question holds; one naming none is bound to the question whose `ask-manager-token:` its `message` echoes, else the question the pending slot holds, else the oldest outstanding question a live listener waits on, and with none of those it is queued carrying no correlation, for the next listener to read. A listener reads its own question's answer, a verdict on any other question its asker raised, and a verdict bound to no question — never a verdict on another asker's question. A reply carries an open `author` word, defaulting to `planner`. The launch configuration declares every non-planner author and its operation grants; undeclared authors and ungranted operations are refused before anything is written, as is `completion: true` without `complete`. Every accepted non-planner edit is surfaced non-blocking with that author as its source. A `retry` naming neither `branch` nor `resume` **inherits both** from the node it supersedes — a node that ran, committed, and stopped leaves work on a branch, and its own settlement pins that branch to it, so the replacement continues it rather than cutting a fresh one beside committed work nothing points at; a `retry` that names either is answered with what it named. The superseded node leaves the graph in the **same edit**, which emits `node-dropped` — a cancelled node left in a continuous graph holds the run in `waiting` for ever — and what became of it stays in the run's record as its own `node-settled` and the `edit-committed` that replaced it. A `context` note carries exactly **one dispatch**: it attaches to the node's next dispatch and is consumed on delivery. It carries one further optional field, `deliver: auto|live|next`, defaulting to `auto`: `auto` delivers the note into the node's running turn when it has a controllable one and otherwise attaches it to the next dispatch, `live` refuses with a reason when it cannot deliver into the running turn, and `next` is the next-dispatch behaviour explicitly. Anything else is refused. Live delivery is `oneagentgraph interrupt RUN MEMBER --input`, addressed by the graph run and member the dispatch's own relayed envelopes stamp; that verb's exit 3 — no controllable turn in flight — is the `auto` fall-through and the `live` refusal and is not an error, while a delivery that was attempted and failed is refused under both. A note the running turn took is not also owed to the next dispatch, and `edit-committed` records which happened as `delivery: live | deferred` on the `context-added` operation it compiled. Surface consumption restarts the check-in clock of **every member the run's recorded observer graph declares `resettable` in its `schedule`** — `oneagentgraph reset-timer RUN MEMBER` for each, addressed by the graph run the launch record names; a scheduled member that is not `resettable` keeps its cadence, and a run whose observer graph declares no resettable member, or that launched no observer graph, resets nothing and reports no failure for it. The engine names no member: the graph document is the configuration that says which clocks a planner's reading restarts. That is the whole check-in reset contract.
**What an author other than the planner meets when it is refused.** The per-author allowlist is the `planner-channel` profile's, from `onemessagebus-agent`, and the launch's bus configuration is what grants an author its ops: an op nothing grants is refused naming the author by its own word and the reason — the one the configuration states for it under that author's `refusals`, else `nothing grants it to this author`. For an author declared as `observer` and granted none of these, a refused reply's error is `refused: ` followed by the text below, word for word, for each op and for a verdict declaring completion:
```json
[
{"author": "observer", "command": {"op": "complete", "reason": "every node landed"}, "refused": "'complete' is not an op the observer may issue: nothing grants it to this author. Surface it to the planner instead"},
{"author": "observer", "command": {"op": "attest", "ref": "sign-off"}, "refused": "'attest' is not an op the observer may issue: nothing grants it to this author. Surface it to the planner instead"},
{"author": "observer", "command": {"op": "drop", "id": "build", "dependents": "detach"}, "refused": "'drop' is not an op the observer may issue: nothing grants it to this author. Surface it to the planner instead"},
{"author": "observer", "command": {"op": "reparent", "id": "build", "deps": []}, "refused": "'reparent' is not an op the observer may issue: nothing grants it to this author. Surface it to the planner instead"},
{"author": "observer", "command": {"op": "amend", "id": "build", "text": "the gate is green"}, "refused": "'amend' is not an op the observer may issue: nothing grants it to this author. Surface it to the planner instead"},
{"author": "observer", "command": {"op": "note", "id": "build", "addressee": "worker", "text": "stop re-running the judged tier"}, "refused": "'note' is not an op the observer may issue: nothing grants it to this author. Surface it to the planner instead"},
{"author": "observer", "command": {"op": "settle", "id": "build", "outcome": "done", "evidence": "the change is on the base"}, "refused": "'settle' is not an op the observer may issue: nothing grants it to this author. Surface it to the planner instead"},
{"author": "observer", "verdict": {"completion": true}, "refused": "declaring the run complete is not something the observer may do: nothing grants it to this author. Surface it to the planner instead"}
]
```
**The planner channel is `onemessagebus`'s.** A run's channel directory is kept by the `onemessagebus` core's local transport under `onemessagebus-agent`'s `planner-channel` layout. Its queues, cursors, projection and exclusive sections, the asker a question is raised under and the wait for its answer, the per-author allowlist above, the command validators that judge what is offered to a queue, and the carry store a note no running turn took waits in are that crate's; `onemessagebus`'s own `docs/contract.md` and `docs/transport.md` are the one source of how they behave. What stays this crate's is its vocabulary and its policy: the channel verbs and their exits, the reply envelope and its ops, which question a verdict is addressed to, the surface kinds, and the bytes of every record the channel directory holds. `start --bus-config PATH`, or a launch config's `bus_config`, names an `onemessagebus` configuration the run's channel is kept under, and the launch record keeps it. The launch refuses it, naming the key and its value and creating no run, when it names a transport other than `local`, a `transport.dir`, a profile other than `planner-channel`, a `queues` block, an `authors` block the planner-channel layout refuses. `bus_config` and `envelope_reviewer_bar` are the keys launch-config `schema_version: 7` adds, `dispatch_env_hook` and `dispatch_env_hook_timeout` the keys `schema_version: 8` adds, and `maintenance_config` the key `schema_version: 9` adds, each refused by name in a document declaring an earlier one.
**The envelope and the filter are `onemessagebus-agent`'s.** The envelope, its labels, sources and phases, the artifact reference, the kind, the filter and its matcher are the agent profile's types over the `onemessagebus` core, re-exported at `onepipeline::event` and `onepipeline::filter` — the same types `oneagentgraph` and `onevcs` re-export, so a relayed envelope is one value on every side of a relay. **`onemessagebus`'s own `docs/contract.md` is the one source of their shape**, with its `docs/wire.md` beside it; what this document says about the envelope and the filter grammar is a marked copy of it, decided against `onemessagebus` 0.7.0, and `tests/contract.rs` drives this document's fixtures through the re-exported types. What stays this crate's is its policy and its vocabulary: the launch config and its `filters:` block, the shipped profiles, the `PipelineKind` set, and each of its payloads, every one registered as the bus message `agent.pipeline.<kind>@2` beside the envelope that carries it.
Merged stream: envelope NDJSON, one store per run, interleaving the three sources `pipeline`, `agentgraph`, `vcs`. A relayed envelope keeps its producer's own `stream`, `seq`, `source`, and kind, so a sibling's kind is a wire string this library never rejects. A lifecycle node's `onevcs` session is **followed** — `onevcs events TOKEN --follow` — from the moment there is a token until the session closes, so the gate run, the push, the change request, the description written onto it, the draft lifted, the check polling, and the merge reach the store while they happen rather than in one batch at settlement; each session envelope is stamped with the node it belongs to, which its producer cannot know, and an enricher never rewrites a key the producer stamped. A follow that never started, or that neither ended cleanly nor relayed a record, falls back to reading the stream once. This library's **own** kinds are a closed set — the `PipelineKind` enum, which is what emits them — and exactly these: `run-started`, `node-ready`, `node-dispatched`, `node-settled`, `node-requeued`, `edit-committed`, `edit-rejected`, `planner-surface-queued`, `planner-surfaced`, `planner-replied`, `human-attested`, `driver-adopted`, `run-stopped`, `quiet-worker`, `decision-pending`, `decision-cleared`, `cross-dag-satisfied`, `upstream-modified`, `completion-requested`, `concurrent-acknowledged`, `body-not-drafted`, `run-hook-fired`, `run-hook-finished`, `run-hook-withheld`, `pool-maintenance`, `dispatch-stopped`, `host-shutdown`. `edit-committed` carries the `author` that submitted the edit. A *dispatch* that produced **nothing** and failed is re-asked, and only that one — an attempt that answered has already answered — and each attempt is recorded as its own `node-dispatched` carrying `attempt`, `attempts`, and the bounded reason the previous attempt gave, so a reader counts dispatches per node to see a retry while the settlement still distinguishes `no-agent-progress` from `task-failed`. A dispatch that ended for a reason that is **not the agent's verdict on its task** settles `dispatch-died` and not `task-failed`, chosen by classifying the failure's own detail rather than by inspecting the branch — so it covers both a dispatch that died holding finished work and one that produced nothing at all. It carries `cause`, the producer's own classification (`rate_limit`, `quota`, `auth`, `spawn-error`, and whatever else that layer spells) carried as the producer spelled it, and `head`, the commit the node's branch was left at; both are omitted where there is none, `branch` carries the branch a node that had one left, and the per-node views name the cause and — where a branch and a head are present — say the branch may carry finished work and name that commit. It is deliberately not `infrastructure-failure`, which is the dispatch layer refusing **before any work began** — an executor that could not start anything, or a session the sibling refused to open — and which is retried for exactly that reason: none of it is the agent's and the failure carries no work to lose. `dispatch-died` is the opposite case, the dispatch started and the agent worked, and it is not retried. One session refusal is neither retried nor settled: a session open the sibling refused as **pool exhausted** (`onevcs::Error::PoolExhausted` — the identity's every slot held and its overflow spent, which the capacity read above can still race) is classified off the typed value at the executor seam, ends the attempt loop at once with no backoff, no boundary attempt spent, no `no-agent-progress` and no `infrastructure-failure`, and **returns the node to queued**: the driver journals `node-requeued` against the node carrying `reason: workspace-exhausted` and the sibling's own `detail`, takes it out of flight without a settlement, holds it under `workspace`, and re-evaluates it on the next paced read. That holds on **any attempt of any node** — no pool-exhausted refusal writes a settlement or spends a boundary attempt. A re-dispatch inside the publication-retry loop refused this way — the attempt after a preserved failure, pinned to the branch the attempt before preserved — **keeps that pin through the queue**: its `node-requeued` also carries the `branch` the wait is against and the `attempt` the refusal interrupted, the wait is shown by `status` for as long as it lasts, and the pass the identity admits it that same attempt is made again on that same branch, recorded as the re-dispatch it is (`attempt`, `attempts`, the reason it answers) and composed with the notes the record holds then, with no settlement in between. A `retry` is never what continues a node the host was merely too busy for. A whole *lifecycle node* is re-dispatched under those same fields where its publication failed in a way that left the work on its branch, which the publication-failure paragraph below decides; the two are one kind and one payload shape, and neither is inferred from the other. The envelope's reserved `round` label is **deprecated and no longer stamped by anything this library writes**; it survives on the envelope because the agent profile's `Labels` still declares it.
**Every publication failure is named, and a preserving one is re-dispatched.** `onevcs` reports a `FailureKind` on a publication that ran and did not land, and each one settles its node under a word of its own: `checks-failed` — a required check the host reports concluded red; `checks-unsettled` — the bound on watching the host elapsed with the change still outstanding; `push-rejected` — the publishing push was refused by the merge path; `sync-conflict` — the base moved under the publication and the bounded resolve-and-requeue did not converge; `pushed-unverified` — the publishing push reached the remote and the merge path could not then be read, so the work is on the origin and what a further attempt re-reads is that path rather than the push; and `infrastructure-failure` — the publishing push was refused by the merge path because **this host** is missing a tool or a credential one of its hooks needs, which the hook said itself on the one line beginning with the host-prerequisite marker whose spelling is onevcs's to state, so it is the host and never the work standing in the way, and it settles under the word the dispatch layer already uses for a host that could not launch a dispatch rather than under a word of its own. `publication-failed` is the **residual** and keeps exactly the meaning it had: the gate the repository's own rules name, a request refused at a trust boundary, a seam with no implementation behind it, and a refusal of the publish call itself. Nothing that already had a word changes meaning, and a kind the sibling adds later is a routing decision this crate makes rather than a residual it falls into silently. Which of them a node can meet follows the policy its publication ran under: `change-auto` asks the host to land the change and is watched to its end, so it is where a red required check and an elapsed bound are observed at all, while `change-open` remains the **stated exception** — a person decides, so the node settles at change-request open with its change unlanded and nothing waits or polls for a merge somebody else owns.
Four of them are **preserving**: the work is still on the branch the session handed back and the *tree* is what was rejected, so the node is **re-dispatched on that branch** rather than settled `failed`. `pushed-unverified` is the exception and is recovered differently — its work is on the origin as well, so nothing about its tree is what has to change, and the recovery is a **bounded re-read of the merge path** rather than a dispatch of the agent: re-dispatching it buys a fresh clone and a fresh complete gate to re-push what the remote already carries. The re-read is bounded by `ONEPIPELINE_MERGE_PATH_READS`, three by default and the whole budget, so `1` is reading once and settling on whatever that said; it stops early on a cancellation, and a verdict that arrives on any read — merged, change request open, queued, or a check the host reports red — settles the node exactly as though the first read had answered it. Reads that never get a verdict settle the node `failed` under `pushed-unverified`, carrying the branch, the commit the push landed at, and `onevcs`'s own sentence about what stopped the read, which also names the `publish-branch` that lands it once the host answers. The re-dispatch is pinned to the preserved branch — continuing it rather than cutting a second one beside committed work — and records **no step as completed**, so every step runs again against the tree that has to change; republishing an unaltered tree meets the same refusal, which is the one ending this must not have. The failure's own reason and the id of every artifact its publication recorded — the check's log, the push's output, the conflict's hunks — reach that dispatch as its **node context**, in the same `## Planner context` section a planner's note is rendered into and disclaiming itself the same way, so a worker meets the diagnosis instead of going to look for it. The planner's own note is not carried again: a note carries exactly one dispatch and the attempt that just ran was it. A preserving failure whose branch the execution checkout did not take back is settled and not re-dispatched — there is nothing left to continue — and `invalid` and `not-implemented` stay terminal by their kind, because they answer the same way however many times they are asked. A refused push carrying the host-prerequisite marker is **not** re-dispatched either, whatever the budget has left: no change to the tree can clear a tool the host does not have, and a worker sent back to the branch would spend every attempt meeting the same refusal. It settles the node `failed` under `infrastructure-failure` — no word of its own and no new event kind — carrying the preserved branch, the commit it stands at, and the hook's own remediation in its detail, so the fix is one install on the host and a `retry`. A refused push carrying no such line is `push-rejected`, exactly as before.
The loop is **bounded**, by `ONEPIPELINE_PUBLICATION_ATTEMPTS` and three by default — the whole budget, so `1` is publishing once and settling on whatever that said. Each attempt after the first is recorded as its own `node-dispatched` carrying `attempt`, `attempts`, and the bounded reason the previous attempt gave, which is the same shape a dispatch that produced nothing is re-asked under. A node that spends the budget settles `failed` under the **last** failure's word, with a detail naming how many attempts were made, the branch they were made on, and what each one ended with — the difference between a check to fix and a check that is never going to pass.
**Filtering is owned by the stream's source.** Consumers have different attention budgets, so each producer takes a filter and narrows what it emits, rather than every consumer downstream re-filtering the same firehose. One grammar across the stack: `{include: [matcher, ...], exclude: [matcher, ...]}`. A matcher's fields are all optional and conjoin — every field it names must hold — and they are `source` (exact equality), `kind` (a glob over the kind's kebab-case wire string, where `*` stands for any run of characters including none and every other character is itself), `phase` (exact equality against the agent profile's one reserved top-level dimension, which this crate stamps on nothing it writes and relays as `onevcs` stamped it), and the reserved labels `run_id`, `node`, `step`, `member`, `persona` (exact equality; a matcher naming a label the envelope did not stamp does not match it). An absent or empty `include` admits everything; a match in `exclude` always rejects, whatever `include` said. `stream` is deliberately not matchable — it names a producing process rather than anything a consumer means — and neither are payload fields, nor the deprecated `round`. A **relayed** envelope's `kind` is matched as the wire string it arrived as: a sibling library's kind is not the reading library's own set, and is never rejected for being unknown. Like the envelope, the filter type is `onemessagebus-agent`'s `EventFilter`, re-exported, so every producer in the stack filters with one implementation; this paragraph is a marked copy of `onemessagebus`'s contract, and this crate's contract test drives the example below through the re-exported type, so a bus release whose grammar stops matching this text fails this crate's gate. A spec is **external input** and is refused where it is read, naming the matcher it is about — which list, and which position in it: a field that is not a matcher field is named in the refusal, as is a matcher naming no field at all (it matches every envelope, so one in `exclude` silences the stream entirely) and one whose field is empty (nothing on the stream carries an empty kind or an empty label).
A launch declares a `filters:` block, in a **launch config** — `onepipeline start PROJECT --launch-config FILE`, a YAML or JSON document of its own — and equivalently on the command line, as `--filter-agentgraph SPEC`, `--filter-vcs SPEC`, and repeatable `--filter-profile NAME=SPEC`, each `SPEC` a path to a file holding a filter or the document itself inline as JSON. The config is the base and each flag overrides the part of it that it names: the two source filters wholesale, and a profile **by name**, so a config holding a team's profiles beside a plan can have one replaced for one launch without restating the rest. The resulting block is retained in the launch record and replayed by `adopt`.
Launch config (YAML or JSON, by path):
```yaml
schema_version: 2 # this build reads 2 and 1; another number is refused by it
pr_author_graph: ./graphs/pr-author.yaml # a schema 2 key, the operator's own graph; --pr-author-graph overrides it
filters: # optional, and omitted from what this crate writes when empty
agentgraph: # forwarded to every oneagentgraph launch this run starts
exclude: [{kind: turn-activity}]
vcs: # the typed value every followed onevcs session is read through
include: [{kind: "gate-*"}, {kind: session-closed}]
profiles: # named read-time views; planner and detailed ship and are overridable
planner:
include: [{source: pipeline}]
detailed: {}
```
The config is **external input**: an unknown key is refused by name rather than dropped, a `schema_version` this build does not read is refused by its number — naming the ones it does — and a filter it carries is refused by the grammar's own rules above; all of it before a run is minted, so a launch that could not be honoured never cuts a session. `schema_version: 2` is `pr_author_graph`, and the bump is **additive** the way the plan schema's is: a version-1 config is a complete document this build still reads, and naming that key there is refused **by the key's own name** rather than by the number. A document declaring only its version is a launch that says nothing at all, which is what a launch naming no config already means; every optional key — the block, and the graph — is omitted from what this crate writes when it is empty, so a config round-trips as the file wrote it, a launch that names no drafting graph is written as a document a version-1 reader accepts, and a launch record from a build predating either still reads.
The filter block itself has two halves, and they are not the same thing:
**Source filters** decide what enters the merged store, and are passed through to the library that owns each stream: `filters.agentgraph` reaches every `oneagentgraph` launch this run starts — the observer graph and every node-scope dispatch — as that binary's own `--event-filter` / `events.filter`, and `filters.vcs` is the typed value the linked `onevcs` `EventStream` filtered constructor takes for every followed session, and for the read that covers that follow's tail. Declared once at launch, so a run also stops paying to relay events nobody will read. A launch naming no `filters:` block ingests exactly what it always did.
**Read-time profiles** shape what one reader is shown and **never touch the store**: `onepipeline next RUN [--filter NAME|SPEC] [--all]` and `onepipeline monitor RUN [--filter NAME|SPEC] [--all]`, where a bare name selects one of the run's profiles, a spec is read inline or from a file, `--all` is no profile at all, and naming neither is the shipped `planner` profile. `next` reports its shaped event view beside the surface it claimed. A profile that names no profile this run has is refused, naming the ones it does. Shipped: `planner` is `include: [{source: pipeline}]` — every pipeline-level event, with the detailed `agentgraph` and `vcs` activity behind them left out — and `detailed` is unfiltered — the whole merged stream, which is what an observer reads; both are overridden by declaring a profile of that name, and no other name is an alias of either. A profile shapes the **event view** and nothing else: which surfaces exist and the unread-surface accounting over them belong to the channel, so a blocking surface is delivered under every profile.
Executor seam:
```rust
pub trait Executor {
fn name(&self) -> &str;
fn capabilities(&self) -> Capabilities; // { vcs_sessions: bool, ... }
fn capacity(&self) -> CapacityReport; // { slots_free, load1, mem_free_bytes }
fn dispatch(&self, req: DispatchRequest) -> Result<Box<dyn DispatchHandle>>;
}
pub struct DispatchRequest {
pub graph: ConfigRef, // content-addressed node-scope agent-graph config (oneagentgraph type)
pub task: String,
pub labels: Labels, // reserved: run_id, node, step, persona
pub controls: NodeControls, // the node's own dispatch controls: { max_turns: Option<NonZeroU32> }
pub workspace: WorkspaceSpec, // Path(PathBuf) | VcsSession(SessionRequest: onevcs type)
pub cancel: CancellationToken,
pub attempt: NonZeroU32, // which attempt of the node this is, counting from one: what its node-dispatched records
}
pub trait DispatchHandle {
fn events(&mut self) -> EventStream; // envelope NDJSON relayed from wherever it runs
fn wait(&mut self) -> Result<DispatchOutcome>;
fn cancel(&self, mode: CancelMode); // Cooperative | Kill
}
#[non_exhaustive]
pub struct DispatchOutcome {
pub succeeded: bool, // the settlement itself: a stream of turns does not carry it
pub detail: String,
pub session: Option<String>, // the executing machine opened it, so it hands the token back
pub branch: Option<String>,
}
```
`WorkspaceSpec::VcsSession` means the machine running the dispatch opens the onevcs session there — so the request carries `onevcs::SessionRequest`, the *ask*, and never an opened `onevcs::Session`; v1 ships `LocalExecutor` only (supports both variants), the trait + rules grammar are shaped for WS dispatch-server and k8s executors. `DispatchOutcome` is `#[non_exhaustive]`: naming a further field later is additive.
Executor rules (YAML, ordered predicates over capacity + node labels):
```yaml
executors:
- {name: local, type: local, max_load1: 8.0, min_free_mem: 2GiB}
rules:
- when: {executor_has_capacity: local}
use: local
- use: local
```
`min_free_mem` is carried as the string a rules file wrote it as, so the file round-trips; the units are exactly `B`, `KiB`, `MiB`, `GiB`, `TiB`, and a bare byte count. Any other unit is refused when the rules file loads, naming the executor and the list — read leniently an unreadable limit means *no limit at all*, so the one file written to keep dispatches off an exhausted host would be the file that removed the bound.
`when` is a mapping, and there are exactly two predicate families in it. `executor_has_capacity: NAME` matches on **capacity**: the named executor's `CapacityReport` against the limits its `executors` entry declares. `node_label: {KEY: VALUE, ...}` matches on the **node's labels** by exact string equality, never a glob or a pattern. The keys it may name are the reserved ones that exist when the choice is made — `run_id`, `node`, `persona` — because an executor is chosen once per node, before any of its steps run; `step`, `round`, and a free-form extra are refused at load rather than left as a condition nothing can satisfy. Several conditions in one `when` conjoin: all of them hold or the rule does not fire. A `when` naming neither family is refused at load rather than read as an always-true rule. A rule with no `when` at all is the fallback, and the first rule that holds decides.
Views (CLI): `runs`, `status`, `host`, `monitor RUN [--filter NAME|SPEC] [--all]`, `results`, `goals`, `transcript RUN [NODE]`, `telemetry [--breakdown]` — unread-surface accounting, driver liveness (DRIVER DEAD vs PARKED vs UNDRIVEN), provider-health block sourced from `oneagentgraph health`. No view reports a round: the read model is the continuous graph state folded from the journal, and the ledger records it as one `result.json` per run, rewritten whenever a driver closes out. `results` names each run-end hook that fired — which hook, its reason kind, its ending, its exit, its log path, and the last 20 lines of its output — and each let-go that withheld one. `status` reports, per in-flight node, **what its dispatch is doing now**, how many events it has recorded, and how long since the last one, read from the `turn-activity` summaries `oneagentgraph` emits from both member kinds. `transcript` renders a dispatched turn's tools from those same summaries and its words from the report a `member-settled` retained at `report_path` — a two-party member's conversation as onejudge's own transcript, and a single-sided member's as the answer each harness that ran gave, because that is the report each kind settles with. That report is **copied into the run's own storage as the settlement is ingested** — from a process this library started, refusing anything that is not the producing library's own plain file, of its own name, within a bounded size — and every reader afterwards opens only that copy, at a path derived from the settlement rather than taken from it. A settlement whose copy the run does not hold is named as unretained, and the path it claimed is printed and never opened. `telemetry` carries per-party `usage` — `agent`, `judge`, `llmlint`, `total`, each with `input`, `output`, `cache_read`, `cache_write`, and `cost_usd` — and eight WALL buckets that sum exactly: `agent`, `judge`, `llmlint`, `gate`, `publication_wait`, `lock_wait`, `setup`, `scheduling`. Where two nodes are doing different things across one millisecond it is named by the more specific of the two, which is what keeps gate time and lock waiting separable from agent time. A bucket or a party nothing in the stack measures is served **absent**, never as a zero that reads as measured.
**A host is shut down with one command, and it is not a stop.** `onepipeline shutdown [RUN] [--mine] [--host] [--grace SECONDS] [--force]` ends the running work on this host the way a person would want it ended: it asks every live dispatch to wrap up and commit, waits, stops what is left, and then makes sure every branch that work is on has reached its origin. Exactly one scope, and it is required — the positional `RUN`, `--mine` (every run this session owns, as `runs --mine` selects them), or `--host` (every run under the runs root this invocation reads, whoever owns it); naming none, or naming two, is refused before anything is signalled. The three scopes behave **identically** — interrupt, wait, forceful stop of survivors, then the preserving push — and differ only in which runs they enumerate. `RUN` and `--mine` keep `stop`'s ownership rule and refuse a run another session owns, naming the owner and signalling nothing; `--host` deliberately does not, because shutting a host down is a decision about the host rather than about one run, and what stands in for the refusal is the report, which names every run's owner. `--grace SECONDS` is how long a dispatch has to end itself after it is asked, **default 600** — integer seconds, as `--hook-timeout` and `--heartbeat-interval` are, with no environment rung under it; `--force` skips the interrupt and the wait and goes straight to the teardown, and `--grace 0` is the same path. There is no `--json`: the typed answer below is what a program reads.
Per run, in order: **nothing new starts** — once a run's shutdown has begun that run dispatches no further node; **every live dispatch is asked to stop** through the lever a `cancel` already pulls, `oneagentgraph interrupt RUN MEMBER --input` per turn address with the cancellation's own redirection and the same three answers, none of which is a failure, a dispatch that has named no turn recorded as having none to ask, and the addresses read off the graph run and member that dispatch's own relayed envelopes stamp; **the wait**, up to `--grace`, watching the dispatch processes the run's own dispatch registry names with its start token and never `ps`; **the teardown**, of whatever is still standing at the deadline and then the run's driver, the way `stop` ends them — SIGTERM first, escalation after, the whole descendant tree and never a process group, survivors reported by pid, answering `stop`'s own teardown vocabulary unchanged; and **the preserving push**, `onevcs::preserve` for every branch the run's own records name — the session each node worked in, in flight or closed, and every branch the run preserved — attempted whether or not the teardown ended everything, with a push that fails for one branch reported and the others still pushed. Nothing here opens a change request, runs a merge path, or touches a base.
**Nodes are left where they are, and the run stays adoptable.** No node is parked, no node is settled by the shutdown, nothing is written back to the plan store and no claim is released. A node whose dispatch was in flight is left in flight, which is exactly the state `adopt` already reconciles, and `onepipeline adopt <run-id>` reattaches a driver as it does after a `stop`. **A shutdown journals no `run-stopped` and fires no run-end hook** — the run has not ended, it has been put down mid-flight to be picked up again, and a failure hook here would launch follow-up work on a machine that is going away. What it journals instead, on each run's own journal, are the two kinds the closed list above carries: `dispatch-stopped`, labelled with the node, one per live dispatch it acted on, `{"pid": u32, "interrupt": "delivered"|"no-turn"|"failed"|"not-asked", "detail": string, "ended": "graceful"|"killed"|"still-running", "waited_ms": u64}`; and `host-shutdown`, one per run acted on, written after the teardown and the pushes, `{"scope": "run"|"mine"|"host", "owner": string, "forced": bool, "grace_seconds": u64, "dispatches": u32, "graceful": u32, "killed": u32, "teardown": <the word `run-stopped` already carries>, "root": string, "branches": [{"identity", "branch", "result": "pushed"|"already-on-origin"|"no-remote"|"refused", "remote": string|null, "commit": string|null, "detail": string}]}`. `not-asked` is the `--force` path. Between them a later reader can tell a host shutdown from a stop, a crash and a kill, and can tell a dispatch that ended on its own terms from one the deadline reaped.
The whole verb is a seam beside `verbs::stop`, so a program linking this crate — the browser view among them — calls it rather than reimplementing any of the above:
```rust
pub enum ShutdownScope { Run(String), Mine, Host }
pub struct ShutdownRequest { pub scope: ShutdownScope, pub session: String,
pub grace: Duration, pub force: bool }
pub enum DispatchEnding { Graceful, Killed, StillRunning }
pub enum Preserved { Pushed, AlreadyOnOrigin, NoRemote, Refused }
pub struct DispatchStopped { pub node: String, pub pid: u32,
pub interrupt: String, pub detail: String,
pub ended: DispatchEnding, pub waited: Duration }
pub struct BranchPreserved { pub identity: String, pub branch: String,
pub outcome: Preserved, pub remote: Option<String>,
pub commit: Option<String>, pub detail: String }
pub struct RunShutdown { pub run: String, pub owner: String, pub forced_over_owner: bool,
pub dispatches: Vec<DispatchStopped>,
pub teardown: journal::StopTeardown,
pub branches: Vec<BranchPreserved> }
pub struct Shutdown { pub root: PathBuf, pub scope: ShutdownScope,
pub grace: Duration, pub forced: bool,
pub runs: Vec<RunShutdown>,
/// Every unpublished branch on this host the shutdown did
/// not push, read from `onevcs::recoverable(&Scope::All)`.
pub not_pushed: Vec<(String, String)>,
/// Named when that read could not be made, so an empty list
/// is never read as "there are none".
pub not_pushed_unread: Option<String> }
pub fn shutdown(root: &Path, request: ShutdownRequest) -> Result<Shutdown>;
pub fn render_shutdown(shutdown: &Shutdown) -> String;
impl Shutdown { pub fn exit_code(&self) -> i32; }
```
**The report is the product.** `render_shutdown` writes, per run: the run, its owner, and which scope selected it; per dispatch, the node, what the interrupt was answered with, and whether it ended within the grace or was killed at the deadline — including a dispatch killed while it was inside a publication, which is named as such together with the state that leaves and the verb that resumes it, since the engine already recovers a pushed-but-unverified change; the teardown, in `stop`'s own words, including any survivor by pid; and per branch, what was pushed where, what was already on its origin, what had no origin, and what was refused and why. **A branch that was pushed is reported as on its origin *unproven***, in those words, because a preserving push skips the repository's own pre-push hook — carried by every pushed branch rather than stated once at the foot of the report. The runs root it read is named, because a run under another runs root is one this will never list. Its **last section** names, by count and by branch, every other unpublished branch still on this host that the shutdown did not push, so somebody decommissioning the machine can see them and push them by hand — a fact, never a failure, and a read that could not be made says so rather than reporting none. The exit status is `EXIT_REFUSED` when anything was killed at the deadline, when a teardown was not clean, or when a push was refused, and `EXIT_SUCCESS` otherwise: a branch already level with its origin, a branch whose identity has no origin at all, and a run with nothing live to interrupt are facts this reports at exit 0.
**`status` reads a shut-down run as one.** A run carrying a `host-shutdown` reads as ended by a host shutdown, naming when and saying `onepipeline adopt <run-id>` resumes it — never as the bare `PARKED` a run whose driver is alive and idle reads as, which an operator is told to treat as a wedged run to intervene in. `results` names, for each node that was in flight, the branch its work is on and whether that branch reached its origin.
**The post-launch verbs are the SDK, and the CLI is argument parsing over them.** `onepipeline::verbs` carries one typed function per post-launch verb, each with a public renderer beside it that produces exactly the text the binary prints, and every post-launch arm of the binary is parse → call → render → exit code over that call and nothing else: a post-launch behaviour the binary has and the SDK lacks is a defect, and `tests/parity.rs` drives the compiled binary and the call over one recorded run and holds stdout and exit code byte for byte. The functions, with the type each answers: `runs(root, session, mine) -> Projects` rendered by `render_runs(&Projects, Grouping::Grouped | Grouping::Flat, session)`; `status(root, Option<&str>) -> Status` — `Status::Run(RunStatus)` is the folded detail of the run named and `Status::Listing(Projects)` the bounded listing, which never folds — rendered by `render_status`; `host(root) -> Host` and `render_host`; `goals(root, Option<&str>) -> Goals` — the survey with, given no run, the grouping — and `render_goals`; `results(&RunPaths) -> Results` and `render_results`; `transcript(&RunPaths, Option<&str>) -> Transcript`, refusing an unknown node, and `render_transcript`; `telemetry(root, Option<&str>) -> Vec<RunTelemetry>` through the public `telemetry::of_run` (the fold over a view the caller holds), with `render_telemetry` and `render_telemetry_breakdown`; `monitor(&RunPaths, &EventFilter, cursor) -> Monitored { view, events, cursor }` and `render_monitor`; `next(&RunPaths, &EventFilter) -> Next { status, surface, events }` — it claims, journals `planner-surfaced` and resets the check-in clock exactly as the verb does — and `render_next`; `channel(&RunPaths) -> ChannelQueue { surfaces, waiting, held, replies, commands, outcomes }` with `pending()`, `abandoned()` and `answered()`, read on the CLI as `channel queue RUN`, rendered by `render_channel`, over the channel's own record types `channel::{Surface, QueuedReply, QueuedCommands, CommandOutcome, CommandResult, CommandVerdict}`; `watch(&RunPaths, &WatchRequest { filter, timeout, tick, cursor, until }, sink) -> WatchOutcome { ending, cursor }`, where the sink is `&mut dyn FnMut(WatchFrame<'_>) -> Result<()>` and a `WatchFrame` — an `Event`, a `Tick`, or the `Ended` condition, each carrying the `RunView` it was decided from — is rendered by `render_watch_frame` into `WatchLines { human, machine }`; the sink's error ends the wait, which is a caller's way to end one early; the call registers the calling process as the run's watcher for the length of the wait; `unwatched(root, session) -> Unwatched { reported, unresolved }` and `render_unwatched`; `reply(&RunPaths, Option<&Correlation>, envelope_json) -> Receipt { outcome: ReplyOutcome, advice }`, over the envelope's **bytes**, parsed and validated through the whole submission path — the declared author and its grants, the completion grant, the node validator and the envelope reviewer with its bar, a local apply when nothing drives the run and the durable queue with the reply timeout otherwise — with `render_receipt` printing the object entry 64 of `docs/contract-divergences.md` states and `advice` process-local, off the wire, for an embedder to show; `attest(&RunPaths, reference) -> Receipt`; `surface(&RunPaths, SurfaceKind, message) -> Surfaced` and `render_surfaced`; `stop(&RunPaths, StopRequest { session, force }) -> Stopped { run, owner, forced, teardown: StopTeardown, clean }` — the session passed in, never read from the environment inside; a run another session owns refused with `Error::NotOwned` unless forced; a teardown that was not clean answered with `refusal()` rather than a stop — and `render_stopped`; `shutdown(&Path, ShutdownRequest { scope, session, grace, force }) -> Result<Shutdown>` — the whole of the host-shutdown verb, the scope and the session passed in the way `stop`'s are, answering the `Shutdown` the shutdown paragraph above spells field by field — and `render_shutdown`; `adopt(&RunPaths, Adopt::Attached | Adopt::Detached(Retain { program, args })) -> Adopted::Attached { run, settlement: Settlement } | Adopted::Detached { run, pid }` — attached drives in-process as the verb does; detached validates and displaces as the verb does, then spawns `program args…` exactly as stated in a process group of its own, stdin null and stdout and stderr on the run's driver log, and returns once the driver has claimed the run — and `render_adopted`; `agents(&RunPaths, AgentScope) -> Agents` and `project_agents(root, project) -> Agents` with `render_agents`, the read the run-history paragraph above states; and `drive_run(&RunPaths, Retained::Driving | Retained::Adopting) -> i32`, the public body of the hidden `drive-run` verb, so an embedding binary can carry a hidden driver verb of its own and be its own detached driver — whoever carries it calls `agentgraph::speaks_this_cli` first, as `run` does; the binary passes `current_exe` with `["drive-run", RUN, "--adopt"]`. Beside them, `views::liveness_of(&RunSummary) -> DriverLiveness` is the listing's own liveness reading over the bounded document, and `views::plan_of(&RunPaths) -> Plan` the loader over `RunPaths::plan()`. Exit codes do not move: each result carries its outcome and answers the code through `exit_code`, and the binary prints that.
**Runs are grouped by the project they were launched from, and that is the default shape of a listing.** `RunSummary` carries `name` — the plan's name, `onepipeline.name` or else the project's own title, read off the run's `plan.json` — from summary schema 6, and `views::Projects { root, groups: Vec<ProjectGroup>, skipped }` with `ProjectGroup { project: Option<String>, name: Option<String>, last_write_at: Option<u64>, runs: Vec<RunSummary> }` is the bounded listing grouped by `RunSummary.project`, built by `Projects::of(&Listing)`: groups ordered by the group's newest `last_write_at` first, then by project id, so the order is total; runs inside a group in the `Listing`'s own order; and the runs whose summary carries no project in one ordinary group with `project: None`, rendered `(no project)` and ordered by its own recency — never hidden, never last by rule. `Projects::flat()` is the ungrouped list. `onepipeline runs` renders the grouped listing — a header line per group, opened by `views::GROUP_HEADER` and naming the project id and its name, with the run lines beneath it byte for byte as the flat listing renders them — and `runs --flat` the flat list by run id; `status` given no run and `goals` given no run render the same grouping.
**Retention and resolution are one published promise**, so a consumer writes a report through the same path it reads one back from rather than restating a name it observed. `views::RunPaths` is the nameable type — the run id `run` and the run's own directory `dir`, minted by `RunPaths::new(RUN)` under the runs root this process reads or by `RunPaths::under(ROOT, RUN)` under an explicit one; `reports_dir()` is the run's own storage, and `report_for(STREAM, SEQ)` is where this run's copy of one relayed settlement's report is kept: `reports/<sanitised stream>-<seq>.json`. The **artifact id names the stream but not the seq**, so the file is derivable from the envelope the artifact was recorded on and never from the id alone. The stream is written as a single sanitised segment, and **the sanitiser is not public**: a report path is obtained by calling `report_for` and in no other way, so the writer and every reader share one implementation instead of two that happen to agree. Those six, and `oneharness_sessions()` — the run's pointer file, `oneharness-sessions.jsonl` under `dir`, as the run-history paragraph states it — are what the type promises; nothing else on it is contracted. `report::retain(&RunPaths, &Envelope)` is the **writer**, published with the precondition it has always been called under: **the caller holds the producing process's authority for the path the envelope names** — it is being ingested from the stdout of a process the caller started, before that line exists anywhere a stranger could have written it, which is the one moment the named path carries the producer's authority rather than the journal's. Publishing it does not widen that, and every refusal it makes it goes on making, out loud and without following: an envelope that is not `agentgraph` / `report::MEMBER_SETTLED`, a `report::REPORT_PATH` whose base name is not `report::ACCEPTED_REPORT_FILE`, anything that is not a plain file — a symlink is the case this exists for, naming one file and delivering another — and anything past `report::MAX_REPORT_BYTES`. `ACCEPTED_REPORT_FILE` is this crate's own re-export of the producing library's report file name, so a consumer proving this path needs no `oneagentgraph` dependency of its own. `onepipeline::VERSION` is this crate's own package version: a host that pins this engine and separately pins a reader of the run store it writes has nothing else to prove the two are the same release.
Shipped content: example personas — an observer (at `personas/orchestrator.yaml`, the shipped file the orchestrator persona was rewritten into), `check-in`, `pr-author`; the example dag-scope agent-graph config, `graphs/dag-scope.yaml`; a default node-scope config (worker+judge); an example plan store, `examples/plan-store/`, holding one project per example plan. The files under `graphs/` and `personas/` are **examples a host may copy**: the engine reads none of them at runtime and names no member or author they declare — a launch names its own observer graph by REF, and that graph's own `schedule` and the launch's bus configuration are what the engine acts on. The shipped observer persona reads the detailed stream, compares activity against the run's goal, each node's task prose, and the graph definition, surfaces issues, opportunities, and anomalies non-blocking, self-applies clearly intent-preserving fixes as attributed live edits within the grants its bus configuration gives it, escalates ambiguity rather than acting on it, and never authors target-project content. pr-author composition: the drafting dispatch runs under the shipped `pr-author` persona, and a plan node or step may not claim that name — the persona is what tells that dispatch from a node's own, which are composed differently, and a node claiming it would silently lose the overrides its dispatch carries. Where a lifecycle node carries no `body` and the launch named a `--pr-author-graph`, one post-verification dispatch runs that graph once — after the branch is verified and before the change request is published, in the node's **own** worktree, which is where the diff is, and carrying none of the node's own turn budget. Its task is the opening sentence, the node's rendered task, the change request the session already holds where it holds one — a draft the worker opened, or one an earlier attempt of the node opened — and how to read the worker's transcript. The body it drafted is `results[].structured.body` of the result that ran, read out of the copy **this run retained** and never by following the `report_path` a producer named. Where the session holds no change request, the change request opens with that body; where it holds one, the body is written onto it as its description under the node's `title`, and the same publication lifts the draft and lands under the resolved policy. A drafting dispatch that does not start, fails, is cancelled, or answers with nothing the schema accepted leaves the publication untouched: the change request opens with no body and the node settles on its publication as before — and a change request the session already held keeps the description the worker left, said on the settlement. Drafting is never on the publication path. **It is never silent either.** Where a drafting dispatch was configured and attempted and produced no body, the run records `body-not-drafted` against the node, carrying `ending` — `dispatch-failed` for one that could not be run or ran without succeeding (a failed turn and a cancelled one both land there, because they differ in nothing a publication carrying no body either way can act on), `schema-refused` for one whose every answer the schema it was validated against rejected, and `no-body` for one that answered inside that schema and put nothing in it — and `detail`, which the node's own settlement carries too — after the publication's own reason where that failed as well, because the publication is what settled the node — so `results` shows the ending without a reader opening the store. Nothing is emitted for the two endings that are not failures: a launch that named no pr-author graph, and a node that carried its own `body`, both spend no dispatch. The three are kept apart because they take three different fixes, and a run that had just wired a drafter could tell none of them from a launch that had wired none.