Skip to main content

choir_node/
reject.rs

1//! Rejections that name the repair.
2//!
3//! An agent that is told "stale head" learns that something failed. An
4//! agent told *what* was expected, *what* was found, and *which* of a
5//! small set of actions is admissible can act without a human. Two 2026
6//! studies (arXiv:2607.14167, arXiv:2606.05037) measure a large gain in
7//! agent task success from errors that name the repair, and both isolate
8//! it to naming **admissible alternatives** — not to verbosity, and not
9//! to JSON rather than prose. The effect was null on at least one small
10//! model, so the magnitude is directional; the mechanism is what this
11//! implements.
12//!
13//! # Why this is not on the sequencer seam
14//!
15//! `SubmitPolicy::check` still returns `Result<(), String>`. Reason
16//! codes and next actions are properties of the *API surface*: they say
17//! what an HTTP client should do next. Pushing them into L2 admission
18//! would make the sequencer carry transport shape, and `SubmitPolicy` is
19//! implemented by things with no HTTP surface at all. So the policy
20//! renders a [`Rejection`] into its reason string, and the HTTP boundary
21//! decodes it — [`Rejection::decode`] falls back to a plain message for
22//! any reason that did not come from here, so nothing is lost when a
23//! rejection originates elsewhere.
24//!
25//! Shape follows RFC 9457 (`application/problem+json`) loosely: `type`
26//! becomes the stable `code`, `detail` becomes `error`. It is not served
27//! as `application/problem+json` because every existing client and test
28//! reads `error` out of an ordinary JSON body, and changing the media
29//! type would break them for no gain an agent can use.
30//!
31//! The symptom-first companion to the code table below:
32//!
33#![doc = include_str!("../../../docs/reference/troubleshooting.md")]
34
35/// Stable, machine-readable rejection reasons.
36///
37/// A code is a contract: clients branch on it, so renaming one is a
38/// breaking change and adding one is not. Every variant is listed in
39/// `ERRORS.md` with the action a client should take.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum Code {
42    /// The signature names a key id this node has no record of. Says
43    /// nothing about the signature itself, which is not checked once the
44    /// key is missing.
45    UnknownKey,
46    /// The payload did not decode as a `ViewOp`.
47    MalformedOp,
48    /// The request body was missing fields or badly encoded.
49    MalformedRequest,
50    /// A verdict or comment claimed an attribution other than the
51    /// signed channel.
52    ReviewerMismatch,
53    /// The signing key is bound to a different channel name.
54    ChannelNotOwned,
55    /// Only the node's own key may author this operation.
56    NodeOnly,
57    /// The node assigns reviewers; a self-named list was refused.
58    AssignmentRequired,
59    /// The target ref is protected and needs a node-drawn reviewer list.
60    ProtectedRef,
61    /// The target ref lacks the required independent approval weight.
62    ReviewRequired,
63    /// The target ref is protected and cannot be deleted.
64    RefUndeletable,
65    /// Compare-and-swap failed: the state moved under the submission.
66    StaleHead,
67    /// A review-op precondition failed (duplicate id, unknown review,
68    /// already assigned, archived).
69    ReviewState,
70    /// A provenance record was missing a subject or kind.
71    ProvenanceState,
72    /// A stable change was unknown, duplicated, archived, or mismatched.
73    ChangeState,
74    /// A workspace lifecycle request conflicted with its durable binding.
75    WorkspaceState,
76    /// A key-binding precondition failed (a key already bound to another
77    /// operator, a revoked or unbound key, a channel naming a different
78    /// operator).
79    IdentityState,
80    /// A vouch precondition failed (an end with no unrevoked binding, a
81    /// self-vouch, an edge that already stands, or a withdrawal of one
82    /// that does not).
83    VouchState,
84    /// A witness precondition failed (a witness with no unrevoked
85    /// binding, a cosignature over anything but the latest ref-state
86    /// attestation, or one that witness has already made).
87    WitnessState,
88    /// The operator's protected-ref list could not be read, so the gate
89    /// failed closed.
90    PolicyUnavailable,
91    /// Requested log entries are older than anything this node can serve.
92    LogEvicted,
93    /// These exact signed bytes have already been admitted.
94    DuplicateSubmission,
95    /// This node requires a signed scope and the op carried none.
96    ScopeRequired,
97    /// The op was signed for a different node's log.
98    ForeignScope,
99    /// The scoped head is no longer recent enough to admit.
100    StaleScope,
101    /// A per-user quota (D37) was already full, or this request was
102    /// larger than one is allowed to be.
103    QuotaExceeded,
104    /// The signature does not verify under the key it names, which this
105    /// node does trust. Distinct from [`Code::UnknownKey`] because the
106    /// repairs are opposites: that one widens the trusted set, this one
107    /// must not.
108    BadSignature,
109    /// Anything that did not originate as a structured rejection.
110    Unclassified,
111}
112
113impl Code {
114    /// The stable wire string. Written out rather than derived from the
115    /// variant name so renaming the Rust identifier cannot silently
116    /// change the contract.
117    #[must_use]
118    pub fn as_str(self) -> &'static str {
119        match self {
120            Self::UnknownKey => "unknown_key",
121            Self::MalformedOp => "malformed_op",
122            Self::MalformedRequest => "malformed_request",
123            Self::ReviewerMismatch => "reviewer_mismatch",
124            Self::ChannelNotOwned => "channel_not_owned",
125            Self::NodeOnly => "node_only",
126            Self::AssignmentRequired => "assignment_required",
127            Self::ProtectedRef => "protected_ref",
128            Self::ReviewRequired => "review_required",
129            Self::RefUndeletable => "ref_undeletable",
130            Self::StaleHead => "stale_head",
131            Self::ReviewState => "review_state",
132            Self::ProvenanceState => "provenance_state",
133            Self::ChangeState => "change_state",
134            Self::WorkspaceState => "workspace_state",
135            Self::IdentityState => "identity_state",
136            Self::VouchState => "vouch_state",
137            Self::WitnessState => "witness_state",
138            Self::PolicyUnavailable => "policy_unavailable",
139            Self::LogEvicted => "log_evicted",
140            Self::DuplicateSubmission => "duplicate_submission",
141            Self::ScopeRequired => "scope_required",
142            Self::ForeignScope => "foreign_scope",
143            Self::StaleScope => "stale_scope",
144            Self::QuotaExceeded => "quota_exceeded",
145            Self::BadSignature => "bad_signature",
146            Self::Unclassified => "unclassified",
147        }
148    }
149
150    /// Every code, for the docs generator and the coverage test.
151    #[must_use]
152    pub fn all() -> &'static [Code] {
153        &[
154            Self::UnknownKey,
155            Self::MalformedOp,
156            Self::MalformedRequest,
157            Self::ReviewerMismatch,
158            Self::ChannelNotOwned,
159            Self::NodeOnly,
160            Self::AssignmentRequired,
161            Self::ProtectedRef,
162            Self::ReviewRequired,
163            Self::RefUndeletable,
164            Self::StaleHead,
165            Self::ReviewState,
166            Self::ProvenanceState,
167            Self::ChangeState,
168            Self::WorkspaceState,
169            Self::IdentityState,
170            Self::VouchState,
171            Self::WitnessState,
172            Self::PolicyUnavailable,
173            Self::LogEvicted,
174            Self::DuplicateSubmission,
175            Self::ScopeRequired,
176            Self::ForeignScope,
177            Self::StaleScope,
178            Self::QuotaExceeded,
179            Self::BadSignature,
180            Self::Unclassified,
181        ]
182    }
183}
184
185/// A refusal, with enough for a client to decide what to do next.
186#[derive(Debug, Clone, PartialEq, Eq)]
187pub struct Rejection {
188    /// Stable machine-readable reason.
189    pub code: String,
190    /// Human-readable detail. Kept named `error` because that is what
191    /// every existing client and test already reads.
192    pub error: String,
193    /// What the check required, when the check compared two states.
194    pub expected: Option<String>,
195    /// What it found instead.
196    pub actual: Option<String>,
197    /// The admissible next action, in the imperative. **This is the
198    /// field the research isolates the gain to**, so it is required
199    /// rather than optional: a rejection that cannot name a next action
200    /// is a rejection whose author has not finished thinking.
201    pub next: String,
202}
203
204impl Rejection {
205    /// A rejection with no state comparison.
206    #[must_use]
207    pub fn new(code: Code, error: impl Into<String>, next: impl Into<String>) -> Self {
208        Self {
209            code: code.as_str().to_string(),
210            error: error.into(),
211            expected: None,
212            actual: None,
213            next: next.into(),
214        }
215    }
216
217    /// Adds the two states a failed comparison was between.
218    #[must_use]
219    pub fn with_states(mut self, expected: Option<String>, actual: Option<String>) -> Self {
220        self.expected = expected;
221        self.actual = actual;
222        self
223    }
224
225    /// This rejection as a JSON value.
226    #[must_use]
227    pub fn to_json(&self) -> serde_json::Value {
228        let mut v = serde_json::json!({
229            "code": self.code,
230            "error": self.error,
231            "next": self.next,
232        });
233        // Absent rather than null: a client checking `expected` should
234        // find nothing when no comparison happened, not a null to
235        // special-case.
236        if let Some(e) = &self.expected {
237            v["expected"] = serde_json::json!(e);
238        }
239        if let Some(a) = &self.actual {
240            v["actual"] = serde_json::json!(a);
241        }
242        v
243    }
244
245    /// Renders for the `Result<(), String>` seam.
246    #[must_use]
247    pub fn encode(&self) -> String {
248        self.to_json().to_string()
249    }
250
251    /// Recovers a rejection from a reason string.
252    ///
253    /// A reason that did not come from [`Rejection::encode`] becomes an
254    /// [`Code::Unclassified`] rejection carrying the original text, so a
255    /// caller always gets the same shape and no message is ever dropped
256    /// on the floor.
257    #[must_use]
258    pub fn decode(reason: &str) -> Self {
259        let unclassified = || Self {
260            code: Code::Unclassified.as_str().to_string(),
261            error: reason.to_string(),
262            expected: None,
263            actual: None,
264            next: "read the message; this path does not yet name a repair".to_string(),
265        };
266        let Ok(v) = serde_json::from_str::<serde_json::Value>(reason) else {
267            return unclassified();
268        };
269        let field = |k: &str| v.get(k).and_then(serde_json::Value::as_str);
270        // All three required fields or nothing: a half-decoded rejection
271        // would report a code it cannot back up.
272        match (field("code"), field("error"), field("next")) {
273            (Some(code), Some(error), Some(next)) => Self {
274                code: code.to_string(),
275                error: error.to_string(),
276                expected: field("expected").map(String::from),
277                actual: field("actual").map(String::from),
278                next: next.to_string(),
279            },
280            _ => unclassified(),
281        }
282    }
283
284    /// The HTTP body: the rejection as JSON.
285    #[must_use]
286    pub fn body(&self) -> String {
287        self.encode()
288    }
289}
290
291/// Maps a `ViewError` onto a rejection, unpacking the states it already
292/// carries rather than stringifying them into prose.
293#[must_use]
294pub fn from_view_error(e: &choir_view::ViewError) -> Rejection {
295    use choir_view::ViewError;
296    match e {
297        ViewError::StaleHead {
298            target,
299            expected,
300            actual,
301        } => Rejection::new(
302            Code::StaleHead,
303            format!("compare-and-swap failed on {target}"),
304            "re-read GET /api/view for the current value, rebase your intent on it, and resubmit \
305             with the new prev",
306        )
307        .with_states(
308            expected.as_ref().map(choir_oplog::ContentHash::to_hex),
309            actual.as_ref().map(choir_oplog::ContentHash::to_hex),
310        ),
311        ViewError::Review(msg) => Rejection::new(
312            Code::ReviewState,
313            msg.clone(),
314            "read GET /api/view `reviews` for this id's current state; a review that is already \
315             assigned, complete, or archived does not accept the op you sent",
316        ),
317        ViewError::Provenance(msg) => Rejection::new(
318            Code::ProvenanceState,
319            msg.clone(),
320            "resubmit with a non-empty subject and kind",
321        ),
322        ViewError::Change(msg) => Rejection::new(
323            Code::ChangeState,
324            msg.clone(),
325            "read GET /api/view `changes` for the current owner, workspace and revision; use a \
326             new change id or checkpoint from the reported revision",
327        ),
328        ViewError::Identity(msg) => Rejection::new(
329            Code::IdentityState,
330            msg.clone(),
331            "not a retry: a key belongs to one operator for its lifetime and a revoked key is \
332             never rebindable, so bind a fresh key instead",
333        ),
334        ViewError::Vouch(msg) => Rejection::new(
335            Code::VouchState,
336            msg.clone(),
337            "read `vouches` in GET /api/view; both ends need an unrevoked key binding, and an \
338             edge that already stands is withdrawn rather than repeated",
339        ),
340        ViewError::Witness(msg) => Rejection::new(
341            Code::WitnessState,
342            msg.clone(),
343            "read `witnessed` and `snapshot` in GET /api/view: a witness cosigns the latest \
344             ref-state attestation and no other, so re-read the snapshot and sign that one",
345        ),
346        ViewError::Decode(msg) => Rejection::new(
347            Code::MalformedOp,
348            format!("decode failed: {msg}"),
349            "serialize the op with the same ViewOp version the node runs; see GET /llms.txt",
350        ),
351        other => Rejection::new(
352            Code::Unclassified,
353            format!("{other:?}"),
354            "retry once; if it persists the node has a problem the client cannot fix",
355        ),
356    }
357}
358
359impl Code {
360    /// One-line meaning, for the `ERRORS.md` table.
361    #[must_use]
362    pub fn meaning(self) -> &'static str {
363        match self {
364            Self::UnknownKey => "The signature names a key id this node has no record of",
365            Self::MalformedOp => "The payload did not decode as a `ViewOp`",
366            Self::MalformedRequest => "The request body was missing fields or badly encoded",
367            Self::ReviewerMismatch => "A verdict or comment claimed an attribution other than the signed channel",
368            Self::ChannelNotOwned => "The signing key is bound to a different channel name",
369            Self::NodeOnly => "Only the node's own key may author this operation",
370            Self::AssignmentRequired => "This node assigns reviewers; a self-named list was refused",
371            Self::ProtectedRef => "The target ref is protected and needs a node-drawn reviewer list",
372            Self::ReviewRequired => "The target ref lacks the required independent approval weight",
373            Self::RefUndeletable => "The target ref is protected and cannot be deleted",
374            Self::StaleHead => "Compare-and-swap failed: the state moved under the submission",
375            Self::ReviewState => "A review-op precondition failed (duplicate id, unknown review, already assigned, archived)",
376            Self::ProvenanceState => "A provenance record was missing a subject or kind",
377            Self::ChangeState => "A stable change was unknown, duplicated, archived, or mismatched",
378            Self::WorkspaceState => "A workspace lifecycle request conflicted with its durable binding",
379            Self::IdentityState => "A key-binding precondition failed (key already bound to another operator, revoked or unbound key, channel naming a different operator)",
380            Self::WitnessState => "A witness precondition failed (a witness with no unrevoked key binding, a cosignature over anything but the latest ref-state attestation, or one that witness has already made)",
381            Self::VouchState => "A vouch precondition failed (an end with no unrevoked key binding, a self-vouch, an edge that already stands, or a withdrawal of one that does not)",
382            Self::PolicyUnavailable => "The operator's protected-ref list could not be read, so the gate failed closed",
383            Self::LogEvicted => "Requested log entries are older than anything this node can serve",
384            Self::DuplicateSubmission => "These exact signed bytes already landed; a signature is admissible once",
385            Self::ScopeRequired => "This node admits only ops signed for its own log and a recent head, and this op carried no scope",
386            Self::ForeignScope => "The op was signed for another node's log",
387            Self::StaleScope => "The head the op was signed against is no longer in the node's recent window",
388            Self::QuotaExceeded => "A per-user quota was already full, or this request was larger than one is allowed to be",
389            Self::BadSignature => "The signature does not verify over these bytes, under a key this node does trust",
390            Self::Unclassified => "A rejection that did not originate as a structured one",
391        }
392    }
393
394    /// What a client should do on receipt. One row of `ERRORS.md`.
395    #[must_use]
396    pub fn action(self) -> &'static str {
397        match self {
398            Self::UnknownKey => "Ask the operator to register your public key.                 `choir key <file> <you>` prints the line; it takes effect on the next request.",
399            Self::MalformedOp => "Serialize a `ViewOp` and sign its bytes. `choir submit` does                 this correctly; `GET /llms.txt` lists the operations.",
400            Self::MalformedRequest => "Send a JSON object with the fields the endpoint wants.                 `GET /llms.txt` lists them.",
401            Self::ReviewerMismatch => "Resubmit on your own channel. `choir verdict` and                 `choir comment` sign on the attribution name by construction, so use them rather                 than hand-rolling.",
402            Self::ChannelNotOwned => "Submit on the channel your key is bound to — it is in                 `expected`. Or ask the operator to bind a key to the channel you want.",
403            Self::NodeOnly => "Nothing to retry: this operation is the node's to author. For                 reviewer assignment, request a review with an empty reviewer list.",
404            Self::AssignmentRequired => "Resubmit with an empty reviewer list. The node draws                 reviewers and returns their names in the response.",
405            Self::ProtectedRef => "Resubmit with an empty reviewer list. On a protected ref only                 a node-drawn list is accepted.",
406            Self::ReviewRequired => "Open a review naming this ref and commit                 (`choir review ... --ref <repo:ref>`), obtain approvals from two distinct                 operators, then push again.",
407            Self::RefUndeletable => "Do not delete this ref, or ask the operator to remove it                 from the protected-ref list.",
408            Self::StaleHead => "Re-read `GET /api/view`, rebase your intent on the value in                 `actual`, and resubmit with that as `prev`. If you are retrying a submission                 whose response you lost, check for `already_applied` first — a completed retry                 answers 200, not this.",
409            Self::ReviewState => "Read `reviews` in `GET /api/view` for this id. A review that                 is already assigned, complete, or archived does not accept the op you sent.",
410            Self::ProvenanceState => "Resubmit with a non-empty subject and kind.",
411            Self::ChangeState => "Read `changes` in `GET /api/view`, then use its owner, workspace and revision or choose a new change id.",
412            Self::WorkspaceState => "Read `changes` and `workspaces` in `GET /api/view`; retry only with the exact existing binding, or choose a new workspace name.",
413            Self::IdentityState => "Read `bindings` in `GET /api/view` for this key. Not a retry:                 a key belongs to one operator for the life of the key, and a revoked key is                 never rebindable. Bind a fresh key instead. `error` names which of the two                 applies.",
414            Self::WitnessState => "Read `snapshot` in `GET /api/view` and cosign the id it \
415                names. Only the latest attestation is witnessable, so a snapshot that landed \
416                while you were signing is not an error to retry blindly — re-read it, because \
417                the ref-state you attest must be the one that is current. A witness with no \
418                key binding needs the operator's `choir bind` first.",
419            Self::VouchState => "Read `vouches` in `GET /api/view`. Both ends of a vouch must                 be operators with an unrevoked key bound in the log, so if `error` names an                 unbound end the repair is the operator's: `choir bind`. An edge that already                 stands is not a retry — withdraw it and vouch again if the note should                 change.",
420            Self::PolicyUnavailable => "Operator problem, not a client one: the gate fails                 closed rather than guessing. Retry once the file is restored.",
421            Self::LogEvicted => "Resync from the sequence in `window_base`; entries before it                 are gone from this node.",
422            Self::DuplicateSubmission => "If you are retrying, this is your op: read `seq`.                 A submission that already landed answers 200 with `already_applied`, and                 only reaches you as a rejection if the window moved underneath the retry.                 If you meant a second, distinct change, sign a new op — two otherwise                 byte-identical ops are told apart by their scope.",
423            Self::ScopeRequired => "Read `log.node` and `log.head` from `GET /api/view`,                 put them in the op's `scope`, and sign that. `choir submit` does this                 automatically. An unscoped op cannot be admitted here because nothing in it                 says which log it was meant for or that it has not run before.",
424            Self::ForeignScope => "Nothing to retry against this node: the op names another                 node's id in `expected`. Sign a scope naming this node, whose id is in                 `actual` and in `log.node` of `GET /api/view`.",
425            Self::StaleScope => "Re-read `log.head` from `GET /api/view` and sign a fresh op                 against it. A signature is only admissible while the head it names is still                 in the node's window, which is what stops a captured op from being replayed                 later.",
426            Self::QuotaExceeded => "Not a retry: retrying the same request gets the same                 answer. `expected` names the ceiling and `actual` what you asked for.                 For a push, send fewer objects — several smaller pushes, or a shallower                 history. For a workspace, archive one you are finished with                 (`POST /api/workspace/archive`) to free the allowance. If neither is                 possible, the ceiling is the operator's to raise.",
427            Self::BadSignature => "Re-sign the exact bytes you are submitting: a signature covers                 one `(channel, payload)` pair and does not carry to another. Registering a key                 does not help here, the key this names is already trusted. If you did not send                 this, a signature of yours was replayed onto bytes you never signed, and the                 operator wants to know.",
428            Self::Unclassified => "Read `error`. This path does not name a repair yet — that is                 a gap, and worth reporting.",
429        }
430    }
431}
432
433/// Collapses runs of whitespace to single spaces.
434///
435/// Rust's `\` string continuation keeps the next line's indentation, so
436/// a wrapped literal renders into markdown with the source's leading
437/// spaces intact — which markdown shows verbatim and, at four spaces,
438/// turns into a code block.
439fn one_line(s: &str) -> String {
440    s.split_whitespace().collect::<Vec<_>>().join(" ")
441}
442
443/// `ERRORS.md`: every code and what to do about it.
444///
445/// Generated rather than authored, for the same reason the agent surface
446/// is: a hand-maintained table of codes drifts from the codes, and a
447/// client branching on a code that no longer exists fails in the least
448/// debuggable way available.
449#[must_use]
450pub fn errors_md() -> String {
451    // Each paragraph is normalized: a wrapped Rust literal keeps the
452    // source's indentation, and four leading spaces in markdown is a code
453    // block rather than prose.
454    let para = |s: &str| format!("{}\n\n", one_line(s));
455    let mut out = String::from("# Rejection codes\n\n");
456    out.push_str(&para(
457        "Generated from `crates/choir-node/src/reject.rs`. Do not edit; edit the table.",
458    ));
459    out.push_str(&para(
460        "Every rejection body carries `code`, `error` and `next`. `expected` and `actual` are
461         present when the check compared two states — a compare-and-swap failure, or a channel
462         bound to a name other than the one used.",
463    ));
464    out.push_str(&para(
465        "`code` is the contract: branch on it, not on `error`. Adding a code is not a breaking
466         change; renaming one is.",
467    ));
468    out.push_str("| Code | Meaning | What to do |\n|---|---|---|\n");
469    for c in Code::all() {
470        out.push_str(&format!(
471            "| `{}` | {} | {} |\n",
472            c.as_str(),
473            one_line(c.meaning()),
474            one_line(c.action())
475        ));
476    }
477    out.push_str("\n## Retrying a submission whose response you lost\n\n");
478    out.push_str(&para(
479        "Resubmit the identical signed bytes. If it already landed, the node answers **200**
480         with `already_applied: true` and the original `seq` and `hash`, rather than the
481         compare-and-swap failure the two cases would otherwise share. Read those back and
482         proceed; do not rebuild the operation.",
483    ));
484    out.push_str(&para(
485        "This is bounded to recent history: the node keeps the index over the same window
486         `GET /api/log` serves from. A retry seconds or minutes later is covered; one after the
487         window has turned over reads as `stale_head`, which is the safe direction.",
488    ));
489    out
490}