RFD 102: Collision-Resistant Ticket Identifiers
- Status: Accepted
- Category: Design
- Authors: Jean Mertz git@jeanmertz.com
- Date: 2026-08-15
- Extends: RFD 100
Summary
Ticket ids move from a sequential counter to a time-ordered random identifier: T-02wt0kx. The docs/ticket/.counter file is deleted.
Motivation
.counter is a coordination primitive, and ticket creation has no coordination point. Parallel git worktrees each see the same counter value and hand out the same number; once testers file tickets from their own clones, the same happens across machines.
Renumbering at merge is not a fix. An id that has reached a pushed commit message cannot be rewritten, and the assistant references ticket ids in commits and pull request descriptions routinely. An id must therefore be final the moment it is created, in a checkout that cannot see its siblings — which rules out any allocator that counts.
Format
T - 0 2 w t 0 k x
└───┬───┘ └┬┘
│ └─── random: 2 × base-32, 1,024 values
│
└────────── time: 5 × base-32, zero-padded
5-second bucket since 2026-08-10T00:00:00Z
33,554,432 buckets, exhausts December 2031| Canonical | T-02wt0kx |
| Filename | docs/ticket/02wt0kx-slug.md |
| Alphabet | Crockford base-32, lowercase: 0123456789abcdefghjkmnpqrstvwxyz |
| Input | T-02wt0kx, T02wt0kx, 02wt0kx, any case; i/l map to 1, o maps to 0 |
| Expiry | past the final bucket, allocation refuses rather than wrapping or widening |
| Ordering | lexicographic, to the bucket; sequential local creations are always ordered |
The alphabet excludes i, l, o, and u so an id read off a screen during a call is unambiguous. Every position is fixed-width and its ASCII order matches its digit order, so plain string comparison sorts by creation time.
The T- prefix makes the id a distinct token: T-[0-9a-z]{7} matches nothing in English prose, where a bare T prefix would match Tuesday and Testing. That is what makes git grep and auto-linking ticket references from RFDs and other tickets reliable.
Exact subcommand names win over id parsing. comment and promote are seven characters that fold onto the alphabet, so both parse as ids; the bare-id alias applies only when the first argument is not a subcommand.
The filename is the only place a ticket names itself
The id is in the filename and nowhere in the document: the heading is # Title, and a reply is - **Re**: #1 rather than - **Re**: T-02wt0kx#1.
Two copies of one fact can disagree, and the tool that repairs a collision is precisely the one pointed at files somebody has been hand-resolving. With one copy, jp ticket refresh and jp ticket migrate are renames — nothing inside the file has to be rewritten to match.
This diverges from RFDs, which carry the number in both the filename and the # RFD 102: heading. That is deliberate: RFD numbers are permanent by policy and renumbering is rare, while ticket ids are reassigned as a normal part of resolving a collision.
The cost is that a ticket read outside its filename — pasted into a chat, quoted in an issue — carries no id, and git grep T-02wt0kx finds references to a ticket rather than the ticket itself.
The time component is not a contract. Nothing may parse it back out of an id or depend on how ids are generated. The alphabet and the seven-character width are contractual: they appear in filenames, CLI arguments, .board.json, and site routes.
Allocation
Draw the two random characters uniformly and redraw if the resulting id already exists in docs/ticket/.
When the computed bucket equals the bucket of the highest id already present, increment the previous id's tail instead of drawing. Five ticket_create calls in one turn execute milliseconds apart and land in one bucket; incrementing keeps them in creation order, so a ticket that references an earlier one never sorts above it. This is the same guarantee ULID gives for same-millisecond generation. If the tail cannot increment, advance to the next bucket and draw fresh.
If the highest local id sits in a future bucket — a ticket merged from a machine with a fast clock — ignore it and use the computed bucket. Otherwise one skewed clock drags every subsequent local timestamp forward.
What changes in RFD 100
| Before | After | |
|---|---|---|
| Id form | T0042 | T-02wt0kx |
| Filename | 0042-slug.md | 02wt0kx-slug.md |
| Allocation | monotonic counter file | time bucket + random, local monotonic |
.counter | authority | deleted |
| Ordering | strict | to the 5-second bucket |
| Reuse | never | not guaranteed |
| Heading | # T0042: Title | # Title |
| Reply | - **Re**: T0042#1 | - **Re**: #1 |
A deleted ticket's id is no longer retired. The local check only sees files that exist, so a later creation in the same bucket can redraw it, and CI cannot catch that — only one file ever claims the id.
Everything else in RFD 100 stands: the file format, the metadata block, comment references (T-02wt0kx#1), the board, and GitHub import.
Drawbacks
Collisions become possible instead of impossible. Two clones can draw the same id in the same 5-second bucket; at 1,024 values that is rare.
CI reports the duplicate on the pull request that would introduce it, so the loser is always an unmerged branch: its commits are still rewritable and every reference to the id on that branch is unambiguously its own. jp ticket refresh assigns a fresh id and rewrites what the branch introduced (git diff main...HEAD) — not the whole repository, where the token already means the winning ticket. This depends on branches being up to date before merging; otherwise both land and neither CI run ever sees the pair.
T0042 is nicer to read than T-02wt0kx. That is the price of the property that matters more.
Alternatives
ULID (26 characters) and UUIDv7 (36) both solve this and are longer than the whole id proposed here. A plain random opaque id drops the time component, and with it the ordering that makes ls docs/ticket/ and the board read in creation order.
Implementation Plan
Id, allocation, and migration.
TicketIdbecomes a fixed-width base-32 newtype with lenient parsing;store::allocate_idcomputes the bucket, draws or increments the tail, and no longer reads or writes.counter. The four existing tickets migrate in the same change: all landed ine4f8b96at2026-08-10T08:30:28Z, which is bucket005zd, so they take sequential tailsT-005zd00throughT-005zd03. Grep for the old ids first —.board.jsoncarries at least one. Splitting this in two leaves the repository in a mixed format needing transitional parsing worth more than it saves. Commit messages that already name the old ids stay dangling.Site readers.
docs/.vitepress/config.mts:188,loaders/ticket-shared.mjs:29,157, and the^# T\d+:title match all gate on the four-digit form and drop every ticket after migration.loaders/rfd-shared.mjsrecognizes ticket links by the old filename shape.Duplicate detection and repair. Two files claiming one id is a hard error from
store::listand from the docs build, which already aborts on duplicate RFD ids and gains the same check for tickets.jp ticket refreshassigns a fresh id and rewrites the references its branch introduced.In-flight branches.
just ticket-migrateconverts any ticket left in the old shape on a branch, deriving each bucket from the file's git add-date so ordering against tickets already onmainholds. The docs build rejects aNNNN-slug.mdfilename outright, so a branch that merges without running it fails rather than losing the ticket silently.Amend RFD 100. Id form, filename pattern, allocation, and the non-reuse claim.