Skip to main content

choir_node/
platform.rs

1//! The platform API: signed op submission and view queries over HTTP.
2//!
3//! This is the production composition the sequencer's `policy.rs` test
4//! proved: signature verification (choir-identity) + cached-view CAS
5//! (choir-view) running inside the single-writer thread. The daemon fronts
6//! it with signed single and batch submission, materialized-view and log
7//! reads, workspace provisioning, and review-queue endpoints. The two core
8//! operation paths are:
9//!
10//! - `POST /api/submit` — body `{"channel", "payload_hex",
11//!   "key_id", "signature_hex"}`; `workspace` remains accepted as the
12//!   legacy v1 alias for `channel`. Payload bytes are a serialized
13//!   [`ViewOp`]. Admitted ops answer `{"seq", "hash"}`; rejections are
14//!   HTTP 400 with the policy's reason.
15//! - `GET /api/view` — the current materialized state plus runtime-only
16//!   projections for D24 T3 concentration and complete-view growth. They
17//!   derive from the same coherent snapshot and never enter persisted ops
18//!   or hash input.
19//!
20//! Hex (not JSON-embedding) carries the payload because the signature
21//! covers the exact bytes the author serialized; re-encoding through a
22//! JSON tree could legally reorder/respace them and break verification.
23
24use std::collections::{BTreeMap, BTreeSet, VecDeque};
25use std::io::Write;
26use std::sync::{Arc, Mutex};
27use std::time::{Duration, Instant};
28
29use choir_identity::{ActorKey, Registry};
30use choir_oplog::{ContentHash, OpEntry, OpLog, Witness};
31use choir_sequencer::journal;
32use choir_sequencer::journal::Journal as _;
33use choir_sequencer::lag::LagMeter;
34use choir_sequencer::{Sequencer, SequencerHandle, Submission, SubmitPolicy};
35use choir_view::{
36    reviewer_operator, ArchiveAuthorization, Authorization, Basis, ChangeState,
37    CreateAuthorization, OpKind, Provenance, ReviewStatus, Verdict, View, ViewOp,
38};
39
40use crate::reject::{Code, Rejection};
41
42/// Default maximum operations accepted in one `/api/submit-batch` body.
43pub const DEFAULT_BATCH_OPS: usize = 256;
44
45/// Operator-selected bound for live review detail retained in memory.
46///
47/// Complete reviews older than `max_live` are archived immediately. An
48/// incomplete review is never archived unless `lapse_after` is explicitly
49/// set, because choosing when an unanswered review is abandoned is policy,
50/// not a harmless memory optimization.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub struct ReviewRetention {
53    max_live: usize,
54    lapse_after: Option<Duration>,
55}
56
57impl ReviewRetention {
58    /// Retains at most `max_live` live reviews when enough reviews are
59    /// complete and therefore safe to archive. Incomplete reviews never
60    /// lapse under this configuration.
61    #[must_use]
62    pub const fn keep(max_live: usize) -> Self {
63        Self {
64            max_live,
65            lapse_after: None,
66        }
67    }
68
69    /// Allows an over-limit incomplete review to lapse after `age` since
70    /// this node observed its request.
71    ///
72    /// The op log has no timestamp, so reviews replayed at startup begin a
73    /// fresh grace period. Restarting can delay a lapse, never make one
74    /// happen early. The emitted `ArchiveReview { lapsed: true }` op is
75    /// persisted, so replicas replay the decision rather than their clocks.
76    #[must_use]
77    pub const fn lapse_incomplete_after(mut self, age: Duration) -> Self {
78        self.lapse_after = Some(age);
79        self
80    }
81}
82
83/// One live review in request-sequence order. `observed_at` is consulted
84/// only for the explicitly configured incomplete-review lapse policy.
85struct TrackedReview {
86    id: String,
87    observed_at: Option<Instant>,
88}
89
90/// Runtime state that exists only when retention was explicitly enabled.
91/// Archived ids are removed, so the tracker grows with live review detail,
92/// not with the repository's lifetime.
93struct ReviewRetentionState {
94    config: ReviewRetention,
95    live: VecDeque<TrackedReview>,
96    /// Set when an observed op may have changed what is prunable, cleared
97    /// by a pass. Without it the submit path rescans the whole tracker on
98    /// every write for as long as the node holds more live reviews than
99    /// the bound and none of them is archivable — which is the *default*
100    /// shape, because incomplete reviews never lapse unless an age is
101    /// configured. Measured at 4.4x on the submit path before this
102    /// existed (1,000 unanswered reviews, `--review-retention 10`).
103    prunable_changed: bool,
104}
105
106impl ReviewRetentionState {
107    fn new(config: ReviewRetention) -> Self {
108        Self {
109            config,
110            live: VecDeque::new(),
111            prunable_changed: false,
112        }
113    }
114
115    fn observe(&mut self, op: &ViewOp, observed_at: Option<Instant>) {
116        match &op.kind {
117            OpKind::RequestReview { id, .. } => {
118                self.live.push_back(TrackedReview {
119                    id: id.clone(),
120                    observed_at,
121                });
122                self.prunable_changed = true;
123            }
124            OpKind::ArchiveReview { id, .. } => {
125                self.live.retain(|review| review.id != *id);
126                self.prunable_changed = true;
127            }
128            // The ops that cannot move a review's count or completeness,
129            // named as an exclusion rather than listing the review ops
130            // positively: a review op added later then defaults to arming
131            // the flag — a wasted scan, never silently stopped pruning.
132            OpKind::SetWorkspaceHead { .. }
133            | OpKind::SetRef { .. }
134            | OpKind::DeleteRef { .. }
135            | OpKind::DeleteWorkspace { .. }
136            | OpKind::RecordProvenance { .. }
137            | OpKind::CreateChange { .. }
138            | OpKind::CheckpointChange { .. }
139            | OpKind::ArchiveChange { .. } => {}
140            _ => self.prunable_changed = true,
141        }
142    }
143
144    /// Whether a pass could possibly find work. Deliberately conservative:
145    /// it may say yes when the answer turns out to be no, never no when
146    /// the answer is yes.
147    fn worth_a_pass(&self) -> bool {
148        // Tracked ids are dropped on archive, so this is an upper bound on
149        // the live count: at or under the bound, no pass can find work.
150        if self.live.len() <= self.config.max_live {
151            return false;
152        }
153        if self.prunable_changed {
154            return true;
155        }
156        // Under a lapse policy the clock alone can make the oldest review
157        // eligible with no op arriving. The deque is in request order, so
158        // the front carries the earliest deadline and one comparison
159        // settles it. Count-only retention still reads no clock.
160        match (self.config.lapse_after, self.live.front()) {
161            (Some(age), Some(oldest)) => oldest
162                .observed_at
163                .is_some_and(|at| Instant::now().saturating_duration_since(at) >= age),
164            _ => false,
165        }
166    }
167}
168
169/// Result of maintenance emitted after an already-successful user request.
170/// A retention failure cannot roll that request back, so it is reported as
171/// an additive response field rather than changing the request's status.
172#[derive(Default)]
173struct ReviewPruneOutcome {
174    archived: Vec<String>,
175    errors: Vec<serde_json::Value>,
176}
177
178/// Sliding window over recent admitted entries: `base` is the seq of
179/// the first held entry, so `/api/log?from=` keeps absolute semantics
180/// after old entries are dropped (full history lives in the op log).
181pub struct LogWindow {
182    base: u64,
183    /// A `VecDeque`, not a `Vec`: eviction pops from the front, which is
184    /// O(1) here and an O(n) memmove of up to `cap` entries there. At the
185    /// 100k cap that cost was paid on every push once full.
186    entries: std::collections::VecDeque<OpEntry>,
187    /// Entries kept before the oldest is dropped. A field rather than a
188    /// constant so a test can drive eviction without writing 100k ops.
189    cap: usize,
190    /// `signing_hash(channel, payload)` → the `(seq, hash)` it landed
191    /// as, for the entries currently in the window.
192    ///
193    /// Lets a resubmission be told "this already landed, here is where"
194    /// instead of the CAS failure it would otherwise see — the two are
195    /// indistinguishable today, and an agent that cannot tell them apart
196    /// either retries a completed write or abandons a successful one.
197    ///
198    /// **Bounded by the window, deliberately.** An index over the whole
199    /// log would grow with the repository's lifetime, which is the exact
200    /// growth profile `FileLog`'s offset index was just built to remove.
201    /// A window is enough because duplicate submissions come from client
202    /// retries — seconds apart, not months — so anything old enough to
203    /// have fallen out of the window is not a retry.
204    ///
205    /// A `HashMap`, and that is safe here specifically because this is
206    /// runtime state that is never serialized or hashed. Invariant 3 —
207    /// "maps in hashed structs are `BTreeMap` so serialization stays
208    /// canonical" — applies to persisted structures; an in-memory index
209    /// has no canonical form to break. Do not "fix" this to a `BTreeMap`
210    /// for consistency: `ContentHash` is `Hash` but not `Ord`.
211    /// Stores the **seq only**. The entry hash is recoverable from the
212    /// window when the rare already-applied path needs it, and computing
213    /// it here would re-serialize every entry on the write path — the
214    /// allocation budget caught exactly that.
215    by_signing: std::collections::HashMap<ContentHash, u64>,
216    /// Entry hash → seq, for the heads a scoped op may name.
217    ///
218    /// Same bound, same reason, and it costs no hashing at all: the
219    /// sequencer hands `push` the hash it already computed, and eviction
220    /// reads the dropped entry's hash out of the next entry's `parent`
221    /// rather than re-deriving it.
222    by_hash: std::collections::HashMap<ContentHash, u64>,
223}
224
225/// Entries retained in memory for `/api/log`; older reads are served from
226/// the persisted op log when the node has one.
227const LOG_WINDOW_CAP: usize = 100_000;
228
229/// Entries per `/api/log` page, whichever source served them.
230const LOG_PAGE: usize = 500;
231
232/// Approval weight required to move a protected ref. The assignment draw
233/// targets the same number of independent operators, but a thin pool may
234/// return fewer; under-assignment stays visible and cannot lower this
235/// landing threshold (D24 layer 5).
236const REQUIRED_APPROVAL_WEIGHT: usize = 2;
237
238/// D24 T3 fires above these bounds. Shares use exact integer arithmetic;
239/// basis points are presentation only and never drive the decision.
240const T3_MAX_AGENT_KEYS_PER_OPERATOR: usize = 100;
241const T3_MAX_SHARE_PERCENT: usize = 1;
242
243/// D24 T2's declared bounds, recorded so the projection can state exactly
244/// which question it is *not* answering. Nothing is ever compared against
245/// them: choir persists no validity classification, so the numerator of a
246/// slop rate does not exist. See [`new_actor_review_outcomes_json`].
247const T2_MAX_INVALID_OR_SLOP_PERCENT: usize = 20;
248const T2_MIN_VALID_PERCENT: usize = 5;
249
250/// Version of the append-only newcomer audit and operator adjudication rows.
251/// These files are not part of the signed op log, but they are persisted
252/// measurement inputs and therefore carry the same explicit-version discipline.
253const NEWCOMER_AUDIT_FORMAT_VERSION: u64 = 1;
254
255#[derive(Debug, Clone)]
256struct NewcomerAttempt {
257    started_at_unix_ms: u64,
258    first_outcome: &'static str,
259    first_rejection_code: Option<String>,
260    first_accepted_at_unix_ms: Option<u64>,
261}
262
263/// Sparse, durable T4 evidence. Incumbents are the keys present when the
264/// operator enables the audit; only keys first admitted after that boundary are
265/// newcomers. At most two outcome records are written per actor (first attempt,
266/// then first acceptance after a rejection), so instrumentation cost scales with
267/// newcomers rather than submissions.
268struct NewcomerAudit {
269    file: std::fs::File,
270    adjudications_path: std::path::PathBuf,
271    activated: bool,
272    incumbents: BTreeSet<String>,
273    attempts: BTreeMap<u64, NewcomerAttempt>,
274    by_actor: BTreeMap<String, u64>,
275    appeals: BTreeSet<u64>,
276    next_attempt_id: u64,
277    available: bool,
278}
279
280impl NewcomerAudit {
281    fn open(
282        audit_path: &std::path::Path,
283        adjudications_path: std::path::PathBuf,
284        incumbents: BTreeSet<String>,
285    ) -> Result<Self, String> {
286        if let Some(parent) = audit_path.parent() {
287            std::fs::create_dir_all(parent)
288                .map_err(|e| format!("create newcomer audit directory: {e}"))?;
289        }
290        let existing = match std::fs::read_to_string(audit_path) {
291            Ok(existing) => existing,
292            Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
293            Err(error) => return Err(format!("read newcomer audit: {error}")),
294        };
295        let mut options = std::fs::OpenOptions::new();
296        options.create(true).append(true);
297        #[cfg(unix)]
298        {
299            use std::os::unix::fs::OpenOptionsExt;
300            options.mode(0o600);
301        }
302        let file = options
303            .open(audit_path)
304            .map_err(|e| format!("open newcomer audit: {e}"))?;
305        #[cfg(unix)]
306        {
307            use std::os::unix::fs::PermissionsExt;
308            std::fs::set_permissions(audit_path, std::fs::Permissions::from_mode(0o600))
309                .map_err(|e| format!("chmod newcomer audit: {e}"))?;
310        }
311        let mut audit = Self {
312            file,
313            adjudications_path,
314            activated: false,
315            incumbents: BTreeSet::new(),
316            attempts: BTreeMap::new(),
317            by_actor: BTreeMap::new(),
318            appeals: BTreeSet::new(),
319            next_attempt_id: 0,
320            available: true,
321        };
322        for (index, line) in existing.lines().enumerate() {
323            if line.trim().is_empty() {
324                continue;
325            }
326            let value: serde_json::Value = serde_json::from_str(line)
327                .map_err(|e| format!("newcomer audit line {}: {e}", index + 1))?;
328            audit
329                .replay(&value)
330                .map_err(|e| format!("newcomer audit line {}: {e}", index + 1))?;
331        }
332        if audit.activated {
333            return Ok(audit);
334        }
335        if existing.lines().any(|line| !line.trim().is_empty()) {
336            return Err("newcomer audit has rows before its activation boundary".to_string());
337        }
338        let record = serde_json::json!({
339            "format_version": NEWCOMER_AUDIT_FORMAT_VERSION,
340            "kind": "activation",
341            "observed_at_unix_ms": unix_ms(),
342            "incumbent_actor_keys": incumbents,
343        });
344        audit.append(&record)?;
345        audit.replay(&record)?;
346        Ok(audit)
347    }
348
349    fn replay(&mut self, value: &serde_json::Value) -> Result<(), String> {
350        if value["format_version"].as_u64() != Some(NEWCOMER_AUDIT_FORMAT_VERSION) {
351            return Err("unsupported format_version".to_string());
352        }
353        let kind = value["kind"].as_str().ok_or("missing kind")?;
354        match kind {
355            "activation" => {
356                if self.activated || !self.attempts.is_empty() || !self.appeals.is_empty() {
357                    return Err("duplicate or late activation boundary".to_string());
358                }
359                value["observed_at_unix_ms"]
360                    .as_u64()
361                    .ok_or("activation needs observed_at_unix_ms")?;
362                let incumbent_actor_keys = value["incumbent_actor_keys"]
363                    .as_array()
364                    .ok_or("activation needs incumbent_actor_keys")?;
365                for actor_key in incumbent_actor_keys {
366                    let actor_key = actor_key
367                        .as_str()
368                        .filter(|actor_key| !actor_key.is_empty())
369                        .ok_or("incumbent actor keys must be non-empty strings")?;
370                    if !self.incumbents.insert(actor_key.to_string()) {
371                        return Err("activation has a duplicate incumbent actor key".to_string());
372                    }
373                }
374                self.activated = true;
375            }
376            "first_attempt" => {
377                if !self.activated {
378                    return Err("first_attempt precedes activation".to_string());
379                }
380                let attempt_id = value["attempt_id"].as_u64().ok_or("missing attempt_id")?;
381                if self.attempts.contains_key(&attempt_id) {
382                    return Err("duplicate first_attempt".to_string());
383                }
384                let actor_key = value["actor_key"]
385                    .as_str()
386                    .filter(|value| !value.is_empty())
387                    .ok_or("missing actor_key")?
388                    .to_string();
389                if self.incumbents.contains(&actor_key) {
390                    return Err("first_attempt belongs to an incumbent actor key".to_string());
391                }
392                if self.by_actor.contains_key(&actor_key) {
393                    return Err("actor has more than one first_attempt".to_string());
394                }
395                let started_at_unix_ms = value["started_at_unix_ms"]
396                    .as_u64()
397                    .ok_or("missing started_at_unix_ms")?;
398                let first_outcome = match value["outcome"].as_str() {
399                    Some("accepted") => "accepted",
400                    Some("rejected") => "rejected",
401                    _ => return Err("outcome must be accepted or rejected".to_string()),
402                };
403                let first_rejection_code = value["rejection_code"].as_str().map(str::to_string);
404                if (first_outcome == "rejected") != first_rejection_code.is_some() {
405                    return Err("rejected first attempts need one rejection_code".to_string());
406                }
407                let first_accepted_at_unix_ms = (first_outcome == "accepted").then_some(
408                    value["completed_at_unix_ms"]
409                        .as_u64()
410                        .ok_or("accepted attempt needs completed_at_unix_ms")?,
411                );
412                self.by_actor.insert(actor_key, attempt_id);
413                self.attempts.insert(
414                    attempt_id,
415                    NewcomerAttempt {
416                        started_at_unix_ms,
417                        first_outcome,
418                        first_rejection_code,
419                        first_accepted_at_unix_ms,
420                    },
421                );
422                self.next_attempt_id = self.next_attempt_id.max(attempt_id.saturating_add(1));
423            }
424            "first_accept" => {
425                let attempt_id = value["attempt_id"].as_u64().ok_or("missing attempt_id")?;
426                let completed = value["completed_at_unix_ms"]
427                    .as_u64()
428                    .ok_or("first_accept needs completed_at_unix_ms")?;
429                let attempt = self
430                    .attempts
431                    .get_mut(&attempt_id)
432                    .ok_or("first_accept precedes first_attempt")?;
433                if attempt.first_outcome != "rejected" {
434                    return Err("first_accept follows an accepted first attempt".to_string());
435                }
436                if attempt
437                    .first_accepted_at_unix_ms
438                    .replace(completed)
439                    .is_some()
440                {
441                    return Err("duplicate first_accept".to_string());
442                }
443            }
444            "appeal" => {
445                let attempt_id = value["attempt_id"].as_u64().ok_or("missing attempt_id")?;
446                if !self.attempts.contains_key(&attempt_id) {
447                    return Err("appeal references an unknown attempt".to_string());
448                }
449                if self.attempts[&attempt_id].first_outcome != "rejected" {
450                    return Err("appeal references an accepted first attempt".to_string());
451                }
452                if !self.appeals.insert(attempt_id) {
453                    return Err("duplicate appeal".to_string());
454                }
455            }
456            _ => return Err("unknown kind".to_string()),
457        }
458        Ok(())
459    }
460
461    fn append(&mut self, value: &serde_json::Value) -> Result<(), String> {
462        serde_json::to_writer(&mut self.file, value)
463            .map_err(|e| format!("write newcomer audit: {e}"))?;
464        self.file
465            .write_all(b"\n")
466            .and_then(|_| self.file.sync_data())
467            .map_err(|e| format!("sync newcomer audit: {e}"))
468    }
469
470    fn observe(
471        &mut self,
472        actor_key: &str,
473        started_at_unix_ms: u64,
474        accepted: bool,
475        rejection_code: Option<&str>,
476    ) -> Result<Option<u64>, String> {
477        // `actor_key` is the *claimed* key id, straight off an
478        // unverified signature, so nothing may be recorded under it
479        // until a signature check has vouched for it. Both signature
480        // failures have to be listed: this read `== Some("unknown_key")`
481        // while that code covered every verification failure, and
482        // splitting `bad_signature` out of it would otherwise have let
483        // anyone who knows a trusted key id append audit rows in that
484        // actor's name by sending deliberate garbage.
485        if self.incumbents.contains(actor_key)
486            || matches!(rejection_code, Some("unknown_key" | "bad_signature"))
487        {
488            return Ok(None);
489        }
490        let completed_at_unix_ms = unix_ms();
491        if let Some(attempt_id) = self.by_actor.get(actor_key).copied() {
492            let needs_accept = accepted
493                && self.attempts[&attempt_id]
494                    .first_accepted_at_unix_ms
495                    .is_none();
496            if needs_accept {
497                let record = serde_json::json!({
498                    "format_version": NEWCOMER_AUDIT_FORMAT_VERSION,
499                    "kind": "first_accept",
500                    "attempt_id": attempt_id,
501                    "completed_at_unix_ms": completed_at_unix_ms,
502                });
503                if let Err(error) = self.append(&record) {
504                    self.available = false;
505                    return Err(error);
506                }
507                self.attempts
508                    .get_mut(&attempt_id)
509                    .expect("attempt exists")
510                    .first_accepted_at_unix_ms = Some(completed_at_unix_ms);
511            }
512            return Ok(Some(attempt_id));
513        }
514
515        let attempt_id = self.next_attempt_id;
516        let outcome = if accepted { "accepted" } else { "rejected" };
517        let mut record = serde_json::json!({
518            "format_version": NEWCOMER_AUDIT_FORMAT_VERSION,
519            "kind": "first_attempt",
520            "attempt_id": attempt_id,
521            "actor_key": actor_key,
522            "started_at_unix_ms": started_at_unix_ms,
523            "completed_at_unix_ms": completed_at_unix_ms,
524            "outcome": outcome,
525        });
526        if let Some(code) = rejection_code {
527            record["rejection_code"] = serde_json::json!(code);
528        }
529        if let Err(error) = self.append(&record) {
530            self.available = false;
531            return Err(error);
532        }
533        self.replay(&record)?;
534        Ok(Some(attempt_id))
535    }
536
537    fn appeal(&mut self, attempt_id: u64) -> Result<(), String> {
538        let Some(attempt) = self.attempts.get(&attempt_id) else {
539            return Err("no such newcomer attempt".to_string());
540        };
541        if attempt.first_outcome != "rejected" {
542            return Err("only a rejected first attempt can be appealed".to_string());
543        }
544        if self.appeals.contains(&attempt_id) {
545            return Ok(());
546        }
547        let record = serde_json::json!({
548            "format_version": NEWCOMER_AUDIT_FORMAT_VERSION,
549            "kind": "appeal",
550            "attempt_id": attempt_id,
551            "observed_at_unix_ms": unix_ms(),
552        });
553        if let Err(error) = self.append(&record) {
554            self.available = false;
555            return Err(error);
556        }
557        self.replay(&record)
558    }
559}
560
561fn unix_ms() -> u64 {
562    std::time::SystemTime::now()
563        .duration_since(std::time::UNIX_EPOCH)
564        .map_or(0, |duration| {
565            u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
566        })
567}
568
569fn median_u64(values: &mut [u64]) -> Option<u64> {
570    if values.is_empty() {
571        return None;
572    }
573    values.sort_unstable();
574    let middle = values.len() / 2;
575    if values.len() % 2 == 1 {
576        Some(values[middle])
577    } else {
578        Some(((u128::from(values[middle - 1]) + u128::from(values[middle])) / 2) as u64)
579    }
580}
581
582fn read_newcomer_adjudications(
583    path: &std::path::Path,
584    attempts: &BTreeMap<u64, NewcomerAttempt>,
585) -> Result<(BTreeMap<u64, bool>, String), String> {
586    let bytes = std::fs::read(path).map_err(|e| format!("read adjudications: {e}"))?;
587    let snapshot_hash = ContentHash::blake3(&bytes).to_hex();
588    let text = String::from_utf8(bytes).map_err(|_| "adjudications are not UTF-8".to_string())?;
589    let mut rows = BTreeMap::new();
590    for (index, line) in text.lines().enumerate() {
591        if line.trim().is_empty() {
592            continue;
593        }
594        let value: serde_json::Value = serde_json::from_str(line)
595            .map_err(|e| format!("adjudication line {}: {e}", index + 1))?;
596        if value["format_version"].as_u64() != Some(NEWCOMER_AUDIT_FORMAT_VERSION) {
597            return Err(format!(
598                "adjudication line {} has unsupported format_version",
599                index + 1
600            ));
601        }
602        let attempt_id = value["attempt_id"]
603            .as_u64()
604            .ok_or_else(|| format!("adjudication line {} needs attempt_id", index + 1))?;
605        let legitimate = value["legitimate"]
606            .as_bool()
607            .ok_or_else(|| format!("adjudication line {} needs legitimate", index + 1))?;
608        if !attempts.contains_key(&attempt_id) {
609            return Err(format!(
610                "adjudication line {} references an unknown attempt",
611                index + 1
612            ));
613        }
614        if rows.insert(attempt_id, legitimate).is_some() {
615            return Err(format!(
616                "adjudication line {} duplicates an attempt",
617                index + 1
618            ));
619        }
620    }
621    Ok((rows, snapshot_hash))
622}
623
624fn newcomer_harm_json(audit: Option<&Arc<Mutex<NewcomerAudit>>>) -> serde_json::Value {
625    let Some(audit) = audit else {
626        return serde_json::json!({
627            "format_version": NEWCOMER_AUDIT_FORMAT_VERSION,
628            "configured": false,
629            "available": false,
630            "tripwire_status": "indeterminate",
631            "evaluation_complete": false,
632        });
633    };
634    let audit = audit.lock().expect("newcomer audit lock");
635    let adjudications = read_newcomer_adjudications(&audit.adjudications_path, &audit.attempts);
636    let (rows, snapshot_hash, adjudications_available, adjudications_error) = match adjudications {
637        Ok((rows, hash)) => (rows, Some(hash), true, None),
638        Err(error) => (BTreeMap::new(), None, false, Some(error)),
639    };
640
641    let mut first_accepted = 0usize;
642    let mut first_rejected = 0usize;
643    let mut rejection_codes: BTreeMap<String, usize> = BTreeMap::new();
644    let mut legitimate = 0usize;
645    let mut legitimate_first_rejected = 0usize;
646    let mut legitimate_pending_acceptance = 0usize;
647    let mut accepted_latencies = Vec::new();
648    for (attempt_id, attempt) in &audit.attempts {
649        if attempt.first_outcome == "accepted" {
650            first_accepted += 1;
651        } else {
652            first_rejected += 1;
653            *rejection_codes
654                .entry(
655                    attempt
656                        .first_rejection_code
657                        .clone()
658                        .expect("rejected attempts carry a code"),
659                )
660                .or_default() += 1;
661        }
662        if rows.get(attempt_id) != Some(&true) {
663            continue;
664        }
665        legitimate += 1;
666        legitimate_first_rejected += usize::from(attempt.first_outcome == "rejected");
667        match attempt.first_accepted_at_unix_ms {
668            Some(accepted) => {
669                accepted_latencies.push(accepted.saturating_sub(attempt.started_at_unix_ms))
670            }
671            None => legitimate_pending_acceptance += 1,
672        }
673    }
674    let median_time_to_first_accepted_ms = median_u64(&mut accepted_latencies);
675    let adjudicated = rows.len();
676    let total = audit.attempts.len();
677    let unresolved_appeals = audit
678        .appeals
679        .iter()
680        .filter(|attempt_id| !rows.contains_key(attempt_id))
681        .count();
682    let false_reject_rate_basis_points =
683        (legitimate != 0).then(|| share_basis_points(legitimate_first_rejected, legitimate));
684    let measurement_complete = audit.available
685        && adjudications_available
686        && legitimate != 0
687        && adjudicated == total
688        && legitimate_pending_acceptance == 0;
689    serde_json::json!({
690        "format_version": NEWCOMER_AUDIT_FORMAT_VERSION,
691        "configured": true,
692        "available": audit.available && adjudications_available,
693        "scope": "post-activation cryptographically verified signed-API actor keys",
694        "thresholds": {
695            "false_reject_rate_basis_points": null,
696            "median_time_to_first_accepted_ms": null,
697            "status": "unset_pending_first_measurement",
698        },
699        "audit": {
700            "available": audit.available,
701            "incumbent_actor_keys_excluded": audit.incumbents.len(),
702            "first_attempts": total,
703            "first_attempts_accepted": first_accepted,
704            "first_attempts_rejected": first_rejected,
705            "first_rejections_by_code": rejection_codes,
706        },
707        "appeals": {
708            "submitted": audit.appeals.len(),
709            "unresolved": unresolved_appeals,
710        },
711        "adjudications": {
712            "available": adjudications_available,
713            "snapshot_hash": snapshot_hash,
714            "error": adjudications_error,
715            "attempts": adjudicated,
716            "coverage_basis_points": share_basis_points(adjudicated, total),
717            "legitimate_attempts": legitimate,
718        },
719        "measurements": {
720            "legitimate_first_attempts_rejected": legitimate_first_rejected,
721            "false_reject_rate_basis_points": false_reject_rate_basis_points,
722            "accepted_legitimate_newcomers": accepted_latencies.len(),
723            "legitimate_newcomers_pending_acceptance": legitimate_pending_acceptance,
724            "median_time_to_first_accepted_ms": median_time_to_first_accepted_ms,
725        },
726        "measurement_complete": measurement_complete,
727        "tripwire_status": "indeterminate",
728        "evaluation_complete": false,
729        "semantics": {
730            "false_reject": "an operator-adjudicated legitimate actor whose first verified signed-API attempt was rejected",
731            "time_to_first_accepted": "elapsed wall time from that actor's first verified signed-API attempt to its first accepted signed operation",
732            "excluded": "unverified unknown-key claims, incumbent keys, Git/Basic-auth pushes, and HTTP-only workspace provisioning",
733        },
734    })
735}
736
737/// One log author's signed identity claim. Resolution is delayed until a
738/// report is read so a hot-reloaded binding file changes the whole current
739/// projection consistently, including entries replayed before the reload.
740#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
741struct ActorEvidence {
742    key_id: Option<String>,
743    channel: String,
744}
745
746impl ActorEvidence {
747    fn from_entry(entry: &OpEntry) -> Self {
748        Self {
749            key_id: entry.author_sig.as_ref().map(|sig| sig.key_id.clone()),
750            channel: entry.channel.clone(),
751        }
752    }
753}
754
755/// Evidence available for one ref move. A directly bound signer wins. A
756/// node-signed Git move may instead be attributed to the unique bound
757/// operator whose approved review named the exact `(ref, target)` pair.
758#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
759struct RefAttribution {
760    direct: ActorEvidence,
761    approved_requesters: Vec<ActorEvidence>,
762}
763
764/// Which source a binding snapshot was built from.
765///
766/// D24 T3 attribution and channel admission read *different* sources on
767/// purpose, so the snapshot has to say which one it is rather than leaving
768/// a reader to infer it from the call site.
769#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
770enum BindingSource {
771    /// The operator-written trusted-keys file. Mutable, unsequenced, and
772    /// therefore usable for admission but not as attribution evidence.
773    #[default]
774    KeysFile,
775    /// [`View::bindings`]: sequenced `BindKey` ops, replayable from the log.
776    DurableLog,
777}
778
779impl BindingSource {
780    fn as_str(self) -> &'static str {
781        match self {
782            Self::KeysFile => "keys_file",
783            Self::DurableLog => "durable_log",
784        }
785    }
786}
787
788/// Current binding snapshot. The effective map preserves admission's
789/// existing one-name-per-actor behaviour; the deterministic records retain
790/// enough information to report duplicate cross-operator bindings as
791/// ambiguous rather than choosing whichever row happened to come last.
792#[derive(Default)]
793struct KeyBindings {
794    effective: std::collections::HashMap<ContentHash, String>,
795    operators_by_actor: BTreeMap<String, BTreeSet<String>>,
796    names_by_actor: BTreeMap<String, BTreeSet<String>>,
797    unbound_actors: BTreeSet<String>,
798    configured: bool,
799    available: bool,
800    source: BindingSource,
801}
802
803impl KeyBindings {
804    fn unavailable(configured: bool) -> Self {
805        Self {
806            configured,
807            ..Self::default()
808        }
809    }
810
811    /// Builds the attribution snapshot from the durable record instead of
812    /// the keys file.
813    ///
814    /// `population` supplies *who is trusted*, which the log cannot answer:
815    /// a `BindKey` names a key, but only the keys file says which keys the
816    /// node accepts at all. So the two compose rather than compete — the
817    /// file decides the denominator, the log decides attribution, and a
818    /// trusted key with no sequenced binding lands in `unbound_actors` and
819    /// holds `evaluation_complete` at false.
820    ///
821    /// Without a readable population there is no denominator, so the
822    /// snapshot is unavailable rather than reporting completeness over
823    /// whatever subset happens to be bound.
824    fn from_view(view: &View, population: &Self) -> Self {
825        if !population.available {
826            return Self {
827                source: BindingSource::DurableLog,
828                ..Self::unavailable(population.configured)
829            };
830        }
831        let trusted: BTreeSet<&String> = population
832            .names_by_actor
833            .keys()
834            .chain(population.unbound_actors.iter())
835            .collect();
836
837        let mut operators_by_actor: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
838        let mut names_by_actor: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
839        for (key_id, binding) in &view.bindings {
840            if !trusted.contains(key_id) {
841                continue;
842            }
843            // Revoked keys keep their operator. Attribution must survive
844            // withdrawal, or an operator could shed a concentration count
845            // by revoking the key that earned it.
846            operators_by_actor
847                .entry(key_id.clone())
848                .or_default()
849                .insert(binding.operator.clone());
850            // Only a bound channel attributes activity, mirroring the keys
851            // file, where a key with no name attributes nothing. The
852            // operator is known; which channel it speaks as is not.
853            if let Some(channel) = &binding.channel {
854                names_by_actor
855                    .entry(key_id.clone())
856                    .or_default()
857                    .insert(channel.clone());
858            }
859        }
860        let unbound_actors = trusted
861            .into_iter()
862            .filter(|actor| !names_by_actor.contains_key(*actor))
863            .cloned()
864            .collect();
865        Self {
866            // Admission is not served from this snapshot; leaving the map
867            // empty keeps it structurally unable to answer `bound_name`.
868            effective: std::collections::HashMap::new(),
869            operators_by_actor,
870            names_by_actor,
871            unbound_actors,
872            configured: population.configured,
873            available: true,
874            source: BindingSource::DurableLog,
875        }
876    }
877
878    fn from_signers(signers: &[crate::TrustedKey]) -> Self {
879        let mut effective = std::collections::HashMap::new();
880        let mut names_by_actor: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
881        let mut all_actors = BTreeSet::new();
882        for signer in signers {
883            all_actors.insert(signer.actor_id.clone());
884            if let Some(name) = &signer.name {
885                effective.insert(ContentHash::blake3(&signer.key), name.clone());
886                names_by_actor
887                    .entry(signer.actor_id.clone())
888                    .or_default()
889                    .insert(name.clone());
890            }
891        }
892        let operators_by_actor = names_by_actor
893            .iter()
894            .map(|(actor, names)| {
895                (
896                    actor.clone(),
897                    names
898                        .iter()
899                        .map(|name| reviewer_operator(name).to_string())
900                        .collect(),
901                )
902            })
903            .collect();
904        let unbound_actors = all_actors
905            .iter()
906            .filter(|actor| !names_by_actor.contains_key(*actor))
907            .cloned()
908            .collect();
909        Self {
910            effective,
911            operators_by_actor,
912            names_by_actor,
913            unbound_actors,
914            configured: true,
915            available: true,
916            source: BindingSource::KeysFile,
917        }
918    }
919
920    fn bound_name(&self, actor_id: &ContentHash) -> Option<&str> {
921        self.effective.get(actor_id).map(String::as_str)
922    }
923
924    fn resolve_evidence(&self, evidence: &ActorEvidence) -> AttributionResolution {
925        let Some(key_id) = evidence.key_id.as_ref() else {
926            return AttributionResolution::Unknown;
927        };
928        let Some(names) = self.names_by_actor.get(key_id) else {
929            return AttributionResolution::Unknown;
930        };
931        if !names.contains(&evidence.channel) {
932            return AttributionResolution::Unknown;
933        }
934        let Some(operators) = self.operators_by_actor.get(key_id) else {
935            return AttributionResolution::Unknown;
936        };
937        match operators.len() {
938            0 => AttributionResolution::Unknown,
939            1 => AttributionResolution::Operator(operators.first().expect("one operator").clone()),
940            _ => AttributionResolution::Ambiguous,
941        }
942    }
943
944    fn snapshot_hash(&self) -> Option<String> {
945        self.available.then(|| {
946            let mut records = self.names_by_actor.clone();
947            for actor in &self.unbound_actors {
948                records.entry(actor.clone()).or_default();
949            }
950            let bytes =
951                serde_json::to_vec(&records).expect("binding snapshot is always serializable");
952            ContentHash::blake3(&bytes).to_hex()
953        })
954    }
955}
956
957/// D24 T2 load evidence: objective counts folded straight from the log.
958///
959/// These answer "how much reviewer work did this cohort create, and how much
960/// of it landed" without any human calling anything slop. That matters
961/// because a classifier-based slop rate goes blind exactly when it is needed:
962/// a flood collapses adjudication coverage, and incomplete coverage must
963/// report `indeterminate`. These counts keep working during the flood, and
964/// no contributor can suppress them by declining to request a review.
965#[derive(Default)]
966struct NewActorLoadState {
967    /// Accepted entries per signing key. The unit is a *contribution
968    /// offered*, not a review: `RequestReview` is authored by the
969    /// contributor, so a review-based denominator lets an actor choose
970    /// whether to be measured.
971    submissions: BTreeMap<String, usize>,
972    /// `PostVerdict` ops per review id: reviewer rounds actually consumed.
973    /// Re-review after changes is the cost signal, so it is counted rather
974    /// than collapsed into a final verdict.
975    verdict_rounds: BTreeMap<String, usize>,
976    /// Reviews whose exact `(target_ref, target)` was observed live in
977    /// [`View::refs`]. Landing is *observed*; approval is not landing.
978    landed: BTreeSet<String>,
979}
980
981impl NewActorLoadState {
982    fn observe(&mut self, entry: &OpEntry, op: &ViewOp, view: &View) {
983        if let Some(signature) = &entry.author_sig {
984            // Only clone the key the first time it is seen. `entry()` would
985            // allocate on every accepted op, and a repeat submitter is the
986            // common case: the allocation budget caught exactly that.
987            if let Some(count) = self.submissions.get_mut(&signature.key_id) {
988                *count += 1;
989            } else {
990                self.submissions.insert(signature.key_id.clone(), 1);
991            }
992        }
993        match &op.kind {
994            OpKind::PostVerdict { id, .. } => {
995                *self.verdict_rounds.entry(id.clone()).or_default() += 1;
996            }
997            OpKind::SetRef { name, commit, .. } => {
998                for (id, review) in &view.reviews {
999                    if review.target_ref.as_deref() == Some(name)
1000                        && review.target.as_ref() == Some(commit)
1001                    {
1002                        self.landed.insert(id.clone());
1003                    }
1004                }
1005            }
1006            _ => {}
1007        }
1008    }
1009}
1010
1011#[derive(Default)]
1012struct ConcentrationState {
1013    review_requesters: BTreeMap<String, ActorEvidence>,
1014    active_branches: BTreeMap<String, RefAttribution>,
1015    ref_updates: BTreeMap<String, BTreeMap<RefAttribution, usize>>,
1016    /// T2's load evidence, folded here rather than behind a second mutex:
1017    /// it needs the same `(entry, op, view)` triple at the same point of the
1018    /// same single-writer fold, and a parallel tracker would add plumbing
1019    /// plus a second chance for the two to disagree about `as_of_seq`.
1020    new_actor_load: NewActorLoadState,
1021    as_of_seq: Option<u64>,
1022}
1023
1024impl ConcentrationState {
1025    fn observe(&mut self, entry: &OpEntry, op: &ViewOp, view: &View) {
1026        self.as_of_seq = Some(entry.seq);
1027        self.new_actor_load.observe(entry, op, view);
1028        match &op.kind {
1029            OpKind::RequestReview { id, .. } => {
1030                self.review_requesters
1031                    .insert(id.clone(), ActorEvidence::from_entry(entry));
1032            }
1033            OpKind::SetRef { name, commit, prev } => {
1034                let mut approved_requesters: Vec<_> = view
1035                    .reviews
1036                    .iter()
1037                    .filter(|(_, review)| {
1038                        review.target_ref.as_deref() == Some(name)
1039                            && review.target.as_ref() == Some(commit)
1040                            && review.approved()
1041                    })
1042                    .filter_map(|(id, _)| self.review_requesters.get(id).cloned())
1043                    .collect();
1044                approved_requesters.sort();
1045                approved_requesters.dedup();
1046                let attribution = RefAttribution {
1047                    direct: ActorEvidence::from_entry(entry),
1048                    approved_requesters,
1049                };
1050                if is_branch_ref(name) {
1051                    self.active_branches
1052                        .insert(name.clone(), attribution.clone());
1053                }
1054                if prev.is_some() {
1055                    *self
1056                        .ref_updates
1057                        .entry(name.clone())
1058                        .or_default()
1059                        .entry(attribution)
1060                        .or_default() += 1;
1061                }
1062            }
1063            OpKind::DeleteRef { name, .. } => {
1064                self.active_branches.remove(name);
1065            }
1066            _ => {}
1067        }
1068    }
1069}
1070
1071fn is_branch_ref(name: &str) -> bool {
1072    name.strip_prefix("refs/heads/")
1073        .or_else(|| name.split_once(":refs/heads/").map(|(_, branch)| branch))
1074        .is_some_and(|branch| !branch.is_empty())
1075}
1076
1077fn share_basis_points(part: usize, total: usize) -> usize {
1078    if total == 0 {
1079        return 0;
1080    }
1081    ((part as u128 * 10_000) / total as u128) as usize
1082}
1083
1084fn share_tripped(part: usize, total: usize) -> bool {
1085    total != 0 && part as u128 * 100 > total as u128 * T3_MAX_SHARE_PERCENT as u128
1086}
1087
1088enum AttributionResolution {
1089    Operator(String),
1090    Unknown,
1091    Ambiguous,
1092}
1093
1094fn resolve_attribution(
1095    attribution: &RefAttribution,
1096    bindings: &KeyBindings,
1097) -> AttributionResolution {
1098    match bindings.resolve_evidence(&attribution.direct) {
1099        AttributionResolution::Operator(operator) => {
1100            return AttributionResolution::Operator(operator);
1101        }
1102        AttributionResolution::Ambiguous => return AttributionResolution::Ambiguous,
1103        AttributionResolution::Unknown => {}
1104    }
1105    let mut operators = BTreeSet::new();
1106    let mut ambiguous = false;
1107    let mut unknown = false;
1108    for requester in &attribution.approved_requesters {
1109        match bindings.resolve_evidence(requester) {
1110            AttributionResolution::Operator(operator) => {
1111                operators.insert(operator);
1112            }
1113            AttributionResolution::Ambiguous => ambiguous = true,
1114            AttributionResolution::Unknown => unknown = true,
1115        }
1116    }
1117    if ambiguous || (unknown && !operators.is_empty()) {
1118        return AttributionResolution::Ambiguous;
1119    }
1120    if unknown {
1121        return AttributionResolution::Unknown;
1122    }
1123    match operators.len() {
1124        0 => AttributionResolution::Unknown,
1125        1 => AttributionResolution::Operator(
1126            operators.first().expect("one requester operator").clone(),
1127        ),
1128        _ => AttributionResolution::Ambiguous,
1129    }
1130}
1131
1132#[derive(Default)]
1133struct OperatorConcentration {
1134    agent_keys: usize,
1135    active_branches: usize,
1136    protected_updates: usize,
1137}
1138
1139struct ProtectedPolicySnapshot {
1140    configured: bool,
1141    available: bool,
1142    hash: Option<String>,
1143    patterns: Vec<String>,
1144}
1145
1146impl ProtectedPolicySnapshot {
1147    fn read(path: Option<&std::path::Path>) -> Self {
1148        let Some(path) = path else {
1149            return Self {
1150                configured: false,
1151                available: false,
1152                hash: None,
1153                patterns: Vec::new(),
1154            };
1155        };
1156        match std::fs::read_to_string(path) {
1157            Ok(text) => Self {
1158                configured: true,
1159                available: true,
1160                hash: Some(ContentHash::blake3(text.as_bytes()).to_hex()),
1161                patterns: text
1162                    .lines()
1163                    .map(str::trim)
1164                    .filter(|line| !line.is_empty() && !line.starts_with('#'))
1165                    .map(str::to_string)
1166                    .collect(),
1167            },
1168            Err(_) => Self {
1169                configured: true,
1170                available: false,
1171                hash: None,
1172                patterns: Vec::new(),
1173            },
1174        }
1175    }
1176
1177    fn matches(&self, name: &str) -> bool {
1178        self.patterns.iter().any(|pattern| {
1179            pattern
1180                .strip_suffix('*')
1181                .map_or(name == pattern, |prefix| name.starts_with(prefix))
1182        })
1183    }
1184}
1185
1186fn concentration_json(
1187    state: &ConcentrationState,
1188    bindings: &KeyBindings,
1189    protected: &ProtectedPolicySnapshot,
1190) -> serde_json::Value {
1191    let mut operators: BTreeMap<String, OperatorConcentration> = BTreeMap::new();
1192    let mut ambiguous_agent_keys = 0usize;
1193    // Only `KeyBindings::from_view` feeds this function, and the fold lets
1194    // one key name exactly one operator for its lifetime, so today every
1195    // set here has exactly one member and `ambiguous_agent_keys` is always
1196    // zero. The other arms are kept deliberately, not by oversight: the
1197    // keys-file shape they answer is still constructible by
1198    // `from_signers`, and if a snapshot from that source is ever routed
1199    // here, refusing to pick a winner is the behaviour that belongs. Note
1200    // this is only the *per-key* ambiguity; ambiguity across several
1201    // requester keys is live and counted in `ambiguous_active_branches`.
1202    for operator_set in bindings.operators_by_actor.values() {
1203        match operator_set.len() {
1204            0 => {}
1205            1 => {
1206                operators
1207                    .entry(operator_set.first().expect("one operator").clone())
1208                    .or_default()
1209                    .agent_keys += 1;
1210            }
1211            _ => ambiguous_agent_keys += 1,
1212        }
1213    }
1214
1215    let active_total = state.active_branches.len();
1216    let mut unknown_active = 0usize;
1217    let mut ambiguous_active = 0usize;
1218    for attribution in state.active_branches.values() {
1219        match resolve_attribution(attribution, bindings) {
1220            AttributionResolution::Operator(operator) => {
1221                operators.entry(operator).or_default().active_branches += 1;
1222            }
1223            AttributionResolution::Unknown => unknown_active += 1,
1224            AttributionResolution::Ambiguous => ambiguous_active += 1,
1225        }
1226    }
1227
1228    let mut protected_total = 0usize;
1229    let mut unknown_protected = 0usize;
1230    let mut ambiguous_protected = 0usize;
1231    if protected.available {
1232        for (name, by_attribution) in &state.ref_updates {
1233            if !protected.matches(name) {
1234                continue;
1235            }
1236            for (attribution, count) in by_attribution {
1237                protected_total += count;
1238                match resolve_attribution(attribution, bindings) {
1239                    AttributionResolution::Operator(operator) => {
1240                        operators.entry(operator).or_default().protected_updates += count;
1241                    }
1242                    AttributionResolution::Unknown => unknown_protected += count,
1243                    AttributionResolution::Ambiguous => ambiguous_protected += count,
1244                }
1245            }
1246        }
1247    }
1248
1249    let mut any_tripwire = false;
1250    let operator_rows: BTreeMap<String, serde_json::Value> = operators
1251        .into_iter()
1252        .map(|(operator, row)| {
1253            let agent_keys_tripped = row.agent_keys > T3_MAX_AGENT_KEYS_PER_OPERATOR;
1254            let active_tripped = share_tripped(row.active_branches, active_total);
1255            let protected_tripped = protected
1256                .available
1257                .then(|| share_tripped(row.protected_updates, protected_total));
1258            any_tripwire |=
1259                agent_keys_tripped || active_tripped || protected_tripped.unwrap_or(false);
1260            let protected_updates = protected.available.then_some(row.protected_updates);
1261            let protected_share = protected
1262                .available
1263                .then(|| share_basis_points(row.protected_updates, protected_total));
1264            (
1265                operator,
1266                serde_json::json!({
1267                    "agent_keys": row.agent_keys,
1268                    "active_branches": row.active_branches,
1269                    "active_branch_share_basis_points":
1270                        share_basis_points(row.active_branches, active_total),
1271                    "protected_updates": protected_updates,
1272                    "protected_update_share_basis_points": protected_share,
1273                    "tripwires": {
1274                        "agent_keys": agent_keys_tripped,
1275                        "active_branch_share": active_tripped,
1276                        "protected_update_share": protected_tripped,
1277                    },
1278                }),
1279            )
1280        })
1281        .collect();
1282
1283    let attributed_active = active_total - unknown_active - ambiguous_active;
1284    let attributed_protected = protected_total - unknown_protected - ambiguous_protected;
1285    let evaluation_complete = bindings.available
1286        && bindings.unbound_actors.is_empty()
1287        && ambiguous_agent_keys == 0
1288        && unknown_active == 0
1289        && ambiguous_active == 0
1290        && protected.available
1291        && unknown_protected == 0
1292        && ambiguous_protected == 0;
1293    let tripwire_status = if any_tripwire {
1294        "observed"
1295    } else if evaluation_complete {
1296        "not_observed"
1297    } else {
1298        "indeterminate"
1299    };
1300    serde_json::json!({
1301        "format_version": 1,
1302        "as_of_seq": state.as_of_seq,
1303        "thresholds": {
1304            "max_agent_keys_per_operator": T3_MAX_AGENT_KEYS_PER_OPERATOR,
1305            "max_share_basis_points": T3_MAX_SHARE_PERCENT * 100,
1306            "comparison": "strictly_greater_than",
1307        },
1308        "bindings": {
1309            "configured": bindings.configured,
1310            "available": bindings.available,
1311            "snapshot_hash": bindings.snapshot_hash(),
1312            // Two sources feed this block and one name for both would read
1313            // as more precise than it is. Attribution is what has to be
1314            // replayable: `durable_log` means every operator name below
1315            // came from a sequenced `BindKey`, not from whatever the keys
1316            // file happened to say at read time. The population — which
1317            // keys the node trusts at all — is not in the log and stays
1318            // with the file, which is why coverage can be incomplete even
1319            // when attribution is sound.
1320            "attribution_source": bindings.source.as_str(),
1321            "population_source": "keys_file",
1322        },
1323        "protected_policy": {
1324            "configured": protected.configured,
1325            "available": protected.available,
1326            "snapshot_hash": protected.hash.as_deref(),
1327            "classification": "current_policy",
1328        },
1329        "totals": {
1330            "bound_agent_keys": bindings.available.then_some(bindings.names_by_actor.len()),
1331            "unbound_agent_keys": bindings.available.then_some(bindings.unbound_actors.len()),
1332            "ambiguous_agent_keys": bindings.available.then_some(ambiguous_agent_keys),
1333            "active_branches": active_total,
1334            "attributed_active_branches": attributed_active,
1335            "unattributed_active_branches": unknown_active + ambiguous_active,
1336            "unknown_active_branches": unknown_active,
1337            "ambiguous_active_branches": ambiguous_active,
1338            "active_branch_attribution_coverage_basis_points":
1339                share_basis_points(attributed_active, active_total),
1340            "protected_updates": protected.available.then_some(protected_total),
1341            "attributed_protected_updates":
1342                protected.available.then_some(attributed_protected),
1343            "unattributed_protected_updates": protected
1344                .available
1345                .then_some(unknown_protected + ambiguous_protected),
1346            "unknown_protected_updates": protected.available.then_some(unknown_protected),
1347            "ambiguous_protected_updates":
1348                protected.available.then_some(ambiguous_protected),
1349            "protected_update_attribution_coverage_basis_points": protected
1350                .available
1351                .then(|| share_basis_points(attributed_protected, protected_total)),
1352        },
1353        "operators": operator_rows,
1354        "tripwire_observed": any_tripwire,
1355        "tripwire_status": tripwire_status,
1356        "evaluation_complete": evaluation_complete,
1357        "semantics": {
1358            "active_branches": "current branch refs grouped by last attributable mover, not ownership",
1359            "protected_updates": "admitted non-creation ref updates matching the current protected policy, not proof of Git publication or merge commits",
1360        },
1361    })
1362}
1363
1364/// Version of the operator-written review adjudication rows. Like the
1365/// newcomer adjudications, this file is not part of the signed op log but is
1366/// a persisted measurement input, so it carries the same explicit version.
1367const REVIEW_ADJUDICATION_FORMAT_VERSION: u64 = 1;
1368
1369/// One operator judgement about a cohort contribution.
1370///
1371/// `Invalid` and `Slop` are deliberately distinct. Invalid is good-faith and
1372/// wrong, which is what newcomers do constantly and must not by itself trip a
1373/// Sybil wire. Slop is unresponsive work that burns reviewer time, which is
1374/// what the cited curl bands are actually about. Collapsing them is how a
1375/// competent newcomer having a bad week reads as an attack.
1376#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1377enum Classification {
1378    Valid,
1379    Invalid,
1380    Slop,
1381    Unclear,
1382}
1383
1384impl Classification {
1385    fn parse(raw: &str) -> Option<Self> {
1386        match raw {
1387            "valid" => Some(Self::Valid),
1388            "invalid" => Some(Self::Invalid),
1389            "slop" => Some(Self::Slop),
1390            "unclear" => Some(Self::Unclear),
1391            _ => None,
1392        }
1393    }
1394}
1395
1396/// Reads the operator's review classifications. Keyed by review id, so a
1397/// classification survives archiving even though the verdict bulk does not.
1398///
1399/// A row naming an unknown review is an error rather than a skipped line: a
1400/// typo that silently vanished would quietly shrink measured coverage.
1401fn read_review_adjudications(
1402    path: &std::path::Path,
1403    reviews: &BTreeMap<String, choir_view::ReviewState>,
1404) -> Result<(BTreeMap<String, Classification>, String), String> {
1405    let bytes = std::fs::read(path).map_err(|e| format!("read review adjudications: {e}"))?;
1406    let snapshot_hash = ContentHash::blake3(&bytes).to_hex();
1407    let text =
1408        String::from_utf8(bytes).map_err(|_| "review adjudications are not UTF-8".to_string())?;
1409    let mut rows = BTreeMap::new();
1410    for (index, line) in text.lines().enumerate() {
1411        if line.trim().is_empty() {
1412            continue;
1413        }
1414        let value: serde_json::Value = serde_json::from_str(line)
1415            .map_err(|e| format!("review adjudication line {}: {e}", index + 1))?;
1416        if value["format_version"].as_u64() != Some(REVIEW_ADJUDICATION_FORMAT_VERSION) {
1417            return Err(format!(
1418                "review adjudication line {} has unsupported format_version",
1419                index + 1
1420            ));
1421        }
1422        let review_id = value["review_id"]
1423            .as_str()
1424            .ok_or_else(|| format!("review adjudication line {} needs review_id", index + 1))?;
1425        let classification = value["classification"]
1426            .as_str()
1427            .and_then(Classification::parse)
1428            .ok_or_else(|| {
1429                format!(
1430                    "review adjudication line {} needs classification valid|invalid|slop|unclear",
1431                    index + 1
1432                )
1433            })?;
1434        if !reviews.contains_key(review_id) {
1435            return Err(format!(
1436                "review adjudication line {} references an unknown review",
1437                index + 1
1438            ));
1439        }
1440        if rows.insert(review_id.to_string(), classification).is_some() {
1441            return Err(format!(
1442                "review adjudication line {} duplicates a review",
1443                index + 1
1444            ));
1445        }
1446    }
1447    Ok((rows, snapshot_hash))
1448}
1449
1450/// The T2 cohort: durable, post-activation, non-incumbent actor keys, read
1451/// from the same T4 audit that defines "newcomer" everywhere else.
1452///
1453/// `None` means no cohort is defined — the audit is off, unreadable, or was
1454/// never activated — which is deliberately different from an empty cohort.
1455/// An empty cohort is the honest statement "no newcomers yet"; `None` is
1456/// "this node cannot tell you who is new".
1457fn new_actor_cohort(audit: Option<&Arc<Mutex<NewcomerAudit>>>) -> Option<BTreeSet<String>> {
1458    let audit = audit?.lock().expect("newcomer audit lock");
1459    (audit.available && audit.activated).then(|| audit.by_actor.keys().cloned().collect())
1460}
1461
1462/// D24 T2 evidence for the new-actor cohort, and an explicit statement that
1463/// the tripwire itself is **not evaluable here**.
1464///
1465/// T2 asks for an invalid/slop rate. Choir persists no such judgement.
1466/// [`choir_view::Verdict::Approve`] means "may land" and
1467/// [`choir_view::Verdict::RequestChanges`] means "needs work", the latter is
1468/// overwritable by the former, and neither proves a contribution valid,
1469/// invalid, or slop. Relabelling one as "slop" would manufacture evidence the
1470/// model does not contain.
1471///
1472/// The unit is a **contribution offered**, not a completed review. A review is
1473/// opened by its own contributor, so a review-shaped denominator lets an actor
1474/// choose whether to be measured: submit a hundred slop changes, request
1475/// review on the three good ones, and a review-based rate reads zero. Reviews
1476/// remain a reported sub-metric.
1477///
1478/// Three counting rules exist to close laundering paths rather than to be
1479/// tidy. An archived review with no classification can never enter the
1480/// numerator or the denominator, because `--review-lapse-after-secs` turns an
1481/// unanswered review into `Archived { approved: false }` on a wall clock and
1482/// that must not become evidence. Pending reviews are reported, never counted.
1483/// And `load` needs no adjudication at all, so it keeps measuring during the
1484/// flood in which a classifier's coverage would collapse to `indeterminate`.
1485///
1486/// Requester attribution is joined from the `RequestReview` log entry's author
1487/// signature, never from the rendered review: archiving discards reviewer and
1488/// verdict detail, so a rendered row cannot supply historical evidence.
1489/// A `RequestReview` author is also not proven to have authored the commit
1490/// under review; that limitation ships in the response.
1491fn new_actor_review_outcomes_json(
1492    state: &ConcentrationState,
1493    view: &View,
1494    cohort: Option<&BTreeSet<String>>,
1495    adjudications_path: Option<&std::path::Path>,
1496) -> serde_json::Value {
1497    let adjudications =
1498        adjudications_path.map(|path| read_review_adjudications(path, &view.reviews));
1499    let (classifications, snapshot_hash, adjudications_available, adjudications_error) =
1500        match &adjudications {
1501            None => (BTreeMap::new(), None, false, None),
1502            Some(Ok((rows, hash))) => (rows.clone(), Some(hash.clone()), true, None),
1503            Some(Err(error)) => (BTreeMap::new(), None, false, Some(error.clone())),
1504        };
1505
1506    let mut unknown_requester = 0usize;
1507    let mut unsigned_author = 0usize;
1508    let mut signed = 0usize;
1509    let mut attributed = 0usize;
1510    let mut approved = 0usize;
1511    let mut request_changes = 0usize;
1512    let mut pending = 0usize;
1513    let mut archived_classified = 0usize;
1514    let mut archived_evidence_lost = 0usize;
1515    let mut slashed = 0usize;
1516    let mut eligible = 0usize;
1517    let mut classified: BTreeMap<&'static str, usize> = BTreeMap::new();
1518    let mut review_rounds = 0usize;
1519    let mut review_rounds_unlanded = 0usize;
1520    let mut landed = 0usize;
1521    for (id, review) in &view.reviews {
1522        let Some(evidence) = state.review_requesters.get(id) else {
1523            unknown_requester += 1;
1524            continue;
1525        };
1526        let Some(key_id) = evidence.key_id.as_deref() else {
1527            unsigned_author += 1;
1528            continue;
1529        };
1530        signed += 1;
1531        let Some(cohort) = cohort else { continue };
1532        if !cohort.contains(key_id) {
1533            continue;
1534        }
1535        attributed += 1;
1536        if review.re_review_required() {
1537            slashed += 1;
1538        }
1539        let rounds = state
1540            .new_actor_load
1541            .verdict_rounds
1542            .get(id)
1543            .copied()
1544            .unwrap_or_default();
1545        review_rounds += rounds;
1546        if state.new_actor_load.landed.contains(id) {
1547            landed += 1;
1548        } else {
1549            review_rounds_unlanded += rounds;
1550        }
1551        let classification = classifications.get(id).copied();
1552        let counts_toward_rate = if matches!(review.status, ReviewStatus::Archived { .. }) {
1553            // Archiving destroys the verdicts an adjudicator needs, and a
1554            // lapse archives a review nobody ever answered. Without a
1555            // standing classification such a row is evidence of nothing.
1556            if classification.is_some() {
1557                archived_classified += 1;
1558                true
1559            } else {
1560                archived_evidence_lost += 1;
1561                false
1562            }
1563        } else if !review.complete() {
1564            pending += 1;
1565            false
1566        } else if review
1567            .verdicts
1568            .values()
1569            .any(|answer| answer.verdict == Verdict::RequestChanges)
1570        {
1571            request_changes += 1;
1572            true
1573        } else {
1574            approved += 1;
1575            true
1576        };
1577        if counts_toward_rate {
1578            eligible += 1;
1579            if let Some(classification) = classification {
1580                *classified
1581                    .entry(match classification {
1582                        Classification::Valid => "valid",
1583                        Classification::Invalid => "invalid",
1584                        Classification::Slop => "slop",
1585                        Classification::Unclear => "unclear",
1586                    })
1587                    .or_default() += 1;
1588            }
1589        }
1590    }
1591
1592    let available = cohort.is_some();
1593    let submissions: usize = cohort
1594        .map(|cohort| {
1595            cohort
1596                .iter()
1597                .filter_map(|key| state.new_actor_load.submissions.get(key))
1598                .sum()
1599        })
1600        .unwrap_or_default();
1601    let adjudicated: usize = classified.values().sum();
1602    let classified_rows: BTreeMap<String, usize> = ["valid", "invalid", "slop", "unclear"]
1603        .into_iter()
1604        .map(|name| {
1605            (
1606                name.to_string(),
1607                classified.get(name).copied().unwrap_or_default(),
1608            )
1609        })
1610        .collect();
1611    serde_json::json!({
1612        "format_version": 2,
1613        "as_of_seq": state.as_of_seq,
1614        "unit": "contribution_offered",
1615        "cohort": {
1616            "definition": "post_activation_non_incumbent_actor_keys",
1617            "source": "newcomer_harm audit",
1618            "available": available,
1619            "actor_keys": cohort.map(BTreeSet::len),
1620        },
1621        "policy": {
1622            "graduation": null,
1623            "trailing_window": null,
1624            "tripwire_subject": null,
1625            "sampling": "census",
1626            "status": "unset_pending_operator_decision",
1627        },
1628        "load": available.then(|| serde_json::json!({
1629            "accepted_operations": submissions,
1630            "reviews_requested": attributed,
1631            "review_rounds": review_rounds,
1632            "review_rounds_on_unlanded": review_rounds_unlanded,
1633            "reviews_landed": landed,
1634            "reviews_never_landed": attributed - landed,
1635        })),
1636        "declared_tripwire": {
1637            "max_invalid_or_slop_percent": T2_MAX_INVALID_OR_SLOP_PERCENT,
1638            "min_valid_percent": T2_MIN_VALID_PERCENT,
1639            "evaluable": false,
1640            "blocked_on": "cohort exit, observation window and tripwire subject are unset, and census adjudication cannot survive a flood",
1641        },
1642        "totals": {
1643            "reviews": view.reviews.len(),
1644            "attributed_to_cohort": available.then_some(attributed),
1645            "excluded_outside_cohort": available.then(|| signed - attributed),
1646            "excluded_unsigned_author": unsigned_author,
1647            "excluded_unknown_requester": unknown_requester,
1648        },
1649        "review_outcomes": available.then(|| serde_json::json!({
1650            "approved": approved,
1651            "request_changes": request_changes,
1652            "pending": pending,
1653            "archived_classified": archived_classified,
1654            "archived_evidence_lost": archived_evidence_lost,
1655            "re_review_required": slashed,
1656        })),
1657        "adjudication": {
1658            "configured": adjudications_path.is_some(),
1659            "available": adjudications_available,
1660            "snapshot_hash": snapshot_hash,
1661            "error": adjudications_error,
1662            "eligible": available.then_some(eligible),
1663            "adjudicated": available.then_some(adjudicated),
1664            "coverage_basis_points": available.then(|| share_basis_points(adjudicated, eligible)),
1665            "classified": available.then_some(classified_rows),
1666            "independent": false,
1667        },
1668        "tripwire_observed": null,
1669        "tripwire_status": "indeterminate",
1670        "evaluation_complete": false,
1671        "semantics": {
1672            "unit": "a contribution offered by a cohort key; reviews are a sub-metric because the contributor opens their own review and can decline to",
1673            "accepted_operations": "every accepted signed operation authored by a cohort key, review participation included; counted because work that never reaches review is invisible to a review-shaped denominator",
1674            "load": "objective and adjudication-free, so it keeps measuring when a classifier's coverage collapses; a rate alone cannot see a flood",
1675            "approved": "live, every listed reviewer answered, and no verdict is RequestChanges — not a validity judgement",
1676            "request_changes": "live, every listed reviewer answered, and at least one standing verdict is RequestChanges — 'needs work', overwritable, not slop",
1677            "pending": "live and unassigned or not yet answered by every listed reviewer; reported, never counted",
1678            "archived_evidence_lost": "archived with no standing classification; a lapse archives an unanswered review on a wall clock, so it is excluded from both numerator and denominator",
1679            "re_review_required": "overlaps the buckets above; counts cohort reviews carrying a retroactive approval slash",
1680            "attribution": "the RequestReview log entry's signing key; that author is not proven to have authored the commit under review",
1681            "independent": "a single-operator node classifies contributions its own reviewers approved; this is self-adjudication, not an independent judgement",
1682            "indeterminate": "structural: the cohort has no exit, the window and tripwire subject are unset, and census adjudication goes blind in the flood it should detect",
1683        },
1684    })
1685}
1686
1687/// Deterministic read-time measurement of the complete authoritative view.
1688///
1689/// The serialized total deliberately contains only the four sections owned by
1690/// `View`. Runtime projections are excluded so adding this report cannot make
1691/// its own byte count grow recursively, and adding another projection later
1692/// cannot rewrite the historical meaning of the measurement.
1693fn view_growth_json(
1694    counts: serde_json::Value,
1695    workspaces: &serde_json::Value,
1696    refs: &serde_json::Value,
1697    reviews: &serde_json::Value,
1698    provenance: &serde_json::Value,
1699    measured: &[(&str, &serde_json::Value)],
1700    as_of_seq: Option<u64>,
1701) -> serde_json::Value {
1702    let serialized_bytes = |value: &serde_json::Value| {
1703        serde_json::to_vec(value)
1704            .expect("materialized view JSON is always serializable")
1705            .len()
1706    };
1707    // `measured` is deliberately absent from the total below. The four
1708    // sections passed by name are the authoritative view and
1709    // `total_authoritative_view` is a tracked series; folding a fifth
1710    // section in would move every past reading and destroy comparability.
1711    // They are still *measured* — see the sibling byte counts — because a
1712    // map nobody counts is how a view grows without anyone noticing.
1713    //
1714    // A slice rather than one argument each, so the next section that
1715    // has to be watched without being totalled costs a call site and not
1716    // a signature. The two shapes are not interchangeable: an argument
1717    // added to the authoritative four changes what a historical number
1718    // means, and this one cannot.
1719    let authoritative = serde_json::json!({
1720        "workspaces": workspaces,
1721        "refs": refs,
1722        "reviews": reviews,
1723        "provenance": provenance,
1724    });
1725    let mut bytes = serde_json::json!({
1726        "workspaces": serialized_bytes(workspaces),
1727        "refs": serialized_bytes(refs),
1728        "reviews": serialized_bytes(reviews),
1729        "provenance": serialized_bytes(provenance),
1730        "total_authoritative_view": serialized_bytes(&authoritative),
1731    });
1732    for (name, value) in measured {
1733        bytes[*name] = serde_json::json!(serialized_bytes(value));
1734    }
1735    serde_json::json!({
1736        "format_version": 1,
1737        "as_of_seq": as_of_seq,
1738        "counts": counts,
1739        "serialized_bytes": bytes,
1740    })
1741}
1742
1743fn view_growth_counts(view: &View) -> serde_json::Value {
1744    let live_reviews = view
1745        .reviews
1746        .values()
1747        .filter(|review| matches!(review.status, ReviewStatus::Live))
1748        .count();
1749    let provenance_records = view.provenance.values().map(BTreeMap::len).sum::<usize>();
1750    // Revoked bindings are counted, never subtracted: the row survives
1751    // revocation, so a `bindings` count that dropped on revoke would
1752    // understate the map it is meant to size.
1753    let revoked_bindings = view
1754        .bindings
1755        .values()
1756        .filter(|binding| binding.is_revoked())
1757        .count();
1758    // Edges, not subjects: the outer map is what paging bounds, and the
1759    // inner one is where the graph actually grows. Counting rows would
1760    // report one number for an operator with a single vouch and for one
1761    // every operator on the node stands behind.
1762    let vouch_edges = view.vouches.values().map(BTreeMap::len).sum::<usize>();
1763    // One row per witness and no more, which is the claim worth
1764    // measuring: this section is bounded by the witness population, not
1765    // by how many snapshots the node has taken.
1766    let witnesses = view.witnessed.len();
1767    let witnesses_current = view.latest_snapshot.as_ref().map_or(0, |latest| {
1768        let id = latest.id();
1769        view.witnessed
1770            .values()
1771            .filter(|state| state.snapshot == id)
1772            .count()
1773    });
1774    serde_json::json!({
1775        "workspaces": view.workspaces.len(),
1776        "refs": view.refs.len(),
1777        "reviews": view.reviews.len(),
1778        "live_reviews": live_reviews,
1779        "archived_reviews": view.reviews.len() - live_reviews,
1780        "provenance_subjects": view.provenance.len(),
1781        "provenance_records": provenance_records,
1782        "bindings": view.bindings.len(),
1783        "revoked_bindings": revoked_bindings,
1784        "vouch_subjects": view.vouches.len(),
1785        "vouch_edges": vouch_edges,
1786        "witnesses": witnesses,
1787        "witnesses_current": witnesses_current,
1788    })
1789}
1790
1791/// Nanosecond clock reading, as a nonzero xorshift seed.
1792fn seed_from_clock() -> u64 {
1793    std::time::SystemTime::now()
1794        .duration_since(std::time::UNIX_EPOCH)
1795        .map_or(0x9e37_79b9_7f4a_7c15, |d| d.as_nanos() as u64)
1796        | 1
1797}
1798
1799/// FNV-1a, so two reviews drawn in the same nanosecond still diverge.
1800fn fnv1a(bytes: &[u8]) -> u64 {
1801    bytes.iter().fold(0xcbf2_9ce4_8422_2325, |h, b| {
1802        (h ^ u64::from(*b)).wrapping_mul(0x100_0000_01b3)
1803    })
1804}
1805
1806impl LogWindow {
1807    fn push(&mut self, entry: OpEntry, hash: ContentHash) {
1808        // `signing_hash` covers only (channel, payload); `content_hash`
1809        // would serialize the whole entry, and this runs per admitted op
1810        // -- which is why `hash` is passed in by the sequencer that
1811        // already computed it rather than derived here.
1812        self.by_signing.insert(entry.signing_hash(), entry.seq);
1813        self.by_hash.insert(hash, entry.seq);
1814        self.entries.push_back(entry);
1815        self.trim();
1816    }
1817
1818    /// Whether `head` is an entry still inside the window, which is what
1819    /// makes an op signed against it admissible.
1820    fn holds_head(&self, head: &ContentHash) -> bool {
1821        self.by_hash.contains_key(head)
1822    }
1823
1824    /// The head a client should sign its next scope against.
1825    ///
1826    /// Derived rather than stored: keeping it would mean cloning a hash
1827    /// on every admitted op to serve a value only read by `/api/view`
1828    /// and by the ops the daemon signs itself. `None` for an empty
1829    /// window, which is also a window that would admit no head.
1830    fn head_hash(&self) -> Option<ContentHash> {
1831        self.entries.back().map(OpEntry::content_hash)
1832    }
1833
1834    /// The seq an identical submission already landed as, if any. The
1835    /// caller has usually just computed the signing hash for the
1836    /// signature check, so it is taken rather than recomputed.
1837    fn seq_for_signing(&self, signing: &ContentHash) -> Option<u64> {
1838        self.by_signing.get(signing).copied()
1839    }
1840
1841    /// The `(seq, hash)` an identical submission already landed as.
1842    ///
1843    /// Keyed on the **signing** hash, not the entry hash: an entry's hash
1844    /// covers `seq` and `parent`, which the sequencer assigns after the
1845    /// author signs, so a resubmitted identical op hashes differently
1846    /// every time and could never match. `signing_hash(channel,
1847    /// payload)` is position-independent by construction, which is
1848    /// exactly the identity "the same submission" needs.
1849    fn already_applied(&self, channel: &str, payload: &[u8]) -> Option<(u64, ContentHash)> {
1850        let seq = *self
1851            .by_signing
1852            .get(&choir_oplog::signing_hash(channel, payload))?;
1853        // Hash the entry only on a hit, which is a client retry rather
1854        // than the common path.
1855        let entry = self.entries.get(seq.checked_sub(self.base)? as usize)?;
1856        Some((seq, entry.content_hash()))
1857    }
1858
1859    /// Drops the oldest entries until the window fits its cap, advancing
1860    /// `base` so `/api/log?from=` keeps absolute sequence semantics.
1861    fn trim(&mut self) {
1862        while self.entries.len() > self.cap {
1863            if let Some(dropped) = self.entries.pop_front() {
1864                // Evict from the index with the entry, or the map becomes
1865                // the unbounded thing the window exists to avoid.
1866                self.by_signing.remove(&dropped.signing_hash());
1867                // The dropped entry's own hash is the new front's
1868                // `parent`, so its eviction costs a lookup instead of a
1869                // re-serialization. An emptied window holds no heads at
1870                // all, which is the only case with no successor to ask.
1871                match self.entries.front().and_then(|e| e.parent.as_ref()) {
1872                    Some(parent) => {
1873                        self.by_hash.remove(parent);
1874                    }
1875                    None => self.by_hash.clear(),
1876                }
1877            }
1878            self.base += 1;
1879        }
1880    }
1881}
1882
1883/// Replays the platform's runtime projections in one pass. Concentration
1884/// attribution needs the entry author as well as the typed payload, while
1885/// review retention additionally needs request order. Neither belongs in
1886/// the persisted `ViewOp` format, and neither justifies a second log scan.
1887fn materialize_platform_state(
1888    log: &dyn OpLog,
1889    retention_config: Option<ReviewRetention>,
1890) -> Result<
1891    (
1892        View,
1893        Option<ReviewRetentionState>,
1894        ConcentrationState,
1895        crate::quota::WorkspaceTally,
1896    ),
1897    choir_view::ViewError,
1898> {
1899    let mut view = View::default();
1900    let mut retention = retention_config.map(ReviewRetentionState::new);
1901    let mut concentration = ConcentrationState::default();
1902    // D37. Folded in this loop rather than in a pass of its own, which is
1903    // the whole reason a per-user workspace ceiling needs no persisted
1904    // file: the log a restart already replays is the tally's storage.
1905    let mut workspace_tally = crate::quota::WorkspaceTally::default();
1906    // Stored entries have no timestamp. Giving every pre-existing live
1907    // review `now` starts a fresh grace period after restart, which can
1908    // delay an incomplete-review lapse but can never trigger one early.
1909    let observed_at =
1910        retention_config.and_then(|config| config.lapse_after.map(|_| Instant::now()));
1911    for seq in 0..log.len() {
1912        let entry = log.get(seq).expect("seq < len");
1913        let op = ViewOp::from_payload(&entry.payload)?;
1914        view.apply(&op)?;
1915        concentration.observe(&entry, &op, &view);
1916        workspace_tally.observe(&entry, &op);
1917        if let Some(retention) = &mut retention {
1918            retention.observe(&op, observed_at);
1919        }
1920    }
1921    Ok((view, retention, concentration, workspace_tally))
1922}
1923
1924/// Verify author signature, then CAS against the shared view. Runs on
1925/// the sequencer's writer thread; API readers share the view mutex.
1926/// The op variant's name, from its own externally-tagged serialization
1927/// rather than a hand-written match: a variant added later journals
1928/// correctly without anyone remembering to extend a table here.
1929fn op_type_name(op: &ViewOp) -> Option<String> {
1930    serde_json::to_value(&op.kind)
1931        .ok()
1932        .and_then(|v| v.as_object().and_then(|o| o.keys().next().cloned()))
1933}
1934
1935/// A journal slot the builder fills after the writer thread is running.
1936///
1937/// [`Sequencer::spawn_with_journal`] takes its journal by value at
1938/// construction, but every `with_*` builder runs afterwards, so the
1939/// value handed to the writer has to be a slot rather than a journal.
1940/// Same shape as the hot-reloadable config beside it.
1941///
1942/// `on` is separate from the lock on purpose: [`journal::Journal::enabled`]
1943/// is consulted once per op on the writer thread, and a node with no
1944/// journal should pay one relaxed atomic load, not a mutex acquisition.
1945#[derive(Clone, Default)]
1946struct SharedJournal {
1947    inner: Arc<Mutex<Option<Box<dyn journal::Journal>>>>,
1948    on: Arc<std::sync::atomic::AtomicBool>,
1949}
1950
1951impl SharedJournal {
1952    /// Installs `journal`, and switches recording on.
1953    fn install(&self, journal: Box<dyn journal::Journal>) {
1954        if let Ok(mut slot) = self.inner.lock() {
1955            *slot = Some(journal);
1956            self.on.store(true, std::sync::atomic::Ordering::Relaxed);
1957        }
1958    }
1959}
1960
1961impl journal::Journal for SharedJournal {
1962    fn record(&self, event: journal::Event) {
1963        if !self.enabled() {
1964            return;
1965        }
1966        if let Ok(slot) = self.inner.lock() {
1967            if let Some(journal) = slot.as_ref() {
1968                journal.record(event);
1969            }
1970        }
1971    }
1972
1973    fn enabled(&self) -> bool {
1974        self.on.load(std::sync::atomic::Ordering::Relaxed)
1975    }
1976}
1977
1978struct ChoirPolicy {
1979    /// Set when this node is a seed (D80). Every submission is then
1980    /// refused before anything else is asked: a seed's log is written by
1981    /// its replicator alone, through [`SubmitPolicy::replicable`].
1982    home: Arc<std::sync::OnceLock<crate::replica::Home>>,
1983    /// Where this policy reports CAS failures. The sequencer cannot:
1984    /// a refusal reaches it as an opaque string, so contention and
1985    /// nonsense look identical from there.
1986    journal: SharedJournal,
1987    /// What the last `check` identified, handed to the journal through
1988    /// [`SubmitPolicy::subject`] rather than derived a second time.
1989    subject: (Option<String>, Option<String>),
1990    registry: Registry,
1991    view: Arc<Mutex<View>>,
1992    entries: Arc<Mutex<LogWindow>>,
1993    /// When set, the trusted-keys file is checked before every signature;
1994    /// registering or removing a key takes effect on its next submission.
1995    keys_file: Option<std::path::PathBuf>,
1996    keys_mtime: Option<std::time::SystemTime>,
1997    node_pub: Vec<u8>,
1998    /// The node key's actor id: the only author allowed to assign reviewers.
1999    node_id: ContentHash,
2000    /// When set, a `RequestReview` may not name its own reviewers —
2001    /// every review must go through the node's draw (D24 layer 5).
2002    /// Shared with [`Platform`] so the switch is one value, not two.
2003    require_assignment: Arc<std::sync::atomic::AtomicBool>,
2004    /// Operator's protected-ref list: the same switch, but conditioned on
2005    /// where the review proposes to land. Shared with [`Platform`].
2006    protected_refs: Arc<Mutex<Option<std::path::PathBuf>>>,
2007    /// The operator's ACL file, read here for [`crate::acl::Level::Own`]
2008    /// grants alone (D42).
2009    ///
2010    /// Deliberately the *file*, not the merged table the HTTP layer
2011    /// enforces. Self-service (D36) contributes grants to that merge, and
2012    /// ownership authorizes a landing on a protected ref, so reading the
2013    /// merge would make ownership reachable by whatever self-service can
2014    /// issue. Reading the file makes "only the operator grants ownership"
2015    /// true by construction rather than by auditing the issuer.
2016    acl_file: Arc<Mutex<Option<std::path::PathBuf>>>,
2017    /// When set, a protected ref only moves to a commit some approved
2018    /// review already named — the landing half of the gate.
2019    require_review: Arc<std::sync::atomic::AtomicBool>,
2020    /// When set, every submission must carry a [`choir_view::OpScope`]
2021    /// naming this node and a head still in the window. Shared with
2022    /// [`Platform`], like the other gates.
2023    require_scope: Arc<std::sync::atomic::AtomicBool>,
2024    /// Actor id → the channel name that key is bound to,
2025    /// for keys whose trusted-keys line carries a name. Keys absent from
2026    /// this map are unconstrained, which is what every key was before the
2027    /// name column existed.
2028    ///
2029    /// Shared with [`Platform`], because a *tightening* must not wait for
2030    /// an unrelated event: the accept loop refreshes it on mtime change,
2031    /// while submission admission refreshes the signature registry too.
2032    key_names: Arc<Mutex<KeyBindings>>,
2033    /// Entry-author-aware runtime projection for D24 T3. Updated on the
2034    /// writer thread immediately after the ordinary view fold.
2035    concentration: Arc<Mutex<ConcentrationState>>,
2036    /// Present only when the operator enabled review retention. Updated
2037    /// after the view fold on the same writer thread, so its FIFO order
2038    /// matches the sequencer order exactly.
2039    review_retention: Option<Arc<Mutex<ReviewRetentionState>>>,
2040    /// The credential store, when the node has one (D39). Shared with
2041    /// [`Platform`] as a slot rather than taken at construction because
2042    /// the two are enabled independently and in either order; a node
2043    /// without accounts simply has no passkey authors.
2044    passkeys: Arc<Mutex<Option<Arc<crate::accounts::Accounts>>>>,
2045    /// Webhook delivery handle (D32), shared with [`Platform`] so the
2046    /// operator's `--hooks-file` can be attached after the writer thread
2047    /// is already running. Offering an event to it never blocks: see
2048    /// [`crate::hooks`] for why invariant 5 forbids anything else here.
2049    hooks: Arc<Mutex<Option<crate::hooks::Hooks>>>,
2050    /// Follower pushes (D21), shared the same way; `None` without
2051    /// `--followers`.
2052    followers: Arc<Mutex<Option<crate::followers::Followers>>>,
2053    /// Which channel holds which workspace (D37). Folded here for the
2054    /// reason `concentration` is: it needs the entry's channel as well as
2055    /// the typed payload, and it must advance in the same single-writer
2056    /// step as the view or the two can disagree about what exists.
2057    workspace_tally: Arc<Mutex<crate::quota::WorkspaceTally>>,
2058}
2059
2060impl ChoirPolicy {
2061    /// Rebuilds the registry from the keys file iff its mtime changed
2062    /// since the last (re)load. Returns whether a reload happened.
2063    fn reload_keys(&mut self) -> bool {
2064        let Some(path) = &self.keys_file else {
2065            return false;
2066        };
2067        let mtime = std::fs::metadata(path).and_then(|m| m.modified()).ok();
2068        if mtime.is_none() || mtime == self.keys_mtime {
2069            return false;
2070        }
2071        // Same parser startup uses, so the two cannot drift — and a
2072        // malformed file keeps the previous registry *and* the previous
2073        // name bindings rather than half-applying either.
2074        let Ok(signers) = crate::parse_keys_file(path) else {
2075            self.keys_mtime = mtime;
2076            return false;
2077        };
2078        let mut registry = Registry::new();
2079        if let Ok(node_pub) = <[u8; 32]>::try_from(self.node_pub.as_slice()) {
2080            registry.register(&node_pub).ok();
2081        }
2082        for signer in &signers {
2083            registry.register(&signer.key).ok();
2084        }
2085        self.registry = registry;
2086        *self.key_names.lock().expect("key names lock") = KeyBindings::from_signers(&signers);
2087        self.keys_mtime = mtime;
2088        true
2089    }
2090
2091    /// Enforces the name a key is bound to, for ops whose submission
2092    /// channel is an identity claim rather than a workspace name.
2093    ///
2094    /// Scope is deliberately narrow. `sub.channel` is the
2095    /// signature-covered attribution/identity channel, not a workspace id.
2096    /// The v1 wire field was named `workspace`; the daemon also uses
2097    /// synthetic push-attribution channels
2098    /// (`git/<user>`, `key/<principal>`). Binding every channel would
2099    /// break workspace provisioning and every git-derived op.
2100    ///
2101    /// A key with no bound name is unconstrained — exactly its behaviour
2102    /// before the name column existed — so this cannot break a running
2103    /// node, and the operator opts in one line at a time.
2104    fn channel_is_owned(&self, actor_id: &ContentHash, channel: &str) -> Result<(), String> {
2105        let names = self.key_names.lock().expect("key names lock");
2106        match names.bound_name(actor_id) {
2107            Some(bound) if bound != channel => Err(Rejection::new(
2108                Code::ChannelNotOwned,
2109                "this key is bound to a different channel",
2110                "submit on the channel your key is bound to, or ask the operator to bind a \
2111                 key to the channel you want",
2112            )
2113            .with_states(Some(bound.to_string()), Some(channel.to_string()))
2114            .encode()),
2115            _ => Ok(()),
2116        }
2117    }
2118
2119    /// Whether `name` matches the operator's protected-ref list. One
2120    /// pattern per line, `#` comments allowed, exact match or a single
2121    /// trailing `*` prefix glob (`repo.git:refs/heads/release/*`).
2122    ///
2123    /// Read per call rather than cached, so editing the list takes effect
2124    /// with no restart. A `RequestReview` always reaches here; a
2125    /// `SetRef`/`DeleteRef` only under `--require-review`, which is the
2126    /// mode that also puts a file read on the git-push path. If push
2127    /// throughput ever notices, an mtime-stat cache is the fix — measure
2128    /// before adding one.
2129    ///
2130    /// Fails **closed**: an unreadable list refuses the op instead of
2131    /// quietly demoting a gate to an advisory.
2132    fn ref_is_protected(&self, name: &str) -> Result<bool, String> {
2133        let guard = self.protected_refs.lock().expect("protected refs lock");
2134        let Some(path) = guard.as_ref() else {
2135            return Ok(false);
2136        };
2137        let text = std::fs::read_to_string(path).map_err(|e| {
2138            Rejection::new(
2139                Code::PolicyUnavailable,
2140                format!("protected-ref list unreadable: {e}"),
2141                "this is an operator problem, not a client one: the gate fails closed \
2142                     rather than guessing. Retry after the operator restores the file",
2143            )
2144            .encode()
2145        })?;
2146        Ok(text.lines().map(str::trim).any(|p| {
2147            if p.is_empty() || p.starts_with('#') {
2148                return false;
2149            }
2150            match p.strip_suffix('*') {
2151                Some(prefix) => name.starts_with(prefix),
2152                None => name == p,
2153            }
2154        }))
2155    }
2156
2157    /// The greatest capped approval weight of any approved review that
2158    /// named exactly this `(ref, commit)` pair as where it wanted to land.
2159    ///
2160    /// Both halves matter. Matching only the commit would let an approval
2161    /// for a scratch branch land the same commit on `main`; matching only
2162    /// the ref would let any approved review authorize any later commit.
2163    fn approval_weight_for(&self, name: &str, commit: &ContentHash) -> usize {
2164        self.view
2165            .lock()
2166            .expect("view lock")
2167            .reviews
2168            .values()
2169            .filter(|r| {
2170                r.target_ref.as_deref() == Some(name)
2171                    && r.target.as_ref() == Some(commit)
2172                    && r.approved()
2173            })
2174            .map(choir_view::ReviewState::approval_weight)
2175            .max()
2176            .unwrap_or(0)
2177    }
2178
2179    /// The operator's ACL, parsed, or `None` when no file is configured.
2180    ///
2181    /// Read per call rather than cached, so granting ownership takes
2182    /// effect with no restart — the same trade [`Self::ref_is_protected`]
2183    /// makes, and paid on the same narrow path, since both are reached
2184    /// only for a landing that is already gated.
2185    ///
2186    /// Fails **closed**. An unreadable or malformed file refuses the
2187    /// landing rather than concluding there are no owners, because "no
2188    /// owners" is precisely the branch that falls back to the weaker
2189    /// rule: a gate that loses its policy file must not quietly demote
2190    /// itself to the policy it was configured to replace.
2191    fn acl_now(&self) -> Result<Option<crate::acl::Effective>, String> {
2192        let guard = self.acl_file.lock().expect("acl file lock");
2193        let Some(path) = guard.as_ref() else {
2194            return Ok(None);
2195        };
2196        let text = std::fs::read_to_string(path).map_err(|e| {
2197            Rejection::new(
2198                Code::PolicyUnavailable,
2199                format!("acl file unreadable: {e}"),
2200                "this is an operator problem, not a client one: the ownership gate fails \
2201                 closed rather than guessing. Retry once the operator restores the file",
2202            )
2203            .encode()
2204        })?;
2205        crate::acl::Acl::parse(&text)
2206            .map(|table| Some(table.at(crate::accounts::now_secs())))
2207            .map_err(|e| {
2208                Rejection::new(
2209                    Code::PolicyUnavailable,
2210                    format!("acl file unparseable: {e}"),
2211                    "ask the operator to repair the ACL file; the ownership gate refuses rather \
2212                 than enforcing a table it only partly understands",
2213                )
2214                .encode()
2215            })
2216    }
2217
2218    /// The ACL user this submission acts as, when one can be established
2219    /// from evidence rather than from a claim (D42).
2220    ///
2221    /// Three populations reach admission and only two of them resolve:
2222    ///
2223    /// - **A transport push.** `provenance` is a label only the node may
2224    ///   apply — refused for any other signer earlier in `check` — so a
2225    ///   [`Provenance::PushTransport`] op carries a channel the node
2226    ///   synthesized from the authenticated HTTP user. `git/<user>` is
2227    ///   therefore evidence about who pushed, not a claim by them.
2228    /// - **An author-signed op.** The identity is the verified signing
2229    ///   key, and the operator's trusted-keys name column is what binds a
2230    ///   key to a name. An **unbound key resolves to nobody**: unbound
2231    ///   keys are deliberately unconstrained in their choice of channel,
2232    ///   so honouring the channel there would let any trusted key call
2233    ///   itself an owner.
2234    /// - **A certified push.** The channel is `key/<signer>`, naming the
2235    ///   push certificate's key rather than an ACL user, and the
2236    ///   authenticated user is not carried into the op. Resolves to
2237    ///   nobody, so a `git push --signed` cannot assert ownership; see
2238    ///   the rejection in [`Self::landing_is_authorized`], which says so
2239    ///   rather than reporting a generic refusal.
2240    fn acting_user(&self, sub: &Submission, op: &ViewOp, actor_id: &ContentHash) -> Option<String> {
2241        match op.provenance {
2242            Some(Provenance::PushTransport) => {
2243                sub.channel.strip_prefix("git/").map(ToString::to_string)
2244            }
2245            Some(Provenance::PushCertified) => None,
2246            None => {
2247                let names = self.key_names.lock().expect("key names lock");
2248                names.bound_name(actor_id).map(ToString::to_string)
2249            }
2250        }
2251    }
2252
2253    /// The landing half of the protected-ref gate: may this submission
2254    /// move this protected ref to this commit?
2255    ///
2256    /// Two rules, and which one applies is a property of the *repository*
2257    /// rather than of the actor (D42):
2258    ///
2259    /// - **Somebody owns the repository.** An owner's assent is necessary
2260    ///   and sufficient. No quantity of non-owner approval substitutes,
2261    ///   and no second opinion is required alongside it.
2262    /// - **Nobody owns it.** The pre-D42 rule stands untouched: approval
2263    ///   weight `REQUIRED_APPROVAL_WEIGHT`, from that many distinct
2264    ///   operators.
2265    ///
2266    /// So a node with no `--acl-file`, or one whose ACL grants nobody
2267    /// `own`, behaves exactly as every node did before D42. Ownership is
2268    /// opt-in per repository, and opting one repository in leaves every
2269    /// other one alone.
2270    ///
2271    /// **Returns the authorization rather than a bare yes** (D43). The
2272    /// gate is evaluated at apply time against files that are not in the
2273    /// log, so this function is the only place that knows why a landing
2274    /// was allowed. A [`OpKind::Submit`] records that answer, and the
2275    /// brief's constraint is that the record must come out of the
2276    /// evaluation that admitted the op and not a second pass that
2277    /// recomputes it — two computations can disagree, and a field that
2278    /// can disagree with the decision it describes is decoration. So
2279    /// there is one function, the `SetRef` path discards its value, and
2280    /// the `Submit` path writes it down.
2281    ///
2282    /// `review` narrows the weight rule to a single named review; `None`
2283    /// keeps the pre-D43 behaviour of taking the best of every review
2284    /// naming this `(ref, commit)`.
2285    fn authorization_for(
2286        &self,
2287        name: &str,
2288        commit: &ContentHash,
2289        review: Option<&str>,
2290        sub: &Submission,
2291        op: &ViewOp,
2292        actor_id: &ContentHash,
2293    ) -> Result<Authorization, String> {
2294        let acl = self.acl_now()?;
2295        let repo = crate::acl::ref_repo(name);
2296        if let (Some(acl), Some(repo)) = (acl.as_ref(), repo.as_ref()) {
2297            if acl.has_owner(repo) {
2298                return self.owner_assented(acl, repo, name, commit, review, sub, op, actor_id);
2299            }
2300        }
2301        let (approval_weight, approvers) = self.weight_and_approvers(name, commit, review)?;
2302        if approval_weight < REQUIRED_APPROVAL_WEIGHT {
2303            return Err(Rejection::new(
2304                Code::ReviewRequired,
2305                format!(
2306                    "{name} is protected and this commit has approval weight \
2307                     {approval_weight}, below the required {REQUIRED_APPROVAL_WEIGHT}"
2308                ),
2309                "open a review naming this ref and commit (`choir review ... --ref \
2310                 <repo:ref>`), obtain approvals from two distinct operators, then push again",
2311            )
2312            .with_states(
2313                Some(format!(
2314                    "approval weight {REQUIRED_APPROVAL_WEIGHT} for {}",
2315                    commit.to_hex()
2316                )),
2317                Some(format!("approval weight {approval_weight}")),
2318            )
2319            .encode());
2320        }
2321        Ok(Authorization::new(
2322            Basis::ApprovalWeight {
2323                required: u32::try_from(REQUIRED_APPROVAL_WEIGHT).unwrap_or(u32::MAX),
2324                met: u32::try_from(approval_weight).unwrap_or(u32::MAX),
2325            },
2326            approvers,
2327        ))
2328    }
2329
2330    /// The approval weight backing a landing, and the actor ids of the
2331    /// approvals it counted (D43).
2332    ///
2333    /// The two come from one call because they must describe the same
2334    /// review: `approval_weight_for` takes the maximum over every review
2335    /// naming `(ref, commit)`, so a weight and a separately-derived
2336    /// approver list could easily belong to different rows.
2337    ///
2338    /// Approver ids are resolved only when a `Submit` asked for them
2339    /// (`review` is `Some`). A plain push does not need them and must not
2340    /// be refused for an unbound reviewer key, which would change the
2341    /// pre-D43 push gate.
2342    fn weight_and_approvers(
2343        &self,
2344        name: &str,
2345        commit: &ContentHash,
2346        review: Option<&str>,
2347    ) -> Result<(usize, Vec<ContentHash>), String> {
2348        let Some(review) = review else {
2349            return Ok((self.approval_weight_for(name, commit), Vec::new()));
2350        };
2351        let view = self.view.lock().expect("view lock");
2352        let Some(state) = view.reviews.get(review) else {
2353            return Ok((0, Vec::new()));
2354        };
2355        if state.target_ref.as_deref() != Some(name) || state.target.as_ref() != Some(commit) {
2356            return Ok((0, Vec::new()));
2357        }
2358        if !state.approved() {
2359            return Ok((0, Vec::new()));
2360        }
2361        let approvers = state
2362            .counted_approvers()
2363            .into_iter()
2364            .map(|(channel, at)| view.bound_actor_at(channel, at))
2365            .collect::<Result<Vec<_>, String>>()
2366            .map_err(|e| Self::unbound_approver(&e))?;
2367        Ok((state.approval_weight(), approvers))
2368    }
2369
2370    /// The rejection for an approval this node cannot name an actor id
2371    /// for (D43).
2372    ///
2373    /// A landing whose approvers cannot be identified is refused rather
2374    /// than recorded with a gap. That is a liveness cost on a node with
2375    /// no `BindKey` records, and it buys the property the whole record
2376    /// exists for: `channel_is_owned` constrains *bound* keys only, so
2377    /// an approval from an unbound channel is one nobody can be held to.
2378    fn unbound_approver(reason: &str) -> String {
2379        Rejection::new(
2380            Code::ReviewRequired,
2381            format!("this landing cannot name its approvers: {reason}"),
2382            "a merge records who approved it as actor ids, which it reads from the log's own \
2383             key bindings. Ask the operator to bind that reviewer's key (`choir bind-key`), \
2384             then merge again",
2385        )
2386        .encode()
2387    }
2388
2389    /// Whether an owner of `repo` assented to this landing, in either of
2390    /// the two ways that count (D42).
2391    ///
2392    /// One question with two answers, not a rule plus an exception:
2393    /// performing the landing is assent, and so is having approved a
2394    /// review that names this exact `(ref, commit)`. The first is what
2395    /// lets an owner land their own work without reviewing themselves —
2396    /// which they could not do anyway, since the reviewer draw excludes
2397    /// the requester's own operator.
2398    ///
2399    /// Returns which of the two answers applied, so a [`OpKind::Submit`]
2400    /// can record it (D43). The order is load-bearing and is the order
2401    /// the two answers are checked in: an owner who both approved and
2402    /// landed is recorded as having landed, because that is the assent
2403    /// the gate actually rested on.
2404    ///
2405    /// **The two answers match the ACL's user column against different
2406    /// namespaces, and an operator writing that file has to know which.**
2407    /// The landing answer asks about [`Self::acting_user`] — for a push
2408    /// that is the transport channel minus its `git/` prefix, which is
2409    /// the `--auth-file` username; for a signed op it is the *bound name*
2410    /// of the signing key. The approval answer asks about a reviewer
2411    /// channel, the key of [`choir_view::ReviewState::verdicts`], spelled
2412    /// the way the reviewer pool spells it (`someone/reviewer`).
2413    ///
2414    /// So `alice choir/choir.git own` grants the landing answer and never
2415    /// the approval one, and `alice/reviewer choir/choir.git own` grants
2416    /// the reverse. Neither is wrong and nothing warns which was meant.
2417    /// Granting the wrong spelling still flips [`crate::acl::Effective::has_owner`],
2418    /// which switches the repository out of the approval-weight rule —
2419    /// so a mismatched grant does not fall back, it narrows the gate to a
2420    /// rule the intended actor cannot satisfy.
2421    ///
2422    /// For an owner landing their own work the landing answer is the only
2423    /// reachable one regardless, because the reviewer draw excludes the
2424    /// requester's own operator.
2425    #[allow(clippy::too_many_arguments)]
2426    fn owner_assented(
2427        &self,
2428        acl: &crate::acl::Effective,
2429        repo: &str,
2430        name: &str,
2431        commit: &ContentHash,
2432        review: Option<&str>,
2433        sub: &Submission,
2434        op: &ViewOp,
2435        actor_id: &ContentHash,
2436    ) -> Result<Authorization, String> {
2437        let acting = self.acting_user(sub, op, actor_id);
2438        if let Some(user) = acting.as_deref() {
2439            if acl.allows_repo(user, repo, crate::acl::Level::Own) {
2440                return Ok(Authorization::new(
2441                    Basis::OwnerLanded {
2442                        owner: user.to_string(),
2443                    },
2444                    Vec::new(),
2445                ));
2446            }
2447        }
2448        if let Some((owner, approved_at)) = self.owner_approved(acl, repo, name, commit, review) {
2449            // Only a `Submit` needs the id, and only a `Submit` may be
2450            // refused for the want of one. Resolving it on the push path
2451            // too would make an unbound reviewer key break a landing that
2452            // D42 admits today, which is a gate change wearing the shape
2453            // of a record change.
2454            let approvers = match review {
2455                Some(_) => vec![self
2456                    .view
2457                    .lock()
2458                    .expect("view lock")
2459                    .bound_actor_at(&owner, approved_at)
2460                    .map_err(|e| Self::unbound_approver(&e))?],
2461                None => Vec::new(),
2462            };
2463            return Ok(Authorization::new(
2464                Basis::OwnerApproved { owner },
2465                approvers,
2466            ));
2467        }
2468        // A certified push fails here for a reason the generic message
2469        // would misdescribe: not "you are not an owner" but "the node
2470        // cannot tell who you are", which has a different repair.
2471        let next = if matches!(op.provenance, Some(Provenance::PushCertified)) {
2472            "a signed push cannot assert repository ownership: its channel names the push \
2473             certificate's key rather than an ACL user. Push without `--signed`, or have an \
2474             owner approve a review naming this ref and commit"
2475        } else {
2476            "have an owner of this repository approve a review naming this ref and commit, \
2477             or land it as an owner yourself. An owner submitting directly must have their \
2478             key bound to their name in the trusted-keys file, or the node cannot tell the \
2479             key is theirs"
2480        };
2481        Err(Rejection::new(
2482            Code::ReviewRequired,
2483            format!(
2484                "{name} is protected and {repo} is owned; no owner has assented to this commit"
2485            ),
2486            next,
2487        )
2488        .with_states(
2489            Some(format!("owner assent for {}", commit.to_hex())),
2490            Some(match acting {
2491                Some(user) => format!("a landing by {user}, who does not own {repo}"),
2492                None => "a landing by an identity the node cannot resolve to an ACL user".into(),
2493            }),
2494        )
2495        .encode())
2496    }
2497
2498    /// Whether an owner of `repo` approved a review naming exactly this
2499    /// `(ref, commit)` pair (D42).
2500    ///
2501    /// Deliberately **not** routed through [`choir_view::ReviewState::approved`],
2502    /// which returns false until every drawn reviewer has answered. Under
2503    /// D42 an owner's approval is sufficient on its own, so consulting
2504    /// `approved()` would let one reviewer who never replies veto a
2505    /// landing the owner had already assented to — a liveness bug wearing
2506    /// the shape of a safety check.
2507    ///
2508    /// A slashed approval does not count. A retroactively invalidated
2509    /// verdict is invalidated for an owner exactly as for anybody else,
2510    /// which is the whole point of `SlashApproval` existing.
2511    ///
2512    /// **The slash test is per operator, not per channel** — the same
2513    /// predicate the weight rule applies, via
2514    /// [`choir_view::ReviewState::approval_stands`]. D42 shipped this
2515    /// check spelled `slashes.contains_key(reviewer)`, which let a
2516    /// slashed operator's *other* channel keep authorizing while its
2517    /// weight contribution was already gone. Two answers to "is this
2518    /// approval still good" is one more than a gate may have.
2519    ///
2520    /// Returns the approving owner's channel, which a
2521    /// [`OpKind::Submit`] records (D43). `review` narrows the search to
2522    /// one named review; `None` searches every review naming this
2523    /// `(ref, commit)`, which is the pre-D43 push behaviour.
2524    fn owner_approved(
2525        &self,
2526        acl: &crate::acl::Effective,
2527        repo: &str,
2528        name: &str,
2529        commit: &ContentHash,
2530        review: Option<&str>,
2531    ) -> Option<(String, u64)> {
2532        let view = self.view.lock().expect("view lock");
2533        view.reviews
2534            .iter()
2535            .filter(|(id, state)| {
2536                review.is_none_or(|wanted| wanted == id.as_str())
2537                    && state.target_ref.as_deref() == Some(name)
2538                    && state.target.as_ref() == Some(commit)
2539            })
2540            .find_map(|(_, state)| {
2541                state.verdicts.keys().find_map(|reviewer| {
2542                    // The position comes back with the name because the
2543                    // approval has to be resolved to the key that was
2544                    // live when it was cast, not the one holding the
2545                    // channel now (D44).
2546                    let at = state.standing_approval_at(reviewer)?;
2547                    acl.allows_repo(reviewer, repo, crate::acl::Level::Own)
2548                        .then(|| (reviewer.clone(), at))
2549                })
2550            })
2551    }
2552
2553    /// Admits a [`OpKind::Submit`] only if the authorization it carries
2554    /// is the one this node's own gate produces (D43).
2555    ///
2556    /// The op is author-signed, so `authorization` arrives as a claim.
2557    /// It is never trusted and never patched: the gate runs, and the
2558    /// claim must equal its result exactly. A client that writes its own
2559    /// basis gets a rejection naming both sides, not a landing.
2560    ///
2561    /// Refused outright when no rule would examine the landing — the
2562    /// operator is not running the review gate, or the ref is not
2563    /// protected. `Submit`'s entire value is the record, and a record of
2564    /// a decision nothing made is worse than no record: it reads, to
2565    /// every later auditor, exactly like one that was checked.
2566    #[allow(clippy::too_many_arguments)]
2567    fn submit_is_authorized(
2568        &self,
2569        gating: bool,
2570        review: &str,
2571        name: &str,
2572        commit: &ContentHash,
2573        claimed: &Authorization,
2574        sub: &Submission,
2575        op: &ViewOp,
2576        actor_id: &ContentHash,
2577    ) -> Result<(), String> {
2578        if !gating || !self.ref_is_protected(name)? {
2579            return Err(Rejection::new(
2580                Code::ReviewRequired,
2581                format!(
2582                    "{name} is not gated on this node, so a landing record for it would \
2583                     assert a review that nothing performed"
2584                ),
2585                "move the ref with an ordinary `SetRef`, or ask the operator to protect this \
2586                 ref and enable `--require-review`",
2587            )
2588            .encode());
2589        }
2590        let computed = self.authorization_for(name, commit, Some(review), sub, op, actor_id)?;
2591        if computed == *claimed {
2592            return Ok(());
2593        }
2594        // `expected` carries the gate's answer as its own canonical JSON,
2595        // not a summary of it. That is what makes the mismatch a usable
2596        // two-step protocol rather than a dead end: a client submits,
2597        // reads the authorization it should have signed, and submits
2598        // again. The alternative is a second implementation of this rule
2599        // on the read path so a client could ask in advance -- and a
2600        // record that can disagree with the decision it describes is the
2601        // one thing this field must never be.
2602        Err(Rejection::new(
2603            Code::ReviewRequired,
2604            format!(
2605                "the authorization on this submit is not the one {name}'s gate produced: \
2606                 it admits this landing as {}, and the submit claims {}",
2607                Self::basis_summary(&computed),
2608                Self::basis_summary(claimed)
2609            ),
2610            "sign and resubmit with the authorization in `expected`, verbatim. It is the \
2611             gate's own answer and a client cannot assert it",
2612        )
2613        .with_states(
2614            serde_json::to_string(&computed).ok(),
2615            serde_json::to_string(claimed).ok(),
2616        )
2617        .encode())
2618    }
2619
2620    /// One-line rendering of an authorization, for the prose half of a
2621    /// mismatch rejection. Approvers are counted rather than listed: the
2622    /// hex ids would bury the part that differs.
2623    fn basis_summary(authorization: &Authorization) -> String {
2624        let basis = match &authorization.basis {
2625            Basis::OwnerLanded { owner } => format!("landed by owner {owner}"),
2626            Basis::OwnerApproved { owner } => format!("approved by owner {owner}"),
2627            Basis::ApprovalWeight { required, met } => {
2628                format!("approval weight {met} against a required {required}")
2629            }
2630        };
2631        format!("{basis} with {} approver(s)", authorization.approvers.len())
2632    }
2633
2634    /// Refuses a submission that has already been admitted, and one whose
2635    /// author never bound it to this log at all.
2636    ///
2637    /// Together these are the replay defence, and they compose into
2638    /// at-most-once permanently even though both indexes are bounded by
2639    /// the window. The argument is short enough to check: a scoped op is
2640    /// admissible only while the head it names is still in the window;
2641    /// its own signing hash entered the window at a *later* sequence
2642    /// than that head, so whenever the head is still there the signing
2643    /// hash is too and the duplicate check refuses it. Once the head is
2644    /// gone the scope check refuses it. There is no sequence at which
2645    /// neither fires.
2646    ///
2647    /// Without a scope only the duplicate half applies, which bounds a
2648    /// replay to the window instead of refusing it outright — the reason
2649    /// `--require-scope` exists.
2650    fn admit_once(&self, signing: &ContentHash, op: &ViewOp) -> Result<(), String> {
2651        // Nothing is cloned out of the window here. Every admitted op runs
2652        // this, while only a refused one needs the head to explain itself,
2653        // so the head is read again on the rejection path instead of
2654        // copied on the hot one -- one allocation per op, which the
2655        // allocation budget notices.
2656        let (already, holds_head, nothing_evicted) = {
2657            let window = self.entries.lock().expect("entries lock");
2658            (
2659                window.seq_for_signing(signing),
2660                op.scope
2661                    .as_ref()
2662                    .and_then(|s| s.head.as_ref())
2663                    .is_some_and(|h| window.holds_head(h)),
2664                window.base == 0,
2665            )
2666        };
2667        let window_head = || {
2668            self.entries
2669                .lock()
2670                .expect("entries lock")
2671                .head_hash()
2672                .as_ref()
2673                .map(ContentHash::to_hex)
2674        };
2675        // A signature is admissible once. `prev` cannot enforce that: it
2676        // compares state, and state recurs — land a commit, revert it,
2677        // and the reverted-away op's CAS matches again. The HTTP layer
2678        // answers any rejection whose submission already landed as 200
2679        // `already_applied` with the original seq, so a lost-response
2680        // retry still reads as success while a replay becomes a no-op.
2681        if let Some(seq) = already {
2682            return Err(Rejection::new(
2683                Code::DuplicateSubmission,
2684                format!("these exact signed bytes already landed at seq {seq}"),
2685                "if you are retrying, read `seq` from this response — it names the op you \
2686                 already have. If you meant a second, distinct change, sign a new op: two \
2687                 otherwise byte-identical ops are told apart by their scope.",
2688            )
2689            .with_states(Some(seq.to_string()), None)
2690            .encode());
2691        }
2692        let Some(scope) = &op.scope else {
2693            if self
2694                .require_scope
2695                .load(std::sync::atomic::Ordering::Relaxed)
2696            {
2697                return Err(Rejection::new(
2698                    Code::ScopeRequired,
2699                    "this node admits only ops signed for its own log and a recent head",
2700                    "read `log.node` and `log.head` from GET /api/view, put them in the \
2701                     op's `scope`, and sign that; `choir submit` does it for you",
2702                )
2703                .with_states(None, window_head())
2704                .encode());
2705            }
2706            return Ok(());
2707        };
2708        if scope.node != self.node_id {
2709            return Err(Rejection::new(
2710                Code::ForeignScope,
2711                "this op was signed for another node's log",
2712                "sign a scope naming this node; its id is in `actual` and in `log.node` \
2713                 of GET /api/view",
2714            )
2715            .with_states(Some(scope.node.to_hex()), Some(self.node_id.to_hex()))
2716            .encode());
2717        }
2718        match &scope.head {
2719            Some(_) if holds_head => Ok(()),
2720            // The op names no head, which is what a client signs when it
2721            // read an empty log — including every op of a batch it signed
2722            // in one go, since only the first of those lands against a
2723            // log that is still empty.
2724            //
2725            // Admissible while this window has evicted nothing, because
2726            // that is exactly the span the duplicate index above covers
2727            // in full: an unevicted window holds every entry, so a replay
2728            // of a headless op cannot slip past it. The moment the first
2729            // entry is evicted the guarantee would thin out, and the op
2730            // stops being admissible instead.
2731            None if nothing_evicted => Ok(()),
2732            None => Err(Rejection::new(
2733                Code::StaleScope,
2734                "the op names no head, and this log has evicted entries since",
2735                "re-read `log.head` from GET /api/view and sign a fresh op against it",
2736            )
2737            .with_states(
2738                Some("a log with nothing evicted".to_string()),
2739                window_head(),
2740            )
2741            .encode()),
2742            Some(head) => Err(Rejection::new(
2743                Code::StaleScope,
2744                "the head this op was signed against is no longer in the window",
2745                "re-read `log.head` from GET /api/view and sign a fresh op against it; a \
2746                 signature stays admissible only as long as the head it names does",
2747            )
2748            .with_states(Some(head.to_hex()), window_head())
2749            .encode()),
2750        }
2751    }
2752}
2753
2754impl ChoirPolicy {
2755    /// Verifies a passkey submission against the credential the *channel*
2756    /// enrolled (D39).
2757    ///
2758    /// The channel is the account name, and that is the whole binding:
2759    /// `Accounts::passkey_spki` is keyed by `(account, credential_id)`, so
2760    /// an assertion by alice's authenticator submitted on bob's channel
2761    /// finds no key and is refused. There is no separate table mapping
2762    /// credentials to channels, because the store already is one — and a
2763    /// second table would be a second thing to keep in agreement.
2764    ///
2765    /// The returned actor id is `blake3` of the credential's public key,
2766    /// the same rule [`choir_identity::ActorKey::actor_id`] uses for an
2767    /// ed25519 key. It is an in-process authorization handle only: an
2768    /// `OpEntry` records the channel and the signature, never an actor
2769    /// id, so this derivation is not frozen into the log and changing it
2770    /// later would not be a migration.
2771    fn verify_passkey(
2772        &self,
2773        signing: &ContentHash,
2774        sig: &Witness,
2775        channel: &str,
2776    ) -> Result<ContentHash, choir_identity::IdentityError> {
2777        let store = self.passkeys.lock().expect("passkey store lock").clone();
2778        let Some(store) = store else {
2779            // No store at all: the node has no self-service, so it has no
2780            // enrolled credentials and this key id is unknown to it.
2781            return Err(choir_identity::IdentityError::UnknownKey(
2782                sig.key_id.clone(),
2783            ));
2784        };
2785        let spki = store
2786            .passkey_spki(channel, &sig.key_id)
2787            .ok_or_else(|| choir_identity::IdentityError::UnknownKey(sig.key_id.clone()))?;
2788        choir_identity::verify_webauthn_assertion(&spki, signing, sig)?;
2789        Ok(ContentHash::blake3(&spki))
2790    }
2791}
2792
2793impl SubmitPolicy for ChoirPolicy {
2794    fn check(&mut self, sub: &Submission) -> Result<(), String> {
2795        if let Some(home) = self.home.get() {
2796            self.subject = (None, None);
2797            return Err(crate::reject::not_home(&home.url).to_string());
2798        }
2799        let sig = sub.author_sig.as_ref().ok_or("unsigned submission")?;
2800        // Refresh before verification so removing a trusted key takes
2801        // effect on that key's very next request. A failed verification
2802        // cannot trigger this tightening: a removed key is still present
2803        // in the stale registry and would verify successfully.
2804        self.reload_keys();
2805        let signing = choir_oplog::signing_hash(&sub.channel, &sub.payload);
2806        let mut verified_actor = if sig.scheme_id() == choir_oplog::scheme::WEBAUTHN_ES256 {
2807            self.verify_passkey(&signing, sig, &sub.channel)
2808        } else {
2809            self.registry.verify_signing_hash(&signing, sig)
2810        };
2811        // Retry a failed signature in case the file changed between the
2812        // pre-verification metadata check and this verification. Only the
2813        // ed25519 path has a file behind it; a passkey lives in the store
2814        // and is read fresh on every call already.
2815        if verified_actor.is_err()
2816            && sig.scheme_id() != choir_oplog::scheme::WEBAUTHN_ES256
2817            && self.reload_keys()
2818        {
2819            verified_actor = self.registry.verify_signing_hash(&signing, sig);
2820        }
2821        self.subject = (None, None);
2822        let actor_id = verified_actor.map_err(|e| {
2823            // Two failures with opposite repairs, and one of them is an
2824            // attack signal, so they cannot share a code. A key id the
2825            // node has no record of is a trust gap the operator closes.
2826            // A signature that does not verify under a key the node
2827            // already trusts is either corruption or a lifted signature
2828            // replayed onto other bytes -- and answering that with "ask
2829            // the operator to register your public key" hands the party
2830            // being impersonated the one repair that helps the attacker.
2831            // Anything else is reported as the bad signature too: of the
2832            // two directions to be wrong in, refusing is the safe one.
2833            let detail = format!("signature check failed: {e:?}");
2834            match &e {
2835                choir_identity::IdentityError::UnknownKey(_) => Rejection::new(
2836                    Code::UnknownKey,
2837                    detail,
2838                    "ask the operator to add your public key to the node's trusted-keys file                  (`choir key <file> <you>` prints the line); it takes effect on the next request",
2839                ),
2840                _ => Rejection::new(
2841                    Code::BadSignature,
2842                    detail,
2843                    "re-sign the exact bytes you are submitting; a signature covers one \
2844                     (channel, payload) pair and does not carry to another. Registering a key \
2845                     does not help here, the key this names is already trusted -- if you did not \
2846                     send this, a signature of yours was replayed onto bytes you never signed",
2847                ),
2848            }
2849            .encode()
2850        })?;
2851        // Derived here, while the signature is already verified, and
2852        // only when something will read it: naming the op means
2853        // serializing its kind, which is not free per op.
2854        let journalling = self.journal.enabled();
2855        if journalling {
2856            self.subject.0 = Some(actor_id.to_hex());
2857        }
2858        let op = ViewOp::from_payload(&sub.payload).map_err(|e| {
2859            Rejection::new(
2860                Code::MalformedOp,
2861                format!("payload did not decode as a ViewOp: {e:?}"),
2862                "sign the bytes of a serialized ViewOp; `choir submit` does this correctly",
2863            )
2864            .encode()
2865        })?;
2866        if journalling {
2867            self.subject.1 = op_type_name(&op);
2868        }
2869        // Before any policy that asks what the op *does*: has this
2870        // signature already been spent, and was it ever meant for this
2871        // log at all.
2872        self.admit_once(&signing, &op)?;
2873        // A verdict's claimed reviewer must be the signature-covered
2874        // submission channel: the log's author attribution and the
2875        // view's verdict attribution can never diverge.
2876        if let OpKind::PostVerdict { reviewer, .. } = &op.kind {
2877            if *reviewer != sub.channel {
2878                return Err(Rejection::new(
2879                    Code::ReviewerMismatch,
2880                    "a verdict's reviewer must be the channel it was signed on",
2881                    "resubmit on your own channel: `choir verdict` signs on the reviewer name                      by construction",
2882                )
2883                .with_states(Some(sub.channel.clone()), Some(reviewer.clone()))
2884                .encode());
2885            }
2886        }
2887        // A comment's claimed author is bound the same way and for a
2888        // sharper reason: a verdict in the wrong name is a wrong
2889        // authorization, a comment in the wrong name is words somebody
2890        // never said. The view stores `author`, so the payload claim is
2891        // what a reader sees, and it must be the channel that signed.
2892        if let OpKind::PostComment { author, .. } = &op.kind {
2893            if *author != sub.channel {
2894                return Err(Rejection::new(
2895                    Code::ReviewerMismatch,
2896                    "a comment's author must be the channel it was signed on",
2897                    "resubmit on your own channel: `choir comment` signs on the author name by \
2898                     construction",
2899                )
2900                .with_states(Some(sub.channel.clone()), Some(author.clone()))
2901                .encode());
2902            }
2903        }
2904        // A receipt's claimed viewer is bound the same way: the receipt
2905        // exists to attribute attention, and attention recorded in
2906        // somebody else's name is exactly the false "it was looked at"
2907        // signal the op exists to remove.
2908        if let OpKind::ViewedReview { viewer, .. } = &op.kind {
2909            if *viewer != sub.channel {
2910                return Err(Rejection::new(
2911                    Code::ReviewerMismatch,
2912                    "a receipt's viewer must be the channel it was signed on",
2913                    "resubmit on your own channel: `choir viewed` signs on the viewer name by \
2914                     construction",
2915                )
2916                .with_states(Some(sub.channel.clone()), Some(viewer.clone()))
2917                .encode());
2918            }
2919        }
2920        // A vouch's claimed voucher is bound the same way, one level up:
2921        // the payload names an *operator* and the submission is signed on
2922        // a *channel*, so the comparison is against the channel's
2923        // operator prefix rather than the channel itself. `ops/agent` and
2924        // `ops` are the same operator and either may sign; `rival` may
2925        // not, and an unchecked `voucher` is precisely a Sybil writing
2926        // somebody else's endorsements (D65).
2927        if let OpKind::CountersignSnapshot { witness, .. } = &op.kind {
2928            let operator = reviewer_operator(&sub.channel);
2929            if witness != operator {
2930                return Err(Rejection::new(
2931                    Code::ReviewerMismatch,
2932                    "a countersignature's witness must be the operator of the channel it was \
2933                     signed on",
2934                    "resubmit on a channel belonging to that operator: `choir witness` derives \
2935                     the witness from the channel by construction",
2936                )
2937                .with_states(Some(operator.to_string()), Some(witness.clone()))
2938                .encode());
2939            }
2940        }
2941        if let OpKind::Vouch { voucher, .. } | OpKind::WithdrawVouch { voucher, .. } = &op.kind {
2942            let operator = reviewer_operator(&sub.channel);
2943            if voucher != operator {
2944                return Err(Rejection::new(
2945                    Code::ReviewerMismatch,
2946                    "a vouch's voucher must be the operator of the channel it was signed on",
2947                    "resubmit on a channel belonging to that operator: `choir vouch` derives \
2948                     the voucher from the channel by construction",
2949                )
2950                .with_states(Some(operator.to_string()), Some(voucher.clone()))
2951                .encode());
2952            }
2953        }
2954        // ...and the channel itself must belong to the signing key, or
2955        // the check above only proves a claim is self-consistent, not
2956        // that it is true. Review ops only: see `channel_is_owned`.
2957        if matches!(
2958            op.kind,
2959            OpKind::PostVerdict { .. }
2960                | OpKind::RequestReview { .. }
2961                | OpKind::PostComment { .. }
2962                | OpKind::ViewedReview { .. }
2963                | OpKind::Vouch { .. }
2964                | OpKind::WithdrawVouch { .. }
2965                | OpKind::CountersignSnapshot { .. }
2966        ) {
2967            self.channel_is_owned(&actor_id, &sub.channel)?;
2968        }
2969        // D41: a provenance label claims "the node signed this on behalf
2970        // of a git pusher". Unenforced, the label would be exactly the
2971        // laundering it exists to prevent — any author could dress an op
2972        // as push-derived, or downstream code could trust the label
2973        // without checking the signer. Enforced here, an accepted labeled
2974        // op always carries the node's own signature.
2975        if op.provenance.is_some() && actor_id != self.node_id {
2976            return Err(Rejection::new(
2977                Code::NodeOnly,
2978                "only the node may label an op with a push provenance",
2979                "submit without `provenance`: author-signed ops are the default class and need no label",
2980            )
2981            .encode());
2982        }
2983        // D24 layer 5: the requester does not choose who reviews them.
2984        // Only the daemon's own key may fill in a reviewer list; every
2985        // other author gets a rejection, so an accepted assignment in
2986        // the log always came from the node's pool draw.
2987        if matches!(op.kind, OpKind::AssignReviewers { .. }) && actor_id != self.node_id {
2988            return Err(Rejection::new(
2989                Code::NodeOnly,
2990                "only the node may assign reviewers",
2991                "request a review with an empty reviewer list and the node will draw them",
2992            )
2993            .encode());
2994        }
2995        // Archiving drops a review's verdicts, so an unguarded one is a
2996        // way to erase a RequestChanges you did not like. Node key only,
2997        // same reasoning as assignment: it is retention, not review.
2998        if matches!(op.kind, OpKind::ArchiveReview { .. }) && actor_id != self.node_id {
2999            return Err(Rejection::new(
3000                Code::NodeOnly,
3001                "only the node may archive reviews",
3002                "nothing to do: archiving is retention, performed by the node",
3003            )
3004            .encode());
3005        }
3006        // Stable change creation is coupled to physical workspace
3007        // provisioning. Only the node may record it, after the exact base
3008        // was verified and the checkout was materialized.
3009        if matches!(
3010            op.kind,
3011            OpKind::CreateChange { .. } | OpKind::ArchiveChange { .. }
3012        ) && actor_id != self.node_id
3013        {
3014            return Err(Rejection::new(
3015                Code::NodeOnly,
3016                "only the node may author physical workspace lifecycle operations",
3017                "call POST /api/workspace to create, or POST /api/workspace/archive with an owner-signed authorization to archive",
3018            )
3019            .encode());
3020        }
3021        if let OpKind::CreateChange {
3022            id,
3023            owner,
3024            workspace,
3025            base_revision,
3026            idempotency_key,
3027            owner_sig: Some(owner_sig),
3028            cone,
3029        } = &op.kind
3030        {
3031            // The cone is rederived into the authorization rather than
3032            // trusted from the op, which is what makes it a *declaration
3033            // by the owner*: a node that attached a scope its owner did
3034            // not sign produces different bytes here and fails the check
3035            // below (D50).
3036            let authorization = CreateAuthorization::new(
3037                id.clone(),
3038                owner.clone(),
3039                workspace.clone(),
3040                base_revision.clone(),
3041                idempotency_key.clone(),
3042            )
3043            .with_cone(cone.clone())
3044            .to_payload();
3045            let mut verified_owner =
3046                self.registry
3047                    .verify_submission(owner, &authorization, owner_sig);
3048            if verified_owner.is_err() && self.reload_keys() {
3049                verified_owner = self
3050                    .registry
3051                    .verify_submission(owner, &authorization, owner_sig);
3052            }
3053            let owner_actor = verified_owner.map_err(|error| {
3054                Rejection::new(
3055                    Code::UnknownKey,
3056                    format!("create owner signature check failed: {error:?}"),
3057                    "register the owner key, then retry the same signed create authorization",
3058                )
3059                .encode()
3060            })?;
3061            self.channel_is_owned(&owner_actor, owner)?;
3062        }
3063        if let OpKind::CheckpointChange { id, .. } = &op.kind {
3064            let owner = self
3065                .view
3066                .lock()
3067                .expect("view lock")
3068                .changes
3069                .get(id)
3070                .map(|change| change.owner.clone());
3071            if let Some(owner) = owner {
3072                if owner != sub.channel {
3073                    return Err(Rejection::new(
3074                        Code::ChannelNotOwned,
3075                        format!("change {id} is owned by a different channel"),
3076                        "sign the change operation on the owner channel reported in GET /api/view, or \
3077                         create a separate change",
3078                    )
3079                    .with_states(Some(owner), Some(sub.channel.clone()))
3080                    .encode());
3081                }
3082                self.channel_is_owned(&actor_id, &sub.channel)?;
3083            }
3084        }
3085        if let OpKind::ArchiveChange {
3086            id,
3087            workspace,
3088            prev_revision,
3089            owner,
3090            owner_sig,
3091        } = &op.kind
3092        {
3093            let authorization =
3094                ArchiveAuthorization::new(id.clone(), workspace.clone(), prev_revision.clone())
3095                    .to_payload();
3096            let mut verified_owner =
3097                self.registry
3098                    .verify_submission(owner, &authorization, owner_sig);
3099            if verified_owner.is_err() && self.reload_keys() {
3100                verified_owner = self
3101                    .registry
3102                    .verify_submission(owner, &authorization, owner_sig);
3103            }
3104            let owner_actor = verified_owner.map_err(|error| {
3105                Rejection::new(
3106                    Code::UnknownKey,
3107                    format!("archive owner signature check failed: {error:?}"),
3108                    "register the owner key, then retry the same signed archive authorization",
3109                )
3110                .encode()
3111            })?;
3112            self.channel_is_owned(&owner_actor, owner)?;
3113        }
3114        // Legacy workspace moves and deletes remain compatible for
3115        // legacy workspaces. Once a workspace is enrolled in a stable
3116        // change, non-node callers must use CheckpointChange and the
3117        // recoverable archive endpoint so identity cannot be bypassed.
3118        let bound_workspace = match &op.kind {
3119            OpKind::SetWorkspaceHead { workspace, .. } | OpKind::DeleteWorkspace { workspace } => {
3120                self.view
3121                    .lock()
3122                    .expect("view lock")
3123                    .changes
3124                    .values()
3125                    .any(|change| change.active_workspace.as_deref() == Some(workspace))
3126            }
3127            _ => false,
3128        };
3129        if bound_workspace && actor_id != self.node_id {
3130            return Err(Rejection::new(
3131                Code::WorkspaceState,
3132                "a change-bound workspace cannot be moved or removed through a legacy op",
3133                "use choir checkpoint to publish a revision, or choir workspace-archive to \
3134                 archive the bound workspace",
3135            )
3136            .encode());
3137        }
3138        // A slash can withdraw authorization from a review whose detail
3139        // has already been compacted. Only the node key may make that
3140        // durable attestation; otherwise any trusted author could erase
3141        // another operator's approval weight.
3142        if matches!(op.kind, OpKind::SlashApproval { .. }) && actor_id != self.node_id {
3143            return Err(Rejection::new(
3144                Code::NodeOnly,
3145                "only the node may slash approvals",
3146                "ask the operator to run `choir slash` with the node key",
3147            )
3148            .encode());
3149        }
3150        // A ref snapshot is the node's own attestation of its complete
3151        // ref-state (D25): the unit a witness will cosign and the thing
3152        // two readers compare to detect equivocation. The fold already
3153        // refuses an untruthful one; this guard is about authorship —
3154        // signed by anyone else it attests nothing about the node while
3155        // reading as though it did.
3156        if matches!(op.kind, OpKind::RecordRefSnapshot { .. }) && actor_id != self.node_id {
3157            return Err(Rejection::new(
3158                Code::NodeOnly,
3159                "only the node may record ref snapshots",
3160                "read the latest snapshot from the view; the node attests its own ref-state",
3161            )
3162            .encode());
3163        }
3164        if matches!(op.kind, OpKind::CountersignSnapshot { .. }) && actor_id == self.node_id {
3165            return Err(Rejection::new(
3166                Code::NodeOnly,
3167                "the node cannot witness its own ref-state attestation",
3168                "a witness is worth counting only because it is not the node that made the \
3169                 claim; have an independent operator cosign it",
3170            )
3171            .encode());
3172        }
3173        // Key bindings are the durable operator record that T3 attribution
3174        // and T1's ordering primitive read, so a binding any trusted key
3175        // could author is evidence forgeable by the actors it is meant to
3176        // weigh -- worse than no record, because it reads as sequenced
3177        // proof. `check` is a series of per-variant guards with a
3178        // fall-through to `validate`, i.e. admit-by-default, so these
3179        // variants have to name themselves here to be refused.
3180        //
3181        // This also supplies the second condition on re-binding: the fold
3182        // lets a binding correct its channel (keeping `bound_at` pinned),
3183        // and that correction is only safe while the node is the one
3184        // making it.
3185        if matches!(op.kind, OpKind::BindKey { .. } | OpKind::RevokeKey { .. })
3186            && actor_id != self.node_id
3187        {
3188            return Err(Rejection::new(
3189                Code::NodeOnly,
3190                "only the node may bind or revoke operator keys",
3191                "ask the operator to record this binding with the node key",
3192            )
3193            .encode());
3194        }
3195        // Required-assignment closes the other half of the same loop:
3196        // naming your own reviewers is refused, so the node's draw is the
3197        // only way a review gets reviewers. Two ways to switch it on —
3198        // node-wide, or per-ref once the review says where it wants to
3199        // land. Node-wide wins because it is the stricter of the two.
3200        if let OpKind::RequestReview {
3201            reviewers,
3202            target_ref,
3203            ..
3204        } = &op.kind
3205        {
3206            if !reviewers.is_empty() {
3207                if self
3208                    .require_assignment
3209                    .load(std::sync::atomic::Ordering::Relaxed)
3210                {
3211                    return Err(Rejection::new(
3212                        Code::AssignmentRequired,
3213                        "this node assigns reviewers",
3214                        "resubmit the same review with an empty reviewer list; the node draws \
3215                         them and returns the names in the response",
3216                    )
3217                    .encode());
3218                }
3219                if let Some(name) = target_ref {
3220                    if self.ref_is_protected(name)? {
3221                        return Err(Rejection::new(
3222                            Code::ProtectedRef,
3223                            format!("{name} is a protected ref"),
3224                            "resubmit with an empty reviewer list; on a protected ref only a \
3225                             node-drawn reviewer list is accepted",
3226                        )
3227                        .encode());
3228                    }
3229                }
3230            }
3231        }
3232        // The landing half of the gate: a protected ref only moves to a
3233        // commit that some independently approved review already named
3234        // as its destination. This is what turns `--protected-refs` from an
3235        // advisory into enforcement — without it a requester escapes by
3236        // simply omitting `target_ref`.
3237        //
3238        // No exemption for the node's own key. Every git push arrives
3239        // here as a node-signed `SetRef`, so exempting the node would
3240        // exempt every push, which is the whole population being gated.
3241        let gating = self
3242            .require_review
3243            .load(std::sync::atomic::Ordering::Relaxed);
3244        if gating {
3245            match &op.kind {
3246                OpKind::SetRef { name, commit, prev } if self.ref_is_protected(name)? => {
3247                    // Creating a protected ref is allowed: there is no
3248                    // history to hijack yet, and deletion is refused
3249                    // below, so "delete then re-create" is not a way in.
3250                    if prev.is_some() {
3251                        self.authorization_for(name, commit, None, sub, &op, &actor_id)?;
3252                    }
3253                }
3254                OpKind::DeleteRef { name, .. } if self.ref_is_protected(name)? => {
3255                    return Err(Rejection::new(
3256                        Code::RefUndeletable,
3257                        format!("{name} is protected and cannot be deleted"),
3258                        "delete a different ref, or ask the operator to remove this one from \
3259                         the protected-ref list",
3260                    )
3261                    .encode());
3262                }
3263                _ => {}
3264            }
3265        }
3266        // Deliberately outside the `gating` block above, and deliberately
3267        // not guarded on `prev`. A `Submit` exists to carry an
3268        // authorization record; letting one through unexamined -- because
3269        // the operator turned the gate off, or because the ref is not
3270        // protected, or because it creates the ref rather than moving it
3271        // -- would mint a signed claim that a rule admitted a landing
3272        // when no rule looked at it. The claim is the asset here, so it
3273        // is the thing that must never be issued unbacked.
3274        if let OpKind::Submit {
3275            review,
3276            name,
3277            commit,
3278            authorization,
3279            ..
3280        } = &op.kind
3281        {
3282            self.submit_is_authorized(
3283                gating,
3284                review,
3285                name,
3286                commit,
3287                authorization,
3288                sub,
3289                &op,
3290                &actor_id,
3291            )?;
3292        }
3293        // Admission is a read. Every precondition `View::apply` enforces
3294        // is a CAS comparison or a key lookup, so this asks the shared
3295        // view directly instead of deep-cloning it -- four nested
3296        // BTreeMaps per submission, O(total state), which grew with the
3297        // repo's lifetime rather than with the size of the op.
3298        //
3299        // `View::apply` calls the same `validate`, so admission and
3300        // application cannot disagree; that shared path is what keeps
3301        // `accepted`'s "checked in check()" honest.
3302        self.view
3303            .lock()
3304            .expect("view lock")
3305            .validate(&op)
3306            .map_err(|e| {
3307                // A lost CAS is recorded as contention in its own right,
3308                // carrying both sides. A rejection count alone cannot
3309                // separate "two writers raced this ref" from "a client
3310                // sent nonsense", and only the first is a fact about
3311                // load.
3312                if let choir_view::ViewError::StaleHead {
3313                    expected, actual, ..
3314                } = &e
3315                {
3316                    self.journal.record(journal::Event::CasFailure {
3317                        workspace: sub.channel.clone(),
3318                        expected: expected.as_ref().map(ContentHash::to_hex),
3319                        actual: actual.as_ref().map(ContentHash::to_hex),
3320                    });
3321                }
3322                crate::reject::from_view_error(&e).encode()
3323            })
3324    }
3325
3326    fn subject(&self) -> (Option<String>, Option<String>) {
3327        self.subject.clone()
3328    }
3329
3330    /// A replicated entry is folded, never admitted: no signature, grant,
3331    /// scope or quota is asked about, because the home already decided.
3332    /// What is asked is whether this build's view can take it at all,
3333    /// which is exactly the question `accepted` would otherwise answer
3334    /// with a panic.
3335    fn replicable(&mut self, entry: &OpEntry) -> Result<(), String> {
3336        let op = ViewOp::from_payload(&entry.payload)
3337            .map_err(|e| format!("payload does not decode as an op this build knows: {e:?}"))?;
3338        self.view
3339            .lock()
3340            .expect("view lock")
3341            .validate(&op)
3342            .map_err(|e| format!("the view cannot fold it: {e:?}"))
3343    }
3344
3345    fn accepted(&mut self, entry: &OpEntry, hash: &ContentHash) {
3346        let op = ViewOp::from_payload(&entry.payload).expect("checked in check()");
3347        {
3348            let mut view = self.view.lock().expect("view lock");
3349            view.apply(&op).expect("checked in check()");
3350            self.concentration
3351                .lock()
3352                .expect("concentration lock")
3353                .observe(entry, &op, &view);
3354            // D37, inside the same view-lock scope as the fold above:
3355            // a reader that took the tally between the two would see a
3356            // workspace the view already has and the tally does not.
3357            self.workspace_tally
3358                .lock()
3359                .expect("workspace tally lock")
3360                .observe(entry, &op);
3361        }
3362        if let Some(retention) = &self.review_retention {
3363            let mut retention = retention.lock().expect("review retention lock");
3364            let observed_at = retention.config.lapse_after.map(|_| Instant::now());
3365            retention.observe(&op, observed_at);
3366        }
3367        self.entries
3368            .lock()
3369            .expect("entries lock")
3370            .push(entry.clone(), hash.clone());
3371        self.offer_hook(entry, hash, &op);
3372    }
3373}
3374
3375/// A ref value as a receiver wants to read it: the git oid when the
3376/// value is one, and the self-describing content hash otherwise (the
3377/// platform API can point a ref at something that is not a git object).
3378fn oid_text(hash: &ContentHash) -> String {
3379    hash.git_oid().unwrap_or_else(|| hash.to_hex())
3380}
3381
3382impl ChoirPolicy {
3383    /// Hands a landed ref to the webhook delivery thread (D32).
3384    ///
3385    /// This runs on the writer thread, which is why it does nothing but
3386    /// build a small struct and `try_send` it. Matching, config reload,
3387    /// address vetting and `curl` all happen on the delivery thread; a
3388    /// full queue drops the event and counts it. Invariant 5 is the
3389    /// reason, and it is not a stylistic one: the target address belongs
3390    /// to somebody else, so a receiver that stops answering would
3391    /// otherwise stall op admission for everyone.
3392    fn offer_hook(&self, entry: &OpEntry, hash: &ContentHash, op: &ViewOp) {
3393        let (name, old, new) = match &op.kind {
3394            OpKind::SetRef { name, commit, prev } => {
3395                (name, prev.as_ref().map(oid_text), Some(oid_text(commit)))
3396            }
3397            OpKind::DeleteRef { name, prev } => (name, prev.as_ref().map(oid_text), None),
3398            _ => return,
3399        };
3400        // Same discipline, same reason: a `try_send`, never a wait.
3401        if let Some(followers) = self.followers.lock().expect("followers lock").as_ref() {
3402            followers.offer(name, new.as_deref());
3403        }
3404        let guard = self.hooks.lock().expect("hooks lock");
3405        let Some(hooks) = guard.as_ref() else {
3406            return;
3407        };
3408        hooks.offer(crate::hooks::RefEvent {
3409            key: name.clone(),
3410            old,
3411            new,
3412            seq: entry.seq,
3413            entry: hash.to_hex(),
3414            actor: entry.channel.clone(),
3415            key_id: entry.author_sig.as_ref().map(|sig| sig.key_id.clone()),
3416        });
3417    }
3418}
3419
3420/// A running platform: the sequencer plus the shared view it maintains.
3421pub struct Platform {
3422    /// The slot `with_journal` fills; shared with the writer thread and
3423    /// the admission policy, which both hold clones.
3424    journal: SharedJournal,
3425    handle: SequencerHandle,
3426    view: Arc<Mutex<View>>,
3427    /// The daemon's own key: signs ops it derives from authenticated git
3428    /// pushes. Attribution: a verified push certificate names the
3429    /// pusher's key (`key/<principal>`); otherwise the basic-auth user
3430    /// (`git/<user>`).
3431    node_key: Arc<ActorKey>,
3432    entries: Arc<Mutex<LogWindow>>,
3433    /// Operator-curated file of eligible reviewer names, one per line.
3434    /// Read fresh on every draw, so editing it takes effect at once.
3435    /// `None` = no pool, and unassigned reviews stay unassigned.
3436    reviewer_pool: Option<std::path::PathBuf>,
3437    /// Optional operator conflict graph plus the maximum graph distance
3438    /// excluded from a review draw. The file is read fresh on every draw,
3439    /// like the reviewer pool. Edges are undirected operator pairs.
3440    reviewer_conflict_graph: Option<(std::path::PathBuf, usize)>,
3441    /// The persisted op log, for readers that have fallen behind the
3442    /// in-memory window. `None` (an in-memory log) means such a reader
3443    /// gets a loud gap error instead of a resync.
3444    log_path: Option<std::path::PathBuf>,
3445    /// Absolute operation-count ceiling for one signed batch.
3446    batch_limit: usize,
3447    /// Shared with the policy: when set, self-named reviewers are
3448    /// refused and every review goes through the node's draw.
3449    require_assignment: Arc<std::sync::atomic::AtomicBool>,
3450    /// Shared with the policy: the same refusal, but only for reviews
3451    /// that propose to land on a ref the operator marked protected.
3452    protected_refs: Arc<Mutex<Option<std::path::PathBuf>>>,
3453    /// Shared with the policy: when set, a protected ref only moves to a
3454    /// commit an approved review already named.
3455    require_review: Arc<std::sync::atomic::AtomicBool>,
3456    /// Shared with the policy: the operator's ACL file, consulted for
3457    /// [`crate::acl::Level::Own`] grants when a landing is gated (D42).
3458    acl_file: Arc<Mutex<Option<std::path::PathBuf>>>,
3459    /// Shared with the policy: the credential store passkey submissions
3460    /// are verified against (D39). Filled by [`Platform::attach_accounts`].
3461    passkeys: Arc<Mutex<Option<Arc<crate::accounts::Accounts>>>>,
3462    /// Shared with the policy: when set, only scoped ops are admitted.
3463    require_scope: Arc<std::sync::atomic::AtomicBool>,
3464    /// Shared with the policy: actor id → bound channel name.
3465    key_names: Arc<Mutex<KeyBindings>>,
3466    /// The trusted-keys table as last read, refreshed in the same step as
3467    /// [`Platform::key_names`], for `GET /api/signers`. The log records a
3468    /// key's actor id and never the key, so a reader verifying signatures
3469    /// has nowhere else to learn one from.
3470    signers: Mutex<Vec<crate::TrustedKey>>,
3471    /// D24 T3 runtime projection, replayed from the signed log at startup.
3472    concentration: Arc<Mutex<ConcentrationState>>,
3473    /// D37 per-user workspace tally, replayed from the same log in the
3474    /// same pass. Shared with the policy, which advances it.
3475    workspace_tally: Arc<Mutex<crate::quota::WorkspaceTally>>,
3476    /// Sequence-ordered live reviews, allocated only under an explicit
3477    /// retention configuration.
3478    review_retention: Option<Arc<Mutex<ReviewRetentionState>>>,
3479    /// Opt-in D24 T4 audit. Sparse and separate from the signed op log:
3480    /// rejected requests never enter that log, while this evidence must.
3481    newcomer_audit: Option<Arc<Mutex<NewcomerAudit>>>,
3482    /// Opt-in D24 T2 operator classifications, re-read on every report so a
3483    /// fresh judgement lands without a restart. Absent means uncounted, not
3484    /// unclassified: coverage simply stays zero.
3485    review_adjudications: Option<std::path::PathBuf>,
3486    /// Serializes concurrent maintenance passes. User submissions still
3487    /// race normally through the sequencer; only duplicate pruning scans
3488    /// and archive batches are coalesced.
3489    review_prune_lock: Mutex<()>,
3490    /// Shared with the policy: the webhook delivery handle (D32), or
3491    /// `None` when the operator passed no `--hooks-file`.
3492    hooks: Arc<Mutex<Option<crate::hooks::Hooks>>>,
3493    /// Follower pushes (D21), shared the same way; `None` without
3494    /// `--followers`.
3495    followers: Arc<Mutex<Option<crate::followers::Followers>>>,
3496    /// The writer's own latency record for the traffic this node is
3497    /// serving, so the Phase-0 decision-latency gate is checked in
3498    /// production and not only by the test suite.
3499    lag: Arc<LagMeter>,
3500    /// Where drained gate breaches are appended, one JSON object per
3501    /// line. `None` keeps them in memory only, where the ring eventually
3502    /// drops the oldest (reported, never silent).
3503    lag_log: Option<std::path::PathBuf>,
3504    /// Last failure to write the lag log and how many writes have failed,
3505    /// surfaced in the report. A breach record that could not be written
3506    /// is itself an operational fact; swallowing it would make the lag log
3507    /// a check that cannot fail. The count does not reset on a later
3508    /// success, so a transient failure is still visible afterwards.
3509    lag_log_error: Mutex<(Option<String>, u64)>,
3510    /// The home this platform's log is a copy of, when it runs as a seed
3511    /// (D80). Set once, before the platform serves anything, and never
3512    /// cleared: a node does not stop being a copy of somebody's log.
3513    ///
3514    /// Shared with the policy, which refuses every submission on a seed,
3515    /// so a write that reached the writer by any path, HTTP or the node's
3516    /// own, is answered the same way.
3517    home: Arc<std::sync::OnceLock<crate::replica::Home>>,
3518    /// Where replication stands, once a [`crate::replica::Replica`] is
3519    /// writing into this platform.
3520    replica: std::sync::OnceLock<Arc<crate::replica::Shared>>,
3521    // Kept alive for the daemon's lifetime; the writer thread exits with
3522    // the process.
3523    _sequencer: Sequencer,
3524}
3525
3526/// Exact owner-authorized binding the node records after materializing a
3527/// physical workspace.
3528pub struct AuthorizedChangeCreate<'a> {
3529    /// Stable logical contribution id.
3530    pub id: &'a str,
3531    /// Bound signing channel.
3532    pub owner: &'a str,
3533    /// Namespaced physical workspace id.
3534    pub workspace: &'a str,
3535    /// Full Git object id selected as the immutable base.
3536    pub base_hex: &'a str,
3537    /// Owner-scoped retry identity.
3538    pub idempotency_key: &'a str,
3539    /// Owner proof over the matching [`CreateAuthorization`].
3540    pub owner_sig: Witness,
3541    /// Directory prefixes the owner declared this change works within,
3542    /// covered by `owner_sig`. Empty means the whole tree.
3543    pub cone: Vec<String>,
3544}
3545
3546impl Platform {
3547    /// Replays `log` into a view and starts the admission sequencer over
3548    /// it with `registry` as the trusted key set plus the daemon's own
3549    /// `node_key` (registered automatically, for git-derived ops).
3550    ///
3551    /// # Errors
3552    ///
3553    /// Returns a description of any replay failure (a log written
3554    /// through this platform always replays cleanly).
3555    pub fn start(
3556        registry: Registry,
3557        log: Box<dyn OpLog>,
3558        node_key: ActorKey,
3559    ) -> Result<Self, String> {
3560        Self::start_inner(registry, log, node_key, None, None)
3561    }
3562
3563    /// [`Platform::start`] with explicit live-review retention.
3564    ///
3565    /// Retention is a startup choice because recovering FIFO request order
3566    /// belongs in the same replay pass that builds the view. The ordinary
3567    /// constructor allocates no tracker and performs no retention checks.
3568    ///
3569    /// # Errors
3570    ///
3571    /// Same as [`Platform::start`].
3572    pub fn start_with_review_retention(
3573        registry: Registry,
3574        log: Box<dyn OpLog>,
3575        node_key: ActorKey,
3576        retention: ReviewRetention,
3577    ) -> Result<Self, String> {
3578        Self::start_inner(registry, log, node_key, None, Some(retention))
3579    }
3580
3581    /// [`Platform::start`] with a trusted-keys file that is hot-reloaded
3582    /// (on mtime change) before signature verification: registering a key
3583    /// is appending a line, no restart, and removing one refuses its next
3584    /// submission. The file's contents replace the whole registry.
3585    ///
3586    /// # Errors
3587    ///
3588    /// Same as [`Platform::start`].
3589    pub fn start_reloading(
3590        registry: Registry,
3591        log: Box<dyn OpLog>,
3592        node_key: ActorKey,
3593        keys_file: Option<std::path::PathBuf>,
3594    ) -> Result<Self, String> {
3595        Self::start_inner(registry, log, node_key, keys_file, None)
3596    }
3597
3598    /// [`Platform::start_reloading`] with explicit live-review retention.
3599    ///
3600    /// # Errors
3601    ///
3602    /// Same as [`Platform::start`].
3603    pub fn start_reloading_with_review_retention(
3604        registry: Registry,
3605        log: Box<dyn OpLog>,
3606        node_key: ActorKey,
3607        keys_file: Option<std::path::PathBuf>,
3608        retention: ReviewRetention,
3609    ) -> Result<Self, String> {
3610        Self::start_inner(registry, log, node_key, keys_file, Some(retention))
3611    }
3612
3613    fn start_inner(
3614        mut registry: Registry,
3615        log: Box<dyn OpLog>,
3616        node_key: ActorKey,
3617        keys_file: Option<std::path::PathBuf>,
3618        retention: Option<ReviewRetention>,
3619    ) -> Result<Self, String> {
3620        registry
3621            .register(&node_key.public_key_bytes())
3622            .map_err(|e| format!("register node key: {e:?}"))?;
3623        let (view, review_retention, concentration, workspace_tally) =
3624            materialize_platform_state(log.as_ref(), retention)
3625                .map_err(|e| format!("replay: {e:?}"))?;
3626        let review_retention = review_retention.map(|state| Arc::new(Mutex::new(state)));
3627        let view = Arc::new(Mutex::new(view));
3628        let concentration = Arc::new(Mutex::new(concentration));
3629        let workspace_tally = Arc::new(Mutex::new(workspace_tally));
3630        // Fill the window from the tail only. Materialising the whole log
3631        // into a Vec and pushing each entry through the window cloned
3632        // every entry twice at startup and held a second full copy of the
3633        // log in memory alongside the log itself -- O(total ops) for a
3634        // window that keeps at most `cap`.
3635        let len = log.len();
3636        let start = len.saturating_sub(LOG_WINDOW_CAP as u64);
3637        let mut window = LogWindow {
3638            base: start,
3639            entries: std::collections::VecDeque::with_capacity(
3640                (len - start).min(LOG_WINDOW_CAP as u64) as usize,
3641            ),
3642            cap: LOG_WINDOW_CAP,
3643            by_signing: std::collections::HashMap::new(),
3644            by_hash: std::collections::HashMap::new(),
3645        };
3646        // Seeding needs each entry's hash, and the log stores it already:
3647        // every entry's `parent` is its predecessor's hash, and the last
3648        // one's is the log head. So a restart re-indexes the window
3649        // without hashing anything, and a scope signed just before the
3650        // restart is still admissible just after it.
3651        let mut pending: Option<OpEntry> = None;
3652        for i in start..len {
3653            let current = log.get(i);
3654            if let (Some(previous), Some(current)) = (pending.take(), current.as_ref()) {
3655                let hash = current
3656                    .parent
3657                    .clone()
3658                    .unwrap_or_else(|| previous.content_hash());
3659                window.push(previous, hash);
3660            }
3661            pending = current;
3662        }
3663        if let Some(last) = pending {
3664            let hash = log.head().unwrap_or_else(|| last.content_hash());
3665            window.push(last, hash);
3666        }
3667        let entries = Arc::new(Mutex::new(window));
3668        let keys_mtime = keys_file
3669            .as_ref()
3670            .and_then(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
3671        // Name bindings are read from the same file at startup; a
3672        // malformed file here is not fatal because `start_reloading`
3673        // already accepted the caller's registry.
3674        let parsed = keys_file
3675            .as_ref()
3676            .map(|path| crate::parse_keys_file(path).ok());
3677        let key_names = Arc::new(Mutex::new(match &parsed {
3678            Some(Some(signers)) => KeyBindings::from_signers(signers),
3679            Some(None) => KeyBindings::unavailable(true),
3680            None => KeyBindings::unavailable(false),
3681        }));
3682        let signers = Mutex::new(parsed.flatten().unwrap_or_default());
3683        let require_assignment = Arc::new(std::sync::atomic::AtomicBool::new(false));
3684        let protected_refs = Arc::new(Mutex::new(None));
3685        let acl_file = Arc::new(Mutex::new(None));
3686        let require_review = Arc::new(std::sync::atomic::AtomicBool::new(false));
3687        let require_scope = Arc::new(std::sync::atomic::AtomicBool::new(false));
3688        let hooks = Arc::new(Mutex::new(None));
3689        let followers = Arc::new(Mutex::new(None));
3690        let passkeys = Arc::new(Mutex::new(None));
3691        let shared_journal = SharedJournal::default();
3692        let home = Arc::new(std::sync::OnceLock::new());
3693        let sequencer = Sequencer::spawn_with_journal(
3694            log,
3695            Box::new(ChoirPolicy {
3696                home: home.clone(),
3697                journal: shared_journal.clone(),
3698                subject: (None, None),
3699                require_assignment: require_assignment.clone(),
3700                protected_refs: protected_refs.clone(),
3701                acl_file: acl_file.clone(),
3702                require_review: require_review.clone(),
3703                require_scope: require_scope.clone(),
3704                registry,
3705                view: view.clone(),
3706                entries: entries.clone(),
3707                keys_file,
3708                keys_mtime,
3709                node_pub: node_key.public_key_bytes().to_vec(),
3710                node_id: node_key.actor_id(),
3711                key_names: key_names.clone(),
3712                concentration: concentration.clone(),
3713                review_retention: review_retention.clone(),
3714                hooks: hooks.clone(),
3715                followers: followers.clone(),
3716                workspace_tally: workspace_tally.clone(),
3717                passkeys: passkeys.clone(),
3718            }),
3719            Box::new(shared_journal.clone()),
3720        );
3721        let platform = Self {
3722            journal: shared_journal,
3723            handle: sequencer.handle(),
3724            lag: sequencer.lag(),
3725            lag_log: None,
3726            lag_log_error: Mutex::new((None, 0)),
3727            view,
3728            node_key: Arc::new(node_key),
3729            entries,
3730            reviewer_pool: None,
3731            reviewer_conflict_graph: None,
3732            log_path: None,
3733            batch_limit: DEFAULT_BATCH_OPS,
3734            require_assignment,
3735            protected_refs,
3736            acl_file,
3737            require_review,
3738            require_scope,
3739            passkeys,
3740            key_names,
3741            signers,
3742            concentration,
3743            workspace_tally,
3744            review_retention,
3745            newcomer_audit: None,
3746            review_adjudications: None,
3747            review_prune_lock: Mutex::new(()),
3748            hooks,
3749            followers,
3750            home,
3751            replica: std::sync::OnceLock::new(),
3752            _sequencer: sequencer,
3753        };
3754        // Enabling a bound applies it at startup, not only after some
3755        // unrelated client happens to write. There are no external handles
3756        // yet, so any refusal here is a real maintenance/startup failure.
3757        if platform.review_retention.is_some() {
3758            let outcome = platform.prune_reviews();
3759            if !outcome.errors.is_empty() {
3760                return Err(format!(
3761                    "review retention failed during startup: {}",
3762                    serde_json::Value::Array(outcome.errors)
3763                ));
3764            }
3765        }
3766        Ok(platform)
3767    }
3768
3769    /// Enables sparse, durable D24 T4 newcomer measurement.
3770    ///
3771    /// `incumbent_actor_keys` is the trusted-key snapshot at activation;
3772    /// those actors are excluded because the audit cannot reconstruct their
3773    /// first attempt or time-to-first-acceptance. The audit records only a
3774    /// later actor's first verified signed-API attempt, its first eventual
3775    /// acceptance, and an optional appeal. Operator adjudications are JSONL
3776    /// rows in the separate file and are re-read for every report.
3777    ///
3778    /// Both files are created mode 0600. Neither changes the signed op log or
3779    /// any hash input.
3780    ///
3781    /// # Errors
3782    ///
3783    /// Unusable paths or an invalid existing audit file.
3784    pub fn with_newcomer_audit(
3785        mut self,
3786        audit_path: std::path::PathBuf,
3787        adjudications_path: std::path::PathBuf,
3788        incumbent_actor_keys: Vec<String>,
3789    ) -> Result<Self, String> {
3790        if let Some(parent) = adjudications_path.parent() {
3791            std::fs::create_dir_all(parent)
3792                .map_err(|e| format!("create adjudications directory: {e}"))?;
3793        }
3794        let mut options = std::fs::OpenOptions::new();
3795        options.create(true).append(true);
3796        #[cfg(unix)]
3797        {
3798            use std::os::unix::fs::OpenOptionsExt;
3799            options.mode(0o600);
3800        }
3801        options
3802            .open(&adjudications_path)
3803            .map_err(|e| format!("open adjudications: {e}"))?;
3804        #[cfg(unix)]
3805        {
3806            use std::os::unix::fs::PermissionsExt;
3807            std::fs::set_permissions(&adjudications_path, std::fs::Permissions::from_mode(0o600))
3808                .map_err(|e| format!("chmod adjudications: {e}"))?;
3809        }
3810        let audit = NewcomerAudit::open(
3811            &audit_path,
3812            adjudications_path,
3813            incumbent_actor_keys.into_iter().collect(),
3814        )?;
3815        self.newcomer_audit = Some(Arc::new(Mutex::new(audit)));
3816        Ok(self)
3817    }
3818
3819    /// Enables the D24 T2 operator classification file: versioned 0600 JSONL
3820    /// rows of `{"format_version":1,"review_id":…,"classification":…}` where
3821    /// classification is `valid`, `invalid`, `slop` or `unclear`.
3822    ///
3823    /// Separate from the signed op log on purpose, exactly like the T4
3824    /// adjudications: a judgement about a contribution is the operator's
3825    /// opinion, not a sequenced claim any actor can make. Keying it by review
3826    /// id rather than by verdict is what lets a classification outlive
3827    /// archiving, which discards the verdict bulk.
3828    ///
3829    /// Enabling it does not make T2 evaluable. The cohort still has no exit
3830    /// rule, the observation window and tripwire subject are unset, and census
3831    /// adjudication cannot survive the flood it would need to detect.
3832    ///
3833    /// # Errors
3834    ///
3835    /// The file or its directory cannot be created.
3836    pub fn with_review_adjudications(mut self, path: std::path::PathBuf) -> Result<Self, String> {
3837        if let Some(parent) = path.parent() {
3838            std::fs::create_dir_all(parent)
3839                .map_err(|e| format!("create review adjudications directory: {e}"))?;
3840        }
3841        let mut options = std::fs::OpenOptions::new();
3842        options.create(true).append(true);
3843        #[cfg(unix)]
3844        {
3845            use std::os::unix::fs::OpenOptionsExt;
3846            options.mode(0o600);
3847        }
3848        options
3849            .open(&path)
3850            .map_err(|e| format!("open review adjudications: {e}"))?;
3851        #[cfg(unix)]
3852        {
3853            use std::os::unix::fs::PermissionsExt;
3854            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
3855                .map_err(|e| format!("chmod review adjudications: {e}"))?;
3856        }
3857        self.review_adjudications = Some(path);
3858        Ok(self)
3859    }
3860
3861    /// Refuses any `RequestReview` that names its own reviewers, so the
3862    /// node's draw is the only path to a reviewer list (D24 layer 5,
3863    /// "the requester does not choose who reviews them" — enforced
3864    /// rather than merely offered).
3865    ///
3866    /// Requires a reviewer pool: without one no review can ever be
3867    /// assigned, so every request would stall unassigned.
3868    ///
3869    /// Scope: node-wide, the blunt instrument. For "only reviews landing
3870    /// somewhere that matters", see [`Platform::with_protected_refs`],
3871    /// which conditions on `RequestReview`'s `target_ref`.
3872    #[must_use]
3873    pub fn with_required_assignment(self) -> Self {
3874        self.require_assignment
3875            .store(true, std::sync::atomic::Ordering::Relaxed);
3876        self
3877    }
3878
3879    /// Points the platform at an operator-curated list of protected refs
3880    /// (one `<repo>:<refname>` pattern per line, `#` comments allowed, a
3881    /// single trailing `*` acting as a prefix glob). A `RequestReview`
3882    /// whose `target_ref` matches must go through the node's draw; one
3883    /// naming an unprotected ref, or naming no ref at all, may still pick
3884    /// its own reviewers.
3885    ///
3886    /// This is the per-review half of D24 layer 5 — "privilege-bearing"
3887    /// finally has a definition the code can read, rather than the
3888    /// node-wide approximation of [`Platform::with_required_assignment`].
3889    ///
3890    /// Requires a reviewer pool, for the same reason.
3891    ///
3892    /// On its own this only binds *reviews*: a requester who omits
3893    /// `target_ref` still escapes it. [`Platform::with_required_review`]
3894    /// is the other half, and closes that.
3895    #[must_use]
3896    pub fn with_protected_refs(self, path: std::path::PathBuf) -> Self {
3897        *self.protected_refs.lock().expect("protected refs lock") = Some(path);
3898        self
3899    }
3900
3901    /// Points the admission policy at the operator's ACL file, so a
3902    /// landing on a protected ref can ask who owns the repository (D42).
3903    ///
3904    /// Without this the ownership rule is simply absent and every
3905    /// protected ref keeps the approval-weight gate, which is the
3906    /// behaviour of every node built before D42. Ownership is opt-in per
3907    /// repository even once the file is attached: a repository nobody
3908    /// holds `own` over is unaffected.
3909    ///
3910    /// The path, not a parsed table, because the file is hot-reloadable
3911    /// and admission must see an ownership change without a restart —
3912    /// the same reason [`Platform::with_protected_refs`] takes a path.
3913    #[must_use]
3914    pub fn with_acl_file(self, path: std::path::PathBuf) -> Self {
3915        *self.acl_file.lock().expect("acl file lock") = Some(path);
3916        self
3917    }
3918
3919    /// Gives the admission policy the credential store, so a submission
3920    /// signed by an enrolled passkey can be verified (D39).
3921    ///
3922    /// A setter rather than a constructor argument because accounts and
3923    /// the platform are enabled independently, in either order, by
3924    /// separate flags. Until this is called a passkey submission is
3925    /// refused for want of a store, which is the same answer a node
3926    /// without self-service gives permanently.
3927    pub fn attach_accounts(&self, store: Arc<crate::accounts::Accounts>) {
3928        *self.passkeys.lock().expect("passkey store lock") = Some(store);
3929    }
3930
3931    /// Writes the credential's public key into a passkey submission, so
3932    /// the entry it becomes can be checked by someone holding nothing
3933    /// but the log (D45).
3934    ///
3935    /// The node supplies this rather than the client because the client
3936    /// *cannot*: `getPublicKey()` exists on a WebAuthn registration
3937    /// response only, so a browser holding an assertion has the
3938    /// credential id and no key. The value written is the one the
3939    /// admission check is about to verify against, read from the same
3940    /// store by the same `(channel, credential id)` pair.
3941    ///
3942    /// **The request cannot influence this field.** `decode_submission`
3943    /// never reads a `credential_key` from the body, so there is no
3944    /// mismatch to refuse and no path by which a caller asserts key
3945    /// material for a credential it does not hold. "Refuse a bad value"
3946    /// and "make a bad value unrepresentable" answer the same question;
3947    /// the second needs no test to stay true.
3948    ///
3949    /// **Absent signature, absent store and unknown credential are all
3950    /// left alone.** Each is a reason [`ChoirPolicy::verify_passkey`]
3951    /// refuses this submission a moment later, with a code and a repair.
3952    /// Refusing here as well would be a second refusal for one cause,
3953    /// raised from the shape of the request instead of by the check that
3954    /// owns the question.
3955    fn stamp_credential_key(&self, sub: &mut DecodedSubmission) {
3956        let Some(sig) = sub.author_sig.as_mut() else {
3957            return;
3958        };
3959        if sig.scheme_id() != choir_oplog::scheme::WEBAUTHN_ES256 {
3960            return;
3961        }
3962        let store = self.passkeys.lock().expect("passkey store lock").clone();
3963        if let Some(spki) = store.and_then(|s| s.passkey_spki(&sub.channel, &sig.key_id)) {
3964            sig.credential_key = Some(spki);
3965        }
3966    }
3967
3968    /// A protected ref only moves to a commit that an **approved** review
3969    /// with weight from two distinct operators already named as its
3970    /// destination, and can never be deleted.
3971    /// This is the landing half of the gate: with it, omitting
3972    /// `target_ref` stops being an escape and becomes a refusal, because
3973    /// the push itself is what gets checked.
3974    ///
3975    /// Requires [`Platform::with_protected_refs`] — with no list nothing
3976    /// is protected and the flag would do nothing.
3977    ///
3978    /// **No exemption for the node's own key.** Every git push reaches the
3979    /// sequencer as a node-signed `SetRef`, so exempting the node would
3980    /// exempt every push. The consequence is deliberate and operational:
3981    /// switching this on means this daemon's *own* repository can only be
3982    /// advanced through a review, `choirctl sync` included.
3983    ///
3984    /// Creating a protected ref is allowed (`prev == None`): there is no
3985    /// history to hijack yet, and since deletion is refused, "delete then
3986    /// re-create" is not a way back in.
3987    ///
3988    /// Not covered, and not silently implied: force-pushes and
3989    /// non-fast-forward updates are only constrained by the CAS `prev`
3990    /// git itself supplies. A sufficiently weighted approval of commit X
3991    /// authorizes landing X, whether or not X is a descendant of the
3992    /// current tip.
3993    #[must_use]
3994    pub fn with_required_review(self) -> Self {
3995        self.require_review
3996            .store(true, std::sync::atomic::Ordering::Relaxed);
3997        self
3998    }
3999
4000    /// Admits only ops whose author bound them to this log and a head
4001    /// still in the window — the replay defence, turned on.
4002    ///
4003    /// Off by default because it is a wire-compatibility break, not
4004    /// because unscoped is safe: an unscoped signature is admissible on
4005    /// any node that trusts the key, and admissible again on the node it
4006    /// came from as soon as CAS state returns to what it expected. Every
4007    /// client in this repository always sends a scope, so turning this
4008    /// on costs them nothing; a client that predates scopes stops
4009    /// working, which is the whole reason for the flag.
4010    #[must_use]
4011    pub fn with_required_scope(self) -> Self {
4012        self.require_scope
4013            .store(true, std::sync::atomic::Ordering::Relaxed);
4014        self
4015    }
4016
4017    /// The log identity a client signs a scope against: this node's
4018    /// actor id and the head it should name.
4019    #[must_use]
4020    pub fn scope_now(&self) -> (ContentHash, Option<ContentHash>) {
4021        let head = self.entries.lock().expect("entries lock").head_hash();
4022        // On a seed the log is the home's, entry for entry, so the node a
4023        // scope must name is the home and the head is a head of the
4024        // home's log. A client that signs against a seed's view therefore
4025        // signs something the home will admit, which is what lets a
4026        // refused write be resent there unchanged.
4027        match self.home.get() {
4028            Some(home) => (home.node_id.clone(), head),
4029            None => (self.node_key.actor_id(), head),
4030        }
4031    }
4032
4033    /// Makes this platform a seed of `home` (D80): its log becomes a copy
4034    /// of the home's, taken through [`Platform::replicate`] and nothing
4035    /// else.
4036    ///
4037    /// Call before the platform serves anything.
4038    #[must_use]
4039    pub fn as_seed_of(self, home: crate::replica::Home) -> Self {
4040        // A second call would change whose copy this is mid-life; the
4041        // first one stands, and the daemon never makes a second.
4042        let _ = self.home.set(home);
4043        self
4044    }
4045
4046    /// The home this platform is a seed of, or `None` on a home.
4047    #[must_use]
4048    pub fn seed_home(&self) -> Option<&crate::replica::Home> {
4049        self.home.get()
4050    }
4051
4052    /// Records the replicator writing into this platform, and the home it
4053    /// copies. The first call stands.
4054    pub(crate) fn attach_replica(
4055        &self,
4056        home: crate::replica::Home,
4057        shared: Arc<crate::replica::Shared>,
4058    ) {
4059        let _ = self.home.set(home);
4060        let _ = self.replica.set(shared);
4061    }
4062
4063    /// What a cached page about this node must be re-rendered for beyond
4064    /// the view's position: on a seed, how far the home has run ahead and
4065    /// whether replication has stopped, neither of which moves the view.
4066    pub(crate) fn replica_marker(&self) -> Option<String> {
4067        let status = self
4068            .replica
4069            .get()?
4070            .status
4071            .lock()
4072            .expect("replica status lock");
4073        Some(format!(
4074            "{:?}:{}:{}",
4075            status.home_head_seq,
4076            status.halted.is_some(),
4077            status.gap
4078        ))
4079    }
4080
4081    /// Appends entries the home already sequenced, through this platform's
4082    /// own writer thread, and folds them into its view. See
4083    /// [`SequencerHandle::replicate`] for the checks each one passes.
4084    ///
4085    /// # Errors
4086    ///
4087    /// The first entry that could not be taken; everything before it was.
4088    pub fn replicate(
4089        &self,
4090        entries: Vec<choir_sequencer::Sequenced>,
4091    ) -> Result<choir_sequencer::Replicated, choir_sequencer::ReplicateError> {
4092        self.handle.replicate(entries)
4093    }
4094
4095    /// This node's own key: on a seed, the key its witness statements are
4096    /// signed with.
4097    pub(crate) fn node_key(&self) -> &ActorKey {
4098        &self.node_key
4099    }
4100
4101    /// Runs `read` against the view as it stands, under its lock.
4102    pub(crate) fn with_view<R>(&self, read: impl FnOnce(&View) -> R) -> R {
4103        read(&self.view.lock().expect("view lock"))
4104    }
4105
4106    /// The round of proposals aimed at `branch` of `repo` (D68).
4107    ///
4108    /// `None` when the branch does not exist. A round with no proposals
4109    /// is `Some` with an empty list: "nobody has proposed anything" and
4110    /// "there is nothing to propose to" are different answers, and only
4111    /// the second is a misconfiguration.
4112    #[must_use]
4113    pub fn proposal_round(&self, repo: &str, branch: &str) -> Option<crate::queue::ProposalRound> {
4114        let view = self.view.lock().expect("view lock");
4115        crate::queue::ProposalRound::from_view(&view, repo, branch)
4116    }
4117
4118    /// A landing that moves `refname` from `base`, signed by this node.
4119    ///
4120    /// The scope is read per landing rather than captured here, because
4121    /// a round appends as it goes; see [`crate::queue::ScopeSource`].
4122    #[must_use]
4123    pub fn ref_landing(&self, refname: String, base: &str) -> crate::queue::RefLanding {
4124        let entries = Arc::clone(&self.entries);
4125        let node = self.node_key.actor_id();
4126        crate::queue::RefLanding::new(
4127            Arc::clone(&self.node_key),
4128            refname,
4129            base,
4130            Box::new(move || {
4131                (
4132                    node.clone(),
4133                    entries.lock().expect("entries lock").head_hash(),
4134                )
4135            }),
4136        )
4137    }
4138
4139    /// A check reporter that this node signs and scopes (D49, D68).
4140    ///
4141    /// The reason `set_check_reporter` was off by default and composed
4142    /// into nothing: the identity a check is reported under belongs to
4143    /// whoever runs the queue, and a bridge has no node-side identity to
4144    /// use. A node does -- its own key -- and its landings are in a log
4145    /// that is kept rather than thrown away, so a report against them is
4146    /// answerable later.
4147    #[must_use]
4148    pub fn check_reporter(
4149        &self,
4150        name: String,
4151        target_ref: Option<String>,
4152    ) -> choir_queue::CheckReporter {
4153        let key = Arc::clone(&self.node_key);
4154        let entries = Arc::clone(&self.entries);
4155        let node = self.node_key.actor_id();
4156        choir_queue::CheckReporter {
4157            channel: crate::queue::QUEUE_CHANNEL.to_string(),
4158            name,
4159            target_ref,
4160            seal: Some(Arc::new(move |channel: &str, op: &ViewOp| {
4161                let head = entries.lock().expect("entries lock").head_hash();
4162                let payload = op.clone().in_scope(node.clone(), head).to_payload();
4163                let sig = key.sign_submission(channel, &payload);
4164                (payload, Some(sig))
4165            })),
4166        }
4167    }
4168
4169    /// Runs one speculative round over this node's own log (D5, D68).
4170    ///
4171    /// `workdir` must be a **worktree of the repository this node
4172    /// serves**, made with `git worktree add --detach` against the bare
4173    /// repo, and owned outright by the queue: every speculative merge
4174    /// detaches it and forces it to a candidate state, so a tree
4175    /// anybody else is working in is the wrong argument.
4176    ///
4177    /// A worktree and not an independent clone, and this is not a
4178    /// preference. A landing names the merge commit the speculator
4179    /// built. An independent clone writes that commit into its own
4180    /// object store, where the served repository cannot see it, so the
4181    /// log would name a commit git does not have --- which
4182    /// [`Platform::reconcile_git_refs`] correctly reads as the view
4183    /// being wrong and compensates back to git's value, silently
4184    /// undoing every landing in the round. A worktree shares the object
4185    /// store, so the commit is already there when the op is submitted.
4186    ///
4187    /// `None` when the branch does not exist. Otherwise the report says
4188    /// what landed, in order, and every landing in it is an op in this
4189    /// node's log rather than a number the round kept to itself.
4190    pub fn run_proposal_queue(
4191        &self,
4192        repo: &str,
4193        branch: &str,
4194        workdir: &std::path::Path,
4195        ci: &mut dyn choir_queue::executor::CiExecutor,
4196        template: choir_queue::JobTemplate,
4197    ) -> Option<choir_queue::QueueReport> {
4198        let round = self.proposal_round(repo, branch)?;
4199        let mut queue = choir_queue::MergeQueue::with_speculator(
4200            &round.base,
4201            Box::new(choir_queue::git::GitSpeculator::new(workdir.to_path_buf())),
4202        );
4203        queue.set_job_template(template);
4204        queue.set_landing(Box::new(self.ref_landing(round.target_ref(), &round.base)));
4205        // Every verdict the round reaches is recorded (D49). The subject
4206        // is the speculative commit CI actually ran against, so a check
4207        // is answerable about a tree rather than about a round number.
4208        queue.set_check_reporter(
4209            self.check_reporter("ci/queue".to_string(), Some(round.target_ref())),
4210        );
4211        // A proposal the branch already contains is not a candidate; see
4212        // [`crate::queue::ProposalRound::already_integrated`].
4213        let integrated = round.already_integrated(workdir);
4214        for change in round.changes() {
4215            if !integrated.contains(&change.id) {
4216                queue.submit(change);
4217            }
4218        }
4219        Some(self.drain_queue(&mut queue, ci))
4220    }
4221
4222    /// Drains a caller-built queue through this node's sequencer.
4223    ///
4224    /// The escape hatch under [`Platform::run_proposal_queue`], for a
4225    /// caller whose round is not simply "every proposal on this
4226    /// branch". The sequencer is the point: a landing recorded through
4227    /// anything else is not in the log this node serves.
4228    pub fn drain_queue(
4229        &self,
4230        queue: &mut choir_queue::MergeQueue,
4231        ci: &mut dyn choir_queue::executor::CiExecutor,
4232    ) -> choir_queue::QueueReport {
4233        queue.drain(ci, &self._sequencer)
4234    }
4235
4236    /// Points the platform at the JSON-lines file its op log persists to,
4237    /// so `/api/log?from=` can serve entries that have already been
4238    /// evicted from the in-memory window. Without it, a reader that has
4239    /// fallen further behind than the window is told so and cannot
4240    /// resync.
4241    ///
4242    /// The file is append-only, so reading a prefix while the writer
4243    /// thread appends is safe: already-written lines never change.
4244    #[must_use]
4245    pub fn with_log_path(mut self, path: std::path::PathBuf) -> Self {
4246        self.log_path = Some(path);
4247        self
4248    }
4249
4250    /// Sets the absolute operation-count ceiling for one batch request.
4251    #[must_use]
4252    pub fn with_batch_limit(mut self, max_ops: usize) -> Self {
4253        self.batch_limit = max_ops.max(1);
4254        self
4255    }
4256
4257    /// Replaces the actor-id → bound-name map from a freshly parsed
4258    /// trusted-keys file.
4259    ///
4260    /// Called from the daemon's accept loop when the file's mtime moves,
4261    /// so binding a name to an already-trusted key takes effect on the
4262    /// next request rather than waiting for some later signature failure.
4263    /// That matters because a binding is a *tightening*: a gate that
4264    /// applies at an unpredictable future moment is not a gate.
4265    pub fn set_key_names(&self, signers: &[crate::TrustedKey]) {
4266        *self.key_names.lock().expect("key names lock") = KeyBindings::from_signers(signers);
4267        *self.signers.lock().expect("signers lock") = signers.to_vec();
4268    }
4269
4270    /// `GET /api/signers`: this node's own public key and every key in
4271    /// its trusted-keys table, with the channel name a key is bound to
4272    /// when its line carries one.
4273    ///
4274    /// Public keys are public data. It sits behind the log's own grant
4275    /// anyway, because the reader it exists for is one that already
4276    /// holds that grant: somebody replaying `/api/log` who wants to check
4277    /// authorship, which SYNC.md's third check cannot do without a key.
4278    fn signers_json(&self) -> serde_json::Value {
4279        let signers: Vec<serde_json::Value> = self
4280            .signers
4281            .lock()
4282            .expect("signers lock")
4283            .iter()
4284            .map(|signer| {
4285                serde_json::json!({
4286                    "actor_id": signer.actor_id,
4287                    "public_key_hex": hex_encode(&signer.key),
4288                    "name": signer.name,
4289                })
4290            })
4291            .collect();
4292        serde_json::json!({
4293            "format_version": 1,
4294            "node": {
4295                "actor_id": self.node_key.actor_id().to_hex(),
4296                "public_key_hex": hex_encode(&self.node_key.public_key_bytes()),
4297            },
4298            "signers": signers,
4299        })
4300    }
4301
4302    /// Shrinks the in-memory `/api/log` window. Exists so tests can
4303    /// exercise eviction and the resync path without writing 100k ops.
4304    #[must_use]
4305    pub fn with_log_window_cap(self, cap: usize) -> Self {
4306        let mut window = self.entries.lock().expect("entries lock");
4307        window.cap = cap.max(1);
4308        // Trim to the new cap now rather than waiting for the next push.
4309        // Without this a platform started over an existing log stays
4310        // over-full until something is submitted, so `/api/log` would
4311        // serve entries from below `base` and a reader could not tell the
4312        // window had shrunk.
4313        window.trim();
4314        drop(window);
4315        self
4316    }
4317
4318    /// Points the platform at an operator-curated pool of eligible
4319    /// reviewer names (one per line, `#` comments allowed). With a pool
4320    /// set, a `RequestReview` carrying an empty reviewer list is
4321    /// answered by a node-signed [`OpKind::AssignReviewers`] drawn from
4322    /// the pool, excluding the requester — D24 layer 5, so a requester
4323    /// cannot pick a friendly reviewer.
4324    #[must_use]
4325    pub fn with_reviewer_pool(mut self, path: std::path::PathBuf) -> Self {
4326        self.reviewer_pool = Some(path);
4327        self
4328    }
4329
4330    /// Excludes reviewer operators whose shortest path from the requester
4331    /// in `path` is at most `max_distance`. Each non-comment line is one
4332    /// undirected `<operator> <operator>` edge. Operator names, not full
4333    /// `operator/agent` channels, belong in the graph.
4334    ///
4335    /// The graph is operator-supplied runtime policy rather than op-log
4336    /// state: changing it affects future draws without changing persisted
4337    /// operations or replay. It is read for every draw and fails closed;
4338    /// an unreadable or malformed graph leaves the review unassigned.
4339    /// Distance zero retains the existing same-operator exclusion.
4340    #[must_use]
4341    pub fn with_reviewer_conflict_graph(
4342        mut self,
4343        path: std::path::PathBuf,
4344        max_distance: usize,
4345    ) -> Self {
4346        self.reviewer_conflict_graph = Some((path, max_distance));
4347        self
4348    }
4349}
4350
4351/// The channel a push-derived op is attributed to, and how it was
4352/// established.
4353///
4354/// Extracted so the ref op and the review a magic push opens beside it
4355/// cannot disagree about who pushed. They are two ops about one act, and
4356/// a review drawn against a different channel than the ref it belongs to
4357/// would exclude the wrong operator from its own reviewer draw.
4358fn push_attribution(user: &str, cert: Option<(&str, &str)>) -> (String, Provenance) {
4359    // Verified push certificate ("G" = good signature) attributes the op
4360    // to the pusher's own key; otherwise the transport user. Either way
4361    // the node signs, and the payload says so (D41): the channel prefix
4362    // alone carried this class only by convention.
4363    match cert {
4364        Some(("G", signer)) if !signer.is_empty() => {
4365            (format!("key/{signer}"), Provenance::PushCertified)
4366        }
4367        _ => (crate::quota::channel_for(user), Provenance::PushTransport),
4368    }
4369}
4370
4371/// A proposal pushed to the magic refspec, as parsed from its refname
4372/// (D53).
4373///
4374/// Gerrit's `refs/for/<branch>` and AGit's after it are the spelling
4375/// every reviewer of a git-hosted project already knows, and the point
4376/// of borrowing it is that the client is `git` and nothing else: no
4377/// binary to install, no key to mint, one push.
4378///
4379/// Two departures from Gerrit, both deliberate:
4380///
4381/// 1. **The ref is really created.** Gerrit intercepts `refs/for/*` in a
4382///    server it owns end to end and, in its own documentation's words,
4383///    lies to the client about the result. Doing that over git's wire
4384///    protocol needs a `proc-receive` hook, which is a second hook type
4385///    and a second protocol; and a node that reported a ref it did not
4386///    write would be a node whose push receipts cannot be trusted. Here
4387///    the ref exists, holds the objects, and is sequenced like any other.
4388/// 2. **A topic is required.** `refs/for/main` alone is one ref shared by
4389///    everyone proposing onto `main`, so the second contributor's push
4390///    would be a non-fast-forward against the first one's proposal
4391///    rather than a proposal of their own. The topic is what makes the
4392///    ref theirs.
4393#[derive(Debug)]
4394struct MagicRef {
4395    /// Branch the proposal asks to land on, as a short name.
4396    onto: String,
4397    /// Review id, derived so that re-pushing the same topic reaches the
4398    /// same review rather than opening a second one.
4399    review_id: String,
4400}
4401
4402impl MagicRef {
4403    /// Parses `refs/for/<branch>/<topic>`, or `None` for any other ref.
4404    ///
4405    /// `Err` is reserved for a ref that *is* under `refs/for/` and
4406    /// cannot be used, because that is a pusher who meant to propose and
4407    /// needs telling why it did not work -- the one case where silence
4408    /// would look like success.
4409    fn parse(refname: &str) -> Option<Result<Self, String>> {
4410        let rest = refname.strip_prefix("refs/for/")?;
4411        let mut segments = rest.split('/').filter(|s| !s.is_empty());
4412        let (Some(onto), Some(first_topic)) = (segments.next(), segments.next()) else {
4413            return Some(Err(format!(
4414                "push to refs/for/<branch>/<topic>, not {refname}: a topic is what makes this \
4415                 proposal yours rather than one ref shared by everyone proposing onto that branch"
4416            )));
4417        };
4418        let topic: Vec<&str> = std::iter::once(first_topic).chain(segments).collect();
4419        let topic = topic.join("-");
4420        if !crate::provision::safe_segment(onto) {
4421            return Some(Err(format!(
4422                "`{onto}` is not a branch name this node can land on"
4423            )));
4424        }
4425        // The id reaches a URL (`/r/<repo>/review/<id>`), which admits
4426        // one path segment. The branch is folded in so that the same
4427        // topic proposed onto two branches is two reviews.
4428        let review_id = format!("for-{onto}-{topic}");
4429        if !crate::provision::safe_segment(&review_id) {
4430            return Some(Err(format!(
4431                "`{topic}` holds characters a review id cannot: use letters, digits, `-`, `_` \
4432                 or `.`"
4433            )));
4434        }
4435        Some(Ok(Self {
4436            onto: onto.to_string(),
4437            review_id,
4438        }))
4439    }
4440}
4441
4442impl Platform {
4443    /// Opens a review for a push to the magic refspec, if the ref was one.
4444    ///
4445    /// Runs after the ref op is already durable, and returns `Ok` when
4446    /// the ref was not a magic one -- so an ordinary push pays nothing
4447    /// and cannot fail here.
4448    ///
4449    /// A second push to the same topic does **not** re-request: a review
4450    /// is one long-lived object per proposal, re-posting a verdict is the
4451    /// re-review flow, and asking again would be refused. What advances
4452    /// is the ref, which is where a reviewer reads the current commit
4453    /// from.
4454    fn open_magic_review(
4455        &self,
4456        repo: &str,
4457        magic: &MagicRef,
4458        new_hex: &str,
4459        user: &str,
4460        cert: Option<(&str, &str)>,
4461    ) -> Result<(), String> {
4462        if self
4463            .view
4464            .lock()
4465            .expect("view lock")
4466            .reviews
4467            .contains_key(&magic.review_id)
4468        {
4469            return Ok(());
4470        }
4471        let target = ContentHash::from_git_oid(new_hex).ok_or("bad new oid")?;
4472        self.submit_ref_op(
4473            OpKind::RequestReview {
4474                id: magic.review_id.clone(),
4475                target,
4476                // Empty on purpose: the node draws them, and a pusher
4477                // cannot name their own reviewers here any more than
4478                // they can through the CLI.
4479                reviewers: Vec::new(),
4480                target_ref: Some(format!("{repo}:refs/heads/{}", magic.onto)),
4481            },
4482            user,
4483            cert,
4484        )?;
4485        // The draw is a second, separate submission -- the same shape
4486        // `/api/submit` uses, and for the same reason: it names
4487        // reviewers, so it cannot be folded into the request that has
4488        // not been admitted yet.
4489        //
4490        // A failed draw does not fail the push. The request stands and
4491        // is visibly unassigned, which is a state no verdict can
4492        // complete; refusing the push instead would throw away objects
4493        // the node has already accepted over an empty reviewer pool.
4494        let (channel, _) = push_attribution(user, cert);
4495        if let Err(reason) = self.assign_reviewers(&magic.review_id, &channel) {
4496            eprintln!(
4497                "choir: review {} opened unassigned: {reason}",
4498                magic.review_id
4499            );
4500        }
4501        Ok(())
4502    }
4503}
4504
4505/// Refuses a ref update that the pusher's grant does not reach (D60).
4506///
4507/// The other half of [`crate::acl::Level::Propose`], and the half that
4508/// has a refname to look at. [`crate::acl::git_requirement`] admits the
4509/// push at `propose` because git sends the ref list only after the
4510/// server has agreed to receive the pack, so there is nothing to check
4511/// at that boundary. The refname first exists here, when the
4512/// `pre-receive` hook reports it.
4513///
4514/// Checked against the **merged** table rather than
4515/// [`Platform::acl_now`]. That reader exists so `own` cannot be
4516/// self-issued (D42); `write` carries no such rule, and a grant issued
4517/// by self-service (D36) is as real as one the operator typed. Reading
4518/// the file alone here would refuse a legitimate pusher whose grant came
4519/// from an invite.
4520///
4521/// Refuses before anything is submitted, which is the rule
4522/// [`Platform::git_update`] already follows for a proposal ref it cannot
4523/// parse: git applies no ref until the hook exits zero, so a refusal at
4524/// this point leaves no op in the log and never enters the compensating
4525/// retraction pass that `Node::create_repo` documents.
4526///
4527/// `None` whenever the question does not arise: a body this does not
4528/// understand, or a pusher who holds `write` and is therefore not
4529/// limited to proposals.
4530pub(crate) fn proposal_denial(
4531    acl: &crate::acl::Effective,
4532    body: &[u8],
4533) -> Option<crate::acl::Denial> {
4534    let json: serde_json::Value = serde_json::from_slice(body).ok()?;
4535    let field = |key: &str| {
4536        json.get(key)
4537            .and_then(serde_json::Value::as_str)
4538            .unwrap_or_default()
4539    };
4540    let (repo, refname, user) = (field("repo"), field("refname"), field("user"));
4541    if repo.is_empty() || refname.is_empty() {
4542        return None;
4543    }
4544    if acl.allows_repo(user, repo, crate::acl::Level::Write) {
4545        return None;
4546    }
4547    let refuse = |reason: String| {
4548        Some(crate::acl::Denial {
4549            status: 403,
4550            reason,
4551        })
4552    };
4553    let Some(rest) = refname.strip_prefix("refs/for/") else {
4554        return refuse(format!(
4555            "`{user}` may propose to {repo} but not write {refname}; \
4556             push to refs/for/<branch>/{user}/<topic> to open a review instead"
4557        ));
4558    };
4559    // Under their own name, so two proposers cannot reach one ref. A
4560    // `propose` grant is the level given to somebody the repository does
4561    // not trust, and several of them hold it at once: without this,
4562    // whoever pushes second silently takes over the first one's proposal,
4563    // or deletes it.
4564    //
4565    // Read off the raw segments rather than `MagicRef`'s topic, because
4566    // that topic is joined with dashes and therefore lossy -- `a/b` and
4567    // `a-b` reach the same review id, and a rule about who owns a ref
4568    // must not be decided by a form that has already merged two names.
4569    //
4570    // A ref under `refs/for/` that this refuses is answered here rather
4571    // than by `git_update`'s own "a topic is what makes this proposal
4572    // yours": for this pusher, naming themselves is the missing part, and
4573    // the message that says so is the more useful of the two. A `write`
4574    // holder never reaches here and still gets the other one.
4575    let mut segments = rest.split('/').filter(|s| !s.is_empty());
4576    let (branch, owner) = (segments.next(), segments.next());
4577    if owner != Some(user) {
4578        let branch = branch.unwrap_or("<branch>");
4579        return refuse(format!(
4580            "`{user}` may propose to {repo} only under their own name; \
4581             push to refs/for/{branch}/{user}/<topic>"
4582        ));
4583    }
4584    None
4585}
4586
4587impl Platform {
4588    /// Routes one git ref update (from a repo's `update` hook) through
4589    /// the sequencer: CAS against the view, node-signed, totally ordered
4590    /// with API ops. Refs are namespaced `<repo>:<refname>`; git oids
4591    /// enter the envelope with their own codec ([`ContentHash::from_git_oid`]).
4592    ///
4593    /// A push to `refs/for/<branch>/<topic>` additionally opens a review
4594    /// targeting that branch, which is the whole magic-refspec path: one
4595    /// `git push`, no client but git, and reviewers drawn by the node.
4596    ///
4597    /// # Errors
4598    ///
4599    /// The policy's rejection reason (stale CAS = concurrent update git
4600    /// itself would also have refused), or the reason a `refs/for/` push
4601    /// could not be read as a proposal.
4602    pub fn git_update(
4603        &self,
4604        repo: &str,
4605        refname: &str,
4606        old_hex: &str,
4607        new_hex: &str,
4608        user: &str,
4609        cert: Option<(&str, &str)>,
4610    ) -> Result<(), String> {
4611        // Read before anything is submitted: a `refs/for/` push that
4612        // cannot be read as a proposal must be refused whole, not left
4613        // as a created ref with no review beside it.
4614        let magic = match MagicRef::parse(refname) {
4615            Some(Ok(magic)) => Some(magic),
4616            Some(Err(reason)) => return Err(reason),
4617            None => None,
4618        };
4619        let name = format!("{repo}:{refname}");
4620        let prev = if is_zero_oid(old_hex) {
4621            None
4622        } else {
4623            Some(ContentHash::from_git_oid(old_hex).ok_or("bad old oid")?)
4624        };
4625        let deleting = is_zero_oid(new_hex);
4626        let kind = if deleting {
4627            OpKind::DeleteRef { name, prev }
4628        } else {
4629            OpKind::SetRef {
4630                name,
4631                commit: ContentHash::from_git_oid(new_hex).ok_or("bad new oid")?,
4632                prev,
4633            }
4634        };
4635        self.submit_ref_op(kind, user, cert)?;
4636        // Only on a ref that now points somewhere. Deleting a proposal
4637        // ref withdraws the objects; it does not open a review on the
4638        // zero oid, and it deliberately does not close the review
4639        // either -- a review is append-only history, and abandoning one
4640        // is its own signed act.
4641        match magic {
4642            Some(magic) if !deleting => self.open_magic_review(repo, &magic, new_hex, user, cert),
4643            _ => Ok(()),
4644        }
4645    }
4646
4647    /// Retracts a ref op this push already had accepted, because the push
4648    /// as a whole is being refused and git will apply none of it.
4649    ///
4650    /// `pre-receive` submits one op per ref but git applies no ref until
4651    /// the hook exits zero, so a push whose third ref is refused has
4652    /// already put two ops in the durable log. Without this the view keeps
4653    /// refs git never created — and they cannot be pushed afterwards
4654    /// either, because the pusher's `old` is git's (absent) value while
4655    /// the view holds the stranded one, so every retry loses the CAS. The
4656    /// ref becomes permanently unpushable.
4657    ///
4658    /// The log is append-only, so the repair is a compensating op, not an
4659    /// erasure: the abort is part of the history rather than hidden from
4660    /// it. `old`/`new` are the same values the accepted op carried, so the
4661    /// inverse restores exactly what git still has.
4662    ///
4663    /// # Errors
4664    ///
4665    /// The policy's rejection reason — most likely a lost CAS, meaning
4666    /// something else moved the ref between the accept and this retraction
4667    /// and the stranded value is no longer what would be undone.
4668    pub fn git_abort(
4669        &self,
4670        repo: &str,
4671        refname: &str,
4672        old_hex: &str,
4673        new_hex: &str,
4674        user: &str,
4675        cert: Option<(&str, &str)>,
4676    ) -> Result<(), String> {
4677        let name = format!("{repo}:{refname}");
4678        // CAS on what the accepted op set, so a retraction that races a
4679        // real update loses instead of clobbering it.
4680        let prev = Some(ContentHash::from_git_oid(new_hex).ok_or("bad new oid")?);
4681        let kind = if is_zero_oid(old_hex) {
4682            // The push was creating the ref, so undoing it removes it.
4683            OpKind::DeleteRef { name, prev }
4684        } else {
4685            // It existed before: put it back where git still has it.
4686            OpKind::SetRef {
4687                name,
4688                commit: ContentHash::from_git_oid(old_hex).ok_or("bad old oid")?,
4689                prev,
4690            }
4691        };
4692        self.submit_ref_op(kind, user, cert)
4693    }
4694
4695    /// Brings every bare repo under `root` back into agreement with the
4696    /// view, which is the source of truth. Run at startup, before the
4697    /// node serves anything, so nothing races the repair.
4698    ///
4699    /// The hook's retraction path handles a push that is refused while
4700    /// the daemon is alive. This handles the rest, and it does so without
4701    /// needing to know which of them happened: power loss between the
4702    /// hook's 200 and git writing the ref, a per-ref failure *after*
4703    /// `pre-receive` passed (`receive.deny*`, an `update` hook, a write
4704    /// error), or a retraction that could not be delivered. All of them
4705    /// leave the same state, and it is the state this reads.
4706    ///
4707    /// Two repairs, chosen by whether git can honour the view:
4708    ///
4709    /// - the commit exists in the repo, so git is simply behind: the ref
4710    ///   is written. Git is the follower (D21 single-canonical), so
4711    ///   moving it is the defined direction.
4712    /// - the commit is absent — a refused push has its objects discarded
4713    ///   from the quarantine — so the view names something git can never
4714    ///   have: a compensating op puts the view back to git's value.
4715    ///
4716    /// A ref git holds and the view does not is **reported, never
4717    /// adopted**. Appending an op for it would launder an out-of-band
4718    /// `update-ref` into the signed log as though it had been submitted.
4719    pub fn reconcile_git_refs(&self, root: &std::path::Path) -> RefReconciliation {
4720        let mut report = RefReconciliation::default();
4721        for finding in self.survey_git_refs(root) {
4722            let full = finding.name();
4723            match finding.state {
4724                // Git is behind on a commit it already has, so move it.
4725                RefState::GitBehind => {
4726                    let Some(want) = &finding.log_oid else {
4727                        continue;
4728                    };
4729                    let path = root.join(&finding.repo);
4730                    match write_git_ref(&path, &finding.refname, want, finding.git_oid.as_deref()) {
4731                        Ok(()) => report.applied.push(full),
4732                        Err(e) => report.unreconciled.push(format!("{full}: {e}")),
4733                    }
4734                }
4735                // Git can never hold this value, so the log gives way.
4736                // `git_abort` builds exactly this inverse: back to git's
4737                // value, or gone if git has none.
4738                RefState::LogUnbackable => {
4739                    let Some(want) = &finding.log_oid else {
4740                        continue;
4741                    };
4742                    let old = finding
4743                        .git_oid
4744                        .clone()
4745                        .unwrap_or_else(|| "0".repeat(want.len()));
4746                    match self.git_abort(&finding.repo, &finding.refname, &old, want, "node", None)
4747                    {
4748                        Ok(()) => report.retracted.push(full),
4749                        Err(e) => report.unreconciled.push(format!("{full}: {e}")),
4750                    }
4751                }
4752                RefState::GitOnly | RefState::Unreadable => report
4753                    .unreconciled
4754                    .push(format!("{full}: {}", finding.reason)),
4755            }
4756        }
4757        report
4758    }
4759
4760    /// Every way the repos under `root` and the view currently disagree,
4761    /// with nothing written and no op appended.
4762    ///
4763    /// This is the half of [`Platform::reconcile_git_refs`] that can be
4764    /// run against a live node. The repair deliberately cannot: it writes
4765    /// refs, so it belongs before the first request, where nothing races
4766    /// it. Reading is safe at any time, and without it a divergence that
4767    /// appears while the daemon is up is invisible until the next
4768    /// restart — which is the difference between a monitor and an
4769    /// autopsy.
4770    ///
4771    /// Served by `GET /api/ref-agreement`, which is deliberately its own
4772    /// endpoint rather than a field on `/api/view`: this shells out to
4773    /// git once per repo, and `/api/view` is on the hot path.
4774    ///
4775    /// Scope, stated because [`RefState::GitOnly`] reads like a stronger
4776    /// claim than it is: only repos the log already names are compared.
4777    /// A repo with refs and no log entry at all is not surveyed, so this
4778    /// finds an out-of-band ref beside logged ones, not an entire
4779    /// smuggled repo.
4780    pub fn survey_git_refs(&self, root: &std::path::Path) -> Vec<RefFinding> {
4781        let mut findings = Vec::new();
4782        let view_refs: Vec<(String, ContentHash)> = self
4783            .view
4784            .lock()
4785            .expect("view lock")
4786            .refs
4787            .iter()
4788            .map(|(name, hash)| (name.clone(), hash.clone()))
4789            .collect();
4790
4791        // One `for-each-ref` per repo rather than one `rev-parse` per
4792        // ref: a view with thousands of refs would otherwise start the
4793        // daemon with thousands of subprocesses.
4794        let mut repos: BTreeMap<String, Vec<(String, ContentHash)>> = BTreeMap::new();
4795        for (name, hash) in view_refs {
4796            match name.split_once(':') {
4797                Some((repo, refname)) => repos
4798                    .entry(repo.to_string())
4799                    .or_default()
4800                    .push((refname.to_string(), hash)),
4801                // Not a git-derived ref (the API can set any name), so
4802                // there is no repo to compare it against.
4803                None => continue,
4804            }
4805        }
4806
4807        for (repo, refs) in repos {
4808            // Ref names come out of the log, which anyone admitted can
4809            // write to, so they get the same traversal guard as a repo
4810            // name off the wire.
4811            if repo.split('/').any(|c| c == ".." || c.is_empty()) || repo.starts_with('/') {
4812                findings.push(RefFinding::unreadable(&repo, "", "refused as a repo path"));
4813                continue;
4814            }
4815            let path = root.join(&repo);
4816            let Some(in_git) = read_git_refs(&path) else {
4817                findings.push(RefFinding::unreadable(
4818                    &repo,
4819                    "",
4820                    &format!(
4821                        "the log holds {} ref(s) for a repo that cannot be read here",
4822                        refs.len()
4823                    ),
4824                ));
4825                continue;
4826            };
4827            let wanted: BTreeSet<String> = refs.iter().map(|(name, _)| name.clone()).collect();
4828            for (refname, want) in refs {
4829                let Some(want_oid) = want.git_oid() else {
4830                    findings.push(RefFinding::unreadable(
4831                        &repo,
4832                        &refname,
4833                        "log value is not a git oid",
4834                    ));
4835                    continue;
4836                };
4837                let have = in_git.get(&refname).cloned();
4838                if have.as_deref() == Some(want_oid.as_str()) {
4839                    continue;
4840                }
4841                // Whether git *can* be moved to the log is the whole
4842                // difference between the two repairs, so it is decided
4843                // here, while reading, and not again while writing.
4844                let state = if object_exists(&path, &want_oid) {
4845                    RefState::GitBehind
4846                } else {
4847                    RefState::LogUnbackable
4848                };
4849                findings.push(RefFinding {
4850                    repo: repo.clone(),
4851                    refname,
4852                    log_oid: Some(want_oid),
4853                    git_oid: have,
4854                    reason: match state {
4855                        RefState::GitBehind => "git is behind a commit it already has".into(),
4856                        _ => "the log names a commit this repo does not have".into(),
4857                    },
4858                    state,
4859                });
4860            }
4861            for (refname, oid) in &in_git {
4862                if !wanted.contains(refname) {
4863                    // A remote-tracking ref is this repo's own record of
4864                    // what it pushed elsewhere — the on-box follower feed
4865                    // writes refs/remotes/<follower>/* on every push — not
4866                    // canonical state, so its absence from the log is not a
4867                    // divergence. Skipped only on this side: a log that
4868                    // *does* name one is still compared above, and still
4869                    // retracted if git cannot back it.
4870                    if refname.starts_with("refs/remotes/") {
4871                        continue;
4872                    }
4873                    findings.push(RefFinding {
4874                        repo: repo.clone(),
4875                        refname: refname.clone(),
4876                        log_oid: None,
4877                        git_oid: Some(oid.clone()),
4878                        state: RefState::GitOnly,
4879                        reason: "in git, not in the log".into(),
4880                    });
4881                }
4882            }
4883        }
4884        findings
4885    }
4886
4887    /// Signs a git-derived ref op as the node and submits it, attributing
4888    /// it to the pusher's own key when the push certificate verified.
4889    fn submit_ref_op(
4890        &self,
4891        kind: OpKind,
4892        user: &str,
4893        cert: Option<(&str, &str)>,
4894    ) -> Result<(), String> {
4895        let (node, head) = self.scope_now();
4896        // Verified push certificate ("G" = good signature) attributes
4897        // the op to the pusher's own key; otherwise the transport user.
4898        // Either way the node signs, and the payload says so (D41): the
4899        // channel prefix alone carried this class only by convention.
4900        let (workspace, provenance) = push_attribution(user, cert);
4901        let payload = ViewOp::new(kind)
4902            .in_scope(node, head)
4903            .with_provenance(provenance)
4904            .to_payload();
4905        let sig = self.node_key.sign_submission(&workspace, &payload);
4906        self.handle
4907            .try_submit(&workspace, payload, Some(sig))
4908            .map(|_| ())?;
4909        // Every ref movement the node authors ends in an attestation of
4910        // where the refs now stand. Per accepted ref rather than per
4911        // push, because the pre-receive protocol has no end-of-push
4912        // signal to hang a single emission on.
4913        self.record_snapshot();
4914        Ok(())
4915    }
4916
4917    /// Attests the current ref-state (D25): submits a node-signed
4918    /// [`OpKind::RecordRefSnapshot`] of the view as it stands, and on
4919    /// admission projects the snapshot's canonical bytes to
4920    /// `refs.snapshot` beside the op log — the detached copy a backup
4921    /// pulls with the log, byte-identical to the payload in it.
4922    ///
4923    /// Best-effort on both legs. A lost submission race means another
4924    /// writer moved the view between read and submit, and that writer's
4925    /// own ref op ends in another attestation, so the chain catches up
4926    /// without retries here. The file write is tmp-plus-rename with a
4927    /// per-call tmp name: concurrent admissions cannot tear the file,
4928    /// and if their renames land out of order it briefly holds the older
4929    /// of two valid snapshots until the next attestation replaces it.
4930    fn record_snapshot(&self) {
4931        let snapshot = self.view.lock().expect("view lock").snapshot();
4932        let bytes = snapshot.canonical_bytes();
4933        let (node, head) = self.scope_now();
4934        let payload = ViewOp::new(OpKind::RecordRefSnapshot { snapshot })
4935            .in_scope(node, head)
4936            .to_payload();
4937        let channel = "node/snapshot";
4938        let sig = self.node_key.sign_submission(channel, &payload);
4939        if self.handle.try_submit(channel, payload, Some(sig)).is_err() {
4940            return;
4941        }
4942        let Some(log) = &self.log_path else { return };
4943        static TMP: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
4944        let n = TMP.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4945        let tmp = log.with_file_name(format!("refs.snapshot.{n}.tmp"));
4946        if std::fs::write(&tmp, &bytes)
4947            .and_then(|()| std::fs::rename(&tmp, log.with_file_name("refs.snapshot")))
4948            .is_err()
4949        {
4950            // The attestation is in the log either way; a missing
4951            // detached copy fails the next backup pull loudly, which is
4952            // the reader that cares.
4953            std::fs::remove_file(&tmp).ok();
4954        }
4955    }
4956
4957    /// Points `workspace` at git oid `head_hex` with a node-signed op,
4958    /// using the view's current head as the CAS `prev` (a lost race is
4959    /// a sequencer rejection, not a clobber). `attribution` is the
4960    /// submission channel (e.g. `git/<user>`), as for git-derived ops.
4961    ///
4962    /// # Errors
4963    ///
4964    /// Bad oid, or the policy's rejection reason.
4965    pub fn set_workspace_head(
4966        &self,
4967        workspace: &str,
4968        head_hex: &str,
4969        attribution: &str,
4970    ) -> Result<(), String> {
4971        let commit = ContentHash::from_git_oid(head_hex).ok_or("bad head oid")?;
4972        let prev = self
4973            .view
4974            .lock()
4975            .expect("view lock")
4976            .workspaces
4977            .get(workspace)
4978            .cloned();
4979        let (node, head) = self.scope_now();
4980        let payload = ViewOp::new(OpKind::SetWorkspaceHead {
4981            workspace: workspace.to_string(),
4982            commit,
4983            prev,
4984        })
4985        .in_scope(node, head)
4986        .to_payload();
4987        let sig = self.node_key.sign_submission(attribution, &payload);
4988        self.handle
4989            .try_submit(attribution, payload, Some(sig))
4990            .map(|_| ())
4991    }
4992
4993    /// Atomically creates a stable change and registers its workspace at
4994    /// an exact Git revision with a node-signed operation.
4995    pub fn create_change(
4996        &self,
4997        request: AuthorizedChangeCreate<'_>,
4998        attribution: &str,
4999    ) -> Result<choir_sequencer::Accepted, String> {
5000        let AuthorizedChangeCreate {
5001            id,
5002            owner,
5003            workspace,
5004            base_hex,
5005            idempotency_key,
5006            owner_sig,
5007            cone,
5008        } = request;
5009        let base_revision = ContentHash::from_git_oid(base_hex).ok_or("bad base revision")?;
5010        let payload = ViewOp::new(OpKind::CreateChange {
5011            id: id.to_string(),
5012            owner: owner.to_string(),
5013            workspace: workspace.to_string(),
5014            base_revision,
5015            idempotency_key: idempotency_key.to_string(),
5016            owner_sig: Some(owner_sig),
5017            cone,
5018        })
5019        .to_payload();
5020        let sig = self.node_key.sign_submission(attribution, &payload);
5021        match self
5022            .handle
5023            .try_submit(attribution, payload.clone(), Some(sig))
5024        {
5025            Ok(accepted) => Ok(accepted),
5026            Err(reason) => match self
5027                .entries
5028                .lock()
5029                .expect("entries lock")
5030                .already_applied(attribution, &payload)
5031            {
5032                Some((seq, hash)) => Ok(choir_sequencer::Accepted {
5033                    seq,
5034                    hash,
5035                    decision_latency: Duration::ZERO,
5036                }),
5037                None => Err(reason),
5038            },
5039        }
5040    }
5041
5042    /// Decodes the exact owner-signed create binding before any physical
5043    /// workspace is copied. Sequencer admission verifies the embedded
5044    /// signature before recording the node-authored change operation.
5045    pub fn decode_create_change_request(
5046        &self,
5047        request: &serde_json::Value,
5048        expected_id: &str,
5049        expected_owner: &str,
5050        expected_workspace: &str,
5051        expected_revision: &ContentHash,
5052        expected_idempotency_key: &str,
5053    ) -> Result<(Witness, Vec<String>), String> {
5054        let (sub, cone) = decode_create_submission(
5055            request,
5056            expected_id,
5057            expected_owner,
5058            expected_workspace,
5059            expected_revision,
5060            expected_idempotency_key,
5061        )?;
5062        // The cone comes back out of the *signed* authorization and
5063        // never out of the request body around it. That is the whole
5064        // guarantee: the node reports a scope its owner signed, or it
5065        // reports none (D50).
5066        Ok((
5067            sub.author_sig
5068                .expect("decode_submission always returns a signature"),
5069            cone,
5070        ))
5071    }
5072
5073    /// Submits a node-authored [`OpKind::ArchiveChange`] carrying the
5074    /// owner's signed authorization, after verifying that the signed
5075    /// payload names exactly the resource the endpoint already moved.
5076    pub fn submit_archive_change(
5077        &self,
5078        request: &serde_json::Value,
5079        expected_id: &str,
5080        expected_workspace: &str,
5081        expected_revision: &ContentHash,
5082        attribution: &str,
5083    ) -> Result<choir_sequencer::Accepted, String> {
5084        let sub =
5085            decode_archive_submission(request, expected_id, expected_workspace, expected_revision)?;
5086        let payload = ViewOp::new(OpKind::ArchiveChange {
5087            id: expected_id.to_string(),
5088            workspace: expected_workspace.to_string(),
5089            prev_revision: expected_revision.clone(),
5090            owner: sub.channel,
5091            owner_sig: sub
5092                .author_sig
5093                .expect("decode_submission always returns a signature"),
5094        })
5095        .to_payload();
5096        let signature = self.node_key.sign_submission(attribution, &payload);
5097        self.handle
5098            .try_submit(attribution, payload, Some(signature))
5099    }
5100
5101    /// Checks the archive request's signed payload shape before the
5102    /// filesystem is renamed. Signature and current-state admission still
5103    /// happen on the sequencer after the rename, with rollback on refusal.
5104    pub fn validate_archive_change_request(
5105        &self,
5106        request: &serde_json::Value,
5107        expected_id: &str,
5108        expected_workspace: &str,
5109        expected_revision: &ContentHash,
5110    ) -> Result<(), String> {
5111        decode_archive_submission(request, expected_id, expected_workspace, expected_revision)
5112            .map(|_| ())
5113    }
5114
5115    /// Original operation identity for an identical completed archive
5116    /// request still present in the durable log window.
5117    #[must_use]
5118    pub fn archive_change_receipt(
5119        &self,
5120        request: &serde_json::Value,
5121        expected_id: &str,
5122        expected_workspace: &str,
5123        expected_revision: &ContentHash,
5124        attribution: &str,
5125    ) -> Option<(u64, ContentHash)> {
5126        let sub =
5127            decode_archive_submission(request, expected_id, expected_workspace, expected_revision)
5128                .ok()?;
5129        let payload = ViewOp::new(OpKind::ArchiveChange {
5130            id: expected_id.to_string(),
5131            workspace: expected_workspace.to_string(),
5132            prev_revision: expected_revision.clone(),
5133            owner: sub.channel,
5134            owner_sig: sub.author_sig?,
5135        })
5136        .to_payload();
5137        self.entries
5138            .lock()
5139            .expect("entries lock")
5140            .already_applied(attribution, &payload)
5141    }
5142
5143    /// Current materialized state for one stable change.
5144    #[must_use]
5145    pub fn change_state(&self, id: &str) -> Option<ChangeState> {
5146        self.view
5147            .lock()
5148            .expect("view lock")
5149            .changes
5150            .get(id)
5151            .cloned()
5152    }
5153
5154    /// Finds the change created by one owner-scoped idempotency key.
5155    #[must_use]
5156    pub fn change_for_idempotency(&self, owner: &str, key: &str) -> Option<(String, ChangeState)> {
5157        self.view
5158            .lock()
5159            .expect("view lock")
5160            .changes
5161            .iter()
5162            .find(|(_, change)| change.owner == owner && change.idempotency_key == key)
5163            .map(|(id, change)| (id.clone(), change.clone()))
5164    }
5165
5166    /// Inactive change generations that previously used `workspace`.
5167    /// This supports migration of the original unversioned archive path
5168    /// when a later generation reuses a deterministic workspace name.
5169    #[must_use]
5170    pub fn archived_change_ids_for_workspace(&self, workspace: &str) -> Vec<String> {
5171        self.view
5172            .lock()
5173            .expect("view lock")
5174            .changes
5175            .iter()
5176            .filter(|(_, change)| {
5177                change.workspace_id == workspace && change.active_workspace.is_none()
5178            })
5179            .map(|(id, _)| id.clone())
5180            .collect()
5181    }
5182
5183    /// Current exact head of an active workspace.
5184    #[must_use]
5185    pub fn workspace_head(&self, workspace: &str) -> Option<ContentHash> {
5186        self.view
5187            .lock()
5188            .expect("view lock")
5189            .workspaces
5190            .get(workspace)
5191            .cloned()
5192    }
5193
5194    /// How many workspaces `channel` currently holds (D37).
5195    ///
5196    /// Read from the projection replayed out of the op log, so a node
5197    /// that has just restarted answers the same number it answered
5198    /// before — the property a per-user ceiling is worthless without.
5199    #[must_use]
5200    pub fn workspaces_held_by(&self, channel: &str) -> usize {
5201        self.workspace_tally
5202            .lock()
5203            .expect("workspace tally lock")
5204            .held_by(channel)
5205    }
5206
5207    /// Every workspace the D37 tally is tracking, sorted.
5208    ///
5209    /// Exposed for the test that pins the tally against
5210    /// [`View::workspaces`]: the two are folded from the same operations
5211    /// and a divergence between them is the tripwire on the D37 register
5212    /// row.
5213    #[must_use]
5214    pub fn tallied_workspaces(&self) -> Vec<String> {
5215        self.workspace_tally
5216            .lock()
5217            .expect("workspace tally lock")
5218            .workspaces()
5219            .map(ToString::to_string)
5220            .collect()
5221    }
5222
5223    /// Draws reviewers for unassigned review `id` and records them with
5224    /// a node-signed op.
5225    ///
5226    /// Candidates are the pool minus everyone sharing the requester's
5227    /// **operator** and, when configured, every operator within the chosen
5228    /// conflict-graph distance. The draw takes at most one reviewer per
5229    /// operator so `REQUIRED_APPROVAL_WEIGHT` reviewers means that many
5230    /// *independent* ones under the configured policy.
5231    ///
5232    /// Excluding only the requester's own name was the original rule and
5233    /// it does not survive the multi-operator case, which is the normal
5234    /// one: an operator running three agents in the pool satisfies
5235    /// two-person integrity by themselves, and can manufacture more
5236    /// agreement by registering more agents. That is the Sybil move D24
5237    /// says must be blocked at the operator level, so the exclusion has
5238    /// to be at that level too.
5239    ///
5240    /// A pool that cannot supply `REQUIRED_APPROVAL_WEIGHT` distinct operators
5241    /// draws fewer rather than doubling up — a visibly under-assigned
5242    /// review beats one that looks independent and is not.
5243    ///
5244    /// # Errors
5245    ///
5246    /// No pool configured, an unreadable pool or conflict graph, malformed
5247    /// graph data, no candidate outside the conflict distance, or the
5248    /// sequencer's rejection reason (e.g. the review was assigned by a
5249    /// concurrent request).
5250    pub fn assign_reviewers(&self, id: &str, requester: &str) -> Result<Vec<String>, String> {
5251        let path = self
5252            .reviewer_pool
5253            .as_ref()
5254            .ok_or("no reviewer pool configured")?;
5255        let text = std::fs::read_to_string(path).map_err(|e| format!("read reviewer pool: {e}"))?;
5256        let mine = reviewer_operator(requester);
5257        let excluded_operators = match &self.reviewer_conflict_graph {
5258            Some((path, max_distance)) => operators_within_distance(path, mine, *max_distance)?,
5259            None => BTreeSet::from([mine.to_string()]),
5260        };
5261        let mut pool: Vec<String> = text
5262            .lines()
5263            .map(str::trim)
5264            .filter(|l| {
5265                !l.is_empty()
5266                    && !l.starts_with('#')
5267                    && !excluded_operators.contains(reviewer_operator(l))
5268            })
5269            .map(String::from)
5270            .collect();
5271        if pool.is_empty() {
5272            return Err(format!(
5273                "reviewer pool has nobody outside the configured conflict distance from {mine:?}"
5274            ));
5275        }
5276        // Partial Fisher-Yates with a hand-rolled xorshift (no rand
5277        // dep). The draw is node-side and recorded in the log, so
5278        // replay reproduces it from the op, not from this seed.
5279        let mut state = seed_from_clock() ^ fnv1a(id.as_bytes());
5280        let mut drawn: Vec<String> = Vec::new();
5281        // Owned, not borrowed: the shuffle mutates `pool` under it.
5282        let mut seen_operators: BTreeSet<String> = BTreeSet::new();
5283        seen_operators.insert(mine.to_string());
5284        for i in 0..pool.len() {
5285            if drawn.len() == REQUIRED_APPROVAL_WEIGHT {
5286                break;
5287            }
5288            state ^= state << 13;
5289            state ^= state >> 7;
5290            state ^= state << 17;
5291            let j = i + (state as usize) % (pool.len() - i);
5292            pool.swap(i, j);
5293            // One seat per operator: a second agent from an operator
5294            // already drawn would add a name, not a second opinion.
5295            if seen_operators.insert(reviewer_operator(&pool[i]).to_string()) {
5296                drawn.push(pool[i].clone());
5297            }
5298        }
5299        let mut pool = drawn;
5300        pool.sort();
5301
5302        let (node, head) = self.scope_now();
5303        let payload = ViewOp::new(OpKind::AssignReviewers {
5304            id: id.to_string(),
5305            reviewers: pool.clone(),
5306        })
5307        .in_scope(node, head)
5308        .to_payload();
5309        let channel = "node/assign";
5310        let sig = self.node_key.sign_submission(channel, &payload);
5311        self.handle.try_submit(channel, payload, Some(sig))?;
5312        Ok(pool)
5313    }
5314
5315    /// Emits enough FIFO `ArchiveReview` ops to bring live review detail
5316    /// back to the configured count when eligible reviews exist.
5317    ///
5318    /// Complete reviews are eligible without a clock. Incomplete reviews
5319    /// are eligible only after the operator-selected lapse age. Multiple
5320    /// archives are offered together so they share durability barriers.
5321    fn prune_reviews(&self) -> ReviewPruneOutcome {
5322        let Some(retention) = &self.review_retention else {
5323            return ReviewPruneOutcome::default();
5324        };
5325        let _pass = self.review_prune_lock.lock().expect("review prune lock");
5326
5327        let candidates: Vec<(String, bool)> = {
5328            // Same lock order as `ChoirPolicy::accepted`: view first,
5329            // tracker second. Holding both makes the count and eligibility
5330            // one writer-consistent snapshot; they are released before any
5331            // submission is sent back to the sequencer.
5332            let view = self.view.lock().expect("view lock");
5333            let mut retention = retention.lock().expect("review retention lock");
5334            if !retention.worth_a_pass() {
5335                return ReviewPruneOutcome::default();
5336            }
5337            // Cleared while the lock is still held, so an op observed from
5338            // here on re-arms the flag instead of being swallowed by the
5339            // pass that did not see it.
5340            retention.prunable_changed = false;
5341            let live_count = retention
5342                .live
5343                .iter()
5344                .filter(|tracked| {
5345                    view.reviews.get(&tracked.id).is_some_and(|review| {
5346                        matches!(review.status, choir_view::ReviewStatus::Live)
5347                    })
5348                })
5349                .count();
5350            let mut needed = live_count.saturating_sub(retention.config.max_live);
5351            // Count-only retention never reads a clock. A timestamp is
5352            // created and consulted only under the explicit lapse policy.
5353            let now = retention.config.lapse_after.map(|_| Instant::now());
5354            let mut candidates = Vec::with_capacity(needed);
5355            for tracked in &retention.live {
5356                if needed == 0 {
5357                    break;
5358                }
5359                let Some(review) = view.reviews.get(&tracked.id) else {
5360                    continue;
5361                };
5362                if !matches!(review.status, choir_view::ReviewStatus::Live) {
5363                    continue;
5364                }
5365                let lapsed = if review.complete() {
5366                    false
5367                } else if retention.config.lapse_after.is_some_and(|age| {
5368                    now.zip(tracked.observed_at)
5369                        .is_some_and(|(now, observed_at)| {
5370                            now.saturating_duration_since(observed_at) >= age
5371                        })
5372                }) {
5373                    true
5374                } else {
5375                    continue;
5376                };
5377                candidates.push((tracked.id.clone(), lapsed));
5378                needed -= 1;
5379            }
5380            candidates
5381        };
5382
5383        if candidates.is_empty() {
5384            return ReviewPruneOutcome::default();
5385        }
5386
5387        let channel = "node/archive";
5388        let (node, head) = self.scope_now();
5389        let submissions: Vec<Submission> = candidates
5390            .iter()
5391            .map(|(id, lapsed)| {
5392                let payload = ViewOp::new(OpKind::ArchiveReview {
5393                    id: id.clone(),
5394                    lapsed: *lapsed,
5395                })
5396                .in_scope(node.clone(), head.clone())
5397                .to_payload();
5398                Submission {
5399                    channel: channel.to_string(),
5400                    author_sig: Some(self.node_key.sign_submission(channel, &payload)),
5401                    payload,
5402                }
5403            })
5404            .collect();
5405        let results = self.handle.try_submit_many(submissions);
5406        let mut outcome = ReviewPruneOutcome::default();
5407        for ((id, _), result) in candidates.into_iter().zip(results) {
5408            match result {
5409                Ok(_) => outcome.archived.push(id),
5410                Err(reason) => {
5411                    let mut error = Rejection::decode(&reason).to_json();
5412                    error["review"] = serde_json::json!(id);
5413                    outcome.errors.push(error);
5414                }
5415            }
5416        }
5417        outcome
5418    }
5419
5420    fn add_retention_outcome(&self, response: &mut serde_json::Value) {
5421        let outcome = self.prune_reviews();
5422        if !outcome.archived.is_empty() {
5423            response["archived_reviews"] = serde_json::json!(outcome.archived);
5424        }
5425        if !outcome.errors.is_empty() {
5426            response["retention_errors"] = serde_json::json!(outcome.errors);
5427        }
5428    }
5429
5430    fn add_newcomer_outcome(
5431        &self,
5432        response: &mut serde_json::Value,
5433        sub: &DecodedSubmission,
5434        started_at_unix_ms: u64,
5435        accepted: bool,
5436        rejection_code: Option<&str>,
5437    ) {
5438        let Some(audit) = &self.newcomer_audit else {
5439            return;
5440        };
5441        let Some(actor_key) = sub
5442            .author_sig
5443            .as_ref()
5444            .map(|signature| signature.key_id.as_str())
5445        else {
5446            return;
5447        };
5448        match audit.lock().expect("newcomer audit lock").observe(
5449            actor_key,
5450            started_at_unix_ms,
5451            accepted,
5452            rejection_code,
5453        ) {
5454            Ok(Some(attempt_id)) => {
5455                response["newcomer_attempt_id"] = serde_json::json!(attempt_id);
5456            }
5457            Ok(None) => {}
5458            Err(error) => {
5459                response["newcomer_audit_error"] = serde_json::json!(error);
5460            }
5461        }
5462    }
5463
5464    /// Whether the sequencer has failed a durability barrier and stopped
5465    /// accepting.
5466    ///
5467    /// The daemon's accept loop polls this so a node that can no longer
5468    /// persist exits rather than staying up refusing everything. Process
5469    /// supervision only restarts a process that *exits*, so without this
5470    /// a transient fsync error is permanent downtime that looks like
5471    /// uptime.
5472    #[must_use]
5473    pub fn durability_failed(&self) -> bool {
5474        self.handle.durability_failed()
5475    }
5476
5477    /// Records every admission decision to `path` as JSONL.
5478    ///
5479    /// Derived data (see [`journal`]): the file is appended to from its
5480    /// own thread, never read back, and its loss or truncation changes
5481    /// no decision this node makes. Safe to rotate by moving it aside;
5482    /// the daemon keeps writing to the open handle until restarted.
5483    ///
5484    /// # Errors
5485    ///
5486    /// If `path` cannot be opened for append.
5487    pub fn with_journal(self, path: &std::path::Path) -> std::io::Result<Self> {
5488        self.journal
5489            .install(Box::new(journal::FileJournal::create(path)?));
5490        Ok(self)
5491    }
5492
5493    /// Appends gate breaches to `path`, one JSON object per line.
5494    ///
5495    /// Separate from the op log on purpose: a breach is an observation
5496    /// about this node's storage and load, not a fact about the ordered
5497    /// history, and it must not change a hash anyone else replays.
5498    #[must_use]
5499    pub fn with_lag_log(mut self, path: std::path::PathBuf) -> Self {
5500        self.lag_log = Some(path);
5501        self
5502    }
5503
5504    /// Enables outbound ref-landed webhooks (D32) from the subscription
5505    /// file `config`, recording every delivery attempt in `log`.
5506    ///
5507    /// Starts one delivery thread. The sequencer's writer thread never
5508    /// waits on it: it offers events to a bounded queue and drops
5509    /// (counted, and written to `log`) when that queue is full, because
5510    /// a receiver this node does not control must not be able to delay
5511    /// op admission. See [`crate::hooks`].
5512    ///
5513    /// # Errors
5514    ///
5515    /// Returns a message when the subscription file cannot be read or
5516    /// does not parse, or when the delivery thread cannot be started.
5517    pub fn with_hooks(
5518        self,
5519        config: std::path::PathBuf,
5520        log: std::path::PathBuf,
5521    ) -> Result<Self, String> {
5522        let hooks = crate::hooks::Hooks::start(config, log)?;
5523        *self.hooks.lock().expect("hooks lock") = Some(hooks);
5524        Ok(self)
5525    }
5526
5527    /// Pushes every landing to the remotes of its repository (D21).
5528    ///
5529    /// Attached the way `with_hooks` is, and for the same reason: the
5530    /// push thread runs `git` against a host somebody else operates, so
5531    /// the writer only ever offers to it. See [`crate::followers`].
5532    ///
5533    /// # Errors
5534    ///
5535    /// Returns a message when the push thread cannot be started.
5536    pub fn with_followers(
5537        self,
5538        root: std::path::PathBuf,
5539        log: std::path::PathBuf,
5540    ) -> Result<Self, String> {
5541        let followers = crate::followers::Followers::start(root, log)?;
5542        *self.followers.lock().expect("followers lock") = Some(followers);
5543        Ok(self)
5544    }
5545
5546    /// Follower offers dropped because the queue was full. Zero without
5547    /// `--followers`.
5548    #[must_use]
5549    pub fn follower_drops(&self) -> u64 {
5550        self.followers
5551            .lock()
5552            .expect("followers lock")
5553            .as_ref()
5554            .map_or(0, crate::followers::Followers::dropped)
5555    }
5556
5557    /// Events the webhook queue dropped because it was full. Zero when
5558    /// no `--hooks-file` is configured.
5559    #[must_use]
5560    pub fn hook_drops(&self) -> u64 {
5561        self.hooks
5562            .lock()
5563            .expect("hooks lock")
5564            .as_ref()
5565            .map_or(0, crate::hooks::Hooks::dropped)
5566    }
5567
5568    /// The live latency record, for a caller that wants to tighten the
5569    /// gate or read it without going through the API.
5570    #[must_use]
5571    pub fn lag(&self) -> Arc<LagMeter> {
5572        self.lag.clone()
5573    }
5574
5575    /// Writes any breaches recorded since the last drain to the lag log.
5576    ///
5577    /// Called by the daemon's accept loop, which is the same place it
5578    /// polls for a failed durability barrier: both are things the writer
5579    /// thread can only report, never act on. No traffic means no drain,
5580    /// which is harmless because no traffic also means no breaches.
5581    pub fn drain_lag_log(&self) {
5582        let Some(path) = self.lag_log.as_ref() else {
5583            return;
5584        };
5585        let (breaches, dropped) = self.lag.drain();
5586        if breaches.is_empty() && dropped == 0 {
5587            return;
5588        }
5589        let gate_us = u64::try_from(self.lag.gate().as_micros()).unwrap_or(u64::MAX);
5590        let mut lines = String::new();
5591        if dropped > 0 {
5592            // The gap is written into the log rather than only counted,
5593            // so a reader of the file alone can see that it is not the
5594            // whole story.
5595            lines.push_str(
5596                &serde_json::json!({
5597                    "format_version": 1,
5598                    "event": "breaches_dropped",
5599                    "count": dropped,
5600                })
5601                .to_string(),
5602            );
5603            lines.push('\n');
5604        }
5605        for breach in breaches {
5606            lines.push_str(
5607                &serde_json::json!({
5608                    "format_version": 1,
5609                    "event": "gate_breach",
5610                    "seq": breach.seq,
5611                    "at_unix_ms": breach.at_unix_ms,
5612                    "gate_us": gate_us,
5613                    "decision_us": breach.decision_us,
5614                    "durable_us": breach.durable_us,
5615                    "batch": breach.batch,
5616                })
5617                .to_string(),
5618            );
5619            lines.push('\n');
5620        }
5621        let write = std::fs::OpenOptions::new()
5622            .create(true)
5623            .append(true)
5624            .open(path)
5625            .and_then(|mut file| std::io::Write::write_all(&mut file, lines.as_bytes()));
5626        if let Err(e) = write {
5627            // The reason, not the path: this string is served over the
5628            // API, and the path names the operator's home directory.
5629            let mut last = self.lag_log_error.lock().expect("lag log error lock");
5630            last.0 = Some(e.to_string());
5631            last.1 += 1;
5632        }
5633    }
5634
5635    /// The latency report as served under `/api/view`.
5636    fn lag_json(&self) -> serde_json::Value {
5637        let report = self.lag.report();
5638        let last_error = self
5639            .lag_log_error
5640            .lock()
5641            .expect("lag log error lock")
5642            .clone();
5643        serde_json::json!({
5644            "format_version": 1,
5645            "gate_us": report.gate_us,
5646            "gate_basis": "dequeue to acknowledgement, durability barrier included",
5647            "observed_ops": report.observed_ops,
5648            "decision": {
5649                "basis": "dequeue to append; the Phase-0 gate as written",
5650                "p50_us": report.decision_p50_us,
5651                "p99_us": report.decision_p99_us,
5652                "max_us": report.decision_max_us,
5653                "breaches": report.decision_breaches,
5654            },
5655            "durable": {
5656                "basis": "dequeue to acknowledgement; what a submitter waits out",
5657                "p50_us": report.durable_p50_us,
5658                "p99_us": report.durable_p99_us,
5659                "max_us": report.durable_max_us,
5660                "breaches": report.durable_breaches,
5661            },
5662            "percentile_basis": "power-of-two bucket upper bound capped at the observed maximum: over-estimates by at most 2x, never under-estimates",
5663            "since": "process start; not replayed from the log",
5664            // Whether, not where. An API client learning the daemon's
5665            // filesystem layout (which carries the operator's home
5666            // directory) buys nothing it can act on; the operator already
5667            // knows the path from the runbook.
5668            "log_configured": self.lag_log.is_some(),
5669            "log_error": last_error.0,
5670            "log_write_failures": last_error.1,
5671            "pending_breaches": report.pending_breaches,
5672        })
5673    }
5674
5675    /// The sequence the next admitted op will occupy, which is the
5676    /// cheapest complete description of "what state is this node in".
5677    ///
5678    /// Exposed for the browser surface's cache: it asks this before
5679    /// deciding whether to rebuild a page, so an unchanged node costs
5680    /// one `u64` read rather than a full view serialization.
5681    pub fn view_seq(&self) -> u64 {
5682        self.view.lock().expect("view lock").next_seq
5683    }
5684
5685    /// The store's generation and the handle-to-name map to render
5686    /// channels through (D46).
5687    ///
5688    /// Both together because a caller that resolves names has to cache
5689    /// on the generation the map was read at: revoking an account
5690    /// deletes a name and appends no op, so the view sequence does not
5691    /// move and a page keyed on it alone would keep showing the deleted
5692    /// name.
5693    ///
5694    /// `(0, empty)` on a node with no accounts store attached, which
5695    /// resolves nothing and renders every channel as itself — the same
5696    /// answer a store gives for an account issued before D46 or issued
5697    /// with an explicit `user`.
5698    #[must_use]
5699    pub fn roster(&self) -> (u64, std::collections::BTreeMap<String, String>) {
5700        match &*self.passkeys.lock().expect("accounts lock") {
5701            Some(accounts) => (accounts.generation(), accounts.roster()),
5702            None => (0, std::collections::BTreeMap::new()),
5703        }
5704    }
5705
5706    /// Repository the review `id` proposes to land on, in the canonical
5707    /// D29 spelling, or `None` when the review is unknown or unbound.
5708    ///
5709    /// Exposed for per-repository authorization: it is what lets posting
5710    /// a verdict require write on the repository under review, instead
5711    /// of the node-wide grant every review op would otherwise need.
5712    pub fn review_repo(&self, id: &str) -> Option<String> {
5713        self.view
5714            .lock()
5715            .expect("view lock")
5716            .reviews
5717            .get(id)?
5718            .target_ref
5719            .as_deref()
5720            .and_then(|target| target.split_once(':'))
5721            .map(|(repo, _)| crate::acl::normalize_repo(repo))
5722    }
5723
5724    /// One review as the API renders it, or `None` when no such review
5725    /// exists.
5726    ///
5727    /// Exposed for the D34 review page, which needs one review rather
5728    /// than the whole view. Same `review_json` the API uses, so the page
5729    /// and the API cannot describe a review differently.
5730    pub fn review_json(&self, id: &str) -> Option<serde_json::Value> {
5731        let view = self.view.lock().expect("view lock");
5732        view.reviews.get(id).map(review_json)
5733    }
5734
5735    /// Every review whose target ref names `repo`, newest id last.
5736    ///
5737    /// A review with no target ref belongs to no repository and is
5738    /// omitted: it cannot be shown under one without asserting a
5739    /// relationship the requester never signed.
5740    pub fn reviews_for_repo(&self, repo: &str) -> Vec<(String, serde_json::Value)> {
5741        let wanted = crate::acl::normalize_repo(repo);
5742        let view = self.view.lock().expect("view lock");
5743        view.reviews
5744            .iter()
5745            .filter(|(_, r)| {
5746                r.target_ref
5747                    .as_deref()
5748                    .and_then(|target| target.split_once(':'))
5749                    .is_some_and(|(named, _)| crate::acl::normalize_repo(named) == wanted)
5750            })
5751            .map(|(id, r)| (id.clone(), review_json(r)))
5752            .collect()
5753    }
5754
5755    /// Every live review that `channel` was drawn for and has not
5756    /// answered, paired with the repository its target ref names.
5757    ///
5758    /// The reviewer's own queue, which the view has always held and no
5759    /// page has ever shown. Three filters and no others:
5760    ///
5761    /// - **drawn**: `channel` is on the review's reviewer list. Being
5762    ///   able to read a repository is not being asked about it.
5763    /// - **unanswered**: no verdict of theirs stands. A review they have
5764    ///   already answered is not owed, whatever anybody else has said.
5765    /// - **live**: not archived. An archived review has dropped its
5766    ///   verdicts, so an answer to it would land nowhere.
5767    ///
5768    /// A review with no target ref is omitted for the same reason
5769    /// [`Self::reviews_for_repo`] omits one: it belongs to no repository,
5770    /// and this page's rows are grouped by one. It cannot become
5771    /// invisible that way — nothing can be drawn on a ref that is not
5772    /// named.
5773    ///
5774    /// **This does no authorization.** The caller filters by what the
5775    /// reader may read, because the ACL lives there and a second copy of
5776    /// that decision here is a second copy that can disagree.
5777    pub fn reviews_awaiting(&self, channel: &str) -> Vec<(String, String, serde_json::Value)> {
5778        let view = self.view.lock().expect("view lock");
5779        view.reviews
5780            .iter()
5781            .filter(|(_, r)| !matches!(r.status, choir_view::ReviewStatus::Archived { .. }))
5782            .filter(|(_, r)| r.reviewers.iter().any(|who| who == channel))
5783            .filter(|(_, r)| !r.verdicts.contains_key(channel))
5784            .filter_map(|(id, r)| {
5785                let repo = r
5786                    .target_ref
5787                    .as_deref()
5788                    .and_then(|target| target.split_once(':'))
5789                    .map(|(named, _)| crate::acl::normalize_repo(named))?;
5790                Some((id.clone(), repo, review_json(r)))
5791            })
5792            .collect()
5793    }
5794
5795    /// Handles one `/api/...` request, returning `(status, json_body)`.
5796    pub fn handle_api(&self, method: &str, path: &str, body: &[u8]) -> (u16, String) {
5797        match (method, path) {
5798            // Query-tolerant: `bound` reads `?limit=`/`?offset=` off the
5799            // same URL after this returns, and an exact match here would
5800            // have sent every paged request to the catch-all instead.
5801            ("GET", path) if path == "/api/view" || path.starts_with("/api/view?") => {
5802                let protected_path = self
5803                    .protected_refs
5804                    .lock()
5805                    .expect("protected refs lock")
5806                    .clone();
5807                let protected = ProtectedPolicySnapshot::read(protected_path.as_deref());
5808                // Read before the view guard: the audit mutex is taken
5809                // under the view lock nowhere else, and this keeps it
5810                // that way.
5811                let cohort = new_actor_cohort(self.newcomer_audit.as_ref());
5812                // Writer order is view -> concentration. Holding both
5813                // through snapshot construction prevents a response whose
5814                // heads include op N while `as_of_seq` and attribution stop
5815                // at N-1. The guards are dropped before byte measurement
5816                // and final response serialization.
5817                let view = self.view.lock().expect("view lock");
5818                let concentration_state = self.concentration.lock().expect("concentration lock");
5819                let key_names = self.key_names.lock().expect("key names lock");
5820                let ws: BTreeMap<_, _> = view
5821                    .workspaces
5822                    .iter()
5823                    .map(|(k, v)| (k.clone(), v.to_hex()))
5824                    .collect();
5825                let refs: BTreeMap<_, _> = view
5826                    .refs
5827                    .iter()
5828                    .map(|(k, v)| (k.clone(), v.to_hex()))
5829                    .collect();
5830                let reviews: BTreeMap<_, _> = view
5831                    .reviews
5832                    .iter()
5833                    .map(|(id, r)| (id.clone(), review_json(r)))
5834                    .collect();
5835                let changes: std::collections::BTreeMap<_, _> = view
5836                    .changes
5837                    .iter()
5838                    .map(|(id, change)| {
5839                        (
5840                            id.clone(),
5841                            serde_json::json!({
5842                                "owner": change.owner,
5843                                "workspace_id": change.workspace_id,
5844                                "active_workspace": change.active_workspace,
5845                                "base_revision": change.base_revision.to_hex(),
5846                                "revision_id": change.revision_id.to_hex(),
5847                                // Omitted when empty, so an unscoped
5848                                // change reads as it always did rather
5849                                // than gaining an empty list (D50).
5850                                "cone": (!change.cone.is_empty())
5851                                    .then(|| change.cone.clone()),
5852                            }),
5853                        )
5854                    })
5855                    .collect();
5856                let bindings: BTreeMap<_, _> = view
5857                    .bindings
5858                    .iter()
5859                    .map(|(key_id, binding)| (key_id.clone(), binding_json(binding)))
5860                    .collect();
5861                // The latest admitted ref-state attestation (D25), as a
5862                // summary: `refs` above already carries the full map, so
5863                // repeating it here would double the hot-path response
5864                // for no reader.
5865                let snapshot = view.latest_snapshot.as_ref().map(|s| {
5866                    serde_json::json!({
5867                        "id": s.id().to_hex(),
5868                        "at_seq": s.at_seq,
5869                        "prev_snapshot": s.prev_snapshot.as_ref().map(ContentHash::to_hex),
5870                    })
5871                });
5872                let ws = serde_json::json!(ws);
5873                let refs = serde_json::json!(refs);
5874                let reviews = serde_json::json!(reviews);
5875                let provenance = serde_json::json!(&view.provenance);
5876                // Not folded into `view_growth`'s authoritative total:
5877                // that byte count is a tracked series, and a fifth
5878                // section would move every past reading. Measured beside
5879                // `bindings` instead, for the same reason.
5880                let checks = serde_json::json!(&view.checks);
5881                let bindings = serde_json::json!(bindings);
5882                // D65. Serialized straight from the fold's own maps:
5883                // there is no projection to write, and a hand-built one
5884                // would be a second place for the shape to drift.
5885                let vouches = serde_json::json!(&view.vouches);
5886                // D67, on the same terms: the fold's map, not a
5887                // projection of it.
5888                let witnessed = serde_json::json!(&view.witnessed);
5889                let counts = view_growth_counts(&view);
5890                let as_of_seq = concentration_state.as_of_seq;
5891                // T3 attribution reads the durable record, not the keys
5892                // file: a tripwire whose evidence the operator can edit in
5893                // place measures the operator's honesty, not concentration.
5894                // `key_names` still supplies the trusted population, which
5895                // no op in the log can answer.
5896                let durable_bindings = KeyBindings::from_view(&view, &key_names);
5897                let concentration =
5898                    concentration_json(&concentration_state, &durable_bindings, &protected);
5899                let new_actor_review_outcomes = new_actor_review_outcomes_json(
5900                    &concentration_state,
5901                    &view,
5902                    cohort.as_ref(),
5903                    self.review_adjudications.as_deref(),
5904                );
5905                drop(key_names);
5906                drop(concentration_state);
5907                // Read under the guard, reported below it. The guard is
5908                // released before `scope_now`, so the position has to be
5909                // taken while the view still holds still.
5910                let next_seq = view.next_seq;
5911                drop(view);
5912                let view_growth = view_growth_json(
5913                    counts,
5914                    &ws,
5915                    &refs,
5916                    &reviews,
5917                    &provenance,
5918                    &[
5919                        ("bindings", &bindings),
5920                        ("vouches", &vouches),
5921                        ("witnessed", &witnessed),
5922                    ],
5923                    as_of_seq,
5924                );
5925                let newcomer_harm = newcomer_harm_json(self.newcomer_audit.as_ref());
5926                // What a client needs to bind its next signature to this
5927                // log: which node, which head, and whether that head is
5928                // being enforced. Read here rather than under the view
5929                // guard above — an op that lands in between only makes
5930                // `head` one entry stale, and a scope naming any head
5931                // still in the window is admissible.
5932                let (node, head) = self.scope_now();
5933                let log = serde_json::json!({
5934                    "node": node.to_hex(),
5935                    "head": head.as_ref().map(ContentHash::to_hex),
5936                    // The position this view describes. It belongs to the
5937                    // log rather than beside it, which is also what keeps
5938                    // it readable: `log` is `Disclosure::Public`, and a
5939                    // reader granted one repository still needs to know
5940                    // where the view they were handed sits. It says no
5941                    // more about a repository than `head` already does.
5942                    //
5943                    // Without it the view could not say its own position,
5944                    // and a caller wanting the age of anything had
5945                    // nothing to measure against -- which is exactly how
5946                    // `ops_since_binding` came to be zero for every key
5947                    // on every real node while its doctest passed.
5948                    "next_seq": next_seq,
5949                    "scope_required": self
5950                        .require_scope
5951                        .load(std::sync::atomic::Ordering::Relaxed),
5952                });
5953                let mut body = serde_json::json!({
5954                    "log": log,
5955                    "snapshot": snapshot,
5956                    "workspaces": ws,
5957                    "changes": changes,
5958                    "refs": refs,
5959                    "reviews": reviews,
5960                    "provenance": provenance,
5961                    "checks": checks,
5962                    "bindings": bindings,
5963                    "vouches": vouches,
5964                    "witnessed": witnessed,
5965                    "concentration": concentration,
5966                    "view_growth": view_growth,
5967                    "newcomer_harm": newcomer_harm,
5968                    "new_actor_review_outcomes": new_actor_review_outcomes,
5969                    "sequencer_lag": self.lag_json(),
5970                    "build": crate::build_json(),
5971                });
5972                // D80. `null` on a home, which is nobody's copy; on a seed,
5973                // whose copy it is and how far behind.
5974                body["replica"] = match (self.home.get(), self.replica.get()) {
5975                    (Some(home), Some(shared)) => shared
5976                        .status
5977                        .lock()
5978                        .expect("replica status lock")
5979                        .to_json(home),
5980                    _ => serde_json::Value::Null,
5981                };
5982                (200, body.to_string())
5983            }
5984            // Pending queue for one reviewer: reviews that fanned out to
5985            // them and are still unanswered by them.
5986            ("GET", path) if path.starts_with("/api/reviews") => {
5987                let reviewer = decode_query_value(
5988                    path.split_once("reviewer=")
5989                        .map(|(_, v)| v.split('&').next().unwrap_or(v))
5990                        .unwrap_or(""),
5991                );
5992                let reviewer = reviewer.as_str();
5993                let view = self.view.lock().expect("view lock");
5994                let pending: std::collections::BTreeMap<_, _> = view
5995                    .reviews
5996                    .iter()
5997                    .filter(|(_, r)| {
5998                        r.reviewers.iter().any(|x| x == reviewer)
5999                            && !r.verdicts.contains_key(reviewer)
6000                    })
6001                    .map(|(id, r)| (id.clone(), review_json(r)))
6002                    .collect();
6003                (200, serde_json::json!({ "pending": pending }).to_string())
6004            }
6005            ("POST", "/api/appeal") => {
6006                let req: serde_json::Value = match serde_json::from_slice(body) {
6007                    Ok(value) => value,
6008                    Err(error) => {
6009                        return (
6010                            400,
6011                            Rejection::new(
6012                                Code::MalformedRequest,
6013                                format!("request body is not valid JSON: {error}"),
6014                                "send {\"attempt_id\": N} using the newcomer_attempt_id from \
6015                                 the rejected response",
6016                            )
6017                            .body(),
6018                        );
6019                    }
6020                };
6021                let Some(attempt_id) = req.get("attempt_id").and_then(serde_json::Value::as_u64)
6022                else {
6023                    return (
6024                        400,
6025                        Rejection::new(
6026                            Code::MalformedRequest,
6027                            "appeal needs an integer attempt_id",
6028                            "send the newcomer_attempt_id from the rejected response",
6029                        )
6030                        .body(),
6031                    );
6032                };
6033                let Some(audit) = &self.newcomer_audit else {
6034                    return (
6035                        503,
6036                        Rejection::new(
6037                            Code::PolicyUnavailable,
6038                            "newcomer audit is not enabled",
6039                            "ask the operator to enable the newcomer audit before filing appeals",
6040                        )
6041                        .body(),
6042                    );
6043                };
6044                match audit
6045                    .lock()
6046                    .expect("newcomer audit lock")
6047                    .appeal(attempt_id)
6048                {
6049                    Ok(()) => (
6050                        200,
6051                        serde_json::json!({ "appealed": attempt_id }).to_string(),
6052                    ),
6053                    Err(error) => (
6054                        400,
6055                        Rejection::new(
6056                            Code::MalformedRequest,
6057                            error,
6058                            "use the attempt id from a rejected first-attempt response",
6059                        )
6060                        .body(),
6061                    ),
6062                }
6063            }
6064            ("POST", "/api/submit") => self.submit(body),
6065            ("POST", "/api/submit-batch") => {
6066                let req: serde_json::Value = match serde_json::from_slice(body) {
6067                    Ok(v) => v,
6068                    Err(e) => return (400, Rejection::new(
6069                            Code::MalformedRequest,
6070                            format!("request body is not valid JSON: {e}"),
6071                            "send a JSON object; GET /llms.txt lists the fields each endpoint wants",
6072                        )
6073                        .body()),
6074                };
6075                let Some(ops) = req.get("ops").and_then(|v| v.as_array()) else {
6076                    return (400, r#"{"error":"need ops array"}"#.to_string());
6077                };
6078                if ops.len() > self.batch_limit {
6079                    return (
6080                        413,
6081                        serde_json::json!({
6082                            "error": "batch has too many operations",
6083                            "limit_ops": self.batch_limit,
6084                            "actual_ops": ops.len(),
6085                        })
6086                        .to_string(),
6087                    );
6088                }
6089                // Ops are admitted in array order; each result is
6090                // independent (a rejection does not abort the batch).
6091                //
6092                // Every op is decoded from the already-parsed request,
6093                // offered to the sequencer, and only then waited on. The
6094                // previous shape called the single-op path in a loop,
6095                // which re-serialised each op to a string, re-parsed it,
6096                // blocked for its reply, and re-parsed the reply. Blocking
6097                // per op was the expensive part: it left the writer's
6098                // queue empty every time it looked, so a 500-op body paid
6099                // 500 durability barriers instead of ceil(500/MAX_BATCH).
6100                let mut decoded = Vec::with_capacity(ops.len());
6101                for op in ops {
6102                    let mut one = decode_submission(op);
6103                    if let Ok(sub) = one.as_mut() {
6104                        self.stamp_credential_key(sub);
6105                    }
6106                    decoded.push(one);
6107                }
6108                let started_at_unix_ms: Vec<u64> = decoded.iter().map(|_| unix_ms()).collect();
6109                // Malformed ops never reach the sequencer. Well-formed
6110                // ones are pushed in request order, so pulling one
6111                // outcome per `Ok` below keeps results aligned with the
6112                // request array without any index bookkeeping.
6113                let subs: Vec<Submission> = decoded
6114                    .iter()
6115                    .filter_map(|d| d.as_ref().ok())
6116                    .map(|sub| Submission {
6117                        channel: sub.channel.clone(),
6118                        payload: sub.payload.clone(),
6119                        author_sig: sub.author_sig.clone(),
6120                    })
6121                    .collect();
6122                let mut outcomes = self.handle.try_submit_many(subs).into_iter();
6123
6124                let mut accepted = 0u64;
6125                let mut rejected = 0u64;
6126                let mut results: Vec<serde_json::Value> = Vec::with_capacity(decoded.len());
6127                for (index, d) in decoded.iter().enumerate() {
6128                    let value = match d {
6129                        Err(reason) => {
6130                            rejected += 1;
6131                            serde_json::json!({ "error": reason })
6132                        }
6133                        Ok(sub) => match outcomes.next().expect("one outcome per submitted op") {
6134                            Ok(acc) => {
6135                                accepted += 1;
6136                                let mut value = self.batch_result(acc, sub);
6137                                self.add_newcomer_outcome(
6138                                    &mut value,
6139                                    sub,
6140                                    started_at_unix_ms[index],
6141                                    true,
6142                                    None,
6143                                );
6144                                value
6145                            }
6146                            Err(reason) => {
6147                                rejected += 1;
6148                                let rejection = Rejection::decode(&reason).to_json();
6149                                let code = rejection["code"].as_str().map(str::to_string);
6150                                let mut value = serde_json::json!({ "error": reason });
6151                                self.add_newcomer_outcome(
6152                                    &mut value,
6153                                    sub,
6154                                    started_at_unix_ms[index],
6155                                    false,
6156                                    code.as_deref(),
6157                                );
6158                                value
6159                            }
6160                        },
6161                    };
6162                    results.push(value);
6163                }
6164                let mut response = serde_json::json!({
6165                    "accepted": accepted,
6166                    "rejected": rejected,
6167                    "results": results,
6168                });
6169                if accepted != 0 {
6170                    // Once per request, after every assignment response
6171                    // has been produced. Calling this from `batch_result`
6172                    // would rescan and emit once per element, undoing the
6173                    // durability-barrier win of the batch endpoint.
6174                    self.add_retention_outcome(&mut response);
6175                }
6176                (200, response.to_string())
6177            }
6178            ("POST", "/api/git-update") => {
6179                let req: serde_json::Value = match serde_json::from_slice(body) {
6180                    Ok(v) => v,
6181                    Err(e) => return (400, Rejection::new(
6182                            Code::MalformedRequest,
6183                            format!("request body is not valid JSON: {e}"),
6184                            "send a JSON object; GET /llms.txt lists the fields each endpoint wants",
6185                        )
6186                        .body()),
6187                };
6188                let f = |k: &str| {
6189                    req.get(k)
6190                        .and_then(|v| v.as_str())
6191                        .unwrap_or("")
6192                        .to_string()
6193                };
6194                let (status, signer) = (f("cert_status"), f("signer"));
6195                match self.git_update(
6196                    &f("repo"),
6197                    &f("refname"),
6198                    &f("old"),
6199                    &f("new"),
6200                    &f("user"),
6201                    Some((status.as_str(), signer.as_str())),
6202                ) {
6203                    Ok(()) => (200, r#"{"ok":true}"#.to_string()),
6204                    Err(reason) => (400, Rejection::decode(&reason).body()),
6205                }
6206            }
6207            // The other half of the same hook: the push is being refused,
6208            // so every ref already accepted for it has to be put back.
6209            ("POST", "/api/git-abort") => {
6210                let req: serde_json::Value = match serde_json::from_slice(body) {
6211                    Ok(v) => v,
6212                    Err(e) => return (400, Rejection::new(
6213                            Code::MalformedRequest,
6214                            format!("request body is not valid JSON: {e}"),
6215                            "send a JSON object; GET /llms.txt lists the fields each endpoint wants",
6216                        )
6217                        .body()),
6218                };
6219                let f = |k: &str| {
6220                    req.get(k)
6221                        .and_then(|v| v.as_str())
6222                        .unwrap_or("")
6223                        .to_string()
6224                };
6225                let (status, signer) = (f("cert_status"), f("signer"));
6226                match self.git_abort(
6227                    &f("repo"),
6228                    &f("refname"),
6229                    &f("old"),
6230                    &f("new"),
6231                    &f("user"),
6232                    Some((status.as_str(), signer.as_str())),
6233                ) {
6234                    Ok(()) => (200, r#"{"ok":true}"#.to_string()),
6235                    Err(reason) => (400, Rejection::decode(&reason).body()),
6236                }
6237            }
6238            ("GET", "/api/signers") => (200, self.signers_json().to_string()),
6239            ("GET", "/api/witness") => match (self.home.get(), self.replica.get()) {
6240                (Some(home), Some(shared)) => {
6241                    (200, shared.witness_json(home, &self.node_key).to_string())
6242                }
6243                _ => (
6244                    404,
6245                    serde_json::json!({
6246                        "error": "this node is not a seed, so it signs no witness statements; \
6247                                  its own attestation is `snapshot` in GET /api/view"
6248                    })
6249                    .to_string(),
6250                ),
6251            },
6252            ("GET", path) if path.starts_with("/api/log") => {
6253                let from: usize = path
6254                    .split_once("from=")
6255                    .and_then(|(_, v)| v.split('&').next()?.parse().ok())
6256                    .unwrap_or(0);
6257                let window = self.entries.lock().expect("entries lock");
6258                let base = window.base as usize;
6259                // Behind the window: the entries the reader still needs
6260                // are no longer in memory. Serving from `base` here
6261                // would hand back a normal-looking page with a silent
6262                // hole in it, so take the persisted log instead — and
6263                // if there is none, say so loudly rather than lie.
6264                if from < base {
6265                    drop(window);
6266                    let Some(path) = &self.log_path else {
6267                        // The brief's reference example: a refusal that
6268                        // already refused correctly (a page with a hole is
6269                        // worse than an error), now carrying the same
6270                        // reason code and named action as every other.
6271                        let mut body = Rejection::new(
6272                            Code::LogEvicted,
6273                            "requested entries have been evicted and no persisted log is configured",
6274                            format!(
6275                                "resync from seq {base} instead; entries before it are gone from \
6276                                 this node"
6277                            ),
6278                        )
6279                        .with_states(Some(from.to_string()), Some(format!("oldest available {base}")))
6280                        .to_json();
6281                        body["window_base"] = serde_json::json!(base);
6282                        return (409, body.to_string());
6283                    };
6284                    return match replay_from_disk(path, from, LOG_PAGE) {
6285                        Ok(rows) => (
6286                            200,
6287                            serde_json::json!({
6288                                "entries": rows,
6289                                "window_base": base,
6290                                "source": "log",
6291                            })
6292                            .to_string(),
6293                        ),
6294                        Err(e) => (
6295                            500,
6296                            Rejection::new(
6297                                Code::Unclassified,
6298                                format!("resync from the persisted log failed: {e}"),
6299                                "retry; this is a node-side read failure, not something the \
6300                                 submission can fix",
6301                            )
6302                            .body(),
6303                        ),
6304                    };
6305                }
6306                let rows: Vec<serde_json::Value> = window
6307                    .entries
6308                    .iter()
6309                    .skip(from - base)
6310                    .take(LOG_PAGE)
6311                    .map(entry_json)
6312                    .collect();
6313                (
6314                    200,
6315                    serde_json::json!({
6316                        "entries": rows,
6317                        "window_base": window.base,
6318                        "source": "window",
6319                    })
6320                    .to_string(),
6321                )
6322            }
6323            _ => (404, r#"{"error":"no such endpoint"}"#.to_string()),
6324        }
6325    }
6326
6327    fn submit(&self, body: &[u8]) -> (u16, String) {
6328        let req: serde_json::Value =
6329            match serde_json::from_slice(body) {
6330                Ok(v) => v,
6331                Err(e) => return (
6332                    400,
6333                    Rejection::new(
6334                        Code::MalformedRequest,
6335                        format!("request body is not valid JSON: {e}"),
6336                        "send a JSON object; GET /llms.txt lists the fields each endpoint wants",
6337                    )
6338                    .body(),
6339                ),
6340            };
6341        let mut sub = match decode_submission(&req) {
6342            Ok(sub) => sub,
6343            Err(reason) => return (400, Rejection::decode(&reason).body()),
6344        };
6345        self.stamp_credential_key(&mut sub);
6346        let started_at_unix_ms = unix_ms();
6347        match self
6348            .handle
6349            .try_submit(&sub.channel, sub.payload.clone(), sub.author_sig.clone())
6350        {
6351            Ok(acc) => {
6352                let mut response = self.batch_result(acc, &sub);
6353                self.add_retention_outcome(&mut response);
6354                self.add_newcomer_outcome(&mut response, &sub, started_at_unix_ms, true, None);
6355                (200, response.to_string())
6356            }
6357            Err(reason) => {
6358                // A retry of an operation that already landed fails CAS
6359                // in exactly the same way as a genuine conflict. They
6360                // call for opposite actions -- read back and proceed,
6361                // versus re-read and rebase -- so an agent that cannot
6362                // tell them apart either retries a completed write or
6363                // abandons a successful one.
6364                //
6365                // Only the policy's own duplicate refusal is converted,
6366                // and that is load-bearing. This lookup used to run for
6367                // *any* rejection, which meant a submission carrying a
6368                // corrupted signature over an already-applied payload was
6369                // answered 200 `already_applied`: the signature check had
6370                // failed, and the response said success. It changed no
6371                // state, but a check whose failure is reported as a
6372                // success is not a check. `duplicate_submission` is
6373                // raised only after the signature verifies.
6374                if Rejection::decode(&reason).code == Code::DuplicateSubmission.as_str() {
6375                    if let Some((seq, hash)) = self
6376                        .entries
6377                        .lock()
6378                        .expect("entries lock")
6379                        .already_applied(&sub.channel, &sub.payload)
6380                    {
6381                        let mut response = serde_json::json!({
6382                            "seq": seq,
6383                            "hash": hash.to_hex(),
6384                            "already_applied": true,
6385                        });
6386                        self.add_newcomer_outcome(
6387                            &mut response,
6388                            &sub,
6389                            started_at_unix_ms,
6390                            true,
6391                            None,
6392                        );
6393                        return (200, response.to_string());
6394                    }
6395                }
6396                let mut response = Rejection::decode(&reason).to_json();
6397                let code = response["code"].as_str().map(str::to_string);
6398                self.add_newcomer_outcome(
6399                    &mut response,
6400                    &sub,
6401                    started_at_unix_ms,
6402                    false,
6403                    code.as_deref(),
6404                );
6405                (400, response.to_string())
6406            }
6407        }
6408    }
6409
6410    /// The success body for one admitted op, shared by `/api/submit` and
6411    /// `/api/submit-batch` so the two cannot answer differently.
6412    ///
6413    /// An unassigned review request is answered with a node-signed
6414    /// assignment draw once the request itself is admitted. That draw is a
6415    /// *further* submission, so it deliberately happens here, after the
6416    /// batch's own barrier, rather than being folded into it.
6417    fn batch_result(
6418        &self,
6419        acc: choir_sequencer::Accepted,
6420        sub: &DecodedSubmission,
6421    ) -> serde_json::Value {
6422        let mut resp = serde_json::json!({ "seq": acc.seq, "hash": acc.hash.to_hex() });
6423        if let Some(id) = &sub.unassigned_review {
6424            match self.assign_reviewers(id, &sub.channel) {
6425                Ok(reviewers) => resp["reviewers"] = serde_json::json!(reviewers),
6426                // The request stands; it is visibly unassigned, which is
6427                // a state no verdict can complete.
6428                Err(e) => resp["assignment_error"] = serde_json::json!(e),
6429            }
6430        }
6431        resp
6432    }
6433}
6434
6435/// How one ref disagrees between the log and a bare repo.
6436#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6437pub enum RefState {
6438    /// The log names a commit the repo has but is not pointing at. Git is
6439    /// the follower, so this is repaired by moving git.
6440    GitBehind,
6441    /// The log names a commit the repo does not have and cannot get — a
6442    /// refused push's objects go out with the quarantine. Repaired by the
6443    /// log giving way, through a compensating op.
6444    LogUnbackable,
6445    /// Git holds a ref the log has never seen. Reported only: adopting it
6446    /// would launder an out-of-band `update-ref` into the signed log.
6447    GitOnly,
6448    /// The comparison could not be made at all.
6449    Unreadable,
6450}
6451
6452impl RefState {
6453    /// Stable wire name, so a monitor can match on it.
6454    pub fn as_str(self) -> &'static str {
6455        match self {
6456            RefState::GitBehind => "git_behind",
6457            RefState::LogUnbackable => "log_unbackable",
6458            RefState::GitOnly => "git_only",
6459            RefState::Unreadable => "unreadable",
6460        }
6461    }
6462}
6463
6464/// One disagreement found by [`Platform::survey_git_refs`].
6465#[derive(Debug, Clone)]
6466pub struct RefFinding {
6467    /// Repo the ref lives in, as it appears in the namespaced log name.
6468    pub repo: String,
6469    /// Ref name inside that repo; empty when the whole repo is the
6470    /// problem.
6471    pub refname: String,
6472    /// What the log says, as a git oid.
6473    pub log_oid: Option<String>,
6474    /// What the repo says.
6475    pub git_oid: Option<String>,
6476    /// Which disagreement this is.
6477    pub state: RefState,
6478    /// Human-readable cause, for the startup log and the endpoint.
6479    pub reason: String,
6480}
6481
6482impl RefFinding {
6483    fn unreadable(repo: &str, refname: &str, reason: &str) -> Self {
6484        Self {
6485            repo: repo.to_string(),
6486            refname: refname.to_string(),
6487            log_oid: None,
6488            git_oid: None,
6489            state: RefState::Unreadable,
6490            reason: reason.to_string(),
6491        }
6492    }
6493
6494    /// The namespaced `<repo>:<refname>` name, as the log spells it.
6495    pub fn name(&self) -> String {
6496        if self.refname.is_empty() {
6497            self.repo.clone()
6498        } else {
6499            format!("{}:{}", self.repo, self.refname)
6500        }
6501    }
6502
6503    /// The finding as it goes over the wire.
6504    pub fn to_json(&self) -> serde_json::Value {
6505        serde_json::json!({
6506            "ref": self.name(),
6507            "state": self.state.as_str(),
6508            "log_oid": self.log_oid,
6509            "git_oid": self.git_oid,
6510            "reason": self.reason,
6511        })
6512    }
6513}
6514
6515/// What [`Platform::reconcile_git_refs`] did, so a caller can report it.
6516/// An all-empty report is the normal case and the only silent one.
6517#[derive(Debug, Default)]
6518pub struct RefReconciliation {
6519    /// Refs written into git because the view held a commit git already
6520    /// had but had not pointed at.
6521    pub applied: Vec<String>,
6522    /// Refs the view gave up, because git can never hold the commit they
6523    /// named. Each one is a compensating op in the log.
6524    pub retracted: Vec<String>,
6525    /// Disagreements left standing, each with its reason. These need an
6526    /// operator: repairing them automatically would either lose history
6527    /// or launder an out-of-band ref into the signed log.
6528    pub unreconciled: Vec<String>,
6529}
6530
6531impl RefReconciliation {
6532    /// Whether anything at all was out of agreement.
6533    pub fn is_empty(&self) -> bool {
6534        self.applied.is_empty() && self.retracted.is_empty() && self.unreconciled.is_empty()
6535    }
6536}
6537
6538/// Every ref in the bare repo at `path`, or `None` if it cannot be read
6539/// (no such repo, or not a repo).
6540pub(crate) fn read_git_refs(path: &std::path::Path) -> Option<BTreeMap<String, String>> {
6541    let out = std::process::Command::new("git")
6542        .args(["for-each-ref", "--format=%(refname) %(objectname)"])
6543        .current_dir(path)
6544        .output()
6545        .ok()?;
6546    if !out.status.success() {
6547        return None;
6548    }
6549    Some(
6550        String::from_utf8_lossy(&out.stdout)
6551            .lines()
6552            .filter_map(|line| {
6553                let (refname, oid) = line.split_once(' ')?;
6554                Some((refname.to_string(), oid.to_string()))
6555            })
6556            .collect(),
6557    )
6558}
6559
6560/// Whether `oid` names an object the repo actually has. A refused push
6561/// leaves its objects in a discarded quarantine, so the view can name a
6562/// commit that was never admitted to the repo.
6563fn object_exists(path: &std::path::Path, oid: &str) -> bool {
6564    std::process::Command::new("git")
6565        .args(["cat-file", "-e", &format!("{oid}^{{object}}")])
6566        .current_dir(path)
6567        .output()
6568        .is_ok_and(|out| out.status.success())
6569}
6570
6571/// Points `refname` at `oid`, CAS'd on `have` so a concurrent writer
6572/// loses rather than gets clobbered.
6573pub(crate) fn write_git_ref(
6574    path: &std::path::Path,
6575    refname: &str,
6576    oid: &str,
6577    have: Option<&str>,
6578) -> Result<(), String> {
6579    let old = have.map_or_else(|| "0".repeat(oid.len()), str::to_string);
6580    let out = std::process::Command::new("git")
6581        .args(["update-ref", refname, oid, &old])
6582        .current_dir(path)
6583        .output()
6584        .map_err(|e| e.to_string())?;
6585    if out.status.success() {
6586        return Ok(());
6587    }
6588    Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
6589}
6590
6591/// Git's "this ref does not exist" oid: all zeros, at whatever width the
6592/// repo's hash function uses.
6593fn is_zero_oid(hex: &str) -> bool {
6594    !hex.is_empty() && hex.chars().all(|c| c == '0')
6595}
6596
6597/// Parses an undirected operator graph and returns every operator within
6598/// `max_distance` of `start`, including `start` itself at distance zero.
6599fn operators_within_distance(
6600    path: &std::path::Path,
6601    start: &str,
6602    max_distance: usize,
6603) -> Result<BTreeSet<String>, String> {
6604    let text =
6605        std::fs::read_to_string(path).map_err(|e| format!("read reviewer conflict graph: {e}"))?;
6606    let mut graph: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
6607    for (index, raw) in text.lines().enumerate() {
6608        let line = raw.trim();
6609        if line.is_empty() || line.starts_with('#') {
6610            continue;
6611        }
6612        let mut fields = line.split_whitespace();
6613        let left = fields.next();
6614        let right = fields.next();
6615        if left.is_none() || right.is_none() || fields.next().is_some() {
6616            return Err(format!(
6617                "reviewer conflict graph line {} must be '<operator> <operator>'",
6618                index + 1
6619            ));
6620        }
6621        let left = left.expect("checked above");
6622        let right = right.expect("checked above");
6623        if left.contains('/') || right.contains('/') {
6624            return Err(format!(
6625                "reviewer conflict graph line {} must name operators, not operator/agent channels",
6626                index + 1
6627            ));
6628        }
6629        graph
6630            .entry(left.to_string())
6631            .or_default()
6632            .insert(right.to_string());
6633        graph
6634            .entry(right.to_string())
6635            .or_default()
6636            .insert(left.to_string());
6637    }
6638
6639    let mut seen = BTreeSet::from([start.to_string()]);
6640    let mut queue = VecDeque::from([(start.to_string(), 0usize)]);
6641    while let Some((operator, distance)) = queue.pop_front() {
6642        if distance == max_distance {
6643            continue;
6644        }
6645        let Some(neighbors) = graph.get(&operator) else {
6646            continue;
6647        };
6648        for neighbor in neighbors {
6649            if seen.insert(neighbor.clone()) {
6650                queue.push_back((neighbor.clone(), distance + 1));
6651            }
6652        }
6653    }
6654    Ok(seen)
6655}
6656
6657/// One decoded `/api/submit` body: the wire fields turned into the bytes
6658/// the sequencer wants, plus whatever the response will need afterwards.
6659struct DecodedSubmission {
6660    channel: String,
6661    payload: Vec<u8>,
6662    author_sig: Option<Witness>,
6663    /// Review id when this op opens a review naming no reviewers, so the
6664    /// node knows to draw for it once the op is admitted.
6665    unassigned_review: Option<String>,
6666}
6667
6668fn decode_create_submission(
6669    request: &serde_json::Value,
6670    expected_id: &str,
6671    expected_owner: &str,
6672    expected_workspace: &str,
6673    expected_revision: &ContentHash,
6674    expected_idempotency_key: &str,
6675) -> Result<(DecodedSubmission, Vec<String>), String> {
6676    let sub = decode_submission(request).map_err(|reason| {
6677        Rejection::new(
6678            Code::MalformedRequest,
6679            reason,
6680            "sign the exact CreateAuthorization payload with the requested owner key and include channel, payload_hex, key_id and signature_hex",
6681        )
6682        .encode()
6683    })?;
6684    let authorization = CreateAuthorization::from_payload(&sub.payload)
6685        .map_err(|error| crate::reject::from_view_error(&error).encode())?;
6686    match authorization {
6687        CreateAuthorization {
6688            id,
6689            owner,
6690            workspace,
6691            base_revision,
6692            idempotency_key,
6693            cone,
6694            ..
6695        } if id == expected_id
6696            && owner == expected_owner
6697            && sub.channel == expected_owner
6698            && workspace == expected_workspace
6699            && &base_revision == expected_revision
6700            && idempotency_key == expected_idempotency_key => Ok((sub, cone)),
6701        _ => Err(Rejection::new(
6702            Code::WorkspaceState,
6703            "signed create payload does not match the requested owner, change, workspace, base and idempotency key",
6704            "rebuild CreateAuthorization from the exact request binding, sign it on the owner channel and retry",
6705        )
6706        .encode()),
6707    }
6708}
6709
6710fn decode_archive_submission(
6711    request: &serde_json::Value,
6712    expected_id: &str,
6713    expected_workspace: &str,
6714    expected_revision: &ContentHash,
6715) -> Result<DecodedSubmission, String> {
6716    let sub = decode_submission(request).map_err(|reason| {
6717        Rejection::new(
6718            Code::MalformedRequest,
6719            reason,
6720            "sign the exact ArchiveAuthorization payload with the bound owner key and include channel, payload_hex, key_id and signature_hex",
6721        )
6722        .encode()
6723    })?;
6724    let authorization = ArchiveAuthorization::from_payload(&sub.payload)
6725        .map_err(|error| crate::reject::from_view_error(&error).encode())?;
6726    match authorization {
6727        ArchiveAuthorization {
6728            id,
6729            workspace,
6730            prev_revision,
6731            ..
6732        } if id == expected_id
6733            && workspace == expected_workspace
6734            && &prev_revision == expected_revision => Ok(sub),
6735        _ => Err(Rejection::new(
6736            Code::WorkspaceState,
6737            "signed archive payload does not match the requested change, workspace and revision",
6738            "re-read GET /api/view, rebuild ArchiveAuthorization from that exact state, sign it on the owner channel and retry",
6739        )
6740        .encode()),
6741    }
6742}
6743
6744/// Turns one already-parsed request object into a submission.
6745///
6746/// Split out so `/api/submit-batch` can decode straight from the parsed
6747/// request array. The previous batch path re-serialised each element back
6748/// to a string and re-parsed it through the single-op entry point, which
6749/// is two extra JSON round-trips per op on the endpoint that exists to
6750/// avoid per-op overhead.
6751fn decode_submission(req: &serde_json::Value) -> Result<DecodedSubmission, String> {
6752    let field = |name: &str| req.get(name).and_then(|v| v.as_str());
6753    let channel = match (field("channel"), field("workspace")) {
6754        (Some(channel), None) | (None, Some(channel)) => channel,
6755        (Some(channel), Some(legacy)) if channel == legacy => channel,
6756        (Some(_), Some(_)) => {
6757            return Err("channel and legacy workspace fields disagree".to_string())
6758        }
6759        (None, None) => return Err("need channel (or legacy workspace)".to_string()),
6760    };
6761    let (Some(payload_hex), Some(key_id), Some(signature_hex)) = (
6762        field("payload_hex"),
6763        field("key_id"),
6764        field("signature_hex"),
6765    ) else {
6766        return Err("need payload_hex, key_id, signature_hex".to_string());
6767    };
6768    let (Some(payload), Some(signature)) = (hex_decode(payload_hex), hex_decode(signature_hex))
6769    else {
6770        return Err("bad hex".to_string());
6771    };
6772    let unassigned_review = match ViewOp::from_payload(&payload) {
6773        Ok(op) => match op.kind {
6774            OpKind::RequestReview { id, reviewers, .. } if reviewers.is_empty() => Some(id),
6775            _ => None,
6776        },
6777        Err(_) => None,
6778    };
6779    // A submission that names no scheme is ed25519, the only one that
6780    // existed before D39 -- the same rule `Witness::scheme_id` applies to
6781    // stored entries, so the wire and the log agree about what silence
6782    // means.
6783    let author_sig = match req.get("scheme").and_then(serde_json::Value::as_u64) {
6784        None => Witness::ed25519(key_id.to_string(), signature),
6785        Some(tag) if tag == u64::from(choir_oplog::scheme::WEBAUTHN_ES256) => {
6786            let (Some(auth_hex), Some(client_hex)) = (
6787                field("authenticator_data_hex"),
6788                field("client_data_json_hex"),
6789            ) else {
6790                return Err(
6791                    "a webauthn signature needs authenticator_data_hex and client_data_json_hex"
6792                        .to_string(),
6793                );
6794            };
6795            let (Some(authenticator_data), Some(client_data_json)) =
6796                (hex_decode(auth_hex), hex_decode(client_hex))
6797            else {
6798                return Err("bad hex in the webauthn fields".to_string());
6799            };
6800            Witness::webauthn_es256(
6801                key_id.to_string(),
6802                signature,
6803                authenticator_data,
6804                client_data_json,
6805            )
6806        }
6807        // Named rather than silently reinterpreted. A scheme this binary
6808        // cannot check is refused here, where the caller learns which tag
6809        // was rejected, instead of being handed to ed25519 and failing as
6810        // a bad signature.
6811        Some(tag) => return Err(format!("unknown signature scheme {tag}")),
6812    };
6813    Ok(DecodedSubmission {
6814        channel: channel.to_string(),
6815        payload,
6816        author_sig: Some(author_sig),
6817        unassigned_review,
6818    })
6819}
6820
6821/// JSON shape of one log entry, shared by the in-memory window and the
6822/// on-disk resync path so a catching-up reader cannot tell them apart.
6823///
6824/// Every field of the hashed form is here, which is what makes the
6825/// chain checkable by someone who does not trust this node: the client
6826/// rebuilds the canonical bytes, hashes them, and compares against
6827/// `hash`. `SYNC.md` writes that recipe out, and
6828/// `tests/sync_contract.rs` executes it — if a field is added here and
6829/// not there, that test fails rather than a third-party client
6830/// silently losing the ability to verify.
6831fn entry_json(e: &OpEntry) -> serde_json::Value {
6832    let mut value = serde_json::json!({
6833        "seq": e.seq,
6834        "workspace": e.channel,
6835        "payload_hex": hex_encode(&e.payload),
6836        "author_key": e.author_sig.as_ref().map(|w| w.key_id.clone()),
6837        // Chain position. `parent` alone lets a client join two pages
6838        // (page N+1's first parent is page N's last hash); `hash` is
6839        // the node's claim about this entry, which the client is meant
6840        // to recompute from the fields below rather than believe.
6841        "hash": e.content_hash().to_hex(),
6842        "parent": e.parent.as_ref().map(ContentHash::to_hex),
6843        "format_version": e.format_version,
6844        // Empty until Phase 2 (D16), and sent anyway: witnesses are
6845        // inside the hashed form, so a client that left them out of its
6846        // recomputation would verify fine today and break on the first
6847        // cosigned entry.
6848        "witnesses": &e.witnesses,
6849        // The signature itself, not just whose it is. Without the bytes
6850        // a client can only take the node's word for authorship.
6851        "author_sig_hex": e.author_sig.as_ref().map(|w| hex_encode(&w.signature)),
6852    });
6853    // D39 put three more fields inside `Witness` and D45 a fourth, and
6854    // therefore inside the hashed form. Omitting them here made a
6855    // passkey-signed entry unreproducible: a client rebuilding from the
6856    // served fields computes a different hash and cannot tell a
6857    // legitimate entry from a lying node. That is the exact failure the
6858    // witnesses comment above warns about, arriving through a different
6859    // field — the fields were added to the format and not to this shape.
6860    //
6861    // So this block grows with `Witness`, every time, and forgetting it
6862    // is silent. `sync_contract.rs` is where that is caught.
6863    //
6864    // Emitted only when present, so an ed25519 entry's served JSON is
6865    // byte-identical to what it always was and no existing client sees a
6866    // new key. That mirrors how they are serialized in the hashed form.
6867    if let Some(sig) = e.author_sig.as_ref() {
6868        let object = value.as_object_mut().expect("entry_json builds an object");
6869        if let Some(scheme) = sig.scheme {
6870            object.insert("author_scheme".into(), serde_json::json!(scheme));
6871        }
6872        if let Some(data) = sig.authenticator_data.as_ref() {
6873            object.insert(
6874                "authenticator_data_hex".into(),
6875                serde_json::json!(hex_encode(data)),
6876            );
6877        }
6878        if let Some(data) = sig.client_data_json.as_ref() {
6879            object.insert(
6880                "client_data_json_hex".into(),
6881                serde_json::json!(hex_encode(data)),
6882            );
6883        }
6884        if let Some(key) = sig.credential_key.as_ref() {
6885            object.insert(
6886                "credential_key_hex".into(),
6887                serde_json::json!(hex_encode(key)),
6888            );
6889        }
6890    }
6891    value
6892}
6893
6894/// Reads up to `take` entries starting at `from` straight out of the
6895/// persisted JSON-lines log, for readers behind the in-memory window.
6896///
6897/// Line number is sequence number, so this skips rather than parses the
6898/// prefix — O(bytes before `from`) per call, which is the price of a
6899/// resync and is paid only by readers that fell behind.
6900fn replay_from_disk(
6901    path: &std::path::Path,
6902    from: usize,
6903    take: usize,
6904) -> Result<Vec<serde_json::Value>, String> {
6905    use std::io::BufRead;
6906    let file = std::fs::File::open(path).map_err(|e| format!("open log: {e}"))?;
6907    let mut reader = std::io::BufReader::new(file);
6908
6909    // Skip by scanning for newlines rather than `.lines().skip(from)`,
6910    // which allocates and UTF-8 validates a String for every line thrown
6911    // away. Still O(bytes before `from`), but it no longer allocates
6912    // per skipped entry, and a resync from a long log is exactly the case
6913    // where "one allocation per line you do not want" is worst.
6914    //
6915    // `read_line` into a reused buffer would be the obvious fix; this
6916    // uses `read_until` on the raw bytes so the skipped prefix is never
6917    // UTF-8 checked either. The entries actually returned are still
6918    // decoded through `serde_json`, which validates them properly.
6919    let mut scratch = Vec::new();
6920    for _ in 0..from {
6921        scratch.clear();
6922        let n = reader
6923            .read_until(b'\n', &mut scratch)
6924            .map_err(|e| format!("read log: {e}"))?;
6925        if n == 0 {
6926            // `from` is past the end of the log: an empty page, not an
6927            // error. The caller already answered 409 for the case where
6928            // the reader is behind the window with no log to fall back on.
6929            return Ok(Vec::new());
6930        }
6931    }
6932
6933    let mut rows = Vec::with_capacity(take.min(LOG_PAGE));
6934    for _ in 0..take {
6935        scratch.clear();
6936        let n = reader
6937            .read_until(b'\n', &mut scratch)
6938            .map_err(|e| format!("read log: {e}"))?;
6939        if n == 0 {
6940            break;
6941        }
6942        let entry: OpEntry = serde_json::from_slice(trim_newline(&scratch))
6943            .map_err(|e| format!("decode log line: {e}"))?;
6944        rows.push(entry_json(&entry));
6945    }
6946    Ok(rows)
6947}
6948
6949/// Percent-decodes one query-string value.
6950///
6951/// `/api/reviews?reviewer=` compares its value against a channel name,
6952/// and every channel here is `operator/agent` — a name with a slash in
6953/// it. A client that escapes the slash, which the `choir` CLI does, was
6954/// answered with an empty queue rather than with its reviews: `choir
6955/// reviews` reported nothing to do to the very reviewer `choir state`
6956/// named as blocking a change. The endpoint's own tests missed it by
6957/// using single-word reviewer names, which no real channel is.
6958///
6959/// Undecodable input is returned unchanged rather than dropped: a name
6960/// that was never encoded is still a name, and the comparison it fails
6961/// is the right outcome for one that is genuinely unknown.
6962fn decode_query_value(raw: &str) -> String {
6963    let bytes = raw.as_bytes();
6964    let mut out = Vec::with_capacity(bytes.len());
6965    let mut i = 0;
6966    while i < bytes.len() {
6967        match bytes[i] {
6968            b'%' => {
6969                let Some(byte) = raw
6970                    .get(i + 1..i + 3)
6971                    .and_then(|hex| u8::from_str_radix(hex, 16).ok())
6972                else {
6973                    return raw.to_string();
6974                };
6975                out.push(byte);
6976                i += 3;
6977            }
6978            b'+' => {
6979                out.push(b' ');
6980                i += 1;
6981            }
6982            byte => {
6983                out.push(byte);
6984                i += 1;
6985            }
6986        }
6987    }
6988    String::from_utf8(out).unwrap_or_else(|_| raw.to_string())
6989}
6990
6991/// Drops a trailing `\n` and an optional preceding `\r`, so a line read
6992/// with `read_until` decodes the same as one produced by `.lines()`.
6993fn trim_newline(line: &[u8]) -> &[u8] {
6994    let line = line.strip_suffix(b"\n").unwrap_or(line);
6995    line.strip_suffix(b"\r").unwrap_or(line)
6996}
6997
6998/// JSON shape of one review's state (shared by /api/view and
6999/// /api/reviews).
7000/// One durable key binding, as `/api/view` reports it.
7001///
7002/// Keyed by actor id, so a client joins this against `author_sig.key_id`
7003/// and the `key_id` a witness carries without deriving anything.
7004///
7005/// `bound_at` is the seq of the op that *first* bound the key and never
7006/// moves, which is what makes it orderable; a later re-bind changes only
7007/// `channel`. `revoked` is present and non-null once withdrawn, and the
7008/// row survives revocation on purpose — attribution for past work must
7009/// not disappear at the moment revocation makes it interesting.
7010fn binding_json(binding: &choir_view::KeyBinding) -> serde_json::Value {
7011    serde_json::json!({
7012        "operator": binding.operator,
7013        "channel": binding.channel,
7014        "bound_at": binding.bound_at,
7015        "revoked": binding.revoked.as_ref().map(|revocation| {
7016            serde_json::json!({ "at": revocation.at, "reason": revocation.reason })
7017        }),
7018    })
7019}
7020
7021fn review_json(r: &choir_view::ReviewState) -> serde_json::Value {
7022    let verdicts: std::collections::BTreeMap<_, _> = r
7023        .verdicts
7024        .iter()
7025        .map(|(who, answer)| {
7026            (
7027                who.clone(),
7028                // `at` is served for the same reason `comments[].at` is,
7029                // plus one specific to D44: it is what decides which key
7030                // gets credited for this approval, so a client checking
7031                // the `expected` authorization a rejection hands back
7032                // cannot rederive it without this number.
7033                serde_json::json!({
7034                    "verdict": format!("{:?}", answer.verdict),
7035                    "note": answer.note,
7036                    "at": answer.at,
7037                }),
7038            )
7039        })
7040        .collect();
7041    // An array, not an object: the thread's order is the order the
7042    // sequencer admitted it, and a JSON object keyed by comment id would
7043    // invite a reader to sort by something else (D38).
7044    let comments: Vec<_> = r
7045        .comments
7046        .iter()
7047        .map(|c| {
7048            serde_json::json!({
7049                "id": c.id,
7050                "author": c.author,
7051                "body": c.body,
7052                "at": c.at,
7053            })
7054        })
7055        .collect();
7056    serde_json::json!({
7057        "target": r.target.as_ref().map(choir_oplog::ContentHash::to_hex),
7058        "target_ref": r.target_ref,
7059        "reviewers": r.reviewers,
7060        "comments": comments,
7061        "verdicts": verdicts,
7062        // viewer → fold position of that viewer's first read. What lets
7063        // an author tell "reviewed and ignored" from "nobody looked".
7064        "viewed": r.viewed,
7065        "slashes": r.slashes,
7066        "complete": r.complete(),
7067        "approved": r.approved(),
7068        "approval_weight": r.approval_weight(),
7069        "re_review_required": r.re_review_required(),
7070        // Empty reviewers on a live review means unassigned; on an
7071        // archived one it means emptied. A reader must be able to tell.
7072        "archived": matches!(r.status, choir_view::ReviewStatus::Archived { .. }),
7073    })
7074}
7075
7076/// Decodes lowercase/uppercase hex; `None` on any bad input.
7077pub fn hex_decode(s: &str) -> Option<Vec<u8>> {
7078    if !s.len().is_multiple_of(2) {
7079        return None;
7080    }
7081    (0..s.len())
7082        .step_by(2)
7083        .map(|i| u8::from_str_radix(s.get(i..i + 2)?, 16).ok())
7084        .collect()
7085}
7086
7087/// Encodes bytes as lowercase hex (client-side convenience, used by
7088/// tests and the demo).
7089pub fn hex_encode(bytes: &[u8]) -> String {
7090    bytes.iter().map(|b| format!("{b:02x}")).collect()
7091}
7092
7093#[cfg(test)]
7094mod magic_ref_tests {
7095    use super::MagicRef;
7096
7097    fn ok(refname: &str) -> MagicRef {
7098        MagicRef::parse(refname)
7099            .unwrap_or_else(|| panic!("{refname} is not recognised as a magic ref"))
7100            .unwrap_or_else(|e| panic!("{refname} refused: {e}"))
7101    }
7102
7103    fn refused(refname: &str) -> String {
7104        MagicRef::parse(refname)
7105            .unwrap_or_else(|| panic!("{refname} was not read as a magic ref at all"))
7106            .expect_err("expected a refusal")
7107    }
7108
7109    #[test]
7110    fn an_ordinary_ref_is_not_magic_at_all() {
7111        // The cost of the feature on every normal push is this `None`.
7112        assert!(MagicRef::parse("refs/heads/main").is_none());
7113        assert!(MagicRef::parse("refs/tags/v1").is_none());
7114        // Adjacent but not under the prefix.
7115        assert!(MagicRef::parse("refs/format/main/x").is_none());
7116    }
7117
7118    #[test]
7119    fn a_branch_and_topic_become_a_destination_and_a_review_id() {
7120        let magic = ok("refs/for/main/fix-parser");
7121        assert_eq!(magic.onto, "main");
7122        assert_eq!(magic.review_id, "for-main-fix-parser");
7123    }
7124
7125    #[test]
7126    fn re_pushing_the_same_topic_reaches_the_same_review() {
7127        assert_eq!(
7128            ok("refs/for/main/fix-parser").review_id,
7129            ok("refs/for/main/fix-parser").review_id
7130        );
7131    }
7132
7133    #[test]
7134    fn the_same_topic_onto_two_branches_is_two_reviews() {
7135        // Without the branch in the id, retargeting would silently
7136        // collide with somebody's proposal onto another branch.
7137        assert_ne!(
7138            ok("refs/for/main/fix").review_id,
7139            ok("refs/for/release/fix").review_id
7140        );
7141    }
7142
7143    #[test]
7144    fn a_deeper_topic_still_yields_one_path_segment() {
7145        // Review ids reach a URL, which admits one segment.
7146        let magic = ok("refs/for/main/team/fix-parser");
7147        assert!(!magic.review_id.contains('/'));
7148        assert_eq!(magic.review_id, "for-main-team-fix-parser");
7149    }
7150
7151    #[test]
7152    fn a_missing_topic_is_refused_with_the_reason() {
7153        // The case that would otherwise make every proposal onto `main`
7154        // fight over one ref.
7155        let reason = refused("refs/for/main");
7156        assert!(reason.contains("<topic>"), "{reason}");
7157        assert!(reason.contains("shared"), "{reason}");
7158    }
7159
7160    #[test]
7161    fn a_topic_that_cannot_be_a_review_id_is_refused_not_mangled() {
7162        // Silently sanitising would map two topics onto one review.
7163        let reason = refused("refs/for/main/fix parser");
7164        assert!(reason.contains("review id"), "{reason}");
7165    }
7166
7167    #[test]
7168    fn an_unsafe_branch_is_refused() {
7169        assert!(refused("refs/for/../fix").contains("branch name"));
7170    }
7171}