Appearance
ADR-0062: A curation refusal names the rule that fired
Status: Accepted Date: 2026-09-20
Decisions
D-136 Every refusal from select-span and reconcile-restriction carries an errorCode, and the codes are one list held by the API and gated across both repositories
A curator surface words the refusal itself from the code; message stays English developer text and is never shown. A blank required field remains a 400 carrying no code, matching what a publish refusal already does. A committed fixture carries the list, and the frontend reads that fixture rather than retyping the strings.
Context
Both commands refuse with a bare BadRequestError. A curator who is told only that the write was refused has to read the server log to learn which rule fired — on the write path that decides what a Handler will later read in an emergency. The selection gate's own refusal (D-119) and the reconciliation rule (D-44) are the two most consequential rules in the product, and neither can currently say its own name.
A publish refusal already carries a code (ticket 42), so this is not a new mechanism. It is the same mechanism applied to the two commands that were left out.
Holding the list once follows the same reasoning as the Section Headings ruling (D-134) and the snapshot seam (D-124): a string the frontend retypes is a string that drifts, and the drift shows up as a refusal worded for a rule that no longer exists.
The timing matters. Pages 39 and 40 are built and awaiting review. Reviewing them against the generic refusal and then again against the coded one touches both pages twice, so the code lands first and the review reads the finished state.
Decision
select-span and reconcile-restriction refuse with an errorCode naming the rule that fired. The API owns the list, a committed fixture carries it, the frontend reads the fixture, and a curator surface does the wording.
The starting list
The API owns this list and may correct it against the code; whatever it lands on is what the fixture carries.
| Code | Refusal |
|---|---|
PAGE_NOT_SELECTABLE | the selection gate's own refusal, in the gate's words (D-119) |
PASSAGE_NOT_VERIFIED_TEXT | the passage occurs in no page's verified text |
SELECTION_POSITION_TAKEN | that field already carries a selection at that position |
SPILL_BRANCH_CONDITION_CONFLICT | the branch was recorded with a different condition, or none |
RESTRICTION_UNRECONCILED | a Restriction has not been reconciled against this span (D-44) |
RECONCILIATION_OUTCOME_INVALID | the outcome is neither carried-as-caveat nor resolved-not-applicable |
CAVEAT_ON_NOT_APPLICABLE | a caveat accompanies RESOLVED_NOT_APPLICABLE |
RESTRICTION_NOT_IN_DOCUMENT | the Restriction belongs to another document |
RESTRICTION_RESOLVED_TWICE | one Restriction resolved twice in one selection |
RESTRICTION_SPAN_DOCUMENT_MISMATCH | the Restriction and the Source Span are in different documents |
RESTRICTION_IS_THE_SPAN | a Restriction resolved against itself records that nothing was considered |
RECONCILIATION_EXISTS | already reconciled; a named curator's judgement is not overwritten |
Rejected options
- Accepting the generic refusal permanently — rejected because it puts a curator on a safety write path in the position of reading the server log to learn which rule fired. That is not a workaround; it is the refusal failing to do the one thing a refusal is for.
- Reviewing pages 39 and 40 against the generic state first — rejected because both pages then get touched twice, once to read a state that is already known to be leaving.
- Wording the refusal on the server and showing
message— rejected becausemessagestays English developer text and the curator surface is where wording belongs. A server that words refusals is a server deciding what a person reads. - Letting the frontend hold its own copy of the codes — rejected for the reason the Section Headings ruling gives (D-134): a retyped string drifts, and here it drifts into a refusal worded for a rule that no longer fires.
This ruling may not be re-decided
If a change contradicts this ADR: stop and raise it. Do not implement over it.
Specifically: do not refuse from either command without a code, do not display message to a curator, do not give a blank required field a code, and do not retype the code strings in the frontend instead of reading the fixture.
Consequences
What becomes true. A refused curation write says which rule refused it, in the curator's own surface and language. A code removed or renamed in the API fails a test in the frontend.
What this costs. A fixture to keep committed and regenerate, and a code to choose for every future refusal — the list is now something a new rule has to join. Pages 39 and 40 wait for the codes before their review.
What is closed off. A bare BadRequestError from either command, and a second copy of the code strings.
Coverage
| Upstream | Landed in | Evidence | Note |
|---|---|---|---|
| D-119 | D-136 | the selection gate's refusal is PAGE_NOT_SELECTABLE, worded in the gate's own terms | ADR-0052; the rule that could not say its name |
| D-44 | D-136 | the unreconciled-Restriction refusal is RESTRICTION_UNRECONCILED, and six further codes carry the reconciliation rules around it | ADR-0022 |
| D-116 | D-136 | SPILL_BRANCH_CONDITION_CONFLICT is the refusal for the null-condition branch rule; a blank stays refused | ADR-0050 |
| D-134 | D-136 | the one-list-gated-across-both-repositories instrument is reused here for the codes | ADR-0060, ruled the same day |