ADR-054: A scheme can constrain which of its tags combine

Status Active · Filed 2026-08-16

Context

tags.yaml declares a scheme’s tag vocabulary — a label, a blurb, an order. It says what each tag means. Nothing has ever said which of them may appear together, and an undeclared tag renders anyway, so the tag axis has been entirely unvalidated.

For most vocabularies that is right. Tags are usually a pile of labels and “this document is both record and process” is a fine thing to be.

But some vocabularies are axes. A downstream record adopting this package tags its arguments by strength — sound, overreach, invalid — where exactly one applies by construction, and by failure mode, where a sound argument cannot coherently also say how it fails. Its decision recording that vocabulary states both rules in prose.

Four documents later an argument shipped carrying a failure mode and no strength tag at all, and it was found by someone counting a summary table by hand. The rule had been written down the same day it was broken, and nothing checked it.

That is precisely the defect this package exists to object to elsewhere: a rule stated in a decision and enforced by nobody. It objects to it in prose that cites retired records and had no answer for it in its own tag vocabulary.

Decision

A scheme may declare tag groups:

[luria.schemes.ARG.tag_groups.strength]
tags = ["sound", "overreach", "invalid"]
require = "exactly-one"
 
[luria.schemes.ARG.tag_groups.failure]
tags = ["equivocation", "gap", "analogy", "circular"]
excluded_by = ["sound"]

require is any (the default — the group is a label, not an axis), at-most-one, or exactly-one. excluded_by names tags that forbid the whole group.

luria lint reports a violation per document per group, naming the group, the vocabulary, and what the document actually has.

Three details are deliberate.

Opt-in per scheme. A scheme declaring no group is unconstrained, so this is silent for every record that predates it. That follows requires (ADR-040), which is the closest existing feature: config declares an expectation, lint enforces it, and a project that declares nothing notices nothing.

A hard lint error, not a status class. A malformed tag set is a defect in the document like a title disagreeing with its heading, not a judgment about a document’s standing. fail_on exists for the judgments; declaring the group is the opt-in here, so a second dial would be one too many.

The rule name is validated at parse time. require = "one" raises a config error rather than silently constraining nothing. A misspelling that surfaced as “no violations” would be exactly the quiet failure this feature removes.

Alternatives considered

  • Put the constraint in tags.yaml. It is where the vocabulary lives, so it looks like the obvious home. Rejected on what the two files are for: tags.yaml is presentation — label, blurb, ordering — and luria.toml is where a scheme’s rules already live (active, render, allocate, requires, titles_generalize). A group is a rule. It would also need a reserved key in a map whose keys are otherwise tag names, and a project with a tag called _groups would be surprising to debug.
  • A closed flag — every tag used must be declared. Cheaper, and catches a different bug: typos, not cardinality. sound misspelled as sould would be caught by both; an argument with no strength tag at all only by this. Worth having eventually, and not a substitute.
  • Leave it to each project’s own tests. What the downstream record did, and it works — but it puts a check on the record’s shape outside the tool that owns record shape, so every adopter reinvents it, and none of them get it reported alongside the other lint findings.
  • Status quo. The tag axis stays unvalidated, and a vocabulary that is an axis stays a convention. Free, and it already failed once.

Consequences

A project can now make a tag vocabulary structural rather than advisory, which makes tags usable for things they were not safe for before — a required owner, a lifecycle stage, a mutually exclusive severity.

The failure mode to watch is over-constraining. A group with exactly-one on a vocabulary that turns out to be a pile will fail lint on documents that are fine, and the fix is to relax the config rather than to bend the record. Tag groups are cheap to declare and should be declared late, once a vocabulary has demonstrably become an axis — which is the same rule this project already applies to adding a principle.

Nothing here validates that a grouped tag is declared in tags.yaml. A group may name a tag no document uses and no tags file describes, and that is deliberate for now: the closed flag above is the check that would catch it, and bundling the two would decide a question this decision does not need to.