Skip to content

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.

CodeRefusal
PAGE_NOT_SELECTABLEthe selection gate's own refusal, in the gate's words (D-119)
PASSAGE_NOT_VERIFIED_TEXTthe passage occurs in no page's verified text
SELECTION_POSITION_TAKENthat field already carries a selection at that position
SPILL_BRANCH_CONDITION_CONFLICTthe branch was recorded with a different condition, or none
RESTRICTION_UNRECONCILEDa Restriction has not been reconciled against this span (D-44)
RECONCILIATION_OUTCOME_INVALIDthe outcome is neither carried-as-caveat nor resolved-not-applicable
CAVEAT_ON_NOT_APPLICABLEa caveat accompanies RESOLVED_NOT_APPLICABLE
RESTRICTION_NOT_IN_DOCUMENTthe Restriction belongs to another document
RESTRICTION_RESOLVED_TWICEone Restriction resolved twice in one selection
RESTRICTION_SPAN_DOCUMENT_MISMATCHthe Restriction and the Source Span are in different documents
RESTRICTION_IS_THE_SPANa Restriction resolved against itself records that nothing was considered
RECONCILIATION_EXISTSalready 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 because message stays 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

UpstreamLanded inEvidenceNote
D-119D-136the selection gate's refusal is PAGE_NOT_SELECTABLE, worded in the gate's own termsADR-0052; the rule that could not say its name
D-44D-136the unreconciled-Restriction refusal is RESTRICTION_UNRECONCILED, and six further codes carry the reconciliation rules around itADR-0022
D-116D-136SPILL_BRANCH_CONDITION_CONFLICT is the refusal for the null-condition branch rule; a blank stays refusedADR-0050
D-134D-136the one-list-gated-across-both-repositories instrument is reused here for the codesADR-0060, ruled the same day