- Status: Accepted
- Date: 2026-08-02
The graph vocabulary has nodes and one kind of edge: depends_on, the
success-flow edge — "run me after my parents passed". Failure has three
answers today, and all three are the wrong shape for the most common
iterative pattern in real pipelines:
retry(node × attempt) re-runs the same node with the same inputs. It recovers transient faults; it cannot express "the work an earlier node produced was judged wrong, redo it". (Amended 2026-08-06 by ADR 0020: a retry's inputs are still the same, but its PROMPT now carries one fenced quote of the attempt it repeats when a check judged that attempt. The sentence above stays true of what retry is FOR — it still re-runs one node against unchanged upstream artifacts, which is exactly what a feedback arc exists to change.)on_fail(graph policy) decides what the run does once a failure is final — halt or prune the subtree and continue. Both discard the branch; neither recovers it.resume(run level) continues a stopped run across processes. It is a boundary between legs, not a control-flow construct.
What none of them can say is the sentence every review-driven pipeline is built around: "if this node fails, re-run that earlier node with this node's output as feedback, then come back — at most N times."
The shipped adr-driven-dev.yaml template is the workaround made flesh: its
round1 → apply1 → round2 → apply2 → round3 chain is a review loop
statically unrolled to a fixed depth. The unrolling has every defect of
manual loop unrolling: the iteration count is baked in (a clean first round
still pays for rounds two and three's sessions; a third round of findings has
nowhere to go), the apply prompts are near-duplicates, and the graph's length
hides its actual shape — a two-node loop — from the reader.
The constraint that shaped everything below: the static graph must stay a
DAG. Graph.ReadyGiven, the three-colour cycle validator, the snapshot's
"graph × completed determines the ready set" resume rule (ADR 0003), and the
inline-adjacency design ("edges are not a separate list; each Node carries
its own DependsOn") all assume an acyclic depends_on relation.
Iteration is a runtime phenomenon and must be represented as one — the
file the validator walks stays acyclic and fully checkable at load time.
A node — typically a reviewer or verifier — declares the arc that fires when it fails:
- id: impl
depends_on: [adr-apply]
prompt: |
Implement the ADR (task: {{ inputs.task }}).
Review feedback from the previous round follows — empty on the
first pass:
{{ feedback.review }}
...
- id: localrun
depends_on: [impl]
...
- id: review
depends_on: [localrun]
agent: code-reviewer-deep
success_check: { exit_zero: true, result_matches: "ready to merge" }
feedback:
rerun: impl # a proper depends_on-ancestor of this node
max: 2 # REQUIRED — bounded iteration on a paid runtimeReading: when review fails for a judgment cause (verify_failed,
result_mismatch, nonzero_exit — the fixed built-in trigger set; see
Semantics, infrastructure and spend faults fail final immediately), after
its own retries if any, the engine
re-runs the path impl → localrun → review, handing review's output to
the re-run as {{ feedback.review }} — at most twice. If review still
fails after the second round, it fails for real. This subsumes the
adr-driven-dev unrolling: round1/apply1/round2/apply2/round3 collapses to
one review node with feedback: { rerun: impl, max: 2 }, the apply prompts
folding into impl's own prompt via the feedback placeholder — same maximum
depth, but a clean first round now costs one review session instead of five
nodes, and a graph reader sees the loop instead of reconstructing it.
Update (2026-08-05): that collapse describes what the construct can express, not a conversion that was performed.
graphs/adr-driven-dev.yamlat v0.4.1 still declares eleven node ids withround1/apply1/round2/apply2/ round3intact and carries nofeedback:block at all. See the note in Consequences for what shipped instead.
Why this shape over the alternatives (argued in full below):
- The declaration is inline on a node, like every other edge in this
schema.
depends_onalready established that edges live on the node that needs them, not in a separate edge list; the failure edge lives on the node that closes the loop, with its bound adjacent to it. - The static graph stays untouched.
feedback.rerunis stored outsideDependsOn, soReadyGiven,DependsOn-walking, cycle validation, the JSON snapshot round-trip and every existing consumer are unchanged. The arc points strictly backward along already-validateddepends_onpaths (enforced at load), so it cannot introduce a static cycle even in principle — it annotates the DAG, it is not an edge in it. - The name is not
on_fail. Candidate (a) in the design discussion spelled this blockon_fail: { rerun: ..., max: N }, but the graph-levelon_fail:already means something on the other side of the finality line: what the run does once a failure is final. This block acts before finality — it is a recovery arc, kin toretry, not a post-mortem policy. One keyword meaning "give up policy" at one indentation and "don't give up yet" at another would be a standing misreading.feedbackalso names the payload: the failing node's output is the feedback, and the{{ feedback.<id> }}namespace ties the edge, the placeholder and the prompt narrative to one word.
Load-time validation (all in internal/graph, all before anything spends):
rerunnames an existing node that is a properdepends_on-ancestor of the declaring node. This one check rules out self-loops, forward arcs, and arcs between unrelated branches, and guarantees the runtime arc closes over a path the static DAG already contains.maxis required and ≥ 1. There is no default: an unbounded loop on a paid runtime must be unrepresentable, not merely discouraged (same posture as the verify-timeout ceiling — refuse at load what would only be discovered as spend at run time).- The loop body is side-exit free. The body is the between-set — every
node on any
depends_onpath from the target up to (and including) the declaring node. Every dependent of a body node other than the declarer must itself be in the body: a node outside the loop consuming an intermediate artifact would observe whichever iteration happened to have written last, a race with no right answer. Rejected at load, not discovered as nondeterminism. (Parents feeding into the body from outside are fine — they ran once, their artifacts are stable.) - No gate in the body. A gate records a standing human decision
(ADR 0003); replaying it on iteration two via
RecordedControllerwould silently re-approve a round the human never saw. If a per-iteration human check is ever wanted, that is its own ADR. - A
{{ feedback.<id> }}placeholder is only legal where it can ever resolve: on a node inside the body of a feedback edge that<id>declares. This is a load error, not an advisory lint —LintPlaceholderswarnings never affect validity, and an out-of-body{{ feedback.<id> }}would otherwise resolve to the empty string silently, forever: the exact invariant-weakening this ADR rejects| optionalfor. The same change must teach the placeholder pattern and the interpolation kind table (internal/handoff) the new namespace together, or the token ships verbatim into a paid prompt. - A body node with
handoff: sessionmust name a session-parent that is itself in the body. An out-of-body session-parent would make round 2's--resumecontinue a session round 1 already continued — contradicting the fresh-session rule in Semantics. - Feedback bodies are disjoint: no node may lie in the bodies of two
feedback edges (a node already declares at most one arc — one
rerun, onemax). Overlapping or nested bodies multiply worst-case spend as(1 + max₁) × (1 + max₂)and give the re-arming path two owners for one node; v1 rejects the shape at load. Lifting this later is additive.
Update (2026-08-06) — there is no rule 8 for fan-in coverage; it is an advisory. These seven rules all judge a fan-in declarer's arc valid even when its body excludes a producer the declarer judges. Issue #118 is that gap paid for: a reviewer fanning in from
qa-planandload-scriptwithrerun: load-scriptfound five defects, all inQA-PLAN.md, and re-ran the healthy branch to exhaustion — the file it needed to repair was not in the body, so the loop was structurally incapable of converging (~$14 of a $42 run).The obvious eighth rule — every producer the declarer depends on must be in the body — was considered and rejected as a load error, because it is not sound:
- It contradicts rule 3's own carve-out. "Parents feeding into the body from outside are fine — they ran once, their artifacts are stable" describes a legitimate and common shape: a reviewer reading a settled spec, criteria or corpus node alongside the work under review. Rule 8 would refuse it.
- It contradicts rule 4. A gate may never be in a body, so a fan-in declarer with a gate parent could satisfy neither rule — an unsatisfiable pair, not a safeguard.
- It is not decidable in the direction that matters. The engine sees
depends_on; it cannot see which artifacts a prompt will judge. The #118 reviewer named both files by literal path (stg-canary/QA-PLAN.md), so even a{{ artifacts.<id> }}scan would have stayed silent.What ships instead is
graph.LintFeedbackReach: an advisory sweep, printed bylintandrun --dry-run, that names the declarer, the rerun target, the unreachable producer and — when one exists and still passes this validation — the covering target to aim at (rerun: scopeon #118's graph). It fires only on a fan-in declarer with an arc, which no shipped graph is; a single-parent declarer is covered by construction.The first two objections above are answered in the sweep itself rather than left as accepted noise, because an advisory that cries wolf on the common shape is one people learn to scroll past. It skips a gate parent (rule 4), and it skips a parent that is an ancestor of the rerun target — rule 3's carve-out stated in topology, since such a parent is upstream of the whole loop and the loop re-runs its consumers. That keeps
spec → impl → reviewwithrerun: implquiet, which matters more than the false positive it removes: the target the sweep would otherwise have advised,rerun: spec, re-runs the acceptance criteria every round and re-judges the implementation against criteria that just moved underneath it. Bad advice on the most idiomatic review graph there is.The third objection cannot be answered this way and is not: a sibling corpus root is topologically indistinguishable from #118's sibling work, so false positives remain by construction. That is why the emitted message carries its own limits — it states that it reads
depends_onand not which artifacts the prompt judges, and says to ignore it if the producer is stable context, rather than asserting that the defect can never be repaired. Note that one half of the bug was always a load error and remains one: a producer left outside the body that asks for the payload via{{ feedback.<id> }}is refused by rule 5. The advisory covers the producer that never asks.The other half of the fix is not validation at all. The planner prompt (
internal/coordinator) describes only the linear implement→review shape, so with several implementing nodes it picks one and the arc silently loses the rest; teaching it to aim at the nearest common ancestor when the reviewer fans in is where a mis-aimed arc stops being written in the first place. That teaching is a planned follow-up and is NOT shipped here — this ADR's change is the advisory alone. (Shipped 2026-08-06 — see the next amendment.)
Update (2026-08-06) — a PLANNED arc that misses a producer is refused, and the planner is taught the rule. The follow-up above shipped, and it shipped as both halves: the planner prompt now carries the fan-in rule, and
coordinator.validatePlannedFeedbackReachenforces it.The three objections that made an eighth load rule unsound are objections about a graph a HUMAN wrote. Reread against planner output, two of them dissolve and the third is priced:
- "Refusing would break working hand-written graphs." There is no author here to have meant the shape. An
autoplan is written, saved tograph.jsonand run in one breath; nobody runslinton it, so an advisory printed at plan time is an advisory nobody weighs.- "A refusal costs the whole plan." It costs one planner call. The reply is untrusted and already faces the whole field-disposition ceiling, and since the bounded re-plan (
repair.go) a refusal buys a fresh corrected attempt carrying the validator's own sentence — which names the target to aim at, so the correction is mechanical rather than a guess.- "The engine sees
depends_on, not which artifacts a prompt judges." Still true, and not answered. It is paid for instead, by making the rule strictly narrower than "every producer must be in the body": auto mode refuses only the advisories carrying a covering target — one that covers every producer and still passes both feedback rule sets. So the two-independent-roots shape, where a sibling corpus root is genuinely indistinguishable from #118's sibling work, is never refused: no target covers two roots, there is no correction to ask for, and burning the one re-plan on a shape no aiming of the arc can fix would lose a plan the user paid for. The rule refuses only where it can also say what to do.A legitimate stable-context parent stays expressible in both of its shapes. The idiomatic one —
spec → impl → reviewwithrerun: impl— never reaches the refusal at all, because the sweep's ancestor-of-the-target skip (above) keeps it out of the advisories. The other one, a context node beside the work under a shared ancestor, IS refused, and that residual false positive is accepted knowingly: the refusal names its own second exit — a spec the work is judged against belongs upstream of the work, so having the implementer depend on it satisfies the check and is the better plan besides. The cost of being wrong there is one planner call; the cost of silence was measured at $14.The topology is computed once.
validatePlannedFeedbackReachconsumesgraph.LintFeedbackReach's advisories — including the covering target it had already re-validated before offering — and decides only the disposition. Two computations of one rule drift, and this repository has paid for that once already (a dashboard card reducer that re-implementedrunfeed.InFlight), so the advisory grew aSuggestionfield rather than the coordinator growing a second opinion.
What re-runs: the path, not just the target. When the arc fires, every
body node re-executes in dependency order — impl, then localrun, then
review re-judges. Re-running only the target would be cheaper but wrong
twice over: the intermediate nodes' artifacts would be stale (a localrun
verdict about iteration 1's code, feeding iteration 2's review), and the
declaring node itself must re-run or nothing re-judges the new work at all.
The between-set is also the smallest correct set: descendants of the
target that do not lead to the declarer are outside the loop (and rule 3
guarantees there are none inside the affected region), so the blast radius
is exactly the segment the graph author drew, not the target's whole
subtree.
Mechanically this is a fourth intercepted signal beside pauseSignal,
limitSignal and rejectSignal: the declarer's judgment failure, with
rounds remaining, surfaces as a feedback signal; the scheduler clears the body
nodes' completed status, re-seeds their in-degrees counting only in-body
parents (out-of-body parents stay satisfied; their artifacts are still on
disk and still interpolate), and launches the target. Independent branches
are untouched, and a pause elsewhere (gate or session limit) suppresses the
re-launch exactly as it suppresses dependents.
What the re-run inherits — and the iteration-1 problem. The declaring
node's output must be readable by the re-run, but on the first execution
of the target it does not exist yet — under today's rules
{{ artifacts.review }} in impl's prompt would be an
InterpolationError before iteration 1 ever ran. The answer is a distinct
namespace with a documented empty default:
{{ feedback.<id> }}always inlines the declaring node's feedback payload: its result text when the failing execution produced one, else its failure detail. There is no path form and no| inlinefilter — a path grammar would need an on-disk file to point at before any round has fired, and an empty string standing in for a path is exactly the kind of half-value the artifact contract exists to forbid. The engine persists the payload to<run-dir>/feedback/<id>.out(overwritten per round, latest wins) as an internal implementation file, not a documented contract — promoting it later is additive, demoting it would not be. The.outartifact keeps meaning "a passed node's result" and dependants'{{ artifacts.<id> }}contract is untouched.- When no round has fired yet, the placeholder resolves to the empty string. Prompts write around it naturally: "review feedback follows — empty on the first pass".
The alternative — an | optional filter on ordinary artifacts — was
rejected: "an unresolvable reference is an error, never a silent empty
substitution" is a load-bearing property of the artifact contract (it is
what turns a mis-wired graph into a loud failure), and weakening it
globally to serve one feature would trade a universal guarantee for a local
convenience. The feedback namespace confines empty-is-normal semantics to
the one place where "not there yet" is an expected state rather than a
wiring bug.
What fires the arc: judgment causes only. The point where the arc is
considered is where recordFail would otherwise be reached — after the
declarer's own retries are spent — but not every cause that lands there
may fire it. The built-in trigger set is fixed to the three judgment
causes: verify_failed, result_mismatch and nonzero_exit — the causes
that mean "the node ran and judged the work insufficient". Everything else
that reaches recordFail — an InterpolationError, a
MissingToolPolicyError, a runner spawn error, budget_exceeded — is an
infrastructure or spend fault that re-running the body cannot repair: a
mis-wired {{ artifacts.x }} in impl would otherwise re-run the whole
body max times with zero chance of success, at full cost — the opposite
of this ADR's own "refuse at load what would be discovered as spend"
posture. Those causes fail final immediately, feedback arc or no. A
user-facing on: filter stays deferred (Alternatives); the built-in set
is not configuration, it is the definition of what a feedback edge is for.
Composition with retry, and exhaustion. retry operates inside one
execution of a node, exactly as today; the feedback arc fires only after
the declarer's retries are spent, and only for the judgment causes above.
When the arc has fired max times and the declarer fails again — or when
it fails for a non-judgment cause at any point — the failure becomes
final: the node FAILs with a detail naming the spend
("feedback exhausted after 2 rounds of impl → review: " + the underlying
cause), and from there the existing story runs unchanged — graph on_fail
halts or prunes, and a later resume --retry-failed may still salvage the
run. Salvage means re-arming the loop, not re-running the declarer
alone: partitionForRetry learns the feedback shape, so clearing an
exhausted declarer's FAIL also clears its body's retained PASS records and
resets the rounds budget — explicit human intervention buys a fresh set of
rounds. Retaining the body's PASSes would relaunch the declarer alone
against unchanged artifacts, exactly the target-only shape Alternatives
rejects. The four mechanisms compose as one narrative: retry answers "try
me again", feedback answers "the decision upstream was wrong, redo the
segment with what we learned", on_fail answers "what does the run do when
we truly give up", resume answers "continue a stopped run later".
Sessions and worktrees. Every re-executed body node starts a fresh
claude session — the cold-start rule retry already documents ("retry
started fresh — parent session not resumed") applies verbatim to feedback
re-runs, and the same ledger detail note marks it. An in-body
handoff: session child still resumes its parent's session of the current
round (the parent re-ran first and recorded its new id), so session chains
inside the body keep working; no re-execution ever resumes a superseded
round's session — and validation rule 6 guarantees the parent is in the
body, so no node exists whose round-2 --resume would target a session an
earlier round already continued. Re-runs share the lane's worktree:
Acquire is idempotent per name, so iteration 2's impl lands in the same
checkout with iteration 1's commits present — which is the point; the
feedback round amends the work, it does not restart it. That sharing is a
within-leg claim: GitManager's created-set is in-process state. A run
stopped mid-loop and resumed provisions worktrees exactly as resume always
has — fresh checkouts, with a branch an earlier leg retained colliding
loudly on the ref rather than being silently reset
(cmd/oh-my-graph/resume.go); a feedback body changes nothing about that
rule.
events.jsonl narrates rounds additively — and node_failed stays
terminal. No new event type (the event-type set is closed per schema
version; a bump for this would tax every consumer for what existing
vocabulary conveys — the ADR 0009 precedent). But no existing type's
meaning moves either: RUN-FEED's own rules count a meaning change as a
schema bump, and node_failed is documented as terminal. The
"multiple terminal events per node id, latest authoritative" precedent
from retry is a cross-leg rule; the in-leg vocabulary for "a failed
attempt that is not final" already exists — node_retried — and a
feedback round reuses it:
- The declarer's non-final judgment failure emits
node_retried, notnode_failed, with adetailof the form "feedback round 1/2: re-running impl → review", so a tailing consumer sees the loop turn without correlating anything.node_failedappears at most once per declarer per leg: when the failure is final. - Node events emitted by a feedback re-execution carry an optional
roundfield (1-based round ordinal; absent on the initial pass — absent means 0, like every other omitted zero value). Body nodes therefore emit one full started→terminal sequence per round within a leg; RUN-FEED gains a sentence extending latest-authoritative to in-leg rounds, in the same change that addsround.
The ledger prices every execution; the snapshot records only what is
real. Each round's execution of each body node appends its own ledger
row (with "feedback round k/N" in the detail), and the run total is the
sum. Aggregating rounds into one row was rejected because the ledger's one
job is the cost story, and the entire risk of a loop construct on a paid
runtime is the multiplier: an operator tuning max needs to see that
round 2 cost what round 1 cost, per node, in the same table that shows
everything else.
The snapshot is where non-final and final part ways. recordFail today
does four things at once — progress line, ledger row, snapshot FAIL
record, node_failed event — and a non-final round keeps only the first
two: a runstate FAIL written for a declarer mid-loop would make it
settled the moment anything stops the run, silently collapsing the loop
into an ordinary failure on resume. Instead:
- Snapshot node records gain an optional
roundfield (additive; absent means 0). Every body-node record written during round k carriesround: k, and a node's recorded spend accumulates across its rounds — a superseded record's cost carries into the record that replaces it — sostate.json's per-node figures sum to the same total the ledger shows. "Cost stays honest" holds for the contract fleetops reads, not just the local table. - Arc-fire is itself durably recorded: in the same step that re-arms the
scheduler, the declarer's record is rewritten as a non-terminal
feedback marker — round k, no verdict. A leg stopped mid-loop
therefore resumes into the loop, not out of it: the marker plus the
graph (the body is derivable from
rerun→ declarer) tells resume that body records withround < kare superseded and drop out of the completed set, records withround = kare retained, andmax − krounds remain.ReadyGiventhen relaunches exactly the unfinished remainder of round k. No separate declarer→rounds-spent map is needed — the recorded position is the counter (Alternatives).
maxis required, ≥ 1, no default, refused at load (above). The worst-case execution count is legible from the file:(1 + max) × |body|node runs per arc — and because bodies are disjoint (rule 7), multiple arcs add their worst cases; the(1 + max₁) × (1 + max₂)blow-up of overlapping loops is unrepresentable. Each run is under its own declaredtimeout,budget_usdand tool policy per execution — the ceilings apply per attempt, so the multiplier is bounded but real, and the docs must say so the way adr-driven-dev's header prices its rounds today.- Planned (auto) graphs:
feedbackis allowed, withRetry's standing. The field-disposition table (coordinator/field_dispositions_test.go) already forces this decision mechanically — adding the field tograph.Nodefails the completeness test until a row exists. The row reads likeRetry's ("bounded re-runs of an already-ceilinged node"), because the disposition framework judges capability, not spend: a feedback arc grants a planned node no tool, no path, no shell — it only repeats nodes that are already inside every ceiling layer, and the requiredmaxplus the load validations (backward-only, side-exit-free, no gates) hold for a planned graph exactly as for a hand-written one. Going stricter (rejecting it) would forbid the planner the one construct this ADR exists to provide while permitting the same spend shape viaretry: { max: N }— an inconsistency, not a safeguard. So plannedfeedbackreceives the same treatment, decomposition and budget posture asRetry: the planner prompt gains guidance (use it for review loops; keepmaxsmall) instead of the field being banned. The "keep it small" guidance is also enforced:coordinator.validatePlannedNodeFeedbackrejects a plannedfeedback.maxabovemaxPlannedFeedbackRounds(3) — a hand-written graph may declare any bound it is willing to pay for, but an unreviewed plan gets a fixed ceiling. - Worktrees: re-runs share the lane's worktree (stated above; the
idempotent
Acquiremakes it free). - Sessions: a re-run is a fresh session, like a retry (stated above; the already-documented cold-start rule applies).
Update (2026-08-10) — a narrowed review check and an arc are ONE guardrail, not two independent choices (issue #151). The seven load rules and the four guardrails above all judge an arc that exists. What this amendment adds is the arc that is missing, on a node whose verdict check was narrowed to make one necessary. No decision above changes; this is an application of the construct, plus the price rule 3 turns out to charge.
The shipped review fragments (
review-style,review-security) pass on both of their verdicts —CLEANandFINDINGS:— because a review's job is to judge, not to be clean. Nothing else in the engine reads a verdict:depends_onis a success edge, and all three "the work was rejected" mechanisms —retry, this ADR'sfeedback, andon_fail— hang off FAILURE. So a passingFINDINGS:reaches none of them, and every node below the review runs regardless, aprnode included. A graph that wants findings to stop it therefore has exactly one spelling available: narrow the review'ssuccess_checkon its own node untilFINDINGS:fails.That narrowing alone is a defect, and this ADR's construct is what repairs it. Without an arc, the run that most deserves to be believed — the one where the reviewer did its job and found something — is the run that reports FAIL, the whole pipeline discarded and nothing repaired. This project already settled the principle for merge-shepherd's
WITHHELD: a deliberate refusal must not read as a broken run. With the arc, the same failure is a repair round — the implementing node re-runs with the findings in{{ feedback.<id> }}, a second round that comes back clean ends the lane green with the defect fixed rather than merely quoted, and only an exhausted loop is final, which is an honest report of a finding nobody repaired. Ship the pair or neither.The narrowing does not break the two-halves verdict convention (DESIGN.md, "Verdict patterns"). Both halves still hold for the token the node accepts: the fragment prompt demands a bare
CLEANfirst, names`**CLEAN**`as wrong and offers the qualifier its place, and the narrowed pattern carries the decoration class. What the narrowing states is which verdict the caller accepts, not which verdicts the reviewer may emit — a different axis, so the two do not drift together. The prompt is deliberately not told thatFINDINGS:will fail the node: a reviewer that knows the cost of finding something is a reviewer biased towardCLEAN.That bias has a second door, and it is
retry:on the gating node — worth naming because the prompt vector is the obvious one and this is not. A retried attempt is handed its own previous reply under "that attempt did NOT pass: the engine ran your reply against this node's success check, the check did not accept it" (retryFeedbackTemplate,internal/schedule/retryfeedback.go), which on a gating review is the withheld sentence delivered anyway, in the one place the reviewer cannot miss it. So a gating review declares the arc and not a retry: a rejected review is work for the implementing node, not a second opinion solicited from the same reviewer with the first one quoted back as a failure. Lane A's review node says so at the node; nothing enforces it, because a retry is legitimate on every non-gating review and the engine cannot tell them apart for the same reason it cannot tell the narrowed patterns apart.Not an eighth load rule, for the reason the fan-in amendment above gives: it is not decidable in the direction that matters. A narrowed
result_matchesis the ordinary shape of every strict verdict in the repo —e2e'sPASS,merge-shepherd's three-valuedrecheck— and none of those owe an arc. The engine cannot see that this narrowed pattern is a reviewer's findings being refused. Where the intent IS knowable isgraphs/, at the point a node splices a review fragment, so the rule is enforced there:TestAGatingReviewCarriesItsRecoveryArc(internal/graph) matches a realisticFINDINGS:reply against the effective pattern of every node that used areview-*fragment and requires afeedback:block on each node whose pattern rejects it — judged by behaviour, not by authoring form, and failing loudly if no shipped graph gates any more so the test cannot quietly start asserting nothing. That the test is load-bearing was measured rather than assumed: strip lane A's arc and its{{ feedback.review-a }}placeholder and the half-pair graph islint-valid — no load rule sees it — while the test names it. (Stripping the arc alone is already refused, but by rule 5's placeholder check, which is a different guarantee: it catches the prompt that asks for a payload nothing produces, not the review that rejects findings with nowhere to send them.)The pair lives in the calling graph by construction rather than by convention: ADR 0013 counts
feedbackas graph-local wiring (fragmentWiringFields), so a fragment may not declare it, and therefore cannot gate on its caller's behalf even if it wanted to.Applied, and priced.
backlog-batch's lane A is the second shipped arc afterreview-loop's, and the first inside a lane:feedback: { rerun: dev-a, max: 1 }over the bodydev-a → e2e-a → review-a,(1 + 1) × 3 = 6body runs — which is the ROUND count, not the execution count:e2e-ainheritsretry: { max: 1 }frome2e-verify, and a retry is charged on top of its round, so the worst case is 2dev-a+ 4e2e-a+ 2review-a= 8 executions. Lane A is the first shipped body to contain a retrying node, which is why the formula needed a qualifier it never did inreview-loop, and the file's header now prices it the way this ADR'smaxguardrail asks.max: 1, not 2 — an unattended batch buys one repair round; a second belongs to a human who has read the first.And rule 3 charges a measured price.
dev-review-prandself-devcannot carry an arc at all. Their two reviews fan out of onee2e, so any body reaching a review fromdevcontainse2ewhile the sibling review depends one2efrom outside it, and the loader refuses the graph:feedback body has a side exit: "review-security" depends on body node "e2e" but is outside the loop— measured on both templates, 2026-08-10, and aimingrerunate2einstead trips the same rule plus rule 6 one2e'shandoff: session. Accepted, not worked around: serializing the two reviews to make room for one arc would spend the parallel fan-in those templates exist to demonstrate, and would still leave the other review ungated. A third shape was on the scale too, and it is already shipped rather than hypothetical:adr-driven-dev's unconditionalround1 → apply1repair node — the chainreview-loop's header already names as the one an arc hand-unrolls. An apply node between the fan-in andprwould repair findings before the PR with the parallel reviews left intact and no arc at all — so repairing findings is not one shape. It is rejected here on price and on intent, not on structure: it pays a node on every clean run where the arc costs nothing unless the review fails, and for these two templates the draft PR is the intended human touchpoint, so findings reaching a reader is the design. Both stay advisory by recorded choice, their pipelines ending at a pull request whose body carries the findings to the human making the merge decision. This is the negative trade-off below — "the side-exit-free rule rejects some legitimate-looking graphs" — meeting shipped templates rather than a hypothetical, and the consolation the bullet claims ("the fix is mechanical") does not apply here. What does hold is that the refusal is at load, before any spend.
Positive
-
The review loop — the single most common iterative shape in real pipelines — becomes one declared arc instead of a hand-unrolled chain. adr-driven-dev's eleven nodes reduce to seven with greater fidelity: a clean first round stops early, and every round's findings actually flow into the re-implementation instead of only rounds the unrolling anticipated.
Update (2026-08-05): this consequence is written in the accomplished tense and the conversion it describes was never performed. At v0.4.1
graphs/adr-driven-dev.yamlstill has all eleven node ids — theround1 → apply1 → round2 → apply2 → round3unrolling this ADR's Context names as its founding complaint is intact — and the file contains nofeedback:block. Nothing reduced to seven.What actually shipped is the arc itself, in a new template:
graphs/review-loop.yamldeclaresfeedback: { rerun: impl, max: 2 }on its review node, which is the shape this bullet predicted, demonstrated on a graph written for it rather than migrated to it. The mechanism is therefore proven; the migration of the pre-existing template is outstanding work, not a delivered consequence.The prediction is left standing rather than rewritten — it is the decision's reasoning, and it may still be carried out. But a reader comparing this ADR against the repo would otherwise conclude the ADR or the template had silently regressed, when neither did: the conversion was simply never done.
-
The static graph remains a DAG and every existing consumer — validation,
ReadyGiven, resume, the snapshot round-trip, fleetops — is untouched or extended only by optional fields. No schema bumps. -
The failure-handling story becomes complete and layered: retry (same node), feedback (path), on_fail (run policy), resume (across legs) — each answers a different question, none overlaps.
-
Cost stays honest: bounded by construction, priced per execution in the ledger, narrated per round on the stream.
Negative / trade-offs
- A third failure keyword (
retry,feedback,on_fail) is real surface area; the docs owe the reader the one-paragraph layering above, or the three will read as synonyms. - The side-exit-free rule rejects some legitimate-looking graphs (a metrics
node hanging off
impl, say) that would be fine in practice most of the time. Accepted: "most of the time" is what nondeterministic artifact races look like from the outside, and the fix (move the tap after the declarer) is mechanical. - The scheduler grows its first re-arming path — clearing completed status
and re-seeding in-degrees mid-leg. It is the largest engine change since
gates, and it must be built and stress-tested against
FakeRunnerfixtures (loops racing pauses, limits mid-round, continue-on-fail around a looping branch) before it can ship. - Feedback payloads persist failed-execution output to disk
(
feedback/<id>.out) — a new file under the run directory, internal for v1 (Semantics): promoting it to a consumer contract later is additive, demoting it would not be. - The documentation lands with the code, not after it: DESIGN.md (the new
feedback:keyword, the fourth intercepted signal, the new run-dir file) andinternal/ledger's package doc — whose "one row per node" becomes "one row per execution" — must be updated in the same change that implements this ADR; code and DESIGN.md drifting apart is a bug in both. - Rounds multiply spend by design.
maxbounds it, but a carelessly largemaxon an expensive body is the closest thing this tool has to a foot-gun; the shipped templates must model small values (2, not 10).
- Node-level
on_fail: { rerun, max }(candidate a's spelling). Rejected on the name alone; the mechanics are what this ADR adopts. The graph-levelon_failis a post-finality policy (halt/continue); reusing the keyword for a pre-finality recovery arc would make one word span both sides of the finality line, differing by indentation. - Edge-level conditions (
depends_onentries withwhen:). Rejected. It turnsDependsOn []stringinto a list of objects, churning every consumer of the field (ready-set computation, dependents walking, session-arity validation, the JSON snapshot round-trip) for a feature none of them needs; and it decomposes the one sentence being expressed — "go back there, with this, at most N times" — into conditions scattered across edges that the reader must reassemble into a loop. Conditional success routing may someday want edge conditions; failure recovery is not that feature. - A
loop:block declaring a bounded subgraph cycle. Rejected. It introduces a second topology construct that overlaps the first: nodes would belong to both adepends_onDAG and a loop membership list, and validation would have to prove the two agree — precisely the two-sources- of-truth bug class the inline-adjacency design exists to avoid (ADR 0003 rejected persisting ready sets on the same grounds). It also misstates the phenomenon: the engine is a ready-set scheduler, not a structured- control-flow interpreter, and iteration here is a failure-recovery arc, not a while-loop. Aloop:block's natural evolution (conditions,while:) points directly at the unbounded iteration this design makes unrepresentable. - Re-run only the target, not the path. Rejected: leaves intermediate artifacts stale and never re-judges the new work; argued in Semantics.
| optionalon ordinary artifact placeholders instead of a feedback namespace. Rejected: globally weakens the "unresolvable is loud" artifact invariant to serve one local need; argued in Semantics.- Aggregate rounds into one ledger row. Rejected: hides the loop's cost multiplier — the one number the bound exists to control; argued in Semantics.
- A new
node_feedbackevent type. Rejected: the event-type set is closed per schema version, so it forces a schema bump on every consumer for what one additive optional field (round) plus the existingdetailalready convey — the same reasoning that rejectednode_limitedin ADR 0009. - A non-final
node_failedon the event stream. Rejected: RUN-FEED documentsnode_failedas terminal and counts a meaning change against the schema version; the retry precedent for repeated terminal events is cross-leg only.node_retriedalready means "a failed attempt that is not final" in-leg; argued in Semantics. - A separate declarer → rounds-spent map in
state.json. Rejected in favour of the per-recordroundfield: a counter records how many times the arc fired but not where the loop stood, which is what a mid-loop resume actually needs — and once records carry their round, the position implies the counter, so the map is redundant surface. - Path-form
{{ feedback.<id> }}(artifact grammar with| inline). Rejected for v1: before any round fires there is no file for a path to name, and an empty string standing in for a path is a half-value the placeholder contract exists to forbid. Always-inline confines the empty-is-normal semantics to prompt text; a path form can be added later if a payload ever outgrows a prompt. - Escalating the fan-in reach sweep into a load error on the subset where
the declarer interpolates the unreachable producer's artifact. Rejected
2026-08-07 (issue #118 follow-up).
LintFeedbackReachis advice because it readsdepends_on, not which artifacts a prompt judges; the proposal was to carve out a subset where that gap closes —{{ artifacts.P }}in the declarer's prompt proves it reads P — and refuse those at load. The subset is mechanically computable, but it is not a safe load-error criterion: it is neither sound nor complete for the defect.{{ artifacts.P }}proves the reviewer READS P; the defect requires the reviewer to JUDGE P, and the graph has no token separating "read as yardstick" from "read as thing measured". The counterexample is ordinary:scope → {criteria, impl} → reviewwithrerun: impland a reviewer that judges{{ artifacts.impl }}against{{ artifacts.criteria }}. It satisfies the predicate, it is correct, and the target the advisory names —rerun: scope— would rewrite the acceptance criteria every round, the same actively-harmful advice the ancestor skip above exists to prevent. Worse, the subset selects adversely: #118's own reviewer named its files by literal path, so the escalation would not have fired on the bug that motivated it. Pinned as a negative regression test (TestLintFeedbackReach_InterpolatingASiblingStaysAdvisory) so the rejection is not re-derived. The remaining lever is runtime — letting a reviewer name its own next target — which is a new construct, not a stricter load rule. - A retry-style
on:cause filter onfeedback. Deferred, not rejected: v1 hard-codes the trigger to the three judgment causes (Semantics), and a user-facing filter narrowing within that set is a purely additive refinement (same closed cause-token spelling asretry.on) if a real graph ever needs "loop on verify_failed but fail fast on nonzero_exit". Widening beyond the judgment set — looping onbudget_exceeded— stays unrepresentable: re-running a body cannot repair a spend fault.