Assembled from changelog.d/ fragments on a cadence — never hand-edited
(ADR-002).
2026-09-21
Added
luria new --body TEXThands over a document’s prose: it replaces the template’s body below the# CODE: titleheading of a scheme document, the placeholder paragraph of a journal entry, or the whole of a fragment. A draft’sbodykey does the same through--draft, so a tool that authored a whole document can file it without writing markdown itself (ADR-117). The heading stays derived fromtitle:— a body that opens with its own is dropped rather than doubled.
Added
luria exportwrites the record as a SQLite database — every document with its frontmatter and body, one row per field value, the typed edges, every citation with whether it resolves and whether a directive excuses it, and every journal entry — atbuild/record.sqliteor--out, rebuilt from scratch each run. A generated view for asking the record questions, never a source (ADR-116, #110).
Added
luria newaccepts--influenced_byon every scheme, written as the list of codes the index and the typed-edge module read. The field was standard frontmatter with no flag: it is not a contract field, so the scaffold refused it by name. A tool handing over a draft — strata-g’s canvas drafts an entry from the documents it was drawn from (dmarx/strata-g#813) — names those documents in exactly this field (#301).luria new --draft FILEfiles the draft object — or every draft in aluria-draftsdocument — a tool wrote, each into the kind itsschemenames, with the flags’ validation. It is the other half of the hand-over above: strata-g exports the entries drafted on its canvas in exactly this shape (#301).luria relate SOURCE FIELD TARGETwrites one relation into an existing document’s frontmatter —influenced_by, the successor field, or a declared reference field — with the flags’ checks: the target has to resolve, a typed field takes only its scheme, a relation already present is reported rather than duplicated, and a declared converse is left toluria repair.--draft FILEfiles a drafts document’srelationslist, the canvas’s hand-over for a relation drawn between two filed documents (#304).
Fixed
ADR-112v2 stops quoting the downstream lockfile’s size as numbers. It said 921 lines and 305titlesentries ofanthology-of-the-sota. The entry count was wrong when written — measured on a branch carrying two entries that record’smaindid not have — and both figures have drifted since. The argument never used them: one file, one entry per cited identifier, rewritten by every contribution that files a paper, which holds at any size. The figures stay in thehistory:note so the old numbers can be traced.
Added
luria.user_agent— what luria announces when it opens a socket (ADR-115). Defaults toluria/<version>: honest about the software, the waycurl/8.0is, and carrying no contact, because a URL or mailbox in a shipped default would route every user’s traffic to whoever maintains luria. Add your own contact here if you want the treatment some hosts reserve for callers who identify themselves — CrossRef’s polite pool wants a real address, and that address belongs to your record rather than to luria.
Fixed
- Luria sent
Python-urllib/3.11from every request, which names the language and nothing else and is the shape of traffic a metadata host rations first. All three call sites — the lint’s identifier check, remote discovery and the reachability probe — now go throughfetch.request()and carry the configured agent. They had drifted apart first: two passed a bare URL tourlopenand one built aRequestfor itsmethod, so a header added to the obvious place would have left a third of luria’s traffic anonymous. luria.__version__was a hand-written"0.1.0"while the package was at 0.28 — the exact driftpyproject.tomlderives the version from git tags to prevent, reintroduced two lines into the package it was protecting (#295). It reads installed distribution metadata now, and a test fails if anyone writes a literal back.
Changed
- New module
luria/fetch.py: the one place a request is built (DP-004). A fourth call site is nowrequest(url)rather than a header dict copied from somewhere.
Fixed
- A 406 from a metadata remote is now classified as
throttledrather thanunreachable(#292,ADR-114). arXiv returns 406 and 429 interchangeably for the same identifier seconds apart when it is shedding load, and the old classification meant such a refusal never tripped the circuit breaker inask— so a sustained throttle cost one socket per unverified identifier on every run, against a host already rationing. That is the case the breaker was built for and the one it sat out.
Changed
- The lint’s report follows from the status, so an identifier in this state
now reads
could not be checked — throttled — HTTP 406instead of— unreachable — HTTP 406. A host that answered and declined no longer reads as a host that was down.
Changed
- A chain’s page is organized by the invariant the chain declares. Where
chains.<name>.invariantis set, each value it finds becomes a section and the lines holding it in common are listed underneath; lines sharing nothing get aSharing no <field>section of their own. The declaration was already there and already checked — the view just did not use it, so a 43-line page was ordered by the component walk. SeeADR-113. - A line is listed under every value it shares rather than one chosen for it: the invariant is what a line is about, and a line can be about two things.
- Grouping is opt-in through the same key that opts into the check. A chain that declares no invariant renders exactly the page it rendered before.
Added
invariants.shared(docs, field)— one definition of what a set of documents holds in common, used by the report that finds unbound components and by the renderer that picks a heading. A view grouping by a rule the check did not use would be worse than no grouping (DP-004).
Changed
luria lintno longer writesremotes.lock.json. It still asks about identifiers the lockfile cannot answer and still reports a mismatch on the run that adds the citation — what moved is the write, not the check. The lint runs on every branch, so persisting there made the lockfile a file every contribution rewrites (DP-002). SeeADR-112.DP-002v3 — retitled from the symptom to the rule: one artifact, one writer, and the writer is wherever merges serialize. Versions 1 and 2 said to generate the shared artifact and not where, soADR-049(codes),ADR-068(views) and now the lockfile each answered that separately and none cited the principle.ADR-049andADR-068now do.
Added
- A
resolve:input on thegenerateaction, runningluria remotes --resolvebefore the views so the answers land in the same commit. Pass ittrueonly where merges serialize, for the same reason asconcretize:.
Added
- Two tags in this project’s own ADR vocabulary.
docsmarks a decision the documentation cites, so retiring one is a signal that a page has to change with it — 16 decisions carry it.load-bearingmarks a decision that fixes something an adopting record must write — a frontmatter field, a path on disk, the spelling of a code, directive syntax in prose — where reversing it invalidates records already filed rather than merely changing luria’s behaviour; 20 decisions carry it. They overlap on 7, which is the point: neither is a restatement of the other.
Fixed
luria siteno longer reports a link to a generated view as leaving the site when that view has not been rendered into the working tree. It asked the filesystem “is this published?”, and under ADR-068 a branch carries no views — so any contribution that ADDS one (a new tag value, a new scheme) made every link to it unplaced. The question is now answered from the views the record declares, which is what publishes them.
Fixed
- The configuration reference now documents the whole schema. It was generated
from the dataclasses and so its rows could never go stale, but two lists
inside the generator were hand-kept and both had:
chainsand seven nested tables —references,fields,field_groups, tag groups, vocabularies andrequired_when, which is whereunique,alert,converseandinvariantare declared — had no section at all, andlint.mute,lint.networkandinclude_recordshad no row. Sections now come from the dataclasses inconfig.pyand scalar keys fromDEFAULTS, so a table or key added to the schema appears on the page without anyone remembering to list it. - Every example in the configuration reference is YAML again. The config moved
from TOML to YAML in ADR-098 and the examples did not follow: most were
still
key = "value"bodies under a mangled table header, and all of them were fenced astoml. A reader copying one got something that does not parse. - The configuration reference no longer says there is no migration command;
luria migratehas shipped since it was written. luria.tomlin the scaffolded template’sCLAUDE.md, decision template and docs index is nowluria.yaml— three places every adopting project has been handed since the format changed.
Documentation
luria upgradeis in the CLI reference, with what each upgrade does and why every one of them is temporary by construction. It had never been documented.- The CLI reference’s account of
luria lintis current: seven warning classes it did not list (unresolved-citations,remote-drift,template-drift,broken-chains,one-sided-relations,spent-upgrades,unlinked-site), the violations raised by the constraint checks added since it was written, andlint.mute— the dial that decides whether a finding is heard, wherefail_ondecides what it costs. - Project memory and designing a record cover the constraints added since
they were written:
uniquefields, typed relations withconverseandinvariant, chains, and thealerta vocabulary or tag group prints with its own violation.
Fixed
code.globsin this repository’s own config now namesexamples/**/*.yaml, soluria concretizerewrites the example configs along with everything else. It did not, and ADR-111 landed on main as a liveADR-tmp92495in the knowledge-base example. Nothing complained, because a temporary code keeps resolving through the successor’sformerly:— the only symptom was a shipped example reading as though it had been authored mid-branch. The stale spelling is corrected here too.
Added
unique: trueon a plain field, and on a field group to say it of every field in the group.luria lintreports two documents in one scheme holding one value — the converse question no existing check asked, since every other check verifies that a pointer resolves and none asks whether two resolve to the same place (ADR-111, #165). A duplicate already retired naming its survivor is the resolution rather than the finding and is skipped, so a record that has answered this correctly needs no acknowledgement directive. Declaringuniqueover a field drawn from a closed vocabulary is refused at load: it would allow one document per term.
Fixed
- A vocabulary could not carry an
alertand ablurbat once. The nested-table discriminator (#279) tested against an inline set of keys that did not includealert(#273), so the first record to use both features was refused — with a message about a value namedvalues, describing a different fault entirely. The allowed keys are now read off aVocabularyTabledataclass that mirrors the table, so the discriminator, the refusal message and the metadata cannot drift apart again.
Changed
- A vocabulary’s
alertis declared on the set, in the centralvocabularies:table, besidelabelandblurb— not on the field that names it. The rule an alert explains is a fact about the vocabulary, and a record whose three schemes name one vocabulary was otherwise writing the same sentence three times, which is the drift ADR-098 centralised vocabularies to prevent. A tag group’salertis unchanged: a group is declared inline and has no central table to move to.
Changed
- A nested vocabulary’s values are declared under
terms:, notvalues:— a controlled vocabulary has terms, and the old spelling collided with the commonest name for a vocabulary’s own entries, which is what made a value namedvaluesplausible enough to need a refusal. Breaking for anything written against 0.25.0, which is the only release that carried the nested form; no alias is accepted, and the refusal message names the current spelling.
Added
-
A config object can say what it is.
labelandblurbon a vocabulary, a tag group, a plain field and a field group — the pair a relation has had since #254 — andtitleandblurbon a scheme, the pair a journal has had.Chaingains theblurbhalf of the one it already half-had. Which pair an object takes follows from what it is:labelfor a thing named inside a scheme,titlefor a thing that renders its own page (ADR-109, #279). -
A vocabulary describes the set, in the central table, so one that two schemes share is described once:
vocabularies: topics: label: Topics blurb: the primary axis of both indexes values: alpha: {label: Alpha, blurb: "..."}The flat form — every key a value — is unchanged, and is what a table without a
values:mapping still means. -
Three render paths, so none of it is inert: a new What each family is section in
docs/record.md; a field’s or group’s blurb appended to its contract line; and a vocabulary’s own description heading each of its value pages, above the value’s. A relation’sblurbrenders for the first time — it has been declarable since #254 and nothing printed it.
Fixed
- A flat vocabulary table containing a key named
valuesis refused, naming the fault, rather than read as a nested table — which would have emptied the vocabulary and reported that as no violations.
Added
- A derived field may hold a list.
derive = "{tags}"— one whole field, no index, no format spec — holds what its source holds, andmanysays so (#276). Previouslymanywas refused on any derivation.
Fixed
- A derivation reading a plural source without
manyis now refused, naming the source’s scheme and what goes wrong. It used to be accepted and silent: the field resolved to a list against a contract saying it held one,contract.values_ofread that as no values at all, and every page the scheme groups by that field stopped being written with nothing failing.
Changed
- The refusal of
manyon an indexed or text-bearing template ({tags[0]},LIT-{author}-{n}) says which shapes render one value, rather than claiming every derivation does.
Added
schemes.X.references.<field>.invariant— the field both ends of a relation must share a value in, declared on the relation rather than only on a chain that walks it. It holds with no chain declared, and it may cross schemes, which a chain may not (#272).
Fixed
- A chain whose
relationorsiblingpoints at another scheme is refused when the config is read, naming the key and where the assertion belongs. It used to raiseKeyErrorpart-way throughluria index, because the walker loads one scheme and the relation left it (#272).
Changed
- The unbound-lineage report names what declared each finding — a chain by
name, or a relation as
SCHEME.field— in place of theChaincolumn.
Added
-
A vocabulary and a tag group can each carry a
alert:, printed after their own violation (#273). A closed set’s message says what is allowed; only the record knows whether the list is finished or merely short, and until now it had nowhere to say so:fields: tags: vocabulary: topics closed: true alert: >- Closed so every tag is one somebody chose, not because the list is finished — add a value here rather than reaching for the nearest wrong one.renders as a continuation of the finding, indented under it:
record/practices.d/SOTA-085.md: `tags: kv-cache-paging` is not in the `topics` vocabulary (vocabulary 'topics') — the values are … ↳ Closed so every tag is one somebody chose, not because the list is finished — add a value here rather than reaching for the nearest wrong one.A
TagGrouptakes the same key and prints it on itsrequireandexcluded_byfindings. A group has exactly one rule, so one alert needs no per-rule spelling.Opt-in and inert when absent: a vocabulary or group without an alert produces byte-identical messages to before.
Added
-
A decision for where a directive stops being syntax.
SHAPED_REmatches a directive from its name to the em-dash, so the argument list is blanked before the reference scan and the reason after it is scanned like any other prose. That boundary is deliberate — naming a code in a directive is governing it, not citing it — and it is not visible from outside the parser.It produced the same bug twice in a week, both times while editing an acknowledgement to fix an acknowledgement: a governed code named in the reason becomes a citation that the annotation then excuses. The boundary stays; the rule that follows is now written where it can be cited rather than left in a devlog entry, which is for observations.
Rejected: blanking the whole comment a directive introduces — it would swallow the genuine citations authors put in reasons, and “the whole comment” is what
blocks()guesses at, so it would build a silencer on a guess and silence most reliably where the guess is worst.
Added
-
mention-ok:— a code that is named, not cited. Every other acknowledgement asserts something about a code’s state:inactive-oksays the document is not in force,unresolved-oksays the code resolves to nothing. That is why they retire correctly, and why neither fits a reference that claims nothing — a specimen quoted as evidence, prose about a code’s literal spelling, a demonstration of what a moved address looks like.Written as
unresolved-ok, those go stale the day somebody allocates that number, for a reason unrelated to why they were written — and take every other code in the same annotation with them. The DP scheme is at 17 and two acknowledgements name the next number today, so this was a scheduled failure, not a hypothesis.mention-ok:covers both findings and does not retire when the state changes. It counts as used while the code it names is cited in its scope, whatever the document’s state, and is still reported stale the one way that is about the annotation rather than the record: when nothing in scope names the code any more. Counted in the reports like every other acknowledgement.Prefer the reserved
FXprefix where you control the spelling — it needs no acknowledgement at all. This is for the mentions whose spelling is the point.
Fixed
- A DP code left an acknowledgement in
doc_refs.pyit never belonged in. The composed remote spelling on the wikilink line is blanked before the local scheme pattern reads it, so there was never a local reference to excuse. It looked otherwise only because the previous change named that code in the acknowledgement’s own reason text, and a continuation comment line is not directive-shaped — so the mention the annotation excused existed because the annotation explained itself. The devlog entry that drew the wrong conclusion from it is corrected in place.
Added
-
A stale annotation now reports what it cost.
ref_status.scandrops an annotation with aproblemwhole —usable = [a for a in anns if not a.problem]— so one stale code in a multi-code acknowledgement stops the others being excused too. The finding said what was wrong with the annotation and stopped there:doc_refs.py:38: annotation names DP-017, which does resolve hereIt now names the citations that lost their excuse with it:
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) unacknowledgedThe information was always in the record — those citations show up as unaccounted for in the reference-status report — but nothing tied them to the annotation that had stopped covering them, so the finding read as a dead comment to sweep up rather than four acknowledgements that had quietly lapsed. Scoped by path and by the directive’s own reach: an annotation is only answerable for what it actually covered.
Still a report and not a failure (ADR-035): which lapsed citation is a typo and which is deliberate is a judgement, and the remedy is an edit to the annotation, not to the code.
Fixed
-
Four deliberately-acknowledged citations had silently stopped being acknowledged.
luria/doc_refs.pycarried oneunresolved-ok-file:naming four illustrative codes — ADR-919, ADR-157, DP-017, DP-018. When a principle was later written at DP-017 that code began resolving, which gave the annotation aproblem, andref_status.scanonly excuses a citation with an annotation that has none:usable = [a for a in anns if not a.problem]So the whole directive went unusable, and ADR-919 at three sites and ADR-157 at one stopped being excused along with it. The reference-status report listed them as unaccounted for; nothing was wrong with them.
Dropping the one stale code from the list restores the other three. This is the failure
ref_status.annotationsnames in its own docstring — “an annotation that silently does nothing is worse than no annotation” — and thedirectives that no longer applyfinding is how it gets said out loud.
Removed
-
The three obsolete
unresolved-okacknowledgements of DP-017, inluria/doc_refs.py,luria/migrate.pyandrecord/decisions.d/ADR-046.md. Each was written when DP-017 named no document and each claimed the code resolves to nothing, which stopped being true. The mentions themselves are left exactly as they were: all of them are in backticks, so the fixer leaves them alone, and each is illustrative prose that reads the same either way.luria lintreports no directives that no longer apply, down from 3, and 2 codes unaccounted for, down from 3.
Changed
-
Eleven decisions that had shipped are now
Active. ADR-089, ADR-092, ADR-094, ADR-095, ADR-096, ADR-098, ADR-099, ADR-100, ADR-101, ADR-102 and ADR-103 were all stillProposed. Every one of them arrived in a commit that is onmain, every one is implemented, and not one carried astatus_noteor a line of prose saying why it was being held open.ADR-052 already says what should have happened: a contribution carrying a
Proposeddecision means the merge is the verdict, and merge flips the decision Active. That step was skipped eleven times running.luria lintnow reports 0 documents awaiting a decision, down from 11, and the README’sneeds decisionbadge reads 0.
Removed
- 53
inactive-okacknowledgements that existed only because those decisions wereProposed. Each said so in as many words — “Proposed. Every mention names it as the decision this file implements… the citation is to the reasoning, not a claim the decision is settled.” Once the decisions are in force the citations need no excuse, and luria reports the leftover annotations as no longer applying.
Added
- Citations for the six decisions nothing pointed at. ADR-089, ADR-092,
ADR-095, ADR-096, ADR-102 and ADR-103 were implemented and cited nowhere —
the code named the issue number and never the decision. They are now cited
where each is enacted:
derive.pyandcontract.pyfor the read-only rule and the reference hop,directives.pyanddocs/directives.mdforuntil <date>,sources.pyfor the rate-limit answer,comment_carry.pyandupgrade.pyfor the comment crossing, andadr_index.pyandconfig.pyfor the one reader and the one walk — each with the test that covers it.
Changed
-
luria lintrenders the view tree once instead of twice — 7.8s to 5.8s over this repository, byte-identical output.adr_index.outputs()renders every view of every scheme, journal and report, then again for each nested record (ADR-078); it is the most expensive thing the lint does. Two checks need it —check_view_dirscompares the committed tree against it, andcheck_anchorsreads links out of the same pages — and each rendered it for itself.Both have taken a
renderedargument since they were written, and nothing had ever passed one.runnow renders once and hands the same dict to both. -
Each record’s reports scan its corpus once, not twice.
reports.reference_statusreaddocs = ref_status.load_docs()and then asked forscan(docs=docs)— which is exactly whatscan()fills in when handed nothing. Restating the default changed no result and only put the call outside the corpus-scan cache, so every record scanned itself once for its reference-status table and again for its pending-decisions table.Together these take one lint from 26 corpus scans to 10: one per record, plus the badges and the lint’s own status section.
Changed
-
luria lintover this repository went from 33 seconds to 10.6, with byte-identical output. Two scans underdirectives._parseare pure functions of(path, text)and neither was cached:blockswalks an AST to find the docstring spans a-blockdirective must treat as atomic, andcomment_fragmentsruns the tokenizer over a Python source. They are not reached once per file —_parsere-derives both on every directive lookup, and the lookups are per check rather than per document. One lint called each 11,142 times over 451 distinct inputs, about 25 readings of every file, and spent 30 of its 33 seconds inside them.Both are now memoized on the text itself rather than on a stat. That is what makes this a cache and not a staleness bug waiting for
field_editandrepairto rewrite a document mid-run: the caller hands the content in, so new content is simply a new key and nothing has to remember to drop anything. -
The same lint went from 11.0 seconds to 10.3 on a second pass.
Scheme.patternandScheme.temp_patternwere properties that rebuilt an f-string and calledre.compileon every access, andref_status.scanreads both once per line, per scheme, per file — 911,410 and 912,206 accesses in one run.rememoizes internally, so this was never 1.8M compilations and the win is proportionate: the cost removed is the string interpolation and the cache probe, on the lint’s hottest loop.Compiled once per prefix in a module-level
lru_cacherather than on the instance.functools.cached_propertydoes work on a frozen dataclass — it writes straight into__dict__— but on Python 3.11, which this package supports, it takes a per-attribute lock, and this is read fromparallel.pmap’s thread pool. The key is the prefix rather than the scheme becauseSchemeis frozen but not hashable: it carries dict fields, so anlru_cacheover the instance would have raised on first call. -
And from 10.4 seconds to 7.9 on a third pass —
33.1s to 7.9s, 4.2×, across the three changes together.ref_status.scan()is called 36 times in one lint and 18 of those pass no arguments: identical full-corpus scans, fanned out throughreports.outputsandadr_pending.pending, each re-reading and re-regexing every file in the record.The no-argument call is now served from a cache keyed on a
(path, mtime_ns, size)fingerprint of the scanned set. Unlike the directive scans this one opens the files itself, so there is no content handed in to key on and the stamp has to be the filesystem’s — which is what keepsrepair,field_editandmigrateable to rewrite a document mid-run and read it back. A call naming its ownfilesordocsdescribes a corpus the fingerprint does not, and is computed every time.
Fixed
-
One reader for a document.
read_documentcaches a parse and expires it when the file’s mtime moves — the bargain that letsfield_editandrepairwrite mid-run and read back. Eleven call sites composedparse_frontmatter(path.read_text(...))and so opened documents for themselves, outside that bargain:forget_documents()cleared a cache half the readers were not using, and a second reader can see a different revision than every other caller in one run. All eleven now askread_document, and a test over the sources keeps the shape closed. -
One walk for a scheme directory.
Scheme.documents()globbed and sorted on every call, 24,960 times per lint;temp_documents()walked the same directory again. Both now read one listing cached on the directory’s(mtime, size)— the bargainnumber_ofalready takes, a directory up, and whose docstring already said “documents()runs on every lint, index and link pass”.Measured on two records, output compared rather than assumed:
anthology-of-the-sotalint 127.6s → 20.4s, byte-identical output. This record is unchanged at ~29s, because its lint spends its time intokenizeandastscanning its own Python sources — a different bottleneck, untouched.
Fixed
-
A comment naming a key that already carries one is no longer written twice.
yaml_set_comment_before_after_keyappends to whatever a key already holds; #262 assumed it replaced, and joined the blocks itself to stop the second erasing the first. Nothing was erased, so the join wrote the first block again — visible in the config this was written for, where the eight-line note aboveprimary_topic’stagsappeared twice.Each block is written on its own now, separated by a bare
#.The completeness property from #262 passed straight through this: it asks whether every source line is present, and a duplicated line is present. Counting is what catches it, so the property is now stated over multiplicities — no line appears more often than it does in the sources.
Fixed
-
The comment carry keeps every line, and puts each where it can be read. The first pass at carrying config comments (#261) left three gaps, all found by asserting the property — every source comment line appears in the result — rather than by checking examples.
A vocabulary file’s prose was emitted at the column it was written at, which is 0, because the file is its own document. Inlined under
vocabularies:it landed flush left inside an indented mapping, documenting nothing a reader could see. Vocabulary comments are now recovered as text and attached at the depth they end up at, the same way every other block is.Two blocks separated by a blank line merged into one and attached to whatever followed — so a trailing comment on
[luria.site]was emitted abovelint:. A blank line now ends a block, and a block that a blank line separates from the next key is read as a trailing comment on the table it sits inside.Prose after a vocabulary’s last entry was dropped outright, and a comment inside a multi-line value (
tags = [..., # why these two, ...]) was skipped silently — the case ADR-102 said would be reported. The first joins the vocabulary’s header; the second attaches to the key whose value it is inside, and_carrynow appends where two blocks name one key instead of letting the second erase the first.Measured on the config this was written for: 369 of 369 comment lines carried, against 352 before.
Changed
luria/toml_comments.pyisluria/comment_carry.py. It reads the YAML vocabulary files as well as the TOML, and a module named for one format was going to be wrong for as long as it existed.
Fixed
-
luria upgrade yamlno longer drops what a project wrote about its own config. The crossing parsed TOML withtomlliband wrote the values — correct, because a regex in auiddoes not survive a byte copy — buttomllibnever sees comments, so every one of them went, and the command reported only what it had folded. Measured: 85 comment lines lost when this record crossed, 273 waiting to be lost inanthology-of-the-sota.Comments are now attached to the key they documented. The scheme vocabulary files are round-tripped rather than re-parsed, so their prose survives too. A comment whose key does not exist on the far side —
tags,statusesandtag_groupseach split into a vocabulary plus a field — follows the field where that mapping is unambiguous, and is otherwise printed in full, because a count says something was lost without saying what.A dotted key (
uris.title = "...") is read as the path it is rather than a key with a dot in its name, and a paragraph break inside a block stays inside it.
Changed
-
The site builds on Quartz 5.
luria sitewrites onequartz.config.yamlwhere it used to writequartz.config.tsandquartz.layout.ts; the site action pins a v5 commit.Two things paid for the upgrade. A hover over a link in a book’s contents list previewed the wrong section — a Quartz bug (
showPopoverreadspopoverInnerbefore itsconst, and popovers are cached per pathname, so every hover after the first threw before scrolling) that v5 fixes and that nothing in luria could reach. And v5 takes each component’s position from that component’s own config entry, so the generatedquartz.layout.ts— 90 lines of TSX whose only purpose was to move the graph out of the right rail (#71) — collapses to two keys:- source: "@quartz-community/graph" layout: {position: beforeBody, priority: 40}Verified against a real build of this record rather than a fixture: 317 pages, driven in a browser. Every contents-list hover lands on its own heading, the graph renders in the content column, and internal links resolve. No address changes — paths, anchors and slugs are as they were.
ADR-042 rejected v5 because v5.0.0 could not build at all, and said to revisit; that is amended rather than superseded, since what it decided — publish as a Quartz vault, paths preserved, sources withheld — is unchanged.
Fixed
-
A journal entry had two addresses, and the one its own contents list used was not the one the page offers. Luria wrote a durable
<a id="{timestamp}">before each entry and linked that; the publisher put its ¶ anchor and its sidebar Table of Contents on the heading. Both resolve — but the address a reader copies off the page was not the address the page’s own contents list used, which is how this got reported as a bug by somebody reading the two.A book’s contents list and a journal’s index now link the heading. The timestamp anchor stays, unlinked: a citation written by hand still has an address that does not move when a title is reworded.
Added
-
luria/slugs.py— the anchor a heading answers to. Linking a heading means producing the id the publisher will assign, which isgithub-slugger, behind bothrehype-slugon a Quartz site and GitHub’s own rendering. Validated against 288 headings across five pages this record had actually published, which is where the two bugs in the first version came from:_is a word character (fail_on, notfailon), and each space becomes one hyphen after punctuation is removed (an em-dash between spaces leaves--). -
luria lintreports a fragment that reaches nothing, not only one reachable by<a name=>. Previously ruled out because a heading’s anchor is the publisher’s and a check that guessed at it would report links that work; owning a slugger removed the objection, and the check is now also the guard on the generator — it reads the page as the generator renders it, so a drift between luria’s slugger and the publisher’s is a lint failure rather than a link that quietly goes nowhere. -
unresolved-citations:cite = "page"in a record that does not publish those pages. A citation’s durable address is either an anchor or a page, andcitechooses; choosing the page and withholding it throughsite.excludeleaves every citation of that scheme’s codes pointing out of the site. A warning — a record may publish a subset deliberately — and failable by naming it inlint.fail_on.
Fixed
-
Every fragment link in a published record pointed at the top of the page it named. The generator anchored a journal entry and an assembled document with
<a name="x"></a>. Anameis reached by a real navigation and nothing else, so those links resolved in the repository, on GitHub and in an editor preview — and not on the site the record publishes to, whose router scrolls withgetElementById. 89 of the 100 fragment links in this repository resolved that way and no other: every devlog entry link on every index, and every citation of a design principle.Both emitters write
idnow. One attribute, and strictly a widening —idis reachable everywherenamewas.luria indexrepairs an existing record’s views in one pass; nothing in a source has to change.
Added
-
luria lintchecks that a fragment link’s target can be reached. A link whose target answers to it by<a name=>alone is a finding, andluria link --fixrewrites the anchor. A view’s is the generator’s to fix, so a finding about one saysluria indexinstead — an edit written into a view is erased by the next build.This is the one reference rule that reads generated pages — but the RENDER, never the committed copy. A journal index links into a journal book and both are generated, so a source-only scan cannot see the defect it exists for; and a branch carries the default branch’s views, so a scan of what is on disk fails every pull request that touches an anchor and names a repair the author is not allowed to make. Handed
adr_index.outputs(), the check asks what this source tree will produce, which has the same answer on a branch and on main.
Changed
- ADR-094 is corrected in place and versioned. Its measurement held —
330 links, none of which resolved — and its stated cause did not: Quartz
does not drop the
<a>element, and never did. The alternative it rejected on that basis, “emit anchors that survive the publisher”, was the right answer and one attribute wide.citekeeps its other reason, which was always the real one.
2026-09-14
Removed
[luria.site] graph,graph_heightandgraph_depthare gone, having never done anything. They reachedmainas schema without an implementation: documented in the configuration reference, accepted inluria.toml, parsed into aPath, and read by nothing. A project that setgraphgot its Quartz local graph and no explanation. They return with the code that implements them.
Added
- A test that a
[luria.site]setting is read by something. A key in the schema publishes itself into the generated reference, so an unimplemented one looks exactly like a working feature from the outside.
Added
-
A directive can carry a deadline —
until <YYYY-MM-DD>(#58).<!-- inactive-ok: ADR-028 until 2026-10-01 — revisit when the API settles -->After that date
luriabehaves as if the directive were never written: the check it silenced starts reporting again. The date is inclusive — good on the 1st, gone on the 2nd.An acknowledgement is a promise about the future, and some of those promises have a horizon. Without one, “circle back to this” becomes “forever” silently, which is the failure directives exist to prevent, one level up.
It works on every directive, not just
inactive-ok:untilis parsed by the same parser that reads the name and the scope suffix, so it is part of the shape rather than a feature of one word. See ADR-095. -
luria lintreports what expired, as its ownexpired-directivesfinding — the file, the directive, the date and the author’s own reason. Separate from “no longer apply”, because they are different facts: a stale acknowledgement means the subject moved under it, an expired one ran out of the time its author gave it. Inert must not mean invisible. -
A date
luriacannot read is reported, and the directive stays live.until nextweekis a typo; dropping a suppression over one would break a build for a reason the message would not explain. What must not happen is a typo quietly meaning “forever”.
Added
-
A document-rendered scheme’s sources are published as pages.
record/principles.d/DP-004.mdis served at/record/principles.d/DP-004, alias/DP-004, exactly like a decision.publishable()excluded them by a derived rule — “are this file’s links spelled for somewhere else?” — which a design principle answered yes to for the same reason a changelog fragment does. It is nothing like one: numbered, titled, statused, versioned, cited by code. The rule now says what it meant — a fragment is not published, a document rendered as a section of one is — and the assembled view is still published alongside. On this record, 281 → 307 staged pages.Their links are re-spelled on the way out, not in the repository: a principle’s source writes
../record/decisions.d/ADR-006.md, correct fromdocs/and wrong from its own page, so staging re-points each target as it writes. -
[luria.schemes.X] cite— where a citation of one of this scheme’s codes points."page"for the cited document’s own file,"view"for an anchor in the assembled view. Unset means what the scheme already does, so nothing changes until a project sets it. An unknown value, or an explicit"view"on a scheme that assembles no view, is refused by name. -
luria repairmoves links a record already wrote, not only bare references. Changingcitegoverns every citation written from then on and nothing already on disk — those are plain markdown links, which the linkifier has no reason to touch. The link text is left exactly as written, a link with no fragment is left alone, and an anchor naming no document is left alone rather than swapped for a dead file.
Fixed
-
A citation of a principle now goes somewhere. Every one resolved to
docs/design-principles.md#dp-N, and those anchors are<a name="dp-3"></a>— raw HTML that Quartz’s markdown pipeline drops, slugifying each heading’s own text instead. Measured on a real v4.5.2 build: 330 links across 81 pages, none of which resolved, while the same links worked in the repository, which is why no lint had ever mentioned them.This record now sets
cite = "page";luria repairrewrote 98 files. Re-measured on the same build: 330 broken → 0, and links to a principle’s page 85 → 404, all resolving. See ADR-094.
Changed
-
The config is
luria.yaml. One format for a system that spoke three — TOML for the config, YAML for the vocabularies, JSON for the lockfile. TOML is gone rather than deprecated.luria upgrade yamlconverts a record: it parses withtomlliband re-encodes rather than moving bytes, which is what makes a regex in auidsurvive, and it leaves the TOML in place for you to read the result against. -
Vocabularies are declared once, under
vocabularies:, and named.vocabularies: statuses: Active: {label: Current, blurb: in force} schemes: ADR: fields: {status: {vocabulary: statuses}} DP: fields: {status: {vocabulary: statuses}} # said oncestatusis a field like any other, so that is the one place its vocabulary is named — there is noschemes.X.statuses:beside it, and writing one is a config error that says where it goes.active:stays scheme-level: WHICH word means in force is the privileged part, and it names a word rather than a vocabulary.A scheme could only point at a file beside its own records, so a vocabulary two schemes share had to be two files. Across the two records running on luria that was four byte-identical copies of one
statuses.yaml, and in the consuming record a topic vocabulary its own ADR decided two schemes share, sitting on disk as two files whose blurbs differed in ten of thirteen entries.Scheme.tags_yaml/.statuses_yamlbecome.tags/.statuses;Vocabulary.filebecomes.values_by_name. -
tagsis a field a scheme declares, and the axis is named. It was the last thing the code knew by name. A vocabulary can now be OPEN (closed: false— the declared values give order, label and blurb; a new one is still just an edit to a document), andschemes.X.tag_groupsmoves tofields.<field>.groups, where a group constrains the field it is declared under. What is left is one key:schemes: SCENE: axis: worlds # which field heads this scheme's index fields: worlds: {vocabulary: worlds, many: true, closed: false}A scheme naming no axis has no taxonomy and renders none — which the old code could not express, since every scheme had
tagswhether it wanted them or not.luria upgrade yamlwrites the axis, the field and the moved groups for you. -
One renderer for every field a view groups by.
tag_order,render_categoriesandrender_tag_pageare gone;vocabularies.pyrenders the axis with everything else. They had drifted in three places — the label fallback, the blurb, and whether a value the vocabulary does not declare appears at all — and withtagsa declared field they wrote into the same directory, so they could not both stay. What is left of the difference is a shape the scheme chooses: the axis lists its documents because it is the browsing surface; every other field is a row of chips, because the value’s own page already holds the table.Published pages change once, and no path moves. A value page’s heading reads
# ADRs with \tags` `record`rather than# ADRs tagged `record`, its blurb carries the label, and a declared value nobody uses now gets a row reading(0)` and a page — which the axis omitted and every other field already showed. -
omegaconf types and merges the config.
OmegaConf.mergereplaces the hand-rolled_mergeoverDEFAULTS. The cross-field checks stay where they were: a structured config validates the shape of a value, not a relationship between two, and luria’s errors say more than a schema complaint would. -
A relation carries
labelandblurb(#254) — the two keys a vocabulary value already had. What a relation means lived in a config comment, which nothing could render, quote in a finding, or scaffold from. -
labelhas one fallback instead of three. It wastag.title()in the tag pages,""in the status legend and the raw value in the vocabulary pages, so a scheme declaring none got a title-cased heading and an empty legend cell.vocabularies.label_ofis the one answer.
Fixed
-
The three commands that rewrite your config no longer edit it as text.
luria init,luria upgradeandluria migratego throughluria/yaml_edit.py— ruamel in round-trip mode, so an edit is addressed by path and the comments you wrote survive it. Each of the three had the same bug in a different place, and all of them were silent:luria migrate’srename_schemerenamed nothing under YAML. It string-replaced a TOML section header, which a YAML key is not, andFXL:appears underschemes:and again under everyremotes.<R>.schemes:— a sweep cannot tell them apart, and a parser can.config_paths_passswept paths belonging to other projects: it found the enclosing section with a TOML-header regex, so under YAML nothing was ever frozen and an unclaimed remote’s owndocument:path was rewritten with everyone else’s. Which lines are whose now comes from the parser.luria upgrade statuseswrote dotted keys:schemes.VP.statuses: xappended to a YAML document is a key literally calledschemes.VP.statuses. It now writes into each scheme’s block, and declares the vocabulary it names.luria init --schemes/--journalsappended an indented block to the end of the file. YAML nests by indentation, so the block joined whichever top-level key happened to be last — a new scheme could land injournals:and every reader would agree it was one.
-
One emitter. The TOML converter writes through
yaml_edittoo, so a converted config starts in the shape every later edit produces and a one-key change never arrives as a whole-file reflow. -
A tag group on any field but
tagspassed every document. The check readmeta["tags"]whatever the group was declared about, so the rule looked enforced and constrained nothing. A group names its field now. -
Axis values are no longer lower-cased. That was a
tagsconvention the code applied to every value, and it disagreed with the vocabulary check beside it, which has always compared the value as written. An axis whose values areAandBis the case it broke.
Documentation
- Every example in
docs/and in theconfig.pydocstrings thatdocs/configuration.mdis generated from is YAML, and[luria.x.y]is spelledx.ythroughout.
Added
-
A converse may live on another scheme.
SOTA.introduced_byholdsLITcodes, so its converseintroducesis a field onLITholdingSOTAcodes — andluria link --fixcompletes the pair in both directions, the same as it always has within one scheme:[luria.schemes.SOTA.references] introduced_by = { scheme = "LIT", many = true, converse = "introduces" } [luria.schemes.LIT.references] introduces = { scheme = "SOTA", many = true, converse = "introduced_by" }The converse is looked up on the scheme whose codes the field holds, and must point back at the declaring one. Same-scheme pairs are the case where those coincide, so nothing about them changes. See
ADR-097.The limitation this lifts was carried in
luria.toml’s own comment onNOTE.paper: “There is no converse on the LIT side, because Luria’s converse must be same-scheme and this crosses.”
Changed
relations.pairs()returns(scheme, field, converse, converse scheme). A signature change, internal — the one caller outside the module usesconverse_of, which is unchanged — andrelations.converse_scheme_ofis new beside it.
Changed
-
A rate limit is no longer retried.
_fetchmade three attempts with a 3s→6s backoff, so under a sustained throttle every identifier paid 9 seconds to reach thethrottledit already had after the first request. It now asks once. SeeADR-096. -
Retry-Afteris honoured instead of formatted. The header was parsed and then spent only on the message string — the one piece of scheduling information a throttle carries, thrown away. A wait at or under 10s is now waited out, once; a longer one is reported rather than slept through. -
A remote that refuses is not asked again for the rest of the run. The breaker is in
ask(), soluria lintinherits it: under a throttle it now reportscould not be checkedafter one round trip per remote, instead of one per unknown citation. -
luria remotes --resolvesurvives being cut off. It asks about unsettled identifiers first — an identifier the lockfile has no entry for is one no run has ever settled — shuffled within each group so a single always-erroring identifier cannot head-block the queue, and it checkpoints the lockfile as it goes. A run that was throttled or killed used to write nothing at all, and walked the same order every time, so the same tail went unresolved run after run.
Fixed
- The module docstring said
luria lintdoes not open sockets. True when it was written; untrue since[luria.lint] networkarrived with"auto"as its default.
Changed
-
A document’s frontmatter is parsed once per revision, not once per reader. Every
Adrconstruction parsed the file it names, and schemes are loaded once per consumer rather than once per run: oneluria lintover a 729-document record made 16,872 frontmatter parses, about 23 per document.read_document()caches on(mtime_ns, size)— the bargain_NUMBER_CACHEalready takes, and the reason it is safe whenfield_editandrepairwrite documents mid-run and read them back.Measured on that record: 95s → 78s, output byte-identical.
-
yaml.CSafeLoaderwhere the environment has libyaml.yaml.safe_loadsilently takes the pure-Python parser, which is what runs in a wheel built without it — the case above, and why the parse was worth caching at all.
Fixed
luria lintnow rejects HTML comments (<!-- ... -->) and duplicate mapping keys in YAML frontmatter. PyYAML accepts both (treating<!--as a key and keeping the last of two identical keys); Quartz and other strict parsers reject the file, so a document that looked clean locally could fail the downstream Pages build (#164). Use a#comment instead.
Changed
- Seven decisions move from
ProposedtoActive: ADR-084 (a relation declares its converse), ADR-085 (status is an ordinary controlled vocabulary), ADR-086 (a marked region carries a derived fact in the README), ADR-087 (identity is a field), ADR-088 (a derived alias is kept, a former spelling rewritten), ADR-090 (the block below a directive is a syntactic unit) and ADR-091 (a stack of pull requests lands from the tip). Each is implemented and in use; the status was the scaffold default nobody had gone back to change. ADR-089 and ADR-092 stayProposed.
Added
- The
FXprefix is a reserved namespace: no project declares a scheme whose prefix begins with it.FXLis the local fixture scheme by convention,FXMthe second one where a fixture needs two prefixes at once, andluria lintrefuses a declared scheme in the namespace so the guarantee cannot be lost by accident (ADR-093, #231). The match is on the leading prefix —AFXis nobody’s business but yours.
Removed
- The three
unlinted-file:opt-outs ontests/test_number.py,tests/test_alias_inference.pyandtests/test_migrations.py. Their specimen codes now come fromFXL/FXM, so 1,038 lines of test file return to reference checking and the reference report’s opt-out section is empty.
Changed
-
The pending-decisions report groups its table by scheme (
#230). AProposeddecision and aProposedpractice are open questions for different readers, and an adopting record’s report intermixed them — 33 practices, two notes and two decisions in one table sorted only by date.Each scheme that has an undecided document now gets its own section, in the order
luria.tomldeclares the schemes. Declaration order rather than alphabetical, for the reasontags.yamlorders topics: which family a reader meets first is the project’s statement about itself, not something to sort.Rendering only.
adr_pending.pending()already spanned every scheme deliberately — “a report that covered one scheme would go quietly blind the day a project configured a second” — and it is unchanged. The badge and the lint headline read the collector, never the rendered file, so the counts they publish do not move; a test pins that.A record with one family renders exactly as before: a heading naming the only scheme a project has is nesting for nothing, and luria’s own record is one of those. A declared scheme with nothing pending gets no empty section.
Considered and not taken: one file per scheme. It would break every existing link to the report, multiply the files a reader has to visit, and lose the one place that can state a cross-scheme total — which is what the badge publishes.
Added
-
derivecan follow a reference (#233). A second declaration says which document the template renders against:[luria.schemes.SOTA.fields.published] derive = "{published}" from = "source[0]"fromnames a reference field the scheme declares, indexed when that field holds several. So a fact one scheme owns — a paper’s publication date, on the note that is about that paper — can be read by the documents that need it instead of copied into each of them.The template is unchanged:
{published}still means what it means everywhere, and only the values it renders against move.derivepromised one template language rather than a second grammar per feature, and this keeps that promise by adding a second variable source.One hop, against written frontmatter. The target’s own derivations are not resolved first, so a cycle is impossible by construction rather than by detection and no evaluation order across schemes has to exist. Derivations do not chain;
ADR-092records why that is deferred rather than refused.The target field must be declared, so a typo is a load error rather than a field that resolves to nothing on every document. Refused at load, with the reason named: a
fromthat names no reference, a plural reference with no index, a scalar one with an index, and a template reading a field the target scheme cannot hold.
Fixed
- A record page for a followed derivation now says which document it reads.
It rendered the template alone —
{published}— which does not say whose.
Found
concretizerewrites references, not values, and the lint had no rule relating one document’s field to another’s. That is why a copied field could disagree with its original indefinitely with every mechanical check green. Measured in the motivating record: eight practices carrying the publication date of a source they had stopped citing, two of them left behind by passes whose entire purpose was correcting the source list.
Added
-
[luria.lint] mute— warning classes suppressed from the report entirely.fail_onchanges a finding’s consequence; this removes it from the output.The two are separate dials, and naming a class in both is a configuration error rather than a precedence question: a project cannot both enforce a check and refuse to hear it, and guessing which it meant would make one of the settings a lie. A conflicting mute never hides an enforced class.
Mutable classes are every failable one plus
acknowledged-uniformity, which is not failable — a project cannot promote its own acknowledgement to a failure — and is exactly the standing note a project may not want repeated on every run. No class is exempt, which is the symmetric choice:fail_onalready lets a project make any class fatal, and a tool that decides which of a project’s own findings it may stop reading is asserting an authority it has not earned.luria reportsstill renders the full accounting either way — muting changes what the command prints, not what the record says.Like
fail_on, amutenaming a class that does not exist is reported rather than silently suppressing nothing (DP-1).
Added
- Optional
luria[syntax]extra: with it installed,-blockscope reads the block below a directive from a tree-sitter grammar — the smallest syntactic unit that starts where the block’s content starts and holds the whole block — instead of guessing at a run of non-blank lines. The same rule in every language the grammar pack supports, and it can only widen what a directive already governed. Without the extra nothing changes;LURIA_TREE_SITTER=0turns it off without uninstalling it. - With the extra, comments in non-Python source come from the grammar rather
than a marker scan, so a
#inside a shell string or a quoted YAML scalar is no longer read as a comment site.
Documentation
docs/directives.mddescribes the extra, what-blockbecomes with it, and the docstring case that motivated it.
Fixed
-
A
-blockdirective above a Python definition now governs the whole docstring (#222). A docstring is one syntactic unit however many paragraphs it holds, and is treated as atomic exactly the way a fenced code block already was:# inactive-ok-block: [ADR-012](record/decisions.d/ADR-012.md) — the decision this function replaced def apply(): '''First paragraph. A later paragraph citing [ADR-012](record/decisions.d/ADR-012.md).'''Before, block scope stopped at the docstring’s first blank line, so a citation in a later paragraph could only be acknowledged with
-file— far too blunt for one sentence of prose that happens to name a code. A directive written inside a docstring still does not fire: a docstring is not a comment.
Changed
-
deriveis astr.formattemplate rather than atake:fieldgrammar (#219):derive = "{tags[0]}"where it used to be"first:tags". One template vocabulary now covers a remote’s URIs, a scheme’salias, and a derived field — the same spelling means the same thing in all three, andderiveandaliasrender through one function.A template that is exactly one replacement field returns the value, not its rendering.
{versions[0]}yields the number rather than"3", which keeps a derived field identical to the vocabulary member a check compares it against. Literal text around the field —"LIT-{first_author}"— means a string was intended, so a string comes back.last:has no template spelling:str.formatreads{tags[-1]}as a string key, not a negative index. A record using it must name the position ({tags[1]}); nothing shipped with one, and adding an extension to keep it would have re-opened the second grammar this change closes.The scalar-rename refusal narrows to where it still applies: a template that builds from single-valued fields is fine, and only a bare
"{year}"under a second name is refused.
Added
-
A scheme can render a second spelling for its documents from their own frontmatter (
#219):[luria.schemes.LIT] alias = "LIT-{first_author}-{published:.4}-{number}"LIT-Dao-2022-074then resolves whereverLIT-074does, andluria link --fixleaves it written — the opposite of aformerly:spelling, which it rewrites away. This recovers the interpretable identifier a record gives up when it adopts sequential codes.A collision is a violation; including
{number}makes one impossible. A template that does not start with the scheme’s prefix is refused where the config is read, since no reference scanner would find it. -
luria repairretires a superseded alias intoformerly:when the field its template reads is edited, so a citation written the old way keeps resolving. The previous spelling is rendered from the last commit rather than stored: the record’s history is git’s, and a stored copy per revision would be the ledger this design exists to avoid.
Changed
- Alias resolution reads a cached map instead of scanning every document’s frontmatter per lookup. The old path was right while the only aliases were temporary codes nobody cites on purpose; a spelling people choose to write makes it hot.
Added
-
A scheme document carries
number:, and its filename is a projection of it (#219) — the model journals have always used forcreated:, applied to schemes.luria lintreports a field and a filename that disagree;luria repairwrites the field from the path where it is absent, so a record migrates without anyone typing a number;luria newandluria concretizewrite it for new documents.Nothing changes for a record that has not migrated: the filename is still read when the field is absent, which is what lets the field arrive without an upgrade to run.
Added
-
A field can be derived from another field (
#216):[luria.schemes.SOTA.fields.primary_topic] derive = "first:tags" vocabulary = "tags"primary_topicis then an ordinary field everywhere a field is read — the invariant a chain asserts, a facet, a report column — and is never written down. Writing one is a finding, unlike a derived URI, which an explicit template may override.firstandlastare the takes; the source must hold a list, sincefirst:of one value is that value.Pairing
derivewithvocabularyis what gets “the first tag must be a real topic” out of the vocabulary check already written, rather than out of a second check to keep in step with it.
Added
-
A chain can declare
invariant, naming the field its relations assert the documents share. Two findings follow, both reported rather than fatal: an unbound relation, where two documents joined directly hold no value in common, and an unbound line, where a whole sequence does — which can happen while every individual step is expressed. They land in a newunbound-lineage.mdreport.Opt-in, and the default matters: most relations assert no shared field. One joining a claim to its evidence crosses two vocabularies on purpose, and checking it would report every such citation as a defect.
Added
- A chain’s
relationtakes one field or several:relation = ["extends", "corrects"]walks both as one spine, the wayfacet_byalready takes either shape. A record can then carry succession with a sign — “builds on the parent” and “exists because the parent is broken” are both steps in one line, and one relation renders them identically. The sign is which field the code sits in, so no reference entry gains an attribute and nothing has to qualify a relation. Closes #211.
Changed
- The page header and the cycle finding name every spine relation rather than
one — “walked from
extends:andcorrects:” — through a single spelling, since they name the same thing and drifting apart would be a small lie in two voices. - A chain naming an undeclared spine relation now says which one. The message
used to interpolate the whole value, so a bad entry in a list read as
`relation = "['extends', 'corrects']"` is not a reference.
Documentation
- A design principle states why a stale spelling in record prose is rewritten
rather than acknowledged: git is the record’s ledger, so a document’s job is
to be true now, and what a rewrite has to preserve is the claim rather than
the characters. Names the corollary that makes
legacy-spellingsawkward — substitution is correct where a code is used and wrong where it is mentioned, and the mention case is fixed by writing the sentence properly, not by keeping the wrong one on file. Closes #201, which asked for the acknowledgement directive instead.
Fixed
-
A chain page nested each step under whichever step at the parent depth happened to precede it, rather than under the parent it actually extends. The page carries nesting as indentation and
lines_ofordered the spine by(depth, code), so the two agreed only by luck — for a single-root line with one parent per step, which is every chain that existed when the feature shipped.With two roots in one group, or one step with two parents, the page asserted a descent nobody declared, silently and in a generated file. Found in
anthology-of-the-sota: Kimi Linear rendered as a descendant of Mamba-3, because Gated DeltaNet has two parents and the sort put the siblings in an order the indentation then misread.Steps are now emitted depth-first from each root, each directly after the parent it nests under — the deepest of its parents, so a step nests under the most specific thing it extends.
Added
- A step with more than one parent names the others:
— also extends LIT-195. A tree layout can draw one parent per node, and the edges it cannot draw are still declared, so the page says them rather than dropping them.
2026-09-07
Changed
- A remote’s code now relates to a set of named URIs rendered through one
template vocabulary (ADR-067):
urlandpin_urlare the short spellings ofuris.readanduris.bytes, a[luria.remotes.X.uris]table names further relations, and {filename} is an ordinary template variable fed by the discovered lockfile map, authority semantics included — so a GitLab-style raw scheme with slug filenames is two template lines. The GitHub blob→raw rebase regex is gone, replaced by shipped default templates; the one behavioral change is that aurltemplate rendering a blob-shaped URL no longer implies pinnable bytes — declareuris.bytes(orpin_url) instead.
Fixed
luria lintchecks the standing of references to merge-allocated documents.ref_statusloaded a scheme by number and matched codes by digits, so a temporary code (ADR-049) was neither a document nor a citation site: for the whole life of the pull request that files them, citations among merge-allocated documents went unchecked, and the findings surfaced only after the merge that concretized the codes — on the trunk, in files nobody was editing (#203).luria link --fixhad handled temporary codes all along; the two halves of the reference system disagreed about whether a temporary code is a code, and only one of them said so.
Changed
-
Five source comments cited
ADR-tmpstat1, a temporary code that never named a document; the decision they meant is ADR-085. The checker above found them on its first run.A record that spells out the temporary shape in prose — a decision that defines it, a README transcript, a CLI page — has illustrative codes that now resolve to nothing, and each wants an
unresolved-ok:acknowledgement once. File them with the upgrade, not before: on the older version the directive excuses nothing and is reported stale.
Fixed
LU-#193linked to the citing project’s issue 193, not the remote’s. The prefix was inert prose and the number resolved through the localissue_url, producing a well-formed link to a different project’s issue of the same number — which in a mature tracker exists and is about something else. No check could see it: the target resolved, sobroken-targetswas satisfied (#194).
Added
[luria.remotes.X] issue_url, defaulting to the GitHub convention for a remote with arepo. A remote reached by aurltemplate alone — an arXiv identifier, a ticket key — has no tracker, so itsX-#7resolves to nothing and is left bare rather than pointed at the local one.
Changed
- Every document code in the site’s record line now carries the document’s
title. A code alone asks the reader to already know the record —
LIT-141says nothing about what it is — and being followed by someone who does not yet know where it goes is the whole point of a backlink. - A field with several values gets a bulleted list, one item per line, instead of values separated by center dots. With a title after each code the items are long enough that a line each is the only thing that reads. A single value stays plain: a one-item bullet is a bullet about nothing.
Fixed
- A title containing markdown syntax is escaped where it is spliced. This
project’s own ADR-025 is titled
Wikilinks: `[[CODE]]` is a typed reference, and two decisions cite it — unescaped, both their pages asked the resolver for a document calledCODE.|is escaped too, which would otherwise end the table cell.
Changed
-
The site’s record line is a two-column table instead of one line of
**Label** valuefragments separated by center dots. It read acceptably at three facts and badly at eleven —LIT-140ran to a paragraph of bolded fragments a reader had to parse before they could scan. The center dot keeps its job inside a cell, where it separates peers.The line is composed only when staging the site, so nothing about how the record reads in the repository changes.
Added
- A
<!-- luria:site -->README region, rewritten byluria indexbeside the badges and the citation block, carrying the URLluria sitepublishes to. Derived fromSite.base_url, which needs no configuration for a GitHub project (#197). unlinked-site, a lint finding for a record that publishes a site its README never names. Satisfied by the URL appearing anywhere in the README, prose link included — it is about the front page, not about the marker.[luria.site] publish, defaulting true. A record that lives only in its repository sets it false and the finding goes quiet.
Changed
- The region machinery — markers, staleness-safe rewrite, README path — is one
implementation in
readme.py, read the same way bybadges,citationand the new region. It had been copied three times and had already drifted in spelling. - This project’s README links its own site from that region instead of a
hand-typed line, which had been sitting immediately below the region
luria indexrewrites.
Fixed
- The site’s record line named a document’s status twice on every page of
every scheme that declares a status vocabulary — which, since #181 requires
the declaration, is every record.
record_linehad always rendered it throughstatuses.display; the generic vocabulary loop then rendered it again as an ordinary declared field. The dedicated path stays, because it is the only one that composesSuperseded — by X; noteout of the fields around the word. - A relation with a declared
converserendered twice on the same line — once humanised from the field it holds, once as a backlink labelled with the raw field name (**Extends** LIT-141 · … · Cited asextended_byby LIT-141). Backlinks exist for the direction the site would otherwise lose, and storing the converse removes the loss. Suppressed by what the page actually holds rather than by the declaration alone, so a record with a one-sided relation still shows the edge it has while the lint reports it.
Added
statuses.FIELD, naming the frontmatter key a scheme may back with a vocabulary, so the two places reasoning about it as a declared field agree.
Added
luria upgrade— one-shot commands that carry a record across a version boundary. Each is temporary by construction and states what has to be true before it is deleted;luria upgradewith no argument lists them with those conditions. Nothing under it goes through config loading, since the config an upgrade repairs is the one the new version refuses to load.luria upgrade statuseswrites thestatusdeclaration and the vocabulary file into a record that predates them.- Lint class
spent-upgrades: an upgrade this record no longer needs is dead code upstream, so the record says so rather than waiting for someone to remember.
Changed
status:is a controlled vocabulary a scheme declares, not a built-in axis.[luria.schemes.X.fields.status] vocabulary = "statuses"wires the field tostatuses.yaml; the words are the project’s, and every check, legend and page follows them.- A scheme that declares no
statusvocabulary is a lint violation, namingluria upgrade statuses. An unchecked field looks exactly like a clean one, which is the failure the declaration exists to remove. - The bespoke status check is gone: one bad word is one finding, raised where every other controlled field is checked.
- A
status:still carrying its— noteis read apart at the boundary where frontmatter is read for checking, so it is reported once — by the check whose finding names the repair. - The record page lists each scheme’s status words and cites the file they come from, instead of saying “nothing beyond the standard fields”.
Documentation
examples/README.mdand the shippedluria.tomlexplain thatactivepicks the in-force word from the vocabulary rather than adding one.
Added
luria lintreports a prose field (summary:,origin:,status_note:) that still says what the scheme’s_template.mdsays — the form’s words, not the document’s. Write it, or drop the key; an absent summary falls back to the title.
Changed
luria newno longer copies the form’s placeholder into a prose field the caller did not fill (summary:,origin:,status_note:); the key is dropped and the comment above it stays as the instruction.
Added
- A reference field may declare its
converse— the field holding the same relation read backwards (extends/extended_by).luria link --fixmakes the two sides agree, so a relation is stated once on whichever document the author was holding. - A relation naming itself as its converse is what symmetry is, so
compared_against = { …, converse = "compared_against" }gets the behaviour that used to be hard-wired to a chain’ssibling. - Removing a relation propagates too.
--fixreads the last committed state to tell a write the other side has not caught up with from a deletion the other side is stale about, and writes or prunes accordingly. A relation withdrawn on one side and asserted on the other is reported and left alone. luria link --fixnever makesluria lintworse. A repair that would move a document from satisfying its scheme to violating it — a back-reference added into a field group that permits one of two fields, a stale one removed out of a field the status requires — is not applied. The pair stays one-sided and the finding names both rules that disagree.- Lint class
one-sided-relations, naming for each finding whether the fixer will write the missing side or remove the stale one.
Changed
- A relation with no declared
converseis now left entirely alone — nothing completed, nothing reported. A project relying on the previous release’s symmetric completion must addconverseto the field’s declaration to keep it; the change is otherwise silent. broken-chainskeeps only the cycle. The one-sided finding moved toone-sided-relations, because a declared pair is one-sided or it is not, whether or not a chain walks it.- A chain reads both its relations through the converse union, so a one-sided declaration renders correctly before the fixer runs.
Documentation
docs/cli.mdcoversconverse, the add-versus-remove table, what--fixwill not touch, and why an undeclared relation is left alone.
Added
luria link --fixnow completes a chain’s symmetricsiblingrelation: where one document declares a comparison and the other does not, the missing back-reference is written into the other document’s frontmatter. You declare a comparison once, on the document that ran it. The directedrelationis never mirrored.luria link --links-onlyrestricts--fixto link rewriting — the behaviour it had before completion existed.
Changed
- The
broken-chainsone-sided-comparison finding now names its remedy, the waylegacy-spellingsdoes.
Documentation
docs/cli.mdcovers the two repairsluria linkperforms and whyPATHSnarrows only the first of them.
Added
[luria.chains.X] annotate = "field"shows a second field beside each step’s status on a chain page. A record can carry an axis the status cannot express — how far the field has converged, as against what the record itself asserts — and without this the page renders an agreed trunk and a disputed branch identically, which is the one distinction a line of work exists to show. Read through the contract, so a field with adefaultshows its default rather than a blank; a field the scheme does not declare is a config error (#173).
Added
required_whenis validated at load against what the scheme can actually say: anonthe scheme cannot name is refused, and so is a value outside a closed set (the status vocabulary, or a vocabulary-backed field). A misspelled field and a miscased status used to be accepted and silently never hold — the outcome eager validation exists to remove (#172 review).
Changed
- A condition is compared against a field’s effective value, resolved
through the compiled contract rather than read raw. A vocabulary field with
a
defaultis never absent (ADR-076), so a condition naming its default now holds for the documents that omit it; a list-valued field matches on any element.RequiredWhenis pure data again, soconfigno longer reaches intostatuses(#172 review). - ADR-071’s “a Superseded document names its successor” is a
required_whenon the built-insuperseded_byfield instead of a hand-written branch incheck_frontmatter. One implementation, and the built-in gets the contract wording, provenance and record-page mention for nothing (#172 review). docs/record.mdnames the built-in conditional once, alongside the standard fields.describe()still lists only what a scheme declares beyond them.
Changed
- A chain page renders the status value, not the composed
Superseded — by [X](…); notedisplay form. The successor is the next line on the page and the note is the argument this view leaves on the document — and composing it dragged a link authored in the source’s frame onto a page that renders elsewhere, which is a thing to avoid rather than a thing to rebase.status,superseded_byandstatus_notebeing three fields is what makes the narrower reading available (#171).
Fixed
- A chain page’s links pointed into the scheme’s view directory, which for an index-rendered scheme holds a README and tag pages and never a page per document — so every rendered link resolved to nothing. Found by the first real corpus; the fixtures had checked the shape of a target and not its existence (#171).
- A status note carrying a link is rebased on a chain page, as it already is on the index and tag pages (#171).
- A chain page registers in
is_generated: its job is to show a line including its retired steps, so scanning it reported every superseded document in every chain (#171). - A chain’s relation fields are no longer read as citation sites. The step a
document extends is superseded by construction, so
extends:produced one “cites a retired document” finding per retired step, at the field whose whole job is to name it. Prose is unaffected (#171).
Added
[luria.chains]: a declared relation is walked transitively and rendered as sequences on one page — the spine directed, optional symmetric cross-links alongside. Order, title and status only: the field carries the sequence, the prose keeps the argument (#171).broken-chains: a succession that loops, and a comparison only one side declares. Both structural, neither acknowledgeable.
Added
required_when: a field can be required by another field’s value —required_when = { status = ["Proposed", "Deferred"] }— so a record can say not only what it believes but what would change its mind. One field against a set of literal values, deliberately not an expression language (#170, ADR-082).- The
fieldstable accepts a declaration with novocabulary: a rule about when a field applies needs no type, so a table declaring onlyrequired_whenis a plain field.
Added
template-drift: a scheme’s_template.mdis now checked against the scheme’s own contract — amanyfield scaffolded as one value, a scalar one scaffolded as a list, a required field the form never prompts for. Shape only; placeholder values stay placeholders. The template was the one file stating the schema that nothing compared to it, and it is the file every document is a copy of (#169).luria newaccepts a scheme’s declared fields as flags —--source LIT-134,LIT-140— and writes each in the shape its contract declares. An undeclared flag is refused by name (#169).
Fixed
luria newdropped a field the template did not already scaffold: the substitution matched nothing and the command reported success. It is appended to the frontmatter now (#169).
Fixed
-
source-mismatchno longer reports a disagreement on HTML escaping. Metadata APIs serve XML and JSON, so a title arrives escaped — arXiv’s Atom feed returnsBetter & Faster— and comparing that against a title a person typed reported a mismatch on the ampersand. Entities are unescaped at the fetch rather than at the comparison, so the lockfile records what the title is rather than markup.Found by running the check on a real corpus of 186 notes, which is the only way it would have been found: the fixtures all had ASCII titles.
Added
source-mismatch: an identifier whose upstream title is not the one the document records. A citation can resolve perfectly and still name a different paper, and nothing looked — 53 of 139 arXiv identifiers in one record pointed at unrelated work and stayed green for two years.source-unchecked: an identifier nothing has verified. The case that matters most, since a citation is likeliest wrong in the minutes after it is typed, which is exactly when no lockfile has an answer for it.[luria.lint] network—auto(default) lets the lint ask about what the lockfile cannot answer,neveris the hermetic build,requiremakes not being able to ask a finding, so a green CI run means the references were verified rather than remembered.luria remotes --resolvefetches the title behind every identifier and records it in the lockfile, which the lint then answers from — and adds to, so what it learns is committed and reviewable.uris.titleandtitle_reon a remote say how to ask and how to read the answer. One line each for arXiv and Crossref, both in the CLI docs.source-ok:acknowledges a deliberate disagreement — a nickname the project prefers, a trimmed subtitle, a title that changed between versions.
Changed
- The lockfile gains a
titlessection, preserved across--refreshand--pinlike the others, and written by the lint as well as read. - Fetch failures are distinguished by HTTP status: 404/410 is upstream saying the identifier names nothing — an answer, recorded and not retried — while 429/503 is retried with backoff and, if it persists, reported as unchecked rather than written down as an absence.
Added
uniform_share, a per-scheme threshold forinert-status. The check reported only on unanimity; a scheme at 133/144 one status is a field a reader can predict without looking, and eleven exceptions were enough to silence it permanently. Defaults to1.0— the previous rule exactly — so no existing project’s output changes.
Changed
- An
inert-statusrow shows the distribution behind the modal status:SOTA: 133/144 at Active — 8 Proposed, 2 Superseded, 1 Deferred. A finding about a proportion that prints only a proportion invites the reply that exceptions exist. uniform_okacknowledges by the same rule, so what is acknowledged and what would have been reported cannot drift apart.
Fixed
- A remote code in a reference field is read whole.
superseded_by:naming a uid-remote document —ARXIV-2110.08058,DOI:10.1145/3600006— was truncated to a scheme-shaped prefix (or to nothing) before the contract check saw it, and failed as “names no scheme or remote” while the same code in prose resolved. The check always meant to admit a remote code; now its reader does.
Added
- Directives in a record document’s frontmatter. A reference field is a
citation site, and the only comment the markdown scan read was an HTML
one, so a
superseded_by:naming a document that was itself later retired could be acknowledged only file-wide. A whole-line#comment inside the frontmatter is now a comment the directive parser reads, and its line scope reaches the whole YAML entry below it — key and list items or continuation lines — so# inactive-ok: ADR-012 — …directly above the field excuses the field.
Fixed
CONTRIBUTING.mdis scanned by the reference machinery.doc_refs.doc_files()listedREADME.md,CLAUDE.mdandAGENTS.md— the files an agent bootstraps from, which is a real category and the wrong one: what the reference rules care about is prose asserting the project’s rules to a reader.CONTRIBUTING.mdstates DP-008, DP-006, DP-003 and the DP-001/DP-010 split almost verbatim while citing none of them, and nothing linked or checked its references. Those citations are now written and held by the lint. Second instance of this gap — the first wasexamples/README.md, where a hand-written link pointed at a path that does not exist.- ADR-077 records that ADR-045’s consequence — “
examples/**joinstemplate/**in the site’s exclusions” — is no longer true, following ADR-017’s pattern: the old body stands as written and the newer decision is where a reader learns the state changed. ADR-045 is not edited and not superseded; its decision is still in force, and only a sentence about its side effects aged out. The other half of that paragraph was already overtaken by ADR-047. CONTRIBUTING.md’s description ofexamples/was left behind by ADR-078: the views are committed and held byluria index --checknow, and the temporary-tree build is about test isolation rather than about avoiding a committed view.
Fixed
examples/constitution:BOUNDARY-001was grounded in a value that does not justify it.VALUE-003governs the manner of a refusal — refuse in a sentence, then stop — and says nothing about which requests are refused. The edge was well-typed and false:required = truedemanded a value and the nearest one to hand filled the slot, which is the failure mode a required reference is advertised to prevent, inverted.VALUE-008now says what the limit actually rests on — a cost landing on someone who was never in the conversation and cannot decline — andVALUE-003stays as the second ground, since it does govern the delivery.groundsismany = true. It was scalar, so a practice sitting on a seam between two values could name only one.PRACTICE-006was that case and carried the second as a tag — a tag standing in for a reference the schema could not express, which is DP-016’s reading. It now names both (VALUE-006for what a correction costs the reader,VALUE-001for the requirement that it happen), and thehonestytag is dropped rather than kept beside the edge.
Added
test_every_section_of_the_source_says_what_accounts_for_it— the reverse of the existing accountability test, and the direction that actually goes wrong. The forward test catches a document nobody derived; the likelier change is the source gaining a paragraph while nothing is written, and until now that left the suite green. Verified against exactly that: a planted## Escalationsection with no document. Anothing yetblock satisfies the check, which is the point rather than a loophole — three sections are accounted for by nothing deliberately, and an explicit “nothing, and nothing should” is a different statement from silence (DP-015).
Changed
-
luria indexregenerates nested records’ views, and--checkfails on a stale one (ADR-078).outputs()andview_dirs()reach into each nested record under its own config;run()andstaleness()inherit it unchanged. So the examples’ views are committed now,examples/.gitignoreis empty, and each example can be read here as a finished record.They were ignored on the argument that a committed view nobody regenerates is the stale projection the examples argue against — which conflated committed with hand-maintained. DP-003’s first rung is derive it, and a view CI regenerates on every push is derived; this project’s own
docs/are committed on exactly that basis. The examples being the one exception was DP-016’s same-shape-opposite-rules, and the gap was real rather than stylistic: nothing regenerated them, so committing them first would have made the original argument true. -
include_recordsmoves from[luria.site]to[luria]. It says a project contains other projects, which generation needs as much as publishing — a keyluria indexhad to reach into the site table to read is DP-016’s awkwardness.Config.nested_records()is the single answer all three callers use, because a record that is published but never regenerated is worse than either alone (DP-004). -
luria sitestages a nested record from the record itself rather than copying it to a temporary directory and generating there. That copy existed only because the views were not committed. -
luria indexnames the nested records it wrote rather than folding them into the tally: “…60 devlog entries, plus examples/collocated, …”. “78 files from 77 ADRs” is arithmetic nobody can check, and a record that silently rendered nothing would look exactly like one that rendered correctly (DP-015).
Changed
- DP-012 to v2, generalized from “one decision, one thing” to one document, one thing. Its test was always general; nothing in the wording said so, and a record of practices or claims would not have read itself as covered. Found in one that isn’t: a record with no decisions in it at all.
- The principle gains a second test. The a-priori one — could these have been
decided differently? — only fires when an author stops to ask. Typed edges
(ADR-060, ADR-071) supply one that arrives as friction: an edge
whose prose has to name which clause of its target it bears on is reporting
that the target is two documents. It also gains the reason not to fix such
an edge with a qualifier — a
when:beside a checked reference is prose in a data field, which is escalating emphasis one level up, and it is why #141 putswhenexpressions among its non-goals. examples/constitution:PRACTICE-001split, as the principle’s worked case. It carried two claims that arrived in the same paragraph of the source — deliver the whole scope, and resolve ambiguity without escalating — and a boundary overriding it had to say in prose which of the two it argued with. The second is nowPRACTICE-010, andBOUNDARY-003names it. The split exposed a second error:BOUNDARY-003’s edge toPRACTICE-005was dropped rather than repointed, because that practice licenses acting on what is established and explicitly not on what is assumed — it never permitted the inference, so there was nothing to override.
Added
-
DP-015 — an absence reads exactly like a success. The premise underneath DP-001, DP-003’s fail-stale rung, DP-006 and DP-010, each of which leans on the word silent at its load-bearing moment and none of which says why silence is the problem. Re-derived four times without being written down; a fifth time in another project (SG-DP-022). All four now cite it, so the unification is an edge rather than an assertion.
-
An audit of the principle set for restatements, whose result is mostly negative and worth recording as such. DP-002, DP-003 and DP-004 look like one principle and are not: DP-003 is asymmetric (a source and a projection, so derive it is available), DP-004 is symmetric (two peers, so the remedy is consolidation and “choose the failure polarity” is meaningless), and DP-002’s failure mode is contention, which occurs with no duplication at all. Every sampled citation of DP-004 invokes divergence-between-implementations; none invokes DP-003’s remedy ladder. Merging them would make the advice a disjunction and cost ~130 citation sites their precision.
-
DP-016 — an awkward structure is reporting a distinction the model has stopped expressing. Extracted from DP-009, where it was the third of three jobs and had never been cited — DP-012’s own symptom for a document carrying two things, firing on a principle. Promoted on its second substrate: DP-009 found the reading in the file tree, and a typed
overridesedge found it again in the citation graph, which is not a tree and which DP-009 does not cover. DP-009 goes to v2 and hands the clause over; DP-012’s edge test cites it as the general form.
Added
site.include_records(ADR-077) — mount a whole record inside another’s site. Each match is staged by its own config into a temporary vault and only the finishedcontent/is mounted, because the source-versus-view test (link_base) answers from the reading config’s schemes: a parent publishing a child’s files directly emits both a fragment and the view it renders into. One level deep, deliberately; a pattern matching no record is an error, a directory that is not a record is a quiet skip.config.rooted()— a bounded context manager that makes another project current and restores what it swapped, including restoring an absence.load(root)builds any config, but the modules underneath callcurrent()for themselves, so switching projects means switching the global; this names and bounds it.- A root
README.mdfor each of the seven examples. Each is now self-contained — its own config, sources, README and generated views — and the README is what the published section uses as its landing page.
Changed
- The examples are published, at
examples/<name>/on this project’s site, instead of being excluded from it. 103 pages to 208. Each keeps its own title, theme derivation and record lines, because each was staged by the config that knows about it.
Added
examples/constitution/docs/constitution.md— the source document the example’s record decomposes. The record asserted things about a constitution that was not in the repository, so nothing could check the decomposition. It lives underdocs/sodoc_files()scans it and its references are held to the same rules as any other prose.- Nine documents covering source passages the record did not account for:
VALUE-005(an error that lands on a person is not symmetric with one that lands on the work),VALUE-006(every sentence the reader must process is a cost charged to them),VALUE-007(a refusal from the person you are working for is information, not an obstacle),PRACTICE-005–PRACTICE-009, andBOUNDARY-003(never infer a person’s pronouns from their name).BOUNDARY-003overrides two practices that would have licensed the inference, which is the example’s clearest demonstration of precedence as a checked edge. test_every_active_document_accounts_for_something_in_the_source— the schema checksPRACTICE → VALUE; nothing checkeddocument → the text it was drawn from, and that is the direction a record drifts in. Retired documents are exempt.
Changed
BOUNDARY-002to v2. As written it said restatement instead of reproduction, which read as a rule against quoting a source at all. Narrowed: reproducing is fine, but a copy must not stand in for the analysis and a reproduction must stay one — which is why the source page’s references sit in annotation blocks after each section rather than as links threaded into quoted prose.
Added
examples/constitution/— a worked record with no code in it: an AI assistant’s operating instructions decomposed intoVALUE,PRACTICEandBOUNDARYschemes, where precedence is a checked reference (BOUNDARY.overrides → PRACTICE) rather than escalating emphasis,groundsis a typed reference so no rule stands on its own authority, and a practice is retired by a boundary from a different scheme.test_every_example_stages_its_own_site— every example is staged as its own Quartz vault and asserted to publish pages with nothing unplaceable and nothing redirected out to the repository. The rootluria.tomlexcludesexamples/**from this site because a parent config cannot stage a child’s record, not because the examples are unpublishable; the test says which.
Fixed
luria indexwas not idempotent for any record whose vocabulary pages cite a retired document.Config.is_generatedcovered a scheme’s index and tag pages but not its vocabulary pages, whileadr_index.view_dirs()did — so the reference machinery (doc_refs.doc_files→ref_status.scanned_files, both filtered onis_generated) read a generated page as prose. The reports render in the same parallel pass that writes those pages, so the report saw the previous run’s copy and a second index produced a different report than the first. A citation inside one also could not be excused: aninactive-ok:written into a generated file is erased by the next build. Present since vocabularies shipped; only a retired vocabulary member makes it visible.tests/test_examples.py::lint_errorsran six oflint.run’s nine checks, so an example could pass these tests and fail the real command. It now runs all nine —check_status_vocabulary,check_contractsandcheck_version_historywere the gap, andcheck_contractsis what makes a typed reference a finding rather than a sentence.
Documentation
examples/README.mdrecords two things staging made visible thatluria lintcannot see: arender = "document"scheme’s sources are cited by their anchor in the assembled view, never by filename — the file exists and lints clean but is never published — and a document scheme’s stub must contain{principles}, without which every member’s body is dropped while index and lint both stay green.
Changed
- The Pages workflow builds after the generation job has committed the
views on the default branch (
workflow_runon the CI workflow), instead of on the push — which had deployed the merge commit, one bot commit behind — and builds nothing on a pull request, where a branch carries no views of its own. The scaffold’spages.ymlhas the same shape.
Changed
luria lintreads sources only. Whether a committed view is current isluria index --check’s question, asked in the generation job on the default branch; the lint keeps the view-directory rule (a hand-written file inside one is a violation), computed without writing anything. A branch is linted as it is, in CI and locally — nobody regenerates a view to check a record.- The generate action’s pull-request shape is repairs only:
views: "false"(wascommit-views) pushes the repairs and writes no view; the lint follows in the same job. The scaffold’s workflow uses it.
Changed
- A temporary code cited from a workflow file is the
workflow-temp-codeswarning class, on the enforcement dial, rather than a lint error: the generation job cannot push the rewrite on the workflow’s own token, and can on a token with workflow write. This repository and the scaffold name the class infail_on.
Added
luria lintreports a temporary code cited in a workflow file: the generation job rewrites it when the decision is numbered, and the workflow token may not modify.github/workflows/, so the job’s push is refused. Cite the number once the decision has one, or say it in prose.
Added
luria repairwrites every mechanical source repair — bare codes linked, a journal entry’s missingcreated:filled from its path, a retired configuration reference removed — each a state the lint reports with this command as its remedy. Idempotent.
Changed
luria indexwrites views only; the source repairs it used to make first areluria repair’s.- Generated views are committed on the default branch only, and source
repairs on the branch that authored them. A pull request pushes its
repairs onto the branch, regenerates the views in the working tree, lints
the result in the same job, and commits no view — so branches never
conflict on the decision index or the devlog book, and the review reads
repaired sources. The generate action takes
commit-views: "false"for that shape and arepair-message; the scaffold’s workflow uses it.
Added
[luria.schemes.X.field_groups.NAME]: several fields of which an entry must carry some —fields = ["arxiv", "doi", "url"]withrequire = "at-least-one"(orexactly-one,at-most-one). The finding names the need and every field that would have met it; the record page lists the group (#141).
Changed
- The knowledge-base example requires a source of a paper — arXiv, DOI or URL — rather than an arXiv identifier, and gains a technical report with only a URL.
Changed
- The status note is its own field:
status: Supersededwithstatus_note: …, where one scalar carried both.status_noteis prose — a code in it is a citation the fixer links. A note still riding instatus:is a lint finding, andluria repairmoves it (#141). superseded_by:is a reference field on every scheme: the successor a superseded document names, one code or a list, checked and resolved, rendered asSuperseded — by Xand as a Supersedes backlink. ASupersededdocument that leaves it empty is a lint finding.luria indexfills it from an old-formby CODEnote.
Fixed
luria new adr --tags record,mechanismcrashed: Fire hands a comma-separated flag to the scaffolder as a tuple, and it expected a string. Both spellings now work.
Added
- A frontmatter field backed by a scheme-local controlled vocabulary
(#141):
[luria.schemes.X.fields.NAME]withvocabulary,many,requiredanddefault, the values inNAME.yamlbeside the records shaped liketags.yaml. Closed — a value the file does not name is a finding. A default is read as the field’s value wherever it is absent and is never written into the source.luria indexrenders a page per value beside the tag pages, the scheme’s index links them, the record page lists the field with what absence means, and the site’s record line shows the written values. Aworld-bibleworked example.
Added
many = trueon a declared reference: the field holds a list of codes, every element is checked and resolved, and each becomes an edge (#141).
Fixed
- A reference field given a YAML list was stringified, its first code
checked and the rest silently ignored. A list where one code was
declared is now a finding that names
many = trueas the remedy.
Changed
- A contract finding cites the key that declared the obligation — every
key when a field is in both
requiresandreferences, and the vocabulary file a derived tag group reads its members from (#141). docs/record.mdgains What an entry must carry: each scheme’s obligations with where each was declared, from the same renderer the findings cite. Says so truthfully when there are none.- The knowledge-base example declares
sourceaLITreference rather than a barerequires, as #141’s second dogfooding experiment asked.
Added
- Typed edges, read from what the record already says (
luria/edges.py, #141): aSuperseded — bynote, aninfluenced_by:list and any declared reference field are edges named for the relation.luria siterenders each document’s edges both ways on its record line — Supersedes, Influenced, Source, Cited assourceby — where the site previously showed only that a page was mentioned.
Changed
requires,referencesandtag_groupsare checked in one pass over a contract compiled per scheme (luria/contract.py, #141), where each was its own loop over the record. Findings are unchanged; a field named in bothrequiresandreferencesis now reported once rather than twice.
2026-08-31
Changed
luria migratenow carries pinned endorsements through a scheme rename (#135, ADR-066 v2): for each remote claimed viaremotes = [...], a pin is re-keyed to the new spelling with both hashes intact — the endorsement is of content, which a rename does not change, and prune-and-re-endorse would have silently vouched for unreviewed upstream drift. The claimed remote’s discovered filename map is dropped for re-discovery instead (its keys and values both spell the old world);luria remotes --refreshrebuilds it, and upstream’s own rename later surfaces as ordinaryremote-driftfor review.
Added
luria remotes --pin [CODE]endorses remote content by hash (#135): the hash of each pinned document’s bytes is committed toremotes.lock.json,--refreshrecords what upstream serves now, andluria lintreports every pinned document that changed since its endorsement — the newremote-driftwarning class, promotable viafail_on. Re-endorsing after review clears the finding; a bare--pinendorses everything cited and prunes pins nothing cites any more (ADR-066).- A
pin_urltemplate on a remote (or remote scheme) declares where its stable bytes live, so content behind a rendered page becomes pinnable —pin_url = "https://arxiv.org/e-print/{1}.{2}"pins the paper an abstract page fronts. Declared rather than guessed: only the project can vouch that a URL is content-stable. - Arbitrary URLs can be pinned too: flag one where it is cited
(
<!-- pin: https://… — why it matters -->) and runluria remotes --pin. The flag is the registration — deleting it retires the pin, so a pin that fires too often costs one removed comment. pin = trueon a remote (or one of its schemes) registers a whole code family: every cited reference is pinned by a bareluria remotes --pin, and the lint reports any not yet endorsed. A bare--pinsyncs the lockfile to what is registered — config declarations,pin:flags, existing pins — and never re-endorses drifted content: that always takes the explicit--pin CODE, so a scheduled sweep cannot quietly launder a drift finding. This repo registers its own citedLU-ADRreferences.
Fixed
luria remotes --refreshno longer writes an authoritative empty map for a remote it could not read: a private repository’s failed discovery used to flip every one of that remote’s references to “absent from the remote”. Failure now leaves the remote off the lockfile — or keeps the map it already had — so it stays on the code-only convention.- The migration sweep (
luria migrate) skipsremotes.lock.json: its JSON nests a remote’s prefix away from its tails, so the composed-span mask could not tell a foreign pin key from a local code, and a scheme rename would have rewritten another project’s namespace. Machine-derived state is re-derived after a migration, never re-spelled.
2026-08-25
Added
- A design principle: exempting a ledger from one matcher exempts it from none of the others. A mechanism that rewrites instances of a pattern records what it rewrote, in the pattern’s own spelling — so every matcher for that pattern also matches its own ledger, and the exemptions do not transfer between them.
Added
uniform_okon a scheme: the acknowledgementinert-statusnever had. Every other judgment call in luria can be answered where it is raised, but that finding is about a scheme and a scheme has no line to comment on, so a project whose uniformity was deliberate had no move. Set it — a mandatory reason — and the scheme leavesinert-statusfor a newacknowledged-uniformitysection that still reports the count and status and appends the reason. It lapses on its own when a second status appears, and it cannot be promoted to a failure.
Changed
- ADR-057 is
Activeat version 2, having absorbed the acknowledgement. The check and the reply to it are one decision restated, not two.
Added
-
A design principle: meet the project where it is. A project picks its language, platform, forge and shape for reasons that have nothing to do with keeping a record; the record arrives afterwards and should fit what it finds. Held by being explicit in what the tool writes and forgiving in what it assumes — and by asking what has been assumed and never written down, since coupling to an environment rarely arrives as a decision.
-
A decision on how principles are worded: write one as a value unless it is actually a rule. The test is whether it can be partly met. If it can, the aspirational voice keeps the state a value spends most of its life in; if the only outcomes are satisfied and violated, it is a rule and should read like one. Two of the fourteen principles here are rules and keep their voice. The question is now in the principle template.
Fixed
- Every file the package reads or writes now names UTF-8 explicitly. Nothing
did before, so each one took the platform’s preferred encoding — cp1252 on
a default Windows install, where
luria indexcrashed writing a check mark into a status report, and a tree written that way was then unreadable to the same tool underPYTHONUTF8=1. Reproducible without Windows underLC_ALL=C, which is how it is now tested. - The CLI reconfigures its output streams with
errors="replace", so a terminal that cannot encode the arrow inluria init → pathprints?rather than a traceback. The console keeps its own encoding; only files are unconditionally UTF-8.
Changed
-
The README leads with the mechanism firing. A page cites a decision, the decision is superseded, and
luria lintnames the page nobody edited — before any vocabulary is introduced. Install and the sixty-second walkthrough follow; the families and the “ADR is not in the code” reveal move after them. -
The quickstart ends by breaking something on purpose: supersede a decision, see the finding land, then close it by fixing the citation or acknowledging it.
concepts.mdhad promised this and the quickstart had never delivered it — a self-checking system whose tutorial never catches anything. -
luria init --dry-runis in the first-run path, andCLAUDE.mdgets a sentence saying what it is and that nothing depends on it. -
The live record at dmarx.github.io/luria is linked from the README and declared as
Documentationin the package metadata. -
adopting.mdopens with what makes a record worth keeping, what happens if statuses never move, what should stay ordinary prose, how much machinery adoption adds, what is GitHub-specific, and what is least settled. -
CITATION.cff, which GitHub reads for its “Cite this repository” button, and a BibTeX block at the bottom of the README derived from it byluria index— a new generated region alongside the badges, checked for staleness byluria lint. Two hand-written copies of a citation is the drift DP-3 names, and a citation is a bad thing to have two versions of: the wrong one is the one that reaches somebody’s bibliography.No version in either. The version comes from the release tag, and writing one into a file by hand is the copy ADR-053 removed.
Fixed
- The ontology said every entry has a name, a standing and declared rules.
Journals and fragments have none of those — a journal entry is identified by
when it was written and carries no status at all.
statusand citation semantics now belong to referable documents, and the other families are described as what they are. - “What people read cannot drift from what people file” claimed more than the tool delivers. Hand-written prose drifts; what cannot is a generated view from its sources. Scoped accordingly.
- Two documents disagreed about how many worked configurations ship — one said four, one said five, and the fifth had been added a day earlier. Neither states a count now.
- The README enumerated four statuses of the five in the closed vocabulary.
- The prior-art section named Doyle, de Kleer, AGM and Dung without references; they have DOIs now.
Fixed
-
tests/test_prose_frontmatter.pywrites anADR-007.mdinto a temporary project as fixture data, and the number collides with this repository’s own ADR-007, which isSuperseded— so the scanner read five lines of fixture data as citations of a retired decision. Acknowledged at file level with what they actually are.The reference-status report now reads clean in every section: nothing cited unacknowledged, every code resolving, no stale annotations.
Added
luria configwrites theluria.tomlthatluria initwould have written, and stops. The shorthand covers the two things projects usually vary; anything else — a directory name, a narrowed status vocabulary, a tag group — is an edit to the config, and making that edit after a scaffold means moving directories the first run already created. Writing the config first and scaffolding second avoids the migration.--stdoutprints instead, and works where a config already exists.
Documentation
render = "index"versusrender = "document"is explained rather than named.modeling.mdgains the choice — one question about how the set is read, with the two checks worth testing an answer against — andproject-memory.mdgains a table of what each actually produces, including thatoutputmeans a directory in one and a file in the other.
Added
luria initinfersissue_urlfrom theoriginremote when one is not given, for hosts whose issue path is known (GitHub, GitLab), and reports what it used. The value cascades —[luria.site]derives its title, Pages URL and source base from it — so a repository with a remote scaffolds a correct record with no configuration at all.
Added
-
luria init --schemesand--journals, for a project that wants the shipped defaults plus a family or two:$ luria init --schemes "RFC,SPEC:document" --journals "incidents:day"Each entry is
NAMEorNAME:kind, and the paths follow the prefix. The shorthand is an argument rather than a stored format — what lands inluria.tomlis the ordinary commented table, so nothing reads it back and the config looks like every other project’s.
Added
docs/concepts.md, between the quickstart and the modeling guide: the shortest complete account of the model — entries, citations, the status field everything hangs off, what a finding is and how one is answered, and the prior art the mechanism comes from. The page existed before the docs rewrite and was dropped; the rewrite left a gap between “do the loop” and “choose between the options”, which this fills.
Changed
- ADR-058’s rejection note no longer rests on the concepts page not existing, since it does. The reason that survives is the one that mattered: the README opens on what the record does rather than on what to call it.
Fixed
- The README carried two badge blocks. The generator updates the first, so the second had frozen at numbers three weeks stale. It was left behind when the pitch rewrite inserted a new block above it.
Changed
- The section introducing the four families is
## Kinds of recordrather than## It is not only for decisions, which argued against an impression the reader has no way to have formed.
Added
- A scheme may name where its tag vocabulary lives —
tags = "record/topics.yaml"— so two schemes can share one file instead of keeping a copy each. - A tag may declare
primary_for: [LIT, SOTA], and atag_groupsentry that lists no tags derives its membership from those keys. One vocabulary file can now give two schemes different primaries without repeating the shared part. [luria.schemes.X.references]declares that a frontmatter field holds a code from a named scheme. Whererequireschecked only that a field was truthy, a declared reference checks that it is present, is a code, belongs to that scheme, and resolves.
Changed
- A scheme’s
_template.mdis no longer scanned for code references. It is a form the tool reads, not an entry in the record, so its example codes were reported as citations — a template with a realistic example produced a finding against itself. Link targets in templates are still checked.
Fixed
- Nothing yet broken by this; all three additions are inert until declared.
Changed
- Nine proposals adopted: ADR-041, ADR-052, ADR-053, ADR-054, ADR-055,
ADR-056, ADR-060, ADR-061 and DP-010. Each describes something the tree
already does; leaving them
Proposedsaid the question was open when it had been settled in code. - ADR-058, which asked the README to call luria a truth maintenance system,
is
Rejected. The README rewrite says what the record does in its own terms, and the concepts page the decision also named no longer exists.
Documentation
- A decision recording why the skip-marker checker was dropped: it cannot fire when the marker is on a tip that stays the tip, which is the case that does harm, and does fire on commits that suppressed nothing. Required status checks in branch protection are the answer, and are not something this package can ship.
Changed
- Test fixtures that model a principles scheme now use a
VPprefix overdocs/values.mdrather than borrowingDP. A rename of the real scheme would have swept them:luria migratewalks tracked source files on the grounds that “a source file answers as truthfully as a document does”, and a fixture’s codes are not claims about this record. Six test files, and theUPremote’s document-scheme fixtures with them.
Fixed
- Five demonstration codes carried no acknowledgement, so they sat in the
unresolved-codes report indistinguishable from typos:
DP-017inmigrate.py,ADR-123inconcretize.pyand again in ADR-049’s worked collision example,DP-018in ADR-040, and the[ADR-0string literal intest_concretize.pythat the scanner reads as a code. Each file now carries anunresolved-ok-file:directive next to the reason. The report reads “every code resolves” for the first time.
Added
docs/modeling.md— designing a record: what belongs in one, which family fits which material, the rule for when two kinds of entry are two schemes, what the schema can be made to refuse, and five worked shapes.docs/importing.md— turning material that already exists as data into a record, and what that transform surfaces.examples/knowledge-base/— a record of domain content rather than project meta-documentation: two schemes citing each other with separate statuses, required fields, and a one-primary-category rule. Built and linted by CI like the others.
Changed
- The README leads with what a record is — entries with a name, a standing, declared rules and generated views — rather than with the furniture it ships with or the file format it happens to use. Markdown is demoted to an implementation note explaining why plain files are chosen (participation) and saying plainly that nothing in the model depends on them.
- The README names four shapes a record can take.
project-memory.mdgains a Constraints section.requires,tag_groups,titles_generalizeandinert-statuspreviously appeared in the prose docs only as names in the lint contract.- A register pass over
modeling.mdandproject-memory.md: headers now label their contents rather than stating verdicts or withholding them, padded triads are cut to the number of things there actually are, and a few staged constructions state their finding instead. Em-dash density is left alone — this project’s own prose runs 2.5 to 3.5 per 150 words, and scrubbing that would make the docs less like the record they document. adopting.mddocuments two CI hazards: another workflow committing to the branch defeats the generate/lint handoff from outside, and a commit message containing the skip marker suppresses its own run.
Changed
- The hand-written documentation — README, CLAUDE.md, CONTRIBUTING, and
every prose page under
docs/— was rewritten from scratch against a deliberately stripped checkout, reorganized around five pages: quickstart, project-memory, cli, directives, and adopting.
Removed
- The docs pages
api.md,schemes.md, andin-practice.md; their subject matter is folded into the rewritten set.
2026-08-24
Added
docs/record.md— what this project’s record is made of, generated byluria indexfrom the loadedluria.toml: the schemes it named and the shape of their codes, where journal entries are filed and where the books render, the fragment directories, the remotes it can cite, theluria newcommand for every kind, and the settings it moved off the defaults. The filing table comes fromnew.kinds()— the CLI’s own dispatch mapping — so it cannot advertise a kindluria newwould reject (ADR-059).
Changed
docs/configuration.mdnow renders only in Luria’s own tree. The reference is generated so it cannot drift fromluria/config.py, and that argument only holds whereconfig.pyis a file the reader can open. Downstream it was a vendored copy of Luria’s schema, stamped editluria/config.py, not this file at a reader with no such file, going stale on their next upgrade with nothing in their repository responsible for it.record.mdis what an adopting project gets instead, and links the schema rather than restating it.
Upgrading
- The first
luria indexafter this release deletes an orphaneddocs/configuration.mdand says so, guarded on the generator’s own marker — a page at that path that Luria did not write is left exactly where it is. luria lintwill then ask for arecord.mdrow in yourdocs/README.md. That is a judgement call (you describe your own page), which is why it is asked for rather than guessed at.- Any prose that linked the old page needs repointing — at
record.mdfor “how our record works”, or at https://github.com/dmarx/luria/blob/main/docs/configuration.md for the schema.
2026-08-17
Added
- An optional
statuses.yamlin a scheme’s directory, besidetags.yamland shaped like it: which of ADR-003’s five statuses the scheme uses, and what each one means there. A record whose status the scheme does not declare fails the lint, and the meanings render above the index table they explain. check_status_vocabulary: astatuses.yamlkey outside the closed five is an error. Narrowing the vocabulary per scheme is the point; extending it is what ADR-003 bought and this does not sell it back.
Documentation
docs/configuration.mdanddocs/adopting.mddescribe the file, including the part that is easy to get backwards — the words stay closed, only their meanings and their per-scheme subset are yours.
Added
broken-targets: every relative markdown link target in record prose is resolved from where that prose renders —link_base, the same authorityluria link --fixuses to write one — and reported when it does not exist. A warning by default, nameable in[luria.lint] fail_on.target-ok:acknowledges a target that deliberately resolves to nothing, such as a link into a build output that CI writes but does not commit. The first directive whose argument is a path rather than a code.
Fixed
- The scaffolded decisions stub shipped two links that are dead in every
project
luria initcreates:[_template.md](_template.md)and[design-principles.md](../design-principles.md), both written relative to the stub’s own directory rather than todocs/decisions/, where it renders. - The scaffolded decision template wrote its supersession example as a link,
[ADR-NNN](ADR-NNN.md), so a placeholder read as a citation to a file nobody has. It is a code span now, matching the placeholder on the next line.
Documentation
docs/directives.mdgains atarget-oksection, and says why this one is about the path rather than the code every other directive governs.
Added
inert-status: a scheme where every record shares one status is reported.activeis whatretired-citationsreads, so nothing is ever retired there and the citation checks cannot fire — the build is green because nothing is being judged rather than because nothing is wrong. A warning by default, nameable infail_on. Exempt below ten records, for arender = "document"scheme, and for a scheme that declares exactly one status on purpose.
Fixed
- The published version and the git tag can no longer disagree. 0.4.0 was
tagged and released against a tree whose
pyproject.tomlstill said0.3.0, sopython -m buildproduced a 0.3.0 wheel and PyPI rejected it as a duplicate — after the GitHub release was already published, and withtwine checkand the cold-install smoke test both passing, because neither validates identity.pyproject.tomlis bumped to 0.4.0, and the build job now asserts the built wheel’s version equals the release tag (vprefix tolerated) before the publish job ever runs. The version was a hand-maintained projection of a source of truth kept in two places, which is what DP-5 predicts will drift; this is its rung-2 remedy — guard the property. Rung 1, deriving the version from the tag withhatch-vcs, is the better fix and needsfetch-depth: 0on the publish checkout, so it is left as a follow-up rather than bundled into a release-unblocking change.
Fixed
-
move_doclands a document under a temporary code, not a number. “The next free number” is no more a fact inside a migration than it is on a branch: every operation plans against the tree as it is now, so two moves into one scheme both read the same highest number, and the secondgit mvsilently overwrote the first. The move now mints a temp code (ADR-049) andluria concretizeassigns the real number afterwards, at the serialization point — the same bargainluria newalready makes, rather than a second allocator with its own arithmetic. The document ends up carrying both aliases: the code it migrated from, and the provisional one it wore in between. -
luria concretizerewrites the anchor spelling too. Its sweep was a case-sensitive replace, so it upgradedADR-tmp47fjebut walked straight past#adr-tmp47fje— leaving a live link pointing at a heading that no longer existed. Generated views are re-derived and were never at risk; a hand-written or migration-written link was. -
Pairno longer returns a tail typedint | str. The padded-number spelling and the opaque temporary identity are not the same kind of value, and collapsing them pushed the ambiguity out to every call site, which then had to test the type to learn which it had. Replaced withold_parts,new_parts,new_is_provisionalandnew_anchor_tail, so the padding question and the provisional question are asked separately — they are separate questions. A test now pins that a rename mirrors each citation’s own spelling:DP-004stays padded,DP-4stays bare, the anchor stays bare.
Fixed
luria migrate’s relink pass now stops where the hyperlink lint stops (#90). It walked every tracked file and linkified what it found there, whileluria link --fixwalksdoc_files()— the fixer running wider than the linter checks, which is the disagreementdoc_refsexists to prevent. The first realmove_docmigration turned two moved documents into a 499-file working tree, 469 of them exactlyHEADplus markdown links written into Python comments, TypeScript comments and workflow YAML. The sweep still walks every tracked file, and should: “does this text spell a code that moved?” is a question a.pycomment answers as truthfully as a document does. Only the linking half was scoped wrong.- A worded citation in a source file follows the move too.
#89 caught the prose-labelled form
by the address it points at — which works in a document, where the citation
is a link, and misses it entirely in code, where
(design-principles #17)is normally unlinked: no code for the code swap, no address for the address swap. Eight of them survived the strata-g promotion, naming a document that had moved. The sweep now respells them usingfind_refs, the same recognizer that would have turned the phrase into a link in the first place, so the two cannot disagree about what counts as a reference. - A
formerly:stamp is no longer reported as a dangling reference. The reference scan is deliberately unmasked, so it read the alias the move had just written and reportedDP-017 resolves to no documentagainst the file the migration had created — one warning per moved document, every time, for the one construct whose entire purpose is to name a code that resolves to nothing.sweep_textalready excludedformerly:blocks for the mirror reason (a later migration must not rewrite an earlier one’s trail); the two exclusions now sharedoc_refs.FORMERLY_RE, because they are one exclusion.
Fixed
- Every warning class
status_sectionscan emit is now nameable in[luria.lint] fail_on, and a test asserts it over the whole vocabulary rather than one class.legacy-spellingshad been emitted since rung one landed but was missing fromFAILABLE, so a project asking to enforce it was told “which is no warning class” — the dial rejecting a notch it was already printing on, which is DP-1 inside the guard written to catch exactly that. The tuple entry itself rode in unremarked with thenarrow-titleswork; this is the test that would have caught the omission, and the changelog line it never got.
Fixed
- A generated view the project gitignores is no longer reported stale. A
project can point
[luria.paths] reportsat a build directory and publish the result as a CI artifact instead of committing it. A fresh clone then never has the file, so missing read as stale — and the remedy the failure printed, “regenerate and commit the result”, is the one thing.gitignoreforbids. Downstream that meant a docs job red on every commit for a day, on a check nothing could satisfy, which is DP-1 wearing a green hat: the tool refused and its explanation was impossible to act on.--checknow excludes gitignored outputs from all three staleness kinds. Writing is unchanged —luria indexstill renders an ignored view, because not committed is not not wanted; that report is exactly what the artifact upload publishes. luria lintandluria index --checkshare one staleness rule set. They each had their own copy of the same three rules — stale view, orphan in a view directory, drifted README badges — and the fix above landed in one of them, solintwent on rejecting the identical tree the generator had just called current. That is the fixer/linter split this package exists to prevent, reproduced inside the package.adr_index.staleness()is now the single answer both consume; only the wording stayed with the linter, because a build log and a--checkwant different sentences. A test pins the invariant from outside: whatever one command says about a tree, the other says too.
Fixed
-
luria migrate’smove_docno longer leaves links pointing at a moved document’s old address. A move always crosses schemes, and a scheme’s address is more than its code — a same-render move changes the directory, a cross-render move changes the whole shape (page.md#anchor↔dir/CODE.md). Swapping the code inside the old link fixed the label and left the target pointing at a file that does not exist, silently, with the lint clean.Citations of a moved document are now found by the ADDRESS they point at rather than by their label, replaced with the new code, and linked by the fixer from the resolver — the one place that knows how each scheme is addressed. A worded citation is rewritten too, label and all: keeping the label resurrects the problem, because the
#17left behind is itself a reference the fixer re-links to the anchor the move just vacated.
Added
requires = [...]on a scheme — frontmatter fields it demands beyond the standard set. This is what makes a cross-schemeluria migratemove safe to automate (ADR-040): a document moved into a scheme whose template asks for fields the source never had cannot have them invented, so the move succeeds and the lint fails until a human supplies them. The machinery relocates a document; only a person vouches that it belongs.
Changed
origin:is prose, likesummary:— references written there are linked byluria link --fixand checked by the lint. It was already rendered as markdown into a principle’s metadata line, so a hand-written link displayed correctly while nothing maintained it: the worst of both, and a rot with no alarm. The reference machinery now reads aPROSE_KEYSset instead of namingsummaryin four places, and the membership rule is stated — a key is prose exactly when the generator renders its value as markdown. Deliberately not configurable: a project cannot make a field prose by declaring it so.
Added
-
luria migrate— execute a migration spec fromrecord/migrations.d/, renaming a scheme or moving documents between schemes without losing the record’s memory (ADR-040, now Active). Two operations:rename_schemerewrites a whole code family, following the scheme’s view, the remotes that mirror this project, and any extra config files named in the spec.move_docrelocates one document to another scheme, auto-numbered in the target. Withstrategy = "supersede"it copies instead: the source stays where it is, tombstoned asSuperseded — by <new code>, and is deliberately left out of the rewrite mapping so existing citations keep resolving to the original. That is the shape a promotion wants — the old document is still a true record of what happened, and only its output moved.
--dry-runprints the plan and changes nothing;--commitcommits and appends the migration to.git-blame-ignore-revsso blame reads through it. The sweep is mapping-driven, never prefix-driven: only enumerated pairs are rewritten, foreign composed codes (SG-DP-4) are masked because another project’s namespace is theirs, and the spec file itself is never swept — its mapping is written in old spellings on purpose. -
luria new migrationscaffolds a numbered spec, because execution order is information: a move can depend on a rename. -
luria/aliases.py— the alias map that migrations resolve through, derived fresh fromformerly:frontmatter rather than hand-kept. Complements the concretization-flavoured alias resolution already indoc_refs: that one answers for temporary codes, this one for any renamed code.
Added
-
narrow-titles, a warning class for a title that names one of the project’s own concrete nouns in a scheme whose documents claim to transfer. A principle stated about the artifact it was first noticed on stays true, renders, and passes every other check — it simply stops being cited, and nothing could see that. Two config surfaces:[luria.lint] narrow_termsfor the project’s vocabulary, andtitles_generalize = trueper scheme for the opt-in. Luria ships no vocabulary, so an adopter who has not configured one sees nothing at all — the class is absent, not empty. A word used in another sense is acknowledged in-document withbroad-ok:, through the same directive parser asinactive-ok:, rather than by shrinking the vocabulary and stopping it protecting every other document. -
A principle carried in from strata-g, luria’s first consumer: “It’s not mine, but I’ll pick it up anyway” — fix the debt you encounter whether or not it belongs to the task you came for, bounded by repair, don’t redesign and say what you picked up. Added to this record and to the
template/starter set.
Fixed
- The
DPscheme now usesallocate = "merge", which the decisions scheme has had since ADR-049. Without it two concurrent branches each took “the next free principle number” and both got the same one — a collision that had already happened here. A scheme that renders as a document is no less prone to it than one that renders as an index.
Added
-
DP-010, “One decision, one thing.” A decision with two unrelated halves is one nobody can cite half of: the second half has no code, so nothing can point at it; superseding the first silently retires reasoning nobody meant to withdraw; and the alternatives section quietly covers whichever half the author found more interesting. The test is whether the two halves could have been decided differently.
Earned on the second re-derivation, per the rule for adding one — three splits in a single session, each made for this reason and none of them by rule.
Documentation
- Templates and the decisions stub now point at
luria new <kind>instead of telling the reader to copy_template.mdby hand. The copy instruction predates the command and had outlived it:new’s kinds are derived from config, soluria new <kind>works for a scheme the moment it is declared, and it assigns the identity — which hand-copying does not, and which is how two branches end up claiming one number. Which identity depends on the scheme’sallocatemode, so the comment names the mechanism rather than one of its two outcomes:filingtakes the next free number on the spot,mergemints a temporary code thatluria concretizenumbers where merges serialize. Fixed in both the shippedtemplate/scaffold and this project’s own record, so an adopter and a maintainer read the same instruction.
Added
- ADR-058: luria is a truth maintenance system, and should say so. Nobody could name the category, so every description reached for a new metaphor. The category exists and is from 1979. The documentation now leads with the mechanism — retract a premise, and the build names what rested on it — and gives TMS as the second sentence.
docs/concepts.md— the model and its prior art.docs/quickstart.md— fifteen minutes ending in a real finding.docs/schemes.md— designing record families beyond decisions.docs/cli.md— every command, and the CI wiring including the version-split trap.docs/api.md— the Python surface, with stability marked.docs/in-practice.md— the three existing records compared: luria itself, strata-g, and a corpus project. What varied, what drove each choice, and the short list of things all three do the same way.CONTRIBUTING.md.
Changed
- Every hand-written page rewritten from scratch:
README.md,docs/README.md,docs/adopting.md,docs/directives.md,docs/project-memory.md. The README’s four competing self-descriptions are replaced by one lead and one placement.
Documentation
- An ADR (Proposed) for the draft signal: a draft pull request carrying a
Proposeddecision means the contribution itself is the question — the choice could only be weighed from the finished diff, the writeup argues the trade in both directions, and rejection is a live, cheap outcome. Merge flips the decisionActive, close files itRejected, and either way the record keeps the reasoning. Use it when the work exists to settle its own worth; skip it for agreed work, where a draft only slows the loop.
Changed
- A declared family replaces the shipped default (ADR-047).
schemes,fragments,journalsandremotesare now yours entirely the moment you declare them: a record of RFCs and specs has no phantom ADR scheme, and a declared scheme’s omittedoutputis genuinely unset — the view renders beside its sources, as the docs always said it would. Settings tables (paths,code,lint,site) still merge per key.
Upgrading: a config that declared part of a family while relying on
the rest from the defaults — say [luria.schemes.DP] alone, expecting ADR
to persist — now owns the family it declared. Add the missing entries
explicitly; the shipped template always declared its families in full, so
records scaffolded by luria init are unaffected.
Added
luria init --config my.toml(ADR-048): write theluria.tomlyou want and init installs it and scaffolds exactly that shape — a directory, template and view stub per scheme, templates per journal and fragment directory, and a docs index listing the views your record actually renders. A project that already has aluria.tomlnow gets its shape scaffolded rather than the template’s.--configagainst a project that already has one is a hard error, never a silent skip.
Fixed
- An index-rendered scheme with no
README.stubis titled after itself rather than# Architecture decision records— the same defect the document render had, fixed the same way. - A fresh
luria init→luria index→luria lintruns clean again: two bare references in the template (LU-ADR-048in the docs index prose,DP-1in the principles stub) became visible to the scheme-driven reference detection and would have made every new scaffold start red. The three-command adoption loop is now a CI-run test, so the class stays closed.
Changed (review round)
luria newstamps an unnamed fragment with its filing moment (20260812-021035.md), the identity the devlog already uses, instead of naming it after the git branch — which collided the first time a branch was restarted after a squash merge and refiled (ADR-036, v2).--nameremains the explicit override and still reopens rather than duplicates.- Generated views are marked
linguist-generatedin.gitattributes, so PR review collapses them by default and a contribution’s diff reads as its sources. The views stay committed; only review’s rendering changes.
Proposed
- ADR-049: schemes gain an
allocate = "merge"mode —luria newissues a temporary code (ADR-tmp47fje) that is first-class on its branch, andluria concretize, run where merges serialize, assigns real numbers in merge order and records the temporary code as a permanentformerly:alias. Filed from the review discussion on #76; implementation to follow in its own PR.
Changed
- The published version is derived from the release tag rather than a
hand-written
pyproject.tomlfield, completing the fix #93 began:hatch-vcsreadsgit describe, and the publish checkout fetches tags so there is something to describe against. #93’s assertion that the built version matches the tag stays — deriving prevents the drift, the guard makes a recurrence loud.
Fixed
- A scaffolded project no longer starts with dangling references. Three
illustrative codes in shipped templates came from the real sequence and
resolved to nothing in a fresh scaffold (
ADR-049in two_template.mdfiles,ADR-001inCLAUDE.md); they now use theFX-fixture prefix. Three more in the scaffolded workflows cited Luria’s own decisions bare, so they read as the adopting project’s decisions — they now compose asLU-. A freshinit+index+lintwent from 5 unresolved codes to none.
Fixed
- A tag page names its own scheme. Pages for a non-ADR scheme were headed
“ADRs tagged
x” and counted “N of M decisions”, regardless of what the scheme actually holds — the same wartDEFAULT_STUBalready avoids for the index. - A tag blurb keeps its capitals.
str.capitalize()lowercases everything after the first character, so any blurb running past one sentence, or naming anything capitalised, was silently downcased.
Added
[luria.schemes.X.tag_groups]— a scheme can declare which of its tags combine, andluria lintenforces it. A group takestags, an optionalrequire(any,at-most-one,exactly-one), and an optionalexcluded_bynaming tags that forbid the group. Opt-in per scheme, so a record declaring no group is unconstrained.tags.yamlhas always said what a tag means; this says which may appear together, for vocabularies that are axes rather than piles.
Added
luria indexnow rendersdocs/configuration.md, a reference for everyluria.tomlkey generated from the config dataclasses themselves — prose from their docstrings, key tables fromdataclasses.fields(). A key that exists in the schema is a documented row whether or not anyone remembered to describe it (ADR-044).
Documentation
- The docs say what Luria can be configured into, not only what it ships
as.
docs/adopting.mdgains “Shaping the record to your project” — worked examples for a second document family, a second journal, collocated views, fragment styles,uidremotes for citing things that are not Luria records (arXiv identifiers, ticket keys), and thefail_onenforcement dial. - The README and the scaffolded
CLAUDE.mdnow say plainly that the four shipped subsystems are a default rather than the machinery’s fixed parts, and point at the configuration reference. - Both documents state a limit rather than leaving it to be discovered: adding a scheme costs one table, but renaming one is still a manual pass (ADR-040).
Added (examples)
examples/holds four complete, working projects — RFCs beside specs, a collocated layout, three journals at three granularities, anduidremotes citing arXiv papers, Jira tickets and CVEs.tests/test_examples.pybuilds each one and runs the realluria indexandluria lintagainst it, so these are configurations CI defends rather than prose (ADR-045).
Fixed
- A
render = "document"scheme with noREADME.stubno longer emits the heading# Design principlesregardless of its prefix. A SPEC family rendered as a document is titled after itself. luria init’s scaffoldeddocs/README.mdnow lists the configuration reference, so a freshly initialized project passesluria linton the first run as the adoption guide promises. An existing project upgrading will see one docs-index violation naming the missing entry; adding the line clears it.- Two wrong claims in the new adoption guidance, both caught by building the
examples:
activeselects from the closed status vocabulary and cannot extend it, and omittingoutputdoes not collocate the shippedADRscheme (set it equal todir). Both are now documented accurately and pinned by tests.
Fixed (reference checking)
- Every configured scheme is now linted and linked, not just
ADR. Reference detection matched three hardcoded patterns, so a project with anRFCorSPECscheme got indexes, tag pages andluria new rfc— and no reference checking at all.RFC-7in prose was neither linked nor reported (ADR-046). - The bare
DP-6spelling is found.CLAUDE.mdand the scaffolded template both tell contributors to write the bare code and letluria link --fixspell the target; for design principles that had never been true, because only the prose spelling (design principles #6) was matched. Applying the fix linked 38 references in this repository that had accumulated unseen. - Cross-scheme references resolve in both directions — a file link into an index-rendered scheme, an anchor into a document-rendered one, each from the base where the citing text renders.
Upgrading: references your record has been carrying unchecked will become
violations in one pass. Run luria link --fix and read a sample of the diff
rather than trusting it wholesale.
Fixed
- Two acknowledgements stopped applying when the sequence reached ADR-053.
The specimen lists in
ADR-014andtests/test_adr_index.pyborrowed a code from the real sequence, and a real fifty-third decision made it resolve. This is the second time —ADR-032went the same way — soADR-014now records that trimming the list is the symptom fix and theFX-prefix is the cause fix.
Fixed
- A generated scheme’s index no longer renders stray
{and}. TheREADME.stubscaffolded for every non-ADR scheme carried{{categories}}and{{table}}— thestr.formatescaping convention — whileinit.pysubstitutes withstr.replace, so the doubled braces survived into the file and every generated index carried two literal braces. The hand-shipped decisions stub uses single braces and was always correct, which is why this only affected schemesluria initgenerated.
Changed
- This repository’s own record now allocates at merge (ADR-049,
adopted):
luria new adrmints a temporary code on the branch, and the push-to-main docs job runsluria concretize— withconcretize --checkguarding the same run. The sharedactions/generatecomposite gained aconcretizeinput, gated to non-PR events, and the scaffolded template workflow passes it the same way, so an adopter flippingallocate = "merge"gets the serialization-point wiring free.
Added
- The
legacy-spellingswarning class (ADR-040, rung 1 complete): a citation still written in a concretized code’s old temporary spelling is reported with its remedy —path:line ADR-tmpxxxxx → ADR-123— and promotable to a failure via[luria.lint] fail_on.luria link --fixupgrades the spelling to the canonical code rather than engraving the old name into a fresh link. The in-tree steady state is zero, so a row means an in-flight branch merged after a concretization pass. Theformerly:field itself is excluded — it is the alias record, not a citation.
Decided
- ADR-044 through ADR-049 — the configuration reference, executable examples, scheme-driven reference detection, family-replacement merge semantics, config-planned init, and merge allocation — are now Active.
Changed
- The decision index gains a real Title column. The middle column was one blob — the summary when present, else the title — under a header that said “Title”, so any document with a summary showed its summary mislabelled. Rows now read code | title | summary | status, and a document without a summary gets an honestly empty cell rather than its title twice.
Added
- Merge-allocated schemes (ADR-049):
allocate = "merge"makesluria newissue a temporary code (ADR-tmp47fje— a tail that can never be read as a number) instead of claiming the next number from a branch. Temporary documents are first-class: indexed, linted, citable bare or as a wikilink, cross-referencable before they have a number. luria concretize: run wherever merges serialize, it assigns real numbers in merge order (commit time, the ordering the changelog collector already trusts), renames the files, rewrites every reference — history included, journals and the collected changelog too, so exactly one spelling of each code exists in the tree afterwards — and records each temporary code in the document’sformerly:frontmatter.- Permanent aliases: a code listed under
formerly:resolves forever, in both the bare and wikilink spellings — for the citations no rewrite can reach: PR threads, commit messages, other repositories, and branches cut before concretization, which merge clean and modernize on their nextluria link --fix. luria concretize --check: the trunk’s guard — exits 1 naming any temporary code, for CI on the default branch.
The default is unchanged: schemes without allocate = "merge" number at
filing exactly as before.
2026-08-10
Added
- Wikilinks (ADR-025,
#9):
[[ADR-013]],[[SG-DP-18]],[[ARXIV-2403.05530|a label]]— typed references the author asserts, resolved against everything the machinery can construct (local scheme codes including the bareDP-3spelling, document-scheme anchors, remote and uid-remote codes, issue numbers with no cue needed).luria link --fixconsumes them into plain markdown links; an unresolvable wikilink is a lint violation with its causes named, because an explicit request deserves an explicit refusal.
Fixed
- The published front page shows its banner again
(#70):
luria siterecognised a relative target after](and inside<a href>, but not inside<img src>— the form a README reaches its logo by, since markdown isn’t parsed inside an HTML block (ADR-005). The image was neither staged nor redirected nor counted, so the run reported nothing to place while dropping one. Any project whose docs centre an image in raw HTML was losing it. - The graph view sits above the article, not below it
(#71): Quartz stacks its
sidebars under the content below 1200px, so on most windows — and on every
phone — the graph the site exists for was the last thing on the page. It
moves into the content column, directly under the title, uniformly at
every width, with its parameters retuned for a column twice a sidebar’s
width.
luria sitenow writesquartz.layout.tsas well asquartz.config.ts, so a project’s layout is Luria’s to decide rather than whatever the generator defaults to. - The landing page has a name. The README is published as
index.md, and a README that opens with a centred logo gives a site no title to read — so the front page was calledindex. It now carries the site title, and an alias so anything still pointing atREADME.mdkeeps resolving.
Added
- The published site can wear your brand
(ADR-043,
#13): four optional
[luria.site]keys —icon,logo,logo_dark, and athemetable that merges over the generator’s palette by name. An unknown colour name is refused with the known ones listed rather than dropped, and a project that sets none of them gets exactly the site it had before.- The favicon is rasterized during the build, from whatever
iconpoints at, using thesharpQuartz already depends on. Point it at the vector master: no derived PNG is committed, so none can drift (DP-3). - The logo replaces the site title in the sidebar, baked once per
theme. Artwork exposing a
--luria-inkcustom property is re-inked to each theme automatically; anything else needslogo_darkor is used as it stands.
- The favicon is rasterized during the build, from whatever
- Luria’s own record wears the brainslug kit: paper and ink from the
kit’s two colours, the horizontal lockup in the sidebar, and a new
luria_project_memory_icon.svg— the mark on a paper badge, contours thickened so the line art still reads at 16px — as the favicon.
Fixed
actions/siteno longer fails the build for a project with no favicon (#73): the icon lookup usedls … 2>/dev/null | head -1, and under the step’s ownset -euo pipefailan unmatched glob ends the step before Quartz ever runs. Silencing a command’s stderr reads as handling its failure and isn’t. It could not bite this repository, which always configures an icon; it would have bitten the first adopter who didn’t.
Added
- Another project’s decision is cited as
LU-ADR-013— a registered remote prefix composed with that project’s own code (ADR-016). One[luria.remotes.LU]entry makes it a first-class reference:luria link --fixwrites the URL,luria lintfails on a bare one, and the citation scan no longer has to guess which project a code belonged to. luria remotes— what is configured and how each foreign reference resolves;--refreshdiscovers code→filename maps from a public repository into a committedremotes.lock.json;--checkprobes reachability. A remote that follows ADR-013 needs no lockfile: the code is the filename.- Two remotes are registered, and their difference is the point.
SGis the pilot this package was extracted from — private, filenames not yet converted, so--checkreports it unverifiable.LUis Luria itself, which theluria initscaffold cites instead of pasting GitHub URLs into a new project’s templates, and which--checkverifies for real. The mechanism is exercised by the package, not only by its tests — which is how the*.stubhole below was found. - A citation may name a document before its URL resolves
(ADR-017).
SG-ADR-032404s today and will land when strata-g’s record is ported; naming the document is the durable half, and the whole set flips tookin one--checkrun when it does. version:is standard frontmatter for every scheme, not just principles. Shown in the decision index only when it isn’t 1, because a column of ones teaches nothing.
Fixed
*.stubfiles are linted. A stub is the hand-written prose of a generated view: the lint skipped it for not being markdown, and skipped the page it renders into for being generated, so a bare reference written there was invisible to both checks at once.- A code whose remote has been discovered is no longer guessed. Once a lockfile has been read from a remote, its silence about a code is authoritative and the reference stays unlinked and reported.
--checkno longer reports a private repository as a shelf of 404s. It probes the repository once and says unverifiable — an anonymous 404 is not the claim “this document was deleted”.
Changed
- Discovery reads public repositories over HTTPS only. The local-clone
option is gone: a resolution that depends on what happens to be on somebody’s
disk produces a committed lockfile nobody else can regenerate. A remote Luria
can’t read gets a
urltemplate, not a credential path (ADR-016 supersedes ADR-015).
Changed
- The repository layout now states the read/write boundary
(ADR-021,
#3):
docs/holds everything a reader browses — prose plus every generated view — andrecord/holds everything a contributor files, each container inside carrying the.dsuffix (record/decisions.d/,record/principles.d/,record/changelog.d/,record/devlog.d/). What you read atdocs/Xyou file atrecord/X.d.CHANGELOG.mdstays at the root, where convention puts it. - A scheme’s
outputis now separate from its sourcedir: the decision index and its tag pages render intodocs/decisions/while the ADR files stay inrecord/decisions.d/, with link rebasing derived from the actual paths. A scheme with nooutputkeeps the old collocated layout unchanged, so existing projects upgrade without moving anything. README.stubandtags.yamllive with the sources; a stub’s links resolve from where the index renders.- The journal’s front page now inlines the current book’s contents, newest entry first, above the shelf of older books — the newest writing is one click from the entrypoint instead of two.
luria initscaffolds the new layout; the template’sdocs/README.mdandCLAUDE.mdexplain the boundary.
Added
-
A view directory holds only what the generator wrote — anything else in one is a lint violation naming the file and the remedy. This generalizes the old orphaned-tag-page check to every view directory, and also catches a journal book stranded by a granularity change.
-
DP-9 — structure is read before text, so affordances are spent deliberately: on shaping attention, on making locations discoverable, and as smells to read when they turn inconsistent. A structural signal beats a documentary one; the read/write boundary is the worked application.
-
A new comment directive,
url-ok— a link whose label is a composed foreign code (SG-DP-18) but whose URL is hand-written rather than constructed is reported as a warning until acknowledged, because a hand URL is frozen at writing time. Same shape and scope rules as every other directive; stale acknowledgements report themselves. Foreign codes only — ADR-022 records why it does not widen to local codes or arbitrary hand-targeted links.
Fixed
- The README badges’ link target is derived from configuration instead of a
hardcoded
docs/decisions/README.md.
Added
- The record publishes as a browsable site
(ADR-042,
#13):
luria sitestages the record as an Obsidian/Quartz vault — pages at their repository paths, plus aquartz.config.tsderived fromluria.toml— and the newactions/sitecomposite action builds it onto GitHub Pages. The citations the lint already guarantees are links become a graph, backlinks, full-text search and per-tag pages, none of it maintained by hand. Luria publishes its own record with the same action adopters get, and the scaffold ships the workflow (ADR-029). One step cannot be scaffolded: set Settings → Pages → Source to “GitHub Actions”, or the deploy job fails with “Pages is not enabled” while the build stays green. [luria.site], and almost nobody needs it: the site’s title, its Pages URL, and the base a link falls back to when it points at a repository file the site does not publish all derive fromissue_urlfor a GitHub project (DP-3). Onlyexcludeis genuinely per-project.- Decisions carry a record line on the site: status, date, issue and
influenced_by, rendered under the title. Those facts live in frontmatter, which a site renders as nothing — so without it a superseded decision reads on the web as current.
Fixed
- Generated index links are normalized
(#67): a summary rebased for
the view directory emitted
../../record/decisions.d/../../docs/design-principles.md#dp-2— valid on GitHub, which collapses it, and a 404 under any generator that doesn’t. Twenty links in this repo, invisible for as long as GitHub was the only reader. Runluria indexto pick up the short form.
Added
- Published to PyPI (ADR-027,
#3):
pip install luria. Publishing runs through GitHub trusted publishing — apublish.ymlworkflow whosepypienvironment identity is the whole credential — on every GitHub release, gated by a cold-install smoke test that scaffolds a fresh project from the built wheel (init → index → journal new → lint).
Fixed
- The scaffold ships inside the package (
luria/template/in the wheel) instead of leaking a baretemplate/directory intosite-packages, where it would have collided with any other package shipping one.luria initresolves the packaged location first and falls back to the repository top level in a checkout. - A freshly scaffolded project now lints with zero warnings: the illustrative wikilinks in the template’s CLAUDE.md no longer read as dangling codes.
Added
- Design principles are fragments, and
docs/design-principles.mdis generated from them (ADR-012). One file per principle indocs/principles/, with frontmatter carrying aversion(principles are living documents — two of Luria’s eight are at v2, and now say so),influenced_bybacklinks to the decisions whose experience produced them,history:for what changed between versions, and anoriginnote. - A scheme declares how its view is rendered.
render = "index"is the browsable shape — a table plus per-tag pages;render = "document"concatenates the bodies into one page for a set that is read as a whole. This is the first exercise of ADR-006’s claim that a second scheme is a config entry and a directory: no scanner changed. docs/principles/_template.md, and principles scaffolding inluria init— a fresh project now gets five seed principles as fragments rather than one hand-maintained document.
Changed
luria indexregenerates every scheme’s view, not just the decision index, soluria lint’s staleness check covers a newly configured scheme the moment it exists.- Links to a principle use a stable
#dp-Nanchor. The generator emits<a name="dp-N">beside each heading, andluria linkprefers it over the heading slug: a principle is a living document, so a heading-derived anchor stops resolving the moment the wording moves — silently, which is the fail-stale polarity DP-3 rules out. Projects whose principles are still one hand-written file keep the heading-slug fallback.
Fixed
- Tag pages no longer credit a script that doesn’t exist here — the
generated header named
scripts/ci/build_adr_index.py, a leftover from the corpus Luria was extracted from.
Changed
ADR-018is atv2. Its rejection of the endpoint-badge alternative cited ADR-002’s per-merge bot commit, which over-applied it — that hazard depends on a file being appended to at a marker and carrying assigned numbers, and a derived badge file has neither. The decision is unchanged; the reason it gives is now the real one (a baked-in URL is correct per commit, so a reviewer sees the count move in the diff).- Contributions to this repository go through a pull request. A decision record is an interpretation of somebody’s intent, and it should be read before it becomes what the project believes.
Added
- ADR-019: a wrong reason is corrected in
place and versioned; a changed choice is superseded. Superseding over a
bad argument retires a decision still in force and points every citation at
an identical claim. “Never rewrite a body” objects to silent revision — a
versionbump with ahistory:note saying what the old version got wrong is the opposite of silent.
Documentation
- The docs no longer read as “these documents are frozen.” Project memory gains a section on what is and isn’t revisable, with a table of the four shapes — choice changed, reason wrong, value reworded, consequence falsified — and a live example of each from this repository, because a rule a project has never applied to itself is a rule nobody has tested.
- ADR-001 is at
v2. Its traffic rule said a decision is “superseded but never rewritten”, which reads as immutability and leaves no way to fix a wrong argument short of retiring a decision still in force. Narrowed to the case it governs — supersede when the choice changes — withhistory:recording the over-broad version. The rule about which layer holds what is unchanged. - The decision templates, both index stubs,
CLAUDE.mdand the adoption guide now say the same thing, and the scaffold points a new project at Luria’s worked examples by remote code rather than a pasted URL.
Added
- Per-scheme remote mappings
(ADR-023,
#6): a remote’s code families
construct independently via
[luria.remotes.X.schemes.Y]—dirfor file-per-code schemes,documentplus ananchortemplate for schemes whose documents are sections of one assembled page, or aurltemplate. The anchor defaults to the stable shape Luria’s document render emits (dp-{number}), so a remote on current conventions needs onedocumentline:SG-DP-18now constructs to…/docs/design-principles.md#dp-18instead of a URL to a file that never existed. luria remoteslabels which construction answered per code — “a document anchor, per the scheme” — alongside the existing rung labels.- uid remotes (ADR-024): a remote can
declare its references’ shape outright — a
uidregex, a configurabledelim, and aurltemplate that indexes the uid’s capture groups by position — soARXIV-2403.05530linkifies, lints andurl-oks like any foreign code. A uid is exact (never zero-padded), has exactly one resolution rung (the template; no lockfile, no convention), and an unconfigured prefix still never matches.
Changed
- The lockfile’s authority is scoped to what discovery can see: files. A document-scheme code absent from the lockfile still constructs — a section never appears in a directory listing, so its absence there is not evidence (ADR-016 unchanged for file-per-code codes).
- The remote-level
dirdefault moves fromdocs/decisionstorecord/decisions.d, following the read/write boundary (ADR-021) — defaults mirror Luria’s own conventions. Remotes with an explicitdirare unaffected. - The
url-okacknowledgingSG-DP-18narrows to its residue: the construction now reaches the right document, and the annotation excuses only strata-g’s legacy heading-derived anchor — the retirement loop ADR-022 designed, exercised in tests in both directions.
Added
- Parallel execution (ADR-026,
#7): one ordered
pmapover a thread pool, applied at three seams — render units inluria index(a scheme, a journal), per-file scans in the bare-reference lint, and per-URL probes inluria remotes --check. Results keep input order, so reports and rendered views are byte-identical at any width.LURIA_JOBS=1forces serial execution;LURIA_JOBS=Ncaps the pool. Measured:remotes --check6.6s → 2.9s on this repo’s citations; index and lint unchanged at today’s cardinality (the seams there are structure for growth, as the issue asked).
Added
- A document can opt out of reference checking
(ADR-033,
#37):
unlinted-file:exempts a whole page from the bare-reference lint, wikilink handling and the reference-status scan — the blunt tool for a fixture-heavy or vendored document where a directive per code is maintenance without information. File-scoped only (backticks are already the narrow form; a bareunlinted:is reported as misuse), and the exemption is counted: the reference report lists every opted-out file and the lint prints the count, so the report stays a complete account of what nobody is checking (ADR-007). - Fixture codes get their own prefix
(ADR-034,
#38):
FXis registered as a remote whose every code resolves to the fixture-codes note in the directives doc, so an example likeFX-ADR-032is a first-class reference that needs nounresolved-okand can never collide with the real sequence. The template scaffold ships the same entry. Mechanizes what filing the real ADR-032 taught the hard way, when five directives using that number as a specimen went stale at once.
Added
- A hand-filed journal entry heals itself (ADR-031,
#33):
luria indexpopulates an emptycreated:from the entry’s path — the path is derived from the timestamp, so it is the one witness left — and the lint error names that remedy instead of asking a human to retype what the tree already states. A field that disagrees with the path is still an error: two witnesses in conflict is a judgement, not a mechanical fix.
Changed
- The status reports are committed views, and the README badges land on
them (ADR-032,
#35):
luria indexrendersdocs/reports/pending-decisions.mdanddocs/reports/reference-status.mdwith every other view, the lint fails when they are stale, and each badge links to the report that explains its number. Everything a report names is a link — the flagged decision, every citing line, every pending code. The reports carry no clock (ages read “open since”), because a committed view that embeds today’s date goes stale at midnight on every branch at once (DP-2). The default reportspath moves frombuild/doc-reportstodocs/reports;luria reportsstill writes them standalone for the CI artifact.
Changed
- Status enforcement is a dial (ADR-035,
#40), superseding
ADR-007’s “warnings, never able to fail a
build”: the warn-first posture stays the default, and
[luria.lint] fail_onpromotes named warning classes —retired-citations,unresolved-codes,hand-written-urls,stale-directives,pending-documents,unlinted-files— to lint failures. Only unacknowledged rows ever fail, soinactive-ok:and its siblings become the way to state a deliberate exception to a rule with teeth. An unknown class name infail_onis itself a lint error naming the vocabulary. The scaffoldedluria.tomldocuments the knob.
Changed
- The CLI is a tiered eight commands instead of a flat eleven
(ADR-030): six for contributors (
lint,link,index,journal,remotes,init) and two labelled as CI’s (reports,collect) inluria --help, the README and the scaffolded CLAUDE.md. The surface had been one command per module — the package layout projected onto the interface — and three of the names claimed workflows nobody had.
Removed
luria badges,luria ref-status,luria pending. Each was already subsumed:luria indexwrites the badges andluria lintchecks them (ADR-029); both status reports print as lint warnings and land in full in theluria reportsartifact (ADR-007, corrected to v2). Removed outright, not deprecated — a name that answers is a name that still exists, and there is no workflow to migrate. The modules keep their entry points (python -m luria.ref_status --allis still the interactive dig), and theref-statusandpendingmake targets are gone.
Added
- Luria: the project-memory machinery, extracted from
strata-g as a reusable package. The four
layers (ADR-001), the
fragment convention (ADR-002),
the generated decision index
(ADR-004), the
reference-hyperlink lint
(ADR-005), the
retired-document and pending-decision reports
(ADR-007), and the
inactive-ok/unexemptdirective vocabulary (ADR-008). luriaCLI —lint,link,index,ref-status,pending,reports,collect,init.luria lintis the only one that can fail.luria initscaffolds the record into a project that has none, and never overwrites: a scaffolder that clobbers is one nobody dares re-run.- Everything project-specific is configuration
(ADR-006): paths,
issue URL, code globs, fragment directories, and reference schemes. A second
scheme (RFC, SPEC) is a
luria.tomlentry and a directory.
Documentation
- The name. The package was very nearly
chester, after Chesterton’s Fence; ADR-010 records that reasoning and ADR-011 supersedes it — Luria, after The Mind of a Mnemonist, because the name should point at the faculty rather than at one failure it prevents, and because the book’s cautionary half (a memory that never forgets and never abstracts becomes unusable) is the design brief.
Fixed
- A literal
|in a decision’ssummary:(or status note) no longer breaks its row in the generated index and tag pages — the renderer escapes cell content, and normalises an author’s hand-escaped\|rather than double-escaping it (#14).
Added
- Fragment directories can declare a collection style
(ADR-028):
append(unchanged default — narrative order, marker at the end) orchangelog— one## <date>batch per collection inserted right after the marker, newest batch first, fragments newest-first within it, and a stub-only batch emits nothing rather than an empty date heading. Luria’s own changelog now collects in the changelog style.
Added
- Journals — dated entries that persist, rendered into one generated book
per period plus an index (ADR-020). Configure one
with
[luria.journals.<name>](dir,output,granularityofyear | month | day,title,blurb); entries live at<dir>/yyyy/mm/dd/hhmmss.md, so identity is the authoring timestamp and ordering is a property of the record rather than of commit order. luria journal new "A title"files an entry at the current timestamp, stepping forward a second on collision; bareluria journalreports what is filed and which books it renders to.make journalruns the latter.- Two lint checks: a journal entry’s path must agree with its
created:and it must carry atitle:; andversion:must agree withhistory:— a bumped version with nothing saying what changed is a silent revision wearing a version number (ADR-019).
Changed
- The devlog is now a journal, not a collected view.
docs/devlog.mdis replaced bydocs/devlog/README.mdand one book per month; entries are no longer consumed, so the view is regenerated byluria indexand a hand edit to it is a lint failure. The seven existing fragments were migrated with the timestamps of the commits that added them. luria initscaffolds the journal:template/luria.tomlgains[luria.journals.devlog], anddevlog.d/_template.mddocuments the entry shape rather than a branch-slug filename.Config.is_historical()is now the one place deciding which files are dated records and therefore out of scope forluria ref-status. It covers journals, whose entries are nested and which the previouspath.parenttest could not see.
Documentation
- ADR-002 and
ADR-012 corrected in place (v2, with
history:): both cited the devlog as an example of a collected view. Neither choice changed — ADR-012’s distinction is precisely what ADR-020 applied. docs/adopting.mdgains a section on adopting into a project that already has a devlog, including how to recover fragments’ real authoring times and the two traps in doing so (committer time zones, and links written for the old collected file’s directory).
Changed
- The README’s two record badges are counts now, not adjectives
(ADR-018). “generated index” and “versioned”
were assertions that could never be false; they are replaced by needs
decision (
Proposed+Deferred) and cited but retired (retired documents still cited without an acknowledgement). Zero is green, non-zero is amber — neither number is a failure. luria pendingcovers every scheme, not just decisions. AProposedprinciple is an open question in exactly the same way, and its rows are keyed by code (ADR-012,DP-004) rather than by ADR number.
Added
luria badges, andluria indexregenerates the counts into a<!-- luria:badges -->region. The numbers are baked into static shields URLs — no endpoint to configure and no committed JSON — andluria lintfails when the region disagrees with the record. Baked in rather than served means the count is correct per commit, so a pull request shows its own numbers rather than the default branch’s.
Added
- A cited code that names no document is now reported rather than silently
dropped (ADR-014). It shows up in
luria lint,luria ref-statusand the CI artifact. A warning, never an error — a typo, another project’s decision and an illustrative code look identical to a scanner, and only a human can tell them apart. unresolved-ok:retires a deliberate one, at the same three scopes asinactive-ok:and with the validity check inverted: it is malformed when it names a code that does resolve. Both counts are printed on a clean run, so “nothing to report” can never mean “everything was silenced”.- Badges on the README: CI status, Python version, licence, and links to
the two generated views. Plus the
LICENSEfilepyproject.tomlhas been claiming all along.
Fixed
- Ten stale references to the ancestor project’s numbering, left in ported
docstrings —
ADR-187,ADR-188,ADR-123andADR-158each cited a decision that says the right thing in the wrong repo. One was a link toadr-123-adr-status-vocabulary-docs-lint.md, a file that has never existed here; the reference lint skipped it because it was already a link. All found by the new report on its first run. - A code inside a URL is no longer read as a citation. Linking out to
another project’s decision is the correct way to name a foreign document, and
the URL contains its code — without this, the
luria inittemplate failed its own scaffolded lint the moment its comments pointed at Luria’s docs.
Changed
pip install luria→pip install git+https://github.com/dmarx/luriain the README and the adoption guide. The package is not on PyPI, and a README that ships a command which 404s is the drift this repo is about.
Changed
- A document’s filename is its code and nothing else —
ADR-013.md, notadr-013-a-documents-filename-is-its-code.md(ADR-013). A slug in the filename is a third copy of the title that no tool reads and that a rename plus every inbound link is needed to correct, so it never gets corrected. - The title moves into a
title:frontmatter field, which the generated index and principles document prefer over the body’s#heading. The heading falls back in — a project mid-adoption, or one that never adds the field, still renders a title rather than a blank cell. - Filename decoding lives on
Scheme(filename(),number_of(),documents()). Five separate places had grown their own regex for it.number_of()reads legacyadr-010-a-slug.mdnames too, so adopting Luria is not a rename-everything-first proposition.
Added
luria lintreports atitle:that disagrees with its body heading, and a missingtitle:. The heading has to stay — someone opening the file alone needs one — so the two copies get a guard rather than a merge: rung 2 of DP-3, since rung 1 isn’t available.tests/test_lint.py, covering the new check in both directions and across both schemes.
Changed
- The reference-status report stops calling a Proposed document “retired” (#63): the page is titled “Reference status”, its first section — “Documents cited while not in force” — spells out the not yet vs no longer split, exclusions read as “Not listed: N citations someone has already vouched for”, per-code tallies say which sites are marked deliberate instead of “acknowledged elsewhere”, and counts pluralize as prose. The README badge follows: “cited, not in force”.
Changed
- Both CLAUDE.mds are maps now, not copies
(ADR-037, part of
#45): a short list of links to
the authoritative docs, the invitation to run
luria --helpfor the current API, and three one-line ground rules — plus the statement that when the file disagrees with the docs or the CLI, the file is the one that’s wrong. The restated command block and doctrine walkthroughs are gone; they had drifted twice in one week, exactly as DP-3 predicts for hand-maintained copies. The scaffoldedtemplate/CLAUDE.mdgets the same treatment, mapping an adopting project instead of this one.
Removed
- The Makefile (ADR-038): its “run what
CI runs is
make <target>” doctrine stopped being true when ADR-029 moved the docs jobs into composite actions, leaving onemake testline wrapping pytest and a set of targets that restated CLI one-liners and drifted twice in a week. ci.yml runs pytest directly;luria --helpis the one list of what you can run.
Added
luria initspeaks up about a kept CLAUDE.md: it never overwrote existing files, but the one file an agent reads first deserved more than a silent skip — when CLAUDE.md exists, init now prints a pointer at the scaffolded map shape (links +luria --help) and suggests asking your agent to fold it in. The recommendation goes to stdout, where permission isn’t needed; the file is never touched.
Added
luria new [kind]scaffolds an entry anywhere the record takes one (ADR-036, #42): the journal by default, any configured scheme by prefix (luria new adrcopies_template.mdto the next free number and stamps the date), any fragment directory by name (luria new changelognames the file after the branch). It computes only what a machine can know, prints the path, and leaves the content to a markdown-aware editor;--title/--status/--summary/--tagsexist for tools driving the CLI, never as requirements. Kinds derive fromluria.toml, so a new scheme scaffolds for free. This fragment and its devlog entry were created with it.
Removed
luria journal— subsumed byluria newand removed without a shim (ADR-030);python -m luria.journalremains for the interactive look at what is filed.
Changed
- The CLI is driven by Fire (ADR-039,
proposed — this ships as a draft PR): every command is a plain typed
function (
<module>.run), flags and help derive from signatures and docstrings, and the hand-rolled dispatcher plus every module’s argparse layer are deleted (~150 lines). Failure is signalled bySystemExitonly — Fire prints return values, and a CI gate’s exit code is not output. Every existing invocation spelling (--fix,--check,--commit,new adr --title …) parses identically; help output becomes Fire’s house format.fire>=0.7joins PyYAML as a runtime dependency.
Added
- ADR-040: the migrations doctrine — how schemes
get renamed and documents move between them (mapping-driven sweeps,
formerly:as identity, full rewrite including history, a rung ladder from prose relabel toluria migrate). Doctrine only; the machinery lands per the ladder, starting with rung 1. - ADR-041: the bug protocol — a defect enters the record as an issue carrying a minimal working example before any fix, the response is classified on the ADR-035 ladder, and the fix PR turns the MWE into a regression test. First live run: the journal link-frame bug.
- DP-010: defaults follow the failure mode — guards ship on and are opted out of visibly at the site; disclosures ship off and are opted into by a config line; either deviation is written down where it applies.
Documentation
- Both CLAUDE.mds (this repo’s and the template’s) rewrite the hyperlink
ground rule as “never hand-write a link target” — bare codes and
[[CODE|label]]wikilinks, with the fixer owning every target because only it knows which render frame a target must resolve in — and add a fourth ground rule: a guard that keeps catching the same mistake is a bug report about the workflow, and the fix belongs upstream of the guard. Prompted by four wrong-frame links in one day, all hand-written, all wanting a prose label the (previously undocumented) labeled-wikilink syntax already provides. - Both CLAUDE.mds now open with a read-this-first directive: load the full design-principles document into context before anything else — the principles are the one part of the record the map assumes rather than links.
Added
- Drop-in CI for the record (ADR-029):
actions/generateregenerates the views, commits and pushes them as the bot, and outputs the SHA a checking job must read (fork PRs get a warning and an un-regenerated SHA instead of a 403);actions/lintrunsluria lintand uploads the status reports. Theluria inittemplate workflow is now the full recommended shape built from those actions — it previously scaffolded a verify-only lint, handing every new adopter a gate with nothing keeping it satisfied — and luria’s ownci.ymlruns the same two actions by local path, so the scaffolded workflow is the one this repository lives on (ADR-009). luria/ci.py: luria notices when it is being read in a build. Detection is crude on purpose (CIplus the vendor variables) and only ever changes what is said — no write and no exit code depends on it.
Fixed
- The staleness remedy now names the half that matters: the output has to be
committed.
stale — run luria indexis complete advice in a working copy and half an answer in a build. Under CI the message names both legitimate routes — regenerate locally, or give CI a generation job — and warns against the specific broken shape: the generator dropped into a checking job with nothing committing its output, which discards the result and leaves a followingluria lintcomparing the generator against itself (#21, #23). - Bare
luria badgessays on stderr that it only printed. As a- run:step it looked exactly like a write and exited 0 having done nothing (DP-1). Stdout is unchanged, so redirection still works.
Documentation
docs/adopting.md’s CI section leads with the scaffolded workflow and the two actions, and keeps what stays in the caller’s hands: the fork-safe checkout ref (a fork’s head branch does not exist in the base repo — the checkout fails before any push guard can help), theneeds:+shahandoff (aGITHUB_TOKENpush does not retrigger workflows), and the warning never to write GitHub’s skip markers into a commit message you author.