fabrika adr next hands out an id that is already taken whenever the taker has no open pull
request behind it. Two children of epic lane 8810 — #8820 and #8821 — were both told 0373 was
free, and both minted a record at it:
Nothing in the chain caught it. Only the driver reading both build reports in one pass did.
The mechanism, read at origin/main
allocate in packages/fabrika-cli/src/adr/next.ts is
max(mergedIds ∪ inFlightIds) + 1. Its own docstring calls both inputs facts. They are not the
whole set of claims: inFlight is sourced from ids claimed by open pull requests, so an id
minted on a local worktree branch or on an epic/* assembly branch with no PR yet contributes
nothing to the union. An epic run is exactly that shape — no PR per child, one PR at the tail — so
for the whole life of a phase every child's mint is invisible to every sibling.
guard decisions-index validate
(packages/fabrika-cli/src/guard/decisions-number.ts)
does hold the duplicate-id invariant, and its own docblock names this collision class ("two branches
each minting 0284, green apart, colliding once both land"). But it is pure over one tree's files, so
it only reds once both records are in one tree — the merge queue on the tail PR, which is the most
expensive place to find it. The two filenames differ, so git merges both cleanly and
packages/fabrika-cli/src/lane/integrate-verb.ts
runs code validators over a tree it never asks about the corpus.
Why it matters
A corpus keyed by number stops being addressable the moment two records share one, and CLAUDE.md
makes ls .decisions/ the entire discovery contract — there is no committed index and no
id · title · status readout to fall back on (ADR 0126/0129; #6332 still open on the replacement).
Every later citation of "0373" is ambiguous, including the status: pointers each of these two
records wrote onto the record it amends.
This is not a race that sometimes fires. Parallel children are the normal shape of an epic phase,
so any two children that both write a record hit it by default.
Known instances
Candidate fixes (a builder picks; all three are internal mechanics)
- Teach the union to read open
epic/* refs alongside open pull requests, so a child's mint counts
the moment it lands on the assembly branch.
- Judge the corpus at assembly time in
integrate-verb.ts — the first point both halves are visible
in one tree, and cheaper than the merge queue but still after the work is done.
- Reserve the number at mint time somewhere a sibling worktree can read.
(1) plus (2) is the belt-and-braces pair: (1) stops the collision being minted, (2) catches whatever
still slips through before it reaches the queue.
Acceptance criteria
Triage note: the mechanism above is read at origin/main, not from the filer's snapshot — the gap
is live. #8919 reports the same root cause from the other side (a child's mint invisible to a
sibling that does have a PR) and cites the same 0373; it is the later filing and should fold
into this one.
Triage note: homed on the standing pipeline lane rather than milestone 48. It is lane machinery, but
the failure is in the ADR-allocation verb and its guard rather than in lane integrity itself, and 48
is an active campaign closed to new p2/non-blocking intake.
Original report (verbatim)
Summary
Two children of one epic built in parallel, each needed a decision record, each asked for the next
free number, and both got 0373. Neither build, neither review, and no guard noticed. The driver
found it only by reading both reports side by side.
What I was doing
Driving epic lane 8810. Phase 5 runs children #8820 and #8821 in parallel. A reviewer FAIL on each
asked for an amending decision record, so both repair rounds wrote one.
What I observed
Two files, same number, different records:
Each child's branch was cut from the same assembly tip, so each read the same corpus and each
correctly computed 0373 as free. They never see each other: a child builds in its own worktree on a
local branch and its range is judged alone.
The merge does not catch it either — the two filenames differ, so git merges both cleanly and the
assembly ends up with two records at one number. Both reviewers passed the record on content. The
only reason it did not land is that the driver happened to read both build reports in the same pass.
Why it matters
A decision corpus keyed by number stops being addressable the moment two records share one. Every
citation of "0373" afterwards is ambiguous, including the status: pointers each of these two wrote
onto the records they amend. Parallel children are the normal shape of an epic phase, and any two of
them that both write a record hit this — so it is not a one-off, it is the default outcome whenever
the case arises.
Pointers
Suggested next step (non-binding)
Cheapest real fix is a guard over the merged tree that reds on two records sharing a number, since
that is where both halves are visible for the first time. Reserving a number at claim time would
also work but needs somewhere to hold the reservation that a worktree can see.
Filed by an agent · session 94113692-2733-4534-9963-c7ae534247d0 · branch main · 2026-09-10T05:50:45Z
fabrika adr nexthands out an id that is already taken whenever the taker has no open pullrequest behind it. Two children of epic lane 8810 — #8820 and #8821 — were both told
0373wasfree, and both minted a record at it:
.decisions/0373-shell-budget-claim-retraction.md(a dead builder shell releases its claim and its timeout is sized to the work #8821).decisions/0373-driver-seat-on-a-spent-repair-budget.md(the retry budget rises to 3 and exhaustion routes to the driver #8820)Nothing in the chain caught it. Only the driver reading both build reports in one pass did.
The mechanism, read at
origin/mainallocateinpackages/fabrika-cli/src/adr/next.tsismax(mergedIds ∪ inFlightIds) + 1. Its own docstring calls both inputs facts. They are not thewhole set of claims:
inFlightis sourced from ids claimed by open pull requests, so an idminted on a local worktree branch or on an
epic/*assembly branch with no PR yet contributesnothing to the union. An epic run is exactly that shape — no PR per child, one PR at the tail — so
for the whole life of a phase every child's mint is invisible to every sibling.
guard decisions-index validate(
packages/fabrika-cli/src/guard/decisions-number.ts)does hold the duplicate-id invariant, and its own docblock names this collision class ("two branches
each minting 0284, green apart, colliding once both land"). But it is pure over one tree's files, so
it only reds once both records are in one tree — the merge queue on the tail PR, which is the most
expensive place to find it. The two filenames differ, so git merges both cleanly and
packages/fabrika-cli/src/lane/integrate-verb.tsruns code validators over a tree it never asks about the corpus.
Why it matters
A corpus keyed by number stops being addressable the moment two records share one, and CLAUDE.md
makes
ls .decisions/the entire discovery contract — there is no committed index and noid · title · statusreadout to fall back on (ADR 0126/0129; #6332 still open on the replacement).Every later citation of "0373" is ambiguous, including the
status:pointers each of these tworecords wrote onto the record it amends.
This is not a race that sometimes fires. Parallel children are the normal shape of an epic phase,
so any two children that both write a record hit it by default.
Known instances
0373.was the lucky direction — the colliding sibling was PR feat(epic): #8810 one-PR run #8843, which the union can see.
Candidate fixes (a builder picks; all three are internal mechanics)
epic/*refs alongside open pull requests, so a child's mint countsthe moment it lands on the assembly branch.
integrate-verb.ts— the first point both halves are visiblein one tree, and cheaper than the merge queue but still after the work is done.
(1) plus (2) is the belt-and-braces pair: (1) stops the collision being minted, (2) catches whatever
still slips through before it reaches the queue.
Acceptance criteria
id by
fabrika adr next, or the collision is red before the epic tail PR reaches the mergequeue — whichever route the fix takes.
each minting a record, neither with an open PR.
other candidates were not taken.
pnpm typecheckand the fabrika-cli unit suite are green.pathsOffBasepasses--branches --remotes, so remote-tracking refs are in the set, butadr/contract.md, theadr nextandadr mint--helptext andverb-reference.mdall read as local-only ("a branch ref of this clone", "this clone's own branches"). Either name the remote-tracking half in those four places, or narrow the walk to local branches so the text is true as written.Triage note: the mechanism above is read at
origin/main, not from the filer's snapshot — the gapis live. #8919 reports the same root cause from the other side (a child's mint invisible to a
sibling that does have a PR) and cites the same
0373; it is the later filing and should foldinto this one.
Triage note: homed on the standing pipeline lane rather than milestone 48. It is lane machinery, but
the failure is in the ADR-allocation verb and its guard rather than in lane integrity itself, and 48
is an active campaign closed to new
p2/non-blocking intake.Original report (verbatim)
Summary
Two children of one epic built in parallel, each needed a decision record, each asked for the next
free number, and both got 0373. Neither build, neither review, and no guard noticed. The driver
found it only by reading both reports side by side.
What I was doing
Driving epic lane 8810. Phase 5 runs children #8820 and #8821 in parallel. A reviewer FAIL on each
asked for an amending decision record, so both repair rounds wrote one.
What I observed
Two files, same number, different records:
.decisions/0373-shell-budget-claim-retraction.mdfrom a dead builder shell releases its claim and its timeout is sized to the work #8821.decisions/0373-driver-seat-on-a-spent-repair-budget.mdfrom the retry budget rises to 3 and exhaustion routes to the driver #8820Each child's branch was cut from the same assembly tip, so each read the same corpus and each
correctly computed 0373 as free. They never see each other: a child builds in its own worktree on a
local branch and its range is judged alone.
The merge does not catch it either — the two filenames differ, so git merges both cleanly and the
assembly ends up with two records at one number. Both reviewers passed the record on content. The
only reason it did not land is that the driver happened to read both build reports in the same pass.
Why it matters
A decision corpus keyed by number stops being addressable the moment two records share one. Every
citation of "0373" afterwards is ambiguous, including the
status:pointers each of these two wroteonto the records they amend. Parallel children are the normal shape of an epic phase, and any two of
them that both write a record hit this — so it is not a one-off, it is the default outcome whenever
the case arises.
Pointers
packages/fabrika-cli/src/adr/next-verb.ts— the verb that answers "next free number", whichreads the corpus on disk and holds nothing.
packages/fabrika-cli/src/lane/integrate-verb.ts— the assembly merge, which judges the mergedtree with code validators and does not judge the corpus.
a dead builder shell releases its claim and its timeout is sized to the work #8821
Suggested next step (non-binding)
Cheapest real fix is a guard over the merged tree that reds on two records sharing a number, since
that is where both halves are visible for the first time. Reserving a number at claim time would
also work but needs somewhere to hold the reservation that a worktree can see.
Filed by an agent · session
94113692-2733-4534-9963-c7ae534247d0· branchmain· 2026-09-10T05:50:45Z