Context
Three formats for one system:
luria.toml TOML the config
tags.yaml YAML per scheme, beside the records
statuses.yaml YAML per scheme, beside the records
<vocabulary>.yaml YAML per scheme, declared by name ([ADR-076](ADR-076.md))
remotes.lock.json JSON generated, committed
Every document’s frontmatter is already YAML. The config was TOML for no
reason either format could state, which is one more set of quoting rules to
know and one more parser to reason about when a regex in a uid does not mean
what it looks like.
The vocabulary split was worse than untidy. A scheme could only name a file beside its own records, so a vocabulary two schemes share had to be two files. Measured across the two records running on luria at the time of writing:
- Four byte-identical copies of one
statuses.yaml—ADRandDPin this repository, and the same pair again inanthology-of-the-sota. - 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 it was two files with the same thirteen keys and ten of thirteen blurbs different. Two edits to one vocabulary, months apart, that nothing could reconcile because nothing knew they were the same vocabulary.
This project already knew. primary_tags’ own docstring records the identical
failure from the other direction: “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, because the config had no way to say “these are the same words”.
Decision
One luria.yaml. TOML is gone rather than deprecated — see the
alternatives.
A central vocabularies: table, referenced by name:
vocabularies:
statuses:
Active: {label: Current, blurb: in force}
schemes:
ADR:
fields: {status: {vocabulary: statuses}}
DP:
fields: {status: {vocabulary: statuses}} # said once
One place names it. status is a field, so fields.status.vocabulary
is where its vocabulary is named and there is no second
schemes.X.statuses: beside it. This change shipped with both, and they
were not merely redundant: statuses.declared read the scheme key while
statuses.undeclared read the field, so a scheme could render a status
legend and be reported as having no status check at the same time. What
stays privileged is active: — WHICH word means in force, the role the
whole citation apparatus rests on — and that names a word, not a
vocabulary. tags: stays a scheme-level key for the opposite reason: it
is the one axis the code still assumes, with no fields. spelling to
collide with.
A vocabulary’s per-value pages render at <view>/<name>/, so its name is
part of a published path. Luria’s own shared vocabulary is called
statuses rather than something more descriptive for exactly that reason:
renaming it would move docs/decisions/statuses/ and leave the old
directory behind as an orphan no generator claims.
Scheme.tags_yaml and Scheme.statuses_yaml — paths — become Scheme.tags
and Scheme.statuses, which return values. Vocabulary.file becomes
Vocabulary.values_by_name. Nothing downstream opens a vocabulary file
because there is no longer one to open.
omegaconf goes underneath the validation, not in place of it.
OmegaConf.merge replaces the hand-rolled _merge over DEFAULTS, and the
schema types the result. The 44 raise ValueErrors stay, and should: they are
cross-field semantic rules — a converse must be mutual and point back at the
declaring scheme, a derivation’s from must name a real reference,
cite = "view" needs render = "document" — and a structured config
validates the shape of a value, not a relationship between two.
label has one fallback. It had three — tag.title() in the tag pages,
"" in the status legend, the raw value in the vocabulary pages — so a scheme
declaring no label rendered a title-cased tag heading and an empty legend
cell. vocabularies.label_of is the one answer.
Reference gains label and blurb (#254), the two keys a vocabulary
value already carried. A relation’s meaning lived in a TOML comment, which
nothing could render, quote in a finding, or scaffold from — which is how a
record ended up with fourteen practices citing adoption as evidence while
every mechanical check stayed green.
Two YAML libraries, and each has one job. omegaconf composes a config:
OmegaConf.merge folds a project’s file onto the defaults and the schema
types the result. ruamel edits one: luria init adds a scheme, luria upgrade wires up a field, luria migrate renames one, and all three rewrite
a file somebody wrote and has to keep reading.
Neither library does the other’s job, and the failure in each direction is
silent. OmegaConf.merge drops every comment — right for building a config
out of defaults, wrong for editing one on disk, where the comments are most
of what the file is. Editing the text instead keeps the comments and gets the
structure wrong: TOML tables concatenate, so a [schemes.RFC] appended to a
file belonged to schemes wherever it landed, while YAML nests by
indentation, so the same block appended joins whichever top-level key happens
to be last — and RFC: occurs under schemes: and under every
remotes.<R>.schemes:, so a sweep cannot tell one from the other either.
This change was written the second way first, and all four sites had the bug.
luria/yaml_edit.py is the one place that knows how a config is edited:
ruamel in round-trip mode, four operations, comments intact and the
structure addressed by path. It is also the only emitter — the TOML
converter writes through it too, so a converted config starts in the shape
every later edit produces and a one-key change never reads as a rewrite.
tags is a field too, and the axis is named rather than assumed. This
was the last thing the code knew by name, and it held out for two stated
reasons:
tagsstays: it is OPEN, and a vocabulary is closed by construction (ADR-054 deferred even aclosedflag), and itstag_groupsconstrain a subset of values, which a vocabulary cannot express.
Both are true, and both are things a declaration can say once it is allowed
to. Vocabulary gains closed (default true; tags sets it false — the
values a project has an opinion about supply order, label and blurb, and
reaching for a new one stays an edit to a document). tag_groups moves from
schemes.X.tag_groups to fields.<field>.groups, which is where it always
belonged: a group constrains a subset of ONE field’s values, and the
scheme-level table could only ever have meant tags. The check read
meta["tags"] whatever the group was about, so a group on any other field
saw nothing and passed every document — a rule that looked enforced.
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 about a
scheme, and one a world-bible answers differently from an anthology. 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.
BUILT_IN_AXES is gone, BUILT_IN_CONDITION_FIELDS is ("status",) —
status alone, and only because it has a default vocabulary no scheme has to
declare — and the lint’s “no tags: in frontmatter” branch is required: true on the axis field, checked with every other obligation.
Two smaller things went with it. many: true now 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 that is exactly what being built in used to say about
tags. And axis values are no longer lower-cased on the way in — a tags
convention the code applied to every value, which disagreed with the
vocabulary check beside it that has always compared the value as written.
One renderer for every field a view groups by. With tags a declared
field, the two renderers wrote into the same directory — so they could not
both stay. vocabularies.py renders all of them; tag_order,
render_categories and render_tag_page are gone.
They had drifted, which is the argument for collapsing rather than choosing. Three ways:
- The label fallback. Already 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 printed**Label** — blurb. One template now:**Label** — blurb. - A value the vocabulary does not declare. The axis showed it, every
other field dropped it. Neither was reasoning about it;
closedis, 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 —
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.
This changes published pages, once. No path moves. A value page’s
heading goes from # ADRs tagged \record`to# ADRs with `tags`
`record`, its blurb gains the label, and a declared value nobody uses now gets a row reading (0)` and a page of its own, where the axis used to
omit it and every other field already showed it.
A vocabulary-backed field’s pages render under the FIELD’s name, not the
vocabulary’s. That was the last thing to go wrong here and the least obvious:
pages rendered at <view>/<vocabulary>/, so sharing a vocabulary between two
schemes — the entire point of this decision — moved published pages and left
the old directory behind as an orphan. What that broke said nothing about
paths: the bare-reference check skips generated views and recognises them by
generated-output path, so an orphan is not one, and pages generated for a
year started being scanned as hand-written prose.
A vocabulary’s name is a config detail; a published path is not. Keyed on the
field, no config change can ever move a page. This moves existing pages
once — <view>/statuses/ becomes <view>/status/ — which is the right
place to spend it: at a format boundary a record crosses deliberately, rather
than in a later release where it would surprise someone.
Alternatives considered
-
Keep vocabularies beside the records. The locality argument: a scheme’s vocabulary is the one piece of config a person editing that directory actually reads. It was mine, it was speculation about a user, and the user reported the opposite — the files were hard to locate. The drift evidence above settles the rest.
-
Central by default, with a per-scheme path override. The compromise, and close to what
tags_filealready was. Rejected because the override is the thing that permits the drift: two schemes can point at one file today and simply do not. A mechanism nobody reaches for is not a mechanism. -
Deprecate TOML over a release or two, dual-reading both. The careful path, and normally right. Rejected on instruction — and the cost is real and bounded: two known records, both converted in this change.
-
Let structured configs replace the hand-written validation. Tempting, since it looks like the point of adopting them. It would trade luria’s errors — “a converse names the field holding the same relation read backwards, and it has to exist to be written into (declared: …)” — for a schema complaint about a key of the wrong type, at exactly the moment a user is confused. The plumbing was worth replacing; the checks are the part that earns its keep.
-
Key the view path on the vocabulary’s name, and have the converter keep the old stem. It would move nothing in the common case. It fails exactly where this decision is aimed: a record whose five schemes each have a
statuses.yamlwith different words cannot keep all five on the namestatuses, so four move anyway — and the rule “your pages move unless your vocabularies happen not to collide” is not one anybody can hold. -
Edit configs with omegaconf, since it is already a dependency. One library instead of two, and it composes correctly. Rejected because it drops comments, and a command that silently deletes a person’s notes from their own config has taken more than it was asked for. The two libraries are not redundant; they answer different questions.
-
Edit configs by rewriting the lines, to keep the comments. What this change did first, in four places. It keeps the comments and puts the block in the wrong mapping, which is worse: a lost comment is visible in the diff, and a scheme that quietly became a journal is not.
-
Leave
tagsbuilt in and generalize the rest. Where this change stopped the first time, on the two reasons quoted above. Rejected once they turned out to be missing features rather than facts:closedwas deferred, not impossible, and a group naming its own field is strictly more expressive than one that cannot. The tell was the group check readingmeta["tags"]— a rule that could only be written for one field was silently wrong for any other. -
Derive the axis instead of naming it (the one open, many-valued field). It works until a scheme has two, and then it picks one without saying so. A rendering decision that a reader cannot find in the config is the failure this whole decision is about.
-
JSON for the lockfile stays. It is generated, not authored, and machine round-tripping is the only thing it is for. Unifying it would be consistency for its own sake.
Consequences
luria lint runs end to end on this repository under the new config, and
ADR and DP now share one status vocabulary declared once — the duplication
that motivated this is gone from the record making the decision.
This is a breaking change with no migration path in-tree. Every existing
record has a luria.toml and per-scheme vocabulary files. A converter is
owed, and the hazard it has to own is escaping: uid = "(\\d{4})[.:](\\d{4,5})"
does not survive TOML → YAML by copying bytes, and that is the kind of thing
that converts silently and breaks at runtime. Round-tripping every uid and
title_re in a real record is the test, not a fixture.
primary_for remains the right mechanism and gets better: it was a way for
one file to say which schemes a tag is primary for, and it now says that
inside the vocabulary every one of those schemes actually shares.