The Sentinel specification is ten documents, and it was finished before a line of the system was written. That was the whole idea: decide what the thing is while deciding is cheap, then build it.
Changes to the spec were anticipated. I have a series of Dev Diary look-backs planned that address exactly this set of deltas starting next week. But barely had the first lines of code been written before a wholly different gap surfaced: registers!
Correcting specifications, recording decisions, creating issues to come back to things later, hand-written notes...these are all fine in their own right, but they rot so fast... This is where registers earn their keep from day one.
Two false starts
The registers were the third arrangement, not the first. Let me be honest about that up front. First the story of the short-shelf-life solutions I mentioned.
Stage one: PR bodies. Anything getting deferred, assumed or decided-in-passing was written in the pull request. This is a natural default and it is not nothing; it is at least written down, next to the diff that motivated it. Where it fails is on retrieval and discovery. Nobody greps forty merged PRs to find out whether a thing was deliberate.
Stage two: one file. So it went into docs/deferred.md: blocked work, overdue work, measurements, and findings about the specification, all in one place. Wonderfully arranged by category with notes and even some commit hashes. Better. It lasted... hmm... a few days if I am generous. Why?
It was carrying four different kinds of statement in one file. Because its content was sourced from PRs merged, it organised along similar lines and mixed findings and deferrals into one because it lacked a set of organising principles to keep it disciplined.
A file that holds four kinds of thing has no rule for what belongs in it, so it also has no rule for what has fallen out of it. Nothing was lost dramatically. Things were lost quietly, which is worse, because a register you have stopped trusting is one you stop reading.
And it had a twin. The driver-host repository had grown its own docs/deferred.md, on the same convention and for the same reasons, so the same mistake was now being made twice, in two repositories, about one specification. It was time to do things properly. Enter, the Registers...
Any deferral, every decision or a freshly discovered risk goes in the register that covers the specification, naming the repository it belongs to. Numbered, immutably and durably, and referenced. Importantly, never repeated elsewhere - when the temptation to do so arises, squash it, and link back to the register that owns it instead.
One set of registers spans both repositories, deliberately. Two registers on one specification is two places to look and one to forget, and the seam between two repositories is exactly where a row goes missing; build step three is split across both, so its deferrals were too. Code does not move; documentation does.
The four registers
docs/
spec/ ← normative. Ten documents. Nothing outside this defines anything.
REQUIREMENTS.md ← SR-* what the system must do, grouped by owning document
CN-* the constraints that may never be violated
COMPONENTS.md ← C-* components, each with a build status and its package
I-* integration contracts
DECISIONS.md ← D-* every choice taken where the spec was silent, superseded ones kept
ROADMAP.md ← CP-* checkpoints: evidence that does not exist yet
A-* assumptions
R-* risks
OI-* open questions - gaps in the specification
DW-* deferred work - code that is owed
spec-deltas/ ← one audit per completed build step
Everything except spec/ is derivative. That word matters rather more than it looks:
Nothing in a register defines anything. They point at
docs/spec/, which is normative; a register that restates a mechanism has created a second home for it, which is the failure the specification exists to prevent.
The registers number what the specification already says, so that a component, a commit, an Issue or a review can cite it. SR-14 is not a requirement written in REQUIREMENTS.md; it is a pointer with a stable handle, and the requirement itself lives in the document that owns the mechanism. Where the two disagree, the row is the bug. Always the row.
Then a precedence order, because two documents will disagree eventually and I would rather settle it now than in the moment:
- On what a mechanism is, or what a term means - the owning specification document wins, always.
- On why a decision was taken where the specification is silent -
DECISIONS.mdwins. - On what is built right now -
COMPONENTS.mdandROADMAP.mdwin over any document's description of intent. - On implementable work - the GitHub Issue wins over all of the above.
Without that written down, every disagreement gets settled by whoever happened to notice it, on the day, in whatever mood they were in. And half of those settlements go the other way next month.
The distinction that is actually load-bearing
If you take one thing from any of this: an open question is not a task, and shipping code does not close one.
An OI-* is a gap in the specification - something that could have been decided and was not. A DW-* is code that is owed. In the moment they feel identical. They are not, and getting them confused is filed as a risk against my own process:
R-2 - A specification gap is "closed" by the commit that routes around it. The work that reveals a gap is almost always the work that works around it, so a finding filed as a task is struck by the commit that proves it real.
Which is exactly how it goes. You are building, you find the document never said which way a case falls, you pick a reading, you ship. If that finding was filed as a task, the task is now done. The specification is still silent - and nobody will ever notice, because the code works. That last part is the one that gets you.
So an OI-* is discharged by amending the owning document and by nothing else. Where a gap was routed around in code, the reading taken gets recorded as a D-* and the open question stays open, pointing at it. Two rows, two lifecycles. One says what the code does; the other says the document never said.
Snippets from the first three steps
Some of what actually landed in the registers as the build got going, quoted as written. The shape of the thing rather than the content of it.
The register vocabulary managed to break the specification's own citation rule, more or less immediately:
OI-1 - Introduction, Retention & Compaction. There is no build step that owns retention policy values. In the absence of one, this repo invented "step 10" - the document's reading-order number wearing a step's clothes - and it has spread into the register, the self-point catalogue test's deferral map and several docstrings. It also directly contradicts Introduction's rule that nothing cites a document by number.
Which is why every specification document here is cited by title, in italics, and never by number or path. The rule pre-dates the incident. The incident is what made it enforceable, because there is now a row explaining precisely what happens when it lapses.
The constraints had the opposite problem. Real, load-bearing, and entirely uncitable:
OI-17 - Introduction. The constraints have no numbered home in the document that states them. Introduction carries twenty-two Conventions - the properties everything else is measured against, each extracted from a specific error - in a document that says of itself "this note is the map and the rationale. It defines nothing." A definition found there is stated to be a bug in the note, and yet these are the closest thing the system has to invariants. They are also uncitable: every reference to one quotes its prose.
They are indexed as CN-1 to CN-22 now, which is an index and not a copy. The argument for each one stays exactly where it was.
An assumption is a different animal again, because work is already resting on it before anyone thinks to check:
A-1 - The test environment has TimescaleDB, not plain Postgres, and NATS started with JetStream. If it is wrong: ~95 tests skip rather than fail and the suite reports green; the tell is the skip count and coverage near 80% rather than ~97%. Tested by: every run - a correct one has 0 skipped.
A green suite that is green because a hundred tests did not run is the most expensive kind of wrong. And it is invisible unless somebody wrote down what the number is supposed to be.
A good many decisions turn out not to be about the system at all, but about the ground it is standing on:
D-07 - Enum members are quoted in YAML. YAML 1.1 resolves bare
on,off,yesandnoto booleans, which would have turned theswitch_statecodebook into[true, false]silently. Not a specification matter - a trap in the serialisation format the specification chose.
No specification was ever going to catch that one. It is the format's trap rather than the design's, and without the codebook check it would have been found eventually by something behaving very strangely at runtime.
And occasionally a decision is really a finding about the tests:
D-14 - A guard with no caller is not a guard. The check passed its fixture and neither real call site supplied a baseline, so the operation it was meant to refuse failed with a different error, from a different check, that happens to look like the guard working.
That one is worth considerably more than its two lines. A test that exercises a function directly, plus a codebase where nothing calls that function in anger, gives you a green build and an unarmed guard - and the failure presents as the guard working correctly. I have no idea how many more of those there are. Presumably not zero.
Then the category for numbers that do not exist yet. A checkpoint says what produces it, what it must yield, what it unblocks, and one more field that is the whole point of the exercise:
CP-1 - Ingest throughput against a populated database. Current value: 4,369 obs/s, taken 2026-08-31 against freshly-truncated hypertables with the three aggregates defined and no refresh policy. Steady state is 220/s, so utilisation ≈ 5%. Do not pre-decide: that the margin holds. Re-taking can only move the figure down.
Do not pre-decide is the field that earns the register its keep. A number measured under favourable conditions reads as headroom, and the caveat only survives if it travels in the same row as the figure. That is R-3 - and R-3 is retired by re-taking the measurement, not by anyone deciding it is probably fine.
The status marker that lies
COMPONENTS.md and ROADMAP.md mark every entry built, partial, or not started. Partial is the interesting one:
A 🟡 is a claim about what is missing, not only about what is there. It is the marker most likely to be wrong, because it is the one that requires someone to have looked.
So partial is only legal when what is missing is enumerated, with a deferred-work row behind each gap. A 🟡 with one named gap and three real ones is worse than an honest "not started"; it has spent the credibility of a status marker to buy an inaccuracy.
Which is where the list of things reviewers are asked to attack begins: any 🟢 that is really a 🟡, and any 🟡 whose account of what is missing looks suspiciously short.
The drift check
None of this stays true on its own. Registers rot exactly like the notes they replaced, unless something makes them false loudly. So there is a ritual, and it does not vary.
At the end of each build step: enumerate the owning document's exit criteria and map each one to a test. A criterion with no test is a finding, not something to quietly fix - all three audits so far have turned one up. Then audit the code against the documents rather than against the commit history, because the commits record what I intended and only the code records what I did. Write it up as spec-deltas/build-step-N.md, in two halves: what changed during implementation, and what the step did not deliver. Then route every finding into exactly one register, which is the part that actually matters.
On each pull request: if it defers something, the DW-* row lands in the same commit. If it adds a mechanism, that mechanism's signals go into the observability document in the same commit. That rule exists in writing because I broke it once.
The audits keep earning their place. The third one found a paragraph in a register asserting the opposite of what the code did - not drift between the code and the specification, but drift between the register and reality, in a document whose entire job is to describe reality. Humbling.
Why this shape, specifically
Sentinel is built almost entirely with coding agents, and that has shaped the registers more than anything else here has.
An agent opening a session has read the repository and none of my recollection. It cannot know that a field is mandatory because of an argument three weeks ago, or that a number is provisional, or that a gap in a document was spotted and deliberately left open. Every one of those is something I would have supplied from memory without noticing - and, if I am honest, supplied differently on different days.
So the project's CLAUDE.md does not explain the registers. It points at them, and says to read the index before adding to any of them. What that buys is that a decision can be cited rather than re-derived. A decision that gets re-derived every time gets decided differently at least once.
Superseded decisions stay for the same reason:
A decision that was overridden is more useful than one that was erased, because the override is itself evidence about what the design gets wrong.
And the registers are themselves under test. test_register_integrity.py asserts that ids are contiguous and unduplicated, and that a requirement is never both a bare row and a set of sub-ids. That is the specification's own convention - every claim a document makes has a test that would fail if it stopped being true - turned around and pointed back at the process documents. It felt faintly ridiculous to write. It has caught things.
What is still unowned
One gap is worth ending on, because it is open and I do not have an answer for it:
OI-7 - Introduction. Nothing reads the documents against each other. All three cross-document gaps found so far were found by building rather than by reading, and each sat between two documents that were each internally consistent.
The single-owning-document discipline optimises for consistency within a document, and it works. The price is that the seams between documents are where the gaps actually are, and nothing currently looks there. The drift check runs per build step, so every seam does get inspected eventually - by the build, at the point where it costs the most to find.
A real hole in the process, then. Filed as an open question, in the register whose job is to hold the questions the process cannot yet answer. Which is at least the machine working as designed.
Tomorrow, Wednesday 9 September - The Semantic Model. Part two of the specification proper: why the primary key for every point in this system is a dotted string you can paste into a grep, and never a UUID.
Next Monday - the diary reaches build step one, and what the first audit found in a step that had already been called complete.
Top comments (0)