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}