Skip to main content

choir_view/
lib.rs

1//! L1 view/workspace model: typed operations over the op log and the
2//! materialized repo state they fold into (DECISIONS.md jj-style).
3//!
4//! The op log ([`choir_oplog`]) stores opaque payloads; this crate gives
5//! them a versioned schema ([`ViewOp`]) and a deterministic fold
6//! ([`View::materialize`]). Because the fold is pure, the view at *any*
7//! point in history is reconstructible by replaying a prefix
8//! ([`View::at`]) — that is the whole undo model, no reverse patches.
9//!
10//! Commits are content-addressed objects in the chunk store
11//! ([`Commit`]/[`TreeEntry`]). A conflicted merge is a *valid* commit
12//! ([`TreeEntry::Conflict`]): work continues on top of it and the
13//! resolution is a later commit, never a blocked workspace (DECISIONS.md
14//! first-class conflicts, D9).
15//!
16//! One-way-door rules (DECISIONS.md): every persisted shape here
17//! ([`ViewOp`], [`Commit`]) carries `format_version`, and all identifiers
18//! are self-describing [`ContentHash`] envelopes.
19//!
20//! # Examples
21//!
22//! ```
23//! use choir_oplog::{MemLog, OpLog};
24//! use choir_view::{View, ViewOp, OpKind, append_op};
25//! use choir_hash::ContentHash;
26//!
27//! let mut log = MemLog::new();
28//! let commit_id = ContentHash::blake3(b"pretend commit");
29//! append_op(&mut log, "agent-1", ViewOp::new(OpKind::SetWorkspaceHead {
30//!     workspace: "agent-1".into(),
31//!     commit: commit_id.clone(),
32//!     prev: None,
33//! })).unwrap();
34//! let view = View::materialize(&log).unwrap();
35//! assert_eq!(view.workspaces.get("agent-1"), Some(&commit_id));
36//! ```
37//!
38//! # Where this sits
39//!
40//! `docs/architecture.md` is the map of the whole workspace.
41//! This crate is L1, the view that refs, reviews and workspaces are all folded from.
42//!
43//! It builds on [`choir_hash`], [`choir_oplog`] and [`choir_store`].
44//!
45//! The workspace's architecture, included here because this crate is
46//! the version model the rest of it folds into:
47//!
48#![doc = include_str!("../../../docs/architecture.md")]
49
50use std::collections::BTreeMap;
51
52use choir_hash::ContentHash;
53use choir_oplog::{LogError, OpEntry, OpLog, Witness};
54use choir_store::{ChunkStore, StoreError};
55use serde::{Deserialize, Serialize};
56
57/// Current view-op and commit wire-format version. Bump on any
58/// incompatible change; additive changes keep the version (DECISIONS.md).
59pub const FORMAT_VERSION: u16 = 1;
60
61/// Where and when an op is admissible: the author's own statement of
62/// which log they are submitting into and which head they observed.
63///
64/// This is an admission precondition, exactly like [`OpKind`]'s `prev`,
65/// and it lives in the payload for the same reason `prev` does — the
66/// payload is what the author signs. A signature over `(channel,
67/// payload)` is otherwise position-independent, log-independent and
68/// occurrence-independent, so a captured op replays onto any node that
69/// trusts the key, and replays again on the node it came from as soon
70/// as CAS state returns to what it expected (ABA). `prev` cannot close
71/// that: it asks whether the state matches, not whether the op has run.
72///
73/// A head hash can occur at exactly one position in exactly one chain,
74/// which is what makes it a usable freshness token without a clock: the
75/// house rule is that elapsed time is not a thing this system measures.
76///
77/// The admission rule is in `choir-node`'s policy, not in [`View`]: the
78/// view is pure state and knows nothing about nodes or log windows.
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct OpScope {
81    /// Actor id of the node whose log this op was signed for.
82    pub node: ContentHash,
83    /// A log head the author had observed when they signed. `None` says
84    /// the author read an empty log — admissible only while the node has
85    /// evicted nothing, which is the span its duplicate index still
86    /// covers in full.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub head: Option<ContentHash>,
89}
90
91/// Which path authored an op, when it was not the default author-signed
92/// submission (D41).
93///
94/// `None` on [`ViewOp::provenance`] is the default class: an actor
95/// built, signed, and submitted the op under its own key. The variants
96/// label the ops the node signs *on behalf of* a git pusher, whose key
97/// never touches the payload — a materially different provenance that
98/// the channel prefix (`key/`, `git/`) previously carried only by
99/// convention. The label sits inside the signed payload, and the node's
100/// admission policy refuses any labeled op not signed by the node's own
101/// key, so the class can neither be claimed by an ordinary author nor
102/// stripped by whoever relays the bytes.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
104pub enum Provenance {
105    /// A git push whose push certificate verified: the channel names
106    /// the pusher's own key, but the node signed the op.
107    PushCertified,
108    /// A git push with no verified certificate: the channel names only
109    /// the transport user the push arrived as.
110    PushTransport,
111}
112
113/// A typed operation carried in [`OpEntry::payload`].
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115pub struct ViewOp {
116    /// Wire-format version this op was written with; see [`FORMAT_VERSION`].
117    pub format_version: u16,
118    /// What the operation does to the view.
119    pub kind: OpKind,
120    /// The log and head this op was signed for, when the author bound it
121    /// to one. Additive (`default` + `skip_serializing_if`), so ops
122    /// written before scopes existed decode as `None` and re-serialize
123    /// byte-identically — invariant 1, and the reason adding a replay
124    /// defence is not a log migration.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub scope: Option<OpScope>,
127    /// How this op was authored, when not the default author-signed
128    /// class; see [`Provenance`]. Additive under the same rule as
129    /// `scope`, so every existing op decodes as `None` and re-serializes
130    /// byte-identically.
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub provenance: Option<Provenance>,
133    /// Explicit change dependencies: content hashes of
134    /// the changes this op declares it builds on. Declared-only — the
135    /// platform never infers dependencies from file overlap; inference
136    /// is a separate decision. Additive under the same rule as `scope`
137    /// (an empty list is not serialized), and because it sits inside
138    /// the payload it is covered by the author's `(channel, payload)`
139    /// signature (invariant 4) with no change to the signing scheme:
140    /// ops written before the field existed re-serialize
141    /// byte-identically, so their signatures still verify.
142    #[serde(default, skip_serializing_if = "Vec::is_empty")]
143    pub depends: Vec<ContentHash>,
144}
145
146impl ViewOp {
147    /// Wraps `kind` at the current [`FORMAT_VERSION`], unscoped, in the
148    /// default author-signed provenance class.
149    pub fn new(kind: OpKind) -> Self {
150        Self {
151            format_version: FORMAT_VERSION,
152            kind,
153            scope: None,
154            provenance: None,
155            depends: Vec::new(),
156        }
157    }
158
159    /// Declares the changes this op builds on. The list rides inside
160    /// the signed payload, so a relay can neither strip nor extend it.
161    #[must_use]
162    pub fn with_depends(mut self, depends: Vec<ContentHash>) -> Self {
163        self.depends = depends;
164        self
165    }
166
167    /// Labels this op with a non-default provenance class. Only the
168    /// node's push path does this; admission refuses the label under
169    /// any other signer, so calling it from an ordinary author buys a
170    /// rejection, not a classification.
171    #[must_use]
172    pub fn with_provenance(mut self, provenance: Provenance) -> Self {
173        self.provenance = Some(provenance);
174        self
175    }
176
177    /// Binds this op to one log and one observed head. The scope is
178    /// inside the payload, so it is covered by the author's signature
179    /// and cannot be stripped or rewritten by whoever relays the bytes.
180    #[must_use]
181    pub fn in_scope(mut self, node: ContentHash, head: Option<ContentHash>) -> Self {
182        self.scope = Some(OpScope { node, head });
183        self
184    }
185
186    /// Serializes into an [`OpEntry::payload`].
187    pub fn to_payload(&self) -> Vec<u8> {
188        serde_json::to_vec(self).expect("ViewOp is always serializable")
189    }
190
191    /// Decodes an [`OpEntry::payload`].
192    ///
193    /// # Errors
194    ///
195    /// Returns [`ViewError::Decode`] when the bytes are not a valid op.
196    pub fn from_payload(payload: &[u8]) -> Result<Self, ViewError> {
197        serde_json::from_slice(payload).map_err(|e| ViewError::Decode(e.to_string()))
198    }
199}
200
201/// Owner-signed authorization carried into a node-authored physical
202/// workspace creation operation.
203///
204/// This is separate from [`ViewOp`]: submitting the authorization to the
205/// raw operation endpoint cannot create a directory or a change. The
206/// workspace endpoint first verifies and materializes the exact binding,
207/// then the node wraps it in [`OpKind::CreateChange`] under its own key.
208#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
209pub struct CreateAuthorization {
210    /// Wire-format version; see [`FORMAT_VERSION`].
211    pub format_version: u16,
212    /// Stable logical contribution id.
213    pub id: String,
214    /// Owner channel that must match the signature attribution.
215    pub owner: String,
216    /// Workspace being created.
217    pub workspace: String,
218    /// Exact immutable starting revision.
219    pub base_revision: ContentHash,
220    /// Owner-scoped retry identity.
221    pub idempotency_key: String,
222    /// Directory prefixes the owner declares this change works within;
223    /// empty means the whole tree.
224    ///
225    /// Covered by the owner's signature on purpose. The cone lands in
226    /// the log as part of an op the *node* authors, so if it were not
227    /// signed here the node could attach a scope its owner never
228    /// declared — and a declaration nobody signed is worth no more than
229    /// the node-side config this was meant to replace. Additive under
230    /// invariant 1: an empty cone is not serialized, so every
231    /// authorization signed before this field existed still verifies
232    /// byte-for-byte.
233    #[serde(default, skip_serializing_if = "Vec::is_empty")]
234    pub cone: Vec<String>,
235}
236
237impl CreateAuthorization {
238    /// Creates an authorization at the current wire-format version.
239    pub fn new(
240        id: String,
241        owner: String,
242        workspace: String,
243        base_revision: ContentHash,
244        idempotency_key: String,
245    ) -> Self {
246        Self {
247            format_version: FORMAT_VERSION,
248            id,
249            owner,
250            workspace,
251            base_revision,
252            idempotency_key,
253            cone: Vec::new(),
254        }
255    }
256
257    /// The same authorization, scoped to `cone`.
258    ///
259    /// A builder rather than a sixth parameter on [`Self::new`], so the
260    /// unscoped call sites -- which are most of them, and every one
261    /// written before cones existed -- keep reading as the plain thing
262    /// they are.
263    #[must_use]
264    pub fn with_cone(mut self, cone: Vec<String>) -> Self {
265        self.cone = cone;
266        self
267    }
268
269    /// Canonical bytes covered by the owner's submission signature.
270    pub fn to_payload(&self) -> Vec<u8> {
271        serde_json::to_vec(self).expect("CreateAuthorization is always serializable")
272    }
273
274    /// Decodes signed authorization bytes.
275    pub fn from_payload(payload: &[u8]) -> Result<Self, ViewError> {
276        serde_json::from_slice(payload).map_err(|error| ViewError::Decode(error.to_string()))
277    }
278}
279
280/// Owner-signed authorization carried into a node-authored physical
281/// workspace archive operation.
282///
283/// This is separate from [`ViewOp`]: submitting the authorization to the
284/// raw operation endpoint cannot detach a workspace. The archive endpoint
285/// first moves the filesystem, then the node wraps this proof in
286/// [`OpKind::ArchiveChange`] and submits that operation under its own key.
287#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
288pub struct ArchiveAuthorization {
289    /// Wire-format version; see [`FORMAT_VERSION`].
290    pub format_version: u16,
291    /// Stable logical contribution id.
292    pub id: String,
293    /// Currently active workspace.
294    pub workspace: String,
295    /// Expected current change/workspace revision.
296    pub prev_revision: ContentHash,
297}
298
299impl ArchiveAuthorization {
300    /// Creates an authorization at the current wire-format version.
301    pub fn new(id: String, workspace: String, prev_revision: ContentHash) -> Self {
302        Self {
303            format_version: FORMAT_VERSION,
304            id,
305            workspace,
306            prev_revision,
307        }
308    }
309
310    /// Canonical bytes covered by the owner's submission signature.
311    pub fn to_payload(&self) -> Vec<u8> {
312        serde_json::to_vec(self).expect("ArchiveAuthorization is always serializable")
313    }
314
315    /// Decodes signed authorization bytes.
316    pub fn from_payload(payload: &[u8]) -> Result<Self, ViewError> {
317        serde_json::from_slice(payload).map_err(|error| ViewError::Decode(error.to_string()))
318    }
319}
320
321/// Why a [`OpKind::Submit`] was allowed to land (D43).
322///
323/// The gate that admits a landing runs at apply time inside the node's
324/// submission policy, so without this the log records *that* a ref moved
325/// and never *why*. A later additive field cannot repair that: entries
326/// written before it stay blank, and that window is permanently
327/// unauditable. So it ships with the first `Submit` ever accepted.
328///
329/// The record is **checked, not trusted**. Everything here except the
330/// ACL's own grants is derivable from the fold, and [`View::validate`]
331/// rederives it and refuses a mismatch — the [`OpKind::RecordRefSnapshot`]
332/// discipline, for the same reason: a claim every replayer verifies is
333/// worth more than one only the admitting node could have checked.
334#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
335pub struct Authorization {
336    /// Wire-format version; see [`FORMAT_VERSION`].
337    pub format_version: u16,
338    /// The rule that admitted the landing.
339    pub basis: Basis,
340    /// Actor ids whose standing approvals the basis rested on, in the
341    /// review's verdict order.
342    ///
343    /// **Actor ids, never channel names.** An id is `hash(pubkey)` and is
344    /// the trust root (D9); a channel name is mutable, and an audit
345    /// record that reads differently later than it read when written is
346    /// not an audit record. Resolved through the log's own
347    /// [`OpKind::BindKey`] records by [`View::bound_actor_at`], so the
348    /// join is replayable — and an approval whose channel the log binds
349    /// to no key, or to more than one, cannot land through this op at
350    /// all.
351    ///
352    /// **What an id here proves, exactly.** It is the key the log bound
353    /// to the approving channel *as of the verdict's own position*
354    /// ([`VerdictState::at`]), not proof that this key cast the verdict:
355    /// the fold is handed only the op, never the entry, so
356    /// [`ReviewState::verdicts`] is keyed by channel and no review can
357    /// record a signer. Resolving at the verdict's position rather than
358    /// the landing's is what keeps the claim true across a key rotation
359    /// (D44) — the replacement key never saw the review. That is also
360    /// why freezing the id here is worth doing: [`KeyBinding::channel`]
361    /// is the one field a later re-binding may change, so the answer is
362    /// only stable once written down.
363    ///
364    /// **Empty is a distinct value from absent.** A basis that requires
365    /// no approvals records `[]`, and a reader can tell that from a log
366    /// predating the field, because such a log holds no `Submit`.
367    pub approvers: Vec<ContentHash>,
368}
369
370impl Authorization {
371    /// Creates an authorization at the current wire-format version.
372    #[must_use]
373    pub fn new(basis: Basis, approvers: Vec<ContentHash>) -> Self {
374        Self {
375            format_version: FORMAT_VERSION,
376            basis,
377            approvers,
378        }
379    }
380}
381
382/// The rule that admitted a landing, as the evaluation that admitted it
383/// computed it (D43).
384///
385/// Three variants because there are three ways a protected-ref landing
386/// is currently allowed, and an approver list alone distinguishes none of
387/// them: under D42 a landing can be authorized with **zero** approvals,
388/// so an empty list would equally mean "an owner landed it" and "the
389/// policy required nobody".
390///
391/// There is deliberately **no variant for an ungated ref.** A `Submit`
392/// whose ref is unprotected, or whose node is not running the review
393/// gate, is refused rather than recorded — see [`OpKind::Submit`]. The
394/// record therefore means exactly one thing, and cannot be used to dress
395/// an unexamined ref move as an authorized one.
396///
397/// Where a variant names a principal it names it in the namespace the
398/// rule actually read. The ownership rule reads the ACL's subject
399/// column, which holds usernames and has no actor id to offer, so
400/// `owner` is a username. Recording the rule's real input beats
401/// recording a prettier identity the rule never saw.
402#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
403pub enum Basis {
404    /// `owner` holds `own` on the repository and performed the landing
405    /// themselves (D42). Performing the landing is assent, which is why
406    /// no approval is recorded.
407    OwnerLanded {
408        /// The ACL subject the node resolved the acting identity to.
409        owner: String,
410    },
411    /// `owner` holds `own` on the repository and approved a review
412    /// naming this exact `(ref, commit)` pair (D42).
413    OwnerApproved {
414        /// The approving owner's reviewer channel, which is also its ACL
415        /// subject. The fold checks it against the review's standing
416        /// approvals; it cannot check the grant, which lives in a file.
417        owner: String,
418    },
419    /// No owner is granted on the repository, so the weight rule applied
420    /// and was met.
421    ApprovalWeight {
422        /// The threshold in force when the landing was admitted.
423        ///
424        /// This is the part of the "the rule itself is not pinned"
425        /// residual that could be closed cheaply: for this basis an
426        /// auditor no longer has to reconstruct the threshold from
427        /// operator-side config history. The ACL grants behind the two
428        /// owner variants stay unpinned, and still need a tripwire
429        /// rather than taste.
430        required: u32,
431        /// The review's approval weight at admission.
432        met: u32,
433    },
434}
435
436/// The view mutations. Head-moving ops carry `prev` (compare-and-set
437/// against the current view) so a stale writer is rejected instead of
438/// silently clobbering a concurrent advance — the same discipline the op
439/// log itself applies to its head.
440#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
441pub enum OpKind {
442    /// Point `workspace` at `commit`; `prev` must equal its current head
443    /// (`None` = workspace must not exist yet).
444    SetWorkspaceHead {
445        /// Workspace being moved.
446        workspace: String,
447        /// New head commit id.
448        commit: ContentHash,
449        /// Expected current head (CAS), `None` to create.
450        prev: Option<ContentHash>,
451    },
452    /// Point named ref `name` at `commit` under the same CAS rule.
453    SetRef {
454        /// Ref name (e.g. `"main"`).
455        name: String,
456        /// New target commit id.
457        commit: ContentHash,
458        /// Expected current target (CAS), `None` to create.
459        prev: Option<ContentHash>,
460    },
461    /// Remove `workspace` from the view (its commits stay in the store).
462    DeleteWorkspace {
463        /// Workspace being removed.
464        workspace: String,
465    },
466    /// Open review `id` on commit `target`, fanning out to `reviewers`
467    /// (additive variant, added for review fan-out; wire-format
468    /// unchanged). `id` must not already exist.
469    ///
470    /// An **empty** `reviewers` list opens the review *unassigned*: the
471    /// requester is not naming their own reviewers, and an
472    /// [`OpKind::AssignReviewers`] op fills the list in later. An
473    /// unassigned review is never `complete()` and never `approved()`,
474    /// so "ask nobody" cannot read as a pass (D24 layer 5).
475    RequestReview {
476        /// Caller-chosen review id (unique per log).
477        id: String,
478        /// The commit under review.
479        target: ContentHash,
480        /// Actor names the review fans out to; empty = unassigned.
481        reviewers: Vec<String>,
482        /// The ref this review proposes to land on, in the view's
483        /// namespaced form `<repo>:<refname>` (e.g.
484        /// `"choir/choir.git:refs/heads/main"`). `None` = unbound: a
485        /// review of a commit that names no destination.
486        ///
487        /// Additive field, and the worked example of invariant 1 for an
488        /// *enum variant*: `default` + `skip_serializing_if` means logs
489        /// written before this field decode as `None` **and** re-serialize
490        /// byte-identically, so their entry hashes do not move.
491        ///
492        /// This is what per-ref policy conditions on. Without it there is
493        /// no way to say "reviews landing on `main` are privilege-bearing"
494        /// — a review named a commit, and a commit belongs to no branch
495        /// (D24 layer 5; D23 blast-radius gating).
496        #[serde(default, skip_serializing_if = "Option::is_none")]
497        target_ref: Option<String>,
498    },
499    /// Fill in the reviewer list of an unassigned review (additive
500    /// variant, wire-format unchanged). Assign-once: the review must
501    /// exist with an empty list, and the new list must be non-empty.
502    ///
503    /// This is the view-level half of D24 layer 5 — "the requester does
504    /// not choose who reviews them". *Who* may assign is admission
505    /// policy (L2), not view semantics: the daemon accepts this op only
506    /// from its own key, and picks from an operator-curated pool.
507    AssignReviewers {
508        /// The unassigned review being filled in.
509        id: String,
510        /// Actor names the review now fans out to (non-empty).
511        reviewers: Vec<String>,
512    },
513    /// Settle review `id` and drop its bulk (additive variant,
514    /// wire-format unchanged).
515    ///
516    /// A review's verdicts, notes and reviewer list are the part that
517    /// grows without bound; the landing gate reads only
518    /// `(target_ref, target, approved)`. Archiving keeps that triple and
519    /// discards the rest, so retention stops being an authorization
520    /// decision — an approval never silently expires.
521    ///
522    /// **Archiving is freezing, not deleting.** `PostVerdict` overwrites
523    /// a reviewer's earlier verdict, so approval is *not* monotonic and a
524    /// review can go approved then not. Once the verdicts are gone that
525    /// transition can no longer be computed, so an archived review
526    /// accepts no further verdicts — and says so, rather than reporting
527    /// itself absent.
528    ///
529    /// *Who* may archive is admission policy (L2), like
530    /// [`OpKind::AssignReviewers`]: the daemon accepts it only from its
531    /// own key, because otherwise archiving would be a way to erase a
532    /// `RequestChanges` you did not like.
533    ArchiveReview {
534        /// The review being settled.
535        id: String,
536        /// Settle an **incomplete** review as not approved, rather than
537        /// refusing because it never reached an outcome.
538        ///
539        /// An unanswered review is exactly the kind that accumulates, and
540        /// it is incomplete by definition — so a pruner that can only
541        /// archive complete reviews reclaims the ones least likely to
542        /// pile up. Lapsing is the answer, and it deliberately invents
543        /// **no new outcome**: a review nobody answered never got
544        /// approval, so `Archived { approved: false }` is the whole truth.
545        /// The landing gate is unchanged, and there is no new persisted
546        /// enum to version.
547        ///
548        /// **When** is not a view question. A pure fold of the log has no
549        /// clock, so the view cannot decide "abandoned"; the node decides
550        /// on wall time and this op records the decision, exactly as the
551        /// reviewer draw is decided node-side and recorded by
552        /// [`OpKind::AssignReviewers`]. Replay reproduces the lapse from
553        /// the op and never from re-running a timer.
554        ///
555        /// Additive: absent in a payload written before this field
556        /// existed, which decodes as `false` — the previous strict
557        /// behaviour exactly.
558        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
559        lapsed: bool,
560    },
561    /// Invalidate one reviewer's approval after a policy or trust finding
562    /// (additive variant, wire-format unchanged).
563    ///
564    /// The operation is append-only: it never moves a ref back and never
565    /// erases the original verdict. A review with any slash visibly needs
566    /// re-review, and its affected operator no longer contributes approval
567    /// weight to future protected-ref authorization.
568    ///
569    /// Live rows verify that `reviewer` currently has an approval. Archived
570    /// rows deliberately no longer retain reviewer detail, so admission
571    /// accepts this operation only from the node key; the signed operation
572    /// is the compact authority attestation and replay input.
573    SlashApproval {
574        /// The review whose approval is invalidated.
575        id: String,
576        /// Reviewer channel whose approval is invalidated.
577        reviewer: String,
578        /// Operator-visible reason for requiring re-review (non-empty).
579        reason: String,
580    },
581    /// Record `reviewer`'s verdict on review `id` (additive variant).
582    /// Only listed reviewers may post; re-posting overwrites the
583    /// reviewer's own earlier verdict (re-review after changes).
584    ///
585    /// `reviewer` is payload data and therefore covered by the author
586    /// signature; binding it to the submitting key is admission policy
587    /// (L2), not view semantics.
588    PostVerdict {
589        /// The review being answered.
590        id: String,
591        /// The responding reviewer (must be in the review's list).
592        reviewer: String,
593        /// The verdict.
594        verdict: Verdict,
595        /// Free-text rationale (may be empty).
596        note: String,
597    },
598    /// Append one comment to the discussion on review `id` (D38).
599    ///
600    /// This is the half D34 deferred: a review page could show what was
601    /// proposed and what was decided, and had nowhere to put the
602    /// conversation that produced the decision. Discussion is a persisted
603    /// operation like any other, so it is sequenced, replayable and
604    /// append-only rather than a mutable side table.
605    ///
606    /// Three properties live in the fold, and each is load-bearing:
607    ///
608    /// 1. **Order is the log's order.** The thread is a `Vec` appended in
609    ///    fold order, not a map keyed by id, because a total order over a
610    ///    conversation is the one thing a single-writer sequencer offers
611    ///    that a mutable comment table cannot.
612    /// 2. **`comment` is the replay defence.** `seq` and `parent` are
613    ///    assigned after signing (invariant 4), so nothing positional is
614    ///    covered by the author's signature; the payload carries its own
615    ///    identity instead and the fold refuses an id the review already
616    ///    holds. That is the CAS `prev` in the only shape a comment can
617    ///    take, since a comment moves no head. The ABA hole `prev` leaves
618    ///    for refs does not open here: an id is never released, because
619    ///    an archived review accepts no comments at all.
620    /// 3. **Nothing is ever edited.** There is no edit and no delete;
621    ///    a correction is a later comment. Removing one is a question
622    ///    about persisted history rather than about presentation, and it
623    ///    needs its own decision row (D38's tripwire).
624    ///
625    /// `author` is payload data and therefore covered by the author
626    /// signature; binding it to the submitting channel is admission
627    /// policy (L2), exactly as for [`OpKind::PostVerdict`]'s `reviewer`.
628    /// Without that binding the log's author attribution and the view's
629    /// comment attribution could disagree, which on a discussion surface
630    /// means words in somebody else's name.
631    PostComment {
632        /// The review being discussed.
633        id: String,
634        /// Caller-chosen comment id, unique within that review. It is the
635        /// author's retry identity — resubmitting the same comment is
636        /// refused rather than duplicated — and the anchor a later reply
637        /// or reaction would name.
638        comment: String,
639        /// The channel making the statement (must be the submitting
640        /// channel, enforced at admission).
641        author: String,
642        /// The comment text (non-empty).
643        body: String,
644    },
645    /// Remove named ref `name` under the same CAS rule (additive
646    /// variant, added for git branch deletion; wire-format unchanged).
647    DeleteRef {
648        /// Ref being removed.
649        name: String,
650        /// Expected current target (CAS).
651        prev: Option<ContentHash>,
652    },
653    /// Attach an intent/provenance record — task spec, plan, rationale
654    /// — to `subject` (D22 substrate; additive variant, wire-format
655    /// unchanged). The latest record per `(subject, kind)` wins in the
656    /// view; the full history stays in the log.
657    ///
658    /// `subject` is deliberately not bound to the submitting channel —
659    /// a shared living spec is written by many agents. Trusting *who*
660    /// wrote a record means reading the log entry's author.
661    RecordProvenance {
662        /// What the record is about: a workspace name, or any agreed
663        /// channel (e.g. a repo's shared spec).
664        subject: String,
665        /// Record type, e.g. `"task-spec"` or `"plan"`.
666        kind: String,
667        /// The record text (stored as-is; an empty body is a valid,
668        /// visible "withdrawn" state, not a deletion).
669        body: String,
670    },
671    /// Create stable logical change `id` and bind its first active
672    /// workspace at an immutable base revision (additive variant;
673    /// existing operation bytes are unchanged).
674    ///
675    /// The change id survives later checkpoints and workspace archival.
676    /// `idempotency_key` is scoped to `owner`; the view rejects a second
677    /// change using the same pair so a lost create response cannot fork
678    /// one logical request into two changes.
679    CreateChange {
680        /// Stable logical contribution id.
681        id: String,
682        /// Channel allowed to checkpoint the change (enforced by L2
683        /// admission because signatures are not part of the pure view).
684        owner: String,
685        /// Exclusively bound active workspace.
686        workspace: String,
687        /// Exact immutable revision the workspace starts from.
688        base_revision: ContentHash,
689        /// Retry identity, unique within `owner`.
690        idempotency_key: String,
691        /// Owner signature over the matching [`CreateAuthorization`].
692        /// Absent only on operations accepted before creation
693        /// authorization was introduced.
694        #[serde(default, skip_serializing_if = "Option::is_none")]
695        owner_sig: Option<Witness>,
696        /// Directory prefixes this change declares it works within, in
697        /// git's cone spelling (`"services/api"`). Empty = the whole
698        /// tree, which is what every change written before this field
699        /// existed decodes to and is exactly the previous behaviour
700        /// (invariant 1).
701        ///
702        /// **Declared, not enforced here.** The fold does not police
703        /// which paths a commit touches; a cone is a statement about
704        /// intent that the node can serve a matching partial clone from
705        /// and that [`conflicts_for_cone`] narrows a conflict report
706        /// against. Making it a hard boundary would need path
707        /// enforcement in the merge layer, which is a separate decision
708        /// and a much larger one.
709        ///
710        /// It sits in the signed payload rather than in node-side
711        /// config for the reason D49 puts a check there: a replayer can
712        /// then reproduce what the change said it was scoped to, and an
713        /// operator cannot rewrite it after the fact.
714        #[serde(default, skip_serializing_if = "Vec::is_empty")]
715        cone: Vec<String>,
716    },
717    /// Publish an immutable revision of an existing change and advance
718    /// its bound workspace under compare-and-set.
719    CheckpointChange {
720        /// Stable logical contribution id.
721        id: String,
722        /// The change's currently bound workspace.
723        workspace: String,
724        /// Newly published immutable revision.
725        revision: ContentHash,
726        /// Expected current change/workspace revision.
727        prev_revision: ContentHash,
728    },
729    /// Archive a stable change's active workspace under revision CAS.
730    /// The revision remains addressable on the change after the mutable
731    /// filesystem surface is detached.
732    ArchiveChange {
733        /// Stable logical contribution id.
734        id: String,
735        /// The change's currently bound workspace.
736        workspace: String,
737        /// Expected current change/workspace revision.
738        prev_revision: ContentHash,
739        /// Owner channel that signed the matching
740        /// [`ArchiveAuthorization`].
741        owner: String,
742        /// Signature over the canonical authorization bytes. Admission
743        /// verifies it before this node-authored operation may land.
744        owner_sig: Witness,
745    },
746    /// Bind actor key `key` to the durable operator identity `operator`
747    /// (additive variant, wire-format unchanged).
748    ///
749    /// Until now an operator existed only as a prefix convention on a
750    /// channel name ([`reviewer_operator`]) plus a line in a mutable,
751    /// operator-owned keys file. Both are rewritable without a trace, so
752    /// "these two keys are the same operator" was an assertion no replay
753    /// could reproduce. This op puts the assertion *in* the log, where it
754    /// is sequenced, append-only, and recoverable at any prefix via
755    /// [`View::at`].
756    ///
757    /// Two properties make it load-bearing, and both live in the fold:
758    ///
759    /// 1. **First binding wins the clock.** [`KeyBinding::bound_at`] is
760    ///    the sequence of the op that *first* bound the key, and never
761    ///    moves again — so re-binding to adjust a channel cannot reset
762    ///    accumulated standing.
763    /// 2. **One key, one operator, for the life of the key.** Re-binding
764    ///    a key to a different operator is refused, so whatever position
765    ///    a key accumulates cannot be handed to somebody else.
766    ///
767    /// **What this does not establish.** *Who* may author a binding is
768    /// admission policy (L2), exactly as for [`OpKind::AssignReviewers`]
769    /// and [`OpKind::SlashApproval`]. With no admission rule wired, any
770    /// key can bind any other key under any operator name, and `operator`
771    /// is a self-chosen label rather than a verified identity. The fold
772    /// proves *sequence and immutability*; it never proves authority.
773    BindKey {
774        /// The durable operator identity the key is bound to. Non-empty,
775        /// and free of `/` so it cannot alias a `operator/agent` channel
776        /// prefix and read as two different operators.
777        operator: String,
778        /// The actor key being bound: the content address of its public
779        /// key, as `choir_identity::ActorKey::actor_id` produces it.
780        key: ContentHash,
781        /// The channel name the operator asserts this key speaks as, when
782        /// it asserts one — the sequenced form of the keys file's
783        /// `<name> <hex>` line.
784        ///
785        /// Constrained so the two available operator answers cannot
786        /// disagree: [`reviewer_operator`] of this channel must equal
787        /// `operator`, i.e. the channel is `operator` itself or
788        /// `operator/<agent>`. Without that rule a durable record reading
789        /// "bob" and a channel prefix reading "alice" would both be live,
790        /// which is worse than a single wrong answer.
791        ///
792        /// Additive per invariant 1: a payload written before this field
793        /// existed decodes as `None` **and** re-serializes byte-identically,
794        /// so entry hashes do not move.
795        #[serde(default, skip_serializing_if = "Option::is_none")]
796        channel: Option<String>,
797    },
798    /// Withdraw `key`'s binding (additive variant, wire-format unchanged).
799    ///
800    /// Append-only and terminal. The binding row stays, so the operator
801    /// attribution for everything the key already did survives; the key
802    /// itself can never be bound again. Allowing a rebind would make
803    /// revocation a formality — revoke, rebind, carry on — so the remedy
804    /// is a fresh key, the same shape as [`OpKind::SlashApproval`]'s
805    /// "open a new review".
806    ///
807    /// This is the record a revocation cascade replays over, and the slot
808    /// a later vouch or bond withdrawal hangs off. It moves no ref and
809    /// undoes no landed change; like a slash, it constrains what comes
810    /// next rather than rewriting what came before.
811    ///
812    /// *Who* may revoke is admission policy (L2), as for
813    /// [`OpKind::BindKey`].
814    RevokeKey {
815        /// The bound key whose binding is withdrawn.
816        key: ContentHash,
817        /// Operator-visible reason for the withdrawal (non-empty).
818        reason: String,
819    },
820    /// Record that one operator vouches for another (D65; additive
821    /// variant, wire-format unchanged).
822    ///
823    /// This is the edge D24's Sybil resistance was missing. Key age
824    /// already orders identities by standing, and it cannot tell a
825    /// hundred keys one stranger bound from a hundred keys a hundred
826    /// operators bound: age is a fact about a key, never about anybody's
827    /// opinion of it. A vouch is the opinion, sequenced.
828    ///
829    /// **Both ends must already be operators this log knows.** The fold
830    /// refuses a `voucher` or `subject` with no unrevoked binding, which
831    /// is what makes the floor replayable rather than one node's private
832    /// admission rule: minting an identity to vouch *with* costs a
833    /// [`OpKind::BindKey`], and only the node authors those. The same
834    /// rule bounds the map — at most one edge per ordered pair of bound
835    /// operators, both drawn from a set the node itself admitted — so no
836    /// client can grow the view by inventing names (D64).
837    ///
838    /// One edge per `(voucher, subject)`: vouching where an edge already
839    /// stands is refused, so the pair is its own retry identity, exactly
840    /// as a comment id is for [`OpKind::PostComment`]. Changing the note
841    /// means withdrawing and vouching again, which is a new statement
842    /// and dated as one.
843    ///
844    /// **It authorizes nothing.** No threshold, no score, no path count:
845    /// nothing in this crate or the daemon reads a vouch to decide
846    /// anything. That is deliberate rather than unfinished. A number
847    /// computed from this graph would read as a measurement of
848    /// trustworthiness while measuring how willing operators are to type
849    /// each other's names, and the first thing that number would do is
850    /// become worth farming.
851    ///
852    /// *Who* may author one is admission policy (L2), as for
853    /// [`OpKind::PostVerdict`]: the daemon binds `voucher` to the
854    /// operator of the signing channel. The fold proves the ends exist
855    /// and the edge is new; it never proves the voucher signed it.
856    Vouch {
857        /// The operator doing the vouching (must be the operator of the
858        /// submitting channel, enforced at admission).
859        voucher: String,
860        /// The operator being vouched for. Never equal to `voucher`: an
861        /// identity asserting its own standing is the one statement a
862        /// Sybil can always make.
863        subject: String,
864        /// What the voucher wants a reader to know. May be empty.
865        note: String,
866    },
867    /// Withdraw a vouch (D65; additive variant, wire-format unchanged).
868    ///
869    /// The edge leaves the view and both ops stay in the log. That split
870    /// is the deliberate half: a tombstone row would either make
871    /// withdrawal terminal, which trust is not, or be overwritten by the
872    /// next vouch, which makes it a row that says nothing. So a current
873    /// view answers "who vouches for X **now**", and "who used to" is a
874    /// question for the log — the same division [`OpKind::DeleteRef`]
875    /// makes, where the ref goes and the commits stay.
876    ///
877    /// Withdrawal is not terminal for the pair, unlike
878    /// [`OpKind::RevokeKey`]. Vouching again is admissible and starts a
879    /// fresh [`VouchState::at`]. The revocation argument does not carry
880    /// over: a rebindable revocation is no revocation, but a withdrawal
881    /// that could never be reconsidered would make one bad afternoon
882    /// permanent, and the remedy `RevokeKey` offers — use a fresh key —
883    /// has no counterpart when the thing withdrawn is an opinion about
884    /// somebody else.
885    WithdrawVouch {
886        /// The operator withdrawing (must be the operator of the
887        /// submitting channel, enforced at admission).
888        voucher: String,
889        /// The operator no longer vouched for.
890        subject: String,
891        /// Operator-visible reason (non-empty). It lives in the log
892        /// rather than in the view, for the reason above.
893        reason: String,
894    },
895    /// One witness's cosignature over the latest ref-state attestation
896    /// (D67): "I saw this complete ref-state at this position".
897    ///
898    /// The unit is D25's [`RefSnapshot`], never an individual ref, and
899    /// never an [`choir_oplog::OpEntry`]. An entry's `witnesses` field
900    /// is inside the bytes its content hash covers, so a cosignature
901    /// added after the fact would rewrite the entry and orphan every
902    /// descendant — in-entry witnessing is therefore synchronous by
903    /// construction, and D16's tripwire exists precisely to keep that
904    /// off the sequencer's critical path. This is the async branch that
905    /// row already names as its alternative: an ordinary op, admitted
906    /// after the snapshot it attests, costing the hot path nothing.
907    ///
908    /// The witness signs in the only way this system has: the op's own
909    /// author signature covers `(channel, payload)`, and the payload
910    /// names the snapshot by content address. No second signature
911    /// scheme, and nothing new to verify.
912    CountersignSnapshot {
913        /// The operator doing the witnessing (must be the operator of
914        /// the submitting channel, enforced at admission, and never the
915        /// node whose log this is).
916        witness: String,
917        /// Content address of the snapshot being attested, which must be
918        /// the latest one the fold admitted. Attesting anything else is
919        /// the stale-ref-state replay D25 names, so it is refused rather
920        /// than recorded as a claim about the past.
921        snapshot: ContentHash,
922    },
923    /// Record a signed attestation of the **complete** ref-state at one
924    /// log position (D25; additive variant, wire-format unchanged).
925    ///
926    /// This is the object that closes the gap the attestation section of
927    /// the design notes states: every existing check proves the chain a
928    /// reader *was shown* is consistent and authentically authored, none
929    /// proves another reader was shown the same chain. A snapshot is the
930    /// unit two readers compare, the record that makes a mirror bundle
931    /// checkable against the log, the checkpoint truncation needs, and —
932    /// when D16's gate opens — the thing a witness cosigns. One object,
933    /// because those are one question: "what was the whole ref-state at
934    /// seq N?".
935    ///
936    /// The fold *verifies* the claim rather than storing it: admission
937    /// compares [`RefSnapshot::refs`] against the view's refs and
938    /// [`RefSnapshot::at_seq`] against the fold position, so a snapshot
939    /// that lies about the log it sits in is refused by every replayer,
940    /// not just by the node that admitted it. The chain rule
941    /// (`prev_snapshot` must name the latest admitted snapshot) makes
942    /// replaying an old snapshot a chain violation even where the ref
943    /// map recurs — the ABA shape D26 measured, answered here the same
944    /// way `prev` answers it for refs.
945    ///
946    /// *Who* may record one is admission policy (L2), as for
947    /// [`OpKind::AssignReviewers`]: the daemon accepts it only from its
948    /// own key. The view enforces truth, not authority.
949    RecordRefSnapshot {
950        /// The snapshot; its detached file projection is these exact
951        /// canonical bytes, never a second schema.
952        snapshot: RefSnapshot,
953    },
954    /// Record that `viewer` read review `id` (a read receipt; additive
955    /// variant, wire-format unchanged; the backlog).
956    ///
957    /// The receipt is what lets an author distinguish "reviewed and
958    /// ignored" from "nobody has looked yet". The fact recorded is the
959    /// *first* read per viewer -- when this review first got that
960    /// reader's attention -- so the fold refuses a viewer the review
961    /// already holds, and that refusal doubles as the replay defence,
962    /// exactly as a comment id does for [`OpKind::PostComment`]: nothing
963    /// positional is signed, so the payload's (review, viewer) pair is
964    /// its own retry identity.
965    ///
966    /// A receipt moves no ref, changes no verdict and carries no
967    /// authorization weight; it is bulk in [`OpKind::ArchiveReview`]'s
968    /// sense and is dropped with the rest of it.
969    ///
970    /// `viewer` is payload data covered by the author signature; binding
971    /// it to the submitting channel is admission policy (L2), exactly as
972    /// for [`OpKind::PostVerdict`]'s `reviewer`.
973    ViewedReview {
974        /// The review that was read.
975        id: String,
976        /// The channel that read it (must be the submitting channel,
977        /// enforced at admission).
978        viewer: String,
979    },
980    /// Record one automated check's outcome on a commit (D49; additive
981    /// variant, wire-format unchanged).
982    ///
983    /// **The node does not run the check.** Executing workflows is
984    /// containers, secrets, caches and artifacts — the largest surface
985    /// on the platform and the least differentiated part of it, since
986    /// every forge already has one. What no forge has is a check result
987    /// that is *ordered against the ref it attests* and replayable by
988    /// someone who trusts none of the parties. So this op carries the
989    /// verdict and nothing else: any runner, or a person, reports by
990    /// signing one.
991    ///
992    /// That inversion is what makes it stronger than a status field on a
993    /// merge gate. A field is mutable and is read at merge time, so
994    /// "was this green when it landed" decays into "is it green now". A
995    /// signed op is append-only and sits at a known sequence, so the
996    /// question stays decidable forever, and `choir log --verify`
997    /// already recomputes the hash and checks the signature.
998    ///
999    /// Re-reporting overwrites the reporter's own earlier result for the
1000    /// same `(subject, name)`, exactly as [`OpKind::PostVerdict`] lets a
1001    /// reviewer re-review. A check that flaps is a check that flaps; the
1002    /// log keeps every report and the view keeps the latest.
1003    ///
1004    /// `reporter` is payload data covered by the author signature.
1005    /// Binding it to the submitting channel is admission policy (L2),
1006    /// the same split as `reviewer` and `viewer` above.
1007    RecordCheck {
1008        /// The commit the check ran against.
1009        subject: ContentHash,
1010        /// Check name, e.g. `"ci/build"` (non-empty).
1011        name: String,
1012        /// What the check found.
1013        status: CheckStatus,
1014        /// Where a human can read the run: a URL, a run id, or empty.
1015        evidence: String,
1016        /// The channel reporting it (must be the submitting channel,
1017        /// enforced at admission).
1018        reporter: String,
1019        /// The ref this check's subject is proposed to land on, in the
1020        /// view's namespaced form `<repo>:<refname>`. `None` = unbound.
1021        ///
1022        /// Present for the same reason [`OpKind::RequestReview`] carries
1023        /// one, and it is load-bearing twice: per-ref policy conditions
1024        /// on it, and it is the only thing that lets the ACL narrow a
1025        /// check to a repository. A commit id names no repository, so a
1026        /// check without this field is visible to node-wide readers
1027        /// only — correct, and useless to the repository it belongs to.
1028        #[serde(default, skip_serializing_if = "Option::is_none")]
1029        target_ref: Option<String>,
1030    },
1031    /// Land a reviewed commit on a ref **and record why it was allowed**
1032    /// (D43; additive variant, wire-format unchanged).
1033    ///
1034    /// [`OpKind::SetRef`] can already move a ref, and the landing gate
1035    /// already runs before it. What the log does not keep is the reason:
1036    /// the gate is evaluated at apply time inside the node's submission
1037    /// policy against files that are not in the log, so a `SetRef` on a
1038    /// protected ref records that a merge happened and nothing about what
1039    /// permitted it. This op is the same move with the answer attached.
1040    ///
1041    /// Three properties live in the fold:
1042    ///
1043    /// 1. **The named review must actually say what the landing claims.**
1044    ///    It must exist, be live, and name exactly this `(name, commit)`
1045    ///    pair. An authorization citing a review about something else is
1046    ///    the failure mode worth refusing.
1047    /// 2. **The authorization is rederived, never trusted.** Approvers,
1048    ///    approval weight, and the approving owner's standing verdict are
1049    ///    all computed from the view and compared; a mismatch is a
1050    ///    rejection. The ACL grant behind an owner basis is the one part
1051    ///    no replayer can check, because it lives in an operator file.
1052    /// 3. **It is the sole reason the record outlives the review.**
1053    ///    [`OpKind::ArchiveReview`] discards verdicts, so after archiving
1054    ///    the entry bytes are the only surviving answer to "who approved
1055    ///    this". Replay still verifies, because validation runs at this
1056    ///    op's own position — before the archive that comes later.
1057    ///
1058    /// **This op narrows the gate on purpose.** The node's weight check
1059    /// takes the maximum over *every* review naming `(ref, commit)`; a
1060    /// `Submit` names one review and is judged on that one, so two
1061    /// half-approved reviews of the same commit cannot pool weight
1062    /// through this path.
1063    ///
1064    /// **It is refused on a ref no rule gates.** A node not running the
1065    /// review gate, or a ref outside its protected set, takes an
1066    /// ordinary [`OpKind::SetRef`]. Admitting a `Submit` there would
1067    /// mint an authorization record for a decision nothing examined,
1068    /// which is worse than no record — so [`Basis`] has no variant for
1069    /// it and admission says so.
1070    ///
1071    /// *Who* may submit one is admission policy (L2). Unlike
1072    /// [`OpKind::AssignReviewers`] this one is **author-signed**: the
1073    /// basis is a node determination, but pressing merge is a human act,
1074    /// and the signature is the only place the log can keep who wanted
1075    /// it. Admission rederives the authorization and refuses any
1076    /// mismatch, so a client that writes its own basis buys a rejection
1077    /// rather than a claim.
1078    Submit {
1079        /// The review being landed.
1080        review: String,
1081        /// Ref name, in the view's namespaced form (`<repo>:<refname>`).
1082        name: String,
1083        /// The commit to land, which must be the review's target.
1084        commit: ContentHash,
1085        /// Expected current target (CAS), `None` to create.
1086        prev: Option<ContentHash>,
1087        /// Why this was allowed.
1088        authorization: Authorization,
1089    },
1090}
1091
1092/// A signed attestation that the complete ref-state at log position
1093/// [`RefSnapshot::at_seq`] was exactly [`RefSnapshot::refs`] (D25).
1094///
1095/// Carried inside [`OpKind::RecordRefSnapshot`], and written detached —
1096/// byte-identical — beside mirror bundles so a bundle is checkable
1097/// against something other than itself.
1098///
1099/// The chain pointer lives **inside** this struct rather than being
1100/// inherited from `OpEntry.parent` because `seq`/`parent` are assigned
1101/// after signing: a snapshot copied out of the log would otherwise carry
1102/// no chain of its own, and an old one could be served forever. Same
1103/// discipline as the CAS `prev` inside a payload.
1104///
1105/// Ref names are map keys, and a git ref name may legally carry `"` and
1106/// any non-ASCII byte (git forbids control bytes, space, `~^:?*[\`, but
1107/// not quotes or high bytes) — and an API-submitted name is not bound by
1108/// git's grammar at all. Canonical serialization of hostile keys is
1109/// therefore pinned by this struct's golden vector and property tests,
1110/// not assumed.
1111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1112pub struct RefSnapshot {
1113    /// Wire-format version; see [`FORMAT_VERSION`].
1114    pub format_version: u16,
1115    /// The complete ref map at `at_seq`, in the view's namespaced form
1116    /// (`<repo>:<refname>`). Every ref the view holds — a selection
1117    /// would let an equivocating node attest only the refs it is honest
1118    /// about.
1119    pub refs: BTreeMap<String, ContentHash>,
1120    /// The fold position the state was read at: the number of ops
1121    /// applied before this one. Admission requires it to equal the
1122    /// view's position, so it is also the sequence this op itself
1123    /// occupies in the log.
1124    pub at_seq: u64,
1125    /// Content address ([`RefSnapshot::id`]) of the previous snapshot on
1126    /// this log; `None` only for a log's first snapshot. Additive per
1127    /// invariant 1.
1128    #[serde(default, skip_serializing_if = "Option::is_none")]
1129    pub prev_snapshot: Option<ContentHash>,
1130}
1131
1132impl RefSnapshot {
1133    /// The canonical bytes: what is hashed, what the author signs over
1134    /// (inside the op payload), and what the detached file contains.
1135    #[must_use]
1136    pub fn canonical_bytes(&self) -> Vec<u8> {
1137        serde_json::to_vec(self).expect("RefSnapshot is always serializable")
1138    }
1139
1140    /// Content address of the canonical bytes — the identity the next
1141    /// snapshot's `prev_snapshot` names.
1142    #[must_use]
1143    pub fn id(&self) -> ContentHash {
1144        ContentHash::blake3(&self.canonical_bytes())
1145    }
1146}
1147
1148/// What one witness has attested, as the fold sees it after replaying
1149/// [`OpKind::CountersignSnapshot`] (D67).
1150///
1151/// One row per witness, not one per `(witness, snapshot)` pair. The
1152/// question worth answering is "how many witnesses have seen the
1153/// ref-state that is current", and keeping only the latest bounds this
1154/// section by the witness population rather than by uptime — the D64
1155/// growth property, and the same trade [`View::vouches`] makes.
1156/// Everything older is still in the log, which is where history lives.
1157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1158pub struct WitnessState {
1159    /// Content address of the snapshot this witness last attested.
1160    pub snapshot: ContentHash,
1161    /// Log sequence of the countersigning op.
1162    pub at: u64,
1163}
1164
1165/// One actor key's durable binding to an operator identity, as the fold
1166/// sees it after replaying [`OpKind::BindKey`] and [`OpKind::RevokeKey`].
1167#[derive(Debug, Clone, PartialEq, Eq)]
1168pub struct KeyBinding {
1169    /// The operator identity this key belongs to. Fixed at first binding.
1170    pub operator: String,
1171    /// The channel the operator asserts this key speaks as, if any. The
1172    /// only field a later re-binding may change.
1173    pub channel: Option<String>,
1174    /// Log sequence of the op that *first* bound this key.
1175    ///
1176    /// This is the age primitive: it is assigned once and never moves, so
1177    /// it orders keys by standing in a way replay reproduces exactly. It
1178    /// counts **sequenced ops, not elapsed time** — see
1179    /// [`View::ops_since_binding`] for what that can and cannot answer.
1180    pub bound_at: u64,
1181    /// The withdrawal record, once revoked; never cleared.
1182    pub revoked: Option<Revocation>,
1183}
1184
1185impl KeyBinding {
1186    /// Whether this binding has been withdrawn. Authorization must ask;
1187    /// attribution must not (see [`View::operator_of`]).
1188    #[must_use]
1189    pub fn is_revoked(&self) -> bool {
1190        self.revoked.is_some()
1191    }
1192}
1193
1194/// The append-only record that a binding was withdrawn.
1195#[derive(Debug, Clone, PartialEq, Eq)]
1196pub struct Revocation {
1197    /// Log sequence of the [`OpKind::RevokeKey`] op that withdrew it.
1198    pub at: u64,
1199    /// Operator-visible reason for the withdrawal.
1200    pub reason: String,
1201}
1202
1203/// One standing vouch, as the fold sees it after replaying
1204/// [`OpKind::Vouch`] (D65).
1205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1206pub struct VouchState {
1207    /// Log sequence of the op that placed this edge.
1208    ///
1209    /// Sequenced ops rather than elapsed time, exactly as
1210    /// [`KeyBinding::bound_at`] is. Unlike `bound_at` it *does* move:
1211    /// withdrawing and vouching again writes a new position, because
1212    /// that is a second statement and not a correction of the first.
1213    pub at: u64,
1214    /// What the voucher said. May be empty.
1215    pub note: String,
1216}
1217
1218/// A reviewer's answer to a review request.
1219#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1220pub enum Verdict {
1221    /// The change may land.
1222    Approve,
1223    /// The change needs work before landing.
1224    RequestChanges,
1225}
1226
1227/// What an automated check found about a commit (D49).
1228///
1229/// Four states and not two. Each one implies a different action, which
1230/// is the only reason a state earns a variant. "Not finished" is a real
1231/// answer and the one a caller most needs to distinguish, because the
1232/// action it implies -- wait -- differs from both pass and fail.
1233/// Collapsing it into failure makes every in-flight check look like a
1234/// broken build; collapsing it into success is worse.
1235///
1236/// `Errored` is the durable half of D18's central claim: a provider
1237/// fault is not a statement about the commit. `choir-queue` already
1238/// refuses to evict a change on one, but until this variant existed the
1239/// fault could be recorded nowhere, so a re-run was the only way anyone
1240/// downstream learned it had happened.
1241#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1242pub enum CheckStatus {
1243    /// The check ran and was satisfied.
1244    Passed,
1245    /// The check ran and was not satisfied.
1246    Failed,
1247    /// The check has started and has not reported an outcome.
1248    Running,
1249    /// The check could not be run, so it says nothing about the commit.
1250    ///
1251    /// A VM that failed to boot, a runner that vanished, a job over its
1252    /// deadline. The distinction from [`CheckStatus::Failed`] is not
1253    /// cosmetic: `Failed` is evidence about the commit and blocks it on
1254    /// its merits, while `Errored` is evidence about us, and the action
1255    /// it implies is a re-run rather than a rewrite.
1256    Errored,
1257}
1258
1259impl CheckStatus {
1260    /// The wire spelling, which is also what the CLI accepts.
1261    #[must_use]
1262    pub fn as_str(self) -> &'static str {
1263        match self {
1264            CheckStatus::Passed => "passed",
1265            CheckStatus::Failed => "failed",
1266            CheckStatus::Running => "running",
1267            CheckStatus::Errored => "errored",
1268        }
1269    }
1270
1271    /// Parses the CLI spelling, or `None` for anything else.
1272    ///
1273    /// Deliberately not a `FromStr` impl taking arbitrary case: a check
1274    /// reported as `"PASSED"` by a runner that upcased its output should
1275    /// be refused loudly rather than accepted into a signed op, because
1276    /// the op is what a later audit reads.
1277    #[must_use]
1278    pub fn parse(raw: &str) -> Option<Self> {
1279        match raw {
1280            "passed" => Some(CheckStatus::Passed),
1281            "failed" => Some(CheckStatus::Failed),
1282            "running" => Some(CheckStatus::Running),
1283            "errored" => Some(CheckStatus::Errored),
1284            _ => None,
1285        }
1286    }
1287}
1288
1289/// The latest report for one `(subject, check name)` pair.
1290#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1291pub struct CheckState {
1292    /// What the check found.
1293    pub status: CheckStatus,
1294    /// Where a human can read the run; may be empty.
1295    pub evidence: String,
1296    /// Channel that reported it.
1297    pub reporter: String,
1298    /// Ref the subject is proposed to land on, when the report named
1299    /// one. This is what the node's ACL narrows a check on.
1300    #[serde(default, skip_serializing_if = "Option::is_none")]
1301    pub target_ref: Option<String>,
1302}
1303
1304/// Whether a review is still accepting verdicts, or has been settled and
1305/// had its bulk dropped.
1306#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1307pub enum ReviewStatus {
1308    /// Accepting verdicts; `reviewers` and `verdicts` are authoritative.
1309    #[default]
1310    Live,
1311    /// Settled: `reviewers` and `verdicts` have been dropped, and the
1312    /// outcome they produced is recorded here instead. Distinguishable
1313    /// from a review that never existed, which is the point — a reviewer
1314    /// told "no such review" would go hunting for a typo.
1315    Archived {
1316        /// The verdict the review had reached when it was archived.
1317        approved: bool,
1318        /// Approval weight at settlement time. Reviewer details are
1319        /// dropped, so this compact scalar preserves the per-operator
1320        /// cap for later protected-ref authorization.
1321        approval_weight: usize,
1322    },
1323}
1324
1325/// Maximum approval weight contributed by one operator, regardless of
1326/// how many agent channels that operator controls.
1327pub const MAX_APPROVAL_WEIGHT_PER_OPERATOR: usize = 1;
1328
1329/// The operator a review channel belongs to: the part before the first
1330/// `/` in `operator/agent`, or the whole name when no prefix is present.
1331///
1332/// Unprefixed names remain distinct operators, preserving the original
1333/// flat-name behavior. Nodes that rely on this boundary must bind channel
1334/// names to trusted keys; otherwise the prefix is only a self-assertion.
1335#[must_use]
1336pub fn reviewer_operator(name: &str) -> &str {
1337    name.split_once('/').map_or(name, |(operator, _)| operator)
1338}
1339
1340/// One comment on a review, as the fold sees it after replaying
1341/// [`OpKind::PostComment`] (D38).
1342///
1343/// There is no timestamp. A pure fold has no clock, so `at` counts
1344/// sequenced ops exactly as [`KeyBinding::bound_at`] does: it orders the
1345/// thread, it is monotonic, and replay reproduces it from the log alone.
1346/// Anything phrased in minutes or days needs a durable timestamp this
1347/// crate does not have.
1348#[derive(Debug, Clone, PartialEq, Eq)]
1349pub struct CommentState {
1350    /// The author's chosen id, unique within the review.
1351    pub id: String,
1352    /// Channel that made the statement.
1353    pub author: String,
1354    /// The comment text.
1355    pub body: String,
1356    /// Fold position the comment was applied at, which is the log
1357    /// sequence its op occupies.
1358    pub at: u64,
1359}
1360
1361/// One reviewer's answer, as the fold recorded it.
1362///
1363/// The `at` field is D44's addition and the reason this is a struct
1364/// rather than the `(Verdict, String)` pair it used to be. A verdict is
1365/// keyed by **channel**, and a channel's key rotates: revoke a key and
1366/// bind a fresh one and the channel now names a key that never cast this
1367/// verdict. Without the position, "which key approved this" has no answer
1368/// the log can reproduce, and an authorization record naming the current
1369/// key would be asserting something false.
1370///
1371/// This is derived state, not wire format — nothing here is hashed or
1372/// persisted, so recording it moves no entry hash.
1373#[derive(Debug, Clone, PartialEq, Eq)]
1374pub struct VerdictState {
1375    /// The answer itself.
1376    pub verdict: Verdict,
1377    /// The note the reviewer attached; empty when they left none.
1378    pub note: String,
1379    /// Fold position the verdict was applied at, which is the log
1380    /// sequence its op occupies — the same clock as
1381    /// [`CommentState::at`] and [`KeyBinding::bound_at`].
1382    pub at: u64,
1383}
1384
1385/// Materialized state of one review: what is under review, who was
1386/// asked, who has answered what, and what was said about it.
1387#[derive(Debug, Clone, PartialEq, Eq, Default)]
1388pub struct ReviewState {
1389    /// The commit under review.
1390    pub target: Option<ContentHash>,
1391    /// Actors the review fanned out to.
1392    pub reviewers: Vec<String>,
1393    /// reviewer → their answer; absent = not answered yet.
1394    pub verdicts: BTreeMap<String, VerdictState>,
1395    /// reviewer → node-recorded reason for retroactively invalidating
1396    /// that approval. Kept separately from verdict bulk so archived rows
1397    /// remain compact while the append-only decision stays visible.
1398    pub slashes: BTreeMap<String, String>,
1399    /// The ref the review proposes to land on (`<repo>:<refname>`), or
1400    /// `None` for a review that named no destination. Policy reads this
1401    /// to decide whether a review is privilege-bearing.
1402    pub target_ref: Option<String>,
1403    /// The discussion, in the order the sequencer admitted it (D38).
1404    /// Emptied by [`OpKind::ArchiveReview`] with the rest of the bulk.
1405    pub comments: Vec<CommentState>,
1406    /// viewer → fold position of that viewer's first recorded read
1407    ///. Emptied by [`OpKind::ArchiveReview`]
1408    /// with the rest of the bulk.
1409    pub viewed: BTreeMap<String, u64>,
1410    /// Live, or settled with its outcome retained. Defaults to
1411    /// [`ReviewStatus::Live`], so replaying a log written before
1412    /// archiving existed yields exactly the previous behaviour.
1413    pub status: ReviewStatus,
1414}
1415
1416/// Materialized identity and current revision of one logical change.
1417///
1418/// Revision history remains in the append-only op log. The view keeps the
1419/// latest exact revision needed for CAS and review selection, plus the
1420/// create binding needed to make workspace retries deterministic.
1421#[derive(Debug, Clone, PartialEq, Eq)]
1422pub struct ChangeState {
1423    /// Channel allowed to checkpoint this change.
1424    pub owner: String,
1425    /// Workspace identity originally bound to the change. Retained after
1426    /// archival so a delayed retry cannot target a later workspace that
1427    /// reused the same name.
1428    pub workspace_id: String,
1429    /// Active mutable workspace, or `None` after archival.
1430    pub active_workspace: Option<String>,
1431    /// Immutable revision from which the change began.
1432    pub base_revision: ContentHash,
1433    /// Latest immutable revision published for the change. This equals
1434    /// `base_revision` until the first checkpoint.
1435    pub revision_id: ContentHash,
1436    /// Owner-scoped identity of the create request.
1437    pub idempotency_key: String,
1438    /// Directory prefixes the change declared it works within; empty
1439    /// means the whole tree.
1440    pub cone: Vec<String>,
1441}
1442
1443impl ReviewState {
1444    fn operator_is_slashed(&self, reviewer: &str) -> bool {
1445        let operator = reviewer_operator(reviewer);
1446        self.slashes
1447            .keys()
1448            .any(|slashed| reviewer_operator(slashed) == operator)
1449    }
1450
1451    fn live_approval_weight(&self, apply_slashes: bool) -> usize {
1452        self.counted_approver_channels(apply_slashes).len() * MAX_APPROVAL_WEIGHT_PER_OPERATOR
1453    }
1454
1455    fn counted_approver_channels(&self, apply_slashes: bool) -> Vec<(&str, u64)> {
1456        self.verdicts
1457            .iter()
1458            .enumerate()
1459            .filter(|(index, (reviewer, answer))| {
1460                if answer.verdict != Verdict::Approve
1461                    || (apply_slashes && self.operator_is_slashed(reviewer))
1462                {
1463                    return false;
1464                }
1465                let operator = reviewer_operator(reviewer);
1466                self.verdicts
1467                    .iter()
1468                    .take(*index)
1469                    .all(|(prior, prior_answer)| {
1470                        prior_answer.verdict != Verdict::Approve
1471                            || reviewer_operator(prior) != operator
1472                    })
1473            })
1474            .map(|(_, (reviewer, answer))| (reviewer.as_str(), answer.at))
1475            .collect()
1476    }
1477
1478    /// The reviewer channels [`ReviewState::approval_weight`] actually
1479    /// counts: the first standing `Approve` from each distinct operator,
1480    /// in verdict order, slashes applied.
1481    ///
1482    /// This exists so a [`OpKind::Submit`] can name the approvals its
1483    /// weight rested on rather than "everyone who clicked approve". The
1484    /// weight is *defined* as this list's length times
1485    /// [`MAX_APPROVAL_WEIGHT_PER_OPERATOR`], so the number in an
1486    /// authorization record and the names beside it cannot drift.
1487    ///
1488    /// Each entry pairs the channel with the fold position of the verdict
1489    /// being counted, because that position is what resolves the channel
1490    /// to a key (see [`View::bound_actor_at`]). Returning the channel
1491    /// alone would leave every caller to look the position up again, and
1492    /// the one that forgot would silently credit whatever key holds the
1493    /// channel today.
1494    ///
1495    /// Meaningless on an archived review, whose verdicts are gone: the
1496    /// list is empty while [`ReviewState::approval_weight`] still answers
1497    /// from the stored total.
1498    #[must_use]
1499    pub fn counted_approvers(&self) -> Vec<(&str, u64)> {
1500        self.counted_approver_channels(true)
1501    }
1502
1503    /// Whether `reviewer` has an `Approve` verdict that no slash has
1504    /// invalidated — the individual half of [`ReviewState::counted_approvers`],
1505    /// used where one named approval has to be checked rather than a set.
1506    #[must_use]
1507    pub fn approval_stands(&self, reviewer: &str) -> bool {
1508        self.standing_approval_at(reviewer).is_some()
1509    }
1510
1511    /// The fold position of `reviewer`'s standing approval, or `None` if
1512    /// they have none — [`ReviewState::approval_stands`] plus the one
1513    /// fact a caller needs to resolve it to a key
1514    /// ([`View::bound_actor_at`]).
1515    ///
1516    /// The predicate is defined as this returning `Some`, so the two can
1517    /// never disagree about what "standing" means.
1518    #[must_use]
1519    pub fn standing_approval_at(&self, reviewer: &str) -> Option<u64> {
1520        self.verdicts
1521            .get(reviewer)
1522            .filter(|answer| answer.verdict == Verdict::Approve)
1523            .filter(|_| !self.operator_is_slashed(reviewer))
1524            .map(|answer| answer.at)
1525    }
1526
1527    fn slashed_operator_count(&self) -> usize {
1528        self.slashes
1529            .keys()
1530            .enumerate()
1531            .filter(|(index, reviewer)| {
1532                let operator = reviewer_operator(reviewer);
1533                self.slashes
1534                    .keys()
1535                    .take(*index)
1536                    .all(|prior| reviewer_operator(prior) != operator)
1537            })
1538            .count()
1539    }
1540
1541    /// Whether every listed reviewer has answered. An unassigned review
1542    /// (no reviewers yet) is never complete — vacuous truth must not
1543    /// turn "asked nobody" into a finished review.
1544    #[must_use]
1545    pub fn complete(&self) -> bool {
1546        if matches!(self.status, ReviewStatus::Archived { .. }) {
1547            // Archiving requires completeness, and the reviewer list it
1548            // was computed from is gone. Recomputing here would read the
1549            // emptied list and answer "not complete", silently unfinishing
1550            // every settled review.
1551            return true;
1552        }
1553        !self.reviewers.is_empty() && self.reviewers.iter().all(|r| self.verdicts.contains_key(r))
1554    }
1555
1556    /// Whether the review is complete with no `RequestChanges`.
1557    #[must_use]
1558    pub fn approved(&self) -> bool {
1559        if let ReviewStatus::Archived { approved, .. } = self.status {
1560            return approved && self.approval_weight() > 0;
1561        }
1562        if !self.complete() {
1563            return false;
1564        }
1565        let mut eligible = self
1566            .reviewers
1567            .iter()
1568            .filter(|reviewer| !self.slashes.contains_key(*reviewer));
1569        let Some(first) = eligible.next() else {
1570            return false;
1571        };
1572        self.verdicts
1573            .get(first)
1574            .is_some_and(|answer| answer.verdict == Verdict::Approve)
1575            && eligible.all(|reviewer| {
1576                self.verdicts
1577                    .get(reviewer)
1578                    .is_some_and(|answer| answer.verdict == Verdict::Approve)
1579            })
1580    }
1581
1582    /// Approval weight after capping every operator at one unit.
1583    ///
1584    /// Multiple agent channels under one `operator/agent` prefix never
1585    /// manufacture additional approval weight. Archived reviews return
1586    /// the compact weight captured before their reviewer detail was
1587    /// dropped.
1588    #[must_use]
1589    pub fn approval_weight(&self) -> usize {
1590        if let ReviewStatus::Archived {
1591            approval_weight, ..
1592        } = self.status
1593        {
1594            return approval_weight
1595                .saturating_sub(self.slashed_operator_count() * MAX_APPROVAL_WEIGHT_PER_OPERATOR);
1596        }
1597        self.live_approval_weight(true)
1598    }
1599
1600    /// Whether a retroactive invalidation requires a fresh review before
1601    /// the same `(ref, commit)` can authorize another protected landing.
1602    #[must_use]
1603    pub fn re_review_required(&self) -> bool {
1604        !self.slashes.is_empty()
1605    }
1606}
1607
1608/// Failure modes of view folding and commit storage.
1609#[derive(Debug)]
1610pub enum ViewError {
1611    /// CAS failure: `prev` did not match the current head/target.
1612    StaleHead {
1613        /// The workspace or ref name that was targeted.
1614        target: String,
1615        /// What the op expected the current value to be.
1616        expected: Option<ContentHash>,
1617        /// What the view actually held.
1618        actual: Option<ContentHash>,
1619    },
1620    /// Payload or stored commit bytes failed to decode.
1621    Decode(String),
1622    /// Underlying op-log failure.
1623    Log(LogError),
1624    /// Underlying chunk-store failure.
1625    Store(StoreError),
1626    /// Review-op precondition failure (duplicate id, unknown review,
1627    /// or a reviewer not on the review's list).
1628    Review(String),
1629    /// Provenance-record precondition failure (empty subject or kind).
1630    Provenance(String),
1631    /// Check-report precondition failure: an empty name or reporter, or
1632    /// a name carrying the key separator.
1633    Check(String),
1634    /// Change lifecycle precondition failure (duplicate identity,
1635    /// invalid binding, unknown/archived change, or no-op checkpoint).
1636    Change(String),
1637    /// Key-binding precondition failure: an empty or `/`-bearing
1638    /// operator, a channel that reads as a different operator, a key
1639    /// already bound elsewhere, or a revoked/unbound key.
1640    Identity(String),
1641    /// Ref-snapshot precondition failure: the claimed ref map does not
1642    /// match the view, the claimed position is not the fold position, or
1643    /// the chain pointer does not name the latest admitted snapshot.
1644    Snapshot(String),
1645    /// Resolution-link precondition failure: [`Commit::resolves`] names
1646    /// a commit the store does not hold, or one with nothing to resolve.
1647    Resolution(String),
1648    /// Vouch precondition failure (D65): an end that is not an operator
1649    /// with an unrevoked binding, a self-vouch, an edge that already
1650    /// stands, or a withdrawal of one that does not.
1651    Vouch(String),
1652    /// Witness precondition failure (D67): a witness with no live key
1653    /// binding, a cosignature over anything but the latest ref-state
1654    /// attestation, or one that witness has already made.
1655    Witness(String),
1656}
1657
1658/// One entry in a commit's tree: a path maps to file content or to an
1659/// unresolved conflict (first-class: committing this is valid, D9).
1660#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1661pub enum TreeEntry {
1662    /// Regular file; `blob` is a [`choir_store`] manifest address.
1663    File {
1664        /// Manifest address of the file content.
1665        blob: ContentHash,
1666    },
1667    /// Unresolved merge conflict for this path, all three sides kept.
1668    Conflict {
1669        /// Base-side manifest address (`None` when the file is new on
1670        /// both sides).
1671        base: Option<ContentHash>,
1672        /// Left-side manifest address.
1673        left: ContentHash,
1674        /// Right-side manifest address.
1675        right: ContentHash,
1676    },
1677}
1678
1679/// A content-addressed commit: parents, a path→entry tree, metadata.
1680#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1681pub struct Commit {
1682    /// Wire-format version; see [`FORMAT_VERSION`].
1683    pub format_version: u16,
1684    /// Parent commit ids (0 = root, 2+ = merge).
1685    pub parents: Vec<ContentHash>,
1686    /// Path → entry, sorted (BTreeMap) so serialization is canonical.
1687    pub tree: BTreeMap<String, TreeEntry>,
1688    /// Author identity string (key-backed from L8 onward).
1689    pub author: String,
1690    /// Commit message.
1691    pub message: String,
1692    /// The commit whose [`TreeEntry::Conflict`] this commit resolves,
1693    /// when it is a resolution — resolution-as-linked-change,
1694    /// as metadata on the existing shape (DECISIONS.md D15: never a new merge
1695    /// substrate). A conflict is a value (invariant 6): the link points
1696    /// *at* the conflicted commit, which stays in history untouched.
1697    /// Additive (`default` + `skip_serializing_if`), so commits written
1698    /// before the field existed decode as `None` and re-serialize
1699    /// byte-identically — invariant 1.
1700    #[serde(default, skip_serializing_if = "Option::is_none")]
1701    pub resolves: Option<ContentHash>,
1702}
1703
1704/// Conflicts in one commit, split by whether the reader's cone covers
1705/// them (D50).
1706///
1707/// The split is the point. `inside` names paths whose content the reader
1708/// may fetch; `outside` names paths and **nothing else** -- no base,
1709/// left or right address, no size, no message. The type is the
1710/// enforcement: `outside` is a list of strings, so there is no field a
1711/// later change could accidentally start populating with content.
1712///
1713/// This is a capability the partial-clone story alone does not have.
1714/// Withholding content is ordinary; git does it, and so does every
1715/// system with a sparse checkout. What is unusual is being able to say
1716/// *that a collision happened* at a path the reader cannot read, which
1717/// choir can do only because the op log is separate from the content it
1718/// orders. A reader who never receives `docs/guide.md` still learns
1719/// that their merge collided there, and can go ask the person who owns
1720/// it. Elsewhere that collision is simply invisible until someone else
1721/// trips over it.
1722#[derive(Debug, Clone, Default, PartialEq, Eq)]
1723pub struct ConflictReport {
1724    /// Conflicted paths the cone covers, readable in full.
1725    pub inside: Vec<String>,
1726    /// Conflicted paths the cone does not cover. Paths only.
1727    pub outside: Vec<String>,
1728}
1729
1730impl ConflictReport {
1731    /// Whether the commit conflicts anywhere, in or out of the cone.
1732    #[must_use]
1733    pub fn is_empty(&self) -> bool {
1734        self.inside.is_empty() && self.outside.is_empty()
1735    }
1736}
1737
1738/// Whether `cone` covers `path`, in git's cone spelling.
1739///
1740/// An empty cone covers everything, which is what a change that
1741/// declared no scope means and what every change written before cones
1742/// existed decodes to. A prefix matches on a directory boundary, so
1743/// `services/api` covers `services/api/main.rs` and does **not** cover
1744/// `services/apiary/main.rs` -- the bug a bare `starts_with` would
1745/// introduce, and the reason this is a named function with a test
1746/// rather than an inline call.
1747#[must_use]
1748pub fn cone_covers(cone: &[String], path: &str) -> bool {
1749    if cone.is_empty() {
1750        return true;
1751    }
1752    cone.iter().any(|prefix| {
1753        let prefix = prefix.trim_end_matches('/');
1754        prefix.is_empty()
1755            || path == prefix
1756            || path
1757                .strip_prefix(prefix)
1758                .is_some_and(|rest| rest.starts_with('/'))
1759    })
1760}
1761
1762/// Splits `commit`'s conflicts into what `cone` covers and what it does
1763/// not.
1764///
1765/// Pure, and deliberately takes the commit rather than a store handle:
1766/// redaction that needed I/O would be redaction that can fail open.
1767#[must_use]
1768pub fn conflicts_for_cone(commit: &Commit, cone: &[String]) -> ConflictReport {
1769    let mut report = ConflictReport::default();
1770    for (path, entry) in &commit.tree {
1771        if !matches!(entry, TreeEntry::Conflict { .. }) {
1772            continue;
1773        }
1774        if cone_covers(cone, path) {
1775            report.inside.push(path.clone());
1776        } else {
1777            report.outside.push(path.clone());
1778        }
1779    }
1780    report
1781}
1782
1783impl Commit {
1784    /// Whether any tree entry is an unresolved [`TreeEntry::Conflict`].
1785    pub fn is_conflicted(&self) -> bool {
1786        self.tree
1787            .values()
1788            .any(|e| matches!(e, TreeEntry::Conflict { .. }))
1789    }
1790
1791    /// Validates this commit's `resolves` link against `store`.
1792    ///
1793    /// A `Some` link must name a commit the store holds whose tree still
1794    /// carries an unresolved [`TreeEntry::Conflict`] — anything else is
1795    /// refused, never silently dropped: a dangling link admitted once
1796    /// would replay forever as a claim about a conflict nobody can load.
1797    /// `None` validates trivially (most commits resolve nothing).
1798    ///
1799    /// # Errors
1800    ///
1801    /// Returns [`ViewError::Resolution`] when the link is dangling or the
1802    /// referenced commit has no conflict to resolve.
1803    pub fn validate_resolves(&self, store: &dyn ChunkStore) -> Result<(), ViewError> {
1804        let Some(target) = &self.resolves else {
1805            return Ok(());
1806        };
1807        let resolved = Commit::get(store, target).map_err(|e| {
1808            ViewError::Resolution(format!(
1809                "resolves names {} which the store cannot supply: {e:?}",
1810                target.to_hex()
1811            ))
1812        })?;
1813        if !resolved.is_conflicted() {
1814            return Err(ViewError::Resolution(format!(
1815                "resolves names {} which holds no unresolved conflict",
1816                target.to_hex()
1817            )));
1818        }
1819        Ok(())
1820    }
1821
1822    /// Stores this commit in `store` and returns its content address.
1823    ///
1824    /// # Errors
1825    ///
1826    /// Propagates [`ViewError::Store`] from the underlying store.
1827    pub fn put(&self, store: &mut dyn ChunkStore) -> Result<ContentHash, ViewError> {
1828        let bytes = serde_json::to_vec(self).expect("Commit is always serializable");
1829        store.put(&bytes).map_err(ViewError::Store)
1830    }
1831
1832    /// Loads the commit at `id` from `store`, verifying its address.
1833    ///
1834    /// # Errors
1835    ///
1836    /// Returns [`ViewError::Store`] on lookup/verification failure and
1837    /// [`ViewError::Decode`] when the bytes are not a commit.
1838    pub fn get(store: &dyn ChunkStore, id: &ContentHash) -> Result<Self, ViewError> {
1839        let bytes = store.get(id).map_err(ViewError::Store)?;
1840        serde_json::from_slice(&bytes).map_err(|e| ViewError::Decode(e.to_string()))
1841    }
1842}
1843
1844/// The materialized repo state: where every workspace and ref points.
1845///
1846/// A `View` is only ever produced by folding ops, so two replicas that
1847/// replay the same log prefix hold identical views.
1848#[derive(Debug, Clone, Default, PartialEq, Eq)]
1849pub struct View {
1850    /// Workspace name → head commit id.
1851    pub workspaces: BTreeMap<String, ContentHash>,
1852    /// Stable logical change id → owner, workspace and exact revision.
1853    pub changes: BTreeMap<String, ChangeState>,
1854    /// Ref name → target commit id.
1855    pub refs: BTreeMap<String, ContentHash>,
1856    /// Review id → review state (fan-out and verdicts).
1857    pub reviews: BTreeMap<String, ReviewState>,
1858    /// Subject → record kind → latest body (D22 provenance records).
1859    pub provenance: BTreeMap<String, BTreeMap<String, String>>,
1860    /// `<subject-hex>:<check-name>` → the latest report for it (D49).
1861    ///
1862    /// One flat map rather than subject → name → state, because every
1863    /// consumer of a view section wants the same two things: the ACL
1864    /// narrows section rows one at a time, and the node's paging bounds
1865    /// them one at a time. A nested map would need both to learn a
1866    /// second shape, and "all checks on commit X" is still a prefix
1867    /// scan. The separator is `:` for the same reason `refs` uses it,
1868    /// and it cannot collide: a hex subject contains no `:`.
1869    pub checks: BTreeMap<String, CheckState>,
1870    /// Actor key id → its durable operator binding (D24 T1/T3 substrate).
1871    ///
1872    /// Keyed by [`ContentHash::to_hex`] rather than by the hash itself so
1873    /// it joins directly against the `key_id` a [`choir_oplog::Witness`]
1874    /// carries, which is how an entry names its author.
1875    pub bindings: BTreeMap<String, KeyBinding>,
1876    /// Number of ops folded so far, which is the log sequence the next
1877    /// applied op will occupy.
1878    ///
1879    /// It is a fold counter rather than a value read off the log, because
1880    /// [`View::apply`] is handed only the op — a signature this crate is
1881    /// deliberately not changing. The counter is correct because every
1882    /// path that builds a view applies exactly the log's ops, in order,
1883    /// once: [`View::at`] replays a prefix, and a node's live view starts
1884    /// from such a replay and then applies each entry the sequencer
1885    /// admits. A caller holding both can assert `view.next_seq ==
1886    /// entry.seq` before applying; nothing inside the fold can check it
1887    /// on their behalf.
1888    pub next_seq: u64,
1889    /// The most recent admitted [`RefSnapshot`], whole rather than by
1890    /// id: readers ask a view "what is the latest attestation?" and the
1891    /// chain check needs its identity, which [`RefSnapshot::id`] derives
1892    /// from the value.
1893    pub latest_snapshot: Option<RefSnapshot>,
1894    /// Witness name to its latest attestation (D67). Only the current
1895    /// [`View::latest_snapshot`] is attestable, so every row here that
1896    /// names it is a live witness of the state the view is serving, and
1897    /// a row naming anything else is a witness that has fallen behind.
1898    pub witnessed: BTreeMap<String, WitnessState>,
1899    /// Vouched-for operator -> voucher -> the standing edge (D65).
1900    ///
1901    /// Keyed by subject first because that is the question a reader
1902    /// brings: "who vouches for this actor". The other direction is a
1903    /// scan, and it is the rarer one.
1904    ///
1905    /// Nested rather than the flat `a:b` key [`View::checks`] uses,
1906    /// because that key would have to join two operator names and an
1907    /// operator name is free-form: any separator can occur inside one.
1908    /// `checks` gets away with `:` only because a hex subject cannot
1909    /// contain it, and borrowing the shape without the property is how
1910    /// two edges come to share a row.
1911    pub vouches: BTreeMap<String, BTreeMap<String, VouchState>>,
1912}
1913
1914impl View {
1915    /// Whether `voucher`'s vouch for `subject` currently stands (D65).
1916    ///
1917    /// One lookup for both directions of the question, so admission and
1918    /// withdrawal cannot come to disagree about what "already vouches"
1919    /// means.
1920    #[must_use]
1921    pub fn vouch_stands(&self, voucher: &str, subject: &str) -> bool {
1922        self.vouches
1923            .get(subject)
1924            .is_some_and(|from| from.contains_key(voucher))
1925    }
1926
1927    /// Whether `operator` holds at least one key binding that has not
1928    /// been revoked (D65).
1929    ///
1930    /// Revoked bindings do not count. A vouch from an operator whose
1931    /// every key has been withdrawn would be a statement nobody can be
1932    /// held to, which is the shape the floor exists to refuse.
1933    #[must_use]
1934    pub fn is_bound_operator(&self, operator: &str) -> bool {
1935        self.bindings
1936            .values()
1937            .any(|binding| binding.operator == operator && !binding.is_revoked())
1938    }
1939
1940    /// The two ends of a vouch: distinct operator identities, each with
1941    /// an unrevoked binding in this log (D65).
1942    ///
1943    /// The binding requirement is the Sybil floor, and it lives here
1944    /// rather than in the daemon so that every replayer enforces it and
1945    /// not just the node that admitted the op. Since [`OpKind::BindKey`]
1946    /// is node-only in the daemon, an attacker cannot mint the identity
1947    /// a fresh vouch would come from; and because both ends are drawn
1948    /// from a set the node itself admitted, the map this guards is
1949    /// bounded by the operator population rather than by anything a
1950    /// client can choose (D64).
1951    fn validate_vouch_ends(&self, voucher: &str, subject: &str) -> Result<(), ViewError> {
1952        if voucher == subject {
1953            return Err(ViewError::Vouch(format!(
1954                "{voucher} cannot vouch for itself"
1955            )));
1956        }
1957        for (role, name) in [("voucher", voucher), ("subject", subject)] {
1958            if name.is_empty() {
1959                return Err(ViewError::Vouch(format!("a vouch must name a {role}")));
1960            }
1961            if name.contains('/') {
1962                // Operator identities, not channels: `alice` and
1963                // `alice/agent` are one operator, and letting both spell
1964                // an edge would give that operator two rows and a reader
1965                // two different answers.
1966                return Err(ViewError::Vouch(format!(
1967                    "{role} {name} must be an operator identity, with no '/'"
1968                )));
1969            }
1970            if !self.is_bound_operator(name) {
1971                return Err(ViewError::Vouch(format!(
1972                    "{role} {name} has no unrevoked key bound in this log"
1973                )));
1974            }
1975        }
1976        Ok(())
1977    }
1978
1979    /// Rederives a [`OpKind::Submit`]'s authorization and refuses a
1980    /// mismatch (D43).
1981    ///
1982    /// The node's gate decides *whether* a landing is allowed, reading an
1983    /// ACL and a protected-ref list that are not in the log. This checks
1984    /// everything about that decision which **is** in the log: that the
1985    /// cited review exists, is live, and proposes exactly this landing;
1986    /// that a named approving owner really has a standing approval on it;
1987    /// that a claimed approval weight is the weight this review actually
1988    /// carries; and that the approver ids are the ones the log's own
1989    /// bindings produce.
1990    ///
1991    /// So the ACL grant is the single unverifiable element, and it is
1992    /// named rather than implied. Everything else is refused on
1993    /// disagreement by every replayer, not just by the node that admitted
1994    /// it — the [`OpKind::RecordRefSnapshot`] discipline.
1995    fn validate_submit(
1996        &self,
1997        review: &str,
1998        name: &str,
1999        commit: &ContentHash,
2000        authorization: &Authorization,
2001    ) -> Result<(), ViewError> {
2002        let state = self
2003            .reviews
2004            .get(review)
2005            .ok_or_else(|| ViewError::Review(format!("no such review {review}")))?;
2006        if matches!(state.status, ReviewStatus::Archived { .. }) {
2007            // Archiving drops the verdicts, so nothing here could be
2008            // rederived. Refusing keeps "the record was checked" true
2009            // without exception, rather than true except when it is not.
2010            return Err(ViewError::Review(format!(
2011                "review {review} is archived and its verdicts are gone, so the \
2012                 authorization this landing claims cannot be checked"
2013            )));
2014        }
2015        if state.target_ref.as_deref() != Some(name) {
2016            return Err(ViewError::Review(format!(
2017                "review {review} proposes to land on {}, not {name}",
2018                state.target_ref.as_deref().unwrap_or("no ref")
2019            )));
2020        }
2021        if state.target.as_ref() != Some(commit) {
2022            return Err(ViewError::Review(format!(
2023                "review {review} names commit {}, not {}",
2024                state
2025                    .target
2026                    .as_ref()
2027                    .map_or_else(|| "none".to_string(), ContentHash::to_hex),
2028                commit.to_hex()
2029            )));
2030        }
2031        let expected: Vec<ContentHash> = match &authorization.basis {
2032            Basis::OwnerLanded { owner } => {
2033                if owner.is_empty() {
2034                    return Err(ViewError::Review(
2035                        "an owner-landed authorization must name the owner".to_string(),
2036                    ));
2037                }
2038                Vec::new()
2039            }
2040            Basis::OwnerApproved { owner } => {
2041                let at = state.standing_approval_at(owner).ok_or_else(|| {
2042                    ViewError::Review(format!(
2043                        "{owner} has no standing approval on review {review}"
2044                    ))
2045                })?;
2046                vec![self.bound_actor_at(owner, at).map_err(ViewError::Review)?]
2047            }
2048            Basis::ApprovalWeight { required, met } => {
2049                if *required == 0 {
2050                    return Err(ViewError::Review(
2051                        "an approval-weight authorization must state a nonzero threshold"
2052                            .to_string(),
2053                    ));
2054                }
2055                let actual = u32::try_from(state.approval_weight()).unwrap_or(u32::MAX);
2056                if *met != actual {
2057                    return Err(ViewError::Review(format!(
2058                        "review {review} carries approval weight {actual}, not the claimed {met}"
2059                    )));
2060                }
2061                if met < required {
2062                    return Err(ViewError::Review(format!(
2063                        "approval weight {met} is below the {required} this authorization claims \
2064                         to satisfy"
2065                    )));
2066                }
2067                state
2068                    .counted_approvers()
2069                    .into_iter()
2070                    .map(|(channel, at)| self.bound_actor_at(channel, at))
2071                    .collect::<Result<Vec<_>, String>>()
2072                    .map_err(ViewError::Review)?
2073            }
2074        };
2075        if authorization.approvers != expected {
2076            return Err(ViewError::Review(format!(
2077                "authorization on review {review} lists {} approvers where the log's own \
2078                 bindings produce {}",
2079                authorization.approvers.len(),
2080                expected.len()
2081            )));
2082        }
2083        Ok(())
2084    }
2085
2086    /// Whether `op` would apply cleanly, without changing anything.
2087    ///
2088    /// Every precondition in this model is a read — a CAS comparison, a
2089    /// key lookup, or a non-empty check — so admission can be decided
2090    /// against a shared `&View` rather than against a private copy of it.
2091    /// That is what lets the single-writer admission path stop cloning the
2092    /// whole view per submission.
2093    ///
2094    /// [`View::apply`] calls this first and mutates only on `Ok`, so the
2095    /// two can never disagree about what is admissible. Keeping them as
2096    /// one code path is the point: a separate fast-path predicate that
2097    /// drifts from the real one is how "checked in check()" turns into a
2098    /// panic on the writer thread.
2099    ///
2100    /// # Errors
2101    ///
2102    /// The same failures [`View::apply`] would return for `op`.
2103    pub fn validate(&self, op: &ViewOp) -> Result<(), ViewError> {
2104        /// CAS comparison shared by the three head-moving ops.
2105        fn cas(
2106            actual: Option<&ContentHash>,
2107            expected: &Option<ContentHash>,
2108            target: &str,
2109        ) -> Result<(), ViewError> {
2110            if actual != expected.as_ref() {
2111                return Err(ViewError::StaleHead {
2112                    target: target.to_string(),
2113                    expected: expected.clone(),
2114                    actual: actual.cloned(),
2115                });
2116            }
2117            Ok(())
2118        }
2119
2120        match &op.kind {
2121            OpKind::SetWorkspaceHead {
2122                workspace, prev, ..
2123            } => cas(self.workspaces.get(workspace), prev, workspace),
2124            OpKind::SetRef { name, prev, .. } | OpKind::DeleteRef { name, prev } => {
2125                cas(self.refs.get(name), prev, name)
2126            }
2127            OpKind::Submit {
2128                review,
2129                name,
2130                commit,
2131                prev,
2132                authorization,
2133            } => {
2134                cas(self.refs.get(name), prev, name)?;
2135                self.validate_submit(review, name, commit, authorization)
2136            }
2137            // Removing an absent workspace is not an error: the op is a
2138            // statement about the end state, not about the transition.
2139            OpKind::DeleteWorkspace { .. } => Ok(()),
2140            OpKind::RequestReview { id, .. } => {
2141                if self.reviews.contains_key(id) {
2142                    return Err(ViewError::Review(format!("review {id} already exists")));
2143                }
2144                Ok(())
2145            }
2146            OpKind::AssignReviewers { id, reviewers } => {
2147                if reviewers.is_empty() {
2148                    return Err(ViewError::Review(
2149                        "assignment must name at least one reviewer".to_string(),
2150                    ));
2151                }
2152                let review = self
2153                    .reviews
2154                    .get(id)
2155                    .ok_or_else(|| ViewError::Review(format!("no such review {id}")))?;
2156                if matches!(review.status, ReviewStatus::Archived { .. }) {
2157                    // Its reviewer list is empty because it was emptied,
2158                    // not because it is unassigned.
2159                    return Err(ViewError::Review(format!("review {id} is archived")));
2160                }
2161                if !review.reviewers.is_empty() {
2162                    return Err(ViewError::Review(format!(
2163                        "review {id} is already assigned"
2164                    )));
2165                }
2166                Ok(())
2167            }
2168            OpKind::PostVerdict { id, reviewer, .. } => {
2169                let review = self
2170                    .reviews
2171                    .get(id)
2172                    .ok_or_else(|| ViewError::Review(format!("no such review {id}")))?;
2173                if matches!(review.status, ReviewStatus::Archived { .. }) {
2174                    return Err(ViewError::Review(format!(
2175                        "review {id} is archived and accepts no further verdicts"
2176                    )));
2177                }
2178                if !review.reviewers.iter().any(|r| r == reviewer) {
2179                    return Err(ViewError::Review(format!(
2180                        "{reviewer} is not a reviewer of {id}"
2181                    )));
2182                }
2183                if review.slashes.contains_key(reviewer) {
2184                    return Err(ViewError::Review(format!(
2185                        "{reviewer}'s approval on {id} was slashed; open a new review"
2186                    )));
2187                }
2188                Ok(())
2189            }
2190            OpKind::SlashApproval {
2191                id,
2192                reviewer,
2193                reason,
2194            } => {
2195                if reviewer.is_empty() || reason.is_empty() {
2196                    return Err(ViewError::Review(
2197                        "a slash must name a reviewer and a non-empty reason".to_string(),
2198                    ));
2199                }
2200                let review = self
2201                    .reviews
2202                    .get(id)
2203                    .ok_or_else(|| ViewError::Review(format!("no such review {id}")))?;
2204                if review.slashes.contains_key(reviewer) {
2205                    return Err(ViewError::Review(format!(
2206                        "{reviewer}'s approval on {id} is already slashed"
2207                    )));
2208                }
2209                match review.status {
2210                    ReviewStatus::Live => {
2211                        if !review.reviewers.iter().any(|listed| listed == reviewer) {
2212                            return Err(ViewError::Review(format!(
2213                                "{reviewer} is not a reviewer of {id}"
2214                            )));
2215                        }
2216                        if !review
2217                            .verdicts
2218                            .get(reviewer)
2219                            .is_some_and(|answer| answer.verdict == Verdict::Approve)
2220                        {
2221                            return Err(ViewError::Review(format!(
2222                                "{reviewer} has no approval to slash on {id}"
2223                            )));
2224                        }
2225                    }
2226                    ReviewStatus::Archived { approved, .. } => {
2227                        if !approved || review.approval_weight() == 0 {
2228                            return Err(ViewError::Review(format!(
2229                                "archived review {id} carries no approval to slash"
2230                            )));
2231                        }
2232                    }
2233                }
2234                Ok(())
2235            }
2236            OpKind::ArchiveReview { id, lapsed } => {
2237                let review = self
2238                    .reviews
2239                    .get(id)
2240                    .ok_or_else(|| ViewError::Review(format!("no such review {id}")))?;
2241                if matches!(review.status, ReviewStatus::Archived { .. }) {
2242                    return Err(ViewError::Review(format!(
2243                        "review {id} is already archived"
2244                    )));
2245                }
2246                if review.complete() && *lapsed {
2247                    // It reached an outcome; lapsing would discard it.
2248                    return Err(ViewError::Review(format!(
2249                        "review {id} is complete and cannot be lapsed"
2250                    )));
2251                }
2252                if !review.complete() && !*lapsed {
2253                    // Freezing an unfinished review would strand it: the
2254                    // outcome is not decided and no further verdict can
2255                    // decide it. Lapsing is the deliberate way to say
2256                    // "this one is abandoned", so it must be asked for.
2257                    return Err(ViewError::Review(format!(
2258                        "review {id} is not complete; archive it with lapsed to settle it as \
2259                         unapproved"
2260                    )));
2261                }
2262                Ok(())
2263            }
2264            OpKind::PostComment {
2265                id,
2266                comment,
2267                author,
2268                body,
2269            } => {
2270                if comment.is_empty() || author.is_empty() || body.is_empty() {
2271                    return Err(ViewError::Review(
2272                        "a comment must carry an id, an author and a body".to_string(),
2273                    ));
2274                }
2275                let review = self
2276                    .reviews
2277                    .get(id)
2278                    .ok_or_else(|| ViewError::Review(format!("no such review {id}")))?;
2279                if matches!(review.status, ReviewStatus::Archived { .. }) {
2280                    // Archiving dropped the thread, so an id that was
2281                    // taken no longer looks taken. Refusing every comment
2282                    // on an archived review is what keeps the uniqueness
2283                    // rule -- and with it the replay defence -- true for
2284                    // the whole life of the review.
2285                    return Err(ViewError::Review(format!(
2286                        "review {id} is archived and accepts no further comments"
2287                    )));
2288                }
2289                if review.comments.iter().any(|held| held.id == *comment) {
2290                    return Err(ViewError::Review(format!(
2291                        "comment {comment} already exists on review {id}"
2292                    )));
2293                }
2294                Ok(())
2295            }
2296            OpKind::ViewedReview { id, viewer } => {
2297                if viewer.is_empty() {
2298                    return Err(ViewError::Review(
2299                        "a read receipt must name its viewer".to_string(),
2300                    ));
2301                }
2302                let review = self
2303                    .reviews
2304                    .get(id)
2305                    .ok_or_else(|| ViewError::Review(format!("no such review {id}")))?;
2306                if matches!(review.status, ReviewStatus::Archived { .. }) {
2307                    // Archiving dropped the receipts, so a viewer that was
2308                    // recorded no longer looks recorded; refusing receipts
2309                    // on an archived review keeps the first-read rule true
2310                    // for the whole life of the review, as for comments.
2311                    return Err(ViewError::Review(format!(
2312                        "review {id} is archived and accepts no further receipts"
2313                    )));
2314                }
2315                if review.viewed.contains_key(viewer) {
2316                    return Err(ViewError::Review(format!(
2317                        "viewer {viewer} already holds a receipt on review {id}"
2318                    )));
2319                }
2320                Ok(())
2321            }
2322            OpKind::RecordProvenance { subject, kind, .. } => {
2323                if subject.is_empty() || kind.is_empty() {
2324                    return Err(ViewError::Provenance(
2325                        "provenance subject and kind must be non-empty".to_string(),
2326                    ));
2327                }
2328                Ok(())
2329            }
2330            OpKind::RecordCheck { name, reporter, .. } => {
2331                if name.is_empty() || reporter.is_empty() {
2332                    return Err(ViewError::Check(
2333                        "a check must name itself and its reporter".to_string(),
2334                    ));
2335                }
2336                // The key is built by joining on `:`, so a name carrying
2337                // one could address a row belonging to another subject.
2338                // Refused here rather than escaped, because the view is
2339                // a map and an escaping scheme is a second encoding of a
2340                // hashed structure (invariant 3's failure mode).
2341                if name.contains(':') {
2342                    return Err(ViewError::Check(format!(
2343                        "check name {name} may not contain ':'"
2344                    )));
2345                }
2346                Ok(())
2347            }
2348            OpKind::CreateChange {
2349                id,
2350                owner,
2351                workspace,
2352                idempotency_key,
2353                ..
2354            } => {
2355                if id.is_empty()
2356                    || owner.is_empty()
2357                    || workspace.is_empty()
2358                    || idempotency_key.is_empty()
2359                {
2360                    return Err(ViewError::Change(
2361                        "change id, owner, workspace and idempotency key must be non-empty"
2362                            .to_string(),
2363                    ));
2364                }
2365                if self.changes.contains_key(id) {
2366                    return Err(ViewError::Change(format!("change {id} already exists")));
2367                }
2368                if self.workspaces.contains_key(workspace) {
2369                    return Err(ViewError::Change(format!(
2370                        "workspace {workspace} already exists"
2371                    )));
2372                }
2373                if let Some((existing, _)) = self.changes.iter().find(|(_, change)| {
2374                    change.workspace_id == *workspace
2375                        && change.active_workspace.as_deref() == Some(workspace)
2376                }) {
2377                    return Err(ViewError::Change(format!(
2378                        "workspace {workspace} is active on change {existing}"
2379                    )));
2380                }
2381                if let Some((existing, _)) = self.changes.iter().find(|(_, change)| {
2382                    change.owner == *owner && change.idempotency_key == *idempotency_key
2383                }) {
2384                    return Err(ViewError::Change(format!(
2385                        "idempotency key already belongs to change {existing}"
2386                    )));
2387                }
2388                Ok(())
2389            }
2390            OpKind::CheckpointChange {
2391                id,
2392                workspace,
2393                revision,
2394                prev_revision,
2395            } => {
2396                if id.is_empty() || workspace.is_empty() {
2397                    return Err(ViewError::Change(
2398                        "change id and workspace must be non-empty".to_string(),
2399                    ));
2400                }
2401                let change = self
2402                    .changes
2403                    .get(id)
2404                    .ok_or_else(|| ViewError::Change(format!("no such change {id}")))?;
2405                if change.active_workspace.as_deref() != Some(workspace) {
2406                    return Err(ViewError::Change(format!(
2407                        "change {id} is not active in workspace {workspace}"
2408                    )));
2409                }
2410                cas(
2411                    Some(&change.revision_id),
2412                    &Some(prev_revision.clone()),
2413                    &format!("change {id}"),
2414                )?;
2415                cas(
2416                    self.workspaces.get(workspace),
2417                    &Some(prev_revision.clone()),
2418                    workspace,
2419                )?;
2420                if revision == prev_revision {
2421                    return Err(ViewError::Change(format!(
2422                        "checkpoint for change {id} must advance to a different revision"
2423                    )));
2424                }
2425                Ok(())
2426            }
2427            OpKind::ArchiveChange {
2428                id,
2429                workspace,
2430                prev_revision,
2431                owner,
2432                ..
2433            } => {
2434                if id.is_empty() || workspace.is_empty() || owner.is_empty() {
2435                    return Err(ViewError::Change(
2436                        "change id, workspace and owner must be non-empty".to_string(),
2437                    ));
2438                }
2439                let change = self
2440                    .changes
2441                    .get(id)
2442                    .ok_or_else(|| ViewError::Change(format!("no such change {id}")))?;
2443                if change.active_workspace.as_deref() != Some(workspace) {
2444                    return Err(ViewError::Change(format!(
2445                        "change {id} is not active in workspace {workspace}"
2446                    )));
2447                }
2448                if change.owner != *owner {
2449                    return Err(ViewError::Change(format!(
2450                        "change {id} is owned by a different channel"
2451                    )));
2452                }
2453                cas(
2454                    Some(&change.revision_id),
2455                    &Some(prev_revision.clone()),
2456                    &format!("change {id}"),
2457                )?;
2458                cas(
2459                    self.workspaces.get(workspace),
2460                    &Some(prev_revision.clone()),
2461                    workspace,
2462                )
2463            }
2464            OpKind::BindKey {
2465                operator,
2466                key,
2467                channel,
2468            } => {
2469                if operator.is_empty() {
2470                    return Err(ViewError::Identity(
2471                        "a binding must name an operator".to_string(),
2472                    ));
2473                }
2474                if operator.contains('/') {
2475                    // A `/` would let one operator identity read as a
2476                    // different one through the channel-prefix rule.
2477                    return Err(ViewError::Identity(format!(
2478                        "operator {operator} must not contain '/'"
2479                    )));
2480                }
2481                if let Some(channel) = channel {
2482                    if channel.is_empty() {
2483                        return Err(ViewError::Identity(
2484                            "a bound channel must be non-empty; omit it instead".to_string(),
2485                        ));
2486                    }
2487                    let reads_as = reviewer_operator(channel);
2488                    if reads_as != operator {
2489                        return Err(ViewError::Identity(format!(
2490                            "channel {channel} reads as operator {reads_as}, not {operator}"
2491                        )));
2492                    }
2493                }
2494                match self.bindings.get(&key.to_hex()) {
2495                    None => Ok(()),
2496                    // Terminal by design: a rebindable revocation is no
2497                    // revocation at all. The remedy is a fresh key.
2498                    Some(bound) if bound.is_revoked() => Err(ViewError::Identity(format!(
2499                        "key {} is revoked; bind a fresh key",
2500                        key.to_hex()
2501                    ))),
2502                    // Re-binding to the *same* operator is how a channel
2503                    // is corrected, and it keeps `bound_at`. Re-binding
2504                    // elsewhere would transfer accumulated standing.
2505                    Some(bound) if bound.operator != *operator => {
2506                        Err(ViewError::Identity(format!(
2507                            "key {} is already bound to operator {}",
2508                            key.to_hex(),
2509                            bound.operator
2510                        )))
2511                    }
2512                    Some(_) => Ok(()),
2513                }
2514            }
2515            OpKind::RevokeKey { key, reason } => {
2516                if reason.is_empty() {
2517                    return Err(ViewError::Identity(
2518                        "a revocation must carry a non-empty reason".to_string(),
2519                    ));
2520                }
2521                let bound = self.bindings.get(&key.to_hex()).ok_or_else(|| {
2522                    ViewError::Identity(format!("key {} is not bound", key.to_hex()))
2523                })?;
2524                if bound.is_revoked() {
2525                    return Err(ViewError::Identity(format!(
2526                        "key {} is already revoked",
2527                        key.to_hex()
2528                    )));
2529                }
2530                Ok(())
2531            }
2532            OpKind::Vouch {
2533                voucher, subject, ..
2534            } => {
2535                self.validate_vouch_ends(voucher, subject)?;
2536                if self.vouch_stands(voucher, subject) {
2537                    // The pair is the retry identity, so a resubmitted
2538                    // vouch whose response was lost is refused rather
2539                    // than silently redating the edge it already placed.
2540                    return Err(ViewError::Vouch(format!(
2541                        "{voucher} already vouches for {subject}; withdraw first to say \
2542                         something else"
2543                    )));
2544                }
2545                Ok(())
2546            }
2547            OpKind::WithdrawVouch {
2548                voucher,
2549                subject,
2550                reason,
2551            } => {
2552                if reason.is_empty() {
2553                    return Err(ViewError::Vouch(
2554                        "a withdrawal must carry a non-empty reason".to_string(),
2555                    ));
2556                }
2557                // No `validate_vouch_ends` here on purpose: a voucher
2558                // whose last key was revoked after vouching must still
2559                // be able to take the edge back, and refusing that would
2560                // strand the statement exactly when its author has most
2561                // reason to retract it.
2562                if !self.vouch_stands(voucher, subject) {
2563                    return Err(ViewError::Vouch(format!(
2564                        "{voucher} does not vouch for {subject}"
2565                    )));
2566                }
2567                Ok(())
2568            }
2569            OpKind::CountersignSnapshot { witness, snapshot } => {
2570                // The Sybil floor, in the fold rather than the daemon,
2571                // so every replayer reaches the same verdict: a witness
2572                // whose standing nobody registered is not a witness.
2573                if !self.is_bound_operator(witness) {
2574                    return Err(ViewError::Witness(format!(
2575                        "`{witness}` has no live key binding, so it cannot witness"
2576                    )));
2577                }
2578                let Some(latest) = self.latest_snapshot.as_ref() else {
2579                    return Err(ViewError::Witness(
2580                        "there is no ref-state attestation to witness yet".to_string(),
2581                    ));
2582                };
2583                // Only the current one. A cosignature over an older
2584                // snapshot is the stale-ref-state replay D25 names: refs
2585                // can return to a prior value, and an attestation of that
2586                // value would read as current. Refused rather than kept
2587                // as a claim about the past, because a reader counting
2588                // witnesses cannot tell the two apart.
2589                let id = latest.id();
2590                if snapshot != &id {
2591                    return Err(ViewError::Witness(format!(
2592                        "witness attests {}, the latest attestation is {}",
2593                        snapshot.to_hex(),
2594                        id.to_hex()
2595                    )));
2596                }
2597                // Saying it twice is not saying it twice as loudly.
2598                if self
2599                    .witnessed
2600                    .get(witness)
2601                    .is_some_and(|state| state.snapshot == id)
2602                {
2603                    return Err(ViewError::Witness(format!(
2604                        "`{witness}` has already witnessed this ref-state"
2605                    )));
2606                }
2607                Ok(())
2608            }
2609            OpKind::RecordRefSnapshot { snapshot } => {
2610                // Truth first: an attestation the fold cannot reproduce
2611                // is refused by every replayer, not archived as a claim.
2612                if snapshot.refs != self.refs {
2613                    return Err(ViewError::Snapshot(
2614                        "snapshot does not match the ref-state it claims to attest".to_string(),
2615                    ));
2616                }
2617                if snapshot.at_seq != self.next_seq {
2618                    return Err(ViewError::Snapshot(format!(
2619                        "snapshot was taken at position {}, the view is at {}",
2620                        snapshot.at_seq, self.next_seq
2621                    )));
2622                }
2623                // The chain rule is what makes an *old* snapshot
2624                // inadmissible even when the ref map recurs (the ABA
2625                // shape): its `prev_snapshot` no longer names the latest.
2626                let expected = self.latest_snapshot.as_ref().map(RefSnapshot::id);
2627                if snapshot.prev_snapshot != expected {
2628                    return Err(ViewError::Snapshot(format!(
2629                        "snapshot chain expected prev {:?}, op names {:?}",
2630                        expected.as_ref().map(ContentHash::to_hex),
2631                        snapshot.prev_snapshot.as_ref().map(ContentHash::to_hex)
2632                    )));
2633                }
2634                Ok(())
2635            }
2636        }
2637    }
2638
2639    /// The [`View::checks`] key for one `(subject, check name)` pair.
2640    #[must_use]
2641    pub fn check_key(subject: &ContentHash, name: &str) -> String {
2642        format!("{}:{name}", subject.to_hex())
2643    }
2644
2645    /// Every check reported against `subject`, as `(name, state)` in
2646    /// name order.
2647    #[must_use]
2648    pub fn checks_for(&self, subject: &ContentHash) -> Vec<(&str, &CheckState)> {
2649        let prefix = format!("{}:", subject.to_hex());
2650        self.checks
2651            .range(prefix.clone()..)
2652            .take_while(|(key, _)| key.starts_with(&prefix))
2653            .filter_map(|(key, state)| key.split_once(':').map(|(_, name)| (name, state)))
2654            .collect()
2655    }
2656
2657    /// The one answer for `subject`, or `None` when nothing reported.
2658    ///
2659    /// Ranked by what the caller must do about it, worst first:
2660    /// `Failed`, then `Errored`, then `Running`, then `Passed`.
2661    ///
2662    /// A failure outranks a run still in flight. Both are "not green",
2663    /// but only one of them can still become green, and a caller
2664    /// deciding whether to wait needs that distinction to point the
2665    /// right way: told `Running` while a sibling check has already
2666    /// failed, it waits for an outcome that cannot arrive.
2667    ///
2668    /// `Errored` outranks `Running` for exactly that reason and no
2669    /// other. A check that could not run will not become green by being
2670    /// waited on -- somebody has to re-run it -- so reporting `Running`
2671    /// beside it sends the caller to wait for an outcome that has
2672    /// already failed to arrive once. It sits below `Failed` because it
2673    /// is not evidence about the commit, and a summary that hid a real
2674    /// red build behind our own outage would be the D18 conflation
2675    /// again, pointed the other way.
2676    #[must_use]
2677    pub fn checks_verdict(&self, subject: &ContentHash) -> Option<CheckStatus> {
2678        let states = self.checks_for(subject);
2679        if states.is_empty() {
2680            return None;
2681        }
2682        if states.iter().any(|(_, s)| s.status == CheckStatus::Failed) {
2683            return Some(CheckStatus::Failed);
2684        }
2685        if states.iter().any(|(_, s)| s.status == CheckStatus::Errored) {
2686            return Some(CheckStatus::Errored);
2687        }
2688        if states.iter().any(|(_, s)| s.status == CheckStatus::Running) {
2689            return Some(CheckStatus::Running);
2690        }
2691        Some(CheckStatus::Passed)
2692    }
2693
2694    /// Applies one op, enforcing its CAS precondition. A rejected op
2695    /// leaves the view unchanged.
2696    ///
2697    /// # Errors
2698    ///
2699    /// Returns [`ViewError::StaleHead`] when `prev` does not match.
2700    pub fn apply(&mut self, op: &ViewOp) -> Result<(), ViewError> {
2701        // Preconditions live in `validate` and nowhere else, so admission
2702        // and application cannot drift apart.
2703        self.validate(op)?;
2704        match &op.kind {
2705            OpKind::SetWorkspaceHead {
2706                workspace, commit, ..
2707            } => {
2708                // A legacy move cannot leave a stale logical-change label
2709                // attached to a revision it did not checkpoint.
2710                for change in self.changes.values_mut() {
2711                    if change.active_workspace.as_deref() == Some(workspace) {
2712                        change.active_workspace = None;
2713                    }
2714                }
2715                self.workspaces.insert(workspace.clone(), commit.clone());
2716            }
2717            OpKind::SetRef { name, commit, .. } => {
2718                self.refs.insert(name.clone(), commit.clone());
2719            }
2720            // The ref move is all of it. The authorization is not
2721            // projected anywhere: it is a statement about one admission
2722            // decision, and the entry bytes are where a statement about
2723            // a past decision belongs. Copying it into the view would
2724            // create a second, mutable-looking home for an immutable
2725            // fact, and the review page can already tell a landed review
2726            // by comparing the ref to the target.
2727            OpKind::Submit { name, commit, .. } => {
2728                self.refs.insert(name.clone(), commit.clone());
2729            }
2730            OpKind::DeleteWorkspace { workspace } => {
2731                self.workspaces.remove(workspace);
2732                for change in self.changes.values_mut() {
2733                    if change.active_workspace.as_deref() == Some(workspace) {
2734                        change.active_workspace = None;
2735                    }
2736                }
2737            }
2738            OpKind::RequestReview {
2739                id,
2740                target,
2741                reviewers,
2742                target_ref,
2743            } => {
2744                self.reviews.insert(
2745                    id.clone(),
2746                    ReviewState {
2747                        target: Some(target.clone()),
2748                        reviewers: reviewers.clone(),
2749                        verdicts: BTreeMap::new(),
2750                        slashes: BTreeMap::new(),
2751                        target_ref: target_ref.clone(),
2752                        comments: Vec::new(),
2753                        viewed: BTreeMap::new(),
2754                        status: ReviewStatus::Live,
2755                    },
2756                );
2757            }
2758            OpKind::AssignReviewers { id, reviewers } => {
2759                self.reviews
2760                    .get_mut(id)
2761                    .expect("validate proved the review exists and is live")
2762                    .reviewers = reviewers.clone();
2763            }
2764            OpKind::ArchiveReview { id, lapsed } => {
2765                let review = self
2766                    .reviews
2767                    .get_mut(id)
2768                    .expect("validate proved the review exists and is settleable");
2769                let approval_weight = if *lapsed {
2770                    0
2771                } else {
2772                    // Store the pre-slash baseline. Archived reads apply
2773                    // the durable slash map, so capturing the already-
2774                    // discounted live value here would subtract a slash
2775                    // twice after compaction.
2776                    review.live_approval_weight(false)
2777                };
2778                review.status = ReviewStatus::Archived {
2779                    // A lapsed review was never answered, so it never got
2780                    // approval. Reading it off `approved()` would work
2781                    // today, but only because an incomplete review is
2782                    // never approved -- stating the outcome directly
2783                    // means a later change to `approved()` cannot quietly
2784                    // turn abandoned reviews into approvals.
2785                    approved: !*lapsed && review.approved(),
2786                    approval_weight,
2787                };
2788                // The bulk goes; the gate's compact authorization row
2789                // (target_ref, target, outcome, approval weight) stays.
2790                // Discussion is bulk by the same measure -- it is the
2791                // part that grows without bound -- and it informs no
2792                // authorization decision, so it goes with the verdicts.
2793                review.reviewers = Vec::new();
2794                review.verdicts = BTreeMap::new();
2795                review.comments = Vec::new();
2796                review.viewed = BTreeMap::new();
2797            }
2798            OpKind::PostVerdict {
2799                id,
2800                reviewer,
2801                verdict,
2802                note,
2803            } => {
2804                // Read before the mutable borrow, and the same clock
2805                // `bound_at` and `Revocation::at` use: this position is
2806                // what later resolves the channel to the key that was
2807                // live when the verdict was cast (D44).
2808                let at = self.next_seq;
2809                self.reviews
2810                    .get_mut(id)
2811                    .expect("validate proved the review exists, is live, and lists this reviewer")
2812                    .verdicts
2813                    .insert(
2814                        reviewer.clone(),
2815                        VerdictState {
2816                            verdict: *verdict,
2817                            note: note.clone(),
2818                            at,
2819                        },
2820                    );
2821            }
2822            OpKind::SlashApproval {
2823                id,
2824                reviewer,
2825                reason,
2826            } => {
2827                self.reviews
2828                    .get_mut(id)
2829                    .expect("validate proved the review has an approval to slash")
2830                    .slashes
2831                    .insert(reviewer.clone(), reason.clone());
2832            }
2833            OpKind::PostComment {
2834                id,
2835                comment,
2836                author,
2837                body,
2838            } => {
2839                let at = self.next_seq;
2840                self.reviews
2841                    .get_mut(id)
2842                    .expect("validate proved the review is live and free of this comment id")
2843                    .comments
2844                    .push(CommentState {
2845                        id: comment.clone(),
2846                        author: author.clone(),
2847                        body: body.clone(),
2848                        at,
2849                    });
2850            }
2851            OpKind::ViewedReview { id, viewer } => {
2852                let at = self.next_seq;
2853                self.reviews
2854                    .get_mut(id)
2855                    .expect("validate proved the review is live and new to this viewer")
2856                    .viewed
2857                    .insert(viewer.clone(), at);
2858            }
2859            OpKind::RecordCheck {
2860                subject,
2861                name,
2862                status,
2863                evidence,
2864                reporter,
2865                target_ref,
2866            } => {
2867                self.checks.insert(
2868                    Self::check_key(subject, name),
2869                    CheckState {
2870                        status: *status,
2871                        evidence: evidence.clone(),
2872                        reporter: reporter.clone(),
2873                        target_ref: target_ref.clone(),
2874                    },
2875                );
2876            }
2877            OpKind::RecordProvenance {
2878                subject,
2879                kind,
2880                body,
2881            } => {
2882                self.provenance
2883                    .entry(subject.clone())
2884                    .or_default()
2885                    .insert(kind.clone(), body.clone());
2886            }
2887            OpKind::DeleteRef { name, .. } => {
2888                self.refs.remove(name);
2889            }
2890            OpKind::CreateChange {
2891                id,
2892                owner,
2893                workspace,
2894                base_revision,
2895                idempotency_key,
2896                cone,
2897                ..
2898            } => {
2899                self.workspaces
2900                    .insert(workspace.clone(), base_revision.clone());
2901                self.changes.insert(
2902                    id.clone(),
2903                    ChangeState {
2904                        owner: owner.clone(),
2905                        workspace_id: workspace.clone(),
2906                        active_workspace: Some(workspace.clone()),
2907                        base_revision: base_revision.clone(),
2908                        revision_id: base_revision.clone(),
2909                        idempotency_key: idempotency_key.clone(),
2910                        cone: cone.clone(),
2911                    },
2912                );
2913            }
2914            OpKind::CheckpointChange {
2915                id,
2916                workspace,
2917                revision,
2918                ..
2919            } => {
2920                self.workspaces.insert(workspace.clone(), revision.clone());
2921                self.changes
2922                    .get_mut(id)
2923                    .expect("validate proved the change exists and is active")
2924                    .revision_id = revision.clone();
2925            }
2926            OpKind::ArchiveChange { id, workspace, .. } => {
2927                self.workspaces.remove(workspace);
2928                self.changes
2929                    .get_mut(id)
2930                    .expect("validate proved the change exists and is active")
2931                    .active_workspace = None;
2932            }
2933            OpKind::BindKey {
2934                operator,
2935                key,
2936                channel,
2937            } => {
2938                let bound_at = self.next_seq;
2939                self.bindings
2940                    .entry(key.to_hex())
2941                    // A re-binding may correct the channel and nothing
2942                    // else. `bound_at` is deliberately untouched here:
2943                    // that omission is the age clock.
2944                    .and_modify(|bound| bound.channel = channel.clone())
2945                    .or_insert_with(|| KeyBinding {
2946                        operator: operator.clone(),
2947                        channel: channel.clone(),
2948                        bound_at,
2949                        revoked: None,
2950                    });
2951            }
2952            OpKind::RevokeKey { key, reason } => {
2953                let at = self.next_seq;
2954                self.bindings
2955                    .get_mut(&key.to_hex())
2956                    .expect("validate proved the key is bound and not yet revoked")
2957                    .revoked = Some(Revocation {
2958                    at,
2959                    reason: reason.clone(),
2960                });
2961            }
2962            OpKind::Vouch {
2963                voucher,
2964                subject,
2965                note,
2966            } => {
2967                let at = self.next_seq;
2968                self.vouches.entry(subject.clone()).or_default().insert(
2969                    voucher.clone(),
2970                    VouchState {
2971                        at,
2972                        note: note.clone(),
2973                    },
2974                );
2975            }
2976            OpKind::WithdrawVouch {
2977                voucher, subject, ..
2978            } => {
2979                let empty = self.vouches.get_mut(subject).is_some_and(|from| {
2980                    from.remove(voucher);
2981                    from.is_empty()
2982                });
2983                if empty {
2984                    // An empty inner map is a row that says nothing and
2985                    // still costs bytes on every read of the section.
2986                    // Dropping it is what keeps vouch churn invisible to
2987                    // the view-growth series (D64).
2988                    self.vouches.remove(subject);
2989                }
2990            }
2991            OpKind::CountersignSnapshot { witness, snapshot } => {
2992                self.witnessed.insert(
2993                    witness.clone(),
2994                    WitnessState {
2995                        snapshot: snapshot.clone(),
2996                        at: self.next_seq,
2997                    },
2998                );
2999            }
3000            OpKind::RecordRefSnapshot { snapshot } => {
3001                self.latest_snapshot = Some(snapshot.clone());
3002            }
3003        }
3004        // Only a successful apply advances the fold position, so the
3005        // counter counts ops that are actually in the log. `validate`
3006        // returned above on every rejection, leaving it untouched.
3007        self.next_seq += 1;
3008        Ok(())
3009    }
3010
3011    /// The snapshot attesting this view's current ref-state, chained to
3012    /// the latest admitted one — the value [`OpKind::RecordRefSnapshot`]
3013    /// admits as long as nothing lands in between (its `at_seq` is the
3014    /// CAS: any interleaved op moves the fold position and the emitter
3015    /// re-takes rather than attesting a state it did not read).
3016    #[must_use]
3017    pub fn snapshot(&self) -> RefSnapshot {
3018        RefSnapshot {
3019            format_version: FORMAT_VERSION,
3020            refs: self.refs.clone(),
3021            at_seq: self.next_seq,
3022            prev_snapshot: self.latest_snapshot.as_ref().map(RefSnapshot::id),
3023        }
3024    }
3025
3026    /// The actor key the log binds to reviewer channel `channel`.
3027    ///
3028    /// The inverse of [`KeyBinding::channel`], and the join that lets an
3029    /// authorization record name approvers as actor ids when a review can
3030    /// only name channels (see [`Authorization::approvers`]).
3031    ///
3032    /// Resolved **as of fold position `at`** (D44), which for an approver
3033    /// is the position of the verdict being credited
3034    /// ([`VerdictState::at`]) rather than the position of the landing.
3035    ///
3036    /// Asking "now" is the bug this replaced. A channel's key rotates:
3037    /// `BindKey` tells the holder of a revoked key to bind a fresh one,
3038    /// and the fresh one necessarily claims the same channel, because the
3039    /// channel is the operator's own name. So resolving at landing time
3040    /// had two failures at once — with the withdrawn row still counted it
3041    /// left the channel permanently ambiguous and refused every later
3042    /// landing citing that reviewer, and with the withdrawn row ignored it
3043    /// would name the fresh key as the approver of a verdict that key
3044    /// never cast. Neither is a record worth keeping. The verdict's own
3045    /// position has one answer and it is the true one.
3046    ///
3047    /// Contrast [`View::operator_of`], which answers for a revoked key
3048    /// unconditionally: that is attribution, and attribution must not go
3049    /// blind the moment a key is withdrawn.
3050    ///
3051    /// **The liveness half is exact; the channel half is the latest one.**
3052    /// `bound_at` and [`Revocation::at`] are both recorded, so whether a
3053    /// key was live at `at` replays exactly. Which channel it claimed is
3054    /// read from the current binding, because a re-binding overwrites
3055    /// [`KeyBinding::channel`] in place and the fold keeps no history of
3056    /// it. A re-binding cannot change the operator (that is refused) and
3057    /// the channel must read as the operator, so the drift this permits is
3058    /// `ana` → `ana/laptop` within one operator, never across two. An
3059    /// exact answer would need [`View::at`] re-folded to `at`, which this
3060    /// method deliberately does not do: it is called from `validate`,
3061    /// which has no log.
3062    ///
3063    /// # Errors
3064    ///
3065    /// Returns a caller-facing reason when the log binds no key to
3066    /// `channel`, none that was live at `at`, or more than one that was.
3067    /// **Ambiguity is refused, not resolved.** Two keys may legitimately
3068    /// be live on one channel at once, and nothing in a review says which
3069    /// of them cast the verdict, so picking one would put a specific key
3070    /// in a durable record on the strength of a tiebreak. A record that
3071    /// says nothing is recoverable; one that says the wrong thing
3072    /// confidently is not.
3073    ///
3074    /// "Nothing live at `at`" is its own message rather than folding into
3075    /// "no binding": the repairs differ. One needs the operator to bind a
3076    /// key; the other needs a fresh verdict from a fresh key, because the
3077    /// approval on file was cast by a key nobody trusts now.
3078    pub fn bound_actor_at(&self, channel: &str, at: u64) -> Result<ContentHash, String> {
3079        let claims = |bound: &KeyBinding| bound.channel.as_deref() == Some(channel);
3080        let live_then = |bound: &KeyBinding| {
3081            bound.bound_at <= at && bound.revoked.as_ref().is_none_or(|gone| gone.at > at)
3082        };
3083        let mut live = self
3084            .bindings
3085            .iter()
3086            .filter(|(_, bound)| claims(bound) && live_then(bound));
3087        let key_id = match live.next() {
3088            Some((key_id, _)) => key_id,
3089            None if self.bindings.values().any(claims) => {
3090                return Err(format!(
3091                    "no key the log binds to {channel} was live at seq {at}; that reviewer \
3092                     needs a fresh key and a fresh verdict"
3093                ))
3094            }
3095            None => return Err(format!("the log binds no key to {channel}")),
3096        };
3097        if live.next().is_some() {
3098            return Err(format!(
3099                "the log binds more than one live key to {channel} at seq {at}"
3100            ));
3101        }
3102        ContentHash::from_hex(key_id)
3103            .ok_or_else(|| format!("binding for {channel} holds an unreadable key id {key_id}"))
3104    }
3105
3106    /// The operator `key` is bound to, if the log ever bound it.
3107    ///
3108    /// Deliberately still answers for a **revoked** key. Revocation
3109    /// withdraws authority going forward; it does not un-attribute what
3110    /// the key already did, and an audit that lost the operator the
3111    /// moment a key was revoked would go blind exactly when it matters.
3112    /// Authorization must therefore also consult
3113    /// [`KeyBinding::is_revoked`]; attribution must not.
3114    #[must_use]
3115    pub fn operator_of(&self, key: &ContentHash) -> Option<&str> {
3116        self.bindings
3117            .get(&key.to_hex())
3118            .map(|bound| bound.operator.as_str())
3119    }
3120
3121    /// Ops sequenced since `key` was **first** bound, measured at this
3122    /// view's fold position.
3123    ///
3124    /// Named for what it counts. This is an ordering and activity
3125    /// primitive: it says a key has been bound across N sequenced ops,
3126    /// and it is monotonic, replayable and unresettable. It is **not** a
3127    /// wall clock, and it must not be relabelled as one — ops are not
3128    /// uniformly spaced in time, so a question phrased in days or weeks
3129    /// (D24 T1's `<2 weeks` branch) still needs a durable timestamp this
3130    /// crate does not have. A pure fold has no clock, the same reason
3131    /// [`OpKind::ArchiveReview`]'s lapse decision is made node-side and
3132    /// merely recorded here.
3133    #[must_use]
3134    pub fn ops_since_binding(&self, key: &ContentHash) -> Option<u64> {
3135        self.bindings
3136            .get(&key.to_hex())
3137            .map(|bound| self.next_seq.saturating_sub(bound.bound_at))
3138    }
3139
3140    /// Every key currently bound to `operator`, revoked ones included, in
3141    /// key-id order.
3142    ///
3143    /// Revoked keys stay in the count because the question T3 asks — how
3144    /// concentrated is control — is not answered by a number an operator
3145    /// can lower by revoking keys it no longer needs. Callers wanting
3146    /// only live keys filter on [`KeyBinding::is_revoked`].
3147    pub fn operator_keys<'a>(
3148        &'a self,
3149        operator: &'a str,
3150    ) -> impl Iterator<Item = (&'a str, &'a KeyBinding)> + 'a {
3151        self.bindings
3152            .iter()
3153            .filter(move |(_, bound)| bound.operator == operator)
3154            .map(|(key_id, bound)| (key_id.as_str(), bound))
3155    }
3156
3157    /// Folds the whole log into a view.
3158    ///
3159    /// # Errors
3160    ///
3161    /// Propagates decode and CAS failures; a log accepted through
3162    /// [`append_op`] always replays cleanly.
3163    pub fn materialize(log: &dyn OpLog) -> Result<Self, ViewError> {
3164        Self::at(log, log.len())
3165    }
3166
3167    /// Folds only the first `upto` entries — the view as it was after op
3168    /// `upto - 1`. This is undo/time-travel: restore by writing a new op
3169    /// that sets heads back to this view (history itself is append-only).
3170    ///
3171    /// # Errors
3172    ///
3173    /// Same failure modes as [`View::materialize`].
3174    pub fn at(log: &dyn OpLog, upto: u64) -> Result<Self, ViewError> {
3175        let mut view = View::default();
3176        for seq in 0..upto.min(log.len()) {
3177            let entry = log.get(seq).expect("seq < len");
3178            view.apply(&ViewOp::from_payload(&entry.payload)?)?;
3179        }
3180        Ok(view)
3181    }
3182}
3183
3184/// Validates `op` against the log's current view, then appends it as a
3185/// new [`OpEntry`] — the single-writer submit path in miniature. The CAS
3186/// check happens *before* the append, so the log never contains an op
3187/// that fails to replay.
3188///
3189/// # Errors
3190///
3191/// Returns [`ViewError::StaleHead`] when the op's precondition fails and
3192/// [`ViewError::Log`] when the backend append fails.
3193pub fn append_op(
3194    log: &mut dyn OpLog,
3195    submitter: &str,
3196    op: ViewOp,
3197) -> Result<ContentHash, ViewError> {
3198    let mut view = View::materialize(log)?;
3199    view.apply(&op)?;
3200    let entry = OpEntry {
3201        format_version: choir_oplog::FORMAT_VERSION,
3202        parent: log.head(),
3203        seq: log.len(),
3204        channel: submitter.to_string(),
3205        payload: op.to_payload(),
3206        witnesses: Vec::new(),
3207        author_sig: None,
3208    };
3209    log.append(entry).map_err(ViewError::Log)
3210}
3211
3212/// [`append_op`], plus the resolution-link check a store makes possible:
3213/// a head-moving op whose commit the store holds is refused when that
3214/// commit's [`Commit::resolves`] link is dangling or names a commit with
3215/// no conflict to resolve.
3216///
3217/// This is admission policy, not part of the pure fold — the same split
3218/// as the node's scope and provenance checks: [`View::apply`] stays a
3219/// store-free function so replicas can replay a log with no store at
3220/// hand, and every op admitted here still replays cleanly through it.
3221/// A commit the store cannot supply is skipped, not refused: refs on the
3222/// git-compat path carry git oids that never enter the chunk store
3223/// (invariant 2), and a resolves link cannot exist on a commit that
3224/// cannot be loaded.
3225///
3226/// # Errors
3227///
3228/// [`ViewError::Resolution`] for an invalid link, plus everything
3229/// [`append_op`] returns.
3230pub fn append_op_with_store(
3231    log: &mut dyn OpLog,
3232    store: &dyn ChunkStore,
3233    submitter: &str,
3234    op: ViewOp,
3235) -> Result<ContentHash, ViewError> {
3236    let named = match &op.kind {
3237        OpKind::SetWorkspaceHead { commit, .. } | OpKind::SetRef { commit, .. } => Some(commit),
3238        _ => None,
3239    };
3240    if let Some(id) = named {
3241        if let Ok(commit) = Commit::get(store, id) {
3242            commit.validate_resolves(store)?;
3243        }
3244    }
3245    append_op(log, submitter, op)
3246}