Sequence allocation

Some numbers are a shared resource that git cannot see. Decision records named 0001-*.md, 0002-*.md. Issues written as ## 58. headings in one long file. Migration numbers, RFC numbers, schema versions – anything where “the next one” is a scalar that two people can reach for at the same moment.

This is the one collision class that every other control in this repository is blind to, and it is the reason scripts/coord/alloc.ps1 and scripts/hooks/seq_check.py exist. Decision records are used as the worked example throughout – see examples/sequence-adr/ – but nothing about the mechanism is specific to them. What a sequence is lives entirely in ccx.config.json.


The defect

Two sessions each look for the next free number. Both compute the same answer – correctly, from their own point of view – and both use it. They create differently named artifacts: 0004-alpha.md and 0004-beta.md, or two ## 58. headings sixteen hundred lines apart in the same file.

Git merges both cleanly. There is no textual conflict, because the two sessions never touched the same bytes.

Control you might expect to catch it Why it does not
A worktree per session The collision is between worktrees. Isolation is what makes it possible.
A file lock Different filenames. Nothing is contended.
git merge-tree / a merge dry-run It merges clean by construction. That is the whole problem.
Code review Both diffs are individually correct.
A green CI on each branch Each branch is internally consistent. The duplicate exists only after the second merge.

Measured on the repo this tooling was developed in, this fired three separate times. Each time the symptom was recorded as “numbers churn, recompute before merging” and a workaround was written down. It is not churn. It is a concurrency defect, and the workaround is the bug.

Rule. Never compute the next free number by scanning for a maximum and adding one. Allocate it atomically, and enforce the allocation at commit time. When a symptom keeps recurring and the remedy keeps being “redo it by hand”, ask whether you are looking at a race.


The two halves

Neither half is sufficient alone.

Half File What it does When it runs
Allocator scripts/coord/alloc.ps1 Hands out a number nobody else can hold, by exclusively creating a file named after it When you ask for a number
Gate scripts/hooks/seq_check.py Refuses a commit that adds a number which is already taken, unallocated, or missing from the index pre-commit, and again in CI with --ci

The allocator is a test-and-set, not a read-modify-write. It claims the number by exclusively creating <state-root>/alloc/<kind>/<number>.json with FileMode::CreateNew and FileShare::None; if a sibling session got there first, the create throws IOException and the loop moves to the next number. That throw is the mutual exclusion.

A shared list you read, edit and write back is not an alternative. Measured on the repo this tooling was developed in: eight concurrent PowerShell writers to one shared file lost four writes, with no error raised anywhere. Eight concurrent allocator processes against the exclusive-create scheme produced eight distinct numbers and zero collisions.

The registry lives beside the shared object store<git-common-dir>/<prefix>-coord/alloc, resolved by Get-CcxStateRoot in scripts/coord/_common.ps1 and by state_root() in scripts/hooks/_ccxconfig.py. Every linked worktree of a clone sees the same allocations; a different clone gets its own automatically; and nothing there can be swept into a commit by git add -A.

Numbers are never reclaimed. An abandoned branch holds its number forever and the sequence develops holes. That is deliberate: holes are free, collisions are not.


Configuring a sequence

One key in ccx.config.json. Omit sequences entirely and both halves are inert – the allocator refuses with a message naming the file to edit, and the gate returns 0 without a word.

{
  "sequences": {
    "adr": {
      "dir": "docs/adr",
      "filePattern": "^docs/adr/(\\d{4})-[^/]+\\.md$",
      "pad": 4,
      "indexFile": "docs/adr/README.md",
      "indexRowPattern": "^\\|\\s*\\[(\\d{4})\\]"
    }
  }
}
Key Required Meaning
dir yes The directory swept for existing numbers, repo-relative, forward slashes
filePattern yes Regex over the repo-relative path. Group 1 must capture the number.
pad no Zero-padding width. 0 or absent means none
indexFile no An index/table file that must carry a row per number
indexRowPattern with indexFile Regex recognizing one row. Group 1 must capture the number.

Both scripts validate this before touching the registry, and both name the file and the key in every message. indexFile and indexRowPattern must be given together or not at all: half a configuration silently drops a whole term from the floor, and a floor that is silently too low is the exact failure the allocator exists to prevent.

indexRowPattern is compiled multiline, and that was once a silent hole

alloc.ps1 builds it with RegexOptions::Multiline; seq_check.py compiles it with re.M. Without that flag ^ anchors to the start of the string, not of each line.

This mattered because the two terms that use the pattern feed it differently. The all-refs term feeds it one line at a time, where it matched and looked correct. The working-tree term feeds it the whole file as one string, where ^ could never match past the first line. Measured on the repo this tooling was developed in: without Multiline the working-tree term found none of the index’s rows. So the term that exists to catch a number written but committed nowhere had been finding nothing since the day it was written. The all-refs term hid it, by covering every number that had been committed somewhere – which is every case except the one that term is for.

Rule. When two terms of the same computation feed one matcher different shapes of input, the stricter shape is the one to test. A term that is subsumed by a broader term in the common case will not announce that it has stopped working.


Using it

# take a number
pwsh -NoProfile -File scripts/coord/alloc.ps1 -Kind adr -Title "Worktree gate"

# inspect the floor without spending anything
pwsh -NoProfile -File scripts/coord/alloc.ps1 -Kind adr -ShowFloor

# what does this worktree currently hold?
pwsh -NoProfile -File scripts/coord/alloc.ps1 -List

-Kind may be omitted when exactly one sequence is configured. With two or more it is required, and the error lists the configured names – the parameter is deliberately not a ValidateSet, so the repo does not carry two lists of kinds that have to agree.

-Title is required for a real allocation. It is recorded in the claim so a sibling session running -List can see what the number is for.

A successful allocation prints the number, the directory to put it in, the pattern the path must match, a suggested filename, and a reminder to add the index row in the same commit. The claim file records number, kind, title, branch, worktree, claimed, as UTF-8 with no BOM – the Python gate reads it with encoding="utf-8" and a BOM makes json.loads raise.


The floor, and why it ratchets

The floor is the maximum over four terms, then ratcheted against a persisted high-water mark:

Term Source Catches
1. Filenames, per ref git ls-tree over dir for HEAD, trunk, every refs/heads and every refs/remotes A number on any branch, published or not
2. Index rows, per ref git cat-file --batch over <ref>:<indexFile> A number recorded in the index but not yet a file
3. Working tree The directory and the index file on disk A draft written but committed nowhere
4. Registry <state-root>/alloc/<kind>/*.json A number claimed but not yet written anywhere

Every ref, not just the trunk. Reading only the published branch is precisely what re-issues numbers that already exist on refs the published branch does not carry. They are invisible to the sweep, so the allocator hands them out as free. The collision surfaces later as two differently-named files that merged clean. A number that exists on any ref is taken.

Term 2 is batched for a reason. A git show per ref spawns one process each; measured on the repo this tooling was developed in, that cost roughly 34 seconds on Windows where two git cat-file processes did the same work in about 3. Most refs share the same blob, so de-duplicating by object id collapses several hundred specs into far fewer reads.

The ratchet

Every term above is derived from refs a routine cleanup can remove. The sweep is only as good as the refs this clone happens to hold. Measured on the repo this tooling was developed in: the floor computed over all refs was materially higher than the floor computed over origin’s refs and local heads alone, because numbers lived on remote-tracking refs for a remote that git remote -v no longer listed. Drop those and the floor silently reverts to a lower value, and the allocator resumes issuing numbers that are already in use – no error, no signal.

So the floor is persisted to <state-root>/alloc/<kind>/.floor-highwater and may rise but never fall. When the computed floor comes in below the mark, alloc.ps1 prints a loud NOTE naming both numbers and telling you to re-fetch before trusting any number-space reasoning in that clone.

Operation Verdict
git fetch origin --prune Safe – prunes only refs/remotes/origin/*, and is what you should run before allocating
git remote prune <other> / git remote remove <other> Dangerous – deletes a block of remote-tracking refs
Deleting remote-tracking refs by hand Dangerous
Aggressive gc / reflog expire dropping unreachable objects Dangerous

The ratchet is a backstop, not a substitute for the refs: it keeps the allocator from re-issuing, but the history those refs pointed at is still gone.

Allocation is a one-way door, so it ships a read-only probe

Numbers are never reclaimed, so before -ShowFloor existed the only way to find out what the floor could see was to spend a number on the question. That made the floor’s own correctness the one property nobody re-tested – which is how it went an entire release reading two refs while its own header promised all of them.

-ShowFloor prints the kind, the resolved trunk, the floor (with the computed value and the high-water mark shown separately), the paths it swept, the number it would issue next, and the watermark path – and allocates nothing.

Two details make it trustworthy:

  • It names its sources, not just the number. “Which paths did this sweep actually read” is the question every silent-narrowing bug turns on, and a bare integer cannot answer it. A floor looks identical whether it swept one path or two.
  • It cannot corrupt what it reads. Get-Floor -Peek skips the high-water write. The first run of -ShowFloor against a deliberately planted number ratcheted that clone to a fabricated floor no later run could undo. An inspection that moves the thing it inspects is not an inspection.

One computation, two callers: -ShowFloor and a real allocation differ only by -Peek, so they cannot report different numbers. They used to – -ShowFloor returned before a guard every real allocation ran, and printed a next number the tool would then refuse to issue. Anything added later that can change the outcome belongs inside Get-Floor or above both branches, never in one of them.

Rule. Any irreversible allocator needs a dry run that reports its own inputs, and the dry run must run the same code path as the real thing.


The gate

scripts/hooks/seq_check.py runs at pre-commit, and again in CI with --ci.

Why a git hook, and why the shared hooks directory

.git/hooks lives in the common git directory, which every linked worktree shares. One file there:

  • reaches every worktree the instant it is written – no branch, no merge, no propagation lag;
  • survives a branch switch in any of them, because it sits outside every working tree;
  • and sees every write route, because it inspects the staged tree rather than a tool call.

That last property is the one that matters here. A PreToolUse gate reads tool arguments, so it is blind to a shell redirect, Set-Content, python -c, a heredoc, an editor, or a subagent. The commit hook sees all of them, because by then the bytes are in the index.

What it checks

Per configured sequence:

  1. An added file carrying number N must not reuse an N that already exists on trunk – unless the index row for N names the new file as a declared companion. (One number, one row, two files is legal; the row itself names the companion. Only an undeclared reuse is a collision. The companion is matched with and without its extension, since an index row conventionally links the stem.)
  2. An added number must have been allocated to this worktree. Local only – see the mode asymmetry below.
  3. An added number must have a row in the sequence’s indexFile.
  4. The index must not gain a duplicate row for one number.

What it deliberately does not check

Anything about numbers already on trunk. Rule 3 applies only to files this change adds; rule 4 only to duplicates this change introduces (duplicates already on the base are subtracted out).

Rule. A gate that fails on pre-existing debt is a gate that gets uninstalled, and it takes the real protection with it when it goes.

For the same reason it reads the staged tree (git show :path), never the working tree. A gate reading the working tree blocks every unrelated commit the moment you have an untracked work-in-progress file in your checkout.

And it is stdlib only, with no project import. Most worktrees have no virtualenv and no project install; a gate that skips because an import failed is worse than no gate, because it still looks installed. The one shared import is its sibling _ccxconfig.py, and a failure to find it exits non-zero with an explicit message rather than degrading to silence.

Two rules that keep it honest

Both live in _ccxconfig.git() and both were paid for:

  • encoding= is required, not cosmetic. text=True alone decodes with the locale default, which is cp1252 on a stock Windows box. Index and ledger files are routinely UTF-8, so the decode raised inside subprocess’s reader thread, proc.stdout came back None, and the caller died on findall(None) – blocking every commit that touched exactly the files the gate guards.
  • A non-zero git exit raises. A bad ref, a missing path or an unfetched base must never read as “the file is empty”, because an empty index parses as “no numbers taken” – the false clean a collision gate must never emit. When the git wrapper swallowed non-zero exits, the added-files list came back [] and the gate reported PASS on every run where it could not see.

The one legitimate “absent from that ref” case gets its own explicit probe, object_exists(), rather than a broad except that would also hide a genuinely broken ref.

Whatever it could not check, it prints – pass or fail, on stderr. An unresolvable trunk means the already-taken-on-trunk rule did not run, and it says so. A skip that prints nothing is byte-identical to a clean run.


Wiring the pre-commit hook

Nothing in this repository installs it for you. scripts/coord/install-git-hooks.ps1 installs the claim gate (commit-msg) and the push guard (pre-push), and never writes pre-commit, at all. Two tools cannot both own that one file. A hook framework that finds a foreign hook there may rename it and invoke it from its own shim, and that chain has failed on Windows and blocked every commit in a repository until the shim was removed.

So the installer does the next best thing: whenever sequences is configured, it prints in yellow that the sequence gate is not installed by it and that until you wire one, nothing at commit time stops two sessions using the same number. bin/ccx-doctor.ps1 goes further and checks: it reports the control as OFF, with the reason, rather than omitting it. An absent gate looks exactly like one that passed.

Wire it into whatever hook framework you already use:

# in your existing pre-commit hook, or as its own file if you own that slot
python scripts/hooks/seq_check.py || exit 1

Verify by receipt, not by presence:

pwsh -NoProfile -File bin/ccx-doctor.ps1

The doctor reports whether any pre-commit in the resolved hooks directory invokes seq_check, and separately fires a read-only floor probe at the allocator – -ShowFloor, which never spends a number – so a broken allocator is caught without corrupting the sequence to find out.


Modes are not symmetric, and saying so is the point

--ci re-runs the same rules against a freshly fetched trunk, which is what catches the stale-base collision: each branch is internally consistent, and the duplicate only exists once both have merged. It re-runs every rule but one.

Rule 2 – allocation ownership – cannot run in CI. It reads a per-clone registry inside the git directory and compares a worktree path; a runner clones fresh and has neither, so the check would return False for every item and nothing could ever merge. So ownership is enforced locally and never in CI.

An earlier version ran the CI half of that rule anyway: it computed a set and discarded it, which made it structurally incapable of failing while reading, in source, exactly like coverage.

Rule. If a rule cannot run in a mode, name it as not running. Never leave it in place looking like coverage. A green CI on a numbered pull request is not evidence that the number was allocated to anybody.

The residual, stated plainly: after a --no-verify commit, a number belonging to another session’s unmerged branch can be taken with nothing objecting. The corruption then surfaces late – but loudly and recoverably – when the second of the two merges.

CI mode also insists on a resolvable base. Locally, an unresolvable trunk downgrades to a printed note; in CI it raises, because there the base comparison is the job and a base that does not resolve is a workflow misconfiguration.

If you wire the CI leg

Two things to get right, neither of which is obvious:

  • Do not gate the step on a “code changed” path filter. A pull request that only adds a decision record is a docs-only change, so a code == 'true' condition makes the governance step skip on exactly the pull requests it exists to police. Path filters written for test suites invert the intent of a docs-governance check.
  • Ride it inside an already-required job rather than adding a brand-new required context. A newly required check wedges every pull request opened before it existed.

Use a two-dot diff (base HEAD), not three-dot. On a pull request the checkout is typically the merge commit, so HEAD already contains base and three-dot buys nothing – while costing everything. It resolves a merge base, the checkout is shallow, and two truncated histories routinely fail to reach their common ancestor. Deepening to fix that is itself a race. A two-dot diff compares two trees: no ancestry, no depth, nothing to race. And the three-dot failure was silent – see the raise-on-non-zero rule above.


Ownership is only as real as your isolation

Rule 2 keys ownership on the worktree that holds the claim: owns() compares the claim’s worktree field, folded through fold_path(), against the current repo root.

That only discriminates because each session gets its own worktree. Measured on the repo this tooling was developed in, the ownership rule was a no-op before worktree isolation was enforced. Every co-tenant session authored in the same shared primary checkout, so every one of them mapped to the same key. The check could not separate exactly the sessions it was written to separate.

Rule. Check that your ownership key actually distinguishes the actors in practice, not merely in principle. Number allocation and worktree isolation are a pair – the first is meaningless without the second making the key real.

The folding is shared deliberately: fold_path() in _ccxconfig.py and ConvertTo-CcxComparablePath in _common.ps1 must agree character for character, because each side compares paths against records the other side wrote. If they fold differently, ownership silently stops matching and the gate either refuses everything or grants everything.


Two lessons from a guard that had to be removed

Both concern a rule that once sat in the allocator and is deliberately not in the shipped code. They are worth knowing because the shape recurs.

Two different maximums got conflated, and the allocator bricked on correct input. A guard meant to detect one band of a partitioned sequence encroaching on another read the floor – the maximum over everything swept. The first legitimate entry filed in the upper band therefore made every allocation in the repository throw a refusal. There were two measurements, not one: the floor answers “what must I not re-issue?” and must include every number from every band; the per-band maximum answers “how much runway does this band have?” and must not. The guard was not detecting a breach. It was detecting the partition being used exactly as designed.

Rule. Name each measurement by the question it answers, then check which one every consumer reads. A guard that fires on correct input will be disabled, and it takes the real protection with it.

A branch that cannot fire reads as protection and is worse than none. The obvious repair was to keep the refusal arm and make it unreachable. But once an entry exists at a number in the shared band, it is indistinguishable in the published files from a legitimate entry at the same number – both are just a number. A refusal arm would have to fire on correct input or never fire at all. Detecting a real breach needed an input the repository did not have.

Rule. Remove a branch that cannot fire; do not leave it dormant. Replace it with something the data can actually support – a warning at a threshold measured on the band where the other band’s numbers cannot distort it – and say in the docs what is no longer detected.


Limits

Stated plainly, because each one is a hole somebody will otherwise assume is covered.

Limit Consequence
git commit --no-verify bypasses the gate This is a guardrail against accident, not a security boundary. The --ci run is the backstop.
Ownership is never checked in CI A green CI is not evidence the number was allocated.
No installer writes pre-commit Until you wire it, nothing at commit time stops two sessions using the same number. The doctor reports this as OFF, not as absent.
The shims fail open with no python Both Python-backed git hooks print to stderr and exit 0 when no interpreter is found. install-git-hooks.ps1 -Status and the doctor both report the interpreter, because that single condition turns the gates off everywhere at once while every file involved is still present.
Ownership is worktree-keyed Where every session shares one checkout, it collapses to “somebody here allocated it”.
Two sessions can still build the same thing under two different numbers Nothing structural sees duplicated work. That is what claims and announce are for – see docs/COORDINATION.md.
The high-water ratchet cannot restore history It stops re-issue. The commits those refs pointed at are still gone.
Numbers are never reclaimed Sequences develop holes. Accepted by design.

Files

Path Role
scripts/coord/alloc.ps1 The allocator: floor sweep, high-water ratchet, atomic claim, -ShowFloor, -List
scripts/hooks/seq_check.py The gate: four rules, pre-commit and --ci modes
scripts/hooks/_ccxconfig.py Config discovery, the raising git runner, path folding – shared by the Python hooks
scripts/coord/_common.ps1 The PowerShell counterpart: Get-CcxConfig, Get-CcxStateRoot, Get-CcxTrunk, ConvertTo-CcxComparablePath
scripts/coord/install-git-hooks.ps1 Installs the claim gate and push guard; reports that the sequence gate is not installed
bin/ccx-doctor.ps1 Reports the sequence gate by receipt, and probes the allocator read-only
ccx.config.json The sequences key – the only place a sequence is defined
examples/sequence-adr/ The worked configuration: a decision-record sequence, end to end
examples/ledger_check.annotated.py The original gate this was distilled from, comments intact. Not wired, not installed.