Parallel branches need one owner for the migration index
The pattern to watch for
A branch adds a database migration with the next number in the chain. By the time it lands, someone else has taken that number. The branch renumbers, re-chains its snapshot and waits. Then it happens again.
With one developer, picking the next migration number on a branch is a bet that usually holds. With a fleet of agents each owning a feature, it does not. A migration index is shared state, and parallel branches that each pick their own will collide.
On the night of 4 November, one finished, tested branch was parked as a draft for exactly this reason. It carried one migration, and its number was already wrong. A different index had been reserved for it on the repo's shared reservation ledger, and its snapshot had already been re-chained once onto main. Five migrations sat in one queue, and none could land until the one in front had.
Why the chain is fragile
The repo's migrations follow a common pattern with three chains in one. Each migration has a numbered SQL file, a snapshot whose parent id points at the previous snapshot, and an entry in a journal file with an index, a tag and a timestamp. File numbers, snapshot parent links and journal order all have to agree.
The repo already had tools for parallel work: a reservation ledger (a shared branch with one marker file per index), a helper to reserve an index, a script to renumber on land, and a collision test. Main's own history still showed the churn. One feature migration was renumbered on land. A different migration then took the slot ahead of it. The first one moved again to a newly reserved index, and its snapshot was re-chained after main's new migration. Numbers chosen on a branch did not hold.
What helped: a landing order with one owner
The first fix was not technical. A coordinator agent put a rule on a GoodQA ticket: land nothing with migrations until told. Then it fixed the order: the main release wave with its two migrations first, then three feature branches, one after another.
That order is not a preference. It is a dependency graph. Each later branch cannot finish its snapshot until the one ahead has landed, because its parent id has to point at that branch's snapshot. The parked branch's recipe began with the steps to take once the branch ahead had landed. Write the order down, and give exactly one agent the authority to say "go".
The re-chain recipe
The owner who parked the branch left an eight-step recipe. It is the most useful artifact from the whole episode:
- Fetch and merge main: merge, never rebase or force-push. In the journal, keep main's entries and add one for ours.
- Rename the SQL file and snapshot to the reserved index. Set the journal timestamp to a fresh value taken at re-chain time, later than every entry on main. The branch after it takes its own fresh timestamp once this one lands, so nobody hard-codes values.
- Re-chain the snapshot with a small three-way merge: the old parent snapshot as base, main's new tail as "theirs", our snapshot as "mine". Keep our key order, set the parent id to main's tail, and exit with a conflict if main changed a key we also changed. The resulting diff against our old snapshot must contain only the parent id plus main's new columns and tables.
- Add a guard test asserting our timestamp is later than every other entry, and "include a negative control."
- Prove it applies: on a pooled throwaway database (through the test runner, "never start a DB") migrated to main plus the branch ahead, run the migrator and assert the ledger grows by exactly one and the new tables exist.
- Run the collision test, the schema snapshot test, the focused feature tests and the type checker, then compare failures with main's baseline.
- Red-team the delta at the exact new head, then hand off a new delivery generation.
- Leave the feature flag off.
Merge instead of rebase is the step people argue with. The branch was already pushed with a draft PR, and other people and agents had reviewed specific commits. A rebase rewrites those and needs a force-push. A merge commit leaves every reviewed commit in place, and the next review covers only the delta. That is why it stayed safe to re-chain the same branch more than once.
The same rule had applied a week earlier to a different feature on its seventh review round: merge current main and regenerate the migration metadata on main's tail. Every merge of main moves the tail, so snapshot and journal metadata have to be regenerated after every merge.
What broke anyway: a provisional number, taken twice
The landing order covered the branches the coordinator knew about. It did not cover a new one.
That same night another owner was building an outbox table. The repo's generator calls the reservation helper, and the helper pushes a marker commit to the shared reservation branch. The owner's authority was "push only the feature branch". So it did the careful thing on paper: it generated the migration through the snapshot chain without the reservation step, and logged why. The helper, in its own words, "pushes a marker to origin: outside authority". It used a provisional index and noted that the migration needed a reservation and a renumber before landing.
The handoff report said so plainly. Under a minute later, the main agent replied that another owner had already reserved that exact index on the ledger, properly. The main agent then authorized the outbox owner to push one reservation marker for its own next unused index, renumber, rerun the migration tests and guards, and "never reuse a provisional number."
The owner reserved a block of indexes for its own stream of work. It also found a tooling gap: the renumber-on-land script "does nothing for an index main hasn't passed", so it renamed the SQL file and snapshot by hand. It reported the SQL bytes unchanged and the provisional number absent from the branch, and the PR went ready with the new index.
Two more effects followed. Review fixes added columns to the still-unreleased migration, and it was regenerated in place more than once. Each time, the owner noted that any dev database that had applied an earlier draft must be recreated. That is fine while the table is empty and nothing is released. Once anything shared has applied it, it is a trap.
Why the careful move was incomplete
Skipping the reservation push was right. A helper that pushes as a side effect is a write to shared state, even when it is the documented path, and keeping an owner off shared refs is the authority model working.
The trap is the next step: treating "I can't reserve" as "I'll pick a number and flag it." A provisional number on a branch is the same bet that had already failed on the parked branch, made again. The flag was honest, but it went up at the end of the work, not the start. By then another owner had legitimately claimed the number.
The better move was available from the beginning: stop and ask the main agent for the reservation, or for permission to push that one marker, before generating anything. Reserving takes one commit. Renumbering after the fact took hand renames, test reruns and another review cycle. I covered the general shape of this in shared git state is the fleet coordination bug, and the handoff side in explicit ownership transfers.
How to detect it
| Signal | What it means |
|---|---|
| Two branches carry the same migration number | The collision test catches this once both are visible. A ledger catches it before. |
| A migration number with no ledger entry | Someone picked it on a branch. Treat it as unowned. |
| A tool that pushes as a side effect | Read what a helper does, not just what it is for. A hidden push is a write to shared state. |
| "Provisional" in a handoff report | Someone else's number may already be in use. |
| In-place edits to an unreleased migration | Any database that applied the earlier draft is now out of step. |
How to prevent it
Give one coordinator the landing order for every branch that touches migrations, and keep it on a ticket. New streams of work register there before generating anything. Reserve the index first. If an owner cannot push the reservation, it escalates for that one step right away instead of working around it with a placeholder.
Treat every documented helper as a possible writer. Before an owner runs a generator, it checks what the generator touches outside the feature branch. When the authority model blocks a step, that is a signal to ask, not a puzzle to route around.
One tension I have not resolved. The repo's own migration guide warns against reparenting snapshots, in the context of generating from historical forks. The recipe reparents, with a three-way merge and a conflict exit. I believe the two cover different cases, but I have not confirmed that reading with the maintainers.
Checklist
- [ ] One coordinator owns the landing order for all migration-bearing branches, written on a ticket.
- [ ] New branches register before generating a migration.
- [ ] Reserve the index first. If you cannot push the reservation, ask before generating. No provisional numbers.
- [ ] Re-chain by merge, never by rebase or force-push.
- [ ] Regenerate snapshot and journal metadata on main's tail after every merge.
- [ ] The re-chained snapshot diff contains only the new parent id and main's new columns and tables.
- [ ] A timestamp-ordering guard test exists, with a negative control that fails on a stale timestamp.
- [ ] Prove apply on a throwaway database migrated to main's tail, and assert exactly one new ledger row.
- [ ] The renumber step handles an index main has not passed yet, instead of silently doing nothing.
- [ ] In-place edits to an unreleased migration are flagged in the PR as a dev-database reset.
- [ ] Red-team the delta at the exact merged head before handoff.
Go deeper: coordinating parallel agent work around shared state is a core thread in the Transition2 course.