75 entries. All books.
Contents
- 3 Sep 03:58 — Three lint passes compile into one contract per scheme
- 3 Sep 04:09 — Three facts the record already stated, read as edges
- 3 Sep 04:43 — Provenance goes into the finding, not into a command
- 3 Sep 14:25 — A list in a reference field was read as its first code, silently
- 3 Sep 16:22 — A field that was neither a reference, a tag nor a status
- 3 Sep 18:00 — One field was two types, and had been for sixty decisions
- 3 Sep 19:21 — Requiring arxiv was requiring the wrong thing
- 3 Sep 19:42 — Six conflicts on files nobody wrote
- 3 Sep 21:57 — A temporary code in a workflow file breaks the generation job
- 3 Sep 22:23 — An ADR closed in review: the constraint was the token’s, not the file’s
- 3 Sep 22:39 — The lint reads sources; nobody regenerates a view to check a branch
- 3 Sep 22:50 — The published site was one bot commit behind
- 5 Sep 01:16 — A record with no code in it, and two failures only staging could see
- 5 Sep 03:18 — Publishing the examples, and the third hand-written link the fixer would have got right
- 5 Sep 06:19 — The uncited principles, and the file the reference machinery could not see
- 5 Sep 16:57 — A remote code in superseded_by, and a directive with nowhere to stand
- 5 Sep 20:29 — A guard that a single exception can switch off is a guard about unanimity, not about information
- 5 Sep 20:55 — A working link to the wrong paper is invisible to every check that asks whether links work — and the first fix had the same shape as the bug
- 6 Sep 00:01 — The form was the only file stating the schema that nothing compared to it
- 6 Sep 00:09 — A status says what a record believes; nothing said what would change its mind
- 6 Sep 00:15 — A line is a DAG, and a rival that succeeds nothing still belongs to it
- 6 Sep 00:30 — Every fixture passed and every rendered link was broken
- 6 Sep 00:38 — The rebase was the wrong fix; the field was already split
- 6 Sep 04:39 — The mechanism was built on a premise it did not enforce
- 6 Sep 18:15 — The symmetry check became a fixer
- 6 Sep 18:38 — The constraint was about the field, not the direction
- 6 Sep 20:24 — What the vocabulary migration hits
- 6 Sep 21:08 — The form’s own words reached the published index
- 6 Sep 21:32 — Migrating status onto the mechanism it was generalized from
- 7 Sep 00:35 — One fact, one bit: two duplications the record line grew
- 7 Sep 00:58 — Three copies of a region, and a link nobody derived
- 7 Sep 01:53 — The properties panel cannot show what the record line is for
- 7 Sep 03:02 — A title is data, and this record has one that reads as syntax
- 7 Sep 04:40 — The link that resolved to the wrong project
- 7 Sep 05:35 — A temporary code is a code to link, and was not one to check
- 7 Sep 16:55 — The chain page encoded depth but not parentage
- 7 Sep 20:08 — Firing a two-relation spine on a real record, and what hid a config error
- 8 Sep 15:49 — A relation asserts an invariant; the check asks which field names it
- 8 Sep 16:59 — Derived fields, and the ADR-066 rule they invert
- 8 Sep 18:57 — Identity moved into the document
- 8 Sep 21:01 — Alias inference, and the map that was never wired up
- 8 Sep 23:04 — One template vocabulary, and the take grammar that went away
- 8 Sep 23:30 — A docstring is one block, the way a fence already was
- 9 Sep 01:41 — tree-sitter 0.26.0 segfaults; the extra pins below it
- 9 Sep 02:49 — Merging a stack bottom-up under squash makes a conflict out of nothing
- 9 Sep 18:53 — A mute dial, because fail_on only changes the consequence
- 10 Sep 15:47 — Firing the reference derivation on 281 real documents
- 10 Sep 22:10 — Grouping the pending report, and being caught twice by the specimen-code hazard
- 11 Sep 01:23 — Reserving a namespace instead of blanking three suites
- 11 Sep 05:41 — Lint only column-0 HTML comments in YAML frontmatter
- 12 Sep 21:59 — Two halves of one fix: a page to cite, and a citation that points at it
- 13 Sep 19:53 — The record was parsed twenty-three times per document per lint
- 13 Sep 19:58 — The retries were paying nine seconds an identifier to learn nothing
- 13 Sep 20:35 — The same-scheme converse rule was an assumption, not a constraint
- 14 Sep 04:20 — The config migration’s two silent failures were both about indentation
- 14 Sep 15:15 — Every fragment link in the record worked everywhere except where it is read
- 14 Sep 17:17 — Owning somebody else’s algorithm, checked against their output
- 14 Sep 18:35 — Quartz 5 builds, and the popover bug was already fixed there
- 14 Sep 20:02 — Three comment-carry bugs the examples passed and the property caught
- 15 Sep 00:30 — The lint’s time was in re-reading, not reading
- 15 Sep 01:06 — A frozen dataclass that cannot be hashed, and a test that cited three ghosts
- 15 Sep 02:23 — The 36 scans were 8 corpora and one render done twice
- 15 Sep 02:46 — The report said so for seven days: eleven decisions the merge never flipped
- 15 Sep 03:08 — One stale code silently un-acknowledged four others
- 15 Sep 03:30 — Firing the new guard on the case that motivated it
- 15 Sep 05:26 — Naming a code in the reason is how you create the citation you are excusing
- 16 Sep 03:52 — A rule can carry its own alert
- 16 Sep 04:27 — A crash in the chain walker was the wrong feature in the right place
- 16 Sep 05:49 — The bug was not the refusal, it was what happened when you worked around it
- 16 Sep 16:05 — The description belongs to the set, and firing it on a real record is what proved it
- 16 Sep 19:00 — The code cited the argument, not the decision
- 17 Sep 05:42 — The generated reference had a hand-kept list inside it
- 17 Sep 09:45 — Two tags, and the one that nearly said nothing
- 19 Sep 07:57 — A backend for the record turned out to be a view
- 20 Sep 07:01 — A document’s prose reaches luria through —body
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
requiredanddefaultare exclusive, as a config error. Drafted as two independent keys; a field with a default is never absent, sorequiredwould have been a key that says nothing, and a key that says nothing reads as though it did.- The table is
fields, notvocabularies. The draft proposed a fourth parallel table; review of the typed-edges work made the case thatstatusandtagsare vocabulary-backed fields already, so the normal form is onefieldstable keyed by the frontmatter field with a type key —vocabulary = "worlds"today, the other kinds whenrequiresandreferencesconsolidate. 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.tagswould have readtags.yamlas a closed vocabulary and quietly changed whattags:means. Refused at load. - The type is two optional attributes, not a sum. The draft wrote
holds: Any | Ref | Vocabulary. Building it,referenceandvocabularyas twoNone-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.
Publishing the examples, and the third hand-written link the fixer would have got right
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.
A working link to the wrong paper is invisible to every check that asks whether links work — and the first fix had the same shape as the bug
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:
-
--resolverecorded 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; asource-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_linerenders 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 readsmanyoff 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.
Every fixture passed and every rendered link was broken
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:
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.Contract.emptyflips once a scheme declaresstatus, so the record page stops saying “nothing beyond the standard fields”. A shipped-record test pins that.- 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.0 | fixed | |
|---|---|---|
**Status** bits | 692 | 346 |
Cited as … bits | 86 | 74 |
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.
Three copies of a region, and a link nobody derived
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:
| bit | where it comes from |
|---|---|
Status | this page’s frontmatter, but composed — Superseded — by X; note is three fields read as one reading |
Extends, Compared against | this page’s frontmatter, but as resolved links rather than bare codes |
Supersedes | another document’s superseded_by: |
Cited as source by | a 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.
The link that resolved to the wrong project
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.
A temporary code is a code to link, and was not one to check
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
relationstill 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:
fieldsis already documented as “the table a field’s shape and type are declared in”, soderiveis a key in a table that exists, and a derived field inheritsvocabularywith no plumbing. That pairing is where the eligibility check comes from: declarevocabulary = "tags"besidederive = "first:tags"and_vocabulary_violationsreports 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_editsays 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.mdparses 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 inrepair.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 isfirst_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}turns2022-05-01into2022. (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:
| placement | before |
|---|---|
| no directive | warns (the control) |
-block above the def | reported STALE |
-file at module top | acknowledged |
inactive-ok: inside the docstring | warns |
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-markdownruns 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.
Every fragment link in the record worked everywhere except where it is read
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.
The fix I recommended was not available
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, sogithub-sluggerkeeps it. My version stripped_as a markdown emphasis marker and turnedfail_onintofailon.- The order is
replace(regex, '').replace(/ /g, '-')— remove punctuation then turn each space into one hyphen. My version collapsed runs, solog — September, whose em-dash removal leaves two spaces, came outlog-septemberwhere the publisher writeslog--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 inputscomment_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 prefix | 11.0s → 10.3s |
| corpus scan memoized on a stat fingerprint | 10.4s → 7.9s |
| one render per lint, one scan per record | 7.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 proseFencing 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:
- The fixture codes leaked again — a fourth time this session. The new test file’s specimens went unacknowledged until the report said so.
- I cited a number that did not exist yet. I wrote the ADR’s own code as
ADR-104throughout 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. - 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. - I trusted a piped exit code.
luria lint 2>&1 | head -6; echo $?reportshead’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:
contract.values_of(field, ["a", "b"])returnsNone— “the shape contradicts the declaration” — because the field is declared scalar.effective_valuespasses theNonethrough.vocabularies._listingiterateseffective_values(...) or [], so every document contributes nothing.vocabularies.pagesreturns no pages, andluria indexwrites 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:
SECTIONSdecided which dataclasses got a section at all;PLAINspelled out which scalar keys existed underluria,paths,codeandlint.
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:
Adrdoes not keep the body. It reads it, takes the H1 as a fallback title, and drops it. The export callsread_documenta 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_byis the directive that acknowledges a finding. The first cut of thecitationstable 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 aWHEREclause 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.