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