gunnar_server/stores.rs
1//! Where the server's state lives on this box, and the one place a repository
2//! is turned into a pair of stores.
3//!
4//! ```text
5//! <data-root>/
6//! control.redb the catalog, the identity table, the issuer
7//! certificate AND the sealed secrets (the certificate-identity plan)
8//! secrets.kek the key-encrypting key — a file, not a row
9//! control.git/ LEGACY. The Arrow secret log, left where it is.
10//! repos/<ns>/<name>.git/ one bare repository per catalog entry
11//! ```
12//!
13//! # One control store, and the migration that made it one
14//!
15//! The catalog and the identity table used to be two JSON blobs behind two
16//! references *inside* `control.git`. They moved to redb because the storage
17//! engine takes objects as packs and refuses a single-object write, which is
18//! exactly what a control document is — `gunnar_core::control` has the argument.
19//!
20//! What did **not** move then was `RepoStore::secrets()`, the Arrow log the
21//! sealed SSH host key lived in, and this header used to say why: *"folding it
22//! in then would have been a second migration riding on the first"*. It
23//! also named what deferring it cost — the control **repository** was still
24//! opened, so a `gunnar-server` still needed a storage engine to read its own
25//! host key, and the only mitigation was to open it **lazily** so that at least
26//! the catalog and the identity table stayed reachable without one.
27//!
28//! **That second migration happened on 2026-08-10.** Secrets are rows in
29//! `control.redb` ([`gunnar_core::secrets`]), the lazy `OnceLock` is gone, and
30//! so is [`RepoStore::secrets`] and the per-repository Arrow log every
31//! `open_repo` used to create beside a repository nobody read secrets from.
32//! `ServerStores` no longer opens a repository for any reason except serving
33//! one.
34//!
35//! # 🚨 The live SSH host key crosses this, and it is not a flag day
36//!
37//! `serve.rs` calls `ensure_host_key` on every boot. A migration that lost the
38//! key would mint a fresh one and hand every existing client
39//! `REMOTE HOST IDENTIFICATION HAS CHANGED`, which is what an attack looks like.
40//! So [`ServerStores::secrets`] is a **read-old / write-new** store
41//! ([`gunnar_core::MigratingSecretStore`]): redb first, the Arrow log as the
42//! fallback, the adopted value written back into redb, and **the Arrow log left
43//! on disk byte for byte**. An appliance that boots the new binary keeps its
44//! fingerprint; one that is rolled back to the old binary still finds its key
45//! where it left it. Nothing here deletes old data.
46//!
47//! The legacy log is opened only if `secrets.arrow` is already there
48//! ([`gunnar_core::legacy_secret_log`]), so a fresh installation never creates
49//! one, and it is opened as a **plain file through `gunnar_core::arrow_log`** —
50//! no engine is involved, and never needed to be.
51//!
52//! # One handle per repository, and a bound on how many
53//!
54//! An engine's store takes a writer lock on its own directory — the znippy arm
55//! caches one `GitStore` per path *under* the lock and says why (*"two
56//! instances over one directory would be two writers with two tails"*) — so two
57//! handles on the same git directory must not exist at once. Opening one per
58//! request would therefore fail the moment two clones of the same repository
59//! overlapped. Handles are cached instead, which is also what the
60//! single-writer requirement of the Arrow-IPC ref log wants.
61//!
62//! **Correction, 2026-08-10.** This paragraph used to make that argument about
63//! `FsStore`'s `O_EXCL` lock on **its Arrow secret log**. That lock is gone with
64//! the log: a repository no longer has a secret store, so nothing about
65//! *secrets* constrains how many handles it can have. The constraint is real and
66//! it is the engine's own writer lock; only the citation was stale.
67//!
68//! Until 2026-08-03 that cache had no bound and no eviction, so a server that
69//! touched a million repositories accumulated a million open handles: a lock
70//! file, an object store with its mmapped packs, and its logs, each. File
71//! descriptors run out long before memory does, and when they do *every* open
72//! fails while the cache holds descriptors for repositories nobody has touched
73//! in weeks.
74//!
75//! It is now a bounded LRU, and the two halves of that are equally load-bearing:
76//!
77//! * **the bound**: [`DEFAULT_OPEN_REPOS`] handles, evicting the least recently
78//! used on insert; and
79//! * **liveness**: the value is an `Arc<OpenRepo>`, and an entry whose
80//! handle somebody else still holds is **skipped by the evictor**. Dropping it
81//! from the map would not close the in-flight transfer (the `Arc` keeps it
82//! alive), but it would let the *next* request for that repository try to open
83//! a second store on a git directory whose writer lock is still held, turning
84//! a capacity policy into a spurious "the store is locked" failure in the
85//! middle of a clone.
86//!
87//! The map is behind one mutex, which is still the server's one global
88//! serialisation point. The critical section holds no I/O (opening a store
89//! happens outside it), but a sharded map is the next thing to do here if
90//! contention ever shows up in a profile. It has not been measured, so it has
91//! not been built.
92
93use std::collections::HashMap;
94use std::path::{Path, PathBuf};
95use std::sync::atomic::AtomicBool;
96use std::sync::atomic::AtomicU64;
97use std::sync::{Arc, Mutex, MutexGuard};
98
99use anyhow::{Context, Result};
100use gunnar_core::{AuthTable, ControlStore, HashKind, NewRepo, Repo, RepoCatalog, RepoRef};
101use gunnar_ssh::Control;
102
103use crate::storage::OpenRepo;
104
105/// **Legacy, and kept.** The bare repository that held the catalog and the
106/// identity table until the certificate-identity plan moved them to [`CONTROL_DB`], and the sealed
107/// SSH host key until 2026-08-10 moved that there too.
108///
109/// It is still named, and still created, for two reasons that have nothing to
110/// do with git: [`gunnar_core::Kek::for_control_dir`] keeps `secrets.kek` in it
111/// — a file beside the data, deliberately not a row inside it — and
112/// [`gunnar_core::legacy_secret_log`] looks under it for the Arrow log an
113/// existing appliance's host key is in. **No storage engine opens it any more.**
114pub const CONTROL_DIR: &str = "control.git";
115
116/// **The control database**: the catalog, the identity table, the issuer
117/// certificate and the sealed secrets (the certificate-identity plan).
118pub const CONTROL_DB: &str = "control.redb";
119
120/// The directory every served repository lives under.
121pub const REPOS_DIR: &str = "repos";
122/// Where the transaction log lives under the data root.
123pub const TXLOG_DIR: &str = "txlog";
124
125/// **The identity half of the metadata mirror**, under
126/// `<slow>/metadata/` — principals and enrolled public keys, and nothing
127/// secret.
128///
129/// A mirror and never a source: nothing reads it back into the control store.
130/// It is there for the morning after the stripe is gone, when the question is
131/// *who had access to this box, and with which keys* — and `control.redb` is on
132/// the four disks that just died together.
133pub const IDENTITY_MIRROR: &str = "identity.json";
134
135/// The hash kind the **legacy** Arrow secret log under [`CONTROL_DIR`] was
136/// opened with, and therefore the one it must be reopened with to be read.
137///
138/// Nothing git-shaped was ever stored in that log — `ArrowLogStore` records a
139/// hash kind and refuses a reopen that disagrees with it (`Error::
140/// HashKindMismatch`), so this is a compatibility constant and not a choice
141/// being made afresh. sha256 because that is gunnar's default and what
142/// every control repository was opened with.
143pub const CONTROL_HASH_KIND: gix_hash::Kind = gix_hash::Kind::Sha256;
144
145/// How many repository handles are kept open at once.
146///
147/// Each one costs several file descriptors: the engine's writer lock and
148/// whatever its object store has open — mmapped packs and a redb tail on the
149/// znippy arm, loose objects and packs on the gix one. (It was "ten, including
150/// the two Arrow logs" until 2026-08-10; a repository has no Arrow logs any
151/// more.) So this is a descriptor budget in disguise. 256 sits an order of magnitude
152/// under a default 1024-descriptor `RLIMIT_NOFILE` while still keeping every
153/// repository an ordinary server touches in a minute resident.
154///
155/// It is a *cache* bound, not a concurrency limit: a repository whose handle is
156/// in use is never evicted, so exceeding it costs a re-open, never a failure.
157///
158/// **It is the CEILING, not the number** (2026-08-29). "256 sits an order of
159/// magnitude under a default 1024-descriptor `RLIMIT_NOFILE`" was arithmetic
160/// on handles, and a handle is five descriptors on the znippy engine — 256
161/// handles are 1280 descriptors, over the 1024 the appliance's PID 1 ran with.
162/// The rig hit `EMFILE` on the 202nd repository. [`ServerStores::open`] now
163/// takes [`crate::limits::open_repo_budget`] of the limit the process actually
164/// has: this value under a comfortable limit, 153 under 1024.
165pub const DEFAULT_OPEN_REPOS: usize = 256;
166
167/// Below this share of `MemTotal` still available, the handle cache gives
168/// handles back ([`ServerStores::reclaim_under_pressure`]).
169///
170/// Twenty-five percent, one step **above** the indexer watchdog's 20 % floor
171/// (`ZNIPPY_GIT_MEM_BUDGET_PCT`, the memory limit) and
172/// deliberately so: the cache is the cheaper thing to give up, and it should
173/// have given some back before the watchdog starts pausing pushes. The 2 GB box's
174/// 2 GB box reached the watchdog's floor with 256 handles still held and no way
175/// to reach them.
176pub const RECLAIM_FLOOR_PERCENT: u64 = 25;
177
178/// How often the memory behind the handle cache is probed, at most.
179///
180/// A quarter second: fast enough that a box falling into pressure gives handles
181/// back within a few opens, slow enough that a `/proc/meminfo` read is never on
182/// a hot path. It is taken only on a cache miss, which already costs a store
183/// open.
184pub const RECLAIM_PROBE_INTERVAL: std::time::Duration = std::time::Duration::from_millis(250);
185
186/// **How long a soft-deleted repository's bytes are kept before a sweep
187/// reclaims them.** Thirty days, which is the ordinary expectation of a paid
188/// forge and the number the drill states.
189///
190/// It is a DEFAULT and not a rule: every delete carries its own
191/// `purge_after_unix_ms`, written into the tombstone at the moment of the
192/// delete, so changing this constant never moves the deadline of a repository
193/// that is already deleted. A promise made to a customer on the day they
194/// deleted something must not be shortened by a later deploy.
195pub const DEFAULT_DELETE_RETENTION_DAYS: u32 = 30;
196
197/// How often [`ServerStores::sweep_expired_tombstones`] runs on a serving box.
198///
199/// Hourly. The window is measured in days, so the sweep's own period is noise
200/// against it, and an hour is short enough that an operator who set a
201/// `--retention-days 0` to reclaim space sees it happen while they are still
202/// watching.
203pub const TOMBSTONE_SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(3600);
204
205/// **How often the flow ledger's live counters are copied into the control
206/// store: every 60 seconds.**
207///
208/// The window this bounds is *how much of today is lost to a hard power cut*,
209/// and a minute of one namespace's byte counts is the right size of loss for
210/// the write it costs — one redb commit per minute for the whole box, however
211/// many namespaces moved bytes, because the flush batches every row into one
212/// document update. A one-second flush would buy a minute of accuracy on a
213/// meter nobody bills to the minute and pay for it sixty times over; an hourly
214/// one would make a reboot look like a quiet hour.
215pub const FLOW_FLUSH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
216
217/// Why a name is tombstoned. The two cases are NOT the same and must never be
218/// collapsed.
219#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
220#[serde(rename_all = "snake_case")]
221pub enum TombstoneReason {
222 /// **A soft delete.** The bytes are being HELD, on purpose, until
223 /// `purge_after_unix_ms`. `repo undelete` puts the repository back.
224 Deleted,
225 /// **A reclaim that FAILED** (2026-08-30). The bytes are OWED, not
226 /// held: a `remove_dir_all` did not work and the name is fenced off until
227 /// `repo delete` is run again and succeeds. There is nothing to undelete —
228 /// the operator asked for the bytes to go.
229 ReclaimFailed,
230}
231
232impl TombstoneReason {
233 /// The word a message uses.
234 pub fn as_str(self) -> &'static str {
235 match self {
236 TombstoneReason::Deleted => "soft-deleted",
237 TombstoneReason::ReclaimFailed => "reclaim failed",
238 }
239 }
240}
241
242/// **A dated tombstone: the record beside a repository whose catalog row is
243/// gone and whose bytes are not.**
244///
245/// Written by [`ServerStores::soft_delete`] and by a failed
246/// [`ServerStores::purge_repo`], read by [`ServerStores::refuse_stale_store`]
247/// (which is what makes the NAME unusable), by
248/// [`ServerStores::undelete`] and by the sweep.
249///
250/// # Why the catalog row is copied in here
251///
252/// `undelete` has to restore what was there — the hash kind above all. A
253/// repository recreated `--hash sha256` over a sha1 store serves sha1 refs to a
254/// client that asked for sha256 and answers `fatal: protocol error`; that is
255/// the 2026-08-30 resurrection this closed, and an undelete that guessed the
256/// defaults would re-open it from the other side. The store's own
257/// `OBJECT_FORMAT` marker is the second opinion, and
258/// [`ServerStores::create_repo`]'s adopt path checks them against each other.
259///
260/// # It is JSON, and it is read leniently
261///
262/// An earlier build wrote a free-text note here. A file that does not parse as JSON is
263/// therefore read as one of those: reason `ReclaimFailed`, no deadline, the
264/// text kept as the note. An old appliance's tombstone keeps working, and a
265/// new one is never mistaken for a soft delete it was not.
266#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
267pub struct Tombstone {
268 /// When the delete was asked for.
269 pub deleted_at_unix_ms: i64,
270 /// When a sweep may reclaim the bytes.
271 ///
272 /// `None` means **never on its own**: a failed reclaim is owed to an
273 /// operator, not scheduled, and a sweep that purged it would be doing the
274 /// thing that already failed, unattended.
275 pub purge_after_unix_ms: Option<i64>,
276 /// Who asked. Empty when a local CLI did it with no principal.
277 #[serde(default)]
278 pub principal_id: String,
279 /// Held, or owed.
280 pub reason: TombstoneReason,
281 /// The catalog row as it was, so `undelete` restores it rather than a
282 /// default. `None` on a tombstone written before this existed.
283 #[serde(default)]
284 pub repo: Option<Repo>,
285 /// A sentence a human reads.
286 #[serde(default)]
287 pub note: String,
288}
289
290impl Tombstone {
291 /// Read one, leniently. See the type docs on the free-text form.
292 pub fn parse(text: &str) -> Tombstone {
293 if let Ok(parsed) = serde_json::from_str::<Tombstone>(text) {
294 return parsed;
295 }
296 Tombstone {
297 deleted_at_unix_ms: 0,
298 purge_after_unix_ms: None,
299 principal_id: String::new(),
300 reason: TombstoneReason::ReclaimFailed,
301 repo: None,
302 note: text.trim().to_owned(),
303 }
304 }
305
306 /// Is the retention window over at `now`?
307 ///
308 /// A tombstone with no deadline is **never** expired, whatever `now` is.
309 pub fn is_expired_at(&self, now_unix_ms: i64) -> bool {
310 match self.purge_after_unix_ms {
311 Some(at) => now_unix_ms >= at,
312 None => false,
313 }
314 }
315
316 /// How long is left, in whole seconds, or `None` when there is no deadline.
317 pub fn seconds_left(&self, now_unix_ms: i64) -> Option<i64> {
318 self.purge_after_unix_ms
319 .map(|at| (at - now_unix_ms).max(0) / 1000)
320 }
321}
322
323/// One soft-deleted repository, as `repo list-deleted` and `Admin.StorageInfo`
324/// report it.
325#[derive(Debug, Clone)]
326pub struct DeletedRepo {
327 /// Where it was.
328 pub path: RepoRef,
329 /// The record beside it.
330 pub tombstone: Tombstone,
331 /// Bytes its directory still holds. **This is the number a bill is made
332 /// of**: a tombstone is storage the account is still using.
333 pub bytes: u64,
334}
335
336/// **A store directory with no catalog row and no soft-delete tombstone: an
337/// ORPHAN**, as `repo list-orphans` reports it.
338///
339/// This is the class of bytes nothing on the box could see before 2026-08-30.
340/// `repo list` reads the catalog and an orphan is not in it; `repo
341/// list-deleted`, `repo purge`, `repo undelete` and the expiry sweep all key
342/// off a `Deleted` tombstone and an orphan has none. Every reclaim path was
343/// keyed off a record that had gone.
344///
345/// The listing invariant this type exists to make true: **every `<name>.git`
346/// directory under `repos/` appears in exactly one of `repo list`, `repo
347/// list-deleted` and `repo list-orphans`** — so no directory on the disk is
348/// invisible to every door.
349#[derive(Debug, Clone)]
350pub struct OrphanStore {
351 /// The name the directory stands at.
352 pub path: RepoRef,
353 /// Where it is, for an operator with a shell — and there is none on the
354 /// appliance, which is why the other fields exist.
355 pub dir: PathBuf,
356 /// What it is holding.
357 pub bytes: u64,
358 /// The `OBJECT_FORMAT` marker, when it has one. `None` is a store written
359 /// before the marker existed, and it is what `create --adopt --hash` takes
360 /// the caller's word about.
361 pub hash_marker: Option<String>,
362 /// Newest mtime under the directory, so an operator can tell Friday's
363 /// wreckage from something still being written to.
364 pub modified_unix_ms: i64,
365 /// A tombstone that is **not** a soft delete — a reclaim that FAILED and
366 /// is owed to an operator. Those are orphans too: `tombstones()` filters
367 /// them out of `list-deleted` on purpose, so without this they would be in
368 /// no listing at all.
369 pub failed_reclaim: Option<String>,
370}
371
372/// The bounded LRU of open repository handles.
373struct HandleCache {
374 capacity: usize,
375 /// Repository key → its handle and the tick it was last handed out at.
376 entries: HashMap<String, (Arc<OpenRepo>, u64)>,
377 /// Monotonic, so "least recently used" is a comparison and not a list.
378 clock: u64,
379}
380
381impl HandleCache {
382 fn new(capacity: usize) -> Self {
383 HandleCache {
384 capacity: capacity.max(1),
385 entries: HashMap::new(),
386 clock: 0,
387 }
388 }
389
390 /// Lower (or raise) the bound and evict down to it.
391 ///
392 /// Returns how many handles were dropped, so the caller can say it once
393 /// rather than have this file guess at a log line's wording.
394 fn set_capacity(&mut self, capacity: usize) -> usize {
395 let before = self.entries.len();
396 self.capacity = capacity.max(1);
397 self.evict();
398 before.saturating_sub(self.entries.len())
399 }
400
401 fn touch(&mut self, key: &str) -> Option<Arc<OpenRepo>> {
402 self.clock += 1;
403 let clock = self.clock;
404 let (store, used) = self.entries.get_mut(key)?;
405 *used = clock;
406 Some(Arc::clone(store))
407 }
408
409 /// Insert `store` under `key` unless another thread already won that race,
410 /// and return whichever handle is now the cached one.
411 fn insert(&mut self, key: String, store: Arc<OpenRepo>) -> Arc<OpenRepo> {
412 if let Some(existing) = self.touch(&key) {
413 // The loser's handle is dropped by the caller, releasing its lock.
414 return existing;
415 }
416 self.clock += 1;
417 let clock = self.clock;
418 self.entries.insert(key, (Arc::clone(&store), clock));
419 self.evict();
420 store
421 }
422
423 /// Drop least-recently-used entries until the bound holds again, skipping
424 /// any handle somebody else is still holding.
425 ///
426 /// A cache that cannot get under its bound because every handle is in use
427 /// says so once per eviction pass rather than growing silently: over the
428 /// bound is a real state, and an operator whose descriptors are about to run
429 /// out needs to hear about it before `accept` starts failing.
430 fn evict(&mut self) {
431 while self.entries.len() > self.capacity {
432 let victim = self
433 .entries
434 .iter()
435 // The iterator borrows rather than clones, so `1` is the
436 // cache's own reference and nothing else. Anything above it is
437 // a caller holding the handle through an operation.
438 .filter(|(_, (store, _))| Arc::strong_count(store) == 1)
439 .min_by_key(|(_, (_, used))| *used)
440 .map(|(key, _)| key.clone());
441 match victim {
442 Some(key) => {
443 self.entries.remove(&key);
444 }
445 None => {
446 tracing::warn!(
447 open = self.entries.len(),
448 capacity = self.capacity,
449 "every open repository handle is in use; the cache is over its bound \
450 until one of them is released"
451 );
452 return;
453 }
454 }
455 }
456 }
457}
458
459/// Every store the server needs, opened once.
460pub struct ServerStores {
461 root: PathBuf,
462 repos_root: PathBuf,
463 control_dir: PathBuf,
464 /// **Which storage engine this process runs.**
465 ///
466 /// Resolved once, at [`ServerStores::open`], from `GUNNAR_ENGINE` or the
467 /// build's default. A field and not a global: the `OnceLock` that used to
468 /// hold an installed engine is gone with the crate that installed into it,
469 /// and "which engine" is now ordinary configuration that arrives with the
470 /// data root rather than a plugin registered from a `main` somewhere else.
471 ///
472 /// It is resolved *before* the first repository is opened, so a build asked
473 /// for an engine it does not carry fails at startup with a message naming
474 /// what it does carry — never by quietly serving from the other one.
475 engine: crate::storage::Engine,
476 /// **The raw secret store**, opened eagerly with everything else.
477 ///
478 /// It used to be a `OnceLock<Arc<dyn RepoStore>>` holding the control
479 /// *repository*, opened lazily because opening it needed a storage engine
480 /// and the catalog and the identity table had stopped needing one. There is
481 /// nothing left to defer: this is a table in the database that is already
482 /// open, plus — on an appliance that predates 2026-08-10 — a read-only
483 /// handle on the legacy Arrow log, which is a plain file.
484 ///
485 /// Raw, not sealed. [`Self::secrets`] is the only supported way to reach it
486 /// and it puts the seal on; nothing on the boot path may write a private key
487 /// through this handle. It is private for exactly that reason.
488 secrets: Arc<dyn gunnar_store::SecretStore>,
489 /// **Whether a pre-sealing secret log is on this disk at all.**
490 ///
491 /// The one fact that decides whether an unsealed value read back out of
492 /// [`Self::secrets`] is gunnar's own history or somebody else's writing.
493 /// `legacy_secret_log` answered it once, at open, out of `secrets.arrow`'s
494 /// existence; [`Self::secrets`] hands that answer to the seal as a
495 /// `gunnar_core::PlaintextPolicy` rather than letting the decorator guess it
496 /// from bytes that cannot carry it. `false` on every installation made after
497 /// the 2026-08-10 redb migration, which is every installation that was not
498 /// upgraded across it.
499 legacy_secrets: bool,
500 /// The key-encrypting key the control store's secrets are sealed under
501 /// Resolved at open, so a KEK that cannot be read fails the boot
502 /// loudly rather than at whatever moment first touches a secret. It is a
503 /// file beside the database, not a row in it — which is what makes a copied
504 /// `control.redb` useless — so it costs no engine either.
505 ///
506 /// Behind a lock (S9, 2026-10-10): a twin opens under a KEK of its own and is handed its
507 /// primary's as the log's first frame ([`Self::adopt_kek`]); from that frame on it opens the
508 /// rows it holds — a sold front's `Sealed.Get` on the twin — without a restart.
509 kek: std::sync::RwLock<gunnar_core::Kek>,
510 /// The catalog, the identity table and the issuer certificate (the certificate-identity plan).
511 control_store: Arc<dyn ControlStore>,
512 /// **The one redb handle** under `control_store` and `secrets` — what the
513 /// runtime data-set growth takes off the stripe and puts back
514 /// (`gunnar_core::ControlDb::suspend` / `resume`, `crate::grow`).
515 control_db: Arc<gunnar_core::ControlDb>,
516 /// **The repository stores are CLOSED for a growth** (`crate::grow`):
517 /// `repo_store` refuses to open anything while this is set, so a
518 /// background worker meets an error and retries rather than pinning the
519 /// mount that is about to go. Never set outside a growth.
520 suspended: AtomicBool,
521 /// **The transaction log** every durable mutation on this box is a frame
522 /// in, before it lands (`gunnar_core::txlog`). The twin's source.
523 txlog: Arc<gunnar_core::txlog::Log>,
524 /// This box is a twin (`<root>/twin.json`, `crate::twin`). Runtime, not decided once at open:
525 /// a fresh box becomes a twin through `Bootstrap.BecomeTwin` while it runs, and from that
526 /// moment it writes no per-box mirror (the KVM drill found the fresh box's own mirror left
527 /// on the twin's disk).
528 ///
529 /// **An `Arc` since 2026-09-21 (lane T4)**, so the SSH/HTTP front door can
530 /// hold the SAME flag rather than a copy taken at start-up: a box conferred
531 /// the twin role over the wire must stop serving git the instant the role
532 /// lands, not at the next restart.
533 twin: Arc<AtomicBool>,
534 /// **Snapshots being streamed from a cut** (R5.1-A, 2026-10-07; [`ServerStores::snapshot_pin`]):
535 /// while any is, a gc waits, and so does the removal of a repository store or an LFS object —
536 /// what the cut names must still be there when the stream reaches it.
537 snapshot_pins: SnapshotPins,
538 /// The control store UNDER the tee — the twin's apply path.
539 raw_control_store: Arc<dyn ControlStore>,
540 /// The secret store UNDER the tee — the twin's apply path.
541 raw_secrets: Arc<dyn gunnar_store::SecretStore>,
542 auth: AuthTable,
543 catalog: RepoCatalog,
544 /// Per-repository policy documents (`refs/gunnar/policy`).
545 ///
546 /// Opened with the other two and not lazily: it is read on the **push
547 /// path**, and a store opened on first push would make the first push of
548 /// every restart pay for it.
549 policies: gunnar_core::PolicyStore,
550 /// Pull-mirror registrations (`refs/gunnar/mirrors`).
551 mirrors: gunnar_core::MirrorStore,
552 /// **The flow ledger** (`refs/gunnar/flow`): what MOVED, per namespace, per
553 /// UTC day.
554 ///
555 /// An `Arc` and not a plain field because it is the one store here that is
556 /// written from the TRANSFER paths as well as read from the control plane —
557 /// `transport::serve_blocking` and `gunnar_ssh::Admission` each hold a
558 /// clone — and because its live half is a set of atomics that must be the
559 /// same set for every holder. Two ledgers over one control store would each
560 /// hold half the day and each overwrite the other's half on flush.
561 ///
562 /// Opened with the other documents rather than lazily, for
563 /// [`ServerStores::policies`]'s reason: it is read and written on the push
564 /// path, and a store opened on first push would make the first push of
565 /// every restart pay for it.
566 flow: Arc<gunnar_core::FlowLedger>,
567 /// **When each principal last signed in and last changed something**
568 /// (2026-10-21): in memory, minute-grained, flushed by its own thread in a
569 /// batch — one redb transaction and one `Activity` frame per flush that
570 /// moved something — never on the request path, never in the identity
571 /// document. See `gunnar_core::activity`.
572 activity: Arc<gunnar_core::PrincipalActivity>,
573 /// **The usage ledger** (2026-10-06): every namespace's stock meters, kept
574 /// current by re-measuring only what changed — `UsageBatch.ReadMany`'s
575 /// source, so a billing poll is not one disk walk per tenant. Marked from
576 /// this type's own mutation points (a store opening, a tombstone, a
577 /// growth's resume) and from the flow ledger's observer.
578 usage: Arc<crate::usage_ledger::UsageLedger>,
579 /// **Every account's audit log** (`refs/gunnar/account-audit/<account>`,
580 /// 2026-10-05): the account admin's, never the operator's.
581 account_audit: Arc<gunnar_core::account_audit::AccountAuditLog>,
582 /// **The name filter's lists and the accounts' review queues**
583 /// (`refs/gunnar/name-filter`, 2026-10-05).
584 names: gunnar_core::name_filter::NameFilterStore,
585 open: Mutex<HandleCache>,
586 /// When the memory probe behind [`ServerStores::reclaim_under_pressure`]
587 /// last ran. `None` until the first cache miss.
588 last_reclaim_probe: Mutex<Option<std::time::Instant>>,
589 /// **Is this box refusing client writes because it is a STANDBY?**
590 ///
591 /// One `Arc<AtomicBool>`, shared by clone into every [`Control`] this
592 /// `ssh_control` hands out AND read/written by the replication slot's
593 /// role changes — one value, so the SSH front door and the control plane
594 /// can never disagree about whether a `git push` should be refused (one implementation).
595 /// `true` = STANDBY (freeze non-`Machine` writes), `false` = PRIMARY.
596 ///
597 /// Initialised from the persisted role in `replication.json` at open, so a
598 /// box that abdicated and then rebooted comes back still frozen — the same
599 /// durability `demote`'s roster already has. Always present, even in a build
600 /// without the `replication` feature, where it simply never turns `true`.
601 writes_frozen: Arc<AtomicBool>,
602 /// **Is this box a browse replica**? Sibling of
603 /// `writes_frozen`, same lifecycle: set from `replication.json` at open,
604 /// flipped by the replication slot on a role change, READ by the control
605 /// plane's mutator refusal and by the front door's wording. A browse box is
606 /// also frozen; this flag is what distinguishes it from a promotable
607 /// standby.
608 browse: Arc<AtomicBool>,
609 /// **How many repository stores this process has opened**, cumulative and
610 /// monotonic.
611 ///
612 /// One counter, bumped at the single site an engine store is actually
613 /// opened (a [`HandleCache`] miss in [`Self::repo_store`]) — not on a cache
614 /// hit, which pays nothing. Each open pays the refold cost, so a
615 /// path that opens every repository on a cold box is a cost measured in
616 /// this number, and it is the number a status call must not run up: see
617 /// Exposed for exactly that assertion — a test proves a plain
618 /// `replication list` adds ZERO to it, and `--refs` adds one per repository.
619 store_opens: Arc<AtomicU64>,
620 /// **Where each kind of data goes** (`crate::slow`), resolved once at open
621 /// out of the layout config's `dir.<name>.path`.
622 ///
623 /// A field and not a global for the reason the engine is a field: which
624 /// directories this server writes to is configuration that arrives with the
625 /// data root, and a process-global would make two `ServerStores` in one
626 /// process (the bench opens the same data root twice) share one answer.
627 ///
628 /// It ALWAYS resolves — to the configured paths, or to the documented
629 /// default under the data root — so nothing downstream branches on whether
630 /// the machine has a second spindle. What the branch is on is the CLAIM:
631 /// see `crate::slow::Claim`.
632 slow: crate::slow::SlowRoot,
633 /// **What the installer decided**, read once at open and never re-read.
634 ///
635 /// Static and historical by nature: it is the only place the *reasoning*
636 /// survives — which disk booted and why, which devices became `/data`,
637 /// what was rejected and on what grounds. Re-reading it would not make it
638 /// fresher, and an absent one stays absent with the sentence that says so.
639 install: crate::layout_config::Load,
640 /// **The front doors' plan-gate chain, where a mirror's worker can read
641 /// it** (2026-10-02). `serve::front_control` builds the chain a push is held
642 /// to — disk headroom, then the entitlement on a metered build — and
643 /// installs it here, so a mirror sync is held to the SAME namespace room a
644 /// push is, by the same gates, rather than being the one write on the box
645 /// no ceiling holds. Empty until something installs it (a store opened by
646 /// a test or a tool), which is "no ceiling", exactly the answer a chain
647 /// with no gates gives.
648 plan_gates: std::sync::RwLock<Vec<Arc<dyn gunnar_ssh::PlanGate>>>,
649 /// **The data set's space ledger** (`gunnar_ssh::space`), installed with
650 /// the plan gates by `serve::front_control` — the disk gate's own. Every
651 /// door that writes a pack or an object asks it before each chunk. `None`
652 /// (a test, a tool): no admission beyond the gates.
653 space: std::sync::RwLock<Option<Arc<gunnar_ssh::SpaceLedger>>>,
654 /// **The keys this primary was FED to enrol and has not enrolled yet**
655 /// (fingerprint → OpenSSH line; `crate::fed_principals::note_fed`), read
656 /// by the gRPC gate through `Identity::awaiting_enrolment`: the holder of
657 /// one is told `Unavailable [key-enrolling]`, ask again, not that it is
658 /// unknown (cov-E r3 2026-10-06). In memory only: the feed on disk is the
659 /// authority, and every feed and every install pass recomputes it.
660 fed_keys: std::sync::RwLock<std::collections::BTreeMap<String, String>>,
661}
662
663/// The longest a gc or a removal waits for a snapshot streaming from its cut
664/// ([`ServerStores::wait_unpinned`]).
665pub const SNAPSHOT_PIN_WAIT: std::time::Duration = std::time::Duration::from_secs(600);
666
667/// The pin count and its condvar ([`ServerStores::snapshot_pin`]).
668#[derive(Default)]
669struct SnapshotPins {
670 count: Mutex<usize>,
671 cv: std::sync::Condvar,
672}
673
674/// Held while a snapshot streams from its cut. See [`ServerStores::snapshot_pin`].
675#[must_use = "the pin holds only while the guard lives"]
676pub struct SnapshotPin<'a> {
677 pins: &'a SnapshotPins,
678}
679
680impl Drop for SnapshotPin<'_> {
681 fn drop(&mut self) {
682 let mut n = self.pins.count.lock().unwrap_or_else(|p| p.into_inner());
683 *n = n.saturating_sub(1);
684 self.pins.cv.notify_all();
685 }
686}
687
688impl std::fmt::Debug for ServerStores {
689 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
690 f.debug_struct("ServerStores")
691 .field("root", &self.root)
692 .finish_non_exhaustive()
693 }
694}
695
696impl ServerStores {
697 /// Open everything under `root`, creating whatever is not there yet.
698 ///
699 /// The handle cache is sized to the descriptor limit this process has —
700 /// after [`crate::limits::nofile`] has raised it as far as it may — so the
701 /// cache can never by itself put the process over `RLIMIT_NOFILE`.
702 pub fn open(root: impl AsRef<Path>) -> Result<Self> {
703 // `crate::budget`, not `limits::open_repo_budget` alone: the descriptor
704 // term is still the descriptor one and still binds first on a small `ulimit -n`,
705 // but it is now the MINIMUM of that and a memory term. The 2 GB box measured
706 // why: on a 2 GB appliance the descriptor term said 256 handles, 256
707 // handles held the whole box, and the indexer's watchdog then spent
708 // every push failing to reclaim memory that was in the OTHER 255.
709 Self::open_with_capacity(root, crate::budget::budgets().open_repos)
710 }
711
712 /// [`ServerStores::open`], with an explicit handle-cache bound.
713 ///
714 /// Exists so the eviction path can be tested at a capacity a test can
715 /// actually exceed. A bound that is only ever exercised at 256 is a bound
716 /// nothing has ever seen fire.
717 pub fn open_with_capacity(root: impl AsRef<Path>, capacity: usize) -> Result<Self> {
718 Self::open_with_engine(root, capacity, crate::storage::Engine::selected()?)
719 }
720
721 /// [`ServerStores::open_with_capacity`], with the engine named rather than
722 /// resolved from the environment.
723 ///
724 /// The one seam a head-to-head bench needs: the same data root opened twice,
725 /// once per engine, from one process — which is what makes an Arrow-vs-gix
726 /// row a comparison rather than two anecdotes.
727 pub fn open_with_engine(
728 root: impl AsRef<Path>,
729 capacity: usize,
730 engine: crate::storage::Engine,
731 ) -> Result<Self> {
732 let root = root.as_ref().to_path_buf();
733 let repos_root = root.join(REPOS_DIR);
734 std::fs::create_dir_all(&repos_root)
735 .with_context(|| format!("creating the repository root {}", repos_root.display()))?;
736
737 let control_dir = root.join(CONTROL_DIR);
738 std::fs::create_dir_all(&control_dir)
739 .with_context(|| format!("creating the control directory {}", control_dir.display()))?;
740
741 // **Where the storage is, read once — and gunnar is TOLD, never
742 // discovers.** `/boot/korp/layout.conf` is what the installer wrote and
743 // sealed while it still knew WHY it chose what it chose; `crate::slow`
744 // obeys its `dir.<name>.path` verbatim and nothing here probes a mount
745 // table to second-guess it. An appliance installed before the config
746 // existed has none, which is a first-class answer carrying the sentence
747 // that says so — never a refusal to start, and never an inference.
748 let install = crate::layout_config::load();
749 let slow = crate::slow::SlowRoot::resolve(&root, install.config());
750 slow.announce(
751 &crate::layout_config::config_path().display().to_string(),
752 install
753 .config()
754 .map(|c| c.authenticity().word())
755 .unwrap_or_default(),
756 );
757 if let Some(why) = install.absent_reason() {
758 tracing::info!(
759 target: crate::slow::TARGET_SLOW,
760 event = "layout.absent",
761 "{why}"
762 );
763 }
764 // The panic hook in `main` has no data root; this is where it learns
765 // where a crash record goes. Best effort — a crash directory that
766 // cannot be made means no crash file, never a refusal to start.
767 match slow.crash_dir() {
768 Ok(dir) => crate::slow::register_crash_dir(dir),
769 Err(err) => tracing::warn!(
770 target: crate::slow::TARGET_SLOW,
771 %err,
772 "no crash directory on the slow disk; a panic will reach the console but \
773 will not be written down"
774 ),
775 }
776
777 // The KEK for the control store's secrets. Read (or minted) here
778 // so `secrets()` cannot fail on the key, and so a missing one is a
779 // boot-time error with a path in it. It is a file, not a git object, so
780 // this does not need an engine.
781 let kek = gunnar_core::Kek::for_control_dir(&control_dir).with_context(|| {
782 format!(
783 "resolving the key-encrypting key for {}; secrets are sealed at rest",
784 control_dir.display()
785 )
786 })?;
787
788 // The certificate-identity plan. This is the whole control plane's storage, and it opens
789 // whether or not this binary carries a storage engine.
790 //
791 // The documents and the secrets come out of ONE call because they share
792 // one `redb::Database`: redb takes an exclusive file lock, so opening
793 // `control.redb` twice in this process would fail rather than give two
794 // views. One file, one lock, one handle.
795 let db = root.join(CONTROL_DB);
796 let (raw_control_store, redb_secrets, control_db) = gunnar_core::open_control_with_db(&db)
797 .with_context(|| format!("opening the control store {}", db.display()))?;
798 // **The transaction log**, before either store: a primary that cannot
799 // write its log has no twin, and that is loud here rather than found
800 // at the restore (`.nornir/twin-transaction-log.md`).
801 let txlog_dir = root.join(TXLOG_DIR);
802 let txlog = Arc::new(gunnar_core::txlog::Log::open(&txlog_dir).with_context(|| {
803 format!("opening the transaction log {}", txlog_dir.display())
804 })?);
805 // **Twin or primary** (`crate::twin`). A twin holds the primary's
806 // numbers and originates none: its log is follower-only, nothing under
807 // its root is framed, and its control plane refuses mutators (the
808 // browse flag). A box mid-restore is follower-only too. A primary
809 // frames its KEK FIRST — seq 1 — so a restore can open every sealed
810 // secret that follows, and registers its root with the sink so the
811 // signed logs and documents under it are frames before they are bytes.
812 let twin = crate::twin::is_twin_root(&root);
813 let restoring = crate::twin::is_restoring(&root);
814 if restoring && !twin {
815 // Said at every open, because the state is silent otherwise: the box serves git,
816 // answers reads, and refuses every write with a message about twins.
817 tracing::warn!(
818 target: gunnar_core::observe::TARGET_TWIN,
819 event = "twin.restoring_at_open",
820 "THIS BOX IS MID-RESTORE (`txlog-restoring` is present): it originates no \
821 transactions, so every control-plane write is refused, until a restore finishes. \
822 Resend the twin's log with `gunnar control twin replay`; a finished restore that \
823 still says this lost its marker's removal to a power cut"
824 );
825 }
826 // **An old generation of its log** (stinky row 18, the main's half): a twin
827 // showed this box a newer generation signed by its own host key, so the
828 // box was fenced (`crate::twin::LOG_FENCED`). A restart must not undo
829 // that: it comes up follower-only, writes nothing, and says why.
830 let fenced = !twin && !restoring && crate::twin::is_fenced(&root);
831 if fenced {
832 tracing::error!(
833 target: gunnar_core::observe::TARGET_TWIN,
834 event = "twin.fenced_by_generation",
835 "THIS BOX IS FENCED: {}. Every write is refused and no twin is served its log.",
836 crate::twin::fenced_why(&root).unwrap_or_default()
837 );
838 }
839 if twin || restoring || fenced {
840 txlog.set_follower(true);
841 } else {
842 // **This history's name** (2026-09-21, lane T4). A primary mints it
843 // once, from the CSPRNG and from nothing on the box, and answers
844 // `Mirror.LogHead` with it from then on. It is what lets a twin ask
845 // "is this the same log?" instead of only "is this number bigger?"
846 // — the question that catches a re-imaged main which has already
847 // written PAST the twin's head, where every number still looks
848 // right. A twin does not mint one: it is told its primary's, and a
849 // box taking a restore is told the stream's. See `twin::LOG_ID`.
850 crate::twin::mint_log_id(&root).context("minting this log's identity")?;
851 if txlog.head_seq() == 0 {
852 let bytes = hex::decode(kek.to_hex()).context("the KEK as bytes")?;
853 txlog
854 .append(gunnar_core::txlog::Kind::Kek, &gunnar_core::txlog::Meta::Kek {}, &bytes)
855 .context("framing the KEK as the log's first transaction")?;
856 }
857 gunnar_core::txlog::sink::register(&root, &txlog);
858 // **And the site's WireGuard hub declaration becomes a transaction**
859 // (lane T11, 2026-09-21). Nothing in gunnar ever WRITES `wg/hub.conf` —
860 // `korp-installer` lays it through the ESP data stage before gunnar's
861 // first boot — so it has no sink call site of its own and the twin
862 // never held it. A restored main with no hub declaration is a box
863 // nobody can reach, which is the worst thing to find on the day the
864 // twin is used.
865 //
866 // Framed on open, once per CHANGE and not once per boot: the digest of
867 // what was last framed sits beside it in a per-box file, so a box that
868 // reboots a thousand times adds one frame, not a thousand.
869 //
870 // **After the re-seal, not here** — see the call site below. A restored
871 // box's FIRST frame after the restore must be its new KEK, and framing a
872 // document before it would put the KEK second in its own log.
873 }
874 let control_store: Arc<dyn ControlStore> = Arc::new(
875 gunnar_core::txlog::tee::LoggedControlStore::new(Arc::clone(&raw_control_store), Arc::clone(&txlog)),
876 );
877
878 // 🚨 THE LIVE SSH HOST KEY CROSSES HERE. Read-old / write-new: the
879 // legacy Arrow log is consulted only for a key redb has never seen, and
880 // whatever it answers is written into redb and returned unchanged. The
881 // log itself is never written and never removed — an appliance rolled
882 // back to a pre-migration binary still finds its host key. `None` is the
883 // normal answer on any installation made after this change, and on that
884 // path no Arrow log is created.
885 let legacy =
886 gunnar_core::legacy_secret_log(&control_dir, CONTROL_HASH_KIND).with_context(|| {
887 format!(
888 "checking {} for a legacy Arrow secret log to carry the SSH host key \
889 forward from",
890 control_dir.display()
891 )
892 })?;
893 // Recorded before the handle is type-erased: once `legacy` is inside an
894 // `Arc<dyn SecretStore>` nothing downstream can ask whether there was
895 // one, and `secrets()` is the caller that has to know.
896 let legacy_secrets = legacy.is_some();
897 let raw_secrets: Arc<dyn gunnar_store::SecretStore> =
898 Arc::new(gunnar_core::MigratingSecretStore::new(redb_secrets, legacy));
899 // **A restored box re-seals under a KEK of its own** before any sealed store is built
900 // (`crate::twin::reseal_after_restore`). Its log is a primary's again by now.
901 // Captured before the re-seal clears its marker: it names this start in what the audit
902 // trail check says (`crate::twin::rematerialise_audit_trail`, below).
903 let just_restored = !twin && !restoring && crate::twin::reseal_pending(&root);
904 let kek = if just_restored {
905 let rows = control_db.export().context("reading the secrets to re-seal")?.secrets;
906 crate::twin::reseal_after_restore(&root, &control_dir, &txlog, &rows, raw_secrets.as_ref())
907 .context("re-sealing a restored box's secrets under its own KEK")?
908 } else {
909 kek
910 };
911 // **And now the site's hub declaration** (lane T11) — deliberately AFTER the re-seal.
912 // A restored box's first frame after the restore is its own KEK (`reseal_after_restore`
913 // frames it first so the twin following the restored box can open everything that comes
914 // after), and a document framed before it would take that place. Measured: it did, and
915 // `a_restored_box_reseals_every_secret_under_a_kek_of_its_own…` said so by name.
916 if !twin && !restoring && !fenced {
917 adopt_hub_conf(&root, &txlog);
918 }
919 let secrets: Arc<dyn gunnar_store::SecretStore> =
920 Arc::new(gunnar_core::txlog::tee::LoggedSecretStore::new(
921 Arc::clone(&raw_secrets),
922 Arc::clone(&txlog),
923 ));
924
925 let auth =
926 AuthTable::new(Arc::clone(&control_store)).context("opening the identity table")?;
927 let catalog = RepoCatalog::new(Arc::clone(&control_store))
928 .context("opening the repository catalog")?;
929 let policies = gunnar_core::PolicyStore::new(Arc::clone(&control_store))
930 .context("opening the policy store")?;
931 let mirrors = gunnar_core::MirrorStore::new(Arc::clone(&control_store))
932 .context("opening the mirror registry")?;
933 let account_audit = Arc::new(
934 gunnar_core::account_audit::AccountAuditLog::new(Arc::clone(&control_store))
935 .context("opening the account audit logs")?,
936 );
937 let names = gunnar_core::name_filter::NameFilterStore::new(Arc::clone(&control_store))
938 .context("opening the name filter")?;
939 let flow = Arc::new(gunnar_core::FlowLedger::new(
940 gunnar_core::FlowStore::new(Arc::clone(&control_store))
941 .context("opening the flow ledger")?,
942 ));
943
944 tracing::info!(
945 target: "gunnar::store",
946 event = "store.engine_selected",
947 engine = engine.name(),
948 compiled = %crate::storage::Engine::compiled_list(),
949 "the storage engine for this process"
950 );
951
952 // The persisted role decides whether this box comes up frozen. A
953 // standby that rebooted is still a standby; a plain box (no
954 // `replication.json`, or the `replication` feature off) is a writable
955 // primary, which is every deployment before failover was built.
956 let role_at_open = replication_role_at_open(&root);
957 let frozen_at_open = role_at_open.refuses_client_writes();
958 if frozen_at_open {
959 tracing::warn!(
960 target: "gunnar::store",
961 event = "store.role_standby",
962 "this box is a STANDBY (replication.json) — direct client pushes are refused; \
963 only the replication push from its primary writes. `gunnar control replication \
964 promote` makes it a writable primary."
965 );
966 }
967
968 if twin {
969 // Before any repository of the twin opens: the engine keeps no derived memo (C2)
970 // and no exploded read cache, and resolves a pack narrow enough for the box (C1 +
971 // the twin memory bound). Both are process-wide, and both used to be cmdline
972 // tokens on a medium that no longer exists.
973 engine.hold_no_graph_memo();
974 engine.hold_twin_memory_bound();
975 }
976 // In the control database's own table; each flush that moved something is
977 // ONE `Activity` frame on a box that originates its log (the twin's copy),
978 // nothing framed on a follower. The old JSON file is read once and left.
979 let activity = gunnar_core::PrincipalActivity::open_flushed(
980 Arc::clone(&control_db),
981 Some(control_dir.join(gunnar_core::ACTIVITY_FILE)),
982 Some(Arc::clone(&txlog)),
983 gunnar_core::activity::FLUSH_EVERY,
984 );
985 let stores = ServerStores {
986 root,
987 repos_root,
988 control_dir,
989 activity,
990 engine,
991 secrets,
992 legacy_secrets,
993 kek: std::sync::RwLock::new(kek),
994 control_store,
995 control_db,
996 suspended: AtomicBool::new(false),
997 raw_control_store,
998 raw_secrets,
999 txlog,
1000 twin: Arc::new(AtomicBool::new(twin)),
1001 auth,
1002 catalog,
1003 policies,
1004 mirrors,
1005 flow,
1006 usage: Arc::new(crate::usage_ledger::UsageLedger::new()),
1007 account_audit,
1008 names,
1009 open: Mutex::new(HandleCache::new(capacity)),
1010 last_reclaim_probe: Mutex::new(None),
1011 writes_frozen: Arc::new(AtomicBool::new(frozen_at_open || twin)),
1012 browse: Arc::new(AtomicBool::new(role_at_open.is_browse() || twin)),
1013 store_opens: Arc::new(AtomicU64::new(0)),
1014 slow,
1015 install,
1016 plan_gates: std::sync::RwLock::new(Vec::new()),
1017 space: std::sync::RwLock::new(None),
1018 fed_keys: std::sync::RwLock::new(std::collections::BTreeMap::new()),
1019 snapshot_pins: SnapshotPins::default(),
1020 };
1021 // **The flow tells the usage ledger** which namespaces moved: their
1022 // `*_today` meters changed, and a transfer is what grows a repository.
1023 {
1024 let usage = Arc::clone(&stores.usage);
1025 stores.flow.observe(gunnar_core::FlowObserver(Arc::new(move |ns: &str| usage.mark_flow(ns))));
1026 }
1027 // The identity half of the metadata mirror, taken once at open so a box
1028 // that has not enrolled anybody since its last boot still has a current
1029 // copy off the stripe. The mutating verbs refresh it; see
1030 // `ServerStores::mirror_identity`.
1031 stores.mirror_identity();
1032 // **The audit trail, checked against the log it came from** — on EVERY open of a box
1033 // that originates its log, before `serve` opens it for the gate (lane T11; every start
1034 // since T17). The window it closes: `AuditChain::append` syncs the frame, then writes the
1035 // line; a primary that crashes between the two comes back with the log one entry ahead,
1036 // and the gate would frame a second entry at an existing seq. See
1037 // `crate::twin::rematerialise_audit_trail`.
1038 if !twin && !restoring {
1039 crate::twin::rematerialise_audit_trail(&stores, just_restored);
1040 }
1041 // A twin keeps no per-box mirror, including one a fresh box wrote before it became a twin.
1042 if stores.is_twin() {
1043 crate::twin::forget_mirrors(&stores);
1044 crate::twin::say_if_stores_were_applied(&stores);
1045 }
1046 Ok(stores)
1047 }
1048
1049 /// **Say which fed keys this box will enrol and has not yet** — the whole
1050 /// set, replacing the last (`crate::fed_principals::note_fed`).
1051 pub fn await_enrolment(&self, keys: std::collections::BTreeMap<String, String>) {
1052 *self.fed_keys.write().unwrap_or_else(|p| p.into_inner()) = keys;
1053 }
1054
1055 /// The fed key under `fingerprint` this box will enrol and has not yet, if
1056 /// any ([`ServerStores::await_enrolment`]).
1057 pub fn awaiting_enrolment(&self, fingerprint: &str) -> Option<String> {
1058 self.fed_keys.read().unwrap_or_else(|p| p.into_inner()).get(fingerprint).cloned()
1059 }
1060
1061 pub fn root(&self) -> &Path {
1062 &self.root
1063 }
1064
1065 /// **The slow disk** this server writes its off-stripe copies to.
1066 ///
1067 /// Always present. See `crate::slow` for what goes there and for the
1068 /// three-valued claim over it.
1069 pub fn slow(&self) -> &crate::slow::SlowRoot {
1070 &self.slow
1071 }
1072
1073 /// **What the installer decided**, or the sentence saying there is no
1074 /// config. Read once at open; see the field's documentation.
1075 pub fn layout_config(&self) -> &crate::layout_config::Load {
1076 &self.install
1077 }
1078
1079 /// **Where the control-plane audit trail lives**: `<slow>/audit/` — one of
1080 /// the three copies that must be on another failure domain.
1081 ///
1082 /// It used to be `<control dir>/audit-chain.jsonl`, on the stripe with the
1083 /// repositories whose history it records. An existing chain is ADOPTED into
1084 /// the new place once, on the first boot of a build that knows about the
1085 /// slow disk (`slow::adopt_file`: copy, fsync, unlink — `rename` answers
1086 /// `EXDEV` across devices, which is exactly the case that matters). If the
1087 /// move cannot be made the old path is returned unchanged and the server
1088 /// keeps writing the trail where it already is.
1089 ///
1090 /// **On a twin, or on a box mid-restore, it is a DIFFERENT file**
1091 /// (`audit-chain.local.jsonl`, [`gunnar_core::txlog::AUDIT_LOCAL_REL`]).
1092 /// Such a box holds the primary's trail as replicated state and must
1093 /// originate nothing into it: its own gate decisions — who browsed it, who
1094 /// was refused — are its own, and interleaving them into a hash chain that
1095 /// has exactly one writer would break every `prev` after the splice. The
1096 /// twin's own trail stays on the twin and is never sent anywhere.
1097 pub fn audit_path(&self) -> std::path::PathBuf {
1098 // The name is decided by the same two facts that decide whether the log originates at
1099 // all (`ServerStores::open`: a twin or a restoring box is a follower and registers no
1100 // sink), so the trail and the log can never disagree about which box this is.
1101 if self.is_twin() || crate::twin::is_restoring(&self.root) {
1102 let local = std::path::Path::new(gunnar_core::txlog::AUDIT_LOCAL_REL)
1103 .file_name()
1104 .expect("a file name")
1105 .to_owned();
1106 return match self.slow.log_dir() {
1107 Ok(dir) => dir.join(local),
1108 Err(_) => self.control_dir.join(local),
1109 };
1110 }
1111 let old = self.control_dir.join(gunnar_core::AUDIT_FILE);
1112 match self.slow.log_dir() {
1113 Ok(dir) => crate::slow::adopt_file(&old, &dir.join(gunnar_core::AUDIT_FILE)),
1114 Err(err) => {
1115 tracing::warn!(
1116 target: crate::slow::TARGET_SLOW,
1117 %err,
1118 path = %old.display(),
1119 "no audit directory on the slow disk; the audit trail stays on /data, where \
1120 it shares a failure domain with the repositories it records"
1121 );
1122 old
1123 }
1124 }
1125 }
1126
1127 /// **The identity half of the metadata mirror**: every principal and every
1128 /// enrolled public key, as one JSON document off the stripe.
1129 ///
1130 /// Public material only — a fingerprint, an `authorized_keys` line, a
1131 /// principal's id and name. No secret in this file, ever: the secrets are
1132 /// sealed in `control.redb` under a KEK that is a separate file, and a
1133 /// mirror that carried them would make the slow disk a second place to
1134 /// steal them from.
1135 ///
1136 /// Best effort, and deliberately so at every call site: this runs after a
1137 /// principal or a key has ALREADY been written to the control store, and
1138 /// failing the verb because the mirror could not be refreshed would turn a
1139 /// full slow disk into an outage of the identity system. A failure is one
1140 /// WARN naming the path.
1141 pub fn mirror_identity(&self) {
1142 // A twin keeps no mirror of its own: its identity table is a replica,
1143 // and a derived per-box copy on its disk is exactly the cache the twin
1144 // must not hold.
1145 if self.is_twin() {
1146 return;
1147 }
1148 if let Err(err) = self.try_mirror_identity() {
1149 tracing::warn!(
1150 target: crate::slow::TARGET_SLOW,
1151 %err,
1152 "the identity metadata mirror could not be refreshed; the control store itself \
1153 is unaffected"
1154 );
1155 }
1156 }
1157
1158 /// **The storage reality of this machine**, for `Admin.StorageInfo`.
1159 ///
1160 /// Three layers that are kept apart because they age differently: what the
1161 /// installer DECIDED (historical, from the record), what is TRUE NOW (the
1162 /// configured filesystems, read here), and what gunnar is CONFIGURED to do
1163 /// (the placement table as built). One artifact behind the first and third
1164 /// — `crate::layout_config` and `crate::slow` — so the console and the
1165 /// placement code cannot drift into two views of the same facts.
1166 ///
1167 /// **Nothing here surveys the machine.** The only paths measured are the
1168 /// two gunnar was told to use; no mount table is walked, no device is
1169 /// enumerated, and the installer's decisions are never inferred from what
1170 /// happens to be mounted now. A record that is missing is reported as
1171 /// missing.
1172 pub fn storage_reality(&self) -> gunnar_proto::StorageReality {
1173 let mut out = gunnar_proto::StorageReality {
1174 data_root: self.root.display().to_string(),
1175 slow_root: self.slow.serialdat().to_owned(),
1176 slow_root_source: self.slow.source().word().to_owned(),
1177 backing_device: self.slow.device().to_owned(),
1178 backing_class: self.slow.class().to_owned(),
1179 off_stripe: self.slow.claim().word().to_owned(),
1180 off_stripe_health: self.slow.health(),
1181 ..Default::default()
1182 };
1183 // ── 1. what the installer decided ──────────────────────────────────
1184 match self.install.config() {
1185 Some(config) => {
1186 out.record_present = true;
1187 out.record_path = config.path().display().to_string();
1188 out.record_version = config.version();
1189 // **The third state, said in the installer's own two words.**
1190 // `INTEGRITY ONLY` is what every appliance built today is, and
1191 // it is never rendered as verified.
1192 out.record_signature = config.authenticity().word().to_owned();
1193 out.record_signature_verified = config.authenticity().is_verified();
1194 out.record_signature_note = config.note().to_owned();
1195 out.record_fields = config
1196 .fields()
1197 .map(|(key, value)| gunnar_proto::InstallRecordField {
1198 key: key.to_owned(),
1199 value: value.to_owned(),
1200 })
1201 .collect();
1202 }
1203 None => {
1204 out.record_absent_reason = self
1205 .install
1206 .absent_reason()
1207 .unwrap_or("there is no layout config")
1208 .to_owned();
1209 }
1210 }
1211 // ── 2. what is true now ────────────────────────────────────────────
1212 //
1213 // Only the paths gunnar was TOLD to use, measured at the moment of the
1214 // call. No mount table is walked and no device is enumerated: a
1215 // filesystem gunnar was not configured with is not this call's to
1216 // report on.
1217 let mut measured: Vec<(&str, PathBuf)> =
1218 vec![("data root", self.root.clone())];
1219 for p in crate::slow::PLACEMENT.iter().filter(|p| p.requires_off_stripe) {
1220 let path = self.slow.path_of(p.dir);
1221 if !measured.iter().any(|(_, seen)| *seen == path) {
1222 measured.push(("configured directory", path));
1223 }
1224 }
1225 out.filesystems = measured
1226 .iter()
1227 .map(|(what, path)| filesystem_use(what, path))
1228 .collect();
1229 // ── 3. what gunnar is configured to do ─────────────────────────────
1230 out.placement = crate::slow::PLACEMENT
1231 .iter()
1232 .map(|p| gunnar_proto::PlacementRow {
1233 what: p.what.to_owned(),
1234 path: self.slow.path_of(p.dir).display().to_string(),
1235 requires_off_stripe: p.requires_off_stripe,
1236 // The verdict is carried ONLY on the rows that need it, and it
1237 // is the config's per-directory answer rather than one summary
1238 // painted across every row. An empty string on a row that does
1239 // not require it is the honest shape; painting "off-stripe" on
1240 // git's own objects would suggest a guarantee nobody made.
1241 off_stripe: if p.requires_off_stripe {
1242 self.slow.claim_of(p.dir).word().to_owned()
1243 } else {
1244 String::new()
1245 },
1246 wired: p.wired,
1247 note: p.note.to_owned(),
1248 })
1249 .collect();
1250 // ── /data's own shape, and the machine at a glance (2026-10-04) ────
1251 //
1252 // `/data`'s devices come from the installer's `data.*` keys and from
1253 // nothing else; `backing_*` above are `/serialdat`'s and stay so.
1254 let config = self.install.config();
1255 if let Some(config) = config {
1256 out.data_shape = config.get("data.shape").unwrap_or_default().to_owned();
1257 out.data_devices = (0..config.count("data"))
1258 .filter_map(|i| config.get(&format!("data.{i}.dev")).map(str::to_owned))
1259 .collect();
1260 }
1261 let data_label = config
1262 .and_then(|c| c.get("data.mountpoint"))
1263 .map(str::to_owned)
1264 .unwrap_or_else(|| out.data_root.clone());
1265 let serialdat_label = config
1266 .and_then(|c| c.get("serialdat.mountpoint"))
1267 .map(str::to_owned)
1268 .unwrap_or_else(|| out.slow_root.clone());
1269 let disk = crate::disktest::last();
1270 let data_fs = out.filesystems.iter().find(|f| f.what == "data root").cloned();
1271 out.glance = crate::glance::lines(&crate::glance::Facts {
1272 record_present: out.record_present,
1273 data_label: &data_label,
1274 data_shape: &out.data_shape,
1275 data_devices: &out.data_devices,
1276 serialdat_label: &serialdat_label,
1277 serialdat_class: &out.backing_class,
1278 serialdat_device: &out.backing_device,
1279 off_stripe: &out.off_stripe,
1280 off_stripe_health: &out.off_stripe_health,
1281 data_fs: data_fs.as_ref(),
1282 disk: disk.as_ref(),
1283 });
1284 out
1285 }
1286
1287 fn try_mirror_identity(&self) -> Result<()> {
1288 let principals = self.auth.principals().context("listing principals")?;
1289 let keys = self.auth.keys().context("listing enrolled keys")?;
1290 let written_unix_ms = std::time::SystemTime::now()
1291 .duration_since(std::time::UNIX_EPOCH)
1292 .map(|d| d.as_millis() as i64)
1293 .unwrap_or_default();
1294 let doc = serde_json::json!({
1295 "written_unix_ms": written_unix_ms,
1296 "principals": principals,
1297 "keys": keys,
1298 });
1299 let bytes = serde_json::to_vec_pretty(&doc).context("encoding the identity mirror")?;
1300 let dir = self.slow.meta_dir()?;
1301 crate::durable::write_atomic_unframed(&dir, IDENTITY_MIRROR, &bytes)
1302 }
1303
1304 /// The storage engine this process is running.
1305 pub fn engine(&self) -> crate::storage::Engine {
1306 self.engine
1307 }
1308
1309 /// Where served repositories live.
1310 pub fn repos_root(&self) -> &Path {
1311 &self.repos_root
1312 }
1313
1314 /// The identity table (the ACL model).
1315 /// **Who signed in and who changed something, and when** (2026-10-21) —
1316 /// the stamps `Access.ListPrincipals` answers beside each principal.
1317 pub fn activity(&self) -> &Arc<gunnar_core::PrincipalActivity> {
1318 &self.activity
1319 }
1320
1321 pub fn auth(&self) -> &AuthTable {
1322 &self.auth
1323 }
1324
1325 /// The repository catalog.
1326 pub fn catalog(&self) -> &RepoCatalog {
1327 &self.catalog
1328 }
1329
1330 /// Per-repository policy documents (`refs/gunnar/policy`).
1331 ///
1332 /// The **one** home for branch protection and ref-name policy since
1333 /// `Policy.*` stopped answering `Unimplemented`. `gunnar-policy.json`
1334 /// beside a repository is still read as a fallback, and
1335 /// `transport::load_policy` says why and for how long.
1336 pub fn policies(&self) -> &gunnar_core::PolicyStore {
1337 &self.policies
1338 }
1339
1340 /// Pull-mirror registrations (`refs/gunnar/mirrors`).
1341 ///
1342 /// The server's own registry, reachable over `Mirror.*`. Not the same thing
1343 /// as `gunnar-client`'s `mirrors.json`, which is the pull-mirror *client's*
1344 /// state on a host running `gunnar mirror`.
1345 pub fn mirrors(&self) -> &gunnar_core::MirrorStore {
1346 &self.mirrors
1347 }
1348
1349 /// **The flow ledger**: what moved, per namespace, per UTC day.
1350 ///
1351 /// Handed out as an `Arc` because its holders outlive any one borrow — the
1352 /// transfer path keeps a clone for the length of a transfer, the flush tick
1353 /// keeps one for the length of the process — and because every holder must
1354 /// share the one set of live atomics. See the field.
1355 /// **Every account's audit log** — read only by each account's admins
1356 /// (`Enterprise.AuditLog`), written by the control plane's handlers.
1357 pub fn account_audit(&self) -> &Arc<gunnar_core::account_audit::AccountAuditLog> {
1358 &self.account_audit
1359 }
1360
1361 /// **The name filter** — its lists and the accounts' review queues.
1362 pub fn names(&self) -> &gunnar_core::name_filter::NameFilterStore {
1363 &self.names
1364 }
1365
1366 pub fn usage_ledger(&self) -> &Arc<crate::usage_ledger::UsageLedger> {
1367 &self.usage
1368 }
1369
1370 pub fn flow(&self) -> &Arc<gunnar_core::FlowLedger> {
1371 &self.flow
1372 }
1373
1374 /// **Pin the stores for a snapshot streamed from a cut** (R5.1-A, 2026-10-07): until the
1375 /// guard drops, [`ServerStores::wait_unpinned`] holds back a gc, a repository store's removal
1376 /// and an LFS object's removal — the objects reachable from the cut's tips, the stores it
1377 /// names and the LFS objects it lists stay where the stream will read them. Take it BEFORE the
1378 /// log's freeze; the ones who wait take it before their own mutation guard, so a freeze can
1379 /// never wait on a thread that waits on a pin.
1380 pub fn snapshot_pin(&self) -> SnapshotPin<'_> {
1381 *self.snapshot_pins.count.lock().unwrap_or_else(|p| p.into_inner()) += 1;
1382 SnapshotPin { pins: &self.snapshot_pins }
1383 }
1384
1385 /// **Wait while a snapshot streams from a cut** ([`ServerStores::snapshot_pin`]) — at most
1386 /// [`SNAPSHOT_PIN_WAIT`], after which the caller goes on and that snapshot fails at the piece
1387 /// that needed what was removed (the twin asks again; it never installs half). `what` names the
1388 /// caller in the line said while it waits. Called BEFORE any mutation guard.
1389 pub fn wait_unpinned(&self, what: &str) {
1390 let pins = &self.snapshot_pins;
1391 let mut n = pins.count.lock().unwrap_or_else(|p| p.into_inner());
1392 if *n == 0 {
1393 return;
1394 }
1395 tracing::info!(target: "gunnar::store", what, snapshots = *n, "a snapshot is streaming from its cut: {what} waits for it");
1396 let deadline = std::time::Instant::now() + SNAPSHOT_PIN_WAIT;
1397 while *n > 0 {
1398 let left = deadline.saturating_duration_since(std::time::Instant::now());
1399 if left.is_zero() {
1400 tracing::warn!(target: "gunnar::store", what, "a snapshot still streams after {} s: {what} goes on; that snapshot fails and is asked for again", SNAPSHOT_PIN_WAIT.as_secs());
1401 return;
1402 }
1403 n = pins.cv.wait_timeout(n, left).unwrap_or_else(|p| p.into_inner()).0;
1404 }
1405 }
1406
1407 /// The catalog and the identity table, as one store (the certificate-identity plan).
1408 /// The transaction log — what the twin fetches and what a restore replays.
1409 pub fn txlog(&self) -> &Arc<gunnar_core::txlog::Log> {
1410 &self.txlog
1411 }
1412
1413 /// **Is this box a twin right now?** Set at open from `twin.json`, and by
1414 /// [`ServerStores::mark_twin`] the moment `Bootstrap.BecomeTwin` confers
1415 /// the role on a running box. NOT decided once, whatever this comment used
1416 /// to say.
1417 pub fn is_twin(&self) -> bool {
1418 self.twin.load(std::sync::atomic::Ordering::SeqCst)
1419 }
1420
1421 /// **The shared twin flag**, for the front door — the same `Arc`
1422 /// [`ServerStores::mark_twin`] writes, so a box conferred the twin role
1423 /// over the wire refuses git from that instant instead of from the next
1424 /// restart. See `gunnar_ssh::Control::with_twin_flag`.
1425 pub fn twin_flag(&self) -> Arc<AtomicBool> {
1426 Arc::clone(&self.twin)
1427 }
1428
1429 /// From now on this box is a twin (`crate::twin::become_twin`).
1430 pub fn mark_twin(&self) {
1431 self.twin.store(true, std::sync::atomic::Ordering::SeqCst);
1432 // No derived memo on a twin's disk from the next store open on (C2), no exploded read
1433 // cache and a resolver narrow enough for the box (C1 + the twin memory bound). This is
1434 // the runtime half that made the five `korp.env.ZNIPPY_*` tokens retirable, so a twin
1435 // needs nothing on its command line.
1436 self.engine.hold_no_graph_memo();
1437 self.engine.hold_twin_memory_bound();
1438 // A setter governs the NEXT open, so the stores this box already holds — it came up a
1439 // primary, which is what one medium means — are swept by hand.
1440 let freed = self.engine.bound_open_stores_to_twin();
1441 if freed > 0 {
1442 tracing::info!(
1443 target: gunnar_core::observe::TARGET_TWIN,
1444 bytes = freed,
1445 "unlinked the exploded read cache of the repositories this box already held: a twin holds none"
1446 );
1447 }
1448 }
1449
1450 /// The sealed secret store over the UNLOGGED half: what a twin reads its
1451 /// replicated host key through (a twin frames nothing).
1452 pub fn secrets_unframed(
1453 &self,
1454 ) -> Result<gunnar_core::SealedSecretStore<std::sync::Arc<dyn gunnar_store::SecretStore>>> {
1455 Ok(gunnar_core::SealedSecretStore::new(Arc::clone(&self.raw_secrets), self.kek()))
1456 }
1457
1458 fn kek(&self) -> gunnar_core::Kek {
1459 self.kek.read().unwrap_or_else(std::sync::PoisonError::into_inner).clone()
1460 }
1461
1462 /// **Hold `bytes` as this process's KEK** (S9): what a follower does with a `Kek` frame
1463 /// once the key file is durable, so the sealed rows it holds open from now on.
1464 pub fn adopt_kek(&self, bytes: &[u8]) -> Result<()> {
1465 let key: [u8; 32] = bytes.try_into().map_err(|_| anyhow::anyhow!("a KEK carries {} bytes, not 32", bytes.len()))?;
1466 *self.kek.write().unwrap_or_else(std::sync::PoisonError::into_inner) = gunnar_core::Kek::from_bytes(key);
1467 Ok(())
1468 }
1469
1470 /// The KEK this process holds, as bytes — a snapshot's `Kek` item when no key file exists
1471 /// (the key was handed in by the environment).
1472 pub fn kek_bytes(&self) -> Vec<u8> {
1473 hex::decode(self.kek().to_hex()).expect("a KEK's hex decodes")
1474 }
1475
1476 /// **The control store with no log in front of it** — the twin applies
1477 /// the primary's `ControlBlob` / `ControlDoc` frames here, and nothing
1478 /// else writes here.
1479 pub fn raw_control_store(&self) -> &Arc<dyn ControlStore> {
1480 &self.raw_control_store
1481 }
1482
1483 /// **The secret store with no log in front of it** — the twin's
1484 /// `Secret` frames land here as the sealed bytes they are.
1485 pub fn raw_secrets(&self) -> &Arc<dyn gunnar_store::SecretStore> {
1486 &self.raw_secrets
1487 }
1488
1489 pub fn control_store(&self) -> &Arc<dyn ControlStore> {
1490 &self.control_store
1491 }
1492
1493 /// The redb handle under the control store — see the field.
1494 pub fn control_db(&self) -> Arc<gunnar_core::ControlDb> {
1495 Arc::clone(&self.control_db)
1496 }
1497
1498 /// Are the repository stores closed for a growth?
1499 pub fn suspended(&self) -> bool {
1500 self.suspended.load(std::sync::atomic::Ordering::SeqCst)
1501 }
1502
1503 /// **Close every repository store for the runtime growth** (`crate::grow`,
1504 /// `DATA-SET-GROWTH-FLOW.md` G6): from here until [`Self::resume_repos`]
1505 /// `repo_store` refuses with `why`, the handle cache is emptied and the
1506 /// engines release what they cached — the packs' mmaps, the redb tails,
1507 /// the descriptors that would otherwise pin `/data`. A store still held
1508 /// by an in-flight request (there are none: the drain finished first)
1509 /// drops when that request returns.
1510 pub fn suspend_repos(&self, why: &str) {
1511 self.suspended.store(true, std::sync::atomic::Ordering::SeqCst);
1512 let dropped = {
1513 let mut cache = self.cache();
1514 let n = cache.entries.len();
1515 cache.entries.clear();
1516 n
1517 };
1518 crate::storage::shutdown();
1519 tracing::warn!(
1520 target: "gunnar::store",
1521 event = "store.suspended",
1522 dropped,
1523 why,
1524 "every repository store is closed and opens are refused: {why}"
1525 );
1526 }
1527
1528 /// The stores may open again (lazily, on the next request), after the
1529 /// growth mounted `/data` back.
1530 pub fn resume_repos(&self) {
1531 // The data set was unmounted and is back, perhaps grown: every reading
1532 // the usage ledger holds is re-taken.
1533 self.usage.mark_all();
1534 if self.suspended.swap(false, std::sync::atomic::Ordering::SeqCst) {
1535 tracing::info!(target: "gunnar::store", event = "store.resumed", "repository stores open again on demand");
1536 }
1537 }
1538
1539 /// Where the KEK and the legacy Arrow secret log live. See [`CONTROL_DIR`].
1540 ///
1541 /// `control()` used to be here — the one accessor on this type that opened
1542 /// a repository, and the reason the server needed a storage engine to boot.
1543 /// There is no control repository any more, so there is no accessor for one.
1544 pub fn control_dir(&self) -> &Path {
1545 &self.control_dir
1546 }
1547
1548 /// **The server's secret store, sealed.**
1549 ///
1550 /// Everything on the boot path must go through this rather than through the
1551 /// raw handle: the raw handle writes plaintext, and the SSH host key is the
1552 /// one secret whose whole purpose is to be persisted. The decorator is the
1553 /// only thing standing between a private key and a file designed to be
1554 /// copied off the machine — and now that the store is a table in
1555 /// `control.redb`, "the file designed to be copied off the machine" is the
1556 /// same file the catalog is in.
1557 ///
1558 /// Still `Result`, and deliberately: the signature is what every caller on
1559 /// the boot path is written against, and the store behind it may yet be one
1560 /// that can fail to open (`VaultSecretStore` is still noted-not-built). It
1561 /// no longer opens anything.
1562 /// # And what it does with a value that is not sealed
1563 ///
1564 /// It refuses it, unless [`Self::legacy_secrets`] says a pre-sealing
1565 /// `secrets.arrow` is on this disk. That is not belt-and-braces: reading an
1566 /// unsealed value through is a way past the seal that needs no KEK at all,
1567 /// so anything that can write `control.redb` — a restored backup, a tampered
1568 /// cold-tier archive, a replica's copied file — could put its own SSH host
1569 /// key in the slot and gunnar would serve it. Only the machine that has the
1570 /// old log has a legitimate unsealed value to read, and only that machine
1571 /// gets the policy that reads one.
1572 ///
1573 /// [`Self::legacy_secrets`]: ServerStores::legacy_secrets
1574 pub fn secrets(
1575 &self,
1576 ) -> Result<gunnar_core::SealedSecretStore<std::sync::Arc<dyn gunnar_store::SecretStore>>> {
1577 let inner = Arc::clone(&self.secrets);
1578 Ok(if self.legacy_secrets {
1579 gunnar_core::SealedSecretStore::migrating(inner, self.kek())
1580 } else {
1581 gunnar_core::SealedSecretStore::new(inner, self.kek())
1582 })
1583 }
1584
1585 /// **The transparency log's signing key**, minting one on first use.
1586 ///
1587 /// Its own key and not the issuer's: the issuer says who somebody *is*, and
1588 /// this says what the log *held*. One key for both would mean compromising
1589 /// the log's signer also mints administrators, and would make revoking one
1590 /// job impossible without revoking the other.
1591 ///
1592 /// Atomic across racing callers, exactly as the issuer key and the host key
1593 /// are: two heads issued at once produce one key, not two.
1594 pub fn transparency_key(&self) -> Result<gunnar_core::ssh_key::PrivateKey> {
1595 use gunnar_store::SecretStoreExt as _;
1596 let secrets = self.secrets()?;
1597 let bytes = secrets
1598 .get_or_create_with(gunnar_store::TRANSPARENCY_LOG_KEY, || {
1599 tracing::info!(
1600 key = gunnar_store::TRANSPARENCY_LOG_KEY,
1601 "no transparency-log signing key stored; minting one. It is sealed under the \
1602 KEK, and its public half is what a verifier pins to check a signed tree head"
1603 );
1604 gunnar_core::mint_ed25519()
1605 .map_err(|err| gunnar_store::Error::backend(format!("{err}")))
1606 })
1607 .context("reading the transparency log's signing key")?;
1608 let pem = String::from_utf8(bytes)
1609 .context("the stored transparency-log key is not utf-8 OpenSSH PEM")?;
1610 gunnar_core::ssh_key::PrivateKey::from_openssh(&pem)
1611 .context("parsing the transparency log's signing key")
1612 }
1613
1614 /// The transparency log over this server's push attestations.
1615 pub fn transparency(&self) -> crate::attest::TransparencyLog {
1616 crate::attest::TransparencyLog::under(&self.root)
1617 }
1618
1619 /// The transparency log over this server's **build** records.
1620 ///
1621 /// The same machinery over a different file: a build record and a push
1622 /// attestation are different shapes with different readers, and mixing them
1623 /// into one file would break every reader of the other.
1624 pub fn build_log(&self) -> crate::attest::TransparencyLog {
1625 crate::attest::TransparencyLog::over(
1626 self.root.join(crate::attest::BUILD_LOG),
1627 self.root.join(crate::attest::BUILD_HEADS),
1628 )
1629 }
1630
1631 /// The transparency log over this server's **forge** events.
1632 ///
1633 /// Issues, reviews and merges, each a row signed by the principal that did
1634 /// it — so an approval is evidence in the same sense a signed commit is,
1635 /// and not the server's word for it.
1636 pub fn forge_log(&self) -> crate::attest::TransparencyLog {
1637 crate::attest::TransparencyLog::over(
1638 self.root.join(crate::attest::FORGE_LOG),
1639 self.root.join(crate::attest::FORGE_HEADS),
1640 )
1641 }
1642
1643 /// The transparency log over this server's **group-membership** changes.
1644 ///
1645 /// The same machinery over a different file: each line is an add or remove
1646 /// signed by the acting admin's own key, so who was in a group at any time
1647 /// is evidence a third party can check and not the server's word. The live
1648 /// membership table ([`gunnar_core::AuthTable`]) stays the authority the SSH
1649 /// listener resolves grants through; this is the durable, signed record of
1650 /// how it came to say what it says.
1651 pub fn membership_log(&self) -> crate::attest::TransparencyLog {
1652 crate::attest::TransparencyLog::over(
1653 self.root.join(crate::attest::MEMBERSHIP_LOG),
1654 self.root.join(crate::attest::MEMBERSHIP_HEADS),
1655 )
1656 }
1657
1658 /// **The builder's provenance signing key**, minting one on first use.
1659 ///
1660 /// A third key beside the issuer's and the log's, because it is a third
1661 /// job — and because a runner on its own host needs a key of its own that
1662 /// revoking the log's signer does not take with it.
1663 pub fn provenance_key(&self) -> Result<gunnar_core::ssh_key::PrivateKey> {
1664 use gunnar_store::SecretStoreExt as _;
1665 let secrets = self.secrets()?;
1666 let bytes = secrets
1667 .get_or_create_with(gunnar_store::PROVENANCE_KEY, || {
1668 tracing::info!(
1669 key = gunnar_store::PROVENANCE_KEY,
1670 "no provenance signing key stored; minting one. Its public half is what a \
1671 verifier pins to check a build record"
1672 );
1673 gunnar_core::mint_ed25519()
1674 .map_err(|err| gunnar_store::Error::backend(format!("{err}")))
1675 })
1676 .context("reading the provenance signing key")?;
1677 let pem = String::from_utf8(bytes)
1678 .context("the stored provenance key is not utf-8 OpenSSH PEM")?;
1679 gunnar_core::ssh_key::PrivateKey::from_openssh(&pem)
1680 .context("parsing the provenance signing key")
1681 }
1682
1683 /// **The outbound webhook signing key**, minting one on first use.
1684 ///
1685 /// A fourth key beside the issuer's, the log's and the builder's, because a
1686 /// delivery signature is a fourth job: it says "this event body came from
1687 /// this server". Its PUBLIC half is what a webhook receiver pins and checks
1688 /// each delivery against with `ssh-keygen -Y verify` — no shared secret, so
1689 /// a leaked delivery URL is not a leaked signing key. Sealed under the KEK
1690 /// and atomic across racing callers, exactly as the keys above are.
1691 pub fn webhook_key(&self) -> Result<gunnar_core::ssh_key::PrivateKey> {
1692 use gunnar_store::SecretStoreExt as _;
1693 let secrets = self.secrets()?;
1694 let bytes = secrets
1695 .get_or_create_with(gunnar_store::WEBHOOK_KEY, || {
1696 tracing::info!(
1697 key = gunnar_store::WEBHOOK_KEY,
1698 "no webhook signing key stored; minting one. Its public half is what a \
1699 receiver pins to verify a delivery"
1700 );
1701 gunnar_core::mint_ed25519()
1702 .map_err(|err| gunnar_store::Error::backend(format!("{err}")))
1703 })
1704 .context("reading the webhook signing key")?;
1705 let pem = String::from_utf8(bytes)
1706 .context("the stored webhook key is not utf-8 OpenSSH PEM")?;
1707 gunnar_core::ssh_key::PrivateKey::from_openssh(&pem)
1708 .context("parsing the webhook signing key")
1709 }
1710
1711 /// **The actor-ticket signing key**, minting one on first use.
1712 ///
1713 /// A fifth key beside the issuer's, the log's, the builder's and the
1714 /// webhook's, and a fifth job: it signs the appliance's word that one
1715 /// human may act for one tenant. Sealed under the KEK and atomic across
1716 /// racing callers, exactly as the four above are.
1717 ///
1718 /// # Why not the SSH host key, which is right there
1719 ///
1720 /// Because the host key's signatures always travel inside something that
1721 /// separates domains for them — the SSH transport's own framing, or the
1722 /// `sshsig` envelope with its namespace — and a raw Ed25519 signature over
1723 /// a JSON document travels inside nothing. One key signing in two formats,
1724 /// one of which has no domain separator at the signature layer, is a
1725 /// cross-protocol confusion that costs nothing today and is not
1726 /// recoverable on the day somebody finds bytes that parse as both. A key
1727 /// per job also keeps a compromise of one a compromise of one, which is
1728 /// the sentence the other four are each here for.
1729 ///
1730 /// # Why this costs no installer pin, which was the alternative
1731 ///
1732 /// It is minted AT RUNTIME into the secret store this box already has.
1733 /// The design that was weighed against it — ship the key, or a
1734 /// configuration naming it, in the image — would have put a new value into
1735 /// the two manifests that pin the installer, and `gunnar-cli`'s own note
1736 /// on that pin is the reason it was not taken: a key that can only be
1737 /// rolled by re-cutting an ISO is a key nobody rolls. Nothing here reaches
1738 /// the image; the first `Billing.MintActorTicket` on a fresh appliance
1739 /// mints it, and `Billing.ApplianceIdentity` hands the operator the public
1740 /// half to paste into their configuration.
1741 ///
1742 /// # Why it is raw Ed25519 and not `sshsig`
1743 ///
1744 /// Because the VERIFIER is `monetize_embed::ticket::verify_ticket`, which
1745 /// holds an `ed25519_dalek::VerifyingKey` and checks the canonical bytes
1746 /// that crate defines. gunnar writes no second canonical form and no
1747 /// second envelope — the stored key is still an ordinary OpenSSH ed25519
1748 /// private key, minted by the same [`gunnar_core::mint_ed25519`] as its
1749 /// four siblings, and only the SIGNATURE format differs.
1750 /// **The named sealed slot `name`** (2026-10-09): `(owner principal id,
1751 /// value)`, or `None`. Stored as the secret `slot/<name>` through
1752 /// [`Self::secrets`] — sealed under the KEK with the key as its AAD, one
1753 /// `Secret` frame — so the twin copies it, a snapshot carries it and a
1754 /// recharge restores it with no code of its own. The ACL is the gRPC
1755 /// service's; this reads and writes.
1756 #[cfg(feature = "metered")]
1757 pub fn sealed_slot_get(&self, name: &str) -> Result<Option<(String, Vec<u8>)>> {
1758 use gunnar_store::SecretStore as _;
1759 let raw = self.secrets()?.get(&sealed_slot::key(name)).context("reading a sealed slot")?;
1760 raw.map(|b| sealed_slot::decode(&b)).transpose()
1761 }
1762
1763 /// **Store `value` in the slot `name`, owned by `owner`.** Answers whether
1764 /// it was created (it did not exist).
1765 #[cfg(feature = "metered")]
1766 pub fn sealed_slot_put(&self, name: &str, owner: &str, value: &[u8]) -> Result<bool> {
1767 use gunnar_store::SecretStore as _;
1768 let secrets = self.secrets()?;
1769 let key = sealed_slot::key(name);
1770 let created = secrets.get(&key).context("reading a sealed slot")?.is_none();
1771 secrets.put(&key, &sealed_slot::encode(owner, value)).context("writing a sealed slot")?;
1772 Ok(created)
1773 }
1774
1775 #[cfg(feature = "metered")]
1776 pub fn actor_ticket_key(&self) -> Result<crate::actor_ticket::ActorTicketKey> {
1777 use gunnar_store::{SecretStore as _, SecretStoreExt as _};
1778 let secrets = self.secrets()?;
1779 // RCH-TK: a re-laid main that awaits its twin, or is being restored, gets the DEAD
1780 // main's key back with the twin's sealed secrets — the key the ONE box still holds for
1781 // this site. Minting one first would sign tickets nobody accepts.
1782 let root = self.root();
1783 if (root.join(crate::twin::RECHARGE_REL).exists() || crate::twin::is_restoring(root))
1784 && secrets.get(gunnar_store::ACTOR_TICKET_KEY).context("reading the actor-ticket signing key")?.is_none()
1785 {
1786 anyhow::bail!("this box awaits its twin's restore (a re-laid main): the actor-ticket key comes back with it, none is minted here");
1787 }
1788 let bytes = secrets
1789 .get_or_create_with(gunnar_store::ACTOR_TICKET_KEY, || {
1790 tracing::info!(
1791 key = gunnar_store::ACTOR_TICKET_KEY,
1792 "no actor-ticket signing key stored; minting one. Its public half is what \
1793 the other side of the seam pins to check what this appliance signed"
1794 );
1795 gunnar_core::mint_ed25519()
1796 .map_err(|err| gunnar_store::Error::backend(format!("{err}")))
1797 })
1798 .context("reading the actor-ticket signing key")?;
1799 let pem =
1800 String::from_utf8(bytes).context("the stored actor-ticket key is not utf-8 OpenSSH PEM")?;
1801 let private = gunnar_core::ssh_key::PrivateKey::from_openssh(&pem)
1802 .context("parsing the actor-ticket signing key")?;
1803 crate::actor_ticket::ActorTicketKey::from_openssh(&private)
1804 }
1805
1806 /// **The server's certificate issuer** (the certificate-identity plan).
1807 ///
1808 /// Built from the two halves that must never be one: the control store,
1809 /// which holds the issuer *certificate* and the trusted-CA list, and the
1810 /// **sealed** secret store, which holds the private key. It is
1811 /// [`Self::secrets`] that is handed over and never the raw handle — the raw
1812 /// handle writes plaintext, and this is the one key on the machine whose
1813 /// loss means an attacker issues themselves an administrator rather than
1814 /// having to break anything.
1815 ///
1816 /// The two halves are now two **tables** rather than two files, and the
1817 /// boundary that protects the key is the seal rather than the file: the KEK
1818 /// is in `secrets.kek` beside the database and not in it, so a copied
1819 /// `control.redb` carries a certificate it cannot sign with.
1820 ///
1821 /// It no longer needs a storage engine. Neither does
1822 /// [`Self::pki_readonly`], which is why both are here and only the
1823 /// difference between them is about what they may write.
1824 pub fn pki(&self) -> Result<gunnar_core::Pki> {
1825 let secrets: std::sync::Arc<dyn gunnar_store::SecretStore> =
1826 std::sync::Arc::new(self.secrets()?);
1827 Ok(gunnar_core::Pki::new(
1828 Arc::clone(&self.control_store),
1829 secrets,
1830 )?)
1831 }
1832
1833 /// **The issuer certificate and the trusted-CA list, and nothing that can
1834 /// sign.**
1835 ///
1836 /// `gunnar issuer show` is a control-plane read. The returned handle refuses
1837 /// every secret-store call **by construction**, so it cannot mint, import or
1838 /// issue — see [`gunnar_core::Pki::read_only`]. That is now the only thing
1839 /// separating it from [`Self::pki`]: since the secrets moved into
1840 /// `control.redb`, "without an engine" describes both, and the distinction
1841 /// this method carries is a capability one rather than a dependency one.
1842 pub fn pki_readonly(&self) -> Result<gunnar_core::Pki> {
1843 Ok(gunnar_core::Pki::read_only(Arc::clone(
1844 &self.control_store,
1845 ))?)
1846 }
1847
1848 /// The identity table and catalog together, as `gunnar-ssh` wants them.
1849 ///
1850 /// Handed the shared write-gate so the front door refuses a client push on
1851 /// a standby. Every `Control` this returns clones the SAME gate, so SSH and
1852 /// smart-HTTP freeze together the instant a role change flips it.
1853 /// **Install the plan-gate chain** a mirror sync is held to — the front
1854 /// doors' own, in their order. Replaces whatever was installed: the chain
1855 /// is built once per serve, and the newest build is the one in force.
1856 pub fn install_plan_gates(&self, gates: Vec<Arc<dyn gunnar_ssh::PlanGate>>) {
1857 *self
1858 .plan_gates
1859 .write()
1860 .unwrap_or_else(|poisoned| poisoned.into_inner()) = gates;
1861 }
1862
1863 /// **Install the data set's space ledger** — the disk gate's
1864 /// ([`gunnar_ssh::DiskHeadroomGate::ledger`]). Replaces any earlier one.
1865 pub fn install_space_ledger(&self, ledger: Arc<gunnar_ssh::SpaceLedger>) {
1866 *self.space.write().unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(ledger);
1867 }
1868
1869 /// The installed space ledger, if any.
1870 pub fn space_ledger(&self) -> Option<Arc<gunnar_ssh::SpaceLedger>> {
1871 self.space.read().unwrap_or_else(|poisoned| poisoned.into_inner()).clone()
1872 }
1873
1874 /// The installed chain, in the order it is asked. Empty: no ceiling.
1875 pub fn plan_gates(&self) -> Vec<Arc<dyn gunnar_ssh::PlanGate>> {
1876 self.plan_gates
1877 .read()
1878 .unwrap_or_else(|poisoned| poisoned.into_inner())
1879 .clone()
1880 }
1881
1882 pub fn ssh_control(&self) -> Control {
1883 Control::new(self.auth.clone(), self.catalog.clone())
1884 .with_write_gate(Arc::clone(&self.writes_frozen))
1885 .with_browse_flag(Arc::clone(&self.browse))
1886 // **And the twin flag** (2026-09-21, lane T4): a twin serves no git
1887 // at all, and the role now arrives over the wire on a box that is
1888 // already listening. One `Arc`, so the door cannot be a restart
1889 // behind the role.
1890 .with_twin_flag(Arc::clone(&self.twin))
1891 // The owner's enterprise list (2026-10-05): a first push's
1892 // repository name meets the name filter, and its creation is in
1893 // its account's audit log.
1894 .with_enterprise(self.names.clone(), Arc::clone(&self.account_audit))
1895 }
1896
1897 /// **The shared browse flag.** `true` = this box is a browse replica: a
1898 /// frozen standby that serves anonymous reads and refuses every
1899 /// control-plane mutator and its own promotion. Same one value as
1900 /// [`Self::writes_frozen`]'s sibling (one implementation).
1901 pub fn browse(&self) -> Arc<AtomicBool> {
1902 Arc::clone(&self.browse)
1903 }
1904
1905 /// **The shared write-gate.** `true` = this box is a frozen standby.
1906 ///
1907 /// The replication slot writes it on a role change; the SSH/HTTP front
1908 /// doors read it on every push. One value (one implementation).
1909 pub fn writes_frozen(&self) -> Arc<AtomicBool> {
1910 Arc::clone(&self.writes_frozen)
1911 }
1912
1913 /// **How many repository stores this process has opened so far** — see the
1914 /// `store_opens` field. Monotonic, so a caller measures a delta across the
1915 /// operation it is pricing. The measurement behind the red: a plain
1916 /// `Mirror.ReplicationStatus` must leave this unchanged.
1917 pub fn store_opens(&self) -> u64 {
1918 self.store_opens.load(std::sync::atomic::Ordering::Relaxed)
1919 }
1920
1921 /// Where a repository's git directory is, checked against symlink escapes.
1922 ///
1923 /// The string sandbox in [`gunnar_core::resolve_repo_path`] cannot see a
1924 /// symlink; that check lives in [`RepoRef::path_under`], and this is the
1925 /// one place gunnar calls it.
1926 pub fn repo_dir(&self, path: &RepoRef) -> Result<PathBuf> {
1927 path.path_under(&self.repos_root)
1928 .with_context(|| format!("resolving the directory for {path}"))
1929 }
1930
1931 /// The handle cache, recovering from a poisoned lock.
1932 ///
1933 /// This lock is taken on every repository lookup on every request, and it
1934 /// used to be three `.expect()`s. One panic anywhere under it poisoned the
1935 /// mutex and every subsequent request panicked: a whole server lost to one
1936 /// bad repository. What the lock protects is a *cache*: every entry in it is
1937 /// a complete, already-opened handle, so a panicking thread cannot leave it
1938 /// half-written, and recovering is strictly better than propagating.
1939 fn cache(&self) -> MutexGuard<'_, HandleCache> {
1940 self.open
1941 .lock()
1942 .unwrap_or_else(|poison| poison.into_inner())
1943 }
1944
1945 /// **The bound the handle cache is currently holding itself to.**
1946 ///
1947 /// The companion to [`Self::open_handles`], which answers how many are held
1948 /// but not what against. `reclaim_under_pressure` MOVES this number, so a
1949 /// caller — or a test — that reads only the count cannot tell a cache that
1950 /// evicted from a cache that was cut.
1951 pub fn open_repo_capacity(&self) -> usize {
1952 self.cache().capacity
1953 }
1954
1955 /// **Lower (or raise) the handle bound and evict down to it now.** Returns
1956 /// how many handles were dropped.
1957 ///
1958 /// The same call [`Self::reclaim_under_pressure`] makes, exposed because
1959 /// that one is reachable only from a `/proc/meminfo` reading below
1960 /// [`RECLAIM_FLOOR_PERCENT`] — a state a test cannot put a shared build box
1961 /// into, and the reason the reclaim path is the one half of the bound that
1962 /// `gunnar-cli/tests/handle_cache.rs` could not reach. Eviction on *insert*
1963 /// is covered there and is not duplicated here (one implementation); eviction on
1964 /// *pressure* had never run outside the appliance it was written for.
1965 pub fn set_open_repo_capacity(&self, capacity: usize) -> usize {
1966 self.cache().set_capacity(capacity)
1967 }
1968
1969 /// The stores for one catalogued repository, opening it if this is the
1970 /// first request for it.
1971 ///
1972 /// The returned `Arc` is what keeps the handle alive: while a caller holds
1973 /// one, the evictor will not drop that repository from the cache, so an
1974 /// in-flight clone cannot have its store lock pulled out from under it.
1975 pub fn repo_store(&self, repo: &Repo) -> Result<Arc<OpenRepo>> {
1976 let path = repo.path();
1977 let key = path.key();
1978 if self.suspended() {
1979 anyhow::bail!(
1980 "the data set is off-line while it is being grown; the repository {} cannot be \
1981 opened until /data is mounted again — retry in a few minutes",
1982 key
1983 );
1984 }
1985 if let Some(store) = self.cache().touch(&key) {
1986 return Ok(store);
1987 }
1988 // A miss is about to open a store, which is the one moment the cache
1989 // grows. Check the box before it does; see `reclaim_under_pressure`.
1990 self.reclaim_under_pressure();
1991
1992 let dir = self.repo_dir(&path)?;
1993 if let Some(parent) = dir.parent() {
1994 std::fs::create_dir_all(parent).with_context(|| {
1995 format!("creating the namespace directory {}", parent.display())
1996 })?;
1997 }
1998 // The engine opens it. Which engine that is, is a fact about startup
1999 // rather than about this file.
2000 // The one site a store is actually opened. Count it here — after the
2001 // cache miss and before anything can early-return — so `store_opens`
2002 // measures engine opens (the refold cost) and nothing a cache
2003 // hit avoided. See the field.
2004 self.store_opens
2005 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2006 // An open store is one whose bytes may move: the usage ledger
2007 // re-measures it while it is open and once after it closes.
2008 self.usage.mark_repo(&repo.namespace, &repo.name);
2009 let store = self
2010 .engine
2011 .open_repo(&dir, repo.hash_kind.into())
2012 .with_context(|| format!("opening the repository at {}", dir.display()))?
2013 .logged(Arc::clone(&self.txlog), &repo.key(), &repo.hash_kind.to_string());
2014 Self::point_head(&store, repo)?;
2015 // The hash marker, written once. `--adopt` is the only reader; a
2016 // store from before 2026-08-30 gets its marker on its first open
2017 // after the upgrade, from the catalog row it has always been served
2018 // under.
2019 let marker = dir.join(Self::HASH_MARKER);
2020 if !marker.exists() {
2021 if let Err(err) = std::fs::write(&marker, format!("{}\n", repo.hash_kind)) {
2022 tracing::warn!(
2023 target: "gunnar::store",
2024 repo = %repo.key(),
2025 marker = %marker.display(),
2026 error = %err,
2027 "the hash marker could not be written; --adopt will refuse this store"
2028 );
2029 }
2030 }
2031
2032 // **The account's quota, applied at the one moment it can be.** The
2033 // store opens on the server's default and is moved onto the plan here,
2034 // so an admin who set a quota while the repository was closed does not
2035 // have to set it again. A namespace with no quota keeps the default,
2036 // which is what `None` means and why the call is skipped rather than
2037 // passed a zero.
2038 self.apply_explode_quota(&repo.namespace, &dir);
2039
2040 // Another thread may have won the race while this one was on disk. Its
2041 // handle holds the lock; ours is about to be dropped, releasing it. So
2042 // keep the winner rather than overwriting it.
2043 Ok(self.cache().insert(key, store))
2044 }
2045
2046 /// Move every open store under `dir` onto the namespace's quota.
2047 ///
2048 /// Called when a store opens and when an admin changes the plan. A failure
2049 /// to read the catalog is logged and the default stands: a quota that
2050 /// cannot be read must not stop a repository serving, because the worst it
2051 /// costs is a cache that is the wrong size and the best it costs is an
2052 /// outage.
2053 pub fn apply_explode_quota(&self, namespace: &str, dir: &Path) {
2054 match self.catalog().namespace_explode_budget(namespace) {
2055 Ok(Some(bytes)) => {
2056 let n = self.engine.set_explode_budget(dir, bytes);
2057 tracing::debug!(
2058 target: "gunnar::store",
2059 namespace,
2060 bytes,
2061 stores = n,
2062 "the account's explode budget was applied"
2063 );
2064 }
2065 Ok(None) => {}
2066 Err(err) => tracing::warn!(
2067 target: "gunnar::store",
2068 namespace,
2069 error = %err,
2070 "the account's explode budget could not be read; the server default stands"
2071 ),
2072 }
2073 }
2074
2075 /// **Shrink the handle cache when the box is short of memory.**
2076 ///
2077 /// # The hole this closes — MEASURED on the 2 GB appliance, 2026-08-30
2078 ///
2079 /// On a 2 GB appliance the box collapsed at ~1200 repositories:
2080 /// `MemAvailable` fell under the indexer watchdog's floor (399 MB) and
2081 /// every store then spilled caches it did not hold — `scanned_rows_dropped=0`
2082 /// on every push — because the memory was in the **other 255 handles in the
2083 /// LRU**, which the watchdog cannot reach. Before the console fix that
2084 /// ended in a hard wedge: eight transfers in flight, no progress for 41
2085 /// minutes, the control plane still answering. The handle cache was bounded
2086 /// by descriptors and nothing else, so it never gave anything back.
2087 ///
2088 /// [`crate::budget`] sizes the cache against memory at start. This is the
2089 /// same rule applied *again*, from the memory the box actually has now:
2090 /// below [`RECLAIM_FLOOR_PERCENT`] of `MemTotal` available, the bound is
2091 /// halved and the least recently used handles are dropped, freeing their
2092 /// redb page caches and their five descriptors each. It is the eviction the
2093 /// watchdog could not do, done by the only thing that owns the handles.
2094 ///
2095 /// **Rate-limited to one probe per [`RECLAIM_PROBE_INTERVAL`]**, and taken
2096 /// only on a cache *miss*, which is the one moment the cache grows. A
2097 /// `/proc/meminfo` read on every request would be a syscall on the hot
2098 /// path; on a miss it is noise beside opening a store.
2099 ///
2100 /// It never shrinks below [`crate::limits::MIN_OPEN_REPOS`]: a server that
2101 /// can keep no repository open re-opens on every request, and a box that
2102 /// low is the WARN row's job, not the cache's.
2103 fn reclaim_under_pressure(&self) {
2104 let now = std::time::Instant::now();
2105 {
2106 let mut probed = self
2107 .last_reclaim_probe
2108 .lock()
2109 .unwrap_or_else(|poison| poison.into_inner());
2110 match *probed {
2111 Some(last) if now.duration_since(last) < RECLAIM_PROBE_INTERVAL => return,
2112 _ => *probed = Some(now),
2113 }
2114 }
2115 let Some((total, available)) = crate::dashboard::probe::memory() else {
2116 return;
2117 };
2118 if total == 0 || available >= total / 100 * RECLAIM_FLOOR_PERCENT {
2119 return;
2120 }
2121 let mut cache = self.cache();
2122 let before = cache.capacity;
2123 let want = (before / 2).max(crate::limits::MIN_OPEN_REPOS);
2124 if want >= before {
2125 return;
2126 }
2127 let dropped = cache.set_capacity(want);
2128 let held = cache.entries.len();
2129 drop(cache);
2130 tracing::warn!(
2131 target: "gunnar::limits",
2132 event = "limits.handles.reclaimed",
2133 mem_available = available,
2134 mem_total = total,
2135 was = before,
2136 now = want,
2137 dropped,
2138 open = held,
2139 "memory is short ({} of {} available): the open-repository cache is cut from {} to \
2140 {} handles and {} were dropped. Each one held five descriptors and its page caches; \
2141 a repository touched again simply re-opens.",
2142 crate::budget::human(available),
2143 crate::budget::human(total),
2144 before,
2145 want,
2146 dropped
2147 );
2148 }
2149
2150 /// Make an open repository's `HEAD` name the default branch its catalog
2151 /// entry carries.
2152 ///
2153 /// **The only place the server projects the catalog onto a repository's
2154 /// `HEAD`** (one implementation), and it is called from exactly two: [`Self::repo_store`]
2155 /// on the open path, so create and every transfer after a restart carry it,
2156 /// and `ControlPlane::set_default_branch`, which has to re-run it because
2157 /// the handle it needs is already in the cache and `repo_store` returns
2158 /// early for those.
2159 ///
2160 /// A move is logged at info. It is a change to what a clone checks out and
2161 /// it happens without anyone asking on the open path, so it must be visible
2162 /// in the log of the repository that had it done to it.
2163 pub fn point_head(store: &OpenRepo, repo: &Repo) -> Result<()> {
2164 // Through the UNLOGGED store: HEAD's default is derived from the
2165 // catalog row, which is itself a logged document, and every box —
2166 // primary, twin, a restore — derives it the same way on open. Logging
2167 // it here would put a frame in a twin's log that the primary never
2168 // numbered, and `append_at` would refuse the primary's next one.
2169 let moved = gunnar_core::point_head_at(store.unlogged(), &repo.default_branch)
2170 .with_context(|| format!("pointing {}'s HEAD at its default branch", repo.key()))?;
2171 if moved {
2172 tracing::info!(
2173 repo = %repo.key(),
2174 default_branch = %repo.default_branch,
2175 "HEAD now names the repository's default branch; it did not before, so this \
2176 repository was not clonable if that branch was the only one with commits"
2177 );
2178 }
2179 Ok(())
2180 }
2181
2182 /// [`Self::point_head`] for a repository this server has catalogued,
2183 /// opening its store first. Used after the catalog's default branch moves.
2184 pub fn repoint_head(&self, repo: &Repo) -> Result<()> {
2185 let store = self.repo_store(repo)?;
2186 Self::point_head(&store, repo)
2187 }
2188
2189 /// Adopt a pushed branch as the default branch of a repository whose `HEAD`
2190 /// still resolves to nothing, and report the name if one was adopted.
2191 ///
2192 /// # Why a push is allowed to change this
2193 ///
2194 /// `repo create` gives every repository `refs/heads/main`, and a donor
2195 /// whose branch is `master` therefore lands in a repository whose `HEAD`
2196 /// names a branch that does not exist and never will. That repository
2197 /// **cannot be cloned**: `HEAD` has no object, so it is not advertised, and
2198 /// stock `git clone` writes an empty worktree, prints *"remote HEAD refers
2199 /// to nonexistent ref"* and exits **0**. Nothing between the successful push
2200 /// and the empty checkout says a word about the default branch, which is
2201 /// what cost the roundtrip harness a day on 2026-08-06.
2202 ///
2203 /// So the first branch to arrive in a repository with an unborn `HEAD` is
2204 /// adopted, which is what every hosted forge does and what makes `push`
2205 /// alone produce something clonable. It is deliberately narrow:
2206 ///
2207 /// * it fires **only** when `HEAD` resolves to nothing *after* the push, so
2208 /// the only repositories it can touch are the ones that are unclonable as
2209 /// they stand — if the configured default branch was among the ones just
2210 /// pushed, `HEAD` resolves and this does nothing;
2211 /// * it considers only `refs/heads/…` that the push **created or moved**,
2212 /// never a delete and never a tag; and
2213 /// * the choice goes through the catalog and then through
2214 /// [`Self::point_head`], so the authority and its projection still move
2215 /// together and there is still one writer of `HEAD`.
2216 ///
2217 /// It is logged at `warn`, not `info`: the server chose a default branch
2218 /// nobody asked for, and the operator has to be able to find that decision
2219 /// later. `set-default-branch` overrides it.
2220 pub fn adopt_default_branch_if_unborn(
2221 &self,
2222 path: &RepoRef,
2223 pushed: &[String],
2224 ) -> Result<Option<String>> {
2225 let Some(repo) = self.catalog().get(path)? else {
2226 // The repository was deleted between the push and here. Not an
2227 // error, and not something to invent a default branch for.
2228 return Ok(None);
2229 };
2230 let store = self.repo_store(&repo)?;
2231 // `HEAD` is not a row in `refs()` (the `GitServe` HEAD ruling), so this is the accessor
2232 // pair plus one resolution against the same snapshot the resolution
2233 // reads — see [`gunnar_wire::RefSnapshot::head`].
2234 // `FullName::new(HEAD_REF)` died with `RefStore`; there is nothing to
2235 // validate here, because the name is not a caller's.
2236 let git = store.serve();
2237 if gunnar_wire::RefSnapshot::read(git)?.head(git)?.is_some() {
2238 return Ok(None);
2239 }
2240 let Some(adopted) = pushed
2241 .iter()
2242 .find(|name| name.starts_with("refs/heads/"))
2243 .cloned()
2244 else {
2245 return Ok(None);
2246 };
2247 let repo = self.catalog().set_default_branch(path, &adopted)?;
2248 Self::point_head(&store, &repo)?;
2249 tracing::warn!(
2250 repo = %repo.key(),
2251 default_branch = %adopted,
2252 "this repository's default branch did not exist, so nothing could clone it; the \
2253 first branch pushed has been adopted as the default. `gunnar control repo \
2254 set-default-branch` overrides this"
2255 );
2256 Ok(Some(adopted))
2257 }
2258
2259 /// **`repo create` is transactional, and it never adopts a store it did
2260 /// not make.** The one verb every create routes through: the gRPC
2261 /// `Repos.Create`, the local `gunnar repo create`, and the
2262 /// first-repository door once a push has passed the hash check.
2263 ///
2264 /// Order, and why each step is where it is:
2265 ///
2266 /// 1. **Look at the disk first.** If a directory already stands at the
2267 /// name — or a [tombstone](Self::tombstone_path) says a delete could not
2268 /// reclaim it — the create is refused **by name**, before the catalog is
2269 /// touched. This is the 2026-08-30 resurrection: on v1.1.3-166 `repo
2270 /// delete` dropped the catalog row and left the store, and the next
2271 /// `repo create` of the same name opened it — `ls-remote` right after
2272 /// the create listed all 59 refs of the deleted skade, and recreated
2273 /// `--hash sha256` the name served sha1 refs (`fatal: protocol
2274 /// error`). `adopt` is the opt-in for the one legitimate case (a
2275 /// catalog restored from backup over a surviving data disk), and only
2276 /// for a store whose [hash marker](Self::HASH_MARKER) matches.
2277 /// 2. The catalog row.
2278 /// 3. The store. If the engine cannot make it, step 2 is **rolled back**
2279 /// and whatever the engine left is removed: a create that failed
2280 /// leaves no directory, no row, nothing for `repo get` to find.
2281 ///
2282 /// Returns the catalogued repository; the handle is in the cache.
2283 pub fn create_repo(&self, spec: NewRepo, adopt: bool) -> Result<Repo> {
2284 let path = spec.path.clone();
2285 // TRUE when the directory was already there and was taken over. The
2286 // rollback below must not remove such a directory: "a create that
2287 // failed leaves no directory" is a promise about a directory THIS
2288 // create made, and applying it to an adopted store would answer a
2289 // transient engine failure by destroying the very bytes the operator
2290 // ran `--adopt` to keep. On the appliance that is 25-39 GB.
2291 let adopted = self.refuse_stale_store(&path, adopt, spec.hash_kind)?;
2292 let repo = self.catalog().create(spec)?;
2293 match self.repo_store(&repo) {
2294 Ok(_) => Ok(repo),
2295 Err(err) => {
2296 // Roll back: the row, the handle, the directory. A failure
2297 // inside the rollback is logged and the original error is the
2298 // one the caller sees, because that is the one they can act on.
2299 self.close_repo(&path);
2300 if let Err(undo) = self.catalog().delete(&path) {
2301 tracing::error!(
2302 target: "gunnar::store",
2303 repo = %path,
2304 error = format!("{undo:#}"),
2305 "rolling back the catalog row of a create whose store failed"
2306 );
2307 }
2308 if adopted {
2309 tracing::error!(
2310 target: "gunnar::store",
2311 event = "repo.adopt_failed",
2312 repo = %path,
2313 "the create that ADOPTED this store failed to open it; the catalog row is \
2314 rolled back and the DIRECTORY IS LEFT WHERE IT IS — it was not made by \
2315 this create and is not this create's to remove"
2316 );
2317 } else if let Ok(dir) = self.repo_dir(&path) {
2318 if let Err(undo) = std::fs::remove_dir_all(&dir) {
2319 if undo.kind() != std::io::ErrorKind::NotFound {
2320 tracing::error!(
2321 target: "gunnar::store",
2322 repo = %path,
2323 dir = %dir.display(),
2324 error = %undo,
2325 "rolling back the directory of a create whose store failed"
2326 );
2327 }
2328 }
2329 }
2330 Err(err.context(if adopted {
2331 format!(
2332 "creating the stores of {path} over an ADOPTED directory; the row was \
2333 rolled back and the store's bytes were left untouched"
2334 )
2335 } else {
2336 format!("creating the stores of {path}; nothing was kept")
2337 }))
2338 }
2339 }
2340 }
2341
2342 /// The file that says which hash a store's objects are: `sha1` or
2343 /// `sha256`, one line, written when the store is first opened. Not the
2344 /// engine's — a store carries no marker of its own, and opening a sha1
2345 /// archive as sha256 does not fail, it decodes garbage. `--adopt` reads
2346 /// it; nothing else does.
2347 pub const HASH_MARKER: &'static str = "OBJECT_FORMAT";
2348
2349 /// Step 1 of [`Self::create_repo`]: nothing may stand at `path` on disk.
2350 ///
2351 /// Returns **true when an existing store was ADOPTED** — the one case in
2352 /// which the directory was not made by this create, and therefore the one
2353 /// case in which [`Self::create_repo`]'s rollback must not remove it.
2354 ///
2355 /// ## `--adopt` means "take it as it is", including a store from before
2356 /// the marker existed
2357 ///
2358 /// MEASURED on the appliance 2026-08-30, `v1.1.3-227-g6a2367a`, request
2359 /// `175daccc0000000d`:
2360 ///
2361 /// ```text
2362 /// repo create alice/linux --hash sha1 --public --adopt
2363 /// -> failed precondition: cannot adopt the store at
2364 /// /data/repos/alice/linux.git: it carries unmarked objects, the
2365 /// create asked for sha1
2366 /// ```
2367 ///
2368 /// The marker is a 2026-08-30 file. **Every store written before that day
2369 /// has none**, so an `--adopt` that requires one refuses precisely the
2370 /// stores adoption exists for — a catalog restored over a surviving data
2371 /// disk, or the orphan an old catalog-only delete left. Composed with a
2372 /// `create` that refuses the name and a `delete` that cannot authorise it,
2373 /// that is a store with no way out at all.
2374 ///
2375 /// The honest rule, and the one implemented here:
2376 ///
2377 /// * **unmarked** — there is no second opinion to check, so the caller's
2378 /// `--hash` is taken at their word AND WRITTEN into the marker. The
2379 /// store is marked from then on, so the *next* adopt is checked; the
2380 /// caller's word is recorded rather than merely trusted.
2381 /// * **marked and equal** — adopted, as before.
2382 /// * **marked and different** — refused, as before. This is the arm that
2383 /// stops a sha256 row over a sha1 store from serving refs no client can
2384 /// read, and it is not weakened: a store that carries a marker is
2385 /// checked against it.
2386 pub fn refuse_stale_store(
2387 &self,
2388 path: &RepoRef,
2389 adopt: bool,
2390 hash_kind: HashKind,
2391 ) -> Result<bool> {
2392 let dir = self.repo_dir(path)?;
2393 let tombstone = Self::tombstone_path(&dir);
2394 let tombstoned = tombstone.exists();
2395 if !dir.exists() && !tombstoned {
2396 return Ok(false);
2397 }
2398 if !adopt {
2399 let why = if tombstoned {
2400 let record =
2401 Tombstone::parse(&std::fs::read_to_string(&tombstone).unwrap_or_default());
2402 match record.reason {
2403 // **The soft-delete window.** The name is unusable because
2404 // the repository is still there, being kept — and the
2405 // sentence has to say `undelete`, because a create is
2406 // exactly what somebody reaches for when they mean it.
2407 TombstoneReason::Deleted => {
2408 let left = record
2409 .seconds_left(gunnar_core::now_ms())
2410 .map(|s| format!("{} day(s) left", s / 86_400))
2411 .unwrap_or_else(|| "no expiry set".to_owned());
2412 format!(
2413 "{path} is DELETED but not purged: its bytes are being kept ({left}). \
2414 `repo undelete {path}` puts it back as it was, `repo purge {path}` \
2415 reclaims it now, or pass --adopt to take the store as a new \
2416 repository and lose the old row"
2417 )
2418 }
2419 TombstoneReason::ReclaimFailed => format!(
2420 "a delete of this name could not reclaim its store ({}); `repo delete \
2421 {path} --purge` retries the reclaim, or pass --adopt to take the store \
2422 as it is",
2423 record.note.trim()
2424 ),
2425 }
2426 } else {
2427 format!(
2428 "a store already exists at {}; `repo delete {path}` reclaims it, or pass \
2429 --adopt to take it as it is",
2430 dir.display()
2431 )
2432 };
2433 anyhow::bail!("{why}");
2434 }
2435 let marker = dir.join(Self::HASH_MARKER);
2436 let on_disk = std::fs::read_to_string(&marker)
2437 .map(|text| text.trim().to_owned())
2438 .unwrap_or_default();
2439 if on_disk.is_empty() {
2440 // Unmarked: a store from before the marker existed. There is
2441 // nothing to disagree with, so the caller's word is taken — and
2442 // WRITTEN, so it is a fact from here on rather than a decision
2443 // taken again on every adopt.
2444 std::fs::write(&marker, format!("{hash_kind}\n")).with_context(|| {
2445 format!(
2446 "marking the adopted store at {} as {hash_kind}; the adopt is refused rather \
2447 than leaving a store whose hash nothing records",
2448 dir.display()
2449 )
2450 })?;
2451 tracing::warn!(
2452 target: "gunnar::store",
2453 event = "repo.adopted_unmarked",
2454 repo = %path,
2455 dir = %dir.display(),
2456 hash = %hash_kind,
2457 marker = %marker.display(),
2458 "an UNMARKED store (written before the OBJECT_FORMAT marker existed) was adopted \
2459 at the caller's word; it is marked {} from now on and the next adopt is CHECKED",
2460 hash_kind
2461 );
2462 } else {
2463 anyhow::ensure!(
2464 on_disk == hash_kind.to_string(),
2465 "cannot adopt the store at {}: its objects are {} (marker {} says so), the \
2466 create asked for {hash_kind}. Adopt it as {} or leave it alone; a {hash_kind} \
2467 row over a {} store serves refs no client can read.",
2468 dir.display(),
2469 on_disk,
2470 marker.display(),
2471 on_disk,
2472 on_disk
2473 );
2474 }
2475 if tombstoned {
2476 std::fs::remove_file(&tombstone)
2477 .with_context(|| format!("removing the tombstone {}", tombstone.display()))?;
2478 }
2479 tracing::warn!(
2480 target: "gunnar::store",
2481 event = "repo.adopted",
2482 repo = %path,
2483 dir = %dir.display(),
2484 hash = %hash_kind,
2485 "an existing store was ADOPTED by a create with --adopt; its objects are served as they are"
2486 );
2487 Ok(true)
2488 }
2489
2490 /// Where a name's tombstone lives: beside its directory, so a store that
2491 /// could not be removed and its marker are removed together by the retry.
2492 pub fn tombstone_path(dir: &Path) -> PathBuf {
2493 let mut name = dir
2494 .file_name()
2495 .map(|n| n.to_os_string())
2496 .unwrap_or_default();
2497 name.push(".tombstone");
2498 dir.with_file_name(name)
2499 }
2500
2501 /// Is `path` tombstoned — deleted from the catalog, store not reclaimed?
2502 pub fn is_tombstoned(&self, path: &RepoRef) -> Result<bool> {
2503 Ok(Self::tombstone_path(&self.repo_dir(path)?).exists())
2504 }
2505
2506 /// How many repository handles are open. For tests and `Admin.Stats`.
2507 pub fn open_handles(&self) -> usize {
2508 self.cache().entries.len()
2509 }
2510
2511 /// Forget a repository's handle, releasing its store lock. Used by
2512 /// `repo delete --purge` before the directory is removed.
2513 pub fn close_repo(&self, path: &RepoRef) {
2514 self.usage.mark_repo(path.namespace(), path.name());
2515 self.cache().entries.remove(&path.key());
2516 }
2517
2518 /// **`repo delete` reclaims the disk.** Stop the repository's index work,
2519 /// release its handle, remove its directory — pack, journal, tail,
2520 /// exploded tier and index tables — and report the bytes that went.
2521 ///
2522 /// MEASURED 2026-08-29 on the appliance: a `repo delete` after the linux
2523 /// push left **25 GB** of `gunnar-git.znippy/` behind, because the catalog
2524 /// row was removed and the directory was not. Order here is the point:
2525 ///
2526 /// 1. the engine cancels the index worker (an in-flight pack is **aborted**
2527 /// at its next checkpoint, milliseconds, never finished — its rows are
2528 /// keyed by oid and the directory is about to go);
2529 /// 2. the handle cache forgets the repository, so the store drops as soon as
2530 /// the last request holding it returns (the worker is cancelled, so the
2531 /// drop does not join hours of work);
2532 /// 3. `remove_dir_all`. A store still held by an in-flight request keeps
2533 /// its open fds on unlinked files until it returns; the bytes are
2534 /// reclaimed when it does, and the directory entry is gone now.
2535 ///
2536 /// Returns `(bytes_before, bytes_after)` measured by walking the directory,
2537 /// so the log line and the caller report applied output.
2538 ///
2539 /// **A reclaim that fails leaves the name TOMBSTONED** (2026-08-30): a
2540 /// file beside the directory saying when and why, so
2541 /// [`Self::create_repo`] refuses the name until `repo delete` is run again
2542 /// and succeeds. Without it a failed `remove_dir_all` (a store held open
2543 /// on a read-only mount, a permissions slip) left a directory the next
2544 /// create would refuse by name anyway — but with no sentence saying that
2545 /// a delete had been asked for and had not finished.
2546 pub fn purge_repo(&self, path: &RepoRef) -> Result<(u64, u64)> {
2547 // A snapshot streaming from its cut may still read this store (R5.1-A).
2548 self.wait_unpinned("removing a repository store");
2549 let _usage = UsageMark::namespace(&self.usage, path.namespace());
2550 // **A `RepoStore` purge frame before the directory goes** (twin note, M9): the twin
2551 // removes the same store at the same point of the sequence, and a restore replays the
2552 // removal, so a purged repository is never resurrected from the log's earlier packs.
2553 // A twin applying the frame reaches `reclaim_repo` directly and frames nothing.
2554 if !self.txlog.is_follower() {
2555 let dir = self.repo_dir(path)?;
2556 let hash = std::fs::read_to_string(dir.join(Self::HASH_MARKER))
2557 .map(|s| s.trim().to_string())
2558 .unwrap_or_default();
2559 let meta = gunnar_core::txlog::Meta::RepoStore { repo: path.key(), op: "purged".into(), hash };
2560 let _framed = self.txlog.mutation();
2561 self.txlog
2562 .append(gunnar_core::txlog::Kind::RepoStore, &meta, &[])
2563 .with_context(|| format!("framing the purge of {path}"))?;
2564 return self.reclaim_repo(path);
2565 }
2566 self.reclaim_repo(path)
2567 }
2568
2569 /// The removal half of [`Self::purge_repo`], unframed: what the twin's apply of a
2570 /// `RepoStore` purge frame runs.
2571 pub fn reclaim_repo(&self, path: &RepoRef) -> Result<(u64, u64)> {
2572 let _usage = UsageMark::namespace(&self.usage, path.namespace());
2573 let dir = self.repo_dir(path)?;
2574 let tombstone = Self::tombstone_path(&dir);
2575 let before = dir_bytes(&dir);
2576 self.engine.cancel_index(&dir);
2577 self.close_repo(path);
2578 match std::fs::remove_dir_all(&dir) {
2579 Ok(()) => {}
2580 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
2581 Err(e) => {
2582 let now = gunnar_core::now_ms();
2583 let record = Tombstone {
2584 deleted_at_unix_ms: now,
2585 // No deadline: a reclaim that failed is OWED to an
2586 // operator, and a sweep that retried it unattended would
2587 // be repeating the thing that just did not work.
2588 purge_after_unix_ms: None,
2589 principal_id: String::new(),
2590 reason: TombstoneReason::ReclaimFailed,
2591 repo: None,
2592 note: format!("delete asked at unix_ms {now}, reclaim failed: {e}"),
2593 };
2594 let note = record.note.clone();
2595 let text = serde_json::to_string_pretty(&record)
2596 .unwrap_or_else(|_| format!("{{\"note\":\"{note}\"}}"));
2597 if let Err(write) = std::fs::write(&tombstone, format!("{text}\n")) {
2598 tracing::error!(
2599 target: "gunnar::store",
2600 repo = %path,
2601 tombstone = %tombstone.display(),
2602 error = %write,
2603 "the reclaim failed AND the tombstone could not be written"
2604 );
2605 }
2606 tracing::error!(
2607 target: "gunnar::store",
2608 event = "repo.purge_failed",
2609 repo = %path,
2610 dir = %dir.display(),
2611 error = %e,
2612 "repo delete could not reclaim the store; the name is tombstoned and a create \
2613 refuses it until `repo delete` is run again and succeeds"
2614 );
2615 return Err(anyhow::anyhow!(
2616 "the catalog row is gone but the store at {} could not be reclaimed ({e}); \
2617 the name is tombstoned — `repo delete {path}` retries, `repo create` refuses \
2618 it until then",
2619 dir.display()
2620 ));
2621 }
2622 }
2623 if tombstone.exists() {
2624 std::fs::remove_file(&tombstone)
2625 .with_context(|| format!("removing the tombstone {}", tombstone.display()))?;
2626 }
2627 let after = dir_bytes(&dir);
2628 tracing::info!(
2629 target: "gunnar::store",
2630 event = "repo.purged",
2631 repo = %path,
2632 dir = %dir.display(),
2633 bytes_before = before,
2634 bytes_after = after,
2635 "repo delete reclaimed {} bytes",
2636 before.saturating_sub(after)
2637 );
2638 Ok((before, after))
2639 }
2640
2641 // ── the soft-delete window (2026-08-30) ────────────────────────────────
2642
2643 /// **`repo delete` without `--purge`: a DATED TOMBSTONE, not a reclaim.**
2644 ///
2645 /// The catalog row is the caller's to remove; this is everything else. The
2646 /// bytes stay exactly where they are, the name becomes unusable
2647 /// ([`Self::refuse_stale_store`] refuses a create over a tombstone), and a
2648 /// deadline is written into the tombstone at this instant.
2649 ///
2650 /// Why this exists at all, in one measured line from
2651 /// MEASURED: `repo delete --purge` returns the bytes in
2652 /// ~2 seconds — **181 GiB to 33 MiB** — and a live mirror does not help
2653 /// because the delete replicates. The failure a first paying customer is
2654 /// most likely to cause is deleting their own repository, and the only
2655 /// thing that answers it is a window.
2656 ///
2657 /// What it does NOT do, and deliberately:
2658 ///
2659 /// * it does not touch the bytes, so it cannot fail on a full disk;
2660 /// * it cancels the index worker and drops the handle, because a
2661 /// repository nobody may reach should not be spending CPU or file
2662 /// descriptors on being resolved;
2663 /// * it does not delete the tombstone of a *failed reclaim* — that record
2664 /// is owed to an operator. Soft-deleting a name already tombstoned that
2665 /// way is refused by name.
2666 ///
2667 /// `retention` of zero is legal and means *purge at the next sweep*: an
2668 /// operator reclaiming space said so, and a zero that silently became
2669 /// thirty days would be the server overruling them.
2670 pub fn soft_delete(
2671 &self,
2672 path: &RepoRef,
2673 repo: Option<&Repo>,
2674 principal_id: &str,
2675 retention: std::time::Duration,
2676 ) -> Result<Tombstone> {
2677 let _usage = UsageMark::namespace(&self.usage, path.namespace());
2678 let dir = self.repo_dir(path)?;
2679 let marker = Self::tombstone_path(&dir);
2680 if let Some(existing) = Self::read_tombstone(&marker) {
2681 if existing.reason == TombstoneReason::ReclaimFailed {
2682 anyhow::bail!(
2683 "{path} is already tombstoned because a reclaim FAILED ({}). That is bytes \
2684 owed, not bytes held: run `repo delete {path} --purge` to finish it. \
2685 Soft-deleting it again would hide the failure behind a deadline.",
2686 existing.note.trim()
2687 );
2688 }
2689 }
2690 anyhow::ensure!(
2691 dir.is_dir(),
2692 "{path} has nothing on disk at {}; there is nothing to keep, and a tombstone over \
2693 no bytes would promise a restore that cannot happen",
2694 dir.display()
2695 );
2696 let now = gunnar_core::now_ms();
2697 let record = Tombstone {
2698 deleted_at_unix_ms: now,
2699 purge_after_unix_ms: Some(now + retention.as_millis() as i64),
2700 principal_id: principal_id.to_owned(),
2701 reason: TombstoneReason::Deleted,
2702 repo: repo.cloned(),
2703 note: format!(
2704 "soft-deleted at unix_ms {now}; the bytes are kept until the deadline, `repo \
2705 undelete {path}` restores it"
2706 ),
2707 };
2708 // The engine first: a repository nobody may reach must not keep
2709 // resolving a pack. The handle after it, so the store drops as soon as
2710 // the last in-flight request returns.
2711 self.engine.cancel_index(&dir);
2712 self.close_repo(path);
2713 let text = serde_json::to_string_pretty(&record).context("serialising the tombstone")?;
2714 std::fs::write(&marker, format!("{text}\n"))
2715 .with_context(|| format!("writing the tombstone {}", marker.display()))?;
2716 let bytes = dir_bytes(&dir);
2717 tracing::warn!(
2718 target: "gunnar::store",
2719 event = "repo.soft_deleted",
2720 repo = %path,
2721 dir = %dir.display(),
2722 bytes,
2723 principal = %principal_id,
2724 purge_after_unix_ms = record.purge_after_unix_ms.unwrap_or_default(),
2725 "repo delete tombstoned this repository; {bytes} bytes are KEPT until the retention \
2726 window ends, and they still count against the account"
2727 );
2728 Ok(record)
2729 }
2730
2731 /// **`repo undelete`: put a soft-deleted repository back.**
2732 ///
2733 /// Returns the [`NewRepo`] the caller must re-insert into the catalog, and
2734 /// removes the tombstone. The two halves are split because the catalog is
2735 /// the caller's — the gRPC path and the local CLI reach it differently —
2736 /// and because the row must go in *before* the tombstone comes off: a
2737 /// crash between them leaves a repository that is still fenced off, which
2738 /// is recoverable, rather than a name a create would adopt, which is the
2739 /// resurrection the reclaim rules closed.
2740 ///
2741 /// The recorded hash kind is the one that comes back, never a default. A
2742 /// tombstone that does not carry a row (one written before this existed)
2743 /// is refused BY NAME rather than restored as sha256 over a sha1 store.
2744 pub fn undelete_spec(&self, path: &RepoRef) -> Result<NewRepo> {
2745 let dir = self.repo_dir(path)?;
2746 let marker = Self::tombstone_path(&dir);
2747 let record = Self::read_tombstone(&marker).ok_or_else(|| {
2748 anyhow::anyhow!(
2749 "{path} is not deleted: there is no tombstone at {}. `repo list-deleted` shows \
2750 what can be undeleted.",
2751 marker.display()
2752 )
2753 })?;
2754 anyhow::ensure!(
2755 record.reason == TombstoneReason::Deleted,
2756 "{path} is tombstoned because a RECLAIM FAILED, not because it was deleted with a \
2757 window ({}). There is nothing to undelete: the operator asked for these bytes to \
2758 go. `repo delete {path} --purge` finishes it.",
2759 record.note.trim()
2760 );
2761 anyhow::ensure!(
2762 dir.is_dir(),
2763 "{path} is tombstoned but its bytes are gone from {}; there is nothing to restore",
2764 dir.display()
2765 );
2766 let repo = record.repo.ok_or_else(|| {
2767 anyhow::anyhow!(
2768 "the tombstone at {} does not record what {path} was — no hash kind, no default \
2769 branch. Restoring it would be a GUESS, and a sha256 row over a sha1 store \
2770 serves refs no client can read. Recreate it with `repo create --adopt --hash \
2771 <the store's OBJECT_FORMAT>` instead.",
2772 marker.display()
2773 )
2774 })?;
2775 Ok(NewRepo {
2776 path: path.clone(),
2777 hash_kind: repo.hash_kind,
2778 default_branch: Some(repo.default_branch),
2779 visibility: repo.visibility,
2780 description: repo.description,
2781 })
2782 }
2783
2784 /// Take the tombstone off, once the catalog row is back.
2785 pub fn clear_tombstone(&self, path: &RepoRef) -> Result<()> {
2786 let _usage = UsageMark::namespace(&self.usage, path.namespace());
2787 let marker = Self::tombstone_path(&self.repo_dir(path)?);
2788 match std::fs::remove_file(&marker) {
2789 Ok(()) => {}
2790 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
2791 Err(e) => {
2792 return Err(e)
2793 .with_context(|| format!("removing the tombstone {}", marker.display()))
2794 }
2795 }
2796 tracing::warn!(
2797 target: "gunnar::store",
2798 event = "repo.undeleted",
2799 repo = %path,
2800 "repo undelete put this repository back; its bytes were never reclaimed"
2801 );
2802 Ok(())
2803 }
2804
2805 /// Read a tombstone file, or `None` when there is not one.
2806 pub fn read_tombstone(marker: &Path) -> Option<Tombstone> {
2807 std::fs::read_to_string(marker)
2808 .ok()
2809 .map(|text| Tombstone::parse(&text))
2810 }
2811
2812 /// The tombstone beside `path`, if any.
2813 pub fn tombstone_of(&self, path: &RepoRef) -> Result<Option<Tombstone>> {
2814 Ok(Self::read_tombstone(&Self::tombstone_path(
2815 &self.repo_dir(path)?,
2816 )))
2817 }
2818
2819 /// **Every soft-deleted repository, with the bytes it is still holding.**
2820 ///
2821 /// This is what makes a tombstone visible on a bill: the bytes are real,
2822 /// the account is still using them, and a customer who deleted a
2823 /// repository and saw no change in their usage would rightly call that a
2824 /// bug. `Admin.StorageInfo` reports the total and `repo list-deleted`
2825 /// prints the rows.
2826 ///
2827 /// It walks `repos/` rather than asking the catalog, because the whole
2828 /// point of a tombstone is that the catalog does not know about it.
2829 pub fn tombstones(&self) -> Result<Vec<DeletedRepo>> {
2830 let root = self.repos_root().to_path_buf();
2831 let mut found = Vec::new();
2832 Self::walk_tombstones(&root, &root, &mut found);
2833 found.sort_by_key(|a| a.path.key());
2834 Ok(found)
2835 }
2836
2837 /// The recursion behind [`Self::tombstones`]. Namespaces nest, so
2838 /// this cannot be one `read_dir`.
2839 fn walk_tombstones(root: &Path, dir: &Path, out: &mut Vec<DeletedRepo>) {
2840 let Ok(entries) = std::fs::read_dir(dir) else {
2841 return;
2842 };
2843 for entry in entries.flatten() {
2844 let path = entry.path();
2845 let name = entry.file_name().to_string_lossy().into_owned();
2846 if name.ends_with(".git.tombstone") {
2847 let Some(record) = Self::read_tombstone(&path) else {
2848 continue;
2849 };
2850 if record.reason != TombstoneReason::Deleted {
2851 continue;
2852 }
2853 let store = path.with_file_name(name.trim_end_matches(".tombstone"));
2854 let Some(repo_ref) = Self::repo_ref_of_dir(root, &store) else {
2855 continue;
2856 };
2857 out.push(DeletedRepo {
2858 path: repo_ref,
2859 tombstone: record,
2860 bytes: dir_bytes(&store),
2861 });
2862 continue;
2863 }
2864 if path.is_dir() && !name.ends_with(".git") {
2865 Self::walk_tombstones(root, &path, out);
2866 }
2867 }
2868 }
2869
2870 /// `<repos-root>/<ns…>/<name>.git` back to a [`RepoRef`].
2871 fn repo_ref_of_dir(root: &Path, dir: &Path) -> Option<RepoRef> {
2872 let rel = dir.strip_prefix(root).ok()?;
2873 let mut parts: Vec<String> = rel
2874 .components()
2875 .map(|c| c.as_os_str().to_string_lossy().into_owned())
2876 .collect();
2877 let name = parts.pop()?;
2878 let name = name.strip_suffix(".git").unwrap_or(&name).to_owned();
2879 if parts.is_empty() {
2880 return None;
2881 }
2882 RepoRef::new(&parts.join("/"), &name).ok()
2883 }
2884
2885 // ── orphan stores: the directory nothing can see (2026-08-30) ──────────
2886
2887 /// **Every store directory with no catalog row and no soft-delete
2888 /// tombstone.**
2889 ///
2890 /// MEASURED on the appliance 2026-08-30 (`v1.1.3-227-g6a2367a`), the
2891 /// wreckage of Friday's kernel-mirror incident: `/data/repos/alice/linux.git`
2892 /// stood on the disk with ~25-39 GB in it, no catalog row and no
2893 /// tombstone. Three guards, each right on its own, composed into a state
2894 /// with no exit — `create` refused the name because the directory was
2895 /// there (`175daccc0000000c`), `create --adopt` refused it because the
2896 /// store predated the `OBJECT_FORMAT` marker (`…0d`), and `delete
2897 /// --purge` refused it because the access check was `Access::repo` on a
2898 /// repository not in the catalog, which no principal can ever hold a grant
2899 /// on (`…0b`/`…0e`). The bytes were stranded and the NAME was permanently
2900 /// unusable.
2901 ///
2902 /// This is the door that was missing. It walks `repos/` rather than
2903 /// asking the catalog, exactly as [`Self::tombstones`] does and for the
2904 /// same reason: the whole point of an orphan is that no record of it
2905 /// exists.
2906 ///
2907 /// What counts as an orphan, and why it is drawn here:
2908 ///
2909 /// * **no catalog row** — a catalogued directory is `repo list`'s;
2910 /// * **no `Deleted` tombstone** — one of those is `repo list-deleted`'s,
2911 /// and it has `undelete` and `purge` as its exits;
2912 /// * a `ReclaimFailed` tombstone IS an orphan. `tombstones()` filters that
2913 /// reason out of `list-deleted` deliberately (bytes owed, not bytes
2914 /// held), which left it in no listing whatsoever; the row carries the
2915 /// note so an operator sees why.
2916 pub fn orphans(&self) -> Result<Vec<OrphanStore>> {
2917 let root = self.repos_root().to_path_buf();
2918 let mut found = Vec::new();
2919 self.walk_orphans(&root, &root, &mut found)?;
2920 found.sort_by_key(|a| a.path.key());
2921 Ok(found)
2922 }
2923
2924 /// The recursion behind [`Self::orphans`]. Namespaces nest, so this
2925 /// cannot be one `read_dir` — the same shape as
2926 /// [`Self::walk_tombstones`].
2927 fn walk_orphans(&self, root: &Path, dir: &Path, out: &mut Vec<OrphanStore>) -> Result<()> {
2928 let Ok(entries) = std::fs::read_dir(dir) else {
2929 return Ok(());
2930 };
2931 for entry in entries.flatten() {
2932 let path = entry.path();
2933 let name = entry.file_name().to_string_lossy().into_owned();
2934 if !path.is_dir() {
2935 continue;
2936 }
2937 if !name.ends_with(".git") {
2938 self.walk_orphans(root, &path, out)?;
2939 continue;
2940 }
2941 let Some(repo_ref) = Self::repo_ref_of_dir(root, &path) else {
2942 continue;
2943 };
2944 if self.catalog().get(&repo_ref)?.is_some() {
2945 continue;
2946 }
2947 let record = Self::read_tombstone(&Self::tombstone_path(&path));
2948 let failed_reclaim = match &record {
2949 // `repo list-deleted` owns this one; it is not an orphan.
2950 Some(t) if t.reason == TombstoneReason::Deleted => continue,
2951 Some(t) => Some(t.note.trim().to_owned()),
2952 None => None,
2953 };
2954 let hash_marker = std::fs::read_to_string(path.join(Self::HASH_MARKER))
2955 .ok()
2956 .map(|text| text.trim().to_owned())
2957 .filter(|text| !text.is_empty());
2958 out.push(OrphanStore {
2959 path: repo_ref,
2960 bytes: dir_bytes(&path),
2961 modified_unix_ms: newest_mtime_unix_ms(&path),
2962 hash_marker,
2963 failed_reclaim,
2964 dir: path,
2965 });
2966 }
2967 Ok(())
2968 }
2969
2970 /// **Is there a store at `path` that no record accounts for?**
2971 ///
2972 /// The one question both delete doors ask, so they cannot answer it
2973 /// differently (one implementation): a directory on disk, no catalog row, and no
2974 /// `Deleted` tombstone. A `ReclaimFailed` tombstone still counts as an
2975 /// orphan — those bytes are owed to an operator and no listing but
2976 /// [`Self::orphans`] shows them.
2977 pub fn is_orphan(&self, path: &RepoRef) -> Result<bool> {
2978 if self.catalog().get(path)?.is_some() {
2979 return Ok(false);
2980 }
2981 let dir = self.repo_dir(path)?;
2982 if !dir.is_dir() {
2983 return Ok(false);
2984 }
2985 Ok(!matches!(
2986 Self::read_tombstone(&Self::tombstone_path(&dir)),
2987 Some(record) if record.reason == TombstoneReason::Deleted
2988 ))
2989 }
2990
2991 /// **What a delete of an orphan may and may not do**, as one sentence both
2992 /// doors print.
2993 ///
2994 /// `--purge` on an orphan is legitimate and is exactly what `repo reclaim`
2995 /// does: the operator said "the bytes go", the row is already gone, the
2996 /// bytes are there. A *soft* delete of one is not, and the reason is the
2997 /// soft delete's own contract — it promises `repo undelete` puts the
2998 /// repository back **as it was**, and for an orphan the server does not
2999 /// know what it was: no hash kind, no default branch, no visibility. Lane
3000 /// AC already refuses to restore a tombstone that carries no row; refusing
3001 /// to WRITE one is that same rule, one step earlier, before a deadline has
3002 /// been put on bytes nobody can bring back.
3003 pub fn orphan_soft_delete_refusal(path: &RepoRef) -> String {
3004 format!(
3005 "{path} is an ORPHAN store: bytes on disk with no catalog row and no tombstone, so \
3006 the server does not know what it was and a tombstone would promise an undelete it \
3007 cannot honour. Three exits, and they are all one command: `repo delete {path} \
3008 --purge` reclaims the bytes now, `repo reclaim {path}` is the same thing said \
3009 plainly, and `repo create {path} --adopt --hash <its OBJECT_FORMAT>` takes the \
3010 store over as a repository instead. `repo list-orphans` reports that hash."
3011 )
3012 }
3013
3014 /// **Reclaim an orphan's bytes.** The exit that did not exist.
3015 ///
3016 /// It refuses anything that is not an orphan BY NAME, because both of the
3017 /// other two states have their own verb and reaching them through this one
3018 /// would be a second way to destroy a repository:
3019 ///
3020 /// * catalogued -> `repo delete --purge`;
3021 /// * soft-deleted -> `repo undelete` or `repo purge`.
3022 ///
3023 /// The reclaim itself is [`Self::purge_repo`] — the same verb `--purge`
3024 /// and the sweep use, so an orphan's bytes go the way every other
3025 /// repository's bytes go (one implementation), including the index worker being
3026 /// cancelled first and a `ReclaimFailed` tombstone if it cannot.
3027 pub fn reclaim_orphan(&self, path: &RepoRef) -> Result<u64> {
3028 let _usage = UsageMark::namespace(&self.usage, path.namespace());
3029 anyhow::ensure!(
3030 self.catalog().get(path)?.is_none(),
3031 "{path} is a LIVE repository, not an orphan: it is in the catalog. `repo delete \
3032 {path} --purge` is the verb that deletes it."
3033 );
3034 let dir = self.repo_dir(path)?;
3035 if let Some(record) = Self::read_tombstone(&Self::tombstone_path(&dir)) {
3036 anyhow::ensure!(
3037 record.reason != TombstoneReason::Deleted,
3038 "{path} is SOFT-DELETED, not an orphan: its bytes are being kept on purpose and \
3039 `repo undelete {path}` still puts it back. `repo purge {path}` ends the window."
3040 );
3041 }
3042 anyhow::ensure!(
3043 dir.is_dir(),
3044 "there is no store at {}; `repo list-orphans` shows what there is to reclaim",
3045 dir.display()
3046 );
3047 let (before, after) = self.purge_repo(path)?;
3048 let bytes = before.saturating_sub(after);
3049 tracing::warn!(
3050 target: "gunnar::store",
3051 event = "repo.orphan_reclaimed",
3052 repo = %path,
3053 dir = %dir.display(),
3054 bytes,
3055 "an ORPHAN store — no catalog row, no tombstone — was reclaimed; {bytes} bytes back \
3056 and the name is usable again"
3057 );
3058 Ok(bytes)
3059 }
3060
3061 /// **The sweep: reclaim every tombstone whose window has closed.**
3062 ///
3063 /// Returns what it reclaimed, `(path, bytes)`. A failure on one repository
3064 /// is logged and the sweep continues — one undeletable directory must not
3065 /// stop the other nineteen from being reclaimed, and `purge_repo` leaves
3066 /// its own `ReclaimFailed` tombstone behind so the failure is not lost.
3067 pub fn sweep_expired_tombstones(&self, now_unix_ms: i64) -> Vec<(RepoRef, u64)> {
3068 let deleted = match self.tombstones() {
3069 Ok(deleted) => deleted,
3070 Err(err) => {
3071 tracing::error!(
3072 target: "gunnar::store",
3073 error = format!("{err:#}"),
3074 "the tombstone sweep could not list what is deleted; nothing was reclaimed"
3075 );
3076 return Vec::new();
3077 }
3078 };
3079 let mut reclaimed = Vec::new();
3080 for row in deleted {
3081 if !row.tombstone.is_expired_at(now_unix_ms) {
3082 continue;
3083 }
3084 match self.purge_repo(&row.path) {
3085 Ok((before, after)) => {
3086 let bytes = before.saturating_sub(after);
3087 tracing::warn!(
3088 target: "gunnar::store",
3089 event = "repo.tombstone_swept",
3090 repo = %row.path,
3091 bytes,
3092 deleted_at_unix_ms = row.tombstone.deleted_at_unix_ms,
3093 "the retention window closed; a soft-deleted repository was reclaimed \
3094 and cannot be undeleted any more"
3095 );
3096 reclaimed.push((row.path, bytes));
3097 }
3098 Err(err) => {
3099 tracing::error!(
3100 target: "gunnar::store",
3101 event = "repo.tombstone_sweep_failed",
3102 repo = %row.path,
3103 error = format!("{err:#}"),
3104 "an expired tombstone could not be reclaimed; the name stays fenced off \
3105 and the sweep continues"
3106 );
3107 }
3108 }
3109 }
3110 reclaimed
3111 }
3112
3113 /// Which catalogued repository the git directory `dir` belongs to, as
3114 /// `namespace/name` — the inverse of [`Self::repo_dir`], for a progress
3115 /// row that knows only a store root.
3116 pub fn repo_of_dir(&self, dir: &Path) -> Option<(String, String)> {
3117 let rel = dir.strip_prefix(&self.repos_root).ok()?;
3118 let mut parts = rel.components().filter_map(|c| match c {
3119 std::path::Component::Normal(s) => Some(s.to_string_lossy().into_owned()),
3120 _ => None,
3121 });
3122 let ns = parts.next()?;
3123 let name = parts.next()?;
3124 Some((ns, name.strip_suffix(".git").unwrap_or(&name).to_owned()))
3125 }
3126}
3127
3128/// **Start the soft-delete sweep**: hourly, and once immediately.
3129///
3130/// The other half of a soft delete. Without it "deleted" would mean "kept
3131/// forever" and a `/data` would fill with repositories nobody can see — which
3132/// is the invisible 25 GB again, only with a promise attached.
3133///
3134/// It runs once at start on purpose: a box that was powered off when a
3135/// retention window closed must not wait an hour to notice, and an operator who
3136/// set `--retention-days 0` to free space should see it happen while they are
3137/// still watching.
3138///
3139/// Fire and forget, exactly like `disktest::spawn`. It holds no lock the server
3140/// needs, its work is on the blocking pool, and a failure on one repository is
3141/// logged and skipped rather than stopping the sweep — `purge_repo` leaves its
3142/// own tombstone behind, so nothing is lost by carrying on.
3143pub fn spawn_tombstone_sweep(stores: Arc<ServerStores>) -> tokio::task::JoinHandle<()> {
3144 tokio::spawn(async move {
3145 loop {
3146 let stores = Arc::clone(&stores);
3147 let reclaimed = tokio::task::spawn_blocking(move || {
3148 stores.sweep_expired_tombstones(gunnar_core::now_ms())
3149 })
3150 .await
3151 .unwrap_or_default();
3152 if !reclaimed.is_empty() {
3153 let bytes: u64 = reclaimed.iter().map(|(_, b)| b).sum();
3154 tracing::warn!(
3155 target: "gunnar::store",
3156 event = "repo.sweep",
3157 repos = reclaimed.len(),
3158 bytes,
3159 "the retention window closed on {} repository(ies); {bytes} bytes were \
3160 reclaimed and they can no longer be undeleted",
3161 reclaimed.len()
3162 );
3163 }
3164 tokio::time::sleep(TOMBSTONE_SWEEP_INTERVAL).await;
3165 }
3166 })
3167}
3168
3169/// What one flow-flush tick did.
3170#[derive(Debug, Clone, PartialEq, Eq)]
3171pub enum FlowFlush {
3172 /// This many namespace-day rows written.
3173 Wrote(usize),
3174 /// Not attempted: the transaction log is RELEASED (a runtime data-set growth has given its
3175 /// files up and `/data` is unmounted). Nothing is lost — the live counters are in RAM and a
3176 /// refused write re-parks its completed days — and nothing is logged above trace.
3177 Held,
3178 /// The write failed for any other reason.
3179 Failed(String),
3180}
3181
3182/// **One flow-flush tick.** Found by the lifecycle lane 2026-09-15: every minute of a runtime
3183/// growth logged `flow.flush_failed`, because the flush wrote through a transaction log that
3184/// had been released for the unmount. The release window is now a quiet hold, and a write
3185/// the release overtook mid-flush is read as the same hold rather than as a failure.
3186pub fn flow_flush_tick(stores: &ServerStores) -> FlowFlush {
3187 if stores.txlog().is_released() {
3188 return FlowFlush::Held;
3189 }
3190 match stores.flow().flush(gunnar_core::now_ms()) {
3191 Ok(rows) => FlowFlush::Wrote(rows),
3192 Err(_) if stores.txlog().is_released() => FlowFlush::Held,
3193 Err(err) => FlowFlush::Failed(err.to_string()),
3194 }
3195}
3196
3197/// **Start the flow flush**: every [`FLOW_FLUSH_INTERVAL`], forever.
3198///
3199/// The live half of the flow ledger is a set of atomics in this process; this
3200/// is what makes it survive a restart. It COPIES the live slots into the
3201/// control store rather than adding to them (`gunnar_core::FlowLedger::flush`),
3202/// so running it twice writes the same rows twice instead of doubling them —
3203/// which is what lets it run on a tick without any coordination, and what lets
3204/// a slot opened after a restart be seeded from the stored row rather than from
3205/// zero.
3206///
3207/// It is a task of its own and NOT a line in the dashboard's two-second tick,
3208/// deliberately: that tick is documented as reading a few atomics and nothing
3209/// else, and this writes redb. A flush on the dashboard's thread would put a
3210/// database commit between the operator and the screen.
3211///
3212/// It also carries the day boundary on an IDLE server. A namespace that was
3213/// pushed to at 23:59 and then left alone has a live slot still holding
3214/// yesterday; nothing on the transfer path will notice, because there is no
3215/// transfer. The flush rolls it and writes the completed day.
3216///
3217/// Fire and forget, like [`spawn_tombstone_sweep`]: it holds no lock the server
3218/// needs, its work is on the blocking pool, and a failed write is logged and
3219/// retried on the next tick — the counters are still in RAM, so nothing is lost
3220/// by carrying on.
3221pub fn spawn_flow_flush(
3222 stores: Arc<ServerStores>,
3223 band: Arc<Mutex<Option<gunnar_core::Fleet>>>,
3224) -> tokio::task::JoinHandle<()> {
3225 tokio::spawn(async move {
3226 loop {
3227 tokio::time::sleep(FLOW_FLUSH_INTERVAL).await;
3228 let tick = Arc::clone(&stores);
3229 let band = Arc::clone(&band);
3230 let written = tokio::task::spawn_blocking(move || {
3231 let written = flow_flush_tick(&tick);
3232 if written == FlowFlush::Held {
3233 // The fold reads the control document, which is under the unmounted
3234 // data set too: the band keeps its last answer through the window.
3235 return written;
3236 }
3237 // **The usage ledger's reconcile pass** (2026-10-06): one slice of
3238 // namespaces re-measured per tick, the whole set every
3239 // `RECONCILE_CYCLE`, so bytes that moved without a mark are
3240 // corrected and counted rather than billed stale. Low priority by
3241 // PACING: on this tick — already off the request path, once a
3242 // minute — and a thirtieth of the namespaces at a time (~330
3243 // one-repository walks a minute at 10,000 tenants).
3244 let _ = tick.usage_ledger().reconcile_step(&tick, FLOW_FLUSH_INTERVAL);
3245 // **The dashboard's fold, taken HERE and not on the band's own
3246 // tick.** It walks every namespace and reads the control
3247 // document; the band re-samples every two seconds and is
3248 // documented as reading a few atomics and nothing else. Once a
3249 // minute is also the right rate for a row a human reads.
3250 *band.lock().expect("the flow band") = Some(tick.flow().fleet(
3251 gunnar_core::now_ms(),
3252 crate::dashboard::SILENT_AFTER_DAYS,
3253 ));
3254 written
3255 })
3256 .await
3257 .unwrap_or_else(|err| FlowFlush::Failed(err.to_string()));
3258 match written {
3259 FlowFlush::Wrote(0) => {}
3260 // The release window of a runtime growth: nothing lost, nothing to say above
3261 // trace. The counters stay in RAM and the first tick after the remount writes.
3262 FlowFlush::Held => tracing::trace!(
3263 target: "gunnar::store",
3264 event = "flow.flush_held",
3265 "the transaction log is released for a data-set growth; the flow ledger waits for the remount"
3266 ),
3267 FlowFlush::Wrote(rows) => tracing::debug!(
3268 target: "gunnar::store",
3269 event = "flow.flushed",
3270 rows,
3271 "wrote {rows} namespace-day flow row(s)"
3272 ),
3273 FlowFlush::Failed(err) => tracing::warn!(
3274 target: "gunnar::store",
3275 event = "flow.flush_failed",
3276 %err,
3277 "the flow ledger could not be written; the counters are still in memory and \
3278 the next tick will try again"
3279 ),
3280 }
3281 }
3282 })
3283}
3284
3285/// Bytes under `dir`, walked (not `du`, not estimated). `0` for a missing dir.
3286pub fn dir_bytes(dir: &Path) -> u64 {
3287 fn walk(p: &Path, acc: &mut u64) {
3288 let Ok(rd) = std::fs::read_dir(p) else { return };
3289 for e in rd.flatten() {
3290 let Ok(m) = e.metadata() else { continue };
3291 if m.is_dir() {
3292 walk(&e.path(), acc);
3293 } else {
3294 *acc += m.len();
3295 }
3296 }
3297 }
3298 let mut n = 0;
3299 walk(dir, &mut n);
3300 n
3301}
3302
3303/// **The newest mtime anywhere under `dir`, as unix milliseconds.**
3304///
3305/// The directory's own mtime is not enough: a store is a tree, and writing a
3306/// pack inside `objects/` does not touch the top. This is what tells an
3307/// operator on a shell-less appliance whether an orphan is Friday's wreckage
3308/// or something a push is still writing to — which is the difference between
3309/// `repo reclaim` and leaving it alone.
3310///
3311/// `0` when nothing can be read, which is a fact ("no timestamp") rather than
3312/// a guess at one.
3313pub fn newest_mtime_unix_ms(dir: &Path) -> i64 {
3314 fn walk(p: &Path, acc: &mut i64) {
3315 let Ok(rd) = std::fs::read_dir(p) else { return };
3316 for e in rd.flatten() {
3317 let Ok(m) = e.metadata() else { continue };
3318 if let Ok(at) = m.modified() {
3319 if let Ok(since) = at.duration_since(std::time::UNIX_EPOCH) {
3320 *acc = (*acc).max(since.as_millis() as i64);
3321 }
3322 }
3323 if m.is_dir() {
3324 walk(&e.path(), acc);
3325 }
3326 }
3327 }
3328 let mut newest = std::fs::metadata(dir)
3329 .and_then(|m| m.modified())
3330 .ok()
3331 .and_then(|at| at.duration_since(std::time::UNIX_EPOCH).ok())
3332 .map(|d| d.as_millis() as i64)
3333 .unwrap_or(0);
3334 walk(dir, &mut newest);
3335 newest
3336}
3337
3338/// **Is the persisted role a STANDBY?** Read from `replication.json` once, at
3339/// open, to decide whether the box comes up frozen.
3340///
3341/// Behind the `replication` feature because the role lives in the replication
3342/// config; a build without it has no way to become a standby and is always a
3343/// writable primary. A missing or malformed file reads as PRIMARY — the same
3344/// fail-open chosen for a torn `replication.json`, because a box that
3345/// refused every push over a config it could not read would be a worse outage
3346/// than one that served.
3347#[cfg(feature = "replication")]
3348fn replication_role_at_open(root: &Path) -> crate::mirror::Role {
3349 crate::mirror::ReplicationConfig::load(root)
3350 .map(|c| c.role)
3351 .unwrap_or_default()
3352}
3353
3354/// The role a build without replication always has: a writable primary. The
3355/// type is a private stand-in with the two questions the open path asks.
3356#[cfg(not(feature = "replication"))]
3357fn replication_role_at_open(_root: &Path) -> PrimaryOnly {
3358 PrimaryOnly
3359}
3360
3361#[cfg(not(feature = "replication"))]
3362struct PrimaryOnly;
3363
3364#[cfg(not(feature = "replication"))]
3365impl PrimaryOnly {
3366 fn refuses_client_writes(&self) -> bool {
3367 false
3368 }
3369 fn is_browse(&self) -> bool {
3370 false
3371 }
3372}
3373
3374#[cfg(test)]
3375mod tombstone_tests {
3376 use super::*;
3377
3378 /// **The old free-text tombstone must not read as a soft delete.**
3379 ///
3380 /// An appliance upgraded in place has files on disk that this code did not
3381 /// write. Reading one of those as `Deleted` would put a *deadline* on bytes
3382 /// that are OWED to an operator, and the sweep would then quietly do the
3383 /// `remove_dir_all` that already failed once — unattended, at 3 a.m.
3384 ///
3385 /// Seen red by making `parse` fall back to `Deleted`: this goes red on the
3386 /// reason and on `is_expired_at`.
3387 #[test]
3388 fn a_free_text_tombstone_reads_as_a_failed_reclaim_and_never_expires() {
3389 let old = "delete asked at unix_ms 1756500000000, reclaim failed: Permission denied\n";
3390 let record = Tombstone::parse(old);
3391 assert_eq!(record.reason, TombstoneReason::ReclaimFailed);
3392 assert_eq!(record.purge_after_unix_ms, None);
3393 assert!(record.note.contains("Permission denied"));
3394 // The load-bearing half: no deadline means the sweep never touches it,
3395 // whatever "now" is.
3396 assert!(!record.is_expired_at(i64::MAX));
3397 assert_eq!(record.seconds_left(0), None);
3398 }
3399
3400 /// A soft delete round-trips through JSON with its deadline and its row.
3401 #[test]
3402 fn a_soft_delete_round_trips_and_expires_exactly_at_its_deadline() {
3403 let record = Tombstone {
3404 deleted_at_unix_ms: 1_000,
3405 purge_after_unix_ms: Some(3_000),
3406 principal_id: "alice".into(),
3407 reason: TombstoneReason::Deleted,
3408 repo: None,
3409 note: "soft-deleted".into(),
3410 };
3411 let text = serde_json::to_string(&record).unwrap();
3412 let back = Tombstone::parse(&text);
3413 assert_eq!(back, record);
3414 assert!(!back.is_expired_at(2_999), "one ms early is not expired");
3415 assert!(back.is_expired_at(3_000), "the deadline itself expires");
3416 assert!(back.is_expired_at(3_001));
3417 assert_eq!(back.seconds_left(1_000), Some(2));
3418 assert_eq!(back.seconds_left(9_999), Some(0), "never negative");
3419 }
3420
3421 /// The two reasons print differently, because they mean different things
3422 /// and an operator acts on them differently.
3423 #[test]
3424 fn the_two_reasons_do_not_share_a_word() {
3425 assert_ne!(
3426 TombstoneReason::Deleted.as_str(),
3427 TombstoneReason::ReclaimFailed.as_str()
3428 );
3429 }
3430}
3431
3432/// One `statvfs` of a path gunnar was TOLD to use.
3433///
3434/// **Never a number that was not read.** A failed measurement leaves
3435/// `measured` false and both byte fields at zero with the OS's reason in
3436/// `error`; a renderer that prints `0 B free` for a filesystem nobody could
3437/// measure is how an operator is sent to look for a full disk that is not
3438/// full.
3439pub(crate) fn filesystem_use(what: &str, path: &Path) -> gunnar_proto::FilesystemUse {
3440 let mut row = gunnar_proto::FilesystemUse {
3441 what: what.to_owned(),
3442 path: path.display().to_string(),
3443 ..Default::default()
3444 };
3445 match rustix::fs::statvfs(path) {
3446 Ok(vfs) => {
3447 let frsize = if vfs.f_frsize == 0 {
3448 vfs.f_bsize
3449 } else {
3450 vfs.f_frsize
3451 };
3452 row.measured = true;
3453 row.total_bytes = vfs.f_blocks.saturating_mul(frsize);
3454 row.free_bytes = vfs.f_bavail.saturating_mul(frsize);
3455 }
3456 Err(err) => row.error = err.to_string(),
3457 }
3458 row
3459}
3460
3461#[cfg(test)]
3462mod flow_flush_tests {
3463 use super::*;
3464
3465 /// **A runtime growth's release window is a quiet hold, and nothing is lost across it.**
3466 ///
3467 /// Found by the lifecycle lane 2026-09-15: `flow.flush_failed` every minute while the txlog
3468 /// was released. Yesterday's counters are the ones at risk — the roll moves them out of the
3469 /// live slot into `completed`, and a flush whose write was refused used to drop them there.
3470 #[test]
3471 fn a_released_txlog_holds_the_flush_and_the_rolled_day_lands_after_reacquire() {
3472 let dir = tempfile::tempdir().unwrap();
3473 let stores = ServerStores::open(dir.path()).unwrap();
3474 let now = gunnar_core::now_ms();
3475 let yesterday = now - 86_400_000;
3476 let push = gunnar_core::flow::Transfer { bytes_in: 42, pushed: true, principal: "p".into(), ..Default::default() };
3477 stores.flow().record("ns", yesterday, &push);
3478
3479 stores.txlog().release().unwrap();
3480 // The red the tick used to log: the write itself is refused while released.
3481 assert!(stores.flow().flush(now).is_err(), "a flush wrote through a released transaction log");
3482 assert_eq!(flow_flush_tick(&stores), FlowFlush::Held);
3483 assert_eq!(flow_flush_tick(&stores), FlowFlush::Held, "still held, every tick of the window");
3484
3485 stores.txlog().reacquire().unwrap();
3486 assert_eq!(flow_flush_tick(&stores), FlowFlush::Wrote(1), "yesterday's row, re-parked by the refused flush, is written after the remount");
3487 assert_eq!(flow_flush_tick(&stores), FlowFlush::Wrote(0), "and written once");
3488 }
3489}
3490
3491/// **Frame the site's WireGuard hub declaration into this primary's log, once per change.**
3492///
3493/// `wg/hub.conf` is public site data that `korp-installer` lays into `/data` through the ESP
3494/// data stage on a fresh install and that nothing in gunnar ever writes. It therefore had no
3495/// sink call site, the twin never held it, and `gunnar control twin replay` brought a main
3496/// back with no way of reaching the tunnel — the D6 failure again, in the one class where the
3497/// symptom is "nobody can reach the box at all".
3498///
3499/// **Best effort, and deliberately so.** This runs on the start-up path of a box whose tunnel
3500/// is already configured — the file on disk is the truth either way, and refusing to serve
3501/// because a 1 KiB document could not be framed would turn a replication gap into an outage.
3502/// A failure is one WARN naming the path, at the place an operator is already looking.
3503///
3504/// The machine's own `wg/identity.key` and `wg/identity.pub` are NOT touched here and are
3505/// refused by `gunnar_core::txlog::is_per_box_path`: a private key on the twin is a second
3506/// place to steal the site's network identity from, and a restored main carrying the dead
3507/// box's identity would claim a tunnel the hub registered to a key that no longer exists.
3508fn adopt_hub_conf(root: &Path, txlog: &Arc<gunnar_core::txlog::Log>) {
3509 use gunnar_core::txlog::{HUB_CONF_FRAMED_REL, HUB_CONF_REL};
3510 let path = root.join(HUB_CONF_REL);
3511 let bytes = match std::fs::read(&path) {
3512 Ok(b) => b,
3513 // No declaration is the ordinary case on every box that is not an appliance, and on
3514 // an appliance before the ceremony. Nothing to say.
3515 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
3516 Err(err) => {
3517 tracing::warn!(
3518 target: gunnar_core::observe::TARGET_TWIN,
3519 path = %path.display(),
3520 %err,
3521 "the hub declaration could not be read, so it is NOT in this box's log and a twin will not hold it"
3522 );
3523 return;
3524 }
3525 };
3526 // The ONE place that decides what a digest of something is.
3527 let digest = gunnar_core::provenance::digest_bytes(&bytes);
3528 let mark = root.join(HUB_CONF_FRAMED_REL);
3529 // Already framed, unchanged: the log carries these bytes. One `read_to_string` per boot.
3530 if std::fs::read_to_string(&mark).map(|s| s.trim() == digest).unwrap_or(false) {
3531 return;
3532 }
3533 let framed = txlog.append(
3534 gunnar_core::txlog::Kind::File,
3535 &gunnar_core::txlog::Meta::File { path: HUB_CONF_REL.to_string(), deleted: false },
3536 &bytes,
3537 );
3538 match framed {
3539 Ok(seq) => {
3540 // The mark is written AFTER the frame is durable. The other order would let a
3541 // crash between them leave a box claiming to have framed what it had not — and
3542 // then never framing it, which is exactly the silent loss being fixed. This order
3543 // costs at worst one duplicate frame of the same bytes after a crash.
3544 if let Err(err) = crate::durable::write_atomic_unframed(
3545 mark.parent().unwrap_or(root),
3546 HUB_CONF_FRAMED_REL.rsplit('/').next().unwrap_or(HUB_CONF_FRAMED_REL),
3547 format!("{digest}\n").as_bytes(),
3548 ) {
3549 tracing::warn!(target: gunnar_core::observe::TARGET_TWIN, %err, "the hub declaration is framed at seq {seq}; the note saying so was not written, so the next boot frames it again");
3550 }
3551 tracing::info!(
3552 target: gunnar_core::observe::TARGET_TWIN,
3553 seq,
3554 path = %path.display(),
3555 "the site's hub declaration is a transaction: the twin holds it and a restore brings it back"
3556 );
3557 }
3558 Err(err) => tracing::warn!(
3559 target: gunnar_core::observe::TARGET_TWIN,
3560 path = %path.display(),
3561 %err,
3562 "the hub declaration could not be framed, so a twin will not hold it and a restored main would come up with no way onto the tunnel"
3563 ),
3564 }
3565}
3566
3567/// **Mark a namespace in the usage ledger when the operation ENDS** — on
3568/// every path out, errors included. Marking at the start would let a settle
3569/// in the middle measure the half-done state and clear the mark; at the end, a
3570/// mid-way measurement is simply taken again.
3571struct UsageMark<'a> {
3572 ledger: &'a crate::usage_ledger::UsageLedger,
3573 namespace: String,
3574}
3575
3576impl<'a> UsageMark<'a> {
3577 fn namespace(ledger: &'a crate::usage_ledger::UsageLedger, namespace: &str) -> Self {
3578 UsageMark { ledger, namespace: namespace.to_owned() }
3579 }
3580}
3581
3582impl Drop for UsageMark<'_> {
3583 fn drop(&mut self) {
3584 self.ledger.mark_namespace(&self.namespace);
3585 }
3586}
3587
3588/// **The named sealed slot's row** (2026-10-09): the secret `slot/<name>`,
3589/// holding `gsl1 | u16 owner length | owner | value` — sealed as one, so the
3590/// owner cannot be moved off its value.
3591#[cfg(feature = "metered")]
3592pub mod sealed_slot {
3593 use anyhow::{bail, Result};
3594
3595 /// Every slot's secret key starts so; no M5 secret does.
3596 pub const PREFIX: &str = "slot/";
3597 const MAGIC: &[u8; 4] = b"gsl1";
3598
3599 pub fn key(name: &str) -> String {
3600 format!("{PREFIX}{name}")
3601 }
3602
3603 pub fn encode(owner: &str, value: &[u8]) -> Vec<u8> {
3604 let mut out = Vec::with_capacity(6 + owner.len() + value.len());
3605 out.extend_from_slice(MAGIC);
3606 out.extend_from_slice(&(owner.len() as u16).to_be_bytes());
3607 out.extend_from_slice(owner.as_bytes());
3608 out.extend_from_slice(value);
3609 out
3610 }
3611
3612 pub fn decode(b: &[u8]) -> Result<(String, Vec<u8>)> {
3613 if b.len() < 6 || &b[..4] != MAGIC {
3614 bail!("a sealed slot's row is not gsl1");
3615 }
3616 let n = u16::from_be_bytes([b[4], b[5]]) as usize;
3617 if b.len() < 6 + n {
3618 bail!("a sealed slot's row is cut short");
3619 }
3620 Ok((String::from_utf8(b[6..6 + n].to_vec())?, b[6 + n..].to_vec()))
3621 }
3622}
3623
3624/// ★ **A re-laid main mints no ticket key of its own before its twin gives the old one back**
3625/// (gunnar.rs audit round 2, RCH-TK). After `estate recharge main` the ONE box keeps the
3626/// site's appliance key: the dead main's, which the twin's sealed secrets restore. A box that
3627/// awaits its twin (`pairing/recharge_from`, [`crate::pairing::Hold::Awaiting`]) or is being
3628/// restored and minted a key first — the installer's offer and self-registration both ask —
3629/// would sign every ticket with a key the ONE box refuses, and the write itself would end
3630/// the box's freshness before the restore.
3631#[cfg(all(test, feature = "metered"))]
3632mod ticket_key_recharge_tests {
3633 use super::*;
3634
3635 #[test]
3636 fn a_main_awaiting_its_twin_mints_no_ticket_key_and_reads_the_restored_one_after() {
3637 use gunnar_store::SecretStore as _;
3638 let dir = tempfile::tempdir().unwrap();
3639 let stores = ServerStores::open(dir.path()).unwrap();
3640 let marker = dir.path().join(crate::pairing::RECHARGE_REL);
3641 std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
3642 std::fs::write(&marker, "10.13.8.3:50051\n").unwrap();
3643
3644 let e = stores.actor_ticket_key().err().expect("no key minted while the box awaits its twin");
3645 assert!(format!("{e:#}").contains("awaits its twin"), "{e:#}");
3646 assert!(stores.secrets().unwrap().get(gunnar_store::ACTOR_TICKET_KEY).unwrap().is_none(), "nothing was written");
3647
3648 // The restore brought the dead main's key back and the marker went: it is read, not minted.
3649 std::fs::remove_file(&marker).unwrap();
3650 let k = stores.actor_ticket_key().expect("restored (or never recharged): the key is there or minted");
3651 assert_eq!(stores.actor_ticket_key().unwrap().public_key_hex(), k.public_key_hex());
3652 }
3653}