Context

Two features shipped in 0.25.0 and had never been used together.

alert (#273) is the sentence a vocabulary prints when a closed-set violation fires — “closed so every tag is one somebody chose, not because the list is finished”. label and blurb (#279) are what the vocabulary is at rest, and #279 put them in the central vocabularies: table, on the argument ADR-098 had already settled: a set two schemes share is declared once so the copies cannot drift.

alert was left on the field. That was not a decision; it is where the Vocabulary object happens to be built, and #273’s own docstring says the opposite — “attached to the vocabulary rather than the field so it rides the one rule it describes”.

The first record to adopt both found it. Writing the alert where the blurb goes was refused with a message about a value named values, because the nested form’s discriminator was spelled inline:

set(table) <= {"label", "blurb", "values"}

alert is not in that set. The message named three keys while the code allowed three and the dataclass carried four, and the failure it produced described a completely different fault.

Decision

alert is read from the central table, beside label and blurb. A vocabulary three schemes name carries one alert.

The key set is read off a dataclass that mirrors the table. VocabularyTable declares label, blurb, alert and the key holding the values; KEYS is {f.name for f in fields(VocabularyTable)}, and both the discriminator and the refusal message read it. _vocabulary_tables builds one and reads its attributes, so the schema is load-bearing rather than decorative and a fifth key reaches all three places in one edit.

The values live under terms:, not values:. A controlled vocabulary has terms; values was the commonest word for the things a vocabulary’s own entries are called, which is exactly why a project naming one of them values was plausible enough to need a refusal. Renaming does not remove the ambiguity — a vocabulary could name a term terms — but it moves the collision from a word every such table is about to one that is merely possible. 0.25.0 is the only release carrying the old spelling, it is hours old, and the sole record using it is on an unmerged branch, so this is a hard rename with no alias.

A TagGroup’s alert stays where it is. A group is declared inline, under the field whose values it constrains, and there is no central table for it to move to — the two are not inconsistent, they are different shapes of thing.

Alternatives considered

  • Add alert to the inline key set and leave it on the field. One character of the bug fixed and none of the cause. A vocabulary three schemes name would still need the sentence three times, which is the exact drift ADR-098 centralised vocabularies to prevent and #279 cited when it moved blurb.
  • Honour both spellings, field-level overriding the set. Two homes for one fact, which is what ADR-089 argues against upstream and what this project keeps deciding against. Nothing has released a record using the field-level spelling — 0.25.0 is hours old — so there is nothing to keep working.
  • A named constant, VOCABULARY_KEYS = frozenset({...}). What this PR shipped first, and what review rejected as inelegant — correctly. The argument for it was that Vocabulary also carries many, required, closed, default and required_when, all declared per field, so the keys a table may carry are not the fields that object has. That is true and it is an argument against deriving from Vocabulary, not against deriving at all: the answer is a second dataclass shaped like the table. Writing the set down by hand was the thing the ADR’s own opening paragraph says not to do.
  • OmegaConf.structured for the table, which omegaconf being already a dependency makes plausible. It works — merging a flat table against a nested schema raises ConfigKeyError, so try/except discriminates — and it is still the wrong trade here. It makes exception handling the control flow for an expected, common case; it replaces a message this project wrote and tested with omegaconf’s; and config.py hand-parses every other object it builds, so one structured table is a local inconsistency rather than a direction. Adopting structured configs across the whole loader is a real question and is filed as its own, not settled by a four-key table.
  • Status quo. The composition stays broken, and the message sends the next person looking for a value named values.

Consequences

The example in test_a_closed_vocabulary_can_print_its_own_advice declares its alert on the set now, which is also the first exercise of the nested form outside the tests written for it.

Fired on the real case, as the working agreement asks. anthology-of-the-sota declares topics with all three keys; the config loads, one declaration reaches SOTA, LIT and THEORY, and a bad tag prints:

record/practices.d/SOTA-001.md: `tags: kv-cache-paging` is not in the
`topics` vocabulary — the values are training-optimization, …
    ↳ Closed so that every tag is one somebody chose and blurbed, NOT
      because the list is finished. If your document wants a word this
      vocabulary cannot say, add it — …

That is #273’s whole purpose, reaching a real reader for the first time.

Left open: nothing here checks that a vocabulary’s alert is only meaningful where some field declares closed: true. An alert on an open vocabulary is inert rather than wrong, and a finding for it would want to know which of the naming fields is closed — worth a look if anyone writes one.