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 });
806 resumed += 1;
807 }
808 }
809 resumed
810 }
811
812 /// **Take the lockout a purge runs under, without downgrading one that is
813 /// already there.**
814 ///
815 /// A lease is written only when the account is *not* already gated. If it
816 /// is — an operator suspending a customer, monetize locking a non-payer —
817 /// that gate stands, and the purge runs under it. Two consequences, both
818 /// deliberate:
819 ///
820 /// * the account does not quietly acquire a **deadline** it did not have.
821 /// Overwriting an administrative gate with a lease would mean a
822 /// suspension expiring ten minutes after a purge finished with it, which
823 /// is a suspended account silently coming back.
824 /// * the job will report `gate_released: false` and the account stays shut,
825 /// because `lift_gate_held_by` will find a holder that is not this job.
826 /// That is the correct outcome: the purge did not take that lockout and
827 /// has no standing to lift it.
828 fn gate_for_purge(
829 &self,
830 account: &str,
831 job_id: &str,
832 options: &gunnar_grpc::seam::PurgeAccountOptions,
833 ) -> ControlResult<()> {
834 if self
835 .stores
836 .auth()
837 .gate_covering(account)
838 .map_err(internal)?
839 .is_some()
840 {
841 return Ok(());
842 }
843 let lease = gunnar_core::AccountGate::purge_lease(
844 account,
845 job_id,
846 gunnar_core::now_ms() + gunnar_core::PURGE_LEASE_MS,
847 )
848 .by(&options.principal_id)
849 .noted(format!(
850 "this account is being permanently deleted: {}",
851 options.reason
852 ));
853 self.stores.auth().gate(lease).map_err(gate_error)?;
854 Ok(())
855 }
856
857 /// **Hand the disk-headroom gate in.** From here on `Repos.Create` refuses
858 /// under the floor with `RESOURCE_EXHAUSTED` and the gate's own line —
859 /// the one a refused `git push` prints, because it is the same function.
860 #[must_use]
861 pub fn with_headroom(mut self, gate: Arc<gunnar_ssh::DiskHeadroomGate>) -> Self {
862 self.headroom = Some(gate);
863 self
864 }
865
866 /// **Hand this plane the runtime growth** (`crate::grow`). Called by
867 /// `serve` once the admission gate and the stores exist.
868 pub fn with_grow(mut self, grow: Arc<crate::grow::GrowMachine>) -> Self {
869 self.grow = Some(grow);
870 self
871 }
872
873 /// **Hand this plane the admission gate** the git fronts share, for the
874 /// in-flight count `Mirror.FlushReplication` reports.
875 pub fn with_admission(mut self, admission: Arc<gunnar_ssh::Admission>) -> Self {
876 self.admission = Some(admission);
877 self
878 }
879
880 /// The growth, or the seam's refusal by name.
881 fn grow_machine(&self) -> ControlResult<&Arc<crate::grow::GrowMachine>> {
882 self.grow.as_ref().ok_or(ControlError::Unimplemented(
883 "this control plane was built with no runtime growth wired in, so it cannot grow the data set",
884 ))
885 }
886
887 /// Verify a signed membership event before it changes anything.
888 ///
889 /// The same shape as `forge_append`'s check and for the same reason (one
890 /// implementation): a membership log that took an event it could not verify would be a
891 /// log of claims, and the point of the design is that it is a log of
892 /// evidence. The key must be in the ACL table (or no later reader could
893 /// check it), it must be the claimed actor's own, and the event must
894 /// describe the change the caller named — re-checked here at the seam
895 /// because this door can also be reached without a connection.
896 fn verify_membership_event(
897 &self,
898 signed: gunnar_proto::SignedMembershipEvent,
899 group_id: &str,
900 member_id: &str,
901 action: gunnar_core::MembershipAction,
902 ) -> ControlResult<gunnar_core::SignedMembershipEvent> {
903 let signed = from_proto_membership(signed)?;
904 if signed.event.group != group_id || signed.event.member != member_id {
905 return Err(ControlError::InvalidArgument(format!(
906 "the signed event is about {:?}/{:?} but the change is {:?}/{:?}",
907 signed.event.group, signed.event.member, group_id, member_id
908 )));
909 }
910 if signed.event.action != action {
911 return Err(ControlError::InvalidArgument(
912 "the signed event's action does not match the change (add vs remove)".to_owned(),
913 ));
914 }
915 let key = self
916 .stores
917 .auth()
918 .signing_keys()
919 .map_err(|err| ControlError::Unavailable(format!("{err:#}")))?
920 .into_iter()
921 .find(|row| row.fingerprint_sha256 == signed.key_fingerprint)
922 .ok_or_else(|| {
923 ControlError::InvalidArgument(format!(
924 "{} is not registered on this server, so nobody reading this event later \
925 could check its signature",
926 signed.key_fingerprint
927 ))
928 })?;
929 if key.principal_id != signed.event.actor {
930 return Err(ControlError::InvalidArgument(format!(
931 "this membership change is attributed to {:?} and is signed by {}, which is \
932 {:?}'s key; a membership change is the actor's own word, so it must carry the \
933 actor's own signature",
934 signed.event.actor, signed.key_fingerprint, key.principal_id
935 )));
936 }
937 let public = gunnar_core::ssh_key::PublicKey::from_openssh(&key.openssh)
938 .map_err(|err| ControlError::Internal(format!("stored key is unreadable: {err}")))?;
939 gunnar_core::membership::verify_event(&signed, &public)
940 .map_err(|problem| ControlError::InvalidArgument(format!("{problem}")))?;
941 Ok(signed)
942 }
943
944 /// Append a verified membership event to the signed membership log, and
945 /// return the index it landed at — the index an inclusion proof is taken
946 /// at. Append-only and never rewritten, so a removal followed by a re-add
947 /// leaves two standing events, which is what makes the history evidence.
948 fn append_membership_event(
949 &self,
950 signed: &gunnar_core::SignedMembershipEvent,
951 ) -> ControlResult<u64> {
952 let log = self.stores.membership_log();
953 let mut line = serde_json::to_string(signed)
954 .map_err(|err| ControlError::Internal(format!("encoding the event: {err}")))?;
955 line.push('\n');
956 let _framed =
957 gunnar_core::txlog::sink::append_line(log.records_path(), line.trim_end_matches('\n'))
958 .map_err(|err| {
959 ControlError::Unavailable(format!(
960 "framing the line in the transaction log: {err}"
961 ))
962 })?;
963 use std::io::Write as _;
964 let mut file = std::fs::OpenOptions::new()
965 .create(true)
966 .append(true)
967 .open(log.records_path())
968 .map_err(|err| {
969 ControlError::Unavailable(format!("opening the membership log: {err}"))
970 })?;
971 file.write_all(line.as_bytes()).map_err(|err| {
972 ControlError::Unavailable(format!("appending to the membership log: {err}"))
973 })?;
974 let count = log
975 .lines_of()
976 .map_err(|err| ControlError::Unavailable(format!("{err:#}")))?
977 .len() as u64;
978 Ok(count.saturating_sub(1))
979 }
980
981 /// Every membership event on the server, decoded, in append order.
982 fn membership_log_events(&self) -> ControlResult<Vec<gunnar_core::SignedMembershipEvent>> {
983 self.stores
984 .membership_log()
985 .lines_of()
986 .map_err(|err| ControlError::Unavailable(format!("{err:#}")))?
987 .iter()
988 .map(|line| {
989 serde_json::from_str(line).map_err(|err| {
990 ControlError::Internal(format!("decoding a membership event: {err}"))
991 })
992 })
993 .collect()
994 }
995
996 /// **Hand this plane the live replication slot**, so an operator can name a
997 /// standby over the control plane.
998 ///
999 /// Called by `Server::start` before the plane is wrapped in an `Arc`, and
1000 /// before anything is running in the slot — which is the point: a target has
1001 /// to be settable on a box that has never had one and holds no repositories
1002 /// yet, and that is every box on its first boot.
1003 #[cfg(feature = "replication")]
1004 pub fn with_replication(mut self, slot: Arc<crate::mirror::ReplicationSlot>) -> Self {
1005 self.replication_slot = Some(slot);
1006 self
1007 }
1008
1009 /// The slot, or a refusal that names WHY there is none.
1010 ///
1011 /// `Unimplemented` and not `Internal`: a plane built without a slot is a
1012 /// plane with no server around it (a unit test), and telling a caller "this
1013 /// server cannot do that" is true, actionable and not an incident.
1014 #[cfg(feature = "replication")]
1015 fn replication_slot(&self) -> ControlResult<&Arc<crate::mirror::ReplicationSlot>> {
1016 self.replication_slot.as_ref().ok_or(ControlError::Unimplemented(
1017 "this control plane was built with no replication slot, so it cannot configure a standby",
1018 ))
1019 }
1020
1021 /// **Hand this plane the live webhook slot**, so an operator can name an
1022 /// outbound endpoint over the control plane. Called by `Server::start`
1023 /// before the plane is wrapped in an `Arc`.
1024 #[cfg(feature = "webhooks")]
1025 pub fn with_webhooks(mut self, slot: Arc<crate::webhook::WebhookSlot>) -> Self {
1026 self.webhook_slot = Some(slot);
1027 self
1028 }
1029
1030 /// Hand the entitlement slot in (`metered` only).
1031 #[cfg(feature = "metered")]
1032 pub fn with_entitlement(mut self, slot: Arc<crate::entitlement::EntitlementSlot>) -> Self {
1033 self.entitlement = Some(slot);
1034 self
1035 }
1036
1037 /// The entitlement slot, or a refusal that names WHY there is none.
1038 #[cfg(feature = "metered")]
1039 fn entitlement(&self) -> ControlResult<&Arc<crate::entitlement::EntitlementSlot>> {
1040 self.entitlement.as_ref().ok_or_else(|| {
1041 ControlError::FailedPrecondition(
1042 "this metered server was started without `--monetize-pubkey`, so it holds no \
1043 key to verify an entitlement fact against and refuses every one; every \
1044 namespace is Free"
1045 .into(),
1046 )
1047 })
1048 }
1049
1050 /// The webhook slot, or a refusal that names WHY there is none.
1051 #[cfg(feature = "webhooks")]
1052 fn webhook_slot(&self) -> ControlResult<&Arc<crate::webhook::WebhookSlot>> {
1053 self.webhook_slot.as_ref().ok_or(ControlError::Unimplemented(
1054 "this control plane was built with no webhook slot, so it cannot configure an outbound endpoint",
1055 ))
1056 }
1057
1058 /// Configuration **and** running state, in one answer.
1059 ///
1060 /// Both halves, always, because they can disagree and the disagreement is
1061 /// the interesting part: `targets` non-empty with `standbys` empty is a
1062 /// server that has the configuration and is not running it.
1063 #[cfg(feature = "replication")]
1064 ///
1065 /// **`include_refs` is the operator's, and it is false for everything but a
1066 /// `replication list --refs`.** The mutating verbs print a snapshot back to
1067 /// confirm the roster, and the ref-coverage walk they used to trigger opens
1068 /// every repository's reference database for a number nobody asked
1069 /// for — so they pass `false`, and only the explicit status verb forwards
1070 /// the flag the operator set.
1071 fn replication_snapshot(
1072 &self,
1073 include_refs: bool,
1074 ) -> ControlResult<gunnar_proto::ReplicationStatusResponse> {
1075 let slot = self.replication_slot()?;
1076 let config = slot
1077 .config()
1078 .map_err(|err| ControlError::Internal(format!("{err:#}")))?;
1079 let mut out = gunnar_proto::ReplicationStatusResponse {
1080 targets: config
1081 .targets
1082 .iter()
1083 .map(crate::replication_wire::target_to_proto)
1084 .collect(),
1085 role: config.role.to_proto() as i32,
1086 ..Default::default()
1087 };
1088 if let Some(replicator) = slot.replicator() {
1089 crate::replication_wire::running_to_proto(&replicator, include_refs, &mut out);
1090 }
1091 Ok(out)
1092 }
1093
1094 /// **The slot `serve` drops the replicator's counters into**, so
1095 /// `Admin.Stats` can report them.
1096 ///
1097 /// Without it the counters exist and nothing reads them, which is exactly
1098 /// the state that let every configured standby receive NOTHING from
1099 /// `e31ab8f4` to 2026-08-30 while five log lines out of five said so into a
1100 /// file nobody was tailing.
1101 pub fn replication_view(&self) -> crate::replication_view::ReplicationView {
1102 self.replication.clone()
1103 }
1104
1105 /// Every forge event, as it was written.
1106 ///
1107 /// Verifying nothing: the fold is over what is in the log, and whether an
1108 /// event is evidence is a separate question with its own answer, asked
1109 /// per approval in [`CoreControlPlane::thread_state`].
1110 fn forge_events(&self) -> ControlResult<Vec<gunnar_core::SignedEvent>> {
1111 self.stores
1112 .forge_log()
1113 .lines_of()
1114 .map_err(|err| ControlError::Unavailable(format!("reading the forge log: {err:#}")))?
1115 .iter()
1116 .map(|line| {
1117 serde_json::from_str(line).map_err(|err| {
1118 ControlError::Internal(format!("a forge event will not decode: {err}"))
1119 })
1120 })
1121 .collect()
1122 }
1123
1124 /// **The subject of a forge event must exist and be open** — the one check
1125 /// for an event its actor signed and one gunnar attested (GHUNT-19).
1126 fn forge_subject_open(&self, signed: &gunnar_core::SignedEvent) -> ControlResult<()> {
1127 // **The subject must exist and be open**, and which subject that is
1128 // depends on the kind. A review event names a pull request the record
1129 // owns; an issue event names an issue THIS log owns. Both refuse an
1130 // event on something merged, closed or never opened — evidence about
1131 // nothing — but they ask different authorities, so they branch here.
1132 let (namespace, name) = signed.event.repo.split_once('/').ok_or_else(|| {
1133 ControlError::InvalidArgument(format!(
1134 "`repo` must be `<namespace>/<repo>`, got {:?}",
1135 signed.event.repo
1136 ))
1137 })?;
1138 let path = RepoPath::new(namespace, name);
1139 match &signed.event.event {
1140 // An opening creates the issue and so has no precondition: there is
1141 // nothing yet to be open. Its number is the fold's to assign.
1142 gunnar_core::Event::IssueOpened { .. } => {}
1143 // A comment or close names an issue by number. The issue is folded
1144 // from this same log — there is no record behind it — so the check
1145 // is against the fold rather than `get_pull`.
1146 gunnar_core::Event::IssueCommented { .. } | gunnar_core::Event::IssueClosed { .. } => {
1147 let repo_events: Vec<gunnar_core::ForgeEvent> = self
1148 .forge_events()?
1149 .into_iter()
1150 .filter(|e| e.event.repo == signed.event.repo)
1151 .map(|e| e.event)
1152 .collect();
1153 match gunnar_core::forge::fold_issue(signed.event.pull, &repo_events) {
1154 None => {
1155 return Err(ControlError::FailedPrecondition(format!(
1156 "no issue #{} in {}: nobody has opened one, so an event on it is \
1157 evidence about nothing",
1158 signed.event.pull, signed.event.repo
1159 )));
1160 }
1161 Some(issue) if !issue.open => {
1162 return Err(ControlError::FailedPrecondition(format!(
1163 "issue #{} in {} is closed; an event on it cannot change what has \
1164 already happened",
1165 signed.event.pull, signed.event.repo
1166 )));
1167 }
1168 Some(_) => {}
1169 }
1170 }
1171 // A review event names a pull request, whose record is the authority
1172 // for whether it exists and is open.
1173 gunnar_core::Event::Commented { .. }
1174 | gunnar_core::Event::Approved { .. }
1175 | gunnar_core::Event::Rejected { .. } => {
1176 let record = self.get_pull(&path, signed.event.pull)?;
1177 if record.state != gunnar_proto::PullRequestState::Open as i32 {
1178 return Err(ControlError::FailedPrecondition(format!(
1179 "pull request #{} in {} is not open; a review event cannot change what \
1180 has already happened",
1181 signed.event.pull, signed.event.repo
1182 )));
1183 }
1184 }
1185 }
1186 Ok(())
1187 }
1188
1189 /// Append a checked forge event to the log, and say where it landed.
1190 fn forge_write(&self, signed: &gunnar_core::SignedEvent) -> ControlResult<u64> {
1191 let log = self.stores.forge_log();
1192 let mut line = serde_json::to_string(signed)
1193 .map_err(|err| ControlError::Internal(format!("encoding the event: {err}")))?;
1194 line.push('\n');
1195 let _framed =
1196 gunnar_core::txlog::sink::append_line(log.records_path(), line.trim_end_matches('\n'))
1197 .map_err(|err| {
1198 ControlError::Unavailable(format!(
1199 "framing the line in the transaction log: {err}"
1200 ))
1201 })?;
1202 use std::io::Write as _;
1203 let mut file = std::fs::OpenOptions::new()
1204 .create(true)
1205 .append(true)
1206 .open(log.records_path())
1207 .map_err(|err| ControlError::Unavailable(format!("opening the forge log: {err}")))?;
1208 file.write_all(line.as_bytes()).map_err(|err| {
1209 ControlError::Unavailable(format!("appending to the forge log: {err}"))
1210 })?;
1211 // The index it landed at, counted after the write so it names this
1212 // event and not the one before it.
1213 let count = self
1214 .stores
1215 .forge_log()
1216 .lines_of()
1217 .map_err(|err| ControlError::Unavailable(format!("{err:#}")))?
1218 .len() as u64;
1219 Ok(count.saturating_sub(1))
1220 }
1221
1222 /// A folded review, with **every approval re-checked against the ACL key
1223 /// table right now** — not trusted because it is in the file.
1224 ///
1225 /// That re-check is the difference the whole design is about, and it is why
1226 /// the wire carries a verdict per approval rather than a count: an approval
1227 /// whose key was since removed has not failed and has not passed, and a
1228 /// number cannot say so.
1229 ///
1230 /// **The key must be the claimed actor's own.** A signature proves which
1231 /// KEY spoke; only the key table's `principal_id` says whose voice that
1232 /// is. Until 2026-08-31 the fingerprint was looked up across the whole
1233 /// table, so an approval claiming `actor: alice` signed by any other
1234 /// registered principal's key verified — and merged. The verdict for that
1235 /// case names the key's real owner, because "your signature failed" and
1236 /// "that is somebody else's key" send an operator down different roads.
1237 fn review_state(
1238 &self,
1239 repo: &str,
1240 state: &gunnar_core::forge::Review,
1241 all: &[gunnar_core::SignedEvent],
1242 ) -> ControlResult<gunnar_proto::ReviewState> {
1243 let keys: Vec<(String, String, gunnar_core::ssh_key::PublicKey)> = self
1244 .stores
1245 .auth()
1246 .signing_keys()
1247 .map_err(|err| ControlError::Unavailable(format!("{err:#}")))?
1248 .iter()
1249 .filter_map(|row| {
1250 gunnar_core::ssh_key::PublicKey::from_openssh(&row.openssh)
1251 .ok()
1252 .map(|key| {
1253 (
1254 row.fingerprint_sha256.clone(),
1255 row.principal_id.clone(),
1256 key,
1257 )
1258 })
1259 })
1260 .collect();
1261 // Gunnar's own forge-attestation key, read without minting: an
1262 // attested approval verifies against it and nothing else (GHUNT-19).
1263 let attest_key = self
1264 .stores
1265 .forge_attest_public()
1266 .map_err(|err| ControlError::Unavailable(format!("{err:#}")))?;
1267
1268 let approvals = state
1269 .approvals
1270 .iter()
1271 .map(|(actor, commit)| {
1272 let signed = all.iter().rev().find(|e| {
1273 e.event.repo == repo
1274 && e.event.pull == state.pull
1275 && e.event.actor == *actor
1276 && matches!(&e.event.event, gunnar_core::Event::Approved { commit: c } if c == commit)
1277 });
1278 let (verified, verdict) = match signed {
1279 None => (false, "NO EVENT".to_owned()),
1280 // **Gunnar's attestation for a person's stepped-up
1281 // session**: signed by gunnar's own key, the actor named,
1282 // the step-up inside the signature. Checked against that
1283 // key alone — never against a principal's.
1284 Some(signed) if signed.attested.is_some() => match &attest_key {
1285 None => (false, "UNKNOWN KEY".to_owned()),
1286 Some(key) => match gunnar_core::forge::verify_event(signed, key) {
1287 Ok(()) => (
1288 true,
1289 signed.attested.as_ref().map(|a| a.words()).unwrap_or_default(),
1290 ),
1291 Err(problem) => (false, format!("BAD ({problem})")),
1292 },
1293 },
1294 Some(signed) => match keys
1295 .iter()
1296 .find(|(fingerprint, _, _)| *fingerprint == signed.key_fingerprint)
1297 {
1298 None => (false, "UNKNOWN KEY".to_owned()),
1299 // The key is registered — to somebody else. The
1300 // signature may be perfectly good; it is still not
1301 // this actor's word, and saying whose it is lets the
1302 // operator see a filed-in-another's-name attempt for
1303 // what it is.
1304 Some((_, owner, _)) if *owner != *actor => {
1305 (false, format!("KEY OF {owner}, NOT {actor}"))
1306 }
1307 Some((_, _, key)) => match gunnar_core::forge::verify_event(signed, key) {
1308 Ok(()) => (true, "verified".to_owned()),
1309 Err(problem) => (false, format!("BAD ({problem})")),
1310 },
1311 },
1312 };
1313 gunnar_proto::Approval {
1314 actor: actor.clone(),
1315 commit: commit.clone(),
1316 verified,
1317 verdict,
1318 }
1319 })
1320 .collect();
1321
1322 Ok(gunnar_proto::ReviewState {
1323 repo: repo.to_owned(),
1324 pull: state.pull,
1325 approvals,
1326 comments: state.comments as u64,
1327 })
1328 }
1329
1330 /// **Set the deployment's object ceiling** for `Refs.ReadObject`.
1331 ///
1332 /// `0` disables the ceiling, which restores the unbounded behaviour: the
1333 /// server will inflate an object of any size to answer a request. That is a
1334 /// real choice for a single-tenant box with a trusted control port and a
1335 /// repository full of large blobs, and it is spelled explicitly rather than
1336 /// being what you get by forgetting to configure anything.
1337 ///
1338 /// This exists as much for the tests as for the operator: a ceiling that can
1339 /// only be a compile-time constant can only be exercised by writing an object
1340 /// bigger than it, which for the real default means a 64 MiB fixture. Being
1341 /// able to set it small is what lets the refusal be seen red cheaply — and it
1342 /// proves the configuration is *wired*, which a test against the constant
1343 /// could never show.
1344 pub fn with_max_object_bytes(mut self, bytes: u64) -> Self {
1345 self.max_object_bytes = bytes;
1346 self
1347 }
1348
1349 /// The pull-request table, opened against the same control store the
1350 /// catalog and the identity table live in. A fresh handle per call: the
1351 /// document cache rides the handle, and a pull-request operation is rare
1352 /// enough that re-parsing beats holding one more long-lived Arc chain.
1353 fn pulls(&self) -> ControlResult<gunnar_core::Pulls> {
1354 gunnar_core::Pulls::new(Arc::clone(self.stores.control_store()))
1355 .map_err(|err| ControlError::Internal(format!("opening the pulls table: {err:#}")))
1356 }
1357
1358 fn repo_ref(path: &RepoPath) -> ControlResult<RepoRef> {
1359 path.validate()
1360 .map_err(|err| ControlError::InvalidArgument(err.to_string()))?;
1361 RepoRef::new(&path.namespace, &path.repo)
1362 .map_err(|err| ControlError::InvalidArgument(err.to_string()))
1363 }
1364
1365 /// Resolve a tree-ish — a ref name, a hex oid, or `HEAD` — to the **tree**
1366 /// it names.
1367 ///
1368 /// The resolution half is the one `log` already does: a hex oid is taken as
1369 /// one, anything else is validated as a reference name and looked up in the
1370 /// snapshot, and an oid of the wrong hash kind is refused with a sentence
1371 /// that says which kind the repository is. What is added here is the last
1372 /// hop — a commit is followed to its tree, a tag is peeled first, and a tree
1373 /// is already the answer — so `from`/`to` accept everything `git diff`
1374 /// accepts in the same position.
1375 fn tree_of(
1376 &self,
1377 git: &dyn GitServe,
1378 hash: gix_hash::Kind,
1379 path: &RepoPath,
1380 spec: &str,
1381 ) -> ControlResult<gix_hash::ObjectId> {
1382 let id = match gix_hash::ObjectId::from_hex(spec.as_bytes()) {
1383 Ok(id) => {
1384 if id.kind() != hash {
1385 return Err(ControlError::InvalidArgument(format!(
1386 "{spec} is a {} oid; this repository is {hash}",
1387 id.kind()
1388 )));
1389 }
1390 id
1391 }
1392 Err(_) => {
1393 gunnar_store::refname::validate(spec)
1394 .map_err(|err| ControlError::InvalidArgument(err.to_string()))?;
1395 let snapshot = RefSnapshot::read(git).map_err(wire_error)?;
1396 let resolved = if spec == gunnar_store::HEAD {
1397 snapshot.head(git).map_err(wire_error)?
1398 } else {
1399 snapshot.resolve(spec).map_err(wire_error)?
1400 };
1401 resolved.ok_or_else(|| ControlError::NotFound(format!("{spec} in {path}")))?
1402 }
1403 };
1404 let id = peel(git, hash, id)?;
1405 let (kind, bytes) = git
1406 .read(id.as_bytes())
1407 .map_err(store_error)?
1408 .ok_or_else(|| ControlError::NotFound(format!("object {id} in {path}")))?;
1409 match kind {
1410 ObjType::Tree => Ok(id),
1411 ObjType::Commit => object::commit(&bytes, hash)
1412 .map(|commit| commit.tree)
1413 .map_err(|err| ControlError::Internal(format!("decoding commit {id}: {err}"))),
1414 // A blob is a legal object and not a legal side of a tree diff.
1415 // Saying so beats walking it and reporting that nothing changed.
1416 other => Err(ControlError::InvalidArgument(format!(
1417 "{spec} names a {other:?}; a diff needs a commit, a tag or a tree"
1418 ))),
1419 }
1420 }
1421
1422 /// The repository's policy as `gunnar-policy` understands it.
1423 ///
1424 /// A repository with no stored policy reads as
1425 /// [`PolicyConfig::default`](gunnar_policy::PolicyConfig) — no gates —
1426 /// which is the same thing the push path concludes and is stated in one
1427 /// place rather than two. A stored document that does **not parse** is an
1428 /// error here and not an empty policy: this path is a caller asking what
1429 /// the rules are, and answering "there are none" about a document that
1430 /// exists and is broken is the worst available answer.
1431 fn policy_of(&self, repo: &gunnar_core::Repo) -> ControlResult<gunnar_policy::PolicyConfig> {
1432 let stored = self.stores.policies().get(&repo.path()).map_err(internal)?;
1433 match stored {
1434 None => Ok(gunnar_policy::PolicyConfig::default()),
1435 Some(document) => gunnar_policy::PolicyConfig::from_json(&document).map_err(|err| {
1436 ControlError::Internal(format!(
1437 "the stored policy for {} does not parse: {err}",
1438 repo.key()
1439 ))
1440 }),
1441 }
1442 }
1443
1444 /// Write `config` back, or delete the document when it configures nothing.
1445 ///
1446 /// Deleting rather than storing an empty document is what keeps "nobody has
1447 /// configured this repository" and "somebody configured nothing" the same
1448 /// state. Two states that behave identically and read differently are a
1449 /// question an operator cannot answer.
1450 fn store_policy(
1451 &self,
1452 repo: &gunnar_core::Repo,
1453 config: &gunnar_policy::PolicyConfig,
1454 ) -> ControlResult<()> {
1455 let policies = self.stores.policies();
1456 if *config == gunnar_policy::PolicyConfig::default() {
1457 policies.remove(&repo.path()).map_err(internal)?;
1458 return Ok(());
1459 }
1460 let json = config
1461 .to_json()
1462 .map_err(|err| ControlError::Internal(format!("rendering the policy: {err}")))?;
1463 policies.set(&repo.path(), &json).map_err(internal)
1464 }
1465
1466 fn open(&self, path: &RepoPath) -> ControlResult<(gunnar_core::Repo, Arc<OpenRepo>)> {
1467 let repo_ref = Self::repo_ref(path)?;
1468 let repo = self
1469 .stores
1470 .catalog()
1471 .get(&repo_ref)
1472 .map_err(internal)?
1473 .ok_or_else(|| ControlError::NotFound(format!("repository {path}")))?;
1474 let store = self.stores.repo_store(&repo).map_err(|err| {
1475 ControlError::Internal(format!("opening the repository's stores: {err:#}"))
1476 })?;
1477 Ok((repo, store))
1478 }
1479
1480 /// **A mirror's registration, with its repository opened when it has
1481 /// one.** A registration whose repository is still to come (an import's
1482 /// first sync has not landed: `MirrorRecord::creates_repo`) has none to
1483 /// open, and that is not an error; every other registration's repository
1484 /// must open, as it always had to.
1485 fn mirror_record(&self, path: &RepoPath) -> ControlResult<gunnar_core::MirrorRecord> {
1486 let repo_ref = Self::repo_ref(path)?;
1487 let record = self.stores.mirrors().get(&repo_ref).map_err(internal)?;
1488 match record {
1489 Some(record)
1490 if record.creates_repo
1491 && self
1492 .stores
1493 .catalog()
1494 .get(&repo_ref)
1495 .map_err(internal)?
1496 .is_none() =>
1497 {
1498 Ok(record)
1499 }
1500 record => {
1501 self.open(path)?;
1502 record.ok_or_else(|| {
1503 ControlError::NotFound(format!("no mirror registered for {path}"))
1504 })
1505 }
1506 }
1507 }
1508}
1509
1510/// **The one place a [`crate::gc_queue::GcJob`] becomes a seam job.**
1511///
1512/// The queue keys on `<namespace>/<name>` because that is what its runner
1513/// needs; the seam speaks [`RepoPath`] because that is what the wire does.
1514/// `path` is threaded in rather than re-parsed out of the job's own string: it
1515/// is the request's own path, already validated, and re-parsing it here would
1516/// be a second resolver that could disagree with the first (one implementation).
1517fn gc_job_out(path: &RepoPath, job: crate::gc_queue::GcJob) -> gunnar_grpc::seam::GcJob {
1518 gunnar_grpc::seam::GcJob {
1519 job_id: job.job_id,
1520 path: path.clone(),
1521 state: match job.state {
1522 crate::gc_queue::GcState::Queued => gunnar_grpc::seam::GcState::Queued,
1523 crate::gc_queue::GcState::Running => gunnar_grpc::seam::GcState::Running,
1524 crate::gc_queue::GcState::Done => gunnar_grpc::seam::GcState::Done,
1525 crate::gc_queue::GcState::Failed => gunnar_grpc::seam::GcState::Failed,
1526 },
1527 queue_position: job.queue_position,
1528 // Named through `report` rather than through the job's `Deref`: the
1529 // job is being taken apart field by field here, and a deref borrow in
1530 // the middle of that would be reading a value that is half moved.
1531 bytes_reclaimed: job.report.bytes_reclaimed,
1532 error: job.error,
1533 enqueued_at_unix_ms: job.enqueued_at_unix_ms,
1534 finished_at_unix_ms: job.finished_at_unix_ms,
1535 }
1536}
1537
1538/// `gunnar_core`'s gate onto the seam's.
1539fn gate_out(gate: gunnar_core::AccountGate) -> gunnar_grpc::seam::AccountGate {
1540 gunnar_grpc::seam::AccountGate {
1541 account: gate.account,
1542 reason: match gate.reason {
1543 gunnar_core::GateReason::Administrative => {
1544 gunnar_grpc::seam::GateReason::Administrative
1545 }
1546 gunnar_core::GateReason::Purge => gunnar_grpc::seam::GateReason::Purge,
1547 },
1548 holder: gate.holder,
1549 actor: gate.actor,
1550 note: gate.note,
1551 at_unix_ms: gate.at,
1552 // **0 on the seam means "no deadline"**, which is what `None` means
1553 // here. Flattened at this boundary rather than on the wire so the proto
1554 // stays a plain `int64` and every reader of it applies the same rule.
1555 expires_unix_ms: gate.expires.unwrap_or(0),
1556 }
1557}
1558
1559/// The queue's job onto the seam's.
1560fn purge_job_out(job: crate::purge_queue::PurgeJob) -> gunnar_grpc::seam::PurgeJob {
1561 // The purge's own half, taken out whole before the queue's half is: the
1562 // failure list is owned and has to be MOVED onto the seam, and a job's
1563 // `Deref` only ever lends its report.
1564 let report = job.report;
1565 gunnar_grpc::seam::PurgeJob {
1566 job_id: job.job_id,
1567 // The queue keys on the account, so the account IS the job's key.
1568 account: job.key,
1569 state: match job.state {
1570 crate::purge_queue::PurgeState::Queued => gunnar_grpc::seam::PurgeState::Queued,
1571 crate::purge_queue::PurgeState::Running => gunnar_grpc::seam::PurgeState::Running,
1572 crate::purge_queue::PurgeState::Done => gunnar_grpc::seam::PurgeState::Done,
1573 crate::purge_queue::PurgeState::Failed => gunnar_grpc::seam::PurgeState::Failed,
1574 },
1575 queue_position: job.queue_position,
1576 stores_found: report.stores_found,
1577 stores_purged: report.stores_purged,
1578 bytes_reclaimed: report.bytes_reclaimed,
1579 principal_removed: report.principal_removed,
1580 gate_released: report.gate_released,
1581 error: job.error,
1582 failures: report.failures,
1583 enqueued_at_unix_ms: job.enqueued_at_unix_ms,
1584 started_at_unix_ms: job.started_at_unix_ms,
1585 finished_at_unix_ms: job.finished_at_unix_ms,
1586 }
1587}
1588
1589/// A gate write that was refused, as the error the caller wants.
1590///
1591/// A reserved or malformed account name is the caller's mistake and comes back
1592/// as `INVALID_ARGUMENT`; anything else is the table failing to be written and
1593/// is not the caller's fault. Folding the first into `Internal` would send an
1594/// operator looking at the disk over a typo.
1595fn gate_error(err: gunnar_core::Error) -> ControlError {
1596 match err {
1597 gunnar_core::Error::ReservedPrincipalId(_) | gunnar_core::Error::InvalidPath { .. } => {
1598 ControlError::InvalidArgument(format!("{err}"))
1599 }
1600 other => internal(other),
1601 }
1602}
1603
1604/// **One stored flow row → one `DayActivity`.** The single writer of that
1605/// mapping, so the wire and the ledger cannot drift by a field.
1606///
1607/// `refused_by_reason` carries only the reasons that HAPPENED. A reason with no
1608/// refusals that day is absent rather than 0, because the map is a breakdown of
1609/// a number the caller already has (`refused`) and a zero entry in it adds a
1610/// row to a console's list that says nothing.
1611fn day_activity(day: i64, row: &gunnar_core::FlowDay) -> gunnar_proto::DayActivity {
1612 gunnar_proto::DayActivity {
1613 day_unix_ms: gunnar_core::midnight_of(day),
1614 bytes_in: row.bytes_in,
1615 bytes_out: row.bytes_out,
1616 pushes: row.pushes,
1617 fetches: row.fetches,
1618 lfs_bytes_in: row.lfs_bytes_in,
1619 lfs_bytes_out: row.lfs_bytes_out,
1620 refused: row.refused(),
1621 refused_by_reason: gunnar_core::Refusal::ALL
1622 .into_iter()
1623 .filter(|reason| row.refused_for(*reason) > 0)
1624 .map(|reason| (reason.name().to_owned(), row.refused_for(reason)))
1625 .collect(),
1626 auth_failures: row.auth_failures,
1627 last_push_unix_ms: row.last_push_unix_ms,
1628 last_fetch_unix_ms: row.last_fetch_unix_ms,
1629 distinct_principals: row.distinct_principals(),
1630 distinct_principals_capped: row.principals_capped,
1631 }
1632}
1633
1634/// **A create's refusal, classified** — the repository or namespace named, a box being
1635/// restored (or off the stripe) as UNAVAILABLE (RUN A 2026-10-08: a create on a main being
1636/// restored answered INTERNAL, and the console showed "transaction log" and "twin" to a
1637/// customer), any other core error INTERNAL, anything else (the disk's own refusal) as is.
1638pub(crate) fn create_error(err: &anyhow::Error, path: &RepoPath) -> ControlError {
1639 match err.downcast_ref::<gunnar_core::Error>() {
1640 Some(gunnar_core::Error::RepoExists(_)) => {
1641 ControlError::AlreadyExists(format!("repository {path}"))
1642 }
1643 Some(gunnar_core::Error::NoSuchNamespace(ns)) => {
1644 ControlError::NotFound(format!("namespace {ns}"))
1645 }
1646 Some(e @ (gunnar_core::Error::LogFollows | gunnar_core::Error::OffStripe { .. })) => {
1647 ControlError::Unavailable(e.to_string())
1648 }
1649 Some(_) => ControlError::Internal(format!("{err:#}")),
1650 None => ControlError::FailedPrecondition(format!("{err:#}")),
1651 }
1652}
1653
1654/// **Is this the mirror's first sync, from its in-flight mark?** (`mark_started`, IMPORT-BIG).
1655/// The mark is what lets a restart resume the import and what makes its outcome audited.
1656pub(crate) fn first_sync_of<E: std::fmt::Display>(
1657 mark: Result<Option<gunnar_core::MirrorRecord>, E>,
1658 path: &str,
1659) -> Result<bool, String> {
1660 match mark {
1661 Ok(record) => {
1662 Ok(record.is_some_and(|record| record.creates_repo || record.sync_count == 0))
1663 }
1664 Err(err) => {
1665 // RA4-10: unwritten, a restart could not resume this import and its outcome would go
1666 // unaudited, so the sync does not start.
1667 tracing::error!(target: "gunnar::git", "ALERT mirror.mark-unwritten {path}: the in-flight mark was not written ({err}); the sync is not started");
1668 Err(format!("{path}: the mirror's in-flight mark was not written ({err}); nothing was fetched, ask again"))
1669 }
1670 }
1671}
1672
1673#[cfg(test)]
1674mod mirror_mark_tests {
1675 use super::*;
1676
1677 /// ★ RA4-10 RED: **an in-flight mark that is not written fails the sync by name** — a
1678 /// dropped write meant a restart could not resume the import and its outcome went
1679 /// unaudited.
1680 #[test]
1681 fn a_mark_that_is_not_written_fails_the_sync_by_name() {
1682 let why = first_sync_of::<String>(Err("the mirrors store is full".into()), "acme/linux")
1683 .expect_err("refused");
1684 assert!(
1685 why.contains("acme/linux")
1686 && why.contains("the mirrors store is full")
1687 && why.contains("in-flight mark"),
1688 "{why}"
1689 );
1690 assert_eq!(first_sync_of::<String>(Ok(None), "acme/linux"), Ok(false));
1691 }
1692}
1693
1694#[cfg(test)]
1695mod create_error_tests {
1696 use super::*;
1697
1698 /// ★ **A create on a box being restored is UNAVAILABLE, never INTERNAL** (RUN A R8,
1699 /// 2026-10-08): the console turns UNAVAILABLE + this sentence into its own word.
1700 #[test]
1701 fn a_create_on_a_restoring_box_is_unavailable() {
1702 let path = RepoPath::new("acme", "site");
1703 let restoring = anyhow::Error::new(gunnar_core::Error::LogFollows);
1704 assert!(
1705 matches!(create_error(&restoring, &path), ControlError::Unavailable(m) if m.contains("being restored"))
1706 );
1707 let exists = anyhow::Error::new(gunnar_core::Error::RepoExists(
1708 gunnar_core::RepoRef::new("acme", "site").unwrap(),
1709 ));
1710 assert!(matches!(
1711 create_error(&exists, &path),
1712 ControlError::AlreadyExists(_)
1713 ));
1714 assert!(matches!(
1715 create_error(&anyhow::anyhow!("a store already stands there"), &path),
1716 ControlError::FailedPrecondition(_)
1717 ));
1718 }
1719}
1720
1721/// A catalog error, mapped to the status code that says **whose fault it is**.
1722///
1723/// This used to be `ControlError::Internal(err.to_string())` for every one of
1724/// its thirty-four call sites, so `gunnar control repo set-default-branch
1725/// team/project trunk` came back as
1726///
1727/// ```text
1728/// gunnar: Internal error: internal error: not a legal default branch name: "trunk"
1729/// ```
1730///
1731/// An `INTERNAL` says *the server is broken*. What actually happened is that
1732/// the caller typed a short branch name where a full ref was required, and
1733/// nothing in that sentence tells them to fix their argument rather than read
1734/// the server's logs. Every "this repository does not exist", "that name is
1735/// taken" and "you are not allowed to" arrived the same way.
1736///
1737/// `enrolment_error` already classified its own errors for exactly this
1738/// reason; this is the same job for the rest of the surface, in one function
1739/// rather than at thirty-four call sites (one implementation). The mapping follows gRPC's
1740/// own definitions:
1741///
1742/// * **INVALID_ARGUMENT** — the request is malformed however the server feels.
1743/// * **NOT_FOUND** / **ALREADY_EXISTS** — the named thing is or is not there.
1744/// * **FAILED_PRECONDITION** — well formed, and the server's *state* refuses.
1745/// * **PERMISSION_DENIED** — authenticated, not allowed.
1746/// * **UNAVAILABLE** — storage did not answer; retrying may work.
1747/// * **INTERNAL** — only what is genuinely a bug here, which is the point:
1748/// an `INTERNAL` an operator sees now means something.
1749fn internal(err: gunnar_core::Error) -> ControlError {
1750 use gunnar_core::Error as E;
1751 match err {
1752 // The caller's argument.
1753 e @ (E::InvalidPath { .. }
1754 | E::PathEscape { .. }
1755 | E::ReservedPrincipalId(_)
1756 | E::BadBranchName(_)
1757 | E::BadFingerprint(_)
1758 | E::Key(_)
1759 | E::InvalidInput { .. }) => ControlError::InvalidArgument(e.to_string()),
1760 // What is not there.
1761 e @ (E::NoSuchRepo(_)
1762 | E::NoSuchNamespace(_)
1763 | E::NoSuchPrincipal(_)
1764 | E::NoSuchCredential(_)) => ControlError::NotFound(e.to_string()),
1765 // …and what already is.
1766 e @ (E::RepoExists(_)
1767 | E::NamespaceExists(_)
1768 | E::PrincipalExists(_)
1769 | E::NameExists { .. }
1770 | E::KeyExists { .. }
1771 | E::CredentialExists { .. }
1772 | E::InviteExists(_)
1773 | E::TokenExists { .. }
1774 | E::AlreadyBootstrapped { .. }
1775 | E::AlreadyProvisioned { .. }) => ControlError::AlreadyExists(e.to_string()),
1776 // Well formed; the server's state says no. A namespace that still holds
1777 // repositories is the case an operator meets, and `--delete-repos` is
1778 // the answer — which is a precondition, not a bad request.
1779 e @ (E::NamespaceNotEmpty { .. }
1780 | E::Contended { .. }
1781 | E::NotAPerson { .. }
1782 | E::NotAMachine { .. }) => ControlError::FailedPrecondition(e.to_string()),
1783 e @ (E::Denied { .. } | E::CounterRollback { .. } | E::InviteInvalid) => {
1784 ControlError::PermissionDenied(e.to_string())
1785 }
1786 // Storage did not answer. NOT `Internal`: the request may well succeed
1787 // on a retry, and telling a caller their request was wrong when the
1788 // disk was busy sends them to fix the wrong thing.
1789 e @ (E::Store(_) | E::Json(_)) => ControlError::Unavailable(e.to_string()),
1790 // The control store is OFF THE STRIPE while the data set grows: the
1791 // write is refused by name and the caller retries after the growth —
1792 // D7's "refuse", and UNAVAILABLE says exactly that to a generated
1793 // client where INTERNAL would say "look at this server".
1794 e @ E::OffStripe { .. } => ControlError::Unavailable(e.to_string()),
1795 // A box being restored takes no write until the restore is whole: "try again".
1796 e @ E::LogFollows => ControlError::Unavailable(e.to_string()),
1797 // A document that will not parse, and anything unclassified. These are
1798 // what genuinely mean "look at this server". `gunnar_core::Error` is
1799 // `#[non_exhaustive]`, so a variant added later lands here — `INTERNAL`
1800 // is the right default for "gunnar does not know what this is", and it
1801 // errs toward the server's fault rather than toward blaming the caller
1802 // for something nobody has classified.
1803 e => ControlError::Internal(e.to_string()),
1804 }
1805}
1806
1807/// **An archive cut's error, classified by whose fault it is** — the same job
1808/// [`internal`] does for `gunnar_core::Error`, for the one path that reports an
1809/// `anyhow` chain instead.
1810///
1811/// Two classes, and they are told apart by what is *in* the chain rather than
1812/// by matching on a message:
1813///
1814/// * an `io::Error` anywhere in it means the disk or the socket did not answer
1815/// — `UNAVAILABLE`, because a retry may well work, and a `BrokenPipe` is the
1816/// *client* having hung up rather than this server being broken;
1817/// * anything else is a refusal the cut made on purpose — the watermark above a
1818/// pack a GC has rewritten, a directory with no `objects.pack`, a repository
1819/// directory holding two stores. Those are `FAILED_PRECONDITION`: the request
1820/// is well formed and the server's state says no. A full archive
1821/// (`--from-generation 0`) is the answer to the first, and the message says so
1822/// in the plugin's own words.
1823#[cfg(feature = "engine-znippy")]
1824fn archive_error(err: anyhow::Error) -> ControlError {
1825 let io = err
1826 .chain()
1827 .any(|cause| cause.downcast_ref::<std::io::Error>().is_some());
1828 let message = format!("{err:#}");
1829 if io {
1830 ControlError::Unavailable(message)
1831 } else {
1832 ControlError::FailedPrecondition(message)
1833 }
1834}
1835
1836/// **The one storage-fault conversion in this file** (one implementation).
1837///
1838/// `GitOps` and `GitServe` answer in `anyhow`, so every read of a repository
1839/// arrives here. `{:#}` and not `to_string()`: the cause chain is the whole of
1840/// what an operator has to go on, and `to_string()` prints only the outermost
1841/// context — *"reading the ref namespace"* with the errno dropped.
1842///
1843/// **The status code is unchanged, deliberately.** `Internal` is what this
1844/// answered before, and `internal_or_denied` twenty lines above classifies
1845/// `gunnar_core::Error::Store` as `Unavailable` for a reason that applies here
1846/// too — a storage fault may well succeed on a retry. Changing it is a change
1847/// to what every gRPC client of `Refs.*` retries on, so it is **recorded here
1848/// and not taken as a drive-by** of a compile fix.
1849///
1850/// ⚠ **A lost compare-and-swap and a broken disk arrive as the same string** on
1851/// this path. `git-storage-trait` carries [`RefRejection`] for exactly that
1852/// distinction and `RefRejection::of(&err)` is how it is recovered; nothing in
1853/// this file needs it yet. Whatever does must use that, and **never** match on
1854/// the message text.
1855///
1856/// [`RefRejection`]: gunnar_store::git_storage_trait::RefRejection
1857fn store_error(err: anyhow::Error) -> ControlError {
1858 ControlError::Internal(format!("{err:#}"))
1859}
1860
1861/// The same, for the one authority on following a symbolic reference.
1862///
1863/// [`gunnar_wire::refs`] returns `gunnar_wire::Error` rather than
1864/// `anyhow::Error`; a symref walk that refuses is a fault in what the store
1865/// holds, which is the same `Internal` this control plane already reports for
1866/// [`store_error`], so the two funnel to one variant rather than inventing a
1867/// second grade of failure for the same event.
1868fn wire_error(err: gunnar_wire::Error) -> ControlError {
1869 ControlError::Internal(format!("{err:#}"))
1870}
1871
1872// ── model translation ───────────────────────────────────────────────────────
1873
1874/// The one translation of a hash kind into the proto enum. `transport.rs`
1875/// reuses it for the push attestation rather than writing a second `match`
1876/// that could drift from this one (one implementation).
1877pub(crate) fn hash_kind(kind: HashKind) -> gunnar_proto::HashKind {
1878 match kind {
1879 HashKind::Sha1 => gunnar_proto::HashKind::Sha1,
1880 HashKind::Sha256 => gunnar_proto::HashKind::Sha256,
1881 }
1882}
1883
1884/// gunnar has **two** visibilities, not three.
1885///
1886/// The proto still carries `VISIBILITY_INTERNAL`, and it used to alias
1887/// `PUBLIC` here — honestly, at the time: with no anonymous path at all,
1888/// "readable by any authenticated principal" and "public" named the same set.
1889/// Anonymous read (2026-08-03) broke that equivalence — `Public` now means readable by
1890/// ANYONE, with no key — and the alias survived the redefinition. What that
1891/// cost, MEASURED 2026-08-19 through the console: an operator chose
1892/// "internal" and the repository came out anonymous-readable. Silently
1893/// WIDENING an access request is the one direction a coercion must never
1894/// fail, so `INTERNAL` is now refused by name instead of mapped. The day a
1895/// real internal tier exists in [`Visibility`], this arm becomes its
1896/// translation; until then the wire value is a claim with no implementation.
1897fn visibility_in(v: gunnar_proto::Visibility) -> ControlResult<Visibility> {
1898 match v {
1899 gunnar_proto::Visibility::Public => Ok(Visibility::Public),
1900 gunnar_proto::Visibility::Internal => Err(ControlError::InvalidArgument(
1901 "visibility \"internal\" has no implementation: since 2026-08-03, \"public\" means \
1902 readable with no key at all, so \"internal\" (any authenticated principal) \
1903 no longer aliases it and gunnar refuses to guess which you meant. Choose \
1904 \"private\" or \"public\""
1905 .into(),
1906 )),
1907 gunnar_proto::Visibility::Private | gunnar_proto::Visibility::Unspecified => {
1908 Ok(Visibility::Private)
1909 }
1910 }
1911}
1912
1913/// The one translation of a stored pull request into the proto shape (one implementation).
1914fn pull_out(path: &RepoPath, record: gunnar_core::PullRecord) -> PullRequest {
1915 PullRequest {
1916 repo: Some(path.clone()),
1917 number: record.number,
1918 title: record.title,
1919 description: record.description,
1920 source_ref: record.source_ref,
1921 target_ref: record.target_ref,
1922 state: match record.state {
1923 gunnar_core::PullState::Open => PullRequestState::Open,
1924 gunnar_core::PullState::Merged => PullRequestState::Merged,
1925 gunnar_core::PullState::Closed => PullRequestState::Closed,
1926 } as i32,
1927 author: record.author,
1928 created_at_unix_ms: record.created_ms,
1929 updated_at_unix_ms: record.updated_ms,
1930 source_oid: record.source_oid,
1931 merge_oid: record.merge_oid.unwrap_or_default(),
1932 }
1933}
1934
1935fn visibility_out(v: Visibility) -> gunnar_proto::Visibility {
1936 match v {
1937 Visibility::Private => gunnar_proto::Visibility::Private,
1938 Visibility::Public => gunnar_proto::Visibility::Public,
1939 }
1940}
1941
1942fn principal_kind_in(kind: gunnar_proto::PrincipalKind) -> PrincipalKind {
1943 match kind {
1944 gunnar_proto::PrincipalKind::Machine => PrincipalKind::Machine,
1945 gunnar_proto::PrincipalKind::Deploy => PrincipalKind::Deploy,
1946 gunnar_proto::PrincipalKind::Group => PrincipalKind::Group,
1947 gunnar_proto::PrincipalKind::User | gunnar_proto::PrincipalKind::Unspecified => {
1948 PrincipalKind::User
1949 }
1950 }
1951}
1952
1953impl CoreControlPlane {
1954 /// **Whose account the namespace `name` is** (`Namespace.owner_kind`): the
1955 /// kind of the principal whose id it is, else UNSPECIFIED — so a reader
1956 /// that bills per namespace can leave out the estate's own machines by
1957 /// what they ARE, not by a list of their names. A store that cannot be
1958 /// read says UNSPECIFIED (the namespace is still listed).
1959 ///
1960 /// A GROUP whose every direct member is a machine is the estate's own too
1961 /// (the `twin` group, the twin's log readers — MEASURED on the mock
1962 /// 2026-09-29, listed as a tenant) and answers MACHINE; a group with anyone
1963 /// else in it, or with nobody, stays a group.
1964 fn owner_kind(&self, name: &str) -> i32 {
1965 let auth = self.stores.auth();
1966 match auth.principal(name) {
1967 Ok(Some(p)) if p.kind == PrincipalKind::Group => {
1968 let members = auth.members_of(name).unwrap_or_default();
1969 let machines = !members.is_empty() && members.iter().all(|m| matches!(auth.principal(m), Ok(Some(q)) if q.kind == PrincipalKind::Machine));
1970 if machines {
1971 gunnar_proto::PrincipalKind::Machine as i32
1972 } else {
1973 principal_kind_out(p.kind) as i32
1974 }
1975 }
1976 Ok(Some(p)) => principal_kind_out(p.kind) as i32,
1977 _ => gunnar_proto::PrincipalKind::Unspecified as i32,
1978 }
1979 }
1980
1981 /// The catalog's namespace onto the wire — one mapping for create, list
1982 /// and the single lookup, so the account's settings and its employee count
1983 /// cannot be on one and missing from another. `employees` is derived (an
1984 /// account with any is an enterprise account; nothing is set).
1985 fn namespace_out(&self, namespace: gunnar_core::Namespace, employees: u32) -> Namespace {
1986 Namespace {
1987 owner_kind: self.owner_kind(&namespace.name),
1988 // A store that cannot be read says SELLER: a customer left out for one tick is given
1989 // its fact on the next, while a fact given to the operator's own namespace stays.
1990 owner_server_grant: self
1991 .stores
1992 .auth()
1993 .holds_server_grant(&namespace.name)
1994 .unwrap_or(true),
1995 name: namespace.name,
1996 description: String::new(),
1997 created_at_unix_ms: 0,
1998 public: namespace.public,
1999 events_public: namespace.events_public,
2000 employees,
2001 }
2002 }
2003
2004 /// How many employees `account` has enrolled.
2005 fn employee_count(&self, account: &str) -> ControlResult<u32> {
2006 Ok(self
2007 .stores
2008 .auth()
2009 .employees_of(account)
2010 .map_err(internal)?
2011 .len() as u32)
2012 }
2013}
2014
2015fn principal_kind_out(kind: PrincipalKind) -> gunnar_proto::PrincipalKind {
2016 match kind {
2017 PrincipalKind::User => gunnar_proto::PrincipalKind::User,
2018 PrincipalKind::Machine => gunnar_proto::PrincipalKind::Machine,
2019 PrincipalKind::Deploy => gunnar_proto::PrincipalKind::Deploy,
2020 PrincipalKind::Group => gunnar_proto::PrincipalKind::Group,
2021 // **Unreachable, and mapped honestly rather than plausibly.**
2022 // Every principal that reaches this function came out of the identity
2023 // table, and `AuthTable::add_principal` refuses the anonymous id, so
2024 // there is no anonymous row to list. `gunnar-proto` has no `ANONYMOUS`
2025 // value, and picking `USER` would put a fabricated human in an operator's
2026 // principal list; `UNSPECIFIED` is the value that means "gunnar cannot
2027 // name this", which is the truth. Adding the enum value is a
2028 // `gunnar-proto` change and is recorded there, not invented here.
2029 PrincipalKind::Anonymous => gunnar_proto::PrincipalKind::Unspecified,
2030 }
2031}
2032
2033fn level_in(level: GrantLevel) -> ControlResult<Level> {
2034 match level {
2035 GrantLevel::Read => Ok(Level::Read),
2036 GrantLevel::Write => Ok(Level::Write),
2037 GrantLevel::Admin => Ok(Level::Admin),
2038 GrantLevel::Unspecified => Err(ControlError::InvalidArgument(
2039 "grant level unspecified".into(),
2040 )),
2041 }
2042}
2043
2044fn level_out(level: Level) -> GrantLevel {
2045 match level {
2046 Level::Read => GrantLevel::Read,
2047 Level::Write => GrantLevel::Write,
2048 Level::Admin => GrantLevel::Admin,
2049 }
2050}
2051
2052fn repo_out(repo: &gunnar_core::Repo) -> Repo {
2053 Repo {
2054 path: Some(RepoPath::new(&repo.namespace, &repo.name)),
2055 hash_kind: hash_kind(repo.hash_kind) as i32,
2056 default_branch: repo.default_branch.clone(),
2057 visibility: visibility_out(repo.visibility) as i32,
2058 created_at_unix_ms: repo.created,
2059 description: repo.description.clone(),
2060 encrypted: repo.encrypted,
2061 }
2062}
2063
2064/// A stored web credential, as the wire carries it (no key material).
2065fn web_credential_out(
2066 row: &gunnar_core::WebCredential,
2067) -> ControlResult<gunnar_proto::WebCredential> {
2068 let credential_id = gunnar_grpc::webauthn::from_b64url(&row.id).ok_or_else(|| {
2069 ControlError::Internal(format!(
2070 "a stored web credential has an id that is not base64url: {}",
2071 row.id
2072 ))
2073 })?;
2074 Ok(gunnar_proto::WebCredential {
2075 credential_id,
2076 principal_id: row.principal_id.clone(),
2077 label: row.label.clone(),
2078 created_at_unix_ms: row.created,
2079 last_used_unix_ms: row.last_used,
2080 sign_count: row.sign_count,
2081 })
2082}
2083
2084fn principal_out(principal: &gunnar_core::Principal) -> Principal {
2085 Principal {
2086 id: principal.id.clone(),
2087 name: principal.name.clone(),
2088 kind: principal_kind_out(principal.kind) as i32,
2089 // The table's stamp. `0` for a row from before the field existed,
2090 // which is exactly what this reported for every row until then.
2091 created_at_unix_ms: principal.created,
2092 }
2093}
2094
2095/// Clans on the identity table (`gunnar_grpc::clans::ClanPlane`, 2026-10-05).
2096mod clan_plane;
2097
2098fn key_out(key: &gunnar_core::PublicKey) -> PublicKey {
2099 PublicKey {
2100 fingerprint: key.fingerprint_sha256.clone(),
2101 algo: key.algo.clone(),
2102 principal_id: key.principal_id.clone(),
2103 // The OpenSSH comment field is part of the stored line; gunnar-core
2104 // normalises the line and does not split it out.
2105 comment: String::new(),
2106 added_at_unix_ms: key.added,
2107 last_used_unix_ms: key.last_used,
2108 expires_at_unix_ms: key.expires,
2109 }
2110}
2111
2112/// A grant, as `<namespace, repo>` strings.
2113///
2114/// A [`Scope::Server`] grant has neither, and is carried as two empty strings.
2115/// See the module docs: that is a proto gap, not an invention here.
2116fn grant_out(grant: &Grant) -> gunnar_proto::Grant {
2117 let (namespace, repo) = match &grant.scope {
2118 Scope::Server => (String::new(), String::new()),
2119 Scope::Namespace(name) => (name.clone(), String::new()),
2120 Scope::Repo(path) => (path.namespace().to_owned(), path.name().to_owned()),
2121 };
2122 gunnar_proto::Grant {
2123 principal_id: grant.principal.clone(),
2124 namespace,
2125 repo,
2126 level: level_out(grant.level) as i32,
2127 granted_at_unix_ms: 0,
2128 }
2129}
2130
2131fn scope_in(namespace: &str, repo: &str) -> ControlResult<Scope> {
2132 match (namespace.is_empty(), repo.is_empty()) {
2133 (true, true) => Ok(Scope::Server),
2134 (false, true) => Ok(Scope::Namespace(namespace.to_owned())),
2135 (false, false) => RepoRef::new(namespace, repo)
2136 .map(Scope::Repo)
2137 .map_err(|err| ControlError::InvalidArgument(err.to_string())),
2138 (true, false) => Err(ControlError::InvalidArgument(
2139 "a repository-scoped grant must name a namespace (multi-tenancy has \
2140 no ungrouped repos)"
2141 .into(),
2142 )),
2143 }
2144}
2145
2146/// The contract's object kind, in the proto's vocabulary.
2147///
2148/// [`ObjType`] also has `OfsDelta` and `RefDelta`, and neither can appear here:
2149/// [`GitServe::read`] and [`GitServe::header`] answer with the **resolved**
2150/// type by contract — that is the difference between them and
2151/// `GitOps::get`, which hands back the pack entry delta and all. A delta
2152/// reaching this function is a backend that broke its own promise, so it is
2153/// named as one rather than folded into `Unspecified`, which a client would
2154/// read as "an object of a kind this server is too old to name".
2155fn object_kind_out(kind: ObjType) -> ControlResult<gunnar_proto::ObjectKind> {
2156 match kind {
2157 ObjType::Blob => Ok(gunnar_proto::ObjectKind::Blob),
2158 ObjType::Tree => Ok(gunnar_proto::ObjectKind::Tree),
2159 ObjType::Commit => Ok(gunnar_proto::ObjectKind::Commit),
2160 ObjType::Tag => Ok(gunnar_proto::ObjectKind::Tag),
2161 other => Err(ControlError::Internal(format!(
2162 "the store answered a read with `{}`, which is a delta; GitServe::read is \
2163 documented to return the resolved type",
2164 other.as_str()
2165 ))),
2166 }
2167}
2168
2169/// Follow an annotated tag down to the object it names.
2170///
2171/// A bounded loop, not a recursion: a tag chain is legal git and a cycle is
2172/// not, but a cycle is also cheap to write and would otherwise hang a control
2173/// plane that has no timeout.
2174///
2175/// What object id `segments` names under `tree` — a blob's id, a subtree's id,
2176/// or `None` when the path names nothing there.
2177///
2178/// The log's path filter rests on this: a commit changed a path exactly when
2179/// this answer differs from its first parent's. A path that continues THROUGH
2180/// a non-directory names nothing, the same rule the wire's tree walk applies.
2181fn oid_at_path(
2182 git: &dyn GitServe,
2183 hash_kind: gix_hash::Kind,
2184 tree: gix_hash::ObjectId,
2185 segments: &[&[u8]],
2186) -> ControlResult<Option<gix_hash::ObjectId>> {
2187 let mut current = tree;
2188 let last = segments.len().saturating_sub(1);
2189 for (index, segment) in segments.iter().enumerate() {
2190 let Some((kind, bytes)) = git.read(current.as_bytes()).map_err(store_error)? else {
2191 return Ok(None);
2192 };
2193 if kind != ObjType::Tree {
2194 return Ok(None);
2195 }
2196 let Some(entry) = object::tree_entry_named(&bytes, hash_kind, segment)
2197 .map_err(|err| ControlError::Internal(format!("decoding tree {current}: {err}")))?
2198 else {
2199 return Ok(None);
2200 };
2201 if index == last {
2202 return Ok(Some(entry.oid));
2203 }
2204 if entry.kind != object::EntryKind::Tree {
2205 return Ok(None);
2206 }
2207 current = entry.oid;
2208 }
2209 Ok(Some(current))
2210}
2211
2212/// `hash_kind` is a parameter and never a default: it comes from
2213/// [`OpenRepo::hash_kind`], which comes from the catalog entry, and answering
2214/// `sha1` for a sha256 repository does not error — it decodes garbage.
2215/// Where a `Refs.Log` walk begins: one start (a ref name, an oid, or empty
2216/// for `HEAD`), or every branch and tag tip plus `HEAD`.
2217#[derive(Clone, Copy)]
2218enum LogStart<'a> {
2219 One(&'a str),
2220 AllRefs,
2221}
2222
2223/// One `Refs.Log` start resolved to an oid: an oid as given, otherwise a ref
2224/// name (empty means `HEAD`) resolved against one snapshot.
2225fn resolve_log_start(
2226 git: &dyn GitServe,
2227 hash: gix_hash::Kind,
2228 path: &RepoPath,
2229 start: &str,
2230) -> ControlResult<gix_hash::ObjectId> {
2231 let start = if start.is_empty() {
2232 gunnar_store::HEAD
2233 } else {
2234 start
2235 };
2236 let head = match gix_hash::ObjectId::from_hex(start.as_bytes()) {
2237 Ok(id) => id,
2238 Err(_) => {
2239 gunnar_store::refname::validate(start)
2240 .map_err(|err| ControlError::InvalidArgument(err.to_string()))?;
2241 let snapshot = RefSnapshot::read(git).map_err(wire_error)?;
2242 // `HEAD` is not in the snapshot (the `GitServe` HEAD ruling) and it is this verb's
2243 // own default, so it is asked through the accessor pair. Both
2244 // branches resolve against the same snapshot.
2245 let resolved = if start == gunnar_store::HEAD {
2246 snapshot.head(git).map_err(wire_error)?
2247 } else {
2248 snapshot.resolve(start).map_err(wire_error)?
2249 };
2250 resolved.ok_or_else(|| ControlError::NotFound(format!("{start} in {path}")))?
2251 }
2252 };
2253 if head.kind() != hash {
2254 return Err(ControlError::InvalidArgument(format!(
2255 "{start} is a {} oid; this repository is {hash}",
2256 head.kind()
2257 )));
2258 }
2259 Ok(head)
2260}
2261
2262impl CoreControlPlane {
2263 fn log_walk(
2264 &self,
2265 path: &RepoPath,
2266 start: LogStart<'_>,
2267 limit: u32,
2268 touching: &str,
2269 ) -> ControlResult<Vec<Commit>> {
2270 use std::collections::{BinaryHeap, HashMap, HashSet};
2271
2272 let (_, store) = self.open(path)?;
2273 let git = store.serve();
2274 let hash = store.hash_kind();
2275
2276 // The path filter, refused loudly rather than normalised: a filter
2277 // that silently ate `..` would answer for a different path than the
2278 // caller named.
2279 let filter: Option<Vec<&[u8]>> = if touching.is_empty() {
2280 None
2281 } else {
2282 let mut segments = Vec::new();
2283 for segment in touching.split('/').filter(|s| !s.is_empty()) {
2284 if segment == "." || segment == ".." {
2285 return Err(ControlError::InvalidArgument(format!(
2286 "the log path filter cannot contain {segment:?}"
2287 )));
2288 }
2289 segments.push(segment.as_bytes());
2290 }
2291 (!segments.is_empty()).then_some(segments)
2292 };
2293
2294 let heads: Vec<gix_hash::ObjectId> = match start {
2295 LogStart::One(start) => vec![resolve_log_start(git, hash, path, start)?],
2296 LogStart::AllRefs => {
2297 let snapshot = RefSnapshot::read(git).map_err(wire_error)?;
2298 let mut heads = Vec::new();
2299 if let Some(head) = snapshot.head(git).map_err(wire_error)? {
2300 heads.push(head);
2301 }
2302 for row in snapshot.rows().iter().filter(|r| {
2303 r.name.starts_with("refs/heads/") || r.name.starts_with("refs/tags/")
2304 }) {
2305 if let Some(id) = snapshot.resolve_row(row).map_err(wire_error)? {
2306 heads.push(id);
2307 }
2308 }
2309 heads
2310 }
2311 };
2312
2313 // Newest first, by EACH COMMIT'S OWN committer time — so tips of
2314 // several branches interleave the way `git log --date-order --all`
2315 // does, and a page cut by `limit` is the newest `limit` commits.
2316 // Each commit is decoded once, when it is queued, and kept until it
2317 // is popped.
2318 let limit = if limit == 0 { 50 } else { limit } as usize;
2319 let mut heap: BinaryHeap<(i64, gix_hash::ObjectId)> = BinaryHeap::new();
2320 let mut seen: HashSet<gix_hash::ObjectId> = HashSet::new();
2321 let mut queued: HashMap<gix_hash::ObjectId, object::Commit> = HashMap::new();
2322 let mut enqueue = |id: gix_hash::ObjectId,
2323 heap: &mut BinaryHeap<(i64, gix_hash::ObjectId)>,
2324 queued: &mut HashMap<gix_hash::ObjectId, object::Commit>|
2325 -> ControlResult<()> {
2326 if !seen.insert(id) {
2327 return Ok(());
2328 }
2329 // A shallow edge (not in this store) or a tip that peels to a
2330 // non-commit ends that line quietly, as it always did.
2331 let Some((kind, bytes)) = git.read(id.as_bytes()).map_err(store_error)? else {
2332 return Ok(());
2333 };
2334 if kind != ObjType::Commit {
2335 return Ok(());
2336 }
2337 let commit = object::commit(&bytes, hash)
2338 .map_err(|err| ControlError::Internal(format!("decoding commit {id}: {err}")))?;
2339 heap.push((commit.committer.seconds, id));
2340 queued.insert(id, commit);
2341 Ok(())
2342 };
2343 for head in heads {
2344 let head = peel(git, hash, head)?;
2345 enqueue(head, &mut heap, &mut queued)?;
2346 }
2347 let mut out = Vec::new();
2348
2349 // What a FILTERED walk may visit before giving up. A path nothing ever
2350 // touched must not cost a full traversal of a quarter-million commits
2351 // per page; a page that comes back short because of this bound is a
2352 // short page, not the end of history — the proto says so.
2353 const MAX_FILTERED_SCAN: usize = 20_000;
2354 let mut scanned = 0usize;
2355 // commit → its tree, remembered so a parent decoded for the filter is
2356 // not decoded again when the walk reaches it — and vice versa.
2357 let mut tree_of: HashMap<gix_hash::ObjectId, gix_hash::ObjectId> = HashMap::new();
2358
2359 while let Some((_, id)) = heap.pop() {
2360 if out.len() >= limit {
2361 break;
2362 }
2363 let Some(commit) = queued.remove(&id) else {
2364 continue;
2365 };
2366 let object::Commit {
2367 tree,
2368 parents,
2369 author,
2370 committer,
2371 message,
2372 } = commit;
2373
2374 // ── the path filter: did THIS commit change what the path names? ─
2375 //
2376 // Judged against the FIRST parent, which is what `git log -- path`
2377 // shows for ordinary history: "what did landing this commit change
2378 // here". Identical ids mean identical content all the way down, so
2379 // the question costs two short descents, never a blob.
2380 if let Some(segments) = &filter {
2381 scanned += 1;
2382 if scanned > MAX_FILTERED_SCAN {
2383 break;
2384 }
2385 tree_of.insert(id, tree);
2386 let current = oid_at_path(git, hash, tree, segments)?;
2387 let previous = match parents.first() {
2388 None => None,
2389 Some(parent) => {
2390 let parent_tree = match tree_of.get(parent) {
2391 Some(known) => Some(*known),
2392 None => match git.read(parent.as_bytes()).map_err(store_error)? {
2393 Some((ObjType::Commit, parent_bytes)) => {
2394 let decoded =
2395 object::commit(&parent_bytes, hash).map_err(|err| {
2396 ControlError::Internal(format!(
2397 "decoding commit {parent}: {err}"
2398 ))
2399 })?;
2400 tree_of.insert(*parent, decoded.tree);
2401 Some(decoded.tree)
2402 }
2403 // A shallow edge: the parent is not in this
2404 // store. Comparing against nothing keeps the
2405 // commit visible rather than silently dropped.
2406 _ => None,
2407 },
2408 };
2409 match parent_tree {
2410 Some(parent_tree) => oid_at_path(git, hash, parent_tree, segments)?,
2411 None => None,
2412 }
2413 }
2414 };
2415 if current == previous {
2416 // Untouched here — keep walking, show nothing.
2417 for parent in parents {
2418 enqueue(parent, &mut heap, &mut queued)?;
2419 }
2420 continue;
2421 }
2422 }
2423
2424 out.push(Commit {
2425 oid: id.to_string(),
2426 parent_oids: parents.iter().map(|p| p.to_string()).collect(),
2427 tree_oid: tree.to_string(),
2428 author: Some(signature_out(
2429 &author.name,
2430 &author.email,
2431 author.seconds,
2432 author.offset_seconds,
2433 )),
2434 committer: Some(signature_out(
2435 &committer.name,
2436 &committer.email,
2437 committer.seconds,
2438 committer.offset_seconds,
2439 )),
2440 message: String::from_utf8_lossy(&message).into_owned(),
2441 });
2442 for parent in parents {
2443 enqueue(parent, &mut heap, &mut queued)?;
2444 }
2445 }
2446 Ok(out)
2447 }
2448}
2449
2450fn peel(
2451 git: &dyn GitServe,
2452 hash_kind: gix_hash::Kind,
2453 mut id: gix_hash::ObjectId,
2454) -> ControlResult<gix_hash::ObjectId> {
2455 for _ in 0..16 {
2456 let Some((kind, bytes)) = git.read(id.as_bytes()).map_err(store_error)? else {
2457 return Ok(id);
2458 };
2459 if kind != ObjType::Tag {
2460 return Ok(id);
2461 }
2462 id = object::tag_target(&bytes, hash_kind)
2463 .map_err(|err| ControlError::Internal(format!("decoding tag {id}: {err}")))?;
2464 }
2465 Err(ControlError::FailedPrecondition(format!(
2466 "the tag chain at {id} is longer than 16 links, or cyclic"
2467 )))
2468}
2469
2470/// One [`RefRow`] as the proto's `RefTarget`, resolved against `snapshot` — the
2471/// **same** snapshot the row came out of, so an answer cannot name an oid for a
2472/// reference that had already moved. See [`gunnar_wire::refs`].
2473fn ref_target_out(
2474 git: &dyn GitServe,
2475 hash_kind: gix_hash::Kind,
2476 snapshot: &RefSnapshot,
2477 row: &RefRow,
2478 want_peeled: bool,
2479) -> ControlResult<RefTarget> {
2480 let (oid, symbolic) = match (&row.oid, &row.symref_target) {
2481 // A symbolic reference is resolved through the snapshot, whatever else
2482 // it carries: the row's own `oid` on a symref is the store's cache of
2483 // the same walk, and one authority is enough.
2484 (_, Some(inner)) => (snapshot.resolve(inner).map_err(wire_error)?, inner.clone()),
2485 (Some(raw), None) => (
2486 Some(gunnar_wire::refs::oid(raw).map_err(wire_error)?),
2487 String::new(),
2488 ),
2489 (None, None) => (None, String::new()),
2490 };
2491 let peeled = match (want_peeled, oid) {
2492 // `row.peeled` is not used even when the store filled it in: `peel`
2493 // follows the whole chain and a stored peel is one hop, so taking the
2494 // cheaper answer where one exists would make two references with the
2495 // same tag chain answer differently.
2496 (true, Some(id)) => {
2497 let peeled = peel(git, hash_kind, id)?;
2498 if peeled == id {
2499 String::new()
2500 } else {
2501 peeled.to_string()
2502 }
2503 }
2504 _ => String::new(),
2505 };
2506 Ok(RefTarget {
2507 ref_name: row.name.clone(),
2508 oid: oid.map(|id| id.to_string()).unwrap_or_default(),
2509 symbolic_target: symbolic,
2510 peeled_oid: peeled,
2511 })
2512}
2513
2514/// A commit's author or committer line, as the gRPC message spells it.
2515///
2516/// **Bytes and numbers, not `gix_actor::SignatureRef`.** That was the parameter
2517/// type until 2026-08-10, and it was the whole reason `gix-actor` was a direct
2518/// dependency of this crate: one private four-line function, in the manifest
2519/// beside `gix-pack`, where the next reader had to work out whether the server
2520/// was *using* an actor parser or merely naming one. It was merely naming one.
2521/// `gix-object` still arrives here — it is what decodes the commit these fields
2522/// come out of — and it brings `gix-actor` with it; what is gone is gunnar
2523/// asserting a need it does not have.
2524///
2525/// `from_utf8_lossy` matches what `BStr`'s own `Display` did before: a name in
2526/// a commit header is whatever the committing client wrote, a gRPC string field
2527/// is UTF-8, and this is a *report* about history rather than anything looked
2528/// up by name.
2529fn signature_out(name: &[u8], email: &[u8], seconds: i64, tz_offset_seconds: i32) -> Signature {
2530 Signature {
2531 name: String::from_utf8_lossy(name).into_owned(),
2532 email: String::from_utf8_lossy(email).into_owned(),
2533 time_unix_seconds: seconds,
2534 tz_offset_minutes: tz_offset_seconds / 60,
2535 }
2536}
2537
2538// ───────────────────────────────────────────────────────────────────────────
2539
2540/// **First boot, delegated straight to `gunnar_core::AuthTable`.**
2541///
2542/// Nothing is decided here. `bootstrap_state` reads the one predicate the
2543/// writer also reads, and `enroll_first_identity` is a call into the one
2544/// compare-and-swap that can produce a `BootstrapRecord`. A second opinion in
2545/// this adapter — "the table looks empty, so the window is open" — would be a
2546/// status that could disagree with the gate, and the permissive one would win
2547/// silently (one implementation).
2548impl gunnar_grpc::FirstBoot for CoreControlPlane {
2549 fn bootstrap_state(&self) -> ControlResult<gunnar_grpc::BootstrapState> {
2550 let auth = self.stores.auth();
2551 // Every one of these is a `Result`. A read fault becomes UNAVAILABLE
2552 // and never a window that is reported as open or as shut on a guess.
2553 let window_open = auth.bootstrap_window_open().map_err(unavailable)?;
2554 let record = auth.bootstrap_record().map_err(unavailable)?;
2555 let (principals, keys, _grants) = auth.counts().map_err(unavailable)?;
2556 Ok(gunnar_grpc::BootstrapState {
2557 window_open,
2558 bootstrapped: record.is_some(),
2559 bootstrapped_at_unix_ms: record.as_ref().map(|r| r.at).unwrap_or_default(),
2560 bootstrapped_principal: record
2561 .as_ref()
2562 .map(|r| r.principal_id.clone())
2563 .unwrap_or_default(),
2564 bootstrapped_fingerprint: record
2565 .as_ref()
2566 .map(|r| r.fingerprint.clone())
2567 .unwrap_or_default(),
2568 principals: principals as u64,
2569 keys: keys as u64,
2570 // The boot window (`crate::readiness`). Answered here rather than
2571 // on `Admin.Health` because this is the one call a server nobody
2572 // holds a key for can answer, and a boot script is exactly that
2573 // caller.
2574 // TWO facts, and the second one is new. The boot window is this
2575 // process's start-up; the listener is a socket. A twin closes no
2576 // window and binds no reachable git port, so before the listener
2577 // was consulted this field answered `true` for a box whose :2222
2578 // was answering ECONNREFUSED — measured on 10.13.8.101, 2026-09-20.
2579 serving_git: crate::readiness::is_serving_git(self.git_listener),
2580 readiness: crate::readiness::detail_with(self.git_listener),
2581 build: gunnar_common::BUILD_REF.to_owned(),
2582 role: if self.twin_role || self.stores.is_twin() {
2583 "twin"
2584 } else {
2585 "primary"
2586 }
2587 .to_owned(),
2588 // A RECHARGED main (the installer's re-admit) or a HELD one (the boot
2589 // check, R-7b) says what it awaits (the restore lane reads it).
2590 recharge_from: awaited_twin(&self.stores),
2591 restoring: crate::twin::is_restoring(self.stores.root()),
2592 log_head_seq: self.stores.txlog().head_seq(),
2593 // What a restoring box holds of its next entry: where the next stream resumes it.
2594 restore_partial: if crate::twin::is_restoring(self.stores.root()) {
2595 crate::twin::intake::partial(self.stores.root())
2596 } else {
2597 None
2598 },
2599 // restore-after-snapshot: this box stages a twin's base across streams, and says how
2600 // much of one it holds while it is restoring.
2601 restore_snapshot_resumable: true,
2602 restore_snapshot: if crate::twin::is_restoring(self.stores.root()) {
2603 crate::twin::snapshot::held_snapshot(self.stores.root())
2604 .map(|h| (h.id, h.items, h.bytes, h.whole))
2605 } else {
2606 None
2607 },
2608 })
2609 }
2610
2611 fn enroll_first_identity(
2612 &self,
2613 name: &str,
2614 openssh_public_key: &str,
2615 ) -> ControlResult<gunnar_grpc::Enrolled> {
2616 // R-7b: a box the boot check found with its data lost (a twin can give it
2617 // back) takes no first admin while it is held.
2618 #[cfg(feature = "replication")]
2619 if let Some(twin) = crate::repair::held(&self.stores) {
2620 return Err(ControlError::FailedPrecondition(
2621 crate::repair::held_refusal(&twin),
2622 ));
2623 }
2624 let enrolled = self
2625 .stores
2626 .auth()
2627 .enroll_first_user(name, openssh_public_key)
2628 .map_err(enrolment_error)?;
2629 // The account namespace, after the door is shut. Deliberately NOT
2630 // inside the identity CAS: it lives in the catalog, a different
2631 // document, and one write cannot span both. The ordering is what makes
2632 // that safe — the security boundary (the record) has already landed, so
2633 // the worst a failure here can do is leave a gunnar-admin whose account
2634 // namespace has to be created by hand, never a second open window.
2635 //
2636 // Idempotent: `AlreadyExists` on a namespace this principal already
2637 // intrinsically owns is not a failure of enrolment.
2638 if let Err(err) = self
2639 .stores
2640 .catalog()
2641 .create_namespace(&enrolled.record.account)
2642 {
2643 tracing::warn!(
2644 account = %enrolled.record.account,
2645 %err,
2646 "the first user was enrolled and the enrolment window is closed, but its account \
2647 namespace could not be created. The user is admin of it by rule and can create \
2648 it themselves with Repos.CreateNamespace."
2649 );
2650 }
2651 // ── the bootstrap admin's client certificate ────────────────────────
2652 //
2653 // Minted AFTER the CAS, so the security boundary is already shut when
2654 // this runs and a failure here cannot reopen it — the same ordering
2655 // argument as the namespace above. Server-generated key, deliberately:
2656 // there is no authenticated channel yet to carry a CSR, and this is the
2657 // one exchange in the server's life where handing a private key over
2658 // the wire is the honest trade. The server retains no copy of the key.
2659 let (client_cert_pem, client_key_pem, issuer_cert_pem) = match self.stores.pki() {
2660 Ok(pki) => match pki.issuer() {
2661 Ok(Some(issuer)) => {
2662 match pki.issue_with_server_key(
2663 &enrolled.principal.id,
2664 gunnar_core::DEFAULT_LEAF_DAYS,
2665 ) {
2666 Ok(issued) => {
2667 if let Err(err) = self.stores.auth().register_certificate(
2668 &enrolled.principal.id,
2669 &issued.cert_der,
2670 None,
2671 ) {
2672 tracing::warn!(
2673 principal = %enrolled.principal.id,
2674 %err,
2675 "the bootstrap certificate was minted but could not be \
2676 registered; it is returned anyway and gunnar-admin can \
2677 register it with `gunnar key add`"
2678 );
2679 }
2680 (
2681 issued.cert_pem,
2682 issued.key_pem.unwrap_or_default(),
2683 issuer.cert_pem,
2684 )
2685 }
2686 Err(err) => {
2687 tracing::warn!(
2688 principal = %enrolled.principal.id,
2689 %err,
2690 "no bootstrap certificate: the issuer refused to mint"
2691 );
2692 (String::new(), String::new(), String::new())
2693 }
2694 }
2695 }
2696 Ok(None) => (String::new(), String::new(), String::new()),
2697 Err(err) => {
2698 tracing::warn!(%err, "no bootstrap certificate: the PKI document could not \
2699 be read");
2700 (String::new(), String::new(), String::new())
2701 }
2702 },
2703 Err(_) => (String::new(), String::new(), String::new()),
2704 };
2705 Ok(gunnar_grpc::Enrolled {
2706 principal: principal_out(&enrolled.principal),
2707 key: key_out(&enrolled.key),
2708 account: enrolled.record.account,
2709 bootstrapped_at_unix_ms: enrolled.record.at,
2710 client_cert_pem,
2711 client_key_pem,
2712 issuer_cert_pem,
2713 })
2714 }
2715}
2716
2717/// A read fault on the identity table. **Never a denial and never a state.**
2718fn unavailable(err: gunnar_core::Error) -> ControlError {
2719 ControlError::Unavailable(format!("the identity table could not be read: {err}"))
2720}
2721
2722/// The refusals of the enrolment window, mapped so a caller can tell them apart
2723/// from a malformed request.
2724/// **The twin this box awaits** (`Bootstrap.Status.recharge_from`): the installer's
2725/// re-admit ([`crate::pairing::recharge_awaited`]), else the boot check's hold
2726/// ([`crate::repair::held`]); empty on every other box.
2727fn awaited_twin(stores: &ServerStores) -> String {
2728 #[cfg(feature = "wg-client")]
2729 if let Some(twin) = crate::pairing::recharge_awaited(stores) {
2730 return twin;
2731 }
2732 #[cfg(feature = "replication")]
2733 if let Some(twin) = crate::repair::held(stores) {
2734 return twin;
2735 }
2736 let _ = stores;
2737 String::new()
2738}
2739
2740fn enrolment_error(err: gunnar_core::Error) -> ControlError {
2741 match err {
2742 // The door is shut. FAILED_PRECONDITION, because the request was well
2743 // formed and the *server's state* is what refuses it.
2744 e @ (gunnar_core::Error::AlreadyBootstrapped { .. }
2745 | gunnar_core::Error::AlreadyProvisioned { .. }) => {
2746 ControlError::FailedPrecondition(e.to_string())
2747 }
2748 // A bad name or a key that is not an authorized_keys line.
2749 e @ (gunnar_core::Error::InvalidPath { .. }
2750 | gunnar_core::Error::ReservedPrincipalId(_)
2751 | gunnar_core::Error::Key(_)) => ControlError::InvalidArgument(e.to_string()),
2752 // Storage. Not "the door is shut", which is what a caller would read a
2753 // FAILED_PRECONDITION as, and not OK, which would be the disaster.
2754 other => ControlError::Unavailable(other.to_string()),
2755 }
2756}
2757
2758impl CoreControlPlane {
2759 /// ★ **The estate's operator, made on its first claim** — the one writer
2760 /// behind `Accounts.ClaimOperator` and behind the primary's own claim when
2761 /// the installer feeds `operator_name` ([`crate::fed_principals::install`]).
2762 ///
2763 /// `fed` is the installer's name for the operator and wins; `asked` is the
2764 /// caller's, used only when nothing was fed (`gunnar_grpc::seam::operator_name`).
2765 ///
2766 /// * A PERSON already under that id or name is the answer, unchanged but
2767 /// for two upserts that make it what an operator is: its account
2768 /// namespace exists, and it is gunnar-admin.
2769 /// * Anything else under it — a machine, a group — is refused by name: an
2770 /// estate's parts are never its operator.
2771 /// * Otherwise the principal is created, KEYLESS (the console's credential
2772 /// for the person is its first key, which owes nobody an attestation; the
2773 /// person's own SSH keys come after and are vouched for by it), with its
2774 /// account namespace — and **a namespace of that name that already exists
2775 /// with no owner is ADOPTED** rather than refused. MEASURED on test2
2776 /// 2026-10-04: a first push with the installer's admin key
2777 /// (`rickard/t2-scratch`) conjured `rickard`, and `AddPrincipal` refused
2778 /// the operator's own account from then on — the first admin's passkey
2779 /// bound to nothing. Adoption is safe exactly here: the namespace has no
2780 /// principal of its id (this call is creating it), only a server Admin can
2781 /// ask, and that Admin could already delete the namespace or grant it.
2782 pub fn claim_operator_named(
2783 &self,
2784 fed: Option<&str>,
2785 asked: &str,
2786 ) -> ControlResult<gunnar_grpc::OperatorClaim> {
2787 let (name, named_by) = gunnar_grpc::seam::operator_name(fed, asked)?;
2788 let auth = self.stores.auth();
2789 let held = match auth.principal(name).map_err(internal)? {
2790 Some(p) => Some(p),
2791 None => auth.principal_by_name(name).map_err(internal)?,
2792 };
2793 if let Some(p) = held {
2794 if p.kind != PrincipalKind::User {
2795 return Err(ControlError::FailedPrecondition(format!(
2796 "the operator's name {name:?} is held by principal {:?}, which is not a person \
2797 ({:?}); an estate's machines are never its operator",
2798 p.id, p.kind
2799 )));
2800 }
2801 let adopted_namespace = false;
2802 if self
2803 .stores
2804 .catalog()
2805 .namespace(&p.id)
2806 .map_err(internal)?
2807 .is_none()
2808 {
2809 self.stores
2810 .catalog()
2811 .create_namespace(&p.id)
2812 .map_err(internal)?;
2813 }
2814 self.grant(&p.id, "", "", GrantLevel::Admin)?;
2815 return Ok(gunnar_grpc::OperatorClaim {
2816 principal: principal_out(&p),
2817 created: false,
2818 adopted_namespace,
2819 named_by,
2820 });
2821 }
2822 let principal =
2823 gunnar_core::Principal::new(name, name, PrincipalKind::User).map_err(internal)?;
2824 let created = auth.add_principal(principal).map_err(internal)?;
2825 let adopted_namespace = match self.stores.catalog().create_namespace(&created.id) {
2826 Ok(_) => false,
2827 Err(gunnar_core::Error::NamespaceExists(_)) => true,
2828 Err(err) => {
2829 let _ = auth.remove_principal(&created.id);
2830 return Err(internal(err));
2831 }
2832 };
2833 if let Err(err) = self.grant(&created.id, "", "", GrantLevel::Admin) {
2834 // Not left half-made: an operator that is not the estate's admin is
2835 // not the one that was asked for, and the next claim makes it whole.
2836 let _ = auth.remove_principal(&created.id);
2837 return Err(err);
2838 }
2839 self.stores.mirror_identity();
2840 tracing::warn!(
2841 target: "gunnar::audit",
2842 event = "operator.claimed",
2843 operator = %created.id,
2844 adopted_namespace,
2845 named_by,
2846 "the estate's operator was created: a person, keyless, gunnar-admin, with its account \
2847 namespace{}",
2848 if adopted_namespace {
2849 " — the namespace already existed with no owner (a first push conjured it) and is \
2850 now the operator's account"
2851 } else {
2852 ""
2853 }
2854 );
2855 Ok(gunnar_grpc::OperatorClaim {
2856 principal: principal_out(&created),
2857 created: true,
2858 adopted_namespace,
2859 named_by,
2860 })
2861 }
2862}
2863
2864/// The owner's enterprise list on the real stores (`gunnar_grpc::enterprise::EnterprisePlane`).
2865mod enterprise_plane;
2866
2867impl ControlPlane for CoreControlPlane {
2868 fn enterprise(&self) -> Option<&dyn gunnar_grpc::enterprise::EnterprisePlane> {
2869 Some(self)
2870 }
2871
2872 // ── namespaces ─────────────────────────────────────────────────────────
2873
2874 fn create_namespace(&self, name: &str, _description: &str) -> ControlResult<Namespace> {
2875 // An EMPLOYEE's id is never a namespace: a namespace named after a
2876 // principal makes that principal its admin (`owns_account`), and an
2877 // employee is admin of nothing (`AuthTable::enroll_employee`).
2878 if self
2879 .stores
2880 .auth()
2881 .employer_of(name)
2882 .map_err(internal)?
2883 .is_some()
2884 {
2885 return Err(ControlError::AlreadyExists(format!(
2886 "{name} is an employee's id, and an employee holds no account of its own"
2887 )));
2888 }
2889 let namespace = self
2890 .stores
2891 .catalog()
2892 .create_namespace(name)
2893 .map_err(internal)?;
2894 Ok(self.namespace_out(namespace, 0))
2895 }
2896
2897 fn list_namespaces(&self) -> ControlResult<Vec<Namespace>> {
2898 let mut employees: std::collections::BTreeMap<String, u32> = Default::default();
2899 for principal in self.stores.auth().principals().map_err(internal)? {
2900 if let Some(account) = self
2901 .stores
2902 .auth()
2903 .employer_of(&principal.id)
2904 .map_err(internal)?
2905 {
2906 *employees.entry(account).or_default() += 1;
2907 }
2908 }
2909 Ok(self
2910 .stores
2911 .catalog()
2912 .namespaces()
2913 .map_err(internal)?
2914 .into_iter()
2915 .map(|ns| {
2916 let count = employees.get(&ns.name).copied().unwrap_or(0);
2917 self.namespace_out(ns, count)
2918 })
2919 .collect())
2920 }
2921
2922 /// One catalog probe, not the listing the trait's default walks: this is
2923 /// asked by the account settings and the outsider's view of a namespace.
2924 fn namespace(&self, name: &str) -> ControlResult<Option<Namespace>> {
2925 let Some(ns) = self.stores.catalog().namespace(name).map_err(internal)? else {
2926 return Ok(None);
2927 };
2928 let count = self.employee_count(name)?;
2929 Ok(Some(self.namespace_out(ns, count)))
2930 }
2931
2932 fn namespace_for_outsider(
2933 &self,
2934 principal_id: &str,
2935 name: &str,
2936 ) -> ControlResult<Option<Namespace>> {
2937 // The gate, both directions, before the setting — the same walk
2938 // `AuthTable::may` makes (an ancestor's gate closes a nested account).
2939 let auth = self.stores.auth();
2940 if auth
2941 .gate_covering(principal_id)
2942 .map_err(internal)?
2943 .is_some()
2944 || auth.gate_covering(name).map_err(internal)?.is_some()
2945 {
2946 return Ok(None);
2947 }
2948 self.namespace(name)
2949 }
2950
2951 fn set_namespace_settings(
2952 &self,
2953 name: &str,
2954 public: Option<bool>,
2955 events_public: Option<bool>,
2956 ) -> ControlResult<Namespace> {
2957 let catalog = self.stores.catalog();
2958 let mut ns = catalog
2959 .namespace(name)
2960 .map_err(internal)?
2961 .ok_or_else(|| ControlError::NotFound(format!("namespace {name}")))?;
2962 if let Some(public) = public {
2963 ns = catalog
2964 .set_namespace_public(name, public)
2965 .map_err(internal)?;
2966 }
2967 if let Some(events_public) = events_public {
2968 ns = catalog
2969 .set_namespace_events_public(name, events_public)
2970 .map_err(internal)?;
2971 }
2972 let count = self.employee_count(name)?;
2973 Ok(self.namespace_out(ns, count))
2974 }
2975
2976 fn delete_namespace(&self, name: &str, delete_repos: bool) -> ControlResult<u32> {
2977 // A CLAN's namespace goes with the clan (`Clans.Dissolve`), never on
2978 // its own: a clan left without its namespace would be a name nobody
2979 // could make anything in, and the namespace's repositories are the
2980 // clan's to dissolve, not one admin grant's to delete.
2981 if self.stores.auth().is_clan(name).map_err(internal)? {
2982 return Err(ControlError::FailedPrecondition(format!(
2983 "namespace {name} is clan {name}'s: it goes when the clan's admin dissolves the clan"
2984 )));
2985 }
2986 let gone = self
2987 .stores
2988 .catalog()
2989 .delete_namespace(name, delete_repos)
2990 .map_err(internal)?;
2991 // **The flow half of deleting a tenant.** The activity ledger keeps
2992 // four hundred days per namespace INCLUDING which key fingerprints
2993 // pushed, and a tenant that is gone must not leave a record of who
2994 // worked on it. Deliberately after the catalog delete: if that refused
2995 // — a non-empty namespace without `delete_repos` — nothing has been
2996 // deleted and the series must stay.
2997 //
2998 // A failure here is logged and not returned: the namespace IS gone, and
2999 // reporting the delete as failed would send an operator to re-run it
3000 // against a name that no longer exists.
3001 if let Err(err) = self.stores.flow().forget(name) {
3002 tracing::warn!(
3003 target: "gunnar::store",
3004 event = "flow.forget_failed",
3005 namespace = %name,
3006 %err,
3007 "the namespace was deleted but its activity series could not be removed"
3008 );
3009 }
3010 Ok(gone.len() as u32)
3011 }
3012
3013 // ── repositories ───────────────────────────────────────────────────────
3014
3015 fn create_repo(&self, spec: ProtoNewRepo) -> ControlResult<Repo> {
3016 let path = Self::repo_ref(&spec.path)?;
3017 // **The disk, before the row.** A repository created on a full disk is
3018 // a catalog row whose store cannot be materialised, which is the
3019 // "listed but git says it does not exist" shape `ServerStores::create_repo`
3020 // exists to prevent — refused here, by the gate the front doors share,
3021 // with the line a refused push prints.
3022 if let Some(line) = self.headroom.as_ref().and_then(|gate| gate.refusal()) {
3023 return Err(ControlError::ResourceExhausted(line));
3024 }
3025 let mut new = gunnar_core::NewRepo::new(path).visibility(visibility_in(spec.visibility)?);
3026 if spec.hash_kind != gunnar_proto::HashKind::Unspecified {
3027 new = new.hash_kind(match spec.hash_kind {
3028 gunnar_proto::HashKind::Sha1 => HashKind::Sha1,
3029 _ => HashKind::Sha256,
3030 });
3031 }
3032 if !spec.default_branch.is_empty() {
3033 new = new.default_branch(spec.default_branch.clone());
3034 }
3035 // The catalog entry and the on-disk repository are created together
3036 // and transactionally — `ServerStores::create_repo` looks at the disk
3037 // first (a store already standing at the name is refused BY NAME
3038 // unless `adopt`), writes the row, makes the store, and rolls the row
3039 // back if the store fails. Creating one without the other is the
3040 // shape that produces "it is listed but git says it does not exist";
3041 // adopting a stranger's directory is the shape that resurrected a
3042 // deleted repository on 2026-08-30.
3043 let repo = self
3044 .stores
3045 .create_repo(new, spec.adopt)
3046 .map_err(|err| create_error(&err, &spec.path))?;
3047 Ok(repo_out(&repo))
3048 }
3049
3050 fn write_first_commit(&self, path: &RepoPath, readme: &str) -> ControlResult<String> {
3051 let (repo, store) = self.open(path)?;
3052 let kind = match repo.hash_kind {
3053 HashKind::Sha1 => gix_hash::Kind::Sha1,
3054 _ => gix_hash::Kind::Sha256,
3055 };
3056 let when = std::time::SystemTime::now()
3057 .duration_since(std::time::UNIX_EPOCH)
3058 .map(|d| d.as_secs() as i64)
3059 .unwrap_or(0);
3060 let (pack, commit) = crate::first_commit::first_commit_pack(
3061 kind,
3062 "README.md",
3063 readme.as_bytes(),
3064 "Welcome",
3065 when,
3066 )
3067 .map_err(ControlError::InvalidArgument)?;
3068 store
3069 .serve()
3070 .put(
3071 &pack,
3072 &[gunnar_store::git_storage_trait::RefUpdate::set(
3073 repo.default_branch.clone(),
3074 commit.clone(),
3075 )],
3076 )
3077 .map_err(|err| {
3078 ControlError::Internal(format!("writing the first commit of {path}: {err:#}"))
3079 })?;
3080 Ok(commit)
3081 }
3082
3083 /// The front doors' plan chain (`ServerStores::plan_gates`), asked as a
3084 /// push-to-create asks it: the verdict on a write (a suspended or retained
3085 /// plan: `FailedPrecondition`), then the cap (`ResourceExhausted`). A gate
3086 /// that does not bind machines is not asked for one.
3087 fn create_room(&self, namespace: &str, machine: bool) -> ControlResult<()> {
3088 use gunnar_ssh::{PlanAction, PlanVerdict};
3089 for gate in self
3090 .stores
3091 .plan_gates()
3092 .iter()
3093 .filter(|gate| !machine || gate.applies_to_machine())
3094 {
3095 if let PlanVerdict::Refuse(line) = gate.verdict(namespace, PlanAction::Write) {
3096 return Err(ControlError::FailedPrecondition(line));
3097 }
3098 if let PlanVerdict::Refuse(line) = gate.pack_quota(namespace) {
3099 return Err(ControlError::ResourceExhausted(line));
3100 }
3101 }
3102 Ok(())
3103 }
3104
3105 fn get_repo(&self, path: &RepoPath) -> ControlResult<Repo> {
3106 let repo_ref = Self::repo_ref(path)?;
3107 self.stores
3108 .catalog()
3109 .get(&repo_ref)
3110 .map_err(internal)?
3111 .as_ref()
3112 .map(repo_out)
3113 .ok_or_else(|| ControlError::NotFound(format!("repository {path}")))
3114 }
3115
3116 fn list_repos(&self, namespace: Option<&str>) -> ControlResult<Vec<Repo>> {
3117 Ok(self
3118 .stores
3119 .catalog()
3120 .list(namespace)
3121 .map_err(internal)?
3122 .iter()
3123 .map(repo_out)
3124 .collect())
3125 }
3126
3127 /// **Delete: a dated tombstone by default, a reclaim when asked.**
3128 ///
3129 /// Two facts had to be held at once here, and they pull in opposite
3130 /// directions:
3131 ///
3132 /// * **The reclaim**: `repo delete` must reclaim the disk. Before it, the
3133 /// catalog row went and the objects stayed — 25 GB of a kernel push left
3134 /// on the appliance with its indexer still running, invisible because
3135 /// nothing listed it.
3136 /// * **Measured**: the bytes are gone in ~2 seconds (181 GiB to 33 MiB
3137 /// measured) and a mirror does not help, because the delete replicates.
3138 /// The failure a paying customer is most likely to cause is deleting
3139 /// their own repository.
3140 ///
3141 /// A tombstone answers both, and only because it is **dated and listed**:
3142 /// the bytes are held on purpose with a deadline written at the instant of
3143 /// the delete, [`ServerStores::sweep_expired_tombstones`] reclaims them
3144 /// when it passes, and `Repos.ListDeleted` / `Admin.StorageInfo` report
3145 /// them so they are never the invisible 25 GB again. **They still count
3146 /// against the account** — see [`Self::storage_info`].
3147 fn delete_repo(
3148 &self,
3149 path: &RepoPath,
3150 options: gunnar_grpc::seam::DeleteOptions,
3151 ) -> ControlResult<gunnar_grpc::seam::DeleteOutcome> {
3152 let repo_ref = Self::repo_ref(path)?;
3153 // The row is read BEFORE it is removed: the tombstone records what the
3154 // repository was, and an undelete that guessed the hash kind would
3155 // reopen the resurrection the reclaim rules closed from the other side.
3156 let was = self.stores.catalog().get(&repo_ref).map_err(internal)?;
3157 // **An ORPHAN is a delete to finish, not a not-found** (2026-08-30). A
3158 // store on disk with no row and no tombstone is what an old
3159 // catalog-only delete left, and on the appliance 2026-08-30 it was ~25-39
3160 // GB at `alice/linux` that `delete` answered with `NoSuchRepo` after
3161 // the access check had already refused everybody. `--purge` is
3162 // honoured — the bytes are what the operator asked for and the row is
3163 // already gone — and a SOFT delete is refused by name, because a
3164 // tombstone with no row promises an undelete the server cannot honour.
3165 let orphan = was.is_none() && self.stores.is_orphan(&repo_ref).unwrap_or(false);
3166 if orphan && !options.purge {
3167 return Err(ControlError::FailedPrecondition(
3168 crate::stores::ServerStores::orphan_soft_delete_refusal(&repo_ref),
3169 ));
3170 }
3171 // A name that is not in the catalog but is TOMBSTONED (a previous
3172 // delete could not reclaim its store) is a delete to retry, not a
3173 // not-found: the row is already gone, the reclaim is what is owed.
3174 match self.stores.catalog().delete(&repo_ref) {
3175 Ok(_) => {}
3176 Err(gunnar_core::Error::NoSuchRepo(_))
3177 if orphan || self.stores.is_tombstoned(&repo_ref).unwrap_or(false) => {}
3178 Err(err) => return Err(internal(err)),
3179 }
3180 if options.purge {
3181 let (before, after) = self.stores.purge_repo(&repo_ref).map_err(|err| {
3182 ControlError::Internal(format!("removing the repository's directory: {err:#}"))
3183 })?;
3184 return Ok(gunnar_grpc::seam::DeleteOutcome::Purged {
3185 bytes_reclaimed: before.saturating_sub(after),
3186 });
3187 }
3188 let retention = std::time::Duration::from_secs(
3189 u64::from(
3190 options
3191 .retention_days
3192 .unwrap_or(crate::stores::DEFAULT_DELETE_RETENTION_DAYS),
3193 ) * 86_400,
3194 );
3195 let record = self
3196 .stores
3197 .soft_delete(&repo_ref, was.as_ref(), &options.principal_id, retention)
3198 .map_err(|err| ControlError::FailedPrecondition(format!("{err:#}")))?;
3199 let bytes_kept = self
3200 .stores
3201 .repo_dir(&repo_ref)
3202 .map(|dir| crate::stores::dir_bytes(&dir))
3203 .unwrap_or(0);
3204 Ok(gunnar_grpc::seam::DeleteOutcome::Tombstoned {
3205 purge_after_unix_ms: record.purge_after_unix_ms.unwrap_or_default(),
3206 bytes_kept,
3207 })
3208 }
3209
3210 /// **Undelete: the row back first, the tombstone off second.**
3211 ///
3212 /// That order is not tidiness. A crash between the two leaves a repository
3213 /// that is catalogued *and* still fenced off, which an operator can fix
3214 /// with a second `undelete`; the other order leaves a name with no
3215 /// tombstone and no row, which the next `repo create` would adopt — and
3216 /// adopting a store a create did not make is exactly the 2026-08-30
3217 /// resurrection.
3218 fn undelete_repo(&self, path: &RepoPath) -> ControlResult<Repo> {
3219 let repo_ref = Self::repo_ref(path)?;
3220 if self
3221 .stores
3222 .catalog()
3223 .get(&repo_ref)
3224 .map_err(internal)?
3225 .is_some()
3226 {
3227 return Err(ControlError::AlreadyExists(format!(
3228 "{path} is in the catalog already; it is not deleted"
3229 )));
3230 }
3231 let spec = self
3232 .stores
3233 .undelete_spec(&repo_ref)
3234 .map_err(|err| ControlError::FailedPrecondition(format!("{err:#}")))?;
3235 // `adopt`: the store on disk is the one this repository always had.
3236 // This is the second of the two cases where adopting is right rather
3237 // than a resurrection (the first is `archive-in`), and it is right
3238 // because the tombstone says so and carries the hash kind the adopt
3239 // path checks the store's marker against.
3240 let repo = self
3241 .stores
3242 .create_repo(spec, true)
3243 .map_err(|err| ControlError::Internal(format!("restoring {path}: {err:#}")))?;
3244 self.stores
3245 .clear_tombstone(&repo_ref)
3246 .map_err(|err| ControlError::Internal(format!("{err:#}")))?;
3247 Ok(repo_out(&repo))
3248 }
3249
3250 fn purge_repo(&self, path: &RepoPath) -> ControlResult<u64> {
3251 let repo_ref = Self::repo_ref(path)?;
3252 if self
3253 .stores
3254 .catalog()
3255 .get(&repo_ref)
3256 .map_err(internal)?
3257 .is_some()
3258 {
3259 return Err(ControlError::FailedPrecondition(format!(
3260 "{path} is a LIVE repository, not a deleted one. `repo delete {path} --purge` \
3261 deletes and reclaims it in one step; purge alone only ends a retention window."
3262 )));
3263 }
3264 let tombstone = self
3265 .stores
3266 .tombstone_of(&repo_ref)
3267 .map_err(|err| ControlError::Internal(format!("{err:#}")))?;
3268 if tombstone.is_none() {
3269 return Err(ControlError::NotFound(format!(
3270 "{path} is neither catalogued nor tombstoned; there is nothing to purge"
3271 )));
3272 }
3273 let (before, after) = self
3274 .stores
3275 .purge_repo(&repo_ref)
3276 .map_err(|err| ControlError::Internal(format!("{err:#}")))?;
3277 Ok(before.saturating_sub(after))
3278 }
3279
3280 /// **`Repos.Gc` — the console's door onto the maintenance the CLI has
3281 /// always had.**
3282 ///
3283 /// The whole body is a resolve and a call: the maintenance itself is
3284 /// [`crate::admin::gc_one`], the same function `gunnar gc` runs, so there
3285 /// is one writer and not a second implementation to drift (one implementation).
3286 ///
3287 /// Unlike [`Self::purge_repo`] this one demands the repository be **live**.
3288 /// A tombstoned store is on its way out and sealing it would spend the I/O
3289 /// of a compaction on bytes a sweep is about to unlink; that is `Purge`'s
3290 /// job, and saying so by name is better than maintaining a corpse.
3291 fn gc_repo(&self, path: &RepoPath) -> ControlResult<gunnar_grpc::seam::GcJob> {
3292 let repo_ref = Self::repo_ref(path)?;
3293 // **The existence check is here and the resolve is in the runner**, and
3294 // that is not a duplicate. This one refuses a name that was never a
3295 // repository *now*, so a typo comes back as NOT_FOUND on the request
3296 // that made it rather than as a FAILED job somebody has to go and read;
3297 // the runner's is what happens when a repository that WAS live is gone
3298 // by the time its turn comes, minutes later.
3299 if self
3300 .stores
3301 .catalog()
3302 .get(&repo_ref)
3303 .map_err(internal)?
3304 .is_none()
3305 {
3306 return Err(ControlError::NotFound(format!(
3307 "{path} is not a live repository; nothing to gc"
3308 )));
3309 }
3310 Ok(gc_job_out(path, self.gc.enqueue(&repo_ref.to_string())))
3311 }
3312
3313 fn gc_status(&self, path: &RepoPath) -> ControlResult<Option<gunnar_grpc::seam::GcJob>> {
3314 let repo_ref = Self::repo_ref(path)?;
3315 Ok(self
3316 .gc
3317 .status(&repo_ref.to_string())
3318 .map(|job| gc_job_out(path, job)))
3319 }
3320
3321 // ── accounts: the gate, and the per-user hard purge ────────────────────
3322
3323 fn gate_account(
3324 &self,
3325 account: &str,
3326 actor: &str,
3327 note: &str,
3328 ) -> ControlResult<(gunnar_grpc::seam::AccountGate, bool)> {
3329 // Read before write, so `was_already_gated` reports what was there
3330 // rather than what this call put there. Not atomic with the write, and
3331 // it does not need to be: the field is a courtesy to a console, and the
3332 // worst a lost race costs is a console saying "you locked this" about a
3333 // lockout that arrived a millisecond earlier. The *lockout* is atomic,
3334 // because `AuthTable::gate` is one compare-and-swap.
3335 let before = self
3336 .stores
3337 .auth()
3338 .gate_covering(account)
3339 .map_err(internal)?
3340 .is_some();
3341 let gate = self
3342 .stores
3343 .auth()
3344 .gate(gunnar_core::AccountGate::administrative(
3345 account, actor, note,
3346 ))
3347 .map_err(gate_error)?;
3348 tracing::warn!(
3349 target: "gunnar::audit",
3350 event = "account.gated",
3351 account = %account,
3352 actor = %actor,
3353 note = %note,
3354 already = before,
3355 "an account was locked out: every level, both doors, until it is ungated"
3356 );
3357 Ok((gate_out(gate), before))
3358 }
3359
3360 fn ungate_account(
3361 &self,
3362 account: &str,
3363 ) -> ControlResult<Option<gunnar_grpc::seam::AccountGate>> {
3364 let lifted = self
3365 .stores
3366 .auth()
3367 .lift_gate(account)
3368 .map_err(internal)?
3369 .map(gate_out);
3370 if let Some(gate) = &lifted {
3371 tracing::warn!(
3372 target: "gunnar::audit",
3373 event = "account.ungated",
3374 account = %account,
3375 held_by = %gate.holder,
3376 "an account lockout was lifted"
3377 );
3378 }
3379 Ok(lifted)
3380 }
3381
3382 fn account_gates(&self) -> ControlResult<Vec<gunnar_grpc::seam::AccountGate>> {
3383 Ok(self
3384 .stores
3385 .auth()
3386 .gates()
3387 .map_err(internal)?
3388 .into_iter()
3389 .map(gate_out)
3390 .collect())
3391 }
3392
3393 /// **Gate first, enqueue second — and read the gate BACK.**
3394 ///
3395 /// The order is the whole guarantee: by the time this returns the account
3396 /// is shut, so there is no window in which a purge has been accepted and
3397 /// the user can still push into the account it is about to walk.
3398 ///
3399 /// The gate is then read back out of the table rather than the one just
3400 /// written being returned, and that is not belt-and-braces — it is the only
3401 /// correct answer in two cases this call cannot distinguish up front:
3402 ///
3403 /// * the account was **already** under an administrative gate. The lease is
3404 /// not written over it (`gate_for_purge` refuses to downgrade), so the
3405 /// gate in force is somebody else's and the job will not lift it.
3406 /// * the account already had a purge **queued or running**. The queue
3407 /// returns that job instead of this one, so the lease that will be
3408 /// released is the *other* job's, and returning this call's freshly
3409 /// minted id would name a lockout nothing is going to lift.
3410 fn purge_account(
3411 &self,
3412 account: &str,
3413 options: gunnar_grpc::seam::PurgeAccountOptions,
3414 ) -> ControlResult<(gunnar_grpc::seam::PurgeJob, gunnar_grpc::seam::AccountGate)> {
3415 let account = account.trim();
3416 if account.is_empty() {
3417 return Err(ControlError::InvalidArgument(
3418 "an account purge needs an account name".into(),
3419 ));
3420 }
3421 // **A machine's account is never purged** (owner ruling g-nonpay 2026-09-29:
3422 // "machine principals ... are never touched"). Only a person's account holds a
3423 // customer's data; a machine, a deploy key or a group is the estate itself —
3424 // the twin, the billing principal, a CI bot — and emptying it is never the
3425 // answer to a question about money. Refused before the lockout: nothing is
3426 // locked and nothing is queued.
3427 if let Some(p) = self.stores.auth().principal(account).map_err(internal)? {
3428 if p.kind != gunnar_core::PrincipalKind::User {
3429 return Err(ControlError::FailedPrecondition(format!(
3430 "{account} is a machine principal ({:?}), not a person: a machine's account is never \
3431 purged. Remove the principal itself if it is really to go.",
3432 p.kind
3433 )));
3434 }
3435 }
3436 let job_id = self.purge.next_job_id();
3437 // **The lockout, before anything else.** A failure here is a refusal
3438 // and nothing is enqueued: a purge that could not lock the account is
3439 // a purge that would race every reader in it, and running it anyway
3440 // would be worse than not running it.
3441 self.gate_for_purge(account, &job_id, &options)?;
3442
3443 let order = crate::purge_queue::PurgeOrder::new(account, &job_id, &options.reason)
3444 .by(&options.principal_id)
3445 .removing_principal(options.remove_principal)
3446 .expecting_empty(options.expect_empty);
3447 let job = self.purge.enqueue(order);
3448
3449 let gate = self
3450 .stores
3451 .auth()
3452 .gate_covering(account)
3453 .map_err(internal)?
3454 .map(gate_out)
3455 // The account is gated — this call just gated it — so an absence
3456 // here means the row went between the write and the read, which is
3457 // an operator ungating an account mid-request. Reported as the
3458 // fact it is rather than as a gate that is not there.
3459 .unwrap_or_else(|| gunnar_grpc::seam::AccountGate {
3460 account: account.to_owned(),
3461 reason: gunnar_grpc::seam::GateReason::Purge,
3462 holder: job_id.clone(),
3463 actor: options.principal_id.clone(),
3464 note: "the lockout was lifted between being taken and being read back".into(),
3465 at_unix_ms: gunnar_core::now_ms(),
3466 expires_unix_ms: 0,
3467 });
3468 Ok((purge_job_out(job), gate))
3469 }
3470
3471 fn purge_account_status(
3472 &self,
3473 account: &str,
3474 ) -> ControlResult<(
3475 Option<gunnar_grpc::seam::PurgeJob>,
3476 Option<gunnar_grpc::seam::AccountGate>,
3477 )> {
3478 let job = self.purge.status(account).map(purge_job_out);
3479 let gate = self
3480 .stores
3481 .auth()
3482 .gate_covering(account)
3483 .map_err(internal)?
3484 .map(gate_out);
3485 Ok((job, gate))
3486 }
3487
3488 fn default_delete_retention_days(&self) -> u32 {
3489 crate::stores::DEFAULT_DELETE_RETENTION_DAYS
3490 }
3491
3492 fn list_deleted(
3493 &self,
3494 namespace: Option<&str>,
3495 ) -> ControlResult<Vec<gunnar_grpc::seam::DeletedRepoRow>> {
3496 Ok(self
3497 .stores
3498 .tombstones()
3499 .map_err(|err| ControlError::Internal(format!("{err:#}")))?
3500 .into_iter()
3501 .filter(|row| namespace.is_none_or(|want| row.path.namespace() == want))
3502 .map(|row| gunnar_grpc::seam::DeletedRepoRow {
3503 path: RepoPath {
3504 namespace: row.path.namespace().to_owned(),
3505 repo: row.path.name().to_owned(),
3506 },
3507 deleted_at_unix_ms: row.tombstone.deleted_at_unix_ms,
3508 purge_after_unix_ms: row.tombstone.purge_after_unix_ms.unwrap_or_default(),
3509 principal_id: row.tombstone.principal_id,
3510 bytes: row.bytes,
3511 })
3512 .collect())
3513 }
3514
3515 /// **The store directories with no catalog row and no tombstone.**
3516 ///
3517 /// It walks the disk rather than asking the catalog — the same reason
3518 /// [`Self::list_deleted`] does, one step further: a tombstone at least
3519 /// exists as a file, and an orphan is defined by the absence of *every*
3520 /// record. On the appliance 2026-08-30 that absence made ~25-39 GB at
3521 /// `alice/linux` unreachable by `delete`, `purge`, `undelete`, the sweep
3522 /// AND `list-deleted`, while `create` refused the name because the bytes
3523 /// were there.
3524 fn list_orphans(
3525 &self,
3526 namespace: Option<&str>,
3527 ) -> ControlResult<Vec<gunnar_grpc::seam::OrphanRow>> {
3528 Ok(self
3529 .stores
3530 .orphans()
3531 .map_err(|err| ControlError::Internal(format!("{err:#}")))?
3532 .into_iter()
3533 .filter(|row| namespace.is_none_or(|want| row.path.namespace() == want))
3534 .map(|row| gunnar_grpc::seam::OrphanRow {
3535 path: RepoPath {
3536 namespace: row.path.namespace().to_owned(),
3537 repo: row.path.name().to_owned(),
3538 },
3539 dir: row.dir.display().to_string(),
3540 bytes: row.bytes,
3541 hash_marker: row.hash_marker.unwrap_or_default(),
3542 modified_unix_ms: row.modified_unix_ms,
3543 failed_reclaim: row.failed_reclaim.unwrap_or_default(),
3544 })
3545 .collect())
3546 }
3547
3548 /// **Reclaim an orphan.** `FailedPrecondition` when it is not one, because
3549 /// "this is a live repository" is a fact the caller can act on and not an
3550 /// internal fault.
3551 fn reclaim_orphan(&self, path: &RepoPath) -> ControlResult<u64> {
3552 let repo_ref = Self::repo_ref(path)?;
3553 self.stores
3554 .reclaim_orphan(&repo_ref)
3555 .map_err(|err| ControlError::FailedPrecondition(format!("{err:#}")))
3556 }
3557
3558 fn index_progress(&self) -> ControlResult<Vec<gunnar_grpc::seam::IndexProgressRow>> {
3559 Ok(self
3560 .stores
3561 .engine()
3562 .index_progress()
3563 .into_iter()
3564 .filter_map(|(dir, s)| {
3565 let (namespace, name) = self.stores.repo_of_dir(&dir)?;
3566 Some(gunnar_grpc::seam::IndexProgressRow {
3567 namespace,
3568 name,
3569 pack_id: s.pack_id,
3570 objects_done: s.objects_done,
3571 objects_total: s.objects_total,
3572 bytes_done: s.bytes_done,
3573 bytes_total: s.bytes_total,
3574 phase: if s.active {
3575 s.phase.to_string()
3576 } else {
3577 "idle".to_string()
3578 },
3579 started_at_unix_ms: s.started_at_ms as i64,
3580 resumed: s.resumed,
3581 reinflations: s.reinflations,
3582 pauses: s.pauses,
3583 })
3584 })
3585 .filter(|r| r.phase != "idle")
3586 .collect())
3587 }
3588
3589 fn set_default_branch(&self, path: &RepoPath, default_branch: &str) -> ControlResult<Repo> {
3590 let repo_ref = Self::repo_ref(path)?;
3591 let repo = self
3592 .stores
3593 .catalog()
3594 .set_default_branch(&repo_ref, default_branch)
3595 .map_err(internal)?;
3596 // The catalog is the authority; the repository's `HEAD` is a projection
3597 // of it and does not move on its own. Without this line the call
3598 // answers with the new branch and `repo get` reads it back, while the
3599 // reference advertisement — which reads `HEAD`, not the catalog — keeps
3600 // naming the old one. That was the whole of the 2026-08-06 defect: a
3601 // `master` donor pushed cleanly into a repository nobody could clone,
3602 // and `set-default-branch` looked like it had fixed it.
3603 self.stores.repoint_head(&repo).map_err(|err| {
3604 ControlError::Internal(format!("pointing HEAD at the new default branch: {err:#}"))
3605 })?;
3606 Ok(repo_out(&repo))
3607 }
3608
3609 fn set_visibility(
3610 &self,
3611 path: &RepoPath,
3612 visibility: gunnar_proto::Visibility,
3613 ) -> ControlResult<Repo> {
3614 if visibility == gunnar_proto::Visibility::Unspecified {
3615 return Err(ControlError::InvalidArgument(
3616 "visibility unspecified".into(),
3617 ));
3618 }
3619 let repo_ref = Self::repo_ref(path)?;
3620 self.stores
3621 .catalog()
3622 .set_visibility(&repo_ref, visibility_in(visibility)?)
3623 .map(|repo| repo_out(&repo))
3624 .map_err(internal)
3625 }
3626
3627 /// **A member's ONE encryption key** (CRYPT-YUBI 2026-10-26): a repository's ring is
3628 /// wrapped to the person's encryption public key, never to an SSH key. Empty when they
3629 /// have none yet.
3630 fn recipient_keys(&self, principal_id: &str) -> ControlResult<Vec<gunnar_proto::RecipientKey>> {
3631 let public = self
3632 .stores
3633 .encryption_keys()
3634 .public_of(principal_id)
3635 .map_err(key_refusal)?;
3636 Ok(public
3637 .into_iter()
3638 .map(|public| gunnar_proto::RecipientKey {
3639 fingerprint: gunnar_core::auth::fingerprint_of_blob(public.as_bytes()).to_string(),
3640 algo: if public.starts_with("age1pq1") {
3641 gunnar_core::auth::PQ_RECIPIENT_ALGO.to_owned()
3642 } else {
3643 "X25519".to_owned()
3644 },
3645 openssh: public,
3646 })
3647 .collect())
3648 }
3649
3650 fn encryption_key(
3651 &self,
3652 principal_id: &str,
3653 ) -> ControlResult<gunnar_proto::GetEncryptionKeyResponse> {
3654 Ok(encryption_key_out(
3655 self.stores
3656 .encryption_keys()
3657 .get(principal_id)
3658 .map_err(key_refusal)?,
3659 ))
3660 }
3661
3662 fn create_encryption_key(
3663 &self,
3664 principal_id: &str,
3665 public_key: &str,
3666 first: gunnar_proto::UnlockMethod,
3667 ) -> ControlResult<gunnar_proto::GetEncryptionKeyResponse> {
3668 let first = method_in(first)?;
3669 let key = self
3670 .stores
3671 .encryption_keys()
3672 .create(
3673 principal_id,
3674 public_key,
3675 first,
3676 gunnar_core::now_ms().max(0) as u64,
3677 )
3678 .map_err(key_refusal)?;
3679 Ok(encryption_key_out(Some(key)))
3680 }
3681
3682 fn add_unlock_method(
3683 &self,
3684 principal_id: &str,
3685 method: gunnar_proto::UnlockMethod,
3686 ) -> ControlResult<gunnar_proto::GetEncryptionKeyResponse> {
3687 let method = method_in(method)?;
3688 let key = self
3689 .stores
3690 .encryption_keys()
3691 .add_method(principal_id, method, gunnar_core::now_ms().max(0) as u64)
3692 .map_err(key_refusal)?;
3693 Ok(encryption_key_out(Some(key)))
3694 }
3695
3696 fn remove_unlock_method(
3697 &self,
3698 principal_id: &str,
3699 id: &str,
3700 ) -> ControlResult<gunnar_proto::GetEncryptionKeyResponse> {
3701 let key = self
3702 .stores
3703 .encryption_keys()
3704 .remove_method(principal_id, id)
3705 .map_err(key_refusal)?;
3706 Ok(encryption_key_out(Some(key)))
3707 }
3708
3709 fn set_encrypted(&self, path: &RepoPath, encrypted: bool) -> ControlResult<Repo> {
3710 let repo_ref = Self::repo_ref(path)?;
3711 self.stores
3712 .catalog()
3713 .set_encrypted(&repo_ref, encrypted)
3714 .map(|repo| repo_out(&repo))
3715 .map_err(internal)
3716 }
3717
3718 /// **`Repos.ArchiveOut`: the same bytes `gunnar repo archive-out` writes,
3719 /// into a sink the control plane owns** — so a cron on another machine can
3720 /// pull a backup off a box that has no shell.
3721 ///
3722 /// It does **not** go through [`Self::open`]. An archive is three files
3723 /// read out of a directory; taking the handle cache and the store's lock
3724 /// for it would make a backup contend with serving, and the whole reason
3725 /// there is no `freeze` verb is that it does not have to. The pack is
3726 /// append-only and fsynced below the cut, so what is read is durable and
3727 /// what arrives after the cut belongs to the next one.
3728 #[cfg(feature = "engine-znippy")]
3729 fn archive_out(
3730 &self,
3731 path: &RepoPath,
3732 from_generation: u64,
3733 sink: &mut dyn std::io::Write,
3734 ) -> ControlResult<gunnar_grpc::seam::ArchiveCut> {
3735 let repo_ref = Self::repo_ref(path)?;
3736 let dir = self
3737 .stores
3738 .repo_dir(&repo_ref)
3739 .map_err(|err| ControlError::Internal(format!("resolving {path}: {err:#}")))?;
3740 if !dir.is_dir() {
3741 return Err(ControlError::NotFound(format!(
3742 "{path} has nothing on disk at {}",
3743 dir.display()
3744 )));
3745 }
3746 let receipt =
3747 crate::storage::znippy_engine::archive_out_to(&dir, &mut { sink }, from_generation)
3748 .map_err(archive_error)?;
3749 // **The second bytes-out counting point, and the only one outside the
3750 // git transport.** `Repos.ArchiveOut` streams a repository's archive
3751 // over gRPC and never touches `transport::serve_blocking`, so its bytes
3752 // are in neither `ServerMetrics::bytes_out` nor — before this line —
3753 // any tenant's meter. A replica pulling a hundred gigabytes would have
3754 // read as a silent namespace.
3755 //
3756 // It counts as a FETCH: a caller asked for objects and objects were
3757 // served, which is what `fetches` means. The principal is not in scope
3758 // at this seam — the gRPC caller's identity stops at the service layer —
3759 // so this transfer moves bytes in nobody's name, which is a smaller
3760 // untruth than attributing it to whoever pushed last.
3761 self.stores.flow().record(
3762 &path.namespace,
3763 gunnar_core::now_ms(),
3764 &gunnar_core::Transfer {
3765 bytes_out: receipt.bytes_out,
3766 fetched: true,
3767 ..gunnar_core::Transfer::default()
3768 },
3769 );
3770 Ok(gunnar_grpc::seam::ArchiveCut {
3771 from_generation: receipt.from_generation,
3772 to_generation: receipt.to_generation,
3773 bytes_out: receipt.bytes_out,
3774 file_sha256: receipt.file_sha256,
3775 })
3776 }
3777
3778 // ── refs and objects ───────────────────────────────────────────────────
3779
3780 fn list_refs(
3781 &self,
3782 path: &RepoPath,
3783 prefix: &str,
3784 peel_them: bool,
3785 ) -> ControlResult<Vec<RefTarget>> {
3786 let (_, store) = self.open(path)?;
3787 let git = store.serve();
3788 // **One `refs()` call is one snapshot.** The prefix is filtered here
3789 // rather than pushed into the store because `GitOps::refs` takes no
3790 // filter — it hands back the ref namespace, and narrowing it is the
3791 // caller's. See `gunnar_wire::refs`.
3792 let snapshot = RefSnapshot::read(git).map_err(wire_error)?;
3793 let mut out = Vec::new();
3794 for row in snapshot.with_prefix(Some(prefix)) {
3795 out.push(ref_target_out(
3796 git,
3797 store.hash_kind(),
3798 &snapshot,
3799 row,
3800 peel_them,
3801 )?);
3802 }
3803 Ok(out)
3804 }
3805
3806 fn get_ref(
3807 &self,
3808 path: &RepoPath,
3809 ref_name: &str,
3810 peel_them: bool,
3811 ) -> ControlResult<RefTarget> {
3812 let (_, store) = self.open(path)?;
3813 let git = store.serve();
3814 // `FullName::new` was the validator and it died with `RefStore`; the
3815 // rules live in `gunnar_store::refname` now, one authority for the
3816 // whole tree. It is still checked here rather than trusted: this is a
3817 // caller's string, and `refname::validate` is a path-traversal boundary
3818 // wherever a name can become a lookup.
3819 gunnar_store::refname::validate(ref_name)
3820 .map_err(|err| ControlError::InvalidArgument(err.to_string()))?;
3821 let snapshot = RefSnapshot::read(git).map_err(wire_error)?;
3822 // `refname::validate` admits exactly `HEAD` and `refs/…`, and `HEAD` is
3823 // **not** one of the rows (the `GitServe` HEAD ruling) — so asking for it in the snapshot
3824 // would answer `NotFound` for a reference every repository has. The
3825 // accessor pair is where it comes from; its target still resolves
3826 // against the snapshot, so both branches answer out of one snapshot.
3827 let head_row;
3828 let row = if ref_name == gunnar_store::HEAD {
3829 head_row = git
3830 .head()
3831 .map_err(store_error)?
3832 .ok_or_else(|| ControlError::NotFound(format!("{ref_name} in {path}")))?;
3833 &head_row
3834 } else {
3835 snapshot
3836 .get(ref_name)
3837 .ok_or_else(|| ControlError::NotFound(format!("{ref_name} in {path}")))?
3838 };
3839 ref_target_out(git, store.hash_kind(), &snapshot, row, peel_them)
3840 }
3841
3842 fn log(
3843 &self,
3844 path: &RepoPath,
3845 start: &str,
3846 limit: u32,
3847 touching: &str,
3848 ) -> ControlResult<Vec<Commit>> {
3849 self.log_walk(path, LogStart::One(start), limit, touching)
3850 }
3851
3852 /// Every branch and tag tip, and `HEAD`, walked as one history: the
3853 /// History page's "all branches" view (owner order 2026-10-18), so a
3854 /// commit that lives only on a side branch is drawn too.
3855 fn log_all_refs(
3856 &self,
3857 path: &RepoPath,
3858 limit: u32,
3859 touching: &str,
3860 ) -> ControlResult<Vec<Commit>> {
3861 self.log_walk(path, LogStart::AllRefs, limit, touching)
3862 }
3863
3864 fn create_pull(
3865 &self,
3866 path: &RepoPath,
3867 author: &str,
3868 title: &str,
3869 description: &str,
3870 source_ref: &str,
3871 target_ref: &str,
3872 ) -> ControlResult<PullRequest> {
3873 for (what, name) in [("source_ref", source_ref), ("target_ref", target_ref)] {
3874 if !name.starts_with("refs/heads/") {
3875 return Err(ControlError::InvalidArgument(format!(
3876 "{what} must be a full branch name under refs/heads/, got {name:?}: a pull \
3877 request merges branches, and a short name stored today is an ambiguity \
3878 resolved differently tomorrow"
3879 )));
3880 }
3881 }
3882 if source_ref == target_ref {
3883 return Err(ControlError::InvalidArgument(
3884 "a branch cannot be merged into itself".to_owned(),
3885 ));
3886 }
3887 if title.trim().is_empty() {
3888 return Err(ControlError::InvalidArgument(
3889 "a title is required".to_owned(),
3890 ));
3891 }
3892 let repo_ref = Self::repo_ref(path)?;
3893 let (_, store) = self.open(path)?;
3894 let git = store.serve();
3895 let snapshot = RefSnapshot::read(git).map_err(wire_error)?;
3896 let source = snapshot
3897 .resolve(source_ref)
3898 .map_err(wire_error)?
3899 .ok_or_else(|| ControlError::NotFound(format!("{source_ref} in {path}")))?;
3900 snapshot
3901 .resolve(target_ref)
3902 .map_err(wire_error)?
3903 .ok_or_else(|| ControlError::NotFound(format!("{target_ref} in {path}")))?;
3904 let record = self
3905 .pulls()?
3906 .create(
3907 &repo_ref.to_string(),
3908 author,
3909 title,
3910 description,
3911 source_ref,
3912 target_ref,
3913 &source.to_string(),
3914 )
3915 .map_err(internal)?;
3916 Ok(pull_out(path, record))
3917 }
3918
3919 fn list_pulls(&self, path: &RepoPath, state: i32) -> ControlResult<Vec<PullRequest>> {
3920 let repo_ref = Self::repo_ref(path)?;
3921 // Existence check: a list for a repository that is not there is
3922 // NotFound, never an empty success.
3923 self.get_repo(path)?;
3924 let filter = match PullRequestState::try_from(state) {
3925 Ok(PullRequestState::Unspecified) | Err(_) => None,
3926 Ok(PullRequestState::Open) => Some(gunnar_core::PullState::Open),
3927 Ok(PullRequestState::Merged) => Some(gunnar_core::PullState::Merged),
3928 Ok(PullRequestState::Closed) => Some(gunnar_core::PullState::Closed),
3929 };
3930 Ok(self
3931 .pulls()?
3932 .list(&repo_ref.to_string(), filter)
3933 .map_err(internal)?
3934 .into_iter()
3935 .map(|record| pull_out(path, record))
3936 .collect())
3937 }
3938
3939 fn get_pull(&self, path: &RepoPath, number: u64) -> ControlResult<PullRequest> {
3940 let repo_ref = Self::repo_ref(path)?;
3941 self.get_repo(path)?;
3942 self.pulls()?
3943 .get(&repo_ref.to_string(), number)
3944 .map_err(internal)?
3945 .map(|record| pull_out(path, record))
3946 .ok_or_else(|| ControlError::NotFound(format!("pull request #{number} in {path}")))
3947 }
3948
3949 fn close_pull(&self, path: &RepoPath, number: u64) -> ControlResult<PullRequest> {
3950 let repo_ref = Self::repo_ref(path)?;
3951 self.get_repo(path)?;
3952 match self
3953 .pulls()?
3954 .close(&repo_ref.to_string(), number)
3955 .map_err(internal)?
3956 {
3957 gunnar_core::PullTransition::Done(record) => Ok(pull_out(path, record)),
3958 gunnar_core::PullTransition::NotFound => Err(ControlError::NotFound(format!(
3959 "pull request #{number} in {path}"
3960 ))),
3961 gunnar_core::PullTransition::NotOpen(state) => Err(ControlError::FailedPrecondition(
3962 format!("pull request #{number} is already {state:?}, not open"),
3963 )),
3964 }
3965 }
3966
3967 /// FAST-FORWARD ONLY, and every step is ordered so a failure leaves the
3968 /// truth on disk: the ancestry is decided by the same
3969 /// [`gunnar_policy::ancestry::is_descendant_of`] branch protection trusts
3970 /// (fail-closed: inconclusive is a refusal, never an allow), the ref moves
3971 /// through the store's own compare-and-swap — the primitive receive-pack
3972 /// uses, so this is not a second ref-write path — and the record is marked
3973 /// merged only AFTER the ref has moved.
3974 fn merge_pull(
3975 &self,
3976 path: &RepoPath,
3977 number: u64,
3978 principal_id: &str,
3979 ) -> ControlResult<(PullRequest, MergedRef)> {
3980 let repo_ref = Self::repo_ref(path)?;
3981 let key = repo_ref.to_string();
3982 let pulls = self.pulls()?;
3983 let record = pulls
3984 .get(&key, number)
3985 .map_err(internal)?
3986 .ok_or_else(|| ControlError::NotFound(format!("pull request #{number} in {path}")))?;
3987 if record.state != gunnar_core::PullState::Open {
3988 return Err(ControlError::FailedPrecondition(format!(
3989 "pull request #{number} is already {:?}, not open",
3990 record.state
3991 )));
3992 }
3993
3994 let (repo_record, store) = self.open(path)?;
3995 let git = store.serve();
3996 let snapshot = RefSnapshot::read(git).map_err(wire_error)?;
3997 let source = snapshot
3998 .resolve(&record.source_ref)
3999 .map_err(wire_error)?
4000 .ok_or_else(|| {
4001 ControlError::FailedPrecondition(format!(
4002 "the source branch {} is gone; nothing to merge",
4003 record.source_ref
4004 ))
4005 })?;
4006 let target = snapshot
4007 .resolve(&record.target_ref)
4008 .map_err(wire_error)?
4009 .ok_or_else(|| {
4010 ControlError::FailedPrecondition(format!(
4011 "the target branch {} is gone",
4012 record.target_ref
4013 ))
4014 })?;
4015
4016 // ── THE REVIEW GATE ────────────────────────────────────────────────
4017 //
4018 // A verified approval must name the commit this is about to move to.
4019 //
4020 // `source` and not `record.source_oid`: the record's oid is the tip at
4021 // CREATION and the branch may have advanced since. The approval names
4022 // what was reviewed, so if the branch moved after the review, no
4023 // approval names the new tip and this refuses — which is the whole
4024 // point. A force-push after a review does not inherit the review, and
4025 // the refusal says which commit was actually approved so the reviewer
4026 // can be asked again.
4027 //
4028 // Verified, not merely present: `review_state` re-checks every approval
4029 // against the ACL key table right now, so an approval by a key that has
4030 // since been revoked stops counting the moment the key does.
4031 let head = source.to_string();
4032 // Read once: `review_state` needs the SIGNED events to re-check each
4033 // approval, and the fold needs the unsigned halves of the same list.
4034 let signed = self.forge_events()?;
4035 let events: Vec<gunnar_core::ForgeEvent> = signed
4036 .iter()
4037 .filter(|s| s.event.repo == key)
4038 .map(|s| s.event.clone())
4039 .collect();
4040 let review = gunnar_core::forge::fold(number, &events);
4041 let approved = self.review_state(&key, &review, &signed)?;
4042 let ok = approved
4043 .approvals
4044 .iter()
4045 .any(|a| a.verified && a.commit == head);
4046 if !ok {
4047 let named: Vec<String> = approved
4048 .approvals
4049 .iter()
4050 .map(|a| {
4051 format!(
4052 "{} → {} [{}]",
4053 a.actor,
4054 &a.commit[..12.min(a.commit.len())],
4055 a.verdict
4056 )
4057 })
4058 .collect();
4059 return Err(ControlError::FailedPrecondition(format!(
4060 "no verified approval names {}, the commit {} is at. gunnar does not merge on \
4061 an approval of something else: an approval names the commit it approved, so a \
4062 branch that moved after the review has not been reviewed.{}",
4063 &head[..12.min(head.len())],
4064 record.source_ref,
4065 if named.is_empty() {
4066 " Nobody has approved this pull request.".to_owned()
4067 } else {
4068 format!(" What is approved: {}", named.join(", "))
4069 }
4070 )));
4071 }
4072
4073 // ── THE REVIEW POLICY ──────────────────────────────────────────────
4074 //
4075 // Who may count, and how many — from the SAME policy document branch
4076 // protection lives in, on the rules covering the TARGET ref. The gate
4077 // above asked "is there a verified approval of this tip"; these two
4078 // fields ask "whose, and how many", and they are policy rather than
4079 // fact, which is why they live in a `ProtectionRule` an administrator
4080 // wrote and not in this function's opinion.
4081 //
4082 // Distinct by construction: the fold keys approvals by actor, so a
4083 // principal who approves twice holds ONE entry, and this count can
4084 // never be inflated by pressing the button again.
4085 let policy = self.policy_of(&repo_record)?;
4086 let rules: Vec<&gunnar_policy::ProtectionRule> = policy
4087 .protection
4088 .iter()
4089 .filter(|rule| rule.pattern.matches_ref(&record.target_ref))
4090 .collect();
4091 let forbid_self = rules.iter().any(|rule| rule.forbid_self_approval);
4092 let required = rules
4093 .iter()
4094 .map(|rule| rule.required_approvals)
4095 .max()
4096 .unwrap_or(0)
4097 .max(1) as usize;
4098 let approvers: Vec<&str> = approved
4099 .approvals
4100 .iter()
4101 .filter(|a| a.verified && a.commit == head)
4102 .map(|a| a.actor.as_str())
4103 .collect();
4104 let counted: Vec<&str> = approvers
4105 .iter()
4106 .copied()
4107 .filter(|actor| !(forbid_self && *actor == record.author))
4108 .collect();
4109 if forbid_self && counted.is_empty() && !approvers.is_empty() {
4110 return Err(ControlError::FailedPrecondition(format!(
4111 "the protection on {} forbids self-approval, and the only verified approval \
4112 of {} is the author's own ({}). An author's sign-off on their own work is \
4113 not a review; somebody else has to look at it.",
4114 record.target_ref,
4115 &head[..12.min(head.len())],
4116 record.author
4117 )));
4118 }
4119 if counted.len() < required {
4120 return Err(ControlError::FailedPrecondition(format!(
4121 "the protection on {} requires {required} distinct verified approvals of {} \
4122 and there {} ({}). Approvals by the same principal count once{}.",
4123 record.target_ref,
4124 &head[..12.min(head.len())],
4125 if counted.len() == 1 {
4126 "is 1".to_owned()
4127 } else {
4128 format!("are {}", counted.len())
4129 },
4130 if counted.is_empty() {
4131 "nobody".to_owned()
4132 } else {
4133 counted.join(", ")
4134 },
4135 if forbid_self {
4136 ", and the author's own does not count here"
4137 } else {
4138 ""
4139 }
4140 )));
4141 }
4142
4143 // ── BRANCH PROTECTION, the same gate the push path runs ────────────
4144 //
4145 // GUN-004. This function read the policy document, took
4146 // `forbid_self_approval` and `required_approvals` out of it, and then
4147 // wrote the ref. `allowed_principals` and `require_signature` live in
4148 // the same `ProtectionRule` and were evaluated only in
4149 // `gates::protection` — which the merge path never went through. A
4150 // contributor with `Level::Write` on a repository in somebody else's
4151 // namespace could move a ref an administrator had explicitly protected,
4152 // while the push path refused them correctly: the protection LOOKED
4153 // like it worked, and the side door was open. The structural cause was
4154 // that the caller's identity never crossed the seam, which it now does.
4155 //
4156 // `BranchProtectionGate::check`, not `PolicyEngine::evaluate`: a merge
4157 // is not a push. It has no pack to measure, no push options to read and
4158 // no reference name the client chose, so `LimitsGate`,
4159 // `PushOptionsGate` and `RefNameGate` would each be deciding about
4160 // something that is not in front of them.
4161 if source != target {
4162 let principal = self
4163 .stores
4164 .auth()
4165 .principal(principal_id)
4166 .map_err(internal)?
4167 .ok_or_else(|| {
4168 // The caller authenticated and passed a grant check to get
4169 // here, so this is a table that changed underneath the
4170 // request. Refuse; a merge attributed to nobody is worse
4171 // than a merge that did not happen.
4172 ControlError::FailedPrecondition(format!(
4173 "the principal {principal_id} is no longer in the identity table; \
4174 refusing to move a protected reference for an identity that \
4175 cannot be resolved"
4176 ))
4177 })?;
4178 let gate = gunnar_policy::BranchProtectionGate::new(policy.protection.clone());
4179 let repo_id = gunnar_policy::RepoId::new(&path.namespace, &path.repo);
4180 let policy_principal = gunnar_policy::Principal {
4181 id: principal.id.clone(),
4182 name: principal.name.clone(),
4183 kind: crate::transport::policy_principal_kind(principal.kind),
4184 };
4185 // What this ref move ADDS. A merge carries no pack, so the pack's
4186 // new-object set — every other gate's answer to "what did this
4187 // introduce" — is empty here, and an empty set makes
4188 // `require_signature` pass by having nothing to look at. The
4189 // honest set is the range `target..source`; see
4190 // `ancestry::commits_between`.
4191 let introduced = gunnar_policy::ancestry::commits_between(
4192 git,
4193 &source,
4194 &target,
4195 gunnar_policy::ancestry::DEFAULT_ANCESTRY_BUDGET,
4196 )
4197 .map_err(|err| {
4198 ControlError::FailedPrecondition(format!(
4199 "the commits this merge would add could not be enumerated ({err}); \
4200 refusing rather than running branch protection over an empty set"
4201 ))
4202 })?;
4203 let updates = [gunnar_policy::RefUpdate::from_wire(
4204 record.target_ref.clone(),
4205 target,
4206 source,
4207 )];
4208 let keys = crate::transport::key_directory(&self.stores);
4209 let ctx = gunnar_policy::PushContext::new(
4210 &policy_principal,
4211 &repo_id,
4212 &updates,
4213 &introduced,
4214 git,
4215 &keys,
4216 );
4217 match gunnar_policy::Gate::check(&gate, &ctx) {
4218 Ok(gunnar_policy::Verdict::Allow) => {}
4219 Ok(verdict) => {
4220 return Err(ControlError::FailedPrecondition(format!(
4221 "branch protection refuses this merge: {}",
4222 verdict.reason().unwrap_or_default()
4223 )));
4224 }
4225 // A gate that could not decide refuses, exactly as it does on
4226 // the push path.
4227 Err(err) => {
4228 return Err(ControlError::FailedPrecondition(format!(
4229 "branch protection could not decide this merge ({err}); refusing rather \
4230 than guessing"
4231 )));
4232 }
4233 }
4234 }
4235
4236 if source != target {
4237 match gunnar_policy::ancestry::is_descendant_of(
4238 git,
4239 &source,
4240 &target,
4241 gunnar_policy::ancestry::DEFAULT_ANCESTRY_BUDGET,
4242 ) {
4243 Ok(true) => {}
4244 Ok(false) => {
4245 return Err(ControlError::FailedPrecondition(format!(
4246 "not a fast-forward: {} does not descend from {}. gunnar has no merge \
4247 engine yet — rebase or merge locally, push the result, and merge again",
4248 record.source_ref, record.target_ref
4249 )));
4250 }
4251 // Inconclusive is a refusal, never an allow — the same
4252 // judgement BranchProtectionGate makes.
4253 Err(err) => {
4254 return Err(ControlError::FailedPrecondition(format!(
4255 "the ancestry walk could not be completed ({err}); refusing rather than \
4256 guessing"
4257 )));
4258 }
4259 }
4260 git.update_ref(
4261 &record.target_ref,
4262 Some(target.as_bytes()),
4263 Some(source.as_bytes()),
4264 )
4265 .map_err(|err| {
4266 ControlError::FailedPrecondition(format!(
4267 "the target ref moved while merging ({err}); nothing was written — retry \
4268 against the new tip"
4269 ))
4270 })?;
4271 }
4272 // source == target is "already up to date": nothing to write, and the
4273 // record honestly says the merge oid is the tip both already share.
4274
4275 let merged = match pulls
4276 .mark_merged(&key, number, &source.to_string())
4277 .map_err(internal)?
4278 {
4279 gunnar_core::PullTransition::Done(record) => record,
4280 // The ref HAS moved; a racing close between our CAS and this write
4281 // must be reported loudly, not papered over.
4282 other => {
4283 return Err(ControlError::Internal(format!(
4284 "the ref moved to {source} but the record could not be marked merged \
4285 ({other:?}); the repository is correct, the record is not"
4286 )));
4287 }
4288 };
4289 Ok((
4290 pull_out(path, merged),
4291 MergedRef {
4292 ref_name: record.target_ref.clone(),
4293 old_oid: target.to_string(),
4294 new_oid: source.to_string(),
4295 },
4296 ))
4297 }
4298
4299 fn read_object(
4300 &self,
4301 path: &RepoPath,
4302 oid: &str,
4303 max_bytes: u64,
4304 ) -> ControlResult<ReadObjectResponse> {
4305 let (_, store) = self.open(path)?;
4306 let git = store.serve();
4307 let id = gix_hash::ObjectId::from_hex(oid.as_bytes())
4308 .map_err(|err| ControlError::InvalidArgument(format!("{oid}: {err}")))?;
4309
4310 // ── the object ceiling, contract half, now complete ────────────────
4311 //
4312 // `header()` costs no inflate — `gix-odb` answers kind and size out of
4313 // the index — so the size is known before any allocation, and both
4314 // refusals happen HERE rather than after the object is materialised.
4315 //
4316 // Two ceilings, and they are different in kind. See their constants.
4317 if let Some((_, object_bytes)) = git.header(id.as_bytes()).map_err(store_error)? {
4318 // The OBJECT ceiling first, and the order is deliberate: an object
4319 // over it cannot be served at any `max_bytes`, so saying so
4320 // immediately is better than sending the caller off to retry with a
4321 // prefix that will be refused for a second reason.
4322 if self.max_object_bytes != 0 && object_bytes > self.max_object_bytes {
4323 return Err(ControlError::ResourceExhausted(format!(
4324 "object {oid} is {object_bytes} bytes, over this server's {}-byte ceiling \
4325 for reading an object. Reading any part of it costs the whole object in \
4326 memory, because a prefix of a delta-encoded object needs its entire base \
4327 chain, so no max_bytes makes this request cheaper.",
4328 self.max_object_bytes
4329 )));
4330 }
4331 // The RESPONSE ceiling second: this one costs nobody anything, and
4332 // `max_bytes` is the way past it.
4333 let response_bytes = if max_bytes == 0 {
4334 object_bytes
4335 } else {
4336 object_bytes.min(max_bytes)
4337 };
4338 if response_bytes > MAX_RESPONSE_BYTES {
4339 return Err(ControlError::ResourceExhausted(format!(
4340 "object {oid} is {object_bytes} bytes and the response would be \
4341 {response_bytes}, over the {MAX_RESPONSE_BYTES}-byte limit a gRPC client \
4342 can receive by default. Ask for a prefix with max_bytes."
4343 )));
4344 }
4345 }
4346
4347 // The half with no contract change: the object is inflated once
4348 // and then **moved** into the response, where it used to be copied out
4349 // of `buf` a second time by `to_vec()`. On a one-gigabyte blob that
4350 // second copy was another gigabyte, for nothing.
4351 //
4352 // `GitServe::read` returns the inflated bytes owned, so the move is the
4353 // signature now rather than a borrow that had to be ended first — the
4354 // `{ … }` block this replaced existed only to drop a `Data<'a>` tied to
4355 // the caller's buffer.
4356 let Some((kind, mut buf)) = git.read(id.as_bytes()).map_err(store_error)? else {
4357 return Err(ControlError::NotFound(format!("object {oid} in {path}")));
4358 };
4359 let size = buf.len() as u64;
4360
4361 // The first inflation is still whole-object — `GitServe` has no bounded
4362 // read and for a delta-encoded object cannot honestly grow one — but it
4363 // is now BOUNDED, by `max_object_bytes` above. A caller asking for one
4364 // byte of a one-gigabyte blob is refused rather than served at the cost
4365 // of a gigabyte.
4366 let truncated = max_bytes != 0 && size > max_bytes;
4367 if truncated {
4368 buf.truncate(max_bytes as usize);
4369 }
4370 Ok(ReadObjectResponse {
4371 oid: id.to_string(),
4372 kind: object_kind_out(kind)? as i32,
4373 size,
4374 data: buf,
4375 truncated,
4376 })
4377 }
4378
4379 /// **`Refs.ReadLfsObject`, served out of the repository's own LFS store.**
4380 ///
4381 /// The same store the SSH adapter and the HTTP batch API serve
4382 /// (`gunnar_lfs::FsLfsStore` under `<git_dir>/lfs`), opened the same way
4383 /// `transport::open_lfs_store` opens it, so there is one place an object
4384 /// lives and one answer to where it is.
4385 ///
4386 /// # Whole object or nothing, and the ceiling that decides
4387 ///
4388 /// `read_object` truncates a git blob past `max_bytes`, because a prefix of
4389 /// a file is still a file's beginning. This does not: the caller asked for
4390 /// a PDF, and half a PDF is not a smaller PDF. So the SIZE decides before
4391 /// a byte is read — `size_of` costs a `stat`, not an open — and an object
4392 /// over the ceiling is refused with its size in the message, which is what
4393 /// lets the caller go to `git lfs` instead.
4394 ///
4395 /// The ceiling is the caller's `max_bytes`, lowered to
4396 /// [`MAX_RESPONSE_BYTES`] because that is what a gRPC client can receive by
4397 /// default. Nothing here allocates from a number the client sent: the
4398 /// allocation is the object's own size, after that size has been refused
4399 /// or accepted.
4400 ///
4401 /// # A ranged read (2026-10-15)
4402 ///
4403 /// With `range`, the same ceiling bounds a RANGE instead of the object, so
4404 /// a caller with no `git lfs` — a console handing a browser a 6 MB README
4405 /// picture — reads any object a range at a time, and one that needs only
4406 /// a picture's header reads only that. The store seeks (`open_at`), so a
4407 /// range deep into a large object does not read what lies before it. Still
4408 /// never a silent prefix: a range ends early only at the object's end,
4409 /// which `offset` + the length against `size` says.
4410 fn read_lfs_object(
4411 &self,
4412 path: &RepoPath,
4413 oid: &str,
4414 max_bytes: u64,
4415 range: Option<(u64, u64)>,
4416 ) -> ControlResult<ReadLfsObjectResponse> {
4417 let (_, store) = self.open(path)?;
4418 let id = gunnar_lfs::Oid::parse(oid)
4419 .map_err(|err| ControlError::InvalidArgument(format!("{oid}: {err}")))?;
4420 // The trait the store's two readers live on; `gunnar-lfs` keeps the
4421 // wire adapters and the backend apart, and this is the backend half.
4422 use gunnar_lfs::LfsStore as _;
4423
4424 let lfs = gunnar_lfs::FsLfsStore::open_or_init(store.git_dir()).map_err(|err| {
4425 ControlError::Internal(format!("opening the repository's LFS store: {err}"))
4426 })?;
4427
4428 let ceiling = match max_bytes {
4429 0 => MAX_RESPONSE_BYTES,
4430 n => n.min(MAX_RESPONSE_BYTES),
4431 };
4432 let size = lfs
4433 .size_of(&id)
4434 .map_err(|err| ControlError::Internal(format!("reading the LFS store: {err}")))?
4435 .ok_or_else(|| ControlError::NotFound(format!("LFS object {oid} in {path}")))?;
4436
4437 // **A ranged read** (`offset`/`length` not both 0): the ceiling bounds
4438 // the RANGE, so an object of any size is read a range at a time. An
4439 // offset at or past the end is refused by name rather than answered
4440 // empty, and a length over the ceiling is refused rather than cut — a
4441 // caller never gets fewer bytes than it asked for unless the object's
4442 // end is the reason.
4443 if let Some((offset, length)) = range {
4444 if offset > size || (offset == size && size > 0) {
4445 return Err(ControlError::InvalidArgument(format!(
4446 "offset {offset} is past the end of LFS object {oid}, which is {size} bytes"
4447 )));
4448 }
4449 if length > ceiling {
4450 return Err(ControlError::ResourceExhausted(format!(
4451 "a range of {length} bytes of LFS object {oid} is over the {ceiling}-byte ceiling \
4452 for this request; ask for {ceiling} bytes or fewer at a time"
4453 )));
4454 }
4455 let want = if length == 0 { ceiling } else { length }.min(size - offset);
4456 let object = lfs
4457 .open_at(&id, offset)
4458 .map_err(|err| ControlError::Internal(format!("opening LFS object {oid}: {err}")))?
4459 .ok_or_else(|| ControlError::NotFound(format!("LFS object {oid} in {path}")))?;
4460 let mut data = Vec::with_capacity(want as usize);
4461 let mut reader = std::io::Read::take(object.reader, want);
4462 std::io::Read::read_to_end(&mut reader, &mut data).map_err(|err| {
4463 ControlError::Internal(format!("reading LFS object {oid}: {err}"))
4464 })?;
4465 if (data.len() as u64) < want {
4466 return Err(ControlError::Internal(format!(
4467 "LFS object {oid} ended {} bytes into a {want}-byte range at {offset}",
4468 data.len()
4469 )));
4470 }
4471 return Ok(ReadLfsObjectResponse {
4472 oid: oid.to_owned(),
4473 size: object.size,
4474 data,
4475 offset,
4476 });
4477 }
4478
4479 if size > ceiling {
4480 return Err(ControlError::ResourceExhausted(format!(
4481 "LFS object {oid} is {size} bytes, over the {ceiling}-byte ceiling for this \
4482 request. An LFS object is served whole or not at all — a prefix of one is not a \
4483 smaller file — so no max_bytes makes this request smaller. `git lfs` streams it, \
4484 and a ranged read (offset/length) reads it a range at a time."
4485 )));
4486 }
4487
4488 let object = lfs
4489 .open(&id)
4490 .map_err(|err| ControlError::Internal(format!("opening LFS object {oid}: {err}")))?
4491 .ok_or_else(|| ControlError::NotFound(format!("LFS object {oid} in {path}")))?;
4492 // `size_of` and `open` are two calls on a store an upload can commit
4493 // to in between, so the length is taken from the object that was
4494 // actually opened and the read is bounded by it rather than by the
4495 // earlier answer.
4496 if object.size > ceiling {
4497 return Err(ControlError::ResourceExhausted(format!(
4498 "LFS object {oid} is {} bytes, over the {ceiling}-byte ceiling for this request",
4499 object.size
4500 )));
4501 }
4502 let mut data = Vec::with_capacity(object.size as usize);
4503 let mut reader = std::io::Read::take(object.reader, object.size);
4504 std::io::Read::read_to_end(&mut reader, &mut data)
4505 .map_err(|err| ControlError::Internal(format!("reading LFS object {oid}: {err}")))?;
4506
4507 Ok(ReadLfsObjectResponse {
4508 oid: oid.to_owned(),
4509 size: data.len() as u64,
4510 data,
4511 offset: 0,
4512 })
4513 }
4514
4515 /// **`Refs.Diff`, served** (`crate::diff`).
4516 ///
4517 /// It used to answer `Unimplemented` — *"the server has no tree-diff
4518 /// engine; gix's blob-diff is client-side porcelain and an S crate must not
4519 /// link it"*. The constraint was right and the conclusion was not: a tree
4520 /// diff needs `GitServe::read` and nothing else, so it costs no crate and
4521 /// the appliance can serve one. `crate::diff` says what it computes exactly
4522 /// and what it deliberately does not (rename detection).
4523 fn diff(
4524 &self,
4525 path: &RepoPath,
4526 from: &str,
4527 to: &str,
4528 path_prefixes: &[String],
4529 ) -> ControlResult<Vec<FileChange>> {
4530 // The repository is opened first so a bad path is still a NotFound
4531 // rather than an argument error, which would hide a typo.
4532 let (_repo, store) = self.open(path)?;
4533 let git = store.serve();
4534 let hash = store.hash_kind();
4535
4536 // An empty side is the empty tree, which is what makes "everything this
4537 // commit added" expressible without a magic oid: `from: ""` diffs
4538 // against nothing.
4539 let resolve = |spec: &str| -> ControlResult<Option<gix_hash::ObjectId>> {
4540 if spec.is_empty() {
4541 return Ok(None);
4542 }
4543 Ok(Some(self.tree_of(git, hash, path, spec)?))
4544 };
4545 let old = resolve(from)?;
4546 let new = resolve(to)?;
4547
4548 crate::diff::compare(git, hash, old, new, path_prefixes).map_err(|err| match err {
4549 // A tree-ish that names an object this repository does not hold is
4550 // the caller's mistake, not a fault: `NotFound`, with the id.
4551 crate::diff::DiffError::Missing(oid) => {
4552 ControlError::NotFound(format!("object {oid} in {path}"))
4553 }
4554 crate::diff::DiffError::WrongKind { .. } => {
4555 ControlError::InvalidArgument(err.to_string())
4556 }
4557 crate::diff::DiffError::Corrupt { .. } | crate::diff::DiffError::Backend(_) => {
4558 ControlError::Internal(err.to_string())
4559 }
4560 })
4561 }
4562
4563 // ── identity and access ────────────────────────────────────────────────
4564
4565 fn add_principal(
4566 &self,
4567 id: &str,
4568 name: &str,
4569 kind: gunnar_proto::PrincipalKind,
4570 ) -> ControlResult<Principal> {
4571 // gunnar-core's principals carry an id chosen by the caller, not a
4572 // minted one: grants name it, so a generated id would make every grant
4573 // in a config file unwritable until after the principal existed.
4574 //
4575 // The id and the name are two arguments on purpose (2026-09-12). Until
4576 // then this line read `Principal::new(name, name, …)`, which twinned
4577 // the account slug with the display name; every rename was a
4578 // re-identification and every console had to pin "the name I saw".
4579 // The service hands the name as the id when the caller set none, so
4580 // every caller from before keeps exactly what it had.
4581 let principal =
4582 gunnar_core::Principal::new(id, name, principal_kind_in(kind)).map_err(internal)?;
4583 let created = self
4584 .stores
4585 .auth()
4586 .add_principal(principal)
4587 .map(|p| principal_out(&p))
4588 .map_err(internal)?;
4589 // ── the account, created WITH the user ─────────────────────────────
4590 //
4591 // A principal is intrinsically admin of the namespace named after its
4592 // ID (`gunnar_core::AuthTable::owns_account`), which is the `<account>`
4593 // in `<host>/<account>/<repo>`. That rule reads no table, so it can
4594 // only be sound if the two names cannot be claimed independently — and
4595 // this is the half that makes them one claim: creating the principal
4596 // creates the namespace, and a namespace that already exists makes the
4597 // whole call fail, principal and all.
4598 //
4599 // Rolled back on failure rather than left half-made. A principal with
4600 // somebody else's namespace as its account is exactly the state the
4601 // intrinsic rule must never be able to reach.
4602 if let Err(err) = self.stores.catalog().create_namespace(id) {
4603 let _ = self.stores.auth().remove_principal(id);
4604 return Err(ControlError::AlreadyExists(format!(
4605 "cannot create the account namespace \"{id}\" for this user: {err}. A user's \
4606 account is the namespace named after their id and cannot be somebody else's; \
4607 the principal was not created."
4608 )));
4609 }
4610 // The metadata mirror's identity half, off the stripe. After the write,
4611 // best effort, never the reason a verb fails: see
4612 // `ServerStores::mirror_identity`.
4613 self.stores.mirror_identity();
4614 Ok(created)
4615 }
4616
4617 fn enroll_employee(
4618 &self,
4619 account: &str,
4620 ask: &gunnar_grpc::EmployeeAsk,
4621 ) -> ControlResult<gunnar_grpc::EmployeeAnswer> {
4622 if self
4623 .stores
4624 .catalog()
4625 .namespace(account)
4626 .map_err(internal)?
4627 .is_none()
4628 {
4629 return Err(ControlError::NotFound(format!("account {account}")));
4630 }
4631 // A clan is no account: employees are a person's account's (clans,
4632 // 2026-10-05), and a clan's members are accounts of their own.
4633 if self.stores.auth().is_clan(account).map_err(internal)? {
4634 return Err(ControlError::FailedPrecondition(format!(
4635 "{account} is a clan, not an account: employees are enrolled by an account's admin, \
4636 and a clan's members are accounts that join it"
4637 )));
4638 }
4639 let principal = if ask.seats == 0 {
4640 // An id that names a namespace would make the employee that
4641 // namespace's admin by name (`owns_account`). Refused, as the
4642 // account a principal is born with would refuse it.
4643 if self
4644 .stores
4645 .catalog()
4646 .namespace(&ask.id)
4647 .map_err(internal)?
4648 .is_some()
4649 {
4650 return Err(ControlError::AlreadyExists(format!(
4651 "namespace {} exists, so an employee may not be called that",
4652 ask.id
4653 )));
4654 }
4655 Some(
4656 gunnar_core::Principal::new(&ask.id, &ask.name, gunnar_core::PrincipalKind::User)
4657 .map_err(internal)?,
4658 )
4659 } else {
4660 None
4661 };
4662 let room = gunnar_core::RoomAsk {
4663 room_bytes: ask.room_bytes,
4664 seats: ask.seats,
4665 from_seat: ask.from_seat,
4666 cap: ask.cap,
4667 stored: ask.stored,
4668 order: ask.order.clone(),
4669 dry_run: ask.dry_run,
4670 };
4671 let answer = match self
4672 .stores
4673 .auth()
4674 .enroll_with_room(account, principal, &room)
4675 {
4676 Ok(answer) => answer,
4677 // Short is an ANSWER at the seam (nothing was written); the
4678 // service says it in the one sentence every plane shares.
4679 Err(gunnar_core::Error::RoomShort {
4680 need,
4681 unused,
4682 short,
4683 ..
4684 }) => {
4685 let (seats, seats_open) = self.stores.auth().seats_of(account).map_err(internal)?;
4686 return Ok(gunnar_grpc::EmployeeAnswer {
4687 principal: None,
4688 room_bytes: ask.room_bytes,
4689 seats,
4690 seats_open,
4691 need_bytes: need,
4692 unused_bytes: Some(unused),
4693 shortfall_bytes: short,
4694 unchanged: true,
4695 });
4696 }
4697 Err(gunnar_core::Error::PrincipalExists(id)) => {
4698 return Err(ControlError::AlreadyExists(format!(
4699 "principal {id} already exists, and an existing principal can never become an \
4700 employee"
4701 )))
4702 }
4703 Err(err @ gunnar_core::Error::NoOpenSeat(_)) => {
4704 return Err(ControlError::FailedPrecondition(err.to_string()))
4705 }
4706 Err(err @ gunnar_core::Error::NameExists { .. }) => {
4707 return Err(ControlError::AlreadyExists(err.to_string()))
4708 }
4709 Err(err @ gunnar_core::Error::InvalidInput { .. }) => {
4710 return Err(ControlError::InvalidArgument(err.to_string()))
4711 }
4712 Err(other) => return Err(internal(other)),
4713 };
4714 if !answer.unchanged {
4715 self.stores.mirror_identity();
4716 }
4717 Ok(gunnar_grpc::EmployeeAnswer {
4718 principal: answer.principal.as_ref().map(principal_out),
4719 room_bytes: answer.room_bytes,
4720 seats: answer.seats,
4721 seats_open: answer.seats_open,
4722 need_bytes: answer.need_bytes,
4723 unused_bytes: answer.unused_bytes,
4724 shortfall_bytes: answer.shortfall_bytes,
4725 unchanged: answer.unchanged,
4726 })
4727 }
4728
4729 fn seats(&self, account: &str) -> ControlResult<(u32, u32)> {
4730 self.stores.auth().seats_of(account).map_err(internal)
4731 }
4732
4733 fn clans(&self) -> Option<&dyn gunnar_grpc::clans::ClanPlane> {
4734 Some(self)
4735 }
4736
4737 fn has_employees(&self, account: &str) -> ControlResult<bool> {
4738 self.stores.auth().is_enterprise(account).map_err(internal)
4739 }
4740
4741 fn employees(&self, account: &str) -> ControlResult<Vec<Principal>> {
4742 Ok(self
4743 .stores
4744 .auth()
4745 .employees_of(account)
4746 .map_err(internal)?
4747 .iter()
4748 .map(principal_out)
4749 .collect())
4750 }
4751
4752 fn principal_by_name(&self, name: &str) -> ControlResult<Option<Principal>> {
4753 Ok(self
4754 .stores
4755 .auth()
4756 .principal_by_name(name)
4757 .map_err(internal)?
4758 .as_ref()
4759 .map(principal_out))
4760 }
4761
4762 /// The fed name is read where the installer left it — the last join reply,
4763 /// `<data>/pairing/joined.toml` — so it holds across restarts and needs no
4764 /// second record. A build without the pairing loop was fed nothing.
4765 /// ★ **The operator's first invite**, kept in the identity table (the
4766 /// twin replicates it): the break-glass token's hash as
4767 /// `operator-bootstrap` for the operator's first passkey, no server grant.
4768 /// The identity core reads it as a fed invite of its own and spends it
4769 /// there (IDENTITY-HOST). Spent — and a leftover one withdrawn — once the
4770 /// operator holds a passkey; otherwise recorded, or renewed for another
4771 /// `OPERATOR_INVITE_TTL_MS` when the same hash is pending: one update.
4772 fn operator_bootstrap_invite(
4773 &self,
4774 operator: &str,
4775 secret_sha256: &str,
4776 now_unix_ms: i64,
4777 ttl_ms: i64,
4778 ) -> ControlResult<gunnar_grpc::BootstrapInvite> {
4779 use gunnar_grpc::seam::OPERATOR_INVITE_ID;
4780 let hash = gunnar_grpc::seam::bootstrap_hash(secret_sha256)?;
4781 let ttl_ms = gunnar_grpc::seam::bootstrap_ttl(ttl_ms)?;
4782 let auth = self.stores.auth();
4783 if !auth
4784 .web_credentials(Some(operator))
4785 .map_err(internal)?
4786 .is_empty()
4787 {
4788 if auth
4789 .drop_first_invite(OPERATOR_INVITE_ID)
4790 .map_err(internal)?
4791 .is_some()
4792 {
4793 self.stores.mirror_identity();
4794 }
4795 return Ok(gunnar_grpc::BootstrapInvite::Spent);
4796 }
4797 let renewed = auth
4798 .first_invite(OPERATOR_INVITE_ID)
4799 .map_err(internal)?
4800 .is_some_and(|row| row.secret_hash == hash && row.principal_id == operator);
4801 auth.put_first_invite(gunnar_core::FirstInviteRow {
4802 id: OPERATOR_INVITE_ID.to_owned(),
4803 principal_id: operator.to_owned(),
4804 secret_hash: hash,
4805 created_by: "installer".to_owned(),
4806 created: now_unix_ms,
4807 expires: now_unix_ms + ttl_ms,
4808 note: "the operator's first passkey: the installer's first invite".to_owned(),
4809 server_level: None,
4810 })
4811 .map_err(internal)?;
4812 self.stores.mirror_identity();
4813 if !renewed {
4814 tracing::warn!(
4815 target: "gunnar::audit",
4816 event = "operator.bootstrap_invite",
4817 operator,
4818 ttl_days = ttl_ms / 86_400_000,
4819 "the installer's first invite (its hash) is recorded for the operator: his first passkey registers on his account, by gunnar's word"
4820 );
4821 }
4822 Ok(if renewed {
4823 gunnar_grpc::BootstrapInvite::Renewed
4824 } else {
4825 gunnar_grpc::BootstrapInvite::Recorded
4826 })
4827 }
4828
4829 fn claim_operator(&self, asked: &str) -> ControlResult<gunnar_grpc::OperatorClaim> {
4830 #[cfg(feature = "wg-client")]
4831 let fed = crate::fed_principals::fed_operator(self.stores.root());
4832 #[cfg(not(feature = "wg-client"))]
4833 let fed: Option<String> = None;
4834 self.claim_operator_named(fed.as_deref(), asked)
4835 }
4836
4837 fn rename_principal(&self, principal_id: &str, name: &str) -> ControlResult<Principal> {
4838 // The label alone. The namespace is the id's and does not move; nothing
4839 // keyed on the id is touched, and `AuthTable::rename_principal` says so
4840 // in its tests.
4841 let renamed = self
4842 .stores
4843 .auth()
4844 .rename_principal(principal_id, name)
4845 .map(|p| principal_out(&p))
4846 .map_err(internal)?;
4847 self.stores.mirror_identity();
4848 Ok(renamed)
4849 }
4850
4851 fn list_principals(&self) -> ControlResult<Vec<Principal>> {
4852 Ok(self
4853 .stores
4854 .auth()
4855 .principals()
4856 .map_err(internal)?
4857 .iter()
4858 .map(principal_out)
4859 .collect())
4860 }
4861
4862 fn principal_activity(
4863 &self,
4864 ) -> ControlResult<std::collections::BTreeMap<String, gunnar_grpc::PrincipalSeen>> {
4865 Ok(self
4866 .stores
4867 .activity()
4868 .all()
4869 .into_iter()
4870 .map(|(id, seen)| {
4871 (
4872 id,
4873 gunnar_grpc::PrincipalSeen {
4874 last_sign_in_unix_ms: seen.signed_in,
4875 last_operation_unix_ms: seen.operated,
4876 },
4877 )
4878 })
4879 .collect())
4880 }
4881
4882 fn holds_server_grant(&self, principal_id: &str) -> ControlResult<bool> {
4883 self.stores
4884 .auth()
4885 .holds_server_grant(principal_id)
4886 .map_err(internal)
4887 }
4888
4889 fn add_credential(
4890 &self,
4891 principal_id: &str,
4892 credential_id: &[u8],
4893 public_key: &[u8],
4894 label: &str,
4895 sign_count: u32,
4896 ) -> ControlResult<gunnar_proto::WebCredential> {
4897 let stored = self
4898 .stores
4899 .auth()
4900 .add_web_credential(gunnar_core::WebCredential {
4901 id: gunnar_grpc::webauthn::b64url(credential_id),
4902 principal_id: principal_id.to_owned(),
4903 label: label.to_owned(),
4904 public_key_hex: hex::encode(public_key),
4905 sign_count,
4906 created: gunnar_core::now_ms(),
4907 last_used: None,
4908 sessions: Vec::new(),
4909 })
4910 .map_err(internal)?;
4911 web_credential_out(&stored)
4912 }
4913
4914 fn credentials(
4915 &self,
4916 principal_id: Option<&str>,
4917 ) -> ControlResult<Vec<gunnar_proto::WebCredential>> {
4918 self.stores
4919 .auth()
4920 .web_credentials(principal_id)
4921 .map_err(internal)?
4922 .iter()
4923 .map(web_credential_out)
4924 .collect()
4925 }
4926
4927 fn credential(
4928 &self,
4929 credential_id: &[u8],
4930 ) -> ControlResult<Option<gunnar_grpc::StoredCredential>> {
4931 let Some(row) = self
4932 .stores
4933 .auth()
4934 .web_credential(&gunnar_grpc::webauthn::b64url(credential_id))
4935 .map_err(internal)?
4936 else {
4937 return Ok(None);
4938 };
4939 let public_key = hex::decode(&row.public_key_hex).map_err(|err| {
4940 ControlError::Internal(format!(
4941 "web credential {} holds a key that is not hex: {err}",
4942 row.id
4943 ))
4944 })?;
4945 Ok(Some(gunnar_grpc::StoredCredential {
4946 credential: web_credential_out(&row)?,
4947 public_key,
4948 }))
4949 }
4950
4951 fn record_passkey_use(
4952 &self,
4953 credential_id: &[u8],
4954 sign_count: u32,
4955 now_unix_ms: i64,
4956 ) -> ControlResult<gunnar_proto::WebCredential> {
4957 let row = self
4958 .stores
4959 .auth()
4960 .touch_web_credential(
4961 &gunnar_grpc::webauthn::b64url(credential_id),
4962 sign_count,
4963 now_unix_ms,
4964 )
4965 .map_err(internal)?;
4966 self.stores.mirror_identity();
4967 // A session was issued: the person's last sign-in (2026-10-21).
4968 self.stores
4969 .activity()
4970 .signed_in(&row.principal_id, now_unix_ms);
4971 web_credential_out(&row)
4972 }
4973
4974 fn revoke_credential(
4975 &self,
4976 credential_id: &[u8],
4977 ) -> ControlResult<Option<gunnar_proto::WebCredential>> {
4978 let removed = self
4979 .stores
4980 .auth()
4981 .revoke_web_credential(&gunnar_grpc::webauthn::b64url(credential_id))
4982 .map_err(internal)?;
4983 // Its session keys went with it.
4984 self.stores.mirror_identity();
4985 removed.as_ref().map(web_credential_out).transpose()
4986 }
4987
4988 fn rename_credential(
4989 &self,
4990 credential_id: &[u8],
4991 label: &str,
4992 ) -> ControlResult<Option<gunnar_proto::WebCredential>> {
4993 self.stores
4994 .auth()
4995 .rename_web_credential(&gunnar_grpc::webauthn::b64url(credential_id), label)
4996 .map_err(internal)?
4997 .as_ref()
4998 .map(web_credential_out)
4999 .transpose()
5000 }
5001
5002 fn first_invite(&self) -> ControlResult<Option<gunnar_grpc::FirstInvite>> {
5003 Ok(self
5004 .stores
5005 .auth()
5006 .first_invite(gunnar_grpc::seam::OPERATOR_INVITE_ID)
5007 .map_err(internal)?
5008 .map(|row| gunnar_grpc::FirstInvite {
5009 principal_id: row.principal_id,
5010 secret_hash: row.secret_hash,
5011 created_at_unix_ms: row.created,
5012 expires_at_unix_ms: row.expires,
5013 }))
5014 }
5015
5016 fn drop_first_invite(&self) -> ControlResult<()> {
5017 if self
5018 .stores
5019 .auth()
5020 .drop_first_invite(gunnar_grpc::seam::OPERATOR_INVITE_ID)
5021 .map_err(internal)?
5022 .is_some()
5023 {
5024 self.stores.mirror_identity();
5025 }
5026 Ok(())
5027 }
5028
5029 fn employer_of(&self, principal_id: &str) -> ControlResult<Option<String>> {
5030 self.stores
5031 .auth()
5032 .employer_of(principal_id)
5033 .map_err(internal)
5034 }
5035
5036 fn sso(&self) -> Option<&dyn gunnar_grpc::sso::plane::SsoPlane> {
5037 Some(self)
5038 }
5039
5040 fn sso_import(
5041 &self,
5042 order: gunnar_grpc::sso::scim::ImportOrder,
5043 ) -> ControlResult<gunnar_grpc::sso::scim::ImportJob> {
5044 // RA5-39: enqueued under an id of its own, and the queue's answer is the judge (never a
5045 // status read before it, which a second import at the same moment also passed): a job
5046 // that is not this order's is the live import of the account, and this file is refused.
5047 let account = order.account.clone();
5048 let id = self.sso_import.next_job_id();
5049 let job = self.sso_import.enqueue_as(order, id.clone());
5050 if job.job_id != id {
5051 return Err(ControlError::FailedPrecondition(format!(
5052 "an import for account {account} is already {} (job {}): wait for it to finish",
5053 if job.state == crate::job_queue::JobState::Queued {
5054 "queued"
5055 } else {
5056 "running"
5057 },
5058 job.job_id
5059 )));
5060 }
5061 Ok(crate::sso_import_queue::job_out(job))
5062 }
5063
5064 fn sso_import_status(
5065 &self,
5066 account: &str,
5067 ) -> ControlResult<Option<gunnar_grpc::sso::scim::ImportJob>> {
5068 Ok(self
5069 .sso_import
5070 .status(account)
5071 .map(crate::sso_import_queue::job_out))
5072 }
5073
5074 fn approve_machine_enrolment(
5075 &self,
5076 approval: gunnar_grpc::MachineApproval,
5077 ) -> ControlResult<gunnar_grpc::MachineApproved> {
5078 let step_up = approval
5079 .step_up
5080 .as_ref()
5081 .map(|(id, count)| (gunnar_grpc::webauthn::b64url(id), *count));
5082 let (principal, credential, created) = self
5083 .stores
5084 .auth()
5085 .approve_machine_credential(
5086 &approval.machine,
5087 level_in(approval.server_level)?,
5088 gunnar_core::WebCredential {
5089 id: gunnar_grpc::webauthn::b64url(&approval.credential_id),
5090 principal_id: String::new(),
5091 label: approval.label,
5092 public_key_hex: hex::encode(&approval.public_key),
5093 sign_count: approval.sign_count,
5094 created: approval.now_unix_ms,
5095 last_used: None,
5096 sessions: Vec::new(),
5097 },
5098 step_up.as_ref().map(|(id, count)| (id.as_str(), *count)),
5099 approval.now_unix_ms,
5100 )
5101 .map_err(internal)?;
5102 self.stores.mirror_identity();
5103 Ok(gunnar_grpc::MachineApproved {
5104 principal: principal_out(&principal),
5105 credential: web_credential_out(&credential)?,
5106 created,
5107 })
5108 }
5109
5110 fn remove_principal(&self, principal_id: &str) -> ControlResult<(u32, u32)> {
5111 let before_keys = self.stores.auth().keys().map_err(internal)?.len();
5112 let before_grants = self.stores.auth().grants(None).map_err(internal)?.len();
5113 self.stores
5114 .auth()
5115 .remove_principal(principal_id)
5116 .map_err(internal)?;
5117 let after_keys = self.stores.auth().keys().map_err(internal)?.len();
5118 let after_grants = self.stores.auth().grants(None).map_err(internal)?.len();
5119 self.stores.mirror_identity();
5120 Ok((
5121 before_keys.saturating_sub(after_keys) as u32,
5122 before_grants.saturating_sub(after_grants) as u32,
5123 ))
5124 }
5125
5126 fn add_key(
5127 &self,
5128 principal_id: &str,
5129 openssh_public_key: &str,
5130 _comment: &str,
5131 expires_at_unix_ms: Option<i64>,
5132 caller: &str,
5133 attestation: Option<gunnar_proto::SignedKeyAttestation>,
5134 ) -> ControlResult<PublicKey> {
5135 // The fingerprint the attestation must name is derived HERE from the
5136 // key line, never taken from the client: an attestation over a
5137 // fingerprint the client chose would attest to nothing the SSH listener
5138 // will ever match. Parsed once more inside `register_key`; that is one
5139 // parse of one line on a verb that runs when a person gets a machine.
5140 // An `age1pq1…` line is a quantum-safe encryption key (crypt-pq),
5141 // fingerprinted over its key bytes; anything else must be SSH.
5142 let adding = match gunnar_core::auth::pq_recipient_fingerprint(openssh_public_key) {
5143 Some(fingerprint) => fingerprint,
5144 None => gunnar_core::Fingerprint::of_key(
5145 &gunnar_core::auth::parse_public_key(openssh_public_key).map_err(internal)?,
5146 ),
5147 };
5148 let holders = self
5149 .stores
5150 .auth()
5151 .attesting_keys_of(principal_id)
5152 .map_err(internal)?;
5153 // **The key it already holds is already there** (L1, release-3 recharge): a restored
5154 // main holds the twin's key from the log, and the pairing door enrols it again. That
5155 // is no new key, so no attestation is owed — ALREADY_EXISTS, which callers tolerate.
5156 if holders.iter().any(|k| k.fingerprint == adding.as_str()) {
5157 return Err(ControlError::AlreadyExists(format!(
5158 "principal {principal_id} already holds key {}",
5159 adding.as_str()
5160 )));
5161 }
5162 let requirement =
5163 gunnar_core::key_attestation::Requirement::of(principal_id, caller, holders);
5164 let attestation = attestation.map(from_proto_attestation).transpose()?;
5165 gunnar_core::key_attestation::check(
5166 &requirement,
5167 principal_id,
5168 adding.as_str(),
5169 attestation.as_ref(),
5170 )
5171 .map_err(|problem| match problem {
5172 // Required and absent: the request is well formed and the table
5173 // says "not without their word" — a precondition, with the
5174 // command that satisfies it in the message.
5175 gunnar_core::AttestationProblem::Missing { .. } => {
5176 ControlError::FailedPrecondition(problem.to_string())
5177 }
5178 // Present and wrong: the caller's argument.
5179 _ => ControlError::InvalidArgument(problem.to_string()),
5180 })?;
5181 let registered = self
5182 .stores
5183 .auth()
5184 .register_key(
5185 principal_id,
5186 openssh_public_key,
5187 expires_at_unix_ms.map(|ms| ms as UnixMillis),
5188 )
5189 .map(|key| key_out(&key))
5190 .map_err(internal)?;
5191 self.stores.mirror_identity();
5192 Ok(registered)
5193 }
5194
5195 fn revoke_key(&self, fingerprint: &str) -> ControlResult<()> {
5196 let parsed = gunnar_core::Fingerprint::parse(fingerprint)
5197 .map_err(|err| ControlError::InvalidArgument(err.to_string()))?;
5198 let revoked = self.stores.auth().revoke_key(&parsed).map_err(internal)?;
5199 self.stores.mirror_identity();
5200 match revoked {
5201 Some(_) => Ok(()),
5202 None => Err(ControlError::NotFound(format!("key {fingerprint}"))),
5203 }
5204 }
5205
5206 fn list_keys(&self, principal_id: Option<&str>) -> ControlResult<Vec<PublicKey>> {
5207 let keys = match principal_id {
5208 Some(id) => self.stores.auth().keys_of(id).map_err(internal)?,
5209 None => self.stores.auth().keys().map_err(internal)?,
5210 };
5211 Ok(keys.iter().map(key_out).collect())
5212 }
5213
5214 fn grant(
5215 &self,
5216 principal_id: &str,
5217 namespace: &str,
5218 repo: &str,
5219 level: GrantLevel,
5220 ) -> ControlResult<gunnar_proto::Grant> {
5221 let scope = scope_in(namespace, repo)?;
5222 let grant = Grant {
5223 principal: principal_id.to_owned(),
5224 scope,
5225 level: level_in(level)?,
5226 };
5227 self.stores
5228 .auth()
5229 .grant(grant)
5230 .map(|g| grant_out(&g))
5231 .map_err(internal)
5232 }
5233
5234 fn revoke(&self, principal_id: &str, namespace: &str, repo: &str) -> ControlResult<()> {
5235 let scope = scope_in(namespace, repo)?;
5236 match self
5237 .stores
5238 .auth()
5239 .revoke_grant(principal_id, &scope)
5240 .map_err(internal)?
5241 {
5242 true => Ok(()),
5243 false => Err(ControlError::NotFound(format!(
5244 "grant for {principal_id} on {scope}"
5245 ))),
5246 }
5247 }
5248
5249 fn list_grants(
5250 &self,
5251 principal_id: Option<&str>,
5252 namespace: Option<&str>,
5253 repo: Option<&str>,
5254 ) -> ControlResult<Vec<gunnar_proto::Grant>> {
5255 Ok(self
5256 .stores
5257 .auth()
5258 .grants(principal_id)
5259 .map_err(internal)?
5260 .iter()
5261 .map(grant_out)
5262 .filter(|g| namespace.is_none_or(|want| want == g.namespace))
5263 .filter(|g| repo.is_none_or(|want| want == g.repo))
5264 .collect())
5265 }
5266
5267 // ── groups (punkt 9) ───────────────────────────────────────────────────
5268
5269 fn add_group_member(
5270 &self,
5271 group_id: &str,
5272 member_id: &str,
5273 signed: gunnar_proto::SignedMembershipEvent,
5274 ) -> ControlResult<u64> {
5275 let signed = self.verify_membership_event(
5276 signed,
5277 group_id,
5278 member_id,
5279 gunnar_core::MembershipAction::Add,
5280 )?;
5281 // Apply to the live table first: if the typecheck (group exists and is
5282 // a group, member exists) fails, nothing is signed into the log. A
5283 // membership event in the log names a change that was actually made.
5284 self.stores
5285 .auth()
5286 .add_to_group(group_id, member_id)
5287 .map_err(internal)?;
5288 self.append_membership_event(&signed)
5289 }
5290
5291 fn remove_group_member(
5292 &self,
5293 group_id: &str,
5294 member_id: &str,
5295 signed: gunnar_proto::SignedMembershipEvent,
5296 ) -> ControlResult<(bool, u64)> {
5297 let signed = self.verify_membership_event(
5298 signed,
5299 group_id,
5300 member_id,
5301 gunnar_core::MembershipAction::Remove,
5302 )?;
5303 let was_member = self
5304 .stores
5305 .auth()
5306 .remove_from_group(group_id, member_id)
5307 .map_err(internal)?;
5308 // Recorded even when it was a no-op: the attempt to remove is evidence
5309 // too, and a reader folding the log sees a remove of a non-member leave
5310 // the set unchanged, exactly as `remove_from_group` did.
5311 let index = self.append_membership_event(&signed)?;
5312 Ok((was_member, index))
5313 }
5314
5315 fn membership_events(
5316 &self,
5317 group_id: Option<&str>,
5318 ) -> ControlResult<Vec<gunnar_proto::SignedMembershipEvent>> {
5319 Ok(self
5320 .membership_log_events()?
5321 .into_iter()
5322 .filter(|signed| group_id.is_none_or(|g| signed.event.group == g))
5323 .map(to_proto_membership)
5324 .collect())
5325 }
5326
5327 fn list_group_members(&self, group_id: &str) -> ControlResult<Vec<Principal>> {
5328 // The group must exist and be a group, so "no members" and "no such
5329 // group" cannot be confused into the same empty list.
5330 let auth = self.stores.auth();
5331 match auth.principal(group_id).map_err(internal)? {
5332 Some(p) if p.kind == PrincipalKind::Group => {}
5333 Some(_) => {
5334 return Err(ControlError::InvalidArgument(format!(
5335 "principal \"{group_id}\" is not a group"
5336 )))
5337 }
5338 None => return Err(ControlError::NotFound(format!("group {group_id}"))),
5339 }
5340 // Membership is stored by member id; resolve each to a full principal so
5341 // a UI can show names and kinds, not bare ids.
5342 let mut members = Vec::new();
5343 for id in auth.members_of(group_id).map_err(internal)? {
5344 if let Some(p) = auth.principal(&id).map_err(internal)? {
5345 members.push(principal_out(&p));
5346 }
5347 }
5348 Ok(members)
5349 }
5350
5351 // ── policy ─────────────────────────────────────────────────────────────
5352
5353 fn get_protection(
5354 &self,
5355 path: &RepoPath,
5356 ref_pattern: Option<&str>,
5357 ) -> ControlResult<Vec<BranchProtection>> {
5358 let (repo, _) = self.open(path)?;
5359 let config = self.policy_of(&repo)?;
5360 Ok(config
5361 .protection
5362 .iter()
5363 .filter(|rule| ref_pattern.is_none_or(|want| rule.pattern.as_str() == want))
5364 .map(protection_out)
5365 .collect())
5366 }
5367
5368 /// **Write one protection rule**, replacing any rule with the same pattern.
5369 ///
5370 /// Two things this deliberately does not do, both of them places where the
5371 /// proto and `gunnar_policy::ProtectionRule` do not line up. Getting either
5372 /// wrong silently is how an operator ends up with a policy they did not
5373 /// write:
5374 ///
5375 /// * **The per-pattern byte and object limits are refused, not dropped.**
5376 /// `BranchProtection` carries `max_object_bytes` and
5377 /// `max_objects_per_push` per rule; `gunnar-policy`'s `LimitPolicy` is
5378 /// **repository-wide**. Storing a per-rule number into a repository-wide
5379 /// limit would apply it to references the caller never named, and
5380 /// ignoring it would report success for a limit that is not enforced.
5381 /// Both are worse than saying so.
5382 /// * **`allow_create` and `ancestry_budget` survive a round trip.** They
5383 /// have no proto field, so a rule written over gRPC would otherwise reset
5384 /// whatever a `gunnar-policy.json` had set — an edit through the console
5385 /// quietly widening a rule it did not display. The existing rule is read
5386 /// first and only the fields the proto carries are overwritten.
5387 fn set_protection(
5388 &self,
5389 path: &RepoPath,
5390 protection: BranchProtection,
5391 ) -> ControlResult<BranchProtection> {
5392 let (repo, _) = self.open(path)?;
5393 if protection.max_object_bytes != 0 || protection.max_objects_per_push != 0 {
5394 return Err(ControlError::InvalidArgument(
5395 "max_object_bytes and max_objects_per_push are per-rule in the protocol and \
5396 repository-wide in this server's policy engine, so a value here would apply to \
5397 references this rule does not name. Leave them 0 and set the repository's \
5398 limits in its policy document."
5399 .to_owned(),
5400 ));
5401 }
5402 let pattern = gunnar_policy::RefPattern::new(protection.ref_pattern.clone())
5403 .map_err(|err| ControlError::InvalidArgument(err.to_string()))?;
5404
5405 let mut config = self.policy_of(&repo)?;
5406 // Start from the rule that is there, so the two fields the proto cannot
5407 // express keep their values.
5408 let mut rule = config
5409 .protection
5410 .iter()
5411 .find(|rule| rule.pattern == pattern)
5412 .cloned()
5413 .unwrap_or_else(|| {
5414 gunnar_policy::ProtectionRule::protect(pattern.as_str())
5415 .expect("the pattern was compiled one line above")
5416 });
5417 rule.pattern = pattern;
5418 rule.allow_force_push = protection.allow_force_push;
5419 rule.allow_delete = protection.allow_delete;
5420 rule.require_signature = protection.require_signature;
5421 // Empty in the proto means "anybody"; `None` means the same in the
5422 // policy. A `Some(vec![])` would mean **nobody may push**, which is a
5423 // very different rule to arrive at by accident.
5424 rule.allowed_principals = if protection.allowed_principal_ids.is_empty() {
5425 None
5426 } else {
5427 Some(protection.allowed_principal_ids.clone())
5428 };
5429 rule.forbid_self_approval = protection.forbid_self_approval;
5430 rule.required_approvals = protection.required_approvals;
5431
5432 config
5433 .protection
5434 .retain(|other| other.pattern != rule.pattern);
5435 config.protection.push(rule.clone());
5436 self.store_policy(&repo, &config)?;
5437 Ok(protection_out(&rule))
5438 }
5439
5440 fn delete_protection(&self, path: &RepoPath, ref_pattern: &str) -> ControlResult<()> {
5441 let (repo, _) = self.open(path)?;
5442 let mut config = self.policy_of(&repo)?;
5443 let before = config.protection.len();
5444 config
5445 .protection
5446 .retain(|rule| rule.pattern.as_str() != ref_pattern);
5447 if config.protection.len() == before {
5448 return Err(ControlError::NotFound(format!(
5449 "no protection rule for {ref_pattern} in {path}"
5450 )));
5451 }
5452 self.store_policy(&repo, &config)
5453 }
5454
5455 fn get_ref_name_policy(&self, path: &RepoPath) -> ControlResult<RefNamePolicy> {
5456 let (repo, _) = self.open(path)?;
5457 let config = self.policy_of(&repo)?;
5458 Ok(ref_name_policy_out(&config.ref_names))
5459 }
5460
5461 fn set_ref_name_policy(
5462 &self,
5463 path: &RepoPath,
5464 policy: RefNamePolicy,
5465 ) -> ControlResult<RefNamePolicy> {
5466 let (repo, _) = self.open(path)?;
5467 let compiled = gunnar_policy::RefNamePolicy::new(
5468 policy.allow_patterns.clone(),
5469 policy.deny_patterns.clone(),
5470 )
5471 .map_err(|err| ControlError::InvalidArgument(err.to_string()))?;
5472 let mut config = self.policy_of(&repo)?;
5473 config.ref_names = compiled.clone();
5474 self.store_policy(&repo, &config)?;
5475 Ok(ref_name_policy_out(&compiled))
5476 }
5477
5478 // ── forge (roadmap phase 1) ────────────────────────────────────────────
5479 //
5480 // **Real**, over the same append-only log `gunnar forge` writes. There is
5481 // one forge log per server and the events carry their repository, so the
5482 // gRPC surface and the CLI are two doors onto one file rather than two
5483 // stores that can disagree (one implementation).
5484
5485 fn forge_append(&self, event: gunnar_proto::SignedForgeEvent) -> ControlResult<u64> {
5486 if event.attested.is_some() {
5487 return Err(ControlError::InvalidArgument(
5488 "this event carries an attestation, and an attestation is gunnar's own word: \
5489 only gunnar makes one, for a person's session that stepped up"
5490 .to_owned(),
5491 ));
5492 }
5493 let signed = from_proto_event(event)?;
5494
5495 // **The signature is checked before anything is written.** A review log
5496 // that took an event it could not verify would be a log of claims, and
5497 // the point of the whole design is that it is a log of evidence. The
5498 // key must already be in the ACL table for the same reason: a signature
5499 // by a key gunnar does not know is not something a later reader could
5500 // check at all.
5501 let key = self
5502 .stores
5503 .auth()
5504 .signing_keys()
5505 .map_err(|err| ControlError::Unavailable(format!("{err:#}")))?
5506 .into_iter()
5507 .find(|row| row.fingerprint_sha256 == signed.key_fingerprint)
5508 .ok_or_else(|| {
5509 ControlError::InvalidArgument(format!(
5510 "{} is not registered on this server, so nobody reading this event later \
5511 could check its signature",
5512 signed.key_fingerprint
5513 ))
5514 })?;
5515 // **The key must be the claimed actor's own.** The signature proves
5516 // which key spoke; the table's `principal_id` says whose voice that
5517 // is, and an event attributed to one principal signed by another's
5518 // key is a claim filed in somebody else's name — exactly what the
5519 // service layer's actor==caller check refuses, re-checked here at the
5520 // seam because this door can also be reached without a connection.
5521 if key.principal_id != signed.event.actor {
5522 return Err(ControlError::InvalidArgument(format!(
5523 "this event is attributed to {:?} and is signed by {}, which is {:?}'s key; \
5524 an approval is the actor's own word, so it must carry the actor's own \
5525 signature",
5526 signed.event.actor, signed.key_fingerprint, key.principal_id
5527 )));
5528 }
5529 let public = gunnar_core::ssh_key::PublicKey::from_openssh(&key.openssh)
5530 .map_err(|err| ControlError::Internal(format!("stored key is unreadable: {err}")))?;
5531 gunnar_core::forge::verify_event(&signed, &public)
5532 .map_err(|problem| ControlError::InvalidArgument(format!("{problem}")))?;
5533
5534 self.forge_subject_open(&signed)?;
5535 self.forge_write(&signed)
5536 }
5537
5538 fn forge_append_attested(
5539 &self,
5540 event: gunnar_proto::SignedForgeEvent,
5541 session_stepped_up_at_unix_ms: i64,
5542 ) -> ControlResult<(u64, String)> {
5543 // **The caller's own signature is not what this takes.** A session
5544 // holds no key of the person's; what it holds is a fresh touch of their
5545 // passkey, which the service checked. A signature on the wire here is
5546 // somebody's key speaking, and that is `forge_append`'s door.
5547 if !event.key_fingerprint.is_empty()
5548 || !event.signature.is_empty()
5549 || event.attested.is_some()
5550 {
5551 return Err(ControlError::InvalidArgument(
5552 "an event gunnar attests arrives unsigned: a signed one is checked against its \
5553 signer's key instead, and an attestation is gunnar's own to make"
5554 .to_owned(),
5555 ));
5556 }
5557 let event = from_proto_event(event)?.event;
5558 if event.actor.is_empty() {
5559 return Err(ControlError::InvalidArgument(
5560 "an attested event must name the person it is attested for".to_owned(),
5561 ));
5562 }
5563 let key = self
5564 .stores
5565 .forge_attest_key()
5566 .map_err(|err| ControlError::Unavailable(format!("{err:#}")))?;
5567 let signed = gunnar_core::forge::attest_event(
5568 &key,
5569 event,
5570 gunnar_core::Attested {
5571 session_stepped_up_at_unix_ms,
5572 },
5573 )
5574 .map_err(|err| ControlError::Internal(format!("signing the attested event: {err}")))?;
5575 self.forge_subject_open(&signed)?;
5576 let words = signed
5577 .attested
5578 .as_ref()
5579 .map(|a| a.words())
5580 .unwrap_or_default();
5581 Ok((self.forge_write(&signed)?, words))
5582 }
5583
5584 fn forge_review(
5585 &self,
5586 repo: &str,
5587 pull: u64,
5588 ) -> ControlResult<(
5589 gunnar_proto::ReviewState,
5590 Vec<gunnar_proto::SignedForgeEvent>,
5591 )> {
5592 let all = self.forge_events()?;
5593 let mine: Vec<gunnar_core::ForgeEvent> = all
5594 .iter()
5595 .filter(|signed| signed.event.repo == repo)
5596 .map(|signed| signed.event.clone())
5597 .collect();
5598 let state = gunnar_core::forge::fold(pull, &mine);
5599 let events = all
5600 .iter()
5601 .filter(|signed| signed.event.repo == repo && signed.event.pull == pull)
5602 .cloned()
5603 .map(to_proto_event)
5604 .collect();
5605 Ok((self.review_state(repo, &state, &all)?, events))
5606 }
5607
5608 fn forge_reviews(&self, repo: &str) -> ControlResult<Vec<gunnar_proto::ReviewState>> {
5609 let all = self.forge_events()?;
5610 let mine: Vec<gunnar_core::ForgeEvent> = all
5611 .iter()
5612 .filter(|signed| signed.event.repo == repo)
5613 .map(|signed| signed.event.clone())
5614 .collect();
5615 let mut numbers: Vec<u64> = mine.iter().map(|event| event.pull).collect();
5616 numbers.sort_unstable();
5617 numbers.dedup();
5618 let mut out = Vec::new();
5619 for pull in numbers {
5620 let state = gunnar_core::forge::fold(pull, &mine);
5621 out.push(self.review_state(repo, &state, &all)?);
5622 }
5623 Ok(out)
5624 }
5625
5626 fn forge_issue(
5627 &self,
5628 repo: &str,
5629 number: u64,
5630 ) -> ControlResult<
5631 Option<(
5632 gunnar_proto::IssueState,
5633 Vec<gunnar_proto::SignedForgeEvent>,
5634 )>,
5635 > {
5636 let all = self.forge_events()?;
5637 let mine: Vec<gunnar_core::ForgeEvent> = all
5638 .iter()
5639 .filter(|signed| signed.event.repo == repo)
5640 .map(|signed| signed.event.clone())
5641 .collect();
5642 let Some(issue) = gunnar_core::forge::fold_issue(number, &mine) else {
5643 return Ok(None);
5644 };
5645 // The whole thread, in log order: the opening, every comment, and the
5646 // close. An `IssueOpened` carries `pull == 0`, so it is matched by
5647 // position — the Nth opening is issue N — while comments and closes name
5648 // their number directly, exactly as the fold reads them.
5649 let mut opened_seen = 0u64;
5650 let events = all
5651 .iter()
5652 .filter(|signed| {
5653 if signed.event.repo != repo {
5654 return false;
5655 }
5656 match &signed.event.event {
5657 gunnar_core::Event::IssueOpened { .. } => {
5658 opened_seen += 1;
5659 opened_seen == number
5660 }
5661 gunnar_core::Event::IssueCommented { .. }
5662 | gunnar_core::Event::IssueClosed { .. } => signed.event.pull == number,
5663 _ => false,
5664 }
5665 })
5666 .cloned()
5667 .map(to_proto_event)
5668 .collect();
5669 Ok(Some((issue_state(repo, &issue), events)))
5670 }
5671
5672 fn forge_issues(&self, repo: &str) -> ControlResult<Vec<gunnar_proto::IssueState>> {
5673 let mine: Vec<gunnar_core::ForgeEvent> = self
5674 .forge_events()?
5675 .into_iter()
5676 .filter(|signed| signed.event.repo == repo)
5677 .map(|signed| signed.event)
5678 .collect();
5679 Ok(gunnar_core::forge::fold_issues(&mine)
5680 .iter()
5681 .map(|issue| issue_state(repo, issue))
5682 .collect())
5683 }
5684
5685 // ── admin ──────────────────────────────────────────────────────────────
5686
5687 /// **The two numbers an operator is surprised by if nobody reports them.**
5688 ///
5689 /// Tombstoned bytes, because a customer who deletes a repository and sees
5690 /// no change in their usage will call the bill wrong; and replication,
5691 /// because every configured standby on this project received NOTHING from
5692 /// `e31ab8f4` until 2026-08-30 and the only thing that said so was a log
5693 /// line nobody was tailing.
5694 fn stats_extras(&self) -> ControlResult<gunnar_grpc::seam::StatsExtras> {
5695 let tombstones = self.stores.tombstones().unwrap_or_default();
5696 let report = self.replication.report();
5697 // The tenants row, off the same slot the band reads (one implementation). A build
5698 // without `metered`, or a metered one with no slot, counts nothing.
5699 #[allow(unused_mut)]
5700 let mut tenants = crate::dashboard::TenantCounts::default();
5701 #[cfg(feature = "metered")]
5702 if let Some(slot) = &self.entitlement {
5703 tenants = slot.counts();
5704 }
5705 Ok(gunnar_grpc::seam::StatsExtras {
5706 tombstoned_repos: tombstones.len() as u64,
5707 tombstoned_bytes: tombstones.iter().map(|row| row.bytes).sum(),
5708 replication_configured: report.configured,
5709 replication_enqueued: report.enqueued,
5710 replication_synced: report.synced,
5711 replication_failed: report.failed,
5712 replication_bus_gaps: report.bus_gaps,
5713 replication_refs_total: report.refs.total,
5714 replication_refs_unmirrored: report.refs.unmirrored,
5715 standbys: report
5716 .standbys
5717 .into_iter()
5718 .map(|row| gunnar_proto::StandbyStatus {
5719 // The word the console badge prints, decided once here
5720 // (one implementation) rather than re-derived from the booleans by
5721 // every surface that draws a standby.
5722 verdict: row.verdict().word().to_owned(),
5723 name: row.name,
5724 remote: row.remote,
5725 pending: row.pending,
5726 consecutive_failures: row.consecutive_failures,
5727 last_error: row.last_error,
5728 last_success_unix_ms: row.last_success_unix_ms,
5729 needs_full_resync: row.needs_full_resync,
5730 stale: row.stale,
5731 })
5732 .collect(),
5733 tenants_paid: tenants.paid,
5734 tenants_grace: tenants.grace,
5735 tenants_suspended: tenants.suspended,
5736 })
5737 }
5738
5739 fn counts(&self) -> ControlResult<ControlCounts> {
5740 let (namespaces, repos) = self.stores.catalog().counts().map_err(internal)?;
5741 let (principals, keys, grants) = self.stores.auth().counts().map_err(internal)?;
5742 Ok(ControlCounts {
5743 namespaces: namespaces as u64,
5744 repos: repos as u64,
5745 principals: principals as u64,
5746 keys: keys as u64,
5747 grants: grants as u64,
5748 })
5749 }
5750
5751 fn verify_chain(
5752 &self,
5753 request: &gunnar_proto::VerifyChainRequest,
5754 ) -> ControlResult<gunnar_proto::VerifyChainResponse> {
5755 // The SAME walk `gunnar verify` runs (one implementation): `crate::verify` is the
5756 // one implementation, and this method is a projection of its `Chain`
5757 // onto the wire. No verdict is computed here.
5758 let target = match request.target.as_str() {
5759 "" => None,
5760 spec => Some(spec),
5761 };
5762 let chain = if !request.artifact_sha256.is_empty() {
5763 crate::verify::verify_artifact(&self.stores, self, &request.artifact_sha256, target)
5764 } else {
5765 let Some(repo) = &request.repo else {
5766 return Err(ControlError::InvalidArgument(
5767 "name a repository, or an artifact_sha256 to start from".to_owned(),
5768 ));
5769 };
5770 let rev = match request.rev.as_str() {
5771 "" => "HEAD",
5772 rev => rev,
5773 };
5774 crate::verify::verify(&self.stores, self, repo, rev, target)
5775 }
5776 // A walk that cannot START is a refusal with the reason — a missing
5777 // repository, a rev that names nothing, a malformed target. It is
5778 // never an empty chain: six grey rows would render as "merely
5779 // incomplete", which is a pass a console reader would believe.
5780 .map_err(|err| ControlError::FailedPrecondition(format!("{err:#}")))?;
5781 Ok(chain.to_proto())
5782 }
5783
5784 fn blame(
5785 &self,
5786 path: &RepoPath,
5787 rev: &str,
5788 file: &str,
5789 ) -> ControlResult<gunnar_proto::BlameResponse> {
5790 // The SAME walk `gunnar blame` runs (one implementation): `crate::blame::blame` is
5791 // the one implementation, routed here through the store handle the
5792 // running server already holds (`&self.stores`) — never a fresh
5793 // `ServerStores::open`, which would fight the server's exclusive lock.
5794 // That is the whole of it: down the verb takes the lock and cannot
5795 // run while a `serve` holds it; here it runs against the open store.
5796 let rev = if rev.is_empty() { "HEAD" } else { rev };
5797 let rows = crate::blame::blame(&self.stores, self, path, rev, file)
5798 .map_err(|err| ControlError::FailedPrecondition(format!("{err:#}")))?;
5799 Ok(gunnar_proto::BlameResponse {
5800 lines: rows
5801 .into_iter()
5802 .map(|row| gunnar_proto::BlameLine {
5803 commit: row.commit.to_hex().to_string(),
5804 author: row.author,
5805 line: row.line,
5806 })
5807 .collect(),
5808 })
5809 }
5810
5811 fn search(
5812 &self,
5813 path: &RepoPath,
5814 rev: &str,
5815 pattern: &str,
5816 max_file_bytes: u64,
5817 max_matches: u32,
5818 ) -> ControlResult<gunnar_proto::SearchResponse> {
5819 // As `blame` above: one implementation (`crate::search::search`),
5820 // walked live against the running server's open store. A zero ceiling
5821 // means the module's default rather than "no files" / "no matches".
5822 let rev = if rev.is_empty() { "HEAD" } else { rev };
5823 let defaults = crate::search::Limits::default();
5824 let limits = crate::search::Limits {
5825 max_file_bytes: if max_file_bytes == 0 {
5826 defaults.max_file_bytes
5827 } else {
5828 max_file_bytes
5829 },
5830 max_matches: if max_matches == 0 {
5831 defaults.max_matches
5832 } else {
5833 max_matches as usize
5834 },
5835 };
5836 let hits = crate::search::search(&self.stores, self, path, rev, pattern, limits)
5837 .map_err(|err| ControlError::FailedPrecondition(format!("{err:#}")))?;
5838 Ok(gunnar_proto::SearchResponse {
5839 matches: hits
5840 .into_iter()
5841 .map(|hit| gunnar_proto::SearchMatch {
5842 path: hit.path,
5843 line_number: hit.line_number as u32,
5844 line: hit.line,
5845 })
5846 .collect(),
5847 })
5848 }
5849
5850 /// **The PKI document, read** (`Admin.IssuerInfo`) — the ONE
5851 /// implementation, and the same document `gunnar issuer show` prints.
5852 ///
5853 /// Through [`ServerStores::pki_readonly`] and never `pki()`: the handle
5854 /// this opens installs a secret store that refuses every call, so the
5855 /// capability to sign is absent from the value rather than absent from
5856 /// whoever remembered not to call `create` on it. A future edit of this
5857 /// body cannot mint with it.
5858 ///
5859 /// An unreadable document is [`ControlError::Unavailable`] and not an
5860 /// empty answer, for the reason that error exists: "this box has no
5861 /// issuer" and "I could not find out" are different facts, and the first
5862 /// one invites an operator to mint over a CA their whole fleet pins.
5863 fn issuer_info(&self) -> ControlResult<gunnar_proto::IssuerInfoResponse> {
5864 let pki = self.stores.pki_readonly().map_err(|err| {
5865 ControlError::Unavailable(format!("the PKI document could not be opened: {err:#}"))
5866 })?;
5867 // The SAME reader `gunnar issuer show` prints from (one
5868 // implementation): there is no second assembly of this answer, so the
5869 // wire and the disk cannot describe one document differently.
5870 crate::admin::issuer_document(&pki).map_err(|err| {
5871 ControlError::Unavailable(format!("the PKI document could not be read: {err:#}"))
5872 })
5873 }
5874
5875 fn setting(&self, name: &str) -> ControlResult<Option<Vec<u8>>> {
5876 self.stores.settings().get(name).map_err(internal)
5877 }
5878
5879 fn set_setting(&self, name: &str, value: &[u8]) -> ControlResult<Option<Vec<u8>>> {
5880 self.stores.settings().set(name, value).map_err(internal)
5881 }
5882
5883 /// **The audit chain, read as a shape** (`Admin.AuditTrail`, lane T11).
5884 ///
5885 /// Opened fresh from [`ServerStores::audit_path`] rather than reaching for
5886 /// the live `AuditChain` the gate holds: the gate's copy carries an
5887 /// in-memory tip and would answer from it, and the question being asked is
5888 /// about the FILE — whether it still holds what gunnar wrote. So this
5889 /// re-reads and re-verifies, which is also what makes `verified: false`
5890 /// mean something.
5891 ///
5892 /// A chain that will not open is **not** an error: it is `present: false`
5893 /// with `why`, because "this box has no durable trail" is exactly the
5894 /// answer an operator needs from a box whose disk filled or whose trail
5895 /// somebody has edited, and turning it into a failed RPC would hide it.
5896 /// The one thing never done here is to answer an empty chain, which would
5897 /// read as "nothing has happened" instead of "I hold no record".
5898 fn audit_trail(&self, at_seq: u64) -> ControlResult<gunnar_proto::AuditTrailResponse> {
5899 let path = self.stores.audit_path();
5900 // Whether the trail is replicated is a property of this BOX, not of
5901 // the open: a twin and a box mid-restore originate nothing and keep a
5902 // per-box trail, and on a primary this is false only when the server
5903 // already shouted about it at start-up.
5904 let replicated = !self.stores.is_twin()
5905 && !crate::twin::is_restoring(self.stores.root())
5906 && gunnar_core::txlog::sink::log_for(&path)
5907 .is_some_and(|(_, rel)| rel == gunnar_core::txlog::AUDIT_REL);
5908 let mut out = gunnar_proto::AuditTrailResponse {
5909 present: false,
5910 entries: 0,
5911 tip: String::new(),
5912 verified: false,
5913 replicated,
5914 hash_at_seq: String::new(),
5915 why: String::new(),
5916 tip_seq: 0,
5917 };
5918 let chain = match gunnar_core::AuditChain::open(&path) {
5919 Ok(chain) => chain,
5920 Err(err) => {
5921 out.why = format!("{err}");
5922 return Ok(out);
5923 }
5924 };
5925 let entries = match chain.entries() {
5926 Ok(entries) => entries,
5927 Err(err) => {
5928 out.why = format!("{err}");
5929 return Ok(out);
5930 }
5931 };
5932 out.present = true;
5933 out.entries = entries.len() as u64;
5934 if let Some(last) = entries.last() {
5935 out.tip.clone_from(&last.hash);
5936 // Reported, not computed from the count: see the proto comment.
5937 out.tip_seq = last.seq;
5938 }
5939 match chain.verify() {
5940 Ok(_) => out.verified = true,
5941 Err(err) => out.why = format!("{err}"),
5942 }
5943 if at_seq != 0 {
5944 // `seq` is dense from 0, so the entry with this seq is at this
5945 // index — but it is LOOKED UP by `seq` rather than indexed, because
5946 // a chain that has been cut is exactly the case this is asked in
5947 // and an index would answer confidently with the wrong entry.
5948 match entries.iter().find(|e| e.seq == at_seq) {
5949 Some(e) => out.hash_at_seq = e.hash.clone(),
5950 None => {
5951 let sep = if out.why.is_empty() { "" } else { "; " };
5952 out.why = format!(
5953 "{}{sep}this trail holds no entry at seq {at_seq} (it holds {} entries)",
5954 out.why,
5955 entries.len()
5956 );
5957 }
5958 }
5959 }
5960 Ok(out)
5961 }
5962
5963 fn storage_info(&self, path: Option<&RepoPath>) -> ControlResult<StorageInfoResponse> {
5964 let repos = match path {
5965 Some(path) => vec![self.open(path)?.0],
5966 None => self.stores.catalog().list(None).map_err(internal)?,
5967 };
5968 let mut total_hot = 0u64;
5969 let mut total_explode = 0u64;
5970 let mut total_budget = 0u64;
5971 // One registry walk for every open store, rather than one per
5972 // repository: `Admin.StorageInfo` over a server with a thousand
5973 // repositories is already an unbounded recursive `stat` walk
5974 // (`control_plane_starvation`), and adding a per-repository lookup
5975 // inside that loop would make it two.
5976 let bands: std::collections::HashMap<std::path::PathBuf, crate::storage::ExplodeBand> =
5977 self.stores.engine().explode_use().into_iter().collect();
5978 let mut out = Vec::new();
5979 for repo in repos {
5980 let store = self.stores.repo_store(&repo).map_err(|err| {
5981 ControlError::Internal(format!("opening the repository's stores: {err:#}"))
5982 })?;
5983 let (loose, _objects_dir) = object_dir_stats(store.git_dir());
5984 // `hot_bytes` was `object_dir_stats`'s byte count until 2026-09-04
5985 // — a walk of `<git_dir>/objects`, which the znippy engine does not
5986 // have (its store is `<git_dir>/gunnar-git.znippy/`), so every
5987 // appliance repository reported 0 hot bytes. It is now the same
5988 // per-file walk `Usage.Read` bills from (`repo_bytes`, one implementation):
5989 // the whole directory minus LFS, which is what the proto comment
5990 // on `RepoStorage.hot_bytes` always said it was.
5991 let bytes = repo_bytes(store.git_dir()).hot();
5992 let refs = store.serve().refs().map_err(store_error)?.len() as u64;
5993 total_hot += bytes;
5994 // A repository nobody has opened has no band to report, and zero is
5995 // the truth of that: the cache is per open store and an unopened
5996 // one is holding nothing this process can name.
5997 let band = bands.get(store.git_dir()).copied().unwrap_or_default();
5998 total_explode += band.used();
5999 total_budget += band.budget;
6000 out.push(RepoStorage {
6001 repo: Some(RepoPath::new(&repo.namespace, &repo.name)),
6002 hash_kind: hash_kind(repo.hash_kind) as i32,
6003 loose_object_count: loose,
6004 hot_bytes: bytes,
6005 // **Not a measurement.** The znippy engine keeps ONE archive
6006 // per repository (counted in `hot_bytes` with the rest of the
6007 // directory) and has no cold tier; the two fields stay 0 and
6008 // `cold_measured: false` says the 0 is not a reading, so a
6009 // console shows N/A rather than "0 B" (owner, 2026-09-28).
6010 cold_bytes: 0,
6011 cold_object_count: 0,
6012 cold_measured: false,
6013 last_repack_at_unix_ms: 0,
6014 ref_count: refs,
6015 explode_bytes: band.used(),
6016 explode_budget_bytes: band.budget,
6017 explode_evicted_bytes: band.evicted_bytes,
6018 });
6019 }
6020 // The backend strings report what THIS process is running, not a plan
6021 // and not what it could have been built with. There is no "none" arm
6022 // any more: `gunnar-server` refuses to compile without an engine, so
6023 // the engine-free binary that used to answer here cannot be built.
6024 //
6025 // Both strings come off the same `Engine`, because one engine answers
6026 // for objects *and* refs — they were two strings when they were two
6027 // backends, and reporting two different ones now would be a shape the
6028 // storage contract no longer has.
6029 let engine = self.stores.engine();
6030 let (object_backend, ref_backend) = (engine.describe(), engine.describe());
6031 // **Tombstoned bytes are reported here or nowhere.** They are in
6032 // none of the `repos` rows above, because a tombstoned repository has
6033 // no catalog row — which is exactly how the measured 25 GB stayed
6034 // invisible. They are still on the disk and still on the account.
6035 let tombstones = self.stores.tombstones().unwrap_or_default();
6036 let total_tombstoned_bytes = tombstones.iter().map(|row| row.bytes).sum();
6037 let tombstoned = tombstones
6038 .into_iter()
6039 .map(|row| gunnar_proto::TombstonedRepo {
6040 repo: Some(RepoPath {
6041 namespace: row.path.namespace().to_owned(),
6042 repo: row.path.name().to_owned(),
6043 }),
6044 bytes: row.bytes,
6045 deleted_at_unix_ms: row.tombstone.deleted_at_unix_ms,
6046 purge_after_unix_ms: row.tombstone.purge_after_unix_ms.unwrap_or_default(),
6047 })
6048 .collect();
6049 Ok(StorageInfoResponse {
6050 object_backend,
6051 ref_backend,
6052 // The same engine, in parts (name, description, source repository).
6053 engine: Some(engine.report()),
6054 total_hot_bytes: total_hot,
6055 // Not a measurement; see `cold_measured` on each row.
6056 total_cold_bytes: 0,
6057 cold_measured: false,
6058 repos: out,
6059 total_explode_bytes: total_explode,
6060 total_explode_budget_bytes: total_budget,
6061 total_tombstoned_bytes,
6062 tombstoned,
6063 // **The storage reality of the machine.** The appliance has no
6064 // shell, so this call is the only way anyone ever sees which disks
6065 // became `/data`, what was rejected and why, or whether the
6066 // metadata mirror is genuinely on another spindle.
6067 reality: Some(self.stores.storage_reality()),
6068 })
6069 }
6070
6071 /// **"If a tenant's cap rose to N bytes, could you serve it?"** —
6072 /// `Admin.Capacity`, the question monetize must ask before it buys a disk.
6073 ///
6074 /// Three measurements, none of them new (one implementation):
6075 ///
6076 /// * the bytes are the disk gate's own `statvfs` — the SAME one a push is
6077 /// refused under, so the capacity a seller is quoted and the floor a
6078 /// push dies at cannot disagree;
6079 /// * what the tenant already holds is [`Self::usage`]'s `pack_bytes` —
6080 /// the meter `caps["pack_bytes"]` caps, so the delta is computed against
6081 /// the number the quota is actually about;
6082 /// * whether the set can grow is the header band's `/proc/mounts` + md
6083 /// sysfs read;
6084 /// * the box's cores and RAM — product scope, the ceilings a seller may
6085 /// sell up to — are [`crate::capacity::MachineFacts::measure`]: the
6086 /// `available_parallelism` the budgets use and the band's
6087 /// `/proc/meminfo`, absent (never 0) when unreadable.
6088 ///
6089 /// [`crate::capacity::answer`] decides over them, and is pure so that the
6090 /// SEALED arm can be driven on a developer box where gunnar is never PID 1.
6091 ///
6092 /// **No gate wired is `Unimplemented`, not a default floor.** A plane
6093 /// built without [`Self::with_headroom`] does not know the `--disk-floor`
6094 /// in force, and quoting a seller against a floor this server is not
6095 /// enforcing is worse than declining to answer.
6096 fn capacity(
6097 &self,
6098 request: &gunnar_proto::CapacityRequest,
6099 ) -> ControlResult<gunnar_proto::CapacityResponse> {
6100 let Some(gate) = self.headroom.as_ref() else {
6101 return Err(ControlError::Unimplemented(
6102 "Admin.Capacity (no disk-headroom gate is wired on this control plane, so the \
6103 --disk-floor in force is unknown and no quote can be made against it)",
6104 ));
6105 };
6106 // A named namespace must EXIST. `usage` is NotFound for one the
6107 // catalog does not hold, and that error is propagated rather than
6108 // swallowed into "it holds nothing": quoting a raise against the wrong
6109 // tenant's zero is how a seller oversells.
6110 let held_bytes = if request.namespace.is_empty() {
6111 0
6112 } else {
6113 self.usage(Some(&request.namespace))?
6114 .first()
6115 .map(|row| row.pack_bytes)
6116 .unwrap_or(0)
6117 };
6118 let root = gate.root().to_path_buf();
6119 let mut answer = crate::capacity::answer(&crate::capacity::Question {
6120 room: gate.headroom().map_err(|err| err.to_string()),
6121 floor_spec: gate.floor().to_string(),
6122 requested_bytes: request.requested_bytes,
6123 held_bytes,
6124 namespace: request.namespace.clone(),
6125 growth: crate::capacity::GrowthFacts::measure(&root),
6126 machine: crate::capacity::MachineFacts::measure(),
6127 root: root.clone(),
6128 });
6129 // Every disk, only when asked: a seller's quote never pays for the
6130 // mount-table walk (`CapacityRequest.disks`).
6131 if request.disks {
6132 let slow = self.stores.slow();
6133 let serialdat = std::path::PathBuf::from(slow.serialdat());
6134 let mut disks = crate::capacity::measure_disks(&root, Some(&serialdat), gate.floor());
6135 // What is on /serialdat, thing by thing — only the paths that
6136 // actually live under it (a directory configured elsewhere is not
6137 // this mount's).
6138 let log = slow.path_of(crate::slow::LOG_DIR);
6139 let known: Vec<(&str, std::path::PathBuf)> = [
6140 ("metadata copy", slow.path_of(crate::slow::META_DIR)),
6141 ("crash record", slow.path_of(crate::slow::CRASH_DIR)),
6142 ("audit trail", log.join("audit-chain.jsonl")),
6143 ("logs", log),
6144 ("grow journal", serialdat.join("grow-journal")),
6145 ("repository mirror", serialdat.join(crate::slow::REPOS_DIR)),
6146 ]
6147 .into_iter()
6148 .filter(|(_, path)| path.starts_with(&serialdat))
6149 .collect();
6150 let used = disks
6151 .mounts
6152 .iter()
6153 .find(|m| m.labels.iter().any(|l| l == "serialdat") && m.measured)
6154 .map(|m| m.used_bytes);
6155 if disks
6156 .mounts
6157 .iter()
6158 .any(|m| m.labels.iter().any(|l| l == "serialdat"))
6159 {
6160 disks.serialdat = crate::capacity::serialdat_breakdown(&known, used);
6161 }
6162 answer.disks = Some(disks);
6163 }
6164 Ok(answer)
6165 }
6166
6167 // ── the runtime data-set growth (`crate::grow`) ─────────────────────
6168
6169 fn grow_start(
6170 &self,
6171 request: &gunnar_proto::GrowStartRequest,
6172 ) -> ControlResult<gunnar_proto::GrowStatusResponse> {
6173 self.grow_machine()?
6174 .start(request)
6175 .map_err(ControlError::FailedPrecondition)
6176 }
6177
6178 fn grow_status(&self) -> ControlResult<gunnar_proto::GrowStatusResponse> {
6179 match &self.grow {
6180 Some(grow) => Ok(grow.status()),
6181 // No machine wired: the seam's honest default, never Unimplemented
6182 // — see the trait's note on why the credential-free status must
6183 // always answer.
6184 None => Ok(gunnar_proto::GrowStatusResponse {
6185 phase: "idle".to_owned(),
6186 engine_present: false,
6187 serving: true,
6188 server_unix_ms: gunnar_grpc::bus::now_unix_ms(),
6189 detail: "this control plane has no runtime growth wired in".to_owned(),
6190 hotplug: crate::dashboard::probe::pci_hotplug().present,
6191 journal: gunnar_grpc::grow_journal::Summary::measure().map(|s| s.to_wire()),
6192 ..Default::default()
6193 }),
6194 }
6195 }
6196
6197 fn grow_resume(&self, give_up: bool) -> ControlResult<gunnar_proto::GrowStatusResponse> {
6198 self.grow_machine()?
6199 .resume(give_up)
6200 .map_err(ControlError::FailedPrecondition)
6201 }
6202
6203 /// **The meters, per namespace.** See `usage.proto` for what each counts
6204 /// and [`repo_bytes`] for the one walk that attributes a directory.
6205 ///
6206 /// Reads the DISK, not the open handles: `Admin.StorageInfo` opens every
6207 /// repository it reports (`repo_store`) to ask for its ref count, which
6208 /// on a server with a thousand repositories is a thousand store opens for
6209 /// one bill. A bill needs bytes, and bytes are on the disk whether or not
6210 /// the store is open — so this walks `repo_dir` and opens nothing. The one
6211 /// meter that IS about open handles (`objects`) reads the engine's live
6212 /// registry, which is a few `len()` calls and no disk.
6213 ///
6214 /// Tombstones are charged to the namespace the tombstone names, off the
6215 /// same walk `Admin.StorageInfo` and `Admin.Stats` use — a deleted
6216 /// repository's bytes are on the account until the purge, and a bill that
6217 /// forgot them would be the one an operator cannot explain.
6218 fn usage(&self, namespace: Option<&str>) -> ControlResult<Vec<gunnar_proto::NamespaceUsage>> {
6219 // A box growing its own data set has /data unmounted for the remount: its repositories'
6220 // sizes are not readable until it is back — a named, retryable refusal the caller waits
6221 // on, never `Internal: … the repository root does not resolve` (round 4 fresh UpCloud
6222 // mockup: a 502 at the console mid-growth). As `log_fetch` says it.
6223 self.usage_readable()?;
6224 // **One namespace: its own repositories and its own tombstones, walked
6225 // now** (2026-10-06). This used to list the WHOLE catalog and walk the
6226 // WHOLE repository root for tombstones on every call, so a poller
6227 // reading N tenants one by one did O(N²) work (load test: ~25–30 s of
6228 // 1.4 cores on main per 300 s at 1,000 tenants). The usage ledger's
6229 // index answers which repositories are the namespace's; the walk is
6230 // the same `repo_bytes`, so the answer is the same exact reading.
6231 if let Some(name) = namespace {
6232 let measured_at_unix_ms = gunnar_core::now_ms();
6233 let Some((held, _)) = self
6234 .stores
6235 .usage_ledger()
6236 .exact(&self.stores, name)
6237 .map_err(internal)?
6238 else {
6239 return Err(ControlError::NotFound(format!(
6240 "namespace {name} is not in the catalog"
6241 )));
6242 };
6243 let objects = self.open_objects_of(name);
6244 return Ok(vec![self.usage_row(
6245 name,
6246 &held,
6247 objects,
6248 measured_at_unix_ms,
6249 )]);
6250 }
6251 self.usage_all()
6252 }
6253
6254 /// **One namespace's repositories with what each stores** — the same
6255 /// per-repository walk [`Self::usage`] sums, row by row (see the seam).
6256 fn repo_usage(&self, namespace: &str) -> ControlResult<Vec<gunnar_proto::RepoStored>> {
6257 let Some((_, repos)) = self
6258 .stores
6259 .usage_ledger()
6260 .exact(&self.stores, namespace)
6261 .map_err(internal)?
6262 else {
6263 return Err(ControlError::NotFound(format!(
6264 "namespace {namespace} is not in the catalog"
6265 )));
6266 };
6267 let open_objects: std::collections::HashMap<std::path::PathBuf, u64> = self
6268 .stores
6269 .engine()
6270 .open_object_counts()
6271 .into_iter()
6272 .collect();
6273 // Sorted by repository name: the ledger keeps them in a BTreeMap.
6274 Ok(repos
6275 .into_iter()
6276 .map(|(name, bytes, dir)| gunnar_proto::RepoStored {
6277 repo: Some(gunnar_proto::RepoPath::new(namespace, &name)),
6278 stored_bytes: bytes.pack,
6279 cache_bytes: bytes.cache,
6280 lfs_bytes: bytes.lfs,
6281 objects: dir.and_then(|d| open_objects.get(&d).copied()).unwrap_or(0),
6282 })
6283 .collect())
6284 }
6285
6286 /// **One page of every namespace's meters, out of the usage ledger**
6287 /// (`UsageBatch.ReadMany`, 2026-10-06) — re-measuring only what changed
6288 /// since the ledger last looked, and with a cursor answering only the
6289 /// namespaces whose row moved. See `crate::usage_ledger`.
6290 fn usage_page(
6291 &self,
6292 page_token: &str,
6293 page_size: usize,
6294 changed_since: &str,
6295 ) -> ControlResult<gunnar_proto::ReadManyUsageResponse> {
6296 self.usage_readable()?;
6297 let now = gunnar_core::now_ms();
6298 let ledger = self.stores.usage_ledger();
6299 let (cursor, after) = gunnar_grpc::seam::usage_page_token::decode(page_token);
6300 // The cursor is taken BEFORE the settle, on the first page, and carried
6301 // in the token by every later one: a change during the read is answered
6302 // again next time rather than lost.
6303 let cursor = if cursor.is_empty() {
6304 ledger.cursor_now(now)
6305 } else {
6306 cursor
6307 };
6308 let (rows, more, changed_only) = ledger
6309 .page(&self.stores, &after, page_size, changed_since, now)
6310 .map_err(internal)?;
6311 let next_page_token = match (more, rows.last()) {
6312 (true, Some((last, _))) => gunnar_grpc::seam::usage_page_token::encode(&cursor, last),
6313 _ => String::new(),
6314 };
6315 let usages = rows
6316 .iter()
6317 .map(|(name, held)| self.usage_row(name, held, held.objects, now))
6318 .collect();
6319 Ok(gunnar_proto::ReadManyUsageResponse {
6320 usages,
6321 next_page_token,
6322 cursor,
6323 changed_only,
6324 as_of_unix_ms: now,
6325 })
6326 }
6327
6328 /// **The flow series** (`Usage.Activity`) — see the seam for the contract.
6329 ///
6330 /// The window is resolved HERE and not in the handler, because this is the
6331 /// half that knows what this server keeps: `0` for `since` means the whole
6332 /// kept window, `0` for `until` means now, and both ends are clamped to
6333 /// whole UTC days. A window that runs backwards returns nothing rather than
6334 /// an error — it is an empty question, and an empty answer is what it
6335 /// deserves.
6336 ///
6337 /// A named namespace must EXIST, for [`ControlPlane::usage`]'s reason:
6338 /// answering "no activity" for a namespace that is not in the catalog would
6339 /// let a typo read as a silent tenant, which is the one conclusion this
6340 /// whole ledger exists to make reliable.
6341 ///
6342 /// **Today is flushed before it is read.** The live slot holds today, the
6343 /// store holds a copy taken at the last tick, and a series that read only
6344 /// the store would end a minute in the past. Rather than fold the live slot
6345 /// into the answer — a second arithmetic on top of the flush's — the flush
6346 /// is simply run, which is a copy and cannot double a count.
6347 fn activity(
6348 &self,
6349 namespace: &str,
6350 since_unix_ms: i64,
6351 until_unix_ms: i64,
6352 ) -> ControlResult<(Vec<gunnar_proto::DayActivity>, i64)> {
6353 if self
6354 .stores
6355 .catalog()
6356 .namespace(namespace)
6357 .map_err(internal)?
6358 .is_none()
6359 {
6360 return Err(ControlError::NotFound(format!(
6361 "namespace {namespace} is not in the catalog"
6362 )));
6363 }
6364 let now = gunnar_core::now_ms();
6365 let flow = self.stores.flow();
6366 if let Err(err) = flow.flush(now) {
6367 // Not fatal: the store still holds everything up to the last tick,
6368 // and a read that refused because a write failed would be worse
6369 // than a read that is a minute stale and says nothing about it.
6370 tracing::warn!(
6371 target: "gunnar::store",
6372 event = "flow.flush_failed",
6373 %err,
6374 "could not flush the flow ledger before an Activity read; the series is as of \
6375 the last successful flush"
6376 );
6377 }
6378 let today = gunnar_core::day_of(now);
6379 let from = if since_unix_ms == 0 {
6380 today - gunnar_core::RETENTION_DAYS
6381 } else {
6382 gunnar_core::day_of(since_unix_ms)
6383 }
6384 .max(today - gunnar_core::RETENTION_DAYS);
6385 let to = if until_unix_ms == 0 {
6386 today
6387 } else {
6388 gunnar_core::day_of(until_unix_ms)
6389 }
6390 .min(today);
6391 if from > to {
6392 return Ok((Vec::new(), gunnar_core::RETENTION_DAYS));
6393 }
6394 let days = flow
6395 .store()
6396 .series(namespace, from, to)
6397 .map_err(internal)?
6398 .into_iter()
6399 .map(|(day, row)| day_activity(day, &row))
6400 .collect();
6401 Ok((days, gunnar_core::RETENTION_DAYS))
6402 }
6403
6404 /// **The admission plan** (`Usage.AdmitPlan`) — see the seam for the
6405 /// contract. A read for the admin view and the tenant, never on the
6406 /// admission path: the policy is asked exactly as the gate asks it, with
6407 /// the usage the gate would plan with, once per kind of work this server
6408 /// admits (`request` stands for ls-remote and gc).
6409 fn admit_plan(&self, namespace: &str) -> ControlResult<gunnar_proto::AdmitPlanResponse> {
6410 use gunnar_ssh::edda_admit::{Consumer, Work};
6411 use gunnar_ssh::governor::OpClass;
6412 if self
6413 .stores
6414 .catalog()
6415 .namespace(namespace)
6416 .map_err(internal)?
6417 .is_none()
6418 {
6419 return Err(ControlError::NotFound(format!(
6420 "namespace {namespace} is not in the catalog"
6421 )));
6422 }
6423 let Some(gate) = &self.admission else {
6424 return Err(ControlError::Unimplemented("Usage.AdmitPlan"));
6425 };
6426 let policy = gate.admit_policy();
6427 let usage = gate.admit_usage(&*policy, namespace);
6428 let limit = policy.limit();
6429 let who = Consumer::Namespace(namespace.to_owned());
6430 let estimates = policy.estimates();
6431 let kinds = [
6432 OpClass::Clone,
6433 OpClass::Fetch,
6434 OpClass::Push,
6435 OpClass::LsRemote,
6436 ]
6437 .into_iter()
6438 .map(|class| {
6439 let kind = gunnar_ssh::admission::work_kind(class);
6440 let plan = policy.plan(&Work::new(kind, None), &who, &usage);
6441 gunnar_proto::AdmitPlanKind {
6442 kind: kind.name().to_owned(),
6443 admit_class: plan.class.name().to_owned(),
6444 why: plan.why,
6445 estimate_bytes: plan.estimate.usage_bytes(),
6446 estimate_cpu_ms: plan.estimate.cpu_ms,
6447 samples: estimates
6448 .iter()
6449 .find(|row| row.kind == kind)
6450 .map_or(0, |row| row.samples),
6451 }
6452 })
6453 .collect();
6454 Ok(gunnar_proto::AdmitPlanResponse {
6455 policy: policy.policy().to_owned(),
6456 window_days: usage.window_days,
6457 used_bytes: usage.bytes,
6458 limit_bytes: limit.map(|l| l.bytes),
6459 budget_left_bytes: limit.map(|l| l.bytes.saturating_sub(usage.bytes)),
6460 kinds,
6461 })
6462 }
6463
6464 // ── the metered appliance: `Entitlement` ────────────────────────────────
6465
6466 #[cfg(feature = "metered")]
6467 fn entitlement_get(
6468 &self,
6469 namespace: &str,
6470 ) -> ControlResult<Option<gunnar_proto::EntitlementFact>> {
6471 self.entitlement()?
6472 .get(namespace)
6473 .map_err(|err| ControlError::Internal(format!("{err:#}")))
6474 }
6475
6476 #[cfg(feature = "metered")]
6477 fn entitlement_list(&self) -> ControlResult<Vec<gunnar_proto::EntitlementFact>> {
6478 self.entitlement()?
6479 .list()
6480 .map_err(|err| ControlError::Internal(format!("{err:#}")))
6481 }
6482
6483 #[cfg(feature = "metered")]
6484 fn entitlement_refused_at_boot(&self) -> ControlResult<Option<gunnar_proto::RefusedAtBoot>> {
6485 Ok(self.entitlement()?.refusal())
6486 }
6487
6488 /// The refusals, by kind: a forged fact is `PermissionDenied` (the caller
6489 /// held the admin grant and still may not move a plan without monetize's
6490 /// signature), a malformed one `InvalidArgument`, and one this box could
6491 /// not record `FailedPrecondition` — verified, and still not enforced,
6492 /// because a plan the log does not show is not a plan.
6493 #[cfg(feature = "metered")]
6494 fn entitlement_set(
6495 &self,
6496 fact: gunnar_proto::EntitlementFact,
6497 ) -> ControlResult<(gunnar_proto::EntitlementState, u64)> {
6498 use crate::entitlement::SetRefused;
6499 let (state, seq) = self.entitlement()?.set(fact).map_err(|err| match err {
6500 SetRefused::Forged { .. } => ControlError::PermissionDenied(err.to_string()),
6501 // **`PermissionDenied` and not `FailedPrecondition`, deliberately.**
6502 // monetize's poller treats a refusal as final and stops retrying
6503 // that fact, and that is the right answer here: a fact this box has
6504 // moved past will never become acceptable, so retrying it for ever
6505 // would be a hot loop against a decision that is not going to
6506 // change. It is the same code as a forged fact because it is the
6507 // same sentence — this caller may not move this plan with this
6508 // fact — and the words say which of the two it was.
6509 SetRefused::Replayed { .. }
6510 | SetRefused::Unstamped { .. }
6511 | SetRefused::Behind { .. } => ControlError::PermissionDenied(err.to_string()),
6512 SetRefused::Malformed(_) => ControlError::InvalidArgument(err.to_string()),
6513 SetRefused::NotRecorded { .. } => ControlError::FailedPrecondition(err.to_string()),
6514 SetRefused::NoKey { .. } => ControlError::FailedPrecondition(err.to_string()),
6515 })?;
6516 Ok((crate::entitlement::state_to_proto(state), seq))
6517 }
6518
6519 // ── the metered appliance: `Billing` ────────────────────────────────────
6520 //
6521 // The key is read out of the sealed secret store on every call rather than
6522 // cached on this struct. Both verbs are human-paced — one is an operator
6523 // reading a string to paste into a configuration, the other is a customer
6524 // clicking renew — so a redb read and an OpenSSH parse are free at this
6525 // rate, and a cached key is a copy that has to be invalidated the day the
6526 // key is rolled. `ServerStores::actor_ticket_key` mints on first use, so
6527 // the FIRST of these calls on a fresh appliance is what brings the key into
6528 // existence; nothing has to run at boot for this to work.
6529
6530 #[cfg(feature = "metered")]
6531 fn actor_ticket_public_key_hex(&self) -> ControlResult<String> {
6532 Ok(self
6533 .stores
6534 .actor_ticket_key()
6535 .map_err(|err| ControlError::Internal(format!("{err:#}")))?
6536 .public_key_hex()
6537 .to_owned())
6538 }
6539
6540 #[cfg(feature = "metered")]
6541 fn actor_ticket_sign(&self, message: &[u8]) -> ControlResult<[u8; 64]> {
6542 Ok(self
6543 .stores
6544 .actor_ticket_key()
6545 .map_err(|err| ControlError::Internal(format!("{err:#}")))?
6546 .sign(message))
6547 }
6548
6549 // The named sealed slot (2026-10-09): stored through the sealed, logged
6550 // secret store; a twin or a box being restored refuses the write as every
6551 // logged write is refused there.
6552 #[cfg(feature = "metered")]
6553 fn sealed_get(&self, name: &str) -> ControlResult<Option<(String, Vec<u8>)>> {
6554 self.stores
6555 .sealed_slot_get(name)
6556 .map_err(|err| ControlError::Internal(format!("{err:#}")))
6557 }
6558
6559 #[cfg(feature = "metered")]
6560 fn sealed_put(&self, name: &str, owner: &str, value: &[u8]) -> ControlResult<bool> {
6561 self.stores
6562 .sealed_slot_put(name, owner, value)
6563 .map_err(|err| ControlError::Unavailable(format!("{err:#}")))
6564 }
6565
6566 // ── mirror ─────────────────────────────────────────────────────────────
6567
6568 /// **Register an upstream.** Bookkeeping only — no fetch happens here.
6569 ///
6570 /// `create_repo` is honoured before the registration, because a mirror of a
6571 /// repository that does not exist is a registration nothing can ever act
6572 /// on. Without the flag the repository must already be there, and a missing
6573 /// one is `NotFound` rather than a silently created empty one.
6574 fn mirror_add(
6575 &self,
6576 path: &RepoPath,
6577 remote_url: &str,
6578 prune: bool,
6579 create_repo: bool,
6580 ) -> ControlResult<MirrorConfig> {
6581 if remote_url.trim().is_empty() {
6582 return Err(ControlError::InvalidArgument(
6583 "a mirror needs a remote URL".to_owned(),
6584 ));
6585 }
6586 // **The destination, before it is written down.** `Mirror.Add` reaches
6587 // every enrolled principal — `owns_account` makes a caller intrinsically
6588 // Admin in its own namespace — so without this the verb is an
6589 // arbitrary outbound request from the server's network position,
6590 // authorised by having a key. See `mirror_fetch::check_destination`.
6591 // Refused at REGISTRATION rather than only on the first sync, so the
6592 // operator's mistake and the attacker's are both answered on the call
6593 // that made them.
6594 crate::mirror_fetch::classify(remote_url).map_err(ControlError::InvalidArgument)?;
6595 crate::mirror_fetch::check_destination(remote_url, self.stores.root())
6596 .map_err(ControlError::InvalidArgument)?;
6597 let repo_ref = Self::repo_ref(path)?;
6598 // **The repository is NOT created here** (owner order 2026-10-18).
6599 // Its object format and its default branch are the upstream's, and
6600 // only the upstream can say them: an ssh upstream is never asked here,
6601 // and a private one answers nothing before its credential is set — so
6602 // creating it now meant a guess (sha256, `main`) that a sha1 upstream
6603 // could never be fetched into, and an empty private repository left
6604 // behind by every import that failed. The registration says the
6605 // repository is to come, and the first sync that reaches the upstream
6606 // creates it (`mirror_fetch::sync_once`).
6607 let creates = create_repo && self.get_repo(path).is_err();
6608 if creates {
6609 // The disk, refused now by the gate `Repos.Create` asks, rather
6610 // than on a job minutes later.
6611 if let Some(line) = self.headroom.as_ref().and_then(|gate| gate.refusal()) {
6612 return Err(ControlError::ResourceExhausted(line));
6613 }
6614 if self
6615 .stores
6616 .catalog()
6617 .namespace(path.namespace.as_str())
6618 .map_err(internal)?
6619 .is_none()
6620 {
6621 return Err(ControlError::NotFound(format!(
6622 "namespace {}",
6623 path.namespace
6624 )));
6625 }
6626 } else {
6627 // Opened rather than merely catalogued: a registration against a
6628 // repository whose store cannot be opened would look fine and fail
6629 // on the first sync.
6630 self.open(path)?;
6631 }
6632 let mut record = gunnar_core::MirrorRecord::new(
6633 &repo_ref,
6634 remote_url,
6635 prune,
6636 gunnar_grpc::bus::now_unix_ms(),
6637 );
6638 record.creates_repo = creates;
6639 let stored = self
6640 .stores
6641 .mirrors()
6642 .set(&repo_ref, record)
6643 .map_err(internal)?;
6644 Ok(mirror_out(&stored))
6645 }
6646
6647 /// **The one verb that genuinely needs the client half**, and the only one
6648 /// still refused.
6649 ///
6650 /// The original refusal covered all four: *"mirroring runs over
6651 /// gunnar-client (nornir-git ls-remote + fetch), which an S crate must
6652 /// never link (the dependency budget)"*. That is true of a sync and of nothing else — adding,
6653 /// listing and removing a registration are a document write, a document
6654 /// read and a document delete. Narrowing the gap to the verb that has it is
6655 /// the difference between "mirroring is unimplemented" and "this build
6656 /// cannot fetch".
6657 /// **Enqueue one sync, and return without syncing.**
6658 ///
6659 /// The refusal this replaced covered all four verbs and then just this
6660 /// one: *"mirroring runs over gunnar-client (nornir-git ls-remote + fetch),
6661 /// which an S crate must never link (the dependency budget)"*. That was true while the only
6662 /// fetch client in the constellation needed `gix`, a `.git` directory and a
6663 /// worktree — none of which exist on an appliance running as PID 1 over
6664 /// znippy. `gunnar_wire::fetch_pack` is a fetch client with none of those
6665 /// requirements, over the same `BufRead`/`Write` pair `upload_pack`
6666 /// already answers on, so the gap is closed rather than narrowed again.
6667 ///
6668 /// **Registration first**, so an unregistered repository is `NotFound` by
6669 /// name and not a job that fails four minutes later — two very different
6670 /// things to tell an operator, and only one of them is their mistake.
6671 ///
6672 /// The upstream and `prune` are read HERE and carried on the order, because
6673 /// they are what the caller asked about; the credential is read on the
6674 /// worker (see [`CoreControlPlane::mirror_queue`]) because it is a secret
6675 /// and an order is `Debug`.
6676 fn mirror_sync(&self, path: &RepoPath) -> ControlResult<MirrorConfig> {
6677 let record = self.mirror_record(path)?;
6678 let repo_ref = Self::repo_ref(path)?;
6679 // Refused at the DOOR rather than on the queue: a remote gunnar cannot
6680 // dial is the caller's mistake and it is knowable now, and a job that
6681 // sat QUEUED and then failed on the shape of a URL is a console button
6682 // that went down for nothing.
6683 crate::mirror_fetch::classify(&record.remote_url).map_err(ControlError::InvalidArgument)?;
6684 // Same door, same reason: a registration that predates the destination
6685 // rule must not become a dial by being clicked.
6686 crate::mirror_fetch::check_destination(&record.remote_url, self.stores.root())
6687 .map_err(ControlError::InvalidArgument)?;
6688 let job = self.mirror.enqueue(crate::mirror_queue::MirrorOrder {
6689 path: repo_ref.key(),
6690 upstream: record.remote_url.clone(),
6691 prune: record.prune,
6692 });
6693 let mut out = mirror_out(&record);
6694 out.job = Some(mirror_job_out(path, job));
6695 Ok(out)
6696 }
6697
6698 /// **Seal the credential first, then record its kind.**
6699 ///
6700 /// That order is the correctness argument. A document that claims a
6701 /// credential the secret store does not hold makes every later sync fail
6702 /// with "no credential stored" against an operator who can see one listed;
6703 /// a sealed value with no document pointing at it is unreachable and
6704 /// harmless. So the value is written before the fact, and forgotten after
6705 /// it.
6706 ///
6707 /// The value never comes back out of this function. `mirror_out` has no
6708 /// field for it, `MirrorConfig` has no field for it, and the two error
6709 /// paths below quote the repository and the kind and nothing else.
6710 fn mirror_set_credential(
6711 &self,
6712 path: &RepoPath,
6713 kind: gunnar_proto::MirrorCredentialKind,
6714 secret: &str,
6715 username: &str,
6716 ) -> ControlResult<MirrorConfig> {
6717 gunnar_grpc::seam::credential_arguments(kind, secret)?;
6718 self.mirror_record(path)?;
6719 let repo_ref = Self::repo_ref(path)?;
6720
6721 let kind = credential_kind_in(kind);
6722 let secrets = self.stores.secrets().map_err(|err| {
6723 ControlError::Internal(format!("the sealed secret store is not available: {err:#}"))
6724 })?;
6725 let key = gunnar_core::credential_secret_key(&repo_ref);
6726 let stored = if kind.is_set() {
6727 gunnar_store::SecretStore::put(&secrets, &key, secret.as_bytes())
6728 } else {
6729 gunnar_store::SecretStore::delete(&secrets, &key)
6730 };
6731 stored.map_err(|err| {
6732 // `{err}` and not the value: this string reaches a client.
6733 ControlError::Internal(format!(
6734 "the mirror credential for {path} could not be sealed: {err}"
6735 ))
6736 })?;
6737
6738 let record = self
6739 .stores
6740 .mirrors()
6741 .set_credential(&repo_ref, kind, username)
6742 .map_err(internal)?
6743 .ok_or_else(|| ControlError::NotFound(format!("no mirror registered for {path}")))?;
6744 Ok(mirror_out(&record))
6745 }
6746
6747 fn mirror_status(
6748 &self,
6749 namespace: Option<&str>,
6750 path: Option<&RepoPath>,
6751 ) -> ControlResult<Vec<MirrorConfig>> {
6752 let all = self.stores.mirrors().all().map_err(internal)?;
6753 Ok(all
6754 .iter()
6755 .filter(|record| match path {
6756 Some(want) => record.repo == format!("{}/{}", want.namespace, want.repo),
6757 None => true,
6758 })
6759 .filter(|record| match namespace {
6760 Some(want) => record
6761 .repo
6762 .split_once('/')
6763 .is_some_and(|(ns, _)| ns == want),
6764 None => true,
6765 })
6766 .map(|record| {
6767 let mut out = mirror_out(record);
6768 // **The job belongs on the listing**, which is what makes
6769 // `Mirror.Status` the poll a console uses: state, queue
6770 // position, progress, error and report, in the `GcStatus`
6771 // shape. A separate `MirrorSyncStatus` verb would have been a
6772 // second door onto one fact.
6773 out.job = out
6774 .repo
6775 .as_ref()
6776 .zip(self.mirror.status(&record.repo))
6777 .map(|(path, job)| mirror_job_out(path, job));
6778 // **A failure outlives its job** (import-big, 2026-10-26). The
6779 // queue forgets a job at a restart and after its retention, and
6780 // the registration's `last_error` does not: when the queue holds
6781 // nothing, the last failure is shown as the FAILED job it was,
6782 // so the repository page and the mirrors list say why an import
6783 // is empty instead of "idle".
6784 if out.job.is_none() && !record.last_error.is_empty() {
6785 out.job = out.repo.clone().map(|path| gunnar_proto::MirrorJob {
6786 job_id: String::new(),
6787 path: Some(path),
6788 state: gunnar_proto::MirrorJobState::Failed as i32,
6789 error: record.last_error.clone(),
6790 ..Default::default()
6791 });
6792 }
6793 out
6794 })
6795 .collect())
6796 }
6797
6798 /// **Un-register an upstream, and forget its credential with it.**
6799 ///
6800 /// The registration goes first and the sealed value second, and the order
6801 /// is the correctness argument — the mirror image of
6802 /// [`Self::mirror_set_credential`]'s. That function writes the value before
6803 /// the document so a document can never claim a credential the store does
6804 /// not hold; this one removes the document before the value for the same
6805 /// reason read backwards. A sealed secret with no document pointing at it
6806 /// is unreachable and harmless; a document with no secret makes every later
6807 /// sync fail with "no credential stored" against an operator who can see
6808 /// one listed. So the failure mode of a crash between the two lines is the
6809 /// harmless one, and the `NotFound` path deletes nothing.
6810 ///
6811 /// Before 2026-09-11 the value was simply left behind: `Mirror.Remove` took
6812 /// the document away and the ssh private key or forge token stayed in the
6813 /// sealed store for the life of the box, with nothing on the control plane
6814 /// naming it or able to name it — un-listable, un-rotatable, and still
6815 /// decryptable by anything that reaches the KEK. An operator who removes a
6816 /// mirror because its credential leaked has been told the credential is
6817 /// gone.
6818 fn mirror_remove(&self, path: &RepoPath) -> ControlResult<()> {
6819 self.mirror_record(path)?;
6820 let repo_ref = Self::repo_ref(path)?;
6821 if !self.stores.mirrors().remove(&repo_ref).map_err(internal)? {
6822 return Err(ControlError::NotFound(format!(
6823 "no mirror registered for {path}"
6824 )));
6825 }
6826 let secrets = self.stores.secrets().map_err(|err| {
6827 ControlError::Internal(format!("the sealed secret store is not available: {err:#}"))
6828 })?;
6829 gunnar_store::SecretStore::delete(&secrets, &gunnar_core::credential_secret_key(&repo_ref))
6830 .map_err(|err| {
6831 // `{err}` and not the value: this string reaches a client. The
6832 // registration IS gone by now, so this says exactly that rather
6833 // than reading as "nothing happened".
6834 ControlError::Internal(format!(
6835 "the registration for {path} was removed and its sealed credential could \
6836 not be deleted: {err}. The secret is still on this box; remove it before \
6837 trusting that it is gone."
6838 ))
6839 })?;
6840 Ok(())
6841 }
6842
6843 // ── replication: the three verbs the appliance is configured with ──────
6844
6845 #[cfg(feature = "replication")]
6846 fn replication_set(
6847 &self,
6848 target: gunnar_proto::ReplicationTarget,
6849 ) -> ControlResult<gunnar_proto::ReplicationStatusResponse> {
6850 let slot = self.replication_slot()?;
6851 let mut target = crate::replication_wire::target_from_proto(target)?;
6852 // **The key the appliance pushes with, minted ON THE BOX.**
6853 //
6854 // An operator who names no identity is not asking for the client key
6855 // ladder — on an image with no home directory that ladder finds nothing
6856 // and every push fails on authentication forever. They are asking for
6857 // "use this box's own key", and there was no way to put one there. So
6858 // the server mints one, keeps the private half, and hands back the
6859 // public line for them to authorise on the standby. See
6860 // `replication_wire::REPLICATION_KEY`.
6861 let minted_public = if target.identity.is_none() {
6862 let (path, public) = crate::replication_wire::ensure_identity(self.stores.root())?;
6863 target.identity = Some(path);
6864 Some(public)
6865 } else {
6866 None
6867 };
6868 // The public-only requirements that depend on the key, now that there
6869 // IS one. Checked here rather than in `target_from_proto` because that
6870 // runs before the mint — and refusing a target for the absence of a key
6871 // this function is about to create is how the browse replica's own
6872 // documented one-liner became unrunnable on an appliance.
6873 crate::replication_wire::require_public_only_identity(&target)?;
6874 let mut config = slot
6875 .config()
6876 .map_err(|err| ControlError::Internal(format!("{err:#}")))?;
6877 // `set_target` REPLACES by name. That is what makes the verb
6878 // idempotent, and idempotent is what an operator on a flaky link needs:
6879 // a retried `SetReplication` must not leave two standbys behind.
6880 config.set_target(target);
6881 slot.apply(config)
6882 .map_err(|err| ControlError::Internal(format!("{err:#}")))?;
6883 let mut out = self.replication_snapshot(false)?;
6884 out.identity_public_key = minted_public.unwrap_or_default();
6885 Ok(out)
6886 }
6887
6888 #[cfg(feature = "replication")]
6889 fn replication_remove(
6890 &self,
6891 name: &str,
6892 ) -> ControlResult<gunnar_proto::ReplicationStatusResponse> {
6893 let slot = self.replication_slot()?;
6894 let mut config = slot
6895 .config()
6896 .map_err(|err| ControlError::Internal(format!("{err:#}")))?;
6897 if !config.remove_target(name) {
6898 // NotFound and not a silent success. "I removed the standby" about a
6899 // standby that was never there reads identically to "I removed the
6900 // standby" about the one you meant, and the difference is whether
6901 // your data is still going somewhere.
6902 return Err(ControlError::NotFound(format!(
6903 "no replication target named {name:?}"
6904 )));
6905 }
6906 slot.apply(config)
6907 .map_err(|err| ControlError::Internal(format!("{err:#}")))?;
6908 self.replication_snapshot(false)
6909 }
6910
6911 // ── The transaction log: what the twin fetches ──────────────────────────
6912
6913 fn is_group_member(&self, principal_id: &str, group: &str) -> ControlResult<bool> {
6914 self.stores
6915 .auth()
6916 .groups_of(principal_id)
6917 .map(|groups| groups.iter().any(|g| g == group))
6918 .map_err(|err| ControlError::Unavailable(format!("reading group membership: {err}")))
6919 }
6920
6921 fn become_twin(&self, primary: &str) -> ControlResult<String> {
6922 let key = crate::twin::become_twin(&self.stores, primary)
6923 .map_err(|err| ControlError::FailedPrecondition(format!("{err:#}")))?;
6924 // Following starts now, not at the next boot: the key is enrolled on
6925 // the primary next, and the loop retries until it is.
6926 crate::twin::start_follower(Arc::clone(&self.stores));
6927 Ok(key)
6928 }
6929
6930 #[cfg(feature = "wg-client")]
6931 fn assign_tunnel_address(
6932 &self,
6933 address: &str,
6934 ) -> ControlResult<gunnar_proto::AssignTunnelAddressResponse> {
6935 let window_open = self.stores.auth().bootstrap_window_open().map_err(|err| {
6936 ControlError::Unavailable(format!("reading the bootstrap window: {err}"))
6937 })?;
6938 crate::appliance::tunnel_address_answer(self.stores.root(), address, window_open)
6939 }
6940
6941 #[cfg(feature = "wg-client")]
6942 fn set_tunnel_address(
6943 &self,
6944 address: &str,
6945 ) -> ControlResult<gunnar_proto::AssignTunnelAddressResponse> {
6946 // `Admin.SetTunnelAddress`: the service already held the caller to
6947 // server-wide Admin, which is what the open window stands in for on
6948 // the unauthenticated door.
6949 crate::appliance::tunnel_address_answer(self.stores.root(), address, true)
6950 }
6951
6952 fn twin_csr(&self) -> ControlResult<String> {
6953 crate::twin_tls::request(self.stores.root())
6954 .map_err(|err| ControlError::FailedPrecondition(format!("{err:#}")))
6955 }
6956
6957 fn install_twin_certificate(
6958 &self,
6959 cert_pem: &str,
6960 ca_pem: &str,
6961 replication_port: u16,
6962 replication_host: &str,
6963 ) -> ControlResult<i64> {
6964 let host = (!replication_host.is_empty()).then_some(replication_host);
6965 crate::twin_tls::install_at(self.stores.root(), cert_pem, ca_pem, replication_port, host)
6966 .map(|expiry| expiry.days_left)
6967 .map_err(|err| ControlError::FailedPrecondition(format!("{err:#}")))
6968 }
6969
6970 fn issue_twin_certificate(
6971 &self,
6972 principal: &str,
6973 csr_pem: &str,
6974 ) -> ControlResult<(String, String, u16, String)> {
6975 crate::twin_tls::issue_for_twin(&self.stores, principal, csr_pem)
6976 .map(|(cert, ca, port)| {
6977 (
6978 cert,
6979 ca,
6980 port,
6981 crate::twin_tls::replication_host(self.stores.root()).unwrap_or_default(),
6982 )
6983 })
6984 .map_err(|err| ControlError::FailedPrecondition(format!("{err:#}")))
6985 }
6986
6987 fn twin_git_listener_needs_restart(&self) -> bool {
6988 crate::twin::git_listener_needs_restart(&self.stores)
6989 }
6990
6991 fn restore_begin(&self, owner: &str) -> ControlResult<u64> {
6992 crate::twin::restore_begin_by(&self.stores, owner).map_err(|err| {
6993 // Somebody else's restore is a NO to a known caller, not a box in the wrong state.
6994 if err.downcast_ref::<crate::twin::RestoreNotYours>().is_some() {
6995 ControlError::PermissionDenied(format!("{err:#}"))
6996 } else {
6997 ControlError::FailedPrecondition(format!("{err:#}"))
6998 }
6999 })
7000 }
7001
7002 /// The keys handed in by `serve` (`--restore-pubkey`), then the admin-grade keys the
7003 /// installer's door fed this box ([`crate::fed_principals::restore_keys_at`]). Read on
7004 /// every call: a feed that arrives while the box runs counts from the next stream on.
7005 fn restore_keys(&self) -> ControlResult<Vec<String>> {
7006 #[allow(unused_mut)]
7007 let mut keys = self.restore_keys.clone();
7008 #[cfg(feature = "wg-client")]
7009 keys.extend(
7010 crate::fed_principals::restore_keys_at(self.stores.root())
7011 .map_err(ControlError::Unavailable)?,
7012 );
7013 keys.sort();
7014 keys.dedup();
7015 Ok(keys)
7016 }
7017
7018 fn restore_room(&self, announced_bytes: u64) -> ControlResult<()> {
7019 crate::twin::room_for_restore(self.stores.root(), announced_bytes)
7020 .map_err(|err| ControlError::FailedPrecondition(format!("{err:#}")))
7021 }
7022
7023 /// **The server's intake** (restore-large-entry, 2026-10-07): an entry larger than one piece
7024 /// is staged on this box's disk as it arrives and survives the stream ending inside it
7025 /// ([`crate::twin::intake`]).
7026 fn restore_intake<'a>(
7027 &'a self,
7028 owner: &str,
7029 ) -> ControlResult<Option<Box<dyn gunnar_grpc::RestoreIntake + 'a>>> {
7030 match crate::twin::intake::Intake::open(&self.stores, owner) {
7031 Ok(Some(intake)) => Ok(Some(Box::new(intake))),
7032 Ok(None) => Ok(None),
7033 // Somebody else's restore is a NO to a known caller, not a box in the wrong state.
7034 Err(err) if err.downcast_ref::<crate::twin::RestoreNotYours>().is_some() => {
7035 Err(ControlError::PermissionDenied(format!("{err:#}")))
7036 }
7037 Err(err) => Err(ControlError::FailedPrecondition(format!("{err:#}"))),
7038 }
7039 }
7040
7041 fn restore_apply(&self, entry: gunnar_proto::LogEntry) -> ControlResult<bool> {
7042 let e = gunnar_core::txlog::Entry {
7043 seq: entry.seq,
7044 kind: entry.kind as u16,
7045 meta: entry.meta,
7046 blob: entry.blob,
7047 };
7048 match crate::twin::restore_apply(&self.stores, &e) {
7049 Ok(crate::twin::Applied::Lost(_)) => Ok(true),
7050 Ok(_) => Ok(false),
7051 Err(err) => Err(ControlError::Internal(format!(
7052 "restoring seq {}: {err:#}",
7053 e.seq
7054 ))),
7055 }
7056 }
7057
7058 fn restore_finish(&self, announced_head: u64, log_id_of_source: &str) -> ControlResult<u64> {
7059 let head =
7060 crate::twin::restore_finish_streamed(&self.stores, announced_head, log_id_of_source)
7061 .map_err(|err| ControlError::FailedPrecondition(format!("{err:#}")))?;
7062 // c-no-restart: the restored box restarts ITSELF (the appliance's PID 1).
7063 crate::twin::restart_after_restore();
7064 Ok(head)
7065 }
7066
7067 fn snapshot_stream(
7068 &self,
7069 sink: &mut dyn FnMut(gunnar_proto::LogChunk) -> ControlResult<()>,
7070 ) -> ControlResult<u64> {
7071 self.snapshot_stream_from(0, 0, "", sink)
7072 }
7073
7074 /// **A snapshot from item `from_item`, byte `from_offset` on** (restore-after-snapshot,
7075 /// 2026-10-07): a twin's kept base, every piece with its item, offset, the item's crc on its
7076 /// last and the base's id ([`crate::twin::snapshot::serve_from`]); a primary's own snapshot,
7077 /// a new cut each time, with none. A base that changed is `[snapshot-changed]`.
7078 fn snapshot_stream_from(
7079 &self,
7080 from_item: u64,
7081 from_offset: u64,
7082 expect_id: &str,
7083 sink: &mut dyn FnMut(gunnar_proto::LogChunk) -> ControlResult<()>,
7084 ) -> ControlResult<u64> {
7085 // The one copy a piece takes: out of the producer's buffer into the wire message.
7086 let mut pieces = |p: crate::twin::snapshot::Piece<'_>,
7087 pos: Option<crate::twin::snapshot::PiecePos<'_>>|
7088 -> anyhow::Result<()> {
7089 sink(gunnar_proto::LogChunk {
7090 seq: 0,
7091 kind: u32::from(p.kind),
7092 meta: p.meta.to_vec(),
7093 blob_len: p.blob_len,
7094 blob: p.blob.to_vec(),
7095 last: p.last,
7096 restore_head: 0,
7097 restore_bytes: 0,
7098 log_id: String::new(),
7099 offset: pos.map(|q| q.offset),
7100 entry_crc: pos.and_then(|q| q.crc),
7101 snapshot_item: pos.map(|q| q.item),
7102 snapshot_id: pos.map(|q| q.id.to_owned()).unwrap_or_default(),
7103 })
7104 .map_err(|err| anyhow::anyhow!("{err}"))
7105 };
7106 crate::twin::snapshot::serve_from(
7107 &self.stores,
7108 from_item,
7109 from_offset,
7110 expect_id,
7111 &mut pieces,
7112 )
7113 .map_err(|err| {
7114 match err.downcast_ref::<crate::twin::snapshot::SnapshotChanged>() {
7115 Some(changed) => ControlError::FailedPrecondition(changed.to_string()),
7116 None => ControlError::Internal(format!("producing a snapshot: {err:#}")),
7117 }
7118 })
7119 }
7120
7121 fn log_retain(&self, from: u64) -> ControlResult<gunnar_proto::LogHeadResponse> {
7122 let log = self.stores.txlog();
7123 if log.is_follower() {
7124 return Err(ControlError::FailedPrecondition(
7125 "retention: a twin's log is the primary's numbers; retain on the primary".into(),
7126 ));
7127 }
7128 let from = if from == 0 { log.head_seq() + 1 } else { from };
7129 let first = log.retain_from(from).map_err(|err| {
7130 ControlError::Unavailable(format!("retaining the transaction log from {from}: {err}"))
7131 })?;
7132 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");
7133 self.log_head()
7134 }
7135
7136 fn log_layout(&self) -> ControlResult<Vec<(u64, u64, u64)>> {
7137 let log = self.stores.txlog();
7138 if log.is_follower() {
7139 return Err(ControlError::FailedPrecondition(
7140 "retention: a twin's log is the primary's numbers; retain on the primary".into(),
7141 ));
7142 }
7143 Ok(log.layout())
7144 }
7145
7146 fn data_volume(&self) -> Option<(u64, u64)> {
7147 // While the set is released for a growth, statvfs of the data root reads the box's
7148 // root filesystem: no number is better than that one.
7149 if self.stores.txlog().is_released() {
7150 return None;
7151 }
7152 let vfs = rustix::fs::statvfs(self.stores.root()).ok()?;
7153 let total = vfs.f_blocks.saturating_mul(vfs.f_frsize);
7154 let free = vfs.f_bfree.saturating_mul(vfs.f_frsize);
7155 (total > 0).then(|| (total, total.saturating_sub(free)))
7156 }
7157
7158 fn twin_owed_read(&self) -> ControlResult<Vec<u8>> {
7159 match std::fs::read(self.stores.root().join(crate::twin::TWIN_OWED_FILE)) {
7160 Ok(b) => Ok(b),
7161 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
7162 Err(e) => Err(ControlError::Unavailable(format!(
7163 "reading {}: {e}",
7164 crate::twin::TWIN_OWED_FILE
7165 ))),
7166 }
7167 }
7168
7169 fn twin_owed_write(&self, bytes: &[u8]) -> ControlResult<()> {
7170 if self.stores.txlog().is_released() {
7171 return Err(ControlError::Unavailable("the data set is released for a growth; twin-owed.json is written after the remount".into()));
7172 }
7173 // Per box, never a transaction: what a primary owes its twins is not the twins' to hold.
7174 crate::durable::write_atomic_unframed(
7175 self.stores.root(),
7176 crate::twin::TWIN_OWED_FILE,
7177 bytes,
7178 )
7179 .map_err(|e| {
7180 ControlError::Unavailable(format!("writing {}: {e:#}", crate::twin::TWIN_OWED_FILE))
7181 })
7182 }
7183
7184 fn restore_snapshot_chunk(&self, chunk: &gunnar_proto::LogChunk) -> ControlResult<Option<u64>> {
7185 let kind = u16::try_from(chunk.kind).map_err(|_| {
7186 ControlError::InvalidArgument(format!("snapshot piece of kind {}", chunk.kind))
7187 })?;
7188 let piece = crate::twin::snapshot::Piece {
7189 kind,
7190 meta: &chunk.meta,
7191 blob_len: chunk.blob_len,
7192 blob: &chunk.blob,
7193 last: chunk.last,
7194 };
7195 crate::twin::snapshot::restore_piece(&self.stores, piece)
7196 .map_err(|err| ControlError::Internal(format!("installing the snapshot: {err:#}")))
7197 }
7198
7199 fn health_alarms(&self) -> Vec<gunnar_proto::HealthAlarm> {
7200 #[allow(unused_mut)]
7201 let mut alarms = Vec::new();
7202 // A box stuck mid-restore serves reads and refuses every write: say so on the wire.
7203 if !self.stores.is_twin() && crate::twin::is_restoring(self.stores.root()) {
7204 alarms.push(gunnar_proto::HealthAlarm {
7205 event: "twin.restoring_at_open".into(),
7206 count: 1,
7207 since_unix_ms: 0,
7208 detail: "this box is MID-RESTORE: it originates no transactions and refuses every \
7209 control-plane write until a restore finishes — resend the twin's log"
7210 .into(),
7211 });
7212 }
7213 // An old main fenced off by a newer generation of its log (stinky row 18): it serves
7214 // reads and refuses every write, and must say so rather than look like a broken box.
7215 if !self.stores.is_twin() {
7216 if let Some(why) = crate::twin::fenced_why(self.stores.root()) {
7217 alarms.push(gunnar_proto::HealthAlarm {
7218 event: "twin.fenced_by_generation".into(),
7219 count: 1,
7220 since_unix_ms: 0,
7221 detail: why,
7222 });
7223 }
7224 }
7225 // ★ twin-resync-space (2026-10-06): a twin that gave its stale copy up to make room for
7226 // a full resync holds NO copy until it lands — main is the only one meanwhile.
7227 if self.stores.is_twin() {
7228 if let Some(d) = crate::twin::snapshot::dropped(self.stores.root()) {
7229 let too_small = crate::twin::snapshot::too_small_total(self.stores.root())
7230 .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))
7231 .unwrap_or_default();
7232 alarms.push(gunnar_proto::HealthAlarm {
7233 event: "twin.resync_without_copy".into(),
7234 count: 1,
7235 since_unix_ms: d.at_unix_ms,
7236 detail: format!("{}.{too_small}", d.sentence()),
7237 });
7238 }
7239 }
7240 // The log's size over its cap (2026-09-22): a warning, never a deletion. A twin's log is
7241 // its primary's numbers and is trimmed there, so only a box that originates one is told.
7242 if !self.stores.is_twin() {
7243 let (total, _) = crate::twin::volume_bytes(self.stores.root());
7244 match crate::twin::txlog_alarm_cap(
7245 std::env::var(crate::twin::TXLOG_ALARM_ENV).ok().as_deref(),
7246 total,
7247 ) {
7248 Ok(Some((cap, how))) => alarms.extend(crate::twin::txlog_size_alarm(
7249 self.stores.txlog().bytes(),
7250 cap,
7251 &how,
7252 )),
7253 Ok(None) => {}
7254 Err(why) => alarms.push(gunnar_proto::HealthAlarm {
7255 event: crate::twin::TXLOG_SIZE_ALARM.into(),
7256 count: 1,
7257 since_unix_ms: 0,
7258 detail: why,
7259 }),
7260 }
7261 }
7262 #[cfg(feature = "metered")]
7263 if let Some(a) = self.entitlement.as_ref().and_then(|slot| slot.alarm()) {
7264 alarms.push(a);
7265 }
7266 // The growth's own alarm (DEFECT D, 2026-09-29): /data kept unmounted past the growth's
7267 // bound (`grow.unmounted_too_long`), or a set that could not be mounted back (`grow.fatal`).
7268 if let Some(a) = self.grow.as_ref().and_then(|g| g.alarm()) {
7269 alarms.push(a);
7270 }
7271 // The installer's feed (`crate::feed`): a medium value the door contradicted, or a
7272 // fed monetize key that waits for a restart.
7273 alarms.extend(crate::feed::alarms());
7274 alarms
7275 }
7276
7277 fn is_twin(&self) -> bool {
7278 self.stores.is_twin()
7279 }
7280
7281 fn frozen(&self) -> Option<String> {
7282 if self.stores.is_twin() {
7283 Some("this box is a twin".to_owned())
7284 } else if self
7285 .stores
7286 .browse()
7287 .load(std::sync::atomic::Ordering::SeqCst)
7288 {
7289 Some("this box is a browse replica".to_owned())
7290 } else {
7291 None
7292 }
7293 }
7294
7295 fn log_follows(&self) -> bool {
7296 self.stores.is_twin() || self.stores.txlog().is_follower()
7297 }
7298
7299 fn topology_facts(&self) -> gunnar_grpc::topology::Facts {
7300 crate::topology::facts(self.stores.root())
7301 }
7302
7303 fn maintenance_reasons(&self) -> Vec<String> {
7304 #[allow(unused_mut)]
7305 let mut why = Vec::new();
7306 #[cfg(feature = "metered")]
7307 if self
7308 .entitlement
7309 .as_ref()
7310 .is_some_and(|slot| slot.maintenance())
7311 {
7312 why.push(crate::entitlement::KEY_MISMATCH_ALARM.to_string());
7313 }
7314 why
7315 }
7316
7317 fn primary_heartbeat(&self, silent_after_ms: u64) -> Option<gunnar_proto::PrimaryHeartbeat> {
7318 let cfg = crate::twin::TwinConfig::load(self.stores.root())
7319 .ok()
7320 .flatten()?;
7321 // The follower's OWN word and reason travel with the numbers (X2): a
7322 // twin that cannot start, or whose every poll fails, records both, and
7323 // this is the only door that reads them out to anybody.
7324 let st = crate::twin::status(self.stores.root());
7325 let (last, primary_head, applied) = match &st {
7326 Some(s) => (s.last_answer_unix_ms, s.primary_head_seq, s.applied_seq),
7327 None => (0, 0, self.stores.txlog().head_seq()),
7328 };
7329 let own = st.as_ref().map(|s| (s.state.to_string(), s.why.clone()));
7330 Some(gunnar_grpc::heartbeat::primary_view(
7331 &cfg.primary,
7332 last,
7333 primary_head,
7334 applied,
7335 crate::twin::now_ms(),
7336 silent_after_ms,
7337 own.as_ref().map(|(a, b)| (a.as_str(), b.as_str())),
7338 ))
7339 }
7340
7341 fn log_head(&self) -> ControlResult<gunnar_proto::LogHeadResponse> {
7342 self.log_head_signed("")
7343 }
7344
7345 fn log_head_signed(&self, nonce: &str) -> ControlResult<gunnar_proto::LogHeadResponse> {
7346 // **A twin's poll in the restore window is refused by name** (T17, 2026-09-21; T15's
7347 // TD8). Mid-replay (`txlog-restoring`) or finished and owed its restart
7348 // (`txlog-reseal`), this process holds the fresh box's KEK while the replayed host key
7349 // is sealed under the dead primary's; the re-seal that reconciles them runs on the next
7350 // open. Answering would mean an UNSIGNED answer a pinned twin refuses, and a
7351 // `cannot read its host key … wrong key-encrypting key` WARN that reads like a broken
7352 // box (MEASURED under KVM, 0.4 s after "Restore finished"). The box is not yet the
7353 // primary it will be, and says exactly that; the twin records it as its `why` and polls
7354 // again. Only a nonce asks for proof of identity — an unnonced LogHead (an operator,
7355 // `twin replay`) still gets the numbers.
7356 if !nonce.trim().is_empty() {
7357 let root = self.stores.root();
7358 if crate::twin::is_restoring(root) {
7359 return Err(ControlError::Unavailable(
7360 "this box is being restored from a twin and is not a primary yet: it answers a twin's poll \
7361 once the replay has finished and the box has restarted"
7362 .to_owned(),
7363 ));
7364 }
7365 if crate::twin::reseal_pending(root) {
7366 return Err(ControlError::Unavailable(
7367 "this box finished a restore and must restart before it answers a twin: its host key is \
7368 sealed under the key-encrypting key the restart re-seals, so until then it cannot prove \
7369 which box it is"
7370 .to_owned(),
7371 ));
7372 }
7373 }
7374 let mut head = crate::twin::own_head(&self.stores);
7375 // **Prove which box answered** (2026-09-21, lane T4). Signed last, over
7376 // the nonce the caller just minted plus the identity and the numbers
7377 // above, so a recorded answer cannot be replayed at the next poll and
7378 // the numbers cannot be edited in flight. No nonce = not a twin's poll,
7379 // and then there is nothing to be fresh about and nothing is signed.
7380 //
7381 // The generation (stinky row 18, 2026-10-10) is signed with them: a
7382 // primary names which restore of its log it is, a twin the highest its
7383 // primary has been. A fence that cannot be read is refused by name, never
7384 // answered as 0 — a twin would take that for an old main.
7385 let root = self.stores.root();
7386 let generation = if crate::twin::is_twin_root(root) {
7387 crate::twin::pinned_generation(root)
7388 } else {
7389 crate::twin::log_generation(root)
7390 };
7391 head.generation = generation
7392 .map_err(|err| ControlError::Internal(format!("this box's log generation: {err:#}")))?;
7393 let (key, sig) = crate::twin::sign_head_answer(
7394 &self.stores,
7395 nonce,
7396 &head.log_id,
7397 head.first_seq,
7398 head.head_seq,
7399 head.generation,
7400 );
7401 head.answer_key = key;
7402 head.answer_sig = sig;
7403 Ok(head)
7404 }
7405
7406 fn fence_by_generation(&self, proof: &gunnar_proto::GenerationProof) -> ControlResult<()> {
7407 crate::twin::fence_by_proof(&self.stores, proof)
7408 .map(drop)
7409 .map_err(|err| {
7410 ControlError::Internal(format!(
7411 "fencing this box off as an old generation of its log: {err:#}"
7412 ))
7413 })
7414 }
7415
7416 fn log_fetch(
7417 &self,
7418 from: u64,
7419 to: u64,
7420 max_bytes: u64,
7421 ) -> ControlResult<Vec<gunnar_proto::LogEntry>> {
7422 let log = self.readable_log()?;
7423 let entries =
7424 crate::twin::fetch_range(log, from, to, max_bytes).map_err(log_read_refusal)?;
7425 Ok(entries
7426 .into_iter()
7427 .map(|e| gunnar_proto::LogEntry {
7428 seq: e.seq,
7429 kind: e.kind as u32,
7430 meta: e.meta,
7431 blob: e.blob,
7432 })
7433 .collect())
7434 }
7435
7436 /// **`Mirror.LogFetch` off the segments, a piece at a time** (twin-streaming, 2026-10-06):
7437 /// the refusals now, as the call's status; then [`crate::twin::stream_range`] — the payload
7438 /// crc of every entry checked before its last piece leaves — through [`ChunkOut`] onto the
7439 /// wire. What this box holds of an entry is one piece, whatever its size.
7440 fn log_fetch_stream(
7441 &self,
7442 from: u64,
7443 to: u64,
7444 max_bytes: u64,
7445 ) -> ControlResult<gunnar_grpc::seam::LogFetchProducer> {
7446 self.log_fetch_stream_at(from, 0, to, max_bytes)
7447 }
7448
7449 /// [`Self::log_fetch_stream`] with the first entry from byte `from_offset` on
7450 /// (restore-large-entry, 2026-10-07): [`crate::twin::stream_range_at`].
7451 fn log_fetch_stream_at(
7452 &self,
7453 from: u64,
7454 from_offset: u64,
7455 to: u64,
7456 max_bytes: u64,
7457 ) -> ControlResult<gunnar_grpc::seam::LogFetchProducer> {
7458 let log = self.readable_log()?;
7459 crate::twin::check_range(log, from, to).map_err(log_read_refusal)?;
7460 let stores = Arc::clone(&self.stores);
7461 Ok(Box::new(move |sink| {
7462 let mut out = ChunkOut { sink, open: None };
7463 crate::twin::stream_range_at(stores.txlog(), from, from_offset, to, max_bytes, &mut out)
7464 .map(drop)
7465 .map_err(|err| {
7466 let err = match err.downcast::<ControlError>() {
7467 Ok(said) => return said,
7468 Err(other) => other,
7469 };
7470 match err.downcast::<gunnar_core::txlog::Error>() {
7471 Ok(e) => log_read_refusal(e),
7472 Err(other) => ControlError::Internal(format!("transaction log: {other:#}")),
7473 }
7474 })
7475 }))
7476 }
7477
7478 #[cfg(feature = "replication")]
7479 fn replication_status(
7480 &self,
7481 include_refs: bool,
7482 ) -> ControlResult<gunnar_proto::ReplicationStatusResponse> {
7483 self.replication_slot()?;
7484 self.replication_snapshot(include_refs)
7485 }
7486
7487 /// **The replication watermark** (`Mirror.FlushReplication`,
7488 /// `DATA-SET-GROWTH-FLOW.md` §2.2, G2, T11): step 0 of the runtime
7489 /// growth, and the proof — as far as replication can give one — that the
7490 /// twin is caught up before `/data` is unmounted.
7491 ///
7492 /// The flush half of `replication_abdicate`, alone: optionally enqueue
7493 /// every repository (`resync`, the only way a repository nobody pushed
7494 /// since the twin existed reaches it), then wait — bounded — for every
7495 /// standby's queue to drain. No freeze, no demote. The tuple it answers
7496 /// is §2.2's conjunction, measured at the end of the wait, and `verdict`
7497 /// says in words what it proves and what §2.1 says it cannot.
7498 #[cfg(feature = "replication")]
7499 fn replication_flush(
7500 &self,
7501 request: &gunnar_proto::FlushReplicationRequest,
7502 ) -> ControlResult<gunnar_proto::FlushReplicationResponse> {
7503 let slot = self.replication_slot()?;
7504 let began = gunnar_grpc::bus::now_unix_ms();
7505 let started = std::time::Instant::now();
7506 let ceiling = if request.timeout_secs == 0 {
7507 abdicate_flush_secs()
7508 } else {
7509 u64::from(request.timeout_secs)
7510 };
7511 let Some(replicator) = slot.replicator() else {
7512 return Err(ControlError::FailedPrecondition(
7513 "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(),
7514 ));
7515 };
7516 if request.resync {
7517 replicator
7518 .resync_scoped(None, None, &[])
7519 .map_err(|err| ControlError::Internal(format!("{err:#}")))?;
7520 }
7521 let deadline = started + std::time::Duration::from_secs(ceiling);
7522 loop {
7523 let drained = replicator.status().iter().all(|s| s.pending == 0);
7524 if drained || std::time::Instant::now() >= deadline {
7525 break;
7526 }
7527 std::thread::sleep(std::time::Duration::from_millis(100));
7528 }
7529 let status = replicator.status();
7530 let counters = replicator.counters();
7531 let pending: u64 = status.iter().map(|s| s.pending as u64).sum();
7532 let needs_full_resync = status.iter().any(|s| s.needs_full_resync);
7533 let last_success = status
7534 .iter()
7535 .map(|s| s.last_success_unix_ms.unwrap_or(0))
7536 .min()
7537 .unwrap_or(0);
7538 let in_flight = self.admission.as_ref().map(|a| a.in_flight()).unwrap_or(0);
7539 let catalog_repos = self
7540 .stores
7541 .catalog()
7542 .counts()
7543 .map(|(_, repos)| repos as u64)
7544 .unwrap_or(0);
7545 let standbys = self.replication_snapshot(false)?.standbys;
7546 let caught_up = in_flight == 0
7547 && !status.is_empty()
7548 && pending == 0
7549 && !needs_full_resync
7550 && status.iter().all(|s| {
7551 s.last_success_unix_ms.is_some_and(|t| t >= began)
7552 || s.pending == 0 && s.last_success_unix_ms.is_some()
7553 });
7554 let verdict = if status.is_empty() {
7555 "no standby is configured: nothing was flushed and nothing is proven".to_owned()
7556 } else if caught_up {
7557 format!(
7558 "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",
7559 status.len(),
7560 in_flight,
7561 counters.bus_gaps,
7562 if request.resync { " (every catalog repository was enqueued)" } else { "" }
7563 )
7564 } else {
7565 let names: Vec<String> = status
7566 .iter()
7567 .filter(|s| s.pending > 0 || s.needs_full_resync)
7568 .map(|s| {
7569 format!(
7570 "{} (pending {}{})",
7571 s.name,
7572 s.pending,
7573 if s.needs_full_resync {
7574 ", needs full resync"
7575 } else {
7576 ""
7577 }
7578 )
7579 })
7580 .collect();
7581 format!(
7582 "NOT caught up after {} s: {} transfer(s) in flight, {} repository(ies) pending [{}]{}; the growth must not unmount the set on this",
7583 started.elapsed().as_secs(),
7584 in_flight,
7585 pending,
7586 names.join(", "),
7587 if needs_full_resync { " — a standby has a hole retrying will not close: run `replication resync`" } else { "" }
7588 )
7589 };
7590 tracing::info!(
7591 target: "gunnar::replication",
7592 event = "replication.flush",
7593 caught_up,
7594 in_flight,
7595 pending,
7596 bus_gaps = counters.bus_gaps,
7597 needs_full_resync,
7598 waited_ms = started.elapsed().as_millis() as u64,
7599 "{verdict}"
7600 );
7601 Ok(gunnar_proto::FlushReplicationResponse {
7602 caught_up,
7603 in_flight,
7604 pending,
7605 bus_gaps: counters.bus_gaps,
7606 needs_full_resync,
7607 last_success_unix_ms: last_success,
7608 flushed_at_unix_ms: began,
7609 waited_ms: started.elapsed().as_millis() as u64,
7610 standbys,
7611 catalog_repos,
7612 verdict,
7613 })
7614 }
7615
7616 /// **Demote: clear EVERY replication target in one write.** The failover
7617 /// primitive — see the `Mirror.DemoteReplication` proto comment for the
7618 /// split-brain it closes. A recovered old-primary still lists the box that
7619 /// is now the new primary, and its replicator force-clobbers the new
7620 /// primary's post-failover writes; demoting it drops that whole roster so it
7621 /// can be re-added as the new primary's standby instead.
7622 ///
7623 /// Idempotent, and never `NotFound`: unlike `replication_remove`, which
7624 /// names one standby and must distinguish "removed the one you meant" from
7625 /// "removed one that was never there", demote clears the lot — a box with no
7626 /// targets is a perfectly good thing to demote and comes back `Ok` with zero
7627 /// cleared, because "make sure I am not a primary" must not fail on a box
7628 /// that already is not one.
7629 #[cfg(feature = "replication")]
7630 fn replication_demote(&self) -> ControlResult<gunnar_proto::ReplicationStatusResponse> {
7631 let slot = self.replication_slot()?;
7632 let mut config = slot
7633 .config()
7634 .map_err(|err| ControlError::Internal(format!("{err:#}")))?;
7635 config.clear_targets();
7636 slot.apply(config)
7637 .map_err(|err| ControlError::Internal(format!("{err:#}")))?;
7638 self.replication_snapshot(false)
7639 }
7640
7641 /// **Set this box's role**: a writable `Primary`, or a read-only `Standby`.
7642 ///
7643 /// The one writer. It persists the role into `replication.json` (so it
7644 /// survives the reboot after a power cut) AND flips the shared
7645 /// write-gate the SSH/HTTP front doors read — both from
7646 /// [`ReplicationSlot::apply`], which is the single place the two are kept in
7647 /// step (one implementation). `PRIMARY` is the **takeover flip** a promoted standby runs;
7648 /// `STANDBY` is how a box is deliberately made read-only. Idempotent: setting
7649 /// the role a box already holds writes the same file and the same flag.
7650 #[cfg(feature = "replication")]
7651 fn replication_set_role(
7652 &self,
7653 role: gunnar_proto::ReplicationRole,
7654 ) -> ControlResult<gunnar_proto::ReplicationStatusResponse> {
7655 let slot = self.replication_slot()?;
7656 let mut config = slot
7657 .config()
7658 .map_err(|err| ControlError::Internal(format!("{err:#}")))?;
7659 let wanted = crate::mirror::Role::from_proto(role);
7660 // **A browse replica cannot be promoted** (the browse replica). Not by an
7661 // operator's `SetRole PRIMARY`, not by `abdicate --promote`, not by the
7662 // passive heartbeat: the box holds only public repositories and serves
7663 // strangers; as a primary it would be a git host with half the data
7664 // and an open door. The only way out is `STANDBY`, which narrows what
7665 // the box serves and keeps it frozen — from there an operator may
7666 // promote, and by then they have said so twice.
7667 if config.role.is_browse() && wanted == crate::mirror::Role::Primary {
7668 return Err(ControlError::FailedPrecondition(
7669 "this box is a browse replica; it cannot be promoted to primary. It holds only \
7670 public repositories and serves anonymous reads — first `gunnar control \
7671 replication demote` it to a plain standby, then promote that"
7672 .into(),
7673 ));
7674 }
7675 config.role = wanted;
7676 slot.apply(config)
7677 .map_err(|err| ControlError::Internal(format!("{err:#}")))?;
7678 self.replication_snapshot(false)
7679 }
7680
7681 /// **Abdicate: the master steps down cleanly, in order.** The voluntary
7682 /// counterpart to the crash [`Self::replication_demote`] covers, and the
7683 /// order is the whole difference between a hand-off and a crash:
7684 ///
7685 /// 1. **FREEZE** — role → `Standby`, so this box stops taking client writes
7686 /// before the flush begins. This closes the residual race `demote` alone
7687 /// left open (a client pushing to the box in the window before it is
7688 /// demoted): once frozen, there is no such write to lose.
7689 /// 2. **FLUSH** — resync every standby and wait, with a ceiling, for the
7690 /// queues to drain, so the standby has everything that was pending. The
7691 /// wait is best-effort: a standby that is down must not wedge the
7692 /// abdication forever — the roster still clears below, and a recovered
7693 /// standby replays from this box's history when it is re-added the other
7694 /// way round.
7695 /// 3. **DEMOTE** — clear the roster (reusing the same `clear_targets`
7696 /// primitive), so the abdicated box can never later clobber the new
7697 /// primary.
7698 ///
7699 /// It does NOT promote the standby: naming a new primary is the operator's
7700 /// decision, run as abdication's last act (`SetRole(PRIMARY)` on the
7701 /// standby — the CLI's `--promote`), never a default this box picks.
7702 #[cfg(feature = "replication")]
7703 fn replication_abdicate(&self) -> ControlResult<gunnar_proto::ReplicationStatusResponse> {
7704 let slot = self.replication_slot()?;
7705
7706 // 1. FREEZE. role -> Standby, targets untouched (the replicator stays up
7707 // for the flush below).
7708 let mut config = slot
7709 .config()
7710 .map_err(|err| ControlError::Internal(format!("{err:#}")))?;
7711 config.role = crate::mirror::Role::Standby;
7712 slot.apply(config)
7713 .map_err(|err| ControlError::Internal(format!("{err:#}")))?;
7714
7715 // 2. FLUSH. Enqueue every repository for every standby, then wait for
7716 // the queues to empty, bounded so a dead standby cannot hang the
7717 // hand-off. The workers run on the git runtime, so they make progress
7718 // while this admin thread sleeps.
7719 if let Some(replicator) = slot.replicator() {
7720 replicator
7721 .resync_scoped(None, None, &[])
7722 .map_err(|err| ControlError::Internal(format!("{err:#}")))?;
7723 let deadline =
7724 std::time::Instant::now() + std::time::Duration::from_secs(abdicate_flush_secs());
7725 loop {
7726 let drained = replicator.status().iter().all(|s| s.pending == 0);
7727 if drained || std::time::Instant::now() >= deadline {
7728 break;
7729 }
7730 std::thread::sleep(std::time::Duration::from_millis(100));
7731 }
7732 }
7733
7734 // 3. DEMOTE. Clear the roster so this box can never clobber the new
7735 // primary. Re-read: the flush did not change the config, but reading
7736 // it back keeps this the same load-modify-apply the other verbs use.
7737 let mut config = slot
7738 .config()
7739 .map_err(|err| ControlError::Internal(format!("{err:#}")))?;
7740 config.clear_targets();
7741 slot.apply(config)
7742 .map_err(|err| ControlError::Internal(format!("{err:#}")))?;
7743 self.replication_snapshot(false)
7744 }
7745
7746 /// **The backfill verb.** See `mirror::Replicator::resync_scoped` for the
7747 /// gap it closes and why enqueueing is the whole of it.
7748 ///
7749 /// Three refusals, each of which would otherwise be a silent nothing:
7750 ///
7751 /// * a server with a **configuration but no running replicator** — a target
7752 /// is on disk and nothing is draining its queue — is `Unavailable` and not
7753 /// an `Ok` with zero queued, because the two read identically and only one
7754 /// of them makes a second copy;
7755 /// * a **standby name that is not configured** is `NotFound`, the same rule
7756 /// `replication_remove` already applies;
7757 /// * a **repository this server does not have** is `NotFound` rather than a
7758 /// resync of nothing, because the ordinary way to get there is a typo.
7759 #[cfg(feature = "replication")]
7760 fn replication_resync(
7761 &self,
7762 name: &str,
7763 repo: Option<&gunnar_proto::RepoPath>,
7764 except: &[gunnar_proto::RepoPath],
7765 ) -> ControlResult<gunnar_proto::ResyncReplicationResponse> {
7766 let slot = self.replication_slot()?;
7767 let Some(replicator) = slot.replicator() else {
7768 return Err(ControlError::Unavailable(format!(
7769 "no replicator is running, so nothing would drain a backfill queue. \
7770 `replication.json` holds {} target(s); the server has not started them.",
7771 slot.config().map(|c| c.targets.len()).unwrap_or(0)
7772 )));
7773 };
7774 let only = match repo {
7775 None => None,
7776 Some(path) => Some(Self::repo_ref(path)?),
7777 };
7778 let except = except
7779 .iter()
7780 .map(Self::repo_ref)
7781 .collect::<ControlResult<Vec<_>>>()?;
7782 let reports = replicator
7783 .resync_scoped(Some(name), only.as_ref(), &except)
7784 .map_err(|err| ControlError::NotFound(format!("{err:#}")))?;
7785 Ok(gunnar_proto::ResyncReplicationResponse {
7786 standbys: reports
7787 .into_iter()
7788 .map(|r| gunnar_proto::ResyncStandby {
7789 standby: r.standby,
7790 in_scope: r.in_scope,
7791 queued: r.queued,
7792 already_pending: r.already_pending,
7793 refused: r.refused,
7794 sha1_repos: r.sha1_repos,
7795 excluded: r.excluded,
7796 })
7797 .collect(),
7798 status: Some(self.replication_snapshot(false)?),
7799 })
7800 }
7801
7802 // ── outbound webhooks ──────────────────────────────────────────────────
7803
7804 #[cfg(feature = "webhooks")]
7805 fn webhook_add(
7806 &self,
7807 request: gunnar_proto::AddWebhookRequest,
7808 ) -> ControlResult<gunnar_proto::WebhookListResponse> {
7809 let slot = self.webhook_slot()?;
7810 let endpoint = crate::webhook::Endpoint {
7811 name: request.name,
7812 url: request.url,
7813 kinds: request.kinds,
7814 namespace: request.namespace,
7815 repo: request.repo,
7816 // An empty secret is "no HMAC", not a secret that is the empty
7817 // string — so it maps to None and `has_secret` reads false.
7818 secret: Some(request.secret).filter(|s| !s.is_empty()),
7819 added_at_unix_ms: gunnar_grpc::bus::now_unix_ms(),
7820 };
7821 // `add` validates the URL and the kind names, so a typo is refused here
7822 // (InvalidArgument) rather than silently delivering nowhere.
7823 slot.add(endpoint)
7824 .map_err(|err| ControlError::InvalidArgument(format!("{err:#}")))
7825 }
7826
7827 #[cfg(feature = "webhooks")]
7828 fn webhook_list(&self) -> ControlResult<gunnar_proto::WebhookListResponse> {
7829 self.webhook_slot()?
7830 .list_response()
7831 .map_err(|err| ControlError::Internal(format!("{err:#}")))
7832 }
7833
7834 #[cfg(feature = "webhooks")]
7835 fn webhook_remove(&self, name: &str) -> ControlResult<gunnar_proto::WebhookListResponse> {
7836 let slot = self.webhook_slot()?;
7837 slot.remove(name)
7838 .map_err(|err| ControlError::Internal(format!("{err:#}")))?
7839 // NotFound and not a silent success: "removed the endpoint" about
7840 // one that was never there reads identically to removing the one you
7841 // meant, and the difference is whether events are still being sent.
7842 .ok_or_else(|| ControlError::NotFound(format!("no webhook endpoint named {name:?}")))
7843 }
7844}
7845
7846/// The largest `Refs.ReadObject` response, and it is **not** a number somebody
7847/// picked.
7848///
7849/// # Measured off the transport, not chosen
7850///
7851/// `tonic 0.13.1`'s `DEFAULT_MAX_RECV_MESSAGE_SIZE` is `4 * 1024 * 1024`
7852/// (`tonic/src/codec/mod.rs:100`; the send default beside it is `usize::MAX`).
7853/// gunnar overrides neither. So a response larger than this **cannot be
7854/// received by a client using the generated stub's defaults** — it fails on the
7855/// client with a length error after the server has already paid for it. Refusing
7856/// it before inflating turns an expensive failure into a cheap one; it does not
7857/// turn a working request into a refusal, which is the objection that kept this
7858/// entry open.
7859///
7860/// The refusal is on the size of the **response**, not of the object, so
7861/// `max_bytes` still works: asking for a 1 KiB prefix of a 1 GiB blob is
7862/// answered exactly as before.
7863///
7864/// # What this alone does NOT fix
7865///
7866/// The server still inflates the whole object to produce that prefix, because
7867/// `ObjectStore` has no bounded read and cannot honestly grow one — a prefix of
7868/// a delta-encoded object needs its whole base chain (the investigation is in
7869/// the object-ceiling record, checked against `gix-odb 0.83.0`). Bounding *that* needs a
7870/// ceiling on the **object**, which is [`DEFAULT_MAX_OBJECT_BYTES`] below.
7871const MAX_RESPONSE_BYTES: u64 = 4 * 1024 * 1024;
7872
7873/// The memory this deployment is willing to spend on `Refs.ReadObject`
7874/// inflations, in total, across every control-plane worker at once.
7875///
7876/// 512 MiB. The control plane is an administrative surface — a handful of
7877/// operators and a UI — sharing a box with the thing that actually matters, the
7878/// git serving path. Half a gigabyte is a rounding error against a forge host's
7879/// memory and is far more than object *inspection* has any business using; the
7880/// number that would need defending is a larger one, not this.
7881const READ_OBJECT_MEMORY_BUDGET: u64 = 512 * 1024 * 1024;
7882
7883/// The most control-plane workers that can be inflating at once.
7884///
7885/// `read_object` runs **synchronously** on a control-plane worker (the split gave
7886/// the control plane its own runtime and deliberately did not add
7887/// `spawn_blocking`), so the number of concurrent inflations is the number of
7888/// those workers, and further callers queue rather than allocate.
7889/// [`control_plane_workers`](crate::serve::control_plane_workers) is
7890/// `cores / 4` clamped to `4..=8` (the handler workers, R5.1-G), so **8** is its ceiling on any machine.
7891///
7892/// The clamp's upper bound is used rather than the live count on purpose: a
7893/// default derived from `available_parallelism()` would make this RPC refuse
7894/// different requests on a laptop and on the forge host, which is precisely the
7895/// class of difference that is not found until production.
7896///
7897/// **This bound moves** if these methods are ever wrapped in `spawn_blocking` —
7898/// which `gunnar-grpc/src/seam.rs` still asks for, and the admission record says
7899/// the gRPC router still has no `ConcurrencyLimitLayer`. On that day the
7900/// denominator becomes unbounded and this constant is where to start.
7901const MAX_CONCURRENT_INFLATES: u64 = 8;
7902
7903/// **The object ceiling** `Refs.ReadObject` refuses above — 64 MiB.
7904///
7905/// ```text
7906/// ceiling = memory budget for this RPC ÷ concurrent inflates
7907/// = 512 MiB ÷ 8
7908/// = 64 MiB (a 512 MiB worst case, which is the budget)
7909/// ```
7910///
7911/// # This one really does refuse a request that works today
7912///
7913/// [`MAX_RESPONSE_BYTES`] costs nobody anything — it declines to build a response
7914/// no client could receive. This is different: asking for a 1 KiB prefix of a
7915/// 100 MiB blob is a request the server can answer today and will now refuse,
7916/// because answering it means materialising 100 MiB to hand back a kilobyte, and
7917/// eight of those concurrently is 800 MiB out of the git path's memory on an
7918/// unauthenticated RPC.
7919///
7920/// 64 MiB is chosen against what git itself does rather than out of the air: a
7921/// blob that large is already outside what belongs in a pack — it is what
7922/// `gunnar-lfs` exists for — and this is an *inspection* surface, not the clone
7923/// path, which is untouched by this and streams. The refusal names the object's
7924/// size, so a caller learns the fact rather than a limit.
7925///
7926/// A deployment that disagrees sets its own with
7927/// [`CoreControlPlane::with_max_object_bytes`], including `0` for no ceiling.
7928const DEFAULT_MAX_OBJECT_BYTES: u64 = READ_OBJECT_MEMORY_BUDGET / MAX_CONCURRENT_INFLATES;
7929
7930// `PROTECTION_GAP` stood here, and it read:
7931//
7932// > Policy.*: gunnar-core has no store for branch protection or ref-name
7933// > policy … Serving this over gRPC needs a policy document in the catalog
7934//
7935// There is one — `gunnar_core::PolicyStore`, at `refs/gunnar/policy` — and
7936// every `Policy.*` verb below is served from it. The constant outlived the
7937// refusal it explained: `cargo check` called it `never used` while
7938// `gunnar-ui/docs/coverage.md` was still quoting it to render six console
7939// panels as disabled. A dead string that a doc still believes is worse than no
7940// string, so it is gone rather than left as a comment with a name.
7941
7942// `MIRROR_GAP` stood here too, and it read:
7943//
7944// > Mirror.*: mirroring runs over gunnar-client (nornir-git ls-remote +
7945// > fetch), which an S crate must never link (the dependency budget). Use `gunnar mirror` on a
7946// > normal-OS host instead
7947//
7948// It had already been narrowed from all four verbs to the one that genuinely
7949// needed a fetch client. That client exists in-tree now —
7950// `gunnar_wire::fetch_pack`, no gix, no `.git` directory, no worktree, over the
7951// same `BufRead`/`Write` pair `upload_pack` answers on — so `Mirror.Sync` is
7952// served rather than refused and the string is gone for the reason
7953// `PROTECTION_GAP`'s is: a dead refusal a doc still quotes is worse than no
7954// refusal. `gunnar-cli/tests/policy_mirror.rs` mentions it by name; that is
7955// prose about the history, and this paragraph is what it now describes.
7956
7957impl CoreControlPlane {
7958 /// `Usage.*` is not readable while the data set is unmounted for a growth.
7959 fn usage_readable(&self) -> ControlResult<()> {
7960 if self.stores.txlog().is_released()
7961 || crate::twin::maintenance::is_held(self.stores.root())
7962 {
7963 return Err(ControlError::Unavailable(format!(
7964 "{} this box's data set is being grown (unmounted): its usage is read again after the remount — ask again",
7965 gunnar_grpc::PRIMARY_GROWING
7966 )));
7967 }
7968 Ok(())
7969 }
7970
7971 /// The indexed objects of `namespace`'s OPEN stores, now.
7972 fn open_objects_of(&self, namespace: &str) -> u64 {
7973 let prefix = self
7974 .stores
7975 .repos_root()
7976 .canonicalize()
7977 .unwrap_or_else(|_| self.stores.repos_root().to_path_buf())
7978 .join(namespace);
7979 self.stores
7980 .engine()
7981 .open_object_counts()
7982 .into_iter()
7983 .filter(|(dir, _)| dir.parent() == Some(prefix.as_path()))
7984 .map(|(_, n)| n)
7985 .sum()
7986 }
7987
7988 /// **One `NamespaceUsage` row**: what the ledger holds, the open objects,
7989 /// and the FLOW half off the flow ledger's live atomics.
7990 fn usage_row(
7991 &self,
7992 name: &str,
7993 held: &crate::usage_ledger::Held,
7994 objects: u64,
7995 measured_at_unix_ms: i64,
7996 ) -> gunnar_proto::NamespaceUsage {
7997 let mut row = gunnar_proto::NamespaceUsage {
7998 namespace: name.to_owned(),
7999 measured_at_unix_ms,
8000 pack_bytes: held.bytes.pack,
8001 cache_bytes: held.bytes.cache,
8002 lfs_bytes: held.bytes.lfs,
8003 tombstoned_bytes: held.tombstoned_bytes,
8004 repos: held.repos,
8005 objects,
8006 ..Default::default()
8007 };
8008 fill_flow(&mut row, self.stores.flow(), name, measured_at_unix_ms);
8009 row
8010 }
8011
8012 /// **Every namespace, walked now** (`Usage.ReadAll`): the full recount —
8013 /// one catalog list, one tombstone walk, every repository's directory. The
8014 /// reading the usage ledger must always agree with.
8015 fn usage_all(&self) -> ControlResult<Vec<gunnar_proto::NamespaceUsage>> {
8016 let catalog = self.stores.catalog();
8017 let namespaces: Vec<String> = catalog
8018 .namespaces()
8019 .map_err(internal)?
8020 .into_iter()
8021 .map(|ns| ns.name)
8022 .collect();
8023 let repos = catalog.list(None).map_err(internal)?;
8024 let tombstones = self.stores.tombstones().unwrap_or_default();
8025 let open_objects: std::collections::HashMap<std::path::PathBuf, u64> = self
8026 .stores
8027 .engine()
8028 .open_object_counts()
8029 .into_iter()
8030 .collect();
8031 let flow = self.stores.flow();
8032 let measured_at_unix_ms = gunnar_core::now_ms();
8033 let mut by_ns: std::collections::HashMap<&str, Vec<&gunnar_core::Repo>> =
8034 std::collections::HashMap::new();
8035 for repo in &repos {
8036 by_ns.entry(repo.namespace.as_str()).or_default().push(repo);
8037 }
8038 let mut out = Vec::with_capacity(namespaces.len());
8039 for name in namespaces {
8040 let mut row = gunnar_proto::NamespaceUsage {
8041 namespace: name.clone(),
8042 measured_at_unix_ms,
8043 ..Default::default()
8044 };
8045 for repo in by_ns.get(name.as_str()).map(Vec::as_slice).unwrap_or(&[]) {
8046 let path = RepoRef::new(&repo.namespace, &repo.name).map_err(|err| {
8047 ControlError::Internal(format!(
8048 "a catalogued repository has an illegal path: {err}"
8049 ))
8050 })?;
8051 let dir = self
8052 .stores
8053 .repo_dir(&path)
8054 .map_err(|err| ControlError::Internal(format!("{err:#}")))?;
8055 let bytes = repo_bytes(&dir);
8056 row.repos += 1;
8057 row.pack_bytes += bytes.pack;
8058 row.cache_bytes += bytes.cache;
8059 row.lfs_bytes += bytes.lfs;
8060 row.objects += open_objects.get(&dir).copied().unwrap_or(0);
8061 }
8062 row.tombstoned_bytes = tombstones
8063 .iter()
8064 .filter(|t| t.path.namespace() == name)
8065 .map(|t| t.bytes)
8066 .sum();
8067 fill_flow(&mut row, flow, &name, measured_at_unix_ms);
8068 out.push(row);
8069 }
8070 Ok(out)
8071 }
8072}
8073
8074/// ── the FLOW half of a usage row ────────────────────────────────────────────
8075///
8076/// Everything else in a row is what the namespace HOLDS, and it reads the same
8077/// for a tenant who pushed this morning and a tenant who last touched this box
8078/// in March. These seven are what MOVED, read off the flow ledger's live
8079/// atomics — no walk, no disk, one hash lookup — so that a console showing a
8080/// usage row can tell a working customer from a silent one without a second
8081/// call.
8082///
8083/// A namespace nothing has moved through today has zeroes here, and for a flow
8084/// meter a zero is a fact: a transfer either occurred or it did not.
8085/// `last_push_unix_ms` and `last_fetch_unix_ms` are the exception and look back
8086/// over the WHOLE kept window, because "how long has this tenant been quiet" is
8087/// the question they exist for.
8088fn fill_flow(
8089 row: &mut gunnar_proto::NamespaceUsage,
8090 flow: &gunnar_core::FlowLedger,
8091 name: &str,
8092 now_unix_ms: i64,
8093) {
8094 let glance = flow.glance(name, now_unix_ms);
8095 row.bytes_in_today = glance.today.bytes_in;
8096 row.bytes_out_today = glance.today.bytes_out;
8097 row.pushes_today = glance.today.pushes;
8098 row.fetches_today = glance.today.fetches;
8099 row.refused_today = glance.today.refused();
8100 row.last_push_unix_ms = glance.last_push_unix_ms;
8101 row.last_fetch_unix_ms = glance.last_fetch_unix_ms;
8102}
8103
8104/// Loose object count and total bytes under `objects/`.
8105///
8106/// Walked rather than estimated: `Admin.StorageInfo` exists so an operator can
8107/// see what is actually on the disk, and a number that came from a guess would
8108/// be worse than no number.
8109fn object_dir_stats(git_dir: &std::path::Path) -> (u64, u64) {
8110 fn walk(dir: &std::path::Path, loose: &mut u64, bytes: &mut u64, in_loose_shard: bool) {
8111 let Ok(entries) = std::fs::read_dir(dir) else {
8112 return;
8113 };
8114 for entry in entries.flatten() {
8115 let Ok(kind) = entry.file_type() else {
8116 continue;
8117 };
8118 let name = entry.file_name();
8119 let name = name.to_string_lossy();
8120 if kind.is_dir() {
8121 // A loose-object shard is two hex characters.
8122 let shard = name.len() == 2 && name.chars().all(|c| c.is_ascii_hexdigit());
8123 walk(&entry.path(), loose, bytes, shard);
8124 } else if kind.is_file() {
8125 if in_loose_shard {
8126 *loose += 1;
8127 }
8128 if let Ok(meta) = entry.metadata() {
8129 *bytes += meta.len();
8130 }
8131 }
8132 }
8133 }
8134 let mut loose = 0;
8135 let mut bytes = 0;
8136 walk(&git_dir.join("objects"), &mut loose, &mut bytes, false);
8137 (loose, bytes)
8138}
8139
8140/// **One repository's bytes, attributed per file.** The one walk both
8141/// `Admin.StorageInfo` and `Usage.Read` are summed from (one implementation), so the two
8142/// cannot disagree about what a directory weighs.
8143///
8144/// The classification is by file name and is engine-neutral on purpose:
8145///
8146/// | file | meter |
8147/// |---|---|
8148/// | `<git_dir>/lfs/**` | `lfs` |
8149/// | `…/objects.exploded*` (znippy's eager and lazy pools) | `cache` |
8150/// | 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` |
8151///
8152/// `pack` is therefore "the hot tier minus the exploded cache" exactly as
8153/// `usage.proto` promises — it includes the derived index files, which are
8154/// re-derivable, because that is the contract monetize's own client-side sum
8155/// reaches and two definitions of one bill is the thing to avoid. The file
8156/// list is written in `usage.proto`'s header; a new file the engine grows lands
8157/// in `pack` until somebody names it, which errs on the side of the operator
8158/// seeing it rather than not.
8159#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
8160pub struct RepoBytes {
8161 /// The truth and its indexes: everything that is neither cache nor LFS.
8162 pub pack: u64,
8163 /// znippy's exploded read cache, as it is on the disk right now.
8164 pub cache: u64,
8165 /// LFS objects, committed and in flight.
8166 pub lfs: u64,
8167}
8168
8169impl RepoBytes {
8170 /// The whole directory minus LFS: what `RepoStorage.hot_bytes` reports.
8171 pub fn hot(&self) -> u64 {
8172 self.pack + self.cache
8173 }
8174}
8175
8176/// See [`RepoBytes`].
8177pub fn repo_bytes(git_dir: &std::path::Path) -> RepoBytes {
8178 fn walk(dir: &std::path::Path, acc: &mut RepoBytes) {
8179 let Ok(entries) = std::fs::read_dir(dir) else {
8180 return;
8181 };
8182 for entry in entries.flatten() {
8183 let Ok(meta) = entry.metadata() else {
8184 continue;
8185 };
8186 if meta.is_dir() {
8187 walk(&entry.path(), acc);
8188 } else if entry
8189 .file_name()
8190 .to_string_lossy()
8191 .starts_with("objects.exploded")
8192 {
8193 acc.cache += meta.len();
8194 } else {
8195 acc.pack += meta.len();
8196 }
8197 }
8198 }
8199 let mut acc = RepoBytes::default();
8200 let Ok(entries) = std::fs::read_dir(git_dir) else {
8201 return acc;
8202 };
8203 for entry in entries.flatten() {
8204 let Ok(meta) = entry.metadata() else {
8205 continue;
8206 };
8207 if meta.is_dir() && entry.file_name() == gunnar_lfs::fs_store::LFS_DIR {
8208 acc.lfs += crate::stores::dir_bytes(&entry.path());
8209 } else if meta.is_dir() {
8210 walk(&entry.path(), &mut acc);
8211 } else {
8212 acc.pack += meta.len();
8213 }
8214 }
8215 acc
8216}
8217
8218// ───────────────────────────────────────────────────────────────────────────
8219// ACL identity: the gRPC gate's two questions, answered by `AuthTable`
8220// ───────────────────────────────────────────────────────────────────────────
8221
8222/// **The one place gRPC identity is decided, and it decides nothing itself.**
8223///
8224/// `gunnar-grpc` closed the allow-all authentication hole with SSH-key-derived
8225/// identity: a caller signs a single-use challenge with a key that is already
8226/// in the ACL key table, and the gate asks the control plane two questions:
8227/// *whose key is this*, and *may they*. Both are delegated here, and both land
8228/// on [`AuthTable`]:
8229///
8230/// | question | answered by |
8231/// |---|---|
8232/// | whose key is this | [`AuthTable::principal_by_fingerprint`] (expiry included) |
8233/// | may they | [`AuthTable::may_server`] / [`AuthTable::may_namespace`] / [`AuthTable::may`] |
8234///
8235/// So gRPC and SSH share one identity table and **one** ACL. Revoking a key
8236/// over gRPC revokes it for git in the same instant, and there is no second
8237/// grant decision that could drift from the first — one implementation,
8238/// because two disagreeing security checks have cost this design once already.
8239impl gunnar_grpc::Identity for CoreControlPlane {
8240 /// The installer's feed, as `crate::fed_principals::note_fed` left it: the
8241 /// keys this primary will enrol and has not yet.
8242 fn awaiting_enrolment(&self, fingerprint: &str) -> Option<String> {
8243 self.stores.awaiting_enrolment(fingerprint)
8244 }
8245
8246 fn registered_key(
8247 &self,
8248 fingerprint: &str,
8249 ) -> ControlResult<Option<gunnar_grpc::RegisteredKey>> {
8250 // A malformed fingerprint is "no such key", not an error: the string
8251 // came off a public port and refusing to parse it loudly would make
8252 // this an oracle for what a well-formed fingerprint looks like.
8253 let Ok(parsed) = gunnar_core::Fingerprint::parse(fingerprint) else {
8254 return Ok(None);
8255 };
8256 // `principal_by_fingerprint` is the SSH listener's own lookup; it
8257 // already refuses an expired key and a key whose principal is gone.
8258 let Some(principal) = self
8259 .stores
8260 .auth()
8261 .principal_by_fingerprint(&parsed)
8262 .map_err(internal)?
8263 else {
8264 return Ok(None);
8265 };
8266 let key = self
8267 .stores
8268 .auth()
8269 .keys_of(&principal.id)
8270 .map_err(internal)?
8271 .into_iter()
8272 .find(|key| key.fingerprint_sha256 == fingerprint);
8273 let machine = principal.kind == gunnar_core::PrincipalKind::Machine;
8274 Ok(key.map(|key| gunnar_grpc::RegisteredKey {
8275 principal_id: principal.id,
8276 machine,
8277 // The `authorized_keys` line the key was registered from. The gate
8278 // verifies the sshsig against it and re-derives the fingerprint
8279 // from it, so a wrong row here cannot become a wrong identity.
8280 openssh: key.openssh,
8281 }))
8282 }
8283
8284 /// The X.509 half of the **same** table `registered_key` reads.
8285 ///
8286 /// `gunnar control bootstrap` has minted client certificates and registered
8287 /// them under `algo: "x509"` since the certificate-identity plan's first-boot
8288 /// half landed; until the listener grew mTLS there was nothing that could
8289 /// look one up, so those registrations were inert. This is the lookup, and it
8290 /// is deliberately the
8291 /// same three steps `registered_key` takes — hash, principal, key row — so
8292 /// that revoking a certificate over gRPC revokes it for the handshake in
8293 /// the same instant, out of one table (one implementation).
8294 ///
8295 /// No `openssh` line comes back and none is wanted: the handshake already
8296 /// proved possession, so there is no second signature to check. See
8297 /// [`gunnar_grpc::CertificateIdentity`].
8298 fn registered_certificate(
8299 &self,
8300 der: &[u8],
8301 ) -> ControlResult<Option<gunnar_grpc::CertificateIdentity>> {
8302 // The one place that decides what bytes a certificate credential is.
8303 // `gunnar-grpc` hands the DER across the seam rather than a
8304 // fingerprint precisely so this stays the only spelling of it.
8305 let fingerprint = gunnar_core::auth::fingerprint_of_certificate(der);
8306 let Some(principal) = self
8307 .stores
8308 .auth()
8309 .principal_by_fingerprint(&fingerprint)
8310 .map_err(internal)?
8311 else {
8312 return Ok(None);
8313 };
8314 // `principal_by_fingerprint` already refuses an expired key and a key
8315 // whose principal is gone. The row is re-read so that a fingerprint
8316 // that resolves to a principal but has no key row — a state only a
8317 // corrupted table can be in — is "no identity" rather than an
8318 // identity with no credential behind it.
8319 let registered = self
8320 .stores
8321 .auth()
8322 .keys_of(&principal.id)
8323 .map_err(internal)?
8324 .into_iter()
8325 .any(|key| key.fingerprint_sha256 == fingerprint.to_string());
8326 if !registered {
8327 return Ok(None);
8328 }
8329 Ok(Some(gunnar_grpc::CertificateIdentity {
8330 principal_id: principal.id,
8331 fingerprint: fingerprint.to_string(),
8332 }))
8333 }
8334
8335 /// f-principal-reach: what `fed_principals::install` narrowed a `billing`
8336 /// principal to, from the data root.
8337 fn narrowed_methods(&self, principal_id: &str) -> ControlResult<Option<Vec<String>>> {
8338 // ★ The estate's repair principal is narrowed by its NAME, on every box and in every
8339 // build (main rebuild, owner 2026-10-12): never admin, whatever it was granted.
8340 if principal_id == gunnar_grpc::REPAIR_PRINCIPAL {
8341 return Ok(Some(
8342 gunnar_grpc::REPAIR_METHODS
8343 .iter()
8344 .map(|m| m.to_string())
8345 .collect(),
8346 ));
8347 }
8348 // The narrowing is written by the fed principals (the `wg-client` build,
8349 // the appliance's); a build without them narrows nobody.
8350 #[cfg(feature = "wg-client")]
8351 return crate::fed_principals::narrowed_of(self.stores.root(), principal_id)
8352 .map_err(ControlError::Internal);
8353 #[cfg(not(feature = "wg-client"))]
8354 {
8355 let _ = principal_id;
8356 Ok(None)
8357 }
8358 }
8359
8360 fn may(
8361 &self,
8362 principal_id: &str,
8363 access: &gunnar_grpc::Access,
8364 level: gunnar_proto::GrantLevel,
8365 ) -> ControlResult<bool> {
8366 let level = level_in(level)?;
8367 let auth = self.stores.auth();
8368 match access {
8369 gunnar_grpc::Access::Server => auth.may_server(principal_id, level).map_err(internal),
8370 gunnar_grpc::Access::Namespace(namespace) => auth
8371 .may_namespace(principal_id, namespace, level)
8372 .map_err(internal),
8373 gunnar_grpc::Access::Repo(path) => {
8374 let repo_ref = Self::repo_ref(path)?;
8375 // A grant on a repository that does not exist means nothing.
8376 // Answering `true` here would let a caller probe the ACL for
8377 // repositories it cannot see.
8378 let Some(repo) = self.stores.catalog().get(&repo_ref).map_err(internal)? else {
8379 return Ok(false);
8380 };
8381 auth.may(principal_id, &repo, level).map_err(internal)
8382 }
8383 }
8384 }
8385
8386 /// One read of the catalog and one of the identity table for the whole
8387 /// listing (round 5.3.1); a row that does not exist is `false`, as `may`.
8388 fn may_each_repo(
8389 &self,
8390 principal_id: &str,
8391 paths: &[RepoPath],
8392 level: gunnar_proto::GrantLevel,
8393 ) -> ControlResult<Vec<bool>> {
8394 let level = level_in(level)?;
8395 let refs = paths
8396 .iter()
8397 .map(Self::repo_ref)
8398 .collect::<ControlResult<Vec<_>>>()?;
8399 let rows = self.stores.catalog().get_many(&refs).map_err(internal)?;
8400 let known: Vec<gunnar_core::model::Repo> = rows.iter().flatten().cloned().collect();
8401 let mut answers = self
8402 .stores
8403 .auth()
8404 .may_each(principal_id, &known, level)
8405 .map_err(internal)?
8406 .into_iter();
8407 Ok(rows
8408 .iter()
8409 .map(|row| row.is_some() && answers.next().unwrap_or(false))
8410 .collect())
8411 }
8412}
8413
8414// ───────────────────────────────────────────────────────────────────────────
8415// Policy: the proto shape and the engine shape
8416// ───────────────────────────────────────────────────────────────────────────
8417
8418/// One `gunnar_policy::ProtectionRule`, as the protocol spells it.
8419///
8420/// The two limit fields come back **0**, which the proto documents as "no
8421/// limit", and that is the truth about a *per-rule* limit: this engine has
8422/// none. The repository-wide ceilings live in the policy document's `limits`
8423/// and are reported by nothing on this service — see `set_protection` for why
8424/// writing them here is refused rather than approximated.
8425fn protection_out(rule: &gunnar_policy::ProtectionRule) -> BranchProtection {
8426 BranchProtection {
8427 ref_pattern: rule.pattern.as_str().to_owned(),
8428 allow_force_push: rule.allow_force_push,
8429 allow_delete: rule.allow_delete,
8430 require_signature: rule.require_signature,
8431 // `None` — "anybody" — is the empty list, which is what the proto
8432 // means by empty. The round trip is stable in both directions.
8433 allowed_principal_ids: rule.allowed_principals.clone().unwrap_or_default(),
8434 max_object_bytes: 0,
8435 max_objects_per_push: 0,
8436 forbid_self_approval: rule.forbid_self_approval,
8437 required_approvals: rule.required_approvals,
8438 }
8439}
8440
8441fn ref_name_policy_out(policy: &gunnar_policy::RefNamePolicy) -> RefNamePolicy {
8442 RefNamePolicy {
8443 allow_patterns: policy.allow.iter().map(|p| p.as_str().to_owned()).collect(),
8444 deny_patterns: policy.deny.iter().map(|p| p.as_str().to_owned()).collect(),
8445 }
8446}
8447
8448/// One `gunnar_core::MirrorRecord`, as the protocol spells it.
8449///
8450/// The state is **derived from the record's own facts**, not stored beside
8451/// them: `SYNCING` is a live condition and nothing here runs a sync, so a
8452/// stored `SYNCING` could only ever be a lie left behind by a crash. What the
8453/// record can honestly report is `FAILED` when the last attempt left an error
8454/// and `IDLE` otherwise — and a registration nobody has synced is `IDLE`, which
8455/// is what it is.
8456fn mirror_out(record: &gunnar_core::MirrorRecord) -> MirrorConfig {
8457 let (namespace, repo) = record
8458 .repo
8459 .split_once('/')
8460 .unwrap_or((record.repo.as_str(), ""));
8461 MirrorConfig {
8462 repo: Some(RepoPath::new(namespace, repo)),
8463 remote_url: record.remote_url.clone(),
8464 prune: record.prune,
8465 state: if record.last_error.is_empty() {
8466 gunnar_proto::MirrorState::Idle as i32
8467 } else {
8468 gunnar_proto::MirrorState::Failed as i32
8469 },
8470 added_at_unix_ms: record.added_at_unix_ms,
8471 last_sync_at_unix_ms: record.last_sync_at_unix_ms,
8472 last_error: record.last_error.clone(),
8473 sync_count: record.sync_count,
8474 // **The kind, and never the value.** `WebhookEndpoint.has_secret`'s
8475 // rule, one step wider because there are two kinds here and picking the
8476 // wrong one is a refusal an operator has to be able to see coming.
8477 credential: credential_kind_out(record.credential) as i32,
8478 credential_username: record.credential_username.clone(),
8479 // Filled in by the caller that holds the queue; a bare record does not
8480 // know whether a job exists for it.
8481 job: None,
8482 repo_pending: record.creates_repo,
8483 lfs_not_copied: record.lfs_detected,
8484 }
8485}
8486
8487/// The proto's credential kind onto the store's.
8488fn credential_kind_in(
8489 kind: gunnar_proto::MirrorCredentialKind,
8490) -> gunnar_core::MirrorCredentialKind {
8491 match kind {
8492 gunnar_proto::MirrorCredentialKind::Unspecified => gunnar_core::MirrorCredentialKind::None,
8493 gunnar_proto::MirrorCredentialKind::SshKey => gunnar_core::MirrorCredentialKind::SshKey,
8494 gunnar_proto::MirrorCredentialKind::Token => gunnar_core::MirrorCredentialKind::Token,
8495 }
8496}
8497
8498/// The store's credential kind onto the proto's.
8499fn credential_kind_out(
8500 kind: gunnar_core::MirrorCredentialKind,
8501) -> gunnar_proto::MirrorCredentialKind {
8502 match kind {
8503 gunnar_core::MirrorCredentialKind::None => gunnar_proto::MirrorCredentialKind::Unspecified,
8504 gunnar_core::MirrorCredentialKind::SshKey => gunnar_proto::MirrorCredentialKind::SshKey,
8505 gunnar_core::MirrorCredentialKind::Token => gunnar_proto::MirrorCredentialKind::Token,
8506 }
8507}
8508
8509/// **Unseal the credential a sync dials with, or say there is none.**
8510///
8511/// The document is the authority on WHETHER there is one and the secret store
8512/// on WHAT it is, and a disagreement between them is reported rather than
8513/// smoothed over: a record that claims a credential whose sealed value is gone
8514/// would otherwise dial anonymously and be refused by the upstream, which sends
8515/// an operator to rotate a key that is not the problem.
8516///
8517/// The returned [`crate::mirror_fetch::Credential`] is deliberately not
8518/// `Debug`, so nothing downstream can format it by accident.
8519fn read_credential(
8520 stores: &Arc<ServerStores>,
8521 repo: &gunnar_core::RepoRef,
8522) -> Result<crate::mirror_fetch::Credential, String> {
8523 let record = stores
8524 .mirrors()
8525 .get(repo)
8526 .map_err(|err| {
8527 format!(
8528 "reading the mirror registration for {}: {err:#}",
8529 repo.key()
8530 )
8531 })?
8532 .ok_or_else(|| format!("no mirror is registered for {} any more", repo.key()))?;
8533 if !record.credential.is_set() {
8534 return Ok(crate::mirror_fetch::Credential::None);
8535 }
8536 let secrets = stores
8537 .secrets()
8538 .map_err(|err| format!("the sealed secret store is not available: {err:#}"))?;
8539 let key = gunnar_core::credential_secret_key(repo);
8540 let sealed = gunnar_store::SecretStore::get(&secrets, &key)
8541 // `{err}` and not `{err:#}`: this string lands on a job a console
8542 // renders, and a chain from the secret store is where a value could
8543 // ride out.
8544 .map_err(|err| {
8545 format!(
8546 "the mirror credential for {} could not be read: {err}",
8547 repo.key()
8548 )
8549 })?
8550 .ok_or_else(|| {
8551 format!(
8552 "{} is registered as having {}, and the sealed value is not in the secret \
8553 store. Set it again with `Mirror.SetCredential`; dialling anonymously \
8554 instead would be refused by the upstream and read as a bad key.",
8555 repo.key(),
8556 record.credential.name()
8557 )
8558 })?;
8559 Ok(match record.credential {
8560 gunnar_core::MirrorCredentialKind::SshKey => {
8561 crate::mirror_fetch::Credential::SshKey(sealed)
8562 }
8563 gunnar_core::MirrorCredentialKind::Token => crate::mirror_fetch::Credential::Token {
8564 username: record.credential_username.clone(),
8565 secret: String::from_utf8(sealed).map_err(|_| {
8566 format!(
8567 "the token stored for {} is not utf-8, so it cannot be an HTTP header value",
8568 repo.key()
8569 )
8570 })?,
8571 },
8572 gunnar_core::MirrorCredentialKind::None => crate::mirror_fetch::Credential::None,
8573 })
8574}
8575
8576/// **One line in the namespace's account audit log** about a mirror sync, and
8577/// the same on the `gunnar::audit` target (a namespace with no account, a
8578/// community box, has the target only).
8579fn audit_line(
8580 stores: &ServerStores,
8581 repo: &gunnar_core::RepoRef,
8582 kind: &str,
8583 detail: &str,
8584 refused: bool,
8585) {
8586 tracing::info!(
8587 target: gunnar_grpc::AUDIT_TARGET,
8588 event = kind,
8589 repo = %repo.key(),
8590 detail,
8591 "mirror"
8592 );
8593 let Ok(Some(account)) = stores.auth().account_of_namespace(repo.namespace()) else {
8594 return;
8595 };
8596 let event = gunnar_core::account_audit::AccountEvent {
8597 kind,
8598 actor: "",
8599 actor_role: "system",
8600 target: &repo.key(),
8601 detail,
8602 outcome: if refused {
8603 gunnar_core::account_audit::Outcome::Refused
8604 } else {
8605 gunnar_core::account_audit::Outcome::Ok
8606 },
8607 };
8608 if let Err(err) = stores
8609 .account_audit()
8610 .append(&account, event, gunnar_core::now_ms())
8611 {
8612 tracing::warn!(target: "gunnar::git", repo = %repo.key(), %err, "a mirror sync's audit line was not written");
8613 }
8614}
8615
8616/// **How a sync ended, audited**: an import's (a first sync's) outcome always,
8617/// a later sync's only when it failed — a mirror polled every few minutes
8618/// would otherwise fill the log with "nothing new".
8619fn audit_mirror_outcome(
8620 stores: &ServerStores,
8621 repo: &gunnar_core::RepoRef,
8622 order: &crate::mirror_queue::MirrorOrder,
8623 first: bool,
8624 error: Option<&str>,
8625 report: &crate::job_queue::Reporter<crate::mirror_queue::Mirror>,
8626) {
8627 let upstream = crate::mirror_fetch::scrub(&order.upstream);
8628 match (first, error) {
8629 (true, None) => {
8630 let refs = report
8631 .snapshot()
8632 .map(|job| job.report.updates.len())
8633 .unwrap_or_default();
8634 audit_line(
8635 stores,
8636 repo,
8637 "mirror.import.done",
8638 &format!("imported {refs} reference(s) from {upstream}"),
8639 false,
8640 );
8641 }
8642 (true, Some(why)) => audit_line(
8643 stores,
8644 repo,
8645 "mirror.import.failed",
8646 &format!("from {upstream}: {why}"),
8647 true,
8648 ),
8649 (false, Some(why)) => audit_line(
8650 stores,
8651 repo,
8652 "mirror.sync.failed",
8653 &format!("from {upstream}: {why}"),
8654 true,
8655 ),
8656 (false, None) => {}
8657 }
8658}
8659
8660/// **The one place a [`crate::mirror_queue::MirrorJob`] becomes a wire job.**
8661///
8662/// `gc_job_out`'s twin, and shaped after it deliberately: three status RPCs
8663/// render the same queue fields, so a console reads a gc, a purge and a mirror
8664/// with one renderer.
8665///
8666/// `path` is threaded in rather than re-parsed out of the job's own key — it is
8667/// the request's own path, already validated, and re-parsing it here would be a
8668/// second resolver that could disagree with the first (one implementation).
8669fn mirror_job_out(path: &RepoPath, job: crate::mirror_queue::MirrorJob) -> gunnar_proto::MirrorJob {
8670 // The mirror's own half, taken out whole before the queue's half is: the
8671 // update list is owned and has to be MOVED onto the wire, and a job's
8672 // `Deref` only ever lends its report.
8673 let report = job.report;
8674 let report_refused = report.refused;
8675 let (progress_done, progress_total) = job.progress.unwrap_or((0, 0));
8676 gunnar_proto::MirrorJob {
8677 job_id: job.job_id,
8678 path: Some(path.clone()),
8679 state: match job.state {
8680 crate::mirror_queue::MirrorState::Queued => gunnar_proto::MirrorJobState::Queued as i32,
8681 crate::mirror_queue::MirrorState::Running => {
8682 gunnar_proto::MirrorJobState::Running as i32
8683 }
8684 crate::mirror_queue::MirrorState::Done => gunnar_proto::MirrorJobState::Done as i32,
8685 crate::mirror_queue::MirrorState::Failed => gunnar_proto::MirrorJobState::Failed as i32,
8686 },
8687 queue_position: job.queue_position,
8688 // **`has_progress` first.** A job that has not reached the packfile
8689 // header has no denominator, and `0/0` would be a progress bar
8690 // claiming zero per cent instead of admitting it does not know yet.
8691 has_progress: job.progress.is_some(),
8692 progress_done,
8693 progress_total,
8694 objects: report.objects,
8695 bytes: report.bytes,
8696 updates: report
8697 .updates
8698 .into_iter()
8699 .map(|(ref_name, old_oid, new_oid)| gunnar_proto::RefUpdate {
8700 ref_name,
8701 old_oid,
8702 new_oid,
8703 })
8704 .collect(),
8705 error: job.error.unwrap_or_default(),
8706 enqueued_at_unix_ms: job.enqueued_at_unix_ms,
8707 started_at_unix_ms: job.started_at_unix_ms,
8708 finished_at_unix_ms: job.finished_at_unix_ms,
8709 // Only a FAILED job carries the plan's refusal; a later success of
8710 // the same registration starts a new job with a fresh report.
8711 refused_reason: match (job.state, report_refused) {
8712 (crate::mirror_queue::MirrorState::Failed, crate::mirror_queue::Refusal::Plan) => {
8713 gunnar_proto::MirrorRefusal::Plan as i32
8714 }
8715 (crate::mirror_queue::MirrorState::Failed, crate::mirror_queue::Refusal::PackCap) => {
8716 gunnar_proto::MirrorRefusal::PackCap as i32
8717 }
8718 _ => gunnar_proto::MirrorRefusal::Unspecified as i32,
8719 },
8720 }
8721}
8722
8723// ── the forge event, across the wire ───────────────────────────────────────
8724//
8725// Two functions and no derive, because the wire shape and the signed shape are
8726// deliberately different things: the signed shape is what `sshsig` covers and
8727// may never change to suit a transport, and the wire shape is a `oneof` because
8728// protobuf has no sum type otherwise. Keeping the conversion explicit is what
8729// makes a change to either one a compile error rather than a silent re-encoding
8730// that breaks every stored signature.
8731
8732/// The wire form of a signed membership event.
8733fn to_proto_membership(
8734 signed: gunnar_core::SignedMembershipEvent,
8735) -> gunnar_proto::SignedMembershipEvent {
8736 gunnar_proto::SignedMembershipEvent {
8737 event: Some(gunnar_proto::MembershipEvent {
8738 group: signed.event.group,
8739 member: signed.event.member,
8740 action: proto_action(signed.event.action) as i32,
8741 actor: signed.event.actor,
8742 at_unix_ms: signed.event.at_unix_ms,
8743 }),
8744 key_fingerprint: signed.key_fingerprint,
8745 signature: signed.signature,
8746 }
8747}
8748
8749/// The signed core form of a wire key attestation. An absent `attestation` is
8750/// refused rather than defaulted, for the reason `from_proto_membership` gives.
8751fn from_proto_attestation(
8752 wire: gunnar_proto::SignedKeyAttestation,
8753) -> ControlResult<gunnar_core::SignedKeyAttestation> {
8754 let attestation = wire.attestation.ok_or_else(|| {
8755 ControlError::InvalidArgument("`attestation.attestation` is required".to_owned())
8756 })?;
8757 Ok(gunnar_core::SignedKeyAttestation {
8758 attestation: gunnar_core::KeyAttestation {
8759 principal_id: attestation.principal_id,
8760 fingerprint: attestation.fingerprint,
8761 at_unix_ms: attestation.at_unix_ms,
8762 },
8763 key_fingerprint: wire.key_fingerprint,
8764 signature: wire.signature,
8765 })
8766}
8767
8768/// The signed core form of a wire membership event.
8769///
8770/// An absent `event` or an `UNSPECIFIED` action is refused rather than
8771/// defaulted: proto3 makes every field optional, and a membership change with
8772/// no action is not a thing that happened.
8773fn from_proto_membership(
8774 wire: gunnar_proto::SignedMembershipEvent,
8775) -> ControlResult<gunnar_core::SignedMembershipEvent> {
8776 let event = wire
8777 .event
8778 .ok_or_else(|| ControlError::InvalidArgument("`event` is required".to_owned()))?;
8779 let action = match gunnar_proto::MembershipAction::try_from(event.action) {
8780 Ok(gunnar_proto::MembershipAction::Add) => gunnar_core::MembershipAction::Add,
8781 Ok(gunnar_proto::MembershipAction::Remove) => gunnar_core::MembershipAction::Remove,
8782 _ => {
8783 return Err(ControlError::InvalidArgument(
8784 "`event.action` must be ADD or REMOVE".to_owned(),
8785 ))
8786 }
8787 };
8788 Ok(gunnar_core::SignedMembershipEvent {
8789 event: gunnar_core::MembershipEvent {
8790 group: event.group,
8791 member: event.member,
8792 action,
8793 actor: event.actor,
8794 at_unix_ms: event.at_unix_ms,
8795 },
8796 key_fingerprint: wire.key_fingerprint,
8797 signature: wire.signature,
8798 })
8799}
8800
8801/// The wire enum for a core [`gunnar_core::MembershipAction`].
8802fn proto_action(action: gunnar_core::MembershipAction) -> gunnar_proto::MembershipAction {
8803 match action {
8804 gunnar_core::MembershipAction::Add => gunnar_proto::MembershipAction::Add,
8805 gunnar_core::MembershipAction::Remove => gunnar_proto::MembershipAction::Remove,
8806 }
8807}
8808
8809/// The wire form of a signed event.
8810fn to_proto_event(signed: gunnar_core::SignedEvent) -> gunnar_proto::SignedForgeEvent {
8811 use gunnar_proto::forge_event::Kind;
8812
8813 let kind = match signed.event.event {
8814 gunnar_core::Event::Commented {
8815 body,
8816 path,
8817 line,
8818 blob_oid,
8819 } => Kind::Commented(gunnar_proto::Commented {
8820 body,
8821 path,
8822 line,
8823 blob_oid,
8824 }),
8825 gunnar_core::Event::Approved { commit } => {
8826 Kind::Approved(gunnar_proto::Approved { commit })
8827 }
8828 gunnar_core::Event::Rejected { commit, reason } => {
8829 Kind::Rejected(gunnar_proto::Rejected { commit, reason })
8830 }
8831 gunnar_core::Event::IssueOpened { title, body } => {
8832 Kind::IssueOpened(gunnar_proto::IssueOpened { title, body })
8833 }
8834 gunnar_core::Event::IssueCommented { body } => {
8835 Kind::IssueCommented(gunnar_proto::IssueCommented { body })
8836 }
8837 gunnar_core::Event::IssueClosed { reason } => {
8838 Kind::IssueClosed(gunnar_proto::IssueClosed { reason })
8839 }
8840 };
8841 gunnar_proto::SignedForgeEvent {
8842 event: Some(gunnar_proto::ForgeEvent {
8843 repo: signed.event.repo,
8844 pull: signed.event.pull,
8845 actor: signed.event.actor,
8846 at_unix_ms: signed.event.at_unix_ms,
8847 kind: Some(kind),
8848 }),
8849 key_fingerprint: signed.key_fingerprint,
8850 signature: signed.signature,
8851 attested: signed.attested.map(|a| gunnar_proto::ForgeAttestation {
8852 session_stepped_up_at_unix_ms: a.session_stepped_up_at_unix_ms,
8853 }),
8854 }
8855}
8856
8857/// A folded [`gunnar_core::forge::Issue`], as the wire carries it.
8858///
8859/// Unlike a review, this IS the whole state — title, body, open — because there
8860/// is no record behind an issue for a reader to ask instead. The signatures on
8861/// the events are what make it evidence; the fold is what makes it a state.
8862fn issue_state(repo: &str, issue: &gunnar_core::forge::Issue) -> gunnar_proto::IssueState {
8863 gunnar_proto::IssueState {
8864 repo: repo.to_owned(),
8865 number: issue.number,
8866 title: issue.title.clone(),
8867 body: issue.body.clone(),
8868 author: issue.author.clone(),
8869 open: issue.open,
8870 comments: issue.comments as u64,
8871 opened_at_unix_ms: issue.opened_at_unix_ms,
8872 }
8873}
8874
8875/// The signed form of a wire event.
8876///
8877/// An absent `kind` is refused rather than defaulted: proto3 makes every field
8878/// optional, and a `ForgeEvent` with no kind is not a thing that happened.
8879fn from_proto_event(
8880 wire: gunnar_proto::SignedForgeEvent,
8881) -> ControlResult<gunnar_core::SignedEvent> {
8882 use gunnar_proto::forge_event::Kind;
8883
8884 let event = wire
8885 .event
8886 .ok_or_else(|| ControlError::InvalidArgument("`event` is required".to_owned()))?;
8887 let kind = event
8888 .kind
8889 .ok_or_else(|| ControlError::InvalidArgument("`event.kind` is required".to_owned()))?;
8890 let inner = match kind {
8891 Kind::Commented(it) => gunnar_core::Event::Commented {
8892 body: it.body,
8893 path: it.path,
8894 line: it.line,
8895 blob_oid: it.blob_oid,
8896 },
8897 Kind::Approved(it) => gunnar_core::Event::Approved { commit: it.commit },
8898 Kind::Rejected(it) => gunnar_core::Event::Rejected {
8899 commit: it.commit,
8900 reason: it.reason,
8901 },
8902 Kind::IssueOpened(it) => gunnar_core::Event::IssueOpened {
8903 title: it.title,
8904 body: it.body,
8905 },
8906 Kind::IssueCommented(it) => gunnar_core::Event::IssueCommented { body: it.body },
8907 Kind::IssueClosed(it) => gunnar_core::Event::IssueClosed { reason: it.reason },
8908 };
8909 Ok(gunnar_core::SignedEvent {
8910 event: gunnar_core::ForgeEvent {
8911 repo: event.repo,
8912 pull: event.pull,
8913 actor: event.actor,
8914 at_unix_ms: event.at_unix_ms,
8915 event: inner,
8916 },
8917 key_fingerprint: wire.key_fingerprint,
8918 signature: wire.signature,
8919 // A client's word never becomes gunnar's attestation: `forge_append`
8920 // refuses a wire event that carries one, and only
8921 // `forge_append_attested` makes one.
8922 attested: None,
8923 })
8924}
8925#[cfg(test)]
8926mod tests {
8927 use super::*;
8928
8929 /// ★ **A box being RESTORED answers a write UNAVAILABLE, never INTERNAL** (r5.1, integration
8930 /// dev run 3: the front certifying the twin against a main being restored read "the primary
8931 /// would not enrol gunnar-twin: the membership: Internal: internal error" for minutes — a bug
8932 /// to anyone reading the log, and to the guest). Its log follows the twin's until the restore
8933 /// is whole, so every write is refused: said as "try again", which a generated client retries.
8934 #[test]
8935 fn a_write_on_a_restoring_box_is_unavailable_not_internal() {
8936 let td = tempfile::tempdir().unwrap();
8937 let stores = crate::stores::ServerStores::open(td.path()).unwrap();
8938 stores.txlog().set_follower(true);
8939 let p = gunnar_core::Principal::new("alice", "alice", gunnar_core::PrincipalKind::User)
8940 .unwrap();
8941 let err = stores
8942 .auth()
8943 .add_principal(p)
8944 .map_err(internal)
8945 .expect_err("a follower log takes no write");
8946 assert!(matches!(err, ControlError::Unavailable(_)), "{err:?}");
8947 assert!(err.to_string().contains("restor"), "{err}");
8948 }
8949
8950 /// **`INTERNAL` must be refused by name, never widened.**
8951 ///
8952 /// The regression this locks down was real: `visibility_in` aliased
8953 /// `INTERNAL` onto `Public` from before 2026-08-03, when public still meant
8954 /// "any authenticated principal". After that the alias silently PUBLISHED
8955 /// repositories whose creator asked for the narrower thing, and the
8956 /// console's visibility picker walked straight into it (2026-08-19).
8957 /// The green arms are the identity-value guard: without them a
8958 /// `visibility_in` that refuses everything would pass the red arm too.
8959 #[test]
8960 fn internal_is_refused_by_name_and_the_two_real_visibilities_pass() {
8961 let err = visibility_in(gunnar_proto::Visibility::Internal)
8962 .expect_err("INTERNAL has no implementation and must be refused");
8963 let text = err.to_string();
8964 assert!(
8965 text.contains("internal") && text.contains("private"),
8966 "the refusal must name the value and the real choices: {text}"
8967 );
8968 assert_eq!(
8969 visibility_in(gunnar_proto::Visibility::Public).expect("public is real"),
8970 Visibility::Public
8971 );
8972 assert_eq!(
8973 visibility_in(gunnar_proto::Visibility::Private).expect("private is real"),
8974 Visibility::Private
8975 );
8976 // Unspecified still lands on the only safe default.
8977 assert_eq!(
8978 visibility_in(gunnar_proto::Visibility::Unspecified).expect("unspecified defaults"),
8979 Visibility::Private
8980 );
8981 }
8982
8983 /// **The abdication flush ceiling is env-tunable, but only UPWARD-sane.**
8984 ///
8985 /// The flush is a poll (drained-or-deadline); the ceiling only bounds how
8986 /// long a slow standby is given before the roster clears. A loaded rig may
8987 /// need more than the 30 s default, so `GUNNAR_ABDICATE_FLUSH_SECS` raises
8988 /// it — but a garbage or zero value must NOT collapse it to something that
8989 /// truncates the flush before a single job drains. The identity-value guard
8990 /// here is the default-path assert: without it a resolver that ignored the
8991 /// override entirely (always returning 30) would still pass the "bad input
8992 /// falls back" arms, so the `120 -> 120` arm is what proves the override is
8993 /// actually read.
8994 #[cfg(feature = "replication")]
8995 #[test]
8996 fn abdicate_flush_ceiling_honours_a_sane_override_and_rejects_the_rest() {
8997 // A real, larger ceiling for a loaded box is taken verbatim.
8998 assert_eq!(
8999 resolve_flush_secs(Some("120")),
9000 120,
9001 "a positive override must win"
9002 );
9003 assert_eq!(
9004 resolve_flush_secs(Some(" 90 ")),
9005 90,
9006 "surrounding whitespace is trimmed"
9007 );
9008 // The default when nothing is set — and it must NOT equal the override
9009 // above, or the test could not tell a read override from a dead one.
9010 assert_eq!(resolve_flush_secs(None), ABDICATE_FLUSH_SECS);
9011 assert_ne!(
9012 ABDICATE_FLUSH_SECS, 120,
9013 "default must differ from the override arm"
9014 );
9015 // Every wrong shape falls back to the safe default, never to 0.
9016 assert_eq!(
9017 resolve_flush_secs(Some("")),
9018 ABDICATE_FLUSH_SECS,
9019 "blank falls back"
9020 );
9021 assert_eq!(
9022 resolve_flush_secs(Some("nope")),
9023 ABDICATE_FLUSH_SECS,
9024 "non-numeric falls back"
9025 );
9026 assert_eq!(
9027 resolve_flush_secs(Some("0")),
9028 ABDICATE_FLUSH_SECS,
9029 "zero would truncate — refused"
9030 );
9031 assert_eq!(
9032 resolve_flush_secs(Some("-5")),
9033 ABDICATE_FLUSH_SECS,
9034 "negative does not parse as u64"
9035 );
9036 }
9037}
9038
9039/// An encryption-key refusal as the control plane says it (CRYPT-YUBI).
9040fn key_refusal(r: gunnar_core::encryption_key::KeyRefusal) -> ControlError {
9041 use gunnar_core::encryption_key::KeyRefusal as R;
9042 match r {
9043 R::AlreadyHasKey(s) | R::MethodHeld(s) => ControlError::AlreadyExists(s),
9044 R::NoSuchMethod(s) => ControlError::NotFound(s),
9045 R::NoKey(s) => ControlError::FailedPrecondition(s),
9046 R::LastMethod(s) => ControlError::FailedPrecondition(s),
9047 R::Invalid(s) => ControlError::InvalidArgument(s),
9048 R::Store(e) => internal(e),
9049 }
9050}
9051
9052fn method_in(
9053 m: gunnar_proto::UnlockMethod,
9054) -> ControlResult<gunnar_core::encryption_key::UnlockMethod> {
9055 Ok(gunnar_core::encryption_key::UnlockMethod {
9056 id: String::new(),
9057 kind: gunnar_core::encryption_key::MethodKind::parse(&m.kind).map_err(key_refusal)?,
9058 label: m.label.trim().to_owned(),
9059 method_ref: m.method_ref.trim().to_owned(),
9060 rp_id: m.rp_id.trim().to_owned(),
9061 wrap: m.wrap,
9062 added_ms: 0,
9063 })
9064}
9065
9066fn encryption_key_out(
9067 key: Option<gunnar_core::encryption_key::EncryptionKey>,
9068) -> gunnar_proto::GetEncryptionKeyResponse {
9069 match key {
9070 None => gunnar_proto::GetEncryptionKeyResponse::default(),
9071 Some(k) => gunnar_proto::GetEncryptionKeyResponse {
9072 exists: true,
9073 public_key: k.public_key,
9074 methods: k
9075 .methods
9076 .into_iter()
9077 .map(|m| gunnar_proto::UnlockMethod {
9078 id: m.id,
9079 kind: m.kind.word().to_owned(),
9080 label: m.label,
9081 method_ref: m.method_ref,
9082 rp_id: m.rp_id,
9083 wrap: m.wrap,
9084 added_ms: m.added_ms,
9085 })
9086 .collect(),
9087 },
9088 }
9089}