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}