ADR-NNN: A field can be required by another field’s value
Context
Every scheme has a status vocabulary and a lint that checks it. Nothing anywhere records what would move a document from one status to another.
The consumer project measured what that costs. Across 144 practices, four
stated a promotion condition; all four stated it in prose; none of the four
was re-read when the evidence arrived. Eight practices sit at Proposed and
one at Deferred, so more than half the provisional entries in that record
say they are provisional and say nothing about what provisional is waiting
for. A reader cannot tell a document genuinely blocked on a specific result
from one filed hesitantly and forgotten, because the two render identically.
Then the predicted thing happened. Two practices filed a day apart carried near-identical prose conditions — promote on an independent result. One paper satisfied both, on the same day. Only one was acted on, and only because a person happened to be reading both bodies that afternoon: on inspection the paper evaluated one of them and merely cited the other. The condition was invisible until somebody stumbled over it, and once found it turned out to be counting papers when what mattered was what the papers were about.
Only the first half is machinery’s to fix. But the first half is what makes the second invisible: a condition nobody rereads is never revised.
Decision
A field may be required conditionally on another field’s value.
[luria.schemes.SOTA.fields.promote_when]
required_when = { status = ["Proposed", "Deferred"] }
required stays the unconditional flag and the two are exclusive — declaring
both is a config error, because the condition would say nothing.
Three consequences of putting it where it is:
It is a property of the field, not of the status. required_when reads
as “this field is meaningful only while something is pending”, which is a
fact about the field, and it generalises to conditions with nothing to do
with status. The fields table already existed to declare a field’s shape
and type; a rule about when the field applies needs no type at all, so a
table declaring only required_when is a plain field — any truthy value,
demanded under its condition.
Every check reads it through one method. Field.demanded(meta) replaces
every direct read of field.required. A conditional requirement honoured by
one check and ignored by the next is exactly the failure the compiled
contract (#141) exists to end, and the fix is that there is one place to
ask.
The compared value is the field’s effective value, not its raw one. Resolution goes through the compiled contract — the same path the checks and the record page use — so a condition sees what the rest of the machinery sees. Three consequences worth stating, because each is a decision:
- A
status:carrying a qualifying note is still that status, and the note is its own field (ADR-072), soProposed — pending a replicationmatchesProposed. Comparing raw strings would have read a qualified status as some other status and exempted the document silently. - A vocabulary field with a
defaultis never absent (ADR-076), so a document that omits it reads as the default. Reading raw frontmatter made a condition on such a field never hold, for precisely the documents it was written about. - A list-valued field —
tags— matches on any element, and an absent field is “the condition does not hold” rather than an error. The missing-field finding belongs to the document check, not to the condition.
The condition itself is validated at load. Shape alone was not enough,
and this decision’s own reasoning is why: a condition that can never hold
surfaces as no violations. { staus = [...] } and { status = ["proposed"] }
are the two likeliest authoring mistakes; both are refused, against the
fields the scheme can name and — where a closed set exists — the values that
field takes.
Alternatives considered
Hang the requirement off the status vocabulary — statuses.yaml naming
which fields each status demands. Reads well for exactly this case and badly
for every other: statuses.yaml is a vocabulary file, and making it a
requirements file too gives it two jobs. It also puts the rule somewhere the
person declaring the field would not think to look.
One field per condition, with the repetition foreseen. The rule is
declared per field, so a scheme with three fields that all apply while
provisional — promote_when, blocked_by, retracted_because — restates
{ status = ["Proposed", "Deferred"] } three times, and the three sets can
drift apart. That is the duplicated-projection shape DP-003 names, and it is
expected rather than overlooked. It is not an argument for moving the
rule to the status side, which the next paragraph rejects for reasons that
do not weaken as the count grows. The fix when it arrives keeps the
field-side placement: either a field_groups entry that carries a
required_when for its members, or a condition named once and cited by
field. Neither needs building for one field.
A general predicate language — negation, conjunction, comparison across fields. Rejected. The entire value of this rule is that a reader sees it in the line that declares it; a config that can state arbitrary predicates is one nobody reads at a glance. One field against a set of literal values is enough for every case anyone has produced. A second real case can argue for more, which is a better way to learn the shape than guessing at it now.
A machine-checkable condition — promote_when = { independent: 2 },
verified against the reference graph. Tempting and wrong. A condition a
machine can check is necessarily a count, and counting is the exact error
that produced this decision: the two practices’ conditions were satisfied,
by the letter, by a paper that did not bear on one of them. A condition that
can be met automatically is a condition that can be met accidentally.
Require the field of every document, not just the conditional ones. Most documents would carry a key with nothing to put in it, and a field that is usually empty is a field readers learn to skip.
Deferred: the condition that outlives its status
A promote_when: still sitting on a document that has since gone Active is
a condition whose purpose is over — the same shape as a directive that no
longer matches anything, which this record already reports as
stale-directives. Nothing here reports it, and that is a choice rather than
an oversight.
Not an error, because the old condition can be worth keeping: what the
document was waiting for is part of why it was promoted, and deleting it on
promotion loses that. A warning class on the fail_on dial (ADR-035) is the
shape that fits — reported, acknowledgeable, enforced only by a project that
wants it. It is deferred because one field on one scheme is not enough
evidence to design the acknowledgement around, and a class that fires on
every deliberate case is a class people switch off.
The consumer record’s own rule — remove the field when the practice goes
Active, and put what happened in the body and the version history — is for
now a convention, which is exactly the kind of thing this decision exists to
say should eventually be a check.
What this does not do
It cannot check that a condition is a good condition — that it names a kind of evidence rather than a quantity of it. Nothing mechanical can, and a check that tried would ratify bad conditions by passing them. What it does is make the condition exist, as a field, on the document, where the next person to touch that document reads it without needing the coincidence.