Skip to main content

sysknife_daemon/
checkpoint_sink.rs

1//! External anchor sinks for signed audit checkpoints.
2//!
3//! A [`Checkpoint`] commits `(seq, chain_tip,
4//! timestamp)` with an Ed25519 signature. Anchoring those checkpoints to an
5//! **independent, append-only** store is what makes tail-truncation and
6//! rewrite of the local chain *detectable* by a host attacker who controls the
7//! primary database: they cannot reproduce a previously anchored signed tip
8//! (see [`verify_checkpoints`](crate::audit_chain::verify_checkpoints)).
9//!
10//! This module defines a small [`CheckpointSink`] interface with one deployable
11//! backend:
12//!
13//! - [`PostgresCheckpointSink`] — writes checkpoints to an append-only
14//!   `audit_checkpoints` table on a separate Postgres database. INSERT-only by
15//!   construction (this code never issues UPDATE/DELETE). Operators should
16//!   additionally grant the SysKnife role only `INSERT`/`SELECT` on the table
17//!   and `REVOKE UPDATE, DELETE` so a stolen daemon credential cannot rewrite
18//!   the anchor either. Append-only permissions alone do not stop a DB
19//!   superuser; the *signature* is what makes tampering detectable.
20//!
21//! It says "two backends" because there was a second, public
22//! `InMemoryCheckpointSink` documented for "tests and dry runs" — with no
23//! dry-run consumer anywhere. The test suite's in-memory sink now lives under
24//! `#[cfg(test)]` in this module, so only the Postgres sink is deployable.
25//!
26//! Additional verifiable backends (immudb, WORM object storage, an RFC 3161
27//! timestamp authority) can implement the same trait.
28
29use async_trait::async_trait;
30use sqlx_core::row::Row;
31use sqlx_postgres::{PgConnectOptions, PgPool, PgPoolOptions};
32use std::str::FromStr;
33// Only the cfg(test) in-memory sink needs interior mutability.
34#[cfg(test)]
35use std::sync::Mutex;
36use std::time::Duration;
37
38use crate::audit_chain::Checkpoint;
39
40#[derive(Debug, thiserror::Error)]
41pub enum CheckpointSinkError {
42    #[error("checkpoint sink connection error: {0}")]
43    Connect(String),
44    #[error("checkpoint sink query error: {0}")]
45    Query(String),
46}
47
48impl From<sqlx_core::Error> for CheckpointSinkError {
49    fn from(e: sqlx_core::Error) -> Self {
50        CheckpointSinkError::Query(e.to_string())
51    }
52}
53
54/// An append-only sink that stores signed checkpoints and hands them back for
55/// verification. Implementations must never mutate or delete a stored
56/// checkpoint.
57#[async_trait]
58pub trait CheckpointSink: Send + Sync {
59    /// Append one signed checkpoint. Must not overwrite prior checkpoints.
60    async fn append(&self, checkpoint: &Checkpoint) -> Result<(), CheckpointSinkError>;
61
62    /// Load every stored checkpoint, ordered by `seq` ascending.
63    async fn load_all(&self) -> Result<Vec<Checkpoint>, CheckpointSinkError>;
64}
65
66/// In-memory checkpoint sink used only by this module's tests. Append-only.
67///
68/// Production anchoring always uses [`PostgresCheckpointSink`]; keeping this
69/// behind `cfg(test)` stops it being mistaken for a deployable backend.
70#[cfg(test)]
71#[derive(Debug, Default)]
72pub struct InMemoryCheckpointSink {
73    stored: Mutex<Vec<Checkpoint>>,
74}
75
76#[cfg(test)]
77impl InMemoryCheckpointSink {
78    pub fn new() -> Self {
79        Self::default()
80    }
81}
82
83#[cfg(test)]
84#[async_trait]
85impl CheckpointSink for InMemoryCheckpointSink {
86    async fn append(&self, checkpoint: &Checkpoint) -> Result<(), CheckpointSinkError> {
87        let mut stored = self
88            .stored
89            .lock()
90            .map_err(|e| CheckpointSinkError::Query(format!("lock poisoned: {e}")))?;
91        stored.push(checkpoint.clone());
92        Ok(())
93    }
94
95    async fn load_all(&self) -> Result<Vec<Checkpoint>, CheckpointSinkError> {
96        let stored = self
97            .stored
98            .lock()
99            .map_err(|e| CheckpointSinkError::Query(format!("lock poisoned: {e}")))?;
100        let mut out = stored.clone();
101        out.sort_by_key(|c| c.seq);
102        Ok(out)
103    }
104}
105
106/// Postgres append-only checkpoint sink. Anchors signed checkpoints to a
107/// separate database so a host attacker cannot silently rewrite or truncate
108/// the local chain without being detected on the next `verify`.
109#[derive(Debug)]
110pub struct PostgresCheckpointSink {
111    pool: PgPool,
112}
113
114impl PostgresCheckpointSink {
115    /// Connect to `url`, create the append-only `audit_checkpoints` table if it
116    /// does not exist, and return the sink.
117    pub async fn connect(url: &str) -> Result<Self, CheckpointSinkError> {
118        // Do not echo the URL/DSN into the error (it can carry credentials).
119        let opts = PgConnectOptions::from_str(url).map_err(|_| {
120            CheckpointSinkError::Connect("invalid checkpoint database URL".to_string())
121        })?;
122        let pool = PgPoolOptions::new()
123            .max_connections(4)
124            .acquire_timeout(Duration::from_secs(10))
125            .connect_with(opts)
126            .await
127            .map_err(|e| CheckpointSinkError::Connect(e.to_string()))?;
128        let sink = Self { pool };
129        sink.initialize().await?;
130        Ok(sink)
131    }
132
133    async fn initialize(&self) -> Result<(), CheckpointSinkError> {
134        // Only CREATE when the table is absent. This lets a hardened,
135        // least-privilege role (INSERT/SELECT only, no DDL, per the module
136        // docs) connect to an already-provisioned table; the one-time CREATE
137        // is an admin/bootstrap step that a DDL-capable role performs once.
138        let row = sqlx_core::query::query(
139            "SELECT to_regclass('audit_checkpoints') IS NOT NULL AS present",
140        )
141        .fetch_one(&self.pool)
142        .await?;
143        let present: bool = row.try_get("present")?;
144        if present {
145            return Ok(());
146        }
147        sqlx_core::query::query(
148            "CREATE TABLE IF NOT EXISTS audit_checkpoints (\
149                 seq BIGINT NOT NULL, \
150                 chain_tip TEXT NOT NULL, \
151                 created_at TEXT NOT NULL, \
152                 signature TEXT NOT NULL\
153             )",
154        )
155        .execute(&self.pool)
156        .await?;
157        Ok(())
158    }
159}
160
161#[async_trait]
162impl CheckpointSink for PostgresCheckpointSink {
163    async fn append(&self, checkpoint: &Checkpoint) -> Result<(), CheckpointSinkError> {
164        sqlx_core::query::query(
165            "INSERT INTO audit_checkpoints (seq, chain_tip, created_at, signature) \
166             VALUES ($1, $2, $3, $4)",
167        )
168        .bind(i64::try_from(checkpoint.seq).map_err(|_| {
169            CheckpointSinkError::Query("checkpoint seq exceeds i64 range".to_string())
170        })?)
171        .bind(&checkpoint.chain_tip)
172        .bind(&checkpoint.created_at)
173        .bind(&checkpoint.signature)
174        .execute(&self.pool)
175        .await?;
176        Ok(())
177    }
178
179    async fn load_all(&self) -> Result<Vec<Checkpoint>, CheckpointSinkError> {
180        let rows = sqlx_core::query::query(
181            "SELECT seq, chain_tip, created_at, signature \
182             FROM audit_checkpoints ORDER BY seq ASC",
183        )
184        .fetch_all(&self.pool)
185        .await?;
186
187        let mut out = Vec::with_capacity(rows.len());
188        for row in rows {
189            let seq: i64 = row.try_get("seq")?;
190            out.push(Checkpoint {
191                seq: u64::try_from(seq).map_err(|e| {
192                    CheckpointSinkError::Query(format!("negative checkpoint seq: {e}"))
193                })?,
194                chain_tip: row.try_get("chain_tip")?,
195                created_at: row.try_get("created_at")?,
196                signature: row.try_get("signature")?,
197            });
198        }
199        Ok(out)
200    }
201}
202
203// ---------------------------------------------------------------------------
204// Anchoring
205// ---------------------------------------------------------------------------
206
207/// Result of one anchoring attempt.
208///
209/// Every non-`Anchored` variant means the anchor did **not** advance, and the
210/// tamper-evidence window did not move forward. Callers must surface them;
211/// treating a failed anchor as routine is how this defence quietly stops
212/// working.
213#[derive(Debug, PartialEq, Eq)]
214pub enum AnchorOutcome {
215    /// A checkpoint was written and every anchored checkpoint still verifies.
216    Anchored { seq: u64, checkpoints_checked: u64 },
217    /// Nothing to anchor yet — the chain has no rows.
218    ChainEmpty,
219    /// The local chain does not verify, so anchoring was refused.
220    ChainBroken(crate::audit_chain::VerifyOutcome),
221    /// The write appeared to succeed but the checkpoint was absent on read-back.
222    ReadBackMissing,
223    /// A stored checkpoint no longer matches the local chain.
224    Inconsistent(crate::audit_chain::CheckpointOutcome),
225}
226
227/// Sign the current chain tip and anchor it to `sink`, then prove the anchor
228/// landed and still agrees with the local chain.
229///
230/// Shared by the `sysknife audit checkpoint` CLI command and the daemon's
231/// periodic anchor task so both enforce the same guarantees:
232///
233/// 1. **Refuse to anchor a broken chain.** Anchoring the tip of a tampered
234///    chain would launder the tamper into a signed checkpoint.
235/// 2. **Read back after writing.** A lagging replica or the wrong database
236///    would otherwise render as a reassuring "consistent (0 verified)".
237/// 3. **Re-verify every anchored checkpoint**, not just the new one.
238pub async fn anchor_once(
239    key: &crate::audit_chain::AuditKey,
240    rows: &[crate::audit_chain::ChainRow],
241    sink: &dyn CheckpointSink,
242    created_at: &str,
243) -> Result<AnchorOutcome, CheckpointSinkError> {
244    use crate::audit_chain::{verify_chain, verify_checkpoints, CheckpointOutcome, VerifyOutcome};
245
246    let Some(tip) = rows.last() else {
247        return Ok(AnchorOutcome::ChainEmpty);
248    };
249
250    match verify_chain(key, rows) {
251        VerifyOutcome::Intact { .. } => {}
252        broken => return Ok(AnchorOutcome::ChainBroken(broken)),
253    }
254
255    let checkpoint = key.sign_checkpoint(tip.seq, &tip.chain_hash, created_at);
256    sink.append(&checkpoint).await?;
257
258    let anchored = sink.load_all().await?;
259    if !anchored.contains(&checkpoint) {
260        return Ok(AnchorOutcome::ReadBackMissing);
261    }
262
263    match verify_checkpoints(&key.verifying_key_hex(), rows, &anchored) {
264        CheckpointOutcome::Consistent {
265            checkpoints_checked,
266        } => Ok(AnchorOutcome::Anchored {
267            seq: checkpoint.seq,
268            checkpoints_checked,
269        }),
270        other => Ok(AnchorOutcome::Inconsistent(other)),
271    }
272}
273
274/// How often the daemon anchors a checkpoint when a sink is configured.
275///
276/// This bounds the tamper window: an attacker who compromises the host can
277/// rewrite history only back to the last anchor, so the interval is the
278/// exposure. Fifteen minutes keeps the write volume trivial (one row) while
279/// keeping that window short.
280pub const DEFAULT_ANCHOR_INTERVAL: Duration = Duration::from_secs(15 * 60);
281
282/// Periodically anchor the chain tip to `sink` for as long as the daemon runs.
283///
284/// Without this the checkpoint machinery is inert: `sign_checkpoint`,
285/// `verify_checkpoints` and the sinks all existed and were tested, but nothing
286/// in the daemon ever called them, so the tail-truncation defence the threat
287/// model relies on was only active if an operator happened to run
288/// `sysknife audit checkpoint` on a timer of their own.
289pub fn spawn_periodic_anchor(
290    audit: std::sync::Arc<dyn crate::store::AuditStore>,
291    key: std::sync::Arc<crate::audit_chain::AuditKey>,
292    sink: std::sync::Arc<dyn CheckpointSink>,
293    interval: Duration,
294) -> tokio::task::JoinHandle<()> {
295    tokio::spawn(async move {
296        loop {
297            tokio::time::sleep(interval).await;
298
299            let rows = match audit.fetch_chain_rows().await {
300                Ok(rows) => rows,
301                Err(e) => {
302                    eprintln!("[sysknife-daemon] checkpoint anchor: reading chain failed: {e}");
303                    continue;
304                }
305            };
306
307            let created_at = chrono::Utc::now().to_rfc3339();
308            match anchor_once(&key, &rows, sink.as_ref(), &created_at).await {
309                Ok(AnchorOutcome::Anchored {
310                    seq,
311                    checkpoints_checked,
312                }) => {
313                    eprintln!(
314                        "[sysknife-daemon] anchored checkpoint seq={seq} \
315                         ({checkpoints_checked} verified)"
316                    );
317                }
318                Ok(AnchorOutcome::ChainEmpty) => {}
319                // Everything below means the anchor did not advance. These are
320                // loud on purpose: a silent anchor failure looks exactly like a
321                // working one until the day someone needs the evidence.
322                Ok(AnchorOutcome::ChainBroken(outcome)) => {
323                    eprintln!(
324                        "[sysknife-daemon] checkpoint anchor REFUSED: the local audit chain \
325                         does not verify ({outcome:?}); not anchoring a tampered chain"
326                    );
327                }
328                Ok(AnchorOutcome::ReadBackMissing) => {
329                    eprintln!(
330                        "[sysknife-daemon] checkpoint anchor FAILED: the checkpoint was absent \
331                         on read-back; the sink may be a lagging replica or a different database"
332                    );
333                }
334                Ok(AnchorOutcome::Inconsistent(outcome)) => {
335                    eprintln!(
336                        "[sysknife-daemon] checkpoint verification FAILED: {outcome:?} — \
337                         the local chain disagrees with a previously anchored checkpoint"
338                    );
339                }
340                Err(e) => {
341                    eprintln!("[sysknife-daemon] checkpoint anchor: sink error: {e}");
342                }
343            }
344        }
345    })
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use crate::audit_chain::{
352        verify_checkpoints, AuditKey, ChainContent, ChainIdentity, ChainRow, CheckpointOutcome,
353        CHAIN_VERSION_CURRENT,
354    };
355    use sysknife_types::RiskLevel;
356
357    fn key() -> AuditKey {
358        AuditKey::from_bytes(vec![0x42; 32])
359    }
360
361    /// Build a small intact chain the same way the daemon would.
362    pub(super) fn build_chain(key: &AuditKey, count: usize) -> Vec<ChainRow> {
363        let mut rows = Vec::with_capacity(count);
364        let mut prev = String::new();
365        for i in 0..count {
366            let seq = (i + 1) as u64;
367            let txid = format!("tx{i}");
368            let content = ChainContent {
369                seq,
370                key_id: "v1",
371                transaction_id: &txid,
372                request_id: "req",
373                request_hash: "hash",
374                action_name: "UpdateSystem",
375                risk_level: RiskLevel::High,
376                summary: "s",
377                approval_id: None,
378                warnings_json: "[]",
379                created_at: "2026-04-24T12:00:00Z",
380                identity: ChainIdentity::V2 {
381                    caller_role: "dev",
382                    event_tip: "",
383                },
384            };
385            let hash = key.chain_hash(&content, &prev);
386            rows.push(ChainRow {
387                seq,
388                key_id: "v1".to_string(),
389                transaction_id: txid,
390                request_id: "req".to_string(),
391                request_hash: "hash".to_string(),
392                action_name: "UpdateSystem".to_string(),
393                risk_level: RiskLevel::High,
394                summary: "s".to_string(),
395                approval_id: None,
396                warnings_json: "[]".to_string(),
397                created_at: "2026-04-24T12:00:00Z".to_string(),
398                prev_chain_hash: prev.clone(),
399                chain_hash: hash.clone(),
400                chain_version: CHAIN_VERSION_CURRENT,
401                caller_role: Some("dev".to_string()),
402                event_tip: Some(String::new()),
403            });
404            prev = hash;
405        }
406        rows
407    }
408
409    #[tokio::test]
410    async fn in_memory_append_and_load_round_trip() {
411        let key = key();
412        let rows = build_chain(&key, 3);
413        let sink = InMemoryCheckpointSink::new();
414        let cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
415        sink.append(&cp).await.unwrap();
416        let loaded = sink.load_all().await.unwrap();
417        assert_eq!(loaded, vec![cp]);
418    }
419
420    #[tokio::test]
421    async fn anchored_checkpoints_verify_and_catch_truncation() {
422        let key = key();
423        let full = build_chain(&key, 5);
424        let sink = InMemoryCheckpointSink::new();
425        // Anchor a checkpoint at the tip.
426        let cp = key.sign_checkpoint(5, &full[4].chain_hash, "2026-04-24T12:00:00Z");
427        sink.append(&cp).await.unwrap();
428
429        // Intact: loading the anchored checkpoints verifies against the full chain.
430        let anchored = sink.load_all().await.unwrap();
431        assert_eq!(
432            verify_checkpoints(&key.verifying_key_hex(), &full, &anchored),
433            CheckpointOutcome::Consistent {
434                checkpoints_checked: 1
435            }
436        );
437
438        // Truncated: the local chain is cut to 3, but the anchored tip (seq=5)
439        // can no longer be reproduced -> detected.
440        let truncated = &full[..3];
441        assert!(matches!(
442            verify_checkpoints(&key.verifying_key_hex(), truncated, &anchored),
443            CheckpointOutcome::Truncated {
444                checkpoint_seq: 5,
445                current_max_seq: 3
446            }
447        ));
448    }
449}
450
451#[cfg(test)]
452mod anchor_tests {
453    use super::*;
454    use crate::audit_chain::{AuditKey, ChainContent, ChainRow, CheckpointOutcome, VerifyOutcome};
455
456    fn key() -> AuditKey {
457        AuditKey::from_bytes(vec![0x42; 32])
458    }
459
460    /// Rebuild a chain from `rows`, re-signing every row with `key` so the
461    /// result verifies cleanly. Used to forge a tampered-but-valid tail — the
462    /// attack only a checkpoint can catch.
463    fn reseal(key: &AuditKey, rows: &[ChainRow]) -> Vec<ChainRow> {
464        let mut out = Vec::with_capacity(rows.len());
465        let mut prev = String::new();
466        for row in rows {
467            let content = ChainContent {
468                seq: row.seq,
469                key_id: &row.key_id,
470                transaction_id: &row.transaction_id,
471                request_id: &row.request_id,
472                request_hash: &row.request_hash,
473                action_name: &row.action_name,
474                risk_level: row.risk_level,
475                summary: &row.summary,
476                approval_id: row.approval_id.as_deref(),
477                warnings_json: &row.warnings_json,
478                created_at: &row.created_at,
479                identity: row.identity().expect("fixture rows are well-formed"),
480            };
481            let hash = key.chain_hash(&content, &prev);
482            let mut cloned = row.clone();
483            cloned.prev_chain_hash = prev.clone();
484            cloned.chain_hash = hash.clone();
485            out.push(cloned);
486            prev = hash;
487        }
488        out
489    }
490
491    fn chain(key: &AuditKey, count: usize) -> Vec<ChainRow> {
492        super::tests::build_chain(key, count)
493    }
494
495    #[tokio::test]
496    async fn anchor_once_writes_and_verifies_the_tip() {
497        let key = key();
498        let rows = chain(&key, 3);
499        let sink = InMemoryCheckpointSink::new();
500
501        let outcome = anchor_once(&key, &rows, &sink, "2026-04-24T12:00:00Z")
502            .await
503            .unwrap();
504
505        assert_eq!(
506            outcome,
507            AnchorOutcome::Anchored {
508                seq: 3,
509                checkpoints_checked: 1
510            }
511        );
512        assert_eq!(sink.load_all().await.unwrap().len(), 1);
513    }
514
515    #[tokio::test]
516    async fn anchor_once_refuses_to_launder_a_tampered_chain() {
517        // Anchoring the tip of a broken chain would sign the tamper into the
518        // one record that is supposed to detect it.
519        let key = key();
520        let mut rows = chain(&key, 3);
521        rows[1].summary = "tampered".to_string();
522        let sink = InMemoryCheckpointSink::new();
523
524        let outcome = anchor_once(&key, &rows, &sink, "2026-04-24T12:00:00Z")
525            .await
526            .unwrap();
527
528        assert!(
529            matches!(
530                outcome,
531                AnchorOutcome::ChainBroken(VerifyOutcome::Broken { .. })
532            ),
533            "expected a refusal, got {outcome:?}"
534        );
535        assert!(
536            sink.load_all().await.unwrap().is_empty(),
537            "nothing may be anchored when the chain does not verify"
538        );
539    }
540
541    #[tokio::test]
542    async fn anchor_once_reports_an_empty_chain_rather_than_anchoring_nothing() {
543        let key = key();
544        let sink = InMemoryCheckpointSink::new();
545        let outcome = anchor_once(&key, &[], &sink, "2026-04-24T12:00:00Z")
546            .await
547            .unwrap();
548        assert_eq!(outcome, AnchorOutcome::ChainEmpty);
549        assert!(sink.load_all().await.unwrap().is_empty());
550    }
551
552    /// A sink that accepts writes and then loses them. Models a lagging read
553    /// replica or a misconfigured second database.
554    #[derive(Default)]
555    struct BlackHoleSink;
556
557    #[async_trait]
558    impl CheckpointSink for BlackHoleSink {
559        async fn append(
560            &self,
561            _checkpoint: &crate::audit_chain::Checkpoint,
562        ) -> Result<(), CheckpointSinkError> {
563            Ok(())
564        }
565        async fn load_all(
566            &self,
567        ) -> Result<Vec<crate::audit_chain::Checkpoint>, CheckpointSinkError> {
568            Ok(Vec::new())
569        }
570    }
571
572    #[tokio::test]
573    async fn anchor_once_detects_a_write_that_did_not_land() {
574        // A sink that swallows writes must not read as a successful anchor:
575        // `verify_checkpoints` over zero checkpoints is trivially "consistent",
576        // which is exactly the reassuring-but-wrong result the read-back check
577        // exists to prevent.
578        let key = key();
579        let rows = chain(&key, 2);
580
581        let outcome = anchor_once(&key, &rows, &BlackHoleSink, "2026-04-24T12:00:00Z")
582            .await
583            .unwrap();
584
585        assert_eq!(outcome, AnchorOutcome::ReadBackMissing);
586    }
587
588    #[tokio::test]
589    async fn a_previously_anchored_checkpoint_catches_a_key_holder_rewrite() {
590        // THE reason checkpoints exist. An attacker with root AND the signing
591        // key can rebuild history so the chain verifies perfectly — every
592        // existing "rewrite" test corrupts `chain_hash` with garbage that a
593        // plain signature check already rejects, so none of them exercise this.
594        //
595        // Here the tampered tail is re-signed with the real key, so
596        // `verify_chain` says Intact. Only the anchored tip catches it.
597        let key = key();
598        let original = chain(&key, 5);
599        let sink = InMemoryCheckpointSink::new();
600
601        anchor_once(&key, &original, &sink, "2026-04-24T12:00:00Z")
602            .await
603            .unwrap();
604        let anchored = sink.load_all().await.unwrap();
605
606        let mut tampered = original.clone();
607        tampered[3].summary = "quietly rewritten".to_string();
608        let tampered = reseal(&key, &tampered);
609
610        assert!(
611            matches!(
612                crate::audit_chain::verify_chain(&key, &tampered),
613                VerifyOutcome::Intact { .. }
614            ),
615            "the forged chain must verify — otherwise this test proves nothing \
616             beyond what a plain signature check already catches"
617        );
618
619        let outcome =
620            crate::audit_chain::verify_checkpoints(&key.verifying_key_hex(), &tampered, &anchored);
621        assert!(
622            matches!(outcome, CheckpointOutcome::TipMismatch { .. }),
623            "the anchored tip must expose the rewrite, got {outcome:?}"
624        );
625    }
626
627    #[tokio::test]
628    async fn verify_chain_alone_cannot_detect_tail_truncation() {
629        // The claim the module docs make about why anchoring is necessary,
630        // asserted directly: a truncated chain is internally consistent.
631        let key = key();
632        let full = chain(&key, 5);
633        let truncated = &full[..3];
634
635        assert!(
636            matches!(
637                crate::audit_chain::verify_chain(&key, truncated),
638                VerifyOutcome::Intact {
639                    rows_checked: 3,
640                    ..
641                }
642            ),
643            "a truncated chain still verifies on its own — this is the gap"
644        );
645
646        let sink = InMemoryCheckpointSink::new();
647        anchor_once(&key, &full, &sink, "2026-04-24T12:00:00Z")
648            .await
649            .unwrap();
650        let anchored = sink.load_all().await.unwrap();
651
652        assert!(
653            matches!(
654                crate::audit_chain::verify_checkpoints(
655                    &key.verifying_key_hex(),
656                    truncated,
657                    &anchored
658                ),
659                CheckpointOutcome::Truncated { .. }
660            ),
661            "the anchor is what turns an undetectable truncation into a detected one"
662        );
663    }
664}