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