75 entries. All books.

Contents


Three lint passes compile into one contract per scheme

2026-09-03 03:58:41 · lint · config

The three per-scheme tables were three lint passes, and #141 asked what one place that answers “what does this scheme demand of an entry?” would look like. requires (ADR-040), tag_groups (ADR-054) and references (ADR-060) each arrived as its own loop in luria/lint.py, each re-reading and re-parsing every document’s frontmatter, and each spelling its own provenance by hand in the message it printed. The requires loop lived inside the frontmatter check; the other two were functions of their own. Nothing related them, so nothing could say, for a given entry, what the whole set of obligations was or where each came from.

They now compile into one Contract per scheme (luria/contract.py): the fields an entry must carry, what each must hold, and which of its tags combine, with every field naming the config keys that declared it. The lint runs one pass over it (check_contracts); the two standalone functions are gone and the requires loop has left the frontmatter check. The messages are the ones the record has always printed, byte for byte, which is what let the existing tests for all three tables be repointed at the single pass without rewriting an assertion.

This is step A and B of the sequence agreed on the issue: an internal representation, existing semantics compiled through it, no new authoring surface and no new command. Deliberately not here: a luria explain verb (ADR-030 retired standalone report commands nobody ran; provenance belongs in the finding an author actually meets), a forbidden presence (nothing would emit it, and it is the only thing that would let presence constraints conflict), and any contribution from statuses.yaml or tags.yaml (ADR-054 rejected that home on purpose, and revisiting it needs a measured failure first).

The one behaviour change, and the ordering

Composition is intersection. A field in both requires and references used to be checked twice and reported twice — ADR-060’s consequences section called that noise and left it. Compiling merges the two into one obligation carrying both declarations, so a missing field is reported once, with the reference wording since it is the stronger claim. A references entry with required = false on a field that requires also names stays required: required and required is required.

The output order of a run with several kinds of violation also moves: a missing required field used to print among the frontmatter findings and now prints with the contract findings, after the status-vocabulary check. Nothing reads the order.

Fired on a real case

This repository’s own schemes declare none of the three tables, so every contract here compiles to empty and the pass is inert on this record — the suite pins that. The knowledge-base example is the record that exercises it: SOTA there declares requires, references and a derived exactly-one group, and the example tests drive a document through all three on the new pass. The contract module reads the same config keys the three loops read, so the cheap check that no key was left behind was grepping lint.py for requires, references and tag_groups after the cut and finding only the docstring.


Three facts the record already stated, read as edges

2026-09-03 04:09:26 · site · record

Step C of the #141 sequence: the typed edges ADR-060 deferred, plus the one this record can actually fire. The proposal wanted a superseded_by: field; the record’s own convention already carries the successor in the status note, so the decision (ADR-071) reads the edge out of the note instead and adds no field. Three edges, all derived: any declared reference field, named for the field; influenced_by:; and the successor a canonical Superseded — by CODE note names.

Nothing read the note before. Worth stating because it looked as though something must: ref_status splits the bare status word off the note and discards the rest, the site renders the status line verbatim, and the index only rebases the note’s link. The successor was visible on the superseded page and invisible from the successor’s — a reader of ADR-035 was never told it replaced ADR-007.

Reading a note as prose

The extraction is deliberately not a new regex. The note may be a bare code or a markdown link, so it is unlinked with the fixer’s own UNLINK_RE and then handed to find_refs, the scheme-driven reference finder (ADR-046). That buys three things for free: temporary codes match, remote codes come back as a different kind and are dropped (a remote has no node for an edge to land on), and a note that cites two codes yields two edges.

Two drafts got the relation wrong, and review caught both. The first named every code in a Superseded note superseded_by, so a note that runs on past its successor — ADR-015’s does — would have had the site say Supersedes of a decision that supersedes nothing. The second kept the canonical by CODE code as the successor and named every other code in any status note a status_note relation, on the evidence that a world-building record has four of ten non-Superseded notes citing a code. The evidence was right and the relation was wrong: where a code was found is provenance, and a relation named for a location is a location wearing a meaning. So the derivation is one function, successor, that reads a canonical note and nothing else; every other code in a note is a mention, and mentions belong to the citation scanner once the note is a prose key — which ADR-072 then did, the same day.

And a third correction, the one that settled it. With the note a prose field, the derivation still read the successor out of its by CODE shape. The author’s review said what should have been obvious from #141’s first line: the point is structured, explicit relationships, and a relation inferred from free text asks the author to phrase prose so a regex can read it. So superseded_by: is a built-in reference field on every scheme — one code or a list, into any local scheme, checked and resolved, an error when a Superseded document leaves it empty — and the edge reads the field. The by CODE shape is read exactly once more, by luria index, as the repair that fills the field: three decisions here moved, two notes that said only the code were dropped, one that said more was kept and trimmed by hand. I resisted the field twice on a drift argument that does not apply to two keys in one frontmatter block; the record says so.

Six splits became one parse. Reading the note needed the status word and the note apart, and the codebase already did that in six places with three spellings of the regex: the status report, the pending report, three in the vocabulary module, and the first draft of the edges. Status(value, note) on the document replaces them all. Storage followed: ADR-072 gives the note its own field.

Fired on this record

Staging the site over this repository: sixty-six record lines as before, and twenty-six of the sixty-six decision pages now carry a typed edge. The three successions all read in both directions. ADR-016’s line is the busy case — it supersedes ADR-015 and influenced three later decisions — and it reads correctly. ADR-035’s page names the two principles it shaped, resolved to their anchors in the design-principles document by the same resolver that links them everywhere else; nothing in the site module spells a target.

The declared-reference direction cannot fire here (this record declares no references), so it is pinned by the knowledge-base example’s shape in the tests: a practice’s page shows Source, the paper’s shows Cited as source by.

What was not built

The lint the edge makes possible — a Superseded document whose note cites no code. The audit in the decision’s alternatives is the reason: three of three name a successor, so by the gate agreed on the issue there is no failed convention to enforce against. It is one function over the graph when a record needs it.


Provenance goes into the finding, not into a command

2026-09-03 04:43:50 · lint · docs

Step D of the #141 sequence, and the one the author reversed himself on after review. The proposal wanted luria explain CODE before any new semantics. The record’s own precedent (ADR-030) says a standalone report verb is a surface nobody runs, so the work went the other way: every contract finding now cites the key that declared the obligation, and the generated record page lists each scheme’s whole contract from the same renderer. Decision recorded as ADR-073.

What “provenance” was before this. Every finding ended in “(luria.toml)”. Correct, and content-free: there is one file, so the word told the reader nothing they could act on. Field.because already carried the key paths from step A; nothing rendered them. The renderer now groups the keys by file — (luria.toml: schemes.SOTA.requires, schemes.SOTA.references.source) for a merged obligation — so a second file, if one ever contributes, appears as luria.toml: …; statuses.yaml: … without the renderer learning about it.

Tag groups gained provenance they never had. A group finding named the group and its members and nothing else. A derived group (primary_for, ADR-060) is the interesting case: its members come from a vocabulary file the group’s own table never mentions, and a reader of the finding had no way to know which file to edit. The citation now says members from record/topics.yaml primary_for. That needed the contract to know the vocabulary path, which it now carries.

One renderer, two consumers

describe() in the contract module is the single description; the record page prints it and the findings cite through the same _cite. The cheap check that they agree is the test that asserts every key path appears in describe()’s output and the tests that assert the same paths in the findings — one set of strings, matched from both sides.

The record page for this repository now says, truthfully, that no scheme here demands anything beyond the standard fields, and names the three tables that would change that. That sentence is the whole section for most adopters, which is fine: the page is generated, so declaring a table is enough to replace it.

The dogfood the issue asked for

The knowledge-base example carried requires = ["source"] for the rule “every practice names the paper behind it”. ADR-060 measured that requires accepts a decision’s code and a sentence as a paper, and the example was still teaching the untyped form — the second experiment #141 named. It now declares source a LIT reference, and the example test fires both halves: a missing source and a prose source both fail. The arxiv field on a paper stays a plain requires, because it names nothing in the record, which is exactly the distinction ADR-060 drew between the two tables.

Fired on this record

Nothing changes in this repository’s lint output — no scheme here declares a table — so the guard was fired on the example and on the fixtures: findings for a required field, a merged field, a wrong-scheme reference and a derived group each cite their key.


A list in a reference field was read as its first code, silently

2026-09-03 14:25:10 · lint · config

A downstream world-building record reported that a list-valued reference field passed lint with only its first element checked. Confirmed in one line: reference_code(str(["SCENE-001", "SCENE-004"])) returns SCENE-001. The list is stringified, the regex finds the first code, the rest is never read, and nothing says so. The edge derivation did the same and emitted one edge for two codes.

This predates the stack and the stack repeated it. check_references had the same str(raw) since ADR-060; #142 preserved it deliberately (its whole claim was byte-identical findings) and #143 copied the idiom when it built edges. A behaviour-preserving refactor preserves the bugs, which is what it is for — the point is that the bug then has one home instead of two, and the fix here touched values_of once and both consumers followed.

The fix is a declaration, not a coercion

The tempting one-liner is to accept a scalar or a list everywhere. That would have silenced the report and thrown away the shape: the anthology’s source is one paper by design, and a practice citing two would pass. So a reference now declares many = true, and the asymmetry is deliberate: a list in a scalar field is a finding that names the remedy, a single value in a plural field is a list of one. Reasoning in ADR-075.

Fixture design, and a mistake in it

The first version of the tests declared follows required and then complained that the two supporting scenes — which exist only to be resolved against — had no follows of their own. Obvious in hindsight: a required reference is required of every document in the scheme, including the ones a test filed as targets. The tests now filter to the document under test rather than weakening the declaration, which keeps the required case honest.

What was not done

min/max cardinalities, and any change to how a plain requires field reads a list (any truthy value satisfies it, list or not, as before). The reviewer’s larger observation — that Field.reference wants to become a type once controlled-vocabulary fields arrive — is left for that decision.


A field that was neither a reference, a tag nor a status

2026-09-03 16:22:30 · config · record

The vocabulary-field decision (ADR-076) went from a draft on a verdict-shaped PR to an implementation on the same PR, with the decision flipped Active and adjusted where building it changed the answer. The need came from a world-building record: worlds: [A, C] on 37 of 75 scenes, six closed values, absent meaning B, a page per world wanted.

Most of it already existed as two special cases. statuses.yaml and tags.yaml each pair a frontmatter field with a scheme-local YAML file. The new module reads a third file the same way, the contract compiles the field beside requires and references, and every consumer that already switched on reference gained one more branch. The index and value pages are the tag pages with the tag replaced by an effective value, which is the one genuinely new idea: an entry with the field absent is listed under the default, and its file is not touched.

What building it changed in the decision

  • required and default are exclusive, as a config error. Drafted as two independent keys; a field with a default is never absent, so required would have been a key that says nothing, and a key that says nothing reads as though it did.
  • The table is fields, not vocabularies. The draft proposed a fourth parallel table; review of the typed-edges work made the case that status and tags are vocabulary-backed fields already, so the normal form is one fields table keyed by the frontmatter field with a type key — vocabulary = "worlds" today, the other kinds when requires and references consolidate. Switched before merge, which is the cheap moment; ADR-063’s two-grammar argument is why it would not stay cheap.
  • The built-in axes are reserved. fields.tags would have read tags.yaml as a closed vocabulary and quietly changed what tags: means. Refused at load.
  • The type is two optional attributes, not a sum. The draft wrote holds: Any | Ref | Vocabulary. Building it, reference and vocabulary as two None-able attributes was the smaller change and kept every consumer honest; the ADR now says so and names the day the three collapse.
  • The record line links the written values to their pages, and never shows the default — settled the way the draft answered it.

Fired on a real shape

This repository has no such field, so the example world-bible is the reporting record in three scenes: a scene that says nothing and sits in the main line, one in world A, one in B and C, with a plural follows across them. luria index on it writes the index block, three value pages and the record-page line; luria lint is clean; and the two failure modes — a world the file does not name, a follows element that resolves to nothing — each produce their finding. The example runs in CI like the other five.

Two rendering defects showed up only on the rendered example, which is the argument for rendering one: the value page’s heading ran the field name into the value’s label, and two outbound follows edges rendered as two Follows bits instead of one. Both fixed before the PR left draft.

What was not built

A render switch for the value pages (declaring the vocabulary is the opt-in, as it is for tags), an open-vocabulary flag (that is tags:), and a file key for sharing a vocabulary between schemes — the sequence tags.yaml went through (ADR-060), to be repeated when a record needs it.


One field was two types, and had been for sixty decisions

2026-09-03 18:00:58 · record · lint

The status field carried two types in one scalar, and the fix I had deferred twice was the cheap one. Review of the typed-edges work made the case that status: Superseded — by ADR-035 is a vocabulary value and a prose note stored together, that the note is prose by ADR-051’s own rule (it renders), and that every place splitting them apart is a symptom. I agreed, then filed the split as “a separate migration decision” behind a field-typing ADR that does not exist yet. The author’s reply was the right one: the two fields sit next to each other in the same frontmatter, so the drift argument I had reached for does not apply, and a few records moving is not a cost worth deferring over. Decided as ADR-072.

What it took, counted. One of() reading both forms; one set_status writing the new one, used by the index’s repair and by the migration’s tombstone so the shape has one spelling; status_note added to the prose keys; the lint’s status pattern back to five bare words with one new finding naming the repair; four decisions here and two example documents moved by running luria index. The templates, the scaffold, and four docs pages updated. Nothing changed on any rendered page: the display form is composed from the two fields wherever a reader sees a status.

The finding names the repair

The combined form is not an error a human has to fix by hand. It is the created: shape from ADR-031 — the file already states both facts — so the lint says luria repair moves it to status_note: and luria index does. A downstream record on the old form gets one line per document and one command to run. The parse of the old form stays as its reader, and the tests keep writing fixtures in the old spelling through a helper that lands them in the new one, which is the cheapest way to keep every existing test meaning what it meant.

What the split bought immediately

A code in a Deferred or Rejected note is a citation: found by the scanner, linked by the fixer, reported when its target retires. That was the gap the previous devlog entry named as waiting on field typing; it closed the day the note became a key. The successor is not a citation: it went to a field of its own, superseded_by:, the same day, and the note that used to carry it is dropped by the repair when it said only that.

A mistake in the first cut

The prose-scanning test asserted that a note citing ADR-001 yields exactly one reference, and failed: the body heading # ADR-002 is a reference too. Obvious once seen, and a reminder that the scanner reads the whole file.


Requiring arxiv was requiring the wrong thing

2026-09-03 19:21:11 · config · lint

Review of #144 caught the knowledge-base example requiring arxiv of every paper, which is requiring the wrong thing. A paper’s provenance is a source, and a source is any of an arXiv identifier, a DOI, or a URL. The example modelled an anthology that would have failed on its first journal article. Decided as ADR-074: a field group names the need and lists the fields that satisfy it, and the lint asks for one.

Why a new table rather than a list inside requires. The finding wants to say what the fields have in common — no source — and the record page wants to describe the need, and neither can if the need has no name. Tag groups had the same shape problem and got a named table for the same reason (ADR-054); this one mirrors it, rules and all, so a reader who knows one knows the other.

What it is not. The reviewer’s framing — fields that satisfy a source type — is the better model and waits on the field-typing work. The group states the same need in the tables that exist and reads as one of those types once they do; the decision says so, so the day the type arrives nobody has to rediscover that the group was its stand-in.

Fired on the example. A third paper, a lab report with only a url:, lints clean; removing the URL produces the finding naming all three fields. The example’s config comment now explains the need rather than the field.


Six conflicts on files nobody wrote

2026-09-03 19:42:37 · ci · record

Every forward merge in the #141 stack conflicted on the decision index, its tag pages and the devlog book, and the last one against main did too. Six times, the same three to five generated files, resolved the same way each time: take either side, luria index, commit. The author asked the right question — hadn’t we solved this? — and the answer was half. Sources never conflict (ADR-002). Views conflicted because the generation job (ADR-029) committed them onto every branch, so two branches carried two correct copies of one generated file.

The first draft threw the baby out. It stopped the bot committing anything on a pull request: regenerate in the working tree, lint, discard. The author asked what happens to the source repairs — the fixer’s links, a journal entry’s created: — and the honest answer was that they were made on the runner, linted, and thrown away, so the check passed on a tree the reviewer never saw and the bot edited sources on main after merge. The draft had even listed “push only source fixes” as a rejected alternative, on the grounds that a bot pushing to branches races the author. That objection is about a shared file rewritten on every merge; a repair is a small edit to a file the branch already changed. Decided as ADR-068: repairs are committed on the branch, views on the default branch only.

Two commit points want two commands. luria index made both kinds of write in one pass, so the action could not commit one without the other short of keeping its own list of which files are views — a second copy of what the generator knows. luria repair now owns the source repairs and luria index writes views only; every lint finding a repair would clear names luria repair as its remedy, and a test holds the two sides together: the remedy has to work, and the index has to leave sources alone. Idempotence got its own test, because the job runs again on the commit it pushed.

What the pull-request job looks like now. Check out the head branch, luria repair, commit and push the diff as the bot, luria index in the working tree, luria lint on the result. The fork-safe checkout expression is back, because a repair needs a branch to land on; the needs:/SHA handoff is still gone, because the lint runs in the same job. Two things worth knowing that the docs now say: a repair commit pushed with GITHUB_TOKEN gets no workflow run of its own, so the same-job lint is its check; and the pull-request job checks the head branch rather than the merge commit.

What it does not fix. Branches that already carry regenerated views — the five in the stack — keep conflicting with each other until they merge. The routine for those is now written in CONTRIBUTING rather than known.

Fired on itself — eventually. The first three commits on this branch shipped a workflow GitHub could not parse: the new docs-lint job had been added beside the old one under the same key, and a duplicate mapping key fails the file before any trigger is evaluated. Every push produced a run with no jobs that failed in the same second, under the file’s path instead of its name — which is what that failure looks like, and which went unread for three commits because the pull-request checks it suppressed were the ones that would have said so. Parsed strictly now, duplicates refused. This pull request is the first to run the new shape: its own views are regenerated on the runner and not committed here, and its sources were repaired locally with the new command before push.


A temporary code in a workflow file breaks the generation job

2026-09-03 21:57:22 · ci · record

The first merge under the new generation shape left main red, and the record was fine. #148 merged; the push job ran luria concretize, which numbered the decision and rewrote its temporary code everywhere the code globs reach — including the comment in .github/workflows/ci.yml that cited it. GitHub refuses a push from the workflow token that modifies .github/workflows/, so the bot’s one commit, views and rename together, was rejected, and the lint job that needs: it never ran.

The hazard is structural, not a typo. A workflow file is in the code globs on purpose — its comments cite decisions, and the reference lint should see a retired one there. A temporary code is the one kind of citation the job must later rewrite, in the one directory it can never write. So the guard is a lint error, not a report: a temporary code in a workflow file is always wrong, and the remedy is mechanical — cite the number once the decision has one, or say it in prose. Fired once on the real case before the fix: it named ci.yml:24 and the code.

The fix on this branch cites by prose. Naming the number the concretizer was about to assign would have been true and unresolvable: the document does not exist until the run that creates it. Prose has no such dependency.


An ADR closed in review: the constraint was the token’s, not the file’s

2026-09-03 22:23:23 · record · ci

#150 was closed with two comments, and both were right. The decision behind #149’s guard said a workflow file never cites a temporary code. A person running luria concretize locally rewrites that file like any other and pushes it like any other; only the generation job’s push is refused, and only because it runs on the workflow’s own token, which cannot write under .github/workflows/. The decision was about the token and had been written about the file.

So the guard moved onto the dial. A lint error is for what is always wrong; a finding that is wrong on one token and fine on another is a warning class (ADR-035). workflow-temp-codes is reported by default, and this repository and the scaffold name it in fail_on because their jobs run on the workflow token. A project that hands its job a personal access token with the workflow scope, or an App token with workflow write, leaves the class alone and the bot rewrites the file — and, as a side effect worth having, that job’s pushes trigger workflow runs, so a repair commit on a pull request gets a check of its own. Decided as ADR-069.

The second comment became a decision too. Three drafts in one day carried a “never” the mechanism did not enforce, and the same reviewer sent each one back with the same note. ADR-070: a decision is stated for what it chooses, and a prohibition is reserved for a constraint that has been verified against the thing that enforces it. The note is now something a review can cite instead of repeat.


The lint reads sources; nobody regenerates a view to check a branch

2026-09-03 22:39:27 · ci · record

Why was I regenerating views to check a branch? The author asked, and the answer was a leftover: ADR-068 had split repairs from views on the writing side and left the checking side alone. luria lint still ran the staleness check, which compares committed views on disk against the generator’s output — a question about the default branch, where views are committed, and one a branch cannot answer, since it carries the default branch’s copies. So the pull-request job regenerated first and linted the runner’s tree, and my local routine copied the job: regenerate, lint, clean the working tree by hand. That clean-up is how two hand-written doc edits were lost this afternoon, docs/ holding sources and views alike.

Staleness moved to where it belongs. luria index --check already ran in the generation job right after it regenerated, on the default branch; it is now the one staleness check there is, and it carries the README region and the orphan sweep of a stale view with it. luria lint reads sources. The one view-directory rule it keeps — a hand-written file inside one is a violation (ADR-021) — is computed against the generator’s output in memory, so it writes nothing, and a leftover that says it was generated is left to luria index rather than reported as a person’s.

The pull-request job is now luria repair, commit, luria lint. No luria index on a branch at all, which is the answer to the very first question asked about the job today. The generate action’s input is views: "false"; commit-views named a distinction that no longer exists. ADR-068 is at version 2, its decision and consequences corrected in place.

One redundancy given up, named in the ADR. A view on the default branch stale without the job having run would fail --check and not the lint; the same job runs both on every push there.

Fired on itself. This branch was linted without a view written: the first pull request whose local check left the working tree clean.


The published site was one bot commit behind

2026-09-03 22:50:03 · ci · site

The Pages build did not need to run on a pull request, and on the default branch it was running at the wrong moment. The author noticed the first half: with no view written on a branch (ADR-068), a preview build there renders the default branch’s views under the branch’s sources, four minutes of Node and Quartz for a page that is wrong by construction. Looking at the trigger found the second half. The workflow ran on push to main, which is the merge commit — before the generation job has regenerated and committed the views — and the bot’s own push, made with the workflow token and marked to skip CI, triggers nothing. So the site had published one bot commit behind on every merge since ADR-029, and nobody could have seen it from the site, because the views it showed were internally consistent, merely last merge’s.

The fix is the dependency the author named. needs: cannot cross workflows, and folding the Pages jobs into the CI workflow would take away the adopter’s ability to delete one file and stop publishing (ADR-042). workflow_run is the cross-workflow needs:: Pages fires when the CI workflow completes on the default branch, by which time the generation job has pushed, and the checkout is the head of the default branch — the bot’s commit. It runs only when that CI run succeeded, since a failed run has no regenerated views to publish. Nothing on a pull request. ADR-042 at version 2, the consequence corrected in place and the cost named: a site that stops building now fails after the merge, not on the branch.

Not fired on itself yet. A workflow_run trigger reads the workflow file on the default branch, so this pull request’s own run proves nothing about it; the first merge after this one is the test, and the thing to check is that the site shows that merge’s entry.


A record with no code in it, and two failures only staging could see

2026-09-05 01:16:30 · examples · site

The example was the point, and the bugs it found were the payoff. examples/constitution/ decomposes an assistant’s operating instructions into three schemes — values that are argued, practices that are situated, boundaries that override — and the reason it is worth having is a single modelling choice: precedence is a reference, not a font size. A constitution written as prose signals precedence by escalation (“IMPORTANT”, “ALWAYS”, “you MUST”), which is unfalsifiable — nothing checks that the emphatic rule actually wins. Declaring overrides = { scheme = "PRACTICE", many = true } makes it an edge the lint resolves and the site draws, and it makes the failure mode visible: a boundary overriding something that does not exist is a finding rather than a sentence. grounds = { scheme = "VALUE", required = true } does the same job downward — a rule with no value behind it is a habit, and habits are how a constitution accumulates rules nobody can justify and nobody dares delete.

Two of luria’s own checks were the wrong ones to trust, and both failures looked exactly like success. luria index reported “Wrote 17 file(s) from 2 BOUNDARYs, 4 PRACTICEs, 4 VALUEs” and luria lint reported clean while docs/values.md contained eight lines of preamble and none of the four values. render_document substitutes each member’s body into a {principles} placeholder in the scheme’s stub; the stub I wrote had no placeholder, so head.replace(...) matched nothing and returned the preamble. Every count was right — four values were loaded and rendered — and the page it produced was a valid page. Nothing in the pipeline asks whether the bodies arrived.

The second was the same shape one layer out. Four citations were spelled [VALUE-004](../values.d/VALUE-004.md). That target exists on disk, so luria lint passed it, and it is wrong: a render = "document" scheme renders its sources into one assembled view, so the source file is never published and the link points at nothing a reader can open. luria link --fix knew the right spelling all along — ../../docs/values.md#value-4, the anchor in the view — and would have caught it if I had used the fixer instead of hand-writing the links. Hand-spelling a reference is writing by hand what a generator writes correctly; the fixer exists because the right target depends on where the prose renders, which is exactly the fact a human gets wrong.

luria site is what saw both, and only because it counts. Staging reported 4 links redirected to the repository — the source_url fallback for a target with no page, which in a standalone example resolves against dmarx/luria and lands on a path that does not exist there. Fixing the stub took the page count of docs/values.md from 8 lines to 105; fixing the links took to_source to 0. Neither number is one index or lint prints.

The test nearly repeated the mistake it was written about. The first version asserted report.unplaced == [], which reads like the obvious correctness check. Running it against the pre-fix source as a positive control returned pages=24, to_source=1, unplaced=[] — the assertion passes on the bug it was written to catch. unplaced counts targets the site could not resolve at all; a document-scheme source resolves fine, to a repository URL. The count that moved was to_source, so that is what the test asserts, and the positive control is now the reason the docstring can say so. A test written from a plausible reading of a report field is a test that was never run against a failure.

A subset helper is a second lint that agrees with the first until it doesn’t. tests/test_examples.py::lint_errors named six of lint.run’s nine checks. It had been correct when written and had since drifted three checks behind — among them check_contracts, which is the one that enforces typed references, so the test asserting “an ungrounded practice is a finding” was calling a lint that never looked. Adding the missing three changed no existing result, which is the good outcome: the subset was hiding nothing today. Naming a subset at all is the defect.

The limit I wrote up as unfixable was a one-line bug. The entry above originally ended by recording a warning I had decided to live with: a Superseded practice is still a member of its surface vocabulary, the vocabulary pages are generated, and an inactive-ok: directive written into a generated file is erased by the next build — so the reference report flagged a citation nobody could acknowledge. Plausible, and wrong. CI failed test_every_example_render_is_stable[constitution] on a clean checkout while the same test passed on mine, because my working tree already held the generated views the fixture copies and CI’s did not — so on my machine the report was reading views from a previous run and finding the citation both times.

The cause is that two definitions of “the generator owns this file” had drifted apart. adr_index.view_dirs() lists a scheme’s vocabulary directory, so the orphan lint and the docs index both know those pages are views — test_value_pages_are_generated_views_nobody_has_to_link has asserted it since vocabularies shipped. Config.is_generated covers index_path and tag_dir and never grew the vocabulary clause, so the reference machinery, which filters on that method through doc_refs.doc_files → ref_status.scanned_files, treated the same page as hand-written prose.

Three consequences in rising order. luria link --fix would rewrite a page the next build overwrites. A citation inside one could not be excused, which is the symptom I mistook for the disease. And luria index stopped converging: outputs() renders every scheme, journal and report as parallel units and writes the merged dict afterwards, so the report unit can only read what is on disk — the previous run’s copy of a page it should never have opened. A second index produced a different report than the first, and idempotence is the entire basis of the staleness check.

Two things worth keeping. A warning nobody can act on is a bug report, not a limit — the acknowledgement machinery having “no seat at a generated citation site” was exactly the observation that the site should not have been scanned, and I wrote it down as an accepted cost instead of following it one step further. And a test that copies a working tree is testing the working tree. The fixture shutil.copytrees the example, so gitignored generated views ride along; the failure was reproducible only via git archive HEAD | tar -x. Any suite that builds from a directory rather than from a commit can pass locally for a reason CI does not have.


2026-09-05 03:18:07 · site · examples

The exclusion read like a limitation and was actually a diagnosis. site.exclude = ["examples/**"] kept seven complete records out of this project’s site, so the worked configurations the repository argues from were the one part of it nobody could browse. The obvious repair — drop the exclusion — is wrong, and the reason is the rule the whole staging design turns on: a source that renders into a view is not published; the view is. That test is link_base(path) != path.parent, and link_base answers from the reading config’s schemes. Measured under the root config before the change:

examples/constitution/record/values.d/VALUE-001.md
    link_base=…/record/values.d  own_dir=…/record/values.d  PUBLISHED IN PLACE

The parent has no VALUE scheme, so the fragment reads as ordinary prose and would be published beside docs/values.md, the document it renders into — with half the views it emitted coming from whatever a contributor’s working tree happened to hold, since none of them is committed.

So the child is staged by the child’s config, and only its content/ is mounted. site.include_records globs directories, each staged into a temporary vault and copied under the parent’s at its own path. The generation happens in a copy — a nested record’s views are not committed, and rendering them into the working tree would create the stale committed view the whole arrangement exists to avoid. config.rooted() does the switching: load(root) builds any config, but doc_refs.link_base, edges.graph and the renderers all call current() for themselves by design, so operating on another project means switching the global. Naming that and bounding it beats having callers set the environment variable and hope.

A separate Quartz build per example was the other option, and lost on the link graph. It gives each one a genuinely independent site, at the cost of seven more builds, seven base_url values to keep right, and — decisively — a link from examples/README into an example becoming a raw URL the generator knows nothing about, with search and the graph view stopping at each boundary. The examples exist to be read from the documentation that cites them, so one vault is the shape that serves the reader.

The examples had no root README, which is why the mount had no front page. Seven sections whose landing page was a directory listing, and [constitution/](constitution/) from the examples index led nowhere. Writing one per example fixed the site and did the other half of what “self-contained” means at the same time: each now carries its own config, sources, README and views, and needs nothing from the directory above it. Each also gained a [luria.site] title, because without one the section is named after the repository in issue_url — which for these is either a fiction (widgets, monolith) or, for constitution/, the wrong project entirely.

The new READMEs immediately failed the assertion written two PRs ago, and that was the system working. Linking [luria.toml](luria.toml) and bare directories is right on GitHub and wrong on a site: they are real files with no page, so they redirect to source_url, which in a standalone record points at somebody else’s repository. to_source == 0 caught all seven. The fix was to name those paths as code and link only pages.

And then I hand-wrote a link and got it wrong for the third time this session. [ADR-077](../docs/decisions/ADR-077.md) — plausible, and wrong, because an index-render scheme publishes its sources in place and docs/decisions/ holds only the generated index and tag pages. The correct target is ../record/decisions.d/ADR-077.md. The first two were ../values.d/VALUE-00N.md (a document scheme’s sources, which are never pages) and a journal entry’s relative paths (which resolve from docs/sessions/, where the entry renders, not from where it is written). Three different mistakes, one cause: the right target depends on where the prose renders, which is exactly the fact luria link --fix exists to know.

The reason the fixer did not save me here is worth recording separately. examples/README.md is not in doc_files() — that covers the root README.md, fragment targets, docs/**, scheme directories and journal directories, and this file is none of them. So it gets no bare-reference lint and no --fix, and a broken link in it is invisible to the reference machinery. What caught it was test_links_out_of_the_site_go_to_the_repository asserting report.unplaced == [] over the real corpus: staging is the check that reads a link the way a reader would. The seven example READMEs are covered — each is its own record’s root README.md — so the gap is now exactly one file wide.


The uncited principles, and the file the reference machinery could not see

2026-09-05 06:19:47 · record

Three principles had zero citations, and the three reasons turned out to be different. DP-012 says a rule nothing points at is a rule nobody knows applies to them, which reads as a prompt to go and add citations. That would have been gaming the count. Asking why each was uncited was the useful move, and only one of the three answers was “the principle is dead” — none of them, in fact.

DP-008 was in force the whole time, in a file nothing scanned. CONTRIBUTING.md has a section headed “Every change ships its record entry” whose first line is “Run luria new in the same branch as the work”. That is DP-008’s claim restated verbatim, at the place contributors actually read it, citing nothing. The same page states DP-006 (fire a new guard on a real case before trusting it), DP-003 (generated files are never edited by hand) and the DP-001/DP-010 split between a failing check and a report — five principles, zero citations.

The cause is that doc_refs.doc_files() listed README.md, CLAUDE.md and AGENTS.md and stopped. That set was drawn as the files an agent bootstraps from, which is a real category and the wrong one: what the reference rules care about is prose that asserts the project’s rules to a reader. CONTRIBUTING.md is exactly that. So nothing linked its references, nothing checked them, and the citation report counted a principle as uncited while the page enforcing it sat one directory away.

That is the second instance of the same gap, which is what made it worth fixing rather than noting. The first was examples/README.md, where a hand-written [ADR-077](../docs/decisions/ADR-077.md) pointed at a path that does not exist — an index-render scheme publishes its sources in place, so docs/decisions/ holds only the generated index. The site staging test caught that one; nothing would have caught a bad reference in CONTRIBUTING.md at all. Adding the file to the scanned set surfaced no violations, which is the good outcome: the page was correct, and now it is checked.

DP-007 and DP-011 are genuinely uncited, and correctly so. DP-007 — no private brains — has its application in what luria init writes into an agent file and in #140, assembling CLAUDE.md from fragments. That is an open question, not a decided one, so there is no decision for it to have shaped yet. DP-011 — pick up the debt you encounter — is a contributor’s posture with no mechanism behind it anywhere in this repository. By DP-005 it sits at rung one, prose, and nothing has walked it up. Both are honest zeros, and neither is improved by a citation manufactured to clear it.

And a decision’s consequences age even when the decision holds. ADR-045’s consequences paragraph said examples/** joins template/** in the site’s exclusions. ADR-077 reversed exactly that, and the other half of the same paragraph — two ADR-scheme limits documented rather than resolved — had already been overtaken by ADR-047. The decision itself is untouched: worked configurations are still executable examples. ADR-017 had already set the pattern for this, and it is the right one — its body stands as written, which is what a record is for, and this decision is where a reader learns the state changed back. So ADR-077 carries the note and ADR-045 is not edited. Superseding it would have retired a decision that is still in force to fix a sentence about its side effects.


A remote code in superseded_by, and a directive with nowhere to stand

2026-09-05 16:57:53 · mechanism

Two findings from the first downstream record to file a paper superseded by a paper. The anthology-of-the-sota record upgraded from 0.4.2 to 0.8.0 in one step, ran luria repair, and met the new superseded_by: check on a note whose successors were another note (itself later Rejected) and an arXiv paper not in the corpus. Both halves of that surfaced a gap.

The remote code was truncated before the check could see it. superseded_by: ARXIV-2110.08058 failed as ARXIV-2110 names no scheme or remote. The check in _any_scheme_violations says a value may be “a code that resolves in the scheme it names, or a remote code”, and calls is_remote — but on the output of reference_code, whose one regex is scheme-shaped (PREFIX-NNN). It read ARXIV-2110 out of the arXiv code and nothing at all out of a :-delimited DOI, so is_remote never saw a remote. The wrong theory, held for a minute: that the DOI remote’s uid pattern was at fault. remotes.parse_code accepted both strings whole; the field reader was the only place they broke. The fix reads remotes first, through remotes.references — the one reader of a composed code’s anatomy — and falls back to the scheme pattern. Fired on the real note: the two-item list passes, the body’s ARXIV- link still resolves, and FAKE-2110.08058 is still a finding.

The directive that could answer the second finding had no place to stand. ref_status scans every line of a note, frontmatter included, so the Superseded→Rejected chain was reported at LIT-031.md:3 — the field. But comment_fragments for markdown read HTML comments only, and frontmatter is YAML. A # inactive-ok: above the field was invisible; the downstream record ended up with a file-scoped directive for a one-line finding. Reading whole-line # comments inside the frontmatter span fixes the spelling, and the guard against reading a heading as a comment is that the scan never leaves the frontmatter.

Then the real case caught the second half. With the comment above superseded_by: the warning disappeared and the stale-directive report fired instead: nothing in scope cites LIT-041. The list form puts the code on the line after the key, and line scope is “this line and the next”. The directive covered the key; the citation sat one line further. So in frontmatter “the line below” now means the entry below — the key and its indented or - continuation lines — because luria repair writes exactly that list shape and a directive that reaches only the key excuses nothing. Prose keeps the one-line reading; the test pins both.

A non-finding, recorded because I nearly filed it as one. In that intermediate state I read the lint as printing the stale-directive line but not the retired-citation warning, and started to suspect how lint composes the two reports. A full ref_status.scan showed the site unexcused, flagged carrying it, and summary_lines printing it — the warning had been there all along, above the four lines of output I had kept. Check the whole report before theorising about the machinery.


A guard that a single exception can switch off is a guard about unanimity, not about information

2026-09-05 20:29:48

inert-status had a stated reason for testing unanimity rather than proportion, written into a test docstring where the next person would find it:

This is not a rule about proportion — a corpus whose claims all survive is legitimate — so a single retirement is enough to say a judgment is being made.

That is a real argument and it is why this change is a dial rather than a new default. But it turns out to be false in a way worth writing down, because the counterexample is unusually clean.

The counterexample

anthology-of-the-sota has 144 practices: 133 Active, 8 Proposed, 2 Superseded, 1 Deferred. The check has been silent for the whole life of that record.

In that time, 39 of those practices — a little over a quarter — were sourced to papers that do not exist. Synthetic entries from a data migration, with fabricated arXiv identifiers that happened to resolve to real papers on unrelated subjects. Every one of the 39 sat at Active throughout.

So the eleven non-Active documents did prove the vocabulary was reachable. They proved nothing whatever about whether the other 133 had been examined, and that is the thing the check is named for. “A judgement is being made” and “this field carries information” are different claims, and unanimity only tests the first.

Why the default did not move

The obvious fix — lower the default to something like 0.9 — is the one that would help, and it is the one rejected. Every existing luria record would start reporting at once, most of them correctly uniform and none consulted. This check has no per-site acknowledgement to soften a false positive; the only escape is a config line, which is exactly the friction a surprised user will not pay before switching the class off.

That asymmetry is the general lesson and not specific to this check: a guard whose cost of arriving unbidden is “the user disables the category” cannot afford to change behaviour under people. uniform_share defaults to 1.0 and the previous behaviour is bit-for-bit intact.

Two things that had to move together

uniform_ok had its own copy of the unanimity test. Left alone, a scheme could have been acknowledged under one rule and reported under another — the acknowledgement covering less than the finding, which is the failure mode that makes people distrust acknowledgements. Both now read the same uniform_share, from one helper.

The row also had to start showing its work. SOTA: 133/144 at Active on its own reads like an accusation a reader can rebut by pointing at the eleven; — 8 Proposed, 2 Superseded, 1 Deferred concedes the eleven in advance and makes the point anyway.

What is deliberately not here

The sharper question is whether a vocabulary is exercised over time — a registry where nothing has been demoted in two years is making a claim about the world regardless of today’s distribution. That needs git history rather than frontmatter, so it belongs in luria reports and not in a lint pass that reads files. Left in #167.

Verification

The four new tests are the four cases that matter: the default still clears a single dissenter (the old rule, intact); a lowered share reports 11/12; a genuinely mixed scheme still passes a lowered share, so the dial is not just a tax on large schemes; and the row names its tail. Full suite: 743 passing.


2026-09-05 20:55:23

unresolved-codes, broken-targets, retired-citations, remote-drift — four classes, and between them they ask two questions: does this pointer resolve, and have the bytes behind it moved since someone vouched for them.

Neither question can see the failure that motivated #166. In anthology-of-the-sota, 53 of 139 arxiv: identifiers named a real paper that was not the paper the note was about. 2305.10755 is a genuine arXiv entry — measurement-device-independent quantum secret sharing — filed as the PaLM 2 technical report. Every check passed on it for two years, correctly, because every check was asking something else.

The thing that makes these survive review is that they are plausible. 2111.09432 looks like a late-2021 systems paper. The year is right, the format is right, and the only way to know is to fetch it, which no human reviewer does for a hundred citations and no check was doing at all.

The first version reproduced the bug it was fixing

remotes.py says the lint must not open a socket, in a sentence worth keeping:

a check that reaches the network is a check that fails on a train

I read that as a prohibition and built the whole check around it: an explicit --resolve writes titles into the lockfile, the lint compares offline, and an identifier the lockfile has never seen is exempt. That last clause is the bug, and it is worth being precise about how bad it was.

A citation is never more likely to be wrong than in the minutes after it is typed. That is exactly the window in which nothing has resolved it. So the check passed, silently, on precisely the case it exists for — and the only thing that would catch a freshly typed wrong identifier was somebody remembering to run a command. That is the same dependence on ritual that let 53 bad identifiers survive two years, reintroduced by the guard meant to end it. Demonstrated rather than reasoned about: a new note with a deliberately wrong arxiv: produced docs lint clean.

The reading was wrong, not the sentence. ADR-016 requires the build to stay possible offline; it does not require the lint to be ignorant. Determinism matters where a check fails, not where it asks. So:

[luria.lint]
network = "auto"      # ask about what the lockfile cannot answer
#         "never"     # the hermetic build, unchanged
#         "require"   # not being able to ask is itself a finding

The lockfile becomes a cache with an endorsement in it. On a current record auto touches no network at all; when it does, it is asking about the one citation a contribution just added, it writes what it learns back so the next run is offline, and a reviewer sees in the diff what upstream said and when. Measured on the 300-document record: 7.4s either way, plus about 0.8s for the single fetch.

uris.title needed no invention — the URI table’s docstring had already described the case: “a relation Luria does not ship yet is one more name.”

Where the judgement went

Two decisions where the tempting option is the one that decays.

Guessing the metadata API per host — arxiv.org → Atom, doi.org → Crossref — would need no configuration and would work today. It fails closed: when a provider changes its response shape, the regex stops matching, the lockfile gains no entry, and no entry is indistinguishable from agreement. The check would go quiet and look healthy. Declaring title_re means a provider change breaks loudly at --resolve instead.

Fuzzy title matching would have forgiven the 22 cosmetic cases in that record for free. It also turns “is this the right paper” into a threshold argument in every future review. The rule that a check joins the lint only if the violation is always wrong and mechanically fixable is the whole reason the lint is trusted, and a similarity score is neither.

Not forgiving truncation has a real example behind it, which is in the tests: one note read “Neural Networks are Surprisingly Modular” for a paper titled “Pruned Neural Networks are Surprisingly Modular”. A dropped word, a much weaker claim, and exactly the kind of thing a similarity threshold waves through.

Fired on the real corpus, not only on fixtures

The fixtures test the logic; they cannot tell you the thing works. So it was run against the 142 identifiers in the record that motivated it:

  • --resolve recorded all 142. The first pass recorded only 123 — arXiv rate-limits a few hundred back-to-back requests, and the failures came back as “no title”, which is the same shape as the answer the check exists to find. A throttled batch would have written 15 documents’ worth of silence and read as agreement.

    The first fix was a blanket delay-and-retry, which worked and was still wrong: it retried everything and reported every failure alike. HTTP already distinguishes these, and now so does the code — 404/410 is an answer (upstream says this identifier names nothing; recorded as such, never retried) and 429/503 is not (retried with backoff, honouring Retry-After, and if it persists reported as unchecked rather than written down as an absence). The general form: a resolver has to distinguish “I could not ask” from “the answer is no”, or it manufactures the failure it is looking for.

  • With the repaired corpus, the lint is clean.

  • Reintroducing the original bug — pointing LIT-099 back at 2305.10755 — produced exactly the row it should:

    LIT-099.md:9: `arxiv: 2305.10755` resolves to "Measurement-Device-
    Independent Quantum Secret Sharing", not "PaLM 2 Technical Report"
    
  • A freshly typed wrong citation, with nothing cached and no command run, is now caught on the run that adds it:

    LIT-153.md:9: `arxiv: 2401.02385` resolves to "TinyLlama: An
    Open-Source Small Language Model", not "Attention Is All You Need"
    
  • With the metadata host made unreachable and the entry uncached, the same citation reports as unchecked rather than passing — and under network = "require" it exits 1.

  • source-ok: silences it; a source-ok: that excuses nothing is itself reported.

Left for later

Titles only. Authors and dates come back in the same fetch and are not compared, because the title is the field a record already maintains for its own reasons and so the one that cannot rot unnoticed. Adding the others is a comparison against lockfile entries that already exist, whenever someone wants it.


The form was the only file stating the schema that nothing compared to it

2026-09-06 00:01:46 · lint · contract

_template.md is exempt from every document check and the exemption is right: its codes are placeholders, its status is a prompt, and reporting them is a finding about a form nobody filed. Scheme.is_template says so in as many words, and ref_status, site and lint each skip it by name.

What nobody noticed is what the exemption left behind. The template is the only file in a record that states the schema and is never compared to it — and it is the file every document is a copy of.

The consumer project caught this the expensive way. It decided its source: field could hold several codes, changed luria.toml, watched the lint honour it, and left the form saying source: LIT-000. Three weeks and 34 new documents later, 140 of its 144 entries were single-sourced. The four exceptions were the ones a person had hand-edited while writing the decision. Nothing was broken. Every one of those 140 documents passes every check, because a scalar in a many field is legal input — it just isn’t the shape the project decided on. The capability was live, enforced, correct, and inert.

That is the shape of the failure worth naming: a drift between the config and the form does not produce one wrong document, it produces every subsequent document, and the lint calls each of them clean. A per-document check cannot see it, because per-document there is nothing to see.

So template-drift compares shape and only shape — many against list, scalar against scalar, required against present. Values are never touched; checking LIT-000 would re-import the exact finding the exemption exists to suppress. Fired on the real record it produced one row, on the one file that was wrong, which is the whole of what it should say.

Two things found on the way, both in luria new:

  • _sub_line renders a comma-separated value as 'LIT-1, LIT-2' — one string holding two codes, which is precisely the stringified list #141 added a finding for. The tool that scaffolds documents was the one thing that could still write the shape the lint rejects. It now reads many off the contract, like the drift check does.
  • A field the template does not scaffold was silently dropped: the substitution matched nothing, sub(count=1) returned the text unchanged, and the command printed a path and exited 0. Appended now.

The second is the more interesting bug, because it is invisible in exactly the way the drift is: a successful command, a well-formed document, and a field that simply is not there.


A status says what a record believes; nothing said what would change its mind

2026-09-06 00:09:01 · contract · config

The consumer project’s numbers are the argument, and they are worse than they look. 144 practices, nine of them at a provisional status, four stating anywhere at all what would settle them — and all four in prose, in the body, where nothing points at them.

The failure that followed is worth writing down precisely, because it is not the one I expected. Two practices, filed a day apart, carried conditions that are almost word-for-word identical. One paper satisfied both. Only one was acted on, and the reason it was acted on is that somebody was reading both bodies the same afternoon and noticed the paper evaluated one of them and merely cited the other.

So there were two bugs stacked on each other:

  • the condition was invisible, so nothing brought it up when the evidence landed;
  • the condition was a count of evidence, so it was satisfiable by evidence pointing the wrong way.

required_when fixes the first and cannot fix the second. That distinction took a while to hold onto while implementing, because the tempting design is a machine-checkable condition — { independent: 2 } verified against the reference graph — and it is precisely wrong. A condition a machine can check is necessarily a count, and counting is the error. The decision says so at length; I am repeating it here because I nearly built it twice.

Two implementation notes for the next person:

Field.demanded(meta) replaced every read of field.required. There were four, in three functions, and one of them was in the vocabulary path which did not take meta at all. A conditional requirement enforced by three of four checks would have been worse than none — it would have looked adopted.

The status comparison had to normalise. Proposed — pending a replication is the vocabulary’s own spelling for a qualified status, and a literal string match would have read it as some other status and exempted the document silently. That is the same shape as the bug this whole mechanism exists to prevent, and it would have been in the mechanism itself. There is a test for it.

Also, template-drift and required_when compose without either knowing about the other, which was luck rather than design and is worth keeping: the drift check asks the form whether it prompts for the fields the form’s own frontmatter demands, so a template that starts a document at Proposed, in a scheme where a proposed entry must state its condition, is told to prompt for it.


A line is a DAG, and a rival that succeeds nothing still belongs to it

2026-09-06 00:15:37 · record · mechanism

Three things that were not obvious until the code existed.

A line is a DAG, not a list. Two papers can replace the same one, and the consumer record has exactly that — two rival replacements for the residual connection, both extending it, neither extending the other. A renderer that assumed a list would have had to pick one and drop the other. So the spine is ordered by longest path from a root rather than by insertion, which is what guarantees a step never renders above something it extends, and the page nests by depth.

A rival that extends nothing still belongs to the story. The first version built components from the spine alone, and the rival design — which compares itself against the line but succeeds nothing in it — came out as an isolated node and rendered nowhere. Cross-links join the component; they just do not join the spine. alongside is that distinction, and it is the reason two papers that only compare themselves to each other still render as a comparison, which is the fact the field exists to record.

The cycle guard had to be in the walk, not only in the report. A cycle is a finding, and a finding is not a licence to render nothing (DP-15) — so both _depths and _reaches terminate on their own: the first bounded by the group’s size, the second visited-guarded. A record with one bad edge still gets its other nine lines. This was worth a test on its own; the natural implementation of both functions recurses and dies.

What I deliberately did not build: anything that reads the argument. The page renders order, title and status. The temptation is to pull each note’s first paragraph in as a summary of the step, and it is wrong for the reason the decision gives — a view that reads well enough to replace the prose invites deleting the prose, and the prose is where “why this step happened” lives. The next person will want to add it. The answer is no.

Untried: the mechanism has never met a real corpus. The consumer record’s residual line is six documents and is where it goes first — one line, read the result, then decide about the other nine.


2026-09-06 00:30:13 · record · mechanism

The chain renderer shipped with 20 green tests and did not survive contact with a real record for one command. Four bugs, and the interesting thing is that three of them are only visible at the scale of a whole project.

The links. _link computed a path into the scheme’s view directory — docs/literature/LIT-140.md. For an index-rendered scheme that directory holds a README and a set of tag pages and never a page per document, so every link on the page resolved to nothing. The fixture asserted the target’s shape ("(literature/LIT-001.md)" in page) and shape was exactly what was right about it. The test now walks every target on the page and asserts the file exists. That is the assertion to write for any generated link, and I will not get this one back.

The fix was already in the codebase: prefix_for, which the index’s own rows have used since the read/write split. Inventing a second convention for the same job is the failure — not the arithmetic.

The status note. Superseded — by [LIT-140](LIT-140.md) is prose authored in the source’s frame and it went out unrebased. adr_index.row has a comment about this exact bug, from when it broke four tag pages. I read that comment while writing _link and did not connect it.

The page as a citing site. is_generated exists so that a view is not scanned for references, and the reason is sharper for a chain than for anything else already on the list: the page’s purpose is to show a line including its retired steps. Scanning it reported every superseded document in every chain, at a site the reader must not edit, in a file the next build overwrites.

The relation as a citation. This is the one worth remembering. With the page exempted, the source documents still reported: extends: LIT-141 counted as citing a retired document. Which it does — and a successor’s predecessor is superseded by construction, so that finding fires once per retired step in every chain, forever, at the field whose entire job is to name it.

The tempting fix is an acknowledgement comment per edge, and CLAUDE.md already says what that is: a guard that keeps catching you is a bug report about the workflow. So the relation fields join formerly: and codes inside URLs as spans that are blanked before the citation scan. The codes are still checked — that a reference resolves, in the declared scheme, is the contract’s business and untouched. What is suppressed is only reading these two fields as citations.

The line to hold, and there is a test for both sides of it: the field is exempt, the prose is not. A paragraph pointing at a retired document is the finding this record adopted the check to get.


The rebase was the wrong fix; the field was already split

2026-09-06 00:38:42 · record · mechanism

Yesterday’s chain renderer called Adr.status, got Superseded — by [LIT-140](LIT-140.md); mHC restores the identity mapping…, and shipped a link authored in the source directory onto a page that renders somewhere else. I fixed it by rebasing the link, which works, and was the wrong fix.

status, superseded_by and status_note are three fields. Adr.status is one composed reading of them — the right one for an index table, where a row is all a reader gets. A chain page is not that: the successor is the next line on the page, so linking it in the status says twice what the shape already says, and the note is an argument about why the step happened, which is precisely the half this view exists to leave on the document.

status_value was there the whole time. Rendering it removes the composed form, the link, and the rebase together.

The lesson generalises past this bug. When a display helper hands you more than you want and the extra needs repair to be safe on your surface, the question is not how to repair it. It is which field you actually meant. The composed form is a convenience for one surface; a new surface should ask the fields what it needs, which is the whole reason ADR-072 made the note a field instead of leaving it inside the status string.

This is the second thing in two days where the answer was “the record already models this, read the narrower field”: the first was reading many off the contract instead of guessing a scaffold’s shape.


The mechanism was built on a premise it did not enforce

2026-09-06 04:39:20 · contract · config

Review of #172 found the hole, and it is the good kind: the thing the design argues for, not enforced by the design.

required_when validated shape — one key, non-empty values, not also required. It did not validate the content of the condition. So { staus = ["Proposed"] } and { status = ["proposed"] } were both accepted, both never held, and both left the field silently never required. That is word for word the failure the module docstring gives as the reason for validating eagerly: “a condition that can never hold … surfaces as no violations.” I wrote that sentence and then shipped the thing it warns about.

The validation had to move to after scheme assembly — a condition can name a field declared in a different table, so no single parser sees enough. Values are checked only where a closed set exists: the status vocabulary and vocabulary-backed fields. A free-text field has nothing to check against, and refusing on that ground would forbid the ordinary case.

The second finding was subtler and I would not have found it. holds read raw frontmatter. A vocabulary field with a default is never absent (ADR-076) — so a condition naming that default never held, for exactly the documents it was written about. Silently. The fix is that resolution goes through the compiled contract, which already knows how to read a field’s effective value, and RequiredWhen goes back to being pure data. That also removes config importing statuses, which was a module reaching up into one that depends on it.

The third was a gift. ADR-071’s “a Superseded document names its successor” had been a hand-written branch in check_frontmatter since before there was any way to declare it. This PR added the way. Stating it as a required_when on the built-in superseded_by deleted the branch, and the built-in inherited the finding wording, the because: provenance and the record-page line — and fired on a real case in the consumer record the same afternoon.

One place the reviewer’s suggestion did not survive contact. They wanted the built-in conditional rendered on the record page, and noted as a wrinkle that describe() skips built-ins. The wrinkle turned out to be the deciding factor: describe() feeds a section headed “beyond the standard fields”, whose own fallback text enumerates them, and rendering the built-in there made every scheme look as though it had declared a contract — a real test caught it. So the rule is stated once, in that section’s intro, with the standard fields where it belongs. The intent survives; the placement changed.


The symmetry check became a fixer

2026-09-06 18:15:24

The chain walker shipped with a broken-chains finding for a comparison only one side declares. The first consumer hit it immediately and the finding was right about the record and wrong about what to do next: a 2026 paper compares itself against a 2024 one, and the 2024 note cannot possibly have declared a comparison against work that did not exist. The fifth member of a family means opening four existing notes to file one. Three documents were edited for one comparison — the edit-N-places shape the chain exists to remove, reappearing as field churn instead of paragraph churn.

Deleting the check was the obvious move and the wrong one: the statement it makes is true, and a page that groups a family correctly from a one-sided declaration is a page whose data is still half-written. The remedy was already in the codebase’s vocabulary — legacy-spellings is reported with “luria link --fix upgrades them” — so the finding stays and the fixer satisfies it. Completion is the default and --links-only is the escape hatch, because a flag that must be remembered to get correct behaviour is a flag nobody remembers.

Two things the implementation had to get right. relation is directed — mirroring it manufactures the cycle the same check reports — so only sibling is completed, and a cycle stays uncompletable on purpose: which of two steps came first is not in the data. And a many field accepts a single code written as a scalar, so _add_to_field widens a scalar into a list rather than replacing it; overwriting would silently delete a relation, which is the exact failure mode this module exists to end.

Fired once on the real corpus before trusting it: the anthology’s three comparison notes, with the hand-written back-references removed so the record read the way an author would have left it. Lint reported both gaps, --fix wrote both, lint went clean, and a second run was a no-op. The only diff was the field’s position in the frontmatter — a created field appends, which is semantically identical and not worth the machinery to place.

The trap for the next person: completion is whole-record even when PATHS is given, because “missing” is only defined against every document of the scheme. Narrowing it to PATHS would report a back-reference as absent whenever the file holding it was not passed.


The constraint was about the field, not the direction

2026-09-06 18:38:42

Shipped the symmetric-completion fixer with this sentence in it: “relation is directed — A extends B does not make B extend A — and mirroring it would manufacture the cycle the lint reports.” Review asked why that ruled out directed relations rather than just self-mirroring, and the answer is that it doesn’t. I had generalised from a true premise to a false conclusion and written the false one into a module docstring, a commit message, a PR body and an issue.

What is actually forbidden is writing a relation into its own field. extends: A on the document A extends asserts something false, which is why it surfaces as a 2-cycle. That is a constraint on the field written into. A directed relation completes fine into a different field — its converse — where the fact is true.

The good part of the correction is what it collapses. Once the converse is the unit, symmetry is not a second mechanism: compared_against is the relation whose converse is itself. What shipped was the special case wearing the general case’s name, and the general case is smaller.

Three things the implementation turned on.

The declaration has to be the licence. Absent a declared converse, nothing is completed and nothing is reported, because naming the reverse of extends means knowing the project calls it extended_by and wants it stored at all. This makes the change silent in the wrong direction — a project whose symmetric field completed last release stops completing until it adds converse — so it is in the changelog under Changed, not Added.

A pair must be mutual. Half a declaration completes in one direction only, which is the kind of thing that looks like it works. Refused at config load, along with a cross-scheme converse and a non-many side: either side is written into, and N documents can extend one.

A contradiction is not a completion. extends: B and extended_by: B on one document, or two documents each claiming to come first, has nothing missing — two incompatible things are present. My first pass at _contradictions was genuinely broken (a dead loop doing nothing, left in), and the test for it was the only reason I looked.

The check and the fixer moved to relations.py and to a new lint class. The trigger was file size — chains.py would have gone past 500 lines — but the reason it was the right move is that a declared pair is one-sided or it is not, whether or not any view walks it. broken-chains keeps the cycle, which is genuinely about a sequence.

Fired on the real corpus before trusting it, and this time on the case that could not exist before: declared extends/extended_by on the anthology’s residual chain. Three one-sided relations reported, three written, lint clean, second run a no-op, and LIT-140 came out with extends: LIT-141 and extended_by: [LIT-151, LIT-181] — the fork legible on the trunk document itself. Also checked the union separately by deleting a forward declaration entirely: the page still nests the child correctly from the converse alone.

The correction after that one

Review asked the right follow-up: does this notice a relation being removed? It did not. The mechanism was monotonic, and the demonstration took four commands — delete extends: LIT-001, run luria link --fix, watch it come back. Silently. The record fighting the author is worse than the edit-N-places problem it was built to fix.

The cause is that a one-sided pair has two opposite readings and the working tree holds neither. Which side changed is the missing fact and it is not in the files; it is in the diff. luria already shells to git in five modules and the workflow is branch-then-PR, so HEAD is both available and the right baseline — the last state the record was consistent in.

The rule that fell out is about change rather than state: gained on either side means write both, lost on either side means prune both, gained on one while lost on the other is two deliberate edits that contradict and gets reported. An edge nothing touched falls through to adding, which is what a corpus predating the fixer needs.

Two things worth writing down.

The sharp edge is a deletion committed before the fixer ran. It looks like “nothing changed” and gets written back. I could not find a way to distinguish it without walking history for the last consistent state, which is git log -S territory and disproportionate. It is self-correcting — delete it once more and the deletion is a change — so the cost is one confusing round, and it is in the ADR and docs/cli.md rather than left to be discovered.

git grep -l at HEAD is what makes the baseline cheap. The answer only depends on documents that declared a relation, which is a handful of a corpus; reading every committed document would have been ~500 blobs per run on the anthology.

Two implementation notes for whoever is next. A pruned field that empties must have the field line removed too — a bare extended_by: with nothing under it is invalid frontmatter, not a relation held by nobody. And when I rewrote completions() around intents I dropped the older check for a relation asserted in both directions of one pair; only its test caught it, which is the second time on this feature that a test I wrote before the code was the thing that noticed.

Fired on the real corpus again, on the case that motivated it: retracted LIT-181’s extends: LIT-140 from the anthology’s residual chain. Lint named it stale, --fix removed LIT-181 from LIT-140’s extended_by: and left LIT-151 in place, lint went clean, second run a no-op.

And the bug that found

Declaring extends/extended_by on the real corpus turned one lint finding red that had been green: extended_by: was being read as a citation site, so a completed edge pointing at a Proposed document reported “cited without acknowledgement”. The forward field was already exempt — a predecessor being retired is what a line looks like — and the converse says the same fact from the far end, so exempting one and reporting the other hands back a finding for every edge the fixer writes.

relation_spans was chain-driven, which is the same incoherence the check itself had: it belongs to the relation, not to the view. Moved to relations.py and widened to both halves of any declared pair.

Worth noting how it surfaced. The unit tests were green; what caught it was running the change against 387 real documents and reading the diff of the generated report, not the code. That is the second finding on this feature that only the real corpus produced.

The interaction with conditional fields

Asked whether required_when and converse interact. They do, and the answer needed building the combination to see: neither PR alone has both features, so I merged #175 into this branch in a scratch worktree (873 tests green) before testing anything.

Two of the three things I suspected were real.

Not a bug: a required_when conditioned on a relation field. I assumed it would be accepted and silently never hold, because a reference has no closed value set to validate against. It holds correctly — the finding even renders the list. I had tested it first against a document whose relation I had already deleted, which is why it looked broken. Re-tested properly before saying anything.

A real one: the fixer edits frontmatter, frontmatter is what the contract judges, and the fixer consulted no contract. So --fix could move a document from satisfying its scheme to violating it. Both directions are reachable with legal config — a back-reference added into an at-most-one field group, or a stale one removed out of a field required_when demands. Demonstrated both: in the second case luria link --fix printed “removed 1 stale one(s)”, exited zero, and the next luria lint failed.

The fix is one rule rather than two: a repair that would introduce a new violation is not applied. I considered pruning-but-warning for the removal case, since the withdrawal is a legitimate edit and refusing it ignores the author. Rejected because two behaviours is more machinery for less of a promise, and the single rule buys a property worth having: running --fix never makes lint worse.

Only new violations block — a document already in breach elsewhere still gets its back-references, or one unrelated mistake would freeze every relation it stands in. That distinction has its own test, because it is the one that would rot quietly.

Written against the compiled Contract rather than against required_when specifically. That is why it works: the guard is on this branch, which does not have required_when at all, and copying relations.py into the merge worktree covered the required_when case with no further change.

A smaller thing, not fixed: a converse naming the built-in superseded_by is refused with “‘superseded_by’ is not a reference LIT declares”, which is untrue — every scheme has it, but _checked_converses only sees the user-declared table. The refusal may well be right; the message is wrong, and it needs deciding whether a built-in can carry a converse at all before the message can be fixed honestly.


What the vocabulary migration hits

2026-09-06 20:24:04

Spiked migrating status: onto the ADR-076 vocabulary mechanism and backed it out. Recording what was learned so the next attempt starts from here rather than from the top.

The prohibition is the only thing holding it. BUILT_IN_AXES refuses [luria.schemes.X.fields.status] outright. Removing status from that tuple broke nothing — 888 tests still passed — and a spiked project then validated statuses correctly through the generic path. So the carve-out is a wall, not a load-bearing dependency.

The duplication is observable, not theoretical. With the declaration in place, one bad status word produces two findings: statuses.undeclared’s and _vocabulary_violations’. Worth keeping as the demonstration if anyone asks whether this is worth doing.

What actually blocks it, in the order I hit them:

  1. status: Deferred — until the audit. The raw value is not the vocabulary value, so the generic check compares the whole string and reports beside the existing “carries a note” finding. Parsing has to happen before the vocabulary check sees the value. This is the only genuinely status-specific thing in the way, and it means the migration is not the pure deletion it looks like.
  2. Contract.empty flips once a scheme declares status, so the record page stops saying “nothing beyond the standard fields”. A shipped-record test pins that.
  3. A page per status value lands beside the tag pages, which moves outbound links a site test pins.

None is hard; together they are more than one sitting, and each wants its own test.

On tags. I said the blocker was openness. That is half of it. The other half is TagGroup — “exactly one of these seven” is a constraint over a subset of values, and a Vocabulary has many, required and default but nothing that says that. Faceted views are NOT the difference: vocabularies.pages() writes a page per value explicitly modelled on the tag pages, so status would get those for free. Worth noting that field_groups is already the same grouping idea over fields, so a value-group would be the third spelling of one pattern.

Method note. Reading ADR-076 before scoping was what made this tractable: it names status and tags together every time, uses “they are themselves vocabulary-backed fields” to reject an alternative, and then carves them out anyway. The decision record had the blast radius in it already, and an hour of code-reading would not have produced the tags-needs-two-things conclusion as cleanly.


The form’s own words reached the published index

2026-09-06 21:08:13

Two Proposed decisions were on the published site with the template’s instruction where their summary should be. “One-paragraph description of the decision, the cost that motivated it, and the alternatives that lost.” The index rendered it in the row, the site rendered it under the title, and nothing objected: the summary key was present and non-empty, which is all any check asked.

The guard reads the form. A prose field whose value is what the scheme’s _template.md says in that field is the form’s text, not the document’s. luria lint reports it as a violation, not a warning: there is no reading under which a document means to say what its blank form says, and the mechanical fix is to drop the key, since an absent summary falls back to the title. Compared with whitespace squashed, so a reflowed placeholder is still the placeholder; a scheme with no form has nothing to compare against. Fired once on the real case before the fix: it named the two files and nothing else.

Then it fired a second time, on the scaffolder. luria init’s test scaffolds a first document with luria new and lints it, and the new check refused it: luria new copies the form whole, placeholder summary included, so every document filed without --summary started out saying what the form says. A guard that catches the same thing twice is a bug report about the workflow, and the workflow was the bug: the scaffolder now drops a prose field the caller did not fill and keeps the comment above it, which is the instruction. An absent summary falls back to the title until the author writes one.

The two summaries are written, condensed from the decisions’ own bodies and alternatives, and are the first thing to read if the condensation lost something the author meant.


Migrating status onto the mechanism it was generalized from

2026-09-06 21:32:02

ADR-085’s migration, done. Notes for whoever does tags.

Reading the decision first was worth more than reading the code. ADR-076 names status and tags together every time, uses “they are themselves vocabulary-backed fields” to reject an alternative, and then carves them out. The blast radius was in the decision record already — including that tags needs two things and status needs neither.

The — note form was the only genuinely status-specific obstacle. status: Deferred — until the audit puts a qualifier inside the value, so the raw frontmatter is not the vocabulary value. Solved by normalising once, at the boundary where frontmatter is read for checking, rather than teaching the generic checker about it. Everything downstream sees a bare word and needs no idea this field is different.

Two things the migration surfaced that are not about status at all.

Declaring [luria.schemes.ADR.fields.status] makes schemes a declared family, which replaces the shipped default whole (ADR-047) — so a project on the default that adds only a fields table silently loses its scheme’s dir. The upgrade command refuses that case and says to write the schemes out first; the sharp edge is worth knowing about generally.

And luria init plans a scaffold from a config whose vocabulary files it is about to write, so eager validation had nothing to validate against. Fixed with a scaffolding seam on load() used by exactly one caller, rather than relaxing ADR-076’s rule for everyone.

The marker is the part I would have skipped and shouldn’t have. An upgrade is temporary by construction and permanent in practice, because nothing asks about it. spent-upgrades reports an upgrade this record no longer needs — the same posture stale-directives takes toward a directive that no longer suppresses anything. Fired on this record the moment its own upgrade ran, which is the guard working.

Where the count went. 892 → 896 tests. The migration cost was spread across seven test files, all of it “this fixture declares no status vocabulary” — which is exactly the shape a real record hits, and exactly what the upgrade command absorbs for them.

One botched edit worth naming, because the symptom was so far from the cause: a string replacement meant for the FAILABLE tuple also matched inside a sections.append(...), turning a 3-tuple into a 4-tuple. The error surfaced as “too many values to unpack” pointing at a test line in an unrelated module. Anchoring a replacement on a short repeated string is how that happens.


One fact, one bit: two duplications the record line grew

2026-09-07 00:35:19 · site · regression

Reported from the outside, which is the part worth noticing: “frontmatter renders funny on the quartz site luria builds”. Both defects were live on two published sites, both were introduced by features that shipped green, and neither was visible to a test.

What was on the page

LIT-140 in the anthology, under 0.10.0:

> **Status** Active · **Filed** 2026-09-05 · **Status** Active ·
  **Extends** LIT-141 · **Extended by** LIT-151 · LIT-181 ·
  **Compared against** LIT-152 · **Supersedes** LIT-141 ·
  **Cited as `compared_against` by** LIT-152 ·
  **Cited as `extended_by` by** LIT-141 ·
  **Cited as `extends` by** LIT-151 · LIT-181 ·
  **Cited as `source` by** SOTA-136

LIT-141 is named three times. Of the eleven bits, four are repeats.

Two causes, each an addition meeting an assumption

Status twice. record_line has always rendered the status itself, through statuses.display. #181 made status: an ordinary declared vocabulary, and _vocabulary_bits — a generic loop over the scheme’s declared vocabularies — started rendering it too. Nobody wrote the second one; it arrived because the field joined a set the loop reads. Since #181 requires the declaration, this fired on every document of every scheme in every record.

Every declared converse twice. _edge_bits renders inbound edges, and its docstring says exactly why: they are “the one direction the site otherwise loses, since frontmatter renders as nothing”. That was true when a relation lived on one side. The converse declaration writes the fact on both sides, so the direction is no longer lost — and the backlink became a second printing of a fact the outbound bit had already shown, under a label built from the raw field name.

The second one is the more interesting failure. The reasoning in that docstring was correct, load-bearing, and quietly falsified by a feature two releases later. Nothing links the two: no test asserts the invariant, and the converse work never read this function.

Why the test suite could not see it

test_record_line_carries_status_date_and_lineage asserts "**Status** Active" in line. A substring test is satisfied by two of them. It has been green throughout. The fix’s test asserts line.count("**Status**") == 1 — the count is the assertion the duplication class needs, and in cannot express it.

The choice inside each fix

Which status bit wins. The vocabulary loop’s version was arguably nicer: it linked the word to its value page. It loses anyway, because it renders the bare word and only statuses.display composes Superseded — by X; note from the fields around it. Restoring the link on the dedicated path is a real improvement and is filed separately rather than smuggled into a fix.

Suppress by declaration, or by what is stored. Suppressing every inbound edge whose relation declares a converse is one line shorter and relies on the one-sided-relations lint holding. Suppressing only where the converse is actually in this document’s outbound edges is exact: a record mid-repair, or one whose lint findings are unaddressed, still sees the edge it has. The guard should not go quiet on precisely the records that are already wrong.

Fired on the real corpus

Synthetic fixtures pass either way, so the number that matters came from staging the anthology’s 353 record lines with both builds:

0.10.0fixed
**Status** bits692346
Cited as … bits8674

692 is exactly twice 346. The twelve backlinks that went are every relation with a declared converse — compared_against (5), extended_by (4), extends (3). The 74 that stay are the cross-scheme relations that declare none and so have no other way to be seen: source (71) and contested_by (3), the latter meaning a paper’s page now says which practice it contests.


2026-09-07 00:58:48 · readme · site

Asked from the outside: “I’m surprised I’m not seeing a link/badge to the site on the readme. Is that because we’re missing an annotation somewhere, that link is manual and the user’s responsibility, or maybe something in luria site is wired halfway?”

Third one, and the answer was on line 57 of this repository’s own README:

<!-- luria:badges -->
…two generated badges…
<!-- /luria:badges -->

📖 **[dmarx.github.io/luria](https://dmarx.github.io/luria/)** — …

Hand-typed, one line below the closing marker of a region luria index rewrites on every run, in the repository that ships DP-3.

What went in

A luria:site region rendered from Site.base_url, plus an unlinked-site finding. The reasoning is in the decision; two things belong here instead.

The predicate was the hard part

“This record publishes a site” has no obvious signal, and I got it wrong twice before it was right.

base_url alone — fires for every GitHub project whether or not one is deployed. A record that lives only in its repository would carry a warning it could never clear.

"site" in raw — the presence of a [luria.site] table. Written, tested, and it failed: DEFAULTS injects "site" before _site ever sees the user’s TOML, so the key is always there. That was luck, because the idea was wrong anyway — the defaults’ own comment says “the conventional case needs no [luria.site] table at all”, so making the table the opt-in would have contradicted the design one function above.

[luria.site] publish, defaulting true. A new key, which needs an excuse. The excuse is that the alternative is a warning a non-publishing project can never clear, and a guard nobody can satisfy is a guard people learn to skip.

The third copy is what asked for the extraction

Looking for where to put the new region found the machinery already written twice — badges.py and citation.py — and about to be written a third time. Four lines each: OPEN, CLOSE, a compiled regex, a sub. They had already drifted: re.DOTALL in one, re.S in the other; an f-string against concatenation.

Nobody would have extracted this at two copies, and that is fine. The signal was reaching for a fourth. readme.py now holds it, and both callers keep their OPEN/CLOSE names so nothing that addresses them had to move.

Splitting region() into _inner() plus markers was the whole mechanical cost — the shared rewrite composes the markers itself, so the callers had to stop composing them too.

Fired before trusting

The finding reports the anthology, which is the record that prompted the question, and is silent on this one now that the region writes the link — clean from a build that had reported it minutes earlier with the hand-typed line deleted and no region in its place.

What is still missing

luria init writes docs/README.md and no root README, so there is nothing for it to scaffold markers into. That is why the finding carries the whole load rather than half of it, and it is worth revisiting if init ever starts owning a project’s front page.


The properties panel cannot show what the record line is for

2026-09-07 01:53:41 · site · quartz

“Frontmatter renders funny on the quartz site” turned out to be two bugs nobody had complained about and one complaint nobody had filed. The bugs got fixed first — a duplicated status, a duplicated converse — and the actual complaint was simpler: “the frontmatter fields were rendering on a single line, separated only by a center dot. I think a table would probably look nicer.”

Correct, and it is record_line’s to fix, not Quartz’s.

Why the plugin does not do this

The suggestion was Quartz’s @quartz-community/note-properties, which “renders frontmatter properties as a visible properties view on the page, similar to Obsidian’s properties panel”. Two reasons it is not this:

It is a v5 plugin, and the site action pins v4.5.2. The action’s own input documentation already says why that pin is deliberate: “luria site writes a quartz.config.ts against this version’s plugin API, and v5 replaced that file with a YAML one.” The 110 lines of generated TypeScript config and layout would have to be rewritten as YAML. That is a real piece of work with its own merits and no bearing on this complaint.

A properties panel shows the page’s OWN frontmatter, and that is the smaller half. What is actually on the record line for LIT-140:

bitwhere it comes from
Statusthis page’s frontmatter, but composed — Superseded — by X; note is three fields read as one reading
Extends, Compared againstthis page’s frontmatter, but as resolved links rather than bare codes
Supersedesanother document’s superseded_by:
Cited as source bya backlink — every page that names this one

A panel over this page’s YAML can render the first row as Superseded and the second as [LIT-141], and cannot render the last two at all: it has no reader for other documents. Turning it on would add a raw-YAML block beside the record line, not replace it.

So the two are not alternatives. Upgrading Quartz is worth doing for the five versions of upstream work; it does not make the record line unnecessary, and record_line’s output was going to need to be a table either way.

The change

record_line and its two helpers built strings (f"**{label}** {value}") and joined them. They now build (label, value) pairs and a _table renders them, which is the whole diff — the mechanical cost of having formatting and content in one expression rather than two.

Header-less (| | |), because “Field” and “Value” name nothing a reader did not already know from the rows.

Twelve test assertions moved from "**Label** value" to "| **Label** | value |", and three that assumed the blockquote (> ) it no longer is. That count is a fair measure of how load-bearing the shape was — worth knowing before changing it again.


A title is data, and this record has one that reads as syntax

2026-09-07 03:02:58 · site · quartz

Two presentation asks — put each document’s title after its code, and give multi-valued fields a bulleted list instead of center dots. Both landed. The interesting part is what the second one required and what the first one broke.

Bullets in a table cell need HTML, and that needed checking

A markdown table cell cannot hold a block-level list, so a real bulleted list inside one means <ul><li>. Whether that survives depends on the renderer, and guessing would have shipped a page full of visible angle brackets.

So it was checked rather than assumed: cloned Quartz v4.5.2 — the version actions/site pins — and built a probe page. Raw HTML passes through, and the part that actually mattered is that markdown inside the <li> is still parsed: the links resolve, and CrawlLinks rewrites them to internal slugs with the right data-slug. Then the whole anthology through the same build: 410 files, 1217 pages emitted, the bullets are a real <ul>, and the links inside them come out class="internal alias".

That last check is what a fixture cannot give you.

The title that reads as syntax

Splicing titles in broke luria site on this repository, and the failure is a good one:

record/decisions.d/ADR-005.md → unresolved in frontmatter: [[CODE]]
record/decisions.d/ADR-024.md → unresolved in frontmatter: [[CODE]]

ADR-025 is titled Wikilinks: `[[CODE]]` is a typed reference, and typing it changes the rules. Both of those decisions carry influenced_by: ADR-025, so both of their record lines now contained a literal [[CODE]], and the wikilink expander dutifully went looking for a document called CODE.

A title is data, and it was being spliced into two surfaces that read syntax: a markdown table cell, where | ends the cell, and a string about to be wikilink-expanded, where [[ opens a reference. Escaping both is the fix; markdown renders \[ as [, so it costs the reader nothing.

The backticks around it in the title are not protection — the expander does not mask code spans, which is consistent with ref_status.scan being deliberately unmasked and worth remembering as a general property rather than a surprise.

What found it was test_links_out_of_the_site_go_to_the_repository, which asserts report.unplaced == [] over the whole real record. A fixture with tidy titles would have passed. There is now also a test that names ADR-025 specifically, so the escaping cannot be quietly removed by someone who does not know why it is there.

One more masking property, learned the hard way

The quoted failure above is in a fenced block, not an indented one. The first draft indented it, and luria link --fix rewrote the codes inside it — record/decisions.d/[ADR-005](…).md. Only fenced spans are masked; a four-space indented block is ordinary prose to the scanner. Worth knowing before quoting tool output that contains codes.

Shape notes

Rows changed from (label, str) to (label, list[str]), which is what lets one renderer decide between a plain cell and a list without parsing its own output back. Titles are looked up once per stage() through ref_status.load_docs() — the reader that already answers “what documents are there and what are they called” — rather than a second one that could disagree.


2026-09-07 04:40:02 · remotes · references

LU-ADR-076 has always resolved to luria’s ADR-076. LU-#193 resolved to the citing project’s issue 193.

The prefix was doing nothing. ISSUE_RE matched the #193, the local issue_url formatted it, and link --fix wrote a link that was correct in every mechanical sense and pointed at the wrong repository.

Why it survived

Every check passed, and each for a good reason.

broken-targets verifies that a target resolves — and it did, to a real issue. hand-written-urls was satisfied because nothing was hand-written. The lint’s whole model of a bad link is one that goes nowhere, and this one went somewhere.

It had already shipped. dmarx/anthology-of-the-sota ADR-015 carried LU-[#173](…/anthology-of-the-sota/issues/173), written meaning luria’s issue 173, pointing at the anthology’s own. It survived review, link --fix, lint and a merge to main, and was found only because the same mistake was made a second time and noticed by eye.

That is the shape worth remembering: a guard that only asks “does this resolve” cannot see a reference that resolves to the wrong thing. The unresolved-codes class has the same blind spot by construction, and source-mismatch exists because the same question came up for titles.

The fix, in two halves

Construction was the easy half: remotes.issue_link, defaulting to GitHub’s convention for a remote with a repo, overridable with issue_url for another forge — the same bargain the uris table already makes for documents.

Scanning was the actual bug. The local issue pattern read #193 out of the middle of LU-#193, so even a correct constructor would not have been reached. The remote reference has to claim its whole span first, which is exactly what find_refs already does for LU-ADR-013 and says so in a comment written for that case. The pattern is built from the declared remotes, so an undeclared prefix stays ordinary prose followed by a local issue number.

Where it deliberately does nothing

A remote with no repo and no issue_url — ARXIV, FX — resolves to nothing, and the reference is left bare. Inventing a tracker for a remote reached by a url template would move the silent wrongness somewhere new rather than remove it.

Fired against the anthology’s real configuration rather than a fixture: LU-#194 to luria’s tracker, a bare #17 still to the anthology’s, ARXIV and FX to nothing.


2026-09-07 05:35:05

Two blind spots, both of which had to hold for the finding to stay hidden.

Scheme.documents() keys by number and skips any file it cannot get one from, so a SOTA-tmpauaby.md never reached ref_status.load_docs() — even though the file carried status: Proposed in its own frontmatter the whole time. And ref_status.CODE_RE required digits, so the citing site was not a site. A Proposed document could be cited as settled architecture from a sibling filed on the same branch, and the lint said nothing until luria concretize gave both real numbers.

The issue floated widening documents() to a union return type, which five call sites had deliberately been consolidated onto. It turned out not to be needed: adr_index.load_scheme(scheme) already returns numbered documents plus temporary ones, and Adr.code already spells either. The fix is to ask that function the question instead of re-deriving it — documents() still answers “every numbered document”, which is a different question and still the right one for its callers.

What it found on its first run

Five comments across config.py, contract.py, init.py, site.py and statuses.py cited ADR-tmpstat1. No document has ever answered to that tail — it is not a minted one, and it is in no formerly: list. It was hand-written as a placeholder for the decision that became ADR-085, and because nothing scanned temporary codes, five source files claimed a decision explained them and nothing said the claim resolved nowhere.

Correcting them to ADR-085 then raised the real finding underneath: ADR-085 is Proposed, and the code that implements it ships on the trunk citing it as settled. That is the drift the module exists to report, left visible rather than acknowledged — an inactive-ok: here would go stale the moment the decision is accepted.

The one-time cost, and where it lands

The check is unmasked by design, so an illustration of the temporary shape reads as a citation: ADR-tmp47fje in ADR-049’s prose, ADR-tmp3kf9x in the README transcript, fixture tails in the tests. Eleven sites here, cleared with nine unresolved-ok acknowledgements. Placement caught me twice, both times the scope rule doing its job: a directive inside a module docstring is not a comment and does not fire, and a directive one line above a comment run needs -block, not line scope.

Every record that documents merge allocation pays the same cost once. The anthology has exactly one — LIT-tmp3kf9x in its ADR-013 — and the acknowledgement has to be filed with the version bump: on the older luria the code is not scanned, so the directive excuses nothing and gets reported as stale.

Fired before trusting

Beyond the five unit tests: on this record, where it found the ADR-tmpstat1 comments; and on anthology-of-the-sota, where filing a Proposed practice with luria new sota and citing it from SOTA-145 produced SOTA-tmp14p3h is Proposed, cited 1× in 1 file(s) — the finding #203 says arrived a merge too late.


The chain page encoded depth but not parentage

2026-09-07 16:55:22

lines_of returned the spine sorted by (depth, code) and _render indented by depth. Nothing anywhere recorded which parent a step nests under, because for the chains that existed when the feature shipped it did not need to: one root, one parent per step, and a depth sort is a valid pre-order.

Both assumptions broke the first time a consumer declared a real DAG. anthology-of-the-sota filed Gated DeltaNet as extending both Mamba-2 and DeltaNet — two parents, and therefore two roots in one weakly-connected group. Sorted by (depth, code) that group came out

LIT-161 (0)  LIT-195 (0)  LIT-162 (1)  LIT-137 (2)  LIT-165 (2)  LIT-133 (3)

and indented by depth it reads as: Mamba-2 descends from DeltaNet, and Kimi Linear descends from Mamba-3. Neither is declared anywhere. Both are stated, confidently, in a generated file.

What makes this worth an entry

The failure is silent and it is in a view. The sources were correct the whole time — extends: said exactly the right thing on every document — and luria lint was clean, because nothing checks that a rendering preserves the relation it renders. A reader would have had to open two notes and compare them against the page to notice, which is precisely the work the page exists to save.

It is also a failure that gets more likely as a record gets better. Every chain is a tree until someone declares the second parent, and declaring the second parent is the thing you do when you understand the lineage properly. So the feature worked for as long as the records using it were thin.

The fix, and the part that is a judgement

Emit each subtree depth-first from its root, so a step always follows the parent it nests under. Where a step has several parents, nest it under the deepest — it should sit beneath the most specific thing it extends, not the most ancestral — and name the rest inline: — also extends LIT-195.

That last part is the judgement. A tree layout can draw one parent per node, so a DAG loses edges to the layout no matter how the order is chosen. The alternatives were to render the node once per parent (which duplicates subtrees and inflates the page) or to say nothing (which drops a declared fact from the only view that claims to show the line). Naming them is the cheap honest option: the page holds every edge, and only the drawing is a tree.

Cycle members are appended after the depth-first walk rather than dropped. They are unreachable from any root by construction, and rows() already reports them; silently omitting them here would hide a finding behind a layout change.


Firing a two-relation spine on a real record, and what hid a config error

2026-09-07 20:08:08 · chains · process

#211 was small on purpose: Chain.relation becomes a tuple, _load unions the edge sets before walking, and everything downstream — _components, _depths, _order — reads a spine that does not know how many fields filled it. That last part is why the change is cheap, and it is worth stating because it is the property a future spine option should preserve.

What the guard caught, on its first real firing

The consumer record has seven extends: edges that mean “this exists because the parent is broken”. I declared corrects/corrected_by there, moved PowLU → GLU-Variants onto it, and set relation = ["extends", "corrects"].

The first attempt failed, correctly, and the failure is the better result:

luria.toml: chains.practice: `relation` names 'corrects', which is not a
reference SOTA declares — a chain over a field nothing types walks no
edges and renders an empty page

My edit had matched relation = "extends" in both chains, and the SOTA scheme declares no corrects. The check named the chain, the field and the scheme, which is exactly what it exists to do. Restricting the edit to the LIT chain, the line renders as one three-step sequence with its last step on the second relation, and the header reads “walked from extends: and corrects:”.

The trap, and it was mine

I did not see that error the first time, because I ran `luria index

/dev/null 2>&1` and then read the page it had not rewritten. The stale page looked plausible — the line was intact, the header named one relation — so I spent a minute concluding the header formatting was broken when the config had simply refused to load.

This is the same silent-failure shape that has bitten this work before with git push >/dev/null. The rule worth keeping: never suppress stderr on a command whose failure would change the conclusion you are about to draw from its output. A command run for its side effect can be quiet; a command run to produce something you are about to read cannot.

What was checked before trusting it

  • 946 tests, six of them new: a two-relation spine as one line, a second relation nesting like the first, a scalar relation still reading, the header naming every relation, an undeclared entry in a list refused by name, and a cycle spanning both relations still reported.
  • Both records regenerated with no diff at all on the scalar path — this project’s own views and the consumer’s 59 files are byte-identical, which is the claim that matters for a change to a shared walker.

A relation asserts an invariant; the check asks which field names it

2026-09-08 15:49:29 · lint · ontology

Shipped for #214, which arrived as a much narrower idea and got better twice in the thread.

The first framing was wrong. I proposed reporting a lineage that crosses a topic boundary — members holding different values. The consumer project pointed out that this treats overlapping membership as a defect when it is the feature: a document legitimately in two areas is the vocabulary working.

The second framing was right and the third was better. What a relation actually asserts is that its documents have something in common, so the finding is the absence of a unifier, not the presence of divergence. Then: a relation is an assertion that an invariant exists, and a field can name the invariant property. That generalises past topics — the same check over status asks whether a succession keeps a document in force.

Two findings, and they are genuinely two rather than one with a knob. Edge findings are a strict subset of path findings, but a path can be unbound while every edge in it is bound: A∩B = {x}, B∩C = {y}, A∩B∩C = ∅. There is a test for exactly that, because it is the whole argument for the second check existing.

The path finding is the weaker one and says so. A component’s intersection shrinks monotonically as the component grows, so one distant member can unbind a line that is locally coherent at every step. That is a property of the operation, not of any record, and it is why this is a report rather than a lint failure — the reader has to look at the whole line.

Why it is opt-in, with a number. Before writing anything I measured what would happen if the check ran over a relation that never implied an invariant. In the consumer record, source: joins a practice to its paper across two vocabularies a decision had deliberately separated: 48 of 235 edges become findings, and every one is a cross-domain citation that decision was written to permit. A check that fires on a project for doing the thing it decided to do is worse than no check. So nothing is checked until a chain names a field, and invariant is validated eagerly against the scheme’s declared fields — a chain asserting a shared value in a field nothing holds would report every line and mean nothing.

Cardinality is not configured. A list-valued field compares by non-empty intersection, a single-valued one by equality — and those are the same operation once a scalar is read as a set of one. Worth stating because “the field contains a value preserved across the relation” reads naturally as equality, and for tags that would demand identical tag sets.

Fired once on a real case, as the working agreement requires. Declared invariant = "tags" on the consumer record’s practice chain, temporarily, and ran it against 189 practices and 23 relation edges: one edge finding and one path finding, SOTA-085 (use flash attention) against SOTA-161 (keep the attention output in FP32 because flash attention). Attention technique and numerical stability, genuinely both, with nothing yet naming what they share. That matches a scratchpad computation done independently before any of this code existed, which is the agreement that made me trust it.

Deferred: the acknowledgement directive. A record will eventually want to say “these two genuinely have nothing nameable in common” and keep the finding quiet. Not built, on a trigger rather than a feeling: build it when a finding survives two passes with nobody willing to add a tag. Designing the annotation before seeing which findings people decline to fix risks designing for the wrong residue — and under this framing there is usually a real fix, which is to name the thing they share.


Derived fields, and the ADR-066 rule they invert

2026-09-08 16:59:29

The feature is small; the two things worth writing down are why it did not need to be bigger, and the one rule it inverts.

It composes instead of adding. Two properties of code already here made the whole thing about 150 lines:

  • fields is already documented as “the table a field’s shape and type are declared in”, so derive is a key in a table that exists, and a derived field inherits vocabulary with no plumbing. That pairing is where the eligibility check comes from: declare vocabulary = "tags" beside derive = "first:tags" and _vocabulary_violations reports a first tag that is not a real topic. No new check was written for the thing the feature is mainly for.
  • Frontmatter edits are text surgery, never a YAML round-trip (field_edit says so in its own docstring, and says why). So list order survives every tool luria has, and order-as-semantics is safe by construction. I checked this before designing anything, because if any tool sorted tags the whole idea would fail silently and late.

The seam is Adr.__init__ and contract.violations. Resolving derivations onto meta in the constructor means every downstream reader — invariants, facets, reports, the site — meets an ordinary field and needs to know nothing. That is only safe because nothing writes a document back through Adr; if anything ever does, a derived value would get persisted and this becomes a bug. contract.violations does the read-only check on the raw meta first, then derives, so both callers get it without either knowing.

The inverted rule. ADR-066 says a declaration beats a derivation for a remote’s URIs; this says a declaration is a finding. Both are right for their case and the distinction is worth holding onto: a URI template is a second source for a fact luria cannot compute, and a written primary_topic: is a second copy of a fact it can. Override where you would otherwise be guessing; refuse where you would otherwise be duplicating.

Fired on the real case, twice, per the agreement. The consuming record: 406 documents, every one already carrying a real topic in first position, so adoption is a no-op and the lint stayed at its exact baseline (six warnings, exit 0). Then both guards, made to fire on real documents rather than on fixtures — a primary_topic: line added to a practice, and a tiny-models secondary moved to first position on another. The second is the one I would have got wrong from a fixture alone: my first attempt tested it against a tag that only exists on an unmerged branch, so the check silently did nothing and looked like it passed.


Identity moved into the document

2026-09-08 18:57:47

The change is small because journals had already solved it. check_journals says the whole thing out loud — “A journal entry’s path is derived from its created: timestamp, and the two have to agree” — and luria repair already back-fills that field from the path. Schemes just never got the same treatment, and nothing anywhere says why. So this is less a feature than the end of an inconsistency, and the implementation is mostly the journal’s, copied one directory over.

Three things made it cheap, all of them already paid for.

  • Scheme.documents() is “the one place a scheme directory is read” — DP-4 consolidated five copies of that glob for unrelated reasons. Twenty-odd callers read identity, and exactly one of them touches the filesystem, so moving the source of truth is a one-function change.
  • number_of() is already tolerant of a trailing slug: adr-010-some-title.md parses today. luria can already read expressive filenames; it has only ever written the short form.
  • journal.populate_created() is the back-fill, working, with a slot beside it in repair.apply().

The caching decision is the one I would get wrong next time. Reading number: costs a parse, on the hot path, so it has to be cached — and the obvious cache is a module dict with an explicit reset(), the way aliases.py does it. That would have been a bug generator: concretize writes a document and immediately re-reads it, repair writes 400, and every future writer would have to remember. Keyed on the file’s (mtime, size) instead, so a write invalidates its own entry and no caller has to know the cache exists.

Why a disagreement is a violation and not a repair. The tempting move is to rename the file to match the field, since the field is the truth. But which of the two is right is genuinely unknowable — someone may have renamed the file deliberately, or fat-fingered the field — and renaming on a guess moves identity and orphans every link. An absent field has a witness (the path) and is repaired; a contradicted one has two witnesses and is reported.

Fired on both records, per the agreement. 436 documents in the anthology and 103 here. Lint held its exact baseline in both, and every generated view came out byte-identical except reference-status.md, whose citation line numbers shift down by one — because the field adds one line, which is the answer you want that check to give. Then the guard itself, on a real document rather than a fixture: number: edited to disagree with the filename, reported, reverted.

One thing to watch. My new test suite tripped the reference checker twice — ADR-007 is Superseded here, and ADR-tmpabcde resolves to nothing — because a suite about how codes are read has to write codes down. tests/test_migrations.py hit this before me and carries the same unlinted-file: directive. Two files is not a pattern yet; three would be, and the fix then is for the scanners to know that a test fixture is a quote, not an address.


Alias inference, and the map that was never wired up

2026-09-08 21:01:39

The plan for this step said “aliases join the resolver’s map.” Checking that before writing against it turned out to be the useful hour.

aliases.alias_map() had no production caller. Two tests, and nothing else in the package:

$ grep -rn "alias_map(" --include=*.py .
./luria/aliases.py:41       # the definition
./tests/test_migrations.py:47, :216

Tested, documented, described in prose as the thing references resolve through — and dead. The live path was doc_refs.alias_number, scanning every document’s formerly: on demand. The module’s other functions (canon, split, reset) are live; migrate.py uses them. It was the map specifically that never got connected.

And its docstring explained why it didn’t matter, in a way that stopped being true here. alias_number says it doesn’t cache because “this path only runs for a temp code with no live document, which is rare.” Sound — for temporary codes, which nobody writes on purpose. A spelling people choose to cite makes that path hot, so the dead function turned out to be exactly the shape the feature needed, sitting one wire away. Wiring it up was the change; extending it was the easy part.

The precision rule is where this could have gone wrong. An alias tail has to be loose — words, digits, hyphens — and a loose pattern in a reference scanner matches prose. SOTA-based would become a citation. The defence was already written down, in legacy_spellings: “a temp-shaped string that resolves to no document is not a legacy spelling.” Same rule, and here it is load-bearing rather than a nicety: the map decides what is a reference, the pattern only decides what to look up. That makes the cache correctness, not just speed.

Two measurements changed the design.

  • I checked whether the anthology’s notes carry the fields the motivating template needs, probed for authors:, got zero, and nearly reported that the feature could not be used on the record it was built for. The field is first_author:, and 218 of 218 notes have it. Reading one real file instead of trusting my own probe is what caught it.
  • The filter vocabulary I had planned — slugify, truncation — is mostly unnecessary. str.format’s spec language already truncates a string, so {published:.4} turns 2022-05-01 into 2022. (It raises on an int, which is why the earlier probe with {year:.2} failed and made me think a filter set was required.) The motivating case needs no filters at all.

Fired on the real record. 218 notes, LIT-Dao-2022-074 for FlashAttention, no collisions — the pre-migration MLR-2014-Kingma001 shape recovered, which is the whole point of the exercise.

Two things the guards caught, both worth writing down.

inactive-ok: governs its own line and the next line. I wrote a three-line wrapped comment above the citation, and the scope ran out before reaching it — reported as a stale directive rather than as an unacknowledged citation, which is the right message and took a moment to read that way. A wrapped comment silently loses scope; single-line, or -block:.

And this suite is the third file to need unlinted-file:, after test_migrations.py and test_number.py. On #220 I said two was not a pattern and three would be. It is three: every one is a test suite that has to write codes down in order to test how codes are read. Filed as its own issue rather than a fourth directive.

Still to do: luria repair moving a superseded alias into formerly: when its source field changes. Without it an alias is safe to resolve but not yet safe to write down durably, since correcting an author silently breaks citations written the old way. That is the next piece, not this one.


One template vocabulary, and the take grammar that went away

2026-09-08 23:04:34

The last step of the identity work, and mostly a deletion: derive’s take:field grammar becomes a str.format template, so luria has one template language rather than one per feature.

What made it cheap was doing it in the wrong order first. derive shipped with its own tiny grammar, alias shipped with str.format, and having both side by side for an afternoon is what made the redundancy obvious and the merge trivial: aliases.render already did the work, so derive delegates to it and the two share a vocabulary.

The lone-field rule is the only real design in it. A template that is exactly one replacement field returns the value; anything with literal text returns a string. That distinction matters because a derived field gets compared against a closed vocabulary and intersected with another document’s tags — "2014" and 2014 are not the same value, and a formatter’s job is to produce the former. The rule reads off the template’s shape rather than a flag, so nothing has to be declared twice.

A capability went away and it is worth naming. last:tags has no template spelling: str.format parses {tags[-1]} as the string key -1, not a negative index, so the take that existed only because it “fell out for free” now costs a real extension to keep. Dropped rather than special-cased — an extension on top of str.format is the second grammar this change exists to end. A record wanting the last element names its position.

My own guard caught my own test, and was right. I wrote a type-preservation test using derive = "{year}", and the scalar-rename check refused it: {year} under a second name copies a field rather than deriving one. The test premise was wrong, not the check. Rewritten against a list-valued field, which is where a lone-field derivation is legitimate — and the refusal now has a test of its own, since narrowing a rule is exactly when you want to pin what survives.

Fired on the real record, both halves through one vocabulary. 188 of 188 practices resolved primary_topic from {tags[0]}, still a str identical to its vocabulary member; 218 notes rendered LIT-{first_author}-{published:.4}-{number} with no collisions.


A docstring is one block, the way a fence already was

2026-09-08 23:30:41

The scope rule caught me three times in a day, and the third one had no good answer: a citation in the third paragraph of a docstring could only be acknowledged with -file, so I deleted the citation instead. Deleting a reference to avoid annotating it is the workflow telling you something.

The fix was already written down, for a different case. The rules say fenced code counts as one block even when it contains blank lines — a syntactic unit is not split by the whitespace inside it. A docstring is the same shape, and once that is said the change is four lines: blocks() learns the file’s language and treats docstring spans as atomic, the way it already treats fences. The span starts at the def line rather than the quotes, so a directive written above the definition governs what the definition says.

Measured before designing. I tried the four placements on the real case rather than reasoning about them:

placementbefore
no directivewarns (the control)
-block above the defreported STALE
-file at module topacknowledged
inactive-ok: inside the docstringwarns

That table is what made the answer obvious, and getting it took two false starts worth recording. My first probe reported every case as quiet, including the control — the replacement string didn’t match, so nothing was ever mutated. My second reported every case as acknowledged, including the control — luria lint writes to stderr and I was capturing only stdout. Both times the control was the tell: a probe whose control does not reproduce the condition is measuring nothing, and it is worth checking that first.

The last row stays as it is, deliberately. A directive written inside a docstring still does not fire, because a docstring is not a comment — that rule exists because prose about the syntax used to invoke it, and a docstring example silently annotated its own module. This change does not touch it, and there is a test pinning it.

And I broke the module while documenting it, by putting a """ example inside a """ docstring. The example now uses '''.

Fired on the real case: the ADR-087 citation I had deleted from derive.value is back, acknowledged by a -block directive above the definition, with the lint identical to main’s baseline.

What this does not fix. The three suites carrying unlinted-file: are a different problem — fixture codes in string literals and comments, not docstring prose. #222’s other options still stand for those.


tree-sitter 0.26.0 segfaults; the extra pins below it

2026-09-09 01:41:21 · record

Building the grammar-backed -block scope cost far more in one failure mode than in the feature: the test suite started dying with Fatal Python error: Segmentation fault, in ast.parse, in a thread, in a module that has nothing to do with any of this.

What made it expensive is that the crash moved. Rewriting the tree walk changed which file it died on. Holding node objects differently changed which module it surfaced in. Each move looked like evidence about the rewrite, and each was really heap corruption landing wherever the next allocation happened to be. Three plausible root causes were chased and written into comments before being falsified:

  • the traversal — a recursive generator, then a stack of live nodes. Both reproduced, so both looked guilty, and neither was;
  • threads — luria builds reports on a thread pool, so a lock went in. It did not help;
  • markdown — tree-sitter-markdown runs a second parser over inline content, which is exactly the kind of thing that would explain it. Excluding markdown did not help either.

The measurement that ended it was the one that used none of luria: about twenty-five lines, one grammar, a cursor walk over this repository’s own .py files, three passes. It segfaults. tree-sitter 0.26.0 is the culprit; 0.25.2 runs the identical script clean, and with it every earlier failure disappears — threaded scan, full suite, markdown included. The extra pins <0.26.

Two things worth keeping from it. First: when a crash relocates in response to unrelated edits, stop editing and go build the smallest thing that reproduces it. Every hypothesis above was reached by changing luria and watching the crash move, which is a method that cannot distinguish “I fixed it” from “I moved it”. Second: the comments those hypotheses left behind were confident and wrong, and a wrong reason in a comment outlives the person who wrote it. They were rewritten, not deleted, once there was a real reason to put there.

The copy-out boundary in luria/syntax.py — nothing native leaves _nodes() — stayed anyway, and the lock with it. Neither is now load-bearing against a known bug, and both are honest insurance: the failure mode of a native dependency is a process that dies without a traceback, and LURIA_TREE_SITTER=0 exists because the next such release will land before the next pin does.


Merging a stack bottom-up under squash makes a conflict out of nothing

2026-09-09 02:49:28 · process

#227 was cut from #225’s branch, both were green, and I merged the lower one first because that is the order the dependency runs in. It is the wrong order here, and the reason is one sentence: a squash merge does not put the branch’s commit on the trunk, it writes a new commit holding the same text.

So main had bf84d02 and the #227 branch still had 22225e9, two unrelated commits that had each introduced the same lines. Merging main in produced conflicts in luria/directives.py and tests/test_directives.py where nothing was actually in dispute — every hunk was the upper branch adding to what the lower one wrote. Resolving it was mechanical and still cost a resolution, a diff against the trunk to prove nothing was dropped, a full revalidation and a second CI cycle.

The tip branch already contained the whole stack. One squash of #227 would have landed both, with no conflict and no second cycle, and the trunk history would look almost identical — a squash concatenates the messages of the commits it holds, so fd1c68c carries both branches’ text either way. The only thing bottom-up bought was that #225 shows as merged rather than needing a manual close. That is not worth a conflict resolution on a stacked change, which is precisely where a quiet dropped line would hide.

Written up as a decision rather than left here, because the rule generalises past this instance and the wrong order is the intuitive one: the dependency points bottom-up, so merging bottom-up feels like following it. What actually matters is whether the trunk’s commits end up as the branch’s ancestors, and under squash they never do.

dmarx caught this immediately — “why don’t you just merge down in the other direction… you make things hard for yourself.” Worth recording that the cost was invisible from inside the task: resolving the conflict felt like ordinary diligence, and none of the care taken over it made it less self-inflicted.


A mute dial, because fail_on only changes the consequence

2026-09-09 18:53:14 · lint · configuration

inert-status fired on the anthology’s new NOTE scheme — 16 of 16 at Read — and it was correct to. A note there is written when someone finishes a paper, so the status genuinely carries no information; the absence of a note is how that record says “unread”.

The scheme has an acknowledgement for exactly this, uniform_ok, and setting it did what it should: the row stopped being a finding and became a note carrying its reason. dmarx’s response was that the finding is not especially helpful in the first place, and that if it could not be turned off globally, it should be able to be.

That was right, and the gap was real. fail_on looks like a visibility dial and is not — it promotes a class to a failure. There was no way to say “do not report this class at all”, for any class. A project’s only options were to see a finding forever or to acknowledge it per-site, and a check whose findings a project has decided are not useful to it has no site to comment at.

What it does

[luria.lint]
mute = ["inert-status"]

Removes the class from the report, headline and detail rows together.

Three decisions worth recording

Mute and fail_on conflict rather than compose. Naming a class in both is a configuration error. It is tempting to give one precedence, and both choices make a setting a lie — if enforcement wins, the mute did nothing; if the mute wins, a project asked for a failure and silently got none. Reporting the conflict says so. Enforcement still wins in the meantime, because a misconfiguration must not be able to hide a check the same file asked to enforce.

No class is exempt. The first draft had a taxonomy — “judgement” classes mutable, “integrity” classes always printed, on the reasoning that hiding stale-directives is how a report stops meaning anything. Then the comment saying so did not match the code, which is how I noticed the taxonomy was mine rather than the project’s. fail_on already lets a project make any class fatal and defaults to none, so latitude over consequence is total; the symmetric thing is latitude over visibility. luria reports renders the accounting regardless, so muting changes what a command prints and not what the record contains.

acknowledged-uniformity is mutable but not failable. The two vocabularies are deliberately different lists. A project cannot promote its own acknowledgement to a failure — that would be strange — but it may reasonably not want the standing note repeated on every run.

The general shape

A dial that changes what happens to a finding is not a dial that changes whether you see it, and this codebase had the first while its name suggested both. Worth checking the other settings for the same conflation.


Firing the reference derivation on 281 real documents

2026-09-10 15:47:49 · mechanism

derive learned to follow a reference (#233). The tests pin the rules; this is what the guard did against a real corpus, and the three things the implementation taught that the design did not predict.

The real case

anthology-of-the-sota, at the commit that had just backfilled published: by hand across 281 documents. The trial: declare the derivation, delete every stored copy, and see whether the record still knows the dates.

stripped stored published: from 281 documents
practices resolving: 207/207
notes resolving:     74/74

Six spot-checks against the hand-computed values, including four of the eight that had been stale, all correct. Then the negative half — reinstating SOTA-150’s actual stale value, 2024-01-01:

luria: 1 violation(s)
  record/practices.d/SOTA-150.md: `published:` is written in frontmatter,
  but SOTA derives it (`{published}` from `source[0]`) — the value has one
  source and this is not it

Exit 1. That is the defect the feature was written for, not a synthetic one.

The trial also produced a finding nobody asked for and which is correct: declaring published required on LIT makes the scheme’s own template a violation, because the form does not scaffold the field. A record adopting this will meet that on day one, and it is the scaffold check doing its job.

Three things the implementation taught

The eager check recursed. _check_derivations runs during config load, and checking a followed template’s names against the target scheme meant asking for the target scheme — via current(), which re-enters the loader. Stack exhaustion, no useful traceback. The fix is to thread the schemes dict being built through the check rather than reading the config that is still being assembled. Anything else added to that pass will hit the same wall.

Two existing rules were about the same document and had to learn it. derive refuses field deriving from field (a cycle) and refuses a lone template reading a single-valued field (a rename under a second name). Both are correct within one document and both are wrong across a reference — published from its source’s published is a cycle in the first reading and the entire point in the second. Neither would have shown up as a test failure in the new file; they showed up as the new feature’s own valid config being refused at load.

The motivating field was not declared. The anthology’s published: on LIT was convention, never a declaration. So the first draft of the eager check — the target’s fields must be nameable — rejected the exact case the feature existed for. That is the decision ADR-092 records: require the declaration anyway, because the alternative is a typo that resolves to nothing on every document and reads like a clean record.

What is deliberately not here

Chaining. A followed template reads written frontmatter, so a target’s own derivations are invisible. That is what makes cycles impossible by construction instead of by detection, and it means no evaluation order over schemes has to be defined or maintained. A project that wants a chain will see nothing resolve rather than a diagnostic — worth knowing, and worth revisiting when something actually needs one.


Grouping the pending report, and being caught twice by the specimen-code hazard

2026-09-10 22:10:41 · mechanism

#230, which is a small rendering change, and the thing that actually cost the time.

The change

pending() collects undecided documents from every scheme on purpose. The report rendered them as one table. Splitting the presentation by scheme is the whole fix; the collector, the badge and the lint headline are untouched, and a test pins that the published counts do not move.

Two judgements worth having written down:

  • Sections, not files. One file per scheme breaks existing links, multiplies what a reader visits, and removes the only place a cross-scheme total can live — and that total is what the badge publishes.
  • Grouped only when there is something to group. A single-scheme record gets the old flat table. A heading naming the only family a project has is nesting that carries no information, and this record is that case: every one of its undecided documents is a decision.

Verified on the adopting record rather than only on fixtures: 37 undecided, rendering as 33 / 2 / 2 across three sections in declaration order, with the fourth declared scheme correctly absent.

What actually cost the time

The suite caught me with #231, twice.

The first draft’s fixture used this record’s own scheme prefix and filed a specimen numbered like a real decision. The reference scanner read it as a citation; the decision it named is Superseded; the lint gained a retired-citation warning that had nothing to do with the change. That is #231 exactly — a suite writes codes down because that is what it tests.

The fix was not a fourth unlinted-file: beside the three that issue is already about. The fixture now declares prefixes this record does not use, so nothing in the file is a code the scanner recognises and no directive is needed.

Then the comment explaining all of that spelled the offending code out, and re-earned the same warning. Same lesson, ten lines later: the mitigation is not writing it down.

The part worth carrying to #231

The escape hatch I used does not generalise, and it would be easy to mistake it for a fix.

It works here only because this record declares two schemes, leaving every other prefix invisible to the scanner. A project that declared RFC and SPEC would have no free prefix to test with — its suite would be back to directives, which is the state #231 is about. “Use a prefix you don’t declare” is a property of this repository, not a technique.


Reserving a namespace instead of blanking three suites

2026-09-11 01:23:08

#231 asked how a test suite is supposed to write codes without those codes being read as citations. Three suites had answered it with unlinted-file:, which blanks the whole file — 1,038 lines between them, to excuse a couple of dozen specimen codes. The user’s answer in the issue thread was better than mine: stop trying to make the codes invisible and give the fixtures a scheme nobody else can declare.

What I measured before changing anything. Deleting the three directives and running the lint produced exactly three new findings, not the flood I expected: ADR-007 (Superseded, 14 sites), ADR-tmpabcde, and DP-018. That number is the trap. The other fixture codes in those files — DP-4, DP-004, DP-1, and the composed LU-DP-004 — were silent because they resolved. A specimen that resolves is counted as a citation of a real document and looks exactly like one, forever, until the document it borrowed moves. The loud findings were the safe ones.

tests/test_alias_inference.py turned out to need no directive at all: its LIT-… spellings match nothing here, because this project has no LIT scheme. Invisible by luck, which is the property the whole exercise exists to stop depending on.

Why a namespace and not a name. #231’s comment proposed one reserved prefix, FXL. tests/test_migrations.py renames a scheme, so it needs an old prefix and a new one in the same fixture — with one reserved name the second falls back to GP, which is safe here only because nobody has declared it. Reserving everything that starts with FX costs nothing (the match is on the leading prefix, so AFX is untouched) and makes the second prefix safe by construction.

Where the guard could not go. The obvious place for “refuse this prefix” is config load. That is self-defeating: fixture projects declare FXL on purpose, so a load-time refusal breaks the mechanism it is protecting. The check had to run where a human reads it — lint.run() — and it is only harmless there because the suites call individual check_* functions rather than the whole run. That is worth knowing before anyone adds a check the other way around.

Fired once on the real case, as this file’s own rule requires. Adding [luria.schemes.FXL] to this project’s luria.toml and running python -m luria.cli lint produced the violation — and, in the same run, made ten fixture codes across 96 citation sites start reporting as dangling. That second half is the better evidence: it is the hazard, demonstrated, not argued.

Backticks do not mask a reference-status citation, and that is on purpose. Writing the ADR, I quoted the specimen codes as evidence — `ADR-007`, `ADR-tmpabcde`, `DP-018` — assuming inline code made them mentions. luria link --fix agreed and left them alone; ref_status.scan() counted all four sites anyway. Its docstring says so (“deliberately unmasked, unlike the link lint”), and eleven other decisions already carry inactive-ok-file: ADR-007 for the same reason. The shorthand in CLAUDE.md — “codes in backticks are mentions, not citations” — is true of the fixer and not of the report, which is the stricter of the two on purpose: a retired code quoted in prose is still a reader following it somewhere retired.

A trap that cost me twenty minutes. luria on PATH is a non-editable install in dist-packages, so luria lint was running the released 0.15.0 and reporting the new check as absent. It reads the working tree’s record, so every measurement of the directive removal above was still valid — but any measurement of new code has to go through python -m luria.cli.


Lint only column-0 HTML comments in YAML frontmatter

2026-09-11 05:41:00

#164 is the Pages-build miss: PyYAML treats a frontmatter line that opens with <!-- as a mapping key and keeps the last of two identical keys, while Quartz rejects both. The first cut of the lint check stripped leading whitespace before looking for <!--, which would also flag an indented comment inside a folded scalar such as summary: >-.

That spelling is legal YAML content. Column 0 is what makes PyYAML invent a key; an indented <!-- is just text. No live vault entry used that shape when the check landed, but the first one that does is likely to be the entry that explains this bug.

The check now uses line.startswith("<!--"). A # comment still stays silent. Duplicate unindented keys are unchanged.


Two halves of one fix: a page to cite, and a citation that points at it

2026-09-12 21:59:52 · site · record

The first version of this change shipped the configurable half alone, and it was right about everything except whether it helped. [[ADR-094]] adds a cite key choosing whether a citation resolves to a document’s own page or to an anchor in the view it assembles into. Shipped by itself, with the default left at today’s behaviour, it passed every test, rewrote nothing, and fixed nothing — which is what the reviewer noticed within the hour: “I ran the Pages workflow off this branch, and the DP links still resolve to output#anchor.”

Dogfooding it was the experiment that found the missing half. Setting cite = "page" in this repository’s own luria.toml and running luria repair rewrote 98 files — and staging went from 10 links redirected to the repository to 195. A page target points at record/principles.d/DP-003.md, and publishable() did not publish that file: it excluded any source whose links are spelled for somewhere else, which a design principle’s are. So the site did the only thing it could and sent every one of those readers to GitHub. That is strictly worse than the bug being fixed — a dead anchor at least lands you on the principles document with the content on it, at the wrong scroll position.

The lesson is narrow and worth keeping: a link target is only better than another if the thing it names is published. Nothing in the record could have told us otherwise, because luria lint checks that a relative target exists on disk and record/principles.d/DP-003.md does. Staging is the only check that reads a link as something a reader would try to follow, and it is a count in a report rather than a failure.

So the second half is publishing the sources. publishable() excluded them by a derived rule — link_base(path) != path.parent, “are this file’s links spelled for somewhere else?” — and the derivation is good. What was wrong is that a design principle answers yes for a completely different reason than a changelog fragment does. A fragment has no code, no title of its own, nothing to give a page to. A principle is numbered, titled, statused, versioned and cited by code: every property a decision has except an address. The rule now says what it meant, and the exception is to the consequence rather than to the rule — those links really are spelled for docs/, so _rebase re-points them as the page is written, leaving the repository spelling alone because the fixer and the lint both depend on it.

Verified on real builds either side, because the failure mode was invisible to everything else. 330 broken anchor links across 81 pages → 0; links to a principle’s page 85 → 404, all resolving; 281 → 307 staged pages. The number that mattered most was the boring one: links redirected to the repository went 10 → 12, not 195, which is how you know the new pages absorb the new targets instead of leaking them off-site.

Two tests changed their claim rather than their threshold, which is the right kind of test change. test_publishable_is_exactly_the_files_whose_links_resolve _in_place asserted link_base(path) == path.parent over the whole corpus — identical to the rule while no exception existed, and now a sharper statement with the exception named. The nested-record test proved its point by the child’s document-scheme source being absent from the parent’s staging, which this change legitimately makes present; it now proves the same point by the presence of the code alias, since only a config that knows the child’s scheme can mint one. Both were load-bearing and neither was relaxed.

One small thing the fixer could not do. Two links were spelled design-principles.md#6-fire-before-trusting — a heading slug no rule this project has ever emitted, so retarget_view_citations correctly refused to guess at them. Rewriting them by hand would have been the third hand-written path target in a file where hand-written paths are exactly the hazard; writing [[DP-6]] and letting luria repair spell it is the move.


The record was parsed twenty-three times per document per lint

2026-09-13 19:53:43 · performance · method

luria lint on a 729-document record takes 95 seconds, and a profile says where it goes:

101.4s  lint.status_sections          (of 170.6s under the profiler)
 75.7s    adr_index.load_scheme       ← 43 calls
 75.3s      Adr.__init__              ← 7,694 constructions
 70.5s        yaml.safe_load          ← 16,872 parses

729 files, 16,872 parses. Each document’s frontmatter was parsed about 23 times per run, because load_scheme rebuilds every Adr from disk on each call and the schemes are loaded once per consumer — the status sections, the pending report, the reference walk — rather than once per run.

The cache the codebase already argues for

Scheme.number_of had the answer written down two years ago:

Cached on (mtime, size) rather than reset by hand: every writer of a document bumps its mtime, so the cache invalidates itself and no caller has to remember.

That property is not a convenience here, it is the whole safety argument. field_edit and repair write documents mid-run and read them back; a cache anyone has to remember to drop would be wrong within a release. So read_document() takes the same key and Adr.__init__ goes through it.

The one trap: Adr folds derived fields into the mapping it is handed, so a shared dict lets one reading’s derivation leak into the next — which is the failure the per-Adr resolver in Adr.__init__ exists to avoid (#233), reintroduced one layer down. The cache hands out a deep copy. There is a test for it, because the bug it prevents would be invisible until a derived field disagreed with itself.

What the profiler was lying about

The profile said 70.5s of YAML, so the fix should have bought ~70s. It bought 17 (95s → 78s). cProfile’s own overhead inflated the run from 95s to 170s, and it inflates unevenly — millions of cheap calls (the YAML scanner is 3.1M reader.forward calls) pay far more instrumentation per unit of real work than a few expensive ones. A profile ranks; it does not measure. The number that matters came from time, before and after, on the same record.

Tried and reverted: the vocabulary files

statuses.declared() re-reads and re-parses statuses.yaml on every call, and Adr.status resolves through it — so the same handful of small files are parsed tens of thousands of times per run. Same defect, same fix, and it is the obvious next thing to cache.

It made no measurable difference (78.6s → 78.0s, inside the noise), so it came back out. Keeping it would have been a change to a hot-path module with no evidence behind it, in a performance patch, which is how a codebase accumulates caches nobody can remove later. Recorded here rather than shipped, so the next person does not re-derive it.

What is actually left

After the cache, sorted by self time:

46.8s  {method 'acquire' of '_thread.lock'}   ← the `pmap` pool
 7.7s  pathlib.Path.__lt__                    ← 6.4M comparisons
 5.9s  posix.stat                             ← 954K calls
28.6s  (cumulative) yaml.scanner.check_token

The lock time is the thread pool: cProfile does not follow into workers, so that is real work, unattributed, not waiting. The pathlib cost is sorted(self.dir.glob("*.md")) in documents() and temp_documents(), which run uncached on every call — Path comparison is expensive and this sorts the same directory listing hundreds of times. That is the next candidate, and it wants a different key: a directory’s mtime moves on create and delete, not on a write to a file inside it, and that is exactly the right invalidation for a listing. The trap is that directory mtime resolution is coarse on some filesystems, so luria new creating a file and reading it back within the same second is the case to test before trusting it.


The retries were paying nine seconds an identifier to learn nothing

2026-09-13 19:58:53 · performance · method

Found from the outside, in a consuming project: adding one paper to a record made luria lint slow and unpredictable, and the command that would have fixed it — luria remotes --resolve — was one nobody could afford to finish.

Three things, and only one of them was the obvious one

The retry schedule. ATTEMPTS = 3, sleeping COURTESY then double. Under a sustained rate limit that is 9 seconds per identifier to arrive at the same answer the first request already gave, because a rate limit is a property of the window, not of the request. ADR-096 has the decision.

Retry-After was parsed and thrown away. _retry_after(error) computed the seconds the host asked for, _once put them in the detail string, and _fetch then slept its own COURTESY-doubling schedule and ignored the number it had just been handed. The one scheduling fact a throttle actually carries, formatted into a message.

--resolve could not accumulate. It walked every identifier in the record’s own order, settled or not, at 3 seconds each — an 11-minute floor on 225 identifiers — and called write_lock once, after the loop. So a run that was throttled or killed partway wrote nothing, and the next one started from the same place and died in the same place. The fix is not speed: ask about what the lockfile cannot answer first, and checkpoint.

The trap in the ordering fix

The instinct is to skip settled identifiers entirely, which would make --resolve fast. It also quietly changes what the command means — re-verifying a title is how an upstream revision gets noticed, which is the entire point of the check (#166). Ordering gets the benefit and keeps the guarantee.

The shuffle within each group is deliberately unseeded, and that is not arbitrariness for its own sake: with a fixed order, one identifier that always errors sits at the head of the queue every single run and spends the window on itself. The test asserts the order varies, which is the only way to state that property mechanically.

What was rejected and why it is the interesting one

Persisting the refusal into the lockfile so the next run skips the remote outright. It is the tidy version and it is wrong: the lockfile is committed, so a refusal would travel to CI and to other contributors as a fact about the world when it is a fact about one machine’s last five minutes. The next run is a new window.

Nothing durable is lost by not persisting it, and noticing why took a minute: the unsettled queue already carries the only part worth keeping — which identifiers still have no answer — and the lockfile has recorded that all along, by their absence.

A note on what found this

Nothing in the test suite could have. Every test here mocks the socket, which is correct — and it means the cost of a policy that only bites under a real throttle is invisible to CI by construction. This arrived as a user report from a project with 225 identifiers and a throttling upstream, which is the only place it exists.


The same-scheme converse rule was an assumption, not a constraint

2026-09-13 20:35:24 · mechanism · method

Raised in review on a consuming record: a practice had gained introduced_by:, naming the paper that first stated the recommendation, and the reviewer asked for introduces on the paper. Reasonable, obvious, and not expressible.

Where the rule came from

Nowhere, is the short answer. _checked_converses(prefix, refs) was called from _references(prefix, raw), which reads one TOML table at a time — so the only reference table it could see was the declaring scheme’s own, and the only converse it could resolve was a sibling field. The same-scheme rule is not a decision anybody made about relations. It is the shape of the function that happened to validate them.

It then got written down as though it were a constraint, in luria.toml’s comment on NOTE.paper — “Luria’s converse must be same-scheme and this crosses” — which is how an implementation detail becomes a rule: somebody documents it accurately.

The check had a home already

_schemes opens with “Every declared scheme, with the cross-scheme checks that need them all” and already validates that a reference names a scheme that exists, for exactly the reason this needed moving. So the fix was to move the validation there, not to invent a place for it.

Ordering matters and is easy to get backwards: the converse check runs after the existing does-this-scheme-exist loop, so a converse naming an undeclared scheme reports the missing scheme rather than a missing field on a scheme that is not there.

The part that took the thinking

Not the validation — the completion. Every function downstream assumed one document set, because both ends of a pair had always been in it:

if a not in docs or b not in docs: continue
repairs.append(Repair(docs[b].path, back, a, "add"))

That is four functions (_held, _intents, _committed, relation_spans) each holding the same assumption in a slightly different way, plus _blocked, which was reading the contract from the relation’s near scheme when a repair can now land in either end’s. The contract belongs to the file being written, and that was only ever coincidentally the same thing.

The invariant that made this safe to do at all: for a non-crossing relation the near and far document sets are the same object. Not equal — the same object, far_docs = near_docs if far == prefix else _documents(far). So the existing path is unchanged by construction rather than by care, and 1118 existing tests passing is evidence rather than luck.

Two things that fall out, and one that does not

A crossing relation cannot express a cycle: every edge runs from a document of one scheme to a document of another, so the reversed edge is never in the set, and _contradictions and both_ways go empty on their own. I guarded them on the scheme anyway. “It happens not to match” and “it cannot match” read identically in a set comprehension, and only the second is a reason not to check — leaving it implicit means the next person has to re-derive it.

What does not fall out is a rule about which end to write on. A crossing pair is exactly where “which document owns this fact?” is a real question, and the config still answers it in a comment. That is #254.

Where a test setup went wrong, twice

Two of the new negative tests failed, and both times the config I wrote was defective in more than one way, so a different — also correct — error fired first. Asserting on the message is what caught it; asserting pytest.raises alone would have passed and tested nothing. The fix was to build each bad config so that the defect under test is the only one, which for the wrong-direction case meant giving the far scheme a self-consistent pair of its own so that nothing else could be the first complaint.


The config migration’s two silent failures were both about indentation

2026-09-14 04:20:25 · mechanism · method

TOML and YAML differ in one way that matters to a migration, and it caused both of the real bugs in this one: a TOML table header is fully qualified and a YAML key is not.

[luria.remotes.SG.schemes.FXL]      # says exactly where it is
  FXL:                              # says only "one level in from here"

Everything that edited the config as text was built on the first fact.

rename_scheme renamed nothing

It string-replaced [luria.schemes.FXL] with [luria.schemes.FXM]. There is no such string in a YAML config, so the rename silently did not happen — and the test that caught it was asserting on the new spelling, which never appeared.

Renaming the bare key would be worse than not renaming it. FXL: occurs under schemes: and again under every remotes.<R>.schemes:, and the point of the section-aware pass is that renaming this project’s scheme leaves another project’s mirror alone.

rename_key_at(text, path, old, new) walks the lines tracking the open-mapping stack by indentation and renames the key only where the stack equals path. Line editing rather than a round-trip: a config is written by a person and carries their comments, and a migration that strips them has taken more than it was asked for.

The same assumption, one function over, sweeping the wrong paths

config_paths_pass freezes an unclaimed remote’s own document: path so a rename of our scheme does not rewrite theirs. It found the current section with a TOML-header regex — so under YAML section stayed "", nothing was ever frozen, and the other project’s path got rewritten.

That one is worse than the rename. A no-op is visible the moment anyone looks; this wrote a wrong value into a config and looked like success.

Where the third one was

luria upgrade statuses appended schemes.VP.statuses: record-statuses to the document. YAML has no dotted-path syntax, so that is a key literally named schemes.VP.statuses, sitting beside schemes: and meaning nothing.

Three bugs, one root: TOML composes by concatenation and YAML composes by nesting. The same fact broke the test fixtures — BASE + extra had to become a merge — and broke luria init, where an indented block appended to the end of a document attaches to whatever the last top-level key happens to be.

A vocabulary’s name is a published path

Not a bug, but the last thing to go wrong, and the least obvious. The shared status vocabulary was called record-statuses — a better name — and a vocabulary’s per-value pages render at <view>/<name>/. So the rename moved docs/decisions/statuses/ to docs/decisions/record-statuses/ and left the old directory behind as an orphan.

What that broke is worth stating, because nothing about it says “path”: the bare-reference check skips generated views, and it recognises them by generated-output path. An orphaned view directory is not one — so pages that had been generated for a year started being scanned as hand-written prose and reported every code in them.

The vocabulary is called statuses.

Numbers

345 tests passing at the low point, 1143 now, 0 failing. luria lint clean on this record with 0 unacknowledged references.


2026-09-14 15:15:40 · mechanism

The report was “the devlog links appear to be broken on the rendered site”, with a guess attached: a render mode configured as an intent not to emit fragments, with no way to ask for anchors. The guess was wrong and the instinct behind it was right — something was configured around this, just not that.

What it actually was

The devlog index links 2026-09.md#20260914042025. The book contains <a name="20260914042025"></a>. Both correct. The link works in the repository, on GitHub, in any editor preview.

It does not work on the site, and the reason is one attribute. From Quartz v4.5.2, quartz/components/scripts/spa.inline.ts, at both call sites:

const el = document.getElementById(decodeURIComponent(url.hash.substring(1)))
el?.scrollIntoView()

getElementById finds an id. An <a name> has no id. The HTML spec has a real navigation fall back to a[name] — which is exactly why this worked in every place anybody looked at it. A single-page app never performs one.

I confirmed it against the live site rather than reasoning about it: the published 2026-09 page contains all 55 <a name="…"> elements and not one matching id. The element is there. Nothing is dropping it.

The part worth writing down

ADR-094 had already measured this — “330 links across 81 pages, none of which resolved” — and recorded the cause as Quartz drops the element and slugifies each heading’s own text instead. On that basis it rejected “emit anchors that survive the publisher”, reasoning that the repair would mean finding a spelling this publisher keeps and re-finding it for the next one.

The measurement was right. The explanation was wrong, and it was wrong in the direction that costs the most: it turned a one-attribute fix into an argument about publisher portability, and the workaround it motivated — cite, pointing citations at a document’s own page instead of the anchor — shipped and worked, which removed the pressure to look again.

The devlog had no equivalent escape hatch, so its links stayed broken for as long as the journal has existed.

An explanation that fits the evidence is not the same as an explanation that is true. “Quartz drops raw HTML” fits “none of these links resolve” exactly. Checking it cost one curl and a grep.

Why no check caught it

89 of the 100 fragment links in this repository resolved by name and no other way, and every reference rule luria runs read straight past them. Two reasons, and both are about the shape of the query rather than the rule:

  • Every reference rule reads sources. That is right — a view is rewritten by the next build, so a finding about one is a finding nobody can act on where it is reported. But the motivating case is a journal index linking into a journal book, and both are generated. The check that would have caught this had to be the one exception, and doc_files(views=True) now is.
  • The link and the anchor were each individually correct. The target exists, the path resolves, the anchor is in the file. There is no broken string anywhere. The only thing wrong is a relationship between two documents that no rule was looking at.

The new check is narrow on purpose. It does not ask “does every fragment resolve” — a fragment naming a heading is resolved by a slug each renderer computes slightly differently, so that check would have to model several slugifiers to avoid reporting links that work. name-only is unambiguous, mechanically repairable, and the whole of what was actually broken.

The check failed CI, and the reason is the good part

The first version read the committed views off disk. Locally it was green, because I had run luria index before luria lint and the views on disk were the fixed ones. CI checks out the branch and lints without generating — and a branch carries the default branch’s copies of every view, because this record deliberately does not commit views on a branch (ADR-068). So the check reported 55 violations against files the pull request is forbidden to fix, and would have done so on every future PR that touched an anchor.

I had even written the wart into the ADR and argued it was acceptable: “the check reads views, so it is the one reference rule that can report a finding a contributor cannot fix where it is reported. That is the right trade here.” It was not a trade. It was a bug I had rationalised because I could not see how else to reach a view→view link.

The answer was already in the codebase, one function away. check_view_dirs:

computed against what the generator would write, in memory, so the check reads sources and writes nothing … a branch carries the default branch’s copies and has nothing to be stale against.

So the check is handed every source plus adr_index.outputs() — the views as this source tree renders them. The question stops being “what is committed” and becomes “what will this record produce”, which is the only version of the question that has the same answer on a branch and on main.

Verified both directions before pushing this time, which is the habit the first round skipped: with the fixed generator and main’s stale views on disk, 0 findings. With my source fix stashed so the old generator runs, 55.

Three things I got wrong on the way

The first version of the check resolved a link target against the linking file’s own directory. It reported five real links in the constitution example as pointing at missing files. They are correct as written: a render = "document" scheme’s prose is written to resolve from the page it assembles into, which is what Config.link_base has always known. Naive resolution reinvented a frame the project already had, and got it wrong.

The first version of the check also read only sources, so when I pointed it at the pre-fix book it found nothing — the bug it was written for, invisible to it. That took thirty seconds to find because I checked it against the actual broken state instead of only against a fixture.

And then I over-corrected into reading the committed views, which is the CI failure above. Sources-only could not see the defect; disk-views could not be trusted on a branch. The render is the only reading that is right on both counts, and I reached it third.

One more, smaller: a stub is the authored part of a generated page, so an anchor written in one is found twice — in the stub and in the view it renders into. Same defect, two findings, and the second one’s suggested remedy was wrong, because a person can fix a stub. One finding per unreachable fragment now, reported where somebody can act on it.


Owning somebody else’s algorithm, checked against their output

2026-09-14 17:17:01 · mechanism

The report was “the devlog links are still broken”, with the observation attached: the contents list links #20260903035841, the page’s own heading link is #three-lint-passes-compile-into-one-contract-per-scheme.

First: they were not broken

I drove a real Chromium at the published page rather than reasoning about it again. Cold load, in-page contents click, and a cross-page SPA hop from the journal index all land at scrollY 3451, with the entry’s heading at the top of the viewport. The SPA hop was verified to be one — a marker set on window before the click survived it, which a full navigation would have destroyed.

So the previous fix held. The site had rebuilt (Pages deployed 16:21 UTC, after the merge) and a stale cached copy is the likely thing that was being read.

But the observation was right, and better than the bug report

Two addresses existed for one entry. Luria wrote the durable one and linked it; the publisher wrote the slug one and put the ¶ anchor and its sidebar Table of Contents on it. A record whose pages disagree with themselves about how to name a thing is a record you have to test rather than read — which is exactly what happened here, to somebody who read it carefully and correctly concluded something was off.

Put the timestamp on the heading: one address, the durable one. I checked before implementing, and both halves are load-bearing:

// rehype-slug
if (headingRank(node) && !node.properties.id) { … }

so a heading carrying the timestamp never gets a slug — and Quartz’s sidebar TOC is a markdown plugin computing its links from the heading text, with its own slugger, ignoring ids entirely. Every sidebar link on every book and on the principles document would have pointed at an id that no longer existed.

I would have shipped that. The only reason I didn’t is that I went to read the plugin before writing the code, which is the third time in two days that reading the publisher’s source beat reasoning about it.

Owning github-slugger, and why it is affordable

Linking the heading means producing the id the publisher will assign. That is github-slugger, and a fourth copy of somebody else’s algorithm is a thing to do carefully or not at all.

I wrote the obvious implementation and diffed it against 288 headings across five pages the site had actually published. 283 matched. The five that did not were both of its bugs:

  • _ is a word character, so github-slugger keeps it. My version stripped _ as a markdown emphasis marker and turned fail_on into failon.
  • The order is replace(regex, '').replace(/ /g, '-') — remove punctuation then turn each space into one hyphen. My version collapsed runs, so log — September, whose em-dash removal leaves two spaces, came out log-september where the publisher writes log--september.

Neither was going to be found by thinking harder about it. Both were found in one diff against real output, which took a curl and a loop.

Then the unit tests found a third: my repeat-numbering carried the counter in a local instead of re-reading it, so a document with “Notes”, “Notes 1” and “Notes” again proposed notes-1 forever. The suite hung rather than failed, which is its own tell.

What makes the copy affordable is not care, it is the check beside it. Luria renders the page it links into, so the anchor check can ask whether every generated fragment resolves against the very text the generator produced. A drift between our slugger and theirs is a lint failure in the first build after, not a link that quietly goes nowhere.

The rule that came out of the conversation

Widening the anchor check to “reaches nothing” was rejected in ADR-099, correctly at the time: “a fragment naming a heading is resolved by a slug each renderer computes slightly differently, so the check would have to model several slugifiers to avoid reporting links that work.” The objection dissolved the moment luria had to model one anyway.

And one level up, the conflict that was actually being pointed at: a citation’s durable address is either an anchor or a page, cite chooses, and choosing the page while site.exclude withholds it leaves every citation of that scheme’s codes pointing out of the site. ADR-094 measured that shape — 10 links leaving the site became 195 — and fixed the derivation that caused it without anything stopping a project from doing it on purpose. unresolved-citations says so now.


Quartz 5 builds, and the popover bug was already fixed there

2026-09-14 18:35:42 · mechanism · method

The contents list of a devlog book had one wart left after ADR-100: the links went to the right section, but hovering one previewed the top of the page instead. Every hover, every link, always the first section.

The cause is upstream. Quartz’s showPopover caches a popover element per pathname; on a cache hit it scrolls the cached element to the requested anchor. The scroll line reads popoverInner before its own const declaration — a temporal dead zone — so it throws, the exception is swallowed by the hover handler, and the preview keeps whatever scroll position the first hover gave it. The first hover is fine precisely because it takes the construction path, not the cache path.

Proving it took patching the minified bundle in a built site and hovering again. That is worth doing before reporting anything upstream: the theory “a TDZ throw is swallowed” and the observation “the preview never moves” are compatible with several other causes, and the patch discriminates.

Then the useful discovery: Quartz 5 already contains the fix. It derives the element from the popover it was handed rather than from a variable declared later, so there was nothing to upstream — only a version to move to.

The measurement that lied

The first attempt to confirm v5 was clean reported a residual error. It was an artifact of how the measurement was taken: hovering link B without moving the mouse away from link A meant the sample landed mid-transition on a popover that was still animating, so the scroll position read as stale when it was merely late. Moving the pointer to (0, 0) between hovers and reading the delta directly gave delta: 0 on every link.

The lesson is the same one this record keeps relearning: a renderer claim checked against a real browser is worth more than a renderer claim derived from reading the renderer, and a measurement harness needs its own skepticism before its output does.

The pin that would have been wrong

ADR-042 rejected Quartz 5 with a real measurement — v5.0.0’s plugin installer crashes on .scss under Node 22 — and said to revisit on the next patch release. The blocker is fixed, but not in a release: v5.0.0 is still the newest tag. Following ADR-042’s own “pinned to a tag” rule would have pinned the exact build that the same ADR measured as unbuildable.

So the pin is a commit, and the departure is written down where the default lives rather than only in the decision. Move it to a tag once one ships past v5.0.0.

What the upgrade paid for

Beyond the popover: v5 positions each component from its own plugin entry in quartz.config.yaml, so the generated quartz.layout.ts disappeared — ninety lines of TSX that luria wrote for one reason, to move the graph out of a sidebar that stacks below the article under 1200px (#71). That is now three keys on the graph plugin. luria site writes one generated file where it used to write two, and actions/site copies one.

Verified end to end before the switch: 372 packages install in ten seconds, 317 pages build in about a minute, 109 internal links resolve, the graph lands in the content column, popovers land on the right section, and the console is clean.


Three comment-carry bugs the examples passed and the property caught

2026-09-14 20:02:26 · mechanism · method

ADR-102 taught the TOML crossing to carry a project’s config comments. Eight tests, every one written failing first, all passing. Verified against the real 477-line config it was written for: 42 of 42 comment blocks carried.

Then I migrated that config for real and read the output. Three things were wrong, and the eight tests were blind to all of them.

What the examples could not see

Vocabulary prose landed flush left. A tags.yaml is written at column 0 because it is its own document. Inlined under vocabularies: its keys are two levels in, and ruamel emits a carried comment at the column it was stored with — so twenty-two lines explaining the thirteen topics sat hard against the left margin inside an indented mapping, attached to nothing a reader could see. The test asserted the text was present. It was.

Two blocks merged into one. [luria.site] had a trailing comment; a blank line later, [luria.lint] had its own. My scanner treated a blank line as not ending a run, so both accumulated and both were emitted above lint:. The site comment was present, in the wrong place, saying something false about the key underneath it.

Two things were dropped outright. Prose after a vocabulary’s last entry, and a comment inside a multi-line array — tags = [..., # why these two, ...]. The second is the case ADR-102 names as a known limit, and says will be reported. It was not reported. The scanner skipped it before any block existed to strand, so the report had nothing to print. The ADR described a behaviour the code did not have.

The check that found them

Not another example. A property:

every comment line in the sources appears in the result

Fifteen lines of script over the real config. It printed 352 of 369, and the seventeen it named were the bugs. After the fixes: 369 of 369.

The examples were not bad tests — each pinned a real behaviour, and they still do. They were just all instances of “this text appears somewhere”, which is the one thing that stayed true through three distinct failures. A property that quantifies over the whole input cannot be satisfied by the case you happened to think of.

It is now a test, over a fixture carrying every shape a comment can take: table, key, dotted key, inside a multi-line value, vocabulary header, per-entry, trailing.

A smaller thing, worth the rename

toml_comments.py reads the YAML vocabulary files too, and had since the hour it was written. A module named for one format, doing two, was going to be wrong for as long as it existed, so it is comment_carry.py now — cheaper an hour after the merge than a year after.

What the carry taught about the record

One carried comment turned out to be false, which is the kind of thing only moving prose surfaces. The LIT vocabulary’s header says it holds “the seven a practice can carry, plus five the corpus needs and the practice registry never did”. Both vocabularies now hold the same thirteen keys — they converged, and the prose describing them as deliberately different never caught up.

Before ADR-102, that sentence would have been deleted by the migration, and with it the evidence that the two lists were once meant to differ. That is a better argument for the carry than the line count.


The lint’s time was in re-reading, not reading

2026-09-15 00:30:50 · performance · lint · method

luria lint over this repository took 33 seconds, and a profile said the time was in tokenize and ast.walk over luria’s own Python sources. That much was already known. What it does not say — and what matters — is why a linter tokenizes the same 451 files enough times to spend 30 seconds doing it.

The number that located the fix

The profile ranks functions by time, which tells you where to look and not what is wrong. The question worth asking of a pure function near the top is how many distinct inputs it saw. Wrapping the two scans under directives._parse with a counter keyed on their arguments answered it:

blocks: 11,142 calls over 451 distinct inputs comment_fragments: 11,142 calls over 451 distinct inputs

About 25 readings of every file. Nothing was slow. Everything was done twenty-five times.

The reason is structural, not accidental. _parse re-derives a file’s blank-line blocks and its comment fragments on every directive lookup, and lookups are per check — ref_status, anchors, doc_refs, the staleness pass — not per document. Each check walks the same corpus and each walk pays the full tokenizer bill again.

Both scans are pure functions of (path, text), so the fix is a dict. 33s → 10.6s, byte-identical output.

Key on the content, not on a stat

The caches already here — _DOCUMENT_CACHE, _NUMBER_CACHE, _LISTING_CACHE — key on (mtime_ns, size), and they have to: they read the file themselves, and field_edit and repair rewrite documents mid-run. A cache that did not expire would hand back what the file said before the repair.

That hazard does not exist here, and it is worth saying why rather than copying the stat key out of habit. _parse is handed the text. New content is a different key. There is no window in which an entry is wrong, and so there is nothing for a caller to remember to drop. forget_scans() exists for tests that count how often a scan runs, not for correctness.

The trap: cProfile only sees the thread it is on

The first profile reported 5,468 calls to _parse. The counter reported 11,142. Both were right. parallel.pmap runs checks on a thread pool and cProfile profiles only the calling thread, so half this program’s work is invisible to a default profile — and the invisible half is the parallel half, which is to say the expensive one.

Profile this codebase with LURIA_JOBS=1. The existing note in config.py about the listing cache quotes serial numbers for the same reason; that convention is load-bearing, not incidental.

Left on the table

Serially profiled, the remaining 10.6s is mostly ref_status.scan. One thing under it looks like the same shape of bug: Scheme.pattern and Scheme.temp_pattern are properties that rebuild an f-string and call re.compile on 911,410 accesses. re memoizes internally so this is not 911,410 compilations, but it is that many string formats and cache probes. Not touched here — it is a different file with its own question about whether a scheme’s prefix is stable across a run — but it is the next thread.


A frozen dataclass that cannot be hashed, and a test that cited three ghosts

2026-09-15 01:06:59 · performance · lint · method

Following the thread the previous entry left: Scheme.pattern and Scheme.temp_pattern were properties rebuilding an f-string and calling re.compile on 1.8M accesses between them. Compiled once per prefix now. 11.0s → 10.3s.

That is a 6% win on a 1.8M-call hot spot, and the smallness is the useful part. re keeps its own compiled-pattern cache, so the accesses were never compilations — they were interpolations and dict probes. The previous entry predicted this in as many words; recording it because the shape recurs. A six-figure call count is an invitation to look, not evidence of a big win. The directive-scan cache was 3.1× because the work behind it was real work (tokenizing, walking an AST). This one was overhead behind an existing cache.

Two things the tests found that reading would not have

Scheme is frozen but not hashable. The obvious implementation is @lru_cache keyed on the scheme, and it raises TypeError: unhashable type: 'dict' the first time it is called — frozen=True gives you __setattr__ protection and a generated __hash__, but a dict field makes that __hash__ raise anyway. A test asserting the opposite is what surfaced it. The cache is keyed on the prefix string.

cached_property works on a frozen dataclass, and is still the wrong answer here. It writes straight into __dict__, around the blocked __setattr__, so frozen=True does not stop it — I checked, expecting it to fail. What stops it is Python 3.11, which this package supports and which takes a per-attribute lock in cached_property. This path runs inside parallel.pmap’s thread pool, so the “optimization” could have serialized it. The lock was removed in 3.12; supporting 3.11 means not relying on that.

The guard caught me twice in four minutes

The new test file needed strings shaped like codes to feed the regex, and I spelled them with this record’s own prefix. luria lint immediately reported three new codes resolving to no document — my fixtures, read as real citations. I fixed the fixtures; the lint then reported one more, this time from the prose I had just written explaining the fix, which had a sample code in it. In backticks, which does not matter: the reference check is deliberately unmasked, because a code in a comment is still a claim.

Per the working agreement, a guard that catches you twice is a bug report about the workflow rather than a net doing its job. The fix is generative rather than another acknowledgement: spell fixture codes with a prefix that is not a scheme. ZZZ-1234 cannot be a dangling citation because nothing claims the ZZZ family. This is a third option beside the two already documented — unresolved-ok: and the reserved FX remote — and it is the cheapest of the three for a unit test, which wants an arbitrary prefix anyway. A test of Scheme.pattern that hardcodes ADR is quietly testing the wrong thing: the whole point is that the prefix is configuration (ADR-006).

Where the remaining time is

ref_status.scan is still most of it, and it is called 36 times per lint, 18 of those with no arguments at all — identical full-corpus scans, fanned out through reports.outputs and adr_pending.pending.

A cache keyed on a (path, mtime_ns, size) fingerprint of the corpus is worth about 2.5 seconds, 24%: 10.4s → 7.9s, output identical. Done, in the commit after this one. That puts the lint at 7.9s from 33.1s across the three changes — 4.2×.

The number is worth recording for how I nearly got it wrong. My first measurement of the same prototype said 0.6s, and I had already written that into this entry as the reason not to bother. It was one run against one baseline, taken while an unrelated edit was still in the tree. Three runs of each put it near 2.9s, and the committed version measures 2.5s. A single timing of a ten-second command is not a measurement — the earlier entries in this file quote medians for a reason, and I should not have made a keep-or-drop decision on one sample.

Unlike the directive scans, this cache reads the files itself, so there is no text handed in to key on and the fingerprint has to be the filesystem’s own stamps. That is the bargain _LISTING_CACHE and _DOCUMENT_CACHE already take, and here it is load-bearing rather than incidental: repair, field_edit and migrate all rewrite documents mid-run and read them back. The test that matters is the one that rewrites a file between two scans.

The Scan is shared rather than copied, as _LISTING_CACHE shares its dicts. That is safe only because no caller mutates one — checked, every use reads — and it is also what keeps Scan.used’s identity comparison meaningful across callers. Copying would have cost more than the scan it saves.

The structural question stands even with the cache in: why does one lint need 36 scans of the same corpus? Memoizing makes the repeats cheap without answering it, and the answer lives in how reports and lint share work rather than in a dict.


The 36 scans were 8 corpora and one render done twice

2026-09-15 02:23:29 · performance · lint · record

The previous entry ended on a question: why does one luria lint need 36 corpus scans? It also contained a wrong answer, which is the more useful half of this entry.

The framing was wrong

I wrote “36 scans of the same corpus.” They were not the same corpus. This repository contains eight records — its own, plus the seven under examples/ — and adr_index.outputs() renders each one’s views under its own config (ADR-078). Eight records, each rendering its own reports, each report scanning its own corpus. Most of that 36 was not redundancy at all; it was eight different questions that looked alike from the call count.

The lesson is about the instrument. A counter keyed on the function tells you how often it ran. It cannot tell you whether the runs were the same work, and I read a count as if it could. The fix was to key on the call chain instead — which immediately separated “16 through reports.reference_status” from “8 through adr_pending.pending” and made the real shape visible.

What was actually redundant

Two things, both real.

The whole view tree was rendered twice per lint. check_view_dirs needs the render to compare the committed tree against it; check_anchors needs it to read links out of pages that only exist as output. Each called adr_index.outputs() for itself. Rendering eight records’ views is the most expensive thing the lint does, and it happened twice.

The fix was already designed in and never wired up: adr_index.staleness and anchors.documents have both taken a rendered argument since they were written, and nothing had ever passed one. run now renders once and hands the same dict to both. That is the whole change — no new cache.

Each record scanned its own corpus twice. reports.reference_status did:

docs = ref_status.load_docs()
result = ref_status.scan(docs=docs)

and docs there is precisely what scan() fills in when handed nothing. So the call named the default, which changed no result and put the call outside the corpus-scan cache — leaving each record to scan itself again for its pending-decisions table. Verified equivalent on the real corpus before changing it, then again in a test.

Where it landed

26 real scans to 10, and one render instead of two. 7.8s to 5.8s, output byte-identical.

The 10 are eight records at one scan apiece, plus the badges and the lint’s own status section. That is not obviously reducible: they are eight genuinely different corpora, and the number only falls further by not rendering example records during a lint at all — which is a question about what the lint is for, not about caching.

The arc, and what it cost to find

Four changes, in the order they were found:

directive scans memoized on (path, text)33.1s → 10.6s
code regexes compiled once per prefix11.0s → 10.3s
corpus scan memoized on a stat fingerprint10.4s → 7.9s
one render per lint, one scan per record7.8s → 5.8s

33.1s to 5.8s, 5.7×. Three of the four were caches; the one that needed no cache at all was the one that came from asking what the repeated work was for, rather than how to avoid repeating it. I reached for that question last.


The report said so for seven days: eleven decisions the merge never flipped

2026-09-15 02:46:16 · record · process · method

Eleven decisions sat at Proposed. All eleven had shipped: each arrived in a commit that is on main, each is implemented in code, and not one carried a status_note or a line of prose saying why it was being held open. They are Active now, the 53 acknowledgements that existed only to excuse citing them are gone, and the six that nothing pointed at are cited where they are enacted.

The rule was already written down

ADR-052 settled this before any of them were filed:

merge flips the decision Active, close files it Rejected, and either way the record keeps the reasoning.

ADR-039 is the worked example — its body says “Proposed rather than Active on purpose: this ships as a draft PR… the decision is the merge verdict”, and it landed on main already Active, because the flip happened in the branch before the merge. For these eleven it did not happen at all.

So this was not a judgement call about eleven decisions. It was one procedural step, skipped eleven times, and the work was mostly establishing that there was no twelfth reading — checking each for a status_note, for prose holding it open, for an implementation that never landed. There was none. That check is the part worth having done; the flip itself is a one-word edit.

The tool called it, and the call went unread

docs/reports/pending-decisions.md is generated on every luria index, and its own prose says exactly what this was:

an old proposal many files cite is a decision the codebase has already made and hasn’t written down

ADR-098 had 62 citations and 26 acknowledgements. Every one of those acknowledgements is a person or a session meeting the report’s condition, and writing three lines of excuse instead of one word of status. The oldest entry was seven days old. The badge on the README read 11.

Nothing was broken. The report worked, the lint worked, the acknowledgement mechanism worked — and worked so smoothly that the cheap local fix (write an inactive-ok) kept winning over the correct global one (flip the status), eleven times, with each acknowledgement making the next one feel more normal. The working agreement has a line for this: a guard that keeps catching you is a bug report about the workflow. Twenty-six catches on one decision is not a guard working. It is a guard being routed around.

What the cleanup actually cost

The acknowledgements were the bulk of it. 53 comment blocks across 43 files, each three lines and each saying “Proposed.” in its first sentence — which is what made them safe to remove mechanically: the reason they gave was the status that had just changed. One named two codes at once and needed both to be Active; they were.

Removing them is not optional tidying. luria reports an annotation that no longer applies as a finding, so flipping the statuses without removing the excuses trades 11 findings for 53.

Where the citations went

Five of the eleven were already well integrated — ADR-098 at 62 citations, ADR-099 at 18, ADR-100 at 13. The acknowledgements were the debt, not missing references.

The other six were implemented and cited nowhere, and the shape of that is worth naming: the code cited the issue number and never the decision. derive.py opens ”(#216)” and explains the read-only rule in a paragraph that is ADR-089’s argument, without naming ADR-089. directives.py describes until <YYYY-MM-DD> and cites ”(#58)”. sources.py explains that a rate limit is a property of the window and cites ”(#250)”.

An issue number says when a thing was built. A decision code says why it is allowed to be this way, which is the question a reader of the code is actually asking. Both belong; only one was there. The citations added here sit beside the issue numbers rather than replacing them.


One stale code silently un-acknowledged four others

2026-09-15 03:08:29 · record · lint · mechanism

Three obsolete acknowledgements were left over from the last change: an unresolved-ok naming DP-017 in doc_refs.py, one in migrate.py, one in ADR-046. All three said the same thing — this code resolves to nothing, on purpose — and all three had stopped being true, because a principle was eventually written at DP-017.

Removing them was supposed to be tidying. It was not.

What the report actually said

Before: 3 codes unaccounted for, among them ADR-919 at four sites and ADR-157 at one. After removing one code from one comment: 2, and both of those codes are fully accounted for.

I did not touch ADR-919 or ADR-157 anywhere. They were acknowledged the whole time, by this line in doc_refs.py:

# unresolved-ok-file: ADR-919, ADR-157, DP-017, DP-018 — illustrative codes

One list, four codes, one annotation. ref_status.annotations gives an annotation a problem when an unresolved-ok names a code that does resolve, and scan then does this:

usable = [a for a in anns if not a.problem]

An annotation with a problem excuses nothing. Not “excuses the codes that are still fine” — nothing. So the day DP-017 became a real principle, this comment stopped covering ADR-919 and ADR-157 as well, and four citations that someone had deliberately marked went back to being reported as unaccounted for.

luria said so, in the words it was built to say

This is not a bug. ref_status.annotations opens by stating the design:

Every annotation of one kind in path, malformed ones included — they are reported rather than dropped, because an annotation that silently does nothing is worse than no annotation.

The directives that no longer apply finding is that report. It named doc_refs.py:38 every run. What it could not say — and what I only saw by diffing the reference-status report before and after — is that the line it was pointing at had also quietly taken four other citations down with it.

So the finding was doing more work than its wording suggests. “This annotation no longer applies” reads like a dead comment to sweep up. It can also mean “this annotation has stopped doing the job you are still relying on it for.”

The shape worth remembering

A shared acknowledgement fails whole. Four codes in one list is four chances for the list to be poisoned, and the blast radius is all four, not the one that went stale. The three one-code directives I removed could never have done this; the four-code one did it the moment any single member resolved.

That is an argument for narrow acknowledgements — one code, one reason, one site — over a tidy combined list. The combined list is cheaper to write and reads better. It also couples the fate of unrelated citations together, which is precisely what an acknowledgement should not do, since the whole point of locality is already written down: each acknowledgement covers findings at its own site only — that locality is the point.

I have not gone and split every multi-code directive in the record; that is a bigger change than this one and wants its own look. But the next one that goes stale will do the same thing.

A hazard this entry walked into

The first draft of this entry showed the offending directive as a four-space indented sample. luria link --fix rewrote the code inside it, turning the literal line it was quoting into a markdown link — the sample stopped being the thing it was showing.

doc_refs.code_spans masks fenced blocks and inline backticks. It does not mask indented code blocks:

text = "    an indented sample naming ADR-002"
doc_refs.code_spans(text)   # -> no span; the code is treated as prose

Fencing the sample fixes it here, and that is all this entry does about it. A real fix is not a one-line widening: in Markdown, four spaces of indent is also how a continuation paragraph under a list item is written, and masking those would quietly stop the fixer linking ordinary prose. That is a decision, not an oversight to patch, so it is written down here rather than made in passing.

What I did not touch

DP-018 stays in the doc_refs.py list. It still resolves to nothing, so luria does not flag it, and I could not establish whether it is excusing a mention I had not traced — [[SG-DP-18]] on line 540 is a composed remote code and may never have been a local citation at all. Unsure is not the same as obsolete, and the instruction was to remove what I was sure about.

The illustrative mentions themselves are untouched. Every one is in backticks, so luria link --fix leaves them alone, and respelling them with the reserved FX fixture prefix would have wrecked the examples: ADR-046’s prose is about the literal spelling DP-17 and the bug that it did not match.


Firing the new guard on the case that motivated it

2026-09-15 03:30:44 · lint · mechanism · method

The previous entry found that a four-code unresolved-ok in doc_refs.py had gone stale on one code and, because scan drops a problem annotation whole, stopped excusing the other three as well. The lint reported the stale code and nothing about the cost. This adds the cost.

Fired on the real case, as the working agreement asks

The guard’s motivating case had already been fixed, so I put it back to fire the check against it — temporarily restoring the stale DP-017 in the doc_refs.py list and running the lint:

luria/doc_refs.py:38: annotation names DP-017, which does resolve here — so it
excuses nothing, leaving ADR-157 (1 site), ADR-919 (3 sites), DP-018 (1 site)
unacknowledged

Then reverted. That is the whole point of firing a guard on a real case rather than only on fixtures: the fixtures were written from my model of the bug, and the real case is the thing that actually happened.

Corrected, the day after. This entry originally said the new finding had answered a question the previous change could not: it reported one site for the DP code left in that list unsure, which I read as proof the acknowledgement was doing real work and that leaving it was right.

That was wrong, and wrong in a way worth keeping written down. The site the check reported was a line of this change’s own prose — rewriting the acknowledgement’s reason, I named the code in the explanation, and a continuation comment line is not directive-shaped, so scan does not blank it. The mention the acknowledgement excused existed only because the acknowledgement explained itself. Before that edit the code had no citation in the file at all.

So the check was right and I read it wrong. It reported a real citation; I did not ask where the citation came from, because the number agreed with what I already believed. A measurement taken after your own edit is a measurement of your own edit until you check otherwise — the same lesson as the 0.6s timing two entries earlier, learned again in a different costume.

The guard caught its own author twice

Writing the tests for this took two acknowledgement mistakes, both caught by the existing checks within a minute of each other.

A fixture code, unacknowledged. The new test file needs a code that resolves nowhere, and it has to be spelled in the local scheme, because the fixture project declares that scheme — ZZZ-777 would not exercise anything. So the file cites ADR-777 six times and the reference report picked all six up. One unresolved-ok-file fixes it. This is the third time this session that a test’s fixture codes have leaked into the record’s own reference graph.

An acknowledgement that acknowledged nothing. I added a second one for the inactive-ok fixture’s codes, and the lint immediately said:

annotation no longer applies — nothing in scope cites ADR-778

Correct, and I had not thought it through: those codes are written inside a directive-shaped string, and scan blanks directive-shaped spans by design — “naming a code in a directive is not citing it”, for a live annotation and an example of one alike. So they were never counted, and acknowledging them was acknowledging nothing. The check that catches an annotation excusing nothing caught it on the first run.

Both of those are the same family of finding as the one being improved here, which is a fair sign the family is worth having.

One code per annotation

The new test file acknowledges with a single-code directive, and says why in a comment. Writing a tidy combined list in the very file that documents how a combined list fails would have been a poor joke. The record already argues the general case — each acknowledgement covers findings at its own site only; that locality is the point — and this failure extends it: locality is not only about where an acknowledgement applies but about how much it takes down when it rots.


Naming a code in the reason is how you create the citation you are excusing

2026-09-15 05:26:56 · record · mechanism · method

Two things, and the second only exists because the first was wrong.

The correction

doc_refs.py acknowledged a DP code that had no citation in the file. Reading the module confirms it: the only spelling of that code is inside a composed remote wikilink, and scan blanks composed remote spans before the local pattern runs, exactly as designed. There was nothing to excuse.

It looked otherwise because of my own previous change. Rewriting that acknowledgement’s reason, I named the code in the explanation — and only the first line of a comment is directive-shaped, so shaped_spans blanks the argument list and leaves the continuation prose alone. One sentence of explanation, one new citation, excused by the annotation that the sentence was explaining.

Then the cost-reporting check added in the same change reported that one site, and I read it as evidence the acknowledgement was doing real work. The check was right. The reading was wrong, and wrong in a specific way worth naming: the number agreed with what I already believed, so I did not ask where it came from. A measurement taken after your own edit measures your own edit until you check otherwise — the same lesson as the 0.6s timing three entries back, in a different costume. That one I caught by re-running. This one I only caught because it was questioned.

The entry that recorded the wrong conclusion is corrected in place rather than appended to. DP-017 is the reason: the record states what is true now, and git keeps what was written then.

The word that was missing

The thing being argued about underneath was real. Every acknowledgement luria had asserts something about a code’s state, which is why each retires correctly when that state changes — and why none of them fits a reference that asserts nothing. ADR-093 quotes three codes as specimens. ADR-046’s prose is about a code’s literal spelling and the regex branch that missed it. migrate.py demonstrates a moved address. None claims the document is retired, or absent, or anything.

Written as unresolved-ok — the closest available word, and false — each of those is a scheduled failure. It fires on the day someone allocates that number, at a moment with no relationship to the work that trips it. The DP scheme is at 17 and two acknowledgements name the next number today.

mention-ok: says named, not cited. The load-bearing part is not the suppression, it is what “still applies” means: a mention is used while the code it names is cited in its scope, whatever the document’s state. Defining used as “suppressed a finding”, the way the other two do, would rot it at precisely the moment it exists to survive.

Four self-inflicted findings while building it

Every one caught by the checks, which is the argument for having them:

  1. The fixture codes leaked again — a fourth time this session. The new test file’s specimens went unacknowledged until the report said so.
  2. I cited a number that did not exist yet. I wrote the ADR’s own code as ADR-104 throughout the implementation, because 103 was the highest. This scheme mints a temporary code and concretizes it where merges serialize (ADR-049); the number I picked was the number the next filing would take.
  3. I named a code in a reason again — in the ADR-093 conversion, the same week I corrected the identical mistake in doc_refs.py. The trap is that the argument list is blanked and reads as safe, so the reason feels like it is inside the directive when the scanner sees plain prose.
  4. I trusted a piped exit code. luria lint 2>&1 | head -6; echo $? reports head’s status, not the lint’s. It printed 0 while the lint was exiting 1 on a real violation — a missing body heading. Caught on the next run, which checked the command directly.

The third is the one that was not a slip. Twice in a week, both times while editing an acknowledgement to fix an acknowledgement, which is the shape of a hazard rather than of carelessness. It has a decision of its own now — this entry originally tried to state the rule here, and a rule is not an observation: the devlog is where you record what happened, and the record’s standing instructions live where they can be cited.

Filed Active, deliberately

ADR-052’s draft signal is for a contribution that is the question — the decision is the merge verdict, so it ships Proposed. This one was decided before the work started. Filing it Proposed would have manufactured five fresh inactive-ok acknowledgements to retire on the day it flipped, which is the debt this record spent an entire change clearing two days ago.


A rule can carry its own alert

2026-09-16 03:52:30 · lint

anthology-of-the-sota closed its tag vocabulary this week, and the request that came back from its owner was not for a different rule but for a different message:

I don’t mind setting the vocabulary closed as an enforcement mechanism to move new tags into the official structured vocabulary, but I’m concerned that the enforcement mechanism will mainly discourage the addition of new tags.

That is a real failure mode for this library rather than for that record. A closed set’s finding reads “is not in the topics vocabulary — the values are …”, which sets up pick the nearest of these fourteen as the path of least resistance. In that record it is the failure they had just spent a week repairing: eight positional-encoding papers filed under the nearest available word because the right topic did not exist when they were filed, and nobody went back after it was added.

The rule was right and the message taught the opposite lesson. Nothing in the config could tell the difference between a list that is finished and a list that is merely short.

Where the alert attaches, and why not on the field

On the Vocabulary and on the TagGroup, not on the field.

A field can fail several ways — absent, wrong shape, outside the closed set — and one alert on the field would print “add a value to the vocabulary” on a missing-field error, where it is wrong. Attaching to the rule’s own carrier gives each alert exactly one failure to explain, and neither carrier has more than one rule to confuse: a vocabulary’s alert is about its values, a group’s is about its require. That is why there is no closed_alert:/required_alert: pair — the per-rule key the issue proposed turns out to be unnecessary once the alert rides the thing the rule belongs to.

Fired on a real case

Per the working agreement, against the record that asked for it rather than only against a fixture. With the alert added to that project’s topics vocabulary and a plausible wrong tag (kv-cache-paging) on a practice:

record/practices.d/SOTA-085.md: `tags: kv-cache-paging` is not in the
`topics` vocabulary (vocabulary 'topics') — the values are … , tiny-models
    ↳ Closed so every tag is one somebody chose and blurbed, NOT because
      the list is finished. If this document needs a word the vocabulary
      cannot say, add it to `vocabularies.topics` with a label and a blurb
      and a decision saying why — that is the intended move, not a
      workaround.

Two things that run alongside and are not this change’s business:

The finding is emitted twice, with and without the alert, and identically on the released 0.22.0 — so it predates this work and is not a regression here. The scheme’s contract holds no duplicate tags field, so the repetition comes from somewhere in the scan rather than from the contract. Worth a look; not looked at here.

An older luria ignores the key, so a record can adopt alert: before the release that reads it and get the old message until then, rather than a config error. Checked rather than assumed.


A crash in the chain walker was the wrong feature in the right place

2026-09-16 04:27:44 · record

chains.<name>.invariant was the only place a record could say these two related documents share a field, and the consumer that wanted it most — anthology-of-the-sota, joining a practice to the paper under it — could not use it: declaring the chain raised KeyError in lines_of. _load keys docs by the chain’s scheme and fills the spine from relations.edges(), which resolves the far scheme and hands back codes the walker never loaded.

Two things worth keeping from the diagnosis.

_load’s docstring described the filter that would have prevented it. It claimed targets were “filtered to codes that land”; the line under it is spine = {code: sorted(held.get(code, ())) for code in docs}, which filters keys and not targets. The filter was never written. Adding it would have made a cross-scheme chain render an empty page instead of crashing, which is the failure DP-015 is specifically against, so the fix went the other way: a chain whose relation leaves its scheme is now refused when the config is read, and the message names the key to use instead.

The feature was in the wrong module. invariants.held() takes a document and a field name. Nothing in the edge check needs a walk, an ordering, a depth or a page — those are what a chain is for. So invariant is now a key on the reference itself, asserting the edge; the chain’s key stays and asserts the edge plus the line, which is the only part transitivity is needed for. Declared both ways on one field they report once. See ADR-106 for the two alternatives that lost.

Both ends are validated, and that was not obvious. The chain check asks whether the invariant field is nameable on the chain’s scheme. For a relation the near scheme holding the field is not enough: the assertion is symmetric, so a far scheme that cannot hold it makes every edge a finding. A check that fires on everything is as invisible as one that fires on nothing, which is the same argument the eager validation everywhere else in config.py makes.

Fired once on a real case, per the working agreement. In anthology-of-the-sota, invariant: tags on schemes.SOTA.references.source renders 67 unbound edges over 220 practices — e.g. LIT-002 (model-stability) under SOTA-005 (inference-optimization) — beside the 22 its two same-scheme chains already report. The guard runs, it names the relation it came from, and the config is refused when the far scheme cannot hold the field. Whether that record wants the default on is its call; the point of the change is that it can now be asked.

Also cleaned up on the way through: nameable(scheme) is now one function instead of the same four-table union written out in _chains and _check_conditions, and the two docstrings justifying the opt-in default with a consumer’s old schema — “two vocabularies that a decision had deliberately separated” — say it in luria’s own terms instead. That record merged those vocabularies (its ADR-026), so the stated reason had been false for a while and was still being quoted.


The bug was not the refusal, it was what happened when you worked around it

2026-09-16 05:49:54 · config · contract

derive refused many with a message asserting that every derivation reads one value off a list. It was written for {tags[0]}, where that is true. It is false for {tags}, which render() returns whole — the list object itself, by the same “a lone field returns the value, not a string” rule the module documents.

Dropping many to satisfy the refusal is the obvious move and it is the trap. What happens next:

  1. contract.values_of(field, ["a", "b"]) returns None — “the shape contradicts the declaration” — because the field is declared scalar.
  2. effective_values passes the None through.
  3. vocabularies._listing iterates effective_values(...) or [], so every document contributes nothing.
  4. vocabularies.pages returns no pages, and luria index writes none.

No exception, no lint finding, no diff in anything that fails. On anthology-of-the-sota this deleted all fourteen docs/notes/tags/*.md — the entire browse-by-topic surface for 158 reading notes — and the only sign was fourteen D lines in git status, which an author who was expecting the tag values to change would have to read carefully to notice.

The guard that was needed is not the one that existed. The refusal was in the right place and asked the wrong question: not “is this a derivation?” but “does what this renders match what the field says it holds?”.

many is declared rather than inferred, and that is the one real design call. derive’s own docstring argues the other way — “the rule is the template’s shape rather than a flag” — and for a same-document derivation inference would work. It does not work across from:. _fields compiles one scheme’s fields while the loader is still assembling the others, so the cardinality of {tags} off paper is not knowable there; _check_target_fields is the first place every scheme exists, and by then the field is frozen. Inferring would mean rebuilding compiled fields after the load loop or resolving lazily at every read. Declaring costs a line, and makes the missing half a finding instead of a silence. ADR-107 has the rest.

Fired once on the real case, both ways round: with many the anthology’s note tag pages come back (twelve, not fourteen — two values now have no notes under them, because the notes that carried them were disagreeing with their own papers, which is the thing the derivation exists to stop); without it, the load refuses and names the field, the source, its scheme and the consequence.

Still reachable by other routes: values_of returns None for any written value whose shape contradicts its declaration, and nothing reports it. A document with tags: [a, b] under a scalar tags: is invisible the same way. That wants its own finding and is not this change.


The description belongs to the set, and firing it on a real record is what proved it

2026-09-16 16:05:47 · config · contract

#279 asked for a blurb on vocabularies. I added label and blurb to five dataclasses, threaded them into the contract, rendered them in three places, wrote eleven tests, and had 1311 passing — and the implementation was wrong in the one way that mattered.

I had put the vocabulary’s pair on the field that invokes the vocabulary. The issue says “the vocabulary as a whole”, and those are different objects. The tests passed because I had written them against my own reading.

What caught it was the working agreement, not the suite: fire any new guard on a real case before trusting it. Writing the blurb into anthology-of-the-sota’s central vocabularies.topics table — which is where a reader would naturally put it — parsed label and blurb as two additional values, taking thirteen topics to fifteen. The feature did nothing and corrupted the vocabulary while doing it.

The deeper fault is a decision that was already on the record. ADR-098 moved vocabularies to a central table precisely because a set two schemes shared had to be two files, and ten of thirteen entries had silently drifted apart. A description declared per invoking field recreates that exactly, one level up. I had re-introduced the bug that ADR-098 exists to have fixed.

The discriminator, wrong twice

Supporting both table shapes needs a rule for telling them apart. The obvious one is isinstance(table["values"], dict) — and it is wrong, because a vocabulary value named values carries {label, blurb}, which is also a mapping. A flat table holding one would read as nested and every other value would vanish. I wrote that version, wrote a test asserting it was refused, and the test failed by not raising.

The rule that works is keys-are-a-subset: nested when the table says only label, blurb and values. One spelling stays unreachable — a flat vocabulary whose only value is named values — and that is written down rather than left for someone to find.

The guard caught its author

Converting the anthology’s table by hand, I inserted values: and forgot to re-indent the sixty-three lines under it. The load refused, naming the fault in the terms the message was written for. That is the second time in two days a guard I had just written fired on me first, and both times the message was the thing I was glad to have spent words on.

Afterwards: 14 values parsed, one description reaching all three schemes that name the vocabulary, rendering above the value’s blurb on every tag page.


The code cited the argument, not the decision

2026-09-16 19:00:34 · record

Five Proposed ADRs, all filed today, all shipped, and the pending-decisions report said “Cited nowhere at all (5) — these are the cheapest to close, since nothing depends on the answer.”

The second half of that sentence is wrong here, and the way it is wrong is the interesting part. Plenty depends on the answer. ADR-106 is why Reference.invariant exists and why a cross-scheme chain is refused at load; ADR-109 is why nine config objects carry a label or a title. The code implementing them is all over config.py, contract.py, invariants.py, chains.py, derive.py, record_doc.py and vocabularies.py.

It cites #272, #276, #273, #279 and #281. Forty-two times. Never once the ADR.

Why that is not a nit

An issue is where the argument happened. It is unversioned, it has no status, and nothing in this tool can tell you it was superseded — because from luria’s side a #272 is not a code at all, just text. An ADR is the decision, and Superseded is a thing that can happen to it.

So the arrangement was: every one of these decisions could be reversed tomorrow, and the reference report would say nothing, about any of the thirty-odd sites that implement it. That is precisely the failure the reference check exists to catch, reached by a route the check cannot see — the citation was not missing, it was pointing at the wrong kind of object.

And it is my habit that produced it. Writing the implementation, the issue is what I have in hand: it is where the argument is, it is in the branch name, it is in the commit message. The ADR is written in the same session and then not used, because at the moment of writing a comment the issue is the live thing and the decision is the paperwork. That is exactly backwards for a reader six months out, who has the code and no idea which conversation produced it.

adr_index.py already had the right shape in one place — (ADR-103, #249) — and it is the shape I have now used at fourteen definition sites, one per (decision, file) pair. Both, not either: the ADR is what is in force, the issue is where to read the argument.

The status flip

All five go Active. Each one shipped, four of them in a release. Active here means in force — the current answer, and what a citation should normally point at, and by that definition they have been Active since they merged; the status was lagging the code, which is DP-002’s subject from the other direction.

ADR-110 also goes to v2: its summary still described a VOCABULARY_KEYS constant and a values: key, both of which review replaced. A decision whose summary describes the first draft is worse than one with no summary, because the summary is what the index shows.

What I would look at next

The report’s sort is open since and its second axis is citation count, with the reading that an old uncited proposal is a stalled idea. That reading assumes the citation count measures dependency. Here it measured spelling. Five of five were mis-scored the same way, which is not a coincidence about these five — it is what happens whenever the implementer and the decision-writer are the same person in the same session, which in this project is always.

Worth asking whether luria lint should be able to notice: a decision with no citations anywhere, whose issue number appears in the code globs, is a detectable shape.


The generated reference had a hand-kept list inside it

2026-09-17 05:42:29 · record · mechanism

docs/configuration.md is generated from luria/config.py precisely so it cannot drift, and its module docstring says so in bold: the key tables come from dataclasses.fields(), not from a list kept here. That claim was true and it was not enough. Two lists in the same file were hand-kept:

  • SECTIONS decided which dataclasses got a section at all;
  • PLAIN spelled out which scalar keys existed under luria, paths, code and lint.

Both had rotted, and nothing said so. chains — a whole family — had never had a section. Neither had Reference, Vocabulary, PlainField, TagGroup, FieldGroup, RequiredWhen or VocabularyTable, which is where unique, alert, converse, invariant and required_when are all declared. lint.mute, lint.network and include_records had no row. The page looked complete, and every test passed.

The tests passing is the part worth keeping. test_renders_a_page_with_every_section asserted, for each entry in SECTIONS, that its class was in a list called ALL_SECTIONS — also written by hand, also in the test file. It asked is every section I listed a class I know about, which is a question that cannot fail while the schema grows underneath it. The inverse — is every class in the schema on the page — is the one that fires, and the same inversion applies to the scalar keys. A guard pointed at its own inputs is not a guard.

So membership is derived now, and the asymmetry is deliberate: tables() reads every dataclass out of config.py and ORDER is only a reading order, so a class nobody listed is appended rather than dropped. A table nobody described renders undescribed; a table nobody listed used to render not at all. The same for scalars — the row comes from DEFAULTS, the prose comes from PLAIN, and a key with no prose gets a row with an empty cell plus a failing test until somebody writes one. The reminder lands in CI instead of in a reader’s head.

Both new guards were fired on the real regression before being trusted (DP-6): removing Chain from the order and from the prose dict, and taking the mute prose out. The section guard did not fail on the first attempt, which was the useful result — the version I first wrote compared tables() against SECTIONS, and since both derive from tables() it was tautological, exactly the shape of the test it was replacing. What fires is checking the rendered headings against the dict-valued keys in DEFAULTS.

The examples were not YAML

Found on the way, and worse than the missing sections. ADR-098 moved the config from TOML to YAML; #286 rewrote the example configs. It reached Fragment’s docstring and stopped. Journal, Remote, RemoteScheme, Site, FieldGroup, Vocabulary and RequiredWhen still carried TOML bodies, with the old [luria.journals.devlog] header mechanically turned into an orphaned backticked line floating above dir = "devlog.d". And fence() still defaulted to lang="toml", so all eleven blocks were labelled as the format they were not. Someone copying the journals example into their luria.yaml got nothing that parses.

A spelling check would not have caught this, because the failure is that the text is valid-looking prose in the wrong language. The guard that does catch it parses: every ```yaml block in the rendered page must yaml.safe_load to a mapping. It earned itself immediately — the arXiv example I rewrote as uid: "(\d{4})..." is not YAML, because a double-quoted scalar processes escapes. Single quotes, which is what the external-citations example had been using all along.

What is still hand-kept

The prose. PROSE maps a class to its heading and one sentence, PLAIN maps a scalar key to its description, and neither can lose a table or a key any more — only leave one undescribed, visibly. That seems like the right line: the schema is the thing that changes without anyone thinking about the docs, and a sentence explaining what a table is for is not derivable from its fields.

The rest of the pass was ordinary staleness. luria upgrade had never been in the CLI reference. The lint’s own account of itself listed 13 of its 20 warning classes and none of the constraint checks added since it was written. luria.toml appeared in three files under template/ — which is what luria init hands every adopting project. And the reference’s “what is not configurable” section still said there was no migration command, citing a decision to build one as still Proposed; luria migrate has been shipping for a while.


Two tags, and the one that nearly said nothing

2026-09-17 09:45:08 · record · process

docs is computable, and computing it was the first thing worth doing. A regex over docs/**.md finds 11 ADR codes; ref_status.scan() finds 8. The difference is the whole of ADR-105: a code in backticks is a mention — the docs are full of ADR-012 standing in for “a code, generically” — and the scanner already knows not to count those. Using the project’s own scanner rather than a fresh regex is what made the set defensible, and this is a general lesson about tagging a corpus by what it says: luria has an opinion about what a reference is, so a script that reimplements one is answering a different question.

The set landed at 16 rather than 8 because docs/configuration.md counts. Its ADR citations live in config.py docstrings and render verbatim into a page a reader browses, so they are documentation references that happen to be authored in code. Excluded, by contrast: the shaped by lines in design-principles.md, which name 16 more ADRs. Those are influenced_by frontmatter rendered as provenance — derived metadata about a principle, not prose anybody wrote for a reader. The line is whether a person wrote the sentence the citation sits in.

The tag that nearly carried no information

load-bearing was almost useless, and the way it nearly failed is worth writing down. The first criterion I tried was “a decision other machinery depends on”. Applied to the 16 docs ADRs it matched 15 of them — which is not a finding about the corpus, it is a finding about the criterion. Of course a decision worth documenting is implemented in code; in a tool like this almost every Active decision is. A tag that fires on everything is the inert-status failure one level up: a field every record agrees on is indistinguishable from no field, and the build stays green because nothing is being judged.

The second criterion was “constrains what an adopting project must write”, which pulled in every X is configuration decision and would have duplicated the config tag that already exists.

What discriminates is narrower and is about the record on disk rather than the tool: a frontmatter field, a path, the spelling of a code, directive syntax in prose. Twenty decisions, seven of them also docs. The overlap is the check that the two tags are actually different questions — had it come out at 15 or at 2 I would have had the wrong criterion in one direction or the other.

What this sets up, and does not

Both tags are claims of intent, not derived facts, so they can go stale in the direction the record cares about: an ADR tagged docs that nothing cites any more, or a newly-cited one nobody tagged. That asymmetry suggests a report — compare the tag against ref_status.scan() and name the disagreements — and deliberately not a lint check, because neither direction is always wrong. A decision can be tagged docs because we intend to cite it in a page not yet written. Under the rule this project uses for new checks, that is a report with an acknowledgement route, not a violation. Not built here; noted so the next person does not have to rediscover that the data is already available.

The tag broke luria site, and the break was older than the tag

Adding two tag values turned test_site.py red on two whole-record assertions: 36 links reported as leaving the published site, all of them of the form record/decisions.d/ADR-001.md → ../../docs/decisions/tags/docs.md.

The link is not in any source. _vocabulary_bits builds it for the record line luria injects under each document’s title — every written vocabulary value linked to its page — and _retarget then asks whether the target is published by checking published, which stage() built by walking the tree. The tag pages were not in the tree, because ADR-068 says a branch carries no generated views. So the link was correct, the page was real, and the tree was in exactly the state the project’s own decision requires.

Two of luria’s rules were quietly incompatible: views live on main only, and a whole-record test reads the committed views. Any contribution that added a view — a tag value, a scheme, a journal — would have failed CI on a branch that was right. Nobody had added one since the record line started linking vocabulary values, so it had never fired.

The fix is one line and it is about the model rather than the test: published is now publishable() union the paths adr_index.outputs() declares. A view is published because the record renders it, not because somebody has run luria index in this checkout. pages stays the disk walk, since only a file that exists can be staged — the two were one set and were answering different questions.

Reproducing it cost more than fixing it. The first attempt used the project fixture and passed against an unfixed site.py: that fixture declares tags as a plain many-valued field with no vocabulary, so it is not in scheme.vocabularies, so no bit is emitted and no link exists to be reported. A test that passes for the wrong reason is worse here than no test, because the next person reads it as coverage. What reproduces is a tree whose tags field names a vocabulary, with the tag page deleted after generation — which is precisely what a branch looks like.


A backend for the record turned out to be a view

2026-09-19 07:57:23 · record · mechanism

#110 is one sentence: the record should be storable in “sqlite or whatever”. The brief that came with it was to judge whether a data-model refactor had to come first. This is the judging, written down, because the answer is no and the reasons are the kind that get re-derived.

Measuring before deciding

The refactor the issue implies is a storage boundary: some Store that lists, reads and writes documents, with the filesystem as one implementation. Before arguing about it I counted what it would have to cover. grep over luria/ for read_text, write_text, glob, stat and unlink finds about 150 sites in thirty modules; 49 callers of documents() and temp_documents() receive a Path and go on to use it as the document’s identity — in a finding’s path:line, in a link’s path.name, in the frame luria link --fix resolves a target from. Every writer is text surgery on a markdown file, on purpose: field_edit and relate edit a frontmatter block without disturbing the comments the scaffold shipped in it, which no YAML round-trip preserves.

That count is the cost. The benefit is what a source in a database would buy, and going through the list it kept coming up empty. Concurrent contributions? Files in git already never conflict; a database blob always does. Review? A diff of a markdown file is the review; a diff of a SQLite file is nothing. History? formerly: and .git-blame-ignore-revs hang off files. The one thing a database genuinely buys is being able to ask the record a question nobody wrote a report for — and that needs the database to exist, not to be the source. ADR-111 had already said as much while deferring this exact issue: “a database is what you reach for when you want to ask arbitrary questions”.

So the shape is: sources stay files, and luria export writes a SQLite view of them. ADR-116 has the alternatives; the rest of this is what building it turned up.

Every row through an existing reader

The rule that made this small was the one the site already follows: never parse a source that something in the package already parses. load_scheme gives a document with its derived fields folded in, edges.graph gives the typed relations, ref_status.scan gives the citations, journal.entries gives the entries. The export is a hundred lines of gluing those into executemany, and it inherits every opinion the lint has — a code in backticks is not a citation, a temporary code has no number, a derived field is an ordinary field.

Two things the readers did not hand over:

  • Adr does not keep the body. It reads it, takes the H1 as a fallback title, and drops it. The export calls read_document a second time for the body, which is free — that is the cache ADR-103 put there — but it is worth knowing that the document object is not the document.
  • The scanner knows which citations are excused and the reports use it; nothing else does. Citation.excused_by is the directive that acknowledges a finding. The first cut of the citations table left it out, and the first query over the real record — 118 dangling citations — was wrong in the DP-001 way: it counted the acknowledged ones as if nobody had looked: 110 of the 118 carry a directive. One column (excused) fixes it, and now “dangling and unacknowledged” is a WHERE clause that returns eight.

Firing it

On this record, 133 documents, 1,277 field values, 70 edges, 2,250 citations, 120 journal entries; 1.8 seconds; 2.1 MB. The first real query was the one the pending-decisions report cannot answer because it only looks at Proposed documents: SELECT … FROM documents d WHERE status = 'Active' AND NOT EXISTS (SELECT 1 FROM citations c WHERE c.code = d.code). Twenty-five Active decisions that nothing in the docs or the code cites. Whether that is 25 decisions nobody needed to write down or 25 that the code implements without saying so is a judgement, which is why it is a query and not a lint.

The overwrite guard was fired on a real case before being trusted (DP-006): a text file at --out is reported and left alone, and the test holds the bytes unchanged. Rebuilding by deleting is the right call for a view — a row for a document that no longer exists is exactly the staleness luria index --check exists to catch — but deleting is only safe for a file this command made, so the guard reads the SQLite header first.

What is deliberately not here

No luria query — sqlite3, Datasette and pandas are better at it. No committed database — binary, undiffable, and every merge to main would rewrite it, which is DP-002’s tell. No chains table — derivable from edges. No nested records — one export is one record. And no Store protocol: the boundary a second file format would need is the set of readers named above, and it is there without a class around it. If a project turns up wanting one YAML document per entry, that is the refactor, and the ADR lists what it touches so the measuring does not have to be done again.


A document’s prose reaches luria through —body

2026-09-20 07:01:00

A tool with a finished document had no way to file its prose. strata-g’s drop dialog authors a record entry on its canvas — frontmatter and body — and files it through luria new --draft. The draft path took every field the scheme declared and refused the body, because body was not a key any scheme has an opinion about, so the entry arrived with the template’s instructions still in it. ADR-117 is the decision; --body TEXT and a draft’s body key are the implementation.

The heading is the load-bearing part. # CODE: title is derived from title: and luria lint holds the two equal (#301 is the scaffold that once shipped a document the lint rejected on first read), so replace_body splices the prose in below the heading the scaffolder just wrote, and drops a level-one heading the body opens with rather than filing two. That case is not hypothetical — the dialog that motivated this shows the user a preview of the whole document, heading included, so the obvious thing for it to hand over is the whole document. Both halves of the rule are tested together: a body with its own heading produces exactly one \n# in the file.

Three kinds have a body and they are not the same shape. A scheme document keeps its frontmatter and its heading; a journal entry keeps its frontmatter and loses the placeholder paragraph; a fragment is body all the way down, so the text replaces the file. new_fragment and journal.new each grew an optional body, and new_entry threads fields.get("body") to whichever applies. A migration is YAML and ignores it, as it already ignores every other field flag.

The flag was dogfooded on its own record. This entry, the changelog fragment and ADR-117 were all filed with luria new --body "$(cat …)" rather than scaffold-then-edit — which is the first real use, and it caught the ergonomic fact that shell quoting is the whole story for multi-line text, so --body-file would have bought nothing the alternatives section could name.

A trap in the tests, not the code. tests/conftest.py writes the fixture luria.yaml from a triple-quoted literal, so every line carries eight spaces of indentation that YAML reads as the document’s base. A test that appends a journals: block has to match it; appending at column zero parses as a second document and fails with expected '<document start>'. Worth knowing before the next test extends that fixture.