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}