Skip to content

Two parallel epic children each claimed decision record 0373, and nothing caught it #8901

Description

@usirin

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)

  1. 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.
  2. 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.
  3. 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

  • Two branches that each mint a record with no open PR behind them cannot both be handed the same
    id by fabrika adr next, or the collision is red before the epic tail PR reaches the merge
    queue — whichever route the fix takes.
  • A regression test covers the specific shape: two sibling branches cut from one assembly tip,
    each minting a record, neither with an open PR.
  • The chosen route is recorded — either an ADR, or a docblock at the changed seam naming why the
    other candidates were not taken.
  • pnpm typecheck and the fabrika-cli unit suite are green.
  • The prose that names the branch-claim set matches what the walk actually reads. pathsOffBase passes --branches --remotes, so remote-tracking refs are in the set, but adr/contract.md, the adr next and adr mint --help text and verb-reference.md all 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 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    axis:pipeline-hardeningStanding cross-cutting axis: pipeline hardening (was milestone #1; go-forward label)p1Medium priorityready-for:agentAn execution engine may pick this up.status:triagedTriage signed off; ready for write-code to picktype:bugBehavior diverges from intent

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions