Skip to main content

gunnar_server/
control_plane.rs

1//! `gunnar-grpc`'s [`ControlPlane`] seam, over `gunnar-core` and
2//! `gunnar-store`: the adapter its module docs asked for.
3//!
4//! `gunnar-grpc` was written before `gunnar-core` existed and defined a local
5//! trait describing what the seven services need. Its own docs say the trait
6//! must become *"a thin adapter, one impl in this crate"* and must never be a
7//! second source of truth for what a `Repo` is. This is that adapter, living
8//! one level up because it is the binary that knows both halves.
9//!
10//! # What is real here, and what is not
11//!
12//! | surface | status |
13//! |---|---|
14//! | namespaces, repos | real, [`RepoCatalog`] |
15//! | principals, keys, grants | real, [`AuthTable`] |
16//! | `Refs.List` / `Get` / `ReadObject` / `Log` | real, the repository's own stores |
17//! | `Admin.Stats` / `StorageInfo` | real, measured off the `fs` backend |
18//! | `Refs.Diff` | **`Unimplemented`**: no diff engine on the server side |
19//! | `Policy.*` | **`Unimplemented`**: `gunnar-core` has no protection store |
20//! | `Mirror.*` | **`Unimplemented`**: mirroring needs `gunnar-client`, which an `S` crate must never link |
21//!
22//! Every one of those is an explicit [`ControlError::Unimplemented`] and never
23//! an empty success. An empty ref list is indistinguishable from a working
24//! server with no refs (measured, not asserted), and the same is true of "0 mirrors synced".
25//!
26//! # Three model mismatches, recorded rather than papered over
27//!
28//! 1. **`RepoPath` cannot express a nested namespace.** `gunnar-proto`'s
29//!    `RepoPath::validate` rejects a `/` inside `namespace`, but nesting makes
30//!    `team/sub/repo.git` legal over SSH. Such a repository is reachable by git
31//!    and invisible to gRPC. That is a `gunnar-proto` change, not one to make
32//!    here.
33//! 2. **`Scope::Server` has no `RepoPath`.** A server-wide grant is carried as
34//!    an empty namespace and an empty repo, which `MemoryControlPlane` refuses
35//!    on the way in. The test double is the one that is wrong: without a
36//!    server-scoped grant the bootstrap admin cannot create the first namespace
37//!    (the secret-store seam).
38//! 3. **`Namespace` in `gunnar-core` carries only a name** — no description, no
39//!    creation time — so those two proto fields come back empty rather than
40//!    invented.
41
42use std::sync::Arc;
43
44use gunnar_core::{Grant, HashKind, Level, PrincipalKind, RepoRef, Scope, UnixMillis, Visibility};
45use gunnar_grpc::seam::ControlCounts;
46use gunnar_grpc::MergedRef;
47use gunnar_grpc::{ControlError, ControlPlane, ControlResult, NewRepo as ProtoNewRepo};
48use gunnar_proto::{
49    BranchProtection, Commit, FileChange, GrantLevel, MirrorConfig, Namespace, Principal,
50    PublicKey, PullRequest, PullRequestState, ReadLfsObjectResponse, ReadObjectResponse,
51    RefNamePolicy, RefTarget, Repo, RepoPath, RepoStorage, Signature, StorageInfoResponse,
52};
53use gunnar_store::git_storage_trait::{GitServe, ObjType, RefRow};
54use gunnar_store::object;
55use gunnar_wire::RefSnapshot;
56
57use crate::storage::OpenRepo;
58use crate::stores::ServerStores;
59
60/// **How long abdication waits for the standby's queues to drain**, seconds.
61///
62/// A ceiling, not a promise: a standby that is down must not wedge the
63/// hand-off, so after this the roster is cleared anyway (a recovered standby
64/// replays from this box when it is re-added the other way round). Long enough
65/// that a reachable standby with a normal backlog finishes first.
66///
67/// The wait itself is already a POLL (drained-or-deadline, below) — it returns
68/// the instant every queue is empty and never sleeps a fixed span. The one
69/// thing a fixed default cannot know is the box's LOAD: on a heavily loaded rig
70/// a healthy standby's normal backlog can take longer than 30 s to drain, and
71/// clearing the roster early truncates the flush. So the ceiling is TUNABLE —
72/// `GUNNAR_ABDICATE_FLUSH_SECS` raises it for a loaded deployment without
73/// changing the poll's shape (still drained-or-deadline, still short-circuits).
74#[cfg(feature = "replication")]
75const ABDICATE_FLUSH_SECS: u64 = 30;
76
77/// The abdication flush ceiling in effect: `GUNNAR_ABDICATE_FLUSH_SECS` if it
78/// parses to a positive value, else the [`ABDICATE_FLUSH_SECS`] default. A zero
79/// or unparseable value is ignored (a 0 s ceiling would clear the roster before
80/// a single job could drain — never what an operator means).
81#[cfg(feature = "replication")]
82fn abdicate_flush_secs() -> u64 {
83    // The raw string is HANDED IN to the pure resolver so the parse is unit
84    // tested without `set_var` — which is process-global and flakes the whole
85    // suite across threads (see `wg.rs`'s note on the same hazard).
86    resolve_flush_secs(std::env::var("GUNNAR_ABDICATE_FLUSH_SECS").ok().as_deref())
87}
88
89/// Pure ceiling resolver: a positive parsed override wins; anything else
90/// (absent, blank, non-numeric, zero) falls back to [`ABDICATE_FLUSH_SECS`].
91#[cfg(feature = "replication")]
92fn resolve_flush_secs(raw: Option<&str>) -> u64 {
93    raw.and_then(|v| v.trim().parse::<u64>().ok())
94        .filter(|&s| s > 0)
95        .unwrap_or(ABDICATE_FLUSH_SECS)
96}
97
98/// Company SSO's storage on the identity table (`gunnar_grpc::sso::plane::SsoPlane`).
99mod sso_plane;
100
101/// **The refusal a read of the transaction log answers** — one spelling for `Mirror.LogFetch`
102/// gathered and streamed: a released log is the named, retryable [`gunnar_grpc::PRIMARY_GROWING`],
103/// a range retention dropped is `FailedPrecondition` (a snapshot is needed), a sequence above the
104/// head is `InvalidArgument`.
105fn log_read_refusal(e: gunnar_core::txlog::Error) -> ControlError {
106    match e {
107        gunnar_core::txlog::Error::Released(_) => ControlError::Unavailable(format!(
108            "{} this box's data set is being grown (unmounted): its transaction log is back after the remount — ask again",
109            gunnar_grpc::PRIMARY_GROWING
110        )),
111        gunnar_core::txlog::Error::NotRetained { seq, first } => ControlError::FailedPrecondition(format!(
112            "seq {seq} is no longer retained; the oldest is {first} — a snapshot is needed, not a range"
113        )),
114        gunnar_core::txlog::Error::BeyondHead { seq, head } => ControlError::InvalidArgument(format!("seq {seq} is beyond the head {head}")),
115        other => ControlError::Internal(format!("transaction log: {other}")),
116    }
117}
118
119/// **A streamed range onto the wire as `LogChunk` pieces** (twin-streaming, 2026-10-06): `meta`
120/// on an entry's first piece, `last` on the piece that completes its declared length — which
121/// [`crate::twin::stream_range`] hands over only after the entry's payload crc checked — and an
122/// entry with no blob as one empty last piece at its end.
123///
124/// Since restore-large-entry (2026-10-07) every piece says the byte of the blob it starts at
125/// (`offset`), an entry may begin part-way ([`crate::twin::stream_range_at`], the resumed
126/// restore's `from_offset`), and the last piece carries the entry's checked payload crc
127/// (`entry_crc`), so a box that put the entry together across several streams applies it only
128/// if it IS the entry.
129#[allow(clippy::type_complexity)] // the seam's sink, spelled as the seam spells it
130struct ChunkOut<'s> {
131    sink: &'s mut dyn FnMut(gunnar_proto::LogChunk) -> ControlResult<()>,
132    open: Option<OutEntry>,
133}
134
135/// The entry [`ChunkOut`] is sending.
136struct OutEntry {
137    seq: u64,
138    kind: u32,
139    /// Not yet sent: it goes on the first piece.
140    meta: Option<Vec<u8>>,
141    blob_len: u64,
142    /// The byte of the blob the next piece starts at.
143    at: u64,
144    /// The checked payload crc, once the source said it (before the last piece).
145    crc: Option<u32>,
146    /// The `last` piece went out.
147    ended: bool,
148}
149
150impl ChunkOut<'_> {
151    fn send(&mut self, blob: &[u8]) -> anyhow::Result<()> {
152        let Some(o) = self.open.as_mut() else {
153            anyhow::bail!("a piece outside an entry");
154        };
155        let offset = o.at;
156        o.at += blob.len() as u64;
157        anyhow::ensure!(
158            o.at <= o.blob_len,
159            "seq {}: more than its {} blob bytes",
160            o.seq,
161            o.blob_len
162        );
163        let last = o.at == o.blob_len;
164        o.ended |= last;
165        let chunk = gunnar_proto::LogChunk {
166            seq: o.seq,
167            kind: o.kind,
168            meta: o.meta.take().unwrap_or_default(),
169            blob_len: o.blob_len,
170            blob: blob.to_vec(),
171            last,
172            restore_head: 0,
173            restore_bytes: 0,
174            log_id: String::new(),
175            offset: Some(offset),
176            entry_crc: if last { o.crc } else { None },
177            snapshot_item: None,
178            snapshot_id: String::new(),
179        };
180        (self.sink)(chunk).map_err(anyhow::Error::from)
181    }
182}
183
184impl crate::twin::FrameSink for ChunkOut<'_> {
185    fn begin(&mut self, seq: u64, kind: u16, meta: &[u8], blob_len: u64) -> anyhow::Result<()> {
186        self.begin_at(seq, kind, meta, blob_len, 0)
187    }
188    fn begin_at(
189        &mut self,
190        seq: u64,
191        kind: u16,
192        meta: &[u8],
193        blob_len: u64,
194        offset: u64,
195    ) -> anyhow::Result<()> {
196        anyhow::ensure!(self.open.is_none(), "seq {seq} began inside another entry");
197        anyhow::ensure!(
198            offset <= blob_len,
199            "seq {seq}: from byte {offset} of a {blob_len}-byte blob"
200        );
201        self.open = Some(OutEntry {
202            seq,
203            kind: u32::from(kind),
204            meta: Some(meta.to_vec()),
205            blob_len,
206            at: offset,
207            crc: None,
208            ended: false,
209        });
210        Ok(())
211    }
212    fn checked(&mut self, payload_crc: u32) -> anyhow::Result<()> {
213        if let Some(o) = self.open.as_mut() {
214            o.crc = Some(payload_crc);
215        }
216        Ok(())
217    }
218    fn piece(&mut self, bytes: &[u8]) -> anyhow::Result<()> {
219        self.send(bytes)
220    }
221    fn end(&mut self) -> anyhow::Result<()> {
222        // An empty blob, or one resumed AT its end: one empty last piece carries the end (and
223        // the crc).
224        if matches!(&self.open, Some(o) if !o.ended) {
225            self.send(&[])?;
226        }
227        let Some(o) = self.open.take() else {
228            anyhow::bail!("an entry ended that never began");
229        };
230        anyhow::ensure!(
231            o.at == o.blob_len && o.ended,
232            "seq {}: {} of {} blob bytes went out",
233            o.seq,
234            o.at,
235            o.blob_len
236        );
237        Ok(())
238    }
239}
240
241impl CoreControlPlane {
242    /// **The log `Mirror.LogFetch` reads**, or the named refusal while it cannot be read: a set
243    /// held for its own growth (the guard over an unmounted set; MEASURED release-3 mock
244    /// 2026-09-29: `txlog: No such file or directory` mid twin-grow), or a PRIMARY whose log is
245    /// released for its own growth's remount (M1, release-3 test2 mock 2026-09-29) — both named
246    /// and retryable, never `Internal: txlog: No such file or directory`.
247    fn readable_log(&self) -> ControlResult<&gunnar_core::txlog::Log> {
248        if crate::twin::maintenance::is_held(self.stores.root()) {
249            return Err(ControlError::Unavailable(
250                "this box's data set is held for its own growth (unmounted): its transaction log is not readable until the growth mounts it back".into(),
251            ));
252        }
253        if !self.stores.is_twin() {
254            if let Some(why) = crate::twin::fenced_why(self.stores.root()) {
255                return Err(ControlError::FailedPrecondition(format!(
256                    "{why}. Nothing is read from it"
257                )));
258            }
259        }
260        let log: &gunnar_core::txlog::Log = self.stores.txlog();
261        if log.is_released() {
262            return Err(log_read_refusal(gunnar_core::txlog::Error::Released(
263                log.dir().to_path_buf(),
264            )));
265        }
266        // ★ twin-resync-space (2026-10-06): a twin that gave its stale copy up for a resync, or
267        // is mid-install, holds no state at any one sequence — its log is not half a recharge.
268        if self.stores.is_twin() {
269            if let Some(why) = crate::twin::snapshot::not_whole(self.stores.root()) {
270                return Err(ControlError::FailedPrecondition(format!(
271                    "{why}. Nothing is read from it"
272                )));
273            }
274        }
275        Ok(log)
276    }
277}
278
279/// The control plane, backed by the real catalog and identity table.
280pub struct CoreControlPlane {
281    stores: Arc<ServerStores>,
282    /// **The runtime data-set growth** (`crate::grow`), when `serve` wired
283    /// one. `None` on a bare plane (tests, `--dry-run`): `GrowStatus` then
284    /// answers the seam's honest default and `GrowStart` is refused by name.
285    grow: Option<Arc<crate::grow::GrowMachine>>,
286    /// Started `--role twin` ([`CoreControlPlane::with_twin_role`]).
287    twin_role: bool,
288    /// **The git listener this server actually bound**, when `serve` handed it
289    /// in ([`CoreControlPlane::with_git_listener`]) — the fact
290    /// `Bootstrap.Status.serving_git` reports. `None` on a bare plane, and the
291    /// status door then falls back to the boot window alone, exactly as it
292    /// always answered. See [`crate::readiness::GitListener`].
293    git_listener: Option<crate::readiness::GitListener>,
294    /// **The ONE admission gate** the three git fronts share, when `serve`
295    /// handed it in — read by `Mirror.FlushReplication` for the in-flight
296    /// count the watermark needs (T11). `None` on a bare plane.
297    admission: Option<Arc<gunnar_ssh::Admission>>,
298    /// **What replication is doing**, for `Admin.Stats`.
299    ///
300    /// A [`ReplicationView`](crate::replication_view::ReplicationView) and not
301    /// a replicator: this plane is built before the replicator is started
302    /// (replication is started last on purpose, so nothing above it can be
303    /// slowed by it), and the view is the slot `serve` drops it into
304    /// afterwards. It also keeps `feature = "replication"` and every replication type
305    /// out of this file, which is what lets the two lanes working on that
306    /// module and on this one stay out of each other's way.
307    ///
308    /// Unfilled it reads `configured: false` — what a box with no standby *is*,
309    /// and a fact rather than an absence.
310    replication: crate::replication_view::ReplicationView,
311    /// **What replication is CONFIGURED to do**, for `Mirror.SetReplication` /
312    /// `RemoveReplication` / `ReplicationStatus`.
313    ///
314    /// The write half beside the read half above, and the two are not twins: the
315    /// view answers *what has happened*, the slot decides *what runs*. It is
316    /// `Option` and behind the feature's own name only through
317    /// [`with_replication`](Self::with_replication), so a build without
318    /// `replication` — and a `CoreControlPlane` built by a test that has no
319    /// server around it — refuses the three verbs by name instead of failing to
320    /// compile.
321    ///
322    /// **This is the whole reason the appliance can be told about a standby.**
323    /// the reference appliance is an OS-less image running as PID 1: no shell, no
324    /// editor, no `scp`. Before this, `replication.json` could only be written
325    /// by something with a filesystem to write it from, which on that box is
326    /// nothing at all.
327    #[cfg(feature = "replication")]
328    replication_slot: Option<Arc<crate::mirror::ReplicationSlot>>,
329    /// **The live webhook slot**, so an operator can name an outbound endpoint
330    /// over the control plane — the appliance has no shell, exactly as with the
331    /// replication slot above.
332    #[cfg(feature = "webhooks")]
333    webhook_slot: Option<Arc<crate::webhook::WebhookSlot>>,
334    /// **The live entitlement slot** (`metered` only): what `Entitlement.Set`
335    /// writes and the front doors read. `None` on a metered build started
336    /// without `--monetize-pubkey`: every namespace Free, and `Set` refuses by
337    /// name rather than accepting a fact it could not verify.
338    #[cfg(feature = "metered")]
339    entitlement: Option<Arc<crate::entitlement::EntitlementSlot>>,
340    /// **The disk-headroom gate** `Repos.Create` asks — the SAME object the
341    /// front doors' chain holds, so "the disk is full" is one line on both
342    /// doors (one implementation). `None` on a bare plane (a test not about the disk).
343    headroom: Option<Arc<gunnar_ssh::DiskHeadroomGate>>,
344    /// The largest object `Refs.ReadObject` will inflate. See
345    /// [`DEFAULT_MAX_OBJECT_BYTES`] for the arithmetic and
346    /// [`with_max_object_bytes`](Self::with_max_object_bytes) for changing it.
347    max_object_bytes: u64,
348    /// **The one gc at a time this box runs**, behind `Repos.Gc` and
349    /// `Repos.GcStatus`.
350    ///
351    /// It belongs to the plane and not to a caller for the reason
352    /// [`crate::gc_queue`] opens with: a console can serialise itself, and
353    /// nothing else. Built here so every door onto this plane — the console, the
354    /// CLI over gRPC, anything else holding a credential — queues behind the
355    /// same worker. No thread is started until something is actually enqueued.
356    gc: crate::gc_queue::GcQueue,
357    /// **The one account purge at a time this box runs**, behind
358    /// `Accounts.Purge` and `Accounts.PurgeStatus`.
359    ///
360    /// Its own queue and not the gc one — see [`crate::purge_queue`]: a gc
361    /// waits holding nothing, a purge waits holding a total lockout on a
362    /// customer's account, and putting them in one line would keep a user shut
363    /// out through somebody else's repository maintenance.
364    purge: crate::purge_queue::PurgeQueue,
365    /// **The one mirror sync at a time this box runs**, behind `Mirror.Sync`
366    /// and `Mirror.Status`.
367    ///
368    /// Its own queue and not the gc one — see [`crate::mirror_queue`]: a gc
369    /// waits holding nothing, a mirror waits holding a repository that is not
370    /// yet usable, and making a first sync queue behind somebody else's hour of
371    /// compaction is the mistake `job_queue`'s module doc names in advance.
372    mirror: crate::mirror_queue::MirrorQueue,
373    /// **The directory import, one per account at a time** (`Sso.ImportUsers`
374    /// and `Sso.ImportStatus`, A34-17): its own lane, see
375    /// [`crate::sso_import_queue`].
376    sso_import: crate::sso_import_queue::SsoImportQueue,
377    /// **Where the event bus arrives, if this plane is ever given one.**
378    ///
379    /// The mirror runner is built in [`CoreControlPlane::new`] and the bus is
380    /// handed over later by [`CoreControlPlane::with_bus`] (`serve.rs` builds
381    /// the bus first and the plane after, but `new` is also what every test
382    /// calls). A slot the closure captures is what lets the runner publish
383    /// `MirrorSynced` **when the sync finishes** rather than when it was asked
384    /// for — an event announcing a fetch that has not happened is worse than no
385    /// event.
386    ///
387    /// `OnceLock`, so it is written once and read without a lock on every
388    /// completed sync. A plane that never gets one simply publishes nothing,
389    /// which is what every test plane wants.
390    bus: Arc<std::sync::OnceLock<Arc<gunnar_grpc::EventBus>>>,
391    /// **The keys `Bootstrap.Restore` is taken from, as the medium named them**
392    /// (`--restore-pubkey`, [`CoreControlPlane::with_restore_keys`]). The installer's fed
393    /// keys are added at each ask ([`gunnar_grpc::ControlPlane::restore_keys`]). Empty on a
394    /// bare plane, which with no feed is a box that refuses every restore by name.
395    restore_keys: Vec<String>,
396}
397
398impl CoreControlPlane {
399    /// Serve out of `stores`, with the default object ceiling.
400    pub fn new(stores: Arc<ServerStores>) -> Self {
401        let for_gc = Arc::clone(&stores);
402        let bus: Arc<std::sync::OnceLock<Arc<gunnar_grpc::EventBus>>> = Arc::default();
403        CoreControlPlane {
404            stores,
405            grow: None,
406            twin_role: false,
407            git_listener: None,
408            admission: None,
409            replication: crate::replication_view::ReplicationView::new(),
410            #[cfg(feature = "replication")]
411            replication_slot: None,
412            #[cfg(feature = "webhooks")]
413            webhook_slot: None,
414            #[cfg(feature = "metered")]
415            entitlement: None,
416            headroom: None,
417            max_object_bytes: DEFAULT_MAX_OBJECT_BYTES,
418            gc: Self::gc_queue(Arc::clone(&for_gc)),
419            purge: Self::purge_queue(Arc::clone(&for_gc)),
420            mirror: Self::mirror_queue(Arc::clone(&for_gc), Arc::clone(&bus)),
421            sso_import: crate::sso_import_queue::SsoImportQueue::new(
422                crate::sso_import_queue::SsoImport,
423            ),
424            bus,
425            restore_keys: Vec::new(),
426        }
427    }
428
429    /// **Keys this box takes a `Bootstrap.Restore` from** (h-v1-6a, 2026-09-28): OpenSSH
430    /// public key lines, from `--restore-pubkey`. For a box that no installer feeds — the
431    /// kit, a drill — this is the only way a restore can be taken at all.
432    pub fn with_restore_keys(mut self, keys: Vec<String>) -> Self {
433        self.restore_keys = keys;
434        self
435    }
436
437    /// **Publish this plane's event-stream events onto `bus`.**
438    ///
439    /// Today that is one event and one producer: `MirrorSynced`, from the
440    /// mirror queue's worker, when a sync actually completes. It is a slot
441    /// rather than a constructor argument because `CoreControlPlane::new` is
442    /// what every test calls and a bus is not something a test of the disk
443    /// should have to build.
444    pub fn with_bus(self, bus: Arc<gunnar_grpc::EventBus>) -> Self {
445        // A second `with_bus` is the caller's mistake and is ignored rather
446        // than panicking a running server; the first bus wins, which is the
447        // one the listener is serving from.
448        let _ = self.bus.set(bus);
449        self
450    }
451
452    /// **This box was started `--role twin`** (`serve.rs`). `Bootstrap.Status`
453    /// then answers `role twin` before the join, when `twin.json` does not
454    /// exist yet and [`ServerStores::is_twin`] still says no — the moment an
455    /// installer door and a re-image verify ask.
456    pub fn with_twin_role(mut self, twin_role: bool) -> Self {
457        self.twin_role = twin_role;
458        self
459    }
460
461    /// **The address the git listener took**, handed in by `serve::start` from
462    /// `local_addr()` beside the address that was asked for.
463    ///
464    /// This is what makes `Bootstrap.Status.serving_git` a reading of a SOCKET
465    /// rather than of a boot latch. Per-plane and not a static: two servers in
466    /// one test process are two listeners, and a process-wide slot would let
467    /// the second answer for the first.
468    pub fn with_git_listener(mut self, listener: crate::readiness::GitListener) -> Self {
469        self.git_listener = Some(listener);
470        self
471    }
472
473    /// **Release every purge lease this box is still holding.**
474    ///
475    /// Called once at start-up, and it is the fast half of the crash-recovery
476    /// story. A purge that was interrupted by a SIGKILL or a power cut left its
477    /// lease in the identity table; nothing is running any more, so every one
478    /// of them is stale by construction — this process has just started and has
479    /// enqueued nothing.
480    ///
481    /// Without it those accounts stay shut until their leases expire, which is
482    /// up to [`gunnar_core::PURGE_LEASE_MS`] of a paying customer being told
483    /// their account is locked by a purge that is not happening. The lease is
484    /// still there as the backstop for the case this cannot cover — a box that
485    /// never comes back up.
486    ///
487    /// **Administrative gates are left alone.** They carry no deadline and were
488    /// set by a person; a restart must not un-suspend an account.
489    ///
490    /// Returns how many were released. Failures are logged and not fatal: a
491    /// server that refused to start because it could not tidy a lease would
492    /// turn a recoverable state into an outage.
493    pub fn release_stale_purge_leases(stores: &ServerStores) -> u64 {
494        let gates = match stores.auth().gates() {
495            Ok(gates) => gates,
496            Err(err) => {
497                tracing::error!(
498                    target: "gunnar::audit",
499                    error = %err,
500                    "the account gates could not be read at start-up; any purge lease left by a \
501                     crash will stay in force until it expires"
502                );
503                return 0;
504            }
505        };
506        let mut released = 0;
507        for gate in gates {
508            if gate.reason != gunnar_core::GateReason::Purge {
509                continue;
510            }
511            match stores.auth().lift_gate_held_by(&gate.account, &gate.holder) {
512                Ok(Some(_)) => {
513                    released += 1;
514                    tracing::warn!(
515                        target: "gunnar::audit",
516                        event = "account.stale_lease_released",
517                        account = %gate.account,
518                        job = %gate.holder,
519                        "an account was still locked by a purge that did not finish; the lockout \
520                         has been lifted and the account may still hold bytes the purge did not \
521                         reclaim — `repo list-orphans` shows them"
522                    );
523                }
524                Ok(None) => {}
525                Err(err) => tracing::error!(
526                    target: "gunnar::audit",
527                    account = %gate.account,
528                    error = %err,
529                    "a stale purge lease could not be lifted at start-up"
530                ),
531            }
532        }
533        released
534    }
535
536    /// The purge queue's two closures: the walk, and the unlock.
537    ///
538    /// The walk is [`crate::account_purge::purge_account`] — the same function
539    /// the CLI verb calls, so there is one definition of what emptying an
540    /// account means (one implementation). The unlock is
541    /// `AuthTable::lift_gate_held_by`, **held_by** and not the unconditional
542    /// lift, so a purge finishing cannot clear a suspension an operator put on
543    /// while it ran.
544    ///
545    /// The lease is re-stamped on every store reclaimed. A purge of a large
546    /// account outlives [`gunnar_core::PURGE_LEASE_MS`] easily, and a lease
547    /// that ran out mid-walk would unlock the account the walk is still
548    /// emptying — a reader could then start re-exploding a read cache into a
549    /// directory the purge has not reached yet. A renewal that FAILS is logged
550    /// and not fatal: it means the row is somebody else's now, the account is
551    /// still shut, and stopping the purge over it would leave more behind.
552    fn purge_queue(stores: Arc<ServerStores>) -> crate::purge_queue::PurgeQueue {
553        let for_release = Arc::clone(&stores);
554        crate::purge_queue::PurgeQueue::new(
555            Arc::new(
556                move |order: &crate::purge_queue::PurgeOrder,
557                      progress: &crate::purge_queue::Progress| {
558                    let request = crate::account_purge::PurgeRequest {
559                        account: order.account.clone(),
560                        principal_id: order.principal_id.clone(),
561                        reason: order.reason.clone(),
562                        remove_principal: order.remove_principal,
563                        expect_empty: order.expect_empty,
564                    };
565                    let auth = stores.auth();
566                    let account = order.account.clone();
567                    let job_id = order.job_id.clone();
568                    let mut step = |step: crate::account_purge::Step| match step {
569                        crate::account_purge::Step::Found(stores_found) => {
570                            progress.found(stores_found)
571                        }
572                        crate::account_purge::Step::Reclaimed { bytes, .. } => {
573                            progress.reclaimed(bytes);
574                            // **The heartbeat.** Pushed out after every store,
575                            // so a purge that takes an hour never runs out of
576                            // lease — and a purge that dies still hands the
577                            // account back within PURGE_LEASE_MS.
578                            let until = gunnar_core::now_ms() + gunnar_core::PURGE_LEASE_MS;
579                            if let Err(err) = auth.renew_gate(&account, &job_id, until) {
580                                tracing::warn!(
581                                    target: "gunnar::audit",
582                                    account = %account,
583                                    job = %job_id,
584                                    error = %err,
585                                    "a purge could not renew its lockout lease; the account is \
586                                     still shut and the walk continues"
587                                );
588                            }
589                        }
590                        crate::account_purge::Step::Failed(note) => progress.failed(note),
591                        crate::account_purge::Step::PrincipalRemoved => {
592                            progress.principal_removed()
593                        }
594                    };
595                    let report = crate::account_purge::purge_account(&stores, request, &mut step)
596                        .map_err(|err| format!("{err:#}"))?;
597                    // A walk that ran and left stores behind is a FAILURE, and
598                    // the queue is told so here rather than left to infer it
599                    // from a report it cannot see. The names are already on the
600                    // job through `Step::Failed`; this is the sentence.
601                    if report.failures.is_empty() {
602                        Ok(())
603                    } else {
604                        Err(format!(
605                            "{} of {} stores could not be reclaimed and are still on the disk; \
606                             `repo list-orphans` shows them",
607                            report.failures.len(),
608                            report.stores_found
609                        ))
610                    }
611                },
612            ),
613            Arc::new(move |account: &str, job_id: &str| {
614                for_release
615                    .auth()
616                    .lift_gate_held_by(account, job_id)
617                    .map(|lifted| lifted.is_some())
618                    .map_err(|err| format!("{err}"))
619            }),
620        )
621    }
622
623    /// The queue's runner: resolve the catalog row, then **call the same
624    /// `admin::gc_one` the CLI verb calls** (one implementation).
625    ///
626    /// Errors come back as strings because they are going onto a job a console
627    /// will read, not up a call stack. The resolution happens *inside* the
628    /// runner rather than at enqueue time on purpose: a repository can be
629    /// deleted while its job waits, and discovering that when the work starts
630    /// is a FAILED job saying so, which is the truth. Resolving early would
631    /// have queued a row that no longer describes anything.
632    fn gc_queue(stores: Arc<ServerStores>) -> crate::gc_queue::GcQueue {
633        crate::gc_queue::GcQueue::new(Arc::new(move |path: &str| {
634            let repo_ref = gunnar_core::resolve_repo_path(path)
635                .map_err(|err| format!("`{path}` is not a valid <namespace>/<name>: {err:#}"))?;
636            let repo = stores
637                .catalog()
638                .get(&repo_ref)
639                .map_err(|err| format!("reading the catalog row for {path}: {err:#}"))?
640                .ok_or_else(|| format!("{path} is no longer a live repository"))?;
641            let report = crate::admin::gc_one(&stores, &repo)
642                .map_err(|err| format!("maintaining {path}: {err:#}"))?;
643            Ok(crate::admin::reclaimed_bytes(&report))
644        }))
645    }
646
647    /// **The mirror queue's runner: read the sealed credential, then run the
648    /// one fetch (one implementation).**
649    ///
650    /// The credential is read **here, on the worker**, and not carried on the
651    /// order. Two reasons, and both are about where a secret is allowed to be:
652    /// an order is `Debug` and lives on a job a status RPC renders, so a key on
653    /// it would be one `{:?}` away from a log line; and a credential rotated
654    /// while a sync waited should be the one the sync uses, not the one that
655    /// was current when somebody clicked.
656    ///
657    /// Errors come back as strings because they are going onto a job a console
658    /// will read, not up a call stack — the same reason `gc_queue`'s runner
659    /// returns one. None of them can contain the credential: the only paths out
660    /// of `mirror_fetch` that mention the remote scrub its userinfo first.
661    fn mirror_queue(
662        stores: Arc<ServerStores>,
663        bus: Arc<std::sync::OnceLock<Arc<gunnar_grpc::EventBus>>>,
664    ) -> crate::mirror_queue::MirrorQueue {
665        crate::mirror_queue::MirrorQueue::new(Arc::new(
666            move |order: &crate::mirror_queue::MirrorOrder,
667                  report: &crate::job_queue::Reporter<crate::mirror_queue::Mirror>| {
668                let repo_ref = gunnar_core::resolve_repo_path(&order.path).map_err(|err| {
669                    format!(
670                        "`{}` is not a valid <namespace>/<name>: {err:#}",
671                        order.path
672                    )
673                })?;
674                // **In flight, durably**, before anything is dialled: a restart
675                // during the sync leaves this mark, and the next start settles
676                // the sync as interrupted and resumes it
677                // (`resume_interrupted_mirrors`) instead of leaving a
678                // repository with nothing in it and a registration that says
679                // nothing went wrong.
680                let first = first_sync_of(
681                    stores
682                        .mirrors()
683                        .mark_started(&repo_ref, gunnar_grpc::bus::now_unix_ms()),
684                    &order.path,
685                )?;
686                let outcome = read_credential(&stores, &repo_ref).and_then(|credential| {
687                    crate::mirror_fetch::sync_once(&stores, order, &credential, report)
688                });
689                audit_mirror_outcome(
690                    &stores,
691                    &repo_ref,
692                    order,
693                    first,
694                    outcome.as_ref().err().map(String::as_str),
695                    report,
696                );
697
698                // **The registration records every attempt, good or bad.**
699                // `record_sync` moves `last_sync_at_unix_ms` only on success —
700                // that field means "when this mirror was last correct", and
701                // moving it on a failure would make a mirror that has been
702                // broken for a week look fresh.
703                let error = outcome.as_ref().err().map(String::as_str);
704                if let Err(err) =
705                    stores
706                        .mirrors()
707                        .record_sync(&repo_ref, gunnar_grpc::bus::now_unix_ms(), error)
708                {
709                    tracing::warn!(
710                        target: "gunnar::git",
711                        repo = %order.path,
712                        %err,
713                        "a mirror sync finished and its registration could not be updated; the \
714                         fetch itself is unaffected"
715                    );
716                }
717
718                // The event stream, **after** the work and only on success. An event that
719                // announced a fetch which had not happened would be worse than
720                // no event, which is why `Mirror.Sync` no longer publishes one.
721                if outcome.is_ok() {
722                    if let Some(bus) = bus.get() {
723                        let job = report.snapshot();
724                        let updates = job
725                            .map(|job| job.report.updates)
726                            .unwrap_or_default()
727                            .into_iter()
728                            .map(|(ref_name, old_oid, new_oid)| gunnar_proto::RefUpdate {
729                                ref_name,
730                                old_oid,
731                                new_oid,
732                            })
733                            .collect::<Vec<_>>();
734                        bus.publish(gunnar_proto::event::Payload::MirrorSynced(
735                            gunnar_proto::MirrorSynced {
736                                repo: Some(RepoPath::new(repo_ref.namespace(), repo_ref.name())),
737                                remote_url: order.upstream.clone(),
738                                refs_changed: updates.len() as u32,
739                                updates,
740                            },
741                        ));
742                    }
743                }
744                outcome
745            },
746        ))
747    }
748
749    /// **Settle every sync a stopped process left in flight, and resume it**
750    /// (import-big, 2026-10-26).
751    ///
752    /// The job queue is memory: a restart in the middle of an import (an
753    /// out-of-memory kill, an update, a power cut) forgot the job, and what was
754    /// left was a repository with nothing in it and a registration with no
755    /// error — a console that showed no failure for an import that never
756    /// landed. The registration now carries an in-flight mark
757    /// (`MirrorRecord::sync_started_at_unix_ms`); here, once at start, each
758    /// marked sync is settled as interrupted (its `last_error` says so, and the
759    /// account's audit log has a line) and enqueued again, up to
760    /// [`gunnar_core::MAX_RESUMES`] times in a row. Past that it stays FAILED
761    /// with the reason, for a person to press Sync. Returns how many were
762    /// resumed.
763    pub fn resume_interrupted_mirrors(&self) -> usize {
764        let settled = match self
765            .stores
766            .mirrors()
767            .settle_interrupted(gunnar_grpc::bus::now_unix_ms())
768        {
769            Ok(settled) => settled,
770            Err(err) => {
771                tracing::warn!(target: "gunnar::git", %err, "the mirror registrations could not be read to resume interrupted syncs");
772                return 0;
773            }
774        };
775        let mut resumed = 0;
776        for record in settled {
777            let Ok(repo_ref) = gunnar_core::resolve_repo_path(&record.repo) else {
778                continue;
779            };
780            let again = record.interrupted <= gunnar_core::MAX_RESUMES;
781            audit_line(
782                &self.stores,
783                &repo_ref,
784                if again {
785                    "mirror.sync.interrupted"
786                } else {
787                    "mirror.sync.failed"
788                },
789                &record.last_error,
790                true,
791            );
792            tracing::warn!(
793                target: "gunnar::git",
794                event = "mirror.sync.interrupted",
795                repo = %record.repo,
796                interrupted = record.interrupted,
797                resumed = again,
798                "a mirror sync was in flight when the server stopped"
799            );
800            if again {
801                self.mirror.enqueue(crate::mirror_queue::MirrorOrder {
802                    path: repo_ref.key(),
803                    upstream: record.remote_url.clone(),
804                    prune: record.prune,
805                    ca: mirror_ca_of(&record),
806                });
807                resumed += 1;
808            }
809        }
810        resumed
811    }
812
813    /// **Take the lockout a purge runs under, without downgrading one that is
814    /// already there.**
815    ///
816    /// A lease is written only when the account is *not* already gated. If it
817    /// is — an operator suspending a customer, monetize locking a non-payer —
818    /// that gate stands, and the purge runs under it. Two consequences, both
819    /// deliberate:
820    ///
821    /// * the account does not quietly acquire a **deadline** it did not have.
822    ///   Overwriting an administrative gate with a lease would mean a
823    ///   suspension expiring ten minutes after a purge finished with it, which
824    ///   is a suspended account silently coming back.
825    /// * the job will report `gate_released: false` and the account stays shut,
826    ///   because `lift_gate_held_by` will find a holder that is not this job.
827    ///   That is the correct outcome: the purge did not take that lockout and
828    ///   has no standing to lift it.
829    fn gate_for_purge(
830        &self,
831        account: &str,
832        job_id: &str,
833        options: &gunnar_grpc::seam::PurgeAccountOptions,
834    ) -> ControlResult<()> {
835        if self
836            .stores
837            .auth()
838            .gate_covering(account)
839            .map_err(internal)?
840            .is_some()
841        {
842            return Ok(());
843        }
844        let lease = gunnar_core::AccountGate::purge_lease(
845            account,
846            job_id,
847            gunnar_core::now_ms() + gunnar_core::PURGE_LEASE_MS,
848        )
849        .by(&options.principal_id)
850        .noted(format!(
851            "this account is being permanently deleted: {}",
852            options.reason
853        ));
854        self.stores.auth().gate(lease).map_err(gate_error)?;
855        Ok(())
856    }
857
858    /// **Hand the disk-headroom gate in.** From here on `Repos.Create` refuses
859    /// under the floor with `RESOURCE_EXHAUSTED` and the gate's own line —
860    /// the one a refused `git push` prints, because it is the same function.
861    #[must_use]
862    pub fn with_headroom(mut self, gate: Arc<gunnar_ssh::DiskHeadroomGate>) -> Self {
863        self.headroom = Some(gate);
864        self
865    }
866
867    /// **Hand this plane the runtime growth** (`crate::grow`). Called by
868    /// `serve` once the admission gate and the stores exist.
869    pub fn with_grow(mut self, grow: Arc<crate::grow::GrowMachine>) -> Self {
870        self.grow = Some(grow);
871        self
872    }
873
874    /// **Hand this plane the admission gate** the git fronts share, for the
875    /// in-flight count `Mirror.FlushReplication` reports.
876    pub fn with_admission(mut self, admission: Arc<gunnar_ssh::Admission>) -> Self {
877        self.admission = Some(admission);
878        self
879    }
880
881    /// The growth, or the seam's refusal by name.
882    fn grow_machine(&self) -> ControlResult<&Arc<crate::grow::GrowMachine>> {
883        self.grow.as_ref().ok_or(ControlError::Unimplemented(
884            "this control plane was built with no runtime growth wired in, so it cannot grow the data set",
885        ))
886    }
887
888    /// Verify a signed membership event before it changes anything.
889    ///
890    /// The same shape as `forge_append`'s check and for the same reason (one
891    /// implementation): a membership log that took an event it could not verify would be a
892    /// log of claims, and the point of the design is that it is a log of
893    /// evidence. The key must be in the ACL table (or no later reader could
894    /// check it), it must be the claimed actor's own, and the event must
895    /// describe the change the caller named — re-checked here at the seam
896    /// because this door can also be reached without a connection.
897    fn verify_membership_event(
898        &self,
899        signed: gunnar_proto::SignedMembershipEvent,
900        group_id: &str,
901        member_id: &str,
902        action: gunnar_core::MembershipAction,
903    ) -> ControlResult<gunnar_core::SignedMembershipEvent> {
904        let signed = from_proto_membership(signed)?;
905        if signed.event.group != group_id || signed.event.member != member_id {
906            return Err(ControlError::InvalidArgument(format!(
907                "the signed event is about {:?}/{:?} but the change is {:?}/{:?}",
908                signed.event.group, signed.event.member, group_id, member_id
909            )));
910        }
911        if signed.event.action != action {
912            return Err(ControlError::InvalidArgument(
913                "the signed event's action does not match the change (add vs remove)".to_owned(),
914            ));
915        }
916        let key = self
917            .stores
918            .auth()
919            .signing_keys()
920            .map_err(|err| ControlError::Unavailable(format!("{err:#}")))?
921            .into_iter()
922            .find(|row| row.fingerprint_sha256 == signed.key_fingerprint)
923            .ok_or_else(|| {
924                ControlError::InvalidArgument(format!(
925                    "{} is not registered on this server, so nobody reading this event later \
926                     could check its signature",
927                    signed.key_fingerprint
928                ))
929            })?;
930        if key.principal_id != signed.event.actor {
931            return Err(ControlError::InvalidArgument(format!(
932                "this membership change is attributed to {:?} and is signed by {}, which is \
933                 {:?}'s key; a membership change is the actor's own word, so it must carry the \
934                 actor's own signature",
935                signed.event.actor, signed.key_fingerprint, key.principal_id
936            )));
937        }
938        let public = gunnar_core::ssh_key::PublicKey::from_openssh(&key.openssh)
939            .map_err(|err| ControlError::Internal(format!("stored key is unreadable: {err}")))?;
940        gunnar_core::membership::verify_event(&signed, &public)
941            .map_err(|problem| ControlError::InvalidArgument(format!("{problem}")))?;
942        Ok(signed)
943    }
944
945    /// Append a verified membership event to the signed membership log, and
946    /// return the index it landed at — the index an inclusion proof is taken
947    /// at. Append-only and never rewritten, so a removal followed by a re-add
948    /// leaves two standing events, which is what makes the history evidence.
949    fn append_membership_event(
950        &self,
951        signed: &gunnar_core::SignedMembershipEvent,
952    ) -> ControlResult<u64> {
953        let log = self.stores.membership_log();
954        let mut line = serde_json::to_string(signed)
955            .map_err(|err| ControlError::Internal(format!("encoding the event: {err}")))?;
956        line.push('\n');
957        let _framed =
958            gunnar_core::txlog::sink::append_line(log.records_path(), line.trim_end_matches('\n'))
959                .map_err(|err| {
960                    ControlError::Unavailable(format!(
961                        "framing the line in the transaction log: {err}"
962                    ))
963                })?;
964        use std::io::Write as _;
965        let mut file = std::fs::OpenOptions::new()
966            .create(true)
967            .append(true)
968            .open(log.records_path())
969            .map_err(|err| {
970                ControlError::Unavailable(format!("opening the membership log: {err}"))
971            })?;
972        file.write_all(line.as_bytes()).map_err(|err| {
973            ControlError::Unavailable(format!("appending to the membership log: {err}"))
974        })?;
975        let count = log
976            .lines_of()
977            .map_err(|err| ControlError::Unavailable(format!("{err:#}")))?
978            .len() as u64;
979        Ok(count.saturating_sub(1))
980    }
981
982    /// Every membership event on the server, decoded, in append order.
983    fn membership_log_events(&self) -> ControlResult<Vec<gunnar_core::SignedMembershipEvent>> {
984        self.stores
985            .membership_log()
986            .lines_of()
987            .map_err(|err| ControlError::Unavailable(format!("{err:#}")))?
988            .iter()
989            .map(|line| {
990                serde_json::from_str(line).map_err(|err| {
991                    ControlError::Internal(format!("decoding a membership event: {err}"))
992                })
993            })
994            .collect()
995    }
996
997    /// **Hand this plane the live replication slot**, so an operator can name a
998    /// standby over the control plane.
999    ///
1000    /// Called by `Server::start` before the plane is wrapped in an `Arc`, and
1001    /// before anything is running in the slot — which is the point: a target has
1002    /// to be settable on a box that has never had one and holds no repositories
1003    /// yet, and that is every box on its first boot.
1004    #[cfg(feature = "replication")]
1005    pub fn with_replication(mut self, slot: Arc<crate::mirror::ReplicationSlot>) -> Self {
1006        self.replication_slot = Some(slot);
1007        self
1008    }
1009
1010    /// The slot, or a refusal that names WHY there is none.
1011    ///
1012    /// `Unimplemented` and not `Internal`: a plane built without a slot is a
1013    /// plane with no server around it (a unit test), and telling a caller "this
1014    /// server cannot do that" is true, actionable and not an incident.
1015    #[cfg(feature = "replication")]
1016    fn replication_slot(&self) -> ControlResult<&Arc<crate::mirror::ReplicationSlot>> {
1017        self.replication_slot.as_ref().ok_or(ControlError::Unimplemented(
1018            "this control plane was built with no replication slot, so it cannot configure                  a standby",
1019        ))
1020    }
1021
1022    /// **Hand this plane the live webhook slot**, so an operator can name an
1023    /// outbound endpoint over the control plane. Called by `Server::start`
1024    /// before the plane is wrapped in an `Arc`.
1025    #[cfg(feature = "webhooks")]
1026    pub fn with_webhooks(mut self, slot: Arc<crate::webhook::WebhookSlot>) -> Self {
1027        self.webhook_slot = Some(slot);
1028        self
1029    }
1030
1031    /// Hand the entitlement slot in (`metered` only).
1032    #[cfg(feature = "metered")]
1033    pub fn with_entitlement(mut self, slot: Arc<crate::entitlement::EntitlementSlot>) -> Self {
1034        self.entitlement = Some(slot);
1035        self
1036    }
1037
1038    /// The entitlement slot, or a refusal that names WHY there is none.
1039    #[cfg(feature = "metered")]
1040    fn entitlement(&self) -> ControlResult<&Arc<crate::entitlement::EntitlementSlot>> {
1041        self.entitlement.as_ref().ok_or_else(|| {
1042            ControlError::FailedPrecondition(
1043                "this metered server was started without `--monetize-pubkey`, so it holds no \
1044                 key to verify an entitlement fact against and refuses every one; every \
1045                 namespace is Free"
1046                    .into(),
1047            )
1048        })
1049    }
1050
1051    /// The webhook slot, or a refusal that names WHY there is none.
1052    #[cfg(feature = "webhooks")]
1053    fn webhook_slot(&self) -> ControlResult<&Arc<crate::webhook::WebhookSlot>> {
1054        self.webhook_slot.as_ref().ok_or(ControlError::Unimplemented(
1055            "this control plane was built with no webhook slot, so it cannot configure                  an outbound endpoint",
1056        ))
1057    }
1058
1059    /// Configuration **and** running state, in one answer.
1060    ///
1061    /// Both halves, always, because they can disagree and the disagreement is
1062    /// the interesting part: `targets` non-empty with `standbys` empty is a
1063    /// server that has the configuration and is not running it.
1064    #[cfg(feature = "replication")]
1065    ///
1066    /// **`include_refs` is the operator's, and it is false for everything but a
1067    /// `replication list --refs`.** The mutating verbs print a snapshot back to
1068    /// confirm the roster, and the ref-coverage walk they used to trigger opens
1069    /// every repository's reference database for a number nobody asked
1070    /// for — so they pass `false`, and only the explicit status verb forwards
1071    /// the flag the operator set.
1072    fn replication_snapshot(
1073        &self,
1074        include_refs: bool,
1075    ) -> ControlResult<gunnar_proto::ReplicationStatusResponse> {
1076        let slot = self.replication_slot()?;
1077        let config = slot
1078            .config()
1079            .map_err(|err| ControlError::Internal(format!("{err:#}")))?;
1080        let mut out = gunnar_proto::ReplicationStatusResponse {
1081            targets: config
1082                .targets
1083                .iter()
1084                .map(crate::replication_wire::target_to_proto)
1085                .collect(),
1086            role: config.role.to_proto() as i32,
1087            ..Default::default()
1088        };
1089        if let Some(replicator) = slot.replicator() {
1090            crate::replication_wire::running_to_proto(&replicator, include_refs, &mut out);
1091        }
1092        Ok(out)
1093    }
1094
1095    /// **The slot `serve` drops the replicator's counters into**, so
1096    /// `Admin.Stats` can report them.
1097    ///
1098    /// Without it the counters exist and nothing reads them, which is exactly
1099    /// the state that let every configured standby receive NOTHING from
1100    /// `e31ab8f4` to 2026-08-30 while five log lines out of five said so into a
1101    /// file nobody was tailing.
1102    pub fn replication_view(&self) -> crate::replication_view::ReplicationView {
1103        self.replication.clone()
1104    }
1105
1106    /// Every forge event, as it was written.
1107    ///
1108    /// Verifying nothing: the fold is over what is in the log, and whether an
1109    /// event is evidence is a separate question with its own answer, asked
1110    /// per approval in [`CoreControlPlane::thread_state`].
1111    fn forge_events(&self) -> ControlResult<Vec<gunnar_core::SignedEvent>> {
1112        self.stores
1113            .forge_log()
1114            .lines_of()
1115            .map_err(|err| ControlError::Unavailable(format!("reading the forge log: {err:#}")))?
1116            .iter()
1117            .map(|line| {
1118                serde_json::from_str(line).map_err(|err| {
1119                    ControlError::Internal(format!("a forge event will not decode: {err}"))
1120                })
1121            })
1122            .collect()
1123    }
1124
1125    /// **The subject of a forge event must exist and be open** — the one check
1126    /// for an event its actor signed and one gunnar attested (GHUNT-19).
1127    fn forge_subject_open(&self, signed: &gunnar_core::SignedEvent) -> ControlResult<()> {
1128        // **The subject must exist and be open**, and which subject that is
1129        // depends on the kind. A review event names a pull request the record
1130        // owns; an issue event names an issue THIS log owns. Both refuse an
1131        // event on something merged, closed or never opened — evidence about
1132        // nothing — but they ask different authorities, so they branch here.
1133        let (namespace, name) = signed.event.repo.split_once('/').ok_or_else(|| {
1134            ControlError::InvalidArgument(format!(
1135                "`repo` must be `<namespace>/<repo>`, got {:?}",
1136                signed.event.repo
1137            ))
1138        })?;
1139        let path = RepoPath::new(namespace, name);
1140        match &signed.event.event {
1141            // An opening creates the issue and so has no precondition: there is
1142            // nothing yet to be open. Its number is the fold's to assign.
1143            gunnar_core::Event::IssueOpened { .. } => {}
1144            // A comment or close names an issue by number. The issue is folded
1145            // from this same log — there is no record behind it — so the check
1146            // is against the fold rather than `get_pull`.
1147            gunnar_core::Event::IssueCommented { .. } | gunnar_core::Event::IssueClosed { .. } => {
1148                let repo_events: Vec<gunnar_core::ForgeEvent> = self
1149                    .forge_events()?
1150                    .into_iter()
1151                    .filter(|e| e.event.repo == signed.event.repo)
1152                    .map(|e| e.event)
1153                    .collect();
1154                match gunnar_core::forge::fold_issue(signed.event.pull, &repo_events) {
1155                    None => {
1156                        return Err(ControlError::FailedPrecondition(format!(
1157                            "no issue #{} in {}: nobody has opened one, so an event on it is \
1158                             evidence about nothing",
1159                            signed.event.pull, signed.event.repo
1160                        )));
1161                    }
1162                    Some(issue) if !issue.open => {
1163                        return Err(ControlError::FailedPrecondition(format!(
1164                            "issue #{} in {} is closed; an event on it cannot change what has \
1165                             already happened",
1166                            signed.event.pull, signed.event.repo
1167                        )));
1168                    }
1169                    Some(_) => {}
1170                }
1171            }
1172            // A review event names a pull request, whose record is the authority
1173            // for whether it exists and is open.
1174            gunnar_core::Event::Commented { .. }
1175            | gunnar_core::Event::Approved { .. }
1176            | gunnar_core::Event::Rejected { .. } => {
1177                let record = self.get_pull(&path, signed.event.pull)?;
1178                if record.state != gunnar_proto::PullRequestState::Open as i32 {
1179                    return Err(ControlError::FailedPrecondition(format!(
1180                        "pull request #{} in {} is not open; a review event cannot change what \
1181                         has already happened",
1182                        signed.event.pull, signed.event.repo
1183                    )));
1184                }
1185            }
1186        }
1187        Ok(())
1188    }
1189
1190    /// Append a checked forge event to the log, and say where it landed.
1191    fn forge_write(&self, signed: &gunnar_core::SignedEvent) -> ControlResult<u64> {
1192        let log = self.stores.forge_log();
1193        let mut line = serde_json::to_string(signed)
1194            .map_err(|err| ControlError::Internal(format!("encoding the event: {err}")))?;
1195        line.push('\n');
1196        let _framed =
1197            gunnar_core::txlog::sink::append_line(log.records_path(), line.trim_end_matches('\n'))
1198                .map_err(|err| {
1199                    ControlError::Unavailable(format!(
1200                        "framing the line in the transaction log: {err}"
1201                    ))
1202                })?;
1203        use std::io::Write as _;
1204        let mut file = std::fs::OpenOptions::new()
1205            .create(true)
1206            .append(true)
1207            .open(log.records_path())
1208            .map_err(|err| ControlError::Unavailable(format!("opening the forge log: {err}")))?;
1209        file.write_all(line.as_bytes()).map_err(|err| {
1210            ControlError::Unavailable(format!("appending to the forge log: {err}"))
1211        })?;
1212        // The index it landed at, counted after the write so it names this
1213        // event and not the one before it.
1214        let count = self
1215            .stores
1216            .forge_log()
1217            .lines_of()
1218            .map_err(|err| ControlError::Unavailable(format!("{err:#}")))?
1219            .len() as u64;
1220        Ok(count.saturating_sub(1))
1221    }
1222
1223    /// A folded review, with **every approval re-checked against the ACL key
1224    /// table right now** — not trusted because it is in the file.
1225    ///
1226    /// That re-check is the difference the whole design is about, and it is why
1227    /// the wire carries a verdict per approval rather than a count: an approval
1228    /// whose key was since removed has not failed and has not passed, and a
1229    /// number cannot say so.
1230    ///
1231    /// **The key must be the claimed actor's own.** A signature proves which
1232    /// KEY spoke; only the key table's `principal_id` says whose voice that
1233    /// is. Until 2026-08-31 the fingerprint was looked up across the whole
1234    /// table, so an approval claiming `actor: alice` signed by any other
1235    /// registered principal's key verified — and merged. The verdict for that
1236    /// case names the key's real owner, because "your signature failed" and
1237    /// "that is somebody else's key" send an operator down different roads.
1238    fn review_state(
1239        &self,
1240        repo: &str,
1241        state: &gunnar_core::forge::Review,
1242        all: &[gunnar_core::SignedEvent],
1243    ) -> ControlResult<gunnar_proto::ReviewState> {
1244        let keys: Vec<(String, String, gunnar_core::ssh_key::PublicKey)> = self
1245            .stores
1246            .auth()
1247            .signing_keys()
1248            .map_err(|err| ControlError::Unavailable(format!("{err:#}")))?
1249            .iter()
1250            .filter_map(|row| {
1251                gunnar_core::ssh_key::PublicKey::from_openssh(&row.openssh)
1252                    .ok()
1253                    .map(|key| {
1254                        (
1255                            row.fingerprint_sha256.clone(),
1256                            row.principal_id.clone(),
1257                            key,
1258                        )
1259                    })
1260            })
1261            .collect();
1262        // Gunnar's own forge-attestation key, read without minting: an
1263        // attested approval verifies against it and nothing else (GHUNT-19).
1264        let attest_key = self
1265            .stores
1266            .forge_attest_public()
1267            .map_err(|err| ControlError::Unavailable(format!("{err:#}")))?;
1268
1269        let approvals = state
1270            .approvals
1271            .iter()
1272            .map(|(actor, commit)| {
1273                let signed = all.iter().rev().find(|e| {
1274                    e.event.repo == repo
1275                        && e.event.pull == state.pull
1276                        && e.event.actor == *actor
1277                        && matches!(&e.event.event, gunnar_core::Event::Approved { commit: c } if c == commit)
1278                });
1279                let (verified, verdict) = match signed {
1280                    None => (false, "NO EVENT".to_owned()),
1281                    // **Gunnar's attestation for a person's stepped-up
1282                    // session**: signed by gunnar's own key, the actor named,
1283                    // the step-up inside the signature. Checked against that
1284                    // key alone — never against a principal's.
1285                    Some(signed) if signed.attested.is_some() => match &attest_key {
1286                        None => (false, "UNKNOWN KEY".to_owned()),
1287                        Some(key) => match gunnar_core::forge::verify_event(signed, key) {
1288                            Ok(()) => (
1289                                true,
1290                                signed.attested.as_ref().map(|a| a.words()).unwrap_or_default(),
1291                            ),
1292                            Err(problem) => (false, format!("BAD ({problem})")),
1293                        },
1294                    },
1295                    Some(signed) => match keys
1296                        .iter()
1297                        .find(|(fingerprint, _, _)| *fingerprint == signed.key_fingerprint)
1298                    {
1299                        None => (false, "UNKNOWN KEY".to_owned()),
1300                        // The key is registered — to somebody else. The
1301                        // signature may be perfectly good; it is still not
1302                        // this actor's word, and saying whose it is lets the
1303                        // operator see a filed-in-another's-name attempt for
1304                        // what it is.
1305                        Some((_, owner, _)) if *owner != *actor => {
1306                            (false, format!("KEY OF {owner}, NOT {actor}"))
1307                        }
1308                        Some((_, _, key)) => match gunnar_core::forge::verify_event(signed, key) {
1309                            Ok(()) => (true, "verified".to_owned()),
1310                            Err(problem) => (false, format!("BAD ({problem})")),
1311                        },
1312                    },
1313                };
1314                gunnar_proto::Approval {
1315                    actor: actor.clone(),
1316                    commit: commit.clone(),
1317                    verified,
1318                    verdict,
1319                }
1320            })
1321            .collect();
1322
1323        Ok(gunnar_proto::ReviewState {
1324            repo: repo.to_owned(),
1325            pull: state.pull,
1326            approvals,
1327            comments: state.comments as u64,
1328        })
1329    }
1330
1331    /// **Set the deployment's object ceiling** for `Refs.ReadObject`.
1332    ///
1333    /// `0` disables the ceiling, which restores the unbounded behaviour: the
1334    /// server will inflate an object of any size to answer a request. That is a
1335    /// real choice for a single-tenant box with a trusted control port and a
1336    /// repository full of large blobs, and it is spelled explicitly rather than
1337    /// being what you get by forgetting to configure anything.
1338    ///
1339    /// This exists as much for the tests as for the operator: a ceiling that can
1340    /// only be a compile-time constant can only be exercised by writing an object
1341    /// bigger than it, which for the real default means a 64 MiB fixture. Being
1342    /// able to set it small is what lets the refusal be seen red cheaply — and it
1343    /// proves the configuration is *wired*, which a test against the constant
1344    /// could never show.
1345    pub fn with_max_object_bytes(mut self, bytes: u64) -> Self {
1346        self.max_object_bytes = bytes;
1347        self
1348    }
1349
1350    /// The pull-request table, opened against the same control store the
1351    /// catalog and the identity table live in. A fresh handle per call: the
1352    /// document cache rides the handle, and a pull-request operation is rare
1353    /// enough that re-parsing beats holding one more long-lived Arc chain.
1354    fn pulls(&self) -> ControlResult<gunnar_core::Pulls> {
1355        gunnar_core::Pulls::new(Arc::clone(self.stores.control_store()))
1356            .map_err(|err| ControlError::Internal(format!("opening the pulls table: {err:#}")))
1357    }
1358
1359    fn repo_ref(path: &RepoPath) -> ControlResult<RepoRef> {
1360        path.validate()
1361            .map_err(|err| ControlError::InvalidArgument(err.to_string()))?;
1362        RepoRef::new(&path.namespace, &path.repo)
1363            .map_err(|err| ControlError::InvalidArgument(err.to_string()))
1364    }
1365
1366    /// Resolve a tree-ish — a ref name, a hex oid, or `HEAD` — to the **tree**
1367    /// it names.
1368    ///
1369    /// The resolution half is the one `log` already does: a hex oid is taken as
1370    /// one, anything else is validated as a reference name and looked up in the
1371    /// snapshot, and an oid of the wrong hash kind is refused with a sentence
1372    /// that says which kind the repository is. What is added here is the last
1373    /// hop — a commit is followed to its tree, a tag is peeled first, and a tree
1374    /// is already the answer — so `from`/`to` accept everything `git diff`
1375    /// accepts in the same position.
1376    fn tree_of(
1377        &self,
1378        git: &dyn GitServe,
1379        hash: gix_hash::Kind,
1380        path: &RepoPath,
1381        spec: &str,
1382    ) -> ControlResult<gix_hash::ObjectId> {
1383        let id = match gix_hash::ObjectId::from_hex(spec.as_bytes()) {
1384            Ok(id) => {
1385                if id.kind() != hash {
1386                    return Err(ControlError::InvalidArgument(format!(
1387                        "{spec} is a {} oid; this repository is {hash}",
1388                        id.kind()
1389                    )));
1390                }
1391                id
1392            }
1393            Err(_) => {
1394                gunnar_store::refname::validate(spec)
1395                    .map_err(|err| ControlError::InvalidArgument(err.to_string()))?;
1396                let snapshot = RefSnapshot::read(git).map_err(wire_error)?;
1397                let resolved = if spec == gunnar_store::HEAD {
1398                    snapshot.head(git).map_err(wire_error)?
1399                } else {
1400                    snapshot.resolve(spec).map_err(wire_error)?
1401                };
1402                resolved.ok_or_else(|| ControlError::NotFound(format!("{spec} in {path}")))?
1403            }
1404        };
1405        let id = peel(git, hash, id)?;
1406        let (kind, bytes) = git
1407            .read(id.as_bytes())
1408            .map_err(store_error)?
1409            .ok_or_else(|| ControlError::NotFound(format!("object {id} in {path}")))?;
1410        match kind {
1411            ObjType::Tree => Ok(id),
1412            ObjType::Commit => object::commit(&bytes, hash)
1413                .map(|commit| commit.tree)
1414                .map_err(|err| ControlError::Internal(format!("decoding commit {id}: {err}"))),
1415            // A blob is a legal object and not a legal side of a tree diff.
1416            // Saying so beats walking it and reporting that nothing changed.
1417            other => Err(ControlError::InvalidArgument(format!(
1418                "{spec} names a {other:?}; a diff needs a commit, a tag or a tree"
1419            ))),
1420        }
1421    }
1422
1423    /// The repository's policy as `gunnar-policy` understands it.
1424    ///
1425    /// A repository with no stored policy reads as
1426    /// [`PolicyConfig::default`](gunnar_policy::PolicyConfig) — no gates —
1427    /// which is the same thing the push path concludes and is stated in one
1428    /// place rather than two. A stored document that does **not parse** is an
1429    /// error here and not an empty policy: this path is a caller asking what
1430    /// the rules are, and answering "there are none" about a document that
1431    /// exists and is broken is the worst available answer.
1432    fn policy_of(&self, repo: &gunnar_core::Repo) -> ControlResult<gunnar_policy::PolicyConfig> {
1433        let stored = self.stores.policies().get(&repo.path()).map_err(internal)?;
1434        match stored {
1435            None => Ok(gunnar_policy::PolicyConfig::default()),
1436            Some(document) => gunnar_policy::PolicyConfig::from_json(&document).map_err(|err| {
1437                ControlError::Internal(format!(
1438                    "the stored policy for {} does not parse: {err}",
1439                    repo.key()
1440                ))
1441            }),
1442        }
1443    }
1444
1445    /// Write `config` back, or delete the document when it configures nothing.
1446    ///
1447    /// Deleting rather than storing an empty document is what keeps "nobody has
1448    /// configured this repository" and "somebody configured nothing" the same
1449    /// state. Two states that behave identically and read differently are a
1450    /// question an operator cannot answer.
1451    fn store_policy(
1452        &self,
1453        repo: &gunnar_core::Repo,
1454        config: &gunnar_policy::PolicyConfig,
1455    ) -> ControlResult<()> {
1456        let policies = self.stores.policies();
1457        if *config == gunnar_policy::PolicyConfig::default() {
1458            policies.remove(&repo.path()).map_err(internal)?;
1459            return Ok(());
1460        }
1461        let json = config
1462            .to_json()
1463            .map_err(|err| ControlError::Internal(format!("rendering the policy: {err}")))?;
1464        policies.set(&repo.path(), &json).map_err(internal)
1465    }
1466
1467    fn open(&self, path: &RepoPath) -> ControlResult<(gunnar_core::Repo, Arc<OpenRepo>)> {
1468        let repo_ref = Self::repo_ref(path)?;
1469        let repo = self
1470            .stores
1471            .catalog()
1472            .get(&repo_ref)
1473            .map_err(internal)?
1474            .ok_or_else(|| ControlError::NotFound(format!("repository {path}")))?;
1475        let store = self.stores.repo_store(&repo).map_err(|err| {
1476            ControlError::Internal(format!("opening the repository's stores: {err:#}"))
1477        })?;
1478        Ok((repo, store))
1479    }
1480
1481    /// **A mirror's registration, with its repository opened when it has
1482    /// one.** A registration whose repository is still to come (an import's
1483    /// first sync has not landed: `MirrorRecord::creates_repo`) has none to
1484    /// open, and that is not an error; every other registration's repository
1485    /// must open, as it always had to.
1486    fn mirror_record(&self, path: &RepoPath) -> ControlResult<gunnar_core::MirrorRecord> {
1487        let repo_ref = Self::repo_ref(path)?;
1488        let record = self.stores.mirrors().get(&repo_ref).map_err(internal)?;
1489        match record {
1490            Some(record)
1491                if record.creates_repo
1492                    && self
1493                        .stores
1494                        .catalog()
1495                        .get(&repo_ref)
1496                        .map_err(internal)?
1497                        .is_none() =>
1498            {
1499                Ok(record)
1500            }
1501            record => {
1502                self.open(path)?;
1503                record.ok_or_else(|| {
1504                    ControlError::NotFound(format!("no mirror registered for {path}"))
1505                })
1506            }
1507        }
1508    }
1509}
1510
1511/// **The one place a [`crate::gc_queue::GcJob`] becomes a seam job.**
1512///
1513/// The queue keys on `<namespace>/<name>` because that is what its runner
1514/// needs; the seam speaks [`RepoPath`] because that is what the wire does.
1515/// `path` is threaded in rather than re-parsed out of the job's own string: it
1516/// is the request's own path, already validated, and re-parsing it here would
1517/// be a second resolver that could disagree with the first (one implementation).
1518fn gc_job_out(path: &RepoPath, job: crate::gc_queue::GcJob) -> gunnar_grpc::seam::GcJob {
1519    gunnar_grpc::seam::GcJob {
1520        job_id: job.job_id,
1521        path: path.clone(),
1522        state: match job.state {
1523            crate::gc_queue::GcState::Queued => gunnar_grpc::seam::GcState::Queued,
1524            crate::gc_queue::GcState::Running => gunnar_grpc::seam::GcState::Running,
1525            crate::gc_queue::GcState::Done => gunnar_grpc::seam::GcState::Done,
1526            crate::gc_queue::GcState::Failed => gunnar_grpc::seam::GcState::Failed,
1527        },
1528        queue_position: job.queue_position,
1529        // Named through `report` rather than through the job's `Deref`: the
1530        // job is being taken apart field by field here, and a deref borrow in
1531        // the middle of that would be reading a value that is half moved.
1532        bytes_reclaimed: job.report.bytes_reclaimed,
1533        error: job.error,
1534        enqueued_at_unix_ms: job.enqueued_at_unix_ms,
1535        finished_at_unix_ms: job.finished_at_unix_ms,
1536    }
1537}
1538
1539/// `gunnar_core`'s gate onto the seam's.
1540fn gate_out(gate: gunnar_core::AccountGate) -> gunnar_grpc::seam::AccountGate {
1541    gunnar_grpc::seam::AccountGate {
1542        account: gate.account,
1543        reason: match gate.reason {
1544            gunnar_core::GateReason::Administrative => {
1545                gunnar_grpc::seam::GateReason::Administrative
1546            }
1547            gunnar_core::GateReason::Purge => gunnar_grpc::seam::GateReason::Purge,
1548        },
1549        holder: gate.holder,
1550        actor: gate.actor,
1551        note: gate.note,
1552        at_unix_ms: gate.at,
1553        // **0 on the seam means "no deadline"**, which is what `None` means
1554        // here. Flattened at this boundary rather than on the wire so the proto
1555        // stays a plain `int64` and every reader of it applies the same rule.
1556        expires_unix_ms: gate.expires.unwrap_or(0),
1557    }
1558}
1559
1560/// The queue's job onto the seam's.
1561fn purge_job_out(job: crate::purge_queue::PurgeJob) -> gunnar_grpc::seam::PurgeJob {
1562    // The purge's own half, taken out whole before the queue's half is: the
1563    // failure list is owned and has to be MOVED onto the seam, and a job's
1564    // `Deref` only ever lends its report.
1565    let report = job.report;
1566    gunnar_grpc::seam::PurgeJob {
1567        job_id: job.job_id,
1568        // The queue keys on the account, so the account IS the job's key.
1569        account: job.key,
1570        state: match job.state {
1571            crate::purge_queue::PurgeState::Queued => gunnar_grpc::seam::PurgeState::Queued,
1572            crate::purge_queue::PurgeState::Running => gunnar_grpc::seam::PurgeState::Running,
1573            crate::purge_queue::PurgeState::Done => gunnar_grpc::seam::PurgeState::Done,
1574            crate::purge_queue::PurgeState::Failed => gunnar_grpc::seam::PurgeState::Failed,
1575        },
1576        queue_position: job.queue_position,
1577        stores_found: report.stores_found,
1578        stores_purged: report.stores_purged,
1579        bytes_reclaimed: report.bytes_reclaimed,
1580        principal_removed: report.principal_removed,
1581        gate_released: report.gate_released,
1582        error: job.error,
1583        failures: report.failures,
1584        enqueued_at_unix_ms: job.enqueued_at_unix_ms,
1585        started_at_unix_ms: job.started_at_unix_ms,
1586        finished_at_unix_ms: job.finished_at_unix_ms,
1587    }
1588}
1589
1590/// A gate write that was refused, as the error the caller wants.
1591///
1592/// A reserved or malformed account name is the caller's mistake and comes back
1593/// as `INVALID_ARGUMENT`; anything else is the table failing to be written and
1594/// is not the caller's fault. Folding the first into `Internal` would send an
1595/// operator looking at the disk over a typo.
1596fn gate_error(err: gunnar_core::Error) -> ControlError {
1597    match err {
1598        gunnar_core::Error::ReservedPrincipalId(_) | gunnar_core::Error::InvalidPath { .. } => {
1599            ControlError::InvalidArgument(format!("{err}"))
1600        }
1601        other => internal(other),
1602    }
1603}
1604
1605/// **One stored flow row → one `DayActivity`.** The single writer of that
1606/// mapping, so the wire and the ledger cannot drift by a field.
1607///
1608/// `refused_by_reason` carries only the reasons that HAPPENED. A reason with no
1609/// refusals that day is absent rather than 0, because the map is a breakdown of
1610/// a number the caller already has (`refused`) and a zero entry in it adds a
1611/// row to a console's list that says nothing.
1612fn day_activity(day: i64, row: &gunnar_core::FlowDay) -> gunnar_proto::DayActivity {
1613    gunnar_proto::DayActivity {
1614        day_unix_ms: gunnar_core::midnight_of(day),
1615        bytes_in: row.bytes_in,
1616        bytes_out: row.bytes_out,
1617        pushes: row.pushes,
1618        fetches: row.fetches,
1619        lfs_bytes_in: row.lfs_bytes_in,
1620        lfs_bytes_out: row.lfs_bytes_out,
1621        refused: row.refused(),
1622        refused_by_reason: gunnar_core::Refusal::ALL
1623            .into_iter()
1624            .filter(|reason| row.refused_for(*reason) > 0)
1625            .map(|reason| (reason.name().to_owned(), row.refused_for(reason)))
1626            .collect(),
1627        auth_failures: row.auth_failures,
1628        last_push_unix_ms: row.last_push_unix_ms,
1629        last_fetch_unix_ms: row.last_fetch_unix_ms,
1630        distinct_principals: row.distinct_principals(),
1631        distinct_principals_capped: row.principals_capped,
1632    }
1633}
1634
1635/// **A create's refusal, classified** — the repository or namespace named, a box being
1636/// restored (or off the stripe) as UNAVAILABLE (RUN A 2026-10-08: a create on a main being
1637/// restored answered INTERNAL, and the console showed "transaction log" and "twin" to a
1638/// customer), any other core error INTERNAL, anything else (the disk's own refusal) as is.
1639pub(crate) fn create_error(err: &anyhow::Error, path: &RepoPath) -> ControlError {
1640    match err.downcast_ref::<gunnar_core::Error>() {
1641        Some(gunnar_core::Error::RepoExists(_)) => {
1642            ControlError::AlreadyExists(format!("repository {path}"))
1643        }
1644        Some(gunnar_core::Error::NoSuchNamespace(ns)) => {
1645            ControlError::NotFound(format!("namespace {ns}"))
1646        }
1647        Some(e @ (gunnar_core::Error::LogFollows | gunnar_core::Error::OffStripe { .. })) => {
1648            ControlError::Unavailable(e.to_string())
1649        }
1650        Some(_) => ControlError::Internal(format!("{err:#}")),
1651        None => ControlError::FailedPrecondition(format!("{err:#}")),
1652    }
1653}
1654
1655/// **Is this the mirror's first sync, from its in-flight mark?** (`mark_started`, IMPORT-BIG).
1656/// The mark is what lets a restart resume the import and what makes its outcome audited.
1657pub(crate) fn first_sync_of<E: std::fmt::Display>(
1658    mark: Result<Option<gunnar_core::MirrorRecord>, E>,
1659    path: &str,
1660) -> Result<bool, String> {
1661    match mark {
1662        Ok(record) => {
1663            Ok(record.is_some_and(|record| record.creates_repo || record.sync_count == 0))
1664        }
1665        Err(err) => {
1666            // RA4-10: unwritten, a restart could not resume this import and its outcome would go
1667            // unaudited, so the sync does not start.
1668            tracing::error!(target: "gunnar::git", "ALERT mirror.mark-unwritten {path}: the in-flight mark was not written ({err}); the sync is not started");
1669            Err(format!("{path}: the mirror's in-flight mark was not written ({err}); nothing was fetched, ask again"))
1670        }
1671    }
1672}
1673
1674#[cfg(test)]
1675mod mirror_mark_tests {
1676    use super::*;
1677
1678    /// ★ RA4-10 RED: **an in-flight mark that is not written fails the sync by name** — a
1679    /// dropped write meant a restart could not resume the import and its outcome went
1680    /// unaudited.
1681    #[test]
1682    fn a_mark_that_is_not_written_fails_the_sync_by_name() {
1683        let why = first_sync_of::<String>(Err("the mirrors store is full".into()), "acme/linux")
1684            .expect_err("refused");
1685        assert!(
1686            why.contains("acme/linux")
1687                && why.contains("the mirrors store is full")
1688                && why.contains("in-flight mark"),
1689            "{why}"
1690        );
1691        assert_eq!(first_sync_of::<String>(Ok(None), "acme/linux"), Ok(false));
1692    }
1693}
1694
1695#[cfg(test)]
1696mod create_error_tests {
1697    use super::*;
1698
1699    /// ★ **A create on a box being restored is UNAVAILABLE, never INTERNAL** (RUN A R8,
1700    /// 2026-10-08): the console turns UNAVAILABLE + this sentence into its own word.
1701    #[test]
1702    fn a_create_on_a_restoring_box_is_unavailable() {
1703        let path = RepoPath::new("acme", "site");
1704        let restoring = anyhow::Error::new(gunnar_core::Error::LogFollows);
1705        assert!(
1706            matches!(create_error(&restoring, &path), ControlError::Unavailable(m) if m.contains("being restored"))
1707        );
1708        let exists = anyhow::Error::new(gunnar_core::Error::RepoExists(
1709            gunnar_core::RepoRef::new("acme", "site").unwrap(),
1710        ));
1711        assert!(matches!(
1712            create_error(&exists, &path),
1713            ControlError::AlreadyExists(_)
1714        ));
1715        assert!(matches!(
1716            create_error(&anyhow::anyhow!("a store already stands there"), &path),
1717            ControlError::FailedPrecondition(_)
1718        ));
1719    }
1720}
1721
1722/// A catalog error, mapped to the status code that says **whose fault it is**.
1723///
1724/// This used to be `ControlError::Internal(err.to_string())` for every one of
1725/// its thirty-four call sites, so `gunnar control repo set-default-branch
1726/// team/project trunk` came back as
1727///
1728/// ```text
1729/// gunnar: Internal error: internal error: not a legal default branch name: "trunk"
1730/// ```
1731///
1732/// An `INTERNAL` says *the server is broken*. What actually happened is that
1733/// the caller typed a short branch name where a full ref was required, and
1734/// nothing in that sentence tells them to fix their argument rather than read
1735/// the server's logs. Every "this repository does not exist", "that name is
1736/// taken" and "you are not allowed to" arrived the same way.
1737///
1738/// `enrolment_error` already classified its own errors for exactly this
1739/// reason; this is the same job for the rest of the surface, in one function
1740/// rather than at thirty-four call sites (one implementation). The mapping follows gRPC's
1741/// own definitions:
1742///
1743/// * **INVALID_ARGUMENT** — the request is malformed however the server feels.
1744/// * **NOT_FOUND** / **ALREADY_EXISTS** — the named thing is or is not there.
1745/// * **FAILED_PRECONDITION** — well formed, and the server's *state* refuses.
1746/// * **PERMISSION_DENIED** — authenticated, not allowed.
1747/// * **UNAVAILABLE** — storage did not answer; retrying may work.
1748/// * **INTERNAL** — only what is genuinely a bug here, which is the point:
1749///   an `INTERNAL` an operator sees now means something.
1750fn internal(err: gunnar_core::Error) -> ControlError {
1751    use gunnar_core::Error as E;
1752    match err {
1753        // The caller's argument.
1754        e @ (E::InvalidPath { .. }
1755        | E::PathEscape { .. }
1756        | E::ReservedPrincipalId(_)
1757        | E::BadBranchName(_)
1758        | E::BadFingerprint(_)
1759        | E::Key(_)
1760        | E::InvalidInput { .. }) => ControlError::InvalidArgument(e.to_string()),
1761        // What is not there.
1762        e @ (E::NoSuchRepo(_)
1763        | E::NoSuchNamespace(_)
1764        | E::NoSuchPrincipal(_)
1765        | E::NoSuchCredential(_)) => ControlError::NotFound(e.to_string()),
1766        // …and what already is.
1767        e @ (E::RepoExists(_)
1768        | E::NamespaceExists(_)
1769        | E::PrincipalExists(_)
1770        | E::NameExists { .. }
1771        | E::KeyExists { .. }
1772        | E::CredentialExists { .. }
1773        | E::InviteExists(_)
1774        | E::TokenExists { .. }
1775        | E::AlreadyBootstrapped { .. }
1776        | E::AlreadyProvisioned { .. }) => ControlError::AlreadyExists(e.to_string()),
1777        // Well formed; the server's state says no. A namespace that still holds
1778        // repositories is the case an operator meets, and `--delete-repos` is
1779        // the answer — which is a precondition, not a bad request.
1780        e @ (E::NamespaceNotEmpty { .. }
1781        | E::Contended { .. }
1782        | E::NotAPerson { .. }
1783        | E::NotAMachine { .. }) => ControlError::FailedPrecondition(e.to_string()),
1784        e @ (E::Denied { .. } | E::CounterRollback { .. } | E::InviteInvalid) => {
1785            ControlError::PermissionDenied(e.to_string())
1786        }
1787        // Storage did not answer. NOT `Internal`: the request may well succeed
1788        // on a retry, and telling a caller their request was wrong when the
1789        // disk was busy sends them to fix the wrong thing.
1790        e @ (E::Store(_) | E::Json(_)) => ControlError::Unavailable(e.to_string()),
1791        // The control store is OFF THE STRIPE while the data set grows: the
1792        // write is refused by name and the caller retries after the growth —
1793        // D7's "refuse", and UNAVAILABLE says exactly that to a generated
1794        // client where INTERNAL would say "look at this server".
1795        e @ E::OffStripe { .. } => ControlError::Unavailable(e.to_string()),
1796        // A box being restored takes no write until the restore is whole: "try again".
1797        e @ E::LogFollows => ControlError::Unavailable(e.to_string()),
1798        // A document that will not parse, and anything unclassified. These are
1799        // what genuinely mean "look at this server". `gunnar_core::Error` is
1800        // `#[non_exhaustive]`, so a variant added later lands here — `INTERNAL`
1801        // is the right default for "gunnar does not know what this is", and it
1802        // errs toward the server's fault rather than toward blaming the caller
1803        // for something nobody has classified.
1804        e => ControlError::Internal(e.to_string()),
1805    }
1806}
1807
1808/// **An archive cut's error, classified by whose fault it is** — the same job
1809/// [`internal`] does for `gunnar_core::Error`, for the one path that reports an
1810/// `anyhow` chain instead.
1811///
1812/// Two classes, and they are told apart by what is *in* the chain rather than
1813/// by matching on a message:
1814///
1815/// * an `io::Error` anywhere in it means the disk or the socket did not answer
1816///   — `UNAVAILABLE`, because a retry may well work, and a `BrokenPipe` is the
1817///   *client* having hung up rather than this server being broken;
1818/// * anything else is a refusal the cut made on purpose — the watermark above a
1819///   pack a GC has rewritten, a directory with no `objects.pack`, a repository
1820///   directory holding two stores. Those are `FAILED_PRECONDITION`: the request
1821///   is well formed and the server's state says no. A full archive
1822///   (`--from-generation 0`) is the answer to the first, and the message says so
1823///   in the plugin's own words.
1824#[cfg(feature = "engine-znippy")]
1825fn archive_error(err: anyhow::Error) -> ControlError {
1826    let io = err
1827        .chain()
1828        .any(|cause| cause.downcast_ref::<std::io::Error>().is_some());
1829    let message = format!("{err:#}");
1830    if io {
1831        ControlError::Unavailable(message)
1832    } else {
1833        ControlError::FailedPrecondition(message)
1834    }
1835}
1836
1837/// **The one storage-fault conversion in this file** (one implementation).
1838///
1839/// `GitOps` and `GitServe` answer in `anyhow`, so every read of a repository
1840/// arrives here. `{:#}` and not `to_string()`: the cause chain is the whole of
1841/// what an operator has to go on, and `to_string()` prints only the outermost
1842/// context — *"reading the ref namespace"* with the errno dropped.
1843///
1844/// **The status code is unchanged, deliberately.** `Internal` is what this
1845/// answered before, and `internal_or_denied` twenty lines above classifies
1846/// `gunnar_core::Error::Store` as `Unavailable` for a reason that applies here
1847/// too — a storage fault may well succeed on a retry. Changing it is a change
1848/// to what every gRPC client of `Refs.*` retries on, so it is **recorded here
1849/// and not taken as a drive-by** of a compile fix.
1850///
1851/// ⚠ **A lost compare-and-swap and a broken disk arrive as the same string** on
1852/// this path. `git-storage-trait` carries [`RefRejection`] for exactly that
1853/// distinction and `RefRejection::of(&err)` is how it is recovered; nothing in
1854/// this file needs it yet. Whatever does must use that, and **never** match on
1855/// the message text.
1856///
1857/// [`RefRejection`]: gunnar_store::git_storage_trait::RefRejection
1858fn store_error(err: anyhow::Error) -> ControlError {
1859    ControlError::Internal(format!("{err:#}"))
1860}
1861
1862/// The same, for the one authority on following a symbolic reference.
1863///
1864/// [`gunnar_wire::refs`] returns `gunnar_wire::Error` rather than
1865/// `anyhow::Error`; a symref walk that refuses is a fault in what the store
1866/// holds, which is the same `Internal` this control plane already reports for
1867/// [`store_error`], so the two funnel to one variant rather than inventing a
1868/// second grade of failure for the same event.
1869fn wire_error(err: gunnar_wire::Error) -> ControlError {
1870    ControlError::Internal(format!("{err:#}"))
1871}
1872
1873// ── model translation ───────────────────────────────────────────────────────
1874
1875/// The one translation of a hash kind into the proto enum. `transport.rs`
1876/// reuses it for the push attestation rather than writing a second `match`
1877/// that could drift from this one (one implementation).
1878pub(crate) fn hash_kind(kind: HashKind) -> gunnar_proto::HashKind {
1879    match kind {
1880        HashKind::Sha1 => gunnar_proto::HashKind::Sha1,
1881        HashKind::Sha256 => gunnar_proto::HashKind::Sha256,
1882    }
1883}
1884
1885/// gunnar has **two** visibilities, not three.
1886///
1887/// The proto still carries `VISIBILITY_INTERNAL`, and it used to alias
1888/// `PUBLIC` here — honestly, at the time: with no anonymous path at all,
1889/// "readable by any authenticated principal" and "public" named the same set.
1890/// Anonymous read (2026-08-03) broke that equivalence — `Public` now means readable by
1891/// ANYONE, with no key — and the alias survived the redefinition. What that
1892/// cost, MEASURED 2026-08-19 through the console: an operator chose
1893/// "internal" and the repository came out anonymous-readable. Silently
1894/// WIDENING an access request is the one direction a coercion must never
1895/// fail, so `INTERNAL` is now refused by name instead of mapped. The day a
1896/// real internal tier exists in [`Visibility`], this arm becomes its
1897/// translation; until then the wire value is a claim with no implementation.
1898fn visibility_in(v: gunnar_proto::Visibility) -> ControlResult<Visibility> {
1899    match v {
1900        gunnar_proto::Visibility::Public => Ok(Visibility::Public),
1901        gunnar_proto::Visibility::Internal => Err(ControlError::InvalidArgument(
1902            "visibility \"internal\" has no implementation: since 2026-08-03, \"public\" means \
1903             readable with no key at all, so \"internal\" (any authenticated principal) \
1904             no longer aliases it and gunnar refuses to guess which you meant. Choose \
1905             \"private\" or \"public\""
1906                .into(),
1907        )),
1908        gunnar_proto::Visibility::Private | gunnar_proto::Visibility::Unspecified => {
1909            Ok(Visibility::Private)
1910        }
1911    }
1912}
1913
1914/// The one translation of a stored pull request into the proto shape (one implementation).
1915fn pull_out(path: &RepoPath, record: gunnar_core::PullRecord) -> PullRequest {
1916    PullRequest {
1917        repo: Some(path.clone()),
1918        number: record.number,
1919        title: record.title,
1920        description: record.description,
1921        source_ref: record.source_ref,
1922        target_ref: record.target_ref,
1923        state: match record.state {
1924            gunnar_core::PullState::Open => PullRequestState::Open,
1925            gunnar_core::PullState::Merged => PullRequestState::Merged,
1926            gunnar_core::PullState::Closed => PullRequestState::Closed,
1927        } as i32,
1928        author: record.author,
1929        created_at_unix_ms: record.created_ms,
1930        updated_at_unix_ms: record.updated_ms,
1931        source_oid: record.source_oid,
1932        merge_oid: record.merge_oid.unwrap_or_default(),
1933    }
1934}
1935
1936fn visibility_out(v: Visibility) -> gunnar_proto::Visibility {
1937    match v {
1938        Visibility::Private => gunnar_proto::Visibility::Private,
1939        Visibility::Public => gunnar_proto::Visibility::Public,
1940    }
1941}
1942
1943fn principal_kind_in(kind: gunnar_proto::PrincipalKind) -> PrincipalKind {
1944    match kind {
1945        gunnar_proto::PrincipalKind::Machine => PrincipalKind::Machine,
1946        gunnar_proto::PrincipalKind::Deploy => PrincipalKind::Deploy,
1947        gunnar_proto::PrincipalKind::Group => PrincipalKind::Group,
1948        gunnar_proto::PrincipalKind::User | gunnar_proto::PrincipalKind::Unspecified => {
1949            PrincipalKind::User
1950        }
1951    }
1952}
1953
1954impl CoreControlPlane {
1955    /// **Whose account the namespace `name` is** (`Namespace.owner_kind`): the
1956    /// kind of the principal whose id it is, else UNSPECIFIED — so a reader
1957    /// that bills per namespace can leave out the estate's own machines by
1958    /// what they ARE, not by a list of their names. A store that cannot be
1959    /// read says UNSPECIFIED (the namespace is still listed).
1960    ///
1961    /// A GROUP whose every direct member is a machine is the estate's own too
1962    /// (the `twin` group, the twin's log readers — MEASURED on the mock
1963    /// 2026-09-29, listed as a tenant) and answers MACHINE; a group with anyone
1964    /// else in it, or with nobody, stays a group.
1965    fn owner_kind(&self, name: &str) -> i32 {
1966        let auth = self.stores.auth();
1967        match auth.principal(name) {
1968            Ok(Some(p)) if p.kind == PrincipalKind::Group => {
1969                let members = auth.members_of(name).unwrap_or_default();
1970                let machines = !members.is_empty() && members.iter().all(|m| matches!(auth.principal(m), Ok(Some(q)) if q.kind == PrincipalKind::Machine));
1971                if machines {
1972                    gunnar_proto::PrincipalKind::Machine as i32
1973                } else {
1974                    principal_kind_out(p.kind) as i32
1975                }
1976            }
1977            Ok(Some(p)) => principal_kind_out(p.kind) as i32,
1978            _ => gunnar_proto::PrincipalKind::Unspecified as i32,
1979        }
1980    }
1981
1982    /// The catalog's namespace onto the wire — one mapping for create, list
1983    /// and the single lookup, so the account's settings and its employee count
1984    /// cannot be on one and missing from another. `employees` is derived (an
1985    /// account with any is an enterprise account; nothing is set).
1986    fn namespace_out(&self, namespace: gunnar_core::Namespace, employees: u32) -> Namespace {
1987        Namespace {
1988            owner_kind: self.owner_kind(&namespace.name),
1989            // A store that cannot be read says SELLER: a customer left out for one tick is given
1990            // its fact on the next, while a fact given to the operator's own namespace stays.
1991            owner_server_grant: self
1992                .stores
1993                .auth()
1994                .holds_server_grant(&namespace.name)
1995                .unwrap_or(true),
1996            name: namespace.name,
1997            description: String::new(),
1998            created_at_unix_ms: 0,
1999            public: namespace.public,
2000            events_public: namespace.events_public,
2001            employees,
2002        }
2003    }
2004
2005    /// How many employees `account` has enrolled.
2006    fn employee_count(&self, account: &str) -> ControlResult<u32> {
2007        Ok(self
2008            .stores
2009            .auth()
2010            .employees_of(account)
2011            .map_err(internal)?
2012            .len() as u32)
2013    }
2014}
2015
2016fn principal_kind_out(kind: PrincipalKind) -> gunnar_proto::PrincipalKind {
2017    match kind {
2018        PrincipalKind::User => gunnar_proto::PrincipalKind::User,
2019        PrincipalKind::Machine => gunnar_proto::PrincipalKind::Machine,
2020        PrincipalKind::Deploy => gunnar_proto::PrincipalKind::Deploy,
2021        PrincipalKind::Group => gunnar_proto::PrincipalKind::Group,
2022        // **Unreachable, and mapped honestly rather than plausibly.**
2023        // Every principal that reaches this function came out of the identity
2024        // table, and `AuthTable::add_principal` refuses the anonymous id, so
2025        // there is no anonymous row to list. `gunnar-proto` has no `ANONYMOUS`
2026        // value, and picking `USER` would put a fabricated human in an operator's
2027        // principal list; `UNSPECIFIED` is the value that means "gunnar cannot
2028        // name this", which is the truth. Adding the enum value is a
2029        // `gunnar-proto` change and is recorded there, not invented here.
2030        PrincipalKind::Anonymous => gunnar_proto::PrincipalKind::Unspecified,
2031    }
2032}
2033
2034fn level_in(level: GrantLevel) -> ControlResult<Level> {
2035    match level {
2036        GrantLevel::Read => Ok(Level::Read),
2037        GrantLevel::Write => Ok(Level::Write),
2038        GrantLevel::Admin => Ok(Level::Admin),
2039        GrantLevel::Unspecified => Err(ControlError::InvalidArgument(
2040            "grant level unspecified".into(),
2041        )),
2042    }
2043}
2044
2045fn level_out(level: Level) -> GrantLevel {
2046    match level {
2047        Level::Read => GrantLevel::Read,
2048        Level::Write => GrantLevel::Write,
2049        Level::Admin => GrantLevel::Admin,
2050    }
2051}
2052
2053fn repo_out(repo: &gunnar_core::Repo) -> Repo {
2054    Repo {
2055        path: Some(RepoPath::new(&repo.namespace, &repo.name)),
2056        hash_kind: hash_kind(repo.hash_kind) as i32,
2057        default_branch: repo.default_branch.clone(),
2058        visibility: visibility_out(repo.visibility) as i32,
2059        created_at_unix_ms: repo.created,
2060        description: repo.description.clone(),
2061        encrypted: repo.encrypted,
2062    }
2063}
2064
2065/// A stored web credential, as the wire carries it (no key material).
2066fn web_credential_out(
2067    row: &gunnar_core::WebCredential,
2068) -> ControlResult<gunnar_proto::WebCredential> {
2069    let credential_id = gunnar_grpc::webauthn::from_b64url(&row.id).ok_or_else(|| {
2070        ControlError::Internal(format!(
2071            "a stored web credential has an id that is not base64url: {}",
2072            row.id
2073        ))
2074    })?;
2075    Ok(gunnar_proto::WebCredential {
2076        credential_id,
2077        principal_id: row.principal_id.clone(),
2078        label: row.label.clone(),
2079        created_at_unix_ms: row.created,
2080        last_used_unix_ms: row.last_used,
2081        sign_count: row.sign_count,
2082    })
2083}
2084
2085fn principal_out(principal: &gunnar_core::Principal) -> Principal {
2086    Principal {
2087        id: principal.id.clone(),
2088        name: principal.name.clone(),
2089        kind: principal_kind_out(principal.kind) as i32,
2090        // The table's stamp. `0` for a row from before the field existed,
2091        // which is exactly what this reported for every row until then.
2092        created_at_unix_ms: principal.created,
2093    }
2094}
2095
2096/// Clans on the identity table (`gunnar_grpc::clans::ClanPlane`, 2026-10-05).
2097mod clan_plane;
2098
2099fn key_out(key: &gunnar_core::PublicKey) -> PublicKey {
2100    PublicKey {
2101        fingerprint: key.fingerprint_sha256.clone(),
2102        algo: key.algo.clone(),
2103        principal_id: key.principal_id.clone(),
2104        // The OpenSSH comment field is part of the stored line; gunnar-core
2105        // normalises the line and does not split it out.
2106        comment: String::new(),
2107        added_at_unix_ms: key.added,
2108        last_used_unix_ms: key.last_used,
2109        expires_at_unix_ms: key.expires,
2110    }
2111}
2112
2113/// A grant, as `<namespace, repo>` strings.
2114///
2115/// A [`Scope::Server`] grant has neither, and is carried as two empty strings.
2116/// See the module docs: that is a proto gap, not an invention here.
2117fn grant_out(grant: &Grant) -> gunnar_proto::Grant {
2118    let (namespace, repo) = match &grant.scope {
2119        Scope::Server => (String::new(), String::new()),
2120        Scope::Namespace(name) => (name.clone(), String::new()),
2121        Scope::Repo(path) => (path.namespace().to_owned(), path.name().to_owned()),
2122    };
2123    gunnar_proto::Grant {
2124        principal_id: grant.principal.clone(),
2125        namespace,
2126        repo,
2127        level: level_out(grant.level) as i32,
2128        granted_at_unix_ms: 0,
2129    }
2130}
2131
2132fn scope_in(namespace: &str, repo: &str) -> ControlResult<Scope> {
2133    match (namespace.is_empty(), repo.is_empty()) {
2134        (true, true) => Ok(Scope::Server),
2135        (false, true) => Ok(Scope::Namespace(namespace.to_owned())),
2136        (false, false) => RepoRef::new(namespace, repo)
2137            .map(Scope::Repo)
2138            .map_err(|err| ControlError::InvalidArgument(err.to_string())),
2139        (true, false) => Err(ControlError::InvalidArgument(
2140            "a repository-scoped grant must name a namespace (multi-tenancy has \
2141             no ungrouped repos)"
2142                .into(),
2143        )),
2144    }
2145}
2146
2147/// The contract's object kind, in the proto's vocabulary.
2148///
2149/// [`ObjType`] also has `OfsDelta` and `RefDelta`, and neither can appear here:
2150/// [`GitServe::read`] and [`GitServe::header`] answer with the **resolved**
2151/// type by contract — that is the difference between them and
2152/// `GitOps::get`, which hands back the pack entry delta and all. A delta
2153/// reaching this function is a backend that broke its own promise, so it is
2154/// named as one rather than folded into `Unspecified`, which a client would
2155/// read as "an object of a kind this server is too old to name".
2156fn object_kind_out(kind: ObjType) -> ControlResult<gunnar_proto::ObjectKind> {
2157    match kind {
2158        ObjType::Blob => Ok(gunnar_proto::ObjectKind::Blob),
2159        ObjType::Tree => Ok(gunnar_proto::ObjectKind::Tree),
2160        ObjType::Commit => Ok(gunnar_proto::ObjectKind::Commit),
2161        ObjType::Tag => Ok(gunnar_proto::ObjectKind::Tag),
2162        other => Err(ControlError::Internal(format!(
2163            "the store answered a read with `{}`, which is a delta; GitServe::read is \
2164             documented to return the resolved type",
2165            other.as_str()
2166        ))),
2167    }
2168}
2169
2170/// Follow an annotated tag down to the object it names.
2171///
2172/// A bounded loop, not a recursion: a tag chain is legal git and a cycle is
2173/// not, but a cycle is also cheap to write and would otherwise hang a control
2174/// plane that has no timeout.
2175///
2176/// What object id `segments` names under `tree` — a blob's id, a subtree's id,
2177/// or `None` when the path names nothing there.
2178///
2179/// The log's path filter rests on this: a commit changed a path exactly when
2180/// this answer differs from its first parent's. A path that continues THROUGH
2181/// a non-directory names nothing, the same rule the wire's tree walk applies.
2182fn oid_at_path(
2183    git: &dyn GitServe,
2184    hash_kind: gix_hash::Kind,
2185    tree: gix_hash::ObjectId,
2186    segments: &[&[u8]],
2187) -> ControlResult<Option<gix_hash::ObjectId>> {
2188    let mut current = tree;
2189    let last = segments.len().saturating_sub(1);
2190    for (index, segment) in segments.iter().enumerate() {
2191        let Some((kind, bytes)) = git.read(current.as_bytes()).map_err(store_error)? else {
2192            return Ok(None);
2193        };
2194        if kind != ObjType::Tree {
2195            return Ok(None);
2196        }
2197        let Some(entry) = object::tree_entry_named(&bytes, hash_kind, segment)
2198            .map_err(|err| ControlError::Internal(format!("decoding tree {current}: {err}")))?
2199        else {
2200            return Ok(None);
2201        };
2202        if index == last {
2203            return Ok(Some(entry.oid));
2204        }
2205        if entry.kind != object::EntryKind::Tree {
2206            return Ok(None);
2207        }
2208        current = entry.oid;
2209    }
2210    Ok(Some(current))
2211}
2212
2213/// `hash_kind` is a parameter and never a default: it comes from
2214/// [`OpenRepo::hash_kind`], which comes from the catalog entry, and answering
2215/// `sha1` for a sha256 repository does not error — it decodes garbage.
2216/// Where a `Refs.Log` walk begins: one start (a ref name, an oid, or empty
2217/// for `HEAD`), or every branch and tag tip plus `HEAD`.
2218#[derive(Clone, Copy)]
2219enum LogStart<'a> {
2220    One(&'a str),
2221    AllRefs,
2222}
2223
2224/// One `Refs.Log` start resolved to an oid: an oid as given, otherwise a ref
2225/// name (empty means `HEAD`) resolved against one snapshot.
2226fn resolve_log_start(
2227    git: &dyn GitServe,
2228    hash: gix_hash::Kind,
2229    path: &RepoPath,
2230    start: &str,
2231) -> ControlResult<gix_hash::ObjectId> {
2232    let start = if start.is_empty() {
2233        gunnar_store::HEAD
2234    } else {
2235        start
2236    };
2237    let head = match gix_hash::ObjectId::from_hex(start.as_bytes()) {
2238        Ok(id) => id,
2239        Err(_) => {
2240            gunnar_store::refname::validate(start)
2241                .map_err(|err| ControlError::InvalidArgument(err.to_string()))?;
2242            let snapshot = RefSnapshot::read(git).map_err(wire_error)?;
2243            // `HEAD` is not in the snapshot (the `GitServe` HEAD ruling) and it is this verb's
2244            // own default, so it is asked through the accessor pair. Both
2245            // branches resolve against the same snapshot.
2246            let resolved = if start == gunnar_store::HEAD {
2247                snapshot.head(git).map_err(wire_error)?
2248            } else {
2249                snapshot.resolve(start).map_err(wire_error)?
2250            };
2251            resolved.ok_or_else(|| ControlError::NotFound(format!("{start} in {path}")))?
2252        }
2253    };
2254    if head.kind() != hash {
2255        return Err(ControlError::InvalidArgument(format!(
2256            "{start} is a {} oid; this repository is {hash}",
2257            head.kind()
2258        )));
2259    }
2260    Ok(head)
2261}
2262
2263impl CoreControlPlane {
2264    fn log_walk(
2265        &self,
2266        path: &RepoPath,
2267        start: LogStart<'_>,
2268        limit: u32,
2269        touching: &str,
2270    ) -> ControlResult<Vec<Commit>> {
2271        use std::collections::{BinaryHeap, HashMap, HashSet};
2272
2273        let (_, store) = self.open(path)?;
2274        let git = store.serve();
2275        let hash = store.hash_kind();
2276
2277        // The path filter, refused loudly rather than normalised: a filter
2278        // that silently ate `..` would answer for a different path than the
2279        // caller named.
2280        let filter: Option<Vec<&[u8]>> = if touching.is_empty() {
2281            None
2282        } else {
2283            let mut segments = Vec::new();
2284            for segment in touching.split('/').filter(|s| !s.is_empty()) {
2285                if segment == "." || segment == ".." {
2286                    return Err(ControlError::InvalidArgument(format!(
2287                        "the log path filter cannot contain {segment:?}"
2288                    )));
2289                }
2290                segments.push(segment.as_bytes());
2291            }
2292            (!segments.is_empty()).then_some(segments)
2293        };
2294
2295        let heads: Vec<gix_hash::ObjectId> = match start {
2296            LogStart::One(start) => vec![resolve_log_start(git, hash, path, start)?],
2297            LogStart::AllRefs => {
2298                let snapshot = RefSnapshot::read(git).map_err(wire_error)?;
2299                let mut heads = Vec::new();
2300                if let Some(head) = snapshot.head(git).map_err(wire_error)? {
2301                    heads.push(head);
2302                }
2303                for row in snapshot.rows().iter().filter(|r| {
2304                    r.name.starts_with("refs/heads/") || r.name.starts_with("refs/tags/")
2305                }) {
2306                    if let Some(id) = snapshot.resolve_row(row).map_err(wire_error)? {
2307                        heads.push(id);
2308                    }
2309                }
2310                heads
2311            }
2312        };
2313
2314        // Newest first, by EACH COMMIT'S OWN committer time — so tips of
2315        // several branches interleave the way `git log --date-order --all`
2316        // does, and a page cut by `limit` is the newest `limit` commits.
2317        // Each commit is decoded once, when it is queued, and kept until it
2318        // is popped.
2319        let limit = if limit == 0 { 50 } else { limit } as usize;
2320        let mut heap: BinaryHeap<(i64, gix_hash::ObjectId)> = BinaryHeap::new();
2321        let mut seen: HashSet<gix_hash::ObjectId> = HashSet::new();
2322        let mut queued: HashMap<gix_hash::ObjectId, object::Commit> = HashMap::new();
2323        let mut enqueue = |id: gix_hash::ObjectId,
2324                           heap: &mut BinaryHeap<(i64, gix_hash::ObjectId)>,
2325                           queued: &mut HashMap<gix_hash::ObjectId, object::Commit>|
2326         -> ControlResult<()> {
2327            if !seen.insert(id) {
2328                return Ok(());
2329            }
2330            // A shallow edge (not in this store) or a tip that peels to a
2331            // non-commit ends that line quietly, as it always did.
2332            let Some((kind, bytes)) = git.read(id.as_bytes()).map_err(store_error)? else {
2333                return Ok(());
2334            };
2335            if kind != ObjType::Commit {
2336                return Ok(());
2337            }
2338            let commit = object::commit(&bytes, hash)
2339                .map_err(|err| ControlError::Internal(format!("decoding commit {id}: {err}")))?;
2340            heap.push((commit.committer.seconds, id));
2341            queued.insert(id, commit);
2342            Ok(())
2343        };
2344        for head in heads {
2345            let head = peel(git, hash, head)?;
2346            enqueue(head, &mut heap, &mut queued)?;
2347        }
2348        let mut out = Vec::new();
2349
2350        // What a FILTERED walk may visit before giving up. A path nothing ever
2351        // touched must not cost a full traversal of a quarter-million commits
2352        // per page; a page that comes back short because of this bound is a
2353        // short page, not the end of history — the proto says so.
2354        const MAX_FILTERED_SCAN: usize = 20_000;
2355        let mut scanned = 0usize;
2356        // commit → its tree, remembered so a parent decoded for the filter is
2357        // not decoded again when the walk reaches it — and vice versa.
2358        let mut tree_of: HashMap<gix_hash::ObjectId, gix_hash::ObjectId> = HashMap::new();
2359
2360        while let Some((_, id)) = heap.pop() {
2361            if out.len() >= limit {
2362                break;
2363            }
2364            let Some(commit) = queued.remove(&id) else {
2365                continue;
2366            };
2367            let object::Commit {
2368                tree,
2369                parents,
2370                author,
2371                committer,
2372                message,
2373            } = commit;
2374
2375            // ── the path filter: did THIS commit change what the path names? ─
2376            //
2377            // Judged against the FIRST parent, which is what `git log -- path`
2378            // shows for ordinary history: "what did landing this commit change
2379            // here". Identical ids mean identical content all the way down, so
2380            // the question costs two short descents, never a blob.
2381            if let Some(segments) = &filter {
2382                scanned += 1;
2383                if scanned > MAX_FILTERED_SCAN {
2384                    break;
2385                }
2386                tree_of.insert(id, tree);
2387                let current = oid_at_path(git, hash, tree, segments)?;
2388                let previous = match parents.first() {
2389                    None => None,
2390                    Some(parent) => {
2391                        let parent_tree = match tree_of.get(parent) {
2392                            Some(known) => Some(*known),
2393                            None => match git.read(parent.as_bytes()).map_err(store_error)? {
2394                                Some((ObjType::Commit, parent_bytes)) => {
2395                                    let decoded =
2396                                        object::commit(&parent_bytes, hash).map_err(|err| {
2397                                            ControlError::Internal(format!(
2398                                                "decoding commit {parent}: {err}"
2399                                            ))
2400                                        })?;
2401                                    tree_of.insert(*parent, decoded.tree);
2402                                    Some(decoded.tree)
2403                                }
2404                                // A shallow edge: the parent is not in this
2405                                // store. Comparing against nothing keeps the
2406                                // commit visible rather than silently dropped.
2407                                _ => None,
2408                            },
2409                        };
2410                        match parent_tree {
2411                            Some(parent_tree) => oid_at_path(git, hash, parent_tree, segments)?,
2412                            None => None,
2413                        }
2414                    }
2415                };
2416                if current == previous {
2417                    // Untouched here — keep walking, show nothing.
2418                    for parent in parents {
2419                        enqueue(parent, &mut heap, &mut queued)?;
2420                    }
2421                    continue;
2422                }
2423            }
2424
2425            out.push(Commit {
2426                oid: id.to_string(),
2427                parent_oids: parents.iter().map(|p| p.to_string()).collect(),
2428                tree_oid: tree.to_string(),
2429                author: Some(signature_out(
2430                    &author.name,
2431                    &author.email,
2432                    author.seconds,
2433                    author.offset_seconds,
2434                )),
2435                committer: Some(signature_out(
2436                    &committer.name,
2437                    &committer.email,
2438                    committer.seconds,
2439                    committer.offset_seconds,
2440                )),
2441                message: String::from_utf8_lossy(&message).into_owned(),
2442            });
2443            for parent in parents {
2444                enqueue(parent, &mut heap, &mut queued)?;
2445            }
2446        }
2447        Ok(out)
2448    }
2449}
2450
2451fn peel(
2452    git: &dyn GitServe,
2453    hash_kind: gix_hash::Kind,
2454    mut id: gix_hash::ObjectId,
2455) -> ControlResult<gix_hash::ObjectId> {
2456    for _ in 0..16 {
2457        let Some((kind, bytes)) = git.read(id.as_bytes()).map_err(store_error)? else {
2458            return Ok(id);
2459        };
2460        if kind != ObjType::Tag {
2461            return Ok(id);
2462        }
2463        id = object::tag_target(&bytes, hash_kind)
2464            .map_err(|err| ControlError::Internal(format!("decoding tag {id}: {err}")))?;
2465    }
2466    Err(ControlError::FailedPrecondition(format!(
2467        "the tag chain at {id} is longer than 16 links, or cyclic"
2468    )))
2469}
2470
2471/// One [`RefRow`] as the proto's `RefTarget`, resolved against `snapshot` — the
2472/// **same** snapshot the row came out of, so an answer cannot name an oid for a
2473/// reference that had already moved. See [`gunnar_wire::refs`].
2474fn ref_target_out(
2475    git: &dyn GitServe,
2476    hash_kind: gix_hash::Kind,
2477    snapshot: &RefSnapshot,
2478    row: &RefRow,
2479    want_peeled: bool,
2480) -> ControlResult<RefTarget> {
2481    let (oid, symbolic) = match (&row.oid, &row.symref_target) {
2482        // A symbolic reference is resolved through the snapshot, whatever else
2483        // it carries: the row's own `oid` on a symref is the store's cache of
2484        // the same walk, and one authority is enough.
2485        (_, Some(inner)) => (snapshot.resolve(inner).map_err(wire_error)?, inner.clone()),
2486        (Some(raw), None) => (
2487            Some(gunnar_wire::refs::oid(raw).map_err(wire_error)?),
2488            String::new(),
2489        ),
2490        (None, None) => (None, String::new()),
2491    };
2492    let peeled = match (want_peeled, oid) {
2493        // `row.peeled` is not used even when the store filled it in: `peel`
2494        // follows the whole chain and a stored peel is one hop, so taking the
2495        // cheaper answer where one exists would make two references with the
2496        // same tag chain answer differently.
2497        (true, Some(id)) => {
2498            let peeled = peel(git, hash_kind, id)?;
2499            if peeled == id {
2500                String::new()
2501            } else {
2502                peeled.to_string()
2503            }
2504        }
2505        _ => String::new(),
2506    };
2507    Ok(RefTarget {
2508        ref_name: row.name.clone(),
2509        oid: oid.map(|id| id.to_string()).unwrap_or_default(),
2510        symbolic_target: symbolic,
2511        peeled_oid: peeled,
2512    })
2513}
2514
2515/// A commit's author or committer line, as the gRPC message spells it.
2516///
2517/// **Bytes and numbers, not `gix_actor::SignatureRef`.** That was the parameter
2518/// type until 2026-08-10, and it was the whole reason `gix-actor` was a direct
2519/// dependency of this crate: one private four-line function, in the manifest
2520/// beside `gix-pack`, where the next reader had to work out whether the server
2521/// was *using* an actor parser or merely naming one. It was merely naming one.
2522/// `gix-object` still arrives here — it is what decodes the commit these fields
2523/// come out of — and it brings `gix-actor` with it; what is gone is gunnar
2524/// asserting a need it does not have.
2525///
2526/// `from_utf8_lossy` matches what `BStr`'s own `Display` did before: a name in
2527/// a commit header is whatever the committing client wrote, a gRPC string field
2528/// is UTF-8, and this is a *report* about history rather than anything looked
2529/// up by name.
2530fn signature_out(name: &[u8], email: &[u8], seconds: i64, tz_offset_seconds: i32) -> Signature {
2531    Signature {
2532        name: String::from_utf8_lossy(name).into_owned(),
2533        email: String::from_utf8_lossy(email).into_owned(),
2534        time_unix_seconds: seconds,
2535        tz_offset_minutes: tz_offset_seconds / 60,
2536    }
2537}
2538
2539// ───────────────────────────────────────────────────────────────────────────
2540
2541/// **First boot, delegated straight to `gunnar_core::AuthTable`.**
2542///
2543/// Nothing is decided here. `bootstrap_state` reads the one predicate the
2544/// writer also reads, and `enroll_first_identity` is a call into the one
2545/// compare-and-swap that can produce a `BootstrapRecord`. A second opinion in
2546/// this adapter — "the table looks empty, so the window is open" — would be a
2547/// status that could disagree with the gate, and the permissive one would win
2548/// silently (one implementation).
2549impl gunnar_grpc::FirstBoot for CoreControlPlane {
2550    fn bootstrap_state(&self) -> ControlResult<gunnar_grpc::BootstrapState> {
2551        let auth = self.stores.auth();
2552        // Every one of these is a `Result`. A read fault becomes UNAVAILABLE
2553        // and never a window that is reported as open or as shut on a guess.
2554        let window_open = auth.bootstrap_window_open().map_err(unavailable)?;
2555        let record = auth.bootstrap_record().map_err(unavailable)?;
2556        let (principals, keys, _grants) = auth.counts().map_err(unavailable)?;
2557        Ok(gunnar_grpc::BootstrapState {
2558            window_open,
2559            bootstrapped: record.is_some(),
2560            bootstrapped_at_unix_ms: record.as_ref().map(|r| r.at).unwrap_or_default(),
2561            bootstrapped_principal: record
2562                .as_ref()
2563                .map(|r| r.principal_id.clone())
2564                .unwrap_or_default(),
2565            bootstrapped_fingerprint: record
2566                .as_ref()
2567                .map(|r| r.fingerprint.clone())
2568                .unwrap_or_default(),
2569            principals: principals as u64,
2570            keys: keys as u64,
2571            // The boot window (`crate::readiness`). Answered here rather than
2572            // on `Admin.Health` because this is the one call a server nobody
2573            // holds a key for can answer, and a boot script is exactly that
2574            // caller.
2575            // TWO facts, and the second one is new. The boot window is this
2576            // process's start-up; the listener is a socket. A twin closes no
2577            // window and binds no reachable git port, so before the listener
2578            // was consulted this field answered `true` for a box whose :2222
2579            // was answering ECONNREFUSED — measured on 10.13.8.101, 2026-09-20.
2580            serving_git: crate::readiness::is_serving_git(self.git_listener),
2581            readiness: crate::readiness::detail_with(self.git_listener),
2582            build: gunnar_common::BUILD_REF.to_owned(),
2583            role: if self.twin_role || self.stores.is_twin() {
2584                "twin"
2585            } else {
2586                "primary"
2587            }
2588            .to_owned(),
2589            // A RECHARGED main (the installer's re-admit) or a HELD one (the boot
2590            // check, R-7b) says what it awaits (the restore lane reads it).
2591            recharge_from: awaited_twin(&self.stores),
2592            restoring: crate::twin::is_restoring(self.stores.root()),
2593            log_head_seq: self.stores.txlog().head_seq(),
2594            // What a restoring box holds of its next entry: where the next stream resumes it.
2595            restore_partial: if crate::twin::is_restoring(self.stores.root()) {
2596                crate::twin::intake::partial(self.stores.root())
2597            } else {
2598                None
2599            },
2600            // restore-after-snapshot: this box stages a twin's base across streams, and says how
2601            // much of one it holds while it is restoring.
2602            restore_snapshot_resumable: true,
2603            restore_snapshot: if crate::twin::is_restoring(self.stores.root()) {
2604                crate::twin::snapshot::held_snapshot(self.stores.root())
2605                    .map(|h| (h.id, h.items, h.bytes, h.whole))
2606            } else {
2607                None
2608            },
2609        })
2610    }
2611
2612    fn enroll_first_identity(
2613        &self,
2614        name: &str,
2615        openssh_public_key: &str,
2616    ) -> ControlResult<gunnar_grpc::Enrolled> {
2617        // R-7b: a box the boot check found with its data lost (a twin can give it
2618        // back) takes no first admin while it is held.
2619        #[cfg(feature = "replication")]
2620        if let Some(twin) = crate::repair::held(&self.stores) {
2621            return Err(ControlError::FailedPrecondition(
2622                crate::repair::held_refusal(&twin),
2623            ));
2624        }
2625        let enrolled = self
2626            .stores
2627            .auth()
2628            .enroll_first_user(name, openssh_public_key)
2629            .map_err(enrolment_error)?;
2630        // The account namespace, after the door is shut. Deliberately NOT
2631        // inside the identity CAS: it lives in the catalog, a different
2632        // document, and one write cannot span both. The ordering is what makes
2633        // that safe — the security boundary (the record) has already landed, so
2634        // the worst a failure here can do is leave a gunnar-admin whose account
2635        // namespace has to be created by hand, never a second open window.
2636        //
2637        // Idempotent: `AlreadyExists` on a namespace this principal already
2638        // intrinsically owns is not a failure of enrolment.
2639        if let Err(err) = self
2640            .stores
2641            .catalog()
2642            .create_namespace(&enrolled.record.account)
2643        {
2644            tracing::warn!(
2645                account = %enrolled.record.account,
2646                %err,
2647                "the first user was enrolled and the enrolment window is closed, but its account \
2648                 namespace could not be created. The user is admin of it by rule and can create \
2649                 it themselves with Repos.CreateNamespace."
2650            );
2651        }
2652        // ── the bootstrap admin's client certificate ────────────────────────
2653        //
2654        // Minted AFTER the CAS, so the security boundary is already shut when
2655        // this runs and a failure here cannot reopen it — the same ordering
2656        // argument as the namespace above. Server-generated key, deliberately:
2657        // there is no authenticated channel yet to carry a CSR, and this is the
2658        // one exchange in the server's life where handing a private key over
2659        // the wire is the honest trade. The server retains no copy of the key.
2660        let (client_cert_pem, client_key_pem, issuer_cert_pem) = match self.stores.pki() {
2661            Ok(pki) => match pki.issuer() {
2662                Ok(Some(issuer)) => {
2663                    match pki.issue_with_server_key(
2664                        &enrolled.principal.id,
2665                        gunnar_core::DEFAULT_LEAF_DAYS,
2666                    ) {
2667                        Ok(issued) => {
2668                            if let Err(err) = self.stores.auth().register_certificate(
2669                                &enrolled.principal.id,
2670                                &issued.cert_der,
2671                                None,
2672                            ) {
2673                                tracing::warn!(
2674                                    principal = %enrolled.principal.id,
2675                                    %err,
2676                                    "the bootstrap certificate was minted but could not be \
2677                                     registered; it is returned anyway and gunnar-admin can \
2678                                     register it with `gunnar key add`"
2679                                );
2680                            }
2681                            (
2682                                issued.cert_pem,
2683                                issued.key_pem.unwrap_or_default(),
2684                                issuer.cert_pem,
2685                            )
2686                        }
2687                        Err(err) => {
2688                            tracing::warn!(
2689                                principal = %enrolled.principal.id,
2690                                %err,
2691                                "no bootstrap certificate: the issuer refused to mint"
2692                            );
2693                            (String::new(), String::new(), String::new())
2694                        }
2695                    }
2696                }
2697                Ok(None) => (String::new(), String::new(), String::new()),
2698                Err(err) => {
2699                    tracing::warn!(%err, "no bootstrap certificate: the PKI document could not \
2700                                          be read");
2701                    (String::new(), String::new(), String::new())
2702                }
2703            },
2704            Err(_) => (String::new(), String::new(), String::new()),
2705        };
2706        Ok(gunnar_grpc::Enrolled {
2707            principal: principal_out(&enrolled.principal),
2708            key: key_out(&enrolled.key),
2709            account: enrolled.record.account,
2710            bootstrapped_at_unix_ms: enrolled.record.at,
2711            client_cert_pem,
2712            client_key_pem,
2713            issuer_cert_pem,
2714        })
2715    }
2716}
2717
2718/// A read fault on the identity table. **Never a denial and never a state.**
2719fn unavailable(err: gunnar_core::Error) -> ControlError {
2720    ControlError::Unavailable(format!("the identity table could not be read: {err}"))
2721}
2722
2723/// The refusals of the enrolment window, mapped so a caller can tell them apart
2724/// from a malformed request.
2725/// **The twin this box awaits** (`Bootstrap.Status.recharge_from`): the installer's
2726/// re-admit ([`crate::pairing::recharge_awaited`]), else the boot check's hold
2727/// ([`crate::repair::held`]); empty on every other box.
2728fn awaited_twin(stores: &ServerStores) -> String {
2729    #[cfg(feature = "wg-client")]
2730    if let Some(twin) = crate::pairing::recharge_awaited(stores) {
2731        return twin;
2732    }
2733    #[cfg(feature = "replication")]
2734    if let Some(twin) = crate::repair::held(stores) {
2735        return twin;
2736    }
2737    let _ = stores;
2738    String::new()
2739}
2740
2741fn enrolment_error(err: gunnar_core::Error) -> ControlError {
2742    match err {
2743        // The door is shut. FAILED_PRECONDITION, because the request was well
2744        // formed and the *server's state* is what refuses it.
2745        e @ (gunnar_core::Error::AlreadyBootstrapped { .. }
2746        | gunnar_core::Error::AlreadyProvisioned { .. }) => {
2747            ControlError::FailedPrecondition(e.to_string())
2748        }
2749        // A bad name or a key that is not an authorized_keys line.
2750        e @ (gunnar_core::Error::InvalidPath { .. }
2751        | gunnar_core::Error::ReservedPrincipalId(_)
2752        | gunnar_core::Error::Key(_)) => ControlError::InvalidArgument(e.to_string()),
2753        // Storage. Not "the door is shut", which is what a caller would read a
2754        // FAILED_PRECONDITION as, and not OK, which would be the disaster.
2755        other => ControlError::Unavailable(other.to_string()),
2756    }
2757}
2758
2759impl CoreControlPlane {
2760    /// ★ **The estate's operator, made on its first claim** — the one writer
2761    /// behind `Accounts.ClaimOperator` and behind the primary's own claim when
2762    /// the installer feeds `operator_name` ([`crate::fed_principals::install`]).
2763    ///
2764    /// `fed` is the installer's name for the operator and wins; `asked` is the
2765    /// caller's, used only when nothing was fed (`gunnar_grpc::seam::operator_name`).
2766    ///
2767    /// * A PERSON already under that id or name is the answer, unchanged but
2768    ///   for two upserts that make it what an operator is: its account
2769    ///   namespace exists, and it is gunnar-admin.
2770    /// * Anything else under it — a machine, a group — is refused by name: an
2771    ///   estate's parts are never its operator.
2772    /// * Otherwise the principal is created, KEYLESS (the console's credential
2773    ///   for the person is its first key, which owes nobody an attestation; the
2774    ///   person's own SSH keys come after and are vouched for by it), with its
2775    ///   account namespace — and **a namespace of that name that already exists
2776    ///   with no owner is ADOPTED** rather than refused. MEASURED on test2
2777    ///   2026-10-04: a first push with the installer's admin key
2778    ///   (`rickard/t2-scratch`) conjured `rickard`, and `AddPrincipal` refused
2779    ///   the operator's own account from then on — the first admin's passkey
2780    ///   bound to nothing. Adoption is safe exactly here: the namespace has no
2781    ///   principal of its id (this call is creating it), only a server Admin can
2782    ///   ask, and that Admin could already delete the namespace or grant it.
2783    pub fn claim_operator_named(
2784        &self,
2785        fed: Option<&str>,
2786        asked: &str,
2787    ) -> ControlResult<gunnar_grpc::OperatorClaim> {
2788        let (name, named_by) = gunnar_grpc::seam::operator_name(fed, asked)?;
2789        let auth = self.stores.auth();
2790        let held = match auth.principal(name).map_err(internal)? {
2791            Some(p) => Some(p),
2792            None => auth.principal_by_name(name).map_err(internal)?,
2793        };
2794        if let Some(p) = held {
2795            if p.kind != PrincipalKind::User {
2796                return Err(ControlError::FailedPrecondition(format!(
2797                    "the operator's name {name:?} is held by principal {:?}, which is not a person \
2798                     ({:?}); an estate's machines are never its operator",
2799                    p.id, p.kind
2800                )));
2801            }
2802            let adopted_namespace = false;
2803            if self
2804                .stores
2805                .catalog()
2806                .namespace(&p.id)
2807                .map_err(internal)?
2808                .is_none()
2809            {
2810                self.stores
2811                    .catalog()
2812                    .create_namespace(&p.id)
2813                    .map_err(internal)?;
2814            }
2815            self.grant(&p.id, "", "", GrantLevel::Admin)?;
2816            return Ok(gunnar_grpc::OperatorClaim {
2817                principal: principal_out(&p),
2818                created: false,
2819                adopted_namespace,
2820                named_by,
2821            });
2822        }
2823        let principal =
2824            gunnar_core::Principal::new(name, name, PrincipalKind::User).map_err(internal)?;
2825        let created = auth.add_principal(principal).map_err(internal)?;
2826        let adopted_namespace = match self.stores.catalog().create_namespace(&created.id) {
2827            Ok(_) => false,
2828            Err(gunnar_core::Error::NamespaceExists(_)) => true,
2829            Err(err) => {
2830                let _ = auth.remove_principal(&created.id);
2831                return Err(internal(err));
2832            }
2833        };
2834        if let Err(err) = self.grant(&created.id, "", "", GrantLevel::Admin) {
2835            // Not left half-made: an operator that is not the estate's admin is
2836            // not the one that was asked for, and the next claim makes it whole.
2837            let _ = auth.remove_principal(&created.id);
2838            return Err(err);
2839        }
2840        self.stores.mirror_identity();
2841        tracing::warn!(
2842            target: "gunnar::audit",
2843            event = "operator.claimed",
2844            operator = %created.id,
2845            adopted_namespace,
2846            named_by,
2847            "the estate's operator was created: a person, keyless, gunnar-admin, with its account \
2848             namespace{}",
2849            if adopted_namespace {
2850                " — the namespace already existed with no owner (a first push conjured it) and is \
2851                 now the operator's account"
2852            } else {
2853                ""
2854            }
2855        );
2856        Ok(gunnar_grpc::OperatorClaim {
2857            principal: principal_out(&created),
2858            created: true,
2859            adopted_namespace,
2860            named_by,
2861        })
2862    }
2863}
2864
2865/// The owner's enterprise list on the real stores (`gunnar_grpc::enterprise::EnterprisePlane`).
2866mod enterprise_plane;
2867
2868impl ControlPlane for CoreControlPlane {
2869    fn enterprise(&self) -> Option<&dyn gunnar_grpc::enterprise::EnterprisePlane> {
2870        Some(self)
2871    }
2872
2873    // ── namespaces ─────────────────────────────────────────────────────────
2874
2875    fn create_namespace(&self, name: &str, _description: &str) -> ControlResult<Namespace> {
2876        // An EMPLOYEE's id is never a namespace: a namespace named after a
2877        // principal makes that principal its admin (`owns_account`), and an
2878        // employee is admin of nothing (`AuthTable::enroll_employee`).
2879        if self
2880            .stores
2881            .auth()
2882            .employer_of(name)
2883            .map_err(internal)?
2884            .is_some()
2885        {
2886            return Err(ControlError::AlreadyExists(format!(
2887                "{name} is an employee's id, and an employee holds no account of its own"
2888            )));
2889        }
2890        let namespace = self
2891            .stores
2892            .catalog()
2893            .create_namespace(name)
2894            .map_err(internal)?;
2895        Ok(self.namespace_out(namespace, 0))
2896    }
2897
2898    fn list_namespaces(&self) -> ControlResult<Vec<Namespace>> {
2899        let mut employees: std::collections::BTreeMap<String, u32> = Default::default();
2900        for principal in self.stores.auth().principals().map_err(internal)? {
2901            if let Some(account) = self
2902                .stores
2903                .auth()
2904                .employer_of(&principal.id)
2905                .map_err(internal)?
2906            {
2907                *employees.entry(account).or_default() += 1;
2908            }
2909        }
2910        Ok(self
2911            .stores
2912            .catalog()
2913            .namespaces()
2914            .map_err(internal)?
2915            .into_iter()
2916            .map(|ns| {
2917                let count = employees.get(&ns.name).copied().unwrap_or(0);
2918                self.namespace_out(ns, count)
2919            })
2920            .collect())
2921    }
2922
2923    /// One catalog probe, not the listing the trait's default walks: this is
2924    /// asked by the account settings and the outsider's view of a namespace.
2925    fn namespace(&self, name: &str) -> ControlResult<Option<Namespace>> {
2926        let Some(ns) = self.stores.catalog().namespace(name).map_err(internal)? else {
2927            return Ok(None);
2928        };
2929        let count = self.employee_count(name)?;
2930        Ok(Some(self.namespace_out(ns, count)))
2931    }
2932
2933    fn namespace_for_outsider(
2934        &self,
2935        principal_id: &str,
2936        name: &str,
2937    ) -> ControlResult<Option<Namespace>> {
2938        // The gate, both directions, before the setting — the same walk
2939        // `AuthTable::may` makes (an ancestor's gate closes a nested account).
2940        let auth = self.stores.auth();
2941        if auth
2942            .gate_covering(principal_id)
2943            .map_err(internal)?
2944            .is_some()
2945            || auth.gate_covering(name).map_err(internal)?.is_some()
2946        {
2947            return Ok(None);
2948        }
2949        self.namespace(name)
2950    }
2951
2952    fn set_namespace_settings(
2953        &self,
2954        name: &str,
2955        public: Option<bool>,
2956        events_public: Option<bool>,
2957    ) -> ControlResult<Namespace> {
2958        let catalog = self.stores.catalog();
2959        let mut ns = catalog
2960            .namespace(name)
2961            .map_err(internal)?
2962            .ok_or_else(|| ControlError::NotFound(format!("namespace {name}")))?;
2963        if let Some(public) = public {
2964            ns = catalog
2965                .set_namespace_public(name, public)
2966                .map_err(internal)?;
2967        }
2968        if let Some(events_public) = events_public {
2969            ns = catalog
2970                .set_namespace_events_public(name, events_public)
2971                .map_err(internal)?;
2972        }
2973        let count = self.employee_count(name)?;
2974        Ok(self.namespace_out(ns, count))
2975    }
2976
2977    fn delete_namespace(&self, name: &str, delete_repos: bool) -> ControlResult<u32> {
2978        // A CLAN's namespace goes with the clan (`Clans.Dissolve`), never on
2979        // its own: a clan left without its namespace would be a name nobody
2980        // could make anything in, and the namespace's repositories are the
2981        // clan's to dissolve, not one admin grant's to delete.
2982        if self.stores.auth().is_clan(name).map_err(internal)? {
2983            return Err(ControlError::FailedPrecondition(format!(
2984                "namespace {name} is clan {name}'s: it goes when the clan's admin dissolves the clan"
2985            )));
2986        }
2987        let gone = self
2988            .stores
2989            .catalog()
2990            .delete_namespace(name, delete_repos)
2991            .map_err(internal)?;
2992        // **The flow half of deleting a tenant.** The activity ledger keeps
2993        // four hundred days per namespace INCLUDING which key fingerprints
2994        // pushed, and a tenant that is gone must not leave a record of who
2995        // worked on it. Deliberately after the catalog delete: if that refused
2996        // — a non-empty namespace without `delete_repos` — nothing has been
2997        // deleted and the series must stay.
2998        //
2999        // A failure here is logged and not returned: the namespace IS gone, and
3000        // reporting the delete as failed would send an operator to re-run it
3001        // against a name that no longer exists.
3002        if let Err(err) = self.stores.flow().forget(name) {
3003            tracing::warn!(
3004                target: "gunnar::store",
3005                event = "flow.forget_failed",
3006                namespace = %name,
3007                %err,
3008                "the namespace was deleted but its activity series could not be removed"
3009            );
3010        }
3011        Ok(gone.len() as u32)
3012    }
3013
3014    // ── repositories ───────────────────────────────────────────────────────
3015
3016    fn create_repo(&self, spec: ProtoNewRepo) -> ControlResult<Repo> {
3017        let path = Self::repo_ref(&spec.path)?;
3018        // **The disk, before the row.** A repository created on a full disk is
3019        // a catalog row whose store cannot be materialised, which is the
3020        // "listed but git says it does not exist" shape `ServerStores::create_repo`
3021        // exists to prevent — refused here, by the gate the front doors share,
3022        // with the line a refused push prints.
3023        if let Some(line) = self.headroom.as_ref().and_then(|gate| gate.refusal()) {
3024            return Err(ControlError::ResourceExhausted(line));
3025        }
3026        let mut new = gunnar_core::NewRepo::new(path).visibility(visibility_in(spec.visibility)?);
3027        if spec.hash_kind != gunnar_proto::HashKind::Unspecified {
3028            new = new.hash_kind(match spec.hash_kind {
3029                gunnar_proto::HashKind::Sha1 => HashKind::Sha1,
3030                _ => HashKind::Sha256,
3031            });
3032        }
3033        if !spec.default_branch.is_empty() {
3034            new = new.default_branch(spec.default_branch.clone());
3035        }
3036        // The catalog entry and the on-disk repository are created together
3037        // and transactionally — `ServerStores::create_repo` looks at the disk
3038        // first (a store already standing at the name is refused BY NAME
3039        // unless `adopt`), writes the row, makes the store, and rolls the row
3040        // back if the store fails. Creating one without the other is the
3041        // shape that produces "it is listed but git says it does not exist";
3042        // adopting a stranger's directory is the shape that resurrected a
3043        // deleted repository on 2026-08-30.
3044        let repo = self
3045            .stores
3046            .create_repo(new, spec.adopt)
3047            .map_err(|err| create_error(&err, &spec.path))?;
3048        Ok(repo_out(&repo))
3049    }
3050
3051    fn write_first_commit(&self, path: &RepoPath, readme: &str) -> ControlResult<String> {
3052        let (repo, store) = self.open(path)?;
3053        let kind = match repo.hash_kind {
3054            HashKind::Sha1 => gix_hash::Kind::Sha1,
3055            _ => gix_hash::Kind::Sha256,
3056        };
3057        let when = std::time::SystemTime::now()
3058            .duration_since(std::time::UNIX_EPOCH)
3059            .map(|d| d.as_secs() as i64)
3060            .unwrap_or(0);
3061        let (pack, commit) = crate::first_commit::first_commit_pack(
3062            kind,
3063            "README.md",
3064            readme.as_bytes(),
3065            "Welcome",
3066            when,
3067        )
3068        .map_err(ControlError::InvalidArgument)?;
3069        store
3070            .serve()
3071            .put(
3072                &pack,
3073                &[gunnar_store::git_storage_trait::RefUpdate::set(
3074                    repo.default_branch.clone(),
3075                    commit.clone(),
3076                )],
3077            )
3078            .map_err(|err| {
3079                ControlError::Internal(format!("writing the first commit of {path}: {err:#}"))
3080            })?;
3081        Ok(commit)
3082    }
3083
3084    /// The front doors' plan chain (`ServerStores::plan_gates`), asked as a
3085    /// push-to-create asks it: the verdict on a write (a suspended or retained
3086    /// plan: `FailedPrecondition`), then the cap (`ResourceExhausted`). A gate
3087    /// that does not bind machines is not asked for one.
3088    fn create_room(&self, namespace: &str, machine: bool) -> ControlResult<()> {
3089        use gunnar_ssh::{PlanAction, PlanVerdict};
3090        for gate in self
3091            .stores
3092            .plan_gates()
3093            .iter()
3094            .filter(|gate| !machine || gate.applies_to_machine())
3095        {
3096            if let PlanVerdict::Refuse(line) = gate.verdict(namespace, PlanAction::Write) {
3097                return Err(ControlError::FailedPrecondition(line));
3098            }
3099            if let PlanVerdict::Refuse(line) = gate.pack_quota(namespace) {
3100                return Err(ControlError::ResourceExhausted(line));
3101            }
3102        }
3103        Ok(())
3104    }
3105
3106    fn get_repo(&self, path: &RepoPath) -> ControlResult<Repo> {
3107        let repo_ref = Self::repo_ref(path)?;
3108        self.stores
3109            .catalog()
3110            .get(&repo_ref)
3111            .map_err(internal)?
3112            .as_ref()
3113            .map(repo_out)
3114            .ok_or_else(|| ControlError::NotFound(format!("repository {path}")))
3115    }
3116
3117    fn list_repos(&self, namespace: Option<&str>) -> ControlResult<Vec<Repo>> {
3118        Ok(self
3119            .stores
3120            .catalog()
3121            .list(namespace)
3122            .map_err(internal)?
3123            .iter()
3124            .map(repo_out)
3125            .collect())
3126    }
3127
3128    /// **Delete: a dated tombstone by default, a reclaim when asked.**
3129    ///
3130    /// Two facts had to be held at once here, and they pull in opposite
3131    /// directions:
3132    ///
3133    /// * **The reclaim**: `repo delete` must reclaim the disk. Before it, the
3134    ///   catalog row went and the objects stayed — 25 GB of a kernel push left
3135    ///   on the appliance with its indexer still running, invisible because
3136    ///   nothing listed it.
3137    /// * **Measured**: the bytes are gone in ~2 seconds (181 GiB to 33 MiB
3138    ///   measured) and a mirror does not help, because the delete replicates.
3139    ///   The failure a paying customer is most likely to cause is deleting
3140    ///   their own repository.
3141    ///
3142    /// A tombstone answers both, and only because it is **dated and listed**:
3143    /// the bytes are held on purpose with a deadline written at the instant of
3144    /// the delete, [`ServerStores::sweep_expired_tombstones`] reclaims them
3145    /// when it passes, and `Repos.ListDeleted` / `Admin.StorageInfo` report
3146    /// them so they are never the invisible 25 GB again. **They still count
3147    /// against the account** — see [`Self::storage_info`].
3148    fn delete_repo(
3149        &self,
3150        path: &RepoPath,
3151        options: gunnar_grpc::seam::DeleteOptions,
3152    ) -> ControlResult<gunnar_grpc::seam::DeleteOutcome> {
3153        let repo_ref = Self::repo_ref(path)?;
3154        // The row is read BEFORE it is removed: the tombstone records what the
3155        // repository was, and an undelete that guessed the hash kind would
3156        // reopen the resurrection the reclaim rules closed from the other side.
3157        let was = self.stores.catalog().get(&repo_ref).map_err(internal)?;
3158        // **An ORPHAN is a delete to finish, not a not-found** (2026-08-30). A
3159        // store on disk with no row and no tombstone is what an old
3160        // catalog-only delete left, and on the appliance 2026-08-30 it was ~25-39
3161        // GB at `alice/linux` that `delete` answered with `NoSuchRepo` after
3162        // the access check had already refused everybody. `--purge` is
3163        // honoured — the bytes are what the operator asked for and the row is
3164        // already gone — and a SOFT delete is refused by name, because a
3165        // tombstone with no row promises an undelete the server cannot honour.
3166        let orphan = was.is_none() && self.stores.is_orphan(&repo_ref).unwrap_or(false);
3167        if orphan && !options.purge {
3168            return Err(ControlError::FailedPrecondition(
3169                crate::stores::ServerStores::orphan_soft_delete_refusal(&repo_ref),
3170            ));
3171        }
3172        // A name that is not in the catalog but is TOMBSTONED (a previous
3173        // delete could not reclaim its store) is a delete to retry, not a
3174        // not-found: the row is already gone, the reclaim is what is owed.
3175        match self.stores.catalog().delete(&repo_ref) {
3176            Ok(_) => {}
3177            Err(gunnar_core::Error::NoSuchRepo(_))
3178                if orphan || self.stores.is_tombstoned(&repo_ref).unwrap_or(false) => {}
3179            Err(err) => return Err(internal(err)),
3180        }
3181        if options.purge {
3182            let (before, after) = self.stores.purge_repo(&repo_ref).map_err(|err| {
3183                ControlError::Internal(format!("removing the repository's directory: {err:#}"))
3184            })?;
3185            return Ok(gunnar_grpc::seam::DeleteOutcome::Purged {
3186                bytes_reclaimed: before.saturating_sub(after),
3187            });
3188        }
3189        let retention = std::time::Duration::from_secs(
3190            u64::from(
3191                options
3192                    .retention_days
3193                    .unwrap_or(crate::stores::DEFAULT_DELETE_RETENTION_DAYS),
3194            ) * 86_400,
3195        );
3196        let record = self
3197            .stores
3198            .soft_delete(&repo_ref, was.as_ref(), &options.principal_id, retention)
3199            .map_err(|err| ControlError::FailedPrecondition(format!("{err:#}")))?;
3200        let bytes_kept = self
3201            .stores
3202            .repo_dir(&repo_ref)
3203            .map(|dir| crate::stores::dir_bytes(&dir))
3204            .unwrap_or(0);
3205        Ok(gunnar_grpc::seam::DeleteOutcome::Tombstoned {
3206            purge_after_unix_ms: record.purge_after_unix_ms.unwrap_or_default(),
3207            bytes_kept,
3208        })
3209    }
3210
3211    /// **Undelete: the row back first, the tombstone off second.**
3212    ///
3213    /// That order is not tidiness. A crash between the two leaves a repository
3214    /// that is catalogued *and* still fenced off, which an operator can fix
3215    /// with a second `undelete`; the other order leaves a name with no
3216    /// tombstone and no row, which the next `repo create` would adopt — and
3217    /// adopting a store a create did not make is exactly the 2026-08-30
3218    /// resurrection.
3219    fn undelete_repo(&self, path: &RepoPath) -> ControlResult<Repo> {
3220        let repo_ref = Self::repo_ref(path)?;
3221        if self
3222            .stores
3223            .catalog()
3224            .get(&repo_ref)
3225            .map_err(internal)?
3226            .is_some()
3227        {
3228            return Err(ControlError::AlreadyExists(format!(
3229                "{path} is in the catalog already; it is not deleted"
3230            )));
3231        }
3232        let spec = self
3233            .stores
3234            .undelete_spec(&repo_ref)
3235            .map_err(|err| ControlError::FailedPrecondition(format!("{err:#}")))?;
3236        // `adopt`: the store on disk is the one this repository always had.
3237        // This is the second of the two cases where adopting is right rather
3238        // than a resurrection (the first is `archive-in`), and it is right
3239        // because the tombstone says so and carries the hash kind the adopt
3240        // path checks the store's marker against.
3241        let repo = self
3242            .stores
3243            .create_repo(spec, true)
3244            .map_err(|err| ControlError::Internal(format!("restoring {path}: {err:#}")))?;
3245        self.stores
3246            .clear_tombstone(&repo_ref)
3247            .map_err(|err| ControlError::Internal(format!("{err:#}")))?;
3248        Ok(repo_out(&repo))
3249    }
3250
3251    fn purge_repo(&self, path: &RepoPath) -> ControlResult<u64> {
3252        let repo_ref = Self::repo_ref(path)?;
3253        if self
3254            .stores
3255            .catalog()
3256            .get(&repo_ref)
3257            .map_err(internal)?
3258            .is_some()
3259        {
3260            return Err(ControlError::FailedPrecondition(format!(
3261                "{path} is a LIVE repository, not a deleted one. `repo delete {path} --purge` \
3262                 deletes and reclaims it in one step; purge alone only ends a retention window."
3263            )));
3264        }
3265        let tombstone = self
3266            .stores
3267            .tombstone_of(&repo_ref)
3268            .map_err(|err| ControlError::Internal(format!("{err:#}")))?;
3269        if tombstone.is_none() {
3270            return Err(ControlError::NotFound(format!(
3271                "{path} is neither catalogued nor tombstoned; there is nothing to purge"
3272            )));
3273        }
3274        let (before, after) = self
3275            .stores
3276            .purge_repo(&repo_ref)
3277            .map_err(|err| ControlError::Internal(format!("{err:#}")))?;
3278        Ok(before.saturating_sub(after))
3279    }
3280
3281    /// **`Repos.Gc` — the console's door onto the maintenance the CLI has
3282    /// always had.**
3283    ///
3284    /// The whole body is a resolve and a call: the maintenance itself is
3285    /// [`crate::admin::gc_one`], the same function `gunnar gc` runs, so there
3286    /// is one writer and not a second implementation to drift (one implementation).
3287    ///
3288    /// Unlike [`Self::purge_repo`] this one demands the repository be **live**.
3289    /// A tombstoned store is on its way out and sealing it would spend the I/O
3290    /// of a compaction on bytes a sweep is about to unlink; that is `Purge`'s
3291    /// job, and saying so by name is better than maintaining a corpse.
3292    fn gc_repo(&self, path: &RepoPath) -> ControlResult<gunnar_grpc::seam::GcJob> {
3293        let repo_ref = Self::repo_ref(path)?;
3294        // **The existence check is here and the resolve is in the runner**, and
3295        // that is not a duplicate. This one refuses a name that was never a
3296        // repository *now*, so a typo comes back as NOT_FOUND on the request
3297        // that made it rather than as a FAILED job somebody has to go and read;
3298        // the runner's is what happens when a repository that WAS live is gone
3299        // by the time its turn comes, minutes later.
3300        if self
3301            .stores
3302            .catalog()
3303            .get(&repo_ref)
3304            .map_err(internal)?
3305            .is_none()
3306        {
3307            return Err(ControlError::NotFound(format!(
3308                "{path} is not a live repository; nothing to gc"
3309            )));
3310        }
3311        Ok(gc_job_out(path, self.gc.enqueue(&repo_ref.to_string())))
3312    }
3313
3314    fn gc_status(&self, path: &RepoPath) -> ControlResult<Option<gunnar_grpc::seam::GcJob>> {
3315        let repo_ref = Self::repo_ref(path)?;
3316        Ok(self
3317            .gc
3318            .status(&repo_ref.to_string())
3319            .map(|job| gc_job_out(path, job)))
3320    }
3321
3322    // ── accounts: the gate, and the per-user hard purge ────────────────────
3323
3324    fn gate_account(
3325        &self,
3326        account: &str,
3327        actor: &str,
3328        note: &str,
3329    ) -> ControlResult<(gunnar_grpc::seam::AccountGate, bool)> {
3330        // Read before write, so `was_already_gated` reports what was there
3331        // rather than what this call put there. Not atomic with the write, and
3332        // it does not need to be: the field is a courtesy to a console, and the
3333        // worst a lost race costs is a console saying "you locked this" about a
3334        // lockout that arrived a millisecond earlier. The *lockout* is atomic,
3335        // because `AuthTable::gate` is one compare-and-swap.
3336        let before = self
3337            .stores
3338            .auth()
3339            .gate_covering(account)
3340            .map_err(internal)?
3341            .is_some();
3342        let gate = self
3343            .stores
3344            .auth()
3345            .gate(gunnar_core::AccountGate::administrative(
3346                account, actor, note,
3347            ))
3348            .map_err(gate_error)?;
3349        tracing::warn!(
3350            target: "gunnar::audit",
3351            event = "account.gated",
3352            account = %account,
3353            actor = %actor,
3354            note = %note,
3355            already = before,
3356            "an account was locked out: every level, both doors, until it is ungated"
3357        );
3358        Ok((gate_out(gate), before))
3359    }
3360
3361    fn ungate_account(
3362        &self,
3363        account: &str,
3364    ) -> ControlResult<Option<gunnar_grpc::seam::AccountGate>> {
3365        let lifted = self
3366            .stores
3367            .auth()
3368            .lift_gate(account)
3369            .map_err(internal)?
3370            .map(gate_out);
3371        if let Some(gate) = &lifted {
3372            tracing::warn!(
3373                target: "gunnar::audit",
3374                event = "account.ungated",
3375                account = %account,
3376                held_by = %gate.holder,
3377                "an account lockout was lifted"
3378            );
3379        }
3380        Ok(lifted)
3381    }
3382
3383    fn account_gates(&self) -> ControlResult<Vec<gunnar_grpc::seam::AccountGate>> {
3384        Ok(self
3385            .stores
3386            .auth()
3387            .gates()
3388            .map_err(internal)?
3389            .into_iter()
3390            .map(gate_out)
3391            .collect())
3392    }
3393
3394    /// **Gate first, enqueue second — and read the gate BACK.**
3395    ///
3396    /// The order is the whole guarantee: by the time this returns the account
3397    /// is shut, so there is no window in which a purge has been accepted and
3398    /// the user can still push into the account it is about to walk.
3399    ///
3400    /// The gate is then read back out of the table rather than the one just
3401    /// written being returned, and that is not belt-and-braces — it is the only
3402    /// correct answer in two cases this call cannot distinguish up front:
3403    ///
3404    /// * the account was **already** under an administrative gate. The lease is
3405    ///   not written over it (`gate_for_purge` refuses to downgrade), so the
3406    ///   gate in force is somebody else's and the job will not lift it.
3407    /// * the account already had a purge **queued or running**. The queue
3408    ///   returns that job instead of this one, so the lease that will be
3409    ///   released is the *other* job's, and returning this call's freshly
3410    ///   minted id would name a lockout nothing is going to lift.
3411    fn purge_account(
3412        &self,
3413        account: &str,
3414        options: gunnar_grpc::seam::PurgeAccountOptions,
3415    ) -> ControlResult<(gunnar_grpc::seam::PurgeJob, gunnar_grpc::seam::AccountGate)> {
3416        let account = account.trim();
3417        if account.is_empty() {
3418            return Err(ControlError::InvalidArgument(
3419                "an account purge needs an account name".into(),
3420            ));
3421        }
3422        // **A machine's account is never purged** (owner ruling g-nonpay 2026-09-29:
3423        // "machine principals ... are never touched"). Only a person's account holds a
3424        // customer's data; a machine, a deploy key or a group is the estate itself —
3425        // the twin, the billing principal, a CI bot — and emptying it is never the
3426        // answer to a question about money. Refused before the lockout: nothing is
3427        // locked and nothing is queued.
3428        if let Some(p) = self.stores.auth().principal(account).map_err(internal)? {
3429            if p.kind != gunnar_core::PrincipalKind::User {
3430                return Err(ControlError::FailedPrecondition(format!(
3431                    "{account} is a machine principal ({:?}), not a person: a machine's account is never \
3432                     purged. Remove the principal itself if it is really to go.",
3433                    p.kind
3434                )));
3435            }
3436        }
3437        let job_id = self.purge.next_job_id();
3438        // **The lockout, before anything else.** A failure here is a refusal
3439        // and nothing is enqueued: a purge that could not lock the account is
3440        // a purge that would race every reader in it, and running it anyway
3441        // would be worse than not running it.
3442        self.gate_for_purge(account, &job_id, &options)?;
3443
3444        let order = crate::purge_queue::PurgeOrder::new(account, &job_id, &options.reason)
3445            .by(&options.principal_id)
3446            .removing_principal(options.remove_principal)
3447            .expecting_empty(options.expect_empty);
3448        let job = self.purge.enqueue(order);
3449
3450        let gate = self
3451            .stores
3452            .auth()
3453            .gate_covering(account)
3454            .map_err(internal)?
3455            .map(gate_out)
3456            // The account is gated — this call just gated it — so an absence
3457            // here means the row went between the write and the read, which is
3458            // an operator ungating an account mid-request. Reported as the
3459            // fact it is rather than as a gate that is not there.
3460            .unwrap_or_else(|| gunnar_grpc::seam::AccountGate {
3461                account: account.to_owned(),
3462                reason: gunnar_grpc::seam::GateReason::Purge,
3463                holder: job_id.clone(),
3464                actor: options.principal_id.clone(),
3465                note: "the lockout was lifted between being taken and being read back".into(),
3466                at_unix_ms: gunnar_core::now_ms(),
3467                expires_unix_ms: 0,
3468            });
3469        Ok((purge_job_out(job), gate))
3470    }
3471
3472    fn purge_account_status(
3473        &self,
3474        account: &str,
3475    ) -> ControlResult<(
3476        Option<gunnar_grpc::seam::PurgeJob>,
3477        Option<gunnar_grpc::seam::AccountGate>,
3478    )> {
3479        let job = self.purge.status(account).map(purge_job_out);
3480        let gate = self
3481            .stores
3482            .auth()
3483            .gate_covering(account)
3484            .map_err(internal)?
3485            .map(gate_out);
3486        Ok((job, gate))
3487    }
3488
3489    fn default_delete_retention_days(&self) -> u32 {
3490        crate::stores::DEFAULT_DELETE_RETENTION_DAYS
3491    }
3492
3493    fn list_deleted(
3494        &self,
3495        namespace: Option<&str>,
3496    ) -> ControlResult<Vec<gunnar_grpc::seam::DeletedRepoRow>> {
3497        Ok(self
3498            .stores
3499            .tombstones()
3500            .map_err(|err| ControlError::Internal(format!("{err:#}")))?
3501            .into_iter()
3502            .filter(|row| namespace.is_none_or(|want| row.path.namespace() == want))
3503            .map(|row| gunnar_grpc::seam::DeletedRepoRow {
3504                path: RepoPath {
3505                    namespace: row.path.namespace().to_owned(),
3506                    repo: row.path.name().to_owned(),
3507                },
3508                deleted_at_unix_ms: row.tombstone.deleted_at_unix_ms,
3509                purge_after_unix_ms: row.tombstone.purge_after_unix_ms.unwrap_or_default(),
3510                principal_id: row.tombstone.principal_id,
3511                bytes: row.bytes,
3512            })
3513            .collect())
3514    }
3515
3516    /// **The store directories with no catalog row and no tombstone.**
3517    ///
3518    /// It walks the disk rather than asking the catalog — the same reason
3519    /// [`Self::list_deleted`] does, one step further: a tombstone at least
3520    /// exists as a file, and an orphan is defined by the absence of *every*
3521    /// record. On the appliance 2026-08-30 that absence made ~25-39 GB at
3522    /// `alice/linux` unreachable by `delete`, `purge`, `undelete`, the sweep
3523    /// AND `list-deleted`, while `create` refused the name because the bytes
3524    /// were there.
3525    fn list_orphans(
3526        &self,
3527        namespace: Option<&str>,
3528    ) -> ControlResult<Vec<gunnar_grpc::seam::OrphanRow>> {
3529        Ok(self
3530            .stores
3531            .orphans()
3532            .map_err(|err| ControlError::Internal(format!("{err:#}")))?
3533            .into_iter()
3534            .filter(|row| namespace.is_none_or(|want| row.path.namespace() == want))
3535            .map(|row| gunnar_grpc::seam::OrphanRow {
3536                path: RepoPath {
3537                    namespace: row.path.namespace().to_owned(),
3538                    repo: row.path.name().to_owned(),
3539                },
3540                dir: row.dir.display().to_string(),
3541                bytes: row.bytes,
3542                hash_marker: row.hash_marker.unwrap_or_default(),
3543                modified_unix_ms: row.modified_unix_ms,
3544                failed_reclaim: row.failed_reclaim.unwrap_or_default(),
3545            })
3546            .collect())
3547    }
3548
3549    /// **Reclaim an orphan.** `FailedPrecondition` when it is not one, because
3550    /// "this is a live repository" is a fact the caller can act on and not an
3551    /// internal fault.
3552    fn reclaim_orphan(&self, path: &RepoPath) -> ControlResult<u64> {
3553        let repo_ref = Self::repo_ref(path)?;
3554        self.stores
3555            .reclaim_orphan(&repo_ref)
3556            .map_err(|err| ControlError::FailedPrecondition(format!("{err:#}")))
3557    }
3558
3559    fn index_progress(&self) -> ControlResult<Vec<gunnar_grpc::seam::IndexProgressRow>> {
3560        Ok(self
3561            .stores
3562            .engine()
3563            .index_progress()
3564            .into_iter()
3565            .filter_map(|(dir, s)| {
3566                let (namespace, name) = self.stores.repo_of_dir(&dir)?;
3567                Some(gunnar_grpc::seam::IndexProgressRow {
3568                    namespace,
3569                    name,
3570                    pack_id: s.pack_id,
3571                    objects_done: s.objects_done,
3572                    objects_total: s.objects_total,
3573                    bytes_done: s.bytes_done,
3574                    bytes_total: s.bytes_total,
3575                    phase: if s.active {
3576                        s.phase.to_string()
3577                    } else {
3578                        "idle".to_string()
3579                    },
3580                    started_at_unix_ms: s.started_at_ms as i64,
3581                    resumed: s.resumed,
3582                    reinflations: s.reinflations,
3583                    pauses: s.pauses,
3584                })
3585            })
3586            .filter(|r| r.phase != "idle")
3587            .collect())
3588    }
3589
3590    fn set_default_branch(&self, path: &RepoPath, default_branch: &str) -> ControlResult<Repo> {
3591        let repo_ref = Self::repo_ref(path)?;
3592        let repo = self
3593            .stores
3594            .catalog()
3595            .set_default_branch(&repo_ref, default_branch)
3596            .map_err(internal)?;
3597        // The catalog is the authority; the repository's `HEAD` is a projection
3598        // of it and does not move on its own. Without this line the call
3599        // answers with the new branch and `repo get` reads it back, while the
3600        // reference advertisement — which reads `HEAD`, not the catalog — keeps
3601        // naming the old one. That was the whole of the 2026-08-06 defect: a
3602        // `master` donor pushed cleanly into a repository nobody could clone,
3603        // and `set-default-branch` looked like it had fixed it.
3604        self.stores.repoint_head(&repo).map_err(|err| {
3605            ControlError::Internal(format!("pointing HEAD at the new default branch: {err:#}"))
3606        })?;
3607        Ok(repo_out(&repo))
3608    }
3609
3610    fn set_visibility(
3611        &self,
3612        path: &RepoPath,
3613        visibility: gunnar_proto::Visibility,
3614    ) -> ControlResult<Repo> {
3615        if visibility == gunnar_proto::Visibility::Unspecified {
3616            return Err(ControlError::InvalidArgument(
3617                "visibility unspecified".into(),
3618            ));
3619        }
3620        let repo_ref = Self::repo_ref(path)?;
3621        self.stores
3622            .catalog()
3623            .set_visibility(&repo_ref, visibility_in(visibility)?)
3624            .map(|repo| repo_out(&repo))
3625            .map_err(internal)
3626    }
3627
3628    /// **A member's ONE encryption key** (CRYPT-YUBI 2026-10-26): a repository's ring is
3629    /// wrapped to the person's encryption public key, never to an SSH key. Empty when they
3630    /// have none yet.
3631    fn recipient_keys(&self, principal_id: &str) -> ControlResult<Vec<gunnar_proto::RecipientKey>> {
3632        let public = self
3633            .stores
3634            .encryption_keys()
3635            .public_of(principal_id)
3636            .map_err(key_refusal)?;
3637        Ok(public
3638            .into_iter()
3639            .map(|public| gunnar_proto::RecipientKey {
3640                fingerprint: gunnar_core::auth::fingerprint_of_blob(public.as_bytes()).to_string(),
3641                algo: if public.starts_with("age1pq1") {
3642                    gunnar_core::auth::PQ_RECIPIENT_ALGO.to_owned()
3643                } else {
3644                    "X25519".to_owned()
3645                },
3646                openssh: public,
3647            })
3648            .collect())
3649    }
3650
3651    fn encryption_key(
3652        &self,
3653        principal_id: &str,
3654    ) -> ControlResult<gunnar_proto::GetEncryptionKeyResponse> {
3655        Ok(encryption_key_out(
3656            self.stores
3657                .encryption_keys()
3658                .get(principal_id)
3659                .map_err(key_refusal)?,
3660        ))
3661    }
3662
3663    fn create_encryption_key(
3664        &self,
3665        principal_id: &str,
3666        public_key: &str,
3667        first: gunnar_proto::UnlockMethod,
3668    ) -> ControlResult<gunnar_proto::GetEncryptionKeyResponse> {
3669        let first = method_in(first)?;
3670        let key = self
3671            .stores
3672            .encryption_keys()
3673            .create(
3674                principal_id,
3675                public_key,
3676                first,
3677                gunnar_core::now_ms().max(0) as u64,
3678            )
3679            .map_err(key_refusal)?;
3680        Ok(encryption_key_out(Some(key)))
3681    }
3682
3683    fn add_unlock_method(
3684        &self,
3685        principal_id: &str,
3686        method: gunnar_proto::UnlockMethod,
3687    ) -> ControlResult<gunnar_proto::GetEncryptionKeyResponse> {
3688        let method = method_in(method)?;
3689        let key = self
3690            .stores
3691            .encryption_keys()
3692            .add_method(principal_id, method, gunnar_core::now_ms().max(0) as u64)
3693            .map_err(key_refusal)?;
3694        Ok(encryption_key_out(Some(key)))
3695    }
3696
3697    fn remove_unlock_method(
3698        &self,
3699        principal_id: &str,
3700        id: &str,
3701    ) -> ControlResult<gunnar_proto::GetEncryptionKeyResponse> {
3702        let key = self
3703            .stores
3704            .encryption_keys()
3705            .remove_method(principal_id, id)
3706            .map_err(key_refusal)?;
3707        Ok(encryption_key_out(Some(key)))
3708    }
3709
3710    fn set_encrypted(&self, path: &RepoPath, encrypted: bool) -> ControlResult<Repo> {
3711        let repo_ref = Self::repo_ref(path)?;
3712        self.stores
3713            .catalog()
3714            .set_encrypted(&repo_ref, encrypted)
3715            .map(|repo| repo_out(&repo))
3716            .map_err(internal)
3717    }
3718
3719    /// **`Repos.ArchiveOut`: the same bytes `gunnar repo archive-out` writes,
3720    /// into a sink the control plane owns** — so a cron on another machine can
3721    /// pull a backup off a box that has no shell.
3722    ///
3723    /// It does **not** go through [`Self::open`]. An archive is three files
3724    /// read out of a directory; taking the handle cache and the store's lock
3725    /// for it would make a backup contend with serving, and the whole reason
3726    /// there is no `freeze` verb is that it does not have to. The pack is
3727    /// append-only and fsynced below the cut, so what is read is durable and
3728    /// what arrives after the cut belongs to the next one.
3729    #[cfg(feature = "engine-znippy")]
3730    fn archive_out(
3731        &self,
3732        path: &RepoPath,
3733        from_generation: u64,
3734        sink: &mut dyn std::io::Write,
3735    ) -> ControlResult<gunnar_grpc::seam::ArchiveCut> {
3736        let repo_ref = Self::repo_ref(path)?;
3737        let dir = self
3738            .stores
3739            .repo_dir(&repo_ref)
3740            .map_err(|err| ControlError::Internal(format!("resolving {path}: {err:#}")))?;
3741        if !dir.is_dir() {
3742            return Err(ControlError::NotFound(format!(
3743                "{path} has nothing on disk at {}",
3744                dir.display()
3745            )));
3746        }
3747        let receipt =
3748            crate::storage::znippy_engine::archive_out_to(&dir, &mut { sink }, from_generation)
3749                .map_err(archive_error)?;
3750        // **The second bytes-out counting point, and the only one outside the
3751        // git transport.** `Repos.ArchiveOut` streams a repository's archive
3752        // over gRPC and never touches `transport::serve_blocking`, so its bytes
3753        // are in neither `ServerMetrics::bytes_out` nor — before this line —
3754        // any tenant's meter. A replica pulling a hundred gigabytes would have
3755        // read as a silent namespace.
3756        //
3757        // It counts as a FETCH: a caller asked for objects and objects were
3758        // served, which is what `fetches` means. The principal is not in scope
3759        // at this seam — the gRPC caller's identity stops at the service layer —
3760        // so this transfer moves bytes in nobody's name, which is a smaller
3761        // untruth than attributing it to whoever pushed last.
3762        self.stores.flow().record(
3763            &path.namespace,
3764            gunnar_core::now_ms(),
3765            &gunnar_core::Transfer {
3766                bytes_out: receipt.bytes_out,
3767                fetched: true,
3768                ..gunnar_core::Transfer::default()
3769            },
3770        );
3771        Ok(gunnar_grpc::seam::ArchiveCut {
3772            from_generation: receipt.from_generation,
3773            to_generation: receipt.to_generation,
3774            bytes_out: receipt.bytes_out,
3775            file_sha256: receipt.file_sha256,
3776        })
3777    }
3778
3779    // ── refs and objects ───────────────────────────────────────────────────
3780
3781    fn list_refs(
3782        &self,
3783        path: &RepoPath,
3784        prefix: &str,
3785        peel_them: bool,
3786    ) -> ControlResult<Vec<RefTarget>> {
3787        let (_, store) = self.open(path)?;
3788        let git = store.serve();
3789        // **One `refs()` call is one snapshot.** The prefix is filtered here
3790        // rather than pushed into the store because `GitOps::refs` takes no
3791        // filter — it hands back the ref namespace, and narrowing it is the
3792        // caller's. See `gunnar_wire::refs`.
3793        let snapshot = RefSnapshot::read(git).map_err(wire_error)?;
3794        let mut out = Vec::new();
3795        for row in snapshot.with_prefix(Some(prefix)) {
3796            out.push(ref_target_out(
3797                git,
3798                store.hash_kind(),
3799                &snapshot,
3800                row,
3801                peel_them,
3802            )?);
3803        }
3804        Ok(out)
3805    }
3806
3807    fn get_ref(
3808        &self,
3809        path: &RepoPath,
3810        ref_name: &str,
3811        peel_them: bool,
3812    ) -> ControlResult<RefTarget> {
3813        let (_, store) = self.open(path)?;
3814        let git = store.serve();
3815        // `FullName::new` was the validator and it died with `RefStore`; the
3816        // rules live in `gunnar_store::refname` now, one authority for the
3817        // whole tree. It is still checked here rather than trusted: this is a
3818        // caller's string, and `refname::validate` is a path-traversal boundary
3819        // wherever a name can become a lookup.
3820        gunnar_store::refname::validate(ref_name)
3821            .map_err(|err| ControlError::InvalidArgument(err.to_string()))?;
3822        let snapshot = RefSnapshot::read(git).map_err(wire_error)?;
3823        // `refname::validate` admits exactly `HEAD` and `refs/…`, and `HEAD` is
3824        // **not** one of the rows (the `GitServe` HEAD ruling) — so asking for it in the snapshot
3825        // would answer `NotFound` for a reference every repository has. The
3826        // accessor pair is where it comes from; its target still resolves
3827        // against the snapshot, so both branches answer out of one snapshot.
3828        let head_row;
3829        let row = if ref_name == gunnar_store::HEAD {
3830            head_row = git
3831                .head()
3832                .map_err(store_error)?
3833                .ok_or_else(|| ControlError::NotFound(format!("{ref_name} in {path}")))?;
3834            &head_row
3835        } else {
3836            snapshot
3837                .get(ref_name)
3838                .ok_or_else(|| ControlError::NotFound(format!("{ref_name} in {path}")))?
3839        };
3840        ref_target_out(git, store.hash_kind(), &snapshot, row, peel_them)
3841    }
3842
3843    fn log(
3844        &self,
3845        path: &RepoPath,
3846        start: &str,
3847        limit: u32,
3848        touching: &str,
3849    ) -> ControlResult<Vec<Commit>> {
3850        self.log_walk(path, LogStart::One(start), limit, touching)
3851    }
3852
3853    /// Every branch and tag tip, and `HEAD`, walked as one history: the
3854    /// History page's "all branches" view (owner order 2026-10-18), so a
3855    /// commit that lives only on a side branch is drawn too.
3856    fn log_all_refs(
3857        &self,
3858        path: &RepoPath,
3859        limit: u32,
3860        touching: &str,
3861    ) -> ControlResult<Vec<Commit>> {
3862        self.log_walk(path, LogStart::AllRefs, limit, touching)
3863    }
3864
3865    fn create_pull(
3866        &self,
3867        path: &RepoPath,
3868        author: &str,
3869        title: &str,
3870        description: &str,
3871        source_ref: &str,
3872        target_ref: &str,
3873    ) -> ControlResult<PullRequest> {
3874        for (what, name) in [("source_ref", source_ref), ("target_ref", target_ref)] {
3875            if !name.starts_with("refs/heads/") {
3876                return Err(ControlError::InvalidArgument(format!(
3877                    "{what} must be a full branch name under refs/heads/, got {name:?}: a pull \
3878                     request merges branches, and a short name stored today is an ambiguity \
3879                     resolved differently tomorrow"
3880                )));
3881            }
3882        }
3883        if source_ref == target_ref {
3884            return Err(ControlError::InvalidArgument(
3885                "a branch cannot be merged into itself".to_owned(),
3886            ));
3887        }
3888        if title.trim().is_empty() {
3889            return Err(ControlError::InvalidArgument(
3890                "a title is required".to_owned(),
3891            ));
3892        }
3893        let repo_ref = Self::repo_ref(path)?;
3894        let (_, store) = self.open(path)?;
3895        let git = store.serve();
3896        let snapshot = RefSnapshot::read(git).map_err(wire_error)?;
3897        let source = snapshot
3898            .resolve(source_ref)
3899            .map_err(wire_error)?
3900            .ok_or_else(|| ControlError::NotFound(format!("{source_ref} in {path}")))?;
3901        snapshot
3902            .resolve(target_ref)
3903            .map_err(wire_error)?
3904            .ok_or_else(|| ControlError::NotFound(format!("{target_ref} in {path}")))?;
3905        let record = self
3906            .pulls()?
3907            .create(
3908                &repo_ref.to_string(),
3909                author,
3910                title,
3911                description,
3912                source_ref,
3913                target_ref,
3914                &source.to_string(),
3915            )
3916            .map_err(internal)?;
3917        Ok(pull_out(path, record))
3918    }
3919
3920    fn list_pulls(&self, path: &RepoPath, state: i32) -> ControlResult<Vec<PullRequest>> {
3921        let repo_ref = Self::repo_ref(path)?;
3922        // Existence check: a list for a repository that is not there is
3923        // NotFound, never an empty success.
3924        self.get_repo(path)?;
3925        let filter = match PullRequestState::try_from(state) {
3926            Ok(PullRequestState::Unspecified) | Err(_) => None,
3927            Ok(PullRequestState::Open) => Some(gunnar_core::PullState::Open),
3928            Ok(PullRequestState::Merged) => Some(gunnar_core::PullState::Merged),
3929            Ok(PullRequestState::Closed) => Some(gunnar_core::PullState::Closed),
3930        };
3931        Ok(self
3932            .pulls()?
3933            .list(&repo_ref.to_string(), filter)
3934            .map_err(internal)?
3935            .into_iter()
3936            .map(|record| pull_out(path, record))
3937            .collect())
3938    }
3939
3940    fn get_pull(&self, path: &RepoPath, number: u64) -> ControlResult<PullRequest> {
3941        let repo_ref = Self::repo_ref(path)?;
3942        self.get_repo(path)?;
3943        self.pulls()?
3944            .get(&repo_ref.to_string(), number)
3945            .map_err(internal)?
3946            .map(|record| pull_out(path, record))
3947            .ok_or_else(|| ControlError::NotFound(format!("pull request #{number} in {path}")))
3948    }
3949
3950    fn close_pull(&self, path: &RepoPath, number: u64) -> ControlResult<PullRequest> {
3951        let repo_ref = Self::repo_ref(path)?;
3952        self.get_repo(path)?;
3953        match self
3954            .pulls()?
3955            .close(&repo_ref.to_string(), number)
3956            .map_err(internal)?
3957        {
3958            gunnar_core::PullTransition::Done(record) => Ok(pull_out(path, record)),
3959            gunnar_core::PullTransition::NotFound => Err(ControlError::NotFound(format!(
3960                "pull request #{number} in {path}"
3961            ))),
3962            gunnar_core::PullTransition::NotOpen(state) => Err(ControlError::FailedPrecondition(
3963                format!("pull request #{number} is already {state:?}, not open"),
3964            )),
3965        }
3966    }
3967
3968    /// A fast-forward, a merge commit or a squash (GUN-MERGE-ENGINE, 2026-11-01), and
3969    /// every step is ordered so a failure leaves the truth on disk: the ancestry is
3970    /// decided by the same [`gunnar_policy::ancestry::is_descendant_of`] branch
3971    /// protection trusts (fail-closed: inconclusive is a refusal, never an allow), a
3972    /// merge commit is made by [`crate::merge_engine`] and judged by every gate before
3973    /// it is stored, the ref moves through the store's own compare-and-swap — the
3974    /// primitive receive-pack uses, so this is not a second ref-write path — and the
3975    /// record is marked merged only AFTER the ref has moved.
3976    fn merge_pull(
3977        &self,
3978        path: &RepoPath,
3979        number: u64,
3980        principal_id: &str,
3981        options: &gunnar_grpc::MergeOptions,
3982    ) -> ControlResult<(PullRequest, MergedRef)> {
3983        self.merge_or_check(path, number, principal_id, options, false)
3984    }
3985
3986    /// Every check of [`Self::merge_pull`], and no write (GUN-FIELD-TEST, 2026-11-01).
3987    fn merge_signing_key(&self) -> ControlResult<(String, String)> {
3988        // Read first: a box that takes no write (a twin, a browse replica) never mints.
3989        let public = match self
3990            .stores
3991            .merge_signing_public()
3992            .map_err(|err| ControlError::Unavailable(format!("{err:#}")))?
3993        {
3994            Some(public) => public,
3995            None => {
3996                if let Some(why) = self.frozen() {
3997                    return Err(ControlError::Unavailable(format!(
3998                        "{why}: no merge-signing key here yet; ask the primary"
3999                    )));
4000                }
4001                self.stores
4002                    .merge_signing_key()
4003                    .map_err(|err| ControlError::Unavailable(format!("{err:#}")))?
4004                    .public_key()
4005                    .clone()
4006            }
4007        };
4008        let mut public = public;
4009        public.set_comment("gunnar-merge-signing");
4010        let openssh = public
4011            .to_openssh()
4012            .map_err(|err| ControlError::Internal(format!("the merge-signing key: {err}")))?;
4013        Ok((openssh, gunnar_core::sshsig::fingerprint(&public)))
4014    }
4015
4016    fn check_merge(
4017        &self,
4018        path: &RepoPath,
4019        number: u64,
4020        principal_id: &str,
4021        options: &gunnar_grpc::MergeOptions,
4022    ) -> ControlResult<(PullRequest, MergedRef)> {
4023        self.merge_or_check(path, number, principal_id, options, true)
4024    }
4025
4026    fn read_object(
4027        &self,
4028        path: &RepoPath,
4029        oid: &str,
4030        max_bytes: u64,
4031    ) -> ControlResult<ReadObjectResponse> {
4032        let (_, store) = self.open(path)?;
4033        let git = store.serve();
4034        let id = gix_hash::ObjectId::from_hex(oid.as_bytes())
4035            .map_err(|err| ControlError::InvalidArgument(format!("{oid}: {err}")))?;
4036
4037        // ── the object ceiling, contract half, now complete ────────────────
4038        //
4039        // `header()` costs no inflate — `gix-odb` answers kind and size out of
4040        // the index — so the size is known before any allocation, and both
4041        // refusals happen HERE rather than after the object is materialised.
4042        //
4043        // Two ceilings, and they are different in kind. See their constants.
4044        if let Some((_, object_bytes)) = git.header(id.as_bytes()).map_err(store_error)? {
4045            // The OBJECT ceiling first, and the order is deliberate: an object
4046            // over it cannot be served at any `max_bytes`, so saying so
4047            // immediately is better than sending the caller off to retry with a
4048            // prefix that will be refused for a second reason.
4049            if self.max_object_bytes != 0 && object_bytes > self.max_object_bytes {
4050                return Err(ControlError::ResourceExhausted(format!(
4051                    "object {oid} is {object_bytes} bytes, over this server's {}-byte ceiling \
4052                     for reading an object. Reading any part of it costs the whole object in \
4053                     memory, because a prefix of a delta-encoded object needs its entire base \
4054                     chain, so no max_bytes makes this request cheaper.",
4055                    self.max_object_bytes
4056                )));
4057            }
4058            // The RESPONSE ceiling second: this one costs nobody anything, and
4059            // `max_bytes` is the way past it.
4060            let response_bytes = if max_bytes == 0 {
4061                object_bytes
4062            } else {
4063                object_bytes.min(max_bytes)
4064            };
4065            if response_bytes > MAX_RESPONSE_BYTES {
4066                return Err(ControlError::ResourceExhausted(format!(
4067                    "object {oid} is {object_bytes} bytes and the response would be \
4068                     {response_bytes}, over the {MAX_RESPONSE_BYTES}-byte limit a gRPC client \
4069                     can receive by default. Ask for a prefix with max_bytes."
4070                )));
4071            }
4072        }
4073
4074        // The half with no contract change: the object is inflated once
4075        // and then **moved** into the response, where it used to be copied out
4076        // of `buf` a second time by `to_vec()`. On a one-gigabyte blob that
4077        // second copy was another gigabyte, for nothing.
4078        //
4079        // `GitServe::read` returns the inflated bytes owned, so the move is the
4080        // signature now rather than a borrow that had to be ended first — the
4081        // `{ … }` block this replaced existed only to drop a `Data<'a>` tied to
4082        // the caller's buffer.
4083        let Some((kind, mut buf)) = git.read(id.as_bytes()).map_err(store_error)? else {
4084            return Err(ControlError::NotFound(format!("object {oid} in {path}")));
4085        };
4086        let size = buf.len() as u64;
4087
4088        // The first inflation is still whole-object — `GitServe` has no bounded
4089        // read and for a delta-encoded object cannot honestly grow one — but it
4090        // is now BOUNDED, by `max_object_bytes` above. A caller asking for one
4091        // byte of a one-gigabyte blob is refused rather than served at the cost
4092        // of a gigabyte.
4093        let truncated = max_bytes != 0 && size > max_bytes;
4094        if truncated {
4095            buf.truncate(max_bytes as usize);
4096        }
4097        Ok(ReadObjectResponse {
4098            oid: id.to_string(),
4099            kind: object_kind_out(kind)? as i32,
4100            size,
4101            data: buf,
4102            truncated,
4103        })
4104    }
4105
4106    /// **`Refs.ReadLfsObject`, served out of the repository's own LFS store.**
4107    ///
4108    /// The same store the SSH adapter and the HTTP batch API serve
4109    /// (`gunnar_lfs::FsLfsStore` under `<git_dir>/lfs`), opened the same way
4110    /// `transport::open_lfs_store` opens it, so there is one place an object
4111    /// lives and one answer to where it is.
4112    ///
4113    /// # Whole object or nothing, and the ceiling that decides
4114    ///
4115    /// `read_object` truncates a git blob past `max_bytes`, because a prefix of
4116    /// a file is still a file's beginning. This does not: the caller asked for
4117    /// a PDF, and half a PDF is not a smaller PDF. So the SIZE decides before
4118    /// a byte is read — `size_of` costs a `stat`, not an open — and an object
4119    /// over the ceiling is refused with its size in the message, which is what
4120    /// lets the caller go to `git lfs` instead.
4121    ///
4122    /// The ceiling is the caller's `max_bytes`, lowered to
4123    /// [`MAX_RESPONSE_BYTES`] because that is what a gRPC client can receive by
4124    /// default. Nothing here allocates from a number the client sent: the
4125    /// allocation is the object's own size, after that size has been refused
4126    /// or accepted.
4127    ///
4128    /// # A ranged read (2026-10-15)
4129    ///
4130    /// With `range`, the same ceiling bounds a RANGE instead of the object, so
4131    /// a caller with no `git lfs` — a console handing a browser a 6 MB README
4132    /// picture — reads any object a range at a time, and one that needs only
4133    /// a picture's header reads only that. The store seeks (`open_at`), so a
4134    /// range deep into a large object does not read what lies before it. Still
4135    /// never a silent prefix: a range ends early only at the object's end,
4136    /// which `offset` + the length against `size` says.
4137    fn read_lfs_object(
4138        &self,
4139        path: &RepoPath,
4140        oid: &str,
4141        max_bytes: u64,
4142        range: Option<(u64, u64)>,
4143    ) -> ControlResult<ReadLfsObjectResponse> {
4144        let (_, store) = self.open(path)?;
4145        let id = gunnar_lfs::Oid::parse(oid)
4146            .map_err(|err| ControlError::InvalidArgument(format!("{oid}: {err}")))?;
4147        // The trait the store's two readers live on; `gunnar-lfs` keeps the
4148        // wire adapters and the backend apart, and this is the backend half.
4149        use gunnar_lfs::LfsStore as _;
4150
4151        let lfs = gunnar_lfs::FsLfsStore::open_or_init(store.git_dir()).map_err(|err| {
4152            ControlError::Internal(format!("opening the repository's LFS store: {err}"))
4153        })?;
4154
4155        let ceiling = match max_bytes {
4156            0 => MAX_RESPONSE_BYTES,
4157            n => n.min(MAX_RESPONSE_BYTES),
4158        };
4159        let size = lfs
4160            .size_of(&id)
4161            .map_err(|err| ControlError::Internal(format!("reading the LFS store: {err}")))?
4162            .ok_or_else(|| ControlError::NotFound(format!("LFS object {oid} in {path}")))?;
4163
4164        // **A ranged read** (`offset`/`length` not both 0): the ceiling bounds
4165        // the RANGE, so an object of any size is read a range at a time. An
4166        // offset at or past the end is refused by name rather than answered
4167        // empty, and a length over the ceiling is refused rather than cut — a
4168        // caller never gets fewer bytes than it asked for unless the object's
4169        // end is the reason.
4170        if let Some((offset, length)) = range {
4171            if offset > size || (offset == size && size > 0) {
4172                return Err(ControlError::InvalidArgument(format!(
4173                    "offset {offset} is past the end of LFS object {oid}, which is {size} bytes"
4174                )));
4175            }
4176            if length > ceiling {
4177                return Err(ControlError::ResourceExhausted(format!(
4178                    "a range of {length} bytes of LFS object {oid} is over the {ceiling}-byte ceiling \
4179                     for this request; ask for {ceiling} bytes or fewer at a time"
4180                )));
4181            }
4182            let want = if length == 0 { ceiling } else { length }.min(size - offset);
4183            let object = lfs
4184                .open_at(&id, offset)
4185                .map_err(|err| ControlError::Internal(format!("opening LFS object {oid}: {err}")))?
4186                .ok_or_else(|| ControlError::NotFound(format!("LFS object {oid} in {path}")))?;
4187            let mut data = Vec::with_capacity(want as usize);
4188            let mut reader = std::io::Read::take(object.reader, want);
4189            std::io::Read::read_to_end(&mut reader, &mut data).map_err(|err| {
4190                ControlError::Internal(format!("reading LFS object {oid}: {err}"))
4191            })?;
4192            if (data.len() as u64) < want {
4193                return Err(ControlError::Internal(format!(
4194                    "LFS object {oid} ended {} bytes into a {want}-byte range at {offset}",
4195                    data.len()
4196                )));
4197            }
4198            return Ok(ReadLfsObjectResponse {
4199                oid: oid.to_owned(),
4200                size: object.size,
4201                data,
4202                offset,
4203            });
4204        }
4205
4206        if size > ceiling {
4207            return Err(ControlError::ResourceExhausted(format!(
4208                "LFS object {oid} is {size} bytes, over the {ceiling}-byte ceiling for this \
4209                 request. An LFS object is served whole or not at all — a prefix of one is not a \
4210                 smaller file — so no max_bytes makes this request smaller. `git lfs` streams it, \
4211                 and a ranged read (offset/length) reads it a range at a time."
4212            )));
4213        }
4214
4215        let object = lfs
4216            .open(&id)
4217            .map_err(|err| ControlError::Internal(format!("opening LFS object {oid}: {err}")))?
4218            .ok_or_else(|| ControlError::NotFound(format!("LFS object {oid} in {path}")))?;
4219        // `size_of` and `open` are two calls on a store an upload can commit
4220        // to in between, so the length is taken from the object that was
4221        // actually opened and the read is bounded by it rather than by the
4222        // earlier answer.
4223        if object.size > ceiling {
4224            return Err(ControlError::ResourceExhausted(format!(
4225                "LFS object {oid} is {} bytes, over the {ceiling}-byte ceiling for this request",
4226                object.size
4227            )));
4228        }
4229        let mut data = Vec::with_capacity(object.size as usize);
4230        let mut reader = std::io::Read::take(object.reader, object.size);
4231        std::io::Read::read_to_end(&mut reader, &mut data)
4232            .map_err(|err| ControlError::Internal(format!("reading LFS object {oid}: {err}")))?;
4233
4234        Ok(ReadLfsObjectResponse {
4235            oid: oid.to_owned(),
4236            size: data.len() as u64,
4237            data,
4238            offset: 0,
4239        })
4240    }
4241
4242    /// **`Refs.Diff`, served** (`crate::diff`).
4243    ///
4244    /// It used to answer `Unimplemented` — *"the server has no tree-diff
4245    /// engine; gix's blob-diff is client-side porcelain and an S crate must not
4246    /// link it"*. The constraint was right and the conclusion was not: a tree
4247    /// diff needs `GitServe::read` and nothing else, so it costs no crate and
4248    /// the appliance can serve one. `crate::diff` says what it computes exactly
4249    /// and what it deliberately does not (rename detection).
4250    fn diff(
4251        &self,
4252        path: &RepoPath,
4253        from: &str,
4254        to: &str,
4255        path_prefixes: &[String],
4256    ) -> ControlResult<Vec<FileChange>> {
4257        // The repository is opened first so a bad path is still a NotFound
4258        // rather than an argument error, which would hide a typo.
4259        let (_repo, store) = self.open(path)?;
4260        let git = store.serve();
4261        let hash = store.hash_kind();
4262
4263        // An empty side is the empty tree, which is what makes "everything this
4264        // commit added" expressible without a magic oid: `from: ""` diffs
4265        // against nothing.
4266        let resolve = |spec: &str| -> ControlResult<Option<gix_hash::ObjectId>> {
4267            if spec.is_empty() {
4268                return Ok(None);
4269            }
4270            Ok(Some(self.tree_of(git, hash, path, spec)?))
4271        };
4272        let old = resolve(from)?;
4273        let new = resolve(to)?;
4274
4275        crate::diff::compare(git, hash, old, new, path_prefixes).map_err(|err| match err {
4276            // A tree-ish that names an object this repository does not hold is
4277            // the caller's mistake, not a fault: `NotFound`, with the id.
4278            crate::diff::DiffError::Missing(oid) => {
4279                ControlError::NotFound(format!("object {oid} in {path}"))
4280            }
4281            crate::diff::DiffError::WrongKind { .. } => {
4282                ControlError::InvalidArgument(err.to_string())
4283            }
4284            crate::diff::DiffError::Corrupt { .. } | crate::diff::DiffError::Backend(_) => {
4285                ControlError::Internal(err.to_string())
4286            }
4287        })
4288    }
4289
4290    // ── identity and access ────────────────────────────────────────────────
4291
4292    fn add_principal(
4293        &self,
4294        id: &str,
4295        name: &str,
4296        kind: gunnar_proto::PrincipalKind,
4297    ) -> ControlResult<Principal> {
4298        // gunnar-core's principals carry an id chosen by the caller, not a
4299        // minted one: grants name it, so a generated id would make every grant
4300        // in a config file unwritable until after the principal existed.
4301        //
4302        // The id and the name are two arguments on purpose (2026-09-12). Until
4303        // then this line read `Principal::new(name, name, …)`, which twinned
4304        // the account slug with the display name; every rename was a
4305        // re-identification and every console had to pin "the name I saw".
4306        // The service hands the name as the id when the caller set none, so
4307        // every caller from before keeps exactly what it had.
4308        let principal =
4309            gunnar_core::Principal::new(id, name, principal_kind_in(kind)).map_err(internal)?;
4310        let created = self
4311            .stores
4312            .auth()
4313            .add_principal(principal)
4314            .map(|p| principal_out(&p))
4315            .map_err(internal)?;
4316        // ── the account, created WITH the user ─────────────────────────────
4317        //
4318        // A principal is intrinsically admin of the namespace named after its
4319        // ID (`gunnar_core::AuthTable::owns_account`), which is the `<account>`
4320        // in `<host>/<account>/<repo>`. That rule reads no table, so it can
4321        // only be sound if the two names cannot be claimed independently — and
4322        // this is the half that makes them one claim: creating the principal
4323        // creates the namespace, and a namespace that already exists makes the
4324        // whole call fail, principal and all.
4325        //
4326        // Rolled back on failure rather than left half-made. A principal with
4327        // somebody else's namespace as its account is exactly the state the
4328        // intrinsic rule must never be able to reach.
4329        if let Err(err) = self.stores.catalog().create_namespace(id) {
4330            let _ = self.stores.auth().remove_principal(id);
4331            return Err(ControlError::AlreadyExists(format!(
4332                "cannot create the account namespace \"{id}\" for this user: {err}. A user's \
4333                 account is the namespace named after their id and cannot be somebody else's; \
4334                 the principal was not created."
4335            )));
4336        }
4337        // The metadata mirror's identity half, off the stripe. After the write,
4338        // best effort, never the reason a verb fails: see
4339        // `ServerStores::mirror_identity`.
4340        self.stores.mirror_identity();
4341        Ok(created)
4342    }
4343
4344    fn enroll_employee(
4345        &self,
4346        account: &str,
4347        ask: &gunnar_grpc::EmployeeAsk,
4348    ) -> ControlResult<gunnar_grpc::EmployeeAnswer> {
4349        if self
4350            .stores
4351            .catalog()
4352            .namespace(account)
4353            .map_err(internal)?
4354            .is_none()
4355        {
4356            return Err(ControlError::NotFound(format!("account {account}")));
4357        }
4358        // A clan is no account: employees are a person's account's (clans,
4359        // 2026-10-05), and a clan's members are accounts of their own.
4360        if self.stores.auth().is_clan(account).map_err(internal)? {
4361            return Err(ControlError::FailedPrecondition(format!(
4362                "{account} is a clan, not an account: employees are enrolled by an account's admin, \
4363                 and a clan's members are accounts that join it"
4364            )));
4365        }
4366        let principal = if ask.seats == 0 {
4367            // An id that names a namespace would make the employee that
4368            // namespace's admin by name (`owns_account`). Refused, as the
4369            // account a principal is born with would refuse it.
4370            if self
4371                .stores
4372                .catalog()
4373                .namespace(&ask.id)
4374                .map_err(internal)?
4375                .is_some()
4376            {
4377                return Err(ControlError::AlreadyExists(format!(
4378                    "namespace {} exists, so an employee may not be called that",
4379                    ask.id
4380                )));
4381            }
4382            Some(
4383                gunnar_core::Principal::new(&ask.id, &ask.name, gunnar_core::PrincipalKind::User)
4384                    .map_err(internal)?,
4385            )
4386        } else {
4387            None
4388        };
4389        let room = gunnar_core::RoomAsk {
4390            room_bytes: ask.room_bytes,
4391            seats: ask.seats,
4392            from_seat: ask.from_seat,
4393            cap: ask.cap,
4394            stored: ask.stored,
4395            order: ask.order.clone(),
4396            dry_run: ask.dry_run,
4397        };
4398        let answer = match self
4399            .stores
4400            .auth()
4401            .enroll_with_room(account, principal, &room)
4402        {
4403            Ok(answer) => answer,
4404            // Short is an ANSWER at the seam (nothing was written); the
4405            // service says it in the one sentence every plane shares.
4406            Err(gunnar_core::Error::RoomShort {
4407                need,
4408                unused,
4409                short,
4410                ..
4411            }) => {
4412                let (seats, seats_open) = self.stores.auth().seats_of(account).map_err(internal)?;
4413                return Ok(gunnar_grpc::EmployeeAnswer {
4414                    principal: None,
4415                    room_bytes: ask.room_bytes,
4416                    seats,
4417                    seats_open,
4418                    need_bytes: need,
4419                    unused_bytes: Some(unused),
4420                    shortfall_bytes: short,
4421                    unchanged: true,
4422                });
4423            }
4424            Err(gunnar_core::Error::PrincipalExists(id)) => {
4425                return Err(ControlError::AlreadyExists(format!(
4426                    "principal {id} already exists, and an existing principal can never become an \
4427                     employee"
4428                )))
4429            }
4430            Err(err @ gunnar_core::Error::NoOpenSeat(_)) => {
4431                return Err(ControlError::FailedPrecondition(err.to_string()))
4432            }
4433            Err(err @ gunnar_core::Error::NameExists { .. }) => {
4434                return Err(ControlError::AlreadyExists(err.to_string()))
4435            }
4436            Err(err @ gunnar_core::Error::InvalidInput { .. }) => {
4437                return Err(ControlError::InvalidArgument(err.to_string()))
4438            }
4439            Err(other) => return Err(internal(other)),
4440        };
4441        if !answer.unchanged {
4442            self.stores.mirror_identity();
4443        }
4444        Ok(gunnar_grpc::EmployeeAnswer {
4445            principal: answer.principal.as_ref().map(principal_out),
4446            room_bytes: answer.room_bytes,
4447            seats: answer.seats,
4448            seats_open: answer.seats_open,
4449            need_bytes: answer.need_bytes,
4450            unused_bytes: answer.unused_bytes,
4451            shortfall_bytes: answer.shortfall_bytes,
4452            unchanged: answer.unchanged,
4453        })
4454    }
4455
4456    fn seats(&self, account: &str) -> ControlResult<(u32, u32)> {
4457        self.stores.auth().seats_of(account).map_err(internal)
4458    }
4459
4460    fn clans(&self) -> Option<&dyn gunnar_grpc::clans::ClanPlane> {
4461        Some(self)
4462    }
4463
4464    fn has_employees(&self, account: &str) -> ControlResult<bool> {
4465        self.stores.auth().is_enterprise(account).map_err(internal)
4466    }
4467
4468    fn employees(&self, account: &str) -> ControlResult<Vec<Principal>> {
4469        Ok(self
4470            .stores
4471            .auth()
4472            .employees_of(account)
4473            .map_err(internal)?
4474            .iter()
4475            .map(principal_out)
4476            .collect())
4477    }
4478
4479    fn principal_by_name(&self, name: &str) -> ControlResult<Option<Principal>> {
4480        Ok(self
4481            .stores
4482            .auth()
4483            .principal_by_name(name)
4484            .map_err(internal)?
4485            .as_ref()
4486            .map(principal_out))
4487    }
4488
4489    /// The fed name is read where the installer left it — the last join reply,
4490    /// `<data>/pairing/joined.toml` — so it holds across restarts and needs no
4491    /// second record. A build without the pairing loop was fed nothing.
4492    /// ★ **The operator's first invite**, kept in the identity table (the
4493    /// twin replicates it): the break-glass token's hash as
4494    /// `operator-bootstrap` for the operator's first passkey, no server grant.
4495    /// The identity core reads it as a fed invite of its own and spends it
4496    /// there (IDENTITY-HOST). Spent — and a leftover one withdrawn — once the
4497    /// operator holds a passkey; otherwise recorded, or renewed for another
4498    /// `OPERATOR_INVITE_TTL_MS` when the same hash is pending: one update.
4499    fn operator_bootstrap_invite(
4500        &self,
4501        operator: &str,
4502        secret_sha256: &str,
4503        now_unix_ms: i64,
4504        ttl_ms: i64,
4505    ) -> ControlResult<gunnar_grpc::BootstrapInvite> {
4506        use gunnar_grpc::seam::OPERATOR_INVITE_ID;
4507        let hash = gunnar_grpc::seam::bootstrap_hash(secret_sha256)?;
4508        let ttl_ms = gunnar_grpc::seam::bootstrap_ttl(ttl_ms)?;
4509        let auth = self.stores.auth();
4510        if !auth
4511            .web_credentials(Some(operator))
4512            .map_err(internal)?
4513            .is_empty()
4514        {
4515            if auth
4516                .drop_first_invite(OPERATOR_INVITE_ID)
4517                .map_err(internal)?
4518                .is_some()
4519            {
4520                self.stores.mirror_identity();
4521            }
4522            return Ok(gunnar_grpc::BootstrapInvite::Spent);
4523        }
4524        let renewed = auth
4525            .first_invite(OPERATOR_INVITE_ID)
4526            .map_err(internal)?
4527            .is_some_and(|row| row.secret_hash == hash && row.principal_id == operator);
4528        auth.put_first_invite(gunnar_core::FirstInviteRow {
4529            id: OPERATOR_INVITE_ID.to_owned(),
4530            principal_id: operator.to_owned(),
4531            secret_hash: hash,
4532            created_by: "installer".to_owned(),
4533            created: now_unix_ms,
4534            expires: now_unix_ms + ttl_ms,
4535            note: "the operator's first passkey: the installer's first invite".to_owned(),
4536            server_level: None,
4537        })
4538        .map_err(internal)?;
4539        self.stores.mirror_identity();
4540        if !renewed {
4541            tracing::warn!(
4542                target: "gunnar::audit",
4543                event = "operator.bootstrap_invite",
4544                operator,
4545                ttl_days = ttl_ms / 86_400_000,
4546                "the installer's first invite (its hash) is recorded for the operator: his first passkey registers on his account, by gunnar's word"
4547            );
4548        }
4549        Ok(if renewed {
4550            gunnar_grpc::BootstrapInvite::Renewed
4551        } else {
4552            gunnar_grpc::BootstrapInvite::Recorded
4553        })
4554    }
4555
4556    fn claim_operator(&self, asked: &str) -> ControlResult<gunnar_grpc::OperatorClaim> {
4557        #[cfg(feature = "wg-client")]
4558        let fed = crate::fed_principals::fed_operator(self.stores.root());
4559        #[cfg(not(feature = "wg-client"))]
4560        let fed: Option<String> = None;
4561        self.claim_operator_named(fed.as_deref(), asked)
4562    }
4563
4564    fn rename_principal(&self, principal_id: &str, name: &str) -> ControlResult<Principal> {
4565        // The label alone. The namespace is the id's and does not move; nothing
4566        // keyed on the id is touched, and `AuthTable::rename_principal` says so
4567        // in its tests.
4568        let renamed = self
4569            .stores
4570            .auth()
4571            .rename_principal(principal_id, name)
4572            .map(|p| principal_out(&p))
4573            .map_err(internal)?;
4574        self.stores.mirror_identity();
4575        Ok(renamed)
4576    }
4577
4578    fn list_principals(&self) -> ControlResult<Vec<Principal>> {
4579        Ok(self
4580            .stores
4581            .auth()
4582            .principals()
4583            .map_err(internal)?
4584            .iter()
4585            .map(principal_out)
4586            .collect())
4587    }
4588
4589    fn principal_activity(
4590        &self,
4591    ) -> ControlResult<std::collections::BTreeMap<String, gunnar_grpc::PrincipalSeen>> {
4592        Ok(self
4593            .stores
4594            .activity()
4595            .all()
4596            .into_iter()
4597            .map(|(id, seen)| {
4598                (
4599                    id,
4600                    gunnar_grpc::PrincipalSeen {
4601                        last_sign_in_unix_ms: seen.signed_in,
4602                        last_operation_unix_ms: seen.operated,
4603                    },
4604                )
4605            })
4606            .collect())
4607    }
4608
4609    fn holds_server_grant(&self, principal_id: &str) -> ControlResult<bool> {
4610        self.stores
4611            .auth()
4612            .holds_server_grant(principal_id)
4613            .map_err(internal)
4614    }
4615
4616    fn add_credential(
4617        &self,
4618        principal_id: &str,
4619        credential_id: &[u8],
4620        public_key: &[u8],
4621        label: &str,
4622        sign_count: u32,
4623    ) -> ControlResult<gunnar_proto::WebCredential> {
4624        let stored = self
4625            .stores
4626            .auth()
4627            .add_web_credential(gunnar_core::WebCredential {
4628                id: gunnar_grpc::webauthn::b64url(credential_id),
4629                principal_id: principal_id.to_owned(),
4630                label: label.to_owned(),
4631                public_key_hex: hex::encode(public_key),
4632                sign_count,
4633                created: gunnar_core::now_ms(),
4634                last_used: None,
4635                sessions: Vec::new(),
4636            })
4637            .map_err(internal)?;
4638        web_credential_out(&stored)
4639    }
4640
4641    fn credentials(
4642        &self,
4643        principal_id: Option<&str>,
4644    ) -> ControlResult<Vec<gunnar_proto::WebCredential>> {
4645        self.stores
4646            .auth()
4647            .web_credentials(principal_id)
4648            .map_err(internal)?
4649            .iter()
4650            .map(web_credential_out)
4651            .collect()
4652    }
4653
4654    fn credential(
4655        &self,
4656        credential_id: &[u8],
4657    ) -> ControlResult<Option<gunnar_grpc::StoredCredential>> {
4658        let Some(row) = self
4659            .stores
4660            .auth()
4661            .web_credential(&gunnar_grpc::webauthn::b64url(credential_id))
4662            .map_err(internal)?
4663        else {
4664            return Ok(None);
4665        };
4666        let public_key = hex::decode(&row.public_key_hex).map_err(|err| {
4667            ControlError::Internal(format!(
4668                "web credential {} holds a key that is not hex: {err}",
4669                row.id
4670            ))
4671        })?;
4672        Ok(Some(gunnar_grpc::StoredCredential {
4673            credential: web_credential_out(&row)?,
4674            public_key,
4675        }))
4676    }
4677
4678    fn record_passkey_use(
4679        &self,
4680        credential_id: &[u8],
4681        sign_count: u32,
4682        now_unix_ms: i64,
4683    ) -> ControlResult<gunnar_proto::WebCredential> {
4684        let row = self
4685            .stores
4686            .auth()
4687            .touch_web_credential(
4688                &gunnar_grpc::webauthn::b64url(credential_id),
4689                sign_count,
4690                now_unix_ms,
4691            )
4692            .map_err(internal)?;
4693        self.stores.mirror_identity();
4694        // A session was issued: the person's last sign-in (2026-10-21).
4695        self.stores
4696            .activity()
4697            .signed_in(&row.principal_id, now_unix_ms);
4698        web_credential_out(&row)
4699    }
4700
4701    fn revoke_credential(
4702        &self,
4703        credential_id: &[u8],
4704    ) -> ControlResult<Option<gunnar_proto::WebCredential>> {
4705        let removed = self
4706            .stores
4707            .auth()
4708            .revoke_web_credential(&gunnar_grpc::webauthn::b64url(credential_id))
4709            .map_err(internal)?;
4710        // Its session keys went with it.
4711        self.stores.mirror_identity();
4712        removed.as_ref().map(web_credential_out).transpose()
4713    }
4714
4715    fn rename_credential(
4716        &self,
4717        credential_id: &[u8],
4718        label: &str,
4719    ) -> ControlResult<Option<gunnar_proto::WebCredential>> {
4720        self.stores
4721            .auth()
4722            .rename_web_credential(&gunnar_grpc::webauthn::b64url(credential_id), label)
4723            .map_err(internal)?
4724            .as_ref()
4725            .map(web_credential_out)
4726            .transpose()
4727    }
4728
4729    fn first_invite(&self) -> ControlResult<Option<gunnar_grpc::FirstInvite>> {
4730        Ok(self
4731            .stores
4732            .auth()
4733            .first_invite(gunnar_grpc::seam::OPERATOR_INVITE_ID)
4734            .map_err(internal)?
4735            .map(|row| gunnar_grpc::FirstInvite {
4736                principal_id: row.principal_id,
4737                secret_hash: row.secret_hash,
4738                created_at_unix_ms: row.created,
4739                expires_at_unix_ms: row.expires,
4740            }))
4741    }
4742
4743    fn drop_first_invite(&self) -> ControlResult<()> {
4744        if self
4745            .stores
4746            .auth()
4747            .drop_first_invite(gunnar_grpc::seam::OPERATOR_INVITE_ID)
4748            .map_err(internal)?
4749            .is_some()
4750        {
4751            self.stores.mirror_identity();
4752        }
4753        Ok(())
4754    }
4755
4756    fn employer_of(&self, principal_id: &str) -> ControlResult<Option<String>> {
4757        self.stores
4758            .auth()
4759            .employer_of(principal_id)
4760            .map_err(internal)
4761    }
4762
4763    fn sso(&self) -> Option<&dyn gunnar_grpc::sso::plane::SsoPlane> {
4764        Some(self)
4765    }
4766
4767    fn sso_import(
4768        &self,
4769        order: gunnar_grpc::sso::scim::ImportOrder,
4770    ) -> ControlResult<gunnar_grpc::sso::scim::ImportJob> {
4771        // RA5-39: enqueued under an id of its own, and the queue's answer is the judge (never a
4772        // status read before it, which a second import at the same moment also passed): a job
4773        // that is not this order's is the live import of the account, and this file is refused.
4774        let account = order.account.clone();
4775        let id = self.sso_import.next_job_id();
4776        let job = self.sso_import.enqueue_as(order, id.clone());
4777        if job.job_id != id {
4778            return Err(ControlError::FailedPrecondition(format!(
4779                "an import for account {account} is already {} (job {}): wait for it to finish",
4780                if job.state == crate::job_queue::JobState::Queued {
4781                    "queued"
4782                } else {
4783                    "running"
4784                },
4785                job.job_id
4786            )));
4787        }
4788        Ok(crate::sso_import_queue::job_out(job))
4789    }
4790
4791    fn sso_import_status(
4792        &self,
4793        account: &str,
4794    ) -> ControlResult<Option<gunnar_grpc::sso::scim::ImportJob>> {
4795        Ok(self
4796            .sso_import
4797            .status(account)
4798            .map(crate::sso_import_queue::job_out))
4799    }
4800
4801    fn approve_machine_enrolment(
4802        &self,
4803        approval: gunnar_grpc::MachineApproval,
4804    ) -> ControlResult<gunnar_grpc::MachineApproved> {
4805        let step_up = approval
4806            .step_up
4807            .as_ref()
4808            .map(|(id, count)| (gunnar_grpc::webauthn::b64url(id), *count));
4809        let (principal, credential, created) = self
4810            .stores
4811            .auth()
4812            .approve_machine_credential(
4813                &approval.machine,
4814                level_in(approval.server_level)?,
4815                gunnar_core::WebCredential {
4816                    id: gunnar_grpc::webauthn::b64url(&approval.credential_id),
4817                    principal_id: String::new(),
4818                    label: approval.label,
4819                    public_key_hex: hex::encode(&approval.public_key),
4820                    sign_count: approval.sign_count,
4821                    created: approval.now_unix_ms,
4822                    last_used: None,
4823                    sessions: Vec::new(),
4824                },
4825                step_up.as_ref().map(|(id, count)| (id.as_str(), *count)),
4826                approval.now_unix_ms,
4827            )
4828            .map_err(internal)?;
4829        self.stores.mirror_identity();
4830        Ok(gunnar_grpc::MachineApproved {
4831            principal: principal_out(&principal),
4832            credential: web_credential_out(&credential)?,
4833            created,
4834        })
4835    }
4836
4837    fn remove_principal(&self, principal_id: &str) -> ControlResult<(u32, u32)> {
4838        let before_keys = self.stores.auth().keys().map_err(internal)?.len();
4839        let before_grants = self.stores.auth().grants(None).map_err(internal)?.len();
4840        self.stores
4841            .auth()
4842            .remove_principal(principal_id)
4843            .map_err(internal)?;
4844        let after_keys = self.stores.auth().keys().map_err(internal)?.len();
4845        let after_grants = self.stores.auth().grants(None).map_err(internal)?.len();
4846        self.stores.mirror_identity();
4847        Ok((
4848            before_keys.saturating_sub(after_keys) as u32,
4849            before_grants.saturating_sub(after_grants) as u32,
4850        ))
4851    }
4852
4853    fn add_key(
4854        &self,
4855        principal_id: &str,
4856        openssh_public_key: &str,
4857        _comment: &str,
4858        expires_at_unix_ms: Option<i64>,
4859        caller: &str,
4860        attestation: Option<gunnar_proto::SignedKeyAttestation>,
4861    ) -> ControlResult<PublicKey> {
4862        // The fingerprint the attestation must name is derived HERE from the
4863        // key line, never taken from the client: an attestation over a
4864        // fingerprint the client chose would attest to nothing the SSH listener
4865        // will ever match. Parsed once more inside `register_key`; that is one
4866        // parse of one line on a verb that runs when a person gets a machine.
4867        // An `age1…` line is an encryption key, never an SSH key (CRYPT-PQ-LEGACY).
4868        if openssh_public_key.trim().starts_with("age1") {
4869            return Err(ControlError::InvalidArgument(
4870                gunnar_core::auth::AGE_LINE_IS_NO_SSH_KEY.to_owned(),
4871            ));
4872        }
4873        let adding = gunnar_core::Fingerprint::of_key(
4874            &gunnar_core::auth::parse_public_key(openssh_public_key).map_err(internal)?,
4875        );
4876        let holders = self
4877            .stores
4878            .auth()
4879            .attesting_keys_of(principal_id)
4880            .map_err(internal)?;
4881        // **The key it already holds is already there** (L1, release-3 recharge): a restored
4882        // main holds the twin's key from the log, and the pairing door enrols it again. That
4883        // is no new key, so no attestation is owed — ALREADY_EXISTS, which callers tolerate.
4884        if holders.iter().any(|k| k.fingerprint == adding.as_str()) {
4885            return Err(ControlError::AlreadyExists(format!(
4886                "principal {principal_id} already holds key {}",
4887                adding.as_str()
4888            )));
4889        }
4890        let requirement =
4891            gunnar_core::key_attestation::Requirement::of(principal_id, caller, holders);
4892        let attestation = attestation.map(from_proto_attestation).transpose()?;
4893        gunnar_core::key_attestation::check(
4894            &requirement,
4895            principal_id,
4896            adding.as_str(),
4897            attestation.as_ref(),
4898        )
4899        .map_err(|problem| match problem {
4900            // Required and absent: the request is well formed and the table
4901            // says "not without their word" — a precondition, with the
4902            // command that satisfies it in the message.
4903            gunnar_core::AttestationProblem::Missing { .. } => {
4904                ControlError::FailedPrecondition(problem.to_string())
4905            }
4906            // Present and wrong: the caller's argument.
4907            _ => ControlError::InvalidArgument(problem.to_string()),
4908        })?;
4909        let registered = self
4910            .stores
4911            .auth()
4912            .register_key(
4913                principal_id,
4914                openssh_public_key,
4915                expires_at_unix_ms.map(|ms| ms as UnixMillis),
4916            )
4917            .map(|key| key_out(&key))
4918            .map_err(internal)?;
4919        self.stores.mirror_identity();
4920        Ok(registered)
4921    }
4922
4923    fn revoke_key(&self, fingerprint: &str) -> ControlResult<()> {
4924        let parsed = gunnar_core::Fingerprint::parse(fingerprint)
4925            .map_err(|err| ControlError::InvalidArgument(err.to_string()))?;
4926        let revoked = self.stores.auth().revoke_key(&parsed).map_err(internal)?;
4927        self.stores.mirror_identity();
4928        match revoked {
4929            Some(_) => Ok(()),
4930            None => Err(ControlError::NotFound(format!("key {fingerprint}"))),
4931        }
4932    }
4933
4934    fn list_keys(&self, principal_id: Option<&str>) -> ControlResult<Vec<PublicKey>> {
4935        let keys = match principal_id {
4936            Some(id) => self.stores.auth().keys_of(id).map_err(internal)?,
4937            None => self.stores.auth().keys().map_err(internal)?,
4938        };
4939        Ok(keys.iter().map(key_out).collect())
4940    }
4941
4942    fn grant(
4943        &self,
4944        principal_id: &str,
4945        namespace: &str,
4946        repo: &str,
4947        level: GrantLevel,
4948    ) -> ControlResult<gunnar_proto::Grant> {
4949        let scope = scope_in(namespace, repo)?;
4950        let grant = Grant {
4951            principal: principal_id.to_owned(),
4952            scope,
4953            level: level_in(level)?,
4954        };
4955        self.stores
4956            .auth()
4957            .grant(grant)
4958            .map(|g| grant_out(&g))
4959            .map_err(internal)
4960    }
4961
4962    fn revoke(&self, principal_id: &str, namespace: &str, repo: &str) -> ControlResult<()> {
4963        let scope = scope_in(namespace, repo)?;
4964        match self
4965            .stores
4966            .auth()
4967            .revoke_grant(principal_id, &scope)
4968            .map_err(internal)?
4969        {
4970            true => Ok(()),
4971            false => Err(ControlError::NotFound(format!(
4972                "grant for {principal_id} on {scope}"
4973            ))),
4974        }
4975    }
4976
4977    fn list_grants(
4978        &self,
4979        principal_id: Option<&str>,
4980        namespace: Option<&str>,
4981        repo: Option<&str>,
4982    ) -> ControlResult<Vec<gunnar_proto::Grant>> {
4983        Ok(self
4984            .stores
4985            .auth()
4986            .grants(principal_id)
4987            .map_err(internal)?
4988            .iter()
4989            .map(grant_out)
4990            .filter(|g| namespace.is_none_or(|want| want == g.namespace))
4991            .filter(|g| repo.is_none_or(|want| want == g.repo))
4992            .collect())
4993    }
4994
4995    // ── groups (punkt 9) ───────────────────────────────────────────────────
4996
4997    fn add_group_member(
4998        &self,
4999        group_id: &str,
5000        member_id: &str,
5001        signed: gunnar_proto::SignedMembershipEvent,
5002    ) -> ControlResult<u64> {
5003        let signed = self.verify_membership_event(
5004            signed,
5005            group_id,
5006            member_id,
5007            gunnar_core::MembershipAction::Add,
5008        )?;
5009        // Apply to the live table first: if the typecheck (group exists and is
5010        // a group, member exists) fails, nothing is signed into the log. A
5011        // membership event in the log names a change that was actually made.
5012        self.stores
5013            .auth()
5014            .add_to_group(group_id, member_id)
5015            .map_err(internal)?;
5016        self.append_membership_event(&signed)
5017    }
5018
5019    fn remove_group_member(
5020        &self,
5021        group_id: &str,
5022        member_id: &str,
5023        signed: gunnar_proto::SignedMembershipEvent,
5024    ) -> ControlResult<(bool, u64)> {
5025        let signed = self.verify_membership_event(
5026            signed,
5027            group_id,
5028            member_id,
5029            gunnar_core::MembershipAction::Remove,
5030        )?;
5031        let was_member = self
5032            .stores
5033            .auth()
5034            .remove_from_group(group_id, member_id)
5035            .map_err(internal)?;
5036        // Recorded even when it was a no-op: the attempt to remove is evidence
5037        // too, and a reader folding the log sees a remove of a non-member leave
5038        // the set unchanged, exactly as `remove_from_group` did.
5039        let index = self.append_membership_event(&signed)?;
5040        Ok((was_member, index))
5041    }
5042
5043    fn membership_events(
5044        &self,
5045        group_id: Option<&str>,
5046    ) -> ControlResult<Vec<gunnar_proto::SignedMembershipEvent>> {
5047        Ok(self
5048            .membership_log_events()?
5049            .into_iter()
5050            .filter(|signed| group_id.is_none_or(|g| signed.event.group == g))
5051            .map(to_proto_membership)
5052            .collect())
5053    }
5054
5055    fn list_group_members(&self, group_id: &str) -> ControlResult<Vec<Principal>> {
5056        // The group must exist and be a group, so "no members" and "no such
5057        // group" cannot be confused into the same empty list.
5058        let auth = self.stores.auth();
5059        match auth.principal(group_id).map_err(internal)? {
5060            Some(p) if p.kind == PrincipalKind::Group => {}
5061            Some(_) => {
5062                return Err(ControlError::InvalidArgument(format!(
5063                    "principal \"{group_id}\" is not a group"
5064                )))
5065            }
5066            None => return Err(ControlError::NotFound(format!("group {group_id}"))),
5067        }
5068        // Membership is stored by member id; resolve each to a full principal so
5069        // a UI can show names and kinds, not bare ids.
5070        let mut members = Vec::new();
5071        for id in auth.members_of(group_id).map_err(internal)? {
5072            if let Some(p) = auth.principal(&id).map_err(internal)? {
5073                members.push(principal_out(&p));
5074            }
5075        }
5076        Ok(members)
5077    }
5078
5079    // ── policy ─────────────────────────────────────────────────────────────
5080
5081    fn get_protection(
5082        &self,
5083        path: &RepoPath,
5084        ref_pattern: Option<&str>,
5085    ) -> ControlResult<Vec<BranchProtection>> {
5086        let (repo, _) = self.open(path)?;
5087        let config = self.policy_of(&repo)?;
5088        Ok(config
5089            .protection
5090            .iter()
5091            .filter(|rule| ref_pattern.is_none_or(|want| rule.pattern.as_str() == want))
5092            .map(protection_out)
5093            .collect())
5094    }
5095
5096    /// **Write one protection rule**, replacing any rule with the same pattern.
5097    ///
5098    /// Two things this deliberately does not do, both of them places where the
5099    /// proto and `gunnar_policy::ProtectionRule` do not line up. Getting either
5100    /// wrong silently is how an operator ends up with a policy they did not
5101    /// write:
5102    ///
5103    /// * **The per-pattern byte and object limits are refused, not dropped.**
5104    ///   `BranchProtection` carries `max_object_bytes` and
5105    ///   `max_objects_per_push` per rule; `gunnar-policy`'s `LimitPolicy` is
5106    ///   **repository-wide**. Storing a per-rule number into a repository-wide
5107    ///   limit would apply it to references the caller never named, and
5108    ///   ignoring it would report success for a limit that is not enforced.
5109    ///   Both are worse than saying so.
5110    /// * **`allow_create` and `ancestry_budget` survive a round trip.** They
5111    ///   have no proto field, so a rule written over gRPC would otherwise reset
5112    ///   whatever a `gunnar-policy.json` had set — an edit through the console
5113    ///   quietly widening a rule it did not display. The existing rule is read
5114    ///   first and only the fields the proto carries are overwritten.
5115    fn set_protection(
5116        &self,
5117        path: &RepoPath,
5118        protection: BranchProtection,
5119    ) -> ControlResult<BranchProtection> {
5120        let (repo, _) = self.open(path)?;
5121        if protection.max_object_bytes != 0 || protection.max_objects_per_push != 0 {
5122            return Err(ControlError::InvalidArgument(
5123                "max_object_bytes and max_objects_per_push are per-rule in the protocol and \
5124                 repository-wide in this server's policy engine, so a value here would apply to \
5125                 references this rule does not name. Leave them 0 and set the repository's \
5126                 limits in its policy document."
5127                    .to_owned(),
5128            ));
5129        }
5130        let pattern = gunnar_policy::RefPattern::new(protection.ref_pattern.clone())
5131            .map_err(|err| ControlError::InvalidArgument(err.to_string()))?;
5132
5133        let mut config = self.policy_of(&repo)?;
5134        // Start from the rule that is there, so the two fields the proto cannot
5135        // express keep their values.
5136        let mut rule = config
5137            .protection
5138            .iter()
5139            .find(|rule| rule.pattern == pattern)
5140            .cloned()
5141            .unwrap_or_else(|| {
5142                gunnar_policy::ProtectionRule::protect(pattern.as_str())
5143                    .expect("the pattern was compiled one line above")
5144            });
5145        rule.pattern = pattern;
5146        rule.allow_force_push = protection.allow_force_push;
5147        rule.allow_delete = protection.allow_delete;
5148        rule.require_signature = protection.require_signature;
5149        // Empty in the proto means "anybody"; `None` means the same in the
5150        // policy. A `Some(vec![])` would mean **nobody may push**, which is a
5151        // very different rule to arrive at by accident.
5152        rule.allowed_principals = if protection.allowed_principal_ids.is_empty() {
5153            None
5154        } else {
5155            Some(protection.allowed_principal_ids.clone())
5156        };
5157        rule.forbid_self_approval = protection.forbid_self_approval;
5158        rule.required_approvals = protection.required_approvals;
5159
5160        config
5161            .protection
5162            .retain(|other| other.pattern != rule.pattern);
5163        config.protection.push(rule.clone());
5164        self.store_policy(&repo, &config)?;
5165        Ok(protection_out(&rule))
5166    }
5167
5168    fn delete_protection(&self, path: &RepoPath, ref_pattern: &str) -> ControlResult<()> {
5169        let (repo, _) = self.open(path)?;
5170        let mut config = self.policy_of(&repo)?;
5171        let before = config.protection.len();
5172        config
5173            .protection
5174            .retain(|rule| rule.pattern.as_str() != ref_pattern);
5175        if config.protection.len() == before {
5176            return Err(ControlError::NotFound(format!(
5177                "no protection rule for {ref_pattern} in {path}"
5178            )));
5179        }
5180        self.store_policy(&repo, &config)
5181    }
5182
5183    fn get_ref_name_policy(&self, path: &RepoPath) -> ControlResult<RefNamePolicy> {
5184        let (repo, _) = self.open(path)?;
5185        let config = self.policy_of(&repo)?;
5186        Ok(ref_name_policy_out(&config.ref_names))
5187    }
5188
5189    /// The gates a push to `path` meets: the stored policy's own (`PolicyConfig::gates`, the
5190    /// list the push path's `PolicyGate::open` starts from), plus the plaintext check an
5191    /// ENCRYPTED repository adds, exactly as the push path adds it.
5192    fn live_gates(&self, path: &RepoPath) -> ControlResult<Option<Vec<String>>> {
5193        use gunnar_policy::Gate as _;
5194        let (repo, _) = self.open(path)?;
5195        let config = self.policy_of(&repo)?;
5196        let mut names: Vec<String> = config.gates().iter().map(|g| g.name().to_owned()).collect();
5197        if repo.encrypted {
5198            names.push("encrypted".to_owned());
5199        }
5200        Ok(Some(names))
5201    }
5202
5203    fn set_ref_name_policy(
5204        &self,
5205        path: &RepoPath,
5206        policy: RefNamePolicy,
5207    ) -> ControlResult<RefNamePolicy> {
5208        let (repo, _) = self.open(path)?;
5209        let compiled = gunnar_policy::RefNamePolicy::new(
5210            policy.allow_patterns.clone(),
5211            policy.deny_patterns.clone(),
5212        )
5213        .map_err(|err| ControlError::InvalidArgument(err.to_string()))?;
5214        let mut config = self.policy_of(&repo)?;
5215        config.ref_names = compiled.clone();
5216        self.store_policy(&repo, &config)?;
5217        Ok(ref_name_policy_out(&compiled))
5218    }
5219
5220    // ── forge (roadmap phase 1) ────────────────────────────────────────────
5221    //
5222    // **Real**, over the same append-only log `gunnar forge` writes. There is
5223    // one forge log per server and the events carry their repository, so the
5224    // gRPC surface and the CLI are two doors onto one file rather than two
5225    // stores that can disagree (one implementation).
5226
5227    fn forge_append(&self, event: gunnar_proto::SignedForgeEvent) -> ControlResult<u64> {
5228        if event.attested.is_some() {
5229            return Err(ControlError::InvalidArgument(
5230                "this event carries an attestation, and an attestation is gunnar's own word: \
5231                 only gunnar makes one, for a person's session that stepped up"
5232                    .to_owned(),
5233            ));
5234        }
5235        let signed = from_proto_event(event)?;
5236
5237        // **The signature is checked before anything is written.** A review log
5238        // that took an event it could not verify would be a log of claims, and
5239        // the point of the whole design is that it is a log of evidence. The
5240        // key must already be in the ACL table for the same reason: a signature
5241        // by a key gunnar does not know is not something a later reader could
5242        // check at all.
5243        let key = self
5244            .stores
5245            .auth()
5246            .signing_keys()
5247            .map_err(|err| ControlError::Unavailable(format!("{err:#}")))?
5248            .into_iter()
5249            .find(|row| row.fingerprint_sha256 == signed.key_fingerprint)
5250            .ok_or_else(|| {
5251                ControlError::InvalidArgument(format!(
5252                    "{} is not registered on this server, so nobody reading this event later \
5253                     could check its signature",
5254                    signed.key_fingerprint
5255                ))
5256            })?;
5257        // **The key must be the claimed actor's own.** The signature proves
5258        // which key spoke; the table's `principal_id` says whose voice that
5259        // is, and an event attributed to one principal signed by another's
5260        // key is a claim filed in somebody else's name — exactly what the
5261        // service layer's actor==caller check refuses, re-checked here at the
5262        // seam because this door can also be reached without a connection.
5263        if key.principal_id != signed.event.actor {
5264            return Err(ControlError::InvalidArgument(format!(
5265                "this event is attributed to {:?} and is signed by {}, which is {:?}'s key; \
5266                 an approval is the actor's own word, so it must carry the actor's own \
5267                 signature",
5268                signed.event.actor, signed.key_fingerprint, key.principal_id
5269            )));
5270        }
5271        let public = gunnar_core::ssh_key::PublicKey::from_openssh(&key.openssh)
5272            .map_err(|err| ControlError::Internal(format!("stored key is unreadable: {err}")))?;
5273        gunnar_core::forge::verify_event(&signed, &public)
5274            .map_err(|problem| ControlError::InvalidArgument(format!("{problem}")))?;
5275
5276        self.forge_subject_open(&signed)?;
5277        self.forge_write(&signed)
5278    }
5279
5280    fn forge_append_attested(
5281        &self,
5282        event: gunnar_proto::SignedForgeEvent,
5283        session_stepped_up_at_unix_ms: i64,
5284    ) -> ControlResult<(u64, String)> {
5285        // **The caller's own signature is not what this takes.** A session
5286        // holds no key of the person's; what it holds is a fresh touch of their
5287        // passkey, which the service checked. A signature on the wire here is
5288        // somebody's key speaking, and that is `forge_append`'s door.
5289        if !event.key_fingerprint.is_empty()
5290            || !event.signature.is_empty()
5291            || event.attested.is_some()
5292        {
5293            return Err(ControlError::InvalidArgument(
5294                "an event gunnar attests arrives unsigned: a signed one is checked against its \
5295                 signer's key instead, and an attestation is gunnar's own to make"
5296                    .to_owned(),
5297            ));
5298        }
5299        let event = from_proto_event(event)?.event;
5300        if event.actor.is_empty() {
5301            return Err(ControlError::InvalidArgument(
5302                "an attested event must name the person it is attested for".to_owned(),
5303            ));
5304        }
5305        let key = self
5306            .stores
5307            .forge_attest_key()
5308            .map_err(|err| ControlError::Unavailable(format!("{err:#}")))?;
5309        let signed = gunnar_core::forge::attest_event(
5310            &key,
5311            event,
5312            gunnar_core::Attested {
5313                session_stepped_up_at_unix_ms,
5314            },
5315        )
5316        .map_err(|err| ControlError::Internal(format!("signing the attested event: {err}")))?;
5317        self.forge_subject_open(&signed)?;
5318        let words = signed
5319            .attested
5320            .as_ref()
5321            .map(|a| a.words())
5322            .unwrap_or_default();
5323        Ok((self.forge_write(&signed)?, words))
5324    }
5325
5326    fn forge_review(
5327        &self,
5328        repo: &str,
5329        pull: u64,
5330    ) -> ControlResult<(
5331        gunnar_proto::ReviewState,
5332        Vec<gunnar_proto::SignedForgeEvent>,
5333    )> {
5334        let all = self.forge_events()?;
5335        let mine: Vec<gunnar_core::ForgeEvent> = all
5336            .iter()
5337            .filter(|signed| signed.event.repo == repo)
5338            .map(|signed| signed.event.clone())
5339            .collect();
5340        let state = gunnar_core::forge::fold(pull, &mine);
5341        let events = all
5342            .iter()
5343            .filter(|signed| signed.event.repo == repo && signed.event.pull == pull)
5344            .cloned()
5345            .map(to_proto_event)
5346            .collect();
5347        Ok((self.review_state(repo, &state, &all)?, events))
5348    }
5349
5350    fn forge_reviews(&self, repo: &str) -> ControlResult<Vec<gunnar_proto::ReviewState>> {
5351        let all = self.forge_events()?;
5352        let mine: Vec<gunnar_core::ForgeEvent> = all
5353            .iter()
5354            .filter(|signed| signed.event.repo == repo)
5355            .map(|signed| signed.event.clone())
5356            .collect();
5357        let mut numbers: Vec<u64> = mine.iter().map(|event| event.pull).collect();
5358        numbers.sort_unstable();
5359        numbers.dedup();
5360        let mut out = Vec::new();
5361        for pull in numbers {
5362            let state = gunnar_core::forge::fold(pull, &mine);
5363            out.push(self.review_state(repo, &state, &all)?);
5364        }
5365        Ok(out)
5366    }
5367
5368    fn forge_issue(
5369        &self,
5370        repo: &str,
5371        number: u64,
5372    ) -> ControlResult<
5373        Option<(
5374            gunnar_proto::IssueState,
5375            Vec<gunnar_proto::SignedForgeEvent>,
5376        )>,
5377    > {
5378        let all = self.forge_events()?;
5379        let mine: Vec<gunnar_core::ForgeEvent> = all
5380            .iter()
5381            .filter(|signed| signed.event.repo == repo)
5382            .map(|signed| signed.event.clone())
5383            .collect();
5384        let Some(issue) = gunnar_core::forge::fold_issue(number, &mine) else {
5385            return Ok(None);
5386        };
5387        // The whole thread, in log order: the opening, every comment, and the
5388        // close. An `IssueOpened` carries `pull == 0`, so it is matched by
5389        // position — the Nth opening is issue N — while comments and closes name
5390        // their number directly, exactly as the fold reads them.
5391        let mut opened_seen = 0u64;
5392        let events = all
5393            .iter()
5394            .filter(|signed| {
5395                if signed.event.repo != repo {
5396                    return false;
5397                }
5398                match &signed.event.event {
5399                    gunnar_core::Event::IssueOpened { .. } => {
5400                        opened_seen += 1;
5401                        opened_seen == number
5402                    }
5403                    gunnar_core::Event::IssueCommented { .. }
5404                    | gunnar_core::Event::IssueClosed { .. } => signed.event.pull == number,
5405                    _ => false,
5406                }
5407            })
5408            .cloned()
5409            .map(to_proto_event)
5410            .collect();
5411        Ok(Some((issue_state(repo, &issue), events)))
5412    }
5413
5414    fn forge_issues(&self, repo: &str) -> ControlResult<Vec<gunnar_proto::IssueState>> {
5415        let mine: Vec<gunnar_core::ForgeEvent> = self
5416            .forge_events()?
5417            .into_iter()
5418            .filter(|signed| signed.event.repo == repo)
5419            .map(|signed| signed.event)
5420            .collect();
5421        Ok(gunnar_core::forge::fold_issues(&mine)
5422            .iter()
5423            .map(|issue| issue_state(repo, issue))
5424            .collect())
5425    }
5426
5427    // ── admin ──────────────────────────────────────────────────────────────
5428
5429    /// **The two numbers an operator is surprised by if nobody reports them.**
5430    ///
5431    /// Tombstoned bytes, because a customer who deletes a repository and sees
5432    /// no change in their usage will call the bill wrong; and replication,
5433    /// because every configured standby on this project received NOTHING from
5434    /// `e31ab8f4` until 2026-08-30 and the only thing that said so was a log
5435    /// line nobody was tailing.
5436    fn stats_extras(&self) -> ControlResult<gunnar_grpc::seam::StatsExtras> {
5437        let tombstones = self.stores.tombstones().unwrap_or_default();
5438        let report = self.replication.report();
5439        // The tenants row, off the same slot the band reads (one implementation). A build
5440        // without `metered`, or a metered one with no slot, counts nothing.
5441        #[allow(unused_mut)]
5442        let mut tenants = crate::dashboard::TenantCounts::default();
5443        #[cfg(feature = "metered")]
5444        if let Some(slot) = &self.entitlement {
5445            tenants = slot.counts();
5446        }
5447        Ok(gunnar_grpc::seam::StatsExtras {
5448            tombstoned_repos: tombstones.len() as u64,
5449            tombstoned_bytes: tombstones.iter().map(|row| row.bytes).sum(),
5450            replication_configured: report.configured,
5451            replication_enqueued: report.enqueued,
5452            replication_synced: report.synced,
5453            replication_failed: report.failed,
5454            replication_bus_gaps: report.bus_gaps,
5455            replication_refs_total: report.refs.total,
5456            replication_refs_unmirrored: report.refs.unmirrored,
5457            standbys: report
5458                .standbys
5459                .into_iter()
5460                .map(|row| gunnar_proto::StandbyStatus {
5461                    // The word the console badge prints, decided once here
5462                    // (one implementation) rather than re-derived from the booleans by
5463                    // every surface that draws a standby.
5464                    verdict: row.verdict().word().to_owned(),
5465                    name: row.name,
5466                    remote: row.remote,
5467                    pending: row.pending,
5468                    consecutive_failures: row.consecutive_failures,
5469                    last_error: row.last_error,
5470                    last_success_unix_ms: row.last_success_unix_ms,
5471                    needs_full_resync: row.needs_full_resync,
5472                    stale: row.stale,
5473                })
5474                .collect(),
5475            tenants_paid: tenants.paid,
5476            tenants_grace: tenants.grace,
5477            tenants_suspended: tenants.suspended,
5478        })
5479    }
5480
5481    fn counts(&self) -> ControlResult<ControlCounts> {
5482        let (namespaces, repos) = self.stores.catalog().counts().map_err(internal)?;
5483        let (principals, keys, grants) = self.stores.auth().counts().map_err(internal)?;
5484        Ok(ControlCounts {
5485            namespaces: namespaces as u64,
5486            repos: repos as u64,
5487            principals: principals as u64,
5488            keys: keys as u64,
5489            grants: grants as u64,
5490        })
5491    }
5492
5493    fn verify_chain(
5494        &self,
5495        request: &gunnar_proto::VerifyChainRequest,
5496    ) -> ControlResult<gunnar_proto::VerifyChainResponse> {
5497        // The SAME walk `gunnar verify` runs (one implementation): `crate::verify` is the
5498        // one implementation, and this method is a projection of its `Chain`
5499        // onto the wire. No verdict is computed here.
5500        let target = match request.target.as_str() {
5501            "" => None,
5502            spec => Some(spec),
5503        };
5504        let Some(repo) = &request.repo else {
5505            return Err(ControlError::InvalidArgument(
5506                "name a repository".to_owned(),
5507            ));
5508        };
5509        let rev = match request.rev.as_str() {
5510            "" => "HEAD",
5511            rev => rev,
5512        };
5513        let chain = crate::verify::verify(&self.stores, self, repo, rev, target)
5514            // A walk that cannot START is a refusal with the reason — a missing
5515            // repository, a rev that names nothing, a malformed target. It is
5516            // never an empty chain: four grey rows would render as "merely
5517            // incomplete", which is a pass a console reader would believe.
5518            .map_err(|err| ControlError::FailedPrecondition(format!("{err:#}")))?;
5519        Ok(chain.to_proto())
5520    }
5521
5522    fn blame(
5523        &self,
5524        path: &RepoPath,
5525        rev: &str,
5526        file: &str,
5527    ) -> ControlResult<gunnar_proto::BlameResponse> {
5528        // The SAME walk `gunnar blame` runs (one implementation): `crate::blame::blame` is
5529        // the one implementation, routed here through the store handle the
5530        // running server already holds (`&self.stores`) — never a fresh
5531        // `ServerStores::open`, which would fight the server's exclusive lock.
5532        // That is the whole of it: down the verb takes the lock and cannot
5533        // run while a `serve` holds it; here it runs against the open store.
5534        let rev = if rev.is_empty() { "HEAD" } else { rev };
5535        let rows = crate::blame::blame(&self.stores, self, path, rev, file)
5536            .map_err(|err| ControlError::FailedPrecondition(format!("{err:#}")))?;
5537        Ok(gunnar_proto::BlameResponse {
5538            lines: rows
5539                .into_iter()
5540                .map(|row| gunnar_proto::BlameLine {
5541                    commit: row.commit.to_hex().to_string(),
5542                    author: row.author,
5543                    line: row.line,
5544                })
5545                .collect(),
5546        })
5547    }
5548
5549    fn search(
5550        &self,
5551        path: &RepoPath,
5552        rev: &str,
5553        pattern: &str,
5554        max_file_bytes: u64,
5555        max_matches: u32,
5556    ) -> ControlResult<gunnar_proto::SearchResponse> {
5557        // As `blame` above: one implementation (`crate::search::search`),
5558        // walked live against the running server's open store. A zero ceiling
5559        // means the module's default rather than "no files" / "no matches".
5560        let rev = if rev.is_empty() { "HEAD" } else { rev };
5561        let defaults = crate::search::Limits::default();
5562        let limits = crate::search::Limits {
5563            max_file_bytes: if max_file_bytes == 0 {
5564                defaults.max_file_bytes
5565            } else {
5566                max_file_bytes
5567            },
5568            max_matches: if max_matches == 0 {
5569                defaults.max_matches
5570            } else {
5571                max_matches as usize
5572            },
5573        };
5574        let hits = crate::search::search(&self.stores, self, path, rev, pattern, limits)
5575            .map_err(|err| ControlError::FailedPrecondition(format!("{err:#}")))?;
5576        Ok(gunnar_proto::SearchResponse {
5577            matches: hits
5578                .into_iter()
5579                .map(|hit| gunnar_proto::SearchMatch {
5580                    path: hit.path,
5581                    line_number: hit.line_number as u32,
5582                    line: hit.line,
5583                })
5584                .collect(),
5585        })
5586    }
5587
5588    /// **The PKI document, read** (`Admin.IssuerInfo`) — the ONE
5589    /// implementation, and the same document `gunnar issuer show` prints.
5590    ///
5591    /// Through [`ServerStores::pki_readonly`] and never `pki()`: the handle
5592    /// this opens installs a secret store that refuses every call, so the
5593    /// capability to sign is absent from the value rather than absent from
5594    /// whoever remembered not to call `create` on it. A future edit of this
5595    /// body cannot mint with it.
5596    ///
5597    /// An unreadable document is [`ControlError::Unavailable`] and not an
5598    /// empty answer, for the reason that error exists: "this box has no
5599    /// issuer" and "I could not find out" are different facts, and the first
5600    /// one invites an operator to mint over a CA their whole fleet pins.
5601    fn issuer_info(&self) -> ControlResult<gunnar_proto::IssuerInfoResponse> {
5602        let pki = self.stores.pki_readonly().map_err(|err| {
5603            ControlError::Unavailable(format!("the PKI document could not be opened: {err:#}"))
5604        })?;
5605        // The SAME reader `gunnar issuer show` prints from (one
5606        // implementation): there is no second assembly of this answer, so the
5607        // wire and the disk cannot describe one document differently.
5608        crate::admin::issuer_document(&pki).map_err(|err| {
5609            ControlError::Unavailable(format!("the PKI document could not be read: {err:#}"))
5610        })
5611    }
5612
5613    fn setting(&self, name: &str) -> ControlResult<Option<Vec<u8>>> {
5614        self.stores.settings().get(name).map_err(internal)
5615    }
5616
5617    fn set_setting(&self, name: &str, value: &[u8]) -> ControlResult<Option<Vec<u8>>> {
5618        self.stores.settings().set(name, value).map_err(internal)
5619    }
5620
5621    /// **The audit chain, read as a shape** (`Admin.AuditTrail`, lane T11).
5622    ///
5623    /// Opened fresh from [`ServerStores::audit_path`] rather than reaching for
5624    /// the live `AuditChain` the gate holds: the gate's copy carries an
5625    /// in-memory tip and would answer from it, and the question being asked is
5626    /// about the FILE — whether it still holds what gunnar wrote. So this
5627    /// re-reads and re-verifies, which is also what makes `verified: false`
5628    /// mean something.
5629    ///
5630    /// A chain that will not open is **not** an error: it is `present: false`
5631    /// with `why`, because "this box has no durable trail" is exactly the
5632    /// answer an operator needs from a box whose disk filled or whose trail
5633    /// somebody has edited, and turning it into a failed RPC would hide it.
5634    /// The one thing never done here is to answer an empty chain, which would
5635    /// read as "nothing has happened" instead of "I hold no record".
5636    fn audit_trail(&self, at_seq: u64) -> ControlResult<gunnar_proto::AuditTrailResponse> {
5637        let path = self.stores.audit_path();
5638        // Whether the trail is replicated is a property of this BOX, not of
5639        // the open: a twin and a box mid-restore originate nothing and keep a
5640        // per-box trail, and on a primary this is false only when the server
5641        // already shouted about it at start-up.
5642        let replicated = !self.stores.is_twin()
5643            && !crate::twin::is_restoring(self.stores.root())
5644            && gunnar_core::txlog::sink::log_for(&path)
5645                .is_some_and(|(_, rel)| rel == gunnar_core::txlog::AUDIT_REL);
5646        let mut out = gunnar_proto::AuditTrailResponse {
5647            present: false,
5648            entries: 0,
5649            tip: String::new(),
5650            verified: false,
5651            replicated,
5652            hash_at_seq: String::new(),
5653            why: String::new(),
5654            tip_seq: 0,
5655        };
5656        let chain = match gunnar_core::AuditChain::open(&path) {
5657            Ok(chain) => chain,
5658            Err(err) => {
5659                out.why = format!("{err}");
5660                return Ok(out);
5661            }
5662        };
5663        let entries = match chain.entries() {
5664            Ok(entries) => entries,
5665            Err(err) => {
5666                out.why = format!("{err}");
5667                return Ok(out);
5668            }
5669        };
5670        out.present = true;
5671        out.entries = entries.len() as u64;
5672        if let Some(last) = entries.last() {
5673            out.tip.clone_from(&last.hash);
5674            // Reported, not computed from the count: see the proto comment.
5675            out.tip_seq = last.seq;
5676        }
5677        match chain.verify() {
5678            Ok(_) => out.verified = true,
5679            Err(err) => out.why = format!("{err}"),
5680        }
5681        if at_seq != 0 {
5682            // `seq` is dense from 0, so the entry with this seq is at this
5683            // index — but it is LOOKED UP by `seq` rather than indexed, because
5684            // a chain that has been cut is exactly the case this is asked in
5685            // and an index would answer confidently with the wrong entry.
5686            match entries.iter().find(|e| e.seq == at_seq) {
5687                Some(e) => out.hash_at_seq = e.hash.clone(),
5688                None => {
5689                    let sep = if out.why.is_empty() { "" } else { "; " };
5690                    out.why = format!(
5691                        "{}{sep}this trail holds no entry at seq {at_seq} (it holds {} entries)",
5692                        out.why,
5693                        entries.len()
5694                    );
5695                }
5696            }
5697        }
5698        Ok(out)
5699    }
5700
5701    fn storage_info(&self, path: Option<&RepoPath>) -> ControlResult<StorageInfoResponse> {
5702        let repos = match path {
5703            Some(path) => vec![self.open(path)?.0],
5704            None => self.stores.catalog().list(None).map_err(internal)?,
5705        };
5706        let mut total_hot = 0u64;
5707        let mut total_explode = 0u64;
5708        let mut total_budget = 0u64;
5709        // One registry walk for every open store, rather than one per
5710        // repository: `Admin.StorageInfo` over a server with a thousand
5711        // repositories is already an unbounded recursive `stat` walk
5712        // (`control_plane_starvation`), and adding a per-repository lookup
5713        // inside that loop would make it two.
5714        let bands: std::collections::HashMap<std::path::PathBuf, crate::storage::ExplodeBand> =
5715            self.stores.engine().explode_use().into_iter().collect();
5716        let mut out = Vec::new();
5717        for repo in repos {
5718            let store = self.stores.repo_store(&repo).map_err(|err| {
5719                ControlError::Internal(format!("opening the repository's stores: {err:#}"))
5720            })?;
5721            let (loose, _objects_dir) = object_dir_stats(store.git_dir());
5722            // `hot_bytes` was `object_dir_stats`'s byte count until 2026-09-04
5723            // — a walk of `<git_dir>/objects`, which the znippy engine does not
5724            // have (its store is `<git_dir>/gunnar-git.znippy/`), so every
5725            // appliance repository reported 0 hot bytes. It is now the same
5726            // per-file walk `Usage.Read` bills from (`repo_bytes`, one implementation):
5727            // the whole directory minus LFS, which is what the proto comment
5728            // on `RepoStorage.hot_bytes` always said it was.
5729            let bytes = repo_bytes(store.git_dir()).hot();
5730            let refs = store.serve().refs().map_err(store_error)?.len() as u64;
5731            total_hot += bytes;
5732            // A repository nobody has opened has no band to report, and zero is
5733            // the truth of that: the cache is per open store and an unopened
5734            // one is holding nothing this process can name.
5735            let band = bands.get(store.git_dir()).copied().unwrap_or_default();
5736            total_explode += band.used();
5737            total_budget += band.budget;
5738            out.push(RepoStorage {
5739                repo: Some(RepoPath::new(&repo.namespace, &repo.name)),
5740                hash_kind: hash_kind(repo.hash_kind) as i32,
5741                loose_object_count: loose,
5742                hot_bytes: bytes,
5743                // **Not a measurement.** The znippy engine keeps ONE archive
5744                // per repository (counted in `hot_bytes` with the rest of the
5745                // directory) and has no cold tier; the two fields stay 0 and
5746                // `cold_measured: false` says the 0 is not a reading, so a
5747                // console shows N/A rather than "0 B" (owner, 2026-09-28).
5748                cold_bytes: 0,
5749                cold_object_count: 0,
5750                cold_measured: false,
5751                last_repack_at_unix_ms: 0,
5752                ref_count: refs,
5753                explode_bytes: band.used(),
5754                explode_budget_bytes: band.budget,
5755                explode_evicted_bytes: band.evicted_bytes,
5756            });
5757        }
5758        // The backend strings report what THIS process is running, not a plan
5759        // and not what it could have been built with. There is no "none" arm
5760        // any more: `gunnar-server` refuses to compile without an engine, so
5761        // the engine-free binary that used to answer here cannot be built.
5762        //
5763        // Both strings come off the same `Engine`, because one engine answers
5764        // for objects *and* refs — they were two strings when they were two
5765        // backends, and reporting two different ones now would be a shape the
5766        // storage contract no longer has.
5767        let engine = self.stores.engine();
5768        let (object_backend, ref_backend) = (engine.describe(), engine.describe());
5769        // **Tombstoned bytes are reported here or nowhere.** They are in
5770        // none of the `repos` rows above, because a tombstoned repository has
5771        // no catalog row — which is exactly how the measured 25 GB stayed
5772        // invisible. They are still on the disk and still on the account.
5773        let tombstones = self.stores.tombstones().unwrap_or_default();
5774        let total_tombstoned_bytes = tombstones.iter().map(|row| row.bytes).sum();
5775        let tombstoned = tombstones
5776            .into_iter()
5777            .map(|row| gunnar_proto::TombstonedRepo {
5778                repo: Some(RepoPath {
5779                    namespace: row.path.namespace().to_owned(),
5780                    repo: row.path.name().to_owned(),
5781                }),
5782                bytes: row.bytes,
5783                deleted_at_unix_ms: row.tombstone.deleted_at_unix_ms,
5784                purge_after_unix_ms: row.tombstone.purge_after_unix_ms.unwrap_or_default(),
5785            })
5786            .collect();
5787        Ok(StorageInfoResponse {
5788            object_backend,
5789            ref_backend,
5790            // The same engine, in parts (name, description, source repository).
5791            engine: Some(engine.report()),
5792            total_hot_bytes: total_hot,
5793            // Not a measurement; see `cold_measured` on each row.
5794            total_cold_bytes: 0,
5795            cold_measured: false,
5796            repos: out,
5797            total_explode_bytes: total_explode,
5798            total_explode_budget_bytes: total_budget,
5799            total_tombstoned_bytes,
5800            tombstoned,
5801            // **The storage reality of the machine.** The appliance has no
5802            // shell, so this call is the only way anyone ever sees which disks
5803            // became `/data`, what was rejected and why, or whether the
5804            // metadata mirror is genuinely on another spindle.
5805            reality: Some(self.stores.storage_reality()),
5806        })
5807    }
5808
5809    /// **"If a tenant's cap rose to N bytes, could you serve it?"** —
5810    /// `Admin.Capacity`, the question monetize must ask before it buys a disk.
5811    ///
5812    /// Three measurements, none of them new (one implementation):
5813    ///
5814    /// * the bytes are the disk gate's own `statvfs` — the SAME one a push is
5815    ///   refused under, so the capacity a seller is quoted and the floor a
5816    ///   push dies at cannot disagree;
5817    /// * what the tenant already holds is [`Self::usage`]'s `pack_bytes` —
5818    ///   the meter `caps["pack_bytes"]` caps, so the delta is computed against
5819    ///   the number the quota is actually about;
5820    /// * whether the set can grow is the header band's `/proc/mounts` + md
5821    ///   sysfs read;
5822    /// * the box's cores and RAM — product scope, the ceilings a seller may
5823    ///   sell up to — are [`crate::capacity::MachineFacts::measure`]: the
5824    ///   `available_parallelism` the budgets use and the band's
5825    ///   `/proc/meminfo`, absent (never 0) when unreadable.
5826    ///
5827    /// [`crate::capacity::answer`] decides over them, and is pure so that the
5828    /// SEALED arm can be driven on a developer box where gunnar is never PID 1.
5829    ///
5830    /// **No gate wired is `Unimplemented`, not a default floor.** A plane
5831    /// built without [`Self::with_headroom`] does not know the `--disk-floor`
5832    /// in force, and quoting a seller against a floor this server is not
5833    /// enforcing is worse than declining to answer.
5834    fn capacity(
5835        &self,
5836        request: &gunnar_proto::CapacityRequest,
5837    ) -> ControlResult<gunnar_proto::CapacityResponse> {
5838        let Some(gate) = self.headroom.as_ref() else {
5839            return Err(ControlError::Unimplemented(
5840                "Admin.Capacity (no disk-headroom gate is wired on this control plane, so the \
5841                 --disk-floor in force is unknown and no quote can be made against it)",
5842            ));
5843        };
5844        // A named namespace must EXIST. `usage` is NotFound for one the
5845        // catalog does not hold, and that error is propagated rather than
5846        // swallowed into "it holds nothing": quoting a raise against the wrong
5847        // tenant's zero is how a seller oversells.
5848        let held_bytes = if request.namespace.is_empty() {
5849            0
5850        } else {
5851            self.usage(Some(&request.namespace))?
5852                .first()
5853                .map(|row| row.pack_bytes)
5854                .unwrap_or(0)
5855        };
5856        let root = gate.root().to_path_buf();
5857        let mut answer = crate::capacity::answer(&crate::capacity::Question {
5858            room: gate.headroom().map_err(|err| err.to_string()),
5859            floor_spec: gate.floor().to_string(),
5860            requested_bytes: request.requested_bytes,
5861            held_bytes,
5862            namespace: request.namespace.clone(),
5863            growth: crate::capacity::GrowthFacts::measure(&root),
5864            machine: crate::capacity::MachineFacts::measure(),
5865            root: root.clone(),
5866        });
5867        // Every disk, only when asked: a seller's quote never pays for the
5868        // mount-table walk (`CapacityRequest.disks`).
5869        if request.disks {
5870            let slow = self.stores.slow();
5871            let serialdat = std::path::PathBuf::from(slow.serialdat());
5872            let mut disks = crate::capacity::measure_disks(&root, Some(&serialdat), gate.floor());
5873            // What is on /serialdat, thing by thing — only the paths that
5874            // actually live under it (a directory configured elsewhere is not
5875            // this mount's).
5876            let log = slow.path_of(crate::slow::LOG_DIR);
5877            let known: Vec<(&str, std::path::PathBuf)> = [
5878                ("metadata copy", slow.path_of(crate::slow::META_DIR)),
5879                ("crash record", slow.path_of(crate::slow::CRASH_DIR)),
5880                ("audit trail", log.join("audit-chain.jsonl")),
5881                ("logs", log),
5882                ("grow journal", serialdat.join("grow-journal")),
5883                ("repository mirror", serialdat.join(crate::slow::REPOS_DIR)),
5884            ]
5885            .into_iter()
5886            .filter(|(_, path)| path.starts_with(&serialdat))
5887            .collect();
5888            let used = disks
5889                .mounts
5890                .iter()
5891                .find(|m| m.labels.iter().any(|l| l == "serialdat") && m.measured)
5892                .map(|m| m.used_bytes);
5893            if disks
5894                .mounts
5895                .iter()
5896                .any(|m| m.labels.iter().any(|l| l == "serialdat"))
5897            {
5898                disks.serialdat = crate::capacity::serialdat_breakdown(&known, used);
5899            }
5900            answer.disks = Some(disks);
5901        }
5902        Ok(answer)
5903    }
5904
5905    // ── the runtime data-set growth (`crate::grow`) ─────────────────────
5906
5907    fn grow_start(
5908        &self,
5909        request: &gunnar_proto::GrowStartRequest,
5910    ) -> ControlResult<gunnar_proto::GrowStatusResponse> {
5911        self.grow_machine()?
5912            .start(request)
5913            .map_err(ControlError::FailedPrecondition)
5914    }
5915
5916    fn grow_status(&self) -> ControlResult<gunnar_proto::GrowStatusResponse> {
5917        match &self.grow {
5918            Some(grow) => Ok(grow.status()),
5919            // No machine wired: the seam's honest default, never Unimplemented
5920            // — see the trait's note on why the credential-free status must
5921            // always answer.
5922            None => Ok(gunnar_proto::GrowStatusResponse {
5923                phase: "idle".to_owned(),
5924                engine_present: false,
5925                serving: true,
5926                server_unix_ms: gunnar_grpc::bus::now_unix_ms(),
5927                detail: "this control plane has no runtime growth wired in".to_owned(),
5928                hotplug: crate::dashboard::probe::pci_hotplug().present,
5929                journal: gunnar_grpc::grow_journal::Summary::measure().map(|s| s.to_wire()),
5930                ..Default::default()
5931            }),
5932        }
5933    }
5934
5935    fn grow_resume(&self, give_up: bool) -> ControlResult<gunnar_proto::GrowStatusResponse> {
5936        self.grow_machine()?
5937            .resume(give_up)
5938            .map_err(ControlError::FailedPrecondition)
5939    }
5940
5941    /// **The meters, per namespace.** See `usage.proto` for what each counts
5942    /// and [`repo_bytes`] for the one walk that attributes a directory.
5943    ///
5944    /// Reads the DISK, not the open handles: `Admin.StorageInfo` opens every
5945    /// repository it reports (`repo_store`) to ask for its ref count, which
5946    /// on a server with a thousand repositories is a thousand store opens for
5947    /// one bill. A bill needs bytes, and bytes are on the disk whether or not
5948    /// the store is open — so this walks `repo_dir` and opens nothing. The one
5949    /// meter that IS about open handles (`objects`) reads the engine's live
5950    /// registry, which is a few `len()` calls and no disk.
5951    ///
5952    /// Tombstones are charged to the namespace the tombstone names, off the
5953    /// same walk `Admin.StorageInfo` and `Admin.Stats` use — a deleted
5954    /// repository's bytes are on the account until the purge, and a bill that
5955    /// forgot them would be the one an operator cannot explain.
5956    fn usage(&self, namespace: Option<&str>) -> ControlResult<Vec<gunnar_proto::NamespaceUsage>> {
5957        // A box growing its own data set has /data unmounted for the remount: its repositories'
5958        // sizes are not readable until it is back — a named, retryable refusal the caller waits
5959        // on, never `Internal: … the repository root does not resolve` (round 4 fresh UpCloud
5960        // mockup: a 502 at the console mid-growth). As `log_fetch` says it.
5961        self.usage_readable()?;
5962        // **One namespace: its own repositories and its own tombstones, walked
5963        // now** (2026-10-06). This used to list the WHOLE catalog and walk the
5964        // WHOLE repository root for tombstones on every call, so a poller
5965        // reading N tenants one by one did O(N²) work (load test: ~25–30 s of
5966        // 1.4 cores on main per 300 s at 1,000 tenants). The usage ledger's
5967        // index answers which repositories are the namespace's; the walk is
5968        // the same `repo_bytes`, so the answer is the same exact reading.
5969        if let Some(name) = namespace {
5970            let measured_at_unix_ms = gunnar_core::now_ms();
5971            let Some((held, _)) = self
5972                .stores
5973                .usage_ledger()
5974                .exact(&self.stores, name)
5975                .map_err(internal)?
5976            else {
5977                return Err(ControlError::NotFound(format!(
5978                    "namespace {name} is not in the catalog"
5979                )));
5980            };
5981            let objects = self.open_objects_of(name);
5982            return Ok(vec![self.usage_row(
5983                name,
5984                &held,
5985                objects,
5986                measured_at_unix_ms,
5987            )]);
5988        }
5989        self.usage_all()
5990    }
5991
5992    /// **One namespace's repositories with what each stores** — the same
5993    /// per-repository walk [`Self::usage`] sums, row by row (see the seam).
5994    fn repo_usage(&self, namespace: &str) -> ControlResult<Vec<gunnar_proto::RepoStored>> {
5995        let Some((_, repos)) = self
5996            .stores
5997            .usage_ledger()
5998            .exact(&self.stores, namespace)
5999            .map_err(internal)?
6000        else {
6001            return Err(ControlError::NotFound(format!(
6002                "namespace {namespace} is not in the catalog"
6003            )));
6004        };
6005        let open_objects: std::collections::HashMap<std::path::PathBuf, u64> = self
6006            .stores
6007            .engine()
6008            .open_object_counts()
6009            .into_iter()
6010            .collect();
6011        // Sorted by repository name: the ledger keeps them in a BTreeMap.
6012        Ok(repos
6013            .into_iter()
6014            .map(|(name, bytes, dir)| gunnar_proto::RepoStored {
6015                repo: Some(gunnar_proto::RepoPath::new(namespace, &name)),
6016                stored_bytes: bytes.pack,
6017                cache_bytes: bytes.cache,
6018                lfs_bytes: bytes.lfs,
6019                objects: dir.and_then(|d| open_objects.get(&d).copied()).unwrap_or(0),
6020            })
6021            .collect())
6022    }
6023
6024    /// **One page of every namespace's meters, out of the usage ledger**
6025    /// (`UsageBatch.ReadMany`, 2026-10-06) — re-measuring only what changed
6026    /// since the ledger last looked, and with a cursor answering only the
6027    /// namespaces whose row moved. See `crate::usage_ledger`.
6028    fn usage_page(
6029        &self,
6030        page_token: &str,
6031        page_size: usize,
6032        changed_since: &str,
6033    ) -> ControlResult<gunnar_proto::ReadManyUsageResponse> {
6034        self.usage_readable()?;
6035        let now = gunnar_core::now_ms();
6036        let ledger = self.stores.usage_ledger();
6037        let (cursor, after) = gunnar_grpc::seam::usage_page_token::decode(page_token);
6038        // The cursor is taken BEFORE the settle, on the first page, and carried
6039        // in the token by every later one: a change during the read is answered
6040        // again next time rather than lost.
6041        let cursor = if cursor.is_empty() {
6042            ledger.cursor_now(now)
6043        } else {
6044            cursor
6045        };
6046        let (rows, more, changed_only) = ledger
6047            .page(&self.stores, &after, page_size, changed_since, now)
6048            .map_err(internal)?;
6049        let next_page_token = match (more, rows.last()) {
6050            (true, Some((last, _))) => gunnar_grpc::seam::usage_page_token::encode(&cursor, last),
6051            _ => String::new(),
6052        };
6053        let usages = rows
6054            .iter()
6055            .map(|(name, held)| self.usage_row(name, held, held.objects, now))
6056            .collect();
6057        Ok(gunnar_proto::ReadManyUsageResponse {
6058            usages,
6059            next_page_token,
6060            cursor,
6061            changed_only,
6062            as_of_unix_ms: now,
6063        })
6064    }
6065
6066    /// **The flow series** (`Usage.Activity`) — see the seam for the contract.
6067    ///
6068    /// The window is resolved HERE and not in the handler, because this is the
6069    /// half that knows what this server keeps: `0` for `since` means the whole
6070    /// kept window, `0` for `until` means now, and both ends are clamped to
6071    /// whole UTC days. A window that runs backwards returns nothing rather than
6072    /// an error — it is an empty question, and an empty answer is what it
6073    /// deserves.
6074    ///
6075    /// A named namespace must EXIST, for [`ControlPlane::usage`]'s reason:
6076    /// answering "no activity" for a namespace that is not in the catalog would
6077    /// let a typo read as a silent tenant, which is the one conclusion this
6078    /// whole ledger exists to make reliable.
6079    ///
6080    /// **Today is flushed before it is read.** The live slot holds today, the
6081    /// store holds a copy taken at the last tick, and a series that read only
6082    /// the store would end a minute in the past. Rather than fold the live slot
6083    /// into the answer — a second arithmetic on top of the flush's — the flush
6084    /// is simply run, which is a copy and cannot double a count.
6085    fn activity(
6086        &self,
6087        namespace: &str,
6088        since_unix_ms: i64,
6089        until_unix_ms: i64,
6090    ) -> ControlResult<(Vec<gunnar_proto::DayActivity>, i64)> {
6091        if self
6092            .stores
6093            .catalog()
6094            .namespace(namespace)
6095            .map_err(internal)?
6096            .is_none()
6097        {
6098            return Err(ControlError::NotFound(format!(
6099                "namespace {namespace} is not in the catalog"
6100            )));
6101        }
6102        let now = gunnar_core::now_ms();
6103        let flow = self.stores.flow();
6104        if let Err(err) = flow.flush(now) {
6105            // Not fatal: the store still holds everything up to the last tick,
6106            // and a read that refused because a write failed would be worse
6107            // than a read that is a minute stale and says nothing about it.
6108            tracing::warn!(
6109                target: "gunnar::store",
6110                event = "flow.flush_failed",
6111                %err,
6112                "could not flush the flow ledger before an Activity read; the series is as of \
6113                 the last successful flush"
6114            );
6115        }
6116        let today = gunnar_core::day_of(now);
6117        let from = if since_unix_ms == 0 {
6118            today - gunnar_core::RETENTION_DAYS
6119        } else {
6120            gunnar_core::day_of(since_unix_ms)
6121        }
6122        .max(today - gunnar_core::RETENTION_DAYS);
6123        let to = if until_unix_ms == 0 {
6124            today
6125        } else {
6126            gunnar_core::day_of(until_unix_ms)
6127        }
6128        .min(today);
6129        if from > to {
6130            return Ok((Vec::new(), gunnar_core::RETENTION_DAYS));
6131        }
6132        let days = flow
6133            .store()
6134            .series(namespace, from, to)
6135            .map_err(internal)?
6136            .into_iter()
6137            .map(|(day, row)| day_activity(day, &row))
6138            .collect();
6139        Ok((days, gunnar_core::RETENTION_DAYS))
6140    }
6141
6142    /// **The admission plan** (`Usage.AdmitPlan`) — see the seam for the
6143    /// contract. A read for the admin view and the tenant, never on the
6144    /// admission path: the policy is asked exactly as the gate asks it, with
6145    /// the usage the gate would plan with, once per kind of work this server
6146    /// admits (`request` stands for ls-remote and gc).
6147    fn admit_plan(&self, namespace: &str) -> ControlResult<gunnar_proto::AdmitPlanResponse> {
6148        use gunnar_ssh::edda_admit::{Consumer, Work};
6149        use gunnar_ssh::governor::OpClass;
6150        if self
6151            .stores
6152            .catalog()
6153            .namespace(namespace)
6154            .map_err(internal)?
6155            .is_none()
6156        {
6157            return Err(ControlError::NotFound(format!(
6158                "namespace {namespace} is not in the catalog"
6159            )));
6160        }
6161        let Some(gate) = &self.admission else {
6162            return Err(ControlError::Unimplemented("Usage.AdmitPlan"));
6163        };
6164        let policy = gate.admit_policy();
6165        let usage = gate.admit_usage(&*policy, namespace);
6166        let limit = policy.limit();
6167        let who = Consumer::Namespace(namespace.to_owned());
6168        let estimates = policy.estimates();
6169        let kinds = [
6170            OpClass::Clone,
6171            OpClass::Fetch,
6172            OpClass::Push,
6173            OpClass::LsRemote,
6174        ]
6175        .into_iter()
6176        .map(|class| {
6177            let kind = gunnar_ssh::admission::work_kind(class);
6178            let plan = policy.plan(&Work::new(kind, None), &who, &usage);
6179            gunnar_proto::AdmitPlanKind {
6180                kind: kind.name().to_owned(),
6181                admit_class: plan.class.name().to_owned(),
6182                why: plan.why,
6183                estimate_bytes: plan.estimate.usage_bytes(),
6184                estimate_cpu_ms: plan.estimate.cpu_ms,
6185                samples: estimates
6186                    .iter()
6187                    .find(|row| row.kind == kind)
6188                    .map_or(0, |row| row.samples),
6189            }
6190        })
6191        .collect();
6192        Ok(gunnar_proto::AdmitPlanResponse {
6193            policy: policy.policy().to_owned(),
6194            window_days: usage.window_days,
6195            used_bytes: usage.bytes,
6196            limit_bytes: limit.map(|l| l.bytes),
6197            budget_left_bytes: limit.map(|l| l.bytes.saturating_sub(usage.bytes)),
6198            kinds,
6199        })
6200    }
6201
6202    // ── the metered appliance: `Entitlement` ────────────────────────────────
6203
6204    #[cfg(feature = "metered")]
6205    fn entitlement_get(
6206        &self,
6207        namespace: &str,
6208    ) -> ControlResult<Option<gunnar_proto::EntitlementFact>> {
6209        self.entitlement()?
6210            .get(namespace)
6211            .map_err(|err| ControlError::Internal(format!("{err:#}")))
6212    }
6213
6214    #[cfg(feature = "metered")]
6215    fn entitlement_list(&self) -> ControlResult<Vec<gunnar_proto::EntitlementFact>> {
6216        self.entitlement()?
6217            .list()
6218            .map_err(|err| ControlError::Internal(format!("{err:#}")))
6219    }
6220
6221    #[cfg(feature = "metered")]
6222    fn entitlement_refused_at_boot(&self) -> ControlResult<Option<gunnar_proto::RefusedAtBoot>> {
6223        Ok(self.entitlement()?.refusal())
6224    }
6225
6226    /// The refusals, by kind: a forged fact is `PermissionDenied` (the caller
6227    /// held the admin grant and still may not move a plan without monetize's
6228    /// signature), a malformed one `InvalidArgument`, and one this box could
6229    /// not record `FailedPrecondition` — verified, and still not enforced,
6230    /// because a plan the log does not show is not a plan.
6231    #[cfg(feature = "metered")]
6232    fn entitlement_set(
6233        &self,
6234        fact: gunnar_proto::EntitlementFact,
6235    ) -> ControlResult<(gunnar_proto::EntitlementState, u64)> {
6236        use crate::entitlement::SetRefused;
6237        let (state, seq) = self.entitlement()?.set(fact).map_err(|err| match err {
6238            SetRefused::Forged { .. } => ControlError::PermissionDenied(err.to_string()),
6239            // **`PermissionDenied` and not `FailedPrecondition`, deliberately.**
6240            // monetize's poller treats a refusal as final and stops retrying
6241            // that fact, and that is the right answer here: a fact this box has
6242            // moved past will never become acceptable, so retrying it for ever
6243            // would be a hot loop against a decision that is not going to
6244            // change. It is the same code as a forged fact because it is the
6245            // same sentence — this caller may not move this plan with this
6246            // fact — and the words say which of the two it was.
6247            SetRefused::Replayed { .. }
6248            | SetRefused::Unstamped { .. }
6249            | SetRefused::Behind { .. } => ControlError::PermissionDenied(err.to_string()),
6250            SetRefused::Malformed(_) => ControlError::InvalidArgument(err.to_string()),
6251            SetRefused::NotRecorded { .. } => ControlError::FailedPrecondition(err.to_string()),
6252            SetRefused::NoKey { .. } => ControlError::FailedPrecondition(err.to_string()),
6253        })?;
6254        Ok((crate::entitlement::state_to_proto(state), seq))
6255    }
6256
6257    // ── the metered appliance: `Billing` ────────────────────────────────────
6258    //
6259    // The key is read out of the sealed secret store on every call rather than
6260    // cached on this struct. Both verbs are human-paced — one is an operator
6261    // reading a string to paste into a configuration, the other is a customer
6262    // clicking renew — so a redb read and an OpenSSH parse are free at this
6263    // rate, and a cached key is a copy that has to be invalidated the day the
6264    // key is rolled. `ServerStores::actor_ticket_key` mints on first use, so
6265    // the FIRST of these calls on a fresh appliance is what brings the key into
6266    // existence; nothing has to run at boot for this to work.
6267
6268    #[cfg(feature = "metered")]
6269    fn actor_ticket_public_key_hex(&self) -> ControlResult<String> {
6270        Ok(self
6271            .stores
6272            .actor_ticket_key()
6273            .map_err(|err| ControlError::Internal(format!("{err:#}")))?
6274            .public_key_hex()
6275            .to_owned())
6276    }
6277
6278    #[cfg(feature = "metered")]
6279    fn actor_ticket_sign(&self, message: &[u8]) -> ControlResult<[u8; 64]> {
6280        Ok(self
6281            .stores
6282            .actor_ticket_key()
6283            .map_err(|err| ControlError::Internal(format!("{err:#}")))?
6284            .sign(message))
6285    }
6286
6287    // The named sealed slot (2026-10-09): stored through the sealed, logged
6288    // secret store; a twin or a box being restored refuses the write as every
6289    // logged write is refused there.
6290    #[cfg(feature = "metered")]
6291    fn sealed_get(&self, name: &str) -> ControlResult<Option<(String, Vec<u8>)>> {
6292        self.stores
6293            .sealed_slot_get(name)
6294            .map_err(|err| ControlError::Internal(format!("{err:#}")))
6295    }
6296
6297    #[cfg(feature = "metered")]
6298    fn sealed_put(&self, name: &str, owner: &str, value: &[u8]) -> ControlResult<bool> {
6299        self.stores
6300            .sealed_slot_put(name, owner, value)
6301            .map_err(|err| ControlError::Unavailable(format!("{err:#}")))
6302    }
6303
6304    // ── mirror ─────────────────────────────────────────────────────────────
6305
6306    /// **Register an upstream.** Bookkeeping only — no fetch happens here.
6307    ///
6308    /// `create_repo` is honoured before the registration, because a mirror of a
6309    /// repository that does not exist is a registration nothing can ever act
6310    /// on. Without the flag the repository must already be there, and a missing
6311    /// one is `NotFound` rather than a silently created empty one.
6312    fn mirror_add(
6313        &self,
6314        path: &RepoPath,
6315        remote_url: &str,
6316        prune: bool,
6317        create_repo: bool,
6318    ) -> ControlResult<MirrorConfig> {
6319        if remote_url.trim().is_empty() {
6320            return Err(ControlError::InvalidArgument(
6321                "a mirror needs a remote URL".to_owned(),
6322            ));
6323        }
6324        // **The destination, before it is written down.** `Mirror.Add` reaches
6325        // every enrolled principal — `owns_account` makes a caller intrinsically
6326        // Admin in its own namespace — so without this the verb is an
6327        // arbitrary outbound request from the server's network position,
6328        // authorised by having a key. See `mirror_fetch::check_destination`.
6329        // Refused at REGISTRATION rather than only on the first sync, so the
6330        // operator's mistake and the attacker's are both answered on the call
6331        // that made them.
6332        crate::mirror_fetch::classify(remote_url).map_err(ControlError::InvalidArgument)?;
6333        crate::mirror_fetch::check_destination(remote_url, self.stores.root())
6334            .map_err(ControlError::InvalidArgument)?;
6335        let repo_ref = Self::repo_ref(path)?;
6336        // **The repository is NOT created here** (owner order 2026-10-18).
6337        // Its object format and its default branch are the upstream's, and
6338        // only the upstream can say them: an ssh upstream is never asked here,
6339        // and a private one answers nothing before its credential is set — so
6340        // creating it now meant a guess (sha256, `main`) that a sha1 upstream
6341        // could never be fetched into, and an empty private repository left
6342        // behind by every import that failed. The registration says the
6343        // repository is to come, and the first sync that reaches the upstream
6344        // creates it (`mirror_fetch::sync_once`).
6345        let creates = create_repo && self.get_repo(path).is_err();
6346        if creates {
6347            // The disk, refused now by the gate `Repos.Create` asks, rather
6348            // than on a job minutes later.
6349            if let Some(line) = self.headroom.as_ref().and_then(|gate| gate.refusal()) {
6350                return Err(ControlError::ResourceExhausted(line));
6351            }
6352            if self
6353                .stores
6354                .catalog()
6355                .namespace(path.namespace.as_str())
6356                .map_err(internal)?
6357                .is_none()
6358            {
6359                return Err(ControlError::NotFound(format!(
6360                    "namespace {}",
6361                    path.namespace
6362                )));
6363            }
6364        } else {
6365            // Opened rather than merely catalogued: a registration against a
6366            // repository whose store cannot be opened would look fine and fail
6367            // on the first sync.
6368            self.open(path)?;
6369        }
6370        let mut record = gunnar_core::MirrorRecord::new(
6371            &repo_ref,
6372            remote_url,
6373            prune,
6374            gunnar_grpc::bus::now_unix_ms(),
6375        );
6376        record.creates_repo = creates;
6377        let stored = self
6378            .stores
6379            .mirrors()
6380            .set(&repo_ref, record)
6381            .map_err(internal)?;
6382        Ok(mirror_out(&stored))
6383    }
6384
6385    /// **The one verb that genuinely needs the client half**, and the only one
6386    /// still refused.
6387    ///
6388    /// The original refusal covered all four: *"mirroring runs over
6389    /// gunnar-client (nornir-git ls-remote + fetch), which an S crate must
6390    /// never link (the dependency budget)"*. That is true of a sync and of nothing else — adding,
6391    /// listing and removing a registration are a document write, a document
6392    /// read and a document delete. Narrowing the gap to the verb that has it is
6393    /// the difference between "mirroring is unimplemented" and "this build
6394    /// cannot fetch".
6395    /// **Enqueue one sync, and return without syncing.**
6396    ///
6397    /// The refusal this replaced covered all four verbs and then just this
6398    /// one: *"mirroring runs over gunnar-client (nornir-git ls-remote + fetch),
6399    /// which an S crate must never link (the dependency budget)"*. That was true while the only
6400    /// fetch client in the constellation needed `gix`, a `.git` directory and a
6401    /// worktree — none of which exist on an appliance running as PID 1 over
6402    /// znippy. `gunnar_wire::fetch_pack` is a fetch client with none of those
6403    /// requirements, over the same `BufRead`/`Write` pair `upload_pack`
6404    /// already answers on, so the gap is closed rather than narrowed again.
6405    ///
6406    /// **Registration first**, so an unregistered repository is `NotFound` by
6407    /// name and not a job that fails four minutes later — two very different
6408    /// things to tell an operator, and only one of them is their mistake.
6409    ///
6410    /// The upstream and `prune` are read HERE and carried on the order, because
6411    /// they are what the caller asked about; the credential is read on the
6412    /// worker (see [`CoreControlPlane::mirror_queue`]) because it is a secret
6413    /// and an order is `Debug`.
6414    fn mirror_sync(&self, path: &RepoPath) -> ControlResult<MirrorConfig> {
6415        let record = self.mirror_record(path)?;
6416        let repo_ref = Self::repo_ref(path)?;
6417        // Refused at the DOOR rather than on the queue: a remote gunnar cannot
6418        // dial is the caller's mistake and it is knowable now, and a job that
6419        // sat QUEUED and then failed on the shape of a URL is a console button
6420        // that went down for nothing.
6421        crate::mirror_fetch::classify(&record.remote_url).map_err(ControlError::InvalidArgument)?;
6422        // Same door, same reason: a registration that predates the destination
6423        // rule must not become a dial by being clicked.
6424        crate::mirror_fetch::check_destination(&record.remote_url, self.stores.root())
6425            .map_err(ControlError::InvalidArgument)?;
6426        let job = self.mirror.enqueue(crate::mirror_queue::MirrorOrder {
6427            path: repo_ref.key(),
6428            upstream: record.remote_url.clone(),
6429            prune: record.prune,
6430            ca: mirror_ca_of(&record),
6431        });
6432        let mut out = mirror_out(&record);
6433        out.job = Some(mirror_job_out(path, job));
6434        Ok(out)
6435    }
6436
6437    /// **Seal the credential first, then record its kind.**
6438    ///
6439    /// That order is the correctness argument. A document that claims a
6440    /// credential the secret store does not hold makes every later sync fail
6441    /// with "no credential stored" against an operator who can see one listed;
6442    /// a sealed value with no document pointing at it is unreachable and
6443    /// harmless. So the value is written before the fact, and forgotten after
6444    /// it.
6445    ///
6446    /// The value never comes back out of this function. `mirror_out` has no
6447    /// field for it, `MirrorConfig` has no field for it, and the two error
6448    /// paths below quote the repository and the kind and nothing else.
6449    fn mirror_set_credential(
6450        &self,
6451        path: &RepoPath,
6452        kind: gunnar_proto::MirrorCredentialKind,
6453        secret: &str,
6454        username: &str,
6455    ) -> ControlResult<MirrorConfig> {
6456        gunnar_grpc::seam::credential_arguments(kind, secret)?;
6457        self.mirror_record(path)?;
6458        let repo_ref = Self::repo_ref(path)?;
6459
6460        let kind = credential_kind_in(kind);
6461        let secrets = self.stores.secrets().map_err(|err| {
6462            ControlError::Internal(format!("the sealed secret store is not available: {err:#}"))
6463        })?;
6464        let key = gunnar_core::credential_secret_key(&repo_ref);
6465        let stored = if kind.is_set() {
6466            gunnar_store::SecretStore::put(&secrets, &key, secret.as_bytes())
6467        } else {
6468            gunnar_store::SecretStore::delete(&secrets, &key)
6469        };
6470        stored.map_err(|err| {
6471            // `{err}` and not the value: this string reaches a client.
6472            ControlError::Internal(format!(
6473                "the mirror credential for {path} could not be sealed: {err}"
6474            ))
6475        })?;
6476
6477        let record = self
6478            .stores
6479            .mirrors()
6480            .set_credential(&repo_ref, kind, username)
6481            .map_err(internal)?
6482            .ok_or_else(|| ControlError::NotFound(format!("no mirror registered for {path}")))?;
6483        Ok(mirror_out(&record))
6484    }
6485
6486    fn mirror_set_ca(&self, path: &RepoPath, ca_pem: &str) -> ControlResult<MirrorConfig> {
6487        self.mirror_record(path)?;
6488        let repo_ref = Self::repo_ref(path)?;
6489        let ca = if ca_pem.trim().is_empty() {
6490            None
6491        } else {
6492            // Judged by the IdP CA's rules (one implementation): PEM, CERTIFICATE blocks
6493            // only, every one a CA valid now; a key or a server's own certificate is
6494            // refused by name and never kept.
6495            let ders = gunnar_grpc::sso::idp_ca::parse_ca(
6496                ca_pem,
6497                gunnar_grpc::bus::now_unix_ms(),
6498                "the mirror's CA",
6499                "the upstream",
6500            )
6501            .map_err(ControlError::InvalidArgument)?;
6502            let (subject, fingerprint) = gunnar_grpc::sso::idp_ca::ca_shown(&ders[0]);
6503            Some((
6504                gunnar_grpc::sso::idp_ca::idp_ca_pem(&ders),
6505                subject,
6506                fingerprint,
6507            ))
6508        };
6509        let record = self
6510            .stores
6511            .mirrors()
6512            .set_ca(&repo_ref, ca)
6513            .map_err(internal)?
6514            .ok_or_else(|| ControlError::NotFound(format!("no mirror registered for {path}")))?;
6515        Ok(mirror_out(&record))
6516    }
6517
6518    fn mirror_status(
6519        &self,
6520        namespace: Option<&str>,
6521        path: Option<&RepoPath>,
6522    ) -> ControlResult<Vec<MirrorConfig>> {
6523        let all = self.stores.mirrors().all().map_err(internal)?;
6524        Ok(all
6525            .iter()
6526            .filter(|record| match path {
6527                Some(want) => record.repo == format!("{}/{}", want.namespace, want.repo),
6528                None => true,
6529            })
6530            .filter(|record| match namespace {
6531                Some(want) => record
6532                    .repo
6533                    .split_once('/')
6534                    .is_some_and(|(ns, _)| ns == want),
6535                None => true,
6536            })
6537            .map(|record| {
6538                let mut out = mirror_out(record);
6539                // **The job belongs on the listing**, which is what makes
6540                // `Mirror.Status` the poll a console uses: state, queue
6541                // position, progress, error and report, in the `GcStatus`
6542                // shape. A separate `MirrorSyncStatus` verb would have been a
6543                // second door onto one fact.
6544                out.job = out
6545                    .repo
6546                    .as_ref()
6547                    .zip(self.mirror.status(&record.repo))
6548                    .map(|(path, job)| mirror_job_out(path, job));
6549                // **A failure outlives its job** (import-big, 2026-10-26). The
6550                // queue forgets a job at a restart and after its retention, and
6551                // the registration's `last_error` does not: when the queue holds
6552                // nothing, the last failure is shown as the FAILED job it was,
6553                // so the repository page and the mirrors list say why an import
6554                // is empty instead of "idle".
6555                if out.job.is_none() && !record.last_error.is_empty() {
6556                    out.job = out.repo.clone().map(|path| gunnar_proto::MirrorJob {
6557                        job_id: String::new(),
6558                        path: Some(path),
6559                        state: gunnar_proto::MirrorJobState::Failed as i32,
6560                        error: record.last_error.clone(),
6561                        ..Default::default()
6562                    });
6563                }
6564                out
6565            })
6566            .collect())
6567    }
6568
6569    /// **Un-register an upstream, and forget its credential with it.**
6570    ///
6571    /// The registration goes first and the sealed value second, and the order
6572    /// is the correctness argument — the mirror image of
6573    /// [`Self::mirror_set_credential`]'s. That function writes the value before
6574    /// the document so a document can never claim a credential the store does
6575    /// not hold; this one removes the document before the value for the same
6576    /// reason read backwards. A sealed secret with no document pointing at it
6577    /// is unreachable and harmless; a document with no secret makes every later
6578    /// sync fail with "no credential stored" against an operator who can see
6579    /// one listed. So the failure mode of a crash between the two lines is the
6580    /// harmless one, and the `NotFound` path deletes nothing.
6581    ///
6582    /// Before 2026-09-11 the value was simply left behind: `Mirror.Remove` took
6583    /// the document away and the ssh private key or forge token stayed in the
6584    /// sealed store for the life of the box, with nothing on the control plane
6585    /// naming it or able to name it — un-listable, un-rotatable, and still
6586    /// decryptable by anything that reaches the KEK. An operator who removes a
6587    /// mirror because its credential leaked has been told the credential is
6588    /// gone.
6589    fn mirror_remove(&self, path: &RepoPath) -> ControlResult<()> {
6590        self.mirror_record(path)?;
6591        let repo_ref = Self::repo_ref(path)?;
6592        if !self.stores.mirrors().remove(&repo_ref).map_err(internal)? {
6593            return Err(ControlError::NotFound(format!(
6594                "no mirror registered for {path}"
6595            )));
6596        }
6597        let secrets = self.stores.secrets().map_err(|err| {
6598            ControlError::Internal(format!("the sealed secret store is not available: {err:#}"))
6599        })?;
6600        gunnar_store::SecretStore::delete(&secrets, &gunnar_core::credential_secret_key(&repo_ref))
6601            .map_err(|err| {
6602                // `{err}` and not the value: this string reaches a client. The
6603                // registration IS gone by now, so this says exactly that rather
6604                // than reading as "nothing happened".
6605                ControlError::Internal(format!(
6606                    "the registration for {path} was removed and its sealed credential could \
6607                     not be deleted: {err}. The secret is still on this box; remove it before \
6608                     trusting that it is gone."
6609                ))
6610            })?;
6611        Ok(())
6612    }
6613
6614    // ── replication: the three verbs the appliance is configured with ──────
6615
6616    #[cfg(feature = "replication")]
6617    fn replication_set(
6618        &self,
6619        target: gunnar_proto::ReplicationTarget,
6620    ) -> ControlResult<gunnar_proto::ReplicationStatusResponse> {
6621        let slot = self.replication_slot()?;
6622        let mut target = crate::replication_wire::target_from_proto(target)?;
6623        // **The key the appliance pushes with, minted ON THE BOX.**
6624        //
6625        // An operator who names no identity is not asking for the client key
6626        // ladder — on an image with no home directory that ladder finds nothing
6627        // and every push fails on authentication forever. They are asking for
6628        // "use this box's own key", and there was no way to put one there. So
6629        // the server mints one, keeps the private half, and hands back the
6630        // public line for them to authorise on the standby. See
6631        // `replication_wire::REPLICATION_KEY`.
6632        let minted_public = if target.identity.is_none() {
6633            let (path, public) = crate::replication_wire::ensure_identity(self.stores.root())?;
6634            target.identity = Some(path);
6635            Some(public)
6636        } else {
6637            None
6638        };
6639        // The public-only requirements that depend on the key, now that there
6640        // IS one. Checked here rather than in `target_from_proto` because that
6641        // runs before the mint — and refusing a target for the absence of a key
6642        // this function is about to create is how the browse replica's own
6643        // documented one-liner became unrunnable on an appliance.
6644        crate::replication_wire::require_public_only_identity(&target)?;
6645        let mut config = slot
6646            .config()
6647            .map_err(|err| ControlError::Internal(format!("{err:#}")))?;
6648        // `set_target` REPLACES by name. That is what makes the verb
6649        // idempotent, and idempotent is what an operator on a flaky link needs:
6650        // a retried `SetReplication` must not leave two standbys behind.
6651        config.set_target(target);
6652        slot.apply(config)
6653            .map_err(|err| ControlError::Internal(format!("{err:#}")))?;
6654        let mut out = self.replication_snapshot(false)?;
6655        out.identity_public_key = minted_public.unwrap_or_default();
6656        Ok(out)
6657    }
6658
6659    #[cfg(feature = "replication")]
6660    fn replication_remove(
6661        &self,
6662        name: &str,
6663    ) -> ControlResult<gunnar_proto::ReplicationStatusResponse> {
6664        let slot = self.replication_slot()?;
6665        let mut config = slot
6666            .config()
6667            .map_err(|err| ControlError::Internal(format!("{err:#}")))?;
6668        if !config.remove_target(name) {
6669            // NotFound and not a silent success. "I removed the standby" about a
6670            // standby that was never there reads identically to "I removed the
6671            // standby" about the one you meant, and the difference is whether
6672            // your data is still going somewhere.
6673            return Err(ControlError::NotFound(format!(
6674                "no replication target named {name:?}"
6675            )));
6676        }
6677        slot.apply(config)
6678            .map_err(|err| ControlError::Internal(format!("{err:#}")))?;
6679        self.replication_snapshot(false)
6680    }
6681
6682    // ── The transaction log: what the twin fetches ──────────────────────────
6683
6684    fn is_group_member(&self, principal_id: &str, group: &str) -> ControlResult<bool> {
6685        self.stores
6686            .auth()
6687            .groups_of(principal_id)
6688            .map(|groups| groups.iter().any(|g| g == group))
6689            .map_err(|err| ControlError::Unavailable(format!("reading group membership: {err}")))
6690    }
6691
6692    fn become_twin(&self, primary: &str) -> ControlResult<String> {
6693        let key = crate::twin::become_twin(&self.stores, primary)
6694            .map_err(|err| ControlError::FailedPrecondition(format!("{err:#}")))?;
6695        // Following starts now, not at the next boot: the key is enrolled on
6696        // the primary next, and the loop retries until it is.
6697        crate::twin::start_follower(Arc::clone(&self.stores));
6698        Ok(key)
6699    }
6700
6701    #[cfg(feature = "wg-client")]
6702    fn assign_tunnel_address(
6703        &self,
6704        address: &str,
6705    ) -> ControlResult<gunnar_proto::AssignTunnelAddressResponse> {
6706        let window_open = self.stores.auth().bootstrap_window_open().map_err(|err| {
6707            ControlError::Unavailable(format!("reading the bootstrap window: {err}"))
6708        })?;
6709        crate::appliance::tunnel_address_answer(self.stores.root(), address, window_open)
6710    }
6711
6712    #[cfg(feature = "wg-client")]
6713    fn set_tunnel_address(
6714        &self,
6715        address: &str,
6716    ) -> ControlResult<gunnar_proto::AssignTunnelAddressResponse> {
6717        // `Admin.SetTunnelAddress`: the service already held the caller to
6718        // server-wide Admin, which is what the open window stands in for on
6719        // the unauthenticated door.
6720        crate::appliance::tunnel_address_answer(self.stores.root(), address, true)
6721    }
6722
6723    fn twin_csr(&self) -> ControlResult<String> {
6724        crate::twin_tls::request(self.stores.root())
6725            .map_err(|err| ControlError::FailedPrecondition(format!("{err:#}")))
6726    }
6727
6728    fn install_twin_certificate(
6729        &self,
6730        cert_pem: &str,
6731        ca_pem: &str,
6732        replication_port: u16,
6733        replication_host: &str,
6734    ) -> ControlResult<i64> {
6735        let host = (!replication_host.is_empty()).then_some(replication_host);
6736        crate::twin_tls::install_at(self.stores.root(), cert_pem, ca_pem, replication_port, host)
6737            .map(|expiry| expiry.days_left)
6738            .map_err(|err| ControlError::FailedPrecondition(format!("{err:#}")))
6739    }
6740
6741    fn issue_twin_certificate(
6742        &self,
6743        principal: &str,
6744        csr_pem: &str,
6745    ) -> ControlResult<(String, String, u16, String)> {
6746        crate::twin_tls::issue_for_twin(&self.stores, principal, csr_pem)
6747            .map(|(cert, ca, port)| {
6748                (
6749                    cert,
6750                    ca,
6751                    port,
6752                    crate::twin_tls::replication_host(self.stores.root()).unwrap_or_default(),
6753                )
6754            })
6755            .map_err(|err| ControlError::FailedPrecondition(format!("{err:#}")))
6756    }
6757
6758    fn twin_git_listener_needs_restart(&self) -> bool {
6759        crate::twin::git_listener_needs_restart(&self.stores)
6760    }
6761
6762    fn restore_begin(&self, owner: &str) -> ControlResult<u64> {
6763        crate::twin::restore_begin_by(&self.stores, owner).map_err(|err| {
6764            // Somebody else's restore is a NO to a known caller, not a box in the wrong state.
6765            if err.downcast_ref::<crate::twin::RestoreNotYours>().is_some() {
6766                ControlError::PermissionDenied(format!("{err:#}"))
6767            } else {
6768                ControlError::FailedPrecondition(format!("{err:#}"))
6769            }
6770        })
6771    }
6772
6773    /// The keys handed in by `serve` (`--restore-pubkey`), then the admin-grade keys the
6774    /// installer's door fed this box ([`crate::fed_principals::restore_keys_at`]). Read on
6775    /// every call: a feed that arrives while the box runs counts from the next stream on.
6776    fn restore_keys(&self) -> ControlResult<Vec<String>> {
6777        #[allow(unused_mut)]
6778        let mut keys = self.restore_keys.clone();
6779        #[cfg(feature = "wg-client")]
6780        keys.extend(
6781            crate::fed_principals::restore_keys_at(self.stores.root())
6782                .map_err(ControlError::Unavailable)?,
6783        );
6784        keys.sort();
6785        keys.dedup();
6786        Ok(keys)
6787    }
6788
6789    fn restore_room(&self, announced_bytes: u64) -> ControlResult<()> {
6790        crate::twin::room_for_restore(self.stores.root(), announced_bytes)
6791            .map_err(|err| ControlError::FailedPrecondition(format!("{err:#}")))
6792    }
6793
6794    /// **The server's intake** (restore-large-entry, 2026-10-07): an entry larger than one piece
6795    /// is staged on this box's disk as it arrives and survives the stream ending inside it
6796    /// ([`crate::twin::intake`]).
6797    fn restore_intake<'a>(
6798        &'a self,
6799        owner: &str,
6800    ) -> ControlResult<Option<Box<dyn gunnar_grpc::RestoreIntake + 'a>>> {
6801        match crate::twin::intake::Intake::open(&self.stores, owner) {
6802            Ok(Some(intake)) => Ok(Some(Box::new(intake))),
6803            Ok(None) => Ok(None),
6804            // Somebody else's restore is a NO to a known caller, not a box in the wrong state.
6805            Err(err) if err.downcast_ref::<crate::twin::RestoreNotYours>().is_some() => {
6806                Err(ControlError::PermissionDenied(format!("{err:#}")))
6807            }
6808            Err(err) => Err(ControlError::FailedPrecondition(format!("{err:#}"))),
6809        }
6810    }
6811
6812    fn restore_apply(&self, entry: gunnar_proto::LogEntry) -> ControlResult<bool> {
6813        let e = gunnar_core::txlog::Entry {
6814            seq: entry.seq,
6815            kind: entry.kind as u16,
6816            meta: entry.meta,
6817            blob: entry.blob,
6818        };
6819        match crate::twin::restore_apply(&self.stores, &e) {
6820            Ok(crate::twin::Applied::Lost(_)) => Ok(true),
6821            Ok(_) => Ok(false),
6822            Err(err) => Err(ControlError::Internal(format!(
6823                "restoring seq {}: {err:#}",
6824                e.seq
6825            ))),
6826        }
6827    }
6828
6829    fn restore_finish(&self, announced_head: u64, log_id_of_source: &str) -> ControlResult<u64> {
6830        let head =
6831            crate::twin::restore_finish_streamed(&self.stores, announced_head, log_id_of_source)
6832                .map_err(|err| ControlError::FailedPrecondition(format!("{err:#}")))?;
6833        // c-no-restart: the restored box restarts ITSELF (the appliance's PID 1).
6834        crate::twin::restart_after_restore();
6835        Ok(head)
6836    }
6837
6838    fn snapshot_stream(
6839        &self,
6840        sink: &mut dyn FnMut(gunnar_proto::LogChunk) -> ControlResult<()>,
6841    ) -> ControlResult<u64> {
6842        self.snapshot_stream_from(0, 0, "", sink)
6843    }
6844
6845    /// **A snapshot from item `from_item`, byte `from_offset` on** (restore-after-snapshot,
6846    /// 2026-10-07): a twin's kept base, every piece with its item, offset, the item's crc on its
6847    /// last and the base's id ([`crate::twin::snapshot::serve_from`]); a primary's own snapshot,
6848    /// a new cut each time, with none. A base that changed is `[snapshot-changed]`.
6849    fn snapshot_stream_from(
6850        &self,
6851        from_item: u64,
6852        from_offset: u64,
6853        expect_id: &str,
6854        sink: &mut dyn FnMut(gunnar_proto::LogChunk) -> ControlResult<()>,
6855    ) -> ControlResult<u64> {
6856        // The one copy a piece takes: out of the producer's buffer into the wire message.
6857        let mut pieces = |p: crate::twin::snapshot::Piece<'_>,
6858                          pos: Option<crate::twin::snapshot::PiecePos<'_>>|
6859         -> anyhow::Result<()> {
6860            sink(gunnar_proto::LogChunk {
6861                seq: 0,
6862                kind: u32::from(p.kind),
6863                meta: p.meta.to_vec(),
6864                blob_len: p.blob_len,
6865                blob: p.blob.to_vec(),
6866                last: p.last,
6867                restore_head: 0,
6868                restore_bytes: 0,
6869                log_id: String::new(),
6870                offset: pos.map(|q| q.offset),
6871                entry_crc: pos.and_then(|q| q.crc),
6872                snapshot_item: pos.map(|q| q.item),
6873                snapshot_id: pos.map(|q| q.id.to_owned()).unwrap_or_default(),
6874            })
6875            .map_err(|err| anyhow::anyhow!("{err}"))
6876        };
6877        crate::twin::snapshot::serve_from(
6878            &self.stores,
6879            from_item,
6880            from_offset,
6881            expect_id,
6882            &mut pieces,
6883        )
6884        .map_err(|err| {
6885            match err.downcast_ref::<crate::twin::snapshot::SnapshotChanged>() {
6886                Some(changed) => ControlError::FailedPrecondition(changed.to_string()),
6887                None => ControlError::Internal(format!("producing a snapshot: {err:#}")),
6888            }
6889        })
6890    }
6891
6892    fn log_retain(&self, from: u64) -> ControlResult<gunnar_proto::LogHeadResponse> {
6893        let log = self.stores.txlog();
6894        if log.is_follower() {
6895            return Err(ControlError::FailedPrecondition(
6896                "retention: a twin's log is the primary's numbers; retain on the primary".into(),
6897            ));
6898        }
6899        let from = if from == 0 { log.head_seq() + 1 } else { from };
6900        let first = log.retain_from(from).map_err(|err| {
6901            ControlError::Unavailable(format!("retaining the transaction log from {from}: {err}"))
6902        })?;
6903        tracing::warn!(target: gunnar_core::observe::TARGET_TWIN, from, first, head = log.head_seq(), "the transaction log now retains from {first}; a twin behind it takes a snapshot");
6904        self.log_head()
6905    }
6906
6907    fn log_layout(&self) -> ControlResult<Vec<(u64, u64, u64)>> {
6908        let log = self.stores.txlog();
6909        if log.is_follower() {
6910            return Err(ControlError::FailedPrecondition(
6911                "retention: a twin's log is the primary's numbers; retain on the primary".into(),
6912            ));
6913        }
6914        Ok(log.layout())
6915    }
6916
6917    fn data_volume(&self) -> Option<(u64, u64)> {
6918        // While the set is released for a growth, statvfs of the data root reads the box's
6919        // root filesystem: no number is better than that one.
6920        if self.stores.txlog().is_released() {
6921            return None;
6922        }
6923        let vfs = rustix::fs::statvfs(self.stores.root()).ok()?;
6924        let total = vfs.f_blocks.saturating_mul(vfs.f_frsize);
6925        let free = vfs.f_bfree.saturating_mul(vfs.f_frsize);
6926        (total > 0).then(|| (total, total.saturating_sub(free)))
6927    }
6928
6929    fn twin_owed_read(&self) -> ControlResult<Vec<u8>> {
6930        match std::fs::read(self.stores.root().join(crate::twin::TWIN_OWED_FILE)) {
6931            Ok(b) => Ok(b),
6932            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
6933            Err(e) => Err(ControlError::Unavailable(format!(
6934                "reading {}: {e}",
6935                crate::twin::TWIN_OWED_FILE
6936            ))),
6937        }
6938    }
6939
6940    fn twin_owed_write(&self, bytes: &[u8]) -> ControlResult<()> {
6941        if self.stores.txlog().is_released() {
6942            return Err(ControlError::Unavailable("the data set is released for a growth; twin-owed.json is written after the remount".into()));
6943        }
6944        // Per box, never a transaction: what a primary owes its twins is not the twins' to hold.
6945        crate::durable::write_atomic_unframed(
6946            self.stores.root(),
6947            crate::twin::TWIN_OWED_FILE,
6948            bytes,
6949        )
6950        .map_err(|e| {
6951            ControlError::Unavailable(format!("writing {}: {e:#}", crate::twin::TWIN_OWED_FILE))
6952        })
6953    }
6954
6955    fn restore_snapshot_chunk(&self, chunk: &gunnar_proto::LogChunk) -> ControlResult<Option<u64>> {
6956        let kind = u16::try_from(chunk.kind).map_err(|_| {
6957            ControlError::InvalidArgument(format!("snapshot piece of kind {}", chunk.kind))
6958        })?;
6959        let piece = crate::twin::snapshot::Piece {
6960            kind,
6961            meta: &chunk.meta,
6962            blob_len: chunk.blob_len,
6963            blob: &chunk.blob,
6964            last: chunk.last,
6965        };
6966        crate::twin::snapshot::restore_piece(&self.stores, piece)
6967            .map_err(|err| ControlError::Internal(format!("installing the snapshot: {err:#}")))
6968    }
6969
6970    fn health_alarms(&self) -> Vec<gunnar_proto::HealthAlarm> {
6971        #[allow(unused_mut)]
6972        let mut alarms = Vec::new();
6973        // A box stuck mid-restore serves reads and refuses every write: say so on the wire.
6974        if !self.stores.is_twin() && crate::twin::is_restoring(self.stores.root()) {
6975            alarms.push(gunnar_proto::HealthAlarm {
6976                event: "twin.restoring_at_open".into(),
6977                count: 1,
6978                since_unix_ms: 0,
6979                detail: "this box is MID-RESTORE: it originates no transactions and refuses every \
6980                         control-plane write until a restore finishes — resend the twin's log"
6981                    .into(),
6982            });
6983        }
6984        // An old main fenced off by a newer generation of its log (stinky row 18): it serves
6985        // reads and refuses every write, and must say so rather than look like a broken box.
6986        if !self.stores.is_twin() {
6987            if let Some(why) = crate::twin::fenced_why(self.stores.root()) {
6988                alarms.push(gunnar_proto::HealthAlarm {
6989                    event: "twin.fenced_by_generation".into(),
6990                    count: 1,
6991                    since_unix_ms: 0,
6992                    detail: why,
6993                });
6994            }
6995        }
6996        // ★ twin-resync-space (2026-10-06): a twin that gave its stale copy up to make room for
6997        // a full resync holds NO copy until it lands — main is the only one meanwhile.
6998        if self.stores.is_twin() {
6999            if let Some(d) = crate::twin::snapshot::dropped(self.stores.root()) {
7000                let too_small = crate::twin::snapshot::too_small_total(self.stores.root())
7001                    .map(|t| format!(" The resync did not fit even then: this twin's volume ({t} bytes) must be grown to at least main's data set before it is tried again ({}).", gunnar_grpc::TWIN_TOO_SMALL))
7002                    .unwrap_or_default();
7003                alarms.push(gunnar_proto::HealthAlarm {
7004                    event: "twin.resync_without_copy".into(),
7005                    count: 1,
7006                    since_unix_ms: d.at_unix_ms,
7007                    detail: format!("{}.{too_small}", d.sentence()),
7008                });
7009            }
7010        }
7011        // The log's size over its cap (2026-09-22): a warning, never a deletion. A twin's log is
7012        // its primary's numbers and is trimmed there, so only a box that originates one is told.
7013        if !self.stores.is_twin() {
7014            let (total, _) = crate::twin::volume_bytes(self.stores.root());
7015            match crate::twin::txlog_alarm_cap(
7016                std::env::var(crate::twin::TXLOG_ALARM_ENV).ok().as_deref(),
7017                total,
7018            ) {
7019                Ok(Some((cap, how))) => alarms.extend(crate::twin::txlog_size_alarm(
7020                    self.stores.txlog().bytes(),
7021                    cap,
7022                    &how,
7023                )),
7024                Ok(None) => {}
7025                Err(why) => alarms.push(gunnar_proto::HealthAlarm {
7026                    event: crate::twin::TXLOG_SIZE_ALARM.into(),
7027                    count: 1,
7028                    since_unix_ms: 0,
7029                    detail: why,
7030                }),
7031            }
7032        }
7033        #[cfg(feature = "metered")]
7034        if let Some(a) = self.entitlement.as_ref().and_then(|slot| slot.alarm()) {
7035            alarms.push(a);
7036        }
7037        // The growth's own alarm (DEFECT D, 2026-09-29): /data kept unmounted past the growth's
7038        // bound (`grow.unmounted_too_long`), or a set that could not be mounted back (`grow.fatal`).
7039        if let Some(a) = self.grow.as_ref().and_then(|g| g.alarm()) {
7040            alarms.push(a);
7041        }
7042        // The installer's feed (`crate::feed`): a medium value the door contradicted, or a
7043        // fed monetize key that waits for a restart.
7044        alarms.extend(crate::feed::alarms());
7045        alarms
7046    }
7047
7048    fn is_twin(&self) -> bool {
7049        self.stores.is_twin()
7050    }
7051
7052    fn frozen(&self) -> Option<String> {
7053        if self.stores.is_twin() {
7054            Some("this box is a twin".to_owned())
7055        } else if self
7056            .stores
7057            .browse()
7058            .load(std::sync::atomic::Ordering::SeqCst)
7059        {
7060            Some("this box is a browse replica".to_owned())
7061        } else {
7062            None
7063        }
7064    }
7065
7066    fn log_follows(&self) -> bool {
7067        self.stores.is_twin() || self.stores.txlog().is_follower()
7068    }
7069
7070    fn topology_facts(&self) -> gunnar_grpc::topology::Facts {
7071        crate::topology::facts(self.stores.root())
7072    }
7073
7074    fn maintenance_reasons(&self) -> Vec<String> {
7075        #[allow(unused_mut)]
7076        let mut why = Vec::new();
7077        #[cfg(feature = "metered")]
7078        if self
7079            .entitlement
7080            .as_ref()
7081            .is_some_and(|slot| slot.maintenance())
7082        {
7083            why.push(crate::entitlement::KEY_MISMATCH_ALARM.to_string());
7084        }
7085        why
7086    }
7087
7088    fn primary_heartbeat(&self, silent_after_ms: u64) -> Option<gunnar_proto::PrimaryHeartbeat> {
7089        let cfg = crate::twin::TwinConfig::load(self.stores.root())
7090            .ok()
7091            .flatten()?;
7092        // The follower's OWN word and reason travel with the numbers (X2): a
7093        // twin that cannot start, or whose every poll fails, records both, and
7094        // this is the only door that reads them out to anybody.
7095        let st = crate::twin::status(self.stores.root());
7096        let (last, primary_head, applied) = match &st {
7097            Some(s) => (s.last_answer_unix_ms, s.primary_head_seq, s.applied_seq),
7098            None => (0, 0, self.stores.txlog().head_seq()),
7099        };
7100        let own = st.as_ref().map(|s| (s.state.to_string(), s.why.clone()));
7101        Some(gunnar_grpc::heartbeat::primary_view(
7102            &cfg.primary,
7103            last,
7104            primary_head,
7105            applied,
7106            crate::twin::now_ms(),
7107            silent_after_ms,
7108            own.as_ref().map(|(a, b)| (a.as_str(), b.as_str())),
7109        ))
7110    }
7111
7112    fn log_head(&self) -> ControlResult<gunnar_proto::LogHeadResponse> {
7113        self.log_head_signed("")
7114    }
7115
7116    fn log_head_signed(&self, nonce: &str) -> ControlResult<gunnar_proto::LogHeadResponse> {
7117        // **A twin's poll in the restore window is refused by name** (T17, 2026-09-21; T15's
7118        // TD8). Mid-replay (`txlog-restoring`) or finished and owed its restart
7119        // (`txlog-reseal`), this process holds the fresh box's KEK while the replayed host key
7120        // is sealed under the dead primary's; the re-seal that reconciles them runs on the next
7121        // open. Answering would mean an UNSIGNED answer a pinned twin refuses, and a
7122        // `cannot read its host key … wrong key-encrypting key` WARN that reads like a broken
7123        // box (MEASURED under KVM, 0.4 s after "Restore finished"). The box is not yet the
7124        // primary it will be, and says exactly that; the twin records it as its `why` and polls
7125        // again. Only a nonce asks for proof of identity — an unnonced LogHead (an operator,
7126        // `twin replay`) still gets the numbers.
7127        if !nonce.trim().is_empty() {
7128            let root = self.stores.root();
7129            if crate::twin::is_restoring(root) {
7130                return Err(ControlError::Unavailable(
7131                    "this box is being restored from a twin and is not a primary yet: it answers a twin's poll \
7132                     once the replay has finished and the box has restarted"
7133                        .to_owned(),
7134                ));
7135            }
7136            if crate::twin::reseal_pending(root) {
7137                return Err(ControlError::Unavailable(
7138                    "this box finished a restore and must restart before it answers a twin: its host key is \
7139                     sealed under the key-encrypting key the restart re-seals, so until then it cannot prove \
7140                     which box it is"
7141                        .to_owned(),
7142                ));
7143            }
7144        }
7145        let mut head = crate::twin::own_head(&self.stores);
7146        // **Prove which box answered** (2026-09-21, lane T4). Signed last, over
7147        // the nonce the caller just minted plus the identity and the numbers
7148        // above, so a recorded answer cannot be replayed at the next poll and
7149        // the numbers cannot be edited in flight. No nonce = not a twin's poll,
7150        // and then there is nothing to be fresh about and nothing is signed.
7151        //
7152        // The generation (stinky row 18, 2026-10-10) is signed with them: a
7153        // primary names which restore of its log it is, a twin the highest its
7154        // primary has been. A fence that cannot be read is refused by name, never
7155        // answered as 0 — a twin would take that for an old main.
7156        let root = self.stores.root();
7157        let generation = if crate::twin::is_twin_root(root) {
7158            crate::twin::pinned_generation(root)
7159        } else {
7160            crate::twin::log_generation(root)
7161        };
7162        head.generation = generation
7163            .map_err(|err| ControlError::Internal(format!("this box's log generation: {err:#}")))?;
7164        let (key, sig) = crate::twin::sign_head_answer(
7165            &self.stores,
7166            nonce,
7167            &head.log_id,
7168            head.first_seq,
7169            head.head_seq,
7170            head.generation,
7171        );
7172        head.answer_key = key;
7173        head.answer_sig = sig;
7174        Ok(head)
7175    }
7176
7177    fn fence_by_generation(&self, proof: &gunnar_proto::GenerationProof) -> ControlResult<()> {
7178        crate::twin::fence_by_proof(&self.stores, proof)
7179            .map(drop)
7180            .map_err(|err| {
7181                ControlError::Internal(format!(
7182                    "fencing this box off as an old generation of its log: {err:#}"
7183                ))
7184            })
7185    }
7186
7187    fn log_fetch(
7188        &self,
7189        from: u64,
7190        to: u64,
7191        max_bytes: u64,
7192    ) -> ControlResult<Vec<gunnar_proto::LogEntry>> {
7193        let log = self.readable_log()?;
7194        let entries =
7195            crate::twin::fetch_range(log, from, to, max_bytes).map_err(log_read_refusal)?;
7196        Ok(entries
7197            .into_iter()
7198            .map(|e| gunnar_proto::LogEntry {
7199                seq: e.seq,
7200                kind: e.kind as u32,
7201                meta: e.meta,
7202                blob: e.blob,
7203            })
7204            .collect())
7205    }
7206
7207    /// **`Mirror.LogFetch` off the segments, a piece at a time** (twin-streaming, 2026-10-06):
7208    /// the refusals now, as the call's status; then [`crate::twin::stream_range`] — the payload
7209    /// crc of every entry checked before its last piece leaves — through [`ChunkOut`] onto the
7210    /// wire. What this box holds of an entry is one piece, whatever its size.
7211    fn log_fetch_stream(
7212        &self,
7213        from: u64,
7214        to: u64,
7215        max_bytes: u64,
7216    ) -> ControlResult<gunnar_grpc::seam::LogFetchProducer> {
7217        self.log_fetch_stream_at(from, 0, to, max_bytes)
7218    }
7219
7220    /// [`Self::log_fetch_stream`] with the first entry from byte `from_offset` on
7221    /// (restore-large-entry, 2026-10-07): [`crate::twin::stream_range_at`].
7222    fn log_fetch_stream_at(
7223        &self,
7224        from: u64,
7225        from_offset: u64,
7226        to: u64,
7227        max_bytes: u64,
7228    ) -> ControlResult<gunnar_grpc::seam::LogFetchProducer> {
7229        let log = self.readable_log()?;
7230        crate::twin::check_range(log, from, to).map_err(log_read_refusal)?;
7231        let stores = Arc::clone(&self.stores);
7232        Ok(Box::new(move |sink| {
7233            let mut out = ChunkOut { sink, open: None };
7234            crate::twin::stream_range_at(stores.txlog(), from, from_offset, to, max_bytes, &mut out)
7235                .map(drop)
7236                .map_err(|err| {
7237                    let err = match err.downcast::<ControlError>() {
7238                        Ok(said) => return said,
7239                        Err(other) => other,
7240                    };
7241                    match err.downcast::<gunnar_core::txlog::Error>() {
7242                        Ok(e) => log_read_refusal(e),
7243                        Err(other) => ControlError::Internal(format!("transaction log: {other:#}")),
7244                    }
7245                })
7246        }))
7247    }
7248
7249    #[cfg(feature = "replication")]
7250    fn replication_status(
7251        &self,
7252        include_refs: bool,
7253    ) -> ControlResult<gunnar_proto::ReplicationStatusResponse> {
7254        self.replication_slot()?;
7255        self.replication_snapshot(include_refs)
7256    }
7257
7258    /// **The replication watermark** (`Mirror.FlushReplication`,
7259    /// `DATA-SET-GROWTH-FLOW.md` §2.2, G2, T11): step 0 of the runtime
7260    /// growth, and the proof — as far as replication can give one — that the
7261    /// twin is caught up before `/data` is unmounted.
7262    ///
7263    /// The flush half of `replication_abdicate`, alone: optionally enqueue
7264    /// every repository (`resync`, the only way a repository nobody pushed
7265    /// since the twin existed reaches it), then wait — bounded — for every
7266    /// standby's queue to drain. No freeze, no demote. The tuple it answers
7267    /// is §2.2's conjunction, measured at the end of the wait, and `verdict`
7268    /// says in words what it proves and what §2.1 says it cannot.
7269    #[cfg(feature = "replication")]
7270    fn replication_flush(
7271        &self,
7272        request: &gunnar_proto::FlushReplicationRequest,
7273    ) -> ControlResult<gunnar_proto::FlushReplicationResponse> {
7274        let slot = self.replication_slot()?;
7275        let began = gunnar_grpc::bus::now_unix_ms();
7276        let started = std::time::Instant::now();
7277        let ceiling = if request.timeout_secs == 0 {
7278            abdicate_flush_secs()
7279        } else {
7280            u64::from(request.timeout_secs)
7281        };
7282        let Some(replicator) = slot.replicator() else {
7283            return Err(ControlError::FailedPrecondition(
7284                "no replicator is running on this box (no standby configured, or this box is not a primary); there is nothing to flush and nothing to prove".to_owned(),
7285            ));
7286        };
7287        if request.resync {
7288            replicator
7289                .resync_scoped(None, None, &[])
7290                .map_err(|err| ControlError::Internal(format!("{err:#}")))?;
7291        }
7292        let deadline = started + std::time::Duration::from_secs(ceiling);
7293        loop {
7294            let drained = replicator.status().iter().all(|s| s.pending == 0);
7295            if drained || std::time::Instant::now() >= deadline {
7296                break;
7297            }
7298            std::thread::sleep(std::time::Duration::from_millis(100));
7299        }
7300        let status = replicator.status();
7301        let counters = replicator.counters();
7302        let pending: u64 = status.iter().map(|s| s.pending as u64).sum();
7303        let needs_full_resync = status.iter().any(|s| s.needs_full_resync);
7304        let last_success = status
7305            .iter()
7306            .map(|s| s.last_success_unix_ms.unwrap_or(0))
7307            .min()
7308            .unwrap_or(0);
7309        let in_flight = self.admission.as_ref().map(|a| a.in_flight()).unwrap_or(0);
7310        let catalog_repos = self
7311            .stores
7312            .catalog()
7313            .counts()
7314            .map(|(_, repos)| repos as u64)
7315            .unwrap_or(0);
7316        let standbys = self.replication_snapshot(false)?.standbys;
7317        let caught_up = in_flight == 0
7318            && !status.is_empty()
7319            && pending == 0
7320            && !needs_full_resync
7321            && status.iter().all(|s| {
7322                s.last_success_unix_ms.is_some_and(|t| t >= began)
7323                    || s.pending == 0 && s.last_success_unix_ms.is_some()
7324            });
7325        let verdict = if status.is_empty() {
7326            "no standby is configured: nothing was flushed and nothing is proven".to_owned()
7327        } else if caught_up {
7328            format!(
7329                "CAUGHT UP as far as replication can prove: {} standby(s), nothing pending, no full-resync hole, {} transfer(s) in flight, {} bus gap(s) since boot{}. What this does NOT prove (DATA-SET-GROWTH-FLOW.md §2.1): refs outside heads/tags, LFS objects, the control database, visibility, tombstones, and — unless `resync` ran once since the twin existed — repositories nobody pushed since",
7330                status.len(),
7331                in_flight,
7332                counters.bus_gaps,
7333                if request.resync { " (every catalog repository was enqueued)" } else { "" }
7334            )
7335        } else {
7336            let names: Vec<String> = status
7337                .iter()
7338                .filter(|s| s.pending > 0 || s.needs_full_resync)
7339                .map(|s| {
7340                    format!(
7341                        "{} (pending {}{})",
7342                        s.name,
7343                        s.pending,
7344                        if s.needs_full_resync {
7345                            ", needs full resync"
7346                        } else {
7347                            ""
7348                        }
7349                    )
7350                })
7351                .collect();
7352            format!(
7353                "NOT caught up after {} s: {} transfer(s) in flight, {} repository(ies) pending [{}]{}; the growth must not unmount the set on this",
7354                started.elapsed().as_secs(),
7355                in_flight,
7356                pending,
7357                names.join(", "),
7358                if needs_full_resync { " — a standby has a hole retrying will not close: run `replication resync`" } else { "" }
7359            )
7360        };
7361        tracing::info!(
7362            target: "gunnar::replication",
7363            event = "replication.flush",
7364            caught_up,
7365            in_flight,
7366            pending,
7367            bus_gaps = counters.bus_gaps,
7368            needs_full_resync,
7369            waited_ms = started.elapsed().as_millis() as u64,
7370            "{verdict}"
7371        );
7372        Ok(gunnar_proto::FlushReplicationResponse {
7373            caught_up,
7374            in_flight,
7375            pending,
7376            bus_gaps: counters.bus_gaps,
7377            needs_full_resync,
7378            last_success_unix_ms: last_success,
7379            flushed_at_unix_ms: began,
7380            waited_ms: started.elapsed().as_millis() as u64,
7381            standbys,
7382            catalog_repos,
7383            verdict,
7384        })
7385    }
7386
7387    /// **Demote: clear EVERY replication target in one write.** The failover
7388    /// primitive — see the `Mirror.DemoteReplication` proto comment for the
7389    /// split-brain it closes. A recovered old-primary still lists the box that
7390    /// is now the new primary, and its replicator force-clobbers the new
7391    /// primary's post-failover writes; demoting it drops that whole roster so it
7392    /// can be re-added as the new primary's standby instead.
7393    ///
7394    /// Idempotent, and never `NotFound`: unlike `replication_remove`, which
7395    /// names one standby and must distinguish "removed the one you meant" from
7396    /// "removed one that was never there", demote clears the lot — a box with no
7397    /// targets is a perfectly good thing to demote and comes back `Ok` with zero
7398    /// cleared, because "make sure I am not a primary" must not fail on a box
7399    /// that already is not one.
7400    #[cfg(feature = "replication")]
7401    fn replication_demote(&self) -> ControlResult<gunnar_proto::ReplicationStatusResponse> {
7402        let slot = self.replication_slot()?;
7403        let mut config = slot
7404            .config()
7405            .map_err(|err| ControlError::Internal(format!("{err:#}")))?;
7406        config.clear_targets();
7407        slot.apply(config)
7408            .map_err(|err| ControlError::Internal(format!("{err:#}")))?;
7409        self.replication_snapshot(false)
7410    }
7411
7412    /// **Set this box's role**: a writable `Primary`, or a read-only `Standby`.
7413    ///
7414    /// The one writer. It persists the role into `replication.json` (so it
7415    /// survives the reboot after a power cut) AND flips the shared
7416    /// write-gate the SSH/HTTP front doors read — both from
7417    /// [`ReplicationSlot::apply`], which is the single place the two are kept in
7418    /// step (one implementation). `PRIMARY` is the **takeover flip** a promoted standby runs;
7419    /// `STANDBY` is how a box is deliberately made read-only. Idempotent: setting
7420    /// the role a box already holds writes the same file and the same flag.
7421    #[cfg(feature = "replication")]
7422    fn replication_set_role(
7423        &self,
7424        role: gunnar_proto::ReplicationRole,
7425    ) -> ControlResult<gunnar_proto::ReplicationStatusResponse> {
7426        let slot = self.replication_slot()?;
7427        let mut config = slot
7428            .config()
7429            .map_err(|err| ControlError::Internal(format!("{err:#}")))?;
7430        let wanted = crate::mirror::Role::from_proto(role);
7431        // **A browse replica cannot be promoted** (the browse replica). Not by an
7432        // operator's `SetRole PRIMARY`, not by `abdicate --promote`, not by the
7433        // passive heartbeat: the box holds only public repositories and serves
7434        // strangers; as a primary it would be a git host with half the data
7435        // and an open door. The only way out is `STANDBY`, which narrows what
7436        // the box serves and keeps it frozen — from there an operator may
7437        // promote, and by then they have said so twice.
7438        if config.role.is_browse() && wanted == crate::mirror::Role::Primary {
7439            return Err(ControlError::FailedPrecondition(
7440                "this box is a browse replica; it cannot be promoted to primary. It holds only \
7441                 public repositories and serves anonymous reads — first `gunnar control \
7442                 replication demote` it to a plain standby, then promote that"
7443                    .into(),
7444            ));
7445        }
7446        config.role = wanted;
7447        slot.apply(config)
7448            .map_err(|err| ControlError::Internal(format!("{err:#}")))?;
7449        self.replication_snapshot(false)
7450    }
7451
7452    /// **Abdicate: the master steps down cleanly, in order.** The voluntary
7453    /// counterpart to the crash [`Self::replication_demote`] covers, and the
7454    /// order is the whole difference between a hand-off and a crash:
7455    ///
7456    /// 1. **FREEZE** — role → `Standby`, so this box stops taking client writes
7457    ///    before the flush begins. This closes the residual race `demote` alone
7458    ///    left open (a client pushing to the box in the window before it is
7459    ///    demoted): once frozen, there is no such write to lose.
7460    /// 2. **FLUSH** — resync every standby and wait, with a ceiling, for the
7461    ///    queues to drain, so the standby has everything that was pending. The
7462    ///    wait is best-effort: a standby that is down must not wedge the
7463    ///    abdication forever — the roster still clears below, and a recovered
7464    ///    standby replays from this box's history when it is re-added the other
7465    ///    way round.
7466    /// 3. **DEMOTE** — clear the roster (reusing the same `clear_targets`
7467    ///    primitive), so the abdicated box can never later clobber the new
7468    ///    primary.
7469    ///
7470    /// It does NOT promote the standby: naming a new primary is the operator's
7471    /// decision, run as abdication's last act (`SetRole(PRIMARY)` on the
7472    /// standby — the CLI's `--promote`), never a default this box picks.
7473    #[cfg(feature = "replication")]
7474    fn replication_abdicate(&self) -> ControlResult<gunnar_proto::ReplicationStatusResponse> {
7475        let slot = self.replication_slot()?;
7476
7477        // 1. FREEZE. role -> Standby, targets untouched (the replicator stays up
7478        //    for the flush below).
7479        let mut config = slot
7480            .config()
7481            .map_err(|err| ControlError::Internal(format!("{err:#}")))?;
7482        config.role = crate::mirror::Role::Standby;
7483        slot.apply(config)
7484            .map_err(|err| ControlError::Internal(format!("{err:#}")))?;
7485
7486        // 2. FLUSH. Enqueue every repository for every standby, then wait for
7487        //    the queues to empty, bounded so a dead standby cannot hang the
7488        //    hand-off. The workers run on the git runtime, so they make progress
7489        //    while this admin thread sleeps.
7490        if let Some(replicator) = slot.replicator() {
7491            replicator
7492                .resync_scoped(None, None, &[])
7493                .map_err(|err| ControlError::Internal(format!("{err:#}")))?;
7494            let deadline =
7495                std::time::Instant::now() + std::time::Duration::from_secs(abdicate_flush_secs());
7496            loop {
7497                let drained = replicator.status().iter().all(|s| s.pending == 0);
7498                if drained || std::time::Instant::now() >= deadline {
7499                    break;
7500                }
7501                std::thread::sleep(std::time::Duration::from_millis(100));
7502            }
7503        }
7504
7505        // 3. DEMOTE. Clear the roster so this box can never clobber the new
7506        //    primary. Re-read: the flush did not change the config, but reading
7507        //    it back keeps this the same load-modify-apply the other verbs use.
7508        let mut config = slot
7509            .config()
7510            .map_err(|err| ControlError::Internal(format!("{err:#}")))?;
7511        config.clear_targets();
7512        slot.apply(config)
7513            .map_err(|err| ControlError::Internal(format!("{err:#}")))?;
7514        self.replication_snapshot(false)
7515    }
7516
7517    /// **The backfill verb.** See `mirror::Replicator::resync_scoped` for the
7518    /// gap it closes and why enqueueing is the whole of it.
7519    ///
7520    /// Three refusals, each of which would otherwise be a silent nothing:
7521    ///
7522    /// * a server with a **configuration but no running replicator** — a target
7523    ///   is on disk and nothing is draining its queue — is `Unavailable` and not
7524    ///   an `Ok` with zero queued, because the two read identically and only one
7525    ///   of them makes a second copy;
7526    /// * a **standby name that is not configured** is `NotFound`, the same rule
7527    ///   `replication_remove` already applies;
7528    /// * a **repository this server does not have** is `NotFound` rather than a
7529    ///   resync of nothing, because the ordinary way to get there is a typo.
7530    #[cfg(feature = "replication")]
7531    fn replication_resync(
7532        &self,
7533        name: &str,
7534        repo: Option<&gunnar_proto::RepoPath>,
7535        except: &[gunnar_proto::RepoPath],
7536    ) -> ControlResult<gunnar_proto::ResyncReplicationResponse> {
7537        let slot = self.replication_slot()?;
7538        let Some(replicator) = slot.replicator() else {
7539            return Err(ControlError::Unavailable(format!(
7540                "no replicator is running, so nothing would drain a backfill queue. \
7541                 `replication.json` holds {} target(s); the server has not started them.",
7542                slot.config().map(|c| c.targets.len()).unwrap_or(0)
7543            )));
7544        };
7545        let only = match repo {
7546            None => None,
7547            Some(path) => Some(Self::repo_ref(path)?),
7548        };
7549        let except = except
7550            .iter()
7551            .map(Self::repo_ref)
7552            .collect::<ControlResult<Vec<_>>>()?;
7553        let reports = replicator
7554            .resync_scoped(Some(name), only.as_ref(), &except)
7555            .map_err(|err| ControlError::NotFound(format!("{err:#}")))?;
7556        Ok(gunnar_proto::ResyncReplicationResponse {
7557            standbys: reports
7558                .into_iter()
7559                .map(|r| gunnar_proto::ResyncStandby {
7560                    standby: r.standby,
7561                    in_scope: r.in_scope,
7562                    queued: r.queued,
7563                    already_pending: r.already_pending,
7564                    refused: r.refused,
7565                    sha1_repos: r.sha1_repos,
7566                    excluded: r.excluded,
7567                })
7568                .collect(),
7569            status: Some(self.replication_snapshot(false)?),
7570        })
7571    }
7572
7573    // ── outbound webhooks ──────────────────────────────────────────────────
7574
7575    #[cfg(feature = "webhooks")]
7576    fn webhook_add(
7577        &self,
7578        request: gunnar_proto::AddWebhookRequest,
7579    ) -> ControlResult<gunnar_proto::WebhookListResponse> {
7580        let slot = self.webhook_slot()?;
7581        let endpoint = crate::webhook::Endpoint {
7582            name: request.name,
7583            url: request.url,
7584            kinds: request.kinds,
7585            namespace: request.namespace,
7586            repo: request.repo,
7587            // An empty secret is "no HMAC", not a secret that is the empty
7588            // string — so it maps to None and `has_secret` reads false.
7589            secret: Some(request.secret).filter(|s| !s.is_empty()),
7590            added_at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
7591        };
7592        // `add` validates the URL and the kind names, so a typo is refused here
7593        // (InvalidArgument) rather than silently delivering nowhere.
7594        slot.add(endpoint)
7595            .map_err(|err| ControlError::InvalidArgument(format!("{err:#}")))
7596    }
7597
7598    #[cfg(feature = "webhooks")]
7599    fn webhook_list(&self) -> ControlResult<gunnar_proto::WebhookListResponse> {
7600        self.webhook_slot()?
7601            .list_response()
7602            .map_err(|err| ControlError::Internal(format!("{err:#}")))
7603    }
7604
7605    #[cfg(feature = "webhooks")]
7606    fn webhook_remove(&self, name: &str) -> ControlResult<gunnar_proto::WebhookListResponse> {
7607        let slot = self.webhook_slot()?;
7608        slot.remove(name)
7609            .map_err(|err| ControlError::Internal(format!("{err:#}")))?
7610            // NotFound and not a silent success: "removed the endpoint" about
7611            // one that was never there reads identically to removing the one you
7612            // meant, and the difference is whether events are still being sent.
7613            .ok_or_else(|| ControlError::NotFound(format!("no webhook endpoint named {name:?}")))
7614    }
7615}
7616
7617/// The largest `Refs.ReadObject` response, and it is **not** a number somebody
7618/// picked.
7619///
7620/// # Measured off the transport, not chosen
7621///
7622/// `tonic 0.13.1`'s `DEFAULT_MAX_RECV_MESSAGE_SIZE` is `4 * 1024 * 1024`
7623/// (`tonic/src/codec/mod.rs:100`; the send default beside it is `usize::MAX`).
7624/// gunnar overrides neither. So a response larger than this **cannot be
7625/// received by a client using the generated stub's defaults** — it fails on the
7626/// client with a length error after the server has already paid for it. Refusing
7627/// it before inflating turns an expensive failure into a cheap one; it does not
7628/// turn a working request into a refusal, which is the objection that kept this
7629/// entry open.
7630///
7631/// The refusal is on the size of the **response**, not of the object, so
7632/// `max_bytes` still works: asking for a 1 KiB prefix of a 1 GiB blob is
7633/// answered exactly as before.
7634///
7635/// # What this alone does NOT fix
7636///
7637/// The server still inflates the whole object to produce that prefix, because
7638/// `ObjectStore` has no bounded read and cannot honestly grow one — a prefix of
7639/// a delta-encoded object needs its whole base chain (the investigation is in
7640/// the object-ceiling record, checked against `gix-odb 0.83.0`). Bounding *that* needs a
7641/// ceiling on the **object**, which is [`DEFAULT_MAX_OBJECT_BYTES`] below.
7642const MAX_RESPONSE_BYTES: u64 = 4 * 1024 * 1024;
7643
7644/// The memory this deployment is willing to spend on `Refs.ReadObject`
7645/// inflations, in total, across every control-plane worker at once.
7646///
7647/// 512 MiB. The control plane is an administrative surface — a handful of
7648/// operators and a UI — sharing a box with the thing that actually matters, the
7649/// git serving path. Half a gigabyte is a rounding error against a forge host's
7650/// memory and is far more than object *inspection* has any business using; the
7651/// number that would need defending is a larger one, not this.
7652const READ_OBJECT_MEMORY_BUDGET: u64 = 512 * 1024 * 1024;
7653
7654/// The most control-plane workers that can be inflating at once.
7655///
7656/// `read_object` runs **synchronously** on a control-plane worker (the split gave
7657/// the control plane its own runtime and deliberately did not add
7658/// `spawn_blocking`), so the number of concurrent inflations is the number of
7659/// those workers, and further callers queue rather than allocate.
7660/// [`control_plane_workers`](crate::serve::control_plane_workers) is
7661/// `cores / 4` clamped to `4..=8` (the handler workers, R5.1-G), so **8** is its ceiling on any machine.
7662///
7663/// The clamp's upper bound is used rather than the live count on purpose: a
7664/// default derived from `available_parallelism()` would make this RPC refuse
7665/// different requests on a laptop and on the forge host, which is precisely the
7666/// class of difference that is not found until production.
7667///
7668/// **This bound moves** if these methods are ever wrapped in `spawn_blocking` —
7669/// which `gunnar-grpc/src/seam.rs` still asks for, and the admission record says
7670/// the gRPC router still has no `ConcurrencyLimitLayer`. On that day the
7671/// denominator becomes unbounded and this constant is where to start.
7672const MAX_CONCURRENT_INFLATES: u64 = 8;
7673
7674/// **The object ceiling** `Refs.ReadObject` refuses above — 64 MiB.
7675///
7676/// ```text
7677///   ceiling  =  memory budget for this RPC  ÷  concurrent inflates
7678///            =  512 MiB                     ÷  8
7679///            =  64 MiB          (a 512 MiB worst case, which is the budget)
7680/// ```
7681///
7682/// # This one really does refuse a request that works today
7683///
7684/// [`MAX_RESPONSE_BYTES`] costs nobody anything — it declines to build a response
7685/// no client could receive. This is different: asking for a 1 KiB prefix of a
7686/// 100 MiB blob is a request the server can answer today and will now refuse,
7687/// because answering it means materialising 100 MiB to hand back a kilobyte, and
7688/// eight of those concurrently is 800 MiB out of the git path's memory on an
7689/// unauthenticated RPC.
7690///
7691/// 64 MiB is chosen against what git itself does rather than out of the air: a
7692/// blob that large is already outside what belongs in a pack — it is what
7693/// `gunnar-lfs` exists for — and this is an *inspection* surface, not the clone
7694/// path, which is untouched by this and streams. The refusal names the object's
7695/// size, so a caller learns the fact rather than a limit.
7696///
7697/// A deployment that disagrees sets its own with
7698/// [`CoreControlPlane::with_max_object_bytes`], including `0` for no ceiling.
7699const DEFAULT_MAX_OBJECT_BYTES: u64 = READ_OBJECT_MEMORY_BUDGET / MAX_CONCURRENT_INFLATES;
7700
7701// `PROTECTION_GAP` stood here, and it read:
7702//
7703//   > Policy.*: gunnar-core has no store for branch protection or ref-name
7704//   > policy … Serving this over gRPC needs a policy document in the catalog
7705//
7706// There is one — `gunnar_core::PolicyStore`, at `refs/gunnar/policy` — and
7707// every `Policy.*` verb below is served from it. The constant outlived the
7708// refusal it explained: `cargo check` called it `never used` while
7709// `gunnar-ui/docs/coverage.md` was still quoting it to render six console
7710// panels as disabled. A dead string that a doc still believes is worse than no
7711// string, so it is gone rather than left as a comment with a name.
7712
7713// `MIRROR_GAP` stood here too, and it read:
7714//
7715//   > Mirror.*: mirroring runs over gunnar-client (nornir-git ls-remote +
7716//   > fetch), which an S crate must never link (the dependency budget). Use `gunnar mirror` on a
7717//   > normal-OS host instead
7718//
7719// It had already been narrowed from all four verbs to the one that genuinely
7720// needed a fetch client. That client exists in-tree now —
7721// `gunnar_wire::fetch_pack`, no gix, no `.git` directory, no worktree, over the
7722// same `BufRead`/`Write` pair `upload_pack` answers on — so `Mirror.Sync` is
7723// served rather than refused and the string is gone for the reason
7724// `PROTECTION_GAP`'s is: a dead refusal a doc still quotes is worse than no
7725// refusal. `gunnar-cli/tests/policy_mirror.rs` mentions it by name; that is
7726// prose about the history, and this paragraph is what it now describes.
7727
7728impl CoreControlPlane {
7729    /// `Usage.*` is not readable while the data set is unmounted for a growth.
7730    fn usage_readable(&self) -> ControlResult<()> {
7731        if self.stores.txlog().is_released()
7732            || crate::twin::maintenance::is_held(self.stores.root())
7733        {
7734            return Err(ControlError::Unavailable(format!(
7735                "{} this box's data set is being grown (unmounted): its usage is read again after the remount — ask again",
7736                gunnar_grpc::PRIMARY_GROWING
7737            )));
7738        }
7739        Ok(())
7740    }
7741
7742    /// The indexed objects of `namespace`'s OPEN stores, now.
7743    fn open_objects_of(&self, namespace: &str) -> u64 {
7744        let prefix = self
7745            .stores
7746            .repos_root()
7747            .canonicalize()
7748            .unwrap_or_else(|_| self.stores.repos_root().to_path_buf())
7749            .join(namespace);
7750        self.stores
7751            .engine()
7752            .open_object_counts()
7753            .into_iter()
7754            .filter(|(dir, _)| dir.parent() == Some(prefix.as_path()))
7755            .map(|(_, n)| n)
7756            .sum()
7757    }
7758
7759    /// **One `NamespaceUsage` row**: what the ledger holds, the open objects,
7760    /// and the FLOW half off the flow ledger's live atomics.
7761    fn usage_row(
7762        &self,
7763        name: &str,
7764        held: &crate::usage_ledger::Held,
7765        objects: u64,
7766        measured_at_unix_ms: i64,
7767    ) -> gunnar_proto::NamespaceUsage {
7768        let mut row = gunnar_proto::NamespaceUsage {
7769            namespace: name.to_owned(),
7770            measured_at_unix_ms,
7771            pack_bytes: held.bytes.pack,
7772            cache_bytes: held.bytes.cache,
7773            lfs_bytes: held.bytes.lfs,
7774            tombstoned_bytes: held.tombstoned_bytes,
7775            repos: held.repos,
7776            objects,
7777            ..Default::default()
7778        };
7779        fill_flow(&mut row, self.stores.flow(), name, measured_at_unix_ms);
7780        row
7781    }
7782
7783    /// **Every namespace, walked now** (`Usage.ReadAll`): the full recount —
7784    /// one catalog list, one tombstone walk, every repository's directory. The
7785    /// reading the usage ledger must always agree with.
7786    fn usage_all(&self) -> ControlResult<Vec<gunnar_proto::NamespaceUsage>> {
7787        let catalog = self.stores.catalog();
7788        let namespaces: Vec<String> = catalog
7789            .namespaces()
7790            .map_err(internal)?
7791            .into_iter()
7792            .map(|ns| ns.name)
7793            .collect();
7794        let repos = catalog.list(None).map_err(internal)?;
7795        let tombstones = self.stores.tombstones().unwrap_or_default();
7796        let open_objects: std::collections::HashMap<std::path::PathBuf, u64> = self
7797            .stores
7798            .engine()
7799            .open_object_counts()
7800            .into_iter()
7801            .collect();
7802        let flow = self.stores.flow();
7803        let measured_at_unix_ms = gunnar_core::now_ms();
7804        let mut by_ns: std::collections::HashMap<&str, Vec<&gunnar_core::Repo>> =
7805            std::collections::HashMap::new();
7806        for repo in &repos {
7807            by_ns.entry(repo.namespace.as_str()).or_default().push(repo);
7808        }
7809        let mut out = Vec::with_capacity(namespaces.len());
7810        for name in namespaces {
7811            let mut row = gunnar_proto::NamespaceUsage {
7812                namespace: name.clone(),
7813                measured_at_unix_ms,
7814                ..Default::default()
7815            };
7816            for repo in by_ns.get(name.as_str()).map(Vec::as_slice).unwrap_or(&[]) {
7817                let path = RepoRef::new(&repo.namespace, &repo.name).map_err(|err| {
7818                    ControlError::Internal(format!(
7819                        "a catalogued repository has an illegal path: {err}"
7820                    ))
7821                })?;
7822                let dir = self
7823                    .stores
7824                    .repo_dir(&path)
7825                    .map_err(|err| ControlError::Internal(format!("{err:#}")))?;
7826                let bytes = repo_bytes(&dir);
7827                row.repos += 1;
7828                row.pack_bytes += bytes.pack;
7829                row.cache_bytes += bytes.cache;
7830                row.lfs_bytes += bytes.lfs;
7831                row.objects += open_objects.get(&dir).copied().unwrap_or(0);
7832            }
7833            row.tombstoned_bytes = tombstones
7834                .iter()
7835                .filter(|t| t.path.namespace() == name)
7836                .map(|t| t.bytes)
7837                .sum();
7838            fill_flow(&mut row, flow, &name, measured_at_unix_ms);
7839            out.push(row);
7840        }
7841        Ok(out)
7842    }
7843}
7844
7845/// ── the FLOW half of a usage row ────────────────────────────────────────────
7846///
7847/// Everything else in a row is what the namespace HOLDS, and it reads the same
7848/// for a tenant who pushed this morning and a tenant who last touched this box
7849/// in March. These seven are what MOVED, read off the flow ledger's live
7850/// atomics — no walk, no disk, one hash lookup — so that a console showing a
7851/// usage row can tell a working customer from a silent one without a second
7852/// call.
7853///
7854/// A namespace nothing has moved through today has zeroes here, and for a flow
7855/// meter a zero is a fact: a transfer either occurred or it did not.
7856/// `last_push_unix_ms` and `last_fetch_unix_ms` are the exception and look back
7857/// over the WHOLE kept window, because "how long has this tenant been quiet" is
7858/// the question they exist for.
7859fn fill_flow(
7860    row: &mut gunnar_proto::NamespaceUsage,
7861    flow: &gunnar_core::FlowLedger,
7862    name: &str,
7863    now_unix_ms: i64,
7864) {
7865    let glance = flow.glance(name, now_unix_ms);
7866    row.bytes_in_today = glance.today.bytes_in;
7867    row.bytes_out_today = glance.today.bytes_out;
7868    row.pushes_today = glance.today.pushes;
7869    row.fetches_today = glance.today.fetches;
7870    row.refused_today = glance.today.refused();
7871    row.last_push_unix_ms = glance.last_push_unix_ms;
7872    row.last_fetch_unix_ms = glance.last_fetch_unix_ms;
7873}
7874
7875/// Loose object count and total bytes under `objects/`.
7876///
7877/// Walked rather than estimated: `Admin.StorageInfo` exists so an operator can
7878/// see what is actually on the disk, and a number that came from a guess would
7879/// be worse than no number.
7880fn object_dir_stats(git_dir: &std::path::Path) -> (u64, u64) {
7881    fn walk(dir: &std::path::Path, loose: &mut u64, bytes: &mut u64, in_loose_shard: bool) {
7882        let Ok(entries) = std::fs::read_dir(dir) else {
7883            return;
7884        };
7885        for entry in entries.flatten() {
7886            let Ok(kind) = entry.file_type() else {
7887                continue;
7888            };
7889            let name = entry.file_name();
7890            let name = name.to_string_lossy();
7891            if kind.is_dir() {
7892                // A loose-object shard is two hex characters.
7893                let shard = name.len() == 2 && name.chars().all(|c| c.is_ascii_hexdigit());
7894                walk(&entry.path(), loose, bytes, shard);
7895            } else if kind.is_file() {
7896                if in_loose_shard {
7897                    *loose += 1;
7898                }
7899                if let Ok(meta) = entry.metadata() {
7900                    *bytes += meta.len();
7901                }
7902            }
7903        }
7904    }
7905    let mut loose = 0;
7906    let mut bytes = 0;
7907    walk(&git_dir.join("objects"), &mut loose, &mut bytes, false);
7908    (loose, bytes)
7909}
7910
7911/// **One repository's bytes, attributed per file.** The one walk both
7912/// `Admin.StorageInfo` and `Usage.Read` are summed from (one implementation), so the two
7913/// cannot disagree about what a directory weighs.
7914///
7915/// The classification is by file name and is engine-neutral on purpose:
7916///
7917/// | file | meter |
7918/// |---|---|
7919/// | `<git_dir>/lfs/**` | `lfs` |
7920/// | `…/objects.exploded*` (znippy's eager and lazy pools) | `cache` |
7921/// | everything else — `objects.pack` + journal, `objects.index` (the persisted oid index, 2026-09-04), `objects.tail`, `objects.graph`, `refs.log`, `OBJECT_FORMAT`; gix's `objects/**` and refs (`objects.ordinal` is gone as of 2026-09-04 — the index's oid column is the ordinal space; a stale one is deleted at open) | `pack` |
7922///
7923/// `pack` is therefore "the hot tier minus the exploded cache" exactly as
7924/// `usage.proto` promises — it includes the derived index files, which are
7925/// re-derivable, because that is the contract monetize's own client-side sum
7926/// reaches and two definitions of one bill is the thing to avoid. The file
7927/// list is written in `usage.proto`'s header; a new file the engine grows lands
7928/// in `pack` until somebody names it, which errs on the side of the operator
7929/// seeing it rather than not.
7930#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
7931pub struct RepoBytes {
7932    /// The truth and its indexes: everything that is neither cache nor LFS.
7933    pub pack: u64,
7934    /// znippy's exploded read cache, as it is on the disk right now.
7935    pub cache: u64,
7936    /// LFS objects, committed and in flight.
7937    pub lfs: u64,
7938}
7939
7940impl RepoBytes {
7941    /// The whole directory minus LFS: what `RepoStorage.hot_bytes` reports.
7942    pub fn hot(&self) -> u64 {
7943        self.pack + self.cache
7944    }
7945}
7946
7947/// See [`RepoBytes`].
7948pub fn repo_bytes(git_dir: &std::path::Path) -> RepoBytes {
7949    fn walk(dir: &std::path::Path, acc: &mut RepoBytes) {
7950        let Ok(entries) = std::fs::read_dir(dir) else {
7951            return;
7952        };
7953        for entry in entries.flatten() {
7954            let Ok(meta) = entry.metadata() else {
7955                continue;
7956            };
7957            if meta.is_dir() {
7958                walk(&entry.path(), acc);
7959            } else if entry
7960                .file_name()
7961                .to_string_lossy()
7962                .starts_with("objects.exploded")
7963            {
7964                acc.cache += meta.len();
7965            } else {
7966                acc.pack += meta.len();
7967            }
7968        }
7969    }
7970    let mut acc = RepoBytes::default();
7971    let Ok(entries) = std::fs::read_dir(git_dir) else {
7972        return acc;
7973    };
7974    for entry in entries.flatten() {
7975        let Ok(meta) = entry.metadata() else {
7976            continue;
7977        };
7978        if meta.is_dir() && entry.file_name() == gunnar_lfs::fs_store::LFS_DIR {
7979            acc.lfs += crate::stores::dir_bytes(&entry.path());
7980        } else if meta.is_dir() {
7981            walk(&entry.path(), &mut acc);
7982        } else {
7983            acc.pack += meta.len();
7984        }
7985    }
7986    acc
7987}
7988
7989// ───────────────────────────────────────────────────────────────────────────
7990// ACL identity: the gRPC gate's two questions, answered by `AuthTable`
7991// ───────────────────────────────────────────────────────────────────────────
7992
7993/// **The one place gRPC identity is decided, and it decides nothing itself.**
7994///
7995/// `gunnar-grpc` closed the allow-all authentication hole with SSH-key-derived
7996/// identity: a caller signs a single-use challenge with a key that is already
7997/// in the ACL key table, and the gate asks the control plane two questions:
7998/// *whose key is this*, and *may they*. Both are delegated here, and both land
7999/// on [`AuthTable`]:
8000///
8001/// | question | answered by |
8002/// |---|---|
8003/// | whose key is this | [`AuthTable::principal_by_fingerprint`] (expiry included) |
8004/// | may they | [`AuthTable::may_server`] / [`AuthTable::may_namespace`] / [`AuthTable::may`] |
8005///
8006/// So gRPC and SSH share one identity table and **one** ACL. Revoking a key
8007/// over gRPC revokes it for git in the same instant, and there is no second
8008/// grant decision that could drift from the first — one implementation,
8009/// because two disagreeing security checks have cost this design once already.
8010impl gunnar_grpc::Identity for CoreControlPlane {
8011    /// The installer's feed, as `crate::fed_principals::note_fed` left it: the
8012    /// keys this primary will enrol and has not yet.
8013    fn awaiting_enrolment(&self, fingerprint: &str) -> Option<String> {
8014        self.stores.awaiting_enrolment(fingerprint)
8015    }
8016
8017    fn registered_key(
8018        &self,
8019        fingerprint: &str,
8020    ) -> ControlResult<Option<gunnar_grpc::RegisteredKey>> {
8021        // A malformed fingerprint is "no such key", not an error: the string
8022        // came off a public port and refusing to parse it loudly would make
8023        // this an oracle for what a well-formed fingerprint looks like.
8024        let Ok(parsed) = gunnar_core::Fingerprint::parse(fingerprint) else {
8025            return Ok(None);
8026        };
8027        // `principal_by_fingerprint` is the SSH listener's own lookup; it
8028        // already refuses an expired key and a key whose principal is gone.
8029        let Some(principal) = self
8030            .stores
8031            .auth()
8032            .principal_by_fingerprint(&parsed)
8033            .map_err(internal)?
8034        else {
8035            return Ok(None);
8036        };
8037        let key = self
8038            .stores
8039            .auth()
8040            .keys_of(&principal.id)
8041            .map_err(internal)?
8042            .into_iter()
8043            .find(|key| key.fingerprint_sha256 == fingerprint);
8044        let machine = principal.kind == gunnar_core::PrincipalKind::Machine;
8045        Ok(key.map(|key| gunnar_grpc::RegisteredKey {
8046            principal_id: principal.id,
8047            machine,
8048            // The `authorized_keys` line the key was registered from. The gate
8049            // verifies the sshsig against it and re-derives the fingerprint
8050            // from it, so a wrong row here cannot become a wrong identity.
8051            openssh: key.openssh,
8052        }))
8053    }
8054
8055    /// The X.509 half of the **same** table `registered_key` reads.
8056    ///
8057    /// `gunnar control bootstrap` has minted client certificates and registered
8058    /// them under `algo: "x509"` since the certificate-identity plan's first-boot
8059    /// half landed; until the listener grew mTLS there was nothing that could
8060    /// look one up, so those registrations were inert. This is the lookup, and it
8061    /// is deliberately the
8062    /// same three steps `registered_key` takes — hash, principal, key row — so
8063    /// that revoking a certificate over gRPC revokes it for the handshake in
8064    /// the same instant, out of one table (one implementation).
8065    ///
8066    /// No `openssh` line comes back and none is wanted: the handshake already
8067    /// proved possession, so there is no second signature to check. See
8068    /// [`gunnar_grpc::CertificateIdentity`].
8069    fn registered_certificate(
8070        &self,
8071        der: &[u8],
8072    ) -> ControlResult<Option<gunnar_grpc::CertificateIdentity>> {
8073        // The one place that decides what bytes a certificate credential is.
8074        // `gunnar-grpc` hands the DER across the seam rather than a
8075        // fingerprint precisely so this stays the only spelling of it.
8076        let fingerprint = gunnar_core::auth::fingerprint_of_certificate(der);
8077        let Some(principal) = self
8078            .stores
8079            .auth()
8080            .principal_by_fingerprint(&fingerprint)
8081            .map_err(internal)?
8082        else {
8083            return Ok(None);
8084        };
8085        // `principal_by_fingerprint` already refuses an expired key and a key
8086        // whose principal is gone. The row is re-read so that a fingerprint
8087        // that resolves to a principal but has no key row — a state only a
8088        // corrupted table can be in — is "no identity" rather than an
8089        // identity with no credential behind it.
8090        let registered = self
8091            .stores
8092            .auth()
8093            .keys_of(&principal.id)
8094            .map_err(internal)?
8095            .into_iter()
8096            .any(|key| key.fingerprint_sha256 == fingerprint.to_string());
8097        if !registered {
8098            return Ok(None);
8099        }
8100        Ok(Some(gunnar_grpc::CertificateIdentity {
8101            principal_id: principal.id,
8102            fingerprint: fingerprint.to_string(),
8103        }))
8104    }
8105
8106    /// f-principal-reach: what `fed_principals::install` narrowed a `billing`
8107    /// principal to, from the data root.
8108    fn narrowed_methods(&self, principal_id: &str) -> ControlResult<Option<Vec<String>>> {
8109        // ★ The estate's repair principal is narrowed by its NAME, on every box and in every
8110        // build (main rebuild, owner 2026-10-12): never admin, whatever it was granted.
8111        if principal_id == gunnar_grpc::REPAIR_PRINCIPAL {
8112            return Ok(Some(
8113                gunnar_grpc::REPAIR_METHODS
8114                    .iter()
8115                    .map(|m| m.to_string())
8116                    .collect(),
8117            ));
8118        }
8119        // The narrowing is written by the fed principals (the `wg-client` build,
8120        // the appliance's); a build without them narrows nobody.
8121        #[cfg(feature = "wg-client")]
8122        return crate::fed_principals::narrowed_of(self.stores.root(), principal_id)
8123            .map_err(ControlError::Internal);
8124        #[cfg(not(feature = "wg-client"))]
8125        {
8126            let _ = principal_id;
8127            Ok(None)
8128        }
8129    }
8130
8131    fn may(
8132        &self,
8133        principal_id: &str,
8134        access: &gunnar_grpc::Access,
8135        level: gunnar_proto::GrantLevel,
8136    ) -> ControlResult<bool> {
8137        let level = level_in(level)?;
8138        let auth = self.stores.auth();
8139        match access {
8140            gunnar_grpc::Access::Server => auth.may_server(principal_id, level).map_err(internal),
8141            gunnar_grpc::Access::Namespace(namespace) => auth
8142                .may_namespace(principal_id, namespace, level)
8143                .map_err(internal),
8144            gunnar_grpc::Access::Repo(path) => {
8145                let repo_ref = Self::repo_ref(path)?;
8146                // A grant on a repository that does not exist means nothing.
8147                // Answering `true` here would let a caller probe the ACL for
8148                // repositories it cannot see.
8149                let Some(repo) = self.stores.catalog().get(&repo_ref).map_err(internal)? else {
8150                    return Ok(false);
8151                };
8152                auth.may(principal_id, &repo, level).map_err(internal)
8153            }
8154        }
8155    }
8156
8157    /// One read of the catalog and one of the identity table for the whole
8158    /// listing (round 5.3.1); a row that does not exist is `false`, as `may`.
8159    fn may_each_repo(
8160        &self,
8161        principal_id: &str,
8162        paths: &[RepoPath],
8163        level: gunnar_proto::GrantLevel,
8164    ) -> ControlResult<Vec<bool>> {
8165        let level = level_in(level)?;
8166        let refs = paths
8167            .iter()
8168            .map(Self::repo_ref)
8169            .collect::<ControlResult<Vec<_>>>()?;
8170        let rows = self.stores.catalog().get_many(&refs).map_err(internal)?;
8171        let known: Vec<gunnar_core::model::Repo> = rows.iter().flatten().cloned().collect();
8172        let mut answers = self
8173            .stores
8174            .auth()
8175            .may_each(principal_id, &known, level)
8176            .map_err(internal)?
8177            .into_iter();
8178        Ok(rows
8179            .iter()
8180            .map(|row| row.is_some() && answers.next().unwrap_or(false))
8181            .collect())
8182    }
8183}
8184
8185// ───────────────────────────────────────────────────────────────────────────
8186// Policy: the proto shape and the engine shape
8187// ───────────────────────────────────────────────────────────────────────────
8188
8189/// One `gunnar_policy::ProtectionRule`, as the protocol spells it.
8190///
8191/// The two limit fields come back **0**, which the proto documents as "no
8192/// limit", and that is the truth about a *per-rule* limit: this engine has
8193/// none. The repository-wide ceilings live in the policy document's `limits`
8194/// and are reported by nothing on this service — see `set_protection` for why
8195/// writing them here is refused rather than approximated.
8196fn protection_out(rule: &gunnar_policy::ProtectionRule) -> BranchProtection {
8197    BranchProtection {
8198        ref_pattern: rule.pattern.as_str().to_owned(),
8199        allow_force_push: rule.allow_force_push,
8200        allow_delete: rule.allow_delete,
8201        require_signature: rule.require_signature,
8202        // `None` — "anybody" — is the empty list, which is what the proto
8203        // means by empty. The round trip is stable in both directions.
8204        allowed_principal_ids: rule.allowed_principals.clone().unwrap_or_default(),
8205        max_object_bytes: 0,
8206        max_objects_per_push: 0,
8207        forbid_self_approval: rule.forbid_self_approval,
8208        required_approvals: rule.required_approvals,
8209    }
8210}
8211
8212fn ref_name_policy_out(policy: &gunnar_policy::RefNamePolicy) -> RefNamePolicy {
8213    RefNamePolicy {
8214        allow_patterns: policy.allow.iter().map(|p| p.as_str().to_owned()).collect(),
8215        deny_patterns: policy.deny.iter().map(|p| p.as_str().to_owned()).collect(),
8216    }
8217}
8218
8219/// One `gunnar_core::MirrorRecord`, as the protocol spells it.
8220///
8221/// The state is **derived from the record's own facts**, not stored beside
8222/// them: `SYNCING` is a live condition and nothing here runs a sync, so a
8223/// stored `SYNCING` could only ever be a lie left behind by a crash. What the
8224/// record can honestly report is `FAILED` when the last attempt left an error
8225/// and `IDLE` otherwise — and a registration nobody has synced is `IDLE`, which
8226/// is what it is.
8227fn mirror_out(record: &gunnar_core::MirrorRecord) -> MirrorConfig {
8228    let (namespace, repo) = record
8229        .repo
8230        .split_once('/')
8231        .unwrap_or((record.repo.as_str(), ""));
8232    MirrorConfig {
8233        repo: Some(RepoPath::new(namespace, repo)),
8234        remote_url: record.remote_url.clone(),
8235        prune: record.prune,
8236        state: if record.last_error.is_empty() {
8237            gunnar_proto::MirrorState::Idle as i32
8238        } else {
8239            gunnar_proto::MirrorState::Failed as i32
8240        },
8241        added_at_unix_ms: record.added_at_unix_ms,
8242        last_sync_at_unix_ms: record.last_sync_at_unix_ms,
8243        last_error: record.last_error.clone(),
8244        sync_count: record.sync_count,
8245        // **The kind, and never the value.** `WebhookEndpoint.has_secret`'s
8246        // rule, one step wider because there are two kinds here and picking the
8247        // wrong one is a refusal an operator has to be able to see coming.
8248        credential: credential_kind_out(record.credential) as i32,
8249        credential_username: record.credential_username.clone(),
8250        // Filled in by the caller that holds the queue; a bare record does not
8251        // know whether a job exists for it.
8252        job: None,
8253        repo_pending: record.creates_repo,
8254        lfs_not_copied: record.lfs_detected,
8255        ca_subject: record.ca_subject.clone(),
8256        ca_fingerprint_sha256: record.ca_fingerprint_sha256.clone(),
8257    }
8258}
8259
8260/// **A mirror's private CA, DER** (GUN-MIRROR-CA): what its order trusts beside the
8261/// public roots, for its own calls alone. Empty when none is set.
8262fn mirror_ca_of(record: &gunnar_core::MirrorRecord) -> Vec<Vec<u8>> {
8263    record
8264        .ca_pem
8265        .as_deref()
8266        .map(gunnar_grpc::sso::idp_ca::idp_ca_blocks)
8267        .unwrap_or_default()
8268}
8269
8270/// The proto's credential kind onto the store's.
8271fn credential_kind_in(
8272    kind: gunnar_proto::MirrorCredentialKind,
8273) -> gunnar_core::MirrorCredentialKind {
8274    match kind {
8275        gunnar_proto::MirrorCredentialKind::Unspecified => gunnar_core::MirrorCredentialKind::None,
8276        gunnar_proto::MirrorCredentialKind::SshKey => gunnar_core::MirrorCredentialKind::SshKey,
8277        gunnar_proto::MirrorCredentialKind::Token => gunnar_core::MirrorCredentialKind::Token,
8278    }
8279}
8280
8281/// The store's credential kind onto the proto's.
8282fn credential_kind_out(
8283    kind: gunnar_core::MirrorCredentialKind,
8284) -> gunnar_proto::MirrorCredentialKind {
8285    match kind {
8286        gunnar_core::MirrorCredentialKind::None => gunnar_proto::MirrorCredentialKind::Unspecified,
8287        gunnar_core::MirrorCredentialKind::SshKey => gunnar_proto::MirrorCredentialKind::SshKey,
8288        gunnar_core::MirrorCredentialKind::Token => gunnar_proto::MirrorCredentialKind::Token,
8289    }
8290}
8291
8292/// **Unseal the credential a sync dials with, or say there is none.**
8293///
8294/// The document is the authority on WHETHER there is one and the secret store
8295/// on WHAT it is, and a disagreement between them is reported rather than
8296/// smoothed over: a record that claims a credential whose sealed value is gone
8297/// would otherwise dial anonymously and be refused by the upstream, which sends
8298/// an operator to rotate a key that is not the problem.
8299///
8300/// The returned [`crate::mirror_fetch::Credential`] is deliberately not
8301/// `Debug`, so nothing downstream can format it by accident.
8302fn read_credential(
8303    stores: &Arc<ServerStores>,
8304    repo: &gunnar_core::RepoRef,
8305) -> Result<crate::mirror_fetch::Credential, String> {
8306    let record = stores
8307        .mirrors()
8308        .get(repo)
8309        .map_err(|err| {
8310            format!(
8311                "reading the mirror registration for {}: {err:#}",
8312                repo.key()
8313            )
8314        })?
8315        .ok_or_else(|| format!("no mirror is registered for {} any more", repo.key()))?;
8316    if !record.credential.is_set() {
8317        return Ok(crate::mirror_fetch::Credential::None);
8318    }
8319    let secrets = stores
8320        .secrets()
8321        .map_err(|err| format!("the sealed secret store is not available: {err:#}"))?;
8322    let key = gunnar_core::credential_secret_key(repo);
8323    let sealed = gunnar_store::SecretStore::get(&secrets, &key)
8324        // `{err}` and not `{err:#}`: this string lands on a job a console
8325        // renders, and a chain from the secret store is where a value could
8326        // ride out.
8327        .map_err(|err| {
8328            format!(
8329                "the mirror credential for {} could not be read: {err}",
8330                repo.key()
8331            )
8332        })?
8333        .ok_or_else(|| {
8334            format!(
8335                "{} is registered as having {}, and the sealed value is not in the secret \
8336                 store. Set it again with `Mirror.SetCredential`; dialling anonymously \
8337                 instead would be refused by the upstream and read as a bad key.",
8338                repo.key(),
8339                record.credential.name()
8340            )
8341        })?;
8342    Ok(match record.credential {
8343        gunnar_core::MirrorCredentialKind::SshKey => {
8344            crate::mirror_fetch::Credential::SshKey(sealed)
8345        }
8346        gunnar_core::MirrorCredentialKind::Token => crate::mirror_fetch::Credential::Token {
8347            username: record.credential_username.clone(),
8348            secret: String::from_utf8(sealed).map_err(|_| {
8349                format!(
8350                    "the token stored for {} is not utf-8, so it cannot be an HTTP header value",
8351                    repo.key()
8352                )
8353            })?,
8354        },
8355        gunnar_core::MirrorCredentialKind::None => crate::mirror_fetch::Credential::None,
8356    })
8357}
8358
8359/// **One line in the namespace's account audit log** about a mirror sync, and
8360/// the same on the `gunnar::audit` target (a namespace with no account, a
8361/// community box, has the target only).
8362fn audit_line(
8363    stores: &ServerStores,
8364    repo: &gunnar_core::RepoRef,
8365    kind: &str,
8366    detail: &str,
8367    refused: bool,
8368) {
8369    tracing::info!(
8370        target: gunnar_grpc::AUDIT_TARGET,
8371        event = kind,
8372        repo = %repo.key(),
8373        detail,
8374        "mirror"
8375    );
8376    let Ok(Some(account)) = stores.auth().account_of_namespace(repo.namespace()) else {
8377        return;
8378    };
8379    let event = gunnar_core::account_audit::AccountEvent {
8380        kind,
8381        actor: "",
8382        actor_role: "system",
8383        target: &repo.key(),
8384        detail,
8385        outcome: if refused {
8386            gunnar_core::account_audit::Outcome::Refused
8387        } else {
8388            gunnar_core::account_audit::Outcome::Ok
8389        },
8390    };
8391    if let Err(err) = stores
8392        .account_audit()
8393        .append(&account, event, gunnar_core::now_ms())
8394    {
8395        tracing::warn!(target: "gunnar::git", repo = %repo.key(), %err, "a mirror sync's audit line was not written");
8396    }
8397}
8398
8399/// **How a sync ended, audited**: an import's (a first sync's) outcome always,
8400/// a later sync's only when it failed — a mirror polled every few minutes
8401/// would otherwise fill the log with "nothing new".
8402fn audit_mirror_outcome(
8403    stores: &ServerStores,
8404    repo: &gunnar_core::RepoRef,
8405    order: &crate::mirror_queue::MirrorOrder,
8406    first: bool,
8407    error: Option<&str>,
8408    report: &crate::job_queue::Reporter<crate::mirror_queue::Mirror>,
8409) {
8410    let upstream = crate::mirror_fetch::scrub(&order.upstream);
8411    match (first, error) {
8412        (true, None) => {
8413            let refs = report
8414                .snapshot()
8415                .map(|job| job.report.updates.len())
8416                .unwrap_or_default();
8417            audit_line(
8418                stores,
8419                repo,
8420                "mirror.import.done",
8421                &format!("imported {refs} reference(s) from {upstream}"),
8422                false,
8423            );
8424        }
8425        (true, Some(why)) => audit_line(
8426            stores,
8427            repo,
8428            "mirror.import.failed",
8429            &format!("from {upstream}: {why}"),
8430            true,
8431        ),
8432        (false, Some(why)) => audit_line(
8433            stores,
8434            repo,
8435            "mirror.sync.failed",
8436            &format!("from {upstream}: {why}"),
8437            true,
8438        ),
8439        (false, None) => {}
8440    }
8441}
8442
8443/// **The one place a [`crate::mirror_queue::MirrorJob`] becomes a wire job.**
8444///
8445/// `gc_job_out`'s twin, and shaped after it deliberately: three status RPCs
8446/// render the same queue fields, so a console reads a gc, a purge and a mirror
8447/// with one renderer.
8448///
8449/// `path` is threaded in rather than re-parsed out of the job's own key — it is
8450/// the request's own path, already validated, and re-parsing it here would be a
8451/// second resolver that could disagree with the first (one implementation).
8452fn mirror_job_out(path: &RepoPath, job: crate::mirror_queue::MirrorJob) -> gunnar_proto::MirrorJob {
8453    // The mirror's own half, taken out whole before the queue's half is: the
8454    // update list is owned and has to be MOVED onto the wire, and a job's
8455    // `Deref` only ever lends its report.
8456    let report = job.report;
8457    let report_refused = report.refused;
8458    let (progress_done, progress_total) = job.progress.unwrap_or((0, 0));
8459    gunnar_proto::MirrorJob {
8460        job_id: job.job_id,
8461        path: Some(path.clone()),
8462        state: match job.state {
8463            crate::mirror_queue::MirrorState::Queued => gunnar_proto::MirrorJobState::Queued as i32,
8464            crate::mirror_queue::MirrorState::Running => {
8465                gunnar_proto::MirrorJobState::Running as i32
8466            }
8467            crate::mirror_queue::MirrorState::Done => gunnar_proto::MirrorJobState::Done as i32,
8468            crate::mirror_queue::MirrorState::Failed => gunnar_proto::MirrorJobState::Failed as i32,
8469        },
8470        queue_position: job.queue_position,
8471        // **`has_progress` first.** A job that has not reached the packfile
8472        // header has no denominator, and `0/0` would be a progress bar
8473        // claiming zero per cent instead of admitting it does not know yet.
8474        has_progress: job.progress.is_some(),
8475        progress_done,
8476        progress_total,
8477        objects: report.objects,
8478        bytes: report.bytes,
8479        updates: report
8480            .updates
8481            .into_iter()
8482            .map(|(ref_name, old_oid, new_oid)| gunnar_proto::RefUpdate {
8483                ref_name,
8484                old_oid,
8485                new_oid,
8486            })
8487            .collect(),
8488        error: job.error.unwrap_or_default(),
8489        enqueued_at_unix_ms: job.enqueued_at_unix_ms,
8490        started_at_unix_ms: job.started_at_unix_ms,
8491        finished_at_unix_ms: job.finished_at_unix_ms,
8492        // Only a FAILED job carries the plan's refusal; a later success of
8493        // the same registration starts a new job with a fresh report.
8494        refused_reason: match (job.state, report_refused) {
8495            (crate::mirror_queue::MirrorState::Failed, crate::mirror_queue::Refusal::Plan) => {
8496                gunnar_proto::MirrorRefusal::Plan as i32
8497            }
8498            (crate::mirror_queue::MirrorState::Failed, crate::mirror_queue::Refusal::PackCap) => {
8499                gunnar_proto::MirrorRefusal::PackCap as i32
8500            }
8501            _ => gunnar_proto::MirrorRefusal::Unspecified as i32,
8502        },
8503    }
8504}
8505
8506// ── the forge event, across the wire ───────────────────────────────────────
8507//
8508// Two functions and no derive, because the wire shape and the signed shape are
8509// deliberately different things: the signed shape is what `sshsig` covers and
8510// may never change to suit a transport, and the wire shape is a `oneof` because
8511// protobuf has no sum type otherwise. Keeping the conversion explicit is what
8512// makes a change to either one a compile error rather than a silent re-encoding
8513// that breaks every stored signature.
8514
8515/// The wire form of a signed membership event.
8516fn to_proto_membership(
8517    signed: gunnar_core::SignedMembershipEvent,
8518) -> gunnar_proto::SignedMembershipEvent {
8519    gunnar_proto::SignedMembershipEvent {
8520        event: Some(gunnar_proto::MembershipEvent {
8521            group: signed.event.group,
8522            member: signed.event.member,
8523            action: proto_action(signed.event.action) as i32,
8524            actor: signed.event.actor,
8525            at_unix_ms: signed.event.at_unix_ms,
8526        }),
8527        key_fingerprint: signed.key_fingerprint,
8528        signature: signed.signature,
8529    }
8530}
8531
8532/// The signed core form of a wire key attestation. An absent `attestation` is
8533/// refused rather than defaulted, for the reason `from_proto_membership` gives.
8534fn from_proto_attestation(
8535    wire: gunnar_proto::SignedKeyAttestation,
8536) -> ControlResult<gunnar_core::SignedKeyAttestation> {
8537    let attestation = wire.attestation.ok_or_else(|| {
8538        ControlError::InvalidArgument("`attestation.attestation` is required".to_owned())
8539    })?;
8540    Ok(gunnar_core::SignedKeyAttestation {
8541        attestation: gunnar_core::KeyAttestation {
8542            principal_id: attestation.principal_id,
8543            fingerprint: attestation.fingerprint,
8544            at_unix_ms: attestation.at_unix_ms,
8545        },
8546        key_fingerprint: wire.key_fingerprint,
8547        signature: wire.signature,
8548    })
8549}
8550
8551/// The signed core form of a wire membership event.
8552///
8553/// An absent `event` or an `UNSPECIFIED` action is refused rather than
8554/// defaulted: proto3 makes every field optional, and a membership change with
8555/// no action is not a thing that happened.
8556fn from_proto_membership(
8557    wire: gunnar_proto::SignedMembershipEvent,
8558) -> ControlResult<gunnar_core::SignedMembershipEvent> {
8559    let event = wire
8560        .event
8561        .ok_or_else(|| ControlError::InvalidArgument("`event` is required".to_owned()))?;
8562    let action = match gunnar_proto::MembershipAction::try_from(event.action) {
8563        Ok(gunnar_proto::MembershipAction::Add) => gunnar_core::MembershipAction::Add,
8564        Ok(gunnar_proto::MembershipAction::Remove) => gunnar_core::MembershipAction::Remove,
8565        _ => {
8566            return Err(ControlError::InvalidArgument(
8567                "`event.action` must be ADD or REMOVE".to_owned(),
8568            ))
8569        }
8570    };
8571    Ok(gunnar_core::SignedMembershipEvent {
8572        event: gunnar_core::MembershipEvent {
8573            group: event.group,
8574            member: event.member,
8575            action,
8576            actor: event.actor,
8577            at_unix_ms: event.at_unix_ms,
8578        },
8579        key_fingerprint: wire.key_fingerprint,
8580        signature: wire.signature,
8581    })
8582}
8583
8584/// The wire enum for a core [`gunnar_core::MembershipAction`].
8585fn proto_action(action: gunnar_core::MembershipAction) -> gunnar_proto::MembershipAction {
8586    match action {
8587        gunnar_core::MembershipAction::Add => gunnar_proto::MembershipAction::Add,
8588        gunnar_core::MembershipAction::Remove => gunnar_proto::MembershipAction::Remove,
8589    }
8590}
8591
8592/// The wire form of a signed event.
8593fn to_proto_event(signed: gunnar_core::SignedEvent) -> gunnar_proto::SignedForgeEvent {
8594    use gunnar_proto::forge_event::Kind;
8595
8596    let kind = match signed.event.event {
8597        gunnar_core::Event::Commented {
8598            body,
8599            path,
8600            line,
8601            blob_oid,
8602        } => Kind::Commented(gunnar_proto::Commented {
8603            body,
8604            path,
8605            line,
8606            blob_oid,
8607        }),
8608        gunnar_core::Event::Approved { commit } => {
8609            Kind::Approved(gunnar_proto::Approved { commit })
8610        }
8611        gunnar_core::Event::Rejected { commit, reason } => {
8612            Kind::Rejected(gunnar_proto::Rejected { commit, reason })
8613        }
8614        gunnar_core::Event::IssueOpened { title, body } => {
8615            Kind::IssueOpened(gunnar_proto::IssueOpened { title, body })
8616        }
8617        gunnar_core::Event::IssueCommented { body } => {
8618            Kind::IssueCommented(gunnar_proto::IssueCommented { body })
8619        }
8620        gunnar_core::Event::IssueClosed { reason } => {
8621            Kind::IssueClosed(gunnar_proto::IssueClosed { reason })
8622        }
8623    };
8624    gunnar_proto::SignedForgeEvent {
8625        event: Some(gunnar_proto::ForgeEvent {
8626            repo: signed.event.repo,
8627            pull: signed.event.pull,
8628            actor: signed.event.actor,
8629            at_unix_ms: signed.event.at_unix_ms,
8630            kind: Some(kind),
8631        }),
8632        key_fingerprint: signed.key_fingerprint,
8633        signature: signed.signature,
8634        attested: signed.attested.map(|a| gunnar_proto::ForgeAttestation {
8635            session_stepped_up_at_unix_ms: a.session_stepped_up_at_unix_ms,
8636        }),
8637    }
8638}
8639
8640/// A folded [`gunnar_core::forge::Issue`], as the wire carries it.
8641///
8642/// Unlike a review, this IS the whole state — title, body, open — because there
8643/// is no record behind an issue for a reader to ask instead. The signatures on
8644/// the events are what make it evidence; the fold is what makes it a state.
8645fn issue_state(repo: &str, issue: &gunnar_core::forge::Issue) -> gunnar_proto::IssueState {
8646    gunnar_proto::IssueState {
8647        repo: repo.to_owned(),
8648        number: issue.number,
8649        title: issue.title.clone(),
8650        body: issue.body.clone(),
8651        author: issue.author.clone(),
8652        open: issue.open,
8653        comments: issue.comments as u64,
8654        opened_at_unix_ms: issue.opened_at_unix_ms,
8655    }
8656}
8657
8658/// The signed form of a wire event.
8659///
8660/// An absent `kind` is refused rather than defaulted: proto3 makes every field
8661/// optional, and a `ForgeEvent` with no kind is not a thing that happened.
8662fn from_proto_event(
8663    wire: gunnar_proto::SignedForgeEvent,
8664) -> ControlResult<gunnar_core::SignedEvent> {
8665    use gunnar_proto::forge_event::Kind;
8666
8667    let event = wire
8668        .event
8669        .ok_or_else(|| ControlError::InvalidArgument("`event` is required".to_owned()))?;
8670    let kind = event
8671        .kind
8672        .ok_or_else(|| ControlError::InvalidArgument("`event.kind` is required".to_owned()))?;
8673    let inner = match kind {
8674        Kind::Commented(it) => gunnar_core::Event::Commented {
8675            body: it.body,
8676            path: it.path,
8677            line: it.line,
8678            blob_oid: it.blob_oid,
8679        },
8680        Kind::Approved(it) => gunnar_core::Event::Approved { commit: it.commit },
8681        Kind::Rejected(it) => gunnar_core::Event::Rejected {
8682            commit: it.commit,
8683            reason: it.reason,
8684        },
8685        Kind::IssueOpened(it) => gunnar_core::Event::IssueOpened {
8686            title: it.title,
8687            body: it.body,
8688        },
8689        Kind::IssueCommented(it) => gunnar_core::Event::IssueCommented { body: it.body },
8690        Kind::IssueClosed(it) => gunnar_core::Event::IssueClosed { reason: it.reason },
8691    };
8692    Ok(gunnar_core::SignedEvent {
8693        event: gunnar_core::ForgeEvent {
8694            repo: event.repo,
8695            pull: event.pull,
8696            actor: event.actor,
8697            at_unix_ms: event.at_unix_ms,
8698            event: inner,
8699        },
8700        key_fingerprint: wire.key_fingerprint,
8701        signature: wire.signature,
8702        // A client's word never becomes gunnar's attestation: `forge_append`
8703        // refuses a wire event that carries one, and only
8704        // `forge_append_attested` makes one.
8705        attested: None,
8706    })
8707}
8708#[cfg(test)]
8709mod tests {
8710    use super::*;
8711
8712    /// ★ **A box being RESTORED answers a write UNAVAILABLE, never INTERNAL** (r5.1, integration
8713    /// dev run 3: the front certifying the twin against a main being restored read "the primary
8714    /// would not enrol gunnar-twin: the membership: Internal: internal error" for minutes — a bug
8715    /// to anyone reading the log, and to the guest). Its log follows the twin's until the restore
8716    /// is whole, so every write is refused: said as "try again", which a generated client retries.
8717    #[test]
8718    fn a_write_on_a_restoring_box_is_unavailable_not_internal() {
8719        let td = tempfile::tempdir().unwrap();
8720        let stores = crate::stores::ServerStores::open(td.path()).unwrap();
8721        stores.txlog().set_follower(true);
8722        let p = gunnar_core::Principal::new("alice", "alice", gunnar_core::PrincipalKind::User)
8723            .unwrap();
8724        let err = stores
8725            .auth()
8726            .add_principal(p)
8727            .map_err(internal)
8728            .expect_err("a follower log takes no write");
8729        assert!(matches!(err, ControlError::Unavailable(_)), "{err:?}");
8730        assert!(err.to_string().contains("restor"), "{err}");
8731    }
8732
8733    /// **`INTERNAL` must be refused by name, never widened.**
8734    ///
8735    /// The regression this locks down was real: `visibility_in` aliased
8736    /// `INTERNAL` onto `Public` from before 2026-08-03, when public still meant
8737    /// "any authenticated principal". After that the alias silently PUBLISHED
8738    /// repositories whose creator asked for the narrower thing, and the
8739    /// console's visibility picker walked straight into it (2026-08-19).
8740    /// The green arms are the identity-value guard: without them a
8741    /// `visibility_in` that refuses everything would pass the red arm too.
8742    #[test]
8743    fn internal_is_refused_by_name_and_the_two_real_visibilities_pass() {
8744        let err = visibility_in(gunnar_proto::Visibility::Internal)
8745            .expect_err("INTERNAL has no implementation and must be refused");
8746        let text = err.to_string();
8747        assert!(
8748            text.contains("internal") && text.contains("private"),
8749            "the refusal must name the value and the real choices: {text}"
8750        );
8751        assert_eq!(
8752            visibility_in(gunnar_proto::Visibility::Public).expect("public is real"),
8753            Visibility::Public
8754        );
8755        assert_eq!(
8756            visibility_in(gunnar_proto::Visibility::Private).expect("private is real"),
8757            Visibility::Private
8758        );
8759        // Unspecified still lands on the only safe default.
8760        assert_eq!(
8761            visibility_in(gunnar_proto::Visibility::Unspecified).expect("unspecified defaults"),
8762            Visibility::Private
8763        );
8764    }
8765
8766    /// **The abdication flush ceiling is env-tunable, but only UPWARD-sane.**
8767    ///
8768    /// The flush is a poll (drained-or-deadline); the ceiling only bounds how
8769    /// long a slow standby is given before the roster clears. A loaded rig may
8770    /// need more than the 30 s default, so `GUNNAR_ABDICATE_FLUSH_SECS` raises
8771    /// it — but a garbage or zero value must NOT collapse it to something that
8772    /// truncates the flush before a single job drains. The identity-value guard
8773    /// here is the default-path assert: without it a resolver that ignored the
8774    /// override entirely (always returning 30) would still pass the "bad input
8775    /// falls back" arms, so the `120 -> 120` arm is what proves the override is
8776    /// actually read.
8777    #[cfg(feature = "replication")]
8778    #[test]
8779    fn abdicate_flush_ceiling_honours_a_sane_override_and_rejects_the_rest() {
8780        // A real, larger ceiling for a loaded box is taken verbatim.
8781        assert_eq!(
8782            resolve_flush_secs(Some("120")),
8783            120,
8784            "a positive override must win"
8785        );
8786        assert_eq!(
8787            resolve_flush_secs(Some("  90 ")),
8788            90,
8789            "surrounding whitespace is trimmed"
8790        );
8791        // The default when nothing is set — and it must NOT equal the override
8792        // above, or the test could not tell a read override from a dead one.
8793        assert_eq!(resolve_flush_secs(None), ABDICATE_FLUSH_SECS);
8794        assert_ne!(
8795            ABDICATE_FLUSH_SECS, 120,
8796            "default must differ from the override arm"
8797        );
8798        // Every wrong shape falls back to the safe default, never to 0.
8799        assert_eq!(
8800            resolve_flush_secs(Some("")),
8801            ABDICATE_FLUSH_SECS,
8802            "blank falls back"
8803        );
8804        assert_eq!(
8805            resolve_flush_secs(Some("nope")),
8806            ABDICATE_FLUSH_SECS,
8807            "non-numeric falls back"
8808        );
8809        assert_eq!(
8810            resolve_flush_secs(Some("0")),
8811            ABDICATE_FLUSH_SECS,
8812            "zero would truncate — refused"
8813        );
8814        assert_eq!(
8815            resolve_flush_secs(Some("-5")),
8816            ABDICATE_FLUSH_SECS,
8817            "negative does not parse as u64"
8818        );
8819    }
8820}
8821
8822/// An encryption-key refusal as the control plane says it (CRYPT-YUBI).
8823fn key_refusal(r: gunnar_core::encryption_key::KeyRefusal) -> ControlError {
8824    use gunnar_core::encryption_key::KeyRefusal as R;
8825    match r {
8826        R::AlreadyHasKey(s) | R::MethodHeld(s) => ControlError::AlreadyExists(s),
8827        R::NoSuchMethod(s) => ControlError::NotFound(s),
8828        R::NoKey(s) => ControlError::FailedPrecondition(s),
8829        R::LastMethod(s) => ControlError::FailedPrecondition(s),
8830        R::Invalid(s) => ControlError::InvalidArgument(s),
8831        R::Store(e) => internal(e),
8832    }
8833}
8834
8835fn method_in(
8836    m: gunnar_proto::UnlockMethod,
8837) -> ControlResult<gunnar_core::encryption_key::UnlockMethod> {
8838    Ok(gunnar_core::encryption_key::UnlockMethod {
8839        id: String::new(),
8840        kind: gunnar_core::encryption_key::MethodKind::parse(&m.kind).map_err(key_refusal)?,
8841        label: m.label.trim().to_owned(),
8842        method_ref: m.method_ref.trim().to_owned(),
8843        rp_id: m.rp_id.trim().to_owned(),
8844        wrap: m.wrap,
8845        added_ms: 0,
8846    })
8847}
8848
8849fn encryption_key_out(
8850    key: Option<gunnar_core::encryption_key::EncryptionKey>,
8851) -> gunnar_proto::GetEncryptionKeyResponse {
8852    match key {
8853        None => gunnar_proto::GetEncryptionKeyResponse::default(),
8854        Some(k) => gunnar_proto::GetEncryptionKeyResponse {
8855            exists: true,
8856            public_key: k.public_key,
8857            methods: k
8858                .methods
8859                .into_iter()
8860                .map(|m| gunnar_proto::UnlockMethod {
8861                    id: m.id,
8862                    kind: m.kind.word().to_owned(),
8863                    label: m.label,
8864                    method_ref: m.method_ref,
8865                    rp_id: m.rp_id,
8866                    wrap: m.wrap,
8867                    added_ms: m.added_ms,
8868                })
8869                .collect(),
8870        },
8871    }
8872}
8873
8874/// How many times a merge checks again against a target that moved under it before it
8875/// gives up (GUN-MERGE-ENGINE, 2026-11-01).
8876const MERGE_RECHECKS: usize = 3;
8877
8878/// The identity gunnar commits a merge as: the committer, never the author.
8879fn gunnar_ident() -> crate::merge_engine::Ident {
8880    crate::merge_engine::Ident {
8881        name: "Gunnar".to_owned(),
8882        email: "noreply@gunnar.invalid".to_owned(),
8883    }
8884}
8885
8886/// A principal as a commit names it: the display name, and an address under `.invalid`
8887/// built from the id. gunnar keeps no e-mail address for a principal and never invents a
8888/// deliverable one.
8889fn principal_ident(principal: &gunnar_core::Principal) -> crate::merge_engine::Ident {
8890    crate::merge_engine::Ident {
8891        name: principal.name.clone(),
8892        email: format!("{}@gunnar.invalid", principal.id),
8893    }
8894}
8895
8896/// The short form of a ref for a commit message: `refs/heads/topic` is `topic`.
8897fn short_ref(name: &str) -> &str {
8898    name.strip_prefix("refs/heads/").unwrap_or(name)
8899}
8900
8901/// One attempt at a merge: done, or the target moved under the compare-and-swap.
8902enum MergeAttempt {
8903    Done(Box<(PullRequest, MergedRef)>),
8904    TargetMoved,
8905}
8906
8907/// **Which strategy the merge uses** — the caller's ask against what the repository's
8908/// policy allows and whether the head descends from the target.
8909fn choose_strategy(
8910    asked: gunnar_proto::MergeStrategy,
8911    fast_forward: bool,
8912    allowed: &gunnar_policy::MergePolicy,
8913    record: &gunnar_core::PullRecord,
8914) -> ControlResult<gunnar_proto::MergeStrategy> {
8915    use gunnar_proto::MergeStrategy as S;
8916    let behind = || {
8917        format!(
8918            "not a fast-forward: {} does not descend from {}",
8919            record.source_ref, record.target_ref
8920        )
8921    };
8922    let refuse = |line: String| Err(ControlError::FailedPrecondition(line));
8923    match asked {
8924        S::FastForward if !allowed.allow_fast_forward => refuse(
8925            "fast-forward merges are turned off for this repository (policy              merge.allow_fast_forward); merge without asking for one"
8926                .to_owned(),
8927        ),
8928        S::FastForward if !fast_forward => refuse(format!(
8929            "{}. Merge without asking for a fast-forward (gunnar writes a merge commit), or              rebase or merge locally, push the result, and merge again",
8930            behind()
8931        )),
8932        S::FastForward => Ok(S::FastForward),
8933        S::MergeCommit if !allowed.allow_merge_commit => refuse(
8934            "merge commits are turned off for this repository (policy              merge.allow_merge_commit)"
8935                .to_owned(),
8936        ),
8937        S::MergeCommit => Ok(S::MergeCommit),
8938        S::Squash if !allowed.allow_squash => refuse(
8939            "squash merges are not turned on for this repository (policy merge.allow_squash)"
8940                .to_owned(),
8941        ),
8942        S::Squash => Ok(S::Squash),
8943        S::Unspecified => {
8944            if fast_forward && allowed.allow_fast_forward {
8945                Ok(S::FastForward)
8946            } else if allowed.allow_merge_commit {
8947                Ok(S::MergeCommit)
8948            } else if allowed.allow_squash {
8949                Ok(S::Squash)
8950            } else if fast_forward {
8951                refuse(
8952                    "this repository's policy allows no merge strategy (merge.allow_fast_forward,                      merge.allow_merge_commit and merge.allow_squash are all off)"
8953                        .to_owned(),
8954                )
8955            } else {
8956                refuse(format!(
8957                    "{}, and this repository allows only fast-forward merges (policy                      merge.allow_merge_commit is off): rebase or merge locally, push the result,                      and merge again",
8958                    behind()
8959                ))
8960            }
8961        }
8962    }
8963}
8964
8965impl CoreControlPlane {
8966    /// The merge, or with `dry_run` only its checks: one body, so the check can never
8967    /// say yes where the merge would say no (GUN-FIELD-TEST, 2026-11-01). A target that
8968    /// moves under the compare-and-swap is checked again from the top — every gate, the
8969    /// merge, the conflicts — against the new tip (GUN-MERGE-ENGINE).
8970    fn merge_or_check(
8971        &self,
8972        path: &RepoPath,
8973        number: u64,
8974        principal_id: &str,
8975        options: &gunnar_grpc::MergeOptions,
8976        dry_run: bool,
8977    ) -> ControlResult<(PullRequest, MergedRef)> {
8978        let mut rechecked = false;
8979        for _ in 0..=MERGE_RECHECKS {
8980            match self.merge_attempt(path, number, principal_id, options, dry_run, rechecked)? {
8981                MergeAttempt::Done(out) => return Ok(*out),
8982                MergeAttempt::TargetMoved => rechecked = true,
8983            }
8984        }
8985        Err(ControlError::FailedPrecondition(format!(
8986            "the target of pull request #{number} kept moving while gunnar merged it ({}              checks against {} tips); no ref moved — merge again",
8987            MERGE_RECHECKS + 1,
8988            MERGE_RECHECKS + 1
8989        )))
8990    }
8991
8992    /// One attempt: every check against the tips as they are now, the merge, the gates
8993    /// over the merge result, and (not `dry_run`) the write.
8994    #[allow(clippy::too_many_lines)]
8995    fn merge_attempt(
8996        &self,
8997        path: &RepoPath,
8998        number: u64,
8999        principal_id: &str,
9000        options: &gunnar_grpc::MergeOptions,
9001        dry_run: bool,
9002        mut rechecked: bool,
9003    ) -> ControlResult<MergeAttempt> {
9004        use gunnar_proto::MergeStrategy as S;
9005        let repo_ref = Self::repo_ref(path)?;
9006        let key = repo_ref.to_string();
9007        let pulls = self.pulls()?;
9008        let record = pulls
9009            .get(&key, number)
9010            .map_err(internal)?
9011            .ok_or_else(|| ControlError::NotFound(format!("pull request #{number} in {path}")))?;
9012        if record.state != gunnar_core::PullState::Open {
9013            return Err(ControlError::FailedPrecondition(format!(
9014                "pull request #{number} is already {:?}, not open",
9015                record.state
9016            )));
9017        }
9018
9019        let (repo_record, store) = self.open(path)?;
9020        let git = store.serve();
9021        let snapshot = RefSnapshot::read(git).map_err(wire_error)?;
9022        let source = snapshot
9023            .resolve(&record.source_ref)
9024            .map_err(wire_error)?
9025            .ok_or_else(|| {
9026                ControlError::FailedPrecondition(format!(
9027                    "the source branch {} is gone; nothing to merge",
9028                    record.source_ref
9029                ))
9030            })?;
9031        let target = snapshot
9032            .resolve(&record.target_ref)
9033            .map_err(wire_error)?
9034            .ok_or_else(|| {
9035                ControlError::FailedPrecondition(format!(
9036                    "the target branch {} is gone",
9037                    record.target_ref
9038                ))
9039            })?;
9040        // The caller's check answered another target tip: everything below runs
9041        // against this one, and the answer says it was checked again.
9042        if let Some(expected) = &options.expected_target_oid {
9043            if !expected.eq_ignore_ascii_case(&target.to_string()) {
9044                rechecked = true;
9045            }
9046        }
9047
9048        // ── MERGED OUTSIDE GUNNAR: the target already holds the head ───────
9049        //
9050        // GUN-PR-FIELD-2026-11-01 item 3. source == target is "already up to
9051        // date", and a target that descends from the head was merged another way
9052        // (somebody pushed the branch to it). Nothing is left to merge and no ref
9053        // moves. The answer names the commit on the target that contains the head.
9054        //
9055        // ★ SEC-G1 (sec review 2026-11-01): "merged" is an approval claim — webhooks
9056        // and changelogs believe it — and this branch runs before the review gate. A
9057        // Write user who resets the branch onto the target's tip (or an ancestor)
9058        // must not get a rejected pull request recorded merged. So the record is
9059        // marked merged only when the head carries the approvals the target's review
9060        // policy asks of a merge (the gate's and the policy's rules, below); else it
9061        // is CLOSED with "closed: content already on target", and no approval is
9062        // claimed.
9063        let budget = gunnar_policy::ancestry::DEFAULT_ANCESTRY_BUDGET;
9064        let contained = source == target
9065            || gunnar_policy::ancestry::is_descendant_of(git, &target, &source, budget).map_err(
9066                |err| {
9067                    ControlError::FailedPrecondition(format!(
9068                        "the ancestry walk could not be completed ({err}); refusing rather than \
9069                         guessing"
9070                    ))
9071                },
9072            )?;
9073        if contained {
9074            let approvers =
9075                self.approvals_enough(&key, number, &record, &repo_record, &source.to_string())?;
9076            let done = MergedRef {
9077                ref_name: record.target_ref.clone(),
9078                old_oid: target.to_string(),
9079                new_oid: target.to_string(),
9080                head_oid: source.to_string(),
9081                rechecked,
9082                contained_in: target.to_string(),
9083                approvers: approvers.clone().unwrap_or_default(),
9084                closed: if approvers.is_some() {
9085                    String::new()
9086                } else {
9087                    gunnar_grpc::CONTENT_ALREADY_ON_TARGET.to_owned()
9088                },
9089                ..MergedRef::default()
9090            };
9091            if dry_run {
9092                return Ok(MergeAttempt::Done(Box::new((
9093                    pull_out(path, record.clone()),
9094                    done,
9095                ))));
9096            }
9097            if approvers.is_none() {
9098                let closed = match pulls
9099                    .close_because(&key, number, gunnar_grpc::CONTENT_ALREADY_ON_TARGET)
9100                    .map_err(internal)?
9101                {
9102                    gunnar_core::PullTransition::Done(record) => record,
9103                    gunnar_core::PullTransition::NotFound => {
9104                        return Err(ControlError::NotFound(format!(
9105                            "pull request #{number} in {path}"
9106                        )))
9107                    }
9108                    gunnar_core::PullTransition::NotOpen(state) => {
9109                        return Err(ControlError::FailedPrecondition(format!(
9110                            "pull request #{number} is already {state:?}, not open"
9111                        )))
9112                    }
9113                };
9114                return Ok(MergeAttempt::Done(Box::new((pull_out(path, closed), done))));
9115            }
9116            let merged = self.mark_merged(&pulls, &key, number, &target.to_string())?;
9117            return Ok(MergeAttempt::Done(Box::new((pull_out(path, merged), done))));
9118        }
9119
9120        // ── THE REVIEW GATE ────────────────────────────────────────────────
9121        //
9122        // A verified approval must name the commit this is about to move to.
9123        //
9124        // `source` and not `record.source_oid`: the record's oid is the tip at
9125        // CREATION and the branch may have advanced since. The approval names
9126        // what was reviewed, so if the branch moved after the review, no
9127        // approval names the new tip and this refuses — which is the whole
9128        // point. A force-push after a review does not inherit the review, and
9129        // the refusal says which commit was actually approved so the reviewer
9130        // can be asked again.
9131        //
9132        // Verified, not merely present: `review_state` re-checks every approval
9133        // against the ACL key table right now, so an approval by a key that has
9134        // since been revoked stops counting the moment the key does.
9135        let head = source.to_string();
9136        // Read once: `review_state` needs the SIGNED events to re-check each
9137        // approval, and the fold needs the unsigned halves of the same list.
9138        let signed = self.forge_events()?;
9139        let events: Vec<gunnar_core::ForgeEvent> = signed
9140            .iter()
9141            .filter(|s| s.event.repo == key)
9142            .map(|s| s.event.clone())
9143            .collect();
9144        let review = gunnar_core::forge::fold(number, &events);
9145        let approved = self.review_state(&key, &review, &signed)?;
9146        let ok = approved
9147            .approvals
9148            .iter()
9149            .any(|a| a.verified && a.commit == head);
9150        if !ok {
9151            let named: Vec<String> = approved
9152                .approvals
9153                .iter()
9154                .map(|a| {
9155                    format!(
9156                        "{} → {} [{}]",
9157                        a.actor,
9158                        &a.commit[..12.min(a.commit.len())],
9159                        a.verdict
9160                    )
9161                })
9162                .collect();
9163            return Err(ControlError::FailedPrecondition(format!(
9164                "no verified approval names {}, the commit {} is at. gunnar does not merge on \
9165                 an approval of something else: an approval names the commit it approved, so a \
9166                 branch that moved after the review has not been reviewed.{}",
9167                &head[..12.min(head.len())],
9168                record.source_ref,
9169                if named.is_empty() {
9170                    " Nobody has approved this pull request.".to_owned()
9171                } else {
9172                    format!(" What is approved: {}", named.join(", "))
9173                }
9174            )));
9175        }
9176
9177        // ── THE REVIEW POLICY ──────────────────────────────────────────────
9178        //
9179        // Who may count, and how many — from the SAME policy document branch
9180        // protection lives in, on the rules covering the TARGET ref. The gate
9181        // above asked "is there a verified approval of this tip"; these two
9182        // fields ask "whose, and how many", and they are policy rather than
9183        // fact, which is why they live in a `ProtectionRule` an administrator
9184        // wrote and not in this function's opinion.
9185        //
9186        // Distinct by construction: the fold keys approvals by actor, so a
9187        // principal who approves twice holds ONE entry, and this count can
9188        // never be inflated by pressing the button again.
9189        let policy = self.policy_of(&repo_record)?;
9190        let rules: Vec<&gunnar_policy::ProtectionRule> = policy
9191            .protection
9192            .iter()
9193            .filter(|rule| rule.pattern.matches_ref(&record.target_ref))
9194            .collect();
9195        let forbid_self = rules.iter().any(|rule| rule.forbid_self_approval);
9196        let required = rules
9197            .iter()
9198            .map(|rule| rule.required_approvals)
9199            .max()
9200            .unwrap_or(0)
9201            .max(1) as usize;
9202        let approvers: Vec<&str> = approved
9203            .approvals
9204            .iter()
9205            .filter(|a| a.verified && a.commit == head)
9206            .map(|a| a.actor.as_str())
9207            .collect();
9208        let counted: Vec<&str> = approvers
9209            .iter()
9210            .copied()
9211            .filter(|actor| !(forbid_self && *actor == record.author))
9212            .collect();
9213        if forbid_self && counted.is_empty() && !approvers.is_empty() {
9214            return Err(ControlError::FailedPrecondition(format!(
9215                "the protection on {} forbids self-approval, and the only verified approval \
9216                 of {} is the author's own ({}). An author's sign-off on their own work is \
9217                 not a review; somebody else has to look at it.",
9218                record.target_ref,
9219                &head[..12.min(head.len())],
9220                record.author
9221            )));
9222        }
9223        if counted.len() < required {
9224            return Err(ControlError::FailedPrecondition(format!(
9225                "the protection on {} requires {required} distinct verified approvals of {} \
9226                 and there {} ({}). Approvals by the same principal count once{}.",
9227                record.target_ref,
9228                &head[..12.min(head.len())],
9229                if counted.len() == 1 {
9230                    "is 1".to_owned()
9231                } else {
9232                    format!("are {}", counted.len())
9233                },
9234                if counted.is_empty() {
9235                    "nobody".to_owned()
9236                } else {
9237                    counted.join(", ")
9238                },
9239                if forbid_self {
9240                    ", and the author's own does not count here"
9241                } else {
9242                    ""
9243                }
9244            )));
9245        }
9246
9247        // ── THE APPROVAL RULE FOR A MERGE COMMIT (GUN-MERGE-ENGINE) ──────────
9248        //
9249        // An approval names a commit, and a merge commit is a commit nobody has
9250        // approved: it does not exist until this function writes it. The rule:
9251        // **approvals of the head authorise merging exactly that head into the
9252        // target tip found here**, and nothing else — the merge commit is the
9253        // mechanical, conflict-free result of those two inputs, and it says so
9254        // (`Head:`, `Target:`, one `Approved-by:` per approver). A head that moved
9255        // has no approval (the gate above); a conflict is refused, never resolved
9256        // by the server; a target that moved is checked again from the top.
9257        let approvers: Vec<String> = counted.iter().map(|id| (*id).to_owned()).collect();
9258        let hash = source.kind();
9259        let plan = MergedRef {
9260            ref_name: record.target_ref.clone(),
9261            old_oid: target.to_string(),
9262            new_oid: String::new(),
9263            strategy: S::Unspecified,
9264            head_oid: source.to_string(),
9265            conflicts: Vec::new(),
9266            approvers: approvers.clone(),
9267            rechecked,
9268            contained_in: String::new(),
9269            closed: String::new(),
9270        };
9271        let inconclusive = |err: gunnar_policy::Error| {
9272            // Inconclusive is a refusal, never an allow — the same judgement
9273            // BranchProtectionGate makes.
9274            ControlError::FailedPrecondition(format!(
9275                "the ancestry walk could not be completed ({err}); refusing rather than guessing"
9276            ))
9277        };
9278
9279        let fast_forward = gunnar_policy::ancestry::is_descendant_of(git, &source, &target, budget)
9280            .map_err(inconclusive)?;
9281        let strategy = choose_strategy(options.strategy, fast_forward, &policy.merge, &record)?;
9282
9283        let principal = self
9284            .stores
9285            .auth()
9286            .principal(principal_id)
9287            .map_err(internal)?
9288            .ok_or_else(|| {
9289                // The caller authenticated and passed a grant check to get
9290                // here, so this is a table that changed underneath the
9291                // request. Refuse; a merge attributed to nobody is worse
9292                // than a merge that did not happen.
9293                ControlError::FailedPrecondition(format!(
9294                    "the principal {principal_id} is no longer in the identity table; \
9295                     refusing to move a protected reference for an identity that \
9296                     cannot be resolved"
9297                ))
9298            })?;
9299
9300        // ── THE MERGE (GUN-MERGE-ENGINE) ───────────────────────────────────
9301        //
9302        // Gunnar's own engine over `GitServe::read` (`crate::merge_engine`). It
9303        // writes nothing: the objects it makes are judged by every gate through
9304        // the overlay below, and stored only after.
9305        let mut made = crate::merge_engine::NewObjects::default();
9306        let engine = |err: crate::merge_engine::EngineError| {
9307            ControlError::FailedPrecondition(format!(
9308                "gunnar could not merge {} into {} ({err}); nothing was written",
9309                record.source_ref, record.target_ref
9310            ))
9311        };
9312        let result = match strategy {
9313            S::FastForward => source,
9314            S::MergeCommit | S::Squash => {
9315                let base = crate::merge_engine::merge_base(git, hash, &target, &source, budget)
9316                    .map_err(engine)?;
9317                let tree = |commit| crate::merge_engine::commit_tree(git, hash, commit);
9318                let (base_tree, ours, theirs) = (
9319                    tree(&base).map_err(engine)?,
9320                    tree(&target).map_err(engine)?,
9321                    tree(&source).map_err(engine)?,
9322                );
9323                let mut merger = crate::merge_engine::Merger::new(git, hash, repo_record.encrypted);
9324                let merged_tree = match merger
9325                    .merge_trees(Some(&base_tree), &ours, &theirs)
9326                    .map_err(engine)?
9327                {
9328                    crate::merge_engine::TreeMerge::Clean(tree) => tree,
9329                    crate::merge_engine::TreeMerge::Conflicts(conflicts) => {
9330                        let listed: Vec<String> =
9331                            conflicts.iter().map(ToString::to_string).collect();
9332                        if dry_run {
9333                            return Ok(MergeAttempt::Done(Box::new((
9334                                pull_out(path, record.clone()),
9335                                MergedRef {
9336                                    strategy,
9337                                    conflicts: listed,
9338                                    ..plan
9339                                },
9340                            ))));
9341                        }
9342                        return Err(ControlError::FailedPrecondition(format!(
9343                            "merge conflict: {} ({}) and {} ({}) both changed {} {}, and gunnar \
9344                             does not guess: {}. Nothing was written. Merge {} into {} locally, \
9345                             resolve them, push, and have the new head reviewed.",
9346                            record.source_ref,
9347                            &plan.head_oid[..12],
9348                            record.target_ref,
9349                            &plan.old_oid[..12],
9350                            listed.len(),
9351                            if listed.len() == 1 { "path" } else { "paths" },
9352                            listed.join("; "),
9353                            short_ref(&record.target_ref),
9354                            short_ref(&record.source_ref),
9355                        )));
9356                    }
9357                };
9358                made = merger.new;
9359                let parents = if strategy == S::MergeCommit {
9360                    vec![target, source]
9361                } else {
9362                    vec![target]
9363                };
9364                let message = self
9365                    .merge_message(strategy, &record, git, &source, &target, &approvers, budget);
9366                let when = std::time::SystemTime::now()
9367                    .duration_since(std::time::UNIX_EPOCH)
9368                    .map(|d| d.as_secs() as i64)
9369                    .unwrap_or(0);
9370                let bytes = crate::merge_engine::commit_bytes(
9371                    &merged_tree,
9372                    &parents,
9373                    &principal_ident(&principal),
9374                    &gunnar_ident(),
9375                    when,
9376                    &message,
9377                );
9378                // GUN-MERGE-SIGN: signed by gunnar's own merge-signing key (minted on the
9379                // box at first use, never handed out), so a repository that requires
9380                // signed commits takes the merge; the gates below verify it.
9381                let key = self
9382                    .stores
9383                    .merge_signing_key()
9384                    .map_err(|err| ControlError::Unavailable(format!("{err:#}")))?;
9385                let bytes = crate::merge_engine::sign_commit(&bytes, hash, &key).map_err(engine)?;
9386                made.add_commit(hash, bytes).map_err(engine)?
9387            }
9388            S::Unspecified => {
9389                return Err(ControlError::Internal(
9390                    "the merge strategy was not decided".to_owned(),
9391                ))
9392            }
9393        };
9394        let overlay = crate::merge_engine::Overlay::new(git, &made);
9395
9396        // ── THE GATES, on the merge RESULT ─────────────────────────────────
9397        //
9398        // GUN-004: branch protection, the same gate the push path runs —
9399        // `allowed_principals` and `require_signature` live in the same
9400        // `ProtectionRule` as the review fields above, and the merge path once
9401        // skipped them. GUN-MERGE-ENGINE: and the other gates that judge COMMITS
9402        // (signed commits, the machine-attribution trailer) — a merge commit is a
9403        // new commit and is judged like one. Over the overlay, so a merge commit
9404        // that does not exist yet is judged exactly as it will be stored.
9405        //
9406        // Not `LimitsGate`, `PushOptionsGate` or `RefNameGate`: a merge has no pack
9407        // a client sent, no push options and no reference name a client chose.
9408        let policy_principal = gunnar_policy::Principal {
9409            id: principal.id.clone(),
9410            name: principal.name.clone(),
9411            kind: crate::transport::policy_principal_kind(principal.kind),
9412        };
9413        let repo_id = gunnar_policy::RepoId::new(&path.namespace, &path.repo);
9414        // What this ref move ADDS: the range `target..result`, never an empty
9415        // pack set (which would make `require_signature` pass by having nothing
9416        // to look at); see `ancestry::commits_between`.
9417        let introduced = gunnar_policy::ancestry::commits_between(
9418            &overlay, &result, &target, budget,
9419        )
9420        .map_err(|err| {
9421            ControlError::FailedPrecondition(format!(
9422                "the commits this merge would add could not be enumerated ({err}); \
9423                         refusing rather than running branch protection over an empty set"
9424            ))
9425        })?;
9426        let updates = [gunnar_policy::RefUpdate::from_wire(
9427            record.target_ref.clone(),
9428            target,
9429            result,
9430        )];
9431        let keys = crate::transport::key_directory(&self.stores);
9432        let ctx = gunnar_policy::PushContext::new(
9433            &policy_principal,
9434            &repo_id,
9435            &updates,
9436            &introduced,
9437            &overlay,
9438            &keys,
9439        );
9440        let mut gates: Vec<(&str, Box<dyn gunnar_policy::Gate>)> = vec![(
9441            "branch protection",
9442            Box::new(gunnar_policy::BranchProtectionGate::new(
9443                policy.protection.clone(),
9444            )),
9445        )];
9446        if policy.signatures.required {
9447            gates.push((
9448                "the signed-commits policy",
9449                Box::new(gunnar_policy::SignedCommitsGate::new(
9450                    policy.signatures.clone(),
9451                )),
9452            ));
9453        }
9454        if policy.ai_trailers.refuse {
9455            gates.push((
9456                "the attribution-trailer policy",
9457                Box::new(gunnar_policy::AiTrailerGate::new(
9458                    policy.ai_trailers.clone(),
9459                )),
9460            ));
9461        }
9462        for (label, gate) in &gates {
9463            match gate.check(&ctx) {
9464                Ok(gunnar_policy::Verdict::Allow) => {}
9465                Ok(verdict) => {
9466                    return Err(ControlError::FailedPrecondition(format!(
9467                        "{label} refuses this merge: {}",
9468                        verdict.reason().unwrap_or_default()
9469                    )));
9470                }
9471                // A gate that could not decide refuses, exactly as it does on
9472                // the push path.
9473                Err(err) => {
9474                    return Err(ControlError::FailedPrecondition(format!(
9475                        "{label} could not decide this merge ({err}); refusing rather \
9476                         than guessing"
9477                    )));
9478                }
9479            }
9480        }
9481
9482        // The check stops here: every gate has answered, nothing is written and
9483        // the record stays open.
9484        if dry_run {
9485            let new_oid = if strategy == S::FastForward {
9486                result.to_string()
9487            } else {
9488                String::new()
9489            };
9490            return Ok(MergeAttempt::Done(Box::new((
9491                pull_out(path, record.clone()),
9492                MergedRef {
9493                    strategy,
9494                    new_oid,
9495                    ..plan
9496                },
9497            ))));
9498        }
9499
9500        // ── THE WRITE: the objects, then the ref through the store's CAS ──
9501        if let Some(pack) = made.pack(hash, git).map_err(engine)? {
9502            git.put_pack(&pack).map_err(|err| {
9503                ControlError::Internal(format!(
9504                    "storing the merge of pull request #{number}: {err:#}; no ref moved"
9505                ))
9506            })?;
9507        }
9508        if let Err(err) = git.update_ref(
9509            &record.target_ref,
9510            Some(target.as_bytes()),
9511            Some(result.as_bytes()),
9512        ) {
9513            // Moved under us: check everything again against the new tip. Not
9514            // moved: a real failure, said as one.
9515            let now = RefSnapshot::read(git)
9516                .map_err(wire_error)?
9517                .resolve(&record.target_ref)
9518                .map_err(wire_error)?;
9519            if now != Some(target) {
9520                return Ok(MergeAttempt::TargetMoved);
9521            }
9522            return Err(ControlError::FailedPrecondition(format!(
9523                "the target ref could not be moved ({err}); no ref moved — retry"
9524            )));
9525        }
9526        let merged = self.mark_merged(&pulls, &key, number, &result.to_string())?;
9527        Ok(MergeAttempt::Done(Box::new((
9528            pull_out(path, merged),
9529            MergedRef {
9530                strategy,
9531                new_oid: result.to_string(),
9532                ..plan
9533            },
9534        ))))
9535    }
9536
9537    /// ★ SEC-G1: **the principals whose verified approvals of `head` satisfy the review
9538    /// policy on the pull request's target** — the review gate's rule (a verified approval
9539    /// names exactly this commit) and the policy's (`forbid_self_approval`,
9540    /// `required_approvals`, at least one) — or `None` when they do not.
9541    fn approvals_enough(
9542        &self,
9543        key: &str,
9544        number: u64,
9545        record: &gunnar_core::PullRecord,
9546        repo_record: &gunnar_core::Repo,
9547        head: &str,
9548    ) -> ControlResult<Option<Vec<String>>> {
9549        let signed = self.forge_events()?;
9550        let events: Vec<gunnar_core::ForgeEvent> = signed
9551            .iter()
9552            .filter(|s| s.event.repo == key)
9553            .map(|s| s.event.clone())
9554            .collect();
9555        let review = gunnar_core::forge::fold(number, &events);
9556        let approved = self.review_state(key, &review, &signed)?;
9557        let policy = self.policy_of(repo_record)?;
9558        let rules: Vec<&gunnar_policy::ProtectionRule> = policy
9559            .protection
9560            .iter()
9561            .filter(|rule| rule.pattern.matches_ref(&record.target_ref))
9562            .collect();
9563        let forbid_self = rules.iter().any(|rule| rule.forbid_self_approval);
9564        let required = rules
9565            .iter()
9566            .map(|rule| rule.required_approvals)
9567            .max()
9568            .unwrap_or(0)
9569            .max(1) as usize;
9570        let counted: Vec<String> = approved
9571            .approvals
9572            .iter()
9573            .filter(|a| a.verified && a.commit == head)
9574            .filter(|a| !(forbid_self && a.actor == record.author))
9575            .map(|a| a.actor.clone())
9576            .collect();
9577        Ok((counted.len() >= required).then_some(counted))
9578    }
9579
9580    /// Mark the record merged at `oid`, after the ref has moved.
9581    fn mark_merged(
9582        &self,
9583        pulls: &gunnar_core::Pulls,
9584        key: &str,
9585        number: u64,
9586        oid: &str,
9587    ) -> ControlResult<gunnar_core::PullRecord> {
9588        match pulls.mark_merged(key, number, oid).map_err(internal)? {
9589            gunnar_core::PullTransition::Done(record) => Ok(record),
9590            // The ref HAS moved; a racing close between our CAS and this write
9591            // must be reported loudly, not papered over.
9592            other => Err(ControlError::Internal(format!(
9593                "the ref moved to {oid} but the record could not be marked merged \
9594                 ({other:?}); the repository is correct, the record is not"
9595            ))),
9596        }
9597    }
9598
9599    /// **The merge commit's message**: what was merged, and what authorised it, as
9600    /// trailers a person (and `git interpret-trailers`) can read — `Pull-request:`,
9601    /// `Head:`, `Target:` and one `Approved-by:` per approver. A squash also lists the
9602    /// subjects of the commits it squashed.
9603    #[allow(clippy::too_many_arguments)]
9604    fn merge_message(
9605        &self,
9606        strategy: gunnar_proto::MergeStrategy,
9607        record: &gunnar_core::PullRecord,
9608        git: &dyn GitServe,
9609        source: &gix_hash::ObjectId,
9610        target: &gix_hash::ObjectId,
9611        approvers: &[String],
9612        budget: usize,
9613    ) -> String {
9614        let title = record.title.lines().next().unwrap_or("").trim();
9615        let mut message = if strategy == gunnar_proto::MergeStrategy::Squash {
9616            let mut out = format!(
9617                "{} (#{})\n\nSquashed pull request #{} from {} into {}",
9618                if title.is_empty() {
9619                    "Squashed changes"
9620                } else {
9621                    title
9622                },
9623                record.number,
9624                record.number,
9625                short_ref(&record.source_ref),
9626                short_ref(&record.target_ref),
9627            );
9628            let mut commits = gunnar_policy::ancestry::commits_between(git, source, target, budget)
9629                .unwrap_or_default();
9630            commits.reverse();
9631            let subjects = crate::merge_engine::subjects(git, source.kind(), &commits);
9632            if !subjects.is_empty() {
9633                out.push_str(":\n");
9634                for subject in subjects.iter().take(100) {
9635                    out.push_str(&format!("\n* {subject}"));
9636                }
9637            }
9638            out
9639        } else {
9640            let mut out = format!(
9641                "Merge pull request #{} from {} into {}",
9642                record.number,
9643                short_ref(&record.source_ref),
9644                short_ref(&record.target_ref),
9645            );
9646            if !title.is_empty() {
9647                out.push_str(&format!("\n\n{title}"));
9648            }
9649            out
9650        };
9651        message.push_str(&format!(
9652            "\n\nPull-request: #{}\nHead: {source}\nTarget: {target}",
9653            record.number
9654        ));
9655        for id in approvers {
9656            let ident = match self.stores.auth().principal(id) {
9657                Ok(Some(p)) => principal_ident(&p),
9658                _ => crate::merge_engine::Ident {
9659                    name: id.clone(),
9660                    email: format!("{id}@gunnar.invalid"),
9661                },
9662            };
9663            message.push_str(&format!("\nApproved-by: {}", ident.line()));
9664        }
9665        message.push('\n');
9666        message
9667    }
9668}