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 NotHome,
114 Unclassified,
116}
117
118impl Code {
119 #[must_use]
123 pub fn as_str(self) -> &'static str {
124 match self {
125 Self::UnknownKey => "unknown_key",
126 Self::MalformedOp => "malformed_op",
127 Self::MalformedRequest => "malformed_request",
128 Self::ReviewerMismatch => "reviewer_mismatch",
129 Self::ChannelNotOwned => "channel_not_owned",
130 Self::NodeOnly => "node_only",
131 Self::AssignmentRequired => "assignment_required",
132 Self::ProtectedRef => "protected_ref",
133 Self::ReviewRequired => "review_required",
134 Self::RefUndeletable => "ref_undeletable",
135 Self::StaleHead => "stale_head",
136 Self::ReviewState => "review_state",
137 Self::ProvenanceState => "provenance_state",
138 Self::ChangeState => "change_state",
139 Self::WorkspaceState => "workspace_state",
140 Self::IdentityState => "identity_state",
141 Self::VouchState => "vouch_state",
142 Self::WitnessState => "witness_state",
143 Self::PolicyUnavailable => "policy_unavailable",
144 Self::LogEvicted => "log_evicted",
145 Self::DuplicateSubmission => "duplicate_submission",
146 Self::ScopeRequired => "scope_required",
147 Self::ForeignScope => "foreign_scope",
148 Self::StaleScope => "stale_scope",
149 Self::QuotaExceeded => "quota_exceeded",
150 Self::BadSignature => "bad_signature",
151 Self::NotHome => "not_home",
152 Self::Unclassified => "unclassified",
153 }
154 }
155
156 #[must_use]
158 pub fn all() -> &'static [Code] {
159 &[
160 Self::UnknownKey,
161 Self::MalformedOp,
162 Self::MalformedRequest,
163 Self::ReviewerMismatch,
164 Self::ChannelNotOwned,
165 Self::NodeOnly,
166 Self::AssignmentRequired,
167 Self::ProtectedRef,
168 Self::ReviewRequired,
169 Self::RefUndeletable,
170 Self::StaleHead,
171 Self::ReviewState,
172 Self::ProvenanceState,
173 Self::ChangeState,
174 Self::WorkspaceState,
175 Self::IdentityState,
176 Self::VouchState,
177 Self::WitnessState,
178 Self::PolicyUnavailable,
179 Self::LogEvicted,
180 Self::DuplicateSubmission,
181 Self::ScopeRequired,
182 Self::ForeignScope,
183 Self::StaleScope,
184 Self::QuotaExceeded,
185 Self::BadSignature,
186 Self::NotHome,
187 Self::Unclassified,
188 ]
189 }
190}
191
192#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct Rejection {
195 pub code: String,
197 pub error: String,
200 pub expected: Option<String>,
202 pub actual: Option<String>,
204 pub next: String,
209}
210
211impl Rejection {
212 #[must_use]
214 pub fn new(code: Code, error: impl Into<String>, next: impl Into<String>) -> Self {
215 Self {
216 code: code.as_str().to_string(),
217 error: error.into(),
218 expected: None,
219 actual: None,
220 next: next.into(),
221 }
222 }
223
224 #[must_use]
226 pub fn with_states(mut self, expected: Option<String>, actual: Option<String>) -> Self {
227 self.expected = expected;
228 self.actual = actual;
229 self
230 }
231
232 #[must_use]
234 pub fn to_json(&self) -> serde_json::Value {
235 let mut v = serde_json::json!({
236 "code": self.code,
237 "error": self.error,
238 "next": self.next,
239 });
240 if let Some(e) = &self.expected {
244 v["expected"] = serde_json::json!(e);
245 }
246 if let Some(a) = &self.actual {
247 v["actual"] = serde_json::json!(a);
248 }
249 v
250 }
251
252 #[must_use]
254 pub fn encode(&self) -> String {
255 self.to_json().to_string()
256 }
257
258 #[must_use]
265 pub fn decode(reason: &str) -> Self {
266 let unclassified = || Self {
267 code: Code::Unclassified.as_str().to_string(),
268 error: reason.to_string(),
269 expected: None,
270 actual: None,
271 next: "read the message; this path does not yet name a repair".to_string(),
272 };
273 let Ok(v) = serde_json::from_str::<serde_json::Value>(reason) else {
274 return unclassified();
275 };
276 let field = |k: &str| v.get(k).and_then(serde_json::Value::as_str);
277 match (field("code"), field("error"), field("next")) {
280 (Some(code), Some(error), Some(next)) => Self {
281 code: code.to_string(),
282 error: error.to_string(),
283 expected: field("expected").map(String::from),
284 actual: field("actual").map(String::from),
285 next: next.to_string(),
286 },
287 _ => unclassified(),
288 }
289 }
290
291 #[must_use]
293 pub fn body(&self) -> String {
294 self.encode()
295 }
296}
297
298#[must_use]
302pub fn not_home(home: &str) -> serde_json::Value {
303 let mut body = Rejection::new(
304 Code::NotHome,
305 format!("this node is a seed of {home} and writes nothing of its own"),
306 format!("send the same request to {home}"),
307 )
308 .to_json();
309 body["home"] = serde_json::json!(home);
310 body
311}
312
313#[must_use]
316pub fn from_view_error(e: &choir_view::ViewError) -> Rejection {
317 use choir_view::ViewError;
318 match e {
319 ViewError::StaleHead {
320 target,
321 expected,
322 actual,
323 } => Rejection::new(
324 Code::StaleHead,
325 format!("compare-and-swap failed on {target}"),
326 "re-read GET /api/view for the current value, rebase your intent on it, and resubmit \
327 with the new prev",
328 )
329 .with_states(
330 expected.as_ref().map(choir_oplog::ContentHash::to_hex),
331 actual.as_ref().map(choir_oplog::ContentHash::to_hex),
332 ),
333 ViewError::Review(msg) => Rejection::new(
334 Code::ReviewState,
335 msg.clone(),
336 "read GET /api/view `reviews` for this id's current state; a review that is already \
337 assigned, complete, or archived does not accept the op you sent",
338 ),
339 ViewError::Provenance(msg) => Rejection::new(
340 Code::ProvenanceState,
341 msg.clone(),
342 "resubmit with a non-empty subject and kind",
343 ),
344 ViewError::Change(msg) => Rejection::new(
345 Code::ChangeState,
346 msg.clone(),
347 "read GET /api/view `changes` for the current owner, workspace and revision; use a \
348 new change id or checkpoint from the reported revision",
349 ),
350 ViewError::Identity(msg) => Rejection::new(
351 Code::IdentityState,
352 msg.clone(),
353 "not a retry: a key belongs to one operator for its lifetime and a revoked key is \
354 never rebindable, so bind a fresh key instead",
355 ),
356 ViewError::Vouch(msg) => Rejection::new(
357 Code::VouchState,
358 msg.clone(),
359 "read `vouches` in GET /api/view; both ends need an unrevoked key binding, and an \
360 edge that already stands is withdrawn rather than repeated",
361 ),
362 ViewError::Witness(msg) => Rejection::new(
363 Code::WitnessState,
364 msg.clone(),
365 "read `witnessed` and `snapshot` in GET /api/view: a witness cosigns the latest \
366 ref-state attestation and no other, so re-read the snapshot and sign that one",
367 ),
368 ViewError::Decode(msg) => Rejection::new(
369 Code::MalformedOp,
370 format!("decode failed: {msg}"),
371 "serialize the op with the same ViewOp version the node runs; see GET /llms.txt",
372 ),
373 other => Rejection::new(
374 Code::Unclassified,
375 format!("{other:?}"),
376 "retry once; if it persists the node has a problem the client cannot fix",
377 ),
378 }
379}
380
381impl Code {
382 #[must_use]
384 pub fn meaning(self) -> &'static str {
385 match self {
386 Self::UnknownKey => "The signature names a key id this node has no record of",
387 Self::MalformedOp => "The payload did not decode as a `ViewOp`",
388 Self::MalformedRequest => "The request body was missing fields or badly encoded",
389 Self::ReviewerMismatch => "A verdict or comment claimed an attribution other than the signed channel",
390 Self::ChannelNotOwned => "The signing key is bound to a different channel name",
391 Self::NodeOnly => "Only the node's own key may author this operation",
392 Self::AssignmentRequired => "This node assigns reviewers; a self-named list was refused",
393 Self::ProtectedRef => "The target ref is protected and needs a node-drawn reviewer list",
394 Self::ReviewRequired => "The target ref lacks the required independent approval weight",
395 Self::RefUndeletable => "The target ref is protected and cannot be deleted",
396 Self::StaleHead => "Compare-and-swap failed: the state moved under the submission",
397 Self::ReviewState => "A review-op precondition failed (duplicate id, unknown review, already assigned, archived)",
398 Self::ProvenanceState => "A provenance record was missing a subject or kind",
399 Self::ChangeState => "A stable change was unknown, duplicated, archived, or mismatched",
400 Self::WorkspaceState => "A workspace lifecycle request conflicted with its durable binding",
401 Self::IdentityState => "A key-binding precondition failed (key already bound to another operator, revoked or unbound key, channel naming a different operator)",
402 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)",
403 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)",
404 Self::PolicyUnavailable => "The operator's protected-ref list could not be read, so the gate failed closed",
405 Self::LogEvicted => "Requested log entries are older than anything this node can serve",
406 Self::DuplicateSubmission => "These exact signed bytes already landed; a signature is admissible once",
407 Self::ScopeRequired => "This node admits only ops signed for its own log and a recent head, and this op carried no scope",
408 Self::ForeignScope => "The op was signed for another node's log",
409 Self::StaleScope => "The head the op was signed against is no longer in the node's recent window",
410 Self::QuotaExceeded => "A per-user quota was already full, or this request was larger than one is allowed to be",
411 Self::BadSignature => "The signature does not verify over these bytes, under a key this node does trust",
412 Self::NotHome => "This node is a seed: it holds a copy of another node's log and writes nothing, so the write belongs at the home in `home`",
413 Self::Unclassified => "A rejection that did not originate as a structured one",
414 }
415 }
416
417 #[must_use]
419 pub fn action(self) -> &'static str {
420 match self {
421 Self::UnknownKey => "Ask the operator to register your public key. `choir key <file> <you>` prints the line; it takes effect on the next request.",
422 Self::MalformedOp => "Serialize a `ViewOp` and sign its bytes. `choir submit` does this correctly; `GET /llms.txt` lists the operations.",
423 Self::MalformedRequest => "Send a JSON object with the fields the endpoint wants. `GET /llms.txt` lists them.",
424 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.",
425 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.",
426 Self::NodeOnly => "Nothing to retry: this operation is the node's to author. For reviewer assignment, request a review with an empty reviewer list.",
427 Self::AssignmentRequired => "Resubmit with an empty reviewer list. The node draws reviewers and returns their names in the response.",
428 Self::ProtectedRef => "Resubmit with an empty reviewer list. On a protected ref only a node-drawn list is accepted.",
429 Self::ReviewRequired => "Open a review naming this ref and commit (`choir review ... --ref <repo:ref>`), obtain approvals from two distinct operators, then push again.",
430 Self::RefUndeletable => "Do not delete this ref, or ask the operator to remove it from the protected-ref list.",
431 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.",
432 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.",
433 Self::ProvenanceState => "Resubmit with a non-empty subject and kind.",
434 Self::ChangeState => "Read `changes` in `GET /api/view`, then use its owner, workspace and revision or choose a new change id.",
435 Self::WorkspaceState => "Read `changes` and `workspaces` in `GET /api/view`; retry only with the exact existing binding, or choose a new workspace name.",
436 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.",
437 Self::WitnessState => "Read `snapshot` in `GET /api/view` and cosign the id it \
438 names. Only the latest attestation is witnessable, so a snapshot that landed \
439 while you were signing is not an error to retry blindly — re-read it, because \
440 the ref-state you attest must be the one that is current. A witness with no \
441 key binding needs the operator's `choir bind` first.",
442 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.",
443 Self::PolicyUnavailable => "Operator problem, not a client one: the gate fails closed rather than guessing. Retry once the file is restored.",
444 Self::LogEvicted => "Resync from the sequence in `window_base`; entries before it are gone from this node.",
445 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.",
446 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.",
447 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`.",
448 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.",
449 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.",
450 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.",
451 Self::NotHome => "Send the same request to the node in `home`, unchanged. \
452 Nothing needs re-signing: a seed's `log.node` and `log.head` already name its \
453 home's log, so an op signed against a seed's view is one the home admits, and \
454 a compare-and-swap read from a seed that has fallen behind fails there with the \
455 ordinary `stale_head`. For a push, change the remote to the home. `choir` \
456 follows this once by itself when the home is the node it is configured for.",
457 Self::Unclassified => "Read `error`. This path does not name a repair yet — that is a gap, and worth reporting.",
458 }
459 }
460}
461
462fn one_line(s: &str) -> String {
469 s.split_whitespace().collect::<Vec<_>>().join(" ")
470}
471
472#[must_use]
479pub fn errors_md() -> String {
480 let para = |s: &str| format!("{}\n\n", one_line(s));
484 let mut out = String::from("# Rejection codes\n\n");
485 out.push_str(¶(
486 "Generated from `crates/choir-node/src/reject.rs`. Do not edit; edit the table.",
487 ));
488 out.push_str(¶(
489 "Every rejection body carries `code`, `error` and `next`. `expected` and `actual` are
490 present when the check compared two states — a compare-and-swap failure, or a channel
491 bound to a name other than the one used.",
492 ));
493 out.push_str(¶(
494 "`code` is the contract: branch on it, not on `error`. Adding a code is not a breaking
495 change; renaming one is.",
496 ));
497 out.push_str("| Code | Meaning | What to do |\n|---|---|---|\n");
498 for c in Code::all() {
499 out.push_str(&format!(
500 "| `{}` | {} | {} |\n",
501 c.as_str(),
502 one_line(c.meaning()),
503 one_line(c.action())
504 ));
505 }
506 out.push_str("\n## Retrying a submission whose response you lost\n\n");
507 out.push_str(¶(
508 "Resubmit the identical signed bytes. If it already landed, the node answers **200**
509 with `already_applied: true` and the original `seq` and `hash`, rather than the
510 compare-and-swap failure the two cases would otherwise share. Read those back and
511 proceed; do not rebuild the operation.",
512 ));
513 out.push_str(¶(
514 "This is bounded to recent history: the node keeps the index over the same window
515 `GET /api/log` serves from. A retry seconds or minutes later is covered; one after the
516 window has turned over reads as `stale_head`, which is the safe direction.",
517 ));
518 out
519}