Skip to main content

gunnar_server/
grpc_gate.rs

1//! **The control plane's one transfer, on the same gate as git** — one
2//! implementation, not a second gate.
3//!
4//! `gunnar-grpc` cannot name `gunnar_ssh::Admission` — it is on the
5//! client/control graph, and a `gunnar-ssh` edge there would drag the whole SSH
6//! server into every `gunnar --features control` client. So it reaches the gate
7//! through the [`gunnar_grpc::TransferGate`] seam, and this is the one
8//! implementation: a thin adapter over the **same** `Arc<Admission>` the SSH and
9//! HTTP front doors hold ([`gunnar_ssh::BoundServer::admission_gate`]). No
10//! admission logic lives here — the max-min share, the concurrency ceiling and
11//! the memory ledger are all `gunnar_ssh::Admission`'s. This crate only
12//! translates one `try_admit` call and its guard across the seam.
13//!
14//! It exists as its own module, and its constructor is public, because two
15//! callers need the exact same wrapper and a second one would be the drift one
16//! implementation forbids: [`crate::serve`] wires it onto the live gRPC control plane, and
17//! `tests/grpc_admission.rs` wires it onto a gate whose ceiling it controls to
18//! drive the refusal.
19
20use std::sync::Arc;
21
22use gunnar_grpc::auth::{AuditLedger, AuditRecord};
23use gunnar_grpc::{TransferGate, TransferHold, TransferPermit, TransferRefused};
24use gunnar_ssh::governor::OpClass;
25use gunnar_ssh::{Admission, AdmissionGuard};
26
27/// Wrap the SSH/HTTP admission gate so the gRPC control plane can put
28/// `Repos.ArchiveOut` through it.
29///
30/// Hand it the very `Arc<Admission>` the git fronts hold
31/// (`BoundServer::admission_gate()`), so an archive stream counts against the
32/// same server-wide limit, the same per-principal share and the same memory
33/// budget as a `git-upload-pack`. The returned trait object is what
34/// [`gunnar_grpc::ServiceContext::with_transfer_gate`] takes.
35pub fn control_transfer_gate(admission: Arc<Admission>) -> Arc<dyn TransferGate> {
36    Arc::new(SshTransferGate { admission })
37}
38
39/// The seam implementation. Nothing but the gate and the translation.
40struct SshTransferGate {
41    admission: Arc<Admission>,
42}
43
44impl TransferGate for SshTransferGate {
45    fn admit_transfer(
46        &self,
47        principal: &str,
48        namespace: &str,
49    ) -> Result<TransferPermit, TransferRefused> {
50        // `OpClass::Clone`: an archive is the expensive class — a whole
51        // repository's stored bytes read off disk and streamed out — so it
52        // reserves and is metered as the heaviest git transfer is, not as a
53        // cheap `ls-remote`.
54        match self
55            .admission
56            .try_admit(principal, namespace, OpClass::Clone)
57        {
58            Ok(guard) => Ok(TransferPermit(Box::new(GuardHold(guard)))),
59            Err(refusal) => {
60                // The counters are read at the refusal so the audit line names
61                // the state that refused — the same snapshot the SSH and HTTP
62                // fronts log from.
63                let stats = self.admission.stats();
64                Err(TransferRefused {
65                    message: refusal.client_message(),
66                    in_flight: stats.in_flight,
67                    peak_in_flight: stats.peak_in_flight,
68                    limit: stats.limit,
69                    reserved_bytes: stats.reserved_bytes,
70                    refused_total: stats.refused(),
71                })
72            }
73        }
74    }
75}
76
77/// The held permit, across the seam. Dropping it drops the
78/// [`AdmissionGuard`], which releases the slot, the per-principal count and the
79/// memory reservation — the whole point of routing through the real gate.
80struct GuardHold(AdmissionGuard);
81
82impl TransferHold for GuardHold {
83    fn entered_service(&self) {
84        self.0.entered_service();
85    }
86}
87
88// ───────────────────────────────────────────────────────────────────────────
89// The audit trail (GUN-044)
90// ───────────────────────────────────────────────────────────────────────────
91
92/// **The durable, tamper-evident half of the control plane's audit trail**, on
93/// the same seam and for the same reason as the gate above: `gunnar-grpc`
94/// cannot name `gunnar_core::AuditChain`, so it reaches one through
95/// [`gunnar_grpc::auth::AuditLedger`] and this is the one implementation.
96///
97/// The chain lives beside the rest of the control state
98/// (`<control dir>/audit-chain.jsonl`, [`gunnar_core::AUDIT_FILE`]) because
99/// that is where everything else a server must not lose already lives, and
100/// because a trail on a different disk from the thing it describes is a trail
101/// somebody eventually forgets to copy.
102///
103/// **Opening it verifies it.** `AuditChain::open` reads the existing file and
104/// checks every link before the first append, so a server refuses to extend a
105/// chain that has already been broken rather than laying genuine records on top
106/// of a forgery and making the break look later than it was. That refusal is
107/// the caller's to handle: [`crate::serve`] logs it and starts without a
108/// ledger, because a control plane that will not start is a worse outcome than
109/// one whose audit trail is loudly known to be damaged.
110pub fn control_audit_ledger(chain: Arc<gunnar_core::AuditChain>) -> Arc<dyn AuditLedger> {
111    control_audit_ledger_with_window(chain, READ_WINDOW)
112}
113
114/// [`control_audit_ledger`] with the plain-read window given (tests use a short one).
115pub fn control_audit_ledger_with_window(
116    chain: Arc<gunnar_core::AuditChain>,
117    window: std::time::Duration,
118) -> Arc<dyn AuditLedger> {
119    let every = window
120        .min(std::time::Duration::from_secs(5))
121        .max(std::time::Duration::from_millis(50));
122    control_audit_ledger_with(chain, window, every)
123}
124
125/// [`control_audit_ledger`] with the plain-read window and the sweeper's interval given.
126pub fn control_audit_ledger_with(
127    chain: Arc<gunnar_core::AuditChain>,
128    window: std::time::Duration,
129    every: std::time::Duration,
130) -> Arc<dyn AuditLedger> {
131    ledger_on(chain, window, every, Arc::new(gunnar_core::now_ms))
132}
133
134/// **The gate's decisions, also read for "who changed something, and when"**
135/// (2026-10-21): every allowed call whose method is not a read
136/// ([`gunnar_grpc::auth::changes_state`]) stamps its caller's last operation in
137/// `activity`, then the decision goes on to `trail` (the hash-chained ledger)
138/// when there is one. The stamp is an in-memory move at most once a minute per
139/// principal ([`gunnar_core::activity`]); the request pays no write for it.
140///
141/// Always installed, with or without a trail: a box whose audit chain would
142/// not open still answers when its people last did something.
143pub fn activity_ledger(
144    trail: Option<Arc<dyn AuditLedger>>,
145    activity: Arc<gunnar_core::PrincipalActivity>,
146) -> Arc<dyn AuditLedger> {
147    Arc::new(ActivityLedger { trail, activity })
148}
149
150struct ActivityLedger {
151    trail: Option<Arc<dyn AuditLedger>>,
152    activity: Arc<gunnar_core::PrincipalActivity>,
153}
154
155impl AuditLedger for ActivityLedger {
156    fn record(&self, record: AuditRecord<'_>) {
157        // An allowed change only: a refusal changed nothing, a narrowed
158        // listing is a read, and so is every method `changes_state` says is.
159        if record.decision == "allow" && gunnar_grpc::auth::changes_state(record.method) {
160            self.activity
161                .operated(record.principal, gunnar_core::now_ms());
162        }
163        if let Some(trail) = &self.trail {
164            trail.record(record);
165        }
166    }
167
168    /// RA4-8: a change is durable only when the trail kept it. With no trail (the chain would
169    /// not open) it can only be a log line, so it is not durable.
170    fn record_change(&self, record: AuditRecord<'_>) -> Result<(), String> {
171        if record.decision == "allow" && gunnar_grpc::auth::changes_state(record.method) {
172            self.activity
173                .operated(record.principal, gunnar_core::now_ms());
174        }
175        match &self.trail {
176            Some(trail) => trail.record_change(record),
177            None => Err("this server keeps no audit trail (its chain did not open)".to_owned()),
178        }
179    }
180}
181
182/// The time the ledger's windows are measured in: the wall clock, or a test's.
183type Clock = Arc<dyn Fn() -> i64 + Send + Sync>;
184
185/// The duty that writes the read windows that close while nobody reads (A28 M18).
186pub const READ_SWEEPER: &str = "audit-reads";
187
188/// [`control_audit_ledger_with`] on `clock`. A test moves its own clock, so a window closes when
189/// the test says and not when a loaded box's fsync happens to outlast it (R5.1, 2026-10-07: a
190/// 50 ms window on the wall clock closed between a read and its repeat while the first read's
191/// append waited on the disk, and the test counted 83 entries for 50 first reads).
192fn ledger_on(
193    chain: Arc<gunnar_core::AuditChain>,
194    window: std::time::Duration,
195    every: std::time::Duration,
196    clock: Clock,
197) -> Arc<dyn AuditLedger> {
198    let ledger = Arc::new(ChainLedger {
199        chain,
200        window_ms: i64::try_from(window.as_millis()).unwrap_or(i64::MAX).max(1),
201        reads: std::sync::Mutex::new(Reads::default()),
202        clock,
203    });
204    // The windows that close while nobody reads are written by a sweeper that lives as long as
205    // the ledger does (it holds only a `Weak`).
206    // A jera job (A28 M18, A32-14): listed, a sweep that panics is said and a FAILED row and the
207    // next one runs. Its first round only waits, as the thread's first sleep did.
208    let weak = Arc::downgrade(&ledger);
209    let mut first = true;
210    let say: gunnar_common::jobs::Say = Arc::new(|l: &str| tracing::error!("{l}"));
211    let started = gunnar_common::jobs::start(READ_SWEEPER, every, say, move || {
212        if std::mem::take(&mut first) {
213            return gunnar_common::jobs::Round::Again;
214        }
215        match weak.upgrade() {
216            Some(ledger) => {
217                ledger.sweep((ledger.clock)(), false);
218                gunnar_common::jobs::Round::Again
219            }
220            None => gunnar_common::jobs::Round::Done,
221        }
222    });
223    if let Err(e) = started {
224        tracing::error!("ALERT {READ_SWEEPER} no sweeper ({e}): a read window that closes while nobody reads is written at the next read only");
225    }
226    ledger
227}
228
229/// **Repeated plain reads are AGGREGATED, never dropped** (P3 of the load test, 2026-10-06).
230///
231/// "Who read what" is an audit promise (GUN-044; the enterprise audit row cov-03), so every plain
232/// read — allowed or narrowed at level `read`, no override — still reaches the chain. What made
233/// them expensive was their number: the console, the sign-in pages and monetize's usage poller
234/// made every read a replicated frame and two flushes on main (measured: 300 reads = 300 frames,
235/// 600 fsyncs, 250,880 B), each the twin's to store too.
236///
237/// Per (method, principal, key, scope, level, mode, decision, reason) the FIRST read of a window
238/// is written at once, as it always was; the repeats inside the window ([`READ_WINDOW`], 60 s from
239/// that first read) are counted, and when the window closes ONE more entry says how many there
240/// were and when the first and last of them came: `reason` = `aggregated: <n> more calls, first
241/// <ms>, last <ms>, window <s> s` (followed by the read's own reason, if it had one), `at` = the
242/// last one. The chain's format is unchanged, so every reader of it (verify, the export, a
243/// restore) reads it as before and still answers who read what, and when to the window.
244///
245/// Said plainly: the COUNT of a window still open when the box dies is not in the trail — its
246/// first read is. Refusals (reads included), writes, admin and self-scope calls and overrides
247/// are one entry each, at once, as before.
248pub const READ_WINDOW: std::time::Duration = std::time::Duration::from_secs(60);
249
250/// The words that open an aggregated entry's `reason`.
251pub const AGGREGATED: &str = "aggregated: ";
252
253/// Is this a plain read ([`READ_WINDOW`])?
254fn plain_read(record: &AuditRecord<'_>) -> bool {
255    record.level == gunnar_grpc::auth::level_name(gunnar_proto::GrantLevel::Read)
256        && matches!(record.decision, "allow" | "narrow")
257        && !record.reason.starts_with("OVERRIDE:")
258}
259
260/// One read's identity, owned.
261#[derive(Clone, PartialEq, Eq, Hash)]
262struct ReadKey {
263    method: String,
264    principal: String,
265    fingerprint: String,
266    scope: String,
267    level: String,
268    auth_mode: String,
269    decision: String,
270    reason: String,
271}
272
273impl ReadKey {
274    fn of(r: &AuditRecord<'_>) -> Self {
275        ReadKey {
276            method: r.method.to_owned(),
277            principal: r.principal.to_owned(),
278            fingerprint: r.fingerprint.to_owned(),
279            scope: r.scope.to_owned(),
280            level: r.level.to_owned(),
281            auth_mode: r.auth_mode.to_owned(),
282            decision: r.decision.to_owned(),
283            reason: r.reason.to_owned(),
284        }
285    }
286}
287
288/// A window: its first read (written), the last, and how many came after the first.
289#[derive(Clone, Copy)]
290struct Window {
291    first_ms: i64,
292    last_ms: i64,
293    more: u64,
294}
295
296#[derive(Default)]
297struct Reads {
298    open: std::collections::HashMap<ReadKey, Window>,
299    /// Windows with repeats that closed when their key was read again: written by the sweeper,
300    /// never by the request that noticed (r5 stall review, 2026-10-07).
301    closed: Vec<(ReadKey, Window)>,
302}
303
304/// The seam implementation. The chain does the arithmetic; this translates.
305struct ChainLedger {
306    chain: Arc<gunnar_core::AuditChain>,
307    window_ms: i64,
308    reads: std::sync::Mutex<Reads>,
309    /// [`gunnar_core::now_ms`] outside tests.
310    clock: Clock,
311}
312
313impl ChainLedger {
314    /// Close every window older than the window length (`all`: every window), writing the
315    /// count of the ones that had repeats. In order of their first read.
316    ///
317    /// **Only the sweeper thread and the ledger's drop call this** — each window it closes is a
318    /// durable audit append, and a request thread must not pay other readers' fsyncs.
319    fn sweep(&self, now_ms: i64, all: bool) {
320        let mut due: Vec<(ReadKey, Window)> = {
321            let mut reads = self.reads.lock().unwrap_or_else(|p| p.into_inner());
322            let window_ms = self.window_ms;
323            let closed: Vec<ReadKey> = reads
324                .open
325                .iter()
326                .filter(|(_, w)| all || now_ms - w.first_ms >= window_ms)
327                .map(|(k, _)| k.clone())
328                .collect();
329            let mut due: Vec<(ReadKey, Window)> = std::mem::take(&mut reads.closed);
330            due.extend(
331                closed
332                    .into_iter()
333                    .filter_map(|k| reads.open.remove(&k).map(|w| (k, w)))
334                    .filter(|(_, w)| w.more > 0),
335            );
336            due
337        };
338        due.sort_by_key(|(_, w)| w.first_ms);
339        for (k, w) in due {
340            self.write_window(&k, w);
341        }
342    }
343
344    /// The one entry a closed window with repeats leaves.
345    fn write_window(&self, k: &ReadKey, w: Window) {
346        let mut reason = format!(
347            "{AGGREGATED}{} more calls, first {}, last {}, window {} s",
348            w.more,
349            w.first_ms,
350            w.last_ms,
351            self.window_ms / 1000
352        );
353        if !k.reason.is_empty() {
354            reason.push_str("; ");
355            reason.push_str(&k.reason);
356        }
357        self.write(
358            AuditRecord {
359                method: &k.method,
360                principal: &k.principal,
361                fingerprint: &k.fingerprint,
362                scope: &k.scope,
363                level: &k.level,
364                auth_mode: &k.auth_mode,
365                decision: &k.decision,
366                reason: &reason,
367            },
368            w.last_ms,
369        );
370    }
371}
372
373impl Drop for ChainLedger {
374    fn drop(&mut self) {
375        // The counts of the windows still open go into the trail rather than with the process.
376        self.sweep((self.clock)(), true);
377    }
378}
379
380impl AuditLedger for ChainLedger {
381    /// RA4-8: a change goes to the chain at once (it is never a plain read) and says whether it
382    /// is there.
383    fn record_change(&self, record: AuditRecord<'_>) -> Result<(), String> {
384        let now = (self.clock)();
385        self.append(record, now)
386    }
387
388    fn record(&self, record: AuditRecord<'_>) {
389        let now = (self.clock)();
390        if !plain_read(&record) {
391            self.write(record, now);
392            return;
393        }
394        let first = {
395            let mut reads = self.reads.lock().unwrap_or_else(|p| p.into_inner());
396            let key = ReadKey::of(&record);
397            let window_ms = self.window_ms;
398            match reads.open.get(&key).copied() {
399                Some(w) if now - w.first_ms < window_ms => {
400                    if let Some(w) = reads.open.get_mut(&key) {
401                        w.more += 1;
402                        w.last_ms = now;
403                    }
404                    false
405                }
406                Some(w) => {
407                    // This key's window closed: its count goes to the sweeper, in order.
408                    if w.more > 0 {
409                        reads.closed.push((key.clone(), w));
410                    }
411                    reads.open.insert(
412                        key,
413                        Window {
414                            first_ms: now,
415                            last_ms: now,
416                            more: 0,
417                        },
418                    );
419                    true
420                }
421                None => {
422                    reads.open.insert(
423                        key,
424                        Window {
425                            first_ms: now,
426                            last_ms: now,
427                            more: 0,
428                        },
429                    );
430                    true
431                }
432            }
433        };
434        // The request writes its OWN first read and nothing else; closed windows are the
435        // sweeper's ([`ChainLedger::sweep`]).
436        if first {
437            self.write(record, now);
438        }
439    }
440}
441
442impl ChainLedger {
443    /// Best effort ([`AuditLedger::record`]): a failure is the ERROR line [`Self::append`] says.
444    fn write(&self, record: AuditRecord<'_>, at_ms: i64) {
445        let _ = self.append(record, at_ms);
446    }
447
448    /// One decision into the chain; `Err` when the chain could not take it (RA4-8: a change
449    /// that must be recorded before it is applied is then refused). A twin's line kept in this
450    /// log only, and a decision held for the remount, are what the box was told to do: `Ok`.
451    fn append(&self, record: AuditRecord<'_>, at_ms: i64) -> Result<(), String> {
452        let entry = gunnar_core::AuditEvent {
453            method: record.method,
454            principal: record.principal,
455            fingerprint: record.fingerprint,
456            scope: record.scope,
457            level: record.level,
458            auth_mode: record.auth_mode,
459            decision: record.decision,
460            reason: record.reason,
461        };
462        // **Best effort, by the seam's contract.** A full disk must not turn
463        // into a control plane that refuses to serve; it turns into this line,
464        // on the audit target, where an operator is already looking.
465        //
466        // A box doing what it was told is not that line (G + G2, release-3
467        // test2 mock 2026-09-29): a twin (or a box being restored) originates
468        // nothing into the trail it holds for its primary, and a growth
469        // releases the log for the remount — the first is said at INFO and the
470        // decision lives in this log only, the second waits and is written
471        // after the remount.
472        match self.chain.record(entry, at_ms) {
473            Ok(gunnar_core::Recorded::Written(_)) => {}
474            Ok(gunnar_core::Recorded::Queued { pending, .. }) => tracing::info!(
475                target: gunnar_grpc::AUDIT_TARGET,
476                method = record.method,
477                principal = record.principal,
478                decision = record.decision,
479                pending,
480                "the data set is being grown: this decision waits for the audit chain and is \
481                 written after the remount"
482            ),
483            Ok(gunnar_core::Recorded::NotOriginated) => tracing::info!(
484                target: gunnar_grpc::AUDIT_TARGET,
485                method = record.method,
486                principal = record.principal,
487                decision = record.decision,
488                "this box follows its primary (a twin, or restoring): it originates nothing into \
489                 the replicated trail, so this decision is in this log only"
490            ),
491            Err(err) => {
492                tracing::error!(
493                    target: gunnar_grpc::AUDIT_TARGET,
494                    error = %err,
495                    method = record.method,
496                    principal = record.principal,
497                    "the control-plane audit chain could not be appended to; this decision is in \
498                     the log and NOT in the trail"
499                );
500                return Err(err.to_string());
501            }
502        }
503        Ok(())
504    }
505}
506
507#[cfg(test)]
508mod tests {
509
510    /// ★ **The read sweeper is a jera job, and a sweep that panics is said, a FAILED row, and the
511    /// next one runs** (A28 M18, A32-14): the raw `audit-reads` thread ended at a panic, and the
512    /// windows that close while nobody reads were never written again — an audit promise (GUN-044)
513    /// kept by nothing.
514    #[test]
515    fn the_read_sweeper_is_a_jera_job_that_outlives_a_panicking_sweep() {
516        let dir = tempfile::tempdir().unwrap();
517        let chain =
518            Arc::new(gunnar_core::AuditChain::open(dir.path().join("audit-chain.jsonl")).unwrap());
519        let calls = Arc::new(std::sync::atomic::AtomicU32::new(0));
520        let clock: Clock = {
521            let calls = Arc::clone(&calls);
522            Arc::new(move || {
523                if calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 {
524                    panic!("the clock broke once");
525                }
526                1_000_000
527            })
528        };
529        let ledger = ledger_on(
530            chain,
531            std::time::Duration::from_millis(50),
532            std::time::Duration::from_millis(10),
533            clock,
534        );
535        let t = std::time::Instant::now();
536        let seen = loop {
537            let d = gunnar_common::jobs::running().into_iter().find(|d| {
538                d.kind() == READ_SWEEPER
539                    && d.failures() >= 1
540                    && d.looks() >= 4
541                    && d.queue().stats().failed >= 1
542            });
543            if d.is_some() || t.elapsed() > std::time::Duration::from_secs(10) {
544                break d;
545            }
546            std::thread::sleep(std::time::Duration::from_millis(5));
547        };
548        let d = seen.expect(
549            "the sweeper is a jera job that went on after its panic, the panic a FAILED row",
550        );
551        assert!(d
552            .last_failure()
553            .unwrap_or_default()
554            .contains("the clock broke once"));
555        drop(ledger);
556        assert!(d.stop(std::time::Duration::from_secs(5)));
557    }
558
559    use super::*;
560    use std::sync::Mutex;
561
562    /// Every line the ledger says, at its level.
563    #[derive(Clone, Default)]
564    struct Heard(Arc<Mutex<Vec<u8>>>);
565
566    impl std::io::Write for Heard {
567        fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
568            self.0.lock().unwrap().extend_from_slice(b);
569            Ok(b.len())
570        }
571        fn flush(&mut self) -> std::io::Result<()> {
572            Ok(())
573        }
574    }
575
576    /// An admin decision: one entry each (a plain read is aggregated — [`READ_WINDOW`]).
577    fn decision(method: &str) -> AuditRecord<'_> {
578        AuditRecord {
579            method,
580            principal: "monetize",
581            fingerprint: "SHA256:x",
582            scope: "server",
583            level: "admin",
584            auth_mode: "required",
585            decision: "allow",
586            reason: "",
587        }
588    }
589
590    /// **A plain read writes at most its own entry on the request thread** (r5 stall review,
591    /// 2026-10-07). The windows of OTHER readers that closed meanwhile used to be written by
592    /// whichever request read next — one durable audit append (an fsync) per window, on a tokio
593    /// worker, while the RPC waited: 50 closed windows = 50 fsyncs inside one `Usage.Read`. They
594    /// belong to the sweeper thread; the request writes its own first read and nothing else.
595    #[test]
596    fn a_plain_read_writes_at_most_its_own_entry_on_the_request_thread() {
597        let dir = tempfile::tempdir().unwrap();
598        let chain =
599            Arc::new(gunnar_core::AuditChain::open(dir.path().join("audit-chain.jsonl")).unwrap());
600        // A 50 ms window on a clock THIS TEST moves, and a sweeper that does not run during the
601        // test: every count below is of entries, whatever the disk's fsyncs cost under load.
602        let now = Arc::new(std::sync::atomic::AtomicI64::new(1_000_000));
603        let clock: Clock = {
604            let now = Arc::clone(&now);
605            Arc::new(move || now.load(std::sync::atomic::Ordering::SeqCst))
606        };
607        let ledger = ledger_on(
608            Arc::clone(&chain),
609            std::time::Duration::from_millis(50),
610            std::time::Duration::from_secs(3600),
611            clock,
612        );
613        let scopes: Vec<String> = (0..50).map(|i| format!("namespace:t{i}")).collect();
614        fn read(scope: &str) -> AuditRecord<'_> {
615            AuditRecord {
616                method: "Usage.Read",
617                principal: "monetize",
618                fingerprint: "SHA256:m",
619                scope,
620                level: "read",
621                auth_mode: "required",
622                decision: "allow",
623                reason: "",
624            }
625        }
626        for scope in &scopes {
627            ledger.record(read(scope));
628            ledger.record(read(scope));
629        }
630        assert_eq!(
631            chain.entries().unwrap().len(),
632            50,
633            "50 first reads, the repeats counted"
634        );
635        // Every window closes.
636        now.fetch_add(1200, std::sync::atomic::Ordering::SeqCst);
637        let before = chain.entries().unwrap().len();
638        ledger.record(read("namespace:someone-else"));
639        let inline = chain.entries().unwrap().len() - before;
640        assert_eq!(
641            inline,
642            1,
643            "the request wrote {inline} entries inline: its own first read and {} other windows",
644            inline.saturating_sub(1)
645        );
646        // A key read again after its window closed: its own new first read, and its old window's
647        // count is the sweeper's, not this request's.
648        let before = chain.entries().unwrap().len();
649        ledger.record(read(&scopes[0]));
650        let inline = chain.entries().unwrap().len() - before;
651        assert_eq!(
652            inline,
653            1,
654            "a re-read wrote {inline} entries inline: its own first read and {} closed windows",
655            inline.saturating_sub(1)
656        );
657        // Nothing is lost: the closed windows are written by the sweeper or at the ledger's end.
658        drop(ledger);
659        let entries = chain.entries().unwrap();
660        let counted: u64 = entries
661            .iter()
662            .map(|e| match e.reason.strip_prefix(AGGREGATED) {
663                Some(rest) => rest
664                    .split_whitespace()
665                    .next()
666                    .and_then(|n| n.parse().ok())
667                    .unwrap_or(0),
668                None => 1,
669            })
670            .sum();
671        assert_eq!(counted, 102, "every read is in the trail");
672    }
673
674    /// **G + G2 (release-3 test2 mock 2026-09-29), the lines the log gate reads.** The twin was
675    /// told `BecomeTwin` at runtime with its chain opened framed, and main's log was released
676    /// for its growth: every decision after was `ERROR gunnar::audit: the control-plane audit
677    /// chain could not be appended to` (twin 13x, main 4x) — a box doing what it was told, at
678    /// ERROR. A follower's decision is said at INFO and written nowhere; a decision during the
679    /// grow waits and is written after the remount. Neither is ever an ERROR.
680    #[test]
681    fn a_twin_or_a_growing_box_says_its_decisions_at_info_never_error() {
682        let dir = tempfile::tempdir().unwrap();
683        let log = Arc::new(gunnar_core::txlog::Log::open(dir.path().join("txlog")).unwrap());
684        gunnar_core::txlog::sink::register(dir.path(), &log);
685        let chain = Arc::new(
686            gunnar_core::AuditChain::open_framed(dir.path().join(gunnar_core::txlog::AUDIT_REL))
687                .unwrap(),
688        );
689        let ledger = control_audit_ledger(Arc::clone(&chain));
690        let heard = Heard::default();
691        let h = heard.clone();
692        let sub = tracing_subscriber::fmt()
693            .with_writer(move || h.clone())
694            .with_ansi(false)
695            .with_max_level(tracing::Level::TRACE)
696            .finish();
697        tracing::subscriber::with_default(sub, || {
698            ledger.record(decision("Repos.Create"));
699            log.release().unwrap();
700            ledger.record(decision("Admin.Capacity"));
701            ledger.record(decision("Admin.GrowStatus"));
702            log.reacquire().unwrap();
703            ledger.record(decision("Admin.GrowStatus"));
704            log.set_follower(true);
705            ledger.record(decision("Admin.GrowStart"));
706        });
707        let said = String::from_utf8(heard.0.lock().unwrap().clone()).unwrap();
708        assert!(
709            !said.contains("ERROR"),
710            "a twin or a growing box is no ERROR:\n{said}"
711        );
712        assert_eq!(
713            chain.verify().unwrap(),
714            4,
715            "the grow's two decisions are in the trail after the remount:\n{said}"
716        );
717        assert!(
718            said.contains("INFO") && said.contains("originates nothing"),
719            "the follower's decision is said at INFO:\n{said}"
720        );
721    }
722}