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 mcpverb surface — the round submits as a job kind and is read back throughjob.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()returnsartifacts+artifacts_state) — but a leg is never told where it is and could not write there if it were.job.statusdeliberately carries no artifact fields; the artifact read is and staysjob.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.modelXORagentat each level. A leg naming either uses its own and ignores both defaults (no cross-level merging of the pair — mergingmodelfrom one level withagentfrom 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.timeoutis 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'senvdict handed toPopen) — 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, andLIONAGI_LEG_ARTIFACTSis accordingly refused here at submit. Declared keys are listed in the leg's durable record asenv_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. roundis never a submit-side noun. A caller submits a manifest and gets a run; the wordroundappears only on the observation side, where it names facts about a run that already exists (round_state, the round summary, theroundfield D6 adds tojob.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-sideroundinvites 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.timeoutis 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 recordscancelled. Around_timeoutfield 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: completeis 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.lockfor 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 andround.json'sround_stateunder 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 andcomplete— release, nothing is owed; nonterminal butcomplete— 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:completeis published only after a proved-quiet sweep); terminal butpending_harvest— the late-facts pass (below), whose sequence is exactly the unfinalized path minus its last step: quiescence sweep of every recorded group first (completeis never published before one), then harvest, then records, then theround_stateflip — 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 norcomplete— 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 rolerunner/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 andround.jsonwrite lands by temp-file-plus-atomic-rename, and a writer that finds a complete leg record already present leaves it — first write wins,recorded_bynames 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.killdelivers 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.jsonis written withround_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.killis two-stage for manifest runs. The current kill path writesstatus: "killed"andfinished_atimmediately 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 recordskill_requested_atand 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 andround_state. A round foundcompletemeans 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 publishingcompleteand 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 rawos.killpgprimitive, 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: completeis 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-timepgidcapture 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 aftercomplete; 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.outputserves 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 afterround_state: complete— or survives it without that leg's record naming a failed removal (D4 removes every harvested scratch beforecompleteon 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_failedwith a reason where a scratch is unreadable — never an empty artifact list), writeround.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
completeis 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 flipsround_statefrompending_harvesttocomplete— 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_statenames 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-openlstat(device+inode); a mismatch recordsskipped_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 recordskipped_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_failedwith 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.statusstays 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 outcomesucceeded; roundpartialorfailed→ job outcomefailed; 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'sindeterminate— and the late pass never rewrites it. A reader can therefore observe roundcompletedbeside job outcomeindeterminate; 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 roundcompletedmust surface both values unchanged. - The read: for manifest runs,
job.output's response carries one additive field,round, and its shape is exact.roundis 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, withreason_codedistinguishing missing from unreadable from malformed, is a read failure ofround.json; it is NOT how in-flight harvest is expressed —round_state: "pending_harvest"is data inside a readable summary.valuehas two parts, matching the two kinds of read behind it:summaryis theround.jsoncontent verbatim (itslegsstay labels, as D3 shows), andleg_recordsis 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 knowroundignore 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 noroundtext 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.outputread on receipt; the cooperative ordering guarantee (D3) makes that read complete by the time the notice fires, andround_statecovers the non-cooperative window honestly.
Consequences¶
- The N-briefs round becomes one submission, one wait, one notice, and one
job.outputread 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.killor 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_harvestwindow 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.statuswith artifact fields — one read instead of two for pollers; loses because the artifact listing already exists onjob.output,status()is deliberately the cheap frequently-polled read, and widening it duplicates a contract consumers already bind to. - Adding
partialto the closed_OUTCOMESvocabulary — 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_harvestwindow (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.submitwhatever the name. - Per-leg environment is IN v1 as D1's
envmap: 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.killmeaningful as an interruption; implementations may make it configurable but the default ships as stated.