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}