Skip to content

ADR-0110: Deterministic manifest fan-out — legs from briefs, no planner

  • Status: Accepted (2026-08-11; see Amendment 1)
  • Kind: Aspirational (records the target state)
  • Implementation-status: partial — the manifest schema v1 loader (#2808) and the quiescence proof before publishing a round complete (#2814) are on main (lionagi/cli/orchestrate/_manifest.py, _quiescence.py, _checkpoint.py, _round_records.py); remaining clauses have not been re-verified clause by clause
  • Area: orchestration
  • Date: 2026-08-03
  • Relations: extends ADR-0106 (machine result contract — D6 here names one additive change to it and preserves its closed outcome vocabulary); extends ADR-0066 (li mcp verb surface — the round submits as a job kind and is read back through job.output, whose artifact and round-summary shape D6 widens); depends on ADR-0107 (conclusive orphan terminal reaping — its identity-verified reads and its rule that only positive evidence of a gone process admits a terminal transition are what D3's reaper path is built on, and D3's finalization claim decides which of them a late arrival still owes)

Amendment 1 (2026-08-11) — the record catches up with the code

This ADR was authored Proposed on 2026-08-03 and its core implementation merged the same day: the manifest schema v1 loader landed in #2808 (2026-08-03) and the quiescence gate — proving a manifest round quiet before publishing it complete — landed in #2814 (2026-08-04). The modules named by D1 exist on main (lionagi/cli/orchestrate/_manifest.py, _quiescence.py, _checkpoint.py, _round_records.py). The status flip to Accepted records that the decision has been in effect since then; it changes nothing in the decision text. Clauses beyond the D1 core have not been re-verified individually — the Implementation-status header field says partial for that reason and should be advanced as each clause is confirmed shipped.

Context

Every orchestrating surface this package ships puts a planner model between the caller's task statement and the legs that execute it. li o fanout runs a decomposition phase first (lionagi/cli/orchestrate/fanout.py, phase 1: "orchestrator decomposing task into ≤N assignments") and the assignments the workers receive are the planner's text, not the caller's. li o flow has the orchestrator compose the DAG. Playbooks template the planner's prompt; they do not remove the planner.

That is the right shape for a prose task and the wrong shape for a class of work that is common and currently unserved. The problems, concretely:

  • P1 — planner interposition corrupts fixed inputs. When the caller already holds N pre-written briefs — review instructions, audit scopes, per-module checklists — each brief IS the contract for its leg. A planner that can rephrase, merge, or re-scope them is not adding intelligence; it is corrupting the input. Multi-round document review is the sharpest instance. A prompt telling the planner "do not rewrite" is not a fix: prompt prohibitions are requests, not controls.
  • P2 — the deterministic fallback costs N of everything. Callers in this position submit N independent agent runs: N handles to track, N terminal notices when one answer to "is the round done" was wanted, and artifact harvest by hand.
  • P3 — sandboxed legs contort output paths. CLI legs under a workspace-write sandbox cannot write outside their own cwd tree, so briefs must smuggle output paths that land inside it. Meanwhile every run already has an artifacts directory listed by job.output (lionagi/mcp/jobs.py, output() returns artifacts + artifacts_state) — but a leg is never told where it is and could not write there if it were. job.status deliberately carries no artifact fields; the artifact read is and stays job.output.
  • P4 — per-leg working directories are the norm. One round may span two repositories, and PR-review rounds are per-worktree by construction. A single run-level cwd excludes the most common round shapes.
Concern Decision
Input format and validation D1: closed manifest schema v1, file-path-only, snapshotted at submit
Execution and aggregation D2: independent parallel legs; per-leg timeout from spawn; no parent deadline in v1; total outcome rules
Durable per-leg record and ordering D3: per-leg records + round summary durable before cooperative terminalization; two-stage kill; the hard-kill window is observable, never silent
Leg artifact channel D4: scratch dir inside leg cwd, env-announced, harvested by descriptor-anchored bounded copy
Planner absence D5: no-planner is a tested invariant with the profile-default drift vector as a named failure case
Observation contract D6: closed job outcome preserved; round facts served by a versioned additive round field on job.output; the notice is the signal, not the carrier

Out of scope: dependencies between legs (the flow surface's job); scheduling recurring rounds (li schedule composes on top); any change to the planner surfaces themselves; artifact content conventions (a verdict file's format is the caller's contract with its own legs).

Decision

D1 — Manifest contract v1

A round is declared by a manifest file (YAML or JSON), passed by absolute path. The manifest is read and snapshotted at submit, same rule as prompt_file on the agent surface: editing the file afterwards cannot change what an already-submitted round executes. Every brief file is likewise read and snapshotted at submit, and each snapshot's content hash is recorded in the run directory as durable evidence of what was dispatched.

manifest_version: 1            # required, exactly 1
defaults:                      # optional; every key below optional
  model: <model spec>          # XOR agent, at each level
  agent: <profile name>
  timeout: 1200                # per-leg default, seconds, positive, <= 86400
legs:                          # required, 1..64 entries
  - brief: /abs/path/briefs/module-a.md    # required
    cwd: /abs/path/worktrees/module-a      # required
    label: review-module-a                 # required
    model: <model spec>        # optional per-leg override
    timeout: 900               # optional per-leg override
    env:                       # optional; closed map of named variables
      CARGO_TARGET_DIR: /abs/path/targets/module-a

Exact semantics, refuse-early at submit (nothing spawns, no job record is created — the pattern lionagi/mcp/dispatch.py already applies to would-be-refused submissions):

  • Unknown keys anywhere are refused by name. The schema is closed; v1 accepts exactly the fields above. A misspelled knob must fail the submit, not silently configure nothing.
  • brief: absolute path to an existing regular file, resolved through symlinks at read time and then treated as bytes; empty (after strip) is refused. Snapshot + BLAKE-family content hash recorded per leg.
  • cwd: absolute path to an existing directory.
  • label: required, matching [a-z0-9][a-z0-9._-]{0,63} after lowercasing, unique across the manifest after normalization. The label is an artifact-directory component (D4), so path separators, .., and empty/dot-only names are unrepresentable by the pattern rather than filtered by a check.
  • model XOR agent at each level. A leg naming either uses its own and ignores both defaults (no cross-level merging of the pair — merging model from one level with agent from another would construct a configuration nobody wrote).
  • timeout: positive integer seconds, at most 86400, and it is a PER-LEG value at both levels: defaults.timeout is nothing more than the default each leg inherits. The ceiling is a sanity bound, not a derivation: a leg that needs more than a day is not a round leg.
  • env (per-leg, optional): a closed map of named environment variables set for that leg — keys matching [A-Z][A-Z0-9_]{0,63}, string values, passed via the process environment array at spawn (never through a shell). Deny-by-default at the manifest surface: no manifest mechanism forwards any environment — not the submitting client's, not the serving process's; the map is literal values only, and the manifest snapshot is their durable source. The baseline a leg otherwise inherits is the runner process's own environment — today the serving daemon's environment as constructed at spawn (lionagi/mcp/jobs.py, the submit path's env dict handed to Popen) — and this ADR neither defines, freezes, nor filters that baseline: scoping it is engine-wide hardening for every job kind at once, a separate decision this ADR names and does not carry. A declared key that also exists in the baseline is overridden by the manifest value — that is the feature (the declared value is the recorded one); the D4 refusal rule protects only the runner's own reserved name, and LIONAGI_LEG_ARTIFACTS is accordingly refused here at submit. Declared keys are listed in the leg's durable record as env_keys; the record never re-prints values. Manifest values are recorded verbatim in the snapshot, which is exactly why a credential in one is a rule violation rather than a supported path — the mechanism cannot stop an author from writing one, and what it CAN guarantee is that nothing hides: whatever a manifest carries, its snapshot shows. A leg that legitimately needs a secret gets it from the serving environment, the channel the existing agent surface already provides; this ADR adds no new one. The reproducibility claim is scoped accordingly: declared keys are the recorded, reproducible deltas over that baseline, and a round is reproducible given the same serving environment, no stronger. The consuming workflow demonstrated the concrete cases (actor identity resolving wrong on workspace cwds, per-worktree build target directories).
  • Leg count 1..64. The floor is definitional. The ceiling is one order of magnitude above the largest observed round (13) — a bound that exists so a generated manifest with a bug cannot fan out unbounded, chosen loose enough that no legitimate round has to think about it.
  • File-path-only in v1. An inline manifest object in the MCP call is DEFERRED (see Alternatives): the file path gives snapshot semantics, a natural durable-evidence story, and parity with prompt_file, and the consuming workflow already produces brief files on disk.
  • round is never a submit-side noun. A caller submits a manifest and gets a run; the word round appears only on the observation side, where it names facts about a run that already exists (round_state, the round summary, the round field D6 adds to job.output). No submit parameter, manifest key, or CLI flag may be called it. The constraint is here rather than left to taste because the alternative reading is available and costly: a submit-side round invites a caller to believe rounds are a thing they create, number, and re-run, which would make a round an identity separate from its run and give every durable record two keys to be consistent about. One run, one round, and the word belongs to whichever half can say that.

D2 — Round execution: independence, clocks, total aggregation

Legs execute in parallel under the existing worker concurrency machinery; concurrency caps compose the same way they do for the planner fanout.

  • Legs are independent by construction. A leg failing, timing out, or being killed never cancels a sibling. For the motivating workload every completed verdict has value regardless of a sibling's fate. Fail-fast is a rejected alternative, not an option flag, in v1.
  • Per-leg timeout clock starts at leg process spawn, not at submit and not at queue admission — queue wait under a concurrency cap is not the leg's time. What a timeout interrupts is the leg's own execution.
  • There is no parent deadline in v1. defaults.timeout is only the per-leg default (D1). A round ends when its last leg ends. External cancellation (job.kill) is an EVENT, not a clock, and is specified in D3; a leg stopped by it records cancelled. A round_timeout field is deliberately absent until a consumer demonstrates the need — one knob, one meaning.
  • Leg terminal vocabulary: succeeded, failed, timed_out, cancelled, killed — plus the orthogonal harvest state (D3). Every leg ends in exactly one.
  • Parent aggregation is total by construction — three rules cover every combination of leg terminal states and harvest states, so no mixed round is undecided:
Rule (evaluated in order) Round result
every leg succeeded AND no leg harvest_failed completed
at least one leg succeeded (anything else true of the others) partial
no leg succeeded failed

dir-empty and dir-absent never degrade the result by themselves: a leg whose whole answer is its final message legitimately writes no artifact. harvest_failed always degrades below completed — artifacts were (or may have been) written and cannot be served, a loss the result must not paper over.

  • A timed-out leg is recorded timed_out, receives cooperative termination escalating to hard kill, and its harvest runs only after its process's death is confirmed. The quiescence invariant (D3) is path-independent in what it protects, and its predicate names the one process that must survive to publish: round_state: complete is never published before a quiescence sweep has run against every recorded control group — the runner's own and each leg's, all captured at spawn. On reap paths the reaper belongs to no recorded group, so the predicate is absolute: every recorded group observed empty. On the cooperative path the publishing finalizer is a member of the runner's own group and must outlive the sweep to write the summary, so the predicate there is every leg group observed empty and the runner's group holding no member but the identified finalizer itself — demanding the finalizer's own absence would make cooperative publication impossible, not safer. D3 states the domain exactly and names the residuals the sweep cannot close.

D3 — Durable records, ordering, and the two-stage end

Each leg gets one durable record in the run directory, {run_dir}/legs/{label}.json:

{
  "label": "review-module-a",
  "status": "succeeded",
  "started_at": "...", "finished_at": "...",
  "cwd": "/abs/path/worktrees/module-a",
  "model": "<resolved spec>",
  "env_keys": ["CARGO_TARGET_DIR"],
  "brief_hash": "<content hash recorded at submit>",
  "pgid": 41230,
  "harvest_state": "harvested-3",
  "harvest_detail": {"files": 3, "bytes": 18211, "skipped": []},
  "artifacts": ["module-a/verdict.md", "module-a/notes.md", "module-a/log.txt"]
}

The dispatch facts — label, cwd, model, env_keys, brief_hash, started_at, and the leg's own process group (pgid) — are durably recorded in the leg record's first write, at spawn; status and harvest fields complete the record at finalization. The pgid capture is the manifest runner's own duty, named here as required work: it reads the group immediately after the spawn returns, the same spawn-time capture the job surface performs for its own child. The provider subprocess layer starts each leg's new session but today neither captures nor persists the resulting group, so the runner performs the read itself (or a provider API is added that returns it) — this ADR does not describe that capture as existing. The spawn-time write is what makes a reaper's quiescence sweep possible at all: the control domain is read from the run directory, never from a live runner's memory, so a reaper that shared nothing with the dead runner sweeps the same groups the runner would have.

The round gets one summary record, {run_dir}/round.json:

{
  "round_version": 1,
  "round_state": "complete",
  "result": "partial",
  "legs_total": 4, "legs_succeeded": 3,
  "legs": ["review-module-a", "review-module-b", "..."]
}

round.json is first written at spawn with round_state: "pending_harvest" and flipped to complete as the last act of finalization — the summary exists before any leg runs, so a terminal status published by ANY writer at ANY point (including a legacy or non-manifest-aware one) is observably pending rather than silently incomplete. round_state is the honesty field: complete means every leg record and harvest is durably written; a reader who finds a terminal job with round_state: pending_harvest is told, in the record itself, that leg facts are still landing — the window exists and is OBSERVABLE, never silent.

  • Finalization has exactly one owner at a time, and the claim cannot go stale. The claim is an OS advisory lock (flock-style, exclusive, acquired non-blocking) held on {run_dir}/finalize.lock for the duration of finalization; the cooperative runner acquires it when teardown starts. The descriptor is opened close-on-exec — a lock a spawned leg could inherit would keep a dead runner's claim alive from inside a living leg. The kernel couples the lock's lifetime to its holder's: a dead owner's claim vanishes with its process, so there is no stale-lock repair path for two reapers to race on — takeover IS acquisition, the same primitive every claimant uses. Acquisition carries an obligation: every decision to claim is made on a PRE-acquisition observation, and the gap between observing and acquiring is exactly where a live holder finishes, publishes, and exits — releasing the very lock whose availability the claimant then reads as confirmation of its premise. So a claim holder's FIRST act, on every path, is to re-read the run's terminal status and round.json's round_state under the claim, and to proceed on what that re-read shows — never on the observation that motivated the claim. The re-read admits exactly four dispositions, shared by every claimant: terminal and complete — release, nothing is owed; nonterminal but complete — the dead holder finished everything except the parent's terminal write, so the claimant makes that single write from the recorded facts and touches nothing else (no kill, no harvest: complete is published only after a proved-quiet sweep); terminal but pending_harvest — the late-facts pass (below), whose sequence is exactly the unfinalized path minus its last step: quiescence sweep of every recorded group first (complete is never published before one), then harvest, then records, then the round_state flip — and no second terminal write and no second notice, because the run already has its terminal facts and the latch (ADR-0107) keeps them; neither terminal nor complete — the claimant's full path runs, quiescence first wherever the path is destructive. A failed non-blocking acquire means a live owner exists; the failed claimant re-checks later and touches no scratch directory or record meanwhile. The file's content (owner role runner / kill-reaper / orphan-reaper, pid, the run's job marker, claimed_at) is observability, written by the holder after acquiring — never the mechanism itself. Every leg record and round.json write lands by temp-file-plus-atomic-rename, and a writer that finds a complete leg record already present leaves it — first write wins, recorded_by names the winner — so even a mis-sequenced writer cannot produce two competing records for one leg.
  • A hung live holder has a named recovery, per holder — one signal path does not cover all three. The runner holder is the runner process itself, and the claim descriptor is close-on-exec, so no surviving child holds the claim. job.kill delivers the stop request (the MCP surface sends a fixed SIGTERM and exposes no signal choice). Where that is ignored, the recovery is an operator kill of the recorded leader pid with the identity checks the job record already supports — the recorded pid, and the group-marker environment variable every group member inherits, exist for exactly this verification — escalating to SIGKILL, which releases the claim with the process. No existing CLI or MCP surface performs that escalation for this id class today; a first-class escalation parameter is possible future work, not claimed here. Claim recovery by the operator is deliberately narrower than group cleanup — the operator only needs to free the claim. The reaper that then acquires it owns the rest: its pre-harvest quiescence sweep (above) is what makes a leader-only kill safe, because surviving domain members hold no claim and cannot outlive the reaper's quiescence sweep. The reaper holders are server-side actors a job-group signal cannot reach; their work is bounded by construction — the same per-leg file and byte caps that bound every harvest — and one that nonetheless hangs holds the claim until the serving process restarts. Restart is the named recovery for a server-side holder, and it is sufficient because the claim is kernel-held and leaves no persistent state behind.
  • Cooperative ordering guarantee: on normal completion and per-leg timeout, the finalizer first proves every recorded control group quiet. For each leg's recorded group it hard-kills identity-verified survivors and verifies absence — a leg that ended normally leaves its group empty already, and the sweep confirms exactly that; a straggling descendant still inside it is ended at round close rather than tolerated into the harvest window. For the runner's own group — which it cannot group-kill, being a member — it scans and signals survivors other than itself individually, identity-checked the same way; the cooperative predicate is "no member but the finalizer" (D2), because the finalizer must survive its own sweep to publish. Only then every leg's harvest runs and its record persists, then round.json is written with round_state: complete, and only then does the parent terminalize and its single notice fire. A notification consumer and a polling consumer read the same facts; there is no cooperative window where the notice says "done" and a record is missing.
  • job.kill is two-stage for manifest runs. The current kill path writes status: "killed" and finished_at immediately when no end is recorded (lionagi/mcp/jobs.py, _mark_killed), which would make the parent observable as terminal while cleanup has not run. For a manifest run the kill surface instead records kill_requested_at and signals the group WITHOUT writing the terminal fields; the runner's cooperative teardown then harvests, records, and terminalizes exactly as above. If the runner does not terminalize within a bounded grace (default 30 s), the kill surface checks the finalization claim: while a live owner holds it, the kill surface waits and re-checks — the stop signal has been delivered, and the terminal write belongs to the claim holder; grace expiry is when the reaper first CHECKS the claim, not an unconditional handoff. Only on acquiring the lock — which a dead owner cannot still hold, the kernel released it with the process — does it proceed, and per the claim obligation above its first act is the re-read of terminal status and round_state. A round found complete means a finalizer proved every recorded group quiet before publishing, and the kill path is never entered — the grace-expiry observation is stale, and firing the one destructive primitive in this sequence on it would act on a premise the claim's own availability had already falsified. What remains owed is only what the dead finalizer had not yet written: a parent still nonterminal gets its single terminal write from the recorded facts (the finalizer died between publishing complete and terminalizing — releasing without that write would strand the run nonterminal forever); a parent already terminal means release with nothing to do. Only when the re-read shows the round unfinalized does the destructive work begin, and it begins with quiescence, not harvest: a hard kill of every recorded control group — the runner's and each leg's, read from the run directory (the raw os.killpg primitive, identity-verified against each recorded pgid and the group marker every descendant inherits — environment survives a new session, so the marker identifies leg-group members too — not the existing plain-kill helper, whose killed-marking record write belongs to ordinary kills; the reaper's single terminal write comes later, after harvest), then verification that no member of any recorded group survives — a reaper belongs to no recorded group, so the reap-path predicate is absolute emptiness, differing from the cooperative predicate (D2) by exactly the publishing finalizer. round_state: complete is never published before that sweep has observed every recorded group empty. The recorded groups are the quiescence domain, stated exactly, and it is plural by design: the provider subprocess layer starts every leg in its own session, so one shared group never existed to sweep — an ordinary leg sits inside the domain because its group was recorded at spawn, not because it stayed inside anyone else's. That correction earns a standing rule for any future revision of this domain: a membership sweep names and cites the mechanism that populates the set it sweeps, because a sweep over a domain nobody joins is indistinguishable from a clean sweep and fails toward reassuring. Here the populating mechanism is the runner's spawn-time pgid capture written into each leg record's first write, plus the job surface's recording of the runner's own group. Its companion rule, earned by the successor defect: a membership or quiescence predicate states where the observer sits relative to the set it observes — the observer is inside the measured population unless something puts it outside, and a predicate that forgets its own observer fails toward unsatisfiable, while one that forgets who joins fails toward reassuring. That is why the predicates above are stated per path rather than once: the reap-path observer is outside every recorded group and demands absolute emptiness; the cooperative observer is a member of the runner's group and exempts exactly itself. What the sweep cannot close, named rather than papered over: a descendant that deliberately leaves its own leg's recorded session while keeping the scratch path can still write after complete; a member that forks during the sweep can leave a child the verification pass never observed; and identification and signal are two syscalls, so the sweep inherits the job surface's stated guarantee shape — never a signal without positive identification, not never a missed or misdirected one (the internals documentation states this window and why process groups alone cannot close it). All three residuals fall under the same consumer rule below. Such an escapee is the caller's own agent executing the caller's own brief, so it crosses no privilege boundary and sabotages only its author's round: D4's harvest copies defensively, so the published record describes the harvested copies and stays internally consistent — writes made after recorded-domain quiescence simply miss the round. What a consumer does about the residual, stated as a rule: consume through the round record only — job.output serves the harvested copies under the run directory and never reads a scratch tree, so the read surface enforces this by construction; a leg-artifacts directory that reappears after round_state: complete — or survives it without that leg's record naming a failed removal (D4 removes every harvested scratch before complete on all paths) — is an escapee's signature, sits outside the round's guarantees, and is disposable — deleting it changes nothing recorded; and a leg that detaches workers past its own round is a defect in that leg's brief, fixed in the brief, not a runner defect. A mechanically non-escapable process domain would close the residual; that is platform-specific hardening this ADR names and does not adopt. Only then the manifest-aware reap: harvest each leg's scratch from disk as D4 specifies, write each leg record with what could be established (harvest_failed with a reason where a scratch is unreadable — never an empty artifact list), write round.json, then make its single terminal write. Records written by a reaper say so ("recorded_by": "kill-reaper").
  • An already-dead parent (crash, OOM, machine restart) is found by the existing orphan-reaping path on the job surface; for manifest runs that reaper acquires the same finalization lock (its previous owner is dead by definition of the path, so the lock is free; acquisition still serializes it against a concurrent kill-reaper), takes the same post-acquisition re-read and dispositions as every claimant — a round already complete is terminalized or released, never re-harvested and never swept — and where the re-read shows the round unfinalized performs the same quiescence-then-harvest-then-record sequence before its terminal write — a dead parent does not mean dead legs, so the pre-harvest quiescence sweep applies identically — recording "recorded_by": "orphan-reaper". Where the existing reaper (or any non-manifest-aware writer) has already published a terminal status, the manifest-aware pass still runs, writes the records late, and flips round_state from pending_harvest to complete — late facts beat lost facts.
  • The bound, stated plainly: harvest-before-notice holds on cooperative paths. On kill and reap paths the guarantee is weaker and explicit — round_state names whether the facts are all in, and every leg record distinguishes what was established from what could not be. At every point there is at most one finalization owner and records are first-write-wins, so the weaker guarantee is about WHEN facts land, never about competing versions of them.

D4 — The leg artifact channel

For each leg the runner creates a scratch directory inside that leg's own cwd tree — {cwd}/.lionagi/leg-artifacts-{run_id}-{label}/ — and exports its absolute path to the leg process as LIONAGI_LEG_ARTIFACTS. A sandboxed leg can always write there: it is inside the tree the sandbox already permits. No sandbox configuration changes anywhere.

  • Harvest is a descriptor-anchored bounded copy. The leg author controls the scratch tree's contents, so the harvester (which is NOT sandboxed) treats it as hostile input. The scratch root is opened once as a directory with no-follow semantics and all traversal proceeds from that descriptor (openat-style), never by re-resolving paths — a path re-resolution between check and open is exactly the race a hostile leg would use to swap a checked regular file for a symlink. Each candidate is opened no-follow and its opened identity is verified against the pre-open lstat (device+inode); a mismatch records skipped_swapped. Symlinks are never followed (skipped_symlink). Hard links are refused: a hard link is a regular file and would pass a naive regular-files-only rule while making the harvester copy an inode the leg never produced under the scratch root — the copy-proxy escape by another door. Files with link count other than one record skipped_hardlink. Special files are skipped and recorded. Relative paths are preserved under {run_dir}/artifacts/{label}/.
  • Caps are enforced during the copy, not before it: 1024 files, 256 MiB per leg — an order of magnitude above observed verdict artifacts (single-digit markdown files); a pre-copy size check would race a growing file, so bytes are counted as written and the cap aborts the copy at the boundary, recording harvest_failed with the counts at the cap. Never a silent truncation.
  • Collisions are unrepresentable, not handled: labels are unique and path-safe by D1's pattern, and each label owns its directory.
  • The scratch dir is removed after harvest on every finalization path — cooperative, kill-reaper, and orphan-reaper alike — and the removal precedes round_state: complete. That ordering is what entitles D3's residual rule to read a surviving directory as an escapee's signature rather than permitted reap residue. A removal that fails is recorded in that leg's record, and a directory whose survival is recorded there is not escapee evidence.
  • The env var name is a decision with a check: before implementation merges, the name is swept against the variables a leg already inherits (provider CLIs document theirs; the leg baseline environment is enumerable), and a test asserts the runner refuses to overwrite a variable that already exists in the leg's inherited environment — a collision is a configuration error surfaced loudly, not a silent override.
  • The read surface is job.output (D6). job.status stays artifact-free.
  • This directory is a new named surface and inherits no protections. It gets its own adversarial pass before the implementation merges, and the required arms now include: hostile file names, symlink escapes, HARD-LINK escapes, check-to-open swap races, cap overflow mid-copy, and kill during harvest — with the victim-alive-and-feature-works outcome asserted, not just absence of the attack's effect.

D5 — No-planner is a tested invariant, not a documentation claim

The mode's run record carries planner_invocations: 0 as an asserted field — the mode has no code path that constructs a planning turn, and the record says so per round rather than the docs saying so once.

The named drift vector is not this mode's own code: it is a configuration-side default. Agent profiles carry model/effort/system-prompt defaults, and a profile (or a future orchestrator default) that would silently interpose a planning model on submissions that name it must FAIL a test. Concretely: the test suite includes a submission whose profile is configured the way the planner surfaces expect (an orchestrator-shaped profile), and the mode either refuses the configuration by name or executes the round with zero planning turns — a planned round is a test failure, not a fallback. No diff of this feature's own code would show that drift, which is exactly why it is pinned by a test rather than a review.

D6 — Observation contract: closed outcomes preserved, round facts on job.output

The job surface's outcome is a closed vocabulary, and consumers legitimately bind to it. The contract text (ADR-0106) froze at three values (succeeded | failed | indeterminate) on 2026-07-25; the wire began emitting cancelled hours later that same day, when the li-kill terminal fix landed, and has shipped it ever since (lionagi/mcp/jobs.py, _OUTCOMES; indeterminate, reserved at freeze, gained its producer when ADR-0107's reaper landed) — an unversioned expansion that ADR-0107's Notes later recorded as a pending ADR-0106 amendment item. This ADR carries that amendment as an ERRATUM with a stated migration policy, and contract_version does not move. Precision the record owes its readers: envelope stamping itself began at 20:11 that same day, so stamped contract_version: 1 envelopes spoke a three-value wire for under three hours on 2026-07-25 and a four-value wire ever since. A bump today was considered and declined: D2's mismatch rule tells a conforming consumer to stop trusting the surface, which is the right medicine when decoding would otherwise go wrong — here the payload shape is unchanged, the only delta is one more value in a closed set, and a bump would cost every current consumer a coordinated update for a change none of them would observe in payload shape. The proportionate remedy is the policy now stated normatively in ADR-0106's correction: consumers built against the three-value text add the cancelled branch, and until they do they treat an out-of-vocabulary outcome the way indeterminate is treated — result not establishable, never success and never failure. The erratum is also a consumer notice, stated plainly there: any consumer exhaustively matching three values has been exposed to an unlisted cancelled since 2026-07-25 22:54. Recording the violation as an erratum with a migration policy keeps D2's rule intact instead of manufacturing an exception to it. partial does NOT join the set either way: widening a closed vocabulary with a genuinely new value breaks every consumer that enumerated it, for the benefit of one producer.

  • Mapping: round completed → job outcome succeeded; round partial or failed → job outcome failed; a round killed before any leg spawned → cancelled. The mapping governs the terminal write a manifest-aware finalizer makes, and only that write: where a terminal outcome already exists when the late-facts pass runs, ADR-0107's terminal latch keeps the first recorded end — including an orphan reap's indeterminate — and the late pass never rewrites it. A reader can therefore observe round completed beside job outcome indeterminate; that is ADR-0107's named succeeded-but-indeterminate window surfacing through the round field, stated here so it reads as a known edge rather than a contract violation. The job outcome answers "what did the first recorded end conclude"; the round summary is authoritative for the round's own facts, and anything finer than the outcome is its job. The required tests include this interleaving: a terminal-latched run whose late pass computes round completed must surface both values unchanged.
  • The read: for manifest runs, job.output's response carries one additive field, round, and its shape is exact. round is an ADR-0106 D7 availability wrapper — {available, value, reason_code, detail} — because it is read-derived and D7 applies to every read-derived field. available: false, with reason_code distinguishing missing from unreadable from malformed, is a read failure of round.json; it is NOT how in-flight harvest is expressed — round_state: "pending_harvest" is data inside a readable summary. value has two parts, matching the two kinds of read behind it: summary is the round.json content verbatim (its legs stay labels, as D3 shows), and leg_records is an array read from {run_dir}/legs/*.json, each entry its own wrapper {label, available, value, reason_code, detail} — one unreadable leg record must not poison the others or masquerade as an absent leg. Consumers that do not know round ignore it; nothing existing changes shape. This is an additive field of the kind ADR-0106 D2 already permits, introduced and specified here rather than smuggled in by implementation — ADR-0106 itself is unchanged by it and contains no round text to look for.
  • The notice is the signal, not the carrier. The terminal-notice payload is unchanged. A notification consumer that needs leg facts performs the job.output read on receipt; the cooperative ordering guarantee (D3) makes that read complete by the time the notice fires, and round_state covers the non-cooperative window honestly.

Consequences

  • The N-briefs round becomes one submission, one wait, one notice, and one job.output read for round result, per-leg outcomes, and all verdict artifacts.
  • The brief-as-contract property becomes structural: nothing between the manifest and the leg can rewrite a brief, and the recorded content hashes make "what did leg 3 actually receive" a first-class, verifiable answer.
  • Briefs stop carrying output-path contortion; sandboxed legs write to an announced in-tree path and the runner does the serving.
  • What becomes harder: the runner takes on a harvest obligation on every terminal path, kill becomes two-stage for manifest runs (a contributor touching job.kill or the reaper must now know the manifest-aware branch), finalization becomes a claimed single-owner step (finalize.lock) every terminal writer must respect, and the harvester must be written as a hostile-input consumer (descriptor-anchored, no-follow, link-count checks) rather than a tree copy.
  • The pending_harvest window is a deliberate admission: on non-cooperative ends, facts can arrive after the terminal status. The alternative — holding the terminal status until harvest completes on a path where the harvesting process may itself be dead — would trade an observable window for an unbounded wait.
  • Reversal costs: D1 (manifest schema) is versioned and extendable; D4's env var name is effectively frozen the day a consumer's briefs reference it — which is why its collision check happens before first merge, not after. D6's additive field is cheap to add and expensive to remove, which is the usual asymmetry of read surfaces.

Alternatives considered

  • N independent agent submissions (status quo) — fully deterministic and available today; loses on N handles, N notices, hand harvest, and per-brief output-path contortion. Remains the correct fallback until this mode lands and is the interim recommendation.
  • Planner fanout with a "do not rewrite the briefs" instruction — would reuse the whole existing surface; loses because a prompt prohibition is a request, not a control, and the failure mode (silently rephrased contract) is exactly the one the round cannot tolerate or even reliably detect.
  • Caller-managed artifact paths in briefs — no runner changes at all; loses because it is the P3 status quo: sandbox-constrained path contortion in every brief, no uniform read surface, artifacts invisible to job.output.
  • A separate collector process that sweeps leg cwds after the round — decouples harvest from the runner; loses because it is a second lifecycle to operate (its own liveness, its own failure states) and it cannot give the harvest-before-notice ordering guarantee without re-coupling to the runner's terminalization anyway.
  • Extending job.status with artifact fields — one read instead of two for pollers; loses because the artifact listing already exists on job.output, status() is deliberately the cheap frequently-polled read, and widening it duplicates a contract consumers already bind to.
  • Adding partial to the closed _OUTCOMES vocabulary — would let the job outcome carry the round result directly; loses because the set is closed precisely so consumers can enumerate it, and every existing consumer's match over four values silently mishandles a fifth. The round summary field is additive instead; ignorance of it is safe.
  • Holding the terminal write until reap-time harvest completes — would make harvest-before-notice unconditional; loses because on the already-dead-parent path there may be nobody to finish the harvest promptly, and an unbounded non-terminal state is worse than an observable pending_harvest window (a caller waiting on terminal would wait on a corpse).
  • Inline manifest object in the MCP call — DEFERRED, not rejected: it would save a temp file for machine-generated rounds, but v1's consumers produce brief files on disk anyway, the file path gives snapshot-and-hash evidence for free, and adding a second input shape later is backward-compatible while removing one is not. The consuming workflow has since confirmed it will use the file path exclusively.
  • Wildcard environment inheritance for legs — one flag instead of named keys; loses because it forwards whatever the submitting process happened to carry (secrets included), makes a round unreproducible from its manifest, and turns the collision check into an unenumerable surface. Named keys with deny-by-default is what the consuming workflow itself asked for. This rejection is about a manifest-level mechanism; the baseline the runner already provides to every job kind is a distinct, pre-existing channel, named in D1 and out of this ADR's scope.

Notes

  • Command and MCP verb naming is an open question for sign-off; the mode ships beside fanout.submit whatever the name.
  • Per-leg environment is IN v1 as D1's env map: the consuming workflow demonstrated the need with named cases and asked for deny-by-default with a closed per-leg allowlist. What contains it: keys are recorded per leg (env_keys), values reach the process only through the environment array (D1), and the reserved-name refusal is a submit-time validation with its own test. The D4 collision check still owns the runner's reserved name.
  • The 30 s kill grace in D3 is a default, not a derivation: it must cover N bounded harvests (256 MiB cap each, local disk) while keeping job.kill meaningful as an interruption; implementations may make it configurable but the default ships as stated.