Development log — August 2026

46 entries. All books.

Contents


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 initindexlint 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 docstringsADR-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-010ADR-011 and ADR-015ADR-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 exactADR-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.


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 inside reports immediately. The commands died; python -m luria.ref_status lives.
  • Folding link into lint --fix. The ruff/eslint idiom, and the two already share doc_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 Proposed puts 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_generated now 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_warnings used to print each class inline with its own scan calls; it now reads one status_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 --title also 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 changelog on 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 test line — the doctrine in its own header (“run what CI runs is always make <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 in luria.toml still said luria journal new, because that sweep grepped .md/.yml/.py and the blurb lives in a .toml string. Command names hide in strings in any file type, not just the file types you thought of.
  • luria init and 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 every run() returns None and raises SystemExit(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 frobnicate as exiting 0 — Fire looked broken. It was the shell: luria frobnicate; echo $? piped through tail measures 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).


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-025 links ADR-005 and ADR-024 (its influenced_by), ADR-015 links ADR-016 (its supersession).
  • A link crawler over the emitted HTML: 1,401 in-site links, 0 broken.
  • Alias redirects emitted: /ADR-025 reaches the decision.
  • 320 tests, 23 of them new; luria lint clean.

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 nobodyluria 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 titled luria; README.html alias emitted.
  • Link crawler: 1,407 in-site links, 0 broken.
  • 324 tests; luria lint clean.

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 sharp invocation: icon.png 512×512 RGBA, favicon.ico 48×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 and rgb(21, 20, 18) dark, link[rel=icon] resolving.
  • Link crawler: 1,431 in-site links, 0 broken.
  • 331 tests; luria lint clean.

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 --contains and main’s luria.toml, establishing the branding was absent from the deployed tree.
  • The glob fix run under set -euo pipefail with 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 lint clean.

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 (initindexlint, 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 rfcindexlint 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.


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.md by 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_groups with require = "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-status was 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 every Proposed entry 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.md illustrated citation syntax with a live code that happened to be Rejected, 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.