sysknife-daemon 0.4.0

Policy-enforced, audit-logged Linux action daemon for SysKnife
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
//! External anchor sinks for signed audit checkpoints.
//!
//! A [`Checkpoint`] commits `(seq, chain_tip,
//! timestamp)` with an Ed25519 signature. Anchoring those checkpoints to an
//! **independent, append-only** store is what makes tail-truncation and
//! rewrite of the local chain *detectable* by a host attacker who controls the
//! primary database: they cannot reproduce a previously anchored signed tip
//! (see [`verify_checkpoints`](crate::audit_chain::verify_checkpoints)).
//!
//! This module defines a small [`CheckpointSink`] interface with one deployable
//! backend:
//!
//! - [`PostgresCheckpointSink`] — writes checkpoints to an append-only
//!   `audit_checkpoints` table on a separate Postgres database. INSERT-only by
//!   construction (this code never issues UPDATE/DELETE). Operators should
//!   additionally grant the SysKnife role only `INSERT`/`SELECT` on the table
//!   and `REVOKE UPDATE, DELETE` so a stolen daemon credential cannot rewrite
//!   the anchor either. Append-only permissions alone do not stop a DB
//!   superuser; the *signature* is what makes tampering detectable.
//!
//! It says "two backends" because there was a second, public
//! `InMemoryCheckpointSink` documented for "tests and dry runs" — with no
//! dry-run consumer anywhere. The test suite's in-memory sink now lives under
//! `#[cfg(test)]` in this module, so only the Postgres sink is deployable.
//!
//! Additional verifiable backends (immudb, WORM object storage, an RFC 3161
//! timestamp authority) can implement the same trait.

use async_trait::async_trait;
use sqlx_core::row::Row;
use sqlx_postgres::{PgConnectOptions, PgPool, PgPoolOptions};
use std::str::FromStr;
// Only the cfg(test) in-memory sink needs interior mutability.
#[cfg(test)]
use std::sync::Mutex;
use std::time::Duration;

use crate::audit_chain::Checkpoint;

#[derive(Debug, thiserror::Error)]
pub enum CheckpointSinkError {
    #[error("checkpoint sink connection error: {0}")]
    Connect(String),
    #[error("checkpoint sink query error: {0}")]
    Query(String),
}

impl From<sqlx_core::Error> for CheckpointSinkError {
    fn from(e: sqlx_core::Error) -> Self {
        CheckpointSinkError::Query(e.to_string())
    }
}

/// An append-only sink that stores signed checkpoints and hands them back for
/// verification. Implementations must never mutate or delete a stored
/// checkpoint.
#[async_trait]
pub trait CheckpointSink: Send + Sync {
    /// Append one signed checkpoint. Must not overwrite prior checkpoints.
    async fn append(&self, checkpoint: &Checkpoint) -> Result<(), CheckpointSinkError>;

    /// Load every stored checkpoint, ordered by `seq` ascending.
    async fn load_all(&self) -> Result<Vec<Checkpoint>, CheckpointSinkError>;
}

/// In-memory checkpoint sink used only by this module's tests. Append-only.
///
/// Production anchoring always uses [`PostgresCheckpointSink`]; keeping this
/// behind `cfg(test)` stops it being mistaken for a deployable backend.
#[cfg(test)]
#[derive(Debug, Default)]
pub struct InMemoryCheckpointSink {
    stored: Mutex<Vec<Checkpoint>>,
}

#[cfg(test)]
impl InMemoryCheckpointSink {
    pub fn new() -> Self {
        Self::default()
    }
}

#[cfg(test)]
#[async_trait]
impl CheckpointSink for InMemoryCheckpointSink {
    async fn append(&self, checkpoint: &Checkpoint) -> Result<(), CheckpointSinkError> {
        let mut stored = self
            .stored
            .lock()
            .map_err(|e| CheckpointSinkError::Query(format!("lock poisoned: {e}")))?;
        stored.push(checkpoint.clone());
        Ok(())
    }

    async fn load_all(&self) -> Result<Vec<Checkpoint>, CheckpointSinkError> {
        let stored = self
            .stored
            .lock()
            .map_err(|e| CheckpointSinkError::Query(format!("lock poisoned: {e}")))?;
        let mut out = stored.clone();
        out.sort_by_key(|c| c.seq);
        Ok(out)
    }
}

/// Postgres append-only checkpoint sink. Anchors signed checkpoints to a
/// separate database so a host attacker cannot silently rewrite or truncate
/// the local chain without being detected on the next `verify`.
#[derive(Debug)]
pub struct PostgresCheckpointSink {
    pool: PgPool,
}

impl PostgresCheckpointSink {
    /// Connect to `url`, create the append-only `audit_checkpoints` table if it
    /// does not exist, and return the sink.
    pub async fn connect(url: &str) -> Result<Self, CheckpointSinkError> {
        // Do not echo the URL/DSN into the error (it can carry credentials).
        let opts = PgConnectOptions::from_str(url).map_err(|_| {
            CheckpointSinkError::Connect("invalid checkpoint database URL".to_string())
        })?;
        let pool = PgPoolOptions::new()
            .max_connections(4)
            .acquire_timeout(Duration::from_secs(10))
            .connect_with(opts)
            .await
            .map_err(|e| CheckpointSinkError::Connect(e.to_string()))?;
        let sink = Self { pool };
        sink.initialize().await?;
        Ok(sink)
    }

    async fn initialize(&self) -> Result<(), CheckpointSinkError> {
        // Only CREATE when the table is absent. This lets a hardened,
        // least-privilege role (INSERT/SELECT only, no DDL, per the module
        // docs) connect to an already-provisioned table; the one-time CREATE
        // is an admin/bootstrap step that a DDL-capable role performs once.
        let row = sqlx_core::query::query(
            "SELECT to_regclass('audit_checkpoints') IS NOT NULL AS present",
        )
        .fetch_one(&self.pool)
        .await?;
        let present: bool = row.try_get("present")?;
        if present {
            return Ok(());
        }
        sqlx_core::query::query(
            "CREATE TABLE IF NOT EXISTS audit_checkpoints (\
                 seq BIGINT NOT NULL, \
                 chain_tip TEXT NOT NULL, \
                 created_at TEXT NOT NULL, \
                 signature TEXT NOT NULL\
             )",
        )
        .execute(&self.pool)
        .await?;
        Ok(())
    }
}

#[async_trait]
impl CheckpointSink for PostgresCheckpointSink {
    async fn append(&self, checkpoint: &Checkpoint) -> Result<(), CheckpointSinkError> {
        sqlx_core::query::query(
            "INSERT INTO audit_checkpoints (seq, chain_tip, created_at, signature) \
             VALUES ($1, $2, $3, $4)",
        )
        .bind(i64::try_from(checkpoint.seq).map_err(|_| {
            CheckpointSinkError::Query("checkpoint seq exceeds i64 range".to_string())
        })?)
        .bind(&checkpoint.chain_tip)
        .bind(&checkpoint.created_at)
        .bind(&checkpoint.signature)
        .execute(&self.pool)
        .await?;
        Ok(())
    }

    async fn load_all(&self) -> Result<Vec<Checkpoint>, CheckpointSinkError> {
        let rows = sqlx_core::query::query(
            "SELECT seq, chain_tip, created_at, signature \
             FROM audit_checkpoints ORDER BY seq ASC",
        )
        .fetch_all(&self.pool)
        .await?;

        let mut out = Vec::with_capacity(rows.len());
        for row in rows {
            let seq: i64 = row.try_get("seq")?;
            out.push(Checkpoint {
                seq: u64::try_from(seq).map_err(|e| {
                    CheckpointSinkError::Query(format!("negative checkpoint seq: {e}"))
                })?,
                chain_tip: row.try_get("chain_tip")?,
                created_at: row.try_get("created_at")?,
                signature: row.try_get("signature")?,
            });
        }
        Ok(out)
    }
}

// ---------------------------------------------------------------------------
// Anchoring
// ---------------------------------------------------------------------------

/// Result of one anchoring attempt.
///
/// Every non-`Anchored` variant means the anchor did **not** advance, and the
/// tamper-evidence window did not move forward. Callers must surface them;
/// treating a failed anchor as routine is how this defence quietly stops
/// working.
#[derive(Debug, PartialEq, Eq)]
pub enum AnchorOutcome {
    /// A checkpoint was written and every anchored checkpoint still verifies.
    Anchored { seq: u64, checkpoints_checked: u64 },
    /// Nothing to anchor yet — the chain has no rows.
    ChainEmpty,
    /// The local chain does not verify, so anchoring was refused.
    ChainBroken(crate::audit_chain::VerifyOutcome),
    /// The write appeared to succeed but the checkpoint was absent on read-back.
    ReadBackMissing,
    /// A stored checkpoint no longer matches the local chain.
    Inconsistent(crate::audit_chain::CheckpointOutcome),
}

/// Sign the current chain tip and anchor it to `sink`, then prove the anchor
/// landed and still agrees with the local chain.
///
/// Shared by the `sysknife audit checkpoint` CLI command and the daemon's
/// periodic anchor task so both enforce the same guarantees:
///
/// 1. **Refuse to anchor a broken chain.** Anchoring the tip of a tampered
///    chain would launder the tamper into a signed checkpoint.
/// 2. **Read back after writing.** A lagging replica or the wrong database
///    would otherwise render as a reassuring "consistent (0 verified)".
/// 3. **Re-verify every anchored checkpoint**, not just the new one.
pub async fn anchor_once(
    key: &crate::audit_chain::AuditKey,
    rows: &[crate::audit_chain::ChainRow],
    sink: &dyn CheckpointSink,
    created_at: &str,
) -> Result<AnchorOutcome, CheckpointSinkError> {
    use crate::audit_chain::{verify_chain, verify_checkpoints, CheckpointOutcome, VerifyOutcome};

    let Some(tip) = rows.last() else {
        return Ok(AnchorOutcome::ChainEmpty);
    };

    match verify_chain(key, rows) {
        VerifyOutcome::Intact { .. } => {}
        broken => return Ok(AnchorOutcome::ChainBroken(broken)),
    }

    let checkpoint = key.sign_checkpoint(tip.seq, &tip.chain_hash, created_at);
    sink.append(&checkpoint).await?;

    let anchored = sink.load_all().await?;
    if !anchored.contains(&checkpoint) {
        return Ok(AnchorOutcome::ReadBackMissing);
    }

    match verify_checkpoints(&key.verifying_key_hex(), rows, &anchored) {
        CheckpointOutcome::Consistent {
            checkpoints_checked,
        } => Ok(AnchorOutcome::Anchored {
            seq: checkpoint.seq,
            checkpoints_checked,
        }),
        other => Ok(AnchorOutcome::Inconsistent(other)),
    }
}

/// How often the daemon anchors a checkpoint when a sink is configured.
///
/// This bounds the tamper window: an attacker who compromises the host can
/// rewrite history only back to the last anchor, so the interval is the
/// exposure. Fifteen minutes keeps the write volume trivial (one row) while
/// keeping that window short.
pub const DEFAULT_ANCHOR_INTERVAL: Duration = Duration::from_secs(15 * 60);

/// Periodically anchor the chain tip to `sink` for as long as the daemon runs.
///
/// Without this the checkpoint machinery is inert: `sign_checkpoint`,
/// `verify_checkpoints` and the sinks all existed and were tested, but nothing
/// in the daemon ever called them, so the tail-truncation defence the threat
/// model relies on was only active if an operator happened to run
/// `sysknife audit checkpoint` on a timer of their own.
pub fn spawn_periodic_anchor(
    audit: std::sync::Arc<dyn crate::store::AuditStore>,
    key: std::sync::Arc<crate::audit_chain::AuditKey>,
    sink: std::sync::Arc<dyn CheckpointSink>,
    interval: Duration,
) -> tokio::task::JoinHandle<()> {
    tokio::spawn(async move {
        loop {
            tokio::time::sleep(interval).await;

            let rows = match audit.fetch_chain_rows().await {
                Ok(rows) => rows,
                Err(e) => {
                    eprintln!("[sysknife-daemon] checkpoint anchor: reading chain failed: {e}");
                    continue;
                }
            };

            let created_at = chrono::Utc::now().to_rfc3339();
            match anchor_once(&key, &rows, sink.as_ref(), &created_at).await {
                Ok(AnchorOutcome::Anchored {
                    seq,
                    checkpoints_checked,
                }) => {
                    eprintln!(
                        "[sysknife-daemon] anchored checkpoint seq={seq} \
                         ({checkpoints_checked} verified)"
                    );
                }
                Ok(AnchorOutcome::ChainEmpty) => {}
                // Everything below means the anchor did not advance. These are
                // loud on purpose: a silent anchor failure looks exactly like a
                // working one until the day someone needs the evidence.
                Ok(AnchorOutcome::ChainBroken(outcome)) => {
                    eprintln!(
                        "[sysknife-daemon] checkpoint anchor REFUSED: the local audit chain \
                         does not verify ({outcome:?}); not anchoring a tampered chain"
                    );
                }
                Ok(AnchorOutcome::ReadBackMissing) => {
                    eprintln!(
                        "[sysknife-daemon] checkpoint anchor FAILED: the checkpoint was absent \
                         on read-back; the sink may be a lagging replica or a different database"
                    );
                }
                Ok(AnchorOutcome::Inconsistent(outcome)) => {
                    eprintln!(
                        "[sysknife-daemon] checkpoint verification FAILED: {outcome:?} — \
                         the local chain disagrees with a previously anchored checkpoint"
                    );
                }
                Err(e) => {
                    eprintln!("[sysknife-daemon] checkpoint anchor: sink error: {e}");
                }
            }
        }
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::audit_chain::{
        verify_checkpoints, AuditKey, ChainContent, ChainIdentity, ChainRow, CheckpointOutcome,
        CHAIN_VERSION_CURRENT,
    };
    use sysknife_types::RiskLevel;

    fn key() -> AuditKey {
        AuditKey::from_bytes(vec![0x42; 32])
    }

    /// Build a small intact chain the same way the daemon would.
    pub(super) fn build_chain(key: &AuditKey, count: usize) -> Vec<ChainRow> {
        let mut rows = Vec::with_capacity(count);
        let mut prev = String::new();
        for i in 0..count {
            let seq = (i + 1) as u64;
            let txid = format!("tx{i}");
            let content = ChainContent {
                seq,
                key_id: "v1",
                transaction_id: &txid,
                request_id: "req",
                request_hash: "hash",
                action_name: "UpdateSystem",
                risk_level: RiskLevel::High,
                summary: "s",
                approval_id: None,
                warnings_json: "[]",
                created_at: "2026-04-24T12:00:00Z",
                identity: ChainIdentity::V3 {
                    caller_role: "dev",
                    event_tip: "",
                    caller_principal: "uid:1000",
                },
            };
            let hash = key.chain_hash(&content, &prev);
            rows.push(ChainRow {
                seq,
                key_id: "v1".to_string(),
                transaction_id: txid,
                request_id: "req".to_string(),
                request_hash: "hash".to_string(),
                action_name: "UpdateSystem".to_string(),
                risk_level: RiskLevel::High,
                summary: "s".to_string(),
                approval_id: None,
                warnings_json: "[]".to_string(),
                created_at: "2026-04-24T12:00:00Z".to_string(),
                prev_chain_hash: prev.clone(),
                chain_hash: hash.clone(),
                chain_version: CHAIN_VERSION_CURRENT,
                caller_role: Some("dev".to_string()),
                event_tip: Some(String::new()),
                caller_principal: Some("uid:1000".to_string()),
            });
            prev = hash;
        }
        rows
    }

    #[tokio::test]
    async fn in_memory_append_and_load_round_trip() {
        let key = key();
        let rows = build_chain(&key, 3);
        let sink = InMemoryCheckpointSink::new();
        let cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
        sink.append(&cp).await.unwrap();
        let loaded = sink.load_all().await.unwrap();
        assert_eq!(loaded, vec![cp]);
    }

    #[tokio::test]
    async fn anchored_checkpoints_verify_and_catch_truncation() {
        let key = key();
        let full = build_chain(&key, 5);
        let sink = InMemoryCheckpointSink::new();
        // Anchor a checkpoint at the tip.
        let cp = key.sign_checkpoint(5, &full[4].chain_hash, "2026-04-24T12:00:00Z");
        sink.append(&cp).await.unwrap();

        // Intact: loading the anchored checkpoints verifies against the full chain.
        let anchored = sink.load_all().await.unwrap();
        assert_eq!(
            verify_checkpoints(&key.verifying_key_hex(), &full, &anchored),
            CheckpointOutcome::Consistent {
                checkpoints_checked: 1
            }
        );

        // Truncated: the local chain is cut to 3, but the anchored tip (seq=5)
        // can no longer be reproduced -> detected.
        let truncated = &full[..3];
        assert!(matches!(
            verify_checkpoints(&key.verifying_key_hex(), truncated, &anchored),
            CheckpointOutcome::Truncated {
                checkpoint_seq: 5,
                current_max_seq: 3
            }
        ));
    }
}

#[cfg(test)]
mod anchor_tests {
    use super::*;
    use crate::audit_chain::{AuditKey, ChainContent, ChainRow, CheckpointOutcome, VerifyOutcome};

    fn key() -> AuditKey {
        AuditKey::from_bytes(vec![0x42; 32])
    }

    /// Rebuild a chain from `rows`, re-signing every row with `key` so the
    /// result verifies cleanly. Used to forge a tampered-but-valid tail — the
    /// attack only a checkpoint can catch.
    fn reseal(key: &AuditKey, rows: &[ChainRow]) -> Vec<ChainRow> {
        let mut out = Vec::with_capacity(rows.len());
        let mut prev = String::new();
        for row in rows {
            let content = ChainContent {
                seq: row.seq,
                key_id: &row.key_id,
                transaction_id: &row.transaction_id,
                request_id: &row.request_id,
                request_hash: &row.request_hash,
                action_name: &row.action_name,
                risk_level: row.risk_level,
                summary: &row.summary,
                approval_id: row.approval_id.as_deref(),
                warnings_json: &row.warnings_json,
                created_at: &row.created_at,
                identity: row.identity().expect("fixture rows are well-formed"),
            };
            let hash = key.chain_hash(&content, &prev);
            let mut cloned = row.clone();
            cloned.prev_chain_hash = prev.clone();
            cloned.chain_hash = hash.clone();
            out.push(cloned);
            prev = hash;
        }
        out
    }

    fn chain(key: &AuditKey, count: usize) -> Vec<ChainRow> {
        super::tests::build_chain(key, count)
    }

    #[tokio::test]
    async fn anchor_once_writes_and_verifies_the_tip() {
        let key = key();
        let rows = chain(&key, 3);
        let sink = InMemoryCheckpointSink::new();

        let outcome = anchor_once(&key, &rows, &sink, "2026-04-24T12:00:00Z")
            .await
            .unwrap();

        assert_eq!(
            outcome,
            AnchorOutcome::Anchored {
                seq: 3,
                checkpoints_checked: 1
            }
        );
        assert_eq!(sink.load_all().await.unwrap().len(), 1);
    }

    #[tokio::test]
    async fn anchor_once_refuses_to_launder_a_tampered_chain() {
        // Anchoring the tip of a broken chain would sign the tamper into the
        // one record that is supposed to detect it.
        let key = key();
        let mut rows = chain(&key, 3);
        rows[1].summary = "tampered".to_string();
        let sink = InMemoryCheckpointSink::new();

        let outcome = anchor_once(&key, &rows, &sink, "2026-04-24T12:00:00Z")
            .await
            .unwrap();

        assert!(
            matches!(
                outcome,
                AnchorOutcome::ChainBroken(VerifyOutcome::Broken { .. })
            ),
            "expected a refusal, got {outcome:?}"
        );
        assert!(
            sink.load_all().await.unwrap().is_empty(),
            "nothing may be anchored when the chain does not verify"
        );
    }

    #[tokio::test]
    async fn anchor_once_reports_an_empty_chain_rather_than_anchoring_nothing() {
        let key = key();
        let sink = InMemoryCheckpointSink::new();
        let outcome = anchor_once(&key, &[], &sink, "2026-04-24T12:00:00Z")
            .await
            .unwrap();
        assert_eq!(outcome, AnchorOutcome::ChainEmpty);
        assert!(sink.load_all().await.unwrap().is_empty());
    }

    /// A sink that accepts writes and then loses them. Models a lagging read
    /// replica or a misconfigured second database.
    #[derive(Default)]
    struct BlackHoleSink;

    #[async_trait]
    impl CheckpointSink for BlackHoleSink {
        async fn append(
            &self,
            _checkpoint: &crate::audit_chain::Checkpoint,
        ) -> Result<(), CheckpointSinkError> {
            Ok(())
        }
        async fn load_all(
            &self,
        ) -> Result<Vec<crate::audit_chain::Checkpoint>, CheckpointSinkError> {
            Ok(Vec::new())
        }
    }

    #[tokio::test]
    async fn anchor_once_detects_a_write_that_did_not_land() {
        // A sink that swallows writes must not read as a successful anchor:
        // `verify_checkpoints` over zero checkpoints is trivially "consistent",
        // which is exactly the reassuring-but-wrong result the read-back check
        // exists to prevent.
        let key = key();
        let rows = chain(&key, 2);

        let outcome = anchor_once(&key, &rows, &BlackHoleSink, "2026-04-24T12:00:00Z")
            .await
            .unwrap();

        assert_eq!(outcome, AnchorOutcome::ReadBackMissing);
    }

    #[tokio::test]
    async fn a_previously_anchored_checkpoint_catches_a_key_holder_rewrite() {
        // THE reason checkpoints exist. An attacker with root AND the signing
        // key can rebuild history so the chain verifies perfectly — every
        // existing "rewrite" test corrupts `chain_hash` with garbage that a
        // plain signature check already rejects, so none of them exercise this.
        //
        // Here the tampered tail is re-signed with the real key, so
        // `verify_chain` says Intact. Only the anchored tip catches it.
        let key = key();
        let original = chain(&key, 5);
        let sink = InMemoryCheckpointSink::new();

        anchor_once(&key, &original, &sink, "2026-04-24T12:00:00Z")
            .await
            .unwrap();
        let anchored = sink.load_all().await.unwrap();

        let mut tampered = original.clone();
        tampered[3].summary = "quietly rewritten".to_string();
        let tampered = reseal(&key, &tampered);

        assert!(
            matches!(
                crate::audit_chain::verify_chain(&key, &tampered),
                VerifyOutcome::Intact { .. }
            ),
            "the forged chain must verify — otherwise this test proves nothing \
             beyond what a plain signature check already catches"
        );

        let outcome =
            crate::audit_chain::verify_checkpoints(&key.verifying_key_hex(), &tampered, &anchored);
        assert!(
            matches!(outcome, CheckpointOutcome::TipMismatch { .. }),
            "the anchored tip must expose the rewrite, got {outcome:?}"
        );
    }

    #[tokio::test]
    async fn verify_chain_alone_cannot_detect_tail_truncation() {
        // The claim the module docs make about why anchoring is necessary,
        // asserted directly: a truncated chain is internally consistent.
        let key = key();
        let full = chain(&key, 5);
        let truncated = &full[..3];

        assert!(
            matches!(
                crate::audit_chain::verify_chain(&key, truncated),
                VerifyOutcome::Intact {
                    rows_checked: 3,
                    ..
                }
            ),
            "a truncated chain still verifies on its own — this is the gap"
        );

        let sink = InMemoryCheckpointSink::new();
        anchor_once(&key, &full, &sink, "2026-04-24T12:00:00Z")
            .await
            .unwrap();
        let anchored = sink.load_all().await.unwrap();

        assert!(
            matches!(
                crate::audit_chain::verify_checkpoints(
                    &key.verifying_key_hex(),
                    truncated,
                    &anchored
                ),
                CheckpointOutcome::Truncated { .. }
            ),
            "the anchor is what turns an undetectable truncation into a detected one"
        );
    }
}