Development log — August 2026
46 entries. All books.
Contents
- 3 Aug 20:35 — Extracting the project-memory framework out of strata-g
- 3 Aug 21:02 — Principles become fragments, and collected turns out not to mean generated
- 3 Aug 21:19 — The filename is the code, and the rename removed a copy rather than adding one
- 3 Aug 21:50 — Four bare codes were a hole in the lint with ten real defects in it
- 3 Aug 22:13 — Foreign decisions get a namespace, and the first version bent the tool around one repo
- 3 Aug 22:56 — Two badges that could never be wrong, replaced with two that can
- 4 Aug 03:27 — A decision cited as a principle
- 4 Aug 03:44 — The devlog becomes a journal, and the migration’s timestamps came from the commits
- 4 Aug 15:37 — The read/write boundary: record/ for filing, docs/ for reading
- 4 Aug 17:09 — A remote learns its schemes, and the url-ok loop closes on schedule
- 4 Aug 17:29 — Numbers were the special case: uid remotes and one parser for a code’s anatomy
- 4 Aug 18:12 — Wikilinks: the brackets are consent, and typing them flips the rules
- 4 Aug 18:18 — Parallelism measured first: the probes were the win, the rest is seams
- 4 Aug 18:44 — Shipping: the wheel that worked was working by accident
- 4 Aug 20:05 — The collector grew the changelog shape, for strata-g’s scriv retirement
- 5 Aug 13:34 — A checking job runs nothing that writes
- 6 Aug 20:48 — Branding and copyediting
- 7 Aug 04:59 — The CLI sheds the commands that were only the module layout showing through
- 7 Aug 15:55 — Reports become views a badge can land on, and created: fills itself in
- 7 Aug 16:48 — A blunt directive and a fixture prefix, both priced in visibility
- 7 Aug 18:17 — The ‘never’ comes out of ADR-007: enforcement becomes a dial
- 7 Aug 18:26 — One scaffold for every entry kind, fired on its own record
- 7 Aug 18:35 — The agent file becomes a map
- 7 Aug 19:05 — Idiomatic Fire, drafted: what the rewrite cost and bought
- 7 Aug 23:48 — The rename that refused to stay small: DP→GP becomes a migrations doctrine
- 8 Aug 00:19 — The link that was wrong in both frames: characterizing the journal link-frame bug
- 9 Aug 21:44 — Building the site was a reference-integrity check the lint doesn’t have
- 10 Aug 00:49 — The first look at the published site found what no build check could
- 10 Aug 03:10 — Branding the site: a rasterizer reads an icon before any human does
- 10 Aug 03:42 — The branding that never shipped: a squash merge photographs the branch, not the branch name
- 11 Aug 21:39 — Generating the configuration reference
- 11 Aug 23:36 — Config-first adoption: replacement semantics and a config-driven init
- 12 Aug 03:51 — Implementing merge allocation: aliases are the load-bearing half
- 12 Aug 06:02 — Dogfooding merge allocation, and finishing rung one
- 16 Aug 16:51 — Ninety-nine dead links behind eleven clean lints
- 16 Aug 18:24 — The five words held; what they mean drifted
- 16 Aug 19:02 — The field was green because nobody was judging
- 16 Aug 19:30 — Four descriptions and no name
- 22 Aug 06:34 — The schema reference was somebody else’s file in everybody else’s repo
- 22 Aug 21:40 — The documentation rewritten from a flushed skeleton
- 24 Aug 20:37 — What a downstream adoption showed about the docs
- 24 Aug 21:33 — Measuring a downstream config before proposing anything
- 27 Aug 03:26 — Firing the remote-drift guard on a real pin
- 27 Aug 04:43 — URL pins fired end-to-end, and where stable bytes hide
- 28 Aug 04:30 — Endorsements travel through a rename
- 28 Aug 05:31 — The regex that parsed its own output
Extracting the project-memory framework out of strata-g
2026-08-03 20:35:05
Extracting the project-memory framework from strata-g into Luria (ADR-009). The machinery arrived as nine scripts and six test files that had grown inside one repo over months. Porting was mostly the work of finding what was load-bearing versus what was merely true of strata-g, and the answer was less obvious than expected: the hardcoded issue URL and source globs were the easy half, but the interesting constants were the ones nobody had noticed were constants at all — that fragment directories collect into files one level up, that two specific files are dated records rather than current guidance, and that “ADR” was baked into an annotation verb (adr-ok) that had already been generalized once during its own construction, for exactly this reason.
The generalization landed as a luria.toml with defaults for every key, which is what lets luria init bootstrap a project that has no config yet. The alternative — threading arguments through every entry point — was rejected on the same grounds as DP-4: the second caller forgets one, the linter and the fixer end up covering different files, and the failure mode is a CI error whose suggested remedy doesn’t work.
Two things surfaced that were invisible inside the original repo. First, the test suite was quietly corpus-dependent: several tests began RETIRED = next(d for d in DOCS.values() if not d.active), which works in a project with retired decisions and raises StopIteration in one where every decision is Active — which Luria is, on day one. Those tests now build the record they need with a fixture. That is strictly better than it was: a test that leans on whatever the corpus happens to contain gets weaker as the corpus changes, and it can’t state the case it is actually testing.
Second, the very first luria lint on Luria’s own record failed — docs/README.md was missing an index entry for the devlog. That is the machinery working before anyone asked it to, and it is the argument for ADR-009’s dogfooding clause: the index-completeness check had been written months earlier for a different repo and had never been run against a fresh one, so “does this work on a project that isn’t strata-g” was an open question until the moment it answered itself.
One deliberate non-port: strata-g’s make-wrapped CI convention and its docs-only fast path stayed behind. Both are real decisions, but they are about that repo’s CI shape rather than about project memory, and a package that ships opinions about someone else’s build matrix is a package people fork rather than adopt. Luria’s Makefile is a convenience; the CLI is the contract.
The naming decision, and the corpus’s first retired document. The package was very nearly called chester, after Chesterton’s Fence — a fence shouldn’t be removed until you know why it was put there, which is what a decision record answers. That reasoning is now ADR-010, Superseded, kept intact rather than deleted.
Three problems retired it, and none was aesthetic. The allusion names one failure the record prevents rather than the faculty it supplies — nothing about failed approaches, operator-facing changes, or principles that stop an argument recurring. It doesn’t survive shortening: “chester” reads as a name, not a reference, which is what ADR-010 itself rejected acronyms for. And it frames the record defensively, as an argument against change, when knowing why a constraint exists is precisely what makes it safe to remove.
ADR-011 names the package after Alexander Luria and The Mind of a Mnemonist, his case study of a man who could not forget. The cautionary half is the useful half: Shereshevsky’s total recall was a burden — detail crowded out meaning — which is the exact pathology a project record acquires if nothing is ever collected, superseded or summarized. The name carries both the goal and the failure mode, which the fence did not.
Keeping the pair also gave the corpus something it lacked: a real retired document, and therefore a real exercise of the whole acknowledgement loop. ADR-011 legitimately cites the decision it supersedes five times, in prose and in its own frontmatter status line — so it carries an inactive-ok-file directive, which is also the only scope that can cover a frontmatter citation, since the directive parser reads HTML comments and YAML has none.
And per DP-6, the guard was fired before being trusted: adding one unacknowledged mention of the superseded decision to docs/project-memory.md produced 1 retired document(s) cited unacknowledged … docs/project-memory.md:114, and removing it returned the report to no unacknowledged references (5 acknowledged). Provisioned is not working; this one works.
Principles become fragments, and collected turns out not to mean generated
2026-08-03 21:02:11
Decomposed the design principles into fragments, and discovered that the interesting question wasn’t the one being asked. The ask was straightforward — one file per principle, frontmatter with a version and backlinks to the decisions that shaped them — and it landed as docs/principles/dp-00N-*.md plus a render setting on Scheme. The reasonable objection came immediately: aren’t we basically doing that already with the changelog? I think the only difference here should be ignoring the frontmatter. The shape is indeed identical — concatenate fragment bodies into a view — and “strip the frontmatter” is a real difference, but it is not the difference, and getting that wrong would have produced the wrong mechanism.
The difference is whether the sources survive, and it turned out to be a distinction ADR-002 already drew without naming. A collected view consumes its fragments: luria collect appends bodies at a marker and deletes them, so CHANGELOG.md accumulates and can only ever be appended to — it cannot be rebuilt, because the fragments that produced last month’s entries no longer exist. A generated view is a pure function of sources that persist, rebuilt from scratch every time. That is the only reason luria lint can tell you one has gone stale: staleness is render() != read(), and you cannot compute render() for a collected view. Collecting principles would have deleted the fragments — taking the version history that was the whole point with them — and made a hand-edit to the assembled document undetectable. So principles are generated, and it goes in outputs() alongside the decision index rather than in collect.py. That reasoning is now ADR-012.
A pleasing consequence: the two conventions had been quietly encoding the distinction all along. A collected view keeps an insert marker in its output, because collection appends to it again next time; a generated view uses a {placeholder} in a stub, which does not survive into the output, because generation rewrites wholesale. Same idea, opposite persistence. It would have been easy to “tidy” those into one convention and lose the information.
link_base had to learn a second kind of fragment, and the lint caught it before a human did. Links written in a docs/principles/dp-002-*.md fragment resolve from docs/, because that is where the text renders — the exact trap ADR-005 records for changelog.d/, arriving from a direction nobody was watching. Two of the first eight fragments had wrong links ([DP-6](dp-006-…) and (decisions/adr-006-…)), and luria lint named both. Generalizing Config.link_base() to cover document-rendered scheme directories fixed the class. The docs-index check needed the mirror-image fix: it had started demanding index entries for the DP fragments, and a reader opens the view, not the sources — scheme source directories are now exempt.
The anchors were the real bug, and they were nobody’s ask. Every [DP-N](../design-principles.md) link in the repo was anchorless — they resolved to the top of a 200-line page. Adding heading-derived anchors “fixed” it and produced link targets like #2-a-file-every-contribution-must-touch-is-a-lock--hand-out-fragments-generate-the-view, at which point the actual problem became obvious: a principle is a living document, its heading is expected to move, and a heading-derived anchor stops resolving silently the moment it does. That is fail-stale, the one polarity DP-3 rules out — and the argument was sitting inside the very principle being linked to. Since we own the rendering, render_document now emits <a name="dp-N"></a> beside each heading, keyed to the number, which is the thing that never moves. dp_anchors() prefers an explicit anchor and falls back to the heading slug, so a project whose principles are still one hand-written file keeps working — the fallback is what makes the convention adoptable before luria index has ever run. 31 existing links were rewritten to the short stable form.
Fired the guards, per DP-6. luria init into an empty tree, then luria index and luria lint on the result: 16 files written, 2 views generated, lint clean — which is the check that matters, because the template ships fragments now and a project that can’t lint what it was just given is a scaffolder nobody trusts. It also caught a seed link in template/docs/decisions/README.stub pointing at adr-003-…md, a file that exists in this repo and never in a scaffolded one; it now points at Luria’s own ADR-001 by absolute URL. Separately, a link resolver run over the whole corpus — link_base-aware, checking anchors as well as files — reports 207 internal links, 0 broken.
Two smaller things worth not rediscovering. os.path.relpath is the right tool for the influenced_by backlinks, not a hardcoded decisions/ prefix — the first version hardcoded it and would have broken for any scheme whose output isn’t in docs/. And Adr grew a prefix attribute rather than a second class: DP-003 and ADR-003 differ in exactly one string, and a Principle class would have been DP-4’s second implementation of the same thing, drifting within a month.
The filename is the code, and the rename removed a copy rather than adding one
2026-08-03 21:19:26
Renamed every document to its bare code and gave the title a frontmatter field — and the interesting part was noticing that this removed a copy rather than adding one. adr-004-generated-decision-index.md carries the title twice: once in the slug, once in the body’s H1. The slug is the worse copy of the two. No tool read it — the generator always took the title from the H1 — and correcting it costs a rename plus every inbound link, which in this repo was 157 links across 32 files. So it never gets corrected, and quietly becomes the oldest surviving statement of what a document is about. Adding title: to the frontmatter and dropping the slug leaves two copies where there were three, and the two that remain are both cheap to edit.
The five-decoder problem surfaced before the rename, not after. Resolving a code to a file meant globbing, and five places had each grown their own regex to do it: ^adr-(\d+), adr-{n:03d}-*.md, {prefix.lower()}-*.md, and two more. Textbook DP-4, harmless only for as long as the filename shape never changed — which is exactly the assumption this change breaks. Consolidating onto Scheme.filename() / number_of() / documents() first turned the migration into a one-line change instead of a five-site sweep, and the consolidation is now the thing that makes a third scheme cheap. Worth doing in this order deliberately: the temptation was to rename first and fix the fallout, which would have meant discovering the five sites one test failure at a time.
number_of() deliberately accepts a trailing slug. Luria writes ADR-013.md and reads adr-013-anything.md too, because an adopting project arrives with slugs and “rename everything before the tool will read your docs” is not an adoption story. For the same reason the short filename is not linted — it is a convention choice, not a defect, and the lint’s contract is that it fails only on things that are always wrong (ADR-007).
The H1 could not simply be deleted, so the pair got a guard. One copy would have been rung 1 of DP-3 — derive it, drift becomes impossible — and it was tempting. But a document that renders as an untitled wall of prose when opened from a grep hit or a raw link is worse for the reader than a lint rule is good for the maintainer, and the record is read that way constantly. Deriving the H1 by rewriting the source at build time was the other tempting option and is worse still: it makes every source file a generated file, and destroys the one property luria lint depends on — that you can tell a stale view from a source somebody edited. So: keep both copies, guard the property that they agree, name both spellings in the error so the reader doesn’t have to go diff them by hand. Rung 2, chosen knowingly, which is what DP-3 actually asks for.
The reports caught me inside a minute. The Scheme.filename docstring needs an example filename, and the first one written was ADR-010.md — which in this repo is the superseded chester naming decision. luria ref-status flagged luria/config.py:122 on the very next lint run, with the document’s title right there in the message so the mistake was obvious without opening anything. Changed the example to ADR-013.md, which is the decision that describes the convention and is Active. This is the second time the retired-document report has earned itself on an accidental citation rather than a deliberate one, which is a mildly different value proposition from the one it was built for.
A git trap worth not rediscovering. The migration inserted title: into each file and then ran git mv. git mv old new stages the rename using the index content of old — which was the pre-title: version — while the working tree keeps the edited content. Everything looks right until a git checkout -- <file> on the new path silently restores the version without the field. It bit exactly once, during a sabotage run, and the lint caught it immediately (no title: in frontmatter), which is a decent argument for the guard on its own. The safe order is git mv first, edit second — or git add between them.
Fired both directions of the new guard, per DP-6. Drifting a heading away from its title: produced docs/decisions/ADR-004.md: title: and the body heading disagree — 'The decision index is generated from frontmatter' vs 'The old name for …'; deleting a title: produced docs/principles/DP-003.md: no title: in frontmatter (see ADR-013). Both cleared on repair. Also re-ran the whole luria init → index → lint path on an empty tree (16 files, clean), and a link resolver over the corpus: 260 internal links, 0 broken — up from 207, because ADR-013 is a long document that cites a lot.
Four bare codes were a hole in the lint with ten real defects in it
2026-08-03 21:50:13
A question about four bare codes in ADR-009 turned out to be a question about a hole in the lint, and the hole had ten real defects in it. The question was reasonable: ADR-032 sits there in prose, unlinked, and the whole point of ADR-005 is that references are hyperlinks — so why isn’t that a failure? The answer is that it’s correct behaviour and the correctness is load-bearing. The linter and the fixer share one scanner precisely so the linter can never demand a rewrite the fixer wouldn’t make. ADR-032 is strata-g’s thirty-second decision; there is no local file; resolve() returns None; the fixer can’t write a link, so the lint says nothing. Working as designed.
But “the fixer can’t help here” and “there is nothing wrong here” are different claims, and the code had been conflating them. Instrumenting the drop took about ten lines and produced fifteen codes across the corpus. Four were stale strata-g numbering left in ported docstrings — ADR-187, ADR-188, ADR-123, ADR-158 — each citing a decision that says exactly the right thing in exactly the wrong project. One of them was worse than a bare code: [ADR-123](adr-123-adr-status-vocabulary-docs-lint.md), a link to a file that has never existed in this repo. The hyperlink lint skipped it because it was already a link, and nothing else was looking at it. That single line is the whole argument for the check: a broken reference that has been dressed as a working one is invisible to every guard that was in place.
The design question was what kind of check it should be, and the corpus answered it. Of the fifteen, four were bugs and eleven were deliberate — illustrative codes in docstrings (# implements ADR-157, fixes the problem ADR-061 caused), fixture numbers in tests (ADR-404, chosen precisely because it resolves to nothing), and quoted examples. A typo, a foreign decision and an example are byte-identical from inside a scanner. So: a warning, and an acknowledgement to retire the deliberate ones — the same shape ADR-007 already settled for retired documents.
unresolved-ok cost one inverted predicate. The parser, the three scopes, the comment handling, the stale-annotation reporting all came free, which is the first real evidence that ADR-008’s “a third directive is a name, not a new syntax” was true rather than aspirational. The inversion is the interesting bit and worth stating plainly: inactive-ok is malformed when it names a code that doesn’t resolve (it would excuse nothing), and unresolved-ok is malformed when it names one that does (there is nothing to excuse). Same check, opposite sign — so either annotation reports itself the day it stops applying.
Two false positives found by running it, both instructive. First: a code inside a URL. https://github.com/dmarx/luria/blob/main/docs/decisions/ADR-013.md contains the text ADR-013, and ref_status.scan() is deliberately unmasked (a reference in a code comment is still a claim). So the correct way to cite a foreign document — a link out, which is what ADR-009 now says to do — was being reported as a dangling local reference. It surfaced when luria init’s scaffolded output started failing its own lint: the templates’ comments point a new project at Luria’s decisions by URL, and every one of those URLs was being read as a citation. Masking URLs is not a convenience; it is what makes the recommended practice survive the check.
Second: writing this up. ADR-014 quotes seven of the stale codes as the specimens it is about, so the report immediately flagged its own decision record — correctly. It gets a file-scoped acknowledgement, which is the mechanism working exactly as intended.
A trap in the acknowledgement syntax, found the hard way. The first unresolved-ok-file: listing seven codes was wrapped across two lines for readability. DIRECTIVE_RE matches args with [^\n]*?, so everything after the line break was silently discarded — and the continuation line’s ADR-188 was then counted as a citation, not an argument. The symptom was visible (the report kept flagging all seven) but the cause was not. A directive is one line; docs/directives.md now says so. Making the parser span a whole comment fragment is possible and was not attempted — the args would then need to stop at something other than a newline, and the em-dash is optional.
Fired the guard, per DP-6. Appended See ADR-999 for the details. to docs/project-memory.md: luria lint reported 1 code(s) resolve to no document, luria ref-status named docs/project-memory.md:136, and removing it cleared. The corpus now reports zero unresolved and zero unacknowledged-retired, with 37 and 6 acknowledgements respectively — both counts printed on every clean run, because a suppression nobody counts is a suppression nobody notices (DP-1).
Badges, and one thing they exposed. Adding a licence badge meant checking there was a licence: pyproject.toml claimed MIT, the README said “MIT.”, and there was no LICENSE file. Written. Likewise a PyPI badge would have 404’d, because pip install luria — the first command in the README and in the adoption guide — does not work. That one is not a badge problem; it is a README shipping a command that fails, in a repo whose entire subject is documentation that quietly stops being true. Both install lines now say pip install git+https://github.com/dmarx/luria # not on PyPI yet.
Foreign decisions get a namespace, and the first version bent the tool around one repo
2026-08-03 22:13:27
Foreign decisions got a namespace, and the first version of it bent the tool around one repository — which took a decision, a supersession, and about four hours to notice. The idea is small: LU-ADR-013 composes a registered remote prefix with that project’s own code, one config entry builds the URL, and from there it is a first-class reference — the fixer writes the link, the lint demands it. That part was right in ADR-015 and is unchanged in ADR-016.
What was wrong was the resolution path built to serve the ancestor project, which is private and whose filenames carry title slugs predating every convention this package settled. No URL template can turn 032 into adr-032-changelog-ci-collection.md, so discovery learned to read a local clone and commit the resulting code→filename map. It worked; the demo was satisfying; deleting the clone and watching fifteen references still resolve felt like proof. It wasn’t. A resolution that depends on what happens to be on somebody’s disk is not reproducible — the committed lockfile could not be regenerated by anyone without that clone, which is the property that makes a hand-maintained projection untrustworthy in the first place (DP-3). The clever part was the tell: the mechanism existed to accommodate one repo’s historical shape, and the pilot’s shape is not the package’s problem. Discovery now reads public HTTPS and nothing else; a remote Luria can’t read gets a url template, not a credential path CI can’t reproduce.
Then I removed the ancestor’s remote entirely, which was one step too far and got corrected. Cleaning up the clone hack, I dropped the SG remote too and rewrote its citations as prose — “eight ancestor decisions, deliberately not enumerated”. Wrong: a reference has two halves that fail independently. The name (which project, which decision) is durable and works the moment you write it; the URL is derived and only works once the remote adopts the convention. Dropping the citation to avoid a broken link throws away the durable half to protect the derived one, and the prefix is what makes the name sayable at all — unprefixed, ADR-032 here is a claim about this project’s thirty-second decision. SG is registered again, its links are correct-and-early rather than wrong, and ADR-017 says so out loud so nobody “fixes” them. When strata-g is ported, every one of them lands at once with no edit here — and that --check run going green is the acceptance test for the port.
Removing the ancestor’s remote left the mechanism with no user but its tests, which is its own smell — a feature exercised only by tests is a feature whose integration is unverified. So Luria registers itself as remote LU, and it has a genuine consumer: the luria init scaffold’s templates used to carry pasted https://github.com/dmarx/luria/blob/main/… URLs so a new project could reach the reasoning behind a convention, and they now cite LU-ADR-013. LU needs no lockfile at all, because Luria follows ADR-013 — the code is the filename — so the dogfood demonstrates the convention paying for itself.
And the dogfood immediately found a hole nothing else could have. Writing is LU-ADR-001. into the scaffold’s index stub produced a bare, unlinked reference in the rendered index that no check reported: the lint skipped README.stub for not being markdown, and skipped README.md for being generated. Two exemptions with a gap between them, each individually correct. A stub is the one hand-written part of a generated view, so it is exactly where prose can hide. link_base already knew where a stub renders, so the fix was one glob — but nothing in the test suite would have found it, because the tests all reason about markdown.
The hard part of the composed code was precedence, not URLs. LU-ADR-013 contains a local-looking code, and four separate scanners can each read the tail out of the middle and quietly say something about the wrong project — the reference finder, the fixer, the citation scan, and the annotation validator. Each failure is silent and different: the finder would link a foreign code to a local file; the citation scan would count it as a citation of the local ADR-013, keeping a retired local decision looking cited forever; and the validator did fail, reporting unresolved-ok: UP-DP-004 as stale with “names DP-004, which does resolve here” — true of this project, irrelevant to the claim. tests/test_remotes.py has one test per mouth.
Two false confidences, both the same shape. Falling back to the code-only convention printed a confident URL for a document that has never existed. The rule that fixes it: discovery, once done, is authoritative — if a lockfile was read from a remote and a code isn’t in it, the answer is “no such document”, not a guessed filename. A never-refreshed remote still falls back, because “we haven’t looked” and “it isn’t there” are different claims. Separately, --check reported a private repo as fourteen broken links, since an anonymous request gets 404 for everything; it now probes the repository once and says unverifiable, keeping 404 for when it means something. A guard wrong on its first run is a guard nobody reads.
version: became standard frontmatter for every scheme, not just principles. For decisions it moves rarely — a decision that changes is superseded, never edited — but “rarely” isn’t “never”: a decision whose scope widens without its choice changing is a revision, and a reader needs that told apart from a fresh decision. It renders in the index only when it isn’t 1, because a column of ones teaches nothing.
The supersession itself is worth recording. ADR-015 was hours old when ADR-016 replaced it, and the temptation to just edit it was strong — nobody had read it, the diff would have been small, and superseding meant repointing eleven citations and adding two acknowledgements. The rule won, and it should: a record you can rewrite is a record that can’t be trusted about what you used to think, and a decision that lasted an afternoon is exactly the kind whose reversal is worth being able to see. The retired-document report earned itself here too — flipping the status produced eleven flagged citations within seconds, which is the repointing checklist written for free.
Fired the guard, per DP-6. luria remotes --check against the real public repo: nothing verifiable is broken. Then appended See LU-ADR-777 for more. to a page — LU-ADR-777: 404 — https://github.com/dmarx/luria/blob/main/docs/decisions/ADR-777.md, 1 reference(s) did not answer 200, cleared on removal. Real network, real repository, real 404.
Two badges that could never be wrong, replaced with two that can
2026-08-03 22:56:48
Replaced two badges that could never be wrong with two that can. “decisions — generated index” and “principles — versioned” are assertions about the repository that are true by construction: nothing could ever make them turn a colour anyone dislikes, which makes them furniture. The test a badge should pass is whether it tells a reader something they’d otherwise have to clone the repo to learn, and whether it can go bad. This record has exactly two such numbers — undecided documents, and retired-but-still-cited ones — and both were already computed, sitting behind commands nobody runs. That is the whole argument for ADR-018: the reports existed, the front page just wasn’t showing them.
The interesting constraint was that shields.io cannot compute either number. Both need frontmatter across the corpus and a full reference scan; there is no service to ask. The standard workaround is a shields endpoint badge reading a JSON file from raw.githubusercontent, which means committing that file and keeping it current from CI. Baking the number straight into the badge URL avoids the whole apparatus — no endpoint, no second artifact, no token, no gist — but the decisive difference is subtler: a baked-in URL is correct per commit, so a pull request that adds a Proposed decision shows the moved count in its own diff, while an endpoint badge always reports whatever main says. The cost is that the URL is now a derived value living in prose, and derived values in prose drift — which is what the rest of this package is about, so the answer was already sitting there. luria index rewrites a <!-- luria:badges --> region; luria lint fails when it is stale. Rung 1 of DP-3 with the staleness check making “derived” enforceable rather than aspirational.
A design detail worth not losing: the region is a region, not a file. The obvious implementation was to add README.md to outputs() so the existing staleness machinery covers it for free. That would have made is_generated(README.md) true — and the reference fixer skips generated files, so the README’s actual prose would silently stop being linted. Two mechanisms that each looked right, and composing them would have quietly disabled a check. So badges get their own comparison in check_generated_index, and README.md stays an ordinary linted file. Almost exactly the shape of the *.stub hole found the day before: one exemption plus one exemption equals a blind spot, and neither is visible from inside the other.
Colour choice is a claim, so: amber, never red. ADR-007 is explicit that citing a Rejected decision is often exactly right and a decision can be legitimately open for months. A red badge would assert a failure that isn’t one, and the predictable result is people learning to ignore it — the same dynamic that keeps flaky guards from being read. Green at zero, amber above, and the badge says look at this rather than you broke it. For the same reason cited but retired counts only unacknowledged citations: counting acknowledged ones would make the number go up when somebody does the right thing.
“All reachable schemes” meant generalizing luria pending, which had only ever looked at decisions. A Proposed principle is an open question in exactly the same way, and a report covering one scheme goes blind the moment a project configures a second — the failure ADR-006 exists to prevent, showing up in a place nobody had checked. Pending rows are keyed by code now (ADR-012, DP-004) rather than by bare number, which rippled through the report renderer and its tests. Remote schemes are not counted and can’t be: a remote’s status isn’t knowable from a URL, and fetching every foreign document to find out would make a badge depend on someone else’s uptime.
Fired all three guards, per DP-6. Adding a Proposed principle (not a decision — that was the point) moved needs decision to 1 and amber; an unacknowledged citation of the superseded ADR-015 moved cited but retired to 1; hand-editing a count to 9 produced README.md: badge counts are stale — run luria index. All three cleared on repair. The second one then happened for real without being staged: writing ADR-018 itself cites ADR-015 as the specimen it was fired on, so the badge went amber on its own and stayed there until the acknowledgement was widened from -block to -file scope. A guard catching its own documentation is a good sign.
A decision cited as a principle
2026-08-04 03:27:11
Got a decision’s rationale wrong, and the fix turned out to need a rule that didn’t exist yet. ADR-018 rejected the endpoint-badge alternative by invoking ADR-002: keeping a committed JSON file current means a bot commit per merge, and per-merge bot commits race in-flight rebases. Correct about ADR-002, wrong about this case. That hazard is specific and I’d flattened it into a slogan: CHANGELOG.md is appended to at a marker and its entries carry assigned numbers, so a bot commit forces in-flight branches to rebase into a conflict exactly where their own content goes, and a careless resolution silently drops somebody’s entry. A derived badge file has neither property — it is a wholesale rewrite of a computed value, and a conflict in it is resolved by regenerating. Nothing is lost, nothing is numbered, nothing is appended.
Worth naming the failure mode, because it is the one this whole package exists to prevent in documentation and I committed it in reasoning: a decision cited as a principle. ADR-002 records a choice made against a particular file’s shape. Quoting it as “bot commits are bad” strips the conditions that made it true, and a rule with its conditions stripped is exactly what gets misapplied to the next problem. The record has a separate layer for standing values, and the fact that a claim didn’t earn a place there is information.
The decision itself survives, for a reason I hadn’t written down: a baked-in URL is correct per commit. Open a pull request that adds a Proposed decision and that branch’s README already says needs decision: 1, in the diff, where a reviewer is looking. An endpoint badge always reports the default branch — so the one moment the number is most worth seeing, while deciding whether to merge the thing that moves it, is the moment it can’t. Needing no CI and no second file is the smaller half of the argument, and I’d led with it.
Then: how do you correct that? The record says never rewrite a decision’s body. Read literally, the only remedy is supersession — retiring a decision that is still in force, repointing its citations at a near-identical replacement, and telling every future reader that the choice changed when only an argument did. Do that a few times and Superseded stops meaning anything, which defeats the closed status vocabulary (ADR-003).
So ADR-019 splits the two cases: a changed choice is superseded, a wrong reason is corrected in place with a version bump and a history: entry saying what the old version claimed and why it was wrong. The insight that makes it safe is that “never rewrite a body” is objecting to silent revision — and a version bump plus a history note is the opposite of silent. The test for the ambiguous case: would a reader who acted on the old version have done something different? If yes, supersede; if they’d have done the same thing for a worse reason, correct in place.
Pleasingly, this is the first thing the version field has had to display for a decision. ADR-016 added it on the argument that a decision whose scope widens is a revision, which was speculative — this is a real second occasion, and it arrived within a day.
A cascade worth recording. Creating ADR-019 made ADR-019 a real code — and it had been serving as a fixture number in doc_refs.py and test_doc_refs.py, covered by unresolved-ok annotations. The inverted check fired immediately: annotation names ADR-019, which does resolve here. Better still, because a malformed annotation excuses nothing, the other code in the same annotation (ADR-163) fell out of cover too and got reported. Two levels of consequence from one new file, both surfaced automatically. Fixture codes now live at 900+ where nothing will collide.
Then came the part that mattered more than the rule: the docs were still telling everyone the record was frozen. ADR-019 settled what to do, but “never rewrite a decision’s body” was written in six places — the traffic rules in ADR-001, the doctrine page, both index stubs, both decision templates, CLAUDE.md — and every one of them stated it absolutely. A rule stated absolutely in six places and refined in one is a rule that will be applied absolutely, because nobody reads all seven.
Worth noticing why the absolute phrasing was so sticky: it is genuinely the right instinct, and the failure it prevents (a record quietly revising what it used to think) is much worse than the failure it causes (a wrong paragraph nobody dares fix). Softening it needed the softer version to be equally memorable, which is why every site now says the same sentence — the objection is to silent revision, not to editing — rather than each hedging in its own words.
ADR-001 went to v2, which is the mechanism proving itself on the document that states it. Its clause now says supersede when the choice changes, with a history: entry recording that version 1 read as “documents are frozen”. That is the cleanest possible demonstration: the decision whose over-broad wording created the problem was fixed by exactly the procedure the fix introduces, and a reader can see both versions. Superseding ADR-001 would have been absurd — it is a four-layer taxonomy, and only one clause of one bullet was wrong.
The examples section is the real deliverable. Abstract permission (“documents may be revised”) changes nobody’s behaviour; a list of things that already happened does. Every shape had already occurred here without being collected in one place: ADR-010 → ADR-011 and ADR-015 → ADR-016 for changed choices, ADR-018 v2 for a wrong reason, DP-2 and DP-3 v2 for reworded values, and — the one I had to go looking for — ADR-016’s consequence being falsified by ADR-017 while its decision stayed correct. That fourth shape had no name before writing this section, and it is distinct: a consequence is an observation, and observations expire without the decision they sit under being wrong.
One tell that the section was needed. Writing it produced two unacknowledged citations of retired decisions — because naming ADR-010 and ADR-015 as the supersession examples is exactly the case inactive-ok exists for, and the report said so immediately. Then the -block scope turned out to cover the heading rather than the bullet list under it, so it had to widen to -file. Fixing that changed the cited but retired badge from amber back to green in the same luria index run — the whole loop, from writing a paragraph to a number on the front page moving, inside one command.
The devlog becomes a journal, and the migration’s timestamps came from the commits
2026-08-04 03:44:08
The devlog looked like a changelog and was mechanised like one, which cost it
three properties nobody had noticed it was missing. Both were fragment
directories collected into a file at a marker, fragments deleted. That is right
for a changelog — an entry is a claim about a release, and once it is in
CHANGELOG.md the fragment has served its purpose — and wrong for a devlog,
where an entry is a dated observation: true when written, and still true.
Consuming it throws away the only copy of something that never expires. The
distinction was already in the repo; ADR-012 drew it
for the principles document and then cited the devlog as an example of the
other side. So the fix was applying a distinction we already had to the case
that had motivated writing it down.
The three costs were: a shared insertion point (every branch appends at the same
marker, DP-2), ordering that came from commit
order rather than from the record — so a rebase could reorder history — and
unbounded growth in a file that can only be appended to and never rebuilt.
Identity is now the authoring timestamp: devlog.d/2026/08/03/211926.md, path
derived from created:, lint checking the two agree. Nothing to allocate,
nothing to collide on, and sorting is a pure function of the tree
(ADR-020).
A dated file per period was already on the record as rejected, in the pilot,
and finding that was the point of the archaeology. The half-memory being
chased was of a strata-g devlog system where fragments persisted and built into
smaller books. It never existed. One hit across every ref, dangling commit and a
400-commit git grep: adr-157-devlog-fragments.md:92, listing “append to a
dated file per month” as a rejected alternative — “narrows the conflict
window without closing it; two PRs in the same month still collide, which is
most of them.” Fragments were unlinked from the first commit onward, and
docs/devlog.md’s line count was strictly monotonic across its whole history,
so nothing had ever been nuked. The likely source of the memory is
SG-ADR-158’s
generated per-tag pages, which shipped the same day and are persisting sources
split into smaller generated books. Worth the hour: the rejected alternative is
the one this design had to beat, and it named the exact failure — per-period
files still share an insertion point. Per-entry files don’t.
Book granularity is configured because the right size is a measurement. The
pilot wrote 8,281 devlog lines over a 40-day span — about 200 lines a day, which
makes a monthly book 3,600–7,200 lines. That number is empirical and specific to
one project, so granularity is year | month | day in config rather than a
constant in the renderer. It is also a correction: I had estimated that corpus
at “roughly a year” of work from nothing but its size. An LLM has no
chronoception; passage of time is an empirical claim, and there are real clocks
in the file mtimes and the git history. git log said 40 days.
The back catalogue kept its real timestamps, from
git log --diff-filter=A --format=%aI -1 -- <path>. Stamping seven entries
with the migration date would have made the log’s first page a lie about a
record whose entire purpose is being trustworthy about when things were thought.
Two traps in doing it. Timestamps come out in the committer’s offset, and one
fragment was authored at -07:00 — normalising to UTC moved it from third-oldest
to newest, which is the order it was actually written in, and sorting the raw
strings would have silently got that wrong. And the bodies’ relative links were
written for docs/devlog.md, so they resolved from docs/; the books sit one
level deeper. Rebasing them is a bulk regex, and the regex is where the second
trap sprang.
A naive rewrite corrupted three quotations, and the guard that caught it did
not exist for this. Two entries quote broken links inside backticks —
[ADR-123](adr-123-adr-status-vocabulary-docs-lint.md), written with backticks
around it, is a specimen of exactly the defect that motivated the
dangling-reference check. Rewriting every ](…) in the file rewrote the
specimens too, turning a faithful quotation into a doctored one. What caught it
was test_every_generated_relative_link_resolves, which had never seen the
devlog before, because a collected view isn’t in outputs() and so has no
rendered form to check. Making the devlog generated put it under a guard written
for something else. The test then needed the same masking the hyperlink lint
already uses — a link in backticks is quoted, not asserted — which was one call
to doc_refs.code_spans, not a new rule.
Config.is_historical replaced a set-membership test that couldn’t see nested
files. Excluding dated records from the retired-reference report was
path.parent in {fragment dirs}, which is fine when fragments are flat and
silently wrong when they are devlog.d/2026/08/03/. It would have failed open —
seven entries’ worth of permanently unactionable warnings, drowning the signal
the report exists for. Now one method decides it, and it covers the rendered
books too.
All four new guards were fired before being trusted
(DP-6): an entry moved off its timestamp, an
entry with its title removed, a hand-edited book, and a version: bumped
without a history: entry. Each produced exactly one violation naming the file
and what to do about it. The scaffold was also run end to end into an empty
directory — luria init, index, journal new, index, lint — because a
journal that only works in the repo that grew it is not a feature of the
package.
The read/write boundary: record/ for filing, docs/ for reading
2026-08-04 15:37:48
The layout got restructured along the axis a first attempt missed, and the
closed PR is the interesting part. The first slice at “this repo is
overwhelming to visit” moved Luria’s own record into a meta/ directory —
segregating by whose content it was. It worked, was reviewed, and was closed
unmerged, because thinking out loud about what actually grated produced a
better diagnosis: two of four source containers marked .d and two not, a
generated document sitting beside its own sources, the decisions index buried
under the ADR- sort with its .stub next to it, and README.md meaning
“edit me” in one directory and “never edit me” in the next. None of that is
ours-vs-theirs. It is sources-vs-views — read-vs-write — and the meta/ move
would have left it intact inside both halves. The failed approach was not
wasted: it proved the mechanics (a record that moves together keeps every
internal link; only boundary-crossers rewrite) and its measurement — the
scaffold is 16 files / 28.6 KB against a ~300 KB record — killed the
token-deadweight framing that had motivated the wrong axis.
ADR-021 records the layout;
DP-9 is the value under it — generalized, on
review, from “the read/write boundary” to the philosophy the boundary applies:
structure is read before text, so affordances are spent deliberately, on
attention, on discovery, and as smells to read when they turn inconsistent.
The pilot’s
SG-DP-18,
“the affordance is the contract”, is the named sibling: that one binds
affordances to the truth, this one is about their reach.
One mechanism change carried the whole restructure: a scheme’s output
split from its dir. Everything else was configuration and moves. The index
and tag pages now render into docs/decisions/ while sources sit in
record/decisions.d/, with the rebase prefix computed by os.path.relpath
instead of the hardcoded "" / "../" pair — and prefix_for returns ""
when view and sources coincide, so a project on the old collocated layout
renders byte-identically and adoption never begins with a move.
The stub was the subtle case, and it produced a third link base. Sources
resolve from where they sit; fragments resolve from where they collect;
and now a stub — authored beside the sources, rendered in the view — resolves
from where the index renders. Getting this wrong was visible immediately:
the freshly rendered index’s own prose pointed _template.md and two ADR
links at the view directory, where none of them live. The general lesson filed
before under ADR-005 holds a
third time: every piece of authored text has exactly one place it renders, and
its links belong to that place, not to its file.
The payoff check is “a view directory holds only what the generator
wrote.” With the stub and tags.yaml moved to the source side, nothing
hand-written legitimately remains in a view directory, so the old
orphaned-tag-page check generalized into adr_index.orphans() across every
view dir — scheme views, tag dirs, journal outputs. That single property is
what upgrades “GENERATED — do not edit” from a comment someone reads after
landing in the wrong place into a build failure with the right polarity. It
also quietly fixes a real gap: a journal book stranded by a granularity change
(2020-01.md after switching to yearly) was previously nobody’s problem.
Fixture paths bit again, in the predicted place. tests/_scheme.decision
spelled docs/decisions, so under the new defaults every test that filed a
decision passed its assertions against an empty corpus — 33 failures, all
really one. It now asks current().schemes["ADR"].dir, and the badge tests
had to move their config-writes before their filings, since where a fixture
files now depends on which config is in force. Path assertions in
test_doc_refs are derived from config for the same reason: they assert
resolution rules, not current addresses, so the next layout change — if any —
is a config edit.
Two smaller things worth keeping. The journal’s front page now inlines the
current book’s contents (newest first) above the shelf — the “looks like an
archive” complaint was about attention, not structure, and one renderer change
fixed it. And the badges’ link default had been restating a configured path
(docs/decisions/README.md as a literal); it survived this move by
coincidence, and now derives from current().index so it stops being a
projection waiting to drift.
All guards fired before trusting
(DP-6): a stray file in each kind of
view dir, a hand edit to a generated index, and a bare reference in a
record/ source — each one violation, each naming the file and the remedy.
610 relative links re-resolved from their render bases; the one “broken” is
the ADR-NNN.md placeholder in the decision template. A fresh luria init
into an empty directory files its first journal entry under record/devlog.d/
and lints clean.
A remote learns its schemes, and the url-ok loop closes on schedule
2026-08-04 17:09:21
The feature was specified by an escape hatch, which is the part worth
remembering. ADR-022 scoped url-ok
one review earlier and predicted that its accumulated reasons would describe
gaps in remote config; the very first reason — “strata-g’s principles are
sections of one document” — was the recurring shape, surfaced by the
maintainer’s challenge that a hand URL looks like the same anti-pattern in
both namespaces. It doesn’t, but the challenge exposed that the real gap was
the remote model assuming one directory of code-named files. Strata-g is a
Luria project on a legacy layout; foreignness was never the issue.
ADR-023 records the fix:
[luria.remotes.X.schemes.Y] maps a code family to dir, document +
anchor, or a url template, with per-scheme config winning over the
remote-level settings that keep serving unconfigured prefixes.
The subtle rule was lockfile jurisdiction. “Discovery, once done, is
authoritative” says a code absent from a lockfile names no document — but
discovery reads directory listings, and a section of an assembled page
never appears in one. Left alone, a lockfile on a remote with a document
scheme would have vetoed every DP construction: absence-of-file read as
absence-of-document, for a document that was never going to be a file. The
authority is now scoped to what discovery can actually see — file-per-code
codes stay under it, anchor and template constructions never consult it —
and the test that pins this (lockfile_authority_does_not_cover_document_schemes)
asserts both halves, because the failure would have been the quiet kind.
Zero-config prefix magic was the tempting wrong default. Mapping DP
codes to docs/design-principles.md#dp-N with no config at all would have
made the demo sparkle and been confidently wrong for exactly the remote in
front of us: strata-g’s legacy anchors are heading-derived, and GitHub lands
a missing anchor silently at the top of the page — a wrong link that looks
like it worked. The rule that runs through this subsystem held: a default is
a guess about the remote, config is a claim by the user. One document line
is the honest price, and the anchor template’s default (dp-{number}, the
stable shape our own document render emits) makes that line sufficient for
any remote on current conventions.
The retirement loop fired, in both directions. With
[luria.remotes.SG.schemes.DP] configured, SG-DP-18 constructs to the
right document with the wrong legacy anchor — so the url-ok in
DP-9 survives with a narrower reason,
excusing only the anchor. And the fixture test walks the full loop
ADR-022 promised: configure the
scheme, replace the hand URL with the construction, and the leftover
acknowledgement reports itself stale. luria remotes now labels which rung
answered per code (“a document anchor, per the scheme”), and the live
--check verified every LU construction against the real repository while
reporting SG honestly unverifiable.
The scaffold caught the last leak. The directive-example table in
CLAUDE.md quotes url-ok: SG-DP-18 …, and in a fresh scaffold — where SG
is not a configured remote — the tail read as a dangling local DP-18,
because the shaped-text mask that treats directive syntax as syntax only knew
the first two vocabulary words. url-ok joined the set. Found by running
luria init into an empty directory and linting, which is the only place
that combination exists; the dogfooding repo could never have shown it,
because here SG resolves.
The remote-level dir default also moved to record/decisions.d, trailing
the read/write boundary — the defaults are Luria’s conventions, and the
conventions moved. The five tests that noticed were asserting the default’s
address rather than its rule; same lesson as the fixture paths one entry
ago, one layer further out.
Numbers were the special case: uid remotes and one parser for a code’s anatomy
2026-08-04 17:29:47
The review question was “why is the tail a number?”, and the answer was
“only by accumulation.” Per-scheme mappings had just generalized where a
remote’s documents live; the follow-up poke was at what a reference is
allowed to look like — the motivating case being arxiv, where the machinery
that already scans, fixes, lints and url-oks foreign codes is obviously the
right tool for ARXIV-2403.05530, except the tail had to match \d{1,4}.
ADR-024 records the generalization: a
remote can declare uid (a regex for the tail) and delim (the separator,
for uids whose own alphabet contains hyphens), and its url template indexes
the uid’s capture groups by position — {1}.{2} restructures 1234:5678
into 1234.5678.
The number assumption turned out to be five assumptions, and counting them
was the real work. The composed-code regex, normalise, Remote.link, the
annotation-argument parser and the url-ok label matcher each spelled the
hyphen and the digit shape independently — five copies of one fact, the
DP-4 drift that stays harmless exactly as
long as the fact never changes, which is the assumption this feature breaks.
The fix replaced the one combined regex with per-remote patterns behind two
functions: references(text) for scanning (longer prefixes claim their spans
first) and parse_code(text) for exact parsing. Every consumer now goes
through them, so the delimiter is spelled in one place — and _exists, which
had been splitting composed codes on a literal "-", got caught by the same
sweep before a delim = ":" remote could silently break it.
Two rules kept the generalization honest. A uid is exact — ADR-32 and
ADR-032 are one document, but zero-padding an arxiv id would quietly cite a
different paper, so canon passes uids through untouched. And a uid remote
has exactly one rung: the template. No lockfile (it maps filenames; a uid
remote has none), no code-only fallback (there is no convention to fall back
on), and a uid remote without a template constructs nothing, so ref-status
reports the citation as dangling instead of the tool inventing a URL — the
same no-guessing polarity the discovery rule set.
The whole pipeline worked end to end on the first smoke test — bare
ARXIV-2403.05530 in prose linkified through the template, JIRA:PROJ-42
with a colon delimiter and a hyphen-bearing uid scanned correctly beside
scheme-shaped codes — which is what the DP-4 consolidation buys: when a
code’s anatomy is read in one place, a new anatomy is one field, not a
five-site sweep. Wikilink-style insertion ([[ARXIV-…]]) was deliberately
left unbundled; the reference shape is now general, and how references are
typed is its own issue.
Wikilinks: the brackets are consent, and typing them flips the rules
2026-08-04 18:12:44
The design question was already answered by the scanner’s own
conservatism. The prose heuristics exist because a confidently wrong link
is worse than a bare code — low #N needs a cue word, DP-3 without a #
is never taken, unregistered prefixes never match. All correct, and all of it
leaves the author no way to assert a reference the heuristics pass over.
[[CODE]] is that assertion (ADR-025),
and the insight worth keeping is the symmetry: consent flips the rules on
both sides. Inside the brackets, no heuristics — [[#10|10]] needs no cue,
because the brackets are the cue. Outside them, no silence — an unresolvable
wikilink is a lint violation the fixer cannot clear, the one deliberate
exception to “the lint never demands what --fix won’t do”, because an
explicit request deserves an explicit refusal
(DP-1).
Consumed, not rendered, and the layout decided it. The tempting design
leaves [[…]] in sources and expands at view-generation time. But the
read/write boundary’s own rule is that a source arrived at by link is the
reading experience — decisions and journal entries are read in place, and
GitHub renders wikilinks as literal brackets. So luria link --fix consumes
them into plain markdown (an <a href> inside raw-HTML blocks), and the
committed corpus never contains the syntax.
One masking line prevented a double demand. Without it, [[ADR-013]]
would be reported twice — once by the wikilink check, once by the prose
scanner reading the bare code out of the middle. The wikilink span is masked
from the prose scanner, so each typed reference is exactly one obligation
with one remedy.
Fired on the real corpus before trusting
(DP-6): a resolvable and an unresolvable
wikilink dropped into a live page produced the two distinct verdicts, the
fixer cleared exactly the resolvable one into
[ADR-013](../record/decisions.d/ADR-013.md), and the unresolvable one kept
failing with its three-cause message. Twelve fixture tests cover the shapes:
local codes, labels, document-scheme anchors, remote and uid-remote codes,
quoted specimens, idempotence.
Parallelism measured first: the probes were the win, the rest is seams
2026-08-04 18:18:14
Measured before deciding, and the measurement wrote the ADR. The issue
(#7) asked for parallelism now,
strategy open, on the argument that seams are cheap early. Baselines on this
repo: index 0.92s, lint 2.68s, remotes --check 6.59s. That last number
is twenty serial network round-trips, and it named the strategy by itself:
the win is I/O overlap, so threads; asyncio would demand an async signature
on every caller between the CLI and one HEAD request for the same overlap,
and processes would add pickling for work that isn’t CPU-bound where it
matters. ADR-026 records it.
The primitive is one function, and its contract is the decision.
parallel.pmap(fn, items) is list(map(...)) with a pool inside — results
in input order, exceptions propagating as they would serially. Ordered
rather than as-completed because the two big consumers are a staleness check
that diffs rendered text and a lint that prints a report: both must read
identically at any width, or parallelism trades determinism for
milliseconds. Applied at three seams — render units in outputs(), per-file
scans in the bare-reference lint, per-URL probes in --check — and going
wide at each was genuinely a one-word change, which was the design goal.
The honest half of the after-measurement: two of the three seams bought
nothing today, and one was briefly slower. Probes: 6.59s → 2.86s, the
real win. But lint’s median went 2.74s serial → 2.89s wide, because
scanning is regex-CPU and the GIL means thread-width buys CPU work nothing
while charging pool overhead. First instinct was to revert that seam;
second thought kept it, stated plainly: it is structure at ~5% of a
three-second command, the issue explicitly asked for structure ahead of
cardinality, and the pmap seam is exactly where a process pool (or
free-threaded Python) swaps in when render units measure in seconds. What
would have been dishonest is the version of this entry that claimed all
three seams sped things up.
One requirement got promoted from habit to contract. Renderers and
scanners were already effectively pure — reads of the config cache and the
tree — but nothing had ever depended on it. Now width does: a future
renderer that mutates shared state will fail intermittently at width 8 and
deterministically at LURIA_JOBS=1, which is why the escape hatch is an
environment variable rather than an edit — a concurrency suspicion should
cost one shell prefix to rule in or out. The determinism test pins the
other side: outputs() byte-identical serial and wide.
Shipping: the wheel that worked was working by accident
2026-08-04 18:44:04
The pre-publish check found a wheel that passed its smoke test for the
wrong reason. The setuptools config declared the scaffold as
parent-relative package data (luria = ["../template/**/*"]), and building
the wheel showed where that lands: template/ at the wheel root, installed
as a bare unnamespaced directory straight into site-packages. luria init
then worked — because Path(__file__).parent.parent from inside
site-packages happens to be site-packages, so the fallback found the leaked
directory. Green smoke test, collision-prone packaging: the first neighbour
package shipping its own template/ directory would fight over the same
files. The lesson is the old one about polarity — a test that passes for the
wrong reason is worse than one that fails — and the fix is recorded in
ADR-027: hatchling force-include
maps the top-level template/ to luria/template/ in the wheel, so the
repo keeps its browsing surface (ADR-021)
and the install keeps to its namespace, with init._template_dir() trying
the packaged location first.
The cold-install smoke test is the only place one class of bug can
appear, and it caught one immediately. A checkout always has the top-level
template fallback, so the dogfooding repo structurally cannot exercise the
packaged path — pip install dist/*.whl into a fresh venv, then
init → index → journal new → lint in an empty directory, is the
configuration that can. Run by hand before writing the workflow, it turned
up the scaffolded CLAUDE.md’s illustrative wikilinks ([[ADR-013]],
[[SG-DP-18]]) leaking as day-one dangling-code warnings in a fresh
project — the third instance of the example-leak pattern (stale strata-g
docstrings, then the url-ok directive example, now this), each caught by
the same move: run the scaffold where the examples’ codes don’t resolve. An
unresolved-ok-block above the examples fixed it; the smoke test is now a
release gate in publish.yml, so the wheel that ships is the wheel that
scaffolded a clean project.
Trusted publishing means the credential is an identity, not a secret.
The publish job runs in the pypi environment with id-token: write and
nothing else; build and publish are separate jobs so the environment gate
covers the smallest surface and the artifact that ships is byte-identical
to the one tested. No token to leak, rotate, or forget — the failure mode
that killed every “just put it in a secret” alternative.
The collector grew the changelog shape, for strata-g’s scriv retirement
2026-08-04 20:05:09
The last foreign moving part in strata-g’s doc machinery was scriv, kept
for one property. Retiring it (the port’s phase two) exposed what that
property actually was: not categories, not versioning — reading order. A
changelog reads newest-first, and this collector had only the narrative
shape, bodies oldest-first at a marker that stays at the end of the file.
ADR-012 had said “point a
categorised changelog at scriv”; with everything else on Luria that
delegation made the memory machinery a two-package install for one
insertion order. ADR-028 makes the
shape configuration: style = "changelog" on the fragment mapping inserts
one dated batch right after the marker, newest batch first, fragments
newest-first within it.
The batch rule fixed a documented scriv caveat for free. strata-g’s
CLAUDE.md carried a warning: a collection round of only
no-user-facing-changes stubs left a bare ## <date> heading in
CHANGELOG.md that someone had to revert by hand. Here a stub-only batch
emits nothing — the date heading only exists if something real sits under
it — and the caveat paragraph gets deleted rather than ported.
What was deliberately not built: scriv’s category merging. A fragment’s
### Added/### Fixed sections stay in its body, per-contribution within
the batch, instead of being merged across fragments under one date.
Merging earns its complexity in a versioned-release changelog; a per-merge
project log reads fine with each contribution’s sections intact — and the
un-merged form preserves which contribution claimed what, which the merged
form erases.
Config compatibility is the string-or-table pattern. A fragment mapping
value that is a string still means the append style, so no existing
luria.toml changes meaning; the table form (file + style) is the
opt-in. Luria’s own changelog switched — its marker was already sitting
after the header, where a changelog-style marker belongs, so the dogfood
cost nothing but the config lines.
A checking job runs nothing that writes
2026-08-05 13:34:45
A luria adopter turned their docs gate off by following one of our error
messages, and it took three green builds for anyone to notice. They added the
ADR-018 badge region to README.md; CI went red with README.md: badge counts are stale — run luria index; they added luria index to the docs-lint job
ahead of luria lint; the red cleared. The badge region on their main stayed
empty the whole time, because the regenerated README lived in a runner checkout
nobody commits.
The empty badges were the harmless half. luria lint verifies generated
views by re-rendering them and diffing against disk, so a generator immediately
ahead of it makes the comparison vacuous — the checker compares the generator’s
output against the generator’s output. Their whole check_generated_index was
inert: index, tag pages and devlog books, not just the badges. Demonstrated by
corrupting a row of the generated index and running each job body:
luria index && luria lint → exit 0 (corruption silently overwritten)
luria lint → exit 1 docs/decisions/README.md: stale
That project is the one DP-6 was minted from, and this is its third inert mechanism. The first two were inert from bugs in themselves — an alert shape that could never fire, a fast path whose fail-safe polarity hid a month of inertness. This one is worse in an interesting way: it was a working gate, switched off by the repair for an unrelated symptom, and every signal afterwards said success. A check that cannot fail is indistinguishable from one that passes, which is the whole of DP-6 restated from the far side.
The proximate cause is ours, and that is the part worth internalising. The remedy text is correct in a working copy and catastrophic in a checking job, and nothing at the point of use distinguishes those contexts. Worse, a staleness failure is usually seen first in a build log — so the single place that sentence is most often read is the place it names the wrong action. We wrote a message that is right where we imagined it being read and wrong where it actually is.
The first fix I wrote for this was wrong, and the shape of the error is the
most useful thing in this entry. I concluded a checking job runs nothing
that writes, made it the rule in the adoption guide, and had the CI remedy
tell adopters to regenerate locally and commit. It reads beautifully and it
outlaws generation jobs — the adopter’s own repo commits a rebuilt screenshot
gallery from CI, and this repo’s ci.yml runs luria collect --commit on
a schedule and pushes. I wrote a rule condemning the recipe we ship, from one
broken job body, without surveying either project’s workflows. The failure was
never “a generator ran in CI”; it was “its output was thrown away”, and the
remedy for a discarded output is to commit it, not to stop generating.
The message fixes only change what is said (ADR-029).
regenerate_remedy() keeps the bare run luria index in a terminal — padding
it with CI advice would train people to skim the one message that matters — and
under CI supplies the half the short form omits: the output has to be
committed, by an author or by a generation job that pushes, and the thing to
avoid is specifically the generator in a checking job with nothing committing
behind it. And bare luria badges now says on stderr that it only printed —
it emits markdown and exits 0, which as a - run: step is indistinguishable
from a write, and is why the adopter’s second CI step also did nothing. That
one was a straight DP-1 violation sitting in plain sight.
A third message was built and then cut under review, and the cut is the
better design. wasted_write_warning() printed whenever a generator wrote
inside CI, and had to say of itself “if this job commits and pushes, this note
is noise” — which it would have been, on every run of every correct generation
job, forever. A warning that is usually noise trains readers to skip warnings:
the flaky-guard dynamic this record already documents, rebuilt in miniature.
The reviewer’s question that killed it was simply “so this adds wordy
warnings?” — and for that function the answer was yes. What replaced it is
worth more: the generation job shipped as machinery. actions/generate and
actions/lint hold the one authoritative implementation of the tricky
commit/push/SHA-handoff logic, the luria init template workflow is now built
from them — it had been scaffolding a verify-only lint, handing every adopter
a gate with nothing keeping it satisfied — and this repository’s own ci.yml
runs the same actions by local path, so the scaffold is the workflow luria
itself lives on and a change to an action is fired by the pull request that
makes it.
The tell I walked past, twice. Refusing to write when CI is set was
rejected in the same breath as the ban I did write, and for exactly the
reason that should have killed the ban: luria collect --commit writes in CI
legitimately, in our own workflow and in the adoption guide. I noticed the
enforcement version broke the recipe we ship and didn’t notice the documented
version broke it just as thoroughly, one rung softer. A rule you decline to
enforce because enforcing it would break your own examples is a rule your own
examples already violate — that’s the check I’d have wanted, and the guide now
documents both arrangements rather than picking one.
Also rejected: I looked for a way to detect the inert ordering directly and
there isn’t an honest one: no shared state between steps, and inferring it from
mtimes or a clean worktree fires on legitimate sequences. The surface that can
carry this is the message, not a detector. Folding README.md into outputs()
was rejected again for ADR-018’s original reason — it would mark the README
generated, and the reference fixer skips generated files, so its prose would
stop being linted — and wouldn’t have helped anyway, since the ordering is the
bug, not the region.
One trap found while writing the tests, which is exactly the class of thing
this entry exists for: the suite runs in CI. tests/test_ci.py asserts on
what luria says when CI is unset, so without an autouse fixture clearing every
variable in CI_VARS, those tests read the runner’s environment — pass locally,
invert on GitHub Actions. A test for CI-awareness is the one test guaranteed to
be run in the environment it is trying to control. Verified both ways before
believing it: 264 pass with the variables unset and with
CI=true GITHUB_ACTIONS=true.
Fired before trusting, since a guard against inert guards that was never fired
would be a joke at its own expense. Hand-edited a badge count and read the
failure twice: the bare remedy in a terminal, the “do not add luria index to
this job” form under CI=true. Both cleared on repair. The corruption test
above is the other half — it is the only thing that distinguishes the two job
bodies, since by construction they look identical from the outside.
Branding and copyediting
2026-08-06 20:48:00
(oh shit, @dmarx making an entry?!? wtf?? who let this human in the devlog??)
Spent most of today (and yesterday… and maybe the day before too?) playing with branding. ChatGPT currently seems to be way better and more useful here than Claude, which was surprising to me. Claude definitely used to be OP here.
After workshopping a logo earlier this week based on a cowrie shell aperture (with a stylized ‘l’ embedded in it), I started today focusing more on the living cowrie iconography (rather than an empty shell), I ended up stumbling on a design that demonstrated the cowrie imagery could be integrated with a stylized portrayal of a human brain. After several more hours of iteration, I finally landed on the “brain slug” which currently occupies the README.
After updating the project branding, I workshopped the copy in the README to actually carry my voice instead of being all AIGC. The top of the README is mostly good, the “four layers” section is still AIGC, as is everything below the line break after the “motivation” section. More cleanup to do for sure, but the project is already public so may as well merge those changes sooner than later in case someone stumbles across this before I share it out. Making this devlog entry as a note-to-self to log what still needs to be done in lieu of creating an issue.
The CLI sheds the commands that were only the module layout showing through
2026-08-07 04:59:29
The prompt was an observation, not a bug: for machinery this small, eleven
CLI commands is a lot. The audit confirmed it quickly, because the codebase
had already written the indictment about itself — reports.py’s docstring
says the detail behind ref-status and pending is detail “nobody runs”,
and bare luria badges printed a stderr note whose content was “you
probably meant luria index”. The third witness was CLAUDE.md, which never
mentioned badges, reports or collect to contributors at all: the
documented surface had already shrunk, the dispatcher just hadn’t noticed.
The mechanism of the accumulation was worth naming in the ADR: cli.py maps
one command per module because every module keeps a standalone main() for
vendoring, so registering a new module cost one dict line and was always
done. The interface was a projection of the package layout — the exact drift
shape the record’s own DP-3
describes, running on the tooling itself.
What shipped (ADR-030): six
contributor commands, reports and collect labelled as CI’s in the help
text, and badges / ref-status / pending removed. The first draft kept
the old names alive as a RETIRED dict — each one exiting 2 with a pointer
at its successor, argued from DP-1 — and review deleted it: a deprecation
shim serves users with a workflow to migrate, this project has one user and
no such workflow, and a name that answers is a name that still exists. The
instinct to preserve was the exact reflex the PR was cutting, pointed at
itself. A removed name now falls through to “unknown command” plus the
usage, which already lists everything that took the old jobs.
Approaches considered and dropped along the way:
- Deleting the retired modules’
main()s. Tempting as a full amputation, but it breaks the vendor-one-file property for no gain in surface, and the interactive flags (--all,--as-of) would have had to grow back insidereportsimmediately. The commands died;python -m luria.ref_statuslives. - Folding
linkintolint --fix. The ruff/eslint idiom, and the two already sharedoc_refs.py. Left alone deliberately: the check/fix split is ADR-005’s shape, and this change is about removing false claims from the surface, not minimizing the count. - Superseding ADR-007. Wrong remedy — its choice (reported, never enforced) is untouched; only its Decision section’s delivery mechanism named the two commands. That is the correct-in-place case, so it went to v2 with a history note instead.
Two traps for the next person. First, hand-writing relative links in a
journal entry: I computed the depth from the entry’s own directory, and the
generated book’s link check failed — the fixer writes devlog links relative
to the book, not the source. Don’t compute anything; type [[ADR-030]]
and run luria link --fix. Second, the retirement messages in lint.py and
ref_status.py pointed readers at luria ref-status --all from inside
warning output. Retiring a command means grepping for it in strings, not
just in the dispatch table — the lint’s own advice would otherwise have
recommended a command that answers with a refusal.
Reports become views a badge can land on, and created: fills itself in
2026-08-07 15:55:34
Two issues in one arc (#33, #35), both about the machinery refusing to use facts it already had.
For #33 the gap was almost comic once stated: journal.read() has always
fallen back to the path when created: is missing — the renderer trusted the
path as a witness — while the lint demanded a human transcribe that same path
into the frontmatter by hand. The fix (ADR-031) puts population in
luria index’s write mode, so CI’s generation job repairs a hand-filed entry
the same way it commits the views. The deliberate non-fix is the disagreement
case: field and path telling different stories stays an error, because
overwriting either side would invent a fact.
For #35 the design work was all in two constraints that only surface after you decide to commit the reports (ADR-032):
- The clock had to come out. The old reports stamped “Generated
” and computed ages in days. Committed and staleness-checked, that report goes stale at midnight with no record change behind it — and worse, every active branch rewrites the same two files daily, which is the exact merge-conflict shape DP-2 names. Ages became dates (“open since 2026-08-03”); the live day-count arithmetic stayed in the lint’s console warnings, which nobody commits. A first attempt pinned this with “today’s date appears nowhere in the output” and review rightly shot it down: a decision filed today and still Proposedputs today’s date in the pending report legitimately, so the blunt assertion fails on a correct report. The pin that survives is on what the clock would have added — the “N days” column — not on the date strings the record itself supplies. - The scanner had to be blinded to them. A reference-status report is a
page that lists retired codes. Scan it like any docs page and every
flagged code gains a citation site inside the page that flags it; the next
render adds rows for the report’s own rows, and the view never converges —
the report reporting the report.
is_generatednow covers the reports directory, which excludes it from the bare-reference lint, the fixer, and the status scan in one move, since all three already route through it.
The false start worth recording: the first plan kept luria reports writing
date-stamped files and only added a committed copy. That is two spellings
of the same view — the projection-drift shape — and it died on the question
“which one does the badge trust?“. The committed view is the report;
luria reports survives only to regenerate it elsewhere for the CI artifact,
which matters exactly when the lint failed on staleness and the committed
copies are the thing that can’t be trusted.
An unplanned collision worth its own line: the record had been using
ADR-032 as the canonical specimen of “a code that resolves to nothing” —
in two decision bodies, two test files, all excused with unresolved-ok.
Filing the real thirty-second decision made every specimen resolve, and five
directives went stale at once. The machinery caught its own fixture: the
stale-annotation warning fired exactly as designed, and the fix was to retire
the excuses (a citation of an Active decision needs none). Lesson: an example
code should come from outside the sequence (ADR-777, ADR-999), because
the sequence eventually arrives.
Trap for the next person: anything that makes a new directory under docs/
has to be threaded through three registries, not one — is_generated (scan
and fixer exemption), view_dirs() (orphan cleanup and stray-file
detection), and check_docs_index’s exempt set (self-indexing view dirs).
Missing any one of the three fails in a different, delayed way.
A blunt directive and a fixture prefix, both priced in visibility
2026-08-07 16:48:32
Both features came out of review on the #35 arc: the reports needed
is_generated to escape the scanner because nothing lighter existed
(#37), and filing ADR-032 detonated every directive that had
borrowed its number as a specimen (#38).
unlinted-file: (ADR-033). The implementation turned out to be
three early returns, which is the payoff of an earlier decision: the lint
and the fixer both read rewritable_refs, so one guard covers both and they
cannot disagree about an exempted page — the ADR-005 sharing paying rent
again. The design work was in what not to build: no line/block scopes
(backticks already are the narrow form — a quoted code is a specimen), and
no exemption from the non-reference checks (a page the machinery no longer
knows about at all is a different, worse feature). The load-bearing piece is
the counting: the reference report lists opted-out files even when the
answer is “none”, because the blanket exemption is the one suppression the
report cannot converge past.
The FX prefix (ADR-034). The pleasant surprise was that no new
machinery was needed at all: a remote with a constant url template and no
repo already resolves every code to that URL — Remote.link calls
.format() on it and a template with no placeholders is a no-op. One config
stanza, and FX-ADR-032 is resolvable by construction. The alternative that
looked right and wasn’t: a dedicated scheme. A scheme’s codes resolve to
documents in a directory, and a fixture code must resolve to no document
while remaining a valid reference — only the remote-url rung has that shape.
One boundary worth restating because it will trip someone: directive
arguments still take local codes only. unresolved-ok: FX-ADR-032 is a
malformed annotation, not a clever one — the prefix is for references in
prose and tests, never for the vocabulary that governs them.
No failed approaches beyond the scheme-vs-remote fork; the traps this time were all inherited from the previous arc and already recorded there.
The ‘never’ comes out of ADR-007: enforcement becomes a dial
2026-08-07 18:17:33
Review on the #32 arc caught the record contradicting itself: ADR-007’s
“warnings, never able to fail a build” forbids the last rung of the
ladder the project instruments — prose → convention → mechanism →
guarantee (DP-5) — for exactly the findings the machinery is best at
counting (#40). The fix is ADR-035: warn-by-default survives with
all of ADR-007’s reasoning intact, and [luria.lint] fail_on promotes named
warning classes to lint failures for the projects that want teeth.
Implementation notes:
- The refactor that made it safe:
report_warningsused to print each class inline with its own scan calls; it now reads onestatus_sections()computation and either prints a section or appends it to the violations. One computation, two consequences — the warning path and the enforcement path cannot disagree about what a class contains, the same shared-scanner property ADR-005 uses for the lint and the fixer. - Only unacknowledged rows reach a class, which fell out for free: the
sections were already built from the acknowledged-filtered pools. Under
enforcement,
inactive-ok:stops being a way to quiet a warning and becomes a way to state an exception to a failing rule — a better version of the same directive. - A wrong notch (
fail_on = ["retired-refs"]) is itself a lint error naming the vocabulary; a dial set to a notch that doesn’t exist must not silently enforce nothing (DP-1).
The supersession chase was the bulk of the work: flipping ADR-007 to
Superseded made 43 citations across 30 files go loud at once — the
ADR-013 rename experience, an order of magnitude larger. The
classification that made it tractable: present-tense guidance repoints to
the successor (docs, module docstrings, test headers — the claim they make
is now ADR-035’s), while historical bodies keep their citation and gain an
acknowledgement (a decision that cited ADR-007 while it was in force is
quoting the record, not asserting current doctrine). One self-inflicted trap
worth writing down: the blanket repoint changed “(ADR-007)” to “(ADR-035)”
inside two sentences that still said “never affect the exit code” — a
citation swap can silently falsify the sentence around it, so read the
sentence, not just the code.
Also filed from the same review round: the luria new <kind> proposal
(generalizing entry creation beyond the journal), taken as its own issue and
arc rather than a rider on this one.
One scaffold for every entry kind, fired on its own record
2026-08-07 18:26:41
Review on ADR-030 pointed out that luria journal new was the special
case pretending to be the feature: decisions and principles were already
templated (“copy _template.md to the next free number” as CLAUDE.md
prose), changelog fragments already had a naming convention, and everything
new needs to know is discoverable in the config (#42). ADR-036 is
the generalization: luria new [kind], kinds derived from luria.toml.
The design constraint that shaped everything came verbatim from the review:
compute identity, never content. The old journal command demanded a
title on the command line; the new scaffold demands nothing, computes the
number/timestamp/date/filename, prints the path and gets out of the way of
the editor. Named field flags exist for tools driving the CLI (an LLM can
pass --title and --summary in one shot), but a human never fills
frontmatter at a prompt.
Two implementation notes worth keeping:
- Line substitution over parse-and-redump. The obvious YAML round-trip
strips the templates’ teaching comments — which are most of what a
template hands a first-time author. So fields are replaced in place with
regexes that understand the two block shapes (
>-scalars and lists), and--titlealso rewrites the body heading, so the scaffold can never fail the title-agreement check it just created. - The fragment path is idempotent on purpose. One fragment per
contribution (ADR-002) means the second
luria new changelogon a branch returns the first file rather than minting a sibling.
Fired before trusted (DP-6): this entry and the branch’s changelog
fragment are the first two documents luria new created outside the test
suite — the fragment took the branch-slug name, this entry took its
timestamp path, and both passed the lint on arrival.
The agent file becomes a map
2026-08-07 18:35:30
Review asked for CLAUDE.md — both this repo’s and the scaffolded one — to be
truncated to links plus “run luria --help” (ADR-037, the CLAUDE.md
half of #45). The evidence was already on the table: the command block
in CLAUDE.md had been hand-chased twice in one week (once when ADR-030
removed three commands, once when ADR-036 swapped journal for new),
which is the DP-3 drifting-copy shape wearing an agent-file costume.
What made the truncation safe rather than lossy: everything CLAUDE.md
restated already has an authoritative, linted home — the four layers and
correction protocol in project memory, the directive vocabulary in the
directives doc, the command surface in luria --help (derived from the
dispatch table, so it cannot drift from the code). The only prose kept is
what a link can’t carry: three one-line ground rules, and the failure-mode
statement that when the map disagrees with the territory, the map is wrong.
One structural consequence worth noting: the two CLAUDE.mds stop mirroring each other. The old file opened with “everything below is also template/CLAUDE.md”, which was a third synchronization obligation; a map of this repository and a map of an adopting project are different documents that merely share a shape, and now they’re maintained as such.
Not done here, deliberately: the README command table and the Makefile help are the remaining hand copies of the surface, still tracked by #45 — this arc retired the copy that misleads an agent fastest, not all four.
Two riders from the same review round, same arc:
- The Makefile retired (ADR-038). The datum that settled it: CI’s
entire Makefile usage was one
make testline — the doctrine in its own header (“run what CI runs is alwaysmake <target>”) had been false since ADR-029 moved the docs jobs into composite actions. Deleting it also surfaced a straggler the ADR-036 sweep missed: the devlog blurb inluria.tomlstill saidluria journal new, because that sweep grepped.md/.yml/.pyand the blurb lives in a.tomlstring. Command names hide in strings in any file type, not just the file types you thought of. luria initand a pre-existing CLAUDE.md: verified it never overwrites (skip-and-report), and the enhancement went to stdout rather than appending into the user’s file — writing into a file luria doesn’t own is the overwrite problem in miniature, and a printed recommendation needs nobody’s permission.
And a third rider: the README’s command table — one of the four surface
copies — became a workflow narrative instead (file it warm → let the
machinery write the plumbing → check before you push), ending in the same
sentence the map ends in: luria --help is the authoritative list. A table
that echoes --help is a copy; a narrative that shows when you’d reach for
each command is content the help text can’t carry.
Idiomatic Fire, drafted: what the rewrite cost and bought
2026-08-07 19:05:09
Review on ADR-030 called the idiomatic-Fire shape (ADR-039,
Proposed — the draft PR is the question, the merge verdict is the
answer): delete every module’s argparse layer, expose typed run()
functions, and let fire.Fire(COMMANDS) derive flags and help from
signatures and docstrings.
The two constraints that shaped every edit:
- Exit codes are not return values. Fire prints what a function
returns —
lint.main()returning 1 under Fire would print “1” and exit 0, a CI gate that green-lights everything. So everyrun()returnsNoneand raisesSystemExit(n)to fail. This is the one convention the old shape enforced by type signature (-> int) that the new shape enforces only by discipline, and it is the strongest argument against the refactor. - The false positive that almost shipped as a bug report: the first
smoke test read
luria frobnicateas exiting 0 — Fire looked broken. It was the shell:luria frobnicate; echo $?piped throughtailmeasures the pipeline. Re-run bare, Fire exits 2 with “Cannot find key” plus the available-commands list, which is the ADR-030 refusal property for free.
What the deletion actually measured: ~150 lines of argparse across ten
modules, replaced by nothing — the run() signatures already existed in
spirit as each parser’s argument list. The whole surface was re-smoked
end-to-end (init → index → new adr/dp → lint in a scratch project, every
flag spelling the composite actions use), and exit codes came through Fire
byte-identically.
Costs, honestly: fire>=0.7 is the first runtime dependency beyond PyYAML;
the tiered contributor/CI help text became Fire’s house
NAME/SYNOPSIS/FLAGS format (the tiering now lives in docs only); and
Fire’s stack-trace-flavoured error footer is chattier than the old
one-line refusal. The worth-it verdict is deliberately left to the PR
review — that is what a draft is for.
The rename that refused to stay small: DP→GP becomes a migrations doctrine
2026-08-07 23:48:04 · record · mechanism
Issue #29 asked whether “design principles” should become “guiding principles”. The answer (ADR-040) is yes — and that the rename should wait for machinery, because it is one instance of a move the record will keep needing: a scheme renamed, or documents shifted to a new rung of the abstraction ladder (norms between decisions and principles; values above them).
Two findings shaped the design, and both would be traps to rediscover:
Unrewritten history is unwatched history. The first draft preserved old
spellings in journals (“labels are history, targets are navigation”). The
review killed it with one observation: a code that matches no configured
scheme is invisible to the reference scanner, so the linter silently drops
every file still using the old spelling. The preservation instinct produces
the opposite of preservation — history nothing checks. Full rewrite won, with
.git-blame-ignore-revs absorbing the blame noise and git itself holding the
immutability guarantee.
The sweep must be a mapping, not a pattern. s/DP-/GP-/ eats the
DP-018 fixture in tests/test_remotes.py, the DP-004 docstring example
in remotes.py, and — worse — composed remote codes like SG-DP-18, which
address another project’s namespace. Enumerating exact pairs makes the
protected set be “everything not named” instead of an exclusion list someone
forgets to extend.
The bookkeeping split took a round to settle: a [[migrations]] ledger in
config lost to a formerly: frontmatter field in each moved document, with
the alias map derived at runtime — config describes the present; documents
carry their pasts.
Plan is filed as #54 (machinery: rung 1 aliasing, then rung 2 luria migrate) and #55 (the DP→GP run itself, which doubles as rung 2’s
acceptance test).
The link that was wrong in both frames: characterizing the journal link-frame bug
2026-08-08 00:19:39 · record
While filing ADR-040’s devlog entry, a hand-written relative link broke the
generated monthly book. The reflex fix — deleting the link and letting
luria link --fix respell it — went in live, tests went green, and the
incident got one sentence of eulogy. Characterizing it properly afterwards
(#57) turned up three things the sentence missed:
The original link was broken in both frames. It used three ..s where
the source location needed four — so it resolved neither from the entry’s
own directory nor from the book’s. Neither lint nor the fixer said a word;
only this repo’s pytest guard did.
The real defect isn’t the convention, it’s lint’s blindness to it. A
journal entry’s body is transplanted verbatim into docs/devlog/ — no
rebase — so links must be written in the book’s frame (link_base() in
doc_refs.py says so, in a docstring no entry author reads). That much is
a coherent design. But no lint check resolves relative targets, and the one
guard that does (test_every_generated_relative_link_resolves) is a pytest
in this repo only. An adopting project gets a green lint and a broken
published view. By ADR-035’s own ladder that’s a lint check waiting to be
written: always wrong, and mechanically fixable since the fixer already
knows the correct spelling.
The reflex fix fixed nothing. Respelling one link left the lint exactly as blind as before — the diff addressed the sentence, not the mechanism. That gap between “handled” and “characterized” is what ADR-041 now formalizes: a bug enters the record as an issue carrying a minimal working example before any fix, and the fix PR turns the MWE into a regression test.
Corollary filed on both #54 and #57: the rung-2 migration sweep must
compute journal-entry link targets in the link_base() frame, never by
path arithmetic from the entry file’s location — otherwise DP→GP mints
this bug nine documents at a time.
Building the site was a reference-integrity check the lint doesn’t have
2026-08-09 21:44:28
Publishing the record to GitHub Pages with Quartz (#13, ADR-042). The plan was a build pipeline. What it turned into was an audit: pointing a static site generator at a corpus that has never been rendered anywhere but GitHub found three things nothing else was going to find, and the third one changes how the whole feature is shaped.
The wrong theory, held for about ten minutes
The first version of the plan was “publish docs/” — the read surface, the
obvious reading of ADR-021. It is
wrong in a way that is obvious the second you try it and not before: the
decision index is a table of forty links into record/decisions.d/, so a
docs/-only site is a site whose front page is forty 404s. The read/write
boundary is not “publish docs/, hide record/”. record/decisions.d/ is
where the record’s links point, so it is a read surface too. What decides
is the link frame, not the directory.
The finding that shaped the design
So: publish everything. Build. 1,764 in-site links checked, and 59 of the pages had broken ones — the broken pages were not random. Every one was a changelog fragment, a devlog entry, or a principle source.
Of course they were. Those files’ links are spelled for the page their prose
lands in, not for where they sit — a fragment’s targets resolve from
CHANGELOG.md’s directory, an entry’s from the book’s, a principle’s from
docs/design-principles.md’s. That is
ADR-005, and Config.link_base is
already the authority on it. So the content rule wrote itself, and it is a
derivation rather than a list:
a file is published iff
link_base(path) == path.parent
Everything else is a source rendered into a view that is already being published — so excluding it removes the broken links and the duplicate content in one move. No directory list to keep in step when the layout moves (DP-3), and the test asserts the invariant over the real corpus rather than the set.
After that rule plus one config setting, the second build came back 0 broken, and it has stayed there since.
The setting that would have failed silently
Quartz’s markdownLinkResolution defaults to "shortest", which
re-resolves a relative link by basename. Under it, docs/README.md’s
decisions/README.md resolved to /decisions/README — a page that doesn’t
exist — while docs/decisions/README.md’s ../../record/… links resolved
correctly. Half right is the worst possible signal: a spot check lands on
the working half. "relative" is the setting the preserved-paths design
requires, and there is now a test asserting it is in the generated config,
because a generator upgrade is exactly where a default comes back.
The bug the build found
The remaining breakage after the content rule was in the generated index:
../../record/decisions.d/../../docs/design-principles.md#dp-2
rebase_links prefixes the source-to-output relative path onto a target and
never collapses the result. GitHub normalizes that, so it has resolved
correctly for every reader this record has ever had; a generator that
resolves segment by segment does not, and all twenty of them 404. Filed with
an MWE as #67 per
ADR-041, then fixed —
posixpath.normpath, fragment preserved — with the MWE as the regression
test.
Worth naming the class: a defect can be invisible for as long as there is only one renderer. The lint checks that codes are linked, not that targets are normalized, and no reader was going to complain about a link that works. Adding a second renderer is what made it observable. That is an argument for the site beyond browsing.
The issue’s open question answered by not applying
#13 worried that Luria
interprets wikilinks differently from Obsidian — a [[CODE]] here resolves
through the scheme config, in a vault by filename — and wondered whether
aliases were needed. The answer is that the vault never sees a wikilink:
ADR-025 has luria link --fix consume
them at source, precisely so files read as plain markdown wherever views
aren’t generated, and a site generator is one more such place. The two
interpretations never meet. What survives is the vault-authoring case, and
for a file-per-code scheme the conventions agree by construction; the
staging adds each code as an aliases: entry so the short URL (/ADR-025)
agrees too.
What frontmatter costs on a website
A site renders frontmatter as nothing. That means status: — the single
most important fact about a decision — is invisible, and
ADR-015 reads on the web exactly like
ADR-016, which replaced it. Hence the
record line: status, date, issue and influenced_by, rendered under the
title, composed as wikilinks and expanded by the same resolver everything
else uses so the fixer still owns every target.
Honest measurement, because it revised my own expectation: I added this
expecting it to be the graph’s main contribution, and it isn’t. Of 16
influenced_by edges across 41 decisions, 14 were already links in the
prose — a well-written decision cites its influences in its own body. The
edges the line genuinely adds are 2 of those plus 3 supersession links that
existed only in a status: field. The lineage payoff is small; the
disclosure payoff — a retired decision saying so above the fold — is the
real one. The block is worth keeping for the second reason, not the first.
Firing the guard
DP-6: the unplaced-link count is new,
so it was sabotaged once before being believed. Two broken relative links
added to the top of docs/directives.md — a decision that does not exist and
a misspelled ../LICENCE:
2 links point outside the site and have no source_url to fall back on:
docs/directives.md → ../LICENCE
docs/directives.md → ../record/decisions.d/ADR-999.md
Back to 0 unplaced after reverting. Worth noting what this is and isn’t:
luria lint has no relative-link resolution check at all
(#57), so this is currently the
only thing in the toolchain that would catch either of those — and it
reports, it does not fail. The Pages job likewise gates that the site
builds, not that every link in it resolves; a link check over the emitted
HTML is a guard this repo should still want, and it is not in this change.
Verification
- 59 pages, 42 record lines, 11 links redirected to the repository, 0 unplaced.
- Built with Quartz v4.5.2: 59 nodes, 418 edges in
contentIndex.json;ADR-025linksADR-005andADR-024(itsinfluenced_by),ADR-015linksADR-016(its supersession). - A link crawler over the emitted HTML: 1,401 in-site links, 0 broken.
- Alias redirects emitted:
/ADR-025reaches the decision. - 320 tests, 23 of them new;
luria lintclean.
Two traps for whoever bumps Quartz
v5 does not work here. Its config is YAML, which would let luria site
generate rather than template it — a real improvement, and worth revisiting.
But npm run install-plugins on v5.0.0 dies with
ERR_UNKNOWN_FILE_EXTENSION: .scss loading a plugin’s components through
tsx on Node 22, so it cannot build at all. Pinned to v4.5.2 in
actions/site; the migration when it lands is one templated file.
Staging inside the project republishes itself. --out defaults to
build/site, which sits inside the tree luria site scans, so the second
run publishes the first run’s output as pages — and the third publishes the
second’s. Caught by writing the test before believing the default; the scan
skips the staging directory.
The first look at the published site found what no build check could
2026-08-10 00:49:59
Two bugs reported within minutes of the site going live (#70, #71). Both had been shipped past a green build, a clean lint, 320 tests and a link crawler that reported 1,401 links and zero broken. The interesting part is why each one got through, because the two reasons are different and only one of them is fixed by a test.
The banner: an unrecognised shape is worse than a wrong one
README.md centres its logo in a <div align="center">, so it reaches the
image by <img src> — markdown isn’t parsed inside an HTML block, which is
the same rule that makes the fixer write <a href> there
(ADR-005). The staging regex knew
](, <a href=" and <a href='. It did not know <img src.
The failure mode is the one worth naming. A target the scanner misreads is
a target it can report. A target the scanner never looks at is staged by
nobody, redirected by nobody, and counted by nobody — luria site
printed 0 references the site cannot place in the same run that dropped
one. That is the unplaced counter behaving as the opposite of what it exists
for (DP-1): the silence read as
“nothing wrong” when it meant “nothing examined”.
My link crawler missed it for the matching reason: it followed <a> and not
<img>. Two independent checks, blind in the same place, because both were
written from the same mental list of what a link looks like. Worth
remembering the next time two checks look like redundancy.
The graph: a layout Luria didn’t own
The graph was in Quartz’s right sidebar — the top of it, which is why it looked right on the wide screen I built for. Quartz stacks its sidebars below the article under 1200px, so the entire rail lands at the bottom of the page. Measured on a decision page:
width 1440: graph top=805 article top=343 pageHeight=2244
width 1200: graph top=2094 article top=343 pageHeight=3034
width 1000: graph top=2396 article top=395 pageHeight=2818
width 420: graph top=3492 article top=415 pageHeight=3978
The one view the site exists for, 3,492px down a 3,978px page. Not a mobile edge case: 1200px is wider than most laptop windows and any half-screen split.
The wrong theory, killed by reading the stylesheet
The obvious fix is MobileOnly(Graph()) in beforeBody plus
DesktopOnly(Graph()) in the sidebar — best of both, sticky rail on
desktop and top-of-page on mobile. It is wrong, and only two numbers in
quartz/styles/variables.scss say so: the display classes switch at the
mobile breakpoint, 800px, while the stacking happens at the desktop
one, 1200px. That leaves the 800–1200px band — the exact band the bug was
reported from — still broken, and it would have looked fixed on both
machines I would have tested.
The other candidate, reordering the grid in CSS, works and couples a
generated file to Quartz’s internal grid-template-areas. Pinning the
generator (ADR-042) exists to avoid
that class of coupling, so it would be strange to add it.
So the graph moves into beforeBody unconditionally, and luria site now
generates quartz.layout.ts alongside the config. The cost is real and
accepted: on a wide screen the graph now scrolls away with the content
instead of staying in the sticky rail. A view that is always there for some
readers and never there for others is the worse trade.
Its parameters needed retuning for the wider column — Quartz’s defaults are
set for a 320px rail and left the neighbourhood huddled in the middle of a
620px box. repelForce is what spreads it; scale did almost nothing.
Depth 2 was tried and abandoned: on a record this densely cross-cited it
renders a hairball with the current page invisible inside it, which is a
picture of nothing.
The third thing, found by looking
Nobody reported this one; it was visible the moment I rendered the front
page. Its title was index — Quartz reads a page’s title from
frontmatter or a first heading, and a README that opens with a centred logo
has neither, so it fell back to the filename we renamed it to. Fixed with a
title: from site.title, plus an aliases: [README] so anything still
pointing at README.md keeps answering.
What actually changed the method
I had verified the first version by counting: pages staged, links crawled,
nodes and edges in contentIndex.json. Every one of those numbers was
right, and all three defects were invisible to all of them, because each was
about what a reader sees rather than what the build emits.
So this round was verified with a headless browser: build, serve, load the
page, and ask the DOM where things are. .graph’s document position versus
<article>’s, at four widths. The banner’s naturalWidth — not whether the
file exists, but whether the browser decoded it. That is a different kind of
check from a link crawler and it found things a link crawler cannot.
One trap for whoever runs this next: in this sandbox every external request
fails (ERR_TUNNEL_CONNECTION_FAILED), so the shields.io badges, the Google
Fonts and the KaTeX CDN all render broken locally. They are fine on the real
site. Check requestfailed reasons before believing a local screenshot —
the first pass at reading one nearly produced a fourth “bug”.
Firing the guard
The new guard is test_the_action_copies_every_file_the_staging_writes, and
it exists because I nearly shipped this fix broken: stage gained a second
generated file and actions/site still copied one, which would have
silently reverted the whole layout to Quartz’s default in CI while every
local check passed. It asserts the property — every file staged beside
content/ appears in the action — rather than a list of two filenames
(DP-3).
Sabotaged per DP-6 by deleting the copy line:
AssertionError: actions/site never copies quartz.layout.ts
Green again after restoring it. The first sabotage attempt was a false
pass — a sed pattern whose unescaped dots didn’t match the line I meant
to delete, so the test “passed” against an unmodified file. Firing a guard
only proves something if you confirm the sabotage landed; a green run is
also what a no-op looks like.
Verification
luria site: 59 pages, 1 asset, 42 record lines, 9 links redirected, 0 unplaced.- Headless Chromium against the built site: graph at y≈323/375/395 and above
<article>at 1440 / 1000 / 420px; banner decoded at 1177×340; front page titledluria;README.htmlalias emitted. - Link crawler: 1,407 in-site links, 0 broken.
- 324 tests;
luria lintclean.
Branding the site: a rasterizer reads an icon before any human does
2026-08-10 03:10:09
Dressing the published record in the brainslug kit (ADR-043, #13). Mostly straightforward plumbing, and three things that were not: an icon that rendered as a solid black square, an assumption about theming that the measurement contradicted, and a guard that passed for the wrong reason for the second time in three days.
The favicon is a different medium
The kit’s SVGs are theme-aware through a --luria-ink custom property, and
composing an icon from the mark in the same style seemed obvious. The
rasterized result was a black rounded square.
librsvg — which is what sharp uses, and what any build-time rasterizer will
use — resolves neither CSS custom properties nor prefers-color-scheme.
fill="var(--luria-paper)" has no value, so it falls back to black; so does
the ink; the badge and the artwork end up the same colour and the icon is a
blob.
An icon’s first reader is a rasterizer, not a browser. So the icon file carries literal fills, with the dark theme as an override rule on top: a rasterizer takes the attributes and ignores the rule, a browser applies both. That constraint belongs to the artwork rather than to Luria, so it is written in the icon file itself.
Two smaller traps in the same neighbourhood. XML forbids -- inside a
comment, so a comment explaining the --luria-ink convention makes the file
unparseable — sharp reports it as a corrupt header, which is an accurate but
unhelpful description of a prose problem. And line art does not survive
downscaling: at 16px the mark averaged to grey mush. Stroking the contours at
12 units — a bolder rendition for small sizes, the standard optical fix — was
picked by rendering candidates at 512/48/32/16 and looking, not by taste.
Weight 6 was still thin at 32px, weight 20 lost the drawing’s delicacy at
512.
The theming assumption I got backwards
The plan baked two logo variants because Quartz’s dark mode is a toggle
(saved-theme on the root element) while an SVG’s prefers-color-scheme
rules follow the operating system — so a reader whose OS disagrees with the
toggle would get dark ink on a dark page. I wrote that into a code comment as
established fact.
Then I measured it, on the README banner, across all four combinations of OS preference and site theme:
OS=light site=light ink=DARK
OS=light site=dark ink=LIGHT
OS=dark site=light ink=DARK
OS=dark site=dark ink=LIGHT
The ink follows the site, not the OS, in all four. Quartz’s CSS bundler
emits color-scheme: light on :root and color-scheme: dark under
[saved-theme="dark"], and Chromium carries the embedding document’s used
colour scheme into an embedded SVG. The banner was never broken.
The two-variant design survives, on a different and better argument: whether artwork can invert itself is a property of the browser, not of the artwork, and baking the variants makes the answer the same everywhere — as well as being the only way to theme a logo with no media query at all. The comment that asserted the false version is corrected; a comment stating a mechanism that isn’t real is worse than no comment, because the next person spends their budget on the wrong model.
Worth noting that the first probe appeared to confirm my expectation and the second contradicted it. The difference was that the first eyeballed a screenshot and the second sampled mean channel values over a known neutral backdrop. Eyeballing a transparent PNG composited over an unknown background tells you very little.
A guard that passed for the wrong reason, again
test_the_action_copies_every_file_the_staging_writes — the guard written
two days ago, and fired then — was widened to cover directories, because the
artwork stages into static/. The widened version passed the sabotage run:
delete the line that copies static/, and it stayed green.
It asked whether the name appeared anywhere in the action. The name is
static, and the action says quartz/static/ on three other lines. The
guard was satisfied by text that has nothing to do with what it checks.
It now matches cp commands and captures what they copy, and the sabotage
fails it. The pattern to notice: a substring check on a short name is
almost never a guard. That is the second false pass in three days — the
first was a sed sabotage whose unescaped dots matched nothing — and both
had the same shape: the run was green and I had to look at why to find out
that green meant nothing. Firing a guard proves something only if you confirm
the sabotage landed.
The palette, and what nobody checks
Paper #f4f1e8 and ink #111111 are the kit’s own two colours; the neutrals
are stepped between them; the accent — a muted teal, #2e5c62 on paper and
#9dbfb4 on dark — is the one addition a monochrome kit doesn’t supply.
Every text pair was computed rather than eyeballed: body 10.6:1, headings
16.7:1, links 6.6:1 in light; 12.3 / 16.3 / 9.2 in dark.
lightgray needed a second pass. At #e3ded0 the graph’s links nearly
vanished, because Quartz draws them in --lightgray and the warm paper is
darker than the generator’s near-white, so the same nominal step gave less
separation. #dcd5c3 restores it.
Nothing checks any of this. A project that overrides the palette carelessly gets no warning, and contrast is exactly the kind of property a report could carry. Named as a consequence in ADR-043 rather than left as a good intention.
Verification
luria site: 60 pages, 4 assets (icon, two logo variants, the README banner), 43 record lines, 9 links redirected, 0 unplaced.- Rasterized through the action’s own
sharpinvocation:icon.png512×512 RGBA,favicon.ico48×48, both showing the slug rather than a black square. - Headless Chromium at 1280px, both themes: the lockup renders in the sidebar
and inverts with the toggle, page background
rgb(244, 241, 232)light andrgb(21, 20, 18)dark,link[rel=icon]resolving. - Link crawler: 1,431 in-site links, 0 broken.
- 331 tests;
luria lintclean.
One trap for the next person to touch actions/site: read the icon from the
staging directory, never from the merged quartz/static. That directory
already holds Quartz’s own icon.png, and a glob that offers the shell both
files picks the stock one, because icon.png sorts before icon.svg. The
first build rasterized Quartz’s icon into Quartz’s icon and reported success.
The branding that never shipped: a squash merge photographs the branch, not the branch name
2026-08-10 03:42:32
“The branding updates don’t seem to have taken on the deployed website.”
They hadn’t. Nothing was wrong with how they were applied — they were never
on main.
What happened
The branding work was pushed to the same branch as the preceding fixes, one
commit later. In between, the pull request was squash-merged, and a
squash merge takes a photograph of the branch as it stood: it resolves to
the commits that existed at merge time, not to the branch name. The later
commit stayed on the branch, the branch stayed open-looking, and main went
to Pages without it.
The tell, in one line:
$ git branch -r --contains 46a092f
origin/claude/issue-13-aadfqq # and not origin/main
and the corroboration, which is the one worth checking first because it needs
no git archaeology: main’s luria.toml has no [luria.site.theme] table
and no icon key at all. A deployment cannot have applied a configuration
that isn’t in the tree it built from.
The diagnosis to reach for before any redesign: check that the thing you are debugging actually ran. The reported symptom — branding not taking — had a perfectly plausible technical explanation waiting (Quartz’s files being overwritten rather than its config being replaced), and that explanation would have led to rewriting a working mechanism. What it needed was two commands establishing that the code under suspicion had never executed.
For the record, the suspicion was also unfounded on its own terms. All four
surfaces the site build writes to are Quartz’s sanctioned customization
points, not internals: quartz.config.ts and quartz.layout.ts are the
configuration, quartz/styles/custom.scss is the file whose contents read
“put your custom CSS here!”, and quartz/static/icon.png is where Quartz
documents that a favicon goes. There is no lower-level thing being reached
around.
The bug found while looking
Re-reading the build step for the first time since writing it turned up a real defect (#73):
ICON=$(ls "$RUNNER_TEMP"/luria-site/static/icon.* 2>/dev/null | head -1)
Under the step’s own set -euo pipefail, a project that configures no icon
gets an unmatched glob, ls exits 2, pipefail promotes that to the
pipeline’s status, and set -e ends the step before npx quartz build.
The Pages build fails, on the branding feature’s first use, for the one
class of user who asked for none of it.
The 2>/dev/null is what hid it from me twice: silencing a command’s
stderr reads as handling its failure and does not. Worth keeping as a
shape — a redirect looks like defensive code and changes nothing about
control flow.
It could never bite this repository, which configures an icon, so the glob always matched and every local run and every CI run was green. That is the ADR-009 dogfooding clause’s blind spot, stated precisely: running your own machinery on your own record exercises your configuration. The paths an adopter takes and you don’t are exactly the ones nothing tests.
Replaced with a loop over the glob — an unmatched pattern expands to itself,
[ -e ] rejects it, ICON stays empty, and nothing in the step ever exits
non-zero. Verified in both directions rather than one: empty when nothing
matches, set when something does.
Verification
git branch -r --containsandmain’sluria.toml, establishing the branding was absent from the deployed tree.- The glob fix run under
set -euo pipefailwith and without a staged icon: survives both, with the right value each time. - Rebuilt end to end from the rebased branch: 60 pages, 4 assets, favicon
rasterized to 512×512, 1,431 in-site links with 0 broken, 331 tests,
luria lintclean.
Generating the configuration reference
2026-08-11 21:39:49 · record · mechanism
The question that started this was “what can Luria actually do, and where do the docs fail to say so?” The answer to the first half was: considerably more than it lets on, and the answer to the second half turned out to be more interesting than a list of missing paragraphs.
The generality was not undocumented. It was misfiled. config.py’s
dataclass docstrings are, in practice, a complete configuration reference —
every one carries prose plus a worked TOML example. template/luria.toml’s
comments are genuinely good and say things like “Add another (RFC, SPEC) as a
third entry; nothing else needs to change”. The decision record has ADR-006,
ADR-024, ADR-028 and ADR-036 covering schemes, uid remotes, collection styles
and config-driven scaffolding. Three good explanations, none of them anywhere
a reader arrives in time: docstrings need you to know which module to open,
the template is seen after luria init, and the decision record is
archaeology — it answers “why”, which is a question you ask second.
docs/ had no configuration page at all, and no mention of luria.toml
outside generated ADR summaries. So the shipped four layers read as Luria’s
parts rather than its defaults, which suppresses the question whose answer has
always been yes.
Writing the missing page by hand was the obvious move and the wrong one — it
is the second copy of a schema that changes, which is the projection
DP-3 exists to forbid. Worse, it would drift specifically for newly added
keys, the ones a reader most needs. So the page is generated, and the
property that matters is that the key tables come from dataclasses.fields()
rather than from a list in the generator. Prose can lag; a key cannot go
missing.
Three things worth not rediscovering.
Reverting a probe with git checkout <file> reverts the whole file. The
second guard firing needed a throwaway field added to Site, and undoing it
with git checkout luria/config.py also silently discarded the config_doc
property and the is_generated clause added earlier in the same session —
both uncommitted, both in that file. Nothing failed loudly; luria index --check simply stopped knowing about the page. Commit before probing, or
patch the probe back out the way it went in.
Union annotations break markdown tables. Path | None renders as a column
boundary, so the row silently becomes four columns and every row beneath it
shifts. adr_index.escape_cell already existed for exactly this, which is the
happier half of the lesson — the generator reuses it rather than growing a
second one (DP-4).
Showing the wrong default is easy and quiet. The first version filled the
default column from config.DEFAULTS, which for schemes.ADR says output = "docs/decisions". That is this repo’s own configuration, not what a project
adding a second scheme gets — the honest answer there is unset, meaning the
view renders beside its sources. A reference is asked “what happens if I omit
this key?”, and answering a different question is the kind of error that reads
as authoritative. There is now a test pinning it.
Firing the guards (DP-6)
Both before trusting either.
The staleness guard: appending a line to docs/configuration.md made luria index --check exit 1 and name the file; regenerating cleared it. The
docs-index check fired unprompted on the very first run after the page
appeared — docs/README.md: missing index entry for configuration.md — which
is the net working without being asked.
The derivation guarantee, which is the whole design: adding a throwaway field
to Site put a documented row on the page with its type and default, with no
edit to the generator, and made the committed view stale. If that ever stops
being true the module has grown a hand-maintained list and is worth deleting.
A gap found on the way
luria link --fix does not detect bare DP-N references. CLAUDE.md and the
scaffolded template both promise it does — “write the bare code (ADR-035,
DP-6, #57) and let luria link --fix spell the target” — but find_refs
returns nothing for DP-3 in any location tested, while ADR-012 and #57
resolve normally. The anchors exist and are correct (dp_anchors() maps 1–10),
and the wikilink form [[DP-3]] expands perfectly, which is what this entry
and ADR-044 use.
So the affordance works and the documented shorthand does not, silently: a
bare DP-6 is neither linked nor reported, which is the worst of the three
possible behaviours. Not fixed here — it is a change to shared reference
machinery and deserves its own contribution rather than a ride-along in a docs
change — but it is exactly the shape of thing this project’s own ground rules
say to write down rather than route around.
Demonstrating the alternatives, and what that cost the docs
The configuration reference and the new adoption section were still prose:
accurate as far as anyone had checked, and unchecked. So four complete
projects went into examples/, with tests that build each into a temp tree
and run the real luria index and luria lint against it.
The first pass falsified two claims written that same day and turned up a third defect. Worth listing, because the pattern is identical each time — the claim was true of the mechanism and false of the shipped defaults.
active does not extend the status vocabulary. The RFC example was
written with active = "Accepted", following prose that said active names
“whatever in force means for your RFCs”. It names a state no document can
hold: the five statuses are closed and lint-enforced, so every document in the
scheme fails. active selects; it does not define.
Omitting output does not collocate the ADR scheme. This is the sharp
one, because it hits the documented adoption path. Scheme.output defaults to
None and the comment on it says unset means the view renders beside its
sources — true, and unreachable for ADR, because config merges over
DEFAULTS and the shipped entry carries output = "docs/decisions". A
project that points dir at its existing decisions and omits output,
expecting to keep its layout, gets its index relocated. The workaround is
output = "decisions" — the same value as dir — which is what
examples/collocated now writes and explains.
A document-rendered scheme was titled after this package.
DEFAULT_DOCUMENT_STUB hardcoded # Design principles, so the SPEC family
rendered as a document came out titled “Design principles”. Fixed: the
fallback heading names the scheme. Only a fallback — a project with a
README.stub was never affected, which is why nobody had hit it.
The same root cause sits under two of these: configuration merges over
DEFAULTS rather than replacing them, so the shipped ADR scheme cannot be
removed and its keys cannot be unset by omission — only overridden. Left as
documented limits rather than fixed here; changing merge semantics is its own
decision, and quietly changing it inside a docs contribution is exactly the
silent revision this record exists to prevent.
One regression I caused and had to chase: making the configuration reference
render for every project meant every project suddenly needed a
docs/configuration.md entry in its docs index. A fresh luria init failed
luria lint on the first run, contradicting the adoption guide’s “should be
clean” two lines above. Caught by scaffolding a throwaway project and running
the documented three commands, which is a habit worth keeping: the guide is a
script, so run it. Fixed in template/docs/README.md. Existing projects
upgrading will see one violation that names the missing entry exactly, which
is the remedy-where-it-is-read shape the record asks for.
The general lesson is the one the README already claims and this contribution kept re-proving: prose about a mechanism is a claim about the mechanism’s defaults too, and only one of those gets exercised by using the tool normally.
The examples found the real one
The DP-N gap written up above turned out to be the visible corner of
something much larger. Chasing it into find_refs showed the loop:
for kind, regex in (("dp", DP_RE), ("adr", ADR_RE), ("issue", ISSUE_RE)):Three hardcoded patterns. A configured scheme’s own Scheme.pattern — which
exists, and which luria new and the renderers use — was never consulted for
finding references in prose. So RFC-7 and SPEC-3 were invisible to the
linter in exactly the way DP-6 was.
The proof was already sitting in the examples. examples/rfcs-and-specs had
an RFC citing SPEC-001 in plain prose, and luria lint passed it. The
package whose entire purpose is catching unlinked references was not catching
them for any scheme but its own default.
That reframes the whole exercise. The generality ADR-006 promised was real in rendering, real in scaffolding, and stopped one layer short of the linter — the layer the promise was actually about. Writing four example configs found in an hour what review had not found in the life of the package, which is the ADR-045 argument making itself.
38 references in this repository linked in one pass, all of them DP-N
codes that had accumulated while the lint said clean.
Two things worth not rediscovering from the fix itself.
A document-rendered scheme needed an explicit self-link guard. For an
index scheme, “don’t link a document to itself” falls out of target == source, because the document is the file being scanned. For a document
scheme the source is the fragment and the target is the assembled page —
different files, so that test never fires. The first run turned every
principle’s own # DP-001: heading into a link, which would then have failed
check_title on a heading that no longer matched its frontmatter. Caught by
reading a sample of the diff, which is advice already in the adoption guide;
following your own documentation is apparently a live skill.
Fixing detection surfaces dangling codes that were always there. Two
illustrative DP codes in doc_refs.py’s own prose started resolving to
nothing the moment they became visible, and needed an unresolved-ok
directive. Not a regression — they were wrong before too, silently. That is
the shape of every upgrade this change causes downstream.
The one thing I said I would not do and then did: this landed on the docs branch rather than its own. Splitting it would have stacked a repo-wide relink on top of a branch that already rewrites the same files, so the split would have bought a merge conflict rather than clarity. Recorded here because changing your mind quietly is the habit this record exists to break.
Config-first adoption: replacement semantics and a config-driven init
2026-08-11 23:36:14 · mechanism · record
Two changes that are one thought: luria.toml becomes the actual interface
to what a record is. ADR-047 makes a declared family replace the
shipped default instead of merging into it; ADR-048 makes luria init
scaffold whatever a config declares — its own, or one handed to it with
--config. The question that prompted the second (“can init not take a
config file as input?”) turned out to be the shape of the answer to the
first: once you can write the record you want as a config, every place the
defaults leak through uninvited becomes visible.
The merge split is the part worth remembering. The old rule was one rule —
everything merges per key — and both documented limits fell out of it: the
unremovable ADR scheme, and the output that couldn’t be unset because the
default entry was always underneath to inherit from. The fix was not a
removal sentinel (ADR = false is three states for a two-state question)
but noticing that the config has two kinds of table. A settings table’s keys
are Luria’s vocabulary — partial override is the point, so it merges. A
family table’s keys are the project’s vocabulary — declaring one is
authorship, so it replaces. Once stated that way there was nothing left to
decide.
The pins did their job and it felt exactly right. The two limit tests
written three rounds ago (the shipped ADR scheme cannot be removed,
omitting output does not collocate) failed the moment the rule changed —
the only two failures in the suite — and flipping them into their inversions
was the code review. A documented limit with a test is a tripwire for the
day it stops being true; both wires fired.
Things worth not rediscovering from the init rewrite:
The scaffold guard caught two template defects on its first run.
Automating the adoption guide’s three commands as a test
(init → index → lint, expect clean) immediately failed on a bare
LU-ADR-048 I had just written into the template’s docs README, and on a
bare DP-1 that had sat in the principles stub since before scheme-driven
detection existed. A fresh scaffold would have started red — the worst first
lesson a tool that lints references could teach. The guide is a script; CI
now runs it.
Same-branch, second contribution: the changelog fragment collides. The
branch restarted from main after the squash merge, so luria new changelog
would have reopened the merged fragment (named for the branch, which
hasn’t changed) and muddled two PRs into one batch. Hand-named this round’s
fragment instead. If restart-same-branch becomes a habit, the fragment
naming wants a better convention than the branch slug alone.
The template stopped being a copyable tree. docs/README.md now carries
a {views} placeholder that init fills from the config, so the view list
and the record cannot disagree on day one. The cost is that the template is
input to a planner rather than a tree you cp -r; the docstring says so,
and init is the only consumer.
Richness follows doctrine. ADR and DP keep the shipped rich templates
because the decision doctrine and the seed principles are content this
package actually has. An RFC gets a neutral template with the
{PREFIX}-NNN placeholder luria new already substitutes — inventing
decision-grade prose for arbitrary prefixes would be Luria pretending to
hold opinions it doesn’t.
Fired before trusting, all four init paths by hand and then as tests:
default scaffold lints clean; a custom config scaffolds exactly its shape
and is drivable (new rfc → index → lint clean, no decision directory
anywhere); --config against an existing luria.toml exits 1 with the
merge-by-hand message; a re-run fills only holes. Plus the DEFAULT_STUB
sibling of the heading bug, found by the same examples that found the first
one — an index scheme with no stub was titled “Architecture decision
records” whatever its prefix.
Review round: the naming was the bug, not the collision
Review called the fragment collision what it was: a solved problem being
re-solved badly. The devlog never collides because an entry’s identity is
when it was filed; the changelog fragment’s identity was where the work
lived, and a branch name stopped being a stable address the moment
squash-merge-and-restart became the workflow. luria new now stamps
fragments the way it stamps journal entries — flat rather than nested,
because the collector and the lint glob a fragment directory one level deep
— and ADR-036 carries the revision as a version bump, since the scaffold
choice stands and only the naming detail changed (ADR-019’s line between
correcting and superseding).
The dropped affordance worth naming: branch naming gave “the second ask on
this branch returns the same fragment.” Timestamps can’t — so two asks make
two fragments, and the one-batch-per-collection behaviour of the collector
is what preserves the reading intent instead. --name keeps the reopenable
address for whoever wants one.
Review also asked whether PRs should merge through an intermediate branch
so documentation builds stop showing up as twenty changed files in every
diff. Taken as a display problem first: .gitattributes now marks the
generated views linguist-generated, which GitHub collapses in review —
one file, reversible, and the committed-views doctrine (ADR-032) untouched.
Whether the architecture question is still live after that is the
reviewer’s call, and it would be its own decision: the trade to weigh is
that a PR would then land views its reviewer never saw, and the lint’s
staleness guarantee currently assumes sources and views travel together.
Implementing merge allocation: aliases are the load-bearing half
2026-08-12 03:51:55 · mechanism
ADR-049 implemented: allocate = "merge" on a scheme, temporary codes
from luria new, and luria concretize at the serialization point. The
surprises, for whoever touches this next.
The temp shape earns its keep in the regexes. A tmp sentinel plus
five base-36 characters: the alphabetic start makes the numeric and
temporary patterns disjoint by construction — number_of and temp_of
can both fullmatch the same filenames with no precedence rules — and every
place that had to “handle both” turned into two patterns that cannot
overlap, which is the cheap version of correct.
The sentinel itself came out of review asking whether the code should be
visibly provisional, and saying yes fixed a bug nobody had named: the
first shape (any six alphanumerics starting with a letter) false-matched
six-letter English after a prefix — “the ADR-review process” parsed as a
temporary reference, because review is six letters. Legibility for
humans and precision for the matcher turned out to be the same change.
The other half of that review question — a provisionality state in
frontmatter — is rejected in the ADR: the filename shape already carries
the fact, and a field would be the drifting second copy.
The tree rewrite is one string replace, on purpose. A temporary code’s
filename is the code plus .md, so replacing ADR-tmp47fje with ADR-123
rewrites the link label and the link target in one pass — no markdown
parsing, no link-shape special cases.
History is swept too, and the first version got this wrong. I initially skipped historical files, reasoning that a dated record stays byte-stable while the alias keeps it resolving — and flagged the disagreement with ADR-040’s full-rewrite commitment rather than deciding it. Review decided it: “temporary” is relative to the historical record, so wherever the tree can be rewritten to promote the code to its canonical ID, it should be. The reasoning that unlocked it for me is ADR-040’s own: a swept run is maximally non-silent — one named commit at the serialization point — so it is not the quiet revision ADR-019 forbids, and git, not the working tree, is the custodian of what was actually written. One spelling per code in the tree; the alias serves what lives outside it.
Aliases resolve on the rare path, so nothing caches. A temp code with a
live document resolves from the directory listing; only a temp code with
no live document triggers the formerly: scan, and that scan re-reads
frontmatter every time. Slow-but-rare beat fast-but-cacheable: a cache keyed
by anything would need invalidation the moment the concretizer renames
files, which is the moment it would be wrong.
The first hand-fired run crashed the index. render_categories formats
a.number:03d for its category chips, and a temporary document has no
number — the kind of integration break (DP-6) that no unit test of the
minter would have found, and the reason the tests drive the whole loop
through a real record instead. Same lesson as every round this month:
firing the machinery found in a minute what reading it did not.
Self-links almost came back. Alias resolution routes a temp code to a
numbered document, and the numbered document can be the file doing the
citing — the formerly: list makes every document reachable under two names, so
the self-link guard has to fire on the resolved target, not the spelled
code. Caught by the fixture citing in both directions.
luria new adr on the scaffolded template substitutes the placeholder in
the frontmatter comments too, so a temp doc’s comment reads
"Superseded — by [ADR-tmp47fje](ADR-tmp47fje.md)" about itself. Pre-existing
behaviour, numeric docs get the same, harmless — noted so nobody chases it
as a temp bug.
One deliberate scope line: this repository’s own record still allocates at
filing. Switching is a separate decision from the mechanism existing —
ADR-049 says so — and it would want concretize --check wired into CI
on main in the same change.
Dogfooding merge allocation, and finishing rung one
2026-08-12 06:02:40 · mechanism · process
The record this machinery lives in now runs it: allocate = "merge" on the
ADR scheme, concretization at the push-to-main docs job, the trunk guard in
the same run. Fired end to end on a scratch copy of the real record before
trusting it — mint (ADR-tmp5jpzj), lint clean with the temporary document
present, concretize --check naming it, concretize assigning ADR-050
with the formerly: alias recorded and the index row correct.
Two integration notes worth keeping.
The wiring lives in the shared action, not this repo’s workflow. The
concretize input on actions/generate is gated to non-pull-request
events, because concretizing on a branch is exactly the premature number
claim temporary codes exist to avoid. The template workflow passes the same
expression, so an adopter who flips the dial gets the serialization-point
wiring without reading this devlog. A project that never flips it passes
true harmlessly — the command is a no-op with nothing pending.
The formerly: field flagged itself. The first legacy-spellings scan
reported every concretized document’s own alias record as a legacy citation
— the one place the old spelling is supposed to persist, read by the one
scanner that goes looking for old spellings. The scan now masks the
formerly: block, and the test that caught it (three rows where one was
expected) is the pin. Same shape as every guard this month: the first
firing found the bug the design review didn’t.
Also in this round: ADR-044 through ADR-049 flip to Active — implemented,
merged, and now endorsed — which empties most of the pending-decisions
badge. ADR-040 stays Proposed on purpose: rung 1 (aliases, the warning
class, the fixer upgrade) is now fully built, but the decision’s substance
is rung 2, and luria migrate does not exist yet.
Ninety-nine dead links behind eleven clean lints
2026-08-16 16:51:05 · lint · directives · scaffold
A downstream project had been hand-writing markdown link targets in its
reading-journal entries since its first commit —
[CLM-007](../../../../record/claims.d/CLM-007.md), five directories counted
carefully from the entry’s own location up to the repo root.
Careful and wrong. The entry renders into docs/reading/2026-08-16.md, so the
target a reader follows is ../../record/…. The hand-written form pointed two
levels above the root, from both the source directory and the view. Ninety-nine
of them, over eleven commits, each of which ran luria lint and got docs lint clean.
Why it was invisible
Every reference check we have is about the code. unresolved-codes asks
whether CLM-007 names a document; retired-citations asks its status;
legacy-spellings asks how it is spelled. All three were satisfied — the codes
were real and current. Nobody asked about the path wrapped around them.
link_base has known the right answer the whole time; luria link --fix
cannot spell a target without it. The gap was that we only ever wrote through
that authority, never read through it.
Firing it before trusting it (DP-6)
Pointed at the downstream repo at the commit before the repair: 99 findings, 88 of them journal entries, and 11 that were not — which is where it got interesting.
Two of those eleven were in this repo’s shipped scaffold.
template/record/decisions.d/README.stub links to [_template.md](_template.md)
and [design-principles.md](../design-principles.md), both written relative to
the stub’s own directory. The stub renders at docs/decisions/README.md, where
neither exists — so every project luria init has ever created starts with
two dead links. Our own copy of that stub has correct targets, because
luria link --fix repaired it at some point and the template never got the
same treatment. That divergence is precisely what made it invisible: the file
looks right in the only place anyone reads it.
Same shape as the template fixture codes. The scaffold is prose nobody re-reads, so a defect in it survives indefinitely and ships to everyone.
After fixing the stub and the template, luria init in an empty directory now
lints clean under the new class. That was the acceptance check.
Two false positives, and what they taught
The first pass on this repo reported two. Both were worth having.
ADR-024:53 — \d{4,5}. An indented config example containing
uid = "(\\d{4})[.:](\\d{4,5})", which the link regex reads as
[.:](\d{4,5}). The tempting fix is to teach code_spans about indented code
blocks, and I did not, because markdown makes four-space indentation ambiguous
inside a list item — a wrong answer there would silence real prose in every
other check too, which is a much worse trade than one false positive here.
Screened on metacharacters instead: a target containing {}\\|()[]*?<> is a
pattern, not a path. Cruder, states its own limit, one line.
_template.md:21 — [ADR-NNN](ADR-NNN.md). A genuine placeholder. My
first instinct was a target-ok: directive, which failed twice and taught me
something: directives in a .md file are read only from HTML comments, and
this placeholder lives in a YAML comment inside frontmatter, where <!-- -->
is not valid. The second attempt (target-ok-block:) failed for the same
reason and I nearly concluded the block-scope resolution was broken.
The actual fix was better than the directive: the line two below it already
writes its placeholder as `# ADR-NNN:`, a code span, because it is a
specimen rather than a citation. Making the supersession example match is what
the file was already doing everywhere else. The escape hatch I reached for
first was hiding that the markup was simply wrong — worth remembering the
next time a directive seems like the answer.
The rule this closes over
CLAUDE.md’s third ground rule already said all of this — only the fixer
knows that frame, a hand-written target that looks right here is wrong
somewhere. Documented, with a working affordance, and no guard. By the fourth
ground rule that is a bug report about the workflow rather than a lesson for
whoever broke it: the arithmetic feels checkable, which is exactly why people
do it by hand, and feeling checkable is a property of the hazard rather than of
the person.
What it still does not catch: a hand-written target that resolves to the wrong existing file. The check knows a target is not anything, never what it should have been. That residual is still carried by the ground rule alone.
The five words held; what they mean drifted
2026-08-16 18:24:20 · lint · schemes · config
ADR-003 is the most cited decision in this record and its finding is the one this package keeps re-deriving: every documentation surface guarded by an executable check had held; every surface governed by prose convention alone had drifted. It closed the status vocabulary to five words, put a lint behind them, and the words have held ever since.
The layer above them was never covered. A status means something different in
every scheme, and that meaning lives in a _template.md comment — prose,
unchecked, and read exactly once by whoever mints a record.
The downstream evidence
A project using six schemes over a corpus of arguments wrote three decisions pinning its status semantics. Twice the record was doing something else.
Fifty-one of fifty-one records at the in-force status. Extraction defaults
to Active and nothing contradicted it, so nothing was ever retired —
including twenty-three records whose own bodies exhibited a counterexample and
two that said “It is false as stated” in as many words. That project’s
fail_on names retired-citations, which makes a retired premise a build
failure in every argument resting on it. With nothing retired, the enforcement
it adopted luria for could not fire, and thirteen green builds meant only
that nobody had judged anything.
A template contradicting nine records. A sibling scheme’s template said
status carried validity; a tag_groups axis was carrying validity; nine
arguments sat at Active tagged invalid.
Both were found by a person re-reading. That is precisely what ADR-003 exists to stop relying on.
What I built, and what I deliberately did not
statuses.yaml beside tags.yaml, same shape, same split: vocabulary in YAML
with the records, rules in luria.toml. A scheme declares which of the five it
uses and what each means; an undeclared status fails the lint; the meanings
render above the index table.
The words stay closed. This was the fork worth getting right. “Make status configurable” reads naturally as “let a scheme add words”, and that hands back exactly what ADR-003 bought — thirty forms across 121 records is what the open vocabulary produced, and per-scheme openness is the same thing with more places to do it. A project wanting a sixth distinction already has tags, which are open by design.
No stub placeholder. {categories} and {table} set the precedent and I
followed it for about ten minutes before noticing it fails the one job: a
project that adds statuses.yaml and forgets the placeholder gets no legend
and no complaint — a config file that looks like it is working. Rendering
automatically means adopting the file is enough.
The check I did not write, which is the one with teeth
Neither downstream failure would have been caught by this. The meaning was already in the template, in capitals, saying READ THIS BEFORE SETTING IT, and it was read and got wrong anyway. Moving prose to a better location is a discoverability win, not an enforcement one, and it is worth being honest that this feature is the former.
What would have caught both: a scheme whose status never varies. Fifty-one of fifty-one at one value; twenty-three of twenty-four; thirteen of thirteen on a third scheme in the same project, still unexamined. A field where every record agrees carries no information, and that is mechanically detectable in three lines.
It is a separate unit of work and it is filed as one. Recording it here because the temptation while building this was to bolt it on, and a decision with two unrelated halves is one nobody can cite half of.
Fired once before trusting it (DP-6)
Six tests, and the load-bearing one is that declaring nothing changes nothing —
an absent file must not read as an empty vocabulary, or every record in every
existing project fails at once. That is the refactor most likely to be made
later by someone tidying declared(), so it is pinned with its reason.
The trailing-note case is the second: ADR-003 allows Superseded — by X, and
comparing the whole string would reject every annotated status a project
declared. Found by writing the test, not by writing the code.
The field was green because nobody was judging
2026-08-16 19:02:10 · lint · schemes · enforcement
The half of #102 with teeth, split out as #104 and built here.
statuses.yaml moves a status’s meaning somewhere a reader will find it.
This asks a different question: is the field doing anything at all?
Why uniformity is the signal
A status where every record agrees is indistinguishable from no status. That
would be cosmetic if nothing read it — but active decides what counts as
retired, and retired-citations fires off that. So a scheme in this state has
an enforcement mechanism that cannot fire, and nothing says so. The build is
green because nothing is being judged.
The downstream case is exact and expensive. mathematics-of-meaning names
retired-citations in fail_on — that is the reason it adopted luria at all.
Extraction files a claim Active, nothing contradicted it, and fifty-one of
fifty-one claims sat there. Twenty-three had a counterexample in their own
body. Two said “It is false as stated” in as many words. Thirteen documents,
thirteen green builds, and the machinery it adopted us for had never run once.
What I got wrong first
My first cut compared whole status strings. Superseded — by ADR-010 and
Superseded — by ADR-012 are two strings and one status, so a scheme with no
variety at all would have looked varied. ADR-003 allows that trailing note and
this is the second feature this week to trip on it — the statuses.yaml check
had the same bug, found the same way. Both now split on — before
comparing, and both have a test that says why.
Worth noticing as a pattern rather than twice-bad-luck: the note is part of
the field and not part of the word, and any new code touching status has to
know that. If a third thing trips on it, the fix is a parser rather than a
third split.
Three exemptions, and one of them is interesting
Below ten records — uniformity in a young scheme is evidence of nothing. A
render = "document" scheme — a design-principles page where everything is in
force is the expected state, since principles move by version:.
The third came out of #102 landing first: a scheme that declares exactly one status has answered the question. Reporting it would be telling a project off for configuring correctly. That interaction did not exist a day ago, and it only showed up because the two halves were built in order rather than together.
Fired before trusting (DP-6)
Nothing on this repo: ADR has variety, DP is document-rendered. Which is
the right first result — a check whose first act is to accuse its own project
usually has the threshold wrong.
Downstream, two schemes. CON at 13/13 is the one worth reporting: that
project’s own decision record had already written, in prose, that its concept
scheme was “either correct or the next instance of this”. The check’s first act
was to turn a written suspicion into a standing finding, which is the whole
argument for compiling culture rather than describing it.
ADR at 12/12 is a true observation nobody will act on — a young decision
record with nothing superseded yet, dismissed in two seconds. That is what a
report is for, and it is why this is not an error: there is no correct
proportion of retired records, and inventing a threshold would nag a project
whose claims genuinely all survive.
Four descriptions and no name
2026-08-16 19:30:00 · docs · positioning
The README carried four self-descriptions — project memory framework, priorities accumulator, reference linter, evidence accumulator — each true of some part. The repository description takes three clauses. Asked directly, I produced three more metaphors: epistemic linter, compiler for culture, build system for beliefs.
Seven descriptions is not a writing problem. It is what happens when a category is assumed to be new.
It isn’t
The mechanism is a truth maintenance system. Doyle, 1979: beliefs plus the justifications linking them, each node IN or OUT, retraction propagating to everything whose justification depended on it.
That is not an analogy. Downstream, twenty-three claims moved to Rejected and
twenty-seven dependent justifications surfaced across files nobody had touched.
Same data structure, same operation.
Naming it costs nothing and buys the reader everything — and the coinages were actively expensive, because anyone who knows the literature would spend their first hour working out whether we knew it too.
What’s actually ours, stated narrowly
Three things, none of them the mechanism: the nodes are human prose rather than inference-engine output; propagation halts at a finding because a bad argument for P is not a defeater for P; and acknowledgement is a first-class move, which a TMS has no way to express.
That last one is load-bearing and I nearly under-sold it. In the downstream
project’s first wave, forty-two of seventy findings were correct citations of
retired material. Without inactive-ok:, the only path to a green build would
have been to un-retire things — which is the one move that must never be how a
build goes green.
So: truth maintenance plus suppression semantics, applied to prose, in CI. A recombination of three existing things, useful because nobody had combined them. Not a new category, and saying otherwise would have been the expensive mistake.
Writing the docs found two bugs in the docs
The README hand-wrote a link target. I wrote
[ADR-058](record/decisions.d/ADR-058.md) in the same paragraph that
tells readers never to do that. The broken-targets check did not catch it
because it happened to resolve; the rule still forbids it, and the fix was to
write the bare code and let the fixer spell it.
The quickstart’s tutorial codes tried to link to real decisions. ADR-001
and ADR-002 are illustrative in a tutorial and live records in this repo, so
the fixer wanted to point them at our actual first two decisions. Backticks —
a specimen, not a citation — which is the same idiom the templates use.
Both are the general hazard of documenting a tool inside the record it manages, and both were caught by the tool. Which is the argument for eating one’s own cooking, made without me having to make it.
Verified rather than asserted
I walked the quickstart end to end in a scratch repo: init, two decisions, a
citation, link --fix, index, then retracting the first record. The output in
the document is what the commands actually print, including the finding at the
end. Every console block in the quickstart is real.
Worth doing because a tutorial that has drifted is worse than none — it teaches someone that the tool is broken when the document is.
The schema reference was somebody else’s file in everybody else’s repo
2026-08-22 06:34:42 · record · docs
A generated page can be accurate, lint-gated, drift-proof and still wrong,
because none of those properties says it is about the repository it is in.
docs/configuration.md was all four. It is rendered from
dataclasses.fields() for the reason ADR-044 gives — a hand-written copy of a
~60-key schema drifts — and that reasoning never stopped being true. What was
never examined is where the page lands: <docs>/, which belongs to whoever is
running the command. So every adopter acquired 336 lines describing Luria’s
configuration schema, committed into their documentation tree, under a
stamp reading “edit luria/config.py, not this file” — naming a file they do
not have, in a package they installed, at a version pinned in their lockfile
that nothing in their repository will ever notice going stale.
It surfaced downstream rather than here, which is the only place it could. In Luria’s own repo the page is correct in every respect, and the tests that cover it assert exactly the properties it has. Running the 0.4.1 bump through strata-g is what made the stamp read as an instruction to a person who cannot follow it.
The half that was about the reader’s repository did not exist. Luria’s
own framing is that its capability surface is luria.toml and that “no code
path spells ADR” — schemes, journals, fragment directories and remotes are
families a project names. But nothing rendered which families this project
named, where its entries go, or what to type to add one. The newcomer’s first
question — where does my file go? — had no generated answer, while the question
they never asked had a very thorough one. That asymmetry is the actual finding;
the vendored copy is a symptom of the same missing distinction.
So: two pages, split by whose question they answer. The reference renders
where its source lives (Config.owns_schema — true when <root>/luria/config.py
is the module executing, exact rather than heuristic, and correctly also true
for a project that vendored the package instead of installing it). Every
project including this one gets docs/record.md, generated from current()
rather than from the schema.
The config-flag version of this was drafted and thrown away, and it is worth
saying why, because it is the obvious design. [luria.docs] configuration_reference = true fails on its default in both directions: off,
and Luria needs a line of config to publish its own reference; on, and every
adopter keeps the vendored copy until they go looking for a key they have no
reason to know exists. A dial whose correct setting is determined by a fact
the program can check is a question that should not have been asked.
The idempotence bug is the one worth writing down. The first draft of
_rel() asked Path.is_dir() to decide whether to put a trailing slash on a
path. That makes the page a function of the filesystem rather than of the
config — and luria index creates directories during its own run, so the same
path answered “not a directory” while rendering and “directory” a moment later.
The page rendered, was written, and then compared unequal to itself:
luria: 1 violation(s)
docs/record.md: stale — run `luria index`
…immediately after luria index had run. tests/test_init.py::test_a_fresh_default_init_lints_clean
caught it — a test that does nothing but init && index && lint on an empty
tree, which is exactly the shape of guard that catches a class of bug no
assertion about content ever will. The config always knew which kind each path
was; dir=True is now passed by the caller and the filesystem is not consulted.
There is a regression test, and its docstring says what bit.
Deleting a file in somebody else’s repository needed a rule. Leaving the
orphan is not neutral — a stale document reads as current, which is the one
thing it is not, and absence at least is visible. But “we stopped writing it”
is a poor reason to remove a file from a repo that is not ours. The guard is
the generator’s own marker: its presence is proof Luria wrote the file, and its
absence means the path holds the project’s prose and is left alone. retire()
runs once, on the first index after upgrade, and prints what it did.
The tests deliberately share no vocabulary with Luria’s record — an RFC
scheme with active = "Ratified", two journals, a news.d fragment directory.
Asserted against this repo’s own config, every one of them would have proved
only that the page matches the fixture it was written against, which is the
failure mode DP-3 describes and the reason the page is generated at all.
Verified end to end against a real adopter, not a fixture: a copy of strata-g
at the 0.4.1 pin, where luria index removed the orphan, wrote the record
page, and luria lint then asked for the docs/README.md row — and, usefully,
caught a link in that project’s own devlog citing the page that had just been
retired. The upgrade note in the changelog fragment is written from that run
rather than from what the code looks like it should do.
The documentation rewritten from a flushed skeleton
2026-08-22 21:40:25 · docs · process
The docs were rewritten blind, on purpose. Previous attempts to
improve them kept converging back on the existing text — an editor who has
read the old page cannot unread it. So this pass inverted the procedure:
first delete every markdown file in the checkout (README, CLAUDE.md, all
of docs/, all of record/) and strip every comment and docstring out of
the Python source, then read only that skeleton — the code, the config,
the tests, the workflows — and write a new documentation set from what the
machinery demonstrably does. The flush is preserved as its own commit so
the basis is auditable; the code and record were restored unchanged
afterward.
What the procedure surfaced:
- The code carries most of the doctrine on its own. The generated-report strings, the scaffold templates, and the lint messages state the system’s opinions (views are built not edited, acknowledgements over suppression, targets are the fixer’s job) clearly enough to document from — a good sign that the opinions genuinely live in the machinery rather than only in the prose.
- One inherited constraint found the rewrite anyway: several record
documents link to
docs/project-memory.mdby path, so the new concepts page took that name. The record’s own citations are load-bearing on docs filenames — worth remembering before renaming any docs page. - The new page set is smaller than the old one (five hand-written pages plus the generated references). The lint’s docs-index check, the broken-target report, and the staleness check between them caught every integration mistake made while wiring the new set in; nothing needed a human eye to find.
What a downstream adoption showed about the docs
2026-08-24 20:37:30 · docs
An anthology of ML training practice adopted Luria this week — 238
documents migrated out of two YAML files — and the friction was almost
entirely in the documentation rather than the tool. Everything the project
needed existed; most of it had to be found by reading config.py.
The docs described the furniture, not the machinery. The README opened with “decisions, principles, changelog and devlog”, which reads as a fixed product rather than a default. There was one paragraph saying the names are not hard-coded, and its examples were still software-engineering meta-documentation. Nothing suggested a record could hold domain content — papers, recommendations, the relationships between them — which is what that project needed and what it built.
The constraint system was invisible. requires, tag_groups,
titles_generalize and inert-status appeared in the prose docs only as
bare names in the lint contract list. They did the most work in that
adoption:
requires = ["source"]made “every recommendation cites a paper” a check, and it immediately found two papers in the corpus carrying no identifier at all.tag_groupswithrequire = "exactly-one"replaced a taxonomy specification that had been written in 2024, complete with a stated one-primary-topic rule, and never once applied. The vocabulary had drifted to 172 distinct strings across 118 entries.inert-statuswas the headline finding: 119 recommendations at a single status, a schema advertising three, and two code branches that could provably never execute on the data.
That last one is the argument for the whole package, stated in one check, and
a reader of the README would never learn it exists. The constraints now have
a section in project-memory.md and a design guide in modeling.md.
The modeling question had no answer anywhere. The hard decision in that
migration was whether papers and practices were one scheme or two. The
rfcs-and-specs example shows how to declare two; nothing said when you
should. The rule that fell out is worth having written down: if one field
would have to mean different things depending on the entry, that is two
schemes — or, put the other way, split when two kinds of claim must be able
to disagree.
What the adoption got wrong, which is now documented
Three mistakes, all of which a page on importing would have prevented:
- Publication dates in
date:. It is the filing date and staleness is measured from it, so everyProposedentry was reported as nine years overdue on the day it was imported. A permanently-wrong warning is the kind that gets a check switched off. - Derived secondary tags. Scoring secondaries the same way as the primary put a paper about an optimizer under model architecture, because a generic word in its keyword list mapped somewhere — as every vague word in a source vocabulary does.
- A real code as a template placeholder. A scheme’s
_template.mdillustrated citation syntax with a live code that happened to beRejected, so the template generated retired-citation findings against itself.
One thing to consider changing in the tool
The adoption tripped the skip-marker hazard: a commit message describing the convention contained the literal token, and GitHub skipped every workflow on that commit. The template comment warns about it and was read before the mistake was made.
It is now documented in adopting.md, with the detail that makes it
findable — the failure presents as no build rather than a red one, so the
pull request goes on showing the previous commit’s green checks. But a
warning that is read and then tripped anyway is weak evidence for
documentation as the fix. The generate action could plausibly detect the
marker in a non-bot commit and say so, which would turn a doc note into a
guard. Not doing that here; noting it as the better shape.
Measuring a downstream config before proposing anything
2026-08-24 21:33:30 · config
Two schemes that cite each other and share a vocabulary — and the config could express the schemes but neither the sharing nor the citing. Both got restated by hand. This is what the restating cost, measured rather than estimated.
The vocabulary was written four times. Twelve terms, seven shared between
the schemes, appearing in two tag_groups lists and two tags.yaml files:
about eighty lines to express one twelve-term vocabulary. The interesting part
is not the line count. Diffing the two tags.yaml files showed the same tag
carrying different definitions in each scheme — data-pipeline was “loading,
quality assessment, preprocessing, batch preparation” in one and “loading,
quality, selection, tokenization” in the other. One author, days apart, no
mechanism relating the copies.
So the founding observation of this record reproduced itself inside this record’s own configuration surface, which is the strongest argument the feature was going to get.
requires checks less than its name suggests. The downstream project had
written a design principle asserting that requires = ["source"] made
citation structural. Rather than take that at face value I edited one document
and re-linted four times:
source: LIT-001— passes, correct.source: ADR-001— passes silently. A practice may cite a decision as its evidence.source: 'a paper I read once'— passes silently. Not a code at all.source: LIT-999— a warning, and only from the generic dangling-code check, which does not know the field was supposed to name a paper.
requires asks whether a field is truthy. It cannot ask what the field means,
so a rule that reads as “every practice cites a paper” was enforced as “the
source field is not blank”. Worth doing the experiment: the limitation is
obvious once stated and was not obvious from the config, and a downstream
principle now overstates a guarantee.
What I chose not to build
Scheme inheritance. The same four schemes repeat render, active, and
an output convention, and a defaults table would trim about eight lines. It
would shrink the config without letting it say anything — and duplication a
reader can see is not the problem the other two additions solve. Repetition
that nothing relates is.
Generated backlinks. A declared reference makes “practices drawn from this paper” derivable, and the downstream record left that section out precisely because a hand-maintained list drifts. It is the obvious payoff and it is a rendering change rather than a configuration one, so it is a separate piece of work. Noting it here so the next person does not have to rediscover that it became possible.
A false positive I withdrew
A generated file tree in that project listed ADR-006.md, the scanner read it
as a citation of a not-in-force decision, and the fix at the time was to mark
the README historical. That looked like a scanning bug worth fixing here.
It was not. The fault was generating a file inventory at all — an alphabetical list of every filename is not prose and makes no claims — and once that was deleted the workaround became dead config, confirmed by removing it and re-linting clean. A case where the check was right and the project was wrong, which is worth writing down precisely because it presented as the opposite.
Firing the remote-drift guard on a real pin
2026-08-27 03:26:25 · mechanism
The new remote-drift guard (ADR-066, #135) was fired once on a real
case before being trusted, per the working agreement. luria remotes --pin LU-ADR-013 fetched the raw document and wrote both hashes to
remotes.lock.json; hand-tampering the seen hash made luria lint print
the drift row with the review URL and the re-endorse command; luria remotes --refresh then re-observed the real content and the finding cleared. The
committed pin stays in the lockfile, so the machinery is exercised by this
repo’s own CI rather than only by the tests.
The dogfood also caught a live bug in --refresh that had never bitten only
because this repo had no committed lockfile: a failed discovery (the private
SG remote answers 404 anonymously) wrote an empty map for the remote,
and an empty lockfile map is authoritative — every SG-… reference then
resolved to “absent from the remote”, the exact false alarm readable()
exists to prevent. discover() now distinguishes “could not read” (None —
the remote is left off the lockfile, or keeps the map it had) from “read,
and empty” ({} — a real finding). The trap for the next person: when a
committed structure’s absence and emptiness mean different things, a
writer that collapses a failure into emptiness is making a claim it never
checked.
URL pins fired end-to-end, and where stable bytes hide
2026-08-27 04:43:23 · mechanism
The pin extensions (pin_url templates and the pin: flag for arbitrary
URLs) were fired on real cases before being trusted. The full URL-pin
lifecycle ran against live network in a scratch project: a flagged
raw.githubusercontent.com URL pinned for real; a tampered seen hash made
luria lint print the drift row with the re-endorse command; --refresh
re-observed and cleared it; deleting the flag turned the row into “no pin:
directive flags it any more”, and a bare --pin pruned the entry. The arXiv
pin_url construction resolved correctly against the real config but the
fetch could not complete from this sandbox (arxiv.org is not proxy-reachable
here) — the refusal path did the right thing: no hash written, reason
printed.
Two traps found while building the flag:
The directive satisfies itself. A pin: comment contains the URL it
names, and the “does this flag govern a real citation?” check reads the
lines the directive covers — which include the directive’s own line. Every
flag passed vacuously, and a flag whose citation was deleted could never
report itself stale. The fix blanks each directive’s comment span before
searching the governed lines, which is the same reason the url-ok checker
masks quoted specimens: syntax about a thing must not count as the thing.
This repo cites no pinnable URL. The obvious dogfood — flag a real external URL here and commit the pin — turned out to be exactly the mistake the design warns against: everything the docs cite outside GitHub (Quartz’s site, doi.org landing pages) is a rendered page whose bytes churn under identical content, so a committed pin would cry wolf on someone else’s deploy schedule. Committing no URL pin was the correct outcome of the exercise, not a gap in it.
The registration model that landed afterwards (pin = true per remote or
scheme) was dogfooded for real: this repo now registers its cited LU-ADR
references, and one bare luria remotes --pin fetched and endorsed all
thirteen — with the pre-existing LU-ADR-013 pin correctly reported
“unchanged” rather than re-fetched into a new claim. Designing the sweep
surfaced a hazard worth writing down: a bare --pin that re-endorses
everything would quietly launder any drift finding the moment a scheduled
job ran it, so the sweep records a changed document as seen and only the
explicit --pin CODE moves endorsed — the two-hash design exists to
force a human review, and the bulk command must not be its bypass.
Endorsements travel through a rename
2026-08-28 04:30:16 · mechanism
ADR-066 v1 said a migration leaves the whole lockfile to be re-derived, and
building the actual mechanism showed that posture quietly defeats the
feature for pins. Re-deriving a pin is prune-and-re-endorse, and a bare
luria remotes --pin run right after a migration would mint fresh
endorsements of whatever upstream serves at that moment — including drift
nobody had reviewed. The sweep’s no-laundering rule exists precisely so a
bulk command cannot move an endorsed hash past a change; a migration that
forced the prune path was that rule’s back door.
So the two lockfile sections part ways in pins.migrate_endorsements,
because they make different kinds of claim. A pin is a human’s claim about
content, and a rename moves the address while the content stands — the pin
is re-keyed with both hashes intact, drifted seen included. A filename map
is a machine’s observation whose keys and values both spell the old world;
re-keying it would leave old filenames under new keys, and keeping it would
be worse — an authoritative map with only old keys resolves every new-spelled
reference to “absent from the remote”. It is dropped, which honestly means
“code-only convention until --refresh re-discovers”.
The end state closes cleanly: when the mirrored upstream executes its own
rename, the pinned documents’ bytes will have changed (their citations were
swept there too), and the next --refresh reports exactly that as ordinary
remote-drift — the human review the two-hash design forces, arriving
through the front door.
The regex that parsed its own output
2026-08-28 05:31:42 · mechanism
The named-URI unification (ADR-067) started from a smell worth naming
for the next refactor of this kind: _github_raw built a blob URL out of
parts the program held in its hands, then ran a regex over the rendered
string to get the parts back and assemble a second URL. Any time code
parses its own output, the abstraction boundary is one step upstream of
where it was drawn — the two URLs were always siblings derived from the
same parts, and the fix was to let them both be templates over those parts.
Two design points earned their keep in testing. Choose-then-render: an early draft fell through to the next precedence rung when a template could not fill a variable, which made a misspelled {flename} silently resolve by the code-only convention — a typo hidden behind a working guess. Picking the template first and letting it render "" keeps the breakage visible as a dangling reference. And the veto-as-variable: porting “an authoritative map’s silence means no file” into “then {filename} is unavailable” made the lockfile’s contract reach custom templates for free, where the old control flow only guarded the built-in construction.
The honest cost: one deliberate behavior change. The regex used to grant bytes to any URL that happened to render blob-shaped, template-produced ones included. That was a guess wearing a construction’s confidence, and the whole prior suite passing unchanged everywhere else made it easy to pin the new refusal with its own test.