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    /// This node is a seed (D80): it holds a copy of another node's log
110    /// and writes nothing, so every write belongs at the home it names.
111    /// A routing answer rather than a failure, which is why it travels as
112    /// `421 Misdirected Request` with the home in the body.
113    NotHome,
114    /// Anything that did not originate as a structured rejection.
115    Unclassified,
116}
117
118impl Code {
119    /// The stable wire string. Written out rather than derived from the
120    /// variant name so renaming the Rust identifier cannot silently
121    /// change the contract.
122    #[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    /// Every code, for the docs generator and the coverage test.
157    #[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/// A refusal, with enough for a client to decide what to do next.
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct Rejection {
195    /// Stable machine-readable reason.
196    pub code: String,
197    /// Human-readable detail. Kept named `error` because that is what
198    /// every existing client and test already reads.
199    pub error: String,
200    /// What the check required, when the check compared two states.
201    pub expected: Option<String>,
202    /// What it found instead.
203    pub actual: Option<String>,
204    /// The admissible next action, in the imperative. **This is the
205    /// field the research isolates the gain to**, so it is required
206    /// rather than optional: a rejection that cannot name a next action
207    /// is a rejection whose author has not finished thinking.
208    pub next: String,
209}
210
211impl Rejection {
212    /// A rejection with no state comparison.
213    #[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    /// Adds the two states a failed comparison was between.
225    #[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    /// This rejection as a JSON value.
233    #[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        // Absent rather than null: a client checking `expected` should
241        // find nothing when no comparison happened, not a null to
242        // special-case.
243        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    /// Renders for the `Result<(), String>` seam.
253    #[must_use]
254    pub fn encode(&self) -> String {
255        self.to_json().to_string()
256    }
257
258    /// Recovers a rejection from a reason string.
259    ///
260    /// A reason that did not come from [`Rejection::encode`] becomes an
261    /// [`Code::Unclassified`] rejection carrying the original text, so a
262    /// caller always gets the same shape and no message is ever dropped
263    /// on the floor.
264    #[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        // All three required fields or nothing: a half-decoded rejection
278        // would report a code it cannot back up.
279        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    /// The HTTP body: the rejection as JSON.
292    #[must_use]
293    pub fn body(&self) -> String {
294        self.encode()
295    }
296}
297
298/// The `not_home` refusal a seed gives every write (D80): the rejection,
299/// with the home's base URL as its own field so a client can follow it
300/// without parsing prose.
301#[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/// Maps a `ViewError` onto a rejection, unpacking the states it already
314/// carries rather than stringifying them into prose.
315#[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    /// One-line meaning, for the `ERRORS.md` table.
383    #[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    /// What a client should do on receipt. One row of `ERRORS.md`.
418    #[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
462/// Collapses runs of whitespace to single spaces.
463///
464/// Rust's `\` string continuation keeps the next line's indentation, so
465/// a wrapped literal renders into markdown with the source's leading
466/// spaces intact — which markdown shows verbatim and, at four spaces,
467/// turns into a code block.
468fn one_line(s: &str) -> String {
469    s.split_whitespace().collect::<Vec<_>>().join(" ")
470}
471
472/// `ERRORS.md`: every code and what to do about it.
473///
474/// Generated rather than authored, for the same reason the agent surface
475/// is: a hand-maintained table of codes drifts from the codes, and a
476/// client branching on a code that no longer exists fails in the least
477/// debuggable way available.
478#[must_use]
479pub fn errors_md() -> String {
480    // Each paragraph is normalized: a wrapped Rust literal keeps the
481    // source's indentation, and four leading spaces in markdown is a code
482    // block rather than prose.
483    let para = |s: &str| format!("{}\n\n", one_line(s));
484    let mut out = String::from("# Rejection codes\n\n");
485    out.push_str(&para(
486        "Generated from `crates/choir-node/src/reject.rs`. Do not edit; edit the table.",
487    ));
488    out.push_str(&para(
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(&para(
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(&para(
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(&para(
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}