Skip to main content

gunnar_server/
twin.rs

1//! **The twin**: the box that holds every transaction the primary was told, in
2//! order, durably, and is never promoted.
3//! `.nornir/twin-transaction-log.md` §"The twin's loop".
4//!
5//! **The twin only stores** (OWNER RULING 2026-09-22: "the twin shouldnt do
6//! indexing or processing, only receive and save data … if the main is
7//! recharged from the twin it could do the indexing then, at recharge time").
8//! A twin appends every frame to its own log — the primary's numbers, the raw
9//! pack bytes as pushed — and applies ONLY the control plane it itself runs on
10//! ([`twin_applies`]: the KEK, control blobs and documents, sealed secrets —
11//! the identity table that gates who may read it, and its host key). Packs,
12//! refs, LFS, repository stores, append-only lines and framed files stay in
13//! the log as bytes: no pack index, no oid tail, no commit graph, no explode
14//! cache, no LFS store is ever built on a twin. They are built on the NEW main
15//! when it is recharged from the twin ([`restore`], `gunnar control twin
16//! replay`), by the same [`apply`] a primary's restore always ran.
17//!
18//! Three things live here:
19//!
20//! * [`stream_range`] — the primary's half of `Mirror.LogFetch`: entries
21//!   `from..=to`, bounded by bytes, never fewer than one, each read off the
22//!   segment in pieces and handed on as it is read (twin-streaming,
23//!   2026-10-06); [`fetch_range`] is the same range gathered, for callers that
24//!   want whole entries.
25//! * **One entry is never held whole on either box.** The batch budget
26//!   ([`DEFAULT_FETCH_BYTES`]) bounds how many entries one fetch carries, never
27//!   the size of one: a 512 MiB pack used to be read whole on the primary, cut
28//!   into a second whole copy of 1 MiB messages, and glued back into one `Vec`
29//!   on the twin before it was appended. Now the primary reads it a piece at a
30//!   time ([`Log::read_pieces`], the payload crc checked before the LAST piece
31//!   leaves), and the twin writes each piece into its own log as it arrives
32//!   ([`FrameSink`] into a [`txlog::Pending`]): the frame exists only once its
33//!   last byte is in, and a cut, short or refused transfer leaves nothing.
34//! * [`Follower`] — the loop over a [`LogSource`]: head, the gap rule, fetch,
35//!   `append_at` into this box's OWN log (the primary's numbers, durable) and
36//!   only then [`take`]: on a twin that keeps the entry (store-only), on a box
37//!   being restored it is the full [`apply`]. A restart resumes at the own
38//!   head; every apply is idempotent or a replayed CAS, so re-applying the
39//!   tail after a crash is the same as applying it.
40//! * [`restore`] — replay a twin's log into a fresh data root: the recharge,
41//!   where every derived index is built.
42//!
43//! **The gap rule is the loop's shape.** The twin never appends `seq` unless
44//! it holds `seq - 1`; `Log::append_at` refuses anything else. When the
45//! primary no longer retains `own_head + 1`, the twin stops and says
46//! `snapshot-needed` — a twin that kept going past a hole would be a copy that
47//! looks complete and is not.
48//!
49//! **Through the unlogged stores.** The twin's `ServerStores` carry the same
50//! tee a primary's do; applying through it would write a SECOND log with the
51//! twin's own numbers. So `apply` goes through `raw_control_store`,
52//! `raw_secrets` and `OpenRepo::unlogged`, and the twin's `<data-root>/txlog`
53//! is exactly the primary's log, number for number.
54
55use std::collections::HashMap;
56use std::path::{Path, PathBuf};
57use std::sync::atomic::{AtomicBool, Ordering};
58use std::sync::{Arc, Mutex, OnceLock};
59use std::time::Duration;
60
61use anyhow::{anyhow, Context};
62use gunnar_core::control::BlobId;
63use gunnar_core::txlog::{self, Entry, Kind, Log, Meta, RefEdit};
64use gunnar_core::RepoRef;
65use gunnar_store::git_storage_trait::{RefCas, RefRejection, RefUpdate};
66
67use crate::stores::ServerStores;
68
69/// `Mirror.LogFetch`'s ceiling when the request says 0.
70pub const DEFAULT_FETCH_BYTES: u64 = 64 << 20;
71
72/// **Entries one [`fetch_range`] may answer with, whatever the byte ceiling buys.**
73/// The byte ceiling alone is not a bound on the WORK: a range of tiny frames costs one
74/// `log.read` and one `Vec` slot each on the primary, and the gRPC layer turns each into
75/// at least one `LogChunk`. The twin's loop asks again the moment it is level, so a cap
76/// here costs a round trip and nothing else.
77pub const MAX_FETCH_ENTRIES: u64 = 10_000;
78
79/// **A twin's group commit** (perf twin-apply-fsync, 2026-10-06): at most this many entries are
80/// written into its log between two `fdatasync`s. MEASURED before (oden, `tests/twin_apply_io.rs`):
81/// 9.6 writes and 1.85 fsyncs per entry — one fsync per frame in the log and one per redb commit
82/// — which on the UpCloud mockup's HDD-tier twin was ~2,800 write IOPS and a lag of ~93,000
83/// entries under a storm of small pushes.
84pub const GROUP_ENTRIES: usize = 2048;
85/// …or this many bytes of frame.
86pub const GROUP_BYTES: u64 = 32 << 20;
87/// …or this long since the group's first entry: what a twin acknowledges (its durable head) is
88/// never older than this while entries keep arriving.
89pub const GROUP_AGE: Duration = Duration::from_millis(500);
90/// The applied marker is moved at least this often even over a run with no control-plane write,
91/// so a restart re-takes at most this many entries.
92pub const MARK_EVERY: u64 = 4096;
93
94/// What `Mirror.LogHead` says about a twin.
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct TwinStatus {
97    /// The twin's own head — every entry up to here is in its log AND its
98    /// stores.
99    pub applied_seq: u64,
100    /// The primary's head as of the last successful `LogHead`.
101    pub primary_head_seq: u64,
102    /// One word.
103    pub state: State,
104    /// Why, when not `Following`.
105    pub why: String,
106    /// Wall clock of the last `LogHead` the primary answered; 0 = never.
107    pub last_answer_unix_ms: i64,
108    /// **Wall clock of the last answer at which this twin was LEVEL** with its
109    /// primary — `following` and `applied >= primary head` (2026-09-21, lane T4,
110    /// the restore guard). 0 = never.
111    ///
112    /// Owned by [`set_status`]: whatever a caller passes is replaced, because
113    /// this is a fact about the HISTORY of statuses — carried forward through
114    /// every `unreachable`, `stalled` and `diverged` — and not something any
115    /// one status knows by itself. Equal to `last_answer_unix_ms` exactly when
116    /// the twin was level at its last word from the primary, which is the one
117    /// fact a restore from a twin whose primary is dead has to be able to see.
118    pub last_level_unix_ms: i64,
119}
120
121impl TwinStatus {
122    /// The watermark: everything the primary has, the twin has applied.
123    pub fn caught_up(&self) -> bool {
124        self.state == State::Following && self.applied_seq == self.primary_head_seq
125    }
126}
127
128/// The twin's state word.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum State {
131    /// Polling, fetching, applying.
132    Following,
133    /// The primary no longer retains what the twin needs next.
134    SnapshotNeeded,
135    /// The last poll could not reach the primary.
136    Unreachable,
137    /// **The poll got as far as the data and then failed** — the fetch died
138    /// mid-range, the append would not go on the volume, an entry would not
139    /// apply. The primary is not the problem; this copy stops where it stopped.
140    Stalled,
141    /// **The box at the other end is not the log this twin has been following.**
142    /// Its head is BELOW what this twin has already applied, and a log's head
143    /// only ever goes forward — so it is a re-imaged primary, a restore from
144    /// somewhere else, or an address that now points at a different set.
145    /// Stopped: an operator decides.
146    Diverged,
147    /// **This twin is growing its own data set** (T21, 2026-09-22, owner ruling:
148    /// the twin grows EXACTLY like main — no restart). The grow machine has
149    /// drained it: the follower applies nothing and the set may be unmounted.
150    /// It still asks its primary for the head, so both ends see the word and
151    /// the lag; it follows again the moment the set is mounted back.
152    Maintenance,
153    /// ★ **This twin is owed a growth before it may follow** (cov-13, 2026-10-06):
154    /// main's data set grew while it was silent, and its volume cannot hold it —
155    /// the primary refuses its fetches by name (`gunnar_grpc::TWIN_TOO_SMALL`)
156    /// rather than let it fill part-way through main's data. It keeps polling
157    /// (so it reads heard, and monetize's twin lane grows it) and follows the
158    /// moment its report holds main's set. Nothing is appended meanwhile.
159    Recovering,
160    /// ★ **This twin is taking a full copy (a snapshot) from its primary** (twin-resync-space,
161    /// 2026-10-06): it fell behind a hole main's retention trimmed. Its old copy is kept while
162    /// the volume holds both; when it does not, the twin GIVES IT UP (`snapshot::DROPPED`) and the
163    /// `why` says it holds NO copy — it serves nothing then, and a crash restarts the resync.
164    Resyncing,
165}
166
167impl std::fmt::Display for State {
168    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169        f.write_str(match self {
170            State::Following => "following",
171            State::SnapshotNeeded => "snapshot-needed",
172            State::Unreachable => "unreachable",
173            State::Stalled => "stalled",
174            State::Diverged => "diverged",
175            State::Maintenance => "maintenance",
176            State::Recovering => "recovering",
177            State::Resyncing => "resyncing",
178        })
179    }
180}
181
182/// **The twin status, per DATA ROOT** (T17, 2026-09-21). It was one process-wide slot: a box
183/// runs one follower, so one slot was enough in production — but every test that ran a
184/// follower wrote the same slot, and `set_status` carries `last_level_unix_ms` forward from
185/// whatever was there before. Tests in parallel read each other's state and inherited each
186/// other's level time (T15: `a_twin_keeps_the_primarys_last_head_and_time_when_the_primary_stops_answering`
187/// failed once in a full run, 5/5 alone). Keyed by the root the follower's stores were opened
188/// at, each box — and each test — has its own.
189static STATUS: OnceLock<Mutex<HashMap<PathBuf, TwinStatus>>> = OnceLock::new();
190
191fn status_slots() -> std::sync::MutexGuard<'static, HashMap<PathBuf, TwinStatus>> {
192    STATUS.get_or_init(|| Mutex::new(HashMap::new())).lock().unwrap_or_else(|p| p.into_inner())
193}
194
195/// The twin status of the box whose data root is `root`, once its follower has polled.
196pub fn status(root: &Path) -> Option<TwinStatus> {
197    status_slots().get(root).cloned()
198}
199
200/// Record a status, carrying [`TwinStatus::last_level_unix_ms`] forward: it moves
201/// only on a `following` answer at which the twin was level, and survives every
202/// other state untouched.
203pub(crate) fn set_status(root: &Path, mut s: TwinStatus) {
204    let mut slots = status_slots();
205    let before = slots.get(root).map(|p| p.last_level_unix_ms).unwrap_or(0);
206    s.last_level_unix_ms = if s.state == State::Following && s.last_answer_unix_ms > 0 && s.applied_seq >= s.primary_head_seq {
207        s.last_answer_unix_ms
208    } else {
209        before
210    };
211    slots.insert(root.to_path_buf(), s);
212}
213
214// ── the primary's half ──────────────────────────────────────────────────────
215
216/// Entries `from..=to` (`to == 0` = the head now), whole, stopping after the
217/// entry that crosses `max_bytes` of TRANSACTION (`0` = [`DEFAULT_FETCH_BYTES`])
218/// — never fewer than one. `from` below the oldest retained sequence is
219/// [`txlog::Error::NotRetained`]; above the head, [`txlog::Error::BeyondHead`].
220///
221/// **The ceiling counts meta as well as blob, and the count of entries is capped
222/// too** (2026-09-16, lane AUDIT-TWIN). It used to count `blob` alone, and most of
223/// a forge's frames carry no blob at all: every `Refs`, `ControlDoc`, `LfsDelete`
224/// and `RepoStore` is meta only. `bytes >= cap` was then never reached over such a
225/// range, so this read the WHOLE range into one `Vec<Entry>` however small a ceiling
226/// the caller asked for — and `service::mirror::chunks_of` built a second whole copy
227/// of it beside this one. A twin catching up over a ref-heavy history, or any member
228/// of group `twin` asking `LogFetch{from: 1, to: 0, max_bytes: 1}`, was an
229/// out-of-memory on the PRIMARY. A `Refs` meta IS the transaction when the blob is
230/// empty, so charging for it is what the word "bounded" was always supposed to mean.
231pub fn fetch_range(log: &Log, from: u64, to: u64, max_bytes: u64) -> txlog::Result<Vec<Entry>> {
232    let head = log.head_seq();
233    let to = if to == 0 { head } else { to.min(head) };
234    let cap = if max_bytes == 0 { DEFAULT_FETCH_BYTES } else { max_bytes };
235    let mut out = Vec::new();
236    let mut bytes = 0u64;
237    let mut seq = from;
238    while seq <= to {
239        let e = log.read(seq)?;
240        bytes += (e.blob.len() + e.meta.len()) as u64;
241        out.push(e);
242        seq += 1;
243        if bytes >= cap || out.len() as u64 >= MAX_FETCH_ENTRIES {
244            break;
245        }
246    }
247    if out.is_empty() && from > head {
248        // Nothing to give, and not because of retention: say so by the
249        // same word the reader would.
250        return Err(txlog::Error::BeyondHead { seq: from, head });
251    }
252    Ok(out)
253}
254
255/// **The most blob bytes one piece of a streamed entry carries** — the wire's `LogChunk` size,
256/// and all a twin or a primary holds of an entry at once.
257pub const PIECE_BYTES: usize = 1 << 20;
258
259/// **The refusals [`fetch_range`] makes before it reads anything**, made up front: `from` above
260/// the head is [`txlog::Error::BeyondHead`]; a non-empty range starting below the oldest
261/// retained sequence is [`txlog::Error::NotRetained`]. What a streamed `Mirror.LogFetch` answers
262/// as the call's status, before its first piece, exactly as the gathered one did.
263pub fn check_range(log: &Log, from: u64, to: u64) -> txlog::Result<()> {
264    let head = log.head_seq();
265    if from > head {
266        return Err(txlog::Error::BeyondHead { seq: from, head });
267    }
268    let to = if to == 0 { head } else { to.min(head) };
269    let first = log.first_seq();
270    if from <= to && from < first {
271        return Err(txlog::Error::NotRetained { seq: from, first });
272    }
273    Ok(())
274}
275
276/// **[`fetch_range`], streamed** (twin-streaming, 2026-10-06): the same entries under the same
277/// bounds — stopping after the entry that crosses `max_bytes` of transaction, or at
278/// [`MAX_FETCH_ENTRIES`], never fewer than one — each handed to `into` as it is read off the
279/// segment, [`PIECE_BYTES`] at a time ([`Log::read_pieces`]). `into.end()` for an entry is called
280/// only after its payload crc checked out, so a reader that makes an entry visible at its end
281/// never makes a corrupt one visible. Answers how many entries went out whole.
282///
283/// What the primary holds is ONE piece, whatever the entry's size; `fetch_range` held the whole
284/// range and the gRPC layer a second copy of it.
285pub fn stream_range(log: &Log, from: u64, to: u64, max_bytes: u64, into: &mut dyn FrameSink) -> anyhow::Result<u64> {
286    stream_range_at(log, from, 0, to, max_bytes, into)
287}
288
289/// **[`stream_range`] with the FIRST entry started at byte `from_offset` of its blob**
290/// (restore-large-entry, 2026-10-07): a restore resuming an entry the box being filled already
291/// holds part of (`LogFetchRequest.from_offset`). Only the blob from the offset is read
292/// ([`Log::read_pieces_from`]): a resumed entry of gigabytes costs this box its rest per call,
293/// never a re-read of what went out before. Its stored payload crc goes out with the last piece
294/// ([`FrameSink::checked`]) and the box that put the entry together checks it — the bytes below
295/// the offset are not here to check it over. `into` is told where the entry starts
296/// ([`FrameSink::begin_at`]). An offset at the blob's end is one empty last piece. An offset PAST
297/// it (or on an erased frame) is not the entry the asker holds part of (one erased since, say):
298/// the entry goes out whole from 0, checked, and the asker sees its length differ.
299pub fn stream_range_at(log: &Log, from: u64, from_offset: u64, to: u64, max_bytes: u64, into: &mut dyn FrameSink) -> anyhow::Result<u64> {
300    check_range(log, from, to)?;
301    let head = log.head_seq();
302    let to = if to == 0 { head } else { to.min(head) };
303    let cap = if max_bytes == 0 { DEFAULT_FETCH_BYTES } else { max_bytes };
304    let (mut bytes, mut sent, mut seq) = (0u64, 0u64, from);
305    while seq <= to {
306        let mut begun = false;
307        let start = if seq == from && from_offset > 0 {
308            let l = log.locate_blob(seq)?;
309            if l.kind == Kind::Redacted as u16 || from_offset > l.blob_len { 0 } else { from_offset }
310        } else {
311            0
312        };
313        let at = log.read_pieces_from::<anyhow::Error>(seq, start, PIECE_BYTES, |l, piece, last| {
314            if !begun {
315                into.begin_at(l.seq, l.kind, &l.meta, l.blob_len, start)?;
316                begun = true;
317            }
318            if last {
319                if let Some(crc) = l.payload_crc {
320                    into.checked(crc)?;
321                }
322            }
323            if !piece.is_empty() {
324                into.piece(piece)?;
325            }
326            if last {
327                into.end()?;
328            }
329            Ok(())
330        })?;
331        bytes += at.blob_len + at.meta.len() as u64;
332        sent += 1;
333        seq += 1;
334        if bytes >= cap || sent >= MAX_FETCH_ENTRIES {
335            break;
336        }
337    }
338    Ok(sent)
339}
340
341// ── streamed entries ────────────────────────────────────────────────────────
342
343/// **Where a streamed range goes: entry by entry, piece by piece** (twin-streaming,
344/// 2026-10-06). `begin` names an entry and the length its blob will have, `piece` carries the
345/// next bytes of it in order, `end` says its last byte arrived and the source vouched for it.
346/// An entry that never reaches `end` — the wire broke, the source refused, a check failed — was
347/// never an entry: an implementation keeps nothing of it.
348pub trait FrameSink {
349    /// An entry begins.
350    fn begin(&mut self, seq: u64, kind: u16, meta: &[u8], blob_len: u64) -> anyhow::Result<()>;
351    /// The next piece of the open entry's blob.
352    fn piece(&mut self, bytes: &[u8]) -> anyhow::Result<()>;
353    /// The open entry is whole.
354    fn end(&mut self) -> anyhow::Result<()>;
355    /// **An entry begins part-way** — its pieces start at byte `offset` of the blob
356    /// (restore-large-entry, 2026-10-07: [`stream_range_at`]). A sink that can only take whole
357    /// entries refuses any offset but 0.
358    fn begin_at(&mut self, seq: u64, kind: u16, meta: &[u8], blob_len: u64, offset: u64) -> anyhow::Result<()> {
359        anyhow::ensure!(offset == 0, "seq {seq}: this sink takes whole entries, not one from byte {offset}");
360        self.begin(seq, kind, meta, blob_len)
361    }
362    /// The open entry's payload crc32c (meta ‖ blob), checked by the source against what it
363    /// read, before its last piece: a sink that forwards entries vouches with it.
364    fn checked(&mut self, _payload_crc: u32) -> anyhow::Result<()> {
365        Ok(())
366    }
367}
368
369/// **Whole entries out of a stream** — [`LogSource::fetch`] spelled over
370/// [`LogSource::fetch_into`], for the callers that want them (tests, small ranges). Holds what it
371/// gathers, which is the point of using it only where that is small — and
372/// BOUNDED (stinky row 20, 2026-10-10): no entry over [`GATHER_MAX_ENTRY`], no
373/// more than [`GATHER_MAX_TOTAL`] of blob in all, and no entry past the length
374/// it declared. `reserve_for` bounds only the first allocation; these bound
375/// what a peer that keeps sending can make this box hold.
376#[derive(Debug)]
377pub struct Gather {
378    /// The entries, in order, once ended.
379    pub out: Vec<Entry>,
380    open: Option<Entry>,
381    /// The open entry's declared blob length.
382    declared: u64,
383    /// Blob bytes held, ended entries and the open one.
384    held: u64,
385    max_entry: u64,
386    max_total: u64,
387}
388
389impl Default for Gather {
390    fn default() -> Self {
391        Gather::bounded(GATHER_MAX_ENTRY, GATHER_MAX_TOTAL)
392    }
393}
394
395/// The largest entry a [`Gather`] holds whole.
396pub const GATHER_MAX_ENTRY: u64 = 256 << 20;
397/// The most blob bytes a [`Gather`] holds in all.
398pub const GATHER_MAX_TOTAL: u64 = 512 << 20;
399
400impl Gather {
401    /// A gather that holds no entry over `max_entry` bytes and no more than
402    /// `max_total` bytes of blob in all.
403    pub fn bounded(max_entry: u64, max_total: u64) -> Gather {
404        Gather { out: Vec::new(), open: None, declared: 0, held: 0, max_entry, max_total }
405    }
406}
407
408impl FrameSink for Gather {
409    fn begin(&mut self, seq: u64, kind: u16, meta: &[u8], blob_len: u64) -> anyhow::Result<()> {
410        anyhow::ensure!(self.open.is_none(), "seq {seq} began inside another entry");
411        anyhow::ensure!(
412            blob_len <= self.max_entry,
413            "seq {seq} declares {blob_len} bytes, more than the {} one gathered entry may hold; it is streamed, not gathered",
414            self.max_entry
415        );
416        anyhow::ensure!(
417            self.held.saturating_add(blob_len) <= self.max_total,
418            "seq {seq} ({blob_len} bytes) would take this gather past the {} bytes it may hold in all ({} held)",
419            self.max_total,
420            self.held
421        );
422        self.declared = blob_len;
423        self.open = Some(Entry { seq, kind, meta: meta.to_vec(), blob: Vec::with_capacity(gunnar_proto::reserve_for(blob_len)) });
424        Ok(())
425    }
426    fn piece(&mut self, bytes: &[u8]) -> anyhow::Result<()> {
427        let open = self.open.as_mut().ok_or_else(|| anyhow!("a piece arrived outside an entry"))?;
428        let after = open.blob.len() as u64 + bytes.len() as u64;
429        anyhow::ensure!(after <= self.declared, "seq {}: {after} bytes arrived for an entry that declared {}", open.seq, self.declared);
430        open.blob.extend_from_slice(bytes);
431        self.held += bytes.len() as u64;
432        Ok(())
433    }
434    fn end(&mut self) -> anyhow::Result<()> {
435        let e = self.open.take().ok_or_else(|| anyhow!("an entry ended that never began"))?;
436        self.out.push(e);
437        Ok(())
438    }
439}
440
441/// **The `LogChunk` protocol, checked, for every reader of `Mirror.LogFetch`** — the twin's
442/// follower and `gunnar control twin follow` read the same stream the same way. One entry's
443/// pieces arrive in order with the same `seq`, `kind` and `blob_len`, `meta` on the first; its
444/// bytes add up to exactly `blob_len` when `last` ends it (more is refused at the piece that
445/// overflows, less at the end); a new `seq` before `last` and a stream that ends inside an
446/// entry are both refused. Each piece goes to the [`FrameSink`] as it arrives and is dropped.
447#[derive(Debug, Default)]
448pub struct ChunkFeed {
449    /// `(seq, kind, blob_len, bytes so far)` of the entry being received.
450    open: Option<(u64, u16, u64, u64)>,
451}
452
453impl ChunkFeed {
454    /// One chunk off the wire.
455    pub fn take(&mut self, chunk: &gunnar_proto::LogChunk, into: &mut dyn FrameSink) -> anyhow::Result<()> {
456        let kind = u16::try_from(chunk.kind).map_err(|_| anyhow!("seq {}: kind {} is not a frame kind", chunk.seq, chunk.kind))?;
457        match self.open {
458            Some((seq, k, len, _)) if seq == chunk.seq => anyhow::ensure!(
459                k == kind && len == chunk.blob_len,
460                "seq {seq}: a later piece says kind {kind} of {} bytes, the first said kind {k} of {len}",
461                chunk.blob_len
462            ),
463            Some((seq, ..)) => anyhow::bail!("the primary moved to seq {} with seq {seq} unfinished", chunk.seq),
464            None => {
465                into.begin(chunk.seq, kind, &chunk.meta, chunk.blob_len)?;
466                self.open = Some((chunk.seq, kind, chunk.blob_len, 0));
467            }
468        }
469        let Some((seq, _, len, got)) = self.open.as_mut() else { unreachable!("an entry is open") };
470        *got += chunk.blob.len() as u64;
471        anyhow::ensure!(*got <= *len, "seq {seq}: more than the {len} blob bytes it declared arrived");
472        if !chunk.blob.is_empty() {
473            into.piece(&chunk.blob)?;
474        }
475        if chunk.last {
476            anyhow::ensure!(*got == *len, "seq {seq}: {got} of {len} blob bytes arrived");
477            self.open = None;
478            into.end()?;
479        }
480        Ok(())
481    }
482
483    /// The stream ended: refused if it ended inside an entry.
484    pub fn finish(&self) -> anyhow::Result<()> {
485        match self.open {
486            Some((seq, _, len, got)) => Err(anyhow!("the stream ended inside seq {seq}, {got} of {len} blob bytes in")),
487            None => Ok(()),
488        }
489    }
490}
491
492/// **A `Mirror.LogFetch` stream into a [`FrameSink`]**, each chunk handed on as it arrives,
493/// none gathered: what both `GrpcSource` and `gunnar control twin follow` run (any stream of
494/// chunks — tonic's `Streaming` is one). `idle` bounds the wait for every next chunk (the
495/// follower's [`FETCH_IDLE_WITHIN`]); `said` turns the stream's error into the caller's.
496pub async fn feed_log_stream<S, E>(
497    stream: &mut S,
498    idle: Option<(Duration, &(dyn Fn() -> anyhow::Error + Sync))>,
499    said: &(dyn Fn(E) -> anyhow::Error + Sync),
500    into: &mut dyn FrameSink,
501) -> anyhow::Result<()>
502where
503    S: tokio_stream::Stream<Item = Result<gunnar_proto::LogChunk, E>> + Unpin,
504{
505    use tokio_stream::StreamExt as _;
506    let mut feed = ChunkFeed::default();
507    loop {
508        let next = match idle {
509            Some((within, quiet)) => tokio::time::timeout(within, stream.next()).await.map_err(|_| quiet())?,
510            None => stream.next().await,
511        };
512        match next.transpose().map_err(said)? {
513            Some(chunk) => feed.take(&chunk, into)?,
514            None => return feed.finish(),
515        }
516    }
517}
518
519// ── the source ──────────────────────────────────────────────────────────────
520
521/// What the twin reads from: `LogHead` and `LogFetch`, over gRPC in the
522/// appliance and over a shared `Log` in tests and in `restore`.
523pub trait LogSource: Send + Sync {
524    /// `(first_seq, head_seq)` on the primary.
525    fn head(&self) -> anyhow::Result<(u64, u64)>;
526    /// Does the primary have another address to re-dial when this one keeps
527    /// failing? (The tunnel's and the utility network's.) In-process sources
528    /// have none.
529    fn alternatives(&self) -> bool {
530        false
531    }
532    /// **`(first_seq, head_seq, the source's log identity)`** — one call, because
533    /// the identity has to come from the SAME answer as the numbers it
534    /// qualifies. Asking twice would leave a window in which the numbers are
535    /// one box's and the name is another's, which is the bug in a nicer shape.
536    ///
537    /// The default carries no identity, and that is the truthful answer for
538    /// every in-process source: a bare `Arc<Log>`, a `LocalSource` over another
539    /// data root in this process, a test's script. Nothing was fetched over a
540    /// wire, so there is nothing to have been lied to about. Only
541    /// [`GrpcSource`] overrides it. See [`LOG_ID`].
542    fn head_and_identity(&self) -> anyhow::Result<(u64, u64, Option<String>)> {
543        let (first, head) = self.head()?;
544        Ok((first, head, None))
545    }
546    /// **[`LogSource::head_and_identity`] and the source's GENERATION**
547    /// ([`LOG_GENERATION`]), out of the same answer. 0 = none claimed: every
548    /// in-process source, and a primary never restored or older than the field.
549    fn head_identity_generation(&self) -> anyhow::Result<(u64, u64, Option<String>, u64)> {
550        let (first, head, id) = self.head_and_identity()?;
551        Ok((first, head, id, 0))
552    }
553    /// Entries `from..=to`, at most about `max_bytes` of blob, never fewer
554    /// than one when `from <= to`.
555    fn fetch(&self, from: u64, to: u64, max_bytes: u64) -> anyhow::Result<Vec<Entry>>;
556    /// **[`LogSource::fetch`], streamed into `into`** — what the follower calls (twin-streaming,
557    /// 2026-10-06). Every source that reads a real log overrides it ([`GrpcSource`] off the wire,
558    /// a bare [`Arc<Log>`] and [`LocalSource`] off the segments), so an entry is never whole in
559    /// memory however large it is. The default gathers through `fetch` and hands each blob over
560    /// as one piece: right for a scripted source, whose entries are already in its hands.
561    fn fetch_into(&self, from: u64, to: u64, max_bytes: u64, into: &mut dyn FrameSink) -> anyhow::Result<()> {
562        for e in self.fetch(from, to, max_bytes)? {
563            into.begin(e.seq, e.kind, &e.meta, e.blob.len() as u64)?;
564            if !e.blob.is_empty() {
565                into.piece(&e.blob)?;
566            }
567            into.end()?;
568        }
569        Ok(())
570    }
571    /// A consistent snapshot of the source's stores, for a twin behind a retention hole, fed
572    /// piece by piece `into` the twin's staging area as it arrives. `false` = this source
573    /// cannot make one (a bare log): the twin stops with snapshot-needed.
574    fn snapshot(&self, _into: &mut snapshot::Stager) -> anyhow::Result<bool> {
575        Ok(false)
576    }
577}
578
579impl LogSource for Arc<Log> {
580    fn head(&self) -> anyhow::Result<(u64, u64)> {
581        Ok((self.first_seq(), self.head_seq()))
582    }
583    fn fetch(&self, from: u64, to: u64, max_bytes: u64) -> anyhow::Result<Vec<Entry>> {
584        Ok(fetch_range(self, from, to, max_bytes)?)
585    }
586    fn fetch_into(&self, from: u64, to: u64, max_bytes: u64, into: &mut dyn FrameSink) -> anyhow::Result<()> {
587        stream_range(self, from, to, max_bytes, into).map(drop)
588    }
589}
590
591// ── apply ───────────────────────────────────────────────────────────────────
592
593/// What one entry did on this box.
594#[derive(Debug, Clone, PartialEq, Eq)]
595pub enum Applied {
596    /// Stored, or already there.
597    Done,
598    /// A CAS the primary lost, lost here too — the design's replayed race.
599    Lost(String),
600    /// A kind this build does not know: kept in the log, not applied.
601    Unknown(u16),
602    /// **Kept in a twin's log and nothing else** (ruling 2026-09-22, "the twin only
603    /// stores"): a data frame a twin does not apply. The recharge applies it on the new main.
604    Stored,
605}
606
607/// **The frames a twin applies** — the control plane the twin itself runs on, and nothing
608/// else (ruling 2026-09-22, "the twin only stores").
609///
610/// * `Kek` — without it the replicated sealed secrets are unreadable on this box.
611/// * `ControlBlob` / `ControlDoc` — the identity table: who may read this twin's log
612///   (`Mirror.LogFetch`/`SnapshotFetch`, `twin status`, `twin replay`) is decided against the
613///   primary's principals and groups, so the twin must hold them to gate its own door.
614/// * `Secret` — the host key the twin answers with, and the rest of the sealed rows (a row
615///   write, no derived structure).
616///
617/// Everything else — `Pack`, `Refs`, `LfsPut`, `LfsDelete`, `RepoStore`, `Append`, `File` —
618/// is the DATA a twin saves and does not process: the frame in the log IS the copy. Applying
619/// a `Pack` is what builds the archive's oid tail, the pack index and (on read) the commit
620/// graph and the explode pool; applying `Refs` needs that tail. None of it exists on a twin.
621pub fn twin_applies(kind: u16) -> bool {
622    // `Erase` too (owner ruling g-nonpay): the twin's log IS the copy, so an account
623    // deleted on main is erased from it here — the one thing a twin does to data.
624    // `Activity` too (2026-10-21): the last-seen stamps are control-plane rows, and a twin
625    // promoted to main answers the admin's user list with them.
626    matches!(Kind::from_u16(kind), Some(Kind::Kek | Kind::ControlBlob | Kind::ControlDoc | Kind::Secret | Kind::Erase | Kind::Activity))
627}
628
629/// **What a follower does with an entry that is durable in its log.** On a twin: the
630/// [`twin_applies`] kinds are applied, every other kind is [`Applied::Stored`] (the log holds
631/// it) — an unknown kind is still [`Applied::Unknown`]. On any other box (a fresh box being
632/// restored — the recharge): the full [`apply`], which is where the indexes get built.
633pub fn take(stores: &ServerStores, e: &Entry) -> anyhow::Result<Applied> {
634    take_raw(stores, e.seq, e.kind, &e.meta, &e.blob)
635}
636
637/// [`take`] over borrowed parts.
638pub fn take_raw(stores: &ServerStores, seq: u64, kind: u16, meta: &[u8], blob: &[u8]) -> anyhow::Result<Applied> {
639    if stores.is_twin() && Kind::from_u16(kind).is_some() && !twin_applies(kind) {
640        // The meta is parsed — a frame that could never be applied is refused NOW, while the
641        // primary is alive to be asked again, not discovered at the recharge. The bytes are
642        // not touched.
643        Meta::from_bytes(meta).with_context(|| format!("seq {seq}: the frame's meta does not parse"))?;
644        return Ok(Applied::Stored);
645    }
646    apply_raw(stores, seq, kind, meta, blob)
647}
648
649/// **[`take`] for an entry already durable in this box's log**, reading its blob back only when
650/// the take needs it (twin-streaming, 2026-10-06). A twin keeps its data frames without touching
651/// their bytes ([`take_raw`] parses the meta and stops), so a 512 MiB pack it just streamed to
652/// disk is never read back into memory; the control plane it applies is small, and a box being
653/// restored reads each frame whole because [`apply`] needs it whole.
654pub fn take_logged(stores: &ServerStores, seq: u64, kind: u16, meta: &[u8]) -> anyhow::Result<Applied> {
655    if stores.is_twin() && !twin_applies(kind) {
656        return take_raw(stores, seq, kind, meta, &[]);
657    }
658    let e = stores.txlog().read(seq)?;
659    take(stores, &e)
660}
661
662fn oid_bytes(hex_text: &str) -> anyhow::Result<Vec<u8>> {
663    hex::decode(hex_text).map_err(|e| anyhow!("oid {hex_text}: {e}"))
664}
665
666fn repo_of(stores: &ServerStores, key: &str) -> anyhow::Result<gunnar_core::Repo> {
667    let (ns, name) = key
668        .split_once('/')
669        .ok_or_else(|| anyhow!("repository key {key:?} is not namespace/name"))?;
670    let path = RepoRef::new(ns, name).with_context(|| format!("repository key {key:?}"))?;
671    stores
672        .catalog()
673        .get(&path)?
674        .ok_or_else(|| anyhow!("the frame names {key}, which the catalog does not hold yet — the catalog row must precede the first pack"))
675}
676
677/// Apply one entry to `stores` through their UNLOGGED halves.
678pub fn apply(stores: &ServerStores, e: &Entry) -> anyhow::Result<Applied> {
679    apply_raw(stores, e.seq, e.kind, &e.meta, &e.blob)
680}
681
682/// [`apply`] over borrowed parts — a snapshot item read back from the twin's staging file
683/// (`seq` 0) is never an owned [`Entry`].
684pub fn apply_raw(stores: &ServerStores, seq: u64, kind: u16, meta: &[u8], blob: &[u8]) -> anyhow::Result<Applied> {
685    struct E<'a> {
686        seq: u64,
687        blob: &'a [u8],
688    }
689    let e = E { seq, blob };
690    let Some(kind) = Kind::from_u16(kind) else {
691        // **A FOLLOWING twin keeps it and goes on; a box being RESTORED stops here**
692        // (2026-09-16, lane AUDIT-TWIN). The two are not the same box:
693        //
694        // * a twin's log holds the frame byte for byte and the twin is never promoted, so a
695        //   kind a newer primary invented costs it nothing — the restore that later reads this
696        //   log gets the frame whole;
697        // * a box being restored stops being a follower at [`restore_finish`] and starts
698        //   ORIGINATING from that head. Skipping the frame there was SILENT: the replay
699        //   answered "restored to seq N (N applied, 0 lost)" and the box came up as a primary
700        //   whose stores were missing whatever those frames carried. Measured 2026-09-16: a log
701        //   with one kind-99 frame restored green.
702        //
703        // Refusing it as an apply failure is the resumable shape the restore already has: the
704        // entry is durable at the head, the stream stops, the box stays restoring, and
705        // [`restore_begin`] re-applies the head — so re-running the replay under a build that
706        // knows the kind finishes it instead of stepping over it.
707        if is_restoring(stores.root()) {
708            anyhow::bail!(
709                "seq {seq}: frame kind {kind} is not one this build can apply — the log that is being \
710                 restored was written by a NEWER gunnar. The frame is durable at this box's head and \
711                 nothing is lost; restore again from a build that knows kind {kind}. This box stays \
712                 restoring and originates nothing."
713            );
714        }
715        return Ok(Applied::Unknown(kind));
716    };
717    // An erased frame's data is what was deleted: nothing to apply. Its meta is the
718    // original frame's, so it is not matched below.
719    if kind == Kind::Redacted {
720        return Ok(Applied::Done);
721    }
722    let meta = Meta::from_bytes(meta).with_context(|| format!("seq {seq}"))?;
723    match (kind, meta) {
724        // **An account's data was deleted** (g-nonpay): every frame of these repositories
725        // before this one is erased from THIS box's log — the twin's copy, or the log a
726        // restore is rebuilding. The stores themselves went with their `RepoStore` frames.
727        (Kind::Erase, Meta::Erase { repos, .. }) => {
728            stores.txlog().erase(seq, &repos).with_context(|| format!("seq {seq}: erasing {} repositor(ies) from the log", repos.len()))?;
729            Ok(Applied::Done)
730        }
731        (Kind::Pack, Meta::Pack { repo, .. }) => {
732            let r = repo_of(stores, &repo)?;
733            let store = stores.repo_store(&r)?;
734            store.unlogged().put_pack(e.blob).with_context(|| format!("seq {}: pack into {repo}", e.seq))?;
735            Ok(Applied::Done)
736        }
737        (Kind::Refs, Meta::Refs { repo, atomic, edits }) => {
738            let r = repo_of(stores, &repo)?;
739            let store = stores.repo_store(&r)?;
740            let git = store.unlogged();
741            if atomic {
742                let olds: Vec<Option<Vec<u8>>> = edits
743                    .iter()
744                    .map(|ed| ed.old.clone().flatten().map(|h| oid_bytes(&h)).transpose())
745                    .collect::<anyhow::Result<_>>()?;
746                let news: Vec<Option<Vec<u8>>> = edits
747                    .iter()
748                    .map(|ed| ed.new.as_deref().map(oid_bytes).transpose())
749                    .collect::<anyhow::Result<_>>()?;
750                let peeled: Vec<Option<Vec<u8>>> = edits
751                    .iter()
752                    .map(|ed| ed.peeled.as_deref().map(oid_bytes).transpose())
753                    .collect::<anyhow::Result<_>>()?;
754                let cas: Vec<RefCas<'_>> = edits
755                    .iter()
756                    .enumerate()
757                    .map(|(i, ed)| RefCas {
758                        name: ed.name.clone(),
759                        old: olds[i].as_deref(),
760                        new: news[i].as_deref(),
761                        peeled: peeled[i].clone(),
762                    })
763                    .collect();
764                match git.put_refs_cas(&cas) {
765                    Ok(_) => Ok(Applied::Done),
766                    Err(err) => match err.downcast_ref::<RefRejection>() {
767                        Some(rej) => Ok(Applied::Lost(format!("{rej:?}"))),
768                        None => Err(err).with_context(|| format!("seq {}: refs into {repo}", e.seq)),
769                    },
770                }
771            } else {
772                let mut plain: Vec<RefUpdate> = Vec::new();
773                for ed in &edits {
774                    if ed.name == "HEAD" {
775                        if let Some(target) = &ed.symref {
776                            git.set_head(target).with_context(|| format!("seq {}: HEAD of {repo}", e.seq))?;
777                            continue;
778                        }
779                    }
780                    plain.push(update_of_edit(ed));
781                }
782                if !plain.is_empty() {
783                    git.put_refs(&plain).with_context(|| format!("seq {}: refs into {repo}", e.seq))?;
784                }
785                Ok(Applied::Done)
786            }
787        }
788        (Kind::ControlBlob, Meta::ControlBlob { id }) => {
789            let got = stores.raw_control_store().put_blob(e.blob)?;
790            let want = BlobId::from_hex(&id).ok_or_else(|| anyhow!("seq {}: blob id {id:?} is not hex", e.seq))?;
791            if got != want {
792                return Err(anyhow!("seq {}: the blob hashes to {} but the frame says {id}", e.seq, hex::encode(got.as_bytes())));
793            }
794            Ok(Applied::Done)
795        }
796        (Kind::ControlDoc, Meta::ControlDoc { name, expected, new }) => {
797            let mut expected = expected
798                .as_deref()
799                .map(|h| BlobId::from_hex(h).ok_or_else(|| anyhow!("seq {}: expected {h:?} is not hex", e.seq)))
800                .transpose()?;
801            let new = BlobId::from_hex(&new).ok_or_else(|| anyhow!("seq {}: new {new:?} is not hex", e.seq))?;
802            if expected.is_none() && fresh_take(stores.root(), &name)? {
803                // The fresh-box rule: this box wrote `name` itself before it
804                // was reset; the primary's create is a create into empty.
805                expected = stores.raw_control_store().read_doc(&name)?;
806            }
807            if stores.raw_control_store().commit_doc(&name, expected, new)? {
808                Ok(Applied::Done)
809            } else {
810                Ok(Applied::Lost(format!("document {name}")))
811            }
812        }
813        (Kind::Secret, Meta::Secret { key, deleted }) => {
814            use gunnar_store::SecretStore as _;
815            if deleted {
816                stores.raw_secrets().delete(&key)?;
817            } else {
818                stores.raw_secrets().put(&key, e.blob)?;
819            }
820            Ok(Applied::Done)
821        }
822        (Kind::LfsPut, Meta::Lfs { repo, oid, size }) => {
823            use gunnar_lfs::LfsStore as _;
824            use std::io::Write as _;
825            let r = repo_of(stores, &repo)?;
826            let store = stores.repo_store(&r)?;
827            let lfs = gunnar_lfs::FsLfsStore::open_or_init(store.git_dir())?;
828            let id = gunnar_lfs::Oid::parse(&oid).map_err(|err| anyhow!("seq {}: {oid}: {err}", e.seq))?;
829            if lfs.size_of(&id)?.is_some() {
830                return Ok(Applied::Done);
831            }
832            let mut w = lfs.begin_write(&id, size)?;
833            w.write_all(e.blob)?;
834            w.commit()?;
835            Ok(Applied::Done)
836        }
837        (Kind::Activity, Meta::Activity {}) => {
838            let rows = gunnar_core::activity::decode_batch(e.blob).with_context(|| format!("seq {}: last-seen stamps", e.seq))?;
839            stores.activity().absorb(rows).with_context(|| format!("seq {}: storing last-seen stamps", e.seq))?;
840            Ok(Applied::Done)
841        }
842        (Kind::Kek, Meta::Kek {}) => {
843            write_kek(stores.control_dir(), e.blob).with_context(|| format!("seq {}: the KEK", e.seq))?;
844            stores.adopt_kek(e.blob).with_context(|| format!("seq {}: holding the KEK", e.seq))?;
845            Ok(Applied::Done)
846        }
847        (Kind::Append, Meta::Append { log }) => {
848            let path = under_root(stores.root(), &log)?;
849            append_line_once(&path, e.blob).with_context(|| format!("seq {}: a line of {log}", e.seq))?;
850            Ok(Applied::Done)
851        }
852        (Kind::File, Meta::File { path, deleted }) => {
853            let full = under_root(stores.root(), &path)?;
854            if deleted {
855                match std::fs::remove_file(&full) {
856                    Ok(()) => {}
857                    Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
858                    Err(err) => return Err(err).with_context(|| format!("seq {}: removing {path}", e.seq)),
859                }
860            } else {
861                let dir = full.parent().expect("under the root");
862                std::fs::create_dir_all(dir)?;
863                let name = full.file_name().expect("a file").to_string_lossy().into_owned();
864                crate::durable::write_atomic_unframed(dir, &name, e.blob).with_context(|| format!("seq {}: {path}", e.seq))?;
865            }
866            Ok(Applied::Done)
867        }
868        (Kind::LfsDelete, Meta::Lfs { repo, oid, .. }) => {
869            use gunnar_lfs::LfsStore as _;
870            let (ns, name) = repo.split_once('/').ok_or_else(|| anyhow!("seq {}: repository key {repo:?} is not namespace/name", e.seq))?;
871            let dir = stores.repo_dir(&RepoRef::new(ns, name)?)?;
872            // No store, no object: the delete is already true here.
873            if !dir.exists() {
874                return Ok(Applied::Done);
875            }
876            let id = gunnar_lfs::Oid::parse(&oid).map_err(|err| anyhow!("seq {}: {oid}: {err}", e.seq))?;
877            gunnar_lfs::FsLfsStore::open_or_init(&dir)?.delete(&id).with_context(|| format!("seq {}: deleting LFS {oid} of {repo}", e.seq))?;
878            Ok(Applied::Done)
879        }
880        (Kind::RepoStore, Meta::RepoStore { repo, op, .. }) => {
881            let (ns, name) = repo.split_once('/').ok_or_else(|| anyhow!("seq {}: repository key {repo:?} is not namespace/name", e.seq))?;
882            let path = RepoRef::new(ns, name)?;
883            match op.as_str() {
884                "purged" => {
885                    stores.reclaim_repo(&path).with_context(|| format!("seq {}: purging the store of {repo}", e.seq))?;
886                }
887                // The store opens on its first pack; a create frame, when one exists, opens it now.
888                "created" => {
889                    if let Some(r) = stores.catalog().get(&path)? {
890                        stores.repo_store(&r)?;
891                    }
892                }
893                other => return Err(anyhow!("seq {}: RepoStore op {other:?} is neither created nor purged", e.seq)),
894            }
895            Ok(Applied::Done)
896        }
897        (kind, meta) => Err(anyhow!("seq {}: kind {kind:?} with meta {meta:?} do not belong together", e.seq)),
898    }
899}
900
901fn update_of_edit(ed: &RefEdit) -> RefUpdate {
902    RefUpdate {
903        name: ed.name.clone(),
904        target: ed.new.clone(),
905        peeled: ed.peeled.clone(),
906        symref_target: ed.symref.clone(),
907    }
908}
909
910// ── the box's role and the fresh-box rule ───────────────────────────────────
911
912/// `<data-root>/twin.json`: this box is a twin of `primary`. Per box, never
913/// framed (a twin's own facts are not the primary's transactions).
914pub const TWIN_CONFIG: &str = "twin.json";
915/// `<data-root>/twin.key`: the twin's own ed25519 key, minted at join — the
916/// credential it presents to the primary's `Mirror.LogFetch`. Per box, never
917/// framed; its public half is enrolled on the primary in group [`TWIN_GROUP`].
918pub const TWIN_KEY: &str = "twin.key";
919/// The group whose members may read the primary's log without being admins.
920pub const TWIN_GROUP: &str = "twin";
921/// `<data-root>/twin-owed.json`, on a PRIMARY: what each twin is owed — a full resync
922/// after retention trimmed past it while it was silent, a growth after main's set grew
923/// while it was silent (`gunnar_grpc::twin_owed`, cov-13). Per box, never framed.
924pub const TWIN_OWED_FILE: &str = "twin-owed.json";
925/// `<data-root>/txlog-fresh-docs.json`: the documents a fresh box wrote itself
926/// before it was reset to take another log's numbers (see [`prepare_replay`]).
927pub const FRESH_DOCS: &str = "txlog-fresh-docs.json";
928/// `<data-root>/txlog-restoring`: a restore is in progress; the log stays
929/// follower-only across a restart until [`restore_finish`].
930pub const RESTORING: &str = "txlog-restoring";
931
932/// What `twin.json` says.
933#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
934pub struct TwinConfig {
935    /// The primary's control plane, `host:port`.
936    pub primary: String,
937    /// When this box became a twin.
938    pub joined_at_unix_ms: i64,
939}
940
941impl TwinConfig {
942    /// `Ok(None)` = not a twin. A `twin.json` that does not parse is an error,
943    /// never "not a twin": a twin that booted as a primary would frame its own
944    /// KEK and fork the numbers.
945    pub fn load(root: &Path) -> anyhow::Result<Option<Self>> {
946        match std::fs::read(root.join(TWIN_CONFIG)) {
947            Ok(bytes) => Ok(Some(serde_json::from_slice(&bytes).with_context(|| format!("{} does not parse", root.join(TWIN_CONFIG).display()))?)),
948            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
949            Err(e) => Err(e).with_context(|| format!("reading {}", root.join(TWIN_CONFIG).display())),
950        }
951    }
952    /// Write it (unframed, atomic).
953    pub fn save(&self, root: &Path) -> anyhow::Result<()> {
954        crate::durable::write_atomic_unframed(root, TWIN_CONFIG, &serde_json::to_vec_pretty(self)?)
955    }
956}
957
958/// Does `root` belong to a twin? Existence decides; [`TwinConfig::load`] parses.
959pub fn is_twin_root(root: &Path) -> bool {
960    root.join(TWIN_CONFIG).exists()
961}
962
963/// `<data>/pairing/recharge_from`: this box was re-admitted as a recharged
964/// main and has not been restored yet — what `Bootstrap.Status.recharge_from`
965/// answers, so the restore lane (monetize's) knows the box awaits its twin.
966///
967/// Declared HERE and re-exported by `pairing`: `stores` reads it in every build, and
968/// `pairing` is compiled only with `wg-client` (2026-10-14: `--features
969/// server,engine-znippy,metered` did not compile — "cannot find `pairing` in `crate`").
970pub const RECHARGE_REL: &str = "pairing/recharge_from";
971
972/// Is a restore in progress under `root`?
973pub fn is_restoring(root: &Path) -> bool {
974    root.join(RESTORING).exists()
975}
976
977// ── the log's identity ──────────────────────────────────────────────────────
978
979/// `<data-root>/txlog-id`: **which log this is.** 128 random bits, hex, minted
980/// once at a primary's first open and never derived from anything.
981///
982/// # Why a log needed a name at all
983///
984/// Until 2026-09-21 (lane T4) NOTHING on the wire said which log a
985/// `Mirror.LogHead` answer was about. `LogHeadResponse` carried nine fields —
986/// head, first, bytes, twin, applied, primary head, caught up, state, why —
987/// and every one of them is a NUMBER ABOUT a log, not a name FOR one. A twin
988/// compared sequence numbers against an address and called that following.
989///
990/// The lineage check added with `State::Diverged` catches a primary whose head
991/// is BELOW the twin's, which is a re-imaged main caught early. It cannot catch
992/// the same main once it has written past the twin's head: a fresh install at a
993/// familiar address, left alone for an afternoon, reaches seq 20 000 and the
994/// twin — sitting at 19 264 of a history that no longer exists anywhere else —
995/// SPLICES ONE HISTORY ONTO ANOTHER, cleanly and silently, and the only copy of
996/// the real one is gone. That is the single worst thing this repository can do,
997/// and no number can catch it. Only a name can.
998///
999/// # Not the host key, and not anything else already on the box
1000///
1001/// The host key is replayed by a restore ON PURPOSE (T3, `T1h host key
1002/// identical`), and so are the KEK and every document — which is exactly right
1003/// for a restore and exactly wrong here, because a value that survives a restore
1004/// cannot by itself distinguish the two boxes we care about. The id is minted
1005/// from the system CSPRNG and from nothing else, so a fresh box CANNOT produce
1006/// one it has not been given.
1007///
1008/// # A restored main KEEPS the id, and that is the whole design
1009///
1010/// The id names the HISTORY, not the box. A restore replays the twin's log into
1011/// a fresh root at the same sequence numbers, frame for frame; if that is not
1012/// the same history then the word restore means nothing. So `restore_finish`
1013/// writes the id that came down the stream, and the twin the new main was built
1014/// FROM keeps following it without being touched.
1015///
1016/// The alternative — a restored main mints a new id — was rejected on what it
1017/// costs at the one moment that matters. It would put the twin into `Diverged`
1018/// against the box that had just been built out of it, so the estate would have
1019/// NO twin in the hour after it has just survived losing its main; and getting
1020/// one back is not cheap, because `become_twin` takes a FRESH box and the twin
1021/// is not fresh — it holds the whole history — so the twin would have to be
1022/// wiped and re-seeded from the new main before the estate was covered again.
1023/// Paying that, every restore, to detect a case the id is not the mechanism for
1024/// is the wrong trade.
1025///
1026/// **What it therefore does NOT catch, said plainly:** two boxes can hold the
1027/// same id — the restored main, and the old main if it ever comes back. That
1028/// split-brain is created by the restore itself, not by this file, and it is
1029/// the sequence rule that meets it: the old main returns BEHIND the restored
1030/// one, so its head is below the twin's and [`State::Diverged`] already stops
1031/// there. An id cannot order two boxes in time; it can only say whether they
1032/// are the same history, which is the question it is here to answer.
1033///
1034/// The old main can also come back AHEAD: it took writes the twin never saw
1035/// before it went dark, or it keeps taking them after it returns, until its
1036/// head passes the twin's and every number agrees again. That ordering is what
1037/// [`LOG_GENERATION`] gives (stinky row 18, 2026-10-10): a restore stamps a newer
1038/// generation, the twin pins the highest it has followed, and an answer from a
1039/// lower one is [`State::Diverged`] by name.
1040pub const LOG_ID: &str = "txlog-id";
1041
1042/// `<data-root>/txlog-generation`: **which restore of its log this box is**
1043/// (stinky row 18, 2026-10-10). Absent = 0: never restored.
1044///
1045/// `restore_finish` writes the unix ms of the restore, kept strictly above the
1046/// generation the root held before, so a later restore is always a higher
1047/// number even across a clock that stepped back. It is time and not a counter
1048/// carried down the restore stream on purpose: the stream (and the pumps that
1049/// relay it) stay exactly as they are, and two restores of the same log are
1050/// still ordered. A primary names it in every `Mirror.LogHead` answer, inside
1051/// its signature.
1052///
1053/// What it fences: the TWIN. An old main that comes back after its replacement
1054/// was restored carries the lower generation, and the twin refuses it however
1055/// good its id, key and numbers are. And the OLD MAIN, from the first poll a twin
1056/// sends it: the twin relays the restored main's signed answer, and the old
1057/// main fences itself ([`LOG_FENCED`]). Until a twin reaches it, an old main
1058/// cut off from every twin still takes writes — knowing sooner would take a
1059/// lease or a quorum, which is a different design.
1060pub const LOG_GENERATION: &str = "txlog-generation";
1061
1062/// `<data-root>/twin-primary-generation`: the highest [`LOG_GENERATION`] this
1063/// twin has followed. Absent = 0. It only ever moves up.
1064pub const PINNED_GENERATION: &str = "twin-primary-generation";
1065
1066/// One generation file. NotFound = 0; anything else that cannot be read, or is
1067/// not a number, is an error by name — a corrupt fence must never read as "no
1068/// fence", which is exactly the hole it is here to close.
1069fn read_generation(root: &Path, name: &str) -> anyhow::Result<u64> {
1070    let path = root.join(name);
1071    match std::fs::read_to_string(&path) {
1072        Ok(raw) => raw
1073            .trim()
1074            .parse::<u64>()
1075            .map_err(|e| anyhow::anyhow!("{} holds {:?}, which is not a generation: {e}", path.display(), raw.trim())),
1076        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(0),
1077        Err(e) => Err(anyhow::Error::from(e).context(format!("reading {}", path.display()))),
1078    }
1079}
1080
1081fn forget_generation(root: &Path, name: &str) -> anyhow::Result<()> {
1082    match std::fs::remove_file(root.join(name)) {
1083        Ok(()) => crate::durable::sync_dir(root),
1084        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
1085        Err(e) => Err(anyhow::Error::from(e).context(format!("removing {}", root.join(name).display()))),
1086    }
1087}
1088
1089/// This box's [`LOG_GENERATION`].
1090pub fn log_generation(root: &Path) -> anyhow::Result<u64> {
1091    read_generation(root, LOG_GENERATION)
1092}
1093
1094/// The generation this twin has pinned ([`PINNED_GENERATION`]).
1095pub fn pinned_generation(root: &Path) -> anyhow::Result<u64> {
1096    read_generation(root, PINNED_GENERATION)
1097}
1098
1099/// **Stamp a restore**: a generation strictly above the one this root held.
1100fn stamp_generation(root: &Path) -> anyhow::Result<u64> {
1101    let before = log_generation(root)?;
1102    let generation = u64::try_from(now_ms()).unwrap_or(0).max(before.saturating_add(1));
1103    crate::durable::write_atomic_unframed(root, LOG_GENERATION, format!("{generation}\n").as_bytes())?;
1104    Ok(generation)
1105}
1106
1107/// Pin a primary's generation. Never moves the pin down.
1108fn pin_generation(root: &Path, generation: u64) -> anyhow::Result<()> {
1109    if generation <= pinned_generation(root)? {
1110        return Ok(());
1111    }
1112    crate::durable::write_atomic_unframed(root, PINNED_GENERATION, format!("{generation}\n").as_bytes())
1113}
1114
1115/// `<data-root>/txlog-fenced`: **this box is an OLD generation of its log**
1116/// (stinky row 18, the main's half, 2026-10-10).
1117///
1118/// [`LOG_GENERATION`] fences the twin off an old main come back after a
1119/// restore. This fences the old main itself: the twin relays the restored
1120/// main's signed answer ([`GENERATION_PROOF`]) on every poll, and a box that
1121/// finds in it a NEWER generation of its OWN log, signed by its OWN host key, is
1122/// that old main — nobody else holds the key, and the restore replayed it onto
1123/// the new main. It writes this file (durably, before anything else), makes its
1124/// log follower-only, and from then on originates nothing and serves no
1125/// `Mirror.LogFetch`; [`ServerStores::open`] reads the file at every start, so a
1126/// restart does not undo it. It still answers `Mirror.LogHead`, with its own
1127/// (older) generation, so a twin that reaches it says why by name.
1128///
1129/// Only an operator removes it, deliberately: the box's writes since the
1130/// restore belong to no history, and the way back is to retire it or to re-join
1131/// it as a twin (`become_twin` forgets the fence along with the rest of its
1132/// own state).
1133pub const LOG_FENCED: &str = "txlog-fenced";
1134
1135/// `<data-root>/twin-generation-proof`: the newest generation of its log this
1136/// twin has followed, **as its main signed it** — the fields of one verified
1137/// `Mirror.LogHead` answer that its `gunnar-log-head-v2` signature covers, and
1138/// the key that made it, one per line. Relayed as
1139/// `LogHeadRequest.generation_proof` on every poll; see [`LOG_FENCED`].
1140pub const GENERATION_PROOF: &str = "twin-generation-proof";
1141
1142/// **Is this box fenced off as an old generation of its log?** ([`LOG_FENCED`])
1143/// A marker that cannot be looked at is a fence: the hole it closes is worse
1144/// than a box refusing writes it could have taken.
1145pub fn is_fenced(root: &Path) -> bool {
1146    match std::fs::symlink_metadata(root.join(LOG_FENCED)) {
1147        Ok(_) => true,
1148        Err(e) => e.kind() != std::io::ErrorKind::NotFound,
1149    }
1150}
1151
1152/// What [`LOG_FENCED`] says, for the alarm and the refusals.
1153pub fn fenced_why(root: &Path) -> Option<String> {
1154    is_fenced(root).then(|| {
1155        let said = std::fs::read_to_string(root.join(LOG_FENCED)).unwrap_or_default();
1156        let said = said.trim();
1157        if said.is_empty() {
1158            format!("this box is fenced off as an OLD generation of its log ({LOG_FENCED} is on its data root)")
1159        } else {
1160            format!("{said} ({LOG_FENCED} is on its data root)")
1161        }
1162    })
1163}
1164
1165/// The proof this twin relays on every poll ([`GENERATION_PROOF`]). `None` =
1166/// it has never followed a restored main. A file that is there and does not
1167/// read as one is an error by name.
1168pub fn generation_proof(root: &Path) -> anyhow::Result<Option<gunnar_proto::GenerationProof>> {
1169    let path = root.join(GENERATION_PROOF);
1170    let raw = match std::fs::read_to_string(&path) {
1171        Ok(raw) => raw,
1172        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1173        Err(e) => return Err(anyhow::Error::from(e).context(format!("reading {}", path.display()))),
1174    };
1175    let lines: Vec<&str> = raw.lines().collect();
1176    let [answer_key, answer_sig, nonce, log_id, first_seq, head_seq, generation] = lines[..] else {
1177        anyhow::bail!("{} holds {} lines, not the 7 of one signed answer", path.display(), lines.len());
1178    };
1179    let number = |what: &str, v: &str| v.trim().parse::<u64>().map_err(|e| anyhow::anyhow!("{}: {what} {v:?} is not a number: {e}", path.display()));
1180    Ok(Some(gunnar_proto::GenerationProof {
1181        answer_key: answer_key.to_owned(),
1182        answer_sig: answer_sig.to_owned(),
1183        nonce: nonce.to_owned(),
1184        log_id: log_id.to_owned(),
1185        first_seq: number("first_seq", first_seq)?,
1186        head_seq: number("head_seq", head_seq)?,
1187        generation: number("generation", generation)?,
1188    }))
1189}
1190
1191/// **Keep a verified answer as this twin's proof** ([`GENERATION_PROOF`]) — called
1192/// only after the answer passed [`GrpcSource`]'s signature check against `nonce`.
1193/// Kept when it is signed, names a generation, and that generation is above the
1194/// proof already kept; otherwise nothing changes.
1195pub fn remember_generation_proof(root: &Path, nonce: &str, head: &gunnar_proto::LogHeadResponse) -> anyhow::Result<()> {
1196    if head.generation == 0 || head.answer_sig.trim().is_empty() || head.answer_key.trim().is_empty() {
1197        return Ok(());
1198    }
1199    if generation_proof(root).ok().flatten().is_some_and(|kept| kept.generation >= head.generation) {
1200        return Ok(());
1201    }
1202    let one_line = |v: &str| !v.contains('\n') && !v.contains('\r');
1203    anyhow::ensure!(
1204        [head.answer_key.as_str(), head.answer_sig.as_str(), nonce, head.log_id.as_str()].into_iter().all(one_line),
1205        "a signed answer whose key, signature, nonce or log id spans lines is not kept as a proof"
1206    );
1207    let text = format!(
1208        "{}\n{}\n{nonce}\n{}\n{}\n{}\n{}\n",
1209        head.answer_key.trim(),
1210        head.answer_sig.trim(),
1211        head.log_id,
1212        head.first_seq,
1213        head.head_seq,
1214        head.generation
1215    );
1216    crate::durable::write_atomic_unframed(root, GENERATION_PROOF, text.as_bytes())
1217}
1218
1219/// **Fence this box when `proof` shows a newer generation of its own log**
1220/// ([`LOG_FENCED`]). `Some(generation)` = fenced by this call; `None` = not a
1221/// proof about this box (its own or an older generation, another log, another
1222/// key, a signature that does not verify), or nothing to fence (a twin, a box
1223/// mid-restore, a box already fenced). Only a failure to WRITE the fence is an
1224/// error, and the log is made follower-only even then.
1225pub fn fence_by_proof(stores: &ServerStores, proof: &gunnar_proto::GenerationProof) -> anyhow::Result<Option<u64>> {
1226    let root = stores.root();
1227    if is_twin_root(root) || stores.is_twin() || is_restoring(root) || is_fenced(root) {
1228        return Ok(None);
1229    }
1230    let own = log_generation(root)?;
1231    if proof.generation <= own {
1232        return Ok(None);
1233    }
1234    let Some(id) = log_id(root) else { return Ok(None) };
1235    if proof.log_id.trim() != id {
1236        tracing::debug!(target: gunnar_core::observe::TARGET_TWIN, theirs = %proof.log_id, mine = %id, "a twin relayed a generation of another log; not this box's business");
1237        return Ok(None);
1238    }
1239    let Some(key) = answering_key(stores) else { return Ok(None) };
1240    let Ok(line) = key.public_key().to_openssh() else { return Ok(None) };
1241    if proof.answer_key.trim() != line.trim()
1242        || !gunnar_grpc::auth::verify_twin_answer_at(&line, &proof.answer_sig, &proof.nonce, &proof.log_id, proof.first_seq, proof.head_seq, proof.generation)
1243    {
1244        tracing::warn!(target: gunnar_core::observe::TARGET_TWIN, generation = proof.generation, "a twin relayed a generation of this log that this box's host key did not sign: ignored");
1245        return Ok(None);
1246    }
1247    let why = format!(
1248        "this box is generation {own} of log {id}; generation {} of the same log exists, restored at {} (unix ms) and signed by this \
1249         box's own host key. This is the OLD main come back after its replacement was restored: it originates nothing and serves no \
1250         log from here, because its writes since that restore belong to no history. Retire it, or re-join it as a twin.",
1251        proof.generation, proof.generation
1252    );
1253    stores.txlog().set_follower(true);
1254    crate::durable::write_atomic_unframed(root, LOG_FENCED, format!("{why}\n").as_bytes()).context("writing the fence")?;
1255    tracing::error!(target: gunnar_core::observe::TARGET_TWIN, event = "twin.fenced_by_generation", own, newer = proof.generation, "{why}");
1256    Ok(Some(proof.generation))
1257}
1258
1259fn forget_fence(root: &Path) -> anyhow::Result<()> {
1260    for name in [LOG_FENCED, GENERATION_PROOF] {
1261        match std::fs::remove_file(root.join(name)) {
1262            Ok(()) => crate::durable::sync_dir(root)?,
1263            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
1264            Err(e) => return Err(anyhow::Error::from(e).context(format!("removing {}", root.join(name).display()))),
1265        }
1266    }
1267    Ok(())
1268}
1269
1270/// **The key this box signs its answers to a twin with** — its SSH host key,
1271/// read once per process out of the sealed store.
1272///
1273/// The host key and not a new one, deliberately. It is the key the box already
1274/// proves itself with on :2222, so an operator who has checked one has checked
1275/// the other; and it is REPLAYED BY A RESTORE, which is exactly the behaviour
1276/// this pairing wants — a main restored from the twin keeps signing with the
1277/// same key, so the twin keeps following it without being re-joined, matching
1278/// what [`LOG_ID`] does for the history. A fresh box at the same address mints
1279/// its own and is refused. (That same survive-a-restore property is why the
1280/// host key could NOT be the log identity: there it would have had to
1281/// distinguish things a restore is supposed to make identical.)
1282///
1283/// Cached because a twin polls every two seconds and a sealed-store read per
1284/// poll is a cost for nothing: a host key does not change under a running
1285/// process.
1286fn answering_key(stores: &ServerStores) -> Option<gunnar_core::ssh_key::PrivateKey> {
1287    static KEY: OnceLock<Option<gunnar_core::ssh_key::PrivateKey>> = OnceLock::new();
1288    KEY.get_or_init(|| {
1289        // A twin reads its (replicated) host key through the unframed store; a
1290        // primary through its own. Both are "the key this box answers as".
1291        let secrets = if stores.is_twin() { stores.secrets_unframed() } else { stores.secrets() };
1292        match secrets.and_then(|s| gunnar_core::ensure_host_key(&s).map_err(anyhow::Error::from)) {
1293            Ok(host) => Some(host.private_key().clone()),
1294            Err(err) => {
1295                tracing::warn!(target: gunnar_core::observe::TARGET_TWIN, %err, "this box cannot read its host key, so it cannot sign its answers to a twin; a twin that has pinned a key will refuse them");
1296                None
1297            }
1298        }
1299    })
1300    .clone()
1301}
1302
1303/// The `authorized_keys` line and one signature over this answer, for
1304/// `Mirror.LogHead`. `("", "")` when the caller sent no nonce (nothing to be
1305/// fresh about) or this box cannot read its key.
1306pub fn sign_head_answer(
1307    stores: &ServerStores,
1308    nonce: &str,
1309    log_id: &str,
1310    first_seq: u64,
1311    head_seq: u64,
1312    generation: u64,
1313) -> (String, String) {
1314    if nonce.trim().is_empty() {
1315        return (String::new(), String::new());
1316    }
1317    let Some(key) = answering_key(stores) else {
1318        return (String::new(), String::new());
1319    };
1320    let line = match key.public_key().to_openssh() {
1321        Ok(line) => line,
1322        Err(err) => {
1323            tracing::warn!(target: gunnar_core::observe::TARGET_TWIN, %err, "this box's host key does not re-encode");
1324            return (String::new(), String::new());
1325        }
1326    };
1327    match gunnar_grpc::auth::sign_twin_answer_at(&key, nonce, log_id, first_seq, head_seq, generation) {
1328        Ok(sig) => (line, sig),
1329        Err(err) => {
1330            tracing::warn!(target: gunnar_core::observe::TARGET_TWIN, %err, "signing this answer to a twin");
1331            (String::new(), String::new())
1332        }
1333    }
1334}
1335
1336/// This data root's log identity, when it has one. `None` = never minted (a box
1337/// older than this change, or a twin that has not yet heard from its primary).
1338pub fn log_id(root: &Path) -> Option<String> {
1339    let raw = std::fs::read_to_string(root.join(LOG_ID)).ok()?;
1340    let id = raw.trim().to_owned();
1341    if id.is_empty() {
1342        None
1343    } else {
1344        Some(id)
1345    }
1346}
1347
1348/// **Mint this log's identity, once.** Returns the existing one when there is
1349/// one, so it is safe to call on every open. 128 bits of CSPRNG, hex.
1350pub fn mint_log_id(root: &Path) -> anyhow::Result<String> {
1351    if let Some(id) = log_id(root) {
1352        return Ok(id);
1353    }
1354    use gunnar_core::ssh_key::rand_core::Rng as _;
1355    let mut bytes = [0u8; 16];
1356    gunnar_core::os_rng().fill_bytes(&mut bytes);
1357    let id: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
1358    crate::durable::write_atomic_unframed(root, LOG_ID, format!("{id}\n").as_bytes())?;
1359    tracing::info!(target: gunnar_core::observe::TARGET_TWIN, log_id = %id, "this box minted its log's identity: from here a twin can tell this history from any other");
1360    Ok(id)
1361}
1362
1363/// **Write the identity of the log this root now holds** — a twin learning its
1364/// primary's on the first poll, or a fresh box taking one off a restore stream.
1365/// Refuses to overwrite a DIFFERENT id: that is the splice this whole file
1366/// exists to prevent, and it must never happen quietly.
1367pub fn pin_log_id(root: &Path, id: &str) -> anyhow::Result<()> {
1368    let id = id.trim();
1369    anyhow::ensure!(!id.is_empty(), "a log identity cannot be empty");
1370    if let Some(mine) = log_id(root) {
1371        anyhow::ensure!(
1372            mine == id,
1373            "this data root already holds log {mine} and was asked to hold log {id}; two histories do not become \
1374             one by overwriting a file"
1375        );
1376        return Ok(());
1377    }
1378    crate::durable::write_atomic_unframed(root, LOG_ID, format!("{id}\n").as_bytes())
1379}
1380
1381/// `<data-root>/twin-primary.pub`: **the key the box this twin follows answers
1382/// with** — one OpenSSH `authorized_keys` line, pinned on the first poll and
1383/// required on every one after (2026-09-21, lane T4).
1384///
1385/// # What was there before
1386///
1387/// Nothing. `GrpcSource::connect` dialled `client_signer::plain_channel` — a
1388/// bare `http://` endpoint, no TLS — and the twin verified NOTHING about who
1389/// answered. The signed credential on every call proved the TWIN to the
1390/// primary; not one byte ever went the other way. The trust was one-directional,
1391/// so whatever won the address won the twin: a DNS answer, a recycled lease, a
1392/// cable in the wrong port, a box rebuilt at the same IP.
1393///
1394/// # Trust on first use, and why there is no earlier moment
1395///
1396/// `Bootstrap.BecomeTwin` is answered by the TWIN. The primary is not in that
1397/// conversation at all — it does not learn it has a twin until that twin's key
1398/// is enrolled on it by hand, afterwards — so at join there is nothing to pin
1399/// except an address the operator typed. The first poll is the first time this
1400/// box has ever heard from the other one. That is exactly the position `ssh`
1401/// is in the first time it connects to a host, and `known_hosts` is the answer
1402/// there for the same reason it is the answer here: pinning at first sight
1403/// converts an open-ended exposure into a single one, and every later change is
1404/// loud.
1405///
1406/// # Its own file, not a field of `twin.json`
1407///
1408/// `twin.json` is what the operator wrote at join: an address and a time.
1409/// This is what the twin MEASURED afterwards. Keeping them apart means a hand
1410/// edit of the join record cannot silently re-pin the key, and it matches
1411/// [`LOG_ID`], which is a measured fact about the log in the same way. (It also
1412/// keeps a struct that four other test files construct by literal from growing
1413/// a field, one of which this lane must not touch.)
1414pub const PRIMARY_KEY: &str = "twin-primary.pub";
1415
1416/// The pinned `authorized_keys` line, when this twin has one.
1417pub fn pinned_primary_key(root: &Path) -> Option<String> {
1418    let raw = std::fs::read_to_string(root.join(PRIMARY_KEY)).ok()?;
1419    let line = raw.trim().to_owned();
1420    if line.is_empty() {
1421        None
1422    } else {
1423        Some(line)
1424    }
1425}
1426
1427/// **Pin the key the primary answers with**, on the first poll. Refuses to
1428/// replace a different one: that is the substitution this exists to catch.
1429pub fn pin_primary_key(root: &Path, line: &str) -> anyhow::Result<()> {
1430    let line = line.trim();
1431    anyhow::ensure!(!line.is_empty(), "a primary's key cannot be empty");
1432    if let Some(mine) = pinned_primary_key(root) {
1433        anyhow::ensure!(mine == line, "this twin already follows a box that answers with {mine}; it was offered {line}");
1434        return Ok(());
1435    }
1436    crate::durable::write_atomic_unframed(root, PRIMARY_KEY, format!("{line}\n").as_bytes())?;
1437    tracing::warn!(target: gunnar_core::observe::TARGET_TWIN, key = %line, "this twin has pinned the key its primary answers with; every later answer must be signed by it");
1438    Ok(())
1439}
1440
1441/// **Give up this box's own log identity**, with its numbers and its secrets: a
1442/// fresh box becoming a twin, or taking another box's log in a restore. Without
1443/// this it would compare the identity it minted at its own first open against
1444/// the one it is about to start following and refuse itself at the first poll.
1445/// **Drop this box's own control-plane audit trail** before it takes another
1446/// box's log (lane T11).
1447///
1448/// Not "best effort": a leftover line would make the restored chain fail
1449/// `AuditChain::open`, and a box that cannot open its trail serves without one.
1450/// The restore would then have destroyed the audit history it exists to carry —
1451/// the exact D6 failure, one layer in.
1452///
1453/// It is the replicated path that is removed, and only that: `prepare_replay`
1454/// runs before the `txlog-restoring` marker exists, so `audit_path()` still
1455/// names `log/audit-chain.jsonl`, which is precisely the file about to be
1456/// rebuilt from the stream.
1457fn forget_own_audit_trail(stores: &ServerStores) -> anyhow::Result<()> {
1458    let path = stores.audit_path();
1459    match std::fs::remove_file(&path) {
1460        Ok(()) => {
1461            tracing::info!(
1462                target: gunnar_core::observe::TARGET_TWIN,
1463                path = %path.display(),
1464                "dropped this fresh box's own audit trail: the one being restored starts at its own seq 0"
1465            );
1466            Ok(())
1467        }
1468        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
1469        Err(e) => Err(e).with_context(|| format!("dropping this box's own audit trail {}", path.display())),
1470    }
1471}
1472
1473/// **Check the control-plane audit trail against this box's own log, on EVERY start**, and
1474/// rebuild what the file lost — before the gate opens it (lane T11, 2026-09-21; every start
1475/// since T17, owner's decision the same day).
1476///
1477/// **What this is NOT: the fix for T3's KVM `T1v`/`T2v` failure** (medium `ff2c7c9`). This
1478/// function was first written on the belief that the restored box's copy of the trail, on the
1479/// boot disk's P3, was lost in the hard stop between the replay and the restart. That diagnosis
1480/// was WRONG, and it is corrected here rather than left standing. T3 found the real cause:
1481/// `restore_finish` unlinked `txlog-restoring` with no directory fsync, the hard stop dropped
1482/// the unlink, and the box reopened STILL RESTORING — a follower that re-sealed nothing,
1483/// originated nothing, and answered `Admin.AuditTrail` from its own per-box trail. The log
1484/// says so: the twin polled the restored box every 2 s and saw its head stay at 262, where a
1485/// box that had re-sealed and served 16 audited calls would have framed far past it. And the
1486/// guard that decides whether THIS function runs is "not restoring", so it never ran. T3 fixed
1487/// the marker (`durable::remove_durable`, alarm `twin.restoring_at_open`).
1488///
1489/// **The window on a running primary** (T11 named it, T17 closes it): `AuditChain::append`
1490/// syncs the FRAME, then writes the line to the file. A crash between the two leaves the log one
1491/// entry ahead of the file; XFS log recovery on P3 after a hard stop can also bring the file
1492/// back shorter, or with a torn last line. The gate would then append to the SHORTER chain — a
1493/// second entry at an existing `seq`, a fork of the chain the twin
1494/// holds, whose copy stops verifying from that entry on. So the check is not a restore's: it
1495/// runs on every open of a box that originates its log (not a twin, not mid-restore), in
1496/// `ServerStores::open`, before `serve` opens the chain for the gate. A box that reopens still
1497/// RESTORING (T3: the marker's unlink lost, fixed by `durable::remove_durable` on
1498/// `agent/twin-restore-proof`) originates nothing and writes its per-box trail; it is not
1499/// checked here, and T3's alarm `twin.restoring_at_open` is what names it.
1500///
1501/// The trail is a materialised view of the log's frames for [`gunnar_core::txlog::AUDIT_REL`]
1502/// — `Append` lines, and a `File` frame where a snapshot carried the whole document (which
1503/// supersedes every earlier line). What the file lacks past its own tip is APPENDED, linked by
1504/// `prev`, checked in memory to verify, written atomically (temporary, fsync, rename, fsync of
1505/// the directory), and checked to open.
1506///
1507/// **It never discards a line the log does not hold.** The log may carry only a WINDOW of the
1508/// trail (the operator's retention door, [`Log::retain_from`]; a box upgraded from before the
1509/// trail was framed), so "the log's view" is never written over the file. A copy LONGER than
1510/// the log is left as found, and so is one whose tip the log contradicts, or one the log's
1511/// window cannot be joined to — each said out loud. The only bytes ever dropped are a torn
1512/// last line that does not parse as an entry.
1513///
1514/// Best effort in the sense the gate is: a failure is one `error!` naming the path and the box
1515/// still serves. But it is never silent — the one thing D6 was.
1516///
1517/// Cost: one header-and-meta pass over the log ([`Log::scan_meta`], one open per segment), then
1518/// blobs read newest-first only until the file's tip is met — ONE blob when the file is whole.
1519pub fn rematerialise_audit_trail(stores: &ServerStores, just_restored: bool) {
1520    let path = stores.audit_path();
1521    let when = if just_restored { "the first start after a restore" } else { "this start" };
1522    let t0 = std::time::Instant::now();
1523    let outcome = rebuild_audit_trail(stores.txlog(), &path);
1524    let micros = t0.elapsed().as_micros() as u64;
1525    match outcome {
1526        Ok(Rebuilt::Nothing) => {}
1527        Ok(Rebuilt::Intact(n)) => tracing::info!(
1528            target: gunnar_core::observe::TARGET_TWIN,
1529            path = %path.display(),
1530            entries = n,
1531            micros,
1532            "the audit trail is whole at {when}: its tip is the log's newest audit entry"
1533        ),
1534        Ok(Rebuilt::Rebuilt { had, now }) => tracing::warn!(
1535            target: gunnar_core::observe::TARGET_TWIN,
1536            path = %path.display(),
1537            had,
1538            now,
1539            micros,
1540            "the audit trail held {had} of the {now} entries its log carries at {when} — the \
1541             off-stripe copy lost its tail (a hard stop; XFS recovery on the boot disk's P3). \
1542             REBUILT from the log, which did not: the trail is whole again and the next entry \
1543             continues the chain the twin holds"
1544        ),
1545        Ok(Rebuilt::Longer { file, log }) => tracing::warn!(
1546            target: gunnar_core::observe::TARGET_TWIN,
1547            path = %path.display(),
1548            file,
1549            log,
1550            "the audit trail holds MORE entries ({file}) than its log carries ({log}) at {when}; \
1551             left exactly as found — nothing is deleted from an audit trail"
1552        ),
1553        Ok(Rebuilt::Diverged { why }) => tracing::error!(
1554            target: gunnar_core::observe::TARGET_TWIN,
1555            path = %path.display(),
1556            why = %why,
1557            "the audit trail and its log DISAGREE at {when}; left exactly as found — nothing is \
1558             guessed into an audit trail. The twin's copy is the one to compare against"
1559        ),
1560        Err(err) => tracing::error!(
1561            target: gunnar_core::observe::TARGET_TWIN,
1562            path = %path.display(),
1563            error = %format!("{err:#}"),
1564            "the audit trail could not be checked against its log at {when}; if the off-stripe \
1565             copy lost its tail, the gate appends to a shorter chain than the twin holds"
1566        ),
1567    }
1568}
1569
1570/// What [`rebuild_audit_trail`] did.
1571#[derive(Debug, PartialEq, Eq)]
1572pub enum Rebuilt {
1573    /// The log carries no audit frame at all (a primary from before lane T11, or a box that has
1574    /// made no audited decision).
1575    Nothing,
1576    /// The file's tip is the log's newest audit entry: this many entries in the file.
1577    Intact(usize),
1578    /// The file held `had` whole entries; `now - had` were appended from the log.
1579    Rebuilt {
1580        /// Whole lines in the file before.
1581        had: usize,
1582        /// Lines in the file now.
1583        now: usize,
1584    },
1585    /// The file's tip is past the log's newest audit entry; left alone.
1586    Longer {
1587        /// Lines in the file.
1588        file: usize,
1589        /// Audit entries the log carries.
1590        log: usize,
1591    },
1592    /// The file and the log cannot be joined without guessing; left alone.
1593    Diverged {
1594        /// Which fact disagreed.
1595        why: String,
1596    },
1597}
1598
1599/// One audit line of the log's view: where it came from, for a lazy read newest-first.
1600enum ViewLine {
1601    /// An `Append` frame's blob.
1602    Frame(u64),
1603    /// Line `i` of the snapshot's document.
1604    Snap(usize),
1605}
1606
1607/// The work of [`rematerialise_audit_trail`], over a log and a path, so it can be proved without
1608/// a whole box.
1609pub fn rebuild_audit_trail(log: &Log, path: &Path) -> anyhow::Result<Rebuilt> {
1610    // 1. Which frames carry the trail: the newest snapshot of it, then every Append after it.
1611    let (append, file) = (Kind::Append as u16, Kind::File as u16);
1612    // The exact bytes this code writes for an audit line's meta: compared, not parsed — parsing the JSON
1613    // of every audit frame's meta made 90 000 more audit frames cost 263 ms more (MEASURED,
1614    // `audit_check_boot_cost`). Any other meta naming the trail is parsed in full.
1615    let audit_meta = Meta::Append { log: txlog::AUDIT_REL.into() }.to_bytes();
1616    let names_trail = |meta: &[u8]| meta.windows(txlog::AUDIT_REL.len()).any(|w| w == txlog::AUDIT_REL.as_bytes());
1617    let mut frames: Vec<u64> = Vec::new();
1618    let mut snapshot: Option<(u64, bool)> = None;
1619    let mut bad_meta: Option<(u64, String)> = None;
1620    log.scan_meta(|seq, kind, meta| {
1621        if kind == append && meta == audit_meta.as_slice() {
1622            frames.push(seq);
1623            return;
1624        }
1625        if (kind != append && kind != file) || !names_trail(meta) {
1626            return;
1627        }
1628        match Meta::from_bytes(meta) {
1629            Ok(Meta::Append { log: rel }) if kind == append && rel == txlog::AUDIT_REL => frames.push(seq),
1630            Ok(Meta::File { path: rel, deleted }) if kind == file && rel == txlog::AUDIT_REL => {
1631                frames.clear();
1632                snapshot = Some((seq, deleted));
1633            }
1634            Ok(_) => {}
1635            Err(e) => bad_meta = bad_meta.take().or(Some((seq, e.to_string()))),
1636        }
1637    })
1638    .context("scanning the log's frame headers")?;
1639    if let Some((seq, e)) = bad_meta {
1640        return Err(anyhow!("frame {seq}'s meta does not parse: {e}"));
1641    }
1642    if frames.is_empty() && snapshot.is_none() {
1643        return Ok(Rebuilt::Nothing);
1644    }
1645    let snap_lines: Vec<Vec<u8>> = match snapshot {
1646        Some((seq, false)) => split_lines(&log.read(seq)?.blob).0,
1647        _ => Vec::new(),
1648    };
1649    let mut view: Vec<ViewLine> = (0..snap_lines.len()).map(ViewLine::Snap).collect();
1650    view.extend(frames.iter().map(|s| ViewLine::Frame(*s)));
1651
1652    // 2. The file: whole lines, and a torn last one (no newline, and not an entry) set aside.
1653    let bytes = match std::fs::read(path) {
1654        Ok(b) => b,
1655        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
1656        Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
1657    };
1658    let (mut lines, rest) = split_lines(&bytes);
1659    let mut torn = false;
1660    if let Some(rest) = rest {
1661        if serde_json::from_slice::<gunnar_core::AuditEntry>(&rest).is_ok() {
1662            lines.push(rest);
1663        } else {
1664            torn = true;
1665        }
1666    }
1667    let had = lines.len();
1668    let tip: Option<gunnar_core::AuditEntry> = match lines.last() {
1669        Some(l) => Some(serde_json::from_slice(l).with_context(|| format!("the last whole line of {} is not an audit entry", path.display()))?),
1670        None => None,
1671    };
1672
1673    // 3. The log's view, newest first, until the file's tip is met: ONE blob when it is whole.
1674    let line_at = |v: &ViewLine| -> anyhow::Result<Vec<u8>> {
1675        Ok(match v {
1676            ViewLine::Frame(seq) => log.read(*seq)?.blob,
1677            ViewLine::Snap(i) => snap_lines[*i].clone(),
1678        })
1679    };
1680    let mut missing: Vec<Vec<u8>> = Vec::new();
1681    let mut met = false;
1682    for (k, v) in view.iter().enumerate().rev() {
1683        let line = line_at(v)?;
1684        let entry: gunnar_core::AuditEntry = serde_json::from_slice(&line).context("an audit line in the log is not an entry")?;
1685        match &tip {
1686            Some(t) if entry.seq == t.seq => {
1687                if entry.hash != t.hash {
1688                    return Ok(Rebuilt::Diverged { why: format!("seq {}: the file's hash is {}, the log's {}", t.seq, t.hash, entry.hash) });
1689                }
1690                met = true;
1691                break;
1692            }
1693            Some(t) if entry.seq < t.seq => {
1694                if k + 1 == view.len() {
1695                    return Ok(Rebuilt::Longer { file: lines.len(), log: view.len() });
1696                }
1697                return Ok(Rebuilt::Diverged { why: format!("the log skips from seq {} to a newer entry, past the file's tip {}", entry.seq, t.seq) });
1698            }
1699            _ => missing.push(line),
1700        }
1701    }
1702    if missing.is_empty() && !torn {
1703        return Ok(Rebuilt::Intact(had));
1704    }
1705    missing.reverse();
1706    if !met {
1707        // The log's window starts after the file's tip, or the file is empty: only a window that
1708        // begins exactly one past the tip (or at seq 0 for an empty file) joins without a gap.
1709        let first: gunnar_core::AuditEntry = serde_json::from_slice(&missing[0])?;
1710        let want = tip.as_ref().map_or(0, |t| t.seq + 1);
1711        if first.seq != want {
1712            return Ok(Rebuilt::Diverged { why: format!("the log's audit entries begin at seq {}, the file needs {want} next — a retention hole nothing can bridge", first.seq) });
1713        }
1714    }
1715
1716    // 4. The joined trail — the exact bytes about to be written — must verify in memory before
1717    //    one of them lands: a file that was already broken is not made to look whole by an
1718    //    append, and nothing is written that the gate would then refuse to open.
1719    let mut out = Vec::with_capacity(bytes.len() + missing.iter().map(|l| l.len() + 1).sum::<usize>());
1720    for l in lines.iter().chain(missing.iter()) {
1721        out.extend_from_slice(l);
1722        out.push(b'\n');
1723    }
1724    let entries: Vec<gunnar_core::AuditEntry> = out
1725        .split(|b| *b == b'\n')
1726        .filter(|l| !l.is_empty())
1727        .map(serde_json::from_slice)
1728        .collect::<Result<_, _>>()
1729        .context("parsing the joined trail")?;
1730    if let Err(e) = gunnar_core::audit::verify_entries(&entries) {
1731        return Ok(Rebuilt::Diverged { why: format!("the file joined to the log's tail does not verify: {e}") });
1732    }
1733    let dir = path.parent().ok_or_else(|| anyhow!("{} has no directory", path.display()))?;
1734    std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
1735    let name = path.file_name().ok_or_else(|| anyhow!("{} names no file", path.display()))?.to_string_lossy().into_owned();
1736    // Unframed: these lines ARE frames already — this box's log holds every one of them.
1737    crate::durable::write_atomic_unframed(dir, &name, &out).with_context(|| format!("rewriting {}", path.display()))?;
1738    gunnar_core::AuditChain::open(path).map_err(|e| anyhow!("the rebuilt trail does not open: {e}"))?;
1739    Ok(Rebuilt::Rebuilt { had, now: had + missing.len() })
1740}
1741
1742/// Whole `\n`-terminated non-empty lines, and what follows the last newline (a torn line), if any.
1743fn split_lines(bytes: &[u8]) -> (Vec<Vec<u8>>, Option<Vec<u8>>) {
1744    let (whole, rest) = match bytes.iter().rposition(|b| *b == b'\n') {
1745        Some(i) => (&bytes[..i], &bytes[i + 1..]),
1746        None => (&bytes[..0], bytes),
1747    };
1748    let lines = whole.split(|b| *b == b'\n').filter(|l| !l.iter().all(u8::is_ascii_whitespace)).map(<[u8]>::to_vec).collect();
1749    let rest = (!rest.iter().all(|b| b.is_ascii_whitespace() || *b == 0)).then(|| rest.to_vec());
1750    (lines, rest)
1751}
1752
1753fn forget_log_id(root: &Path) -> anyhow::Result<()> {
1754    match std::fs::remove_file(root.join(LOG_ID)) {
1755        Ok(()) => Ok(()),
1756        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
1757        Err(e) => Err(e).with_context(|| format!("dropping this box's own {LOG_ID}")),
1758    }
1759}
1760
1761/// **Was the git listener bound in a twin's shape?** — set once by
1762/// `serve::start`, when it chose the bind (2026-09-21, lane T4).
1763static GIT_BOUND_AS_TWIN: AtomicBool = AtomicBool::new(false);
1764
1765/// `serve::start` says which shape it bound the git listener in.
1766pub fn note_git_bind_is_twin_shaped(yes: bool) {
1767    GIT_BOUND_AS_TWIN.store(yes, Ordering::SeqCst);
1768}
1769
1770/// **Does this box need a restart before its git listener is where a twin's
1771/// belongs?**
1772///
1773/// `serve::start` picks the git bind ONCE, from `stores.is_twin()` at that
1774/// instant: a twin gets `127.0.0.1:0`, everything else gets the operator's
1775/// address. A box conferred the twin role over the wire by
1776/// `Bootstrap.BecomeTwin` is past that point — and under the owner's
1777/// one-medium ruling that is the ONLY way a twin is ever made, because a twin
1778/// is installed from the same image as a main with no per-box parameter to say
1779/// which it is. MEASURED under KVM by lane T3, 2026-09-21: such a box kept
1780/// `0.0.0.0:2222` bound for the rest of its life and the
1781/// `this box is a TWIN: the git listener is bound to loopback only` warning
1782/// never printed.
1783///
1784/// The door refuses every git service on a twin from the instant the role lands
1785/// (`gunnar_ssh::Control::is_twin`), so nothing is SERVED through that socket —
1786/// but it is still bound on an address a twin's socket has no business being
1787/// on, and only a restart moves it. This is how that fact is told rather than
1788/// left for somebody to find with `ss`.
1789pub fn git_listener_needs_restart(stores: &ServerStores) -> bool {
1790    stores.is_twin() && !GIT_BOUND_AS_TWIN.load(Ordering::SeqCst)
1791}
1792
1793/// Wall clock, ms since the epoch.
1794pub fn now_ms() -> i64 {
1795    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0)
1796}
1797
1798/// **A fresh box gives up its own numbers.** Refused unless the box is
1799/// fresh: no key enrolled, the bootstrap window open, no repository. The
1800/// documents it DID write (its first-start issuer, its catalog seed — every
1801/// `ControlDoc` in its own log) are remembered in [`FRESH_DOCS`]; its log is
1802/// made follower-only and reset to empty. The first replayed frame for a
1803/// remembered document is then applied as if the document were absent — the
1804/// replay is into an empty store, and the fresh box's own value is not a
1805/// claim the primary ever made.
1806pub fn prepare_replay(stores: &ServerStores) -> anyhow::Result<()> {
1807    if !stores.auth().is_empty()? || !stores.auth().bootstrap_window_open()? {
1808        anyhow::bail!("this box has enrolled identities; only a fresh box (bootstrap window open, no key) can take another box's log");
1809    }
1810    if !stores.catalog().list(None)?.is_empty() {
1811        anyhow::bail!("this box holds repositories; only a fresh box can take another box's log");
1812    }
1813    let log = stores.txlog();
1814    let mut names = std::collections::BTreeSet::new();
1815    if log.head_seq() >= log.first_seq() {
1816        for e in log.range(log.first_seq(), log.head_seq())? {
1817            if let Ok(Meta::ControlDoc { name, .. }) = e.meta() {
1818                names.insert(name);
1819            }
1820        }
1821    }
1822    crate::durable::write_atomic_unframed(stores.root(), FRESH_DOCS, &serde_json::to_vec(&names)?)?;
1823    forget_own_secrets(stores)?;
1824    // **And its own log's NAME.** This box minted one at its first open, for a
1825    // history it is about to give up entirely. Keeping it would make the twin
1826    // refuse itself at its very first poll — its own id against the primary's —
1827    // and would make a restored box claim to be a history it only holds a copy
1828    // of. The id it ends up with is the one it is told: the primary's, on the
1829    // first poll, or the stream's, at `restore_finish`.
1830    forget_log_id(stores.root())?;
1831    // And any primary generation it pinned: the generation it ends up with is
1832    // the one `restore_finish` stamps (row 18). Its OWN `txlog-generation` stays,
1833    // so the stamp only ever moves up.
1834    forget_generation(stores.root(), PINNED_GENERATION)?;
1835    // And a fence from a life as an old main, and any proof it relayed (row 18).
1836    forget_fence(stores.root())?;
1837    // **And its own AUDIT TRAIL** (lane T11, 2026-09-21). The trail is a
1838    // replicated, hash-chained document now, so the frames arriving from the
1839    // twin are `Append`s of the DEAD primary's chain from its seq 0. A fresh
1840    // box that had written even one line of its own would take them on the end
1841    // of it, and the result would not verify: entry 0's `prev` is GENESIS, and
1842    // here it would follow somebody else's tip. The same reasoning as
1843    // `forget_own_secrets` and `forget_log_id` above — this box is giving up
1844    // its own short history entirely, not merging it with the one it is being
1845    // handed.
1846    forget_own_audit_trail(stores)?;
1847    log.set_follower(true);
1848    log.reset(u64::MAX)?;
1849    Ok(())
1850}
1851
1852/// **A fresh box gives up its own SECRETS with its own numbers** (2026-09-17, lane
1853/// TWIN-COMPLETENESS).
1854///
1855/// Every secret a fresh box holds is sealed under the KEK it minted at first open, and the
1856/// replay's `Kek` frame — seq 1 of every primary's log — overwrites that key on disk before any
1857/// `Secret` frame lands. A row the replay then OVERWRITES is fine; a row it has no frame for is
1858/// left sealed under a key that no longer exists, and [`reseal_after_restore`] meets it with
1859/// neither key able to open it.
1860///
1861/// MEASURED: a fresh box holding one secret of its own refused to open after an otherwise
1862/// successful restore, every time, for ever. [`reseal_after_restore`] now drops such a row
1863/// loudly rather than bricking the box; this is the other half — the row never reaches it,
1864/// because a box that gives up its numbers and its documents has no business keeping the keys
1865/// it sealed under a KEK it is about to lose. The box is FRESH by the checks above (bootstrap
1866/// window open, no enrolled identity, no repository), so nothing here is anybody's data: a host
1867/// key it minted at first boot, an issuer it minted at first start. A twin re-mints what it
1868/// needs through its unlogged store at the next open, and the primary's replayed value
1869/// overwrites that in turn.
1870fn forget_own_secrets(stores: &ServerStores) -> anyhow::Result<()> {
1871    use gunnar_store::SecretStore as _;
1872    let rows = stores.control_db().export().context("reading this box's own secrets before it gives up its numbers")?.secrets;
1873    let mut gone = Vec::new();
1874    for (key, value) in &rows {
1875        if value.is_none() {
1876            continue;
1877        }
1878        stores.raw_secrets().delete(key).with_context(|| format!("dropping this box's own secret {key}"))?;
1879        gone.push(key.clone());
1880    }
1881    if !gone.is_empty() {
1882        tracing::warn!(
1883            target: gunnar_core::observe::TARGET_TWIN,
1884            secrets = ?gone,
1885            "this box gave up the secrets it minted for itself along with its own log numbers: they are sealed under a KEK the replay is about to replace, and what the log carries is the only identity this box keeps"
1886        );
1887    }
1888    Ok(())
1889}
1890
1891/// The fresh-box rule, one document: `true` (and forgotten) when `name` was
1892/// written by this box before it was reset.
1893fn fresh_take(root: &Path, name: &str) -> anyhow::Result<bool> {
1894    let path = root.join(FRESH_DOCS);
1895    let bytes = match std::fs::read(&path) {
1896        Ok(b) => b,
1897        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
1898        Err(e) => return Err(e.into()),
1899    };
1900    let mut names: std::collections::BTreeSet<String> = serde_json::from_slice(&bytes)?;
1901    if !names.remove(name) {
1902        return Ok(false);
1903    }
1904    crate::durable::write_atomic_unframed(root, FRESH_DOCS, &serde_json::to_vec(&names)?)?;
1905    Ok(true)
1906}
1907
1908/// A path from a frame, joined under `root` — never absolute, never `..`,
1909/// never a cache.
1910fn under_root(root: &Path, rel: &str) -> anyhow::Result<std::path::PathBuf> {
1911    let p = Path::new(rel);
1912    if p.is_absolute() || p.components().any(|c| !matches!(c, std::path::Component::Normal(_))) {
1913        anyhow::bail!("the frame names {rel:?}, which is not a plain path under the data root");
1914    }
1915    if txlog::never_framed(rel) {
1916        anyhow::bail!("the frame names {rel:?}, a cache or per-box path — never applied on a twin");
1917    }
1918    Ok(root.join(p))
1919}
1920
1921/// Append `line` + newline to `path` unless the file already ends with
1922/// exactly that line (a re-apply of the tail after a restart).
1923fn append_line_once(path: &Path, line: &[u8]) -> anyhow::Result<()> {
1924    use std::io::{Read as _, Seek as _, SeekFrom, Write as _};
1925    let mut want = line.to_vec();
1926    want.push(b'\n');
1927    if let Ok(mut f) = std::fs::File::open(path) {
1928        let len = f.metadata()?.len();
1929        if len >= want.len() as u64 {
1930            f.seek(SeekFrom::Start(len - want.len() as u64))?;
1931            let mut tail = vec![0u8; want.len()];
1932            f.read_exact(&mut tail)?;
1933            let boundary = len == want.len() as u64 || {
1934                f.seek(SeekFrom::Start(len - want.len() as u64 - 1))?;
1935                let mut b = [0u8; 1];
1936                f.read_exact(&mut b)?;
1937                b[0] == b'\n'
1938            };
1939            if tail == want && boundary {
1940                return Ok(());
1941            }
1942        }
1943    }
1944    if let Some(parent) = path.parent() {
1945        std::fs::create_dir_all(parent)?;
1946    }
1947    let mut f = std::fs::OpenOptions::new().create(true).append(true).open(path)?;
1948    f.write_all(&want)?;
1949    f.sync_data()?;
1950    Ok(())
1951}
1952
1953/// The primary's KEK, written where `Kek::for_control_dir` reads it (0600,
1954/// atomic). Takes effect at the next open — a restored box restarts.
1955fn write_kek(control_dir: &Path, bytes: &[u8]) -> anyhow::Result<()> {
1956    use std::io::Write as _;
1957    anyhow::ensure!(bytes.len() == 32, "a KEK frame carries {} bytes, not 32", bytes.len());
1958    let path = gunnar_core::seal::kek_path(control_dir);
1959    if std::fs::read(&path).ok().as_deref() == Some(bytes) {
1960        return Ok(());
1961    }
1962    std::fs::create_dir_all(control_dir)?;
1963    let tmp = control_dir.join("secrets.kek.tmp");
1964    let mut options = std::fs::OpenOptions::new();
1965    options.write(true).create(true).truncate(true);
1966    {
1967        use std::os::unix::fs::OpenOptionsExt as _;
1968        options.mode(0o600);
1969    }
1970    let mut f = options.open(&tmp)?;
1971    f.write_all(bytes)?;
1972    f.sync_all()?;
1973    drop(f);
1974    std::fs::rename(&tmp, &path)?;
1975    // The KEK is written once its name is durable (stinky row 9).
1976    crate::durable::sync_dir(control_dir)
1977}
1978
1979// ── the doors: join as a twin, restore a fresh box ──────────────────────────
1980
1981/// **`Bootstrap.BecomeTwin`**: a fresh box becomes a twin of `primary`. Mints
1982/// the twin's key (per box, `twin.key`), writes `twin.json`, gives up its own
1983/// numbers ([`prepare_replay`]) and freezes its control plane. Returns the
1984/// public key line to enrol on the primary in group [`TWIN_GROUP`]. Idempotent
1985/// for the same primary (the same key comes back); another primary is refused.
1986pub fn become_twin(stores: &ServerStores, primary: &str) -> anyhow::Result<String> {
1987    let root = stores.root();
1988    if let Some(existing) = TwinConfig::load(root)? {
1989        anyhow::ensure!(existing.primary == primary, "this box is already the twin of {}; a twin is never re-pointed", existing.primary);
1990        return twin_public_key(root);
1991    }
1992    anyhow::ensure!(!primary.trim().is_empty(), "the primary's address is required");
1993    prepare_replay(stores)?;
1994    if !root.join(TWIN_KEY).exists() {
1995        let key = gunnar_core::mint_ed25519()?;
1996        crate::durable::write_atomic_unframed(root, TWIN_KEY, &key)?;
1997        use std::os::unix::fs::PermissionsExt as _;
1998        std::fs::set_permissions(root.join(TWIN_KEY), std::fs::Permissions::from_mode(0o600))?;
1999    }
2000    TwinConfig { primary: primary.trim().to_string(), joined_at_unix_ms: now_ms() }.save(root)?;
2001    stores.mark_twin();
2002    forget_mirrors(stores);
2003    stores.browse().store(true, Ordering::SeqCst);
2004    stores.writes_frozen().store(true, Ordering::SeqCst);
2005    twin_public_key(root)
2006}
2007
2008/// **A twin keeps no per-box mirror.** The metadata mirror (`slow`'s `meta` dir) holds DERIVED
2009/// copies — the identity table, `replication.json`, `webhooks.json` — and a fresh box writes its
2010/// own at first open, before anybody can tell it to become a twin. Those copies are cache on a
2011/// twin (C7): removed here, and never written again once the box is marked. Best effort: a file
2012/// that is not there is the goal, and a removal that fails is one WARN naming the path.
2013pub fn forget_mirrors(stores: &ServerStores) {
2014    let meta = stores.slow().path_of(crate::slow::META_DIR);
2015    #[allow(unused_mut)]
2016    let mut names = vec![crate::stores::IDENTITY_MIRROR];
2017    // A build without the feature never wrote that mirror, so it has nothing to forget.
2018    #[cfg(feature = "replication")]
2019    names.push(crate::mirror::REPLICATION_CONFIG);
2020    #[cfg(feature = "webhooks")]
2021    names.push(crate::webhook::WEBHOOK_CONFIG);
2022    for name in names {
2023        let path = meta.join(name);
2024        match std::fs::remove_file(&path) {
2025            Ok(()) => tracing::info!(target: gunnar_core::observe::TARGET_TWIN, path = %path.display(), "removed a per-box mirror: a twin keeps none"),
2026            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
2027            Err(err) => tracing::warn!(target: gunnar_core::observe::TARGET_TWIN, path = %path.display(), %err, "could not remove a per-box mirror on a twin"),
2028        }
2029    }
2030}
2031
2032/// The twin's key.
2033pub fn twin_private_key(root: &Path) -> anyhow::Result<gunnar_core::ssh_key::PrivateKey> {
2034    let bytes = std::fs::read(root.join(TWIN_KEY)).with_context(|| format!("reading {}", root.join(TWIN_KEY).display()))?;
2035    gunnar_core::ssh_key::PrivateKey::from_openssh(&bytes).map_err(|e| anyhow!("{TWIN_KEY}: {e}"))
2036}
2037
2038/// The twin's public key line.
2039pub fn twin_public_key(root: &Path) -> anyhow::Result<String> {
2040    let key = twin_private_key(root)?;
2041    let mut line = key.public_key().to_openssh().map_err(|e| anyhow!("{e}"))?;
2042    line.push_str(" gunnar-twin");
2043    Ok(line)
2044}
2045
2046/// **The owner of an in-process restore** ([`restore`], [`restore_from`]): a caller that
2047/// holds the data root itself, and so needs no key to prove anything to this box.
2048pub const RESTORE_OWNER_LOCAL: &str = "local";
2049
2050/// The refusal word for a resume by somebody who did not start the restore.
2051pub const RESTORE_NOT_YOURS: &str = "restore-not-yours";
2052
2053/// **A resume by somebody else, refused** (h-v1-6a, 2026-09-28). Its own type so the
2054/// control plane answers it `PERMISSION_DENIED` — the caller is known and the answer is no —
2055/// rather than the `FAILED_PRECONDITION` of "this box is not fresh".
2056#[derive(Debug)]
2057pub struct RestoreNotYours {
2058    /// Who the [`RESTORING`] marker says started it.
2059    pub started_by: String,
2060    /// Who asked to resume it.
2061    pub asked_by: String,
2062}
2063
2064impl std::fmt::Display for RestoreNotYours {
2065    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2066        write!(
2067            f,
2068            "REFUSED [{RESTORE_NOT_YOURS}] this box is mid-restore and the restore was started by {}; {} did not start it \
2069             and cannot resume it. Only the key that started a restore resumes it — run the same replay again with the \
2070             same key (monetize's restore lane, or `gunnar control twin replay -i <that key>`). A box whose starter is \
2071             gone for good is re-imaged, which makes it fresh again; nothing of the stream so far was touched",
2072            self.started_by, self.asked_by
2073        )
2074    }
2075}
2076
2077impl std::error::Error for RestoreNotYours {}
2078
2079/// The [`RESTORING`] marker's content: the word, then who started it.
2080fn restoring_marker(owner: &str) -> String {
2081    format!("restoring\nowner {owner}\n")
2082}
2083
2084/// Who started the restore in progress, out of the [`RESTORING`] marker. `None` for a marker
2085/// with no owner line — one written before 2026-09-28, when the marker was the bare word.
2086fn restoring_owner(root: &Path) -> anyhow::Result<Option<String>> {
2087    let text = std::fs::read_to_string(root.join(RESTORING)).with_context(|| format!("reading {}", root.join(RESTORING).display()))?;
2088    Ok(text.lines().find_map(|l| l.strip_prefix("owner ")).map(|o| o.trim().to_owned()).filter(|o| !o.is_empty()))
2089}
2090
2091/// **One lock per data root around `restore_begin`'s check-then-mark** — so two streams
2092/// arriving together cannot both find the box fresh (or both find no owner) and both mark
2093/// it. Per ROOT rather than one for the process because a test process holds many boxes.
2094fn restore_begin_lock(root: &Path) -> Arc<std::sync::Mutex<()>> {
2095    static LOCKS: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<PathBuf, Arc<std::sync::Mutex<()>>>>> = std::sync::OnceLock::new();
2096    let mut locks = LOCKS.get_or_init(Default::default).lock().unwrap_or_else(|p| p.into_inner());
2097    Arc::clone(locks.entry(root.to_path_buf()).or_default())
2098}
2099
2100/// **`Bootstrap.Restore`, the start**, for a caller holding the data root itself
2101/// ([`RESTORE_OWNER_LOCAL`]). See [`restore_begin_by`].
2102pub fn restore_begin(stores: &ServerStores) -> anyhow::Result<u64> {
2103    restore_begin_by(stores, RESTORE_OWNER_LOCAL)
2104}
2105
2106/// **`Bootstrap.Restore`, the start**: the next sequence this box takes. A
2107/// box already restoring continues at its head + 1 — **only for the `owner`
2108/// that started it**; otherwise it must be fresh ([`prepare_replay`]), starts at
2109/// 1, and the marker records `owner`.
2110///
2111/// `owner` is who the caller PROVED to be — `Bootstrap.Restore` passes the
2112/// fingerprint of the key its credential verified under (h-v1-6a, 2026-09-28).
2113/// Until then the resume branch below asked nothing at all: once
2114/// `txlog-restoring` existed, anybody who reached :50051 could stream the rest
2115/// of the box's history into it, KEK and identity table included. A marker
2116/// written before that day names no owner; the first verified caller adopts it,
2117/// said out loud — the credential it presented is what keeps a stranger out.
2118///
2119/// The check and the mark are one step under [`restore_begin_lock`].
2120pub fn restore_begin_by(stores: &ServerStores, owner: &str) -> anyhow::Result<u64> {
2121    anyhow::ensure!(
2122        !owner.trim().is_empty() && !owner.contains('\n'),
2123        "a restore needs the name of who is restoring; {owner:?} is not one"
2124    );
2125    let lock = restore_begin_lock(stores.root());
2126    let _held = lock.lock().unwrap_or_else(|p| p.into_inner());
2127    if is_restoring(stores.root()) {
2128        match restoring_owner(stores.root())? {
2129            Some(started_by) if started_by != owner => {
2130                return Err(RestoreNotYours { started_by, asked_by: owner.to_owned() }.into());
2131            }
2132            Some(_) => {}
2133            None => {
2134                tracing::warn!(
2135                    target: gunnar_core::observe::TARGET_TWIN,
2136                    owner,
2137                    "this box is mid-restore under a marker that names no owner (written before 2026-09-28); {owner} resumes it and owns it from now on"
2138                );
2139                crate::durable::write_atomic_unframed(stores.root(), RESTORING, restoring_marker(owner).as_bytes())?;
2140            }
2141        }
2142    }
2143    // One stream, one staging area: whatever a stream that BROKE left staged is not part of
2144    // this one ([`snapshot::forget_restore_stager`]). After the owner check, so a refused
2145    // caller throws away nothing of the starter's.
2146    snapshot::forget_restore_stager(stores.root());
2147    if is_restoring(stores.root()) {
2148        stores.txlog().set_follower(true);
2149        // The stream that broke may have appended its last entry without applying it: apply
2150        // the head again before taking more (the follower's `reapply` rule).
2151        let head = stores.txlog().head_seq();
2152        if head > 0 && head >= stores.txlog().first_seq() && !snapshot::installing(stores.root()) {
2153            let e = stores.txlog().read(head)?;
2154            apply(stores, &e).with_context(|| format!("re-applying seq {head} before the restore resumes"))?;
2155        }
2156        return Ok(stores.txlog().head_seq() + 1);
2157    }
2158    anyhow::ensure!(!stores.is_twin(), "a twin is never restored into; restore into a fresh primary");
2159    prepare_replay(stores)?;
2160    // A fresh box holds no part of anybody's entry, nor of anybody's snapshot.
2161    intake::forget(stores.root())?;
2162    snapshot::forget_restore_snapshot(stores.root())?;
2163    crate::durable::write_atomic_unframed(stores.root(), RESTORING, restoring_marker(owner).as_bytes())?;
2164    Ok(1)
2165}
2166
2167/// **What a restore costs on the box taking it, per byte the sender streams.**
2168///
2169/// A restore writes each frame TWICE: once into this box's own log ([`restore_apply`] does
2170/// `append_at` before `apply`, durable first) and once into the store the frame builds — a pack
2171/// into the archive, an LFS object into its file, a document into `control.redb`. So the floor
2172/// is 2. The margin above it is the engine's own overhead on ingest: the archive's oid index
2173/// (`objects.tail`, measured at ~0.8 % of a 200 MB pack in the note's twin-image table), the
2174/// ref log, redb's B-tree pages, and the filesystem's own metadata on tens of thousands of
2175/// small files.
2176///
2177/// **Deliberately an ESTIMATE, and it is named as one in the refusal.** A tight number would
2178/// need the sender to say what its stores measure rather than what its log measures, which is a
2179/// wire change this rule does not need in order to catch the case it exists for: a set that is
2180/// half the size it left with. It is a floor, not a promise — a restore that passes this can
2181/// still run out on a set that only just fits, and that failure is the ordinary mid-stream one
2182/// (the box stays `restoring`, resumable), not a silent short restore.
2183pub const RESTORE_BYTES_PER_LOG_BYTE: u64 = 2;
2184
2185/// Room the box keeps for itself on top of the restore: the exploded read cache the engine
2186/// grows on the first clone, the crash and audit records, and the slack a full xfs needs to
2187/// keep allocating. 5 % of the volume, never less than 256 MiB.
2188fn reserve_bytes(total: u64) -> u64 {
2189    (total / 20).max(256 << 20)
2190}
2191
2192/// **Does this box have room for the restore, asked BEFORE the first byte of it lands?**
2193///
2194/// The case this exists for is a restore onto DIFFERENT hardware: the data set left a machine
2195/// with four members and arrives at one with two, which may hold less total space than the set
2196/// being restored. Without this the stream fills the volume and the apply fails somewhere in
2197/// the middle — recoverable (the box stays `restoring` and nothing says "restored"), but the
2198/// operator learns it after an hour of streaming and learns it as an ENOSPC rather than as a
2199/// number. With it the box refuses at the first chunk and says how much is short.
2200///
2201/// `announced_bytes` of 0 means the sender did not say (an older `gunnar control twin replay`,
2202/// or a caller streaming a snapshot whose size it cannot know in advance): no refusal, because
2203/// a guess is not a measurement. The whole check is one `statvfs`.
2204pub fn room_for_restore(root: &Path, announced_bytes: u64) -> anyhow::Result<()> {
2205    if announced_bytes == 0 {
2206        return Ok(());
2207    }
2208    let (total, used) = volume_bytes(root);
2209    if total == 0 {
2210        // Unmeasurable is not "full": a box whose statvfs failed is one this must not refuse.
2211        tracing::warn!(target: gunnar_core::observe::TARGET_TWIN, root = %root.display(), "the data volume could not be measured; the restore's room is not checked");
2212        return Ok(());
2213    }
2214    let free = total.saturating_sub(used);
2215    let need = announced_bytes.saturating_mul(RESTORE_BYTES_PER_LOG_BYTE).saturating_add(reserve_bytes(total));
2216    if free >= need {
2217        tracing::info!(
2218            target: gunnar_core::observe::TARGET_TWIN,
2219            announced_bytes, need, free, total,
2220            "room for the restore: the data set on this machine holds it"
2221        );
2222        return Ok(());
2223    }
2224    anyhow::bail!(
2225        "NOT ENOUGH ROOM for this restore, and nothing has been written: the sender streams \
2226         {announced} of log, which needs about {need} on this box ({factor}× the log — a restore \
2227         writes every frame into this box's own log AND into the store it builds — plus {reserve} \
2228         the box keeps for its caches and slack), and this data set has {free} free of {total}. \
2229         SHORT BY {short}. This box is being restored onto a SMALLER data set than the one the \
2230         twin's log came from: give it more or larger members and restore again. NOTHING of the \
2231         twin's log has landed — this box's log is still empty and it stays `restoring`, so the \
2232         same replay into a bigger set starts at seq 1 and loses nothing. The estimate is \
2233         DELIBERATELY conservative and a long-lived box's log is larger than the stores it \
2234         builds; if you have read these numbers and this set does hold it, re-run the replay \
2235         with `--ignore-room`.",
2236        announced = human(announced_bytes),
2237        need = human(need),
2238        factor = RESTORE_BYTES_PER_LOG_BYTE,
2239        reserve = human(reserve_bytes(total)),
2240        free = human(free),
2241        total = human(total),
2242        short = human(need - free),
2243    )
2244}
2245
2246/// Bytes as an operator reads them. Small enough to live here rather than pull a crate in.
2247fn human(n: u64) -> String {
2248    const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
2249    let mut v = n as f64;
2250    let mut u = 0;
2251    while v >= 1024.0 && u + 1 < UNITS.len() {
2252        v /= 1024.0;
2253        u += 1;
2254    }
2255    if u == 0 {
2256        format!("{n} B")
2257    } else {
2258        format!("{v:.1} {}", UNITS[u])
2259    }
2260}
2261
2262/// One entry of a restore: durable in the log at the twin's number, then applied.
2263pub fn restore_apply(stores: &ServerStores, e: &Entry) -> anyhow::Result<Applied> {
2264    stores.txlog().append_at(e.seq, e.kind, &e.meta, &e.blob)?;
2265    apply(stores, e)
2266}
2267
2268/// The end of a restore: the marker and the fresh-docs list go. The log stays
2269/// follower-only in THIS process — it holds the fresh box's KEK in memory and
2270/// the replayed secrets are sealed under the primary's — so the box restarts,
2271/// and opens as the primary it was.
2272/// `announced_head` is the head the sender said it streams through (`LogChunk.restore_head`,
2273/// the largest seen; 0 = not announced): a box below it has taken a SHORT stream — one that
2274/// ended cleanly although the sender's fetch broke — and stays restoring, resumable by
2275/// resending from its head + 1.
2276pub fn restore_finish(stores: &ServerStores, announced_head: u64, log_id_of_source: &str) -> anyhow::Result<u64> {
2277    finish_restore(stores, announced_head, log_id_of_source, false)
2278}
2279
2280/// **[`restore_finish`] of a restore streamed over the network** (`Bootstrap.Restore`): the stream
2281/// MUST name the log it restores, and so must every earlier stream of the same restore (stinky
2282/// row 7, 2026-10-10). The lineage check lets an empty id through on either side — right for a
2283/// local replay, which reads the id off the twin's disk — so a sender that named nothing could
2284/// finish a box on another history whenever the sequence numbers happened to fit. Every sender
2285/// in the tree names it (`LogHead.log_id`, copied onto every chunk).
2286pub fn restore_finish_streamed(stores: &ServerStores, announced_head: u64, log_id_of_source: &str) -> anyhow::Result<u64> {
2287    finish_restore(stores, announced_head, log_id_of_source, true)
2288}
2289
2290fn finish_restore(stores: &ServerStores, announced_head: u64, log_id_of_source: &str, streamed: bool) -> anyhow::Result<u64> {
2291    // **What this restore was told it restores holds for every resend** (estate-box3
2292    // plain6): a resend whose sender's fetch failed before its first entry announces
2293    // nothing, and judged alone it would finish a box that an EARLIER stream of the
2294    // same restore announced to reach further. The largest head and the source's log
2295    // id ever announced are kept beside the marker until the restore finishes.
2296    let kept = restore_announced(stores.root())?;
2297    let now_id = log_id_of_source.trim();
2298    if streamed {
2299        anyhow::ensure!(
2300            !now_id.is_empty(),
2301            "this restore stream named no log (LogChunk.log_id is empty): a box is restored from one named history, never from whatever numbers fit — this box stays restoring; resend the twin's log with its log id"
2302        );
2303        if let Some(kept) = &kept {
2304            anyhow::ensure!(
2305                !kept.log_id.is_empty(),
2306                "an earlier stream of this restore named no log ({RESTORE_ANNOUNCED} says head {} and no log id): which history this box holds is unknown — this box stays restoring; start the restore again on a fresh box",
2307                kept.head
2308            );
2309        }
2310    }
2311    let Announced { head: kept_head, log_id: kept_id } = kept.unwrap_or_default();
2312    anyhow::ensure!(
2313        kept_id.is_empty() || now_id.is_empty() || kept_id == now_id,
2314        "this restore began as log {kept_id} and a resend is log {now_id}: one restore is one history — this box stays restoring"
2315    );
2316    let announced_head = announced_head.max(kept_head);
2317    let log_id_of_source = if now_id.is_empty() { kept_id.as_str() } else { now_id };
2318    if announced_head > 0 || !log_id_of_source.is_empty() {
2319        crate::durable::write_atomic_unframed(stores.root(), RESTORE_ANNOUNCED, format!("{announced_head} {log_id_of_source}\n").as_bytes())?;
2320    }
2321    // A stream that ended with nothing in it is not a restore: a fetch that failed before its
2322    // first entry looks exactly like this from here. The box stays restoring, so the operator
2323    // resends the twin's log rather than restarting into an empty primary.
2324    anyhow::ensure!(
2325        stores.txlog().head_seq() > 0,
2326        "nothing was restored (the stream carried no entry); this box stays restoring — resend the twin's log"
2327    );
2328    let head = stores.txlog().head_seq();
2329    anyhow::ensure!(
2330        head >= announced_head,
2331        "the stream ended at seq {head} but the sender announced the twin's head {announced_head}; a short stream is not a restore — this box stays restoring, resend the twin's log"
2332    );
2333    // **This box IS the history it was just handed** (2026-09-21, lane T4).
2334    // `prepare_replay` dropped whatever id this fresh box had minted for itself;
2335    // the stream's id goes on now, BEFORE the restoring marker comes off, so no
2336    // open can ever see a restored box without the name of what it restored. A
2337    // sender older than this change announces nothing, and then the box mints
2338    // its own at the next open — the pre-2026-09-21 behaviour exactly.
2339    let announced_id = log_id_of_source.trim();
2340    if !announced_id.is_empty() {
2341        pin_log_id(stores.root(), announced_id).context("taking the identity of the log this box was restored from")?;
2342        tracing::warn!(target: gunnar_core::observe::TARGET_TWIN, log_id = %announced_id, "this box IS log {announced_id} now: the twin it was restored from keeps following it without being re-joined");
2343    }
2344    // **And a NEWER generation of it** (stinky row 18): the same name as the
2345    // main it replaces, ordered after it, so the twin can refuse that old main
2346    // if it ever comes back. Stamped before the restoring marker comes off, like
2347    // the id.
2348    let generation = stamp_generation(stores.root()).context("stamping this restore's generation of the log")?;
2349    forget_fence(stores.root()).context("a restored box is the newest generation: no fence from an older life")?;
2350    tracing::info!(target: gunnar_core::observe::TARGET_TWIN, generation, "this restore is generation {generation} of its log: a twin refuses any older one from here");
2351    let _ = std::fs::remove_file(stores.root().join(FRESH_DOCS));
2352    // The next open re-seals every secret under a KEK this box owns ([`reseal_after_restore`]).
2353    // Written BEFORE the restoring marker goes, so no open can see "restored" without "re-seal".
2354    crate::durable::write_atomic_unframed(stores.root(), RESEAL, b"reseal\n")?;
2355    // DURABLY: the box is "restarted" next, and on the appliance that can be a stop that
2356    // loses the guest's page cache. An unlink that did not reach the disk reopens the box
2357    // mid-restore — read-only, forever (`crate::durable::remove_durable`, MEASURED).
2358    crate::durable::remove_durable(stores.root(), RESTORING)?;
2359    let _ = std::fs::remove_file(stores.root().join(RESTORE_ANNOUNCED));
2360    // Nothing staged outlives the restore (restore-large-entry): an entry staged is applied
2361    // before its staging goes, so whatever is left here is no part of this box's history.
2362    let _ = intake::forget(stores.root());
2363    snapshot::forget_restore_snapshot_or_warn(stores.root());
2364    Ok(stores.txlog().head_seq())
2365}
2366
2367/// `<data-root>/txlog-restore-announced`: `<head> <log id>` — the largest head
2368/// and the source's log id any stream of the restore under way announced.
2369pub const RESTORE_ANNOUNCED: &str = "txlog-restore-announced";
2370
2371/// What a restore under way was announced to be ([`RESTORE_ANNOUNCED`]).
2372#[derive(Debug, Clone, Default, PartialEq, Eq)]
2373pub struct Announced {
2374    /// The largest head any stream announced; 0 = none.
2375    pub head: u64,
2376    /// The source's log id; empty = none named.
2377    pub log_id: String,
2378}
2379
2380/// **[`RESTORE_ANNOUNCED`] could not be trusted** (stinky row 4, 2026-10-10): it used to be read
2381/// with `unwrap_or_default()` and its head parsed with `.ok().unwrap_or(0)`, so a marker that
2382/// could not be read, or did not parse, meant "nothing announced" — and a restore could finish
2383/// on a shorter head, as another log. Now the restore refuses by name and the box stays restoring.
2384#[derive(Debug, thiserror::Error)]
2385pub enum RestoreMarkerError {
2386    /// The marker is there and could not be read.
2387    #[error("the restore marker {path} could not be read ({source}): what this restore was announced to reach is unknown — this box stays restoring")]
2388    Unreadable {
2389        /// The marker.
2390        path: std::path::PathBuf,
2391        /// Why.
2392        source: std::io::Error,
2393    },
2394    /// The marker does not say `<head> [<log id>]`.
2395    #[error("the restore marker {path} is corrupt ({what}: {text:?}): what this restore was announced to reach is unknown — this box stays restoring")]
2396    Corrupt {
2397        /// The marker.
2398        path: std::path::PathBuf,
2399        /// What is wrong with it.
2400        what: &'static str,
2401        /// What it holds.
2402        text: String,
2403    },
2404}
2405
2406/// What the restore under way was announced to be; `None` when no stream announced anything (no
2407/// marker). A marker that cannot be read or parsed is a [`RestoreMarkerError`], never "nothing".
2408pub fn restore_announced(root: &Path) -> Result<Option<Announced>, RestoreMarkerError> {
2409    let path = root.join(RESTORE_ANNOUNCED);
2410    let text = match std::fs::read_to_string(&path) {
2411        Ok(t) => t,
2412        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
2413        Err(source) => return Err(RestoreMarkerError::Unreadable { path, source }),
2414    };
2415    let corrupt = |what| RestoreMarkerError::Corrupt { path: path.clone(), what, text: text.clone() };
2416    let mut words = text.split_whitespace();
2417    let head = words.next().ok_or_else(|| corrupt("it is empty"))?.parse::<u64>().map_err(|_| corrupt("its head is not a number"))?;
2418    let log_id = words.next().unwrap_or("").to_string();
2419    if words.next().is_some() {
2420        return Err(corrupt("it holds more than a head and a log id"));
2421    }
2422    Ok(Some(Announced { head, log_id }))
2423}
2424
2425/// **What a box does once a restore FINISHED** (c-no-restart): the restored
2426/// box must restart to open as the primary it was (the replayed secrets are
2427/// sealed under the dead main's KEK, the log is follower-only in this
2428/// process). Nobody outside restarted it — monetize only logged
2429/// `needs_restart` — so the box stayed half-open for ever.
2430#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2431pub enum AfterRestore {
2432    /// The appliance's PID 1: sync and reboot itself, a few seconds after the
2433    /// answer went out.
2434    Reboot,
2435    /// Anything else (a container, a developer's process): say so loudly; the
2436    /// operator restarts it.
2437    Operator,
2438}
2439
2440/// Pure: which [`AfterRestore`], for a process that is (or is not) PID 1 of
2441/// the appliance's own kernel.
2442pub fn after_restore(is_init: bool, appliance: bool) -> AfterRestore {
2443    if is_init && appliance {
2444        AfterRestore::Reboot
2445    } else {
2446        AfterRestore::Operator
2447    }
2448}
2449
2450/// How long the restored box waits before it reboots: the `Bootstrap.Restore`
2451/// answer must reach the lane first.
2452pub const REBOOT_AFTER_RESTORE: std::time::Duration = std::time::Duration::from_secs(5);
2453
2454/// **Restart the restored box** ([`after_restore`]): on the appliance, sync
2455/// and reboot on a thread of its own after [`REBOOT_AFTER_RESTORE`]; anywhere
2456/// else, say that the operator must. Returns what was decided.
2457pub fn restart_after_restore() -> AfterRestore {
2458    let how = after_restore(appliance_pid1::net::is_init(), appliance_pid1::net::booted_by_korp_start());
2459    match how {
2460        AfterRestore::Reboot => {
2461            tracing::warn!(target: gunnar_core::observe::TARGET_TWIN, after_s = REBOOT_AFTER_RESTORE.as_secs(), "RESTORED from the twin: this box reboots itself to open as the primary it was");
2462            let spawned = std::thread::Builder::new().name("restore-reboot".into()).spawn(|| {
2463                std::thread::sleep(REBOOT_AFTER_RESTORE);
2464                crate::narrate::reboot_now();
2465            });
2466            if let Err(e) = spawned {
2467                tracing::error!(target: gunnar_core::observe::TARGET_TWIN, error = %e, "the reboot after the restore could not be scheduled; restart this box");
2468            }
2469        }
2470        AfterRestore::Operator => {
2471            tracing::warn!(target: gunnar_core::observe::TARGET_TWIN, "RESTORED from the twin: RESTART this process — it opens as the primary it was (not the appliance's PID 1, so it does not reboot itself)");
2472        }
2473    }
2474    how
2475}
2476
2477#[cfg(test)]
2478mod after_restore_tests {
2479    use super::*;
2480
2481    #[test]
2482    fn only_the_appliances_pid_1_reboots_itself_after_a_restore() {
2483        assert_eq!(after_restore(true, true), AfterRestore::Reboot);
2484        assert_eq!(after_restore(true, false), AfterRestore::Operator, "a container's PID 1 never reboots the host");
2485        assert_eq!(after_restore(false, true), AfterRestore::Operator);
2486        assert_eq!(after_restore(false, false), AfterRestore::Operator);
2487    }
2488}
2489
2490/// `<data-root>/txlog-reseal`: a restore finished; the next open re-seals under a KEK of this
2491/// box's own. Per box, never framed.
2492pub const RESEAL: &str = "txlog-reseal";
2493/// `<control-dir>/secrets.kek.new`: the KEK being re-sealed to, durable before the first secret
2494/// moves, so a crash mid-way resumes with the same key.
2495const KEK_NEXT: &str = "secrets.kek.new";
2496
2497/// Is a re-seal owed under `root`?
2498pub fn reseal_pending(root: &Path) -> bool {
2499    root.join(RESEAL).exists()
2500}
2501
2502/// **A restored box takes a KEK of its own** (`.nornir/twin-transaction-log.md` §"The KEK after
2503/// a restore"). The replayed `secrets.kek` is the dead primary's; every sealed secret is opened
2504/// with it and sealed again under a new key (or `GUNNAR_SECRET_KEK` when the box is handed one),
2505/// and the old key leaves the disk. Each step is a frame first — a `Kek` frame with the new key,
2506/// then one `Secret` frame per re-sealed value — so the twin following this box holds the new
2507/// key and the re-sealed bytes, and a later restore from it opens them.
2508///
2509/// Crash-safe by construction: the new key is durable in `secrets.kek.new` before any secret
2510/// moves; a value that already opens under the new key is skipped; the rename over
2511/// `secrets.kek` and the marker's removal come last. Run once at open, before any sealed store
2512/// is built. Returns the KEK the box now uses.
2513pub fn reseal_after_restore(
2514    root: &Path,
2515    control_dir: &Path,
2516    log: &Log,
2517    rows: &std::collections::BTreeMap<String, Option<Vec<u8>>>,
2518    raw: &dyn gunnar_store::SecretStore,
2519) -> anyhow::Result<gunnar_core::Kek> {
2520    use gunnar_core::Kek;
2521    let path = gunnar_core::seal::kek_path(control_dir);
2522    let key32 = |bytes: Vec<u8>, what: &Path| -> anyhow::Result<Kek> {
2523        let arr: [u8; 32] = bytes.as_slice().try_into().map_err(|_| anyhow!("{} is {} bytes, not 32", what.display(), bytes.len()))?;
2524        Ok(Kek::from_bytes(arr))
2525    };
2526    let old = key32(std::fs::read(&path).with_context(|| format!("reading the replayed KEK {}", path.display()))?, &path)?;
2527    let next_path = control_dir.join(KEK_NEXT);
2528    let env = Kek::from_env()?;
2529    let new = match &env {
2530        Some(k) => k.clone(),
2531        None => match std::fs::read(&next_path) {
2532            Ok(bytes) => key32(bytes, &next_path)?,
2533            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
2534                let k = Kek::generate()?;
2535                write_key_file(control_dir, KEK_NEXT, &hex::decode(k.to_hex())?)?;
2536                k
2537            }
2538            Err(e) => return Err(e).with_context(|| format!("reading {}", next_path.display())),
2539        },
2540    };
2541    let mut moved = 0usize;
2542    let mut dropped = 0usize;
2543    if new.to_hex() != old.to_hex() {
2544        log.append(Kind::Kek, &Meta::Kek {}, &hex::decode(new.to_hex())?).context("framing the restored box's own KEK")?;
2545        for (key, row) in rows {
2546            let Some(sealed) = row else { continue };
2547            // A legacy plaintext row is the migrating store's business, not a sealed value.
2548            if !gunnar_core::is_sealed(sealed) || gunnar_core::unseal(&new, key, sealed).is_ok() {
2549                continue;
2550            }
2551            // **A row this box wrote ITSELF, under a KEK that no longer exists** (MEASURED
2552            // 2026-09-17, lane TWIN-COMPLETENESS). A fresh box mints secrets of its own before
2553            // it is restored into — its host key at first boot, its issuer at first start, and
2554            // any key a request minted on first use. `prepare_replay` gives up its DOCUMENTS
2555            // (`FRESH_DOCS`) and its numbers, and since the same commit its secrets too; but a
2556            // box restored by an older build, or one whose fresh row the replay had no frame
2557            // for, reaches here with a row sealed under a KEK the `Kek` frame has already
2558            // overwritten on disk. That row opens under NEITHER key, and this used to be a `?`:
2559            // the restored box then REFUSED TO OPEN, every time, for ever —
2560            //
2561            //     re-sealing a restored box's secrets under its own KEK: opening fresh-only/key
2562            //     with the replayed KEK: … wrong key-encrypting key …
2563            //
2564            // — a box that had been restored successfully and could not start. The value is
2565            // unrecoverable by construction (its KEK is gone) and it is not the primary's, so
2566            // it is DROPPED, loudly and by name, rather than kept as the thing that bricks the
2567            // box. Nothing of the restore is lost: every row the twin's log carried opens under
2568            // the replayed KEK, which is the branch above.
2569            let plain = match gunnar_core::unseal(&old, key, sealed) {
2570                Ok(plain) => plain,
2571                Err(err) => {
2572                    tracing::warn!(
2573                        target: gunnar_core::observe::TARGET_TWIN,
2574                        key = %key,
2575                        %err,
2576                        "a secret that opens under NEITHER the replayed KEK nor this box's own: it \
2577                         was written by this box before the restore, under a key that is gone. \
2578                         DROPPED — it is not the restored primary's and it cannot be read by \
2579                         anyone. If something needs it, it will be minted again on first use"
2580                    );
2581                    raw.delete(key).with_context(|| format!("dropping the unopenable {key}"))?;
2582                    dropped += 1;
2583                    continue;
2584                }
2585            };
2586            let resealed = gunnar_core::seal(&new, key, &plain)?;
2587            log.append(Kind::Secret, &Meta::Secret { key: key.clone(), deleted: false }, &resealed)
2588                .with_context(|| format!("framing the re-sealed {key}"))?;
2589            raw.put(key, &resealed).with_context(|| format!("storing the re-sealed {key}"))?;
2590            moved += 1;
2591        }
2592    }
2593    if env.is_some() {
2594        // The box is handed its key; the dead primary's must not stay on the disk.
2595        match std::fs::remove_file(&path) {
2596            Ok(()) => {}
2597            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
2598            Err(e) => return Err(e).with_context(|| format!("removing the replayed KEK {}", path.display())),
2599        }
2600    } else {
2601        std::fs::rename(&next_path, &path).with_context(|| format!("installing the new KEK at {}", path.display()))?;
2602    }
2603    // The KEK's rename (or removal) is durable BEFORE the marker that owes it goes (stinky
2604    // row 9): a power cut between the two must find the re-seal still owed, never a box that
2605    // thinks it re-sealed with the replayed KEK back on its disk.
2606    crate::durable::sync_dir(control_dir).context("flushing the re-sealed KEK into place")?;
2607    crate::durable::remove_durable(root, RESEAL)?;
2608    tracing::warn!(target: gunnar_core::observe::TARGET_TWIN, moved, dropped, "a restored box re-sealed its secrets under a KEK of its own; the replayed KEK is gone from this disk");
2609    Ok(new)
2610}
2611
2612/// A 0600 key file under `dir`, atomic and durable.
2613fn write_key_file(dir: &Path, name: &str, bytes: &[u8]) -> anyhow::Result<()> {
2614    use std::io::Write as _;
2615    std::fs::create_dir_all(dir)?;
2616    let tmp = dir.join(format!("{name}.tmp"));
2617    let mut options = std::fs::OpenOptions::new();
2618    options.write(true).create(true).truncate(true);
2619    {
2620        use std::os::unix::fs::OpenOptionsExt as _;
2621        options.mode(0o600);
2622    }
2623    let mut f = options.open(&tmp)?;
2624    f.write_all(bytes)?;
2625    f.sync_all()?;
2626    drop(f);
2627    std::fs::rename(&tmp, dir.join(name))?;
2628    crate::durable::sync_dir(dir)
2629}
2630
2631// ── the follower ────────────────────────────────────────────────────────────
2632
2633/// What one [`Follower::step`] did.
2634#[derive(Debug, Clone, PartialEq, Eq)]
2635pub enum Progress {
2636    /// Applied this many entries; the twin's head is now `head`.
2637    Applied { count: u64, head: u64 },
2638    /// Nothing new on the primary.
2639    CaughtUp { head: u64 },
2640    /// The primary retains from `first`; the twin needs `wanted`. Stopped.
2641    SnapshotNeeded { wanted: u64, first: u64 },
2642    /// The hole was closed by a snapshot taken at `at`; the twin follows from `at + 1`.
2643    SnapshotInstalled { at: u64 },
2644    /// The primary's head is BELOW this twin's own — a different log. Stopped.
2645    Diverged {
2646        /// What this twin has applied.
2647        own: u64,
2648        /// What the box at the other end says its head is.
2649        primary_head: u64,
2650    },
2651}
2652
2653/// Consecutive failed polls after which a follower whose primary has another
2654/// address hands back to the supervisor to re-dial.
2655pub const FAILOVER_AFTER: u32 = 5;
2656
2657/// The twin's loop over one source into one set of stores.
2658pub struct Follower {
2659    stores: Arc<ServerStores>,
2660    source: Box<dyn LogSource>,
2661    /// Blob bytes per fetch.
2662    fetch_bytes: u64,
2663    /// **The log is one entry ahead of the stores.** The twin appends BEFORE it applies
2664    /// (durable first), so an apply that failed — or a process that died between the two —
2665    /// leaves the head entry unapplied. Set at construction (a restart does not know) and on
2666    /// every append; cleared once that entry applied. Measured 2026-09-15 under KVM: without
2667    /// it the next poll resumed at head + 1 and the twin reported `applied == head` with the
2668    /// failed entries' refs missing from its stores.
2669    reapply: AtomicBool,
2670    /// The run of failed polls being lived through — kept across the supervisor's re-dials
2671    /// ([`Follower::sharing_failures`]), so a primary that stays unreachable is said once.
2672    failures: Arc<Mutex<Option<Failing>>>,
2673    /// The peak resident set this follower last said ([`Follower::say_memory`]), bytes; 0 = never.
2674    said_peak: std::sync::atomic::AtomicU64,
2675    /// The applied marker ([`gunnar_core::TWIN_APPLIED`]) this follower last made durable.
2676    marked: std::sync::atomic::AtomicU64,
2677    /// The volume a snapshot is staged on ([`snapshot::Disk`]; a test caps one).
2678    volume: Arc<dyn snapshot::Volume>,
2679}
2680
2681/// **This process's memory, from the kernel**: `(VmHWM, VmRSS)` of `/proc/self/status` in bytes —
2682/// the peak resident set since the process started, and the resident set now. `None` where the
2683/// file cannot be read (not Linux).
2684pub fn own_memory() -> Option<(u64, u64)> {
2685    let s = std::fs::read_to_string("/proc/self/status").ok()?;
2686    let kib = |key: &str| s.lines().find_map(|l| l.strip_prefix(key)).and_then(|v| v.split_whitespace().next()).and_then(|v| v.parse::<u64>().ok());
2687    Some((kib("VmHWM:")? << 10, kib("VmRSS:")? << 10))
2688}
2689
2690/// A following twin says its memory again once its PEAK rose this much above the figure it last
2691/// said — so a twin whose memory is bounded says it once, and one that keeps climbing says so in
2692/// steps, never more often than its memory grows.
2693pub const SAY_MEMORY_STEP: u64 = 32 << 20;
2694
2695/// Whether a peak of `peak` bytes is news after `said` was said (0 = nothing said yet).
2696pub fn memory_is_news(said: u64, peak: u64) -> bool {
2697    said == 0 || peak >= said.saturating_add(SAY_MEMORY_STEP)
2698}
2699
2700/// **A run of failed polls** (L1, estate-box check 3): what failed (its shape), since when,
2701/// how many, and when it was last said. A recharge makes a twin's primary unreachable, then
2702/// a blank box that does not prove itself (`BadSignature`) for minutes: every poll said
2703/// `poll failed; retrying` at WARN (30/min) and every hand-back `re-dialling` at WARN. The
2704/// first failure of a run is said at WARN; the same failure again only every [`RESAY_FAILING`];
2705/// the end of the run once, at INFO.
2706#[derive(Debug, Clone)]
2707pub struct Failing {
2708    shape: String,
2709    since: std::time::Instant,
2710    said: std::time::Instant,
2711    polls: u64,
2712}
2713
2714/// While the same failure lasts, it is said again this often.
2715pub const RESAY_FAILING: Duration = Duration::from_secs(300);
2716
2717/// An error's shape: its words without its numbers, so the same failure on another port or
2718/// with another nonce is the same failure.
2719/// Whether a poll failure is the primary refusing this twin's credential for clock SKEW —
2720/// [`gunnar_grpc::auth::CLOCK_SKEW_PHRASE`] in the refusal, which the gate's `Stale` status
2721/// carries — and not any other refusal or a transport failure.
2722pub(crate) fn refused_for_skew(err: &anyhow::Error) -> bool {
2723    format!("{err:#}").contains(gunnar_grpc::auth::CLOCK_SKEW_PHRASE)
2724}
2725
2726fn failure_shape(err: &anyhow::Error) -> String {
2727    let text = format!("{err:#}");
2728    let mut out = String::with_capacity(text.len().min(200));
2729    let mut in_num = false;
2730    for c in text.chars().take(200) {
2731        if c.is_ascii_digit() {
2732            if !in_num {
2733                out.push('#');
2734            }
2735            in_num = true;
2736        } else {
2737            in_num = false;
2738            out.push(c);
2739        }
2740    }
2741    out
2742}
2743
2744/// **The follower's end of a streamed range** (twin-streaming, 2026-10-06): each entry straight
2745/// into this box's OWN log as it arrives — a [`txlog::Pending`] at the primary's number, so the
2746/// gap rule holds piece by piece — and, once whole, [`take_logged`]. The frame does not exist
2747/// until its last byte is in: the length is held to the declared one in both directions
2748/// (`Pending::write` refuses more, `Pending::commit` refuses less), the primary hands over an
2749/// entry's last piece only after its payload crc checked, and an entry that never ends is cut off
2750/// the segment when the `Pending` drops. What this box holds of an entry in flight is the piece
2751/// in hand.
2752struct Appender<'a> {
2753    stores: &'a ServerStores,
2754    reapply: &'a AtomicBool,
2755    /// The entry being received: its frame in progress, its kind, meta and blob length.
2756    open: Option<(txlog::Pending<'a>, u16, Vec<u8>, u64)>,
2757    /// The sequence due next.
2758    next: u64,
2759    /// Entries appended and taken.
2760    count: u64,
2761    /// **The group commit of a store-only twin** (perf twin-apply-fsync, 2026-10-06); `None` on a
2762    /// box being restored, which keeps one durable frame and one full apply per entry.
2763    group: Option<Group>,
2764    /// The applied marker this follower last made durable.
2765    marked: &'a std::sync::atomic::AtomicU64,
2766}
2767
2768/// Entries written into the twin's log and not yet durable, in order, with what their take needs.
2769struct Group {
2770    entries: Vec<(u64, u16, Vec<u8>)>,
2771    bytes: u64,
2772    since: std::time::Instant,
2773}
2774
2775impl Group {
2776    fn new() -> Self {
2777        Group { entries: Vec::new(), bytes: 0, since: std::time::Instant::now() }
2778    }
2779    fn full(&self) -> bool {
2780        self.entries.len() >= GROUP_ENTRIES || self.bytes >= GROUP_BYTES || self.since.elapsed() >= GROUP_AGE
2781    }
2782}
2783
2784impl Appender<'_> {
2785    /// **End the group**: ONE `fdatasync` makes every entry of it durable in this twin's log —
2786    /// from here the head says so, and the head is what this twin acknowledges — then the
2787    /// control-plane entries among them are taken, in order, into ONE durable redb commit
2788    /// ([`take_group`]). A half-received entry is cut first. Nothing pending = nothing done.
2789    fn flush(&mut self) -> anyhow::Result<()> {
2790        drop(self.open.take());
2791        let Some(g) = self.group.as_mut() else { return Ok(()) };
2792        if g.entries.is_empty() {
2793            return Ok(());
2794        }
2795        let entries = std::mem::take(&mut g.entries);
2796        g.bytes = 0;
2797        g.since = std::time::Instant::now();
2798        self.stores.txlog().sync().context("making a group of entries durable in this twin's log")?;
2799        self.reapply.store(true, Ordering::SeqCst);
2800        take_group(self.stores, &entries, self.marked)?;
2801        self.reapply.store(false, Ordering::SeqCst);
2802        Ok(())
2803    }
2804}
2805
2806/// The name a `ControlDoc` frame's meta moves, if it is one.
2807fn doc_name(kind: u16, meta: &[u8]) -> Option<String> {
2808    if kind != Kind::ControlDoc as u16 {
2809        return None;
2810    }
2811    match Meta::from_bytes(meta) {
2812        Ok(Meta::ControlDoc { name, .. }) => Some(name),
2813        _ => None,
2814    }
2815}
2816
2817/// The document names the fresh-box rule still holds ([`FRESH_DOCS`]); empty when none.
2818fn fresh_names(root: &Path) -> anyhow::Result<std::collections::BTreeSet<String>> {
2819    match std::fs::read(root.join(FRESH_DOCS)) {
2820        Ok(b) => Ok(serde_json::from_slice(&b).with_context(|| format!("{} does not parse", root.join(FRESH_DOCS).display()))?),
2821        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Default::default()),
2822        Err(e) => Err(e.into()),
2823    }
2824}
2825
2826/// **Take a group of entries that are durable in a twin's log, with ONE durable redb commit**
2827/// (perf twin-apply-fsync, 2026-10-06).
2828///
2829/// The control-plane writes of the whole group go into ONE redb write transaction
2830/// ([`gunnar_core::ControlDb::begin_group`]) and are committed once, with the applied marker
2831/// ([`gunnar_core::TWIN_APPLIED`]) at the group's last entry inside the same commit. A crash
2832/// anywhere leaves redb EXACTLY at its last durable marker — the group is in whole or not at all —
2833/// so the entries after the marker are re-taken in order onto exactly the state they were first
2834/// taken onto. That is what makes a re-take of more than one entry safe: a compare-and-swap the
2835/// primary LOST can only lose again when it is replayed onto the state it lost against. A write
2836/// that fails rolls the whole group back; the twin stops there and re-takes it next poll.
2837///
2838/// A `ControlDoc` the fresh-box rule still names is taken on its own (its `fresh_take` reads the
2839/// committed document and writes its own file durably): the group so far is committed first. A
2840/// run of data frames moves no redb page and is marked only every [`MARK_EVERY`] entries.
2841fn take_group(stores: &ServerStores, entries: &[(u64, u16, Vec<u8>)], marked: &std::sync::atomic::AtomicU64) -> anyhow::Result<()> {
2842    let db = stores.control_db();
2843    let mut fresh = fresh_names(stores.root())?;
2844    let mut done: Option<u64> = None;
2845    db.begin_group()?;
2846    // A panic inside the take must not leave the group's redb write transaction open for ever
2847    // (every other writer of this box's control database would wait on it, and the next take
2848    // would be refused "already open"): unwinding drops this, which rolls the group back. On
2849    // every ordinary path the group is already committed or aborted and this finds none.
2850    struct AbortOnDrop<'a>(&'a gunnar_core::ControlDb);
2851    impl Drop for AbortOnDrop<'_> {
2852        fn drop(&mut self) {
2853            self.0.abort_group();
2854        }
2855    }
2856    let _abort = AbortOnDrop(&db);
2857    let taken = (|| -> anyhow::Result<()> {
2858        for (seq, kind, meta) in entries {
2859            let (seq, kind) = (*seq, *kind);
2860            let fresh_doc = if fresh.is_empty() { None } else { doc_name(kind, meta).filter(|n| fresh.contains(n)) };
2861            let applied = if let Some(name) = fresh_doc {
2862                if let Some(prev) = done {
2863                    db.commit_group(gunnar_core::TWIN_APPLIED, prev, false)?;
2864                } else {
2865                    db.abort_group();
2866                }
2867                let a = take_logged(stores, seq, kind, meta)?;
2868                db.set_mark(gunnar_core::TWIN_APPLIED, seq)?;
2869                marked.store(seq, Ordering::SeqCst);
2870                fresh.remove(&name);
2871                db.begin_group()?;
2872                a
2873            } else {
2874                take_logged(stores, seq, kind, meta)?
2875            };
2876            match applied {
2877                Applied::Done | Applied::Stored => {}
2878                Applied::Lost(what) => tracing::debug!(target: gunnar_core::observe::TARGET_TWIN, seq, %what, "replayed a lost CAS"),
2879                Applied::Unknown(kind) => tracing::warn!(target: gunnar_core::observe::TARGET_TWIN, seq, kind, "kept a frame of a kind this build does not apply"),
2880            }
2881            done = Some(seq);
2882        }
2883        Ok(())
2884    })();
2885    match (taken, done) {
2886        (Ok(()), Some(last)) => {
2887            let force = last >= marked.load(Ordering::SeqCst).saturating_add(MARK_EVERY);
2888            // The group's one durable redb commit.
2889            if db.commit_group(gunnar_core::TWIN_APPLIED, last, force).context("committing the group's control-plane writes")? {
2890                marked.store(last, Ordering::SeqCst);
2891            }
2892            Ok(())
2893        }
2894        (Ok(()), None) => {
2895            db.abort_group();
2896            Ok(())
2897        }
2898        (Err(err), _) => {
2899            db.abort_group();
2900            Err(err)
2901        }
2902    }
2903}
2904
2905impl FrameSink for Appender<'_> {
2906    fn begin(&mut self, seq: u64, kind: u16, meta: &[u8], blob_len: u64) -> anyhow::Result<()> {
2907        if let Some((p, ..)) = &self.open {
2908            anyhow::bail!("the primary began seq {seq} with seq {} unfinished", p.seq());
2909        }
2910        if seq != self.next {
2911            return Err(anyhow!("the primary sent seq {seq} where {} was due", self.next));
2912        }
2913        // Into the twin's own log first — the primary's number.
2914        let pending = self.stores.txlog().begin(Some(seq), kind, meta, blob_len)?;
2915        self.open = Some((pending, kind, meta.to_vec(), blob_len));
2916        Ok(())
2917    }
2918
2919    fn piece(&mut self, bytes: &[u8]) -> anyhow::Result<()> {
2920        let (p, ..) = self.open.as_mut().ok_or_else(|| anyhow!("the primary sent blob bytes outside an entry"))?;
2921        let seq = p.seq();
2922        p.write(bytes).with_context(|| format!("seq {seq}: writing its blob into this box's log"))?;
2923        Ok(())
2924    }
2925
2926    fn end(&mut self) -> anyhow::Result<()> {
2927        let (p, kind, meta, blob_len) = self.open.take().ok_or_else(|| anyhow!("the primary ended an entry it never began"))?;
2928        if let Some(g) = self.group.as_mut() {
2929            // Whole in the log, durable with the rest of its group.
2930            let seq = p.commit_unsynced()?;
2931            if g.entries.is_empty() {
2932                g.since = std::time::Instant::now();
2933            }
2934            g.bytes += blob_len + meta.len() as u64;
2935            g.entries.push((seq, kind, meta));
2936            let full = g.full();
2937            self.next = seq + 1;
2938            self.count += 1;
2939            if full {
2940                self.flush()?;
2941            }
2942            return Ok(());
2943        }
2944        let seq = p.commit()?;
2945        self.reapply.store(true, Ordering::SeqCst);
2946        let applied = take_logged(self.stores, seq, kind, &meta)?;
2947        self.reapply.store(false, Ordering::SeqCst);
2948        match applied {
2949            Applied::Done => {}
2950            Applied::Stored => {}
2951            Applied::Lost(what) => tracing::debug!(target: gunnar_core::observe::TARGET_TWIN, seq, %what, "replayed a lost CAS"),
2952            Applied::Unknown(kind) => tracing::warn!(target: gunnar_core::observe::TARGET_TWIN, seq, kind, "kept a frame of a kind this build does not apply"),
2953        }
2954        self.next = seq + 1;
2955        self.count += 1;
2956        Ok(())
2957    }
2958}
2959
2960/// **The resync's own words** for the status (twin-resync-space, 2026-10-06).
2961fn resyncing_why(first: u64, own: u64, dropped: Option<&snapshot::Dropped>) -> String {
2962    match dropped {
2963        Some(d) => format!("taking a full copy from its primary (it retains from {first}) — {}", d.sentence()),
2964        None => format!(
2965            "taking a full copy from its primary (it retains from {first}; this twin holds up to {own}) — its previous \
2966             copy is kept until the new one is whole, and it is NOT a current copy until then"
2967        ),
2968    }
2969}
2970
2971/// **Give this twin's stale copy up for room** (twin-resync-space, 2026-10-06), called by the
2972/// stager ONCE when the volume runs short mid-resync. The [`snapshot::DROPPED`] marker is written
2973/// FIRST (durable): from then on the twin serves nothing and a crash restarts the resync. Then the
2974/// kept base and the log go ([`finish_giving_up`]); the head stays the twin's own. Said at WARN
2975/// as an ALERT, alarmed (`twin.resync_without_copy`), and written in the status. Answers the
2976/// bytes freed.
2977fn give_up_stale_copy(stores: &ServerStores, short: &snapshot::Short, first: u64, primary_head: u64, answered: i64) -> anyhow::Result<u64> {
2978    let root = stores.root();
2979    let log = stores.txlog();
2980    let head = log.head_seq();
2981    let base_at = snapshot::base_at(root).ok().flatten();
2982    let freed = snapshot::bytes_under(&root.join(snapshot::BASE)) + snapshot::bytes_under(&root.join(format!("{}.old", snapshot::BASE))) + log.bytes();
2983    let d = snapshot::Dropped { at_unix_ms: now_ms(), head, base_at, freed };
2984    snapshot::mark_dropped(root, &d)?;
2985    tracing::warn!(
2986        target: gunnar_core::observe::TARGET_TWIN,
2987        event = "twin.resync_without_copy",
2988        head,
2989        freed,
2990        staged = short.staged,
2991        free = short.free,
2992        total = short.total,
2993        "ALERT the full resync does not fit beside this twin's stale copy ({} staged, {} free of {}{}): {}",
2994        human(short.staged),
2995        human(short.free),
2996        human(short.total),
2997        if short.enospc { ", the volume said ENOSPC" } else { "" },
2998        d.sentence()
2999    );
3000    finish_giving_up(stores)?;
3001    set_status(root, TwinStatus {
3002        applied_seq: head,
3003        primary_head_seq: primary_head,
3004        state: State::Resyncing,
3005        why: resyncing_why(first, head, Some(&d)),
3006        last_answer_unix_ms: answered,
3007        last_level_unix_ms: 0,
3008    });
3009    Ok(freed)
3010}
3011
3012/// **The stale copy goes**: the kept base (and an `.old` one a crash left) and every entry of
3013/// the log — emptied at the twin's own head, so the numbers do not move. Idempotent: a give-up
3014/// a crash cut short is finished by calling it again.
3015fn finish_giving_up(stores: &ServerStores) -> anyhow::Result<()> {
3016    let root = stores.root();
3017    for dir in [root.join(snapshot::BASE), root.join(format!("{}.old", snapshot::BASE))] {
3018        match std::fs::remove_dir_all(&dir) {
3019            Ok(()) => {}
3020            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
3021            Err(e) => return Err(e).with_context(|| format!("giving up {}", dir.display())),
3022        }
3023    }
3024    let log = stores.txlog();
3025    let head = log.head_seq();
3026    if log.first_seq() <= head || log.bytes() > 0 {
3027        log.restart_after(head).context("giving up the stale log")?;
3028    }
3029    Ok(())
3030}
3031
3032impl Follower {
3033    /// A follower of `source` into `stores`.
3034    pub fn new(stores: Arc<ServerStores>, source: Box<dyn LogSource>) -> Self {
3035        Self {
3036            stores,
3037            source,
3038            fetch_bytes: DEFAULT_FETCH_BYTES,
3039            reapply: AtomicBool::new(true),
3040            failures: Arc::new(Mutex::new(None)),
3041            said_peak: std::sync::atomic::AtomicU64::new(0),
3042            marked: std::sync::atomic::AtomicU64::new(0),
3043            volume: Arc::new(snapshot::Disk),
3044        }
3045    }
3046
3047    /// Stage snapshots on `volume` instead of the box's own filesystem (the tests' capped
3048    /// volume, twin-resync-space 2026-10-06).
3049    pub fn with_volume(mut self, volume: Arc<dyn snapshot::Volume>) -> Self {
3050        self.volume = volume;
3051        self
3052    }
3053
3054    /// Share the run of failures with the follower before this one (the supervisor's
3055    /// re-dial starts a new follower on the same primary: the run goes on).
3056    pub fn sharing_failures(mut self, failures: Arc<Mutex<Option<Failing>>>) -> Self {
3057        self.failures = failures;
3058        self
3059    }
3060
3061    /// **The twin says its own memory** (twin-streaming, 2026-10-06), at INFO, after a poll that
3062    /// applied something — the first time, and again whenever the peak rose
3063    /// [`SAY_MEMORY_STEP`] above what it last said. A twin has no shell: this line, on its log
3064    /// door, is how an operator (and the UpCloud mockup) sees whether following a fill of large
3065    /// packs costs it the packs' size. The test2 twin that went silent on 1 GB under 256 MiB
3066    /// packs said nothing of the kind.
3067    fn say_memory(&self, head: u64) {
3068        let Some((peak, now)) = own_memory() else { return };
3069        let said = self.said_peak.load(Ordering::Relaxed);
3070        if !memory_is_news(said, peak) {
3071            return;
3072        }
3073        self.said_peak.store(peak, Ordering::Relaxed);
3074        tracing::info!(
3075            target: gunnar_core::observe::TARGET_TWIN,
3076            peak_mib = peak >> 20,
3077            rss_mib = now >> 20,
3078            head,
3079            "twin memory: peak resident set {} MiB, {} MiB now, following through seq {head}",
3080            peak >> 20,
3081            now >> 20
3082        );
3083    }
3084
3085    /// A poll answered: the end of a run of failures, said once.
3086    fn failures_ended(&self) {
3087        if let Some(f) = self.failures.lock().unwrap_or_else(|p| p.into_inner()).take() {
3088            tracing::info!(target: gunnar_core::observe::TARGET_TWIN, polls = f.polls, seconds = f.since.elapsed().as_secs(), "the primary answers again: following on after {} failed poll(s) over {} s", f.polls, f.since.elapsed().as_secs());
3089        }
3090    }
3091
3092    /// A poll failed: said at WARN when it starts a run (or every [`RESAY_FAILING`] of the
3093    /// same run), at DEBUG otherwise.
3094    fn failed(&self, err: &anyhow::Error) {
3095        // ★ This twin's OWN credential refused for skew: its clock or the primary's is wrong, and
3096        // the front is the judge — the same trigger a refusal on this box's own gate pulls,
3097        // rate-limited, carrying nothing from the refusal (test5's in-place upgrade, 2026-10-09:
3098        // the re-laid twin's clock stayed two hours off while every poll was refused).
3099        if refused_for_skew(err) {
3100            estate_clock::trigger::skew_refused(0);
3101        }
3102        let shape = failure_shape(err);
3103        let now = std::time::Instant::now();
3104        let mut g = self.failures.lock().unwrap_or_else(|p| p.into_inner());
3105        match g.as_mut() {
3106            Some(f) if f.shape == shape => {
3107                f.polls += 1;
3108                if now.duration_since(f.said) >= RESAY_FAILING {
3109                    f.said = now;
3110                    tracing::warn!(target: gunnar_core::observe::TARGET_TWIN, %err, polls = f.polls, seconds = now.duration_since(f.since).as_secs(), "poll still failing the same way; this twin keeps asking");
3111                } else {
3112                    tracing::debug!(target: gunnar_core::observe::TARGET_TWIN, %err, polls = f.polls, "poll failed (said already)");
3113                }
3114            }
3115            _ => {
3116                tracing::warn!(target: gunnar_core::observe::TARGET_TWIN, %err, "poll failed; retrying (said once while it fails this way)");
3117                *g = Some(Failing { shape, since: now, said: now, polls: 1 });
3118            }
3119        }
3120    }
3121
3122    /// How many blob bytes one fetch asks for (tests ask for tiny fetches
3123    /// to exercise the restart-mid-range path).
3124    pub fn with_fetch_bytes(mut self, bytes: u64) -> Self {
3125        self.fetch_bytes = bytes.max(1);
3126        self
3127    }
3128
3129    /// The twin's own head.
3130    pub fn applied_seq(&self) -> u64 {
3131        self.stores.txlog().head_seq()
3132    }
3133
3134    /// Apply the log's head entry again when it may not have landed (see `reapply`).
3135    /// Harmless when it had: every apply is idempotent or a replayed CAS that loses the same
3136    /// way. An entry that still does not apply stops the twin HERE, loudly, instead of
3137    /// being skipped.
3138    fn reapply_head(&self) -> anyhow::Result<()> {
3139        if !self.reapply.load(Ordering::SeqCst) {
3140            return Ok(());
3141        }
3142        if self.stores.is_twin() {
3143            return self.retake_from_marker();
3144        }
3145        let log = self.stores.txlog();
3146        let head = log.head_seq();
3147        if head > 0 && head >= log.first_seq() {
3148            // The header and meta only: a twin re-takes a data frame without reading its bytes.
3149            let at = log.locate_blob(head)?;
3150            take_logged(&self.stores, head, at.kind, &at.meta).with_context(|| format!("re-applying seq {head}, the head of this twin's log, before fetching anything newer"))?;
3151        }
3152        self.reapply.store(false, Ordering::SeqCst);
3153        Ok(())
3154    }
3155
3156    /// **A store-only twin re-takes what its log holds past its applied marker** (perf
3157    /// twin-apply-fsync, 2026-10-06) — in order, from exactly the state the marker vouches for
3158    /// ([`take_group`]): the log is durable before anything is taken, so after a crash the log
3159    /// may be a whole group ahead of redb, never behind. A twin from before the marker has none:
3160    /// it keeps the old rule (its head was the only entry that could be missing) and gets one.
3161    fn retake_from_marker(&self) -> anyhow::Result<()> {
3162        let log = self.stores.txlog();
3163        let db = self.stores.control_db();
3164        let head = log.head_seq();
3165        match db.mark(gunnar_core::TWIN_APPLIED)? {
3166            Some(m) if m >= head => {
3167                if m > head {
3168                    // The log was cut below what redb holds (it cannot be by this code: redb is
3169                    // only ever written after the log's fsync). Say so; follow from the log.
3170                    tracing::warn!(target: gunnar_core::observe::TARGET_TWIN, marker = m, head, "the applied marker is past this twin's log head; following on from the head");
3171                    db.set_mark(gunnar_core::TWIN_APPLIED, head)?;
3172                }
3173                self.marked.store(head, Ordering::SeqCst);
3174            }
3175            Some(m) => {
3176                self.marked.store(m, Ordering::SeqCst);
3177                let from = (m + 1).max(log.first_seq());
3178                let mut entries = Vec::with_capacity((head + 1).saturating_sub(from).min(1 << 16) as usize);
3179                for seq in from..=head {
3180                    let at = log.locate_blob(seq)?;
3181                    entries.push((seq, at.kind, at.meta));
3182                }
3183                take_group(&self.stores, &entries, &self.marked).with_context(|| format!("re-taking seq {from}..={head}, past this twin's applied marker {m}, before fetching anything newer"))?;
3184                if self.marked.load(Ordering::SeqCst) < head {
3185                    db.set_mark(gunnar_core::TWIN_APPLIED, head)?;
3186                    self.marked.store(head, Ordering::SeqCst);
3187                }
3188            }
3189            None => {
3190                if head > 0 && head >= log.first_seq() {
3191                    let at = log.locate_blob(head)?;
3192                    take_logged(&self.stores, head, at.kind, &at.meta).with_context(|| format!("re-applying seq {head}, the head of this twin's log, before fetching anything newer"))?;
3193                }
3194                db.set_mark(gunnar_core::TWIN_APPLIED, head)?;
3195                self.marked.store(head, Ordering::SeqCst);
3196            }
3197        }
3198        self.reapply.store(false, Ordering::SeqCst);
3199        Ok(())
3200    }
3201
3202    /// One poll: head, the gap rule, fetch-append-apply until level.
3203    ///
3204    /// **A poll that reached the primary and then failed says so in the status**
3205    /// (2026-09-21, lane T4). Only the `head()` call used to set a status on
3206    /// failure. Everything after it — `append_at` on a full volume, an `apply`
3207    /// that will not go in, a fetch that dies mid-range — propagated with `?`,
3208    /// and the STATUS WAS LEFT EXACTLY AS THE LAST GOOD POLL SET IT: `following`,
3209    /// often `caught_up`, with an empty `why`. `run` logged one `warn` and
3210    /// polled again. So the shape a twin's life actually ends in — its data
3211    /// volume filling — read as a healthy, current copy on both `twin status`
3212    /// and the primary's heartbeat, for as long as the box stayed up. A twin's
3213    /// whole job is to be trustworthy at the moment nobody has time to check.
3214    pub fn step(&self) -> anyhow::Result<Progress> {
3215        // `step_inner` sets this when it has already written a status of its
3216        // own for the failure it is returning (`unreachable` from `head()`,
3217        // `snapshot-needed` from a half-installed snapshot). Every OTHER error
3218        // path is one this twin used to keep quiet about.
3219        let mut said_why = false;
3220        // What THIS poll's answer said, when it got one: the primary's head and
3221        // when. A poll that reached the primary and then failed must record the
3222        // answer it got — it used to record the PREVIOUS poll's, so a twin that
3223        // stalled behind a primary still taking writes reported an older head
3224        // and an older time than the primary had actually just given it.
3225        let mut heard: Option<(u64, i64)> = None;
3226        let r = self.step_inner(&mut said_why, &mut heard);
3227        if let (Err(err), false) = (&r, said_why) {
3228            let last = status(self.stores.root());
3229            let (primary_head_seq, last_answer_unix_ms) = heard.unwrap_or((
3230                last.as_ref().map(|s| s.primary_head_seq).unwrap_or(0),
3231                last.as_ref().map(|s| s.last_answer_unix_ms).unwrap_or(0),
3232            ));
3233            // ★ cov-13: the primary refused to let this twin follow into a volume smaller than
3234            // main's grown set. Not a stall: a debt, said in the primary's own words.
3235            let said = format!("{err:#}");
3236            // ★ twin-resync-space: a twin that gave its stale copy up says so on every failure
3237            // after it, until the resync lands.
3238            let no_copy = snapshot::dropped(self.stores.root()).map(|d| format!(" — {}", d.sentence())).unwrap_or_default();
3239            if let Some(at) = said.find(gunnar_grpc::TWIN_TOO_SMALL) {
3240                set_status(self.stores.root(), TwinStatus {
3241                    applied_seq: self.applied_seq(),
3242                    primary_head_seq,
3243                    state: State::Recovering,
3244                    why: format!("{}{no_copy}", &said[at..]),
3245                    last_answer_unix_ms,
3246                    last_level_unix_ms: 0,
3247                });
3248                return r;
3249            }
3250            let holds = if no_copy.is_empty() { "what it holds stops here".to_owned() } else { format!("it holds NO copy{no_copy}") };
3251            set_status(self.stores.root(), TwinStatus {
3252                applied_seq: self.applied_seq(),
3253                primary_head_seq,
3254                state: State::Stalled,
3255                why: format!("this twin could not take what its primary sent — it is NOT a current copy, and {holds}: {err:#}"),
3256                last_answer_unix_ms,
3257                last_level_unix_ms: 0,
3258            });
3259        }
3260        r
3261    }
3262
3263    fn step_inner(&self, said_why: &mut bool, heard: &mut Option<(u64, i64)>) -> anyhow::Result<Progress> {
3264        // A crash mid-install: the stores are half a snapshot; only a whole one fixes that.
3265        let installing = snapshot::installing(self.stores.root());
3266        if !installing {
3267            self.reapply_head()?;
3268        }
3269        let (first, primary_head, their_id, their_generation) = match self.source.head_identity_generation() {
3270            Ok(h) => h,
3271            Err(err) => {
3272                // **An unanswered poll keeps what the last answer said** — the
3273                // primary's head AND the time (2026-09-21, lane T4). This used
3274                // to write `primary_head_seq: 0`, so the moment a primary died
3275                // the twin that had followed it to 221 reported "a primary head
3276                // it last saw at 0" (MEASURED under KVM by T3) — and a restore
3277                // guard reading that could not tell a complete copy from an
3278                // empty one. The primary saying nothing now is not the primary
3279                // having had nothing then.
3280                let last = status(self.stores.root());
3281                set_status(self.stores.root(), TwinStatus {
3282                    applied_seq: self.applied_seq(),
3283                    primary_head_seq: last.as_ref().map(|s| s.primary_head_seq).unwrap_or(0),
3284                    state: State::Unreachable,
3285                    why: err.to_string(),
3286                    last_answer_unix_ms: last.as_ref().map(|s| s.last_answer_unix_ms).unwrap_or(0),
3287                    last_level_unix_ms: 0,
3288                });
3289                *said_why = true;
3290                return Err(err);
3291            }
3292        };
3293        let answered = now_ms();
3294        let own = self.applied_seq();
3295        *heard = Some((primary_head, answered));
3296        // ── the identity check (2026-09-21, lane T4) ────────────────────────
3297        //
3298        // **Before any number is believed, the box that gave it is asked which
3299        // log it is.** The sequence rule below catches a primary that is BEHIND
3300        // this twin. It cannot catch one that is AHEAD of it for the wrong
3301        // reason: a re-imaged main at the same address, left running for an
3302        // afternoon, passes the twin's head, and from then on every number
3303        // agrees — `first <= wanted`, `wanted <= primary_head` — and the twin
3304        // splices one history onto another and applies a stranger's frames over
3305        // the only surviving copy of its own. No arithmetic can see that. Only
3306        // a name can, so [`LOG_ID`] gives the log one.
3307        //
3308        // Three cases, and the middle one is the join:
3309        //   * both named and different — DIVERGED, and it stays stopped,
3310        //   * this root unnamed — pin what the source says (the first poll
3311        //     after `become_twin`, which dropped this box's own id),
3312        //   * the source unnamed — a primary older than this change. Nothing is
3313        //     claimed and nothing is refused; the numbers are all there is, as
3314        //     they were before. Saying so beats inventing a verdict.
3315        if let Some(theirs) = their_id.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
3316            match log_id(self.stores.root()) {
3317                Some(mine) if mine != theirs => {
3318                    let why = format!(
3319                        "the box at the other end is log {theirs}; this twin holds log {mine}. Two different histories, \
3320                         whatever their sequence numbers say — this is a re-imaged or reinstalled primary at a familiar \
3321                         address, or an address that now points at another set. NOTHING is applied on top of it, and \
3322                         retrying cannot fix it: either point this twin at the box that holds {mine}, or — if {mine} is \
3323                         gone — restore a primary FROM this twin, which keeps the id, and it will follow again."
3324                    );
3325                    set_status(self.stores.root(), TwinStatus {
3326                        applied_seq: own,
3327                        primary_head_seq: primary_head,
3328                        state: State::Diverged,
3329                        why,
3330                        last_answer_unix_ms: answered,
3331                        last_level_unix_ms: 0,
3332                    });
3333                    return Ok(Progress::Diverged { own, primary_head });
3334                }
3335                Some(_) => {}
3336                None => {
3337                    pin_log_id(self.stores.root(), theirs).context("pinning the primary's log identity on this twin")?;
3338                    tracing::info!(target: gunnar_core::observe::TARGET_TWIN, log_id = %theirs, "this twin now knows which log it follows");
3339                }
3340            }
3341        }
3342        // ── the generation fence (stinky row 18, 2026-10-10) ───────────────
3343        //
3344        // **A restored main keeps the id and the key, so the OLD main is the
3345        // same name too.** If it comes back after its replacement was restored
3346        // from this twin, and it took writes the twin never saw (or takes them
3347        // now), its head passes this twin's and every check above and below
3348        // agrees: the twin would splice the dead main's lost writes onto the
3349        // restored history. A restore stamps a newer [`LOG_GENERATION`]; this
3350        // twin pins the highest it has followed and refuses a lower one. A
3351        // fence that cannot be read refuses the poll rather than reading as 0.
3352        let pinned = pinned_generation(self.stores.root())?;
3353        if their_generation < pinned {
3354            let why = format!(
3355                "the box at the other end is generation {their_generation} of this log; this twin already follows generation \
3356                 {pinned}, a later restore of it. This is the OLD main come back after its replacement was restored: the same \
3357                 log id and the same key, so nothing else can tell them apart. NOTHING is applied from it — its writes since \
3358                 the restore belong to no history; this poll handed it the proof, so it fences itself ({LOG_FENCED}). Point \
3359                 this twin back at the restored main, and retire the old box."
3360            );
3361            set_status(self.stores.root(), TwinStatus {
3362                applied_seq: own,
3363                primary_head_seq: primary_head,
3364                state: State::Diverged,
3365                why,
3366                last_answer_unix_ms: answered,
3367                last_level_unix_ms: 0,
3368            });
3369            return Ok(Progress::Diverged { own, primary_head });
3370        }
3371        // ── the lineage check (2026-09-21, lane T4) ─────────────────────────
3372        // **A log's head only ever goes forward.** Retention moves `first`; a
3373        // restore continues the same numbers; nothing in gunnar rewinds a head.
3374        // So a primary answering a head BELOW what this twin has already applied
3375        // is not the log this twin has been following — a re-imaged box, a
3376        // restore from somewhere else, or an address that now points at a
3377        // different set.
3378        //
3379        // MEASURED on master 2026-09-21, before this: that case fell straight
3380        // through. `wanted <= primary_head` is false, so the snapshot branch and
3381        // the snapshot-needed refusal are both skipped, the fetch loop's
3382        // condition is false, and the poll ended at the bottom of `step` setting
3383        // `State::Following` with an EMPTY `why`. On the primary's side the lag
3384        // is `head_seq.saturating_sub(applied)` = 0, so the heartbeat read `ok`.
3385        // BOTH ENDS reported a healthy pairing, for ever, while not one entry
3386        // would ever be applied again and the twin's contents belonged to
3387        // another box. A restore leaning on that twin is the worst shape this
3388        // repo knows — and re-imaging a main is a thing this estate does.
3389        if !installing && primary_head < own {
3390            let why = format!(
3391                "the primary answers head {primary_head}, BELOW the {own} this twin has already applied. A log's head never goes \
3392                 backwards, so this is NOT the log this twin was following (a re-imaged or restored primary, or an address that now \
3393                 points at a different set). Nothing is applied on top of it; an operator decides whether to re-join from scratch."
3394            );
3395            set_status(self.stores.root(), TwinStatus {
3396                applied_seq: own,
3397                primary_head_seq: primary_head,
3398                state: State::Diverged,
3399                why,
3400                last_answer_unix_ms: answered,
3401                last_level_unix_ms: 0,
3402            });
3403            return Ok(Progress::Diverged { own, primary_head });
3404        }
3405        // A newer generation that passed every check above is this twin's
3406        // primary from now on; the pin only ever moves up.
3407        if their_generation > pinned {
3408            pin_generation(self.stores.root(), their_generation).context("pinning the primary's log generation on this twin")?;
3409            tracing::info!(target: gunnar_core::observe::TARGET_TWIN, generation = their_generation, "this twin now follows generation {their_generation} of its log and refuses any older one");
3410        }
3411        let wanted = own + 1;
3412        if installing || (wanted <= primary_head && first > wanted) {
3413            let root = self.stores.root();
3414            // ★ twin-resync-space (2026-10-06): a twin makes ROOM for the resync instead of
3415            // filling its volume part-way (see `snapshot`'s "Room on a twin's volume").
3416            let twin = self.stores.is_twin();
3417            let mut given_up = false;
3418            if twin {
3419                // A resync this volume could not hold even alone is not asked for again until
3420                // the volume is larger: every snapshot holds main's writes for its whole stream.
3421                if let Some(was) = snapshot::too_small_total(root) {
3422                    if self.volume.space(root).is_some_and(|(total, _)| total <= was) {
3423                        let no_copy = snapshot::dropped(root).map(|d| format!(" — {}", d.sentence())).unwrap_or_default();
3424                        anyhow::bail!(
3425                            "{} this twin's last full resync ran out of room on its {} volume with its stale copy given up; it asks main for another only once its volume is larger (main holds its writes for every snapshot stream){no_copy}",
3426                            gunnar_grpc::TWIN_TOO_SMALL,
3427                            human(was)
3428                        );
3429                    }
3430                }
3431                // A give-up a crash cut short is finished before anything is staged: the marker
3432                // says the copy is gone, so all of it goes.
3433                if snapshot::dropped(root).is_some() {
3434                    finish_giving_up(&self.stores)?;
3435                    given_up = true;
3436                }
3437                set_status(root, TwinStatus {
3438                    applied_seq: own,
3439                    primary_head_seq: primary_head,
3440                    state: State::Resyncing,
3441                    why: resyncing_why(first, own, snapshot::dropped(root).as_ref()),
3442                    last_answer_unix_ms: answered,
3443                    last_level_unix_ms: 0,
3444                });
3445            }
3446            let mut stager = snapshot::Stager::begin_on(root, Arc::clone(&self.volume))?;
3447            if twin {
3448                let stores = Arc::clone(&self.stores);
3449                stager = stager.making_room(Box::new(move |short: &snapshot::Short| give_up_stale_copy(&stores, short, first, primary_head, answered)), given_up);
3450            }
3451            let fetched = self.source.snapshot(&mut stager);
3452            if let Err(err) = &fetched {
3453                if let Some(t) = err.downcast_ref::<snapshot::TooSmall>() {
3454                    snapshot::note_too_small(root, t)?;
3455                }
3456            }
3457            if fetched.context("fetching a snapshot to close the hole")? {
3458                let snap = stager.finish()?;
3459                let was_given_up = snapshot::dropped(root).is_some();
3460                snapshot::install(&self.stores, &snap)?;
3461                // The installed state IS the state through `at`.
3462                self.stores.control_db().set_mark(gunnar_core::TWIN_APPLIED, snap.at)?;
3463                self.marked.store(snap.at, Ordering::SeqCst);
3464                self.reapply.store(false, Ordering::SeqCst);
3465                let whole_again = if was_given_up { "; the stale copy given up for it is replaced — this twin holds a whole copy again" } else { "" };
3466                set_status(root, TwinStatus {
3467                    applied_seq: snap.at,
3468                    primary_head_seq: primary_head.max(snap.at),
3469                    state: State::Following,
3470                    why: format!("installed a snapshot at {} (the source retained from {first}){whole_again}", snap.at),
3471                    last_answer_unix_ms: answered,
3472                    last_level_unix_ms: 0,
3473                });
3474                return Ok(Progress::SnapshotInstalled { at: snap.at });
3475            }
3476        }
3477        // **The marker outlives the attempt.** `snapshot()` answering `false` — a source that
3478        // cannot make one — used to fall straight through to the fetch loop below, because the
3479        // `snapshot-needed` refusal after this is guarded by the RETENTION test and a source
3480        // that retains everything passes it. MEASURED 2026-09-16: a twin with the install
3481        // marker on its disk and a bare-log source applied 29 entries onto stores `install` had
3482        // already wiped, and said nothing. Half a snapshot is not a state to follow on from,
3483        // and only a whole one replaces it.
3484        if installing {
3485            let why = match snapshot::dropped(self.stores.root()) {
3486                Some(d) => format!(
3487                    "{} — and the source cannot make a snapshot to replace it; nothing is applied on \
3488                     top of an empty copy",
3489                    d.sentence()
3490                ),
3491                None => format!(
3492                    "this box crashed inside a snapshot install ({}/{}) and the source cannot make a \
3493                     snapshot to replace it; half a snapshot is not a state to follow on from, so \
3494                     nothing is applied on top of it",
3495                    self.stores.root().display(),
3496                    snapshot::INSTALLING
3497                ),
3498            };
3499            set_status(self.stores.root(), TwinStatus {
3500                applied_seq: own,
3501                primary_head_seq: primary_head,
3502                state: State::SnapshotNeeded,
3503                why: why.clone(),
3504                last_answer_unix_ms: answered,
3505                last_level_unix_ms: 0,
3506            });
3507            *said_why = true;
3508            return Err(anyhow!(why));
3509        }
3510        if wanted <= primary_head && first > wanted {
3511            set_status(self.stores.root(), TwinStatus {
3512                applied_seq: own,
3513                primary_head_seq: primary_head,
3514                state: State::SnapshotNeeded,
3515                why: format!("the primary retains from {first}; this twin holds up to {own} and needs {wanted} next"),
3516                last_answer_unix_ms: answered,
3517                last_level_unix_ms: 0,
3518            });
3519            return Ok(Progress::SnapshotNeeded { wanted, first });
3520        }
3521        let mut count = 0u64;
3522        let mut next = wanted;
3523        while next <= primary_head {
3524            // **Streamed, never gathered** (twin-streaming, 2026-10-06): each entry goes into this
3525            // box's own log piece by piece as it arrives and exists once its last byte is in; an
3526            // entry the fetch did not finish is cut off the segment when `into` drops.
3527            let group = self.stores.is_twin().then(Group::new);
3528            let mut into = Appender { stores: &self.stores, reapply: &self.reapply, open: None, next, count: 0, group, marked: &self.marked };
3529            let fetched = self.source.fetch_into(next, primary_head, self.fetch_bytes, &mut into);
3530            // Whatever arrived whole is made durable and taken, whether or not the fetch ended well.
3531            let flushed = into.flush();
3532            let (taken, after) = (into.count, into.next);
3533            drop(into);
3534            count += taken;
3535            fetched?;
3536            flushed?;
3537            if after == next {
3538                return Err(anyhow!("the primary answered an empty range for {next}..={primary_head}"));
3539            }
3540            next = after;
3541        }
3542        let head = self.applied_seq();
3543        set_status(self.stores.root(), TwinStatus {
3544            applied_seq: head,
3545            primary_head_seq: primary_head,
3546            state: State::Following,
3547            why: String::new(),
3548            last_answer_unix_ms: answered,
3549            last_level_unix_ms: 0,
3550        });
3551        Ok(if count == 0 { Progress::CaughtUp { head } } else { Progress::Applied { count, head } })
3552    }
3553
3554    /// Poll until `stop`; an unreachable primary is retried every `poll`, a
3555    /// snapshot-needed stops the loop and leaves the status saying so. What it
3556    /// returns says why it returned ([`Ended`]).
3557    pub fn run(&self, poll: Duration, stop: &AtomicBool) -> Ended {
3558        let hold = maintenance::hold_of(self.stores.root());
3559        let mut failed_in_a_row = 0u32;
3560        // M2: the primary answered `Unavailable [primary-growing]` — said once, waited on.
3561        let mut primary_growing = false;
3562        // ★ cov-13: the primary refused this twin as too small for main's grown set — said
3563        // once, waited on at the same walking pace, never re-dialled.
3564        let mut recovering = false;
3565        while !stop.load(Ordering::Relaxed) {
3566            // A step runs under `busy`, so `maintenance::hold` returns only once no
3567            // fetch/append/apply is in flight — the grow machine unmounts after that.
3568            let busy = hold.busy.lock().unwrap_or_else(|p| p.into_inner());
3569            if hold.held.load(Ordering::SeqCst) {
3570                drop(busy);
3571                self.maintenance_beat();
3572                nap(poll, stop);
3573                continue;
3574            }
3575            let stepped = self.step();
3576            drop(busy);
3577            // T1 (estate-box check 2): a poll that APPLIED something asks again at once.
3578            // Its `LogHead` carried the report taken BEFORE the fetch, so the primary's
3579            // heartbeat reads this twin one poll behind until the next ask; and a primary
3580            // still taking writes has more to give. Only a caught-up (or failed) poll
3581            // waits the interval — progress is bounded by the primary's own writes.
3582            let mut again = false;
3583            match stepped {
3584                Ok(Progress::SnapshotNeeded { wanted, first }) => {
3585                    tracing::error!(target: gunnar_core::observe::TARGET_TWIN, wanted, first, "snapshot needed — the twin stops rather than append past a hole");
3586                    return Ended::SnapshotNeeded;
3587                }
3588                Ok(Progress::Diverged { own, primary_head }) => {
3589                    tracing::error!(target: gunnar_core::observe::TARGET_TWIN, own, primary_head, "DIVERGED — the primary's head is below this twin's; the twin stops rather than pretend to follow a different log");
3590                    return Ended::Diverged;
3591                }
3592                Ok(p) => {
3593                    failed_in_a_row = 0;
3594                    self.failures_ended();
3595                    again = matches!(p, Progress::Applied { .. } | Progress::SnapshotInstalled { .. });
3596                    if let Progress::Applied { head, .. } = p {
3597                        self.say_memory(head);
3598                    }
3599                    if primary_growing {
3600                        primary_growing = false;
3601                        tracing::info!(target: gunnar_core::observe::TARGET_TWIN, "the primary's data set is back: following on");
3602                    }
3603                    if recovering {
3604                        recovering = false;
3605                        tracing::warn!(target: gunnar_core::observe::TARGET_TWIN, "this twin's volume holds main's set again: following on");
3606                    }
3607                }
3608                // M2 (release-3 test2 mock 2026-09-29): a primary growing its own set is not
3609                // a fault and not a reason to re-dial: said ONCE at INFO, waited on at a
3610                // walking pace, never handed back to the supervisor.
3611                Err(err) if format!("{err:#}").contains(gunnar_grpc::PRIMARY_GROWING) => {
3612                    failed_in_a_row = 0;
3613                    if !primary_growing {
3614                        primary_growing = true;
3615                        tracing::info!(target: gunnar_core::observe::TARGET_TWIN, "the primary is growing its data set: this twin waits and follows on after the remount");
3616                    }
3617                    for _ in 0..GROWING_WAIT_POLLS {
3618                        if stop.load(Ordering::Relaxed) {
3619                            break;
3620                        }
3621                        nap(poll, stop);
3622                    }
3623                    continue;
3624                }
3625                // ★ cov-13: owed a growth before it may follow. The primary is fine and so is
3626                // the connection: wait, polling (the head and this twin's report go with every
3627                // poll, so the primary and monetize see it heard and growable).
3628                Err(err) if format!("{err:#}").contains(gunnar_grpc::TWIN_TOO_SMALL) => {
3629                    failed_in_a_row = 0;
3630                    if !recovering {
3631                        recovering = true;
3632                        tracing::warn!(target: gunnar_core::observe::TARGET_TWIN, %err, "recovering: the primary will not let this twin follow until its volume holds main's grown set; it keeps polling and follows once grown");
3633                    }
3634                    for _ in 0..GROWING_WAIT_POLLS {
3635                        if stop.load(Ordering::Relaxed) {
3636                            break;
3637                        }
3638                        nap(poll, stop);
3639                    }
3640                    continue;
3641                }
3642                // T1: the connection went quiet under the call (a path that died beneath
3643                // it: vm1's relay restarted, the TCP back-off holding the next segment for
3644                // seconds). The connection is the broken thing, so it is dropped AT ONCE:
3645                // the supervisor dials afresh, the first address that answers.
3646                Err(err) if format!("{err:#}").contains(STALLED) => {
3647                    tracing::info!(target: gunnar_core::observe::TARGET_TWIN, %err, "the primary said nothing on this connection: re-dialling it now");
3648                    return Ended::Redial;
3649                }
3650                Err(err) => {
3651                    failed_in_a_row += 1;
3652                    self.failed(&err);
3653                    // The primary has another address (tunnel / utility): hand
3654                    // back to the supervisor, which re-dials the first that answers.
3655                    if self.source.alternatives() && failed_in_a_row >= FAILOVER_AFTER {
3656                        tracing::info!(target: gunnar_core::observe::TARGET_TWIN, failed_in_a_row, "re-dialling the primary on the address that answers (tunnel or utility network)");
3657                        return Ended::Redial;
3658                    }
3659                }
3660            }
3661            if !again {
3662                nap(poll, stop);
3663            }
3664        }
3665        Ended::Stopped
3666    }
3667
3668    /// **One poll while this twin grows its own set**: the status says
3669    /// `maintenance` FIRST (so the report on this very poll carries the word), then
3670    /// the primary is asked for its head only — nothing is fetched, appended or
3671    /// applied, so nothing touches the unmounted set.
3672    pub fn maintenance_beat(&self) {
3673        let root = self.stores.root();
3674        let last = status(root);
3675        let mut s = TwinStatus {
3676            applied_seq: self.applied_seq(),
3677            primary_head_seq: last.as_ref().map(|s| s.primary_head_seq).unwrap_or(0),
3678            state: State::Maintenance,
3679            why: maintenance::WHY.to_owned(),
3680            last_answer_unix_ms: last.as_ref().map(|s| s.last_answer_unix_ms).unwrap_or(0),
3681            last_level_unix_ms: 0,
3682        };
3683        set_status(root, s.clone());
3684        if let Ok((_, head)) = self.source.head() {
3685            s.primary_head_seq = head;
3686            s.last_answer_unix_ms = now_ms();
3687            set_status(root, s);
3688        }
3689    }
3690}
3691
3692/// **The twin's own data-set growth** (T21, 2026-09-22). Owner ruling: the twin,
3693/// built from the same medium, behaves EXACTLY like main and never restarts on a
3694/// growth; `maintenance` is the word it reports meanwhile. The grow machine calls
3695/// [`hold`] where main pauses its admission gate and [`release`] where main
3696/// unpauses it, so the twin drains (no step in flight), unmounts, grows and
3697/// follows again — the SAME engine, no stop, no start.
3698pub mod maintenance {
3699    use super::*;
3700
3701    /// The `why` a held twin reports.
3702    pub const WHY: &str = "this twin is growing its own data set (the same runtime growth as main: drained, \
3703                           unmounted, grown, mounted back — never restarted); it applies nothing until the set \
3704                           is back, then follows on from its own head";
3705
3706    pub(super) struct Hold {
3707        pub(super) held: AtomicBool,
3708        pub(super) busy: Mutex<()>,
3709    }
3710
3711    static HOLDS: OnceLock<Mutex<HashMap<PathBuf, Arc<Hold>>>> = OnceLock::new();
3712
3713    pub(super) fn hold_of(root: &Path) -> Arc<Hold> {
3714        let mut m = HOLDS.get_or_init(|| Mutex::new(HashMap::new())).lock().unwrap_or_else(|p| p.into_inner());
3715        Arc::clone(m.entry(root.to_path_buf()).or_insert_with(|| Arc::new(Hold { held: AtomicBool::new(false), busy: Mutex::new(()) })))
3716    }
3717
3718    /// **Hold the follower of `root` for a growth** and return once no step is in
3719    /// flight. The status says `maintenance` from this call on, follower running or not.
3720    pub fn hold(root: &Path) {
3721        let h = hold_of(root);
3722        h.held.store(true, Ordering::SeqCst);
3723        drop(h.busy.lock().unwrap_or_else(|p| p.into_inner()));
3724        let last = status(root);
3725        set_status(root, TwinStatus {
3726            applied_seq: last.as_ref().map(|s| s.applied_seq).unwrap_or(0),
3727            primary_head_seq: last.as_ref().map(|s| s.primary_head_seq).unwrap_or(0),
3728            state: State::Maintenance,
3729            why: WHY.to_owned(),
3730            last_answer_unix_ms: last.as_ref().map(|s| s.last_answer_unix_ms).unwrap_or(0),
3731            last_level_unix_ms: 0,
3732        });
3733        tracing::warn!(target: gunnar_core::observe::TARGET_TWIN, root = %root.display(), "maintenance: the twin's follower is held for its own data-set growth");
3734    }
3735
3736    /// The set is back: the next poll follows again.
3737    pub fn release(root: &Path) {
3738        hold_of(root).held.store(false, Ordering::SeqCst);
3739        tracing::warn!(target: gunnar_core::observe::TARGET_TWIN, root = %root.display(), "maintenance over: the twin follows again");
3740    }
3741
3742    /// Is the twin at `root` held for a growth?
3743    pub fn is_held(root: &Path) -> bool {
3744        hold_of(root).held.load(Ordering::SeqCst)
3745    }
3746
3747    #[cfg(test)]
3748    mod tests;
3749}
3750
3751// ── following over the wire ─────────────────────────────────────────────────
3752
3753/// **A tokio runtime that is DROPPED ON A THREAD OF ITS OWN.**
3754///
3755/// MEASURED 2026-09-23 (T19r, the live gunnar.rs estate). The twin came up,
3756/// printed
3757///
3758/// ```text
3759/// gunnar::twin: following the primary primary=10.13.7.223:50051
3760/// gunnar::twin: this twin's client certificate is good for 364 more day(s)
3761/// ```
3762///
3763/// and then died:
3764///
3765/// ```text
3766/// gunnar PANIC (exit 101): panicked at tokio/src/runtime/blocking/shutdown.rs:51:21:
3767/// Cannot drop a runtime in a context where blocking is not allowed.
3768/// This happens when a runtime is dropped from within an asynchronous context.
3769/// Kernel panic - not syncing: Attempted to kill init! exitcode=0x00006500
3770/// ```
3771///
3772/// [`GrpcSource`] builds its own current-thread runtime and keeps it, because
3773/// the follower's `head`/`fetch`/`snapshot` are blocking calls on a blocking
3774/// loop. Dropping that runtime is a BLOCKING operation — it joins the runtime's
3775/// threads — and tokio refuses it from inside another runtime's async context.
3776/// The follower is constructed and dropped from the server, which is async, so
3777/// every path that drops the source panicked. On the appliance gunnar is PID 1,
3778/// so the panic took the kernel with it: a twin that could not be followed was
3779/// a box that could not stay alive.
3780///
3781/// The fix is the documented one and the smallest: the runtime is moved out in
3782/// `Drop` and dropped on a thread created for that purpose, where blocking is
3783/// allowed. `shutdown_background` was the other candidate and is not used — it
3784/// may leak the runtime's worker threads, and a follower that is rebuilt on
3785/// every reconnect would leak one set per attempt.
3786struct OffThreadRuntime(Option<tokio::runtime::Runtime>);
3787
3788impl OffThreadRuntime {
3789    fn new(rt: tokio::runtime::Runtime) -> Self {
3790        OffThreadRuntime(Some(rt))
3791    }
3792
3793    /// The runtime, which is present for the whole of this value's life: only
3794    /// [`Drop`] takes it, and nothing can call this afterwards.
3795    fn get(&self) -> &tokio::runtime::Runtime {
3796        self.0.as_ref().expect("the follower's runtime is taken only by Drop")
3797    }
3798
3799    fn block_on<F: std::future::Future>(&self, f: F) -> F::Output {
3800        self.get().block_on(f)
3801    }
3802}
3803
3804impl Drop for OffThreadRuntime {
3805    fn drop(&mut self) {
3806        let Some(rt) = self.0.take() else { return };
3807        // The thread is not joined: joining it here would block in exactly the
3808        // context this exists to avoid.
3809        match std::thread::Builder::new().name("twin-rt-drop".to_string()).spawn(move || drop(rt)) {
3810            Ok(_) => {}
3811            Err(e) => {
3812                // A box that cannot spawn a thread is in no state to follow
3813                // anything. Say so and LEAK the runtime: panicking in a
3814                // destructor aborts the process, which is the failure this
3815                // whole type exists to stop.
3816                tracing::error!(
3817                    target: gunnar_core::observe::TARGET_TWIN, error = %e,
3818                    "the follower's runtime could not be dropped on a thread of its own; it is leaked rather than dropped here"
3819                );
3820            }
3821        }
3822    }
3823}
3824
3825/// `Mirror.LogHead` / `Mirror.LogFetch` against the primary, signed with the
3826/// twin's own key by the one client signer (`gunnar_grpc::client_signer`).
3827pub struct GrpcSource {
3828    rt: OffThreadRuntime,
3829    client: Mutex<gunnar_proto::mirror_client::MirrorClient<gunnar_grpc::client_signer::ControlChannel>>,
3830    signer: gunnar_core::ssh_key::PrivateKey,
3831    /// The twin's half of the dual heartbeat, sent on every `LogHead`.
3832    reporter: Option<Box<dyn Fn() -> gunnar_proto::TwinReport + Send + Sync>>,
3833    /// This twin's data root — where [`PRIMARY_KEY`] is pinned and read back.
3834    root: std::path::PathBuf,
3835    /// The primary named more than one replication host (`<tunnel>,<utility>`):
3836    /// a follower that keeps failing on this one should re-dial.
3837    alternatives: bool,
3838}
3839
3840fn say(status: impl std::fmt::Display) -> anyhow::Error {
3841    anyhow!("{status}")
3842}
3843
3844impl GrpcSource {
3845    /// A source for the primary at `at` (`host:port`), as `signer`, for the twin
3846    /// whose data root is `root`.
3847    ///
3848    /// **`root` is not decoration**: it is where the key this primary answers
3849    /// with is pinned on the first poll and read back on every one after
3850    /// ([`PRIMARY_KEY`]). A source that could not say which twin it belongs to
3851    /// could not enforce anything, so it is a parameter rather than an optional
3852    /// builder — there is no such thing here as a follower without a data root.
3853    ///
3854    /// # Mutual TLS whenever the primary is another machine
3855    ///
3856    /// The `root` pinning above answers **who** and **which history** — the
3857    /// primary's sshsig over `gunnar-log-head-v1`, TOFU-pinned, and the
3858    /// `log_id` carried on every poll. Those are settled and nothing here
3859    /// re-solves them. What they do not answer is **confidentiality**: this
3860    /// channel carries the primary's transaction log, and *seq 1 of that log is
3861    /// the KEK*. A signed answer proves the bytes are genuine; it does not stop
3862    /// anyone on the path from reading every sealed secret in the estate as
3863    /// they go by. Until 2026-09-21 they went by in clear text.
3864    ///
3865    /// So the channel is encrypted too, and the rule has no flag:
3866    ///
3867    /// * a **loopback** primary (the test harness, a solo box following itself)
3868    ///   stays plaintext — the bytes never reach a wire;
3869    /// * any **other** primary needs [`ClientTrust`](gunnar_grpc::client_signer::ClientTrust)
3870    ///   from `GUNNAR_CLIENT_CERT`/`GUNNAR_CLIENT_KEY`/`GUNNAR_CA`, and a
3871    ///   follower without it REFUSES to start rather than following in the
3872    ///   clear. `plain_channel` refuses the same target for the same reason;
3873    ///   this refusal exists so the twin says which three files are missing
3874    ///   instead of failing at the transport with the address alone.
3875    ///
3876    /// The channel is built **once** and reused for every poll: `connect_lazy`
3877    /// hands back a `Channel` that opens its HTTP/2 connection on first use and
3878    /// keeps it, so the 2-second poll loop pays one TLS handshake per
3879    /// connection and not one per poll. Measured at ~0.5 ms, a handshake per
3880    /// poll would still be under a tenth of a percent of the poll budget — but
3881    /// it would also be far more than the per-poll signature verification the
3882    /// liveness half already does, and there is no reason to spend it.
3883    pub fn connect(at: &str, signer: gunnar_core::ssh_key::PrivateKey, root: &Path) -> anyhow::Result<Self> {
3884        let rt = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
3885        let loopback = gunnar_grpc::client_signer::is_loopback_target(at);
3886        // **Where the credential comes from, in order.** The PAIRED one first —
3887        // minted at `twin certify`, in this box's data root, the only shape the
3888        // one medium allows — then the operator's environment. Paired, the twin
3889        // dials the primary's MUTUAL REPLICATION LISTENER (its host, the port it
3890        // reported) and checks the fixed service name, even on loopback: the
3891        // credential is there, and a lane that encrypts only sometimes is a lane
3892        // whose tests prove the other branch.
3893        let mut alternatives = false;
3894        let (trust, dial, domain) = match crate::twin_tls::load(root)? {
3895            Some((trust, port)) => {
3896                // The hosts the primary REPORTED at `twin certify` win over the
3897                // host this twin was joined to: `<tunnel>,<utility>` while its
3898                // replication listener also answers on `wg0`. The overlay is
3899                // preferred; the first that answers is dialled, so a tunnel that
3900                // is down falls back to the utility network (2026-09-24).
3901                let candidates = crate::twin_tls::load_host(root)
3902                    .map(|r| crate::twin_tls::hosts(&r))
3903                    .filter(|h| !h.is_empty())
3904                    .unwrap_or_else(|| vec![gunnar_grpc::client_signer::tls_domain_for(at)]);
3905                alternatives = candidates.len() > 1;
3906                let dial = pick_replication_dial(&candidates, port, DIAL_PROBE);
3907                (Some(trust), dial, gunnar_grpc::client_signer::REPLICATION_SERVER_NAME.to_owned())
3908            }
3909            None => (
3910                gunnar_grpc::client_signer::ClientTrust::from_env().map_err(|e| anyhow!(e))?,
3911                at.to_owned(),
3912                gunnar_grpc::client_signer::tls_domain_for(at),
3913            ),
3914        };
3915        let channel = {
3916            let _g = rt.enter();
3917            match (loopback, &trust) {
3918                (_, Some(trust)) => {
3919                    // **Before the first poll, not after the lapse.** This box
3920                    // has no shell and its only door is the plane this
3921                    // certificate opens, so a lapse is not a degraded service:
3922                    // it is a box nobody can repair. The grade refuses while
3923                    // the certificate is STILL VALID, which is the only moment
3924                    // at which anyone can act on it.
3925                    let now = std::time::SystemTime::now()
3926                        .duration_since(std::time::UNIX_EPOCH)
3927                        .map(|d| d.as_millis() as i64)
3928                        .unwrap_or(0);
3929                    match gunnar_core::expiry::grade_pem(now, &trust.cert) {
3930                        Ok(expiry) => {
3931                            let line = expiry.line("this twin's client certificate");
3932                            if expiry.is_refusal() {
3933                                anyhow::bail!("{line}");
3934                            }
3935                            match expiry.grade {
3936                                gunnar_core::expiry::Grade::Renew => {
3937                                    tracing::error!(target: gunnar_core::observe::TARGET_TWIN, days_left = expiry.days_left, "{line}")
3938                                }
3939                                _ => tracing::info!(target: gunnar_core::observe::TARGET_TWIN, days_left = expiry.days_left, "{line}"),
3940                            }
3941                        }
3942                        // Unreadable is NOT waved through: bytes that are not a
3943                        // certificate are the same operational fact as a
3944                        // certificate that cannot be used.
3945                        Err(err) => anyhow::bail!(
3946                            "this twin's client certificate ({}) could not be read, so nothing \
3947                             can say how long it is good for: {err}",
3948                            gunnar_grpc::client_signer::CLIENT_CERT_ENV
3949                        ),
3950                    }
3951                    tracing::info!(
3952                        target: gunnar_core::observe::TARGET_TWIN,
3953                        %at, %dial, %domain, suite = gunnar_grpc::preferred_bulk_suite(),
3954                        "following the primary over MUTUAL TLS 1.3"
3955                    );
3956                    // **THE BOUNDARY A TWIN DIES AT.** MEASURED 2026-09-23:
3957                    // the estate's twin took its role, held this certificate,
3958                    // started following, and panicked — and nothing on the
3959                    // narration wire had said any of it, because the wire went
3960                    // quiet at `end of hand-off` six seconds earlier. This is
3961                    // the line that makes the death legible: WHICH primary,
3962                    // over WHICH name, with a certificate graded above.
3963                    crate::narrate::at(
3964                        crate::narrate::Stage::Follow,
3965                        &format!(
3966                            "primary {at} dialled as {dial} (server name {domain}) over mutual \
3967                             TLS 1.3; this twin's client certificate is held and in grade"
3968                        ),
3969                    );
3970                    gunnar_grpc::client_signer::mutual_channel_with(&dial, trust, &domain, kept_alive()).map_err(|e| anyhow!(e))?
3971                }
3972                (true, None) => {
3973                    tracing::info!(
3974                        target: gunnar_core::observe::TARGET_TWIN, %at,
3975                        "following a LOOPBACK primary in the clear: the bytes never leave this box"
3976                    );
3977                    gunnar_grpc::client_signer::plain_channel_with(at, kept_alive()).map_err(|e| anyhow!(e))?
3978                }
3979                (false, None) => anyhow::bail!(
3980                    "this twin's primary is {at}, which is another machine, and no client \
3981                     certificate is configured on this box — so the only way to follow it would \
3982                     be in the clear. It will not. The log this stream carries holds the \
3983                     estate's sealed secrets and the KEK as seq 1.\n\n\
3984                     Set all three of {}, {} and {} to the files this box was enrolled with, and \
3985                     make sure the primary is reached over the WireGuard address its listener \
3986                     bound (the certificate carries that address, not the tunnel's name).",
3987                    gunnar_grpc::client_signer::CLIENT_CERT_ENV,
3988                    gunnar_grpc::client_signer::CLIENT_KEY_ENV,
3989                    gunnar_grpc::client_signer::CA_ENV,
3990                ),
3991            }
3992        };
3993        Ok(Self {
3994            rt: OffThreadRuntime::new(rt),
3995            client: Mutex::new(gunnar_proto::mirror_client::MirrorClient::new(channel)),
3996            signer,
3997            reporter: None,
3998            root: root.to_path_buf(),
3999            alternatives,
4000        })
4001    }
4002}
4003
4004/// **How long a `Mirror.LogHead` may take** before the call is STALLED (T1,
4005/// estate-box check 2). A head is a few hundred bytes; on a healthy link it
4006/// answers in milliseconds. A connection whose path died under it (vm1's relay
4007/// restarted beneath the tunnel) answered after 8–57 s of TCP back-off instead.
4008pub const HEAD_WITHIN: Duration = Duration::from_secs(3);
4009
4010/// **How long a `Mirror.LogFetch` stream may go without its next piece.**
4011pub const FETCH_IDLE_WITHIN: Duration = Duration::from_secs(15);
4012
4013/// **How long a `Mirror.SnapshotFetch` stream may go without its next piece**
4014/// (r5.2, the r5.1 integration test: a twin whose primary died or restarted
4015/// mid-snapshot hung for minutes on a half-open connection). Past it the call
4016/// is STALLED: the loop cuts the connection and re-dials.
4017///
4018/// **60 s, not 15** (wiring tracer B 2026-10-08): a LIVE primary sends nothing
4019/// while it walks a repository with no reachability index or takes its cut,
4020/// and past 15 s the twin re-dialled and asked a NEW cut — on such a repository
4021/// it never installed. A dead path is the channel's to find, by its HTTP/2
4022/// keepalive ([`KEEPALIVE_EVERY`], [`KEEPALIVE_WITHIN`]): about 30 s, and the
4023/// next step's `Mirror.LogHead` ([`HEAD_WITHIN`]) then re-dials. The primary
4024/// waits 120 s on the twin (`SNAPSHOT_STALL_TIMEOUT`).
4025pub const SNAPSHOT_IDLE_WITHIN: Duration = Duration::from_secs(60);
4026
4027/// **HTTP/2 keepalive on the twin's channels to its primary**: a PING every
4028/// [`KEEPALIVE_EVERY`], idle or not, and the connection is dropped when one is
4029/// not answered within [`KEEPALIVE_WITHIN`] — a dead path is found by the
4030/// channel itself, not by a call that waits on it.
4031pub const KEEPALIVE_EVERY: Duration = Duration::from_secs(10);
4032pub const KEEPALIVE_WITHIN: Duration = Duration::from_secs(20);
4033
4034/// The word a stalled call's error carries: the loop re-dials on it at once.
4035pub const STALLED: &str = "[twin-primary-stalled]";
4036
4037/// **The twin's channel to its primary, kept alive** ([`KEEPALIVE_EVERY`],
4038/// [`KEEPALIVE_WITHIN`], idle too): a path that died under the connection is
4039/// found by an unanswered PING and the connection dropped, so the next call
4040/// dials afresh instead of writing into a half-open socket.
4041fn kept_alive() -> impl FnOnce(gunnar_grpc::client_signer::Endpoint) -> gunnar_grpc::client_signer::Endpoint {
4042    gunnar_grpc::client_signer::keep_alive(KEEPALIVE_EVERY, KEEPALIVE_WITHIN)
4043}
4044
4045/// How long a replication host gets to accept a TCP connection before the
4046/// next one is tried.
4047pub const DIAL_PROBE: Duration = Duration::from_secs(3);
4048
4049/// `host:port` for a host (bracketing IPv6).
4050fn dial_of(host: &str, port: u16) -> String {
4051    if host.contains(':') { format!("[{host}]:{port}") } else { format!("{host}:{port}") }
4052}
4053
4054/// **The replication host to dial**: the first of `candidates` (in the
4055/// primary's order of preference — the overlay first) that accepts a TCP
4056/// connection within `probe`; the first one when none does (the follower
4057/// retries, and says why).
4058pub fn pick_replication_dial(candidates: &[String], port: u16, probe: Duration) -> String {
4059    for host in candidates {
4060        let dial = dial_of(host, port);
4061        let Ok(addr) = dial.parse::<std::net::SocketAddr>() else { continue };
4062        if std::net::TcpStream::connect_timeout(&addr, probe).is_ok() {
4063            return dial;
4064        }
4065        tracing::info!(target: gunnar_core::observe::TARGET_TWIN, %dial, "the primary's replication door does not answer here; trying its next address");
4066    }
4067    dial_of(candidates.first().map(String::as_str).unwrap_or("127.0.0.1"), port)
4068}
4069
4070impl GrpcSource {
4071    /// Carry `report()` on every `LogHead` (the dual heartbeat).
4072    pub fn with_reporter(mut self, report: impl Fn() -> gunnar_proto::TwinReport + Send + Sync + 'static) -> Self {
4073        self.reporter = Some(Box::new(report));
4074        self
4075    }
4076
4077    /// **Did the box this twin agreed to follow sign this answer?** — the whole
4078    /// of the twin's half of the pairing, run before a single number out of it
4079    /// is believed.
4080    ///
4081    /// Three states, and only one of them is new trust:
4082    ///
4083    /// * **pinned, and the answer is signed by it** — the ordinary case;
4084    /// * **pinned, and it is not** — refused, by name. A different key, a
4085    ///   signature that does not verify, a replayed answer from an earlier poll
4086    ///   (the nonce is inside the signature), or no signature at all. The poll
4087    ///   fails, so the status goes `stalled` with this reason and the twin
4088    ///   applies nothing;
4089    /// * **not pinned yet** — trust on first use. The key is written down and
4090    ///   every answer after this one is measured against it. An UNSIGNED answer
4091    ///   here is left unpinned rather than pinned as "no key": a primary too old
4092    ///   to sign must keep working, and a twin that recorded "this primary has
4093    ///   no key" would never start requiring one.
4094    fn check_answer(&self, nonce: &str, head: &gunnar_proto::LogHeadResponse) -> anyhow::Result<()> {
4095        let offered = head.answer_key.trim();
4096        match pinned_primary_key(&self.root) {
4097            Some(pinned) => {
4098                anyhow::ensure!(
4099                    !head.answer_sig.trim().is_empty(),
4100                    "the box at this address answered UNSIGNED, and this twin has pinned the key its primary answers with \
4101                     ({pinned}). An answer nobody signed is an answer from nobody: this twin applies nothing on it."
4102                );
4103                anyhow::ensure!(
4104                    offered == pinned,
4105                    "the box at this address now answers with key {offered}; this twin follows the box that answers with \
4106                     {pinned}. The address is the same and the box is not: nothing is applied. If the primary was rebuilt \
4107                     rather than restored, it is a different history too — re-join this twin deliberately, or restore a \
4108                     primary FROM it."
4109                );
4110                anyhow::ensure!(
4111                    gunnar_grpc::auth::verify_twin_answer_at(&pinned, &head.answer_sig, nonce, &head.log_id, head.first_seq, head.head_seq, head.generation),
4112                    "the answer from this address does not verify against the key this twin pinned ({pinned}) — the signature \
4113                     is not over this poll's challenge and these numbers (and this generation). A replayed or edited answer is not an answer."
4114                );
4115                Ok(())
4116            }
4117            None if offered.is_empty() => Ok(()),
4118            None => {
4119                anyhow::ensure!(
4120                    gunnar_grpc::auth::verify_twin_answer_at(offered, &head.answer_sig, nonce, &head.log_id, head.first_seq, head.head_seq, head.generation),
4121                    "the box at this address offered key {offered} with a signature that does not verify against it; this twin \
4122                     pins nothing it has not seen prove itself once"
4123                );
4124                pin_primary_key(&self.root, offered)
4125            }
4126        }
4127    }
4128}
4129
4130// ── the log's size alarm (2026-09-22, the owner's ruling) ───────────────────
4131
4132/// The alarm's `event` name on `Admin.Health` and in the log line.
4133pub const TXLOG_SIZE_ALARM: &str = "txlog.size_over_cap";
4134
4135/// `GUNNAR_TXLOG_ALARM_BYTES`: the cap, in bytes, over which [`TXLOG_SIZE_ALARM`] is raised.
4136/// Unset = [`TXLOG_ALARM_DEFAULT_PERCENT`] of the data volume.
4137pub const TXLOG_ALARM_ENV: &str = "GUNNAR_TXLOG_ALARM_BYTES";
4138
4139/// **The default cap: a quarter of the data volume.** Chosen from the restore's own arithmetic,
4140/// not a round number: `room_for_restore` asks for [`RESTORE_BYTES_PER_LOG_BYTE`] × the log free
4141/// on the box being restored. At a quarter of the volume, a restore onto the SAME hardware needs
4142/// half its disk free for the log alone — the last point at which "restore onto a box like this
4143/// one" is still comfortably true. Past it the operator is losing that without being told, which
4144/// is what this alarm exists to say. Relative, so a 64 GB lab box and a 20 TB appliance are both
4145/// told at the same fraction.
4146pub const TXLOG_ALARM_DEFAULT_PERCENT: u64 = 25;
4147
4148/// The cap in force for the data root at `root`, and how it was decided (for the alarm's
4149/// sentence). `env` is `GUNNAR_TXLOG_ALARM_BYTES` as read (passed in so a test does not race the
4150/// process environment). `None` = no cap can be decided (the variable unparseable is refused
4151/// loudly by the caller; an unmeasurable volume with no variable has no default to apply).
4152pub fn txlog_alarm_cap(env: Option<&str>, volume_total: u64) -> Result<Option<(u64, String)>, String> {
4153    match env.map(str::trim).filter(|v| !v.is_empty()) {
4154        Some(v) => v
4155            .parse::<u64>()
4156            .map(|b| Some((b, format!("{TXLOG_ALARM_ENV}={b}"))))
4157            .map_err(|_| format!("{TXLOG_ALARM_ENV}={v:?} is not a number of bytes; the log's size is NOT watched")),
4158        None if volume_total == 0 => Ok(None),
4159        None => Ok(Some((
4160            volume_total / 100 * TXLOG_ALARM_DEFAULT_PERCENT,
4161            format!("the default, {TXLOG_ALARM_DEFAULT_PERCENT}% of the {volume_total}-byte data volume"),
4162        ))),
4163    }
4164}
4165
4166/// **[`TXLOG_SIZE_ALARM`] when the log's bytes on disk pass `cap`** — a warning, never an action:
4167/// nothing here deletes, rolls or retains a frame (the operator's `Mirror.LogRetain` is the one
4168/// door that does, and it is guarded by the twin's position).
4169pub fn txlog_size_alarm(log_bytes: u64, cap: u64, how: &str) -> Option<gunnar_proto::HealthAlarm> {
4170    (log_bytes > cap).then(|| gunnar_proto::HealthAlarm {
4171        event: TXLOG_SIZE_ALARM.into(),
4172        count: 1,
4173        since_unix_ms: 0,
4174        detail: format!(
4175            "the transaction log holds {log_bytes} bytes on disk, over the cap of {cap} ({how}). \
4176             Nothing has been deleted and nothing will be: let every twin catch up, then retain \
4177             (`gunnar control twin retain --at <primary>`), or raise {TXLOG_ALARM_ENV}. A log this \
4178             size needs {} bytes free on any box restored from it",
4179            log_bytes.saturating_mul(RESTORE_BYTES_PER_LOG_BYTE)
4180        ),
4181    })
4182}
4183
4184/// **What this twin tells its primary on each poll**: its applied head, its
4185/// data volume (one `statvfs` of the data root — used = total − free-to-root,
4186/// so the reserved blocks count as used, the same view `df` gives), and the
4187/// follower's state word. A `statvfs` that fails reports 0 / 0, which the
4188/// primary shows as fill 0 of an unknown volume, never as a full one.
4189pub fn measure_report(stores: &ServerStores) -> gunnar_proto::TwinReport {
4190    // Held for its own growth, the data root may be the guard tmpfs over an unmounted set:
4191    // its size is not the twin's volume, so the report says "unknown" (0 / 0), never a size.
4192    let (total, used) = if maintenance::is_held(stores.root()) { (0, 0) } else { volume_bytes(stores.root()) };
4193    let (cpu_millicores_busy, cpu_millicores_total) = own_load();
4194    gunnar_proto::TwinReport {
4195        applied_seq: stores.txlog().head_seq(),
4196        disk_total_bytes: total,
4197        disk_used_bytes: used,
4198        state: status(stores.root()).map(|s| s.state.to_string()).unwrap_or_default(),
4199        cpu_millicores_busy,
4200        cpu_millicores_total,
4201    }
4202}
4203
4204/// **This box's own `Mirror.LogHead`**, unsigned: its log's numbers and, on a twin, what
4205/// its follower last saw of the primary. `Mirror.LogHead` signs it; a twin's Fleet report
4206/// reviews it (G12).
4207pub fn own_head(stores: &ServerStores) -> gunnar_proto::LogHeadResponse {
4208    let log = stores.txlog();
4209    let mut head = gunnar_proto::LogHeadResponse {
4210        head_seq: log.head_seq(),
4211        first_seq: log.first_seq(),
4212        bytes: log.bytes(),
4213        // **Which log these numbers are about** (2026-09-21, lane T4).
4214        // Answered on every poll, by a twin as well as a primary: a twin's
4215        // log IS its primary's, so it carries the same id, and that is what
4216        // lets `control twin replay` hand it to the fresh box it restores.
4217        log_id: crate::twin::log_id(stores.root()).unwrap_or_default(),
4218        ..Default::default()
4219    };
4220    // **A box with a `twin.json` is a twin, whatever its follower is doing**
4221    // (2026-09-21, lane T4). This used to read `twin` off the follower's
4222    // in-process status, which is `None` until the first successful poll —
4223    // so a twin that had just restarted, or whose follower could not start,
4224    // answered `twin = false`, and every reader took it for a primary:
4225    // `control twin replay` skipped its staleness warning entirely and
4226    // restored from it without a word, and `twin catchup`'s not-a-primary
4227    // refusal did not fire.
4228    if crate::twin::is_twin_root(stores.root()) {
4229        head.twin = true;
4230        // restore-after-snapshot: the base this twin keeps — everything up to its `at` is
4231        // there and its log starts at `at + 1`; a restore streams it first. Said only while
4232        // the copy is whole (a twin mid-resync holds no base to restore from).
4233        if crate::twin::snapshot::not_whole(stores.root()).is_none() {
4234            if let Ok(Some(at)) = crate::twin::snapshot::base_at(stores.root()) {
4235                head.snapshot_at = at;
4236                head.snapshot_bytes = crate::twin::snapshot::base_bytes(stores.root());
4237            }
4238        }
4239        match crate::twin::status(stores.root()) {
4240            Some(twin) => {
4241                head.applied_seq = twin.applied_seq;
4242                head.primary_head_seq = twin.primary_head_seq;
4243                head.caught_up = twin.caught_up();
4244                head.state = twin.state.to_string();
4245                head.why = twin.why;
4246                head.last_answer_unix_ms = twin.last_answer_unix_ms;
4247                head.last_level_unix_ms = twin.last_level_unix_ms;
4248                head.answer_ago_ms = if twin.last_answer_unix_ms > 0 {
4249                    crate::twin::now_ms().saturating_sub(twin.last_answer_unix_ms).max(0) as u64
4250                } else {
4251                    0
4252                };
4253            }
4254            None => {
4255                // Nothing has polled yet in this process. The applied head
4256                // is real (it is on disk); everything about the PRIMARY is
4257                // unknown, and saying so is the whole point.
4258                head.applied_seq = log.head_seq();
4259                head.state = "idle".to_owned();
4260                head.why = "this box is a twin (twin.json is on its data root) but its follower has not \
4261                            reported since this process started: the primary's head, the lag and the age \
4262                            of this copy are all UNKNOWN"
4263                    .to_owned();
4264            }
4265        }
4266    }
4267    head
4268}
4269
4270/// **The twin's own load, for its poll** (2026-10-05): `/proc/loadavg`'s
4271/// 1-minute load ×1000 and the cores this process may run on ×1000 — the
4272/// readers `Admin.Capacity` uses (`capacity::machine_fields`), the same
4273/// approximation it states. Each absent when it could not be read, never 0.
4274pub fn own_load() -> (Option<u64>, Option<u64>) {
4275    let busy = crate::dashboard::probe::load1_value().ok().map(|l| (l * 1000.0).round().max(0.0) as u64);
4276    let total = std::thread::available_parallelism().ok().map(|n| n.get() as u64 * 1000);
4277    (busy, total)
4278}
4279
4280/// `(total, used)` bytes of the filesystem holding `path`; `(0, 0)` when unmeasurable.
4281pub fn volume_bytes(path: &Path) -> (u64, u64) {
4282    match rustix::fs::statvfs(path) {
4283        Ok(v) => {
4284            let frsize = if v.f_frsize == 0 { v.f_bsize } else { v.f_frsize };
4285            let total = v.f_blocks.saturating_mul(frsize);
4286            let free = v.f_bfree.saturating_mul(frsize);
4287            (total, total.saturating_sub(free))
4288        }
4289        Err(_) => (0, 0),
4290    }
4291}
4292
4293impl LogSource for GrpcSource {
4294    fn alternatives(&self) -> bool {
4295        self.alternatives
4296    }
4297    fn head(&self) -> anyhow::Result<(u64, u64)> {
4298        let (first, head, _) = self.head_and_identity()?;
4299        Ok((first, head))
4300    }
4301
4302    /// The numbers AND the name, out of one answer — see
4303    /// [`LogSource::head_and_identity`]. This is the only override, because this
4304    /// is the only source that reached across a wire to a box that could be
4305    /// anything.
4306    fn head_and_identity(&self) -> anyhow::Result<(u64, u64, Option<String>)> {
4307        let (first, head, id, _) = self.head_identity_generation()?;
4308        Ok((first, head, id))
4309    }
4310
4311    fn head_identity_generation(&self) -> anyhow::Result<(u64, u64, Option<String>, u64)> {
4312        let mut client = self.client.lock().expect("twin client").clone();
4313        let report = self.reporter.as_ref().map(|r| r());
4314        // The newest generation this twin has followed, relayed on every poll: an
4315        // old main that answers it fences itself ([`LOG_FENCED`]).
4316        let generation_proof = generation_proof(&self.root).unwrap_or_else(|err| {
4317            tracing::warn!(target: gunnar_core::observe::TARGET_TWIN, %err, "the kept generation proof does not read; relaying none");
4318            None
4319        });
4320        // **A fresh challenge per poll.** The primary signs it back, so this
4321        // answer is bound to this poll: a recorded one cannot be replayed later,
4322        // however convincing its numbers.
4323        let nonce = {
4324            use gunnar_core::ssh_key::rand_core::Rng as _;
4325            let mut bytes = [0u8; 16];
4326            gunnar_core::os_rng().fill_bytes(&mut bytes);
4327            bytes.iter().map(|b| format!("{b:02x}")).collect::<String>()
4328        };
4329        let req = gunnar_grpc::client_signer::authed(&self.signer, gunnar_proto::LogHeadRequest { report, nonce: nonce.clone(), generation_proof });
4330        let head = self
4331            .rt
4332            .block_on(async { tokio::time::timeout(HEAD_WITHIN, client.log_head(req)).await })
4333            .map_err(|_| anyhow!("{STALLED} the primary said nothing to Mirror.LogHead within {} s on this connection", HEAD_WITHIN.as_secs()))?
4334            .map_err(say)?
4335            .into_inner();
4336        self.check_answer(&nonce, &head)?;
4337        // Signed by the box this twin follows: if it names a newer generation, it
4338        // is the proof an old main is fenced with (stinky row 18, the main's half).
4339        if let Err(err) = remember_generation_proof(&self.root, &nonce, &head) {
4340            tracing::warn!(target: gunnar_core::observe::TARGET_TWIN, %err, "keeping the restored main's signed generation to relay it");
4341        }
4342        let id = Some(head.log_id).filter(|s| !s.trim().is_empty());
4343        Ok((head.first_seq, head.head_seq, id, head.generation))
4344    }
4345
4346    fn fetch(&self, from: u64, to: u64, max_bytes: u64) -> anyhow::Result<Vec<Entry>> {
4347        let mut whole = Gather::default();
4348        self.fetch_into(from, to, max_bytes, &mut whole)?;
4349        Ok(whole.out)
4350    }
4351
4352    /// **Each chunk into `into` as it arrives** (twin-streaming, 2026-10-06). This used to glue
4353    /// every entry's pieces back into one `Vec` — a 256 MiB pack was 256 MiB on the twin before a
4354    /// byte of it reached the disk, and a 1 GiB twin under such packs went silent. Now the twin
4355    /// holds the piece in hand; HTTP/2 flow control holds the primary back while the twin writes.
4356    fn fetch_into(&self, from: u64, to: u64, max_bytes: u64, into: &mut dyn FrameSink) -> anyhow::Result<()> {
4357        let mut client = self.client.lock().expect("twin client").clone();
4358        let req = gunnar_grpc::client_signer::authed(&self.signer, gunnar_proto::LogFetchRequest { from, to, max_bytes, from_offset: 0 });
4359        let quiet = || anyhow!("{STALLED} the primary sent nothing of Mirror.LogFetch {from}..={to} for {} s on this connection", FETCH_IDLE_WITHIN.as_secs());
4360        self.rt.block_on(async {
4361            let mut stream = tokio::time::timeout(FETCH_IDLE_WITHIN, client.log_fetch(req)).await.map_err(|_| quiet())?.map_err(say)?.into_inner();
4362            feed_log_stream(&mut stream, Some((FETCH_IDLE_WITHIN, &quiet)), &|status| say(status), into).await
4363        })
4364    }
4365
4366    fn snapshot(&self, into: &mut snapshot::Stager) -> anyhow::Result<bool> {
4367        let mut client = self.client.lock().expect("twin client").clone();
4368        let req = gunnar_grpc::client_signer::authed(&self.signer, gunnar_proto::SnapshotFetchRequest::default());
4369        // ★ No piece for SNAPSHOT_IDLE_WITHIN is a primary that died or restarted under the
4370        // stream (a half-open connection): STALLED, so the loop cuts it and re-dials (r5.2).
4371        let quiet = || anyhow!("{STALLED} the primary sent nothing of Mirror.SnapshotFetch for {} s on this connection", SNAPSHOT_IDLE_WITHIN.as_secs());
4372        self.rt.block_on(async {
4373            let mut stream = tokio::time::timeout(SNAPSHOT_IDLE_WITHIN, client.snapshot_fetch(req)).await.map_err(|_| quiet())?.map_err(say)?.into_inner();
4374            // Each piece goes to the staging file as it arrives and is dropped: the twin holds
4375            // one piece of the snapshot at a time, and pulls the next only when it has written
4376            // this one (HTTP/2 flow control is the backpressure). The bound is on the WAIT for
4377            // a piece, never on the twin's own write of it.
4378            while let Some(chunk) = tokio::time::timeout(SNAPSHOT_IDLE_WITHIN, stream.message()).await.map_err(|_| quiet())?.map_err(say)? {
4379                anyhow::ensure!(chunk.seq == 0, "the primary's snapshot stream carried seq {}", chunk.seq);
4380                let kind = u16::try_from(chunk.kind).map_err(|_| anyhow!("snapshot piece of kind {}", chunk.kind))?;
4381                into.feed(snapshot::Piece { kind, meta: &chunk.meta, blob_len: chunk.blob_len, blob: &chunk.blob, last: chunk.last })?;
4382            }
4383            Ok::<_, anyhow::Error>(())
4384        })?;
4385        anyhow::ensure!(into.terminated().is_some(), "the primary's snapshot stream carried no SnapshotAt terminator");
4386        Ok(true)
4387    }
4388}
4389
4390/// **The poll's wait, cut short by `stop`**: slept in slices, so a stop (a re-point,
4391/// the wind-down) is heard within [`NAP_SLICE`] instead of a whole poll later.
4392fn nap(poll: Duration, stop: &AtomicBool) {
4393    let until = std::time::Instant::now() + poll;
4394    while !stop.load(Ordering::Relaxed) {
4395        let left = until.saturating_duration_since(std::time::Instant::now());
4396        if left.is_zero() {
4397            return;
4398        }
4399        std::thread::sleep(left.min(NAP_SLICE));
4400    }
4401}
4402
4403/// How often a sleeping poll loop looks at its stop flag.
4404pub const NAP_SLICE: Duration = Duration::from_millis(100);
4405
4406/// **Why [`Follower::run`] returned.**
4407#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4408pub enum Ended {
4409    /// Asked to stop.
4410    Stopped,
4411    /// A hole the primary no longer retains: an operator's `twin catchup`.
4412    SnapshotNeeded,
4413    /// The primary's head is below this twin's.
4414    Diverged,
4415    /// The primary kept failing on this address and has another: re-dial it (the supervisor
4416    /// starts the loop again at once — a hand-back, not a fault).
4417    Redial,
4418}
4419
4420/// While the primary grows its set, the twin asks again every this many polls (2 s × 5 =
4421/// 10 s): its growth takes minutes, and every ask is a line in the primary's log.
4422pub const GROWING_WAIT_POLLS: u32 = 5;
4423
4424static FOLLOWING: AtomicBool = AtomicBool::new(false);
4425static STOP_FOLLOWING: AtomicBool = AtomicBool::new(false);
4426/// Set by [`repoint`]: the stop that follows is a re-dial, not a wind-down.
4427static REPOINTED: AtomicBool = AtomicBool::new(false);
4428
4429/// **How long the supervisor waits before starting the follower again.**
4430///
4431/// The same order as the poll itself: a twin that cannot start is retried at
4432/// walking pace, not in a spin. There is no back-off ladder on purpose — the
4433/// failures this retries (a key not yet enrolled, a primary that is down, a
4434/// data root that is not mounted yet) are all closed by something OUTSIDE this
4435/// process, and an exponential wait would mean an estate that is fixed in
4436/// minute three stays dark until minute forty.
4437pub const FOLLOWER_RETRY: Duration = Duration::from_secs(5);
4438
4439/// A re-dial is dialled at once at most this often (T1); otherwise after [`FOLLOWER_RETRY`].
4440pub const QUICK_REDIAL_EVERY: Duration = Duration::from_secs(30);
4441
4442/// **Start this twin's loop**, once per process, on its own thread: read
4443/// `twin.json` and `twin.key`, follow the primary every two seconds. An
4444/// unreachable primary, or a key not yet enrolled there, is retried; a
4445/// snapshot-needed stops the loop and the status says so.
4446pub fn start_follower(stores: Arc<ServerStores>) {
4447    start_follower_every(stores, FOLLOWER_RETRY);
4448}
4449
4450/// [`start_follower`] with the supervisor's retry wait named — the tests drive
4451/// it in milliseconds.
4452///
4453/// **Supervised since 2026-09-21 (STINK S1).** It used to call `run()` ONCE:
4454/// every failure before the poll loop — no `twin.json` yet, an unreadable
4455/// `twin.key`, a runtime that would not build, an address that would not parse
4456/// — killed the thread for the life of the process, and nothing retried it. A
4457/// twin whose follower is dead can never catch up, and `flushing` refuses to
4458/// unmount a set whose twin is behind, so ONE dead thread makes every growth
4459/// the estate will ever order fail — while the only thing anybody sees is a
4460/// lag number that never moves (measured on the live twin: `applied 0 of
4461/// 19264`). Two things changed here and both matter:
4462///
4463/// * the run is **retried for ever**, every [`FOLLOWER_RETRY`], and
4464/// * a follower that is NOT running now says so **in the status**, with the
4465///   attempt count and the error, so the twin's state word on the heartbeat is
4466///   `unreachable` (printed loud) instead of an empty string beside a lag.
4467///
4468/// A `SnapshotNeeded` is the one stop that stays a stop: the twin needs an
4469/// operator's `twin catchup` and re-running the same step would only write the
4470/// same refusal every five seconds. The status says `snapshot-needed`, which
4471/// is the loud word for exactly that.
4472pub fn start_follower_every(stores: Arc<ServerStores>, retry: Duration) {
4473    if FOLLOWING.swap(true, Ordering::SeqCst) {
4474        return;
4475    }
4476    STOP_FOLLOWING.store(false, Ordering::SeqCst);
4477    std::thread::Builder::new()
4478        .name("gunnar-twin".into())
4479        .spawn(move || {
4480            // One run of failures across the re-dials (L1): said once, not once per dial.
4481            let failures: Arc<Mutex<Option<Failing>>> = Arc::new(Mutex::new(None));
4482            let run = || -> anyhow::Result<Ended> {
4483                let cfg = TwinConfig::load(stores.root())?.ok_or_else(|| anyhow!("no {TWIN_CONFIG}: not a twin"))?;
4484                let key = twin_private_key(stores.root())?;
4485                tracing::info!(target: gunnar_core::observe::TARGET_TWIN, primary = %cfg.primary, "following the primary");
4486                let reporting = Arc::clone(&stores);
4487                let source = GrpcSource::connect(&cfg.primary, key, stores.root())?.with_reporter(move || measure_report(&reporting));
4488                Ok(Follower::new(Arc::clone(&stores), Box::new(source)).sharing_failures(Arc::clone(&failures)).run(Duration::from_secs(2), &STOP_FOLLOWING))
4489            };
4490            let mut attempt: u64 = 0;
4491            // T1: a hand-back for a re-dial is dialled again AT ONCE — once per
4492            // [`QUICK_REDIAL_EVERY`]; a primary that stays quiet waits `retry` as before.
4493            let mut quick: Option<std::time::Instant> = None;
4494            loop {
4495                if STOP_FOLLOWING.load(Ordering::SeqCst) {
4496                    // A re-point stops the follower to have it dial the new
4497                    // address; anything else is the wind-down.
4498                    if !REPOINTED.swap(false, Ordering::SeqCst) {
4499                        break;
4500                    }
4501                    STOP_FOLLOWING.store(false, Ordering::SeqCst);
4502                    attempt = 0;
4503                    continue;
4504                }
4505                attempt += 1;
4506                match run() {
4507                    Err(err) => {
4508                        tracing::error!(target: gunnar_core::observe::TARGET_TWIN, %err, attempt, "the twin cannot follow; starting it again");
4509                        stalled(&stores, attempt, &format!("{err:#}"), retry);
4510                    }
4511                    // M2 (release-3 test2 mock 2026-09-29): a hand-back for a re-dial is the
4512                    // loop doing its job — said at INFO and started again, never the ERROR
4513                    // below.
4514                    Ok(Ended::Redial) => {
4515                        tracing::info!(target: gunnar_core::observe::TARGET_TWIN, attempt, "the twin's poll loop handed back to re-dial its primary; following again");
4516                        attempt = 0;
4517                        if quick.is_none_or(|t| t.elapsed() >= QUICK_REDIAL_EVERY) {
4518                            quick = Some(std::time::Instant::now());
4519                            continue;
4520                        }
4521                    }
4522                    Ok(_) => {
4523                        // `Follower::run` returns on the stop flag or on a
4524                        // snapshot-needed, and the second one left the status
4525                        // saying so.
4526                        if STOP_FOLLOWING.load(Ordering::SeqCst) {
4527                            continue;
4528                        }
4529                        // Diverged and snapshot-needed are the two stops that
4530                        // STAY stopped: both need an operator, and re-running
4531                        // the same step would write the same refusal every five
4532                        // seconds.
4533                        if status(stores.root()).map(|s| s.state) == Some(State::Diverged) {
4534                            tracing::error!(target: gunnar_core::observe::TARGET_TWIN, why = %status(stores.root()).map(|s| s.why).unwrap_or_default(), "the twin stopped: the box it is pointed at is a different log");
4535                            break;
4536                        }
4537                        if status(stores.root()).map(|s| s.state) == Some(State::SnapshotNeeded) {
4538                            tracing::error!(target: gunnar_core::observe::TARGET_TWIN, "the twin stopped at a hole its primary no longer retains: it needs `gunnar control twin catchup`");
4539                            break;
4540                        }
4541                        tracing::error!(target: gunnar_core::observe::TARGET_TWIN, attempt, "the twin's poll loop returned without being asked to stop; starting it again");
4542                        stalled(&stores, attempt, "the poll loop returned without being asked to stop", retry);
4543                    }
4544                }
4545                std::thread::sleep(retry);
4546            }
4547            FOLLOWING.store(false, Ordering::SeqCst);
4548        })
4549        .expect("spawning the twin's follower thread");
4550}
4551
4552/// **The SAME primary, now reached over its tunnel** (the WireGuard-only
4553/// upgrade drill, 2026-10-05): a twin joined on the line before WireGuard-only
4554/// holds its primary's UTILITY address (`twin.json`); upgraded in place (its
4555/// `/data` kept), the door names that same primary by its OVERLAY address and
4556/// `become_twin` refused it as a re-point. `utility` is the address the door
4557/// names for the primary (`JoinReply.primary_utility`), `overlay` the one it
4558/// is joined at now. Only a twin that follows exactly `utility` moves; any
4559/// other address is left for `become_twin` to refuse. `Ok(true)` when it moved.
4560pub fn onto_tunnel(stores: &ServerStores, utility: &str, overlay: &str) -> anyhow::Result<bool> {
4561    let root = stores.root();
4562    let (utility, overlay) = (utility.trim(), overlay.trim());
4563    anyhow::ensure!(!overlay.is_empty(), "the primary's overlay address is required");
4564    let Some(mut cfg) = TwinConfig::load(root)? else { return Ok(false) };
4565    if utility.is_empty() || cfg.primary != utility || utility == overlay {
4566        return Ok(false);
4567    }
4568    cfg.primary = overlay.to_string();
4569    cfg.save(root)?;
4570    tracing::info!(target: gunnar_core::observe::TARGET_TWIN, was = %utility, now = %overlay, "this twin follows the SAME primary over the tunnel now: joined on its utility address before WireGuard-only, its follower dials the overlay address from its next attempt");
4571    // The follower dials the address it read when it started: stop it and let
4572    // the supervisor start it again on the overlay address (as `repoint`).
4573    if FOLLOWING.load(Ordering::SeqCst) {
4574        REPOINTED.store(true, Ordering::SeqCst);
4575        STOP_FOLLOWING.store(true, Ordering::SeqCst);
4576    }
4577    Ok(true)
4578}
4579
4580/// **Re-point this twin at its primary's NEW control plane** — the one move a
4581/// twin makes when its primary DIED and was recharged from it at another
4582/// address (the installer re-feeds the closed twin: Skidbladnir nordisk-pairing
4583/// `installer::readmit`). The restored main IS the same log and signs with the
4584/// same host key, so the pins hold; only the address moves. `Ok(true)` when it
4585/// moved (the follower re-dials it), `Ok(false)` when it already follows there.
4586pub fn repoint(stores: &ServerStores, primary: &str) -> anyhow::Result<bool> {
4587    let root = stores.root();
4588    let primary = primary.trim();
4589    anyhow::ensure!(!primary.is_empty(), "the primary's address is required");
4590    let mut cfg = TwinConfig::load(root)?.ok_or_else(|| anyhow!("no {TWIN_CONFIG}: this box is not a twin, there is nothing to re-point"))?;
4591    if cfg.primary == primary {
4592        return Ok(false);
4593    }
4594    let was = std::mem::replace(&mut cfg.primary, primary.to_string());
4595    cfg.save(root)?;
4596    tracing::warn!(target: gunnar_core::observe::TARGET_TWIN, was = %was, now = %primary, "RE-POINTED: this twin's primary was recharged at another address; the follower dials it from its next attempt");
4597    // The follower dials the address it read when it started: stop it and let
4598    // the supervisor start it again on the new one.
4599    if FOLLOWING.load(Ordering::SeqCst) {
4600        REPOINTED.store(true, Ordering::SeqCst);
4601        STOP_FOLLOWING.store(true, Ordering::SeqCst);
4602    }
4603    Ok(true)
4604}
4605
4606/// **Ask the follower to stop and let the supervisor wind down.** For the
4607/// tests, and for a box that is no longer a twin.
4608pub fn stop_follower() {
4609    STOP_FOLLOWING.store(true, Ordering::SeqCst);
4610}
4611
4612/// **The words a `why` starts with when THIS twin's follower is not running** —
4613/// as opposed to its primary not answering. Both read `unreachable`, and a restore
4614/// guard must tell them apart: the first says nothing about the primary at all.
4615/// Written again, verbatim, in `gunnar-cli`'s `control/twin.rs`; the CLI's test
4616/// `the_follower_not_running_wording_is_the_servers` holds the two together.
4617pub const FOLLOWER_NOT_RUNNING: &str = "the follower is NOT running";
4618
4619/// **Say in the STATUS that this twin is not following.** The heartbeat carries
4620/// the state word, so a primary printing `twin <p> UNREACHABLE` is what an
4621/// operator sees instead of a lag number that never moves. The applied head is
4622/// real; the primary's head is whatever the last successful poll saw, or 0 when
4623/// there has never been one.
4624fn stalled(stores: &ServerStores, attempt: u64, why: &str, retry: Duration) {
4625    // A twin held for its own growth keeps saying so: its follower failing to start meanwhile
4626    // is not news about its primary.
4627    if maintenance::is_held(stores.root()) {
4628        return;
4629    }
4630    let last = status(stores.root());
4631    set_status(stores.root(), TwinStatus {
4632        applied_seq: stores.txlog().head_seq(),
4633        primary_head_seq: last.as_ref().map(|s| s.primary_head_seq).unwrap_or(0),
4634        state: State::Unreachable,
4635        why: format!("{FOLLOWER_NOT_RUNNING} (attempt {attempt}, starting again in {} s): {why}", retry.as_secs_f32()),
4636        last_answer_unix_ms: last.as_ref().map(|s| s.last_answer_unix_ms).unwrap_or(0),
4637        last_level_unix_ms: 0,
4638    });
4639}
4640
4641// ── restore ─────────────────────────────────────────────────────────────────
4642
4643/// Replay `from` (a twin's log) into `into` (a fresh data root's stores):
4644/// the restored primary's own log then holds the same numbers and continues
4645/// from the twin's head. Returns the head reached.
4646pub fn restore(from: Arc<Log>, into: Arc<ServerStores>, source_log_id: &str) -> anyhow::Result<u64> {
4647    restore_from(Box::new(from), into, source_log_id)
4648}
4649
4650/// [`restore`] from any source — one that can make a snapshot closes a hole with it (a twin
4651/// whose own log starts after a snapshot it installed).
4652pub fn restore_from(source: Box<dyn LogSource>, into: Arc<ServerStores>, source_log_id: &str) -> anyhow::Result<u64> {
4653    restore_begin(&into)?;
4654    let f = Follower::new(Arc::clone(&into), source);
4655    loop {
4656        match f.step()? {
4657            Progress::Applied { .. } | Progress::SnapshotInstalled { .. } => continue,
4658            Progress::CaughtUp { .. } => return restore_finish(&into, 0, source_log_id),
4659            Progress::SnapshotNeeded { wanted, first } => {
4660                return Err(anyhow!("the source starts at {first}; a restore needs {wanted}"))
4661            }
4662            // A restore's target is a FRESH box (`restore_begin` refuses
4663            // otherwise), so its own head is 0 and nothing can be below it.
4664            // Reachable only if that ever stops being true, and then it is the
4665            // same refusal: the source is not this box's log.
4666            Progress::Diverged { own, primary_head } => {
4667                return Err(anyhow!(
4668                    "the source's head is {primary_head}, below the {own} already on this box — it is not this box's log"
4669                ))
4670            }
4671        }
4672    }
4673}
4674
4675pub mod snapshot;
4676
4677/// The restore's intake: an entry larger than one call, staged on disk (restore-large-entry).
4678pub mod intake;
4679
4680/// **A source over another box's stores in this process** — its log for ranges, and a snapshot
4681/// of its stores for a hole. What a restore from a twin's data root reads, and what the tests
4682/// follow a primary through.
4683pub struct LocalSource {
4684    /// The box read from.
4685    pub stores: Arc<ServerStores>,
4686    /// Pretend retention starts here (tests: a hole without rolling segments). 0 = the log's own.
4687    pub retained_from: u64,
4688}
4689
4690impl LocalSource {
4691    /// A twin that holds no whole copy (twin-resync-space) gives out no log either.
4692    fn whole(&self) -> anyhow::Result<()> {
4693        match self.stores.is_twin().then(|| snapshot::not_whole(self.stores.root())).flatten() {
4694            Some(why) => anyhow::bail!("{why}. Nothing is read from it"),
4695            None => Ok(()),
4696        }
4697    }
4698}
4699
4700impl LogSource for LocalSource {
4701    fn head(&self) -> anyhow::Result<(u64, u64)> {
4702        let log = self.stores.txlog();
4703        Ok((log.first_seq().max(self.retained_from), log.head_seq()))
4704    }
4705    fn fetch(&self, from: u64, to: u64, max_bytes: u64) -> anyhow::Result<Vec<Entry>> {
4706        self.whole()?;
4707        let first = self.head()?.0;
4708        if from < first {
4709            return Err(txlog::Error::NotRetained { seq: from, first }.into());
4710        }
4711        Ok(fetch_range(self.stores.txlog(), from, to, max_bytes)?)
4712    }
4713    fn fetch_into(&self, from: u64, to: u64, max_bytes: u64, into: &mut dyn FrameSink) -> anyhow::Result<()> {
4714        self.whole()?;
4715        let first = self.head()?.0;
4716        if from < first {
4717            return Err(txlog::Error::NotRetained { seq: from, first }.into());
4718        }
4719        stream_range(self.stores.txlog(), from, to, max_bytes, into).map(drop)
4720    }
4721    fn snapshot(&self, into: &mut snapshot::Stager) -> anyhow::Result<bool> {
4722        snapshot::serve(&self.stores, &mut |p| into.feed(p))?;
4723        Ok(true)
4724    }
4725}
4726
4727#[cfg(test)]
4728mod tests;
4729
4730/// **A twin that was following before the store-only ruling says so, once, at open.** Its
4731/// `repos/` tree is what an older build applied; nothing reads it now and nothing updates it,
4732/// so it is stale bytes taking room on the twin's volume. It is NOT deleted here: deleting a
4733/// box's data on an upgrade is not a thing an open does. The operator reclaims it (or re-joins
4734/// the twin, which starts a fresh root).
4735pub fn say_if_stores_were_applied(stores: &ServerStores) {
4736    let repos = stores.root().join(crate::stores::REPOS_DIR);
4737    let stale = std::fs::read_dir(&repos).map(|d| d.flatten().count()).unwrap_or(0);
4738    if stale > 0 {
4739        tracing::warn!(
4740            target: gunnar_core::observe::TARGET_TWIN,
4741            path = %repos.display(),
4742            namespaces = stale,
4743            "this twin holds repository stores an OLDER build applied; a twin only stores (2026-09-22) and nothing reads or updates them — \
4744             they are stale bytes on the twin's volume, kept rather than deleted by an open"
4745        );
4746    }
4747}
4748
4749#[cfg(test)]
4750mod skew_tests {
4751    /// ★ **The twin's OWN credential refused for skew is a reason to ask the front** (test5's
4752    /// in-place upgrade, 2026-10-09: the primary answered every poll "the credential is
4753    /// -7198470 ms away from this server's clock" and the twin, whose clock was the wrong one,
4754    /// asked nobody — the skew trigger fired only on credentials THIS box refused). The
4755    /// primary's exact refusal is recognised; any other poll failure is not.
4756    #[test]
4757    fn a_poll_refused_for_skew_is_told_from_any_other_failure() {
4758        let skew = anyhow::anyhow!("status: Unauthenticated, message: \"the credential is -7198470 ms away from this server's clock; the window is ±300000 ms\"");
4759        assert!(super::refused_for_skew(&skew));
4760        let stale = gunnar_grpc::auth::AuthFailure::Stale { skew_ms: -7_198_470 }.status();
4761        assert!(super::refused_for_skew(&anyhow::anyhow!("{stale}")), "the gate's own Stale status: {stale}");
4762        for other in ["status: Unauthenticated, message: \"this credential has already been used\"", "transport error: connection refused", "the primary is not answering"] {
4763            assert!(!super::refused_for_skew(&anyhow::anyhow!("{other}")), "{other}");
4764        }
4765    }
4766}
4767
4768#[cfg(test)]
4769mod own_load_tests {
4770    /// ★ **A twin reports its own load on every poll** (2026-10-05): the
4771    /// cores it may run on and how busy the box is, read here — on Linux
4772    /// both are there; a reader that fails is absent, never 0.
4773    #[test]
4774    fn a_twin_reports_its_own_cores_and_load() {
4775        let (busy, total) = super::own_load();
4776        assert!(total.is_some_and(|t| t >= 1_000 && t % 1_000 == 0), "{total:?}");
4777        assert!(busy.is_some(), "/proc/loadavg is readable here");
4778    }
4779}