- Status: Accepted — implemented, with the deviations recorded inline as
"amended at implementation" (
LoadResult,LintFile's advisory channel, the coordinator-side backstop conversion, the shipped fragment'spermission_mode/budget_usd, and the review fragments'evidencesubstitution point) - Date: 2026-08-03
A mining pass over this machine's recorded run corpus measured what people actually do with graph files: 62 runs, 326 node definitions, 58 distinct graphs — and only 6 of those runs came from shipped templates. The other 52 distinct graphs are not 52 inventions; they are copy-variations of about a half-dozen recurring node shapes: the e2e/verify node, the security review, the style review, the pr-publish node, the dev-implement node. People copy a proven node out of a template, tweak two sentences, and ship the copy.
The cost of that pattern is not hypothetical. When the cold-safe retry
wording was fixed — the sentence "Continue the work — or, on a retried
attempt, start from the branch's committed state" that every session-handoff
e2e node needs, because a retried attempt starts cold instead of resuming
its parent's session — it had to be swept across every template by
hand. It is character-identical in graphs/dev-review-pr.yaml and
graphs/self-dev.yaml today only because a human made it so, and it is
whatever it is in the 52 unshipped copies. A copied prompt is a fork with
no upstream: every fix to a proven node shape either gets re-applied
everywhere by hand or silently drifts.
What exists today for reuse is YAML anchors, and they genuinely work — but only within one file (YAML defines no cross-document anchor). The corpus's drift is cross-file by construction: the copies live in 58 different graphs. Anchors cannot even express the problem.
The constraint that shapes everything below is ADR 0010's, transposed: no new runtime concept. The Scheduler, handoff, the snapshot, resume, the event stream and fleetops must see exactly the graphs they see today. Reuse is an authoring-time phenomenon and must be represented as one — resolved away before validation, so a resolved graph is indistinguishable from a hand-written one to every consumer downstream of the file loader.
A fragment is a single-node definition file under a fragments/
directory, with declared substitution points:
# graphs/fragments/e2e-verify.yaml
fragment: e2e-verify
description: cold-safe e2e gate — session continuation, synchronous checks, verified verdict
substitutions: [checks]
node:
type: claude-run
prompt: |
Continue the work — or, on a retried attempt, start from the branch's
committed state — and {{ with.checks }}
Run every check SYNCHRONOUSLY and wait for it to finish — never in the
background. Your FINAL reply is the verdict: never end the turn on an
interim report. Report exactly PASS if everything succeeds, or FAIL
with the failing step.
allowed_tools: [Read, "Bash(make *)", "Bash(go build *)", "Bash(go test *)", "Bash(go vet *)", "Bash(git status*)", "Bash(git diff*)", "Bash(git log*)"]
permission_mode: dontAsk
budget_usd: 10.00 # runaway insurance, an order of magnitude above a plausible run
handoff: session
success_check:
exit_zero: true
result_matches: "^PASS$"
verify: { command: "make local", timeout: 5m }
retry: { max: 1, on: [nonzero_exit, verify_failed] }Update (2026-08-05): the block above is the fragment as drafted, and the shipped
graphs/fragments/e2e-verify.yamlhas diverged from it in four places. It is left as drafted (this is the record of what was decided) with the divergences named here, because a quoted example that no file matches is worse than no example:
drafted above shipped at v0.4.1 why substitutions: [checks]substitutions: [checks, verify_command]the Migration section below, in this same ADR verify: { command: "make local" }verify: { command: "{{ with.verify_command }}" }same result_matches: "^PASS$"result_matches: '^[*_`\s]*PASS[*_`\s]*$'#107 prompt: "Report exactly PASS … or FAIL with the failing step" the bare-four-characters wording, naming **PASS**as wrong#107 The first two are not drift against a later decision — they contradict this ADR's own Migration section, which already records that backlog-batch's conversion made the command "join
checksas a declared substitution point (verify_command)". The example was simply never swept when the section below was written. A reader who trusted the example over the prose would conclude the shipped fragment had regressed.The other two are #107: a model emitting
**PASS**halted a real run, so the verdict pattern became markdown-tolerant and the prompt began saying so explicitly. Neither touches this ADR's decision — the merge rules, the resolution stage and the trust boundary are unaffected by what a proven shape's ownsuccess_checkhappens to contain.
Note what is not a substitution point: allowed_tools is the
fragment's own, because the grant is part of the proven shape (Semantics,
below). A using graph that needs a different grant overrides the key
explicitly — it does not get asked to supply one every time. (Amended at
implementation: the shipped fragment also carries permission_mode and
the runaway-insurance budget_usd, shown above — a gate that must run
non-interactively and its insurance ceiling are part of the proven shape
too, and the budget is the "budget_usd on one" convergence the
Migration section enumerates. The shipped grant is also narrower than
either pre-migration template's: a gate reports, it never fixes, so it
gets make plus read-only go/git verbs and not the Bash(go *) /
Bash(git *) wildcards — which would have let a report-only node
go run the tree under review and rewrite its history. A stack whose
check verbs differ overrides allowed_tools on the using node, where
the disclosure line announces it.)
A using graph splices it in with use:, binding the substitution points
and adding its own wiring:
- id: e2e
use: e2e-verify
depends_on: [dev]
cwd: "{{ inputs.repo }}"
with:
checks: |
run `make local` (build + test + vet). ...Reading: the loader replaces this node with the fragment's node: block,
substitutes {{ with.checks }} with the bound value, overlays the using
node's own fields per the merge rules below,
and hands the result to the exact same decode → Validate pipeline every
graph already goes through. The cold-safe sentence — the one that had to
be hand-swept — now exists once, and the next fix to it is one edit.
What a fragment file may carry: behavior, never wiring. The node:
block may declare type, prompt, allowed_tools, permission_mode,
budget_usd, timeout, handoff, success_check, retry, agent —
the fields that make the shape proven. It must NOT declare id,
depends_on, cwd, worktree, or feedback — those are graph-local
wiring (depends_on and feedback.rerun name ids that only exist in the
using graph; a worktree name is lane choreography; cwd is
invocation-specific). A fragment carrying a wiring field is a load
error: a fragment is a node's behavior, portable precisely because it
says nothing about where it sits. (handoff stays on the behavior side
deliberately: e2e-verify's session continuation is part of the proven
shape, and if a using graph gives such a node two parents, the existing
session-arity validation rejects the resolved graph exactly as it would a
hand-written one — loudly, after resolution.)
Substitution tokens reuse the placeholder grammar with a new kind:
{{ with.<name> }}. One token shape for graph authors (the ADR 0010
precedent — feedback joined the same grammar), but a different
lifetime: with resolves once, at load, and the runtime never sees it.
Two tables enforce that split, and they move in opposite directions. The
runtime's placeholderPattern (internal/handoff, what Interpolate
matches) must not learn with — the token must be gone before the
runtime looks. Lint's placeholderKinds table
(internal/handoff/placeholder_lint.go) must learn it, because that
table is the gate deciding whether LintPlaceholders warns about a token
at all: today a stray {{ with.x }} is in neither table and ships
verbatim into a paid prompt with no warning. The with entry carries a
dedicated message ("resolved at load time — outside a fragment this
ships verbatim") rather than the generic malformed-token one. A substituted value
may itself contain {{ inputs.x }} / {{ artifacts.y }}; those survive
resolution untouched and interpolate at run time as always — the two
layers compose because they fire at different times.
-
id: always the using node's, and required. An id is the graph's wiring vocabulary; a fragment has no business naming itself into someone else's namespace, and two uses of one fragment in one graph need two ids. -
prompt: NEVER wholesale-replaced. A using node declaringprompt:alongsideuse:is a load error. The prompt is the fragment — it is the thing that drifted, the thing the incident was about. A wholesale override recreates the copy-variation this ADR exists to kill, while still claiming the fragment's name in the file: worse than a copy, because it looks like reuse. Customization goes through declared substitution points or it does not happen; a shape that needs more divergence than its points allow is a different shape, and the honest spelling of that is a new fragment or an inline node. -
allowed_tools,success_check,retry(and the other behavior fields): from the fragment unless explicitly overridden. Each for its own reason:allowed_toolsis the grant the fragment's prompt was written against — a copy that silently narrows it breaks the prompt, one that silently widens it escalates it. So the fragment's grant is the default, and an override must be written in the using file, where review sees it.success_checkis half of what "proven" means — e2e-verify's value is precisely that its verdict is evidence-grounded (verify: make local), not self-reported. It travels with the fragment by default; a using graph may still override it (a repo whose gate isnpm testexists), and the override is again visible where it is declared. The same argument that kills aprompt:override applies in weakened form here —use: e2e-verifyplus a hollowed-outsuccess_checkis an unproven gate wearing a proven name — which is why the resolver's per-fragment disclosure line (Trust, below) names every overridden key, so the hollowing-out is announced at every run, not only visible to whoever reads the file.retryencodes the shape's known failure modes (e2e retries onnonzero_exit/verify_failedbecause a cold retry is designed for — the prompt's first sentence handles it). Default from the fragment, explicit override wins.
"Explicitly overridden" is judged by key presence in the raw YAML mapping, not by Go zero values — the resolver splices at the
yaml.Nodelevel, before any decode, sobudget_usd: 0written in the using node is an override and an absent key is not, with no ambiguity. This is also what makes the next section literally true.Merge mechanics, pinned down (so two implementations cannot read this section differently):
- Override granularity is the whole top-level key — subtree
replacement, never deep merge. A using node writing
retry: {max: 2}gets exactly{max: 2}: the fragment'son:does not survive. An overriddensuccess_checkreplaces the whole block,verify:included. Deep merge is rejected because it makes the resolved value a function of two files' internal structure — the reader of the using file could no longer tell what the node does without mentally zipping mappings. If you override a block, you own the block; the disclosure line names it. - Substitution is typed when the token stands alone, textual when
embedded. If a
{{ with.x }}token is the entire scalar value (checks: "{{ with.x }}"), the bound YAML node replaces it wholesale, preserving type — a bound list stays a list, a mapping a mapping. If the token is embedded inside a longer string (theprompt:case), it is string substitution, and the bound value must be a scalar; binding a list or mapping into an embedded token is a load error, not a Go-sidefmt.Sprintfcoercion. - Every scalar in the fragment's
node:block is scanned for tokens, recursively — no per-field whitelist.prompt:is the common case, not a special one. - The token grammar is exactly
placeholderPattern's — same whitespace rules, same body shape, withwithas the leading word. No second grammar to keep in sync. - The resolved document is decoded from the spliced
*yaml.Nodedirectly (yaml.Node.Decodeinto the samerawGraphthatdecodefills today), never re-marshaled to bytes and re-parsed — a serialize/reparse round-trip is a place for anchors, tags and styles to shift, and it buys nothing.
Resolution cannot be "a front half of graph.Load", because graph.Load
is on no product path: its only callers are tests
(internal/graph/shipped_graphs_test.go). All three real entry points
read bytes themselves — run does os.ReadFile + graph.Parse
(deliberately, to keep the raw bytes for GraphSHA256), lint calls
graph.Lint(data), --dry-run the same. A resolver specified on Load
would never run, and every fragment graph would fail at exactly the
commands that matter.
So resolution is a new path-aware load stage that those three entry points are rewired onto:
graph.LoadFile(path) (*LoadResult, error)(amended at implementation from the draft's(*Graph, []byte, error): the disclosure line and the snapshot re-encode decision both need the per-fragment resolution record, so the three data travel as one result) — read the entry file, resolve everyuse:against the fragment location (splice, substitute, overlay, all on the raw YAML document), hand the resolved document to the same decode →Validatepath as today, and return aLoadResultholding the validated graph, the entry file's raw bytes (the datumrunneeds forGraphSHA256and the snapshot), and oneFragmentResolutionper resolveduse:— the disclosure line's input, and non-empty exactly when the snapshot must re-encode. Whether this replaces the test-onlyLoador sits beside it is implementation detail; the rewiring ofrun,lintand--dry-runis not.- The fail-fast/collect-all duality is preserved, because the codebase
already defines
ValidateasIssues()[0]precisely so the two can never disagree.LoadFilefail-fasts on the first resolution error; a collect-all counterpart (LintFile(path) ([]error, []FragmentAdvisory, error), mirroringLint— amended at implementation from the draft's[]error: the advisory findings this ADR names need a channel that is not the issue list, because advice must never affect an exit code, and an unreadable file is an I/O error with no list to collect) returns every fragment issue plus every structural issue of the resolved graph, first element identical to whatLoadFilewould have failed with.lintand--dry-runcall the collect-all form — a migratedgraphs/self-dev.yamlmust lint as a whole list, not die on one "unresolved fragment reference".
This also closes a hole that exists today: decode unmarshals with
plain yaml.Unmarshal and no KnownFields(true), so an unknown use:
key is silently dropped — a fragment node handed to the current binary
decodes to a node with an empty prompt and spends real money running
garbage. Adding Use/With to graph.Node makes the key decode
instead of vanish, and Validate refuses it (below); the refusal is the
backstop that turns today's silent smuggle into a loud error on any
path that somehow bypasses resolution.
Consequences, in order of importance:
- A resolved graph validates exactly like a hand-written one. Every
invariant in
validate.go— cycle check, session arity, verify timeouts, feedback bodies — runs on the resolved nodes. No validation learns what a fragment is. Parserejects what it cannot resolve.Parseoperates on bytes with no file context, so it cannot resolve fragments — and therefore a node still carryinguse:/with:when it reachesValidateis a load error ("unresolved fragment reference — fragments are resolved by the file loader").graph.Nodegains the two fields (with the mandatory matching json tags), but a validated graph always has them empty. One snapshot seam must change to keep that true on disk:newRunRecordercurrently reusesrawSourceverbatim wheneverjson.Valid(rawSource)— and re-running a saved JSON spec withrun <path>is an advertised flow — so a JSON-authored graph carryinguse:would snapshot unresolved bytes andresume'sgraph.Parse(snap.Graph)would hit the refusal above. Therefore the snapshot must storejson.Marshalof the resolved graph whenever any node resolved a fragment; the rawSource-verbatim shortcut is only legal for a document resolution never touched. With that, resume works on fragment-free material by construction.- The engine has no fragment concept. Scheduler, handoff, ledger, runfeed, serve, fleetops: unchanged, not even by an optional field.
Lint behavior (oh-my-graph lint renders the whole list, as always;
errors first, advisories after — every fragment "load error" this ADR
names, in this list and in Semantics, fail-fasts LoadFile and is
collected by LintFile, the same Validate-is-Issues()[0]
contract the structural checks live under):
use:naming a fragment with no file at the fragment location — load error. Nothing can be judged about a node that did not resolve.with:on a node withoutuse:— load error (dead keys are a wiring bug, not a style choice).- a
with:key the fragment does not declare insubstitutions:— load error: a typoed key would otherwise silently not substitute, the same silent-mismatch class as a typoedretry.oncause, refused on the same grounds. - a declared substitution point left unbound by the using node — load error. There are no defaults in v1 (Alternatives), so unbound means the fragment's prompt ships with a hole in it.
- a
{{ with.x }}token in the fragment body wherexis not insubstitutions:— load error, charged to the fragment file: an undeclared point is an authoring bug in the fragment, found the first time any graph resolves it. - a fragment-body token that claims the
withnamespace without obeying the grammar —{{ with.checks | inline }}(a substitution point is bound, not filtered),{{ with. }},{{ With.checks }}— load error, charged to the fragment file. (Amended at implementation: the draft judged body tokens with the strict token regex, which by construction can only see tokens that are already well-formed. A malformed one is worse than an undeclared point: it can never substitute, so it survives resolution into the spliced prompt and reaches the model verbatim — the failure the load-time / run-time token split exists to abolish. The body is therefore scanned loosely for every{{ … }}and each namespace-claiming token judged against the same regexsubstituteWithTokensreplaces with. Tokens in other namespaces —{{ inputs.x }},{{ artifacts.y | inline }}— and literal{{ … }}prose pass through untouched.) - a declared substitution point never referenced in the fragment body —
advisory warning: harmless at run time, but it is drift smell (the
body moved and the declaration didn't). (Amended at implementation:
LoadResultcarries these too, not onlyLintFile, sorunprints them on the same warning channellintand--dry-runuse.runalready discloses which fragments it spliced; a file that warned underlintand went silent under the command that spends money was the disclosure contradicting itself. The two handoff sweeps stay lint-only by contrast: those judge the whole graph and are the pre-flight command's job, not a per-run disclosure.) - a
{{ with.x }}token in a plain (non-use:) node — advisory warning viaLintPlaceholders: the runtime will pass it through verbatim into a paid prompt.
One location, no search path: the graph file's own fragments/
sibling. A use: in /repo/graphs/foo.yaml resolves to
/repo/graphs/fragments/<name>.yaml, and nowhere else. Resolution is a
pure function of the entry file's path — no cwd dependence.
Amended at implementation (twice, both corrections to this paragraph).
First: the name must be bare, matching
^[A-Za-z0-9][A-Za-z0-9._-]*$ and refused before any file is opened.
"Nowhere else" is not self-enforcing — filepath.Join cleans lexically,
so an unconstrained use: ../../evil resolved to <repo>/evil.yaml, and
the file a use: names supplies the node's real prompt, tool grant and
success_check.verify.command. The constraint is what makes
fragments/ the review boundary this section claims it is.
Second, a factual correction: this ADR originally cut a
shipped/embedded fragment tier partly on the grounds that "no such
vehicle exists (nothing go:embeds graphs/)". That was wrong —
graphs/embed.go embeds the shipped templates, and oh-my-graph init
unpacks them, which is how a go install user gets any graphs at all.
The consequence was a real regression: //go:embed *.yaml does not
descend into subdirectories, so the migrated templates shipped without
their fragments and died at load for every go install user. The
pattern is now *.yaml fragments/*.yaml and init unpacks the tree.
What stays cut is a fragment search tier — a precedence order in
which a use: not found locally falls back to an embedded copy — which
would still mean a search order, a shadowing lint rule and a local-wins
narrative for a need no graph has demonstrated. Unpacking is not
shadowing: the unpacked fragments are ordinary files in the user's own
graphs/fragments/, resolved by the one rule above. Adding a real
shipped tier later remains purely additive, with its own decision about
precedence when it earns one. The
resolver prints one line per resolved fragment naming the source file,
the fragment's own description: and every key the using node
overrides — the disclosure posture the agent-mapping design
established, extended to overrides so a hollowed success_check or
widened allowed_tools is announced at every run, and at every lint.
Amended at implementation: fragment: and description: were
initially read by nothing. Both are now required — the first must equal
the filename, since the filename is what a use: resolves and a
disagreement is a typo no reader would catch; the second is what makes
the disclosure line legible without opening the file. A schema key that
nothing checks is the same silent-mismatch class this ADR refuses
everywhere else.
For a hand-written graph this adds no new trust surface at all: the
fragment lives in the same repo, at the same trust level, under the
same review as the graph file that names it — a repo that could plant a
hostile fragment could just as easily plant the hostile node inline.
Planned (auto) graphs: the planner may not emit use: in v1 — and
mechanically cannot. The value case is real and worth stating: a
planner that cites proven fragments instead of inventing prompts from
scratch would inherit exactly the verification discipline this repo's
templates encode. But the trust case decides against it for v1. A
fragment file in the run's repo is attacker-influencable whenever the
repo is untrusted, and a planner-emitted use: would let unreviewed
plan output pick which local file's prompt text, tool grant and verify
command run — the same line the subagent-mapping design and the
skill-mapping ADR (0012) refused to cross: trusted Go code injects;
the planner LLM never names local resources. Enforcement is a real
coordinator-owned refusal, not only the structural one — because the
disposition harness demands it: TestPlannedNodeRefusalsAreReal
probes every rejected row and requires the probe to fail with a
*PlanError, while a bare graph.Parse refusal surfaces from the
coordinator wrapped as a plain fmt.Errorf("generated graph is invalid: %w", …), which would fail the harness, not satisfy it.
Mechanically that refusal cannot live in validatePlannedNodes
(amended at implementation): Plan parses the reply through
graph.Parse first, and graph.Validate's backstop already refuses
any unresolved use:/with: there, so no fragment-carrying graph
ever reaches the per-node checks — a case there would be unreachable
code masquerading as a guard. So the backstop refusal is a distinct
error type (graph.UnresolvedFragmentError, embedding the ordinary
validation error), and the coordinator recognizes it at its
graph.Parse boundary and converts it into the *PlanError
("planned nodes may not reference fragments") naming the offending
node. The Use/With rows in the field-disposition table read
rejected, probed through that conversion — defense in depth with
the layers stated honestly: the structural backstop is the refusal;
the coordinator conversion is the layer the harness can see. The future path stays open and is deferred, not rejected: a
plan-time mapping pass in trusted Go code that resolves
shipped-embedded fragments only (not attacker-influencable; the
binary vouches for them) would fit the agentmap/skillmap mold exactly —
its own ADR, with ADR 0012's measurement discipline, if the
citing-proven-fragments value case is ever pursued; note it presupposes
the shipped fragment tier that v1 also defers (Trust, above).
Versioning: a fragment edit changes every user — say so, and make the blast radius reviewable. That multiplication is the feature: the cold-safe-wording sweep becomes one edit. But it must be honest. Three facts govern it:
- Running and resumed runs are immune. The snapshot stores the
resolved graph (
Snapshot.Graph, the JSONgraph.Parsere-reads), and resume reconstructs from it — it never re-reads the entry file, let alone a fragment. This holds only because of thenewRunRecorderrequirement above: whenever any node resolved a fragment, the snapshot is the re-encoded resolved graph, never the raw entry bytes — without that, a JSON-authored fragment graph would snapshot unresolved and this claim would be false. A fragment edited mid-pause cannot alter the leg that resumes. No fragment hash is needed in the snapshot for correctness: the resolved bytes themselves are already there, which is strictly stronger than a hash of them. - One honest gap:
GraphSHA256hashes the entry file's source bytes, and its only job is the resume-time "this file changed on disk since the snapshot" warning. A fragment edit during a pause will not trip that warning, even though re-running the graph would now produce different nodes. Accepted for v1 — the warning is a courtesy, the snapshot is the authority — and fixing it later (hashing per resolved source file) is additive. - At rest, the blast radius is a reviewed diff, not folklore. The migration below checks in golden resolved-graph fixtures for the shipped templates; any edit to a shipped fragment fails that test until the goldens are regenerated, which puts every affected template's resolved change into the PR diff. A fragment edit is a multi-graph change and CI makes it look like one. Fragment files carry no version field in v1: git history is the version, and a graph that must not follow a fragment's evolution forks it under a new fragment name — or goes back to an inline node, honestly.
Migration: every shipped template holding the shape — structure proven, prompts converged in the open
The proof that the machinery is sound is shipped with the machinery.
One honesty correction first, because the measurement demands it:
"byte-identical resolved graphs" is not achievable for these
templates, and claiming it would be the ADR's own stated smell.
Measured across self-dev.yaml and dev-review-pr.yaml, the
review-security and review-style prompts share zero identical
lines (they are paraphrases of one shape, not variants of one text),
the e2e prompts share about one line in ten, allowed_tools differs
on all three shapes (Bash(go *) vs Bash(go test *)), and
budget_usd on one. A substitution point that made the resolved output
byte-identical would have to swallow essentially the whole prompt — the
exact reason dev/pr are left inline. So the migration does not
preserve the templates' wording; it converges it: the fragment's
prompt (and default grant) becomes the single upstream, and both
templates change wording to match. That is the feature — it is the
cold-safe sweep, done once, on purpose, in review. In the same PR:
-
Extract
e2e-verify,review-securityandreview-stylefragments from the shipped templates, with substitution points covering the spans that should stay per-graph (what checks to run, which diff to review, which extra focus paragraphs apply — those divergences are real and stay declared). Amended at implementation, two points:review-securityalso declaresevidence— the upstream gate's{{ artifacts.<id> | inline }}reference names a graph-local node id, which is wiring, so it binds in rather than being hardcoded into the fragment (and it exercises the compose rule: a bound runtime placeholder survives resolution untouched);review-styledoes not, its shape reading a diff rather than the gate's verdict. Andreview-style'sfocusbinds""indev-review-pr.yaml, whose pipeline adds no extra paragraph — the honest spelling of "none" under v1's no-defaults rule. -
Convert
self-dev.yamlanddev-review-pr.yaml— the e2e and review nodes;devandprstay inline for now (their prompts diverge more than they share; forcing them into fragments would mean substitution points bigger than the shared text, which is the smell that says "different shape").Update (2026-08-09): that was true of
devand false ofpr, and it was never measured. A proposal to makeverdict:a first-class schema field raised the prior question — are the 22 of 31result_matchesdeclarations that share a pattern outside fragments because they cannot be, or because nobody tried? So every shipped node was grouped by its verdict pattern and the prompts compared word-for-word, longest common suffix, insensitive to line rewrapping (the wrapping differs per file and is not a divergence):shared pattern nodes common suffix, all members grants ^[*_`\s]*PR[*_`\s:]*https?://\S5 83 words (75% of the shortest prompt); 4 of the 5 pairwise 86-100 4 identical, 1 wider ^[*_`\s]*(FINDINGS[*_`\s]*:|CLEAN\b)6 30 words (21%) 2 kinds ^[*_`\s]*DONE\b6 0 words 4 kinds ^[*_`\s]*PASS5 0 words ( apply1/apply2pairwise 124)3 kinds pris one shape by any reading, so it was extracted:graphs/fragments/pr-publish.yaml, cited byself-dev'spr,dev-review-pr'sprandbacklog-batch'spr-a/pr-b. The substitution point ispublish— the graph's own instruction (which branch, DRAFT or ready, and a body naming graph-local node ids, which is wiring, theevidenceprecedent). Each binding carries that head verbatim, so all four resolved prompts are byte-identical to the ones they replaced: the blast-radius goldens did not move, which is the proof. Nothing is overridden by any using node.adr-driven-dev'sfinalizeshares the same 83 words and stays inline: it also applies the last review's remaining flags and gates on an engine-runmake local, so it carriesEdit/Writeand averify:. A node with a second job is a different shape, and the 83 words it shares are the same 83 an inline node is free to write.The three groups left are left for one reason, and it is the one that decides the
verdict:question. They do not group by node.DONEcovers a repo-specific implementer, a docs-lane implementer, a feedback-loop implementer and an ADR-feedback applier: six nodes of four kinds, with four different grants that agree on nothing but the four characters they must emit — zero shared words. A fragment is a node's behavior; it cannot be a paragraph, so it cannot express "these six nodes share their last paragraph". The closest sub-shapes are real but small:apply1/apply2share 124 words, and they live in ONE file, which is the intra-file case this ADR's Alternatives hand to YAML anchors. The fourFINDINGS:/CLEANrounds are that same intra-file case — all four live inadr-driven-dev.yaml— and they share 21% with the review fragments while carryingGrep/Globthe fragments deliberately do not, so citing them would mean either narrowing four review nodes' grants or overridingallowed_toolsat every use — a fragment whose grant every caller overrides is not a proven grant.Update (2026-08-10): the same question asked of the operator's own hand-written lanes — NO EXTRACTION, and the arrow points the other way. Full numbers:
docs/measurements/0013-lane-corpus-has-no-extractable-fragment.md. This machine's run corpus holds 75 distinct lane graphs that end in aprnode, 33 of them built by hand around a fullreview→apply→prscaffold, and the proposal was to ship that scaffold as a fragment. It does not survive this ADR's own standard, on three counts:- What repeats is wiring.
worktree:,cwd:, thedepends_onchain and the ids — the exact list Semantics makes a load error in a fragment. What is left to carry istypeand a timeout. - The one prose candidate carries no proof. The 35
applynodes collapse to 9 texts whose top two share 82% of their words (Jaccard) — agreement of the orderpr-publishwas extracted on, though measured differently (see the correction below). But they declareresult_matches0/35, a verdict-first clause 0/35, andallowed_tools0/35 — so both halves of the verdict convention and the grant would have to be authored, not extracted. And all 35 key their second sentence off aNO FINDINGScontract neither shipped review fragment emits (CLEAN/FINDINGS:), so shipping it beside them would manufacture the silent mismatch this repo keeps closing. No shipped template has an apply stage to cite it, either. - The corpus is a consumer, not a supplier. Across those 75
lanes,
result_matchesis declared by 0 of 35apply, 0 of 47reviewand 0 of 75prnodes, anduse:appears zero times. Every one of those lanes published a PR with nothing asserting the PR exists — the failurepr-publishwas extracted to abolish. And where the corpus does declare a verdict pattern — 32 of its 349 nodes, all in other roles — every one is a retypede2e-verifypattern and all 32 drifted from the shipped one: 19 to a barePASS(unanchored, andresult_matchesis a search, so "the suite did not PASS" passes — the merge-shepherd failure class, live), 7 missing the trailing anchor, 6 intolerant of**PASS**. Sixteen more nodes are namedreview-security/review-styleafter shipped fragments while carrying no pattern at all. These lanes did not fail to hear about the shapes; they copied them by hand and lost the anchors. The fix for those lanes isuse: pr-publish, not a new fragment.
One methodological correction to the update above, from the same measurement: the longest-common-suffix metric only works where the shared text collects at the tail, which is true of shipped nodes because their verdict clause is last. The
applynodes diverge mid-body, so their suffix reads 8 words against 82% word agreement. Suffix length understates convergence off this repo's own templates; it is not a general similarity measure. - What repeats is wiring.
-
Convert
backlog-batch.yamltoo — added at implementation, and its omission would have been this ADR's founding complaint surviving its own fix: that template held two more copies of the cold-safe e2e shape (e2e-a/e2e-b) and two of the review shape (review-a/review-b), so the next cold-safe correction would still have been a partial hand sweep. Converting it honestly required one fragment change:e2e-verifyhardcodedverify: { command: "make local" }, which is this repo's build and not every repo's, while backlog-batch is a skeleton you copy into any repo — which is exactly why its gates had noverify:at all. Overridingsuccess_checkfrom the using node would have produced what this ADR argues against: an unproven gate wearing a proven name. So the command joinschecksas a declared substitution point (verify_command) — what only the graph can know — and the shape stays the fragment's.self-devanddev-review-prbindmake local, so their resolved graphs are unchanged and the equivalence proof below is untouched. backlog-batch's conversion is deliberately not an equivalence claim: its gates gain a real engine-runverify(and the graph a requiredchecks_commandinput to feed it), which is an upgrade — previously the node's own "PASS" was the only thing judging it. It therefore has no frozen pre-migration fixture, only a golden. -
Gate the PR twice, on two different fixtures with two different jobs:
- Structure and non-converged behavior fields: byte-identical,
tested. The pre-migration files are checked in as
testdata/pre-migration/*.yaml— the actual old files, the one-time equivalence evidence. A test asserts that each migrated template's resolved graph (json.Marshalof the loaded*Graph, deterministic — the same bytes a snapshot would store) is byte-identical to the parsed pre-migration file after masking exactly the fields the PR converges (prompton the three shapes;allowed_toolson four — the three review/gate shapes, plusself-dev's gate once the fragment's grant was narrowed;budget_usdon one). Ids, edges, handoff, success_check, retry, timeouts — everything else is byte-for-byte, or the PR does not merge. - The converged fields: an explicitly reviewed diff. The masked fields are enumerated in the PR description with their old→new values per template, reviewed as the deliberate behavior change they are — not laundered through a "zero behavior change" claim.
- Structure and non-converged behavior fields: byte-identical,
tested. The pre-migration files are checked in as
-
The ongoing blast-radius golden (Versioning, above) is a separate fixture with a separate job: it captures the post-migration resolved graphs of all three fragment-citing templates (a wider set than the two-template equivalence mask, on purpose) and fails on any future fragment edit until regenerated. One fixture cannot honestly be both the equivalence proof and the drift tripwire;
testdata/pre-migration/is frozen history, the golden is living state.
Positive
- The half-dozen proven node shapes get an upstream. The next cold-safe-wording-class fix is one edit plus a regenerated golden, not a hand sweep with a miss rate.
- Copies become citations: a graph that says
use: e2e-verifytells its reader "this is the proven gate" instead of making them diff two 40-line prompts to discover it is almost the proven gate. - Zero new runtime concept. Everything downstream of the loader — scheduler, handoff, snapshot, resume, events, fleetops — is bit-for-bit unaffected; the snapshot proves it by construction, and the migration proves structure and non-converged behavior fields byte-for-byte while putting the converged prompts into a reviewed diff.
- The trust posture stays one sentence long: trusted code resolves files; the planner still never names local resources.
Negative / trade-offs
- A graph file is no longer self-contained: reading a
use:node means opening a second file. The disclosure line andlintsoften this; they do not remove it. (Anchors have the opposite trade — see Alternatives — and remain available for the single-file case.) - The resolver is new load-path machinery: raw-YAML splicing, a path-aware load stage rewiring all three entry points, seven new lint rules. It is justified by the 52-of-58 corpus evidence, and it would not be justified without it.
- Substitution points are a straitjacket by design. Shapes will occasionally want one more knob, and the answer "declare it upstream or fork honestly" costs a PR to the fragment where a copy cost nothing. That friction is the mechanism working, but it will be felt as friction.
- A shipped-fragment edit now ripples through every shipped template's golden in one PR — bigger diffs, deliberately. Reviewers must read regenerated goldens rather than rubber-stamp them, or the visibility mechanism decays into noise.
- DESIGN.md (graph loading, the new
use:/with:keys, the fragment file schema, the lookup rule) and the README's graph-authoring section must land in the same change that implements this ADR — code and DESIGN.md drifting apart is a bug in both.
- YAML anchors and merge keys (
&,*,<<:) — already work today. Kept, honestly credited, and insufficient. Anchors are the right tool for intra-file repetition and nothing in this ADR touches them — with one narrow exception added at implementation: a fragment file'snode:block may not contain an alias or a<<:merge key (load error). That block is walked twice, to check body tokens againstsubstitutions:and to substitute them, and an alias hides its scalars from both walks — soprompt: &p "{{ with.x }}"/other: *pwould ship a literal{{ with.x }}into a paid prompt: the exact silent-verbatim failure the load-time/run-time token split exists to abolish. Descending into the alias instead would mean inlining its target to substitute into it (the target lives in the cached fragment tree that every using node shares), and inlining nested aliases is an exponential-expansion bomb on a file read before any validation runs. Anchors remain untouched everywhere else, including in a using graph'swith:values. But the measured problem is cross-file: 58 distinct graphs forking a half-dozen shapes, and YAML defines no cross-document anchor, so anchors cannot express the fix at all. They also lack the two properties the incident demands: declared substitution points (an anchor override replaces wholesale — exactly the drift vector) and any lint surface. If the corpus had shown one big graph with internal repetition, anchors would win and this ADR would not exist; it showed the opposite, and cross-file reuse is the whole justification for the machinery. - Graph-level includes (import a subgraph of nodes with its edges).
Rejected as over-scoped for the evidence. The corpus shows recurring
node shapes, not recurring subgraphs — the wiring around the
shapes is precisely what varies per graph. Includes would import all
of the hard problems node fragments avoid (id namespacing across
files, collision policy, how imported edges splice into the host
DAG, what
depends_onmay point across the boundary) to serve a pattern the data does not contain. If a recurring multi-node motif ever shows up in a future corpus pass, that is its own ADR, and node fragments compose upward into it; the reverse is not true. - Wholesale-overridable
prompt:(fragment as a mere default). Rejected: recreates the copy-variation drift under a name that claims reuse — the incident, with better camouflage. Argued in Semantics. - Runtime resolution (the engine resolves
use:when the node becomes ready). Rejected. It puts a file read and a failure mode into the scheduler's hot path, makes the snapshot either store unresolved nodes (breaking "resume never re-reads files") or store resolved ones anyway (conceding the point), and buys nothing: fragments have no runtime inputs. Load-time resolution is the entire reason this feature costs the engine zero. - Default values on substitution points. Deferred, not rejected —
purely additive later (
substitutions: [{name, default}]). Cut from v1 because the migration needs no defaults (both templates bind every point), and an unbound-means-default rule weakens the loudest lint in the set (unbound-means-error) before any evidence asks for it. - Fragments referencing fragments. Rejected for v1: a fragment's
node:block may not carryuse:(load error). Single-pass resolution, no cycle detection, no partially-resolved intermediate states. Nesting is additive later if composition evidence appears. - The planner may emit
use:for shipped-embedded fragments. Deferred, not rejected — the one alternative with a real value case (plans that cite proven shapes instead of inventing prompts). It stays out of v1 because it crosses the planner-never-names line, and the safe variant (trusted Go code maps planned nodes onto shipped fragments, agentmap-style) deserves its own ADR with ADR 0012's measurement discipline rather than a paragraph here — noting it also presupposes the shipped fragment tier that v1 defers (Trust). - A fragment hash (or version pin) in the snapshot. Rejected: the
snapshot already stores the resolved graph itself, which subsumes
any hash of the inputs to resolution; resume never re-resolves, so
there is nothing for a pin to protect. The only thing a hash would
add is a sharper resume-time "sources changed on disk" warning, and
that courtesy (extending
GraphSHA256's check to fragment files) is additive whenever it earns its keep.