1#![doc = include_str!("../../../docs/reference/troubleshooting.md")]
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum Code {
42 UnknownKey,
46 MalformedOp,
48 MalformedRequest,
50 ReviewerMismatch,
53 ChannelNotOwned,
55 NodeOnly,
57 AssignmentRequired,
59 ProtectedRef,
61 ReviewRequired,
63 RefUndeletable,
65 StaleHead,
67 ReviewState,
70 ProvenanceState,
72 ChangeState,
74 WorkspaceState,
76 IdentityState,
80 VouchState,
84 WitnessState,
88 PolicyUnavailable,
91 LogEvicted,
93 DuplicateSubmission,
95 ScopeRequired,
97 ForeignScope,
99 StaleScope,
101 QuotaExceeded,
104 BadSignature,
109 Unclassified,
111}
112
113impl Code {
114 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
187pub struct Rejection {
188 pub code: String,
190 pub error: String,
193 pub expected: Option<String>,
195 pub actual: Option<String>,
197 pub next: String,
202}
203
204impl Rejection {
205 #[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 #[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 #[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 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 #[must_use]
247 pub fn encode(&self) -> String {
248 self.to_json().to_string()
249 }
250
251 #[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 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 #[must_use]
286 pub fn body(&self) -> String {
287 self.encode()
288 }
289}
290
291#[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 #[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 #[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
433fn one_line(s: &str) -> String {
440 s.split_whitespace().collect::<Vec<_>>().join(" ")
441}
442
443#[must_use]
450pub fn errors_md() -> String {
451 let para = |s: &str| format!("{}\n\n", one_line(s));
455 let mut out = String::from("# Rejection codes\n\n");
456 out.push_str(¶(
457 "Generated from `crates/choir-node/src/reject.rs`. Do not edit; edit the table.",
458 ));
459 out.push_str(¶(
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(¶(
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(¶(
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(¶(
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}