Skip to main content

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