sprig and prose docs classic docs
Reference› ADRs› ADR028 RecordFactsInTheUniverse

ADR028 RecordFactsInTheUniverse

Title: Record facts in the universe

  • Status: Accepted
  • Decided: D2026-09-24
  • Follows: ADR022 ReceiptBoundary, ADR027 DatesAsNames
  • Revised by: ADR029 EdgeBlocks
  • Affects: adr

Context

Every record here used to open with a header: the number and title in the heading, then status, date, lineage and what it affects. Most of those facts had been edges in universe/adr.prose since the records became concepts, so each was written twice, and the README's index wrote it a third time. Nothing kept the copies together.

They had drifted, in both directions. The universe held links no header stated: two records never said what they follow, and three were missing the other half of a related link another record had declared. The headers held twenty-one package entries the universe did not. And seven files could not be matched to their concept by heading at all, so any comparison that joined the two that way could not see the gaps in those seven.

Meanwhile the docs began generating pages from the universe. An error page cannot disagree with the universe, because it is made from it. A record's page could be made the same way, with its header from the edges and its text from its file, except that the date had nowhere to live. 027 settles that.

022 left one case open on purpose. For code, a receipt points across a real gap: a file is evidence of a concept without being it. "For an ADR, the markdown file is the decision," so a fact about the document and a fact about the concept are the same fact. That record ended by saying "an artifact that is its concept needs its own record, and that record has to decide something this one does not." This is that record.

Decision

A record's facts live in the universe, and its argument lives in its file. Nothing is written in both.

The facts are edges on the record's concept in universe/adr.prose:

  • its number, a code node such as ADR012, modelled the way error codes are, through hasAdrNumber
  • its title, a title block shown beside its name
  • its status, through hasStatus
  • its dates, as names (027): proposedOn while it is a proposal, decidedOn once it is decided, and recordedOn when a retrospective record was written materially later than its decision
  • its links: follows, revises, supersedes and related
  • what it affects, as packages and constructs rather than paths

The file holds the argument: context, decision, consequences, alternatives and evidence. It opens with a marker naming its concept, and the receipt that marker produces is the only thing joining the two. A receipt records where the file is, which is a fact about the evidence, and nothing about the decision. So 022's rule holds even here, where the document is the decision: the split is made by kind of fact rather than by object. A fact about the decision is an edge. Everything that argues for it is the file.

A record writes only its own side of a link. The universe mirrors every relationship, so an earlier record shows what followed, revised or superseded it without being edited. A note about one particular link, such as which part of a ruling was revised, goes in a relates block for the pair, where both records show it.

Pages are generated. The docs build each record's page from both halves: a header from the edges, then the file's text. The list of records is generated from the same edges, so the README no longer keeps an index.

Consequences

What this makes easy. A header cannot disagree with the universe, because it is the universe. The list of records cannot go stale. Writing a new record never means editing an old one. The field guide reads the same facts the docs do, dates included, so a day's page shows what was decided on it.

What it costs. A record read on its own, on GitHub, opens at its context. Its number, title, status and dates are in the universe, the docs and the field guide, and not in the file; the file name still carries the number. Writing a record now means editing two files, the markdown and adr.prose, and the README says how. And a page's text is only as current as the last sprig observe, because receipts are how the docs find a record's file. A record edited since then shows its earlier text until observe and the docs projector run again, and the generated list says so.

What is not carried over. Headers listed where a decision landed as paths: packages/prose-parser, archive/scenes, .changeset/, the root README. The packages survive as affects edges to the package concepts. The paths do not, and neither do the entries with no concept at all. A path is evidence of where a decision landed in code, not a fact about the decision, so it was the wrong kind of thing for either half of this split. Its right home is a citation: a marker in the code naming the record, collected by receipts. Nothing writes those yet.

What to watch. Each record's concept also has a describe, a second and shorter telling of the same decision, written for the field guide. The two tellings can disagree, and nothing compares them. That was already true; it is more visible now that the facts cannot drift and the prose still can. And the far side will be tempting to write, because an old record feels as though it should mention what replaced it. The mirror already does. Writing it by hand is how the copies started.

Alternatives considered

Keep the headers and check them against the universe. A check comparing each header with the edges would have caught all of the drift above. Rejected: a passing check still leaves two copies, and the next edit reopens the gap. Generation removes the second copy; a check only watches it. A check of exactly that kind was used once, to prove the generated headers said everything the written ones did before the written ones were removed.

Frontmatter, fenced regions or templates in the files. Keep the facts in each file, in a block the docs parse, or generate part of the file and leave the rest authored. Every version makes one file half authored and half generated, and a generated half is one edit away from being edited by hand. It is also where the dates would have become values. Rejected.

Put the facts in the marker. A marker carrying a status or an affects list. 022 refused this in advance: a receipt that describes its target is not a receipt.

Put the argument in the universe too. Then a page would need no file. But the argument is markdown, with tables, code and headings, and markdown stays outside prose (015, 016). Rejected.

Keep the index by hand. It is the third copy. Rejected with the second.

Keep writing both sides of a link. What the headers did, with Revised by and Superseded by. Rejected. It edits old records for new reasons, and every missing related link in the drift above was a far side somebody did not write.

Evidence

The drift was measured with every file joined to its concept through a hand-checked mapping, not by heading.

  • All twenty-six statuses agreed, and no header claimed a link the universe lacked.
  • The universe held five links no header stated: 010 follows 016, 011 follows 009, and the related links between 011 and 026, 018 and 025, and 025 and 026, each declared on one side only.
  • Twenty-one package entries were in headers and not in the universe: sixteen for the parser, three for the UI and two for the CLI.
  • Seven files could not be matched to their concept by heading: 001, 003, 005, 006, 009, 010 and 024. An earlier comparison that joined by heading found 011's missing link and missed 010's, because 010 was one of the seven.
  • Three pairs of records carried a note on a link: "the relates exception", "the neither-endpoint fallback" and "parallel thinking over the same ground". Two of the three were written in both records.

This is the agreement check's last run before the headers were removed.

26 compared, 2 new shape, 0 failures.
Allowed: 10 mirrored links, 3 far-side related links, 2 follows omissions, 5 affects entries with no concept.
7 phrases matched their pairing notes, across 5 pairs.