Config overhaul: one YAML file, vocabularies declared once - #257
Conversation
Three formats for one system - TOML config, YAML vocabularies beside each scheme's records, JSON lockfile - and a vocabulary two schemes share had to be two files. Measured across the two records running on luria: four byte-identical copies of one statuses.yaml (ADR and DP here, the same pair again in anthology-of-the-sota), and in that record a topic vocabulary its ADR-026 DECIDED two schemes share, sitting on disk as two files whose blurbs differ in ten of thirteen entries. primary_tags' own docstring records the identical failure within a scheme (ADR-060); between schemes it was unreachable, because the config had no way to say these are the same words. - luria.yaml replaces luria.toml. One format, one set of quoting rules. - A central vocabularies: table, referenced by name. Scheme.tags_yaml and .statuses_yaml (paths) become .tags and .statuses (values); Vocabulary.file becomes .values_by_name. Nothing opens a vocabulary file because there is no longer one to open. - OmegaConf.merge replaces the hand-rolled _merge over DEFAULTS, and types the result. The 44 semantic checks stay: a structured config validates the shape of a value, not a relationship between two, and luria's errors are better than a schema complaint. - label has one fallback instead of three (tag.title(), '', and the raw value), so a scheme declaring none no longer gets a title-cased heading and an empty legend cell. - Reference gains label and blurb (#254) - the two keys a vocabulary value already carried. This repo's own config is converted: ADR and DP now share one status vocabulary, declared once. luria lint runs end to end on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BuxSz1G5aEoaQYfKSToenz
The engine landed in the previous commit; this is what it took to make the rest of the tree speak the same config. - tests: TOML fixtures converted to YAML. Composition changed shape rather than spelling - TOML tables concatenate and YAML mappings do not, so 'BASE + extra' became merged(BASE, extra) with a deep merge in tests/_config.py. That is the better shape anyway: a test that says which keys it adds says something a reader can check. - examples/: all seven projects converted. Three of them had a vocabulary two schemes shared as two files - constitution (PRACTICE and BOUNDARY on VALUE's statuses), knowledge-base (SOTA on LIT's tags) and rfcs-and-specs (SPEC on RFC's statuses). Each is now one declaration. 46/46 pass. - init.py scaffolds YAML, and no longer writes a statuses.yaml per scheme: the template declares the vocabulary once and every scheme names it. Appending a scheme table now inserts INSIDE the schemes mapping, because an indented block at the end of a YAML document attaches to whatever the last top-level key happens to be - silently, and wrongly. - luria/migrate_config.py converts a downstream record. It parses with tomllib and writes with yaml rather than moving bytes, which is what makes a regex in a uid survive; and it leaves the TOML in place, because deleting what you just converted before anyone has read the result is not a migration anybody should trust. Suite: 861 passing, 257 failing, from 345 at the low point. The remainder is fixture conversion in about fifteen files, not library behaviour. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BuxSz1G5aEoaQYfKSToenz
Also restores two contract fixtures the codemod silently truncated: a
fragment whose first lines were bare keys put them at the document root,
where parsed.get('luria', parsed) discarded them. The tests asserting on
'requires' plus a reference were passing a fragment that had lost its
requires.
Status: 1106 passing, 11 failing (was 345 / 773 at the low point)Pushed. The tail is nearly closed and the remainder is a short, characterised list — including one item I deliberately stopped on rather than hack. Done since the draftThe test corpus, via four AST codemods plus hand work where the shape resisted:
A codemod bug worth knowing aboutA fragment whose first lines were bare keys — What is still failing (11)
plan.section_renames.append((config_file, f"[luria.schemes.{old}]", f"[luria.schemes.{new}]"))A fully-qualified TOML header is unambiguous. A YAML key is not:
Still not done
One environment noteomegaconf is a new hard dependency and it does not install on a Debian-patched setuptools — Generated by Claude Code |
Brings the frontmatter cache (#251), the rate-limit fail-fast (#252) and cross-scheme converses (#255), all merged since this branch opened. One conflict, in _checked_converses: #255 made the converse resolve against the scheme whose codes the field holds, and this branch had renamed the file in the same message. Both, obviously - the lookup is #255's.
A fully-qualified TOML header was unambiguous. A YAML key is not: FXL: appears under schemes: and again under every remotes.<R>.schemes:, and renaming this project's scheme has to leave another project's mirror alone. - rename_key_at() renames a mapping key only where it sits under a given parent path, recovering the path from indentation. Line editing rather than a round-trip through the parser: a config carries the comments a person wrote, and a migration that strips them has taken more than it was asked for. - config_paths_pass() was section-aware via a TOML header regex, so under YAML nothing was ever frozen and an unclaimed remote's own document: path got swept with everyone else's. It now recovers the section the same way. Both were silent: the rename simply did not happen, and the over-sweep rewrote a path belonging to another project.
A vocabulary's per-value pages render at <view>/<name>/, so its name is part of a published path. Calling it record-statuses moved docs/decisions/statuses/ to docs/decisions/record-statuses/ and left the old directory behind as an orphan no generator claims - which is how a generated page stopped being recognised as generated and started being scanned for bare references. Suite is green: 1143 passed, 33 skipped.
Every config example in docs/ and in the config.py docstrings that docs/configuration.md is generated from is YAML, and the inline TOML table spelling [luria.x.y] is now the path it names, x.y. Prose says luria.yaml. Also acknowledges the Proposed ADR where code and fixtures cite it, so 'cited, not in force' is back to zero. Suite: 1143 passed, 33 skipped, 0 failed. luria lint clean on this record.
Green, and out of draft: 1143 passed, 33 skipped, 0 failed
The three real bugs, and they were one factA TOML table header is fully qualified. A YAML key is not. [luria.remotes.SG.schemes.FXL] # says exactly where it is FXL: # says only "one level in from here"Everything that edited a config as text was built on the first fact.
Same fact broke the fixtures ( The last thing to go wrong, which said nothing about pathsThe shared vocabulary was called What that broke: the bare-reference check skips generated views, and recognises them by generated-output path. An orphan is not one — so pages generated for a year started being scanned as hand-written prose, and every code in them was reported bare. A vocabulary's name is a published path. It is called I only found this because DocsEvery config example in Also fixedA codemod bug worth recording: a fragment whose first lines were bare keys put them at the TOML document root, where Two things still for youomegaconf does not install on a Debian-patched setuptools —
Generated by Claude Code |
The converter existed as luria/migrate_config.py and was not reachable: not imported, not in COMMANDS, and - after a blanket rename - reading luria.yaml and parsing it as TOML. Dead code that would have been broken on arrival, for the one job a record on the old format actually needs. It belongs in upgrade, whose own docstring says an upgrade repairs the config the new version refuses to load. That is exactly this: the new version does not read TOML at all, so a record that has not crossed cannot be linted, indexed or repaired until it does. It gets the mechanism that comes with that home - a SUNSET entry saying what has to be true before it is deleted, --dry-run, and the spent-upgrade lint - and 'luria upgrade statuses' now refuses on a record still on TOML instead of reporting nothing to do. Verified against the real thing rather than a fixture: converting anthology-of-the-sota's own config collapses DP's statuses into ADR's (the same words, one entry), leaves its two drifted tag vocabularies as two, and the result loads with ARXIV's uid still matching 1904.10509. Also: a vocabulary-backed field's pages now render under the FIELD's name. They rendered under the vocabulary's, so sharing a vocabulary - the point of this change - moved published pages and orphaned the old directory, which is how a generated page stopped being recognised as generated and started being scanned for bare references. A name is a config detail; a path is not. Moves <view>/statuses/ to <view>/status/ once, here, at a boundary a record crosses deliberately. 1153 passed, 33 skipped, 0 failed. luria lint clean, 0 unacknowledged.
|
The three commands that rewrite a config a person wrote — `luria init` adds a scheme, `luria upgrade` wires up a field, `luria migrate` renames one — were editing the text. Each had the same bug in a different place, and every one of them was silent. TOML tables concatenate: `[schemes.RFC]` appended to a file belonged to `schemes` wherever it landed. YAML nests by indentation, so the same block appended joins whichever top-level key happens to be last — `luria init --schemes RFC` could put a scheme in `journals:`, and every reader would agree it was one. `schemes.VP.statuses: x` appended is a key literally called "schemes.VP.statuses". And `FXL:` occurs under `schemes:` and under every `remotes.<R>.schemes:`, so a sweep cannot tell a rename's target from a bystander. Neither obvious library fixes this alone. `OmegaConf.merge` composes values correctly and drops every comment, which is right for building a config out of defaults and wrong for editing one on disk. Line surgery keeps the comments and gets the structure wrong. `luria/yaml_edit.py` is ruamel in round-trip mode: four operations, addressed by path, comments intact — and the one place that knows how a config is edited. It is also the only emitter now. The TOML converter writes through it, and the configs in this repo are normalized into the shape it emits, so a converted config starts where every later edit leaves it and a one-key change never arrives as a whole-file reflow. `config_paths_pass` keeps its textual rewrite — a rename reaches into comments and into the middle of string values, neither of which is a node to reassign — but takes its section boundaries from the parser's line numbers instead of counting indentation itself. 1164 passing; 11 of them new, one per failure mode the old line editing produced and a parser cannot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BuxSz1G5aEoaQYfKSToenz
Review on #257: a scheme carried both `statuses: X` and `fields.status.vocabulary: X`. Either `status` is special and `statuses:` is the wiring, or it is a field like any other and the field table is enough. The code already answered, in `_facets`' own comment: *"`status` is nameable because it is a declared vocabulary since #181, arriving through `vocabularies` like any other field. Only `tags` is still an axis the code assumes."* So `schemes.X.statuses` is gone and `statuses_vocab` is derived from `fields.status.vocabulary`. It was not merely redundant — the two keys could disagree, and the two readers already did. `statuses.declared` (the legend, `active`'s check) read the scheme key while `statuses.undeclared` (is anything checking this field at all) read the field, so a scheme could render a status legend and be reported as having no status check at the same time. Every config in the tree had both saying the same thing, which is why nothing caught it. What stays privileged is `active:` — WHICH word means in force, the role the whole citation apparatus rests on. That names a word, not a vocabulary. `tags:` stays scheme-level for the opposite reason: it is the one axis the code still assumes, with no `fields.` spelling to collide with. The key is refused rather than ignored, with a message naming where it goes. Silently dropping a vocabulary somebody named is the same failure one step later. `luria upgrade yaml` gains the other half of this: a record that folded a `statuses.yaml` but never declared `fields.status` — which is every record predating #181, the ones this converter exists for — now gets the field wired to the vocabulary it just folded. Carrying the words across with nothing pointing at them would lose the vocabulary while reporting success. 1167 passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BuxSz1G5aEoaQYfKSToenz
The last thing the code knew by name. It held out for two reasons the
comment beside `BUILT_IN_AXES` states plainly:
`tags` stays: it is OPEN, and a vocabulary is closed by construction
(ADR-054 deferred even a `closed` flag), and its `tag_groups` constrain
a *subset of values*, which a vocabulary cannot express.
Both true, and both missing features rather than facts.
`Vocabulary` gains `closed` (default true). `tags` sets it false: the
declared values supply order, label and blurb, and reaching for a new one
stays an edit to a document rather than to the config. That is ADR-054's
deferred flag, written.
`schemes.X.tag_groups` becomes `fields.<field>.groups`, where a group
constrains the field it is declared under. This was not only tidier — the
check read `meta["tags"]` whatever the group was declared about, so a group
on any other field saw nothing and passed every document. A rule that
looked enforced and constrained nothing is the exact failure tag groups
exist to remove.
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}
`axis` is not `tags` renamed. It says which of this scheme's own fields
gets the categories block and a page per value — a rendering choice a
world-bible answers differently from an anthology, validated eagerly
against the fields the scheme declares. A scheme naming none has no
taxonomy and renders none, which the old code could not express: every
scheme had `tags`, and every document was told to carry them.
Gone with it: `BUILT_IN_AXES`; `tags` from `BUILT_IN_CONDITION_FIELDS`,
which is now `status` alone and only because it has a default vocabulary no
scheme has to declare; the `"tags"` literal in the plural set; the carve-out
refusing `fields.tags`; and lint's "no `tags:` in frontmatter" branch, which
is `required: true` on the axis field, checked with every other obligation.
Two smaller things followed. `many: true` types a field on its own — it
says the field holds a list, which is what makes it nameable in a chain or
a derivation, and is exactly what being built in used to say about `tags`.
And axis values are no longer lower-cased on the way in: that was a `tags`
convention applied to every value, and it disagreed with the vocabulary
check beside it, which has always compared the value as written. Every tag
in this record is already lower-case, so nothing here moves.
`luria upgrade yaml` writes the axis, the field and the moved groups, so a
record crosses without hand-editing. No published path changes: every
scheme's axis is `tags`, and the pages render under the axis's own name.
1177 passing; ten new in tests/test_axis.py.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuxSz1G5aEoaQYfKSToenz
|
`tag_order`, `render_categories` and `render_tag_page` are gone; `vocabularies.py` renders the axis with everything else. Once `tags` became a declared field, the two renderers wrote into the same directory — so they could not both stay. They had drifted, which is the argument for collapsing rather than picking one. Three ways: - The label fallback. One answer since `vocabularies.label_of`, but the tag page reached it and the value page reached it differently. - The blurb. The tag page sentence-cased it with a hand-rolled `raw[:1].upper()` — `str.capitalize()` having silently destroyed a blurb running to more than one sentence — while the value page rendered `**Label** — blurb`. - A value the vocabulary does not declare. The axis showed it; every other field dropped it. Neither was reasoning about it. `closed` is, and a field naming no vocabulary is open for the plainer reason that there is no closed set to fall outside of. What is left of the difference is a shape the *scheme* chooses, not one the code knows: the axis lists the documents under each value because it is the browsing surface, and every other field is a row of chips because the value's own page already holds the table. `Scheme.grouped_fields` is the one answer to which fields those are, axis first. Three places needed it — which directories the generator owns, which are exempt from the docs index, which paths count as generated — and a fourth disagreeing with them is how a page becomes an orphan. Axis first because it heads the index, and the `fields:` table is written in whatever order reads best. Published pages change once; 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. The row carries no colon when there is no list to introduce. adr_index.py loses 58 lines net. 1183 passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BuxSz1G5aEoaQYfKSToenz
The two renderers are one —
|
Draft — the engine is converted and working, the test corpus is not. Status and remaining work at the bottom; please read that before reviewing depth-first.
Closes #254 and #256 when finished.
What is done
One
luria.yaml. TOML is gone, not deprecated.tomllibis out ofconfig.py,upgrade.pyandremotes.py.A central
vocabularies:table, referenced by name — this is the part with a measured bug behind it:Scheme.tags_yaml/.statuses_yaml(paths) become.tags/.statuses(values);Vocabulary.filebecomes.values_by_name. Nothing downstream opens a vocabulary file, because there is no longer one to open.omegaconf underneath the validation, not in place of it.
OmegaConf.mergereplaces the hand-rolled_mergeoverDEFAULTSand types the result. The 44raise ValueErrors stay — they are cross-field semantic rules, and a structured config validates the shape of a value, not a relationship between two. Trading luria's errors for a schema complaint about a key of the wrong type would make the tool worse exactly when a user is confused.Referencegainslabel/blurb(#254) — the two keys a vocabulary value already carried.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 nolabelgot a title-cased heading and an empty legend cell.The evidence for centralizing
Four byte-identical copies of one
statuses.yamlacross the two records —ADRandDPhere, the same pair again inanthology-of-the-sota.And the one that matters: that record's ADR-026 decided its practice registry and reading list share one topic vocabulary, and says so in its CLAUDE.md. On disk, two files, same thirteen keys, ten of thirteen blurbs different. Two edits to one vocabulary, months apart, that nothing could reconcile because nothing knew they were the same vocabulary.
luria already knew this failure mode from the other direction —
primary_tags' docstring records "seven terms across four places, and the blurbs for the same tag already disagreed between two of them (ADR-060)". ADR-060 fixed the copies within a scheme; the copies between schemes were unreachable.This repo's own config is converted, and
ADR/DPnow share one vocabulary declared once.Verification so far
luria lintruns end to end on this repository under the new config. Its only findings are three stale links in generated views, which regenerate.luria.toml; the lint run above is why I believe the library itself is sound, but I have not audited all 556.What is NOT done — do not merge
BASE + extraneeds restructuring rather than a codemod. 8 sites are converted; the rest are not.init.pystill writes a commentedluria.tomlplus per-scheme vocabulary files. Its generator is a rewrite, not a port.migrate.py's spec reader still usestomllib(unreached by the lint run).config_doc.py— the generated configuration reference — is unreviewed against the new shape.docs/*.mdstill sayluria.tomlin ten files.anthology-of-the-sotacannot upgrade without one, and the hazard it must own is escaping:uid = "(\\d{4})[.:](\\d{4,5})"does not survive TOML → YAML by copying bytes. Round-tripping everyuid/title_rein a real record is the test, not a fixture. The throwaway script I used for this repo is not that converter.ADR-tmp8hp25is here; the other two are not.Roughly: the design is settled and demonstrated, and the long tail of mechanical conversion is ahead. I stopped here rather than push a half-migrated tree without saying so.
ADR-tmp8hp25carries the decision and the rejected alternatives — keeping vocabularies local (withdrawn on @dmarx's evidence), central-with-an-override (rejected: the override is what permits the drift), dual-reading both formats (rejected on instruction), and letting structured configs replace the semantic checks.Generated by Claude Code