ADR-076: A frontmatter field can be backed by a scheme-local controlled vocabulary
Status Active · Filed 2026-09-03 · Issue #141
Context
A record built for narrative world-building adopted Luria and reported the first measured needs from outside the project’s original domain. One of them fits nothing the configuration can say.
Its scenes carry a field:
worlds:
- A
- Con 37 of 75 entries. The values come from a closed set of six trajectories. An absent field means world B, by a convention every author knows and no reader can discover. And the record has an immediate consumer waiting: a view per world, the way the decision index has a page per tag.
Nothing in the record’s vocabulary holds this field.
- It is not a reference. The values are not codes; there is no
document for
Ato resolve to, and making one is the alternative priced below. - It is not a tag.
tags:is the browsing pile, open by design (ADR-054 deferred even aclosedflag), and the scenes already carry topical tags. Folding six world values into the same list makes one field mean two things, which is the smell the modeling guide names as the reason to split a scheme — and it cannot say absent means B. - It is not a status. A status is one word from a closed set of five (ADR-003); this is a set of values from a closed set the project defines.
What it is has two precedents in the scheme directory already.
statuses.yaml (ADR-056) is a closed single-valued vocabulary backing the
status: field; tags.yaml is an open multi-valued vocabulary backing
tags:. Each pairs a frontmatter field with a scheme-local YAML file that
says what the values mean, and each was built as a special case. The world
record is the third instance, and it is the one that shows the pattern.
The compiled contract (#142) is where this lands. A Field today carries
required, a reference scheme or none, and since ADR-075 a many
shape. A reviewer of that work observed that reference: str | None is a
type wearing a boolean’s clothes: Any, Ref[Scheme], and now a third
case. This decision adds the third case and names the type.
Decision
A scheme may declare a frontmatter field backed by a controlled
vocabulary. The declaration is explicit, in a fields table under the
scheme, keyed by the frontmatter field and naming what it holds:
[luria.schemes.SCENE.fields.worlds]
vocabulary = "worlds" # what the field holds: values from worlds.yaml
many = true # a list of values; default false, one value
required = false # default false; required + many means non-empty
default = ["B"] # the effective value when the field is absentfields is the table a field’s shape and type are declared in, and
vocabulary is the one type it takes today. requires and references
stay as they are, the spellings for the other two kinds of field, until a
decision consolidates them here; a field declared in both fields and
references is an error. The table was chosen over a parallel
vocabularies table because the contract’s Field already has one shape
with a type in it, and a reader of fields.worlds learns the field’s kind
from its one type key rather than from which of three tables it sits in.
The field and the vocabulary may be named differently — world: drawn
from worlds.yaml — and usually are not.
The values live beside the records, in worlds.yaml in the scheme’s
directory, shaped exactly like tags.yaml:
A:
label: The unbroken line
blurb: the trajectory where the treaty holds
B:
label: The default
blurb: where most scenes sit; an absent field means thisThe file is the vocabulary; the TOML table is the wiring. That split is the
one ADR-054 and ADR-056 already draw — meanings in YAML with the records,
rules in luria.toml — and the placement rule from #141 says the same: a
value’s label is explained by pointing at the value; many and default
are facts about the field.
The vocabulary is closed. A value not in the file is a lint finding,
naming the file. This is the statuses.yaml posture, not the tags.yaml
one: a controlled vocabulary that accepts unknown values is a pile of
labels, and the record has tags: for those. The declaration is validated
at load, eagerly, the way a tag group is: a vocabulary whose file names no
values, a default the file does not contain, a default of the wrong shape,
and a required beside a default (a field with a default is never
absent, so required would say nothing) are all configuration errors. The
built-in axes — status, tags — cannot be redeclared here; they have
their own files and their own rules.
A default is an effective value, not a rewrite. An absent worlds: is
read as ["B"] by every consumer — the lint, the index, the graph — and
the source file is left absent. Two things follow. The convention becomes
discoverable where the contract renders: the record page’s what an entry
must carry lists worlds — one or more of A, B, C, D, E, F; absent means B, and a finding about the field cites the same line. And default is
distinct from required = false: optional means no value is a meaningful
state; default means no value written is this value.
The compiled contract grows a second typed case. What a field holds is now one of three things — anything, a code from a scheme, or a value from a vocabulary:
Field
name
required
many
reference: SCHEME | None what `references` compiles to
vocabulary: NAME | None what this table compiles to
values, default the vocabulary's members and its defaultWritten as two optional attributes rather than one sum type, because that
is the smallest change that keeps every consumer honest; the day a fourth
case arrives, the three collapse into one holds. The lint, the record
page and the site read the field; the edge derivation ignores it — no
edges are derived from a vocabulary field, because its values are not
nodes.
The index renders a page per value. For an index-rendered scheme,
luria index writes <view>/worlds/A.md beside the tag pages, listing the
entries whose effective value includes A, and the scheme’s index links
the set the way it links the tag pages. This follows the existing axis
rather than adding a switch: declaring the vocabulary is the opt-in, and
the consumer is the reason the record declared it.
Alternatives considered
- Tags plus a tag group. Put the six values in
tags.yaml, declaretag_groups.worldsover them, and the tag pages are the per-world views for free. Everything works today except the two facts that matter: a group cannot say absent means B, andtags:now carries two axes in one list — a reader of a tag page cannot tell a world from a topic, and the world record has both. This is the cheapest option and it is the shape the record arrived in flight from. - A
WORLDscheme, cited by a plural reference. Six documents,worlds = { scheme = "WORLD", many = true }, and the typed edges’ Cited asworldsby on each world page is the per-world view, unbuilt. It fails the record’s own test for what deserves a code (the modeling guide’s identity, standing, relation): a world has identity and relation but no standing. Six documents whose status never varies is exactly whatinert-status(ADR-057) reports, and the values would spell asWORLD-002where the author writesB. It also cannot express the default. - Inline values in the TOML table (
values = ["A", "B", …]). One file, no sidecar. Rejected on the split above: the values want labels and blurbs, and those belong beside the records where every other vocabulary keeps them. An inline list is the vocabulary written once in TOML and again wherever it is described — the drift DP-3 names, and the exact duplication ADR-060 removed for tags. - Implicit wiring: any
NAME.yamlbeside the records declares a field. Zero configuration, and a stray file becomes a schema change nobody wrote down. The two existing files are recognised by name; a third convention should be declared, andfields.worldswith its type key tells a reader what kind of thingworldsis before they open the file. - A
vocabulariestable besiderequiresandreferences. What the draft of this decision proposed: a table per kind of field, matching the tables a reader already knows. Rejected on review, before the draft merged:statusandtagsare themselves vocabulary-backed fields, the contract has oneFieldshape with a type in it, and a fourth parallel table is a fourth spelling of one thing — the two-grammar cost ADR-063 names, paid a table at a time.fieldsis the normal form; the older two tables consolidate into it when the field-typing work lands, and adding a third to consolidate later was the wrong direction. - Write the default into the source.
luria indexfillsworlds: [B]into the 38 records that omit it, and the convention disappears. This is ADR-031’s move — populate what the tree states — and it does not apply: a default is not a fact the tree states, it is a convention the config states, and rewriting 38 files to carry it is the record constraining the project (DP-14). - Status quo. Thirty-seven records carry a field nothing validates and thirty-eight carry a convention nothing renders.
Consequences
What this buys: a third axis with the same guarantees as the first two —
declared once, checked by the lint, explained on the record page, browsed
by value — and a contract whose Field is honest about what it holds.
statuses.yaml and tags.yaml stop being special cases in the reader’s
model even if they stay special in the code for a while: a closed
single-valued vocabulary and an open multi-valued one are two settings of
the same thing.
What it costs: a third file kind in a scheme directory, a fourth table
under a scheme, and a Field type that every consumer has to switch on
where it used to test one attribute. The index grows a directory per
vocabulary; a scheme with three vocabularies has three, beside tags/.
The measured need is one record. The issue’s own acceptance criterion asks that a semantic addition improve at least two unrelated domains before it is trusted; the anthology has not reported a facet, and this repository has none. That is the strongest argument for the draft flag: the shape is priced from one corpus, and the verdict should say whether one is enough.
Questions the implementation settled
Filed as open questions on the draft, each taken as the draft answered it
and proved on the world-bible example, which is the reporting record’s
shape in three scenes:
- Pages per value: automatic, or declared? Draft: automatic, because
tag pages are, and declaring the vocabulary is the opt-in. The other
reading is that a rendering choice belongs to the consumer and wants a
renderkey on the table. - Closed, or open with a
closedflag? Draft: closed. An open controlled vocabulary istags:. - May a default be a list? Draft: it takes the field’s shape — a list
for
many = true, a scalar otherwise. - Does the default render on the entry? Draft: on the record page and in findings only; the site’s record line shows written values, each linked to its page, and never the effective ones, so a reader is never shown a field the file does not have. The alternative is Worlds: B (default) on every page.
- Sidecar path: fixed
NAME.yaml, or afilekey liketags = …? Draft: fixed name, with afilekey added the day two schemes share a vocabulary — the same sequencetags.yamlwent through (ADR-060).