polyc-eventlog-model 2026.8.3

Storage-agnostic event, integrity, trust, and navigation model for Polychrome journals.
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
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
//! Per-conversation tamper-evidence over the event log (#799).
//!
//! The partition journal gives durability and ordering but — on its own — no
//! way for a reader to tell that an operator (or a storage-layer bit flip)
//! hasn't quietly rewritten a persisted event between when it was appended
//! and when it was replayed. This module closes that gap by threading a
//! [`polyc_mmr::VerifiableLog`] alongside the journal:
//!
//! 1. Every conversation event becomes one MMR leaf (see
//!    [`rebuild_from_events`] for how a cold log's tree is reconstructed).
//! 2. After extending the tree with a batch of new events, the caller signs
//!    the current root and appends it to the SAME partition as one more
//!    event ([`MMR_SIGNED_ROOT_KIND`]) — see [`extend_and_sign`]. Because it
//!    lands in the same commit as the turn's other events, the root is
//!    signed atomically with the content it covers.
//! 3. A later reader replays the whole partition and calls [`verify_replay`],
//!    which rebuilds the tree from scratch and checks every signed root it
//!    finds along the way — catching a tampered event, a forged or
//!    substituted root marker, or a root signed under the wrong key.
//!
//! This is intentionally decoupled from the journal itself: everything here
//! operates on plain `(kind, payload)` / [`Event`] sequences, so it is
//! testable without a live journal and reusable by anything that replays a
//! partition (the eventlog host, the CLI's `conversation repair`, forensics).

// Several doc summaries here need two sentences to state both the "what"
// and the atomicity/ordering contract in one place, rather than splitting
// across a line an editor of this module would have to re-join to reread.
#![allow(clippy::too_long_first_doc_paragraph)]

use polyc_crypto::signing_role::JournalAttestationSigner;
use polyc_mmr::{SignedRoot, VerifiableLog, verify_root_signature_with_trust};

use crate::Event;

/// Event kind naming a persisted [`SignedRoot`] marker. Namespaced so it
/// cannot collide with any conversation-content kind (every real kind in
/// `polyc-proto`'s `events.proto` is a bare identifier with no `__`
/// wrapping).
pub const MMR_SIGNED_ROOT_KIND: &str = "__mmr_signed_root__";

/// Failures from extending, signing, or verifying a partition's MMR.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum IntegrityError {
    /// The underlying MMR operation failed (lock poisoned, proof error).
    #[error("mmr: {0}")]
    Mmr(#[from] polyc_mmr::MmrError),
    /// A signed-root marker's payload was not the JSON [`SignedRoot`] this
    /// module writes — a corrupted or foreign event landed under the
    /// reserved kind.
    #[error("malformed signed-root marker at leaf count {leaf_count_hint}: {source}")]
    MalformedRoot {
        /// Running leaf count at the point the malformed marker was found,
        /// for locating it in the replay.
        leaf_count_hint: u64,
        /// The JSON decode error.
        source: serde_json::Error,
    },
    /// A persisted root's ed25519 signature does not verify under the
    /// expected signer public key — the marker was forged, or signed by a
    /// different key than the caller trusts.
    #[error("signed root at leaf count {leaf_count} does not verify under the expected signer")]
    SignatureInvalid {
        /// The root's claimed leaf count.
        leaf_count: u64,
    },
    /// The recomputed MMR root (or leaf count) at a checkpoint does not
    /// match what was signed — the tamper-evidence violation this whole
    /// module exists to catch.
    #[error(
        "integrity violation: at leaf count {leaf_count}, replay computed root {computed_root_hex} \
         but the signed marker recorded {expected_root_hex}"
    )]
    RootMismatch {
        /// Leaf count at the point of the mismatch.
        leaf_count: u64,
        /// The root the signed marker claims.
        expected_root_hex: String,
        /// The root replay actually computed.
        computed_root_hex: String,
    },
}

/// Extend `log` with each of `new_events`'s `(kind, payload)` leaves, sign
/// the resulting root with `signer`, and return the [`Event`] to append
/// (kind [`MMR_SIGNED_ROOT_KIND`]) — the caller places it in the SAME
/// journal batch as `new_events` (e.g. right before `turn_complete`) so the
/// signature is atomic with the content it covers.
///
/// # Errors
///
/// Returns [`IntegrityError::Mmr`] if extending the tree or signing fails.
///
/// # Panics
///
/// Never in practice: [`SignedRoot`] always serializes (plain strings and
/// integers), so the internal `expect` cannot fail for any value this
/// module produces.
pub fn extend_and_sign(
    log: &VerifiableLog,
    new_events: &[Event],
    signer: &JournalAttestationSigner,
) -> Result<Event, IntegrityError> {
    for event in new_events {
        log.append(&event.kind, &event.payload)?;
    }
    let root = log.sign_root(signer)?;
    let payload = serde_json::to_vec(&root).expect("SignedRoot serializes");
    Ok(Event::new(MMR_SIGNED_ROOT_KIND, payload))
}

/// Reconstruct a partition's running MMR from a full replay, for a caller
/// that wants to keep extending it (the eventlog host, on first touching a
/// partition after a restart). Root-marker events themselves are not MMR
/// leaves — only real conversation events are — so this filters them out
/// before delegating to [`VerifiableLog::rebuild`].
///
/// # Errors
///
/// Returns [`IntegrityError::Mmr`] if the rebuild fails.
pub fn rebuild_from_events(events: &[Event]) -> Result<VerifiableLog, IntegrityError> {
    let log = VerifiableLog::rebuild(
        events
            .iter()
            .filter(|e| e.kind != MMR_SIGNED_ROOT_KIND)
            .map(|e| (e.kind.as_str(), e.payload.as_slice())),
    )?;
    Ok(log)
}

/// Verify a full partition replay's tamper-evidence: rebuild the MMR leaf by
/// leaf in append order, and at every [`MMR_SIGNED_ROOT_KIND`] marker check
/// that (a) its signature verifies under `expected_signer_pk_hex` and (b)
/// the tree's root and leaf count at that point match what the marker
/// claims. Returns on the FIRST violation found, naming exactly where it
/// occurred.
///
/// A partition with no signed-root markers at all verifies trivially — this
/// is the transitional state before the first turn completes.
///
/// # Errors
///
/// Returns [`IntegrityError`] describing the first tamper/forgery/mismatch
/// encountered.
pub fn verify_replay(events: &[Event], expected_signer_pk_hex: &str) -> Result<(), IntegrityError> {
    let public_key = hex::decode(expected_signer_pk_hex)
        .map_err(|_| IntegrityError::SignatureInvalid { leaf_count: 0 })?;
    let trust = polyc_crypto::signing_role::RoleTrustSet::<
        polyc_crypto::signing_role::JournalAttestationRole,
    >::from_public_keys(vec![public_key])
    .map_err(|_| IntegrityError::SignatureInvalid { leaf_count: 0 })?;
    verify_replay_with_trust(events, &trust)
}

/// Replays and verifies every root against current and retired role keys.
///
/// # Errors
///
/// Returns the first malformed, untrusted, or inconsistent root.
pub fn verify_replay_with_trust(
    events: &[Event],
    trust: &polyc_crypto::signing_role::RoleTrustSet<
        polyc_crypto::signing_role::JournalAttestationRole,
    >,
) -> Result<(), IntegrityError> {
    verify_extension_with_trust(&VerifiableLog::new(), events, trust)
}

/// Verifies `events` as the CONTINUATION of the partition `log` already covers,
/// extending `log` leaf by leaf exactly as a replay of the whole partition
/// would, and checking every [`MMR_SIGNED_ROOT_KIND`] marker it meets against
/// the tree at that point.
///
/// This is [`verify_replay_with_trust`] with its starting tree supplied rather
/// than empty, which is what a caller holding a partition's running tree needs:
/// verifying the tail it just appended costs the tail, not the whole partition,
/// and the check it applies to that tail is the same one a full replay applies.
/// A caller with no tree passes a fresh [`VerifiableLog`] and gets the full
/// replay back, which is exactly what [`verify_replay_with_trust`] does.
///
/// `log` is extended in place by every non-marker event, including on the way
/// to an error: a caller that gets an error back holds a tree it must discard
/// rather than keep extending.
///
/// # Errors
///
/// Returns [`IntegrityError`] describing the first tamper/forgery/mismatch
/// encountered, in the same vocabulary a full replay reports it in.
pub fn verify_extension_with_trust(
    log: &VerifiableLog,
    events: &[Event],
    trust: &polyc_crypto::signing_role::RoleTrustSet<
        polyc_crypto::signing_role::JournalAttestationRole,
    >,
) -> Result<(), IntegrityError> {
    for event in events {
        if event.kind == MMR_SIGNED_ROOT_KIND {
            let leaf_count = log.leaf_count()?;
            let root: SignedRoot = serde_json::from_slice(&event.payload).map_err(|source| {
                IntegrityError::MalformedRoot {
                    leaf_count_hint: leaf_count,
                    source,
                }
            })?;
            let sig_ok = verify_root_signature_with_trust(&root, trust).unwrap_or(false);
            if !sig_ok {
                return Err(IntegrityError::SignatureInvalid { leaf_count });
            }
            let computed_root = log.root()?;
            let computed_root_hex = hex::encode(computed_root.as_ref());
            if root.leaf_count != leaf_count || root.root_hex != computed_root_hex {
                return Err(IntegrityError::RootMismatch {
                    leaf_count,
                    expected_root_hex: root.root_hex,
                    computed_root_hex,
                });
            }
        } else {
            log.append(&event.kind, &event.payload)?;
        }
    }
    Ok(())
}

/// What the signed roots over a replay say about the content beneath them.
///
/// [`verify_extension_with_trust`] answers one question — does this partition
/// verify — and stops at the first failure. A repair needs a second one: may
/// this partition's roots be replaced. Those are different questions, and
/// answering the second from the first is what makes a repair either useless
/// or dangerous.
///
/// The distinction is whether a failure is *attributable to lost content*.
/// Repair drops what it cannot read and signs one fresh root over what is
/// left, so it converts a verification failure into a success by construction.
/// It may only do that where the failure is a claim about content that is
/// gone. Where the failure says the surviving content is not what a trusted
/// key attested, re-rooting would sign the contradiction under this journal's
/// own key, and the tamper-evidence would be gone rather than reported.
///
/// # What [`RootStanding::Stale`] does NOT promise
///
/// The tree is running: every root covers every leaf before it. So one lost
/// leaf makes every LATER root claim more leaves than survive, and a count
/// mismatch is all any of them can report from then on. A change to content
/// after the loss is invisible underneath that, and this reports the partition
/// stale.
///
/// So a stale verdict means one thing exactly: no trusted root is
/// contradicted at the leaf count it claims. Past the first loss, a repair is
/// a trust reset over what follows, not a verified recovery.
/// `a_lost_leaf_hides_every_later_change_from_the_roots_that_follow_it` pins
/// this, so the claim cannot quietly grow.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RootStanding {
    /// Every root holds over the leaves that precede it.
    Holds,
    /// At least one root covers leaves that no longer survive, or carries no
    /// readable claim at all, and none is contradicted. Re-rooting drops a
    /// claim nothing can satisfy any more.
    ///
    /// This is the state `#2322` left behind: a repair kept the survivors and
    /// their old roots, so each root attests a leaf count larger than what
    /// precedes it.
    Stale,
    /// A trusted root disagrees with the content it covers, or a root does not
    /// verify under any trusted key. The content changed, or the marker was
    /// forged. Repair is not the remedy.
    Contradicted,
}

/// Reports how `events`' signed roots stand against the leaves beneath them.
///
/// Every marker is examined, not just the first that fails, and
/// [`RootStanding::Contradicted`] wins over [`RootStanding::Stale`]. A walk
/// that stopped at the first failure could report a stale root at one batch
/// and never reach a forged one at the next, which is the whole population a
/// caller uses this to exclude.
///
/// A marker whose payload does not decode counts as stale. It carries no
/// claim, so there is nothing for the content to contradict, and the other
/// markers still constrain the leaves they cover.
///
/// # Errors
///
/// Returns [`IntegrityError::Mmr`] if the tree cannot be extended or read.
/// Integrity failures are the return value here, not errors.
pub fn root_standing_with_trust(
    events: &[Event],
    trust: &polyc_crypto::signing_role::RoleTrustSet<
        polyc_crypto::signing_role::JournalAttestationRole,
    >,
) -> Result<RootStanding, IntegrityError> {
    let log = VerifiableLog::new();
    let mut standing = RootStanding::Holds;
    for event in events {
        if event.kind != MMR_SIGNED_ROOT_KIND {
            log.append(&event.kind, &event.payload)?;
            continue;
        }
        let leaf_count = log.leaf_count()?;
        let Ok(root) = serde_json::from_slice::<SignedRoot>(&event.payload) else {
            standing = RootStanding::Stale;
            continue;
        };
        if !verify_root_signature_with_trust(&root, trust).unwrap_or(false) {
            return Ok(RootStanding::Contradicted);
        }
        // A claim over MORE leaves than precede it is a claim about content
        // that is gone. A claim over FEWER is content that arrived after the
        // attestation and before the marker, which no writer here produces.
        if root.leaf_count > leaf_count {
            standing = RootStanding::Stale;
            continue;
        }
        if root.leaf_count < leaf_count || root.root_hex != hex::encode(log.root()?.as_ref()) {
            return Ok(RootStanding::Contradicted);
        }
    }
    Ok(standing)
}

#[cfg(test)]
mod tests {
    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
    use super::*;

    fn signer() -> JournalAttestationSigner {
        JournalAttestationSigner::from_seed(7)
    }

    fn pk_hex(signer: &JournalAttestationSigner) -> String {
        hex::encode(signer.public_key_bytes())
    }

    /// The distinction `#2561` turns on: a root left over content that is gone
    /// is repairable, and a root contradicted by content that is still there
    /// is not.
    ///
    /// Both fail `verify_replay` with the same `RootMismatch` arm, which is
    /// why a repair that keyed on the error type alone would either refuse
    /// every recoverable partition or launder every tampered one.
    #[test]
    fn a_root_over_lost_leaves_is_stale_and_one_over_changed_leaves_is_contradicted() {
        let s = signer();
        let trust = polyc_crypto::signing_role::RoleTrustSet::current(&s);
        let events = vec![
            Event::new("user_msg", b"one".to_vec()),
            Event::new("output_msg", b"two".to_vec()),
            Event::new("output_msg", b"three".to_vec()),
        ];
        let log = VerifiableLog::new();
        let marker = extend_and_sign(&log, &events, &s).expect("sign");

        let mut healthy = events.clone();
        healthy.push(marker.clone());
        assert_eq!(
            root_standing_with_trust(&healthy, &trust).expect("standing"),
            RootStanding::Holds
        );

        // The `#2322` shape: a repair kept the survivors and their old root,
        // so the root attests three leaves over the two that remain.
        let stale = vec![events[0].clone(), events[1].clone(), marker.clone()];
        assert!(
            verify_replay(&stale, &pk_hex(&s)).is_err(),
            "the state this describes does not verify"
        );
        assert_eq!(
            root_standing_with_trust(&stale, &trust).expect("standing"),
            RootStanding::Stale
        );

        // The same error class, and the opposite verdict: every leaf the root
        // claims is present, and one of them is not what was signed.
        let mut tampered = healthy.clone();
        tampered[1].payload[0] ^= 0xFF;
        assert_eq!(
            root_standing_with_trust(&tampered, &trust).expect("standing"),
            RootStanding::Contradicted
        );
    }

    /// A root nobody trusted signed is never repairable, whatever it claims.
    ///
    /// Repair re-signs under the journal's own key. Treating a foreign root as
    /// stale would let one forged marker convert a partition into a trusted
    /// one, which is the exact laundering the standing exists to refuse.
    #[test]
    fn a_root_signed_under_an_untrusted_key_is_contradicted() {
        let s = signer();
        let intruder = JournalAttestationSigner::from_seed(99);
        let trust = polyc_crypto::signing_role::RoleTrustSet::current(&s);
        let events = vec![Event::new("user_msg", b"one".to_vec())];
        let log = VerifiableLog::new();
        let marker = extend_and_sign(&log, &events, &intruder).expect("sign");

        let mut forged = events;
        forged.push(marker);
        assert_eq!(
            root_standing_with_trust(&forged, &trust).expect("standing"),
            RootStanding::Contradicted
        );
    }

    /// A marker whose payload does not decode carries no claim, so nothing
    /// contradicts it. It reads as stale.
    ///
    /// The other markers still constrain the leaves they cover, so this does
    /// not let a corrupted marker unlock a partition whose content changed —
    /// `a_contradiction_anywhere_outranks_a_stale_root_before_it` pins that.
    #[test]
    fn a_marker_that_does_not_decode_is_stale() {
        let s = signer();
        let trust = polyc_crypto::signing_role::RoleTrustSet::current(&s);
        let events = vec![Event::new("user_msg", b"one".to_vec())];
        let log = VerifiableLog::new();
        let mut marker = extend_and_sign(&log, &events, &s).expect("sign");
        marker.payload = b"not json".to_vec();

        let mut damaged = events;
        damaged.push(marker);
        assert_eq!(
            root_standing_with_trust(&damaged, &trust).expect("standing"),
            RootStanding::Stale
        );
    }

    /// Every marker is examined, not just the first that fails, and a
    /// contradiction outranks a stale root found before it.
    ///
    /// `verify_extension_with_trust` returns on the first violation. A caller
    /// that classified ITS error would see the unreadable marker at batch one
    /// and never reach the tampering at batch two — and would then re-root
    /// over content a trusted key says is wrong.
    #[test]
    fn a_contradiction_outranks_a_stale_root_found_before_it() {
        let s = signer();
        let trust = polyc_crypto::signing_role::RoleTrustSet::current(&s);
        let first = vec![Event::new("user_msg", b"one".to_vec())];
        let log = VerifiableLog::new();
        let mut damaged_marker = extend_and_sign(&log, &first, &s).expect("sign");
        damaged_marker.payload = b"not json".to_vec();
        let second = vec![Event::new("output_msg", b"two".to_vec())];
        let live_marker = extend_and_sign(&log, &second, &s).expect("sign");

        let mut events = first;
        events.push(damaged_marker);
        events.extend(second);
        events.push(live_marker);
        assert_eq!(
            root_standing_with_trust(&events, &trust).expect("standing"),
            RootStanding::Stale,
            "an unreadable marker alone leaves the partition repairable"
        );

        // Batch two keeps every leaf its root claims, so its root still
        // detects the change.
        events[2].payload[0] ^= 0xFF;
        assert_eq!(
            root_standing_with_trust(&events, &trust).expect("standing"),
            RootStanding::Contradicted
        );
    }

    /// The limit of what a standing can promise, pinned so nobody reads the
    /// refusal as stronger than it is.
    ///
    /// The tree is running: every root covers every leaf before it. So one
    /// lost leaf makes EVERY later root claim more leaves than survive, and a
    /// count mismatch is all any of them can report from then on. A change to
    /// content after the loss is invisible underneath that.
    ///
    /// So repair's refusal guards one thing exactly: a trusted root
    /// contradicted at the leaf count it claims. Past the first loss, repair
    /// is a trust reset, not a verified recovery. This test exists to fail if
    /// anyone strengthens the claim in a doc comment without strengthening the
    /// mechanism.
    #[test]
    fn a_lost_leaf_hides_every_later_change_from_the_roots_that_follow_it() {
        let s = signer();
        let trust = polyc_crypto::signing_role::RoleTrustSet::current(&s);
        let first = vec![
            Event::new("user_msg", b"one".to_vec()),
            Event::new("output_msg", b"two".to_vec()),
        ];
        let log = VerifiableLog::new();
        let stale_marker = extend_and_sign(&log, &first, &s).expect("sign");
        let second = vec![Event::new("user_msg", b"three".to_vec())];
        let later_marker = extend_and_sign(&log, &second, &s).expect("sign");

        let mut lost = vec![first[0].clone(), stale_marker];
        lost.extend(second);
        lost.push(later_marker);
        assert_eq!(
            root_standing_with_trust(&lost, &trust).expect("standing"),
            RootStanding::Stale
        );

        lost[2].payload[0] ^= 0xFF;
        assert_eq!(
            root_standing_with_trust(&lost, &trust).expect("standing"),
            RootStanding::Stale,
            "a change after a lost leaf is indistinguishable from the loss"
        );
    }

    /// The pinning test (#799): append a turn's worth of events, sign the
    /// root, replay, flip one byte of one persisted event's payload, and
    /// assert replay reports an integrity violation. Before this module
    /// existed nothing checked this at all — `verify_replay` didn't exist.
    #[test]
    fn mmr_verify_replay_detects_tampered_event() {
        let log = VerifiableLog::new();
        let s = signer();
        let turn_events = vec![
            Event::new("user_msg", b"what is 2+2?".to_vec()),
            Event::new("output_msg", b"4".to_vec()),
        ];
        let marker = extend_and_sign(&log, &turn_events, &s).expect("sign");

        let mut persisted = turn_events.clone();
        persisted.push(marker);

        // Untampered: verifies cleanly.
        verify_replay(&persisted, &pk_hex(&s)).expect("untampered replay must verify");

        // Flip one byte of a persisted event's payload — the "torn write /
        // quiet rewrite" the audit describes.
        persisted[1].payload[0] ^= 0xFF;
        let err = verify_replay(&persisted, &pk_hex(&s))
            .expect_err("tampered replay must report an integrity violation");
        assert!(
            matches!(err, IntegrityError::RootMismatch { .. }),
            "expected a root mismatch, got {err:?}"
        );
    }

    /// The MMR is blind to the trust tag: a leaf is `(kind, payload)`, so two
    /// event sequences differing ONLY in their tags produce a byte-identical
    /// root at an identical leaf count, and each sequence verifies against the
    /// other's marker.
    ///
    /// This is why a `RewriteDecision::Replace` in the event-log host must
    /// carry the original event's tag forward rather than rebuild the survivor
    /// with a default one. A rewrite whose only effect was to reset tags would
    /// re-sign the same root at the same position, leaving a reader pinned to
    /// the partition's head unable to see that anything happened — the failure
    /// re-rooting a rewritten partition exists to close. It also bounds what
    /// tamper-evidence covers: the trust byte sits outside it entirely.
    #[test]
    fn the_signed_root_is_blind_to_the_trust_tag() {
        let s = signer();
        let tagged = vec![
            Event::trusted("user_msg", b"a".to_vec()),
            Event::quarantined("tool_result", b"b".to_vec()),
        ];
        let untagged = vec![
            Event::new("user_msg", b"a".to_vec()),
            Event::new("tool_result", b"b".to_vec()),
        ];

        let tagged_tree = rebuild_from_events(&tagged).expect("rebuild tagged");
        let untagged_tree = rebuild_from_events(&untagged).expect("rebuild untagged");
        assert_eq!(
            tagged_tree.root().expect("tagged root"),
            untagged_tree.root().expect("untagged root"),
            "the trust tag is not a leaf input, so it cannot move the root"
        );
        assert_eq!(
            tagged_tree.leaf_count().expect("tagged leaves"),
            untagged_tree.leaf_count().expect("untagged leaves")
        );

        // The marker signed over one sequence verifies over the other, which is
        // the same statement from the verifier's side: an on-disk flip of a
        // trust tag is undetectable here.
        let marker = extend_and_sign(&VerifiableLog::new(), &tagged, &s).expect("sign tagged");
        let mut swapped = untagged;
        swapped.push(marker);
        verify_replay(&swapped, &pk_hex(&s))
            .expect("a tag-only difference does not disturb verification");
    }

    #[test]
    fn mmr_verify_replay_accepts_multi_turn_untampered_log() {
        let log = VerifiableLog::new();
        let s = signer();
        let mut persisted = Vec::new();

        for turn in 0..3u8 {
            let events = vec![
                Event::new("user_msg", vec![turn]),
                Event::new("output_msg", vec![turn, turn]),
            ];
            let marker = extend_and_sign(&log, &events, &s).expect("sign");
            persisted.extend(events);
            persisted.push(marker);
        }

        verify_replay(&persisted, &pk_hex(&s)).expect("three untampered turns must verify");
    }

    #[test]
    fn replay_spans_attestation_rotation_only_with_explicit_history() {
        use polyc_crypto::signing_role::{JournalAttestationRole, RoleTrustSet};

        let first = JournalAttestationSigner::from_seed(71);
        let second = JournalAttestationSigner::from_seed(72);
        let log = VerifiableLog::new();
        let first_events = vec![Event::new("user_msg", b"before".to_vec())];
        let first_marker = extend_and_sign(&log, &first_events, &first).expect("first root");
        let second_events = vec![Event::new("output_msg", b"after".to_vec())];
        let second_marker = extend_and_sign(&log, &second_events, &second).expect("second root");
        let persisted = [
            first_events,
            vec![first_marker],
            second_events,
            vec![second_marker],
        ]
        .concat();

        let current_only = RoleTrustSet::<JournalAttestationRole>::current(&second);
        assert!(verify_replay_with_trust(&persisted, &current_only).is_err());
        let history = RoleTrustSet::<JournalAttestationRole>::checked(vec![
            second.identity(),
            first.identity(),
        ])
        .expect("valid history");
        verify_replay_with_trust(&persisted, &history)
            .expect("retired root remains verifiable during rotation overlap");
    }

    #[test]
    fn mmr_verify_replay_rejects_root_signed_by_a_different_key() {
        let log = VerifiableLog::new();
        let s = signer();
        let events = vec![Event::new("user_msg", b"hi".to_vec())];
        let marker = extend_and_sign(&log, &events, &s).expect("sign");
        let mut persisted = events;
        persisted.push(marker);

        let other = JournalAttestationSigner::from_seed(999);
        let err = verify_replay(&persisted, &pk_hex(&other))
            .expect_err("a root signed under a different key must not verify");
        assert!(matches!(err, IntegrityError::SignatureInvalid { .. }));
    }

    #[test]
    fn mmr_verify_replay_accepts_partition_with_no_signed_roots_yet() {
        let events = vec![Event::new("user_msg", b"no marker yet".to_vec())];
        verify_replay(&events, &pk_hex(&signer())).expect("no markers is trivially fine");
    }

    /// Verifying a tail against the tree the earlier turns already built is the
    /// same check as verifying the whole partition from empty — that equality
    /// is what lets a commit verify what it just appended without replaying
    /// everything before it.
    #[test]
    fn verifying_a_tail_against_a_running_tree_matches_a_full_replay() {
        use polyc_crypto::signing_role::{JournalAttestationRole, RoleTrustSet};

        let s = signer();
        let trust = RoleTrustSet::<JournalAttestationRole>::current(&s);
        let writer = VerifiableLog::new();
        let mut persisted = Vec::new();
        let running = VerifiableLog::new();

        for turn in 0..4u8 {
            let events = vec![
                Event::new("user_msg", vec![turn]),
                Event::new("output_msg", vec![turn, turn]),
            ];
            let marker = extend_and_sign(&writer, &events, &s).expect("sign");
            let mut tail = events;
            tail.push(marker);

            verify_extension_with_trust(&running, &tail, &trust)
                .expect("each tail verifies against the tree its predecessors built");
            persisted.extend(tail);
            verify_replay_with_trust(&persisted, &trust).expect("and so does the whole partition");
            assert_eq!(running.root().unwrap(), writer.root().unwrap());
            assert_eq!(running.leaf_count().unwrap(), writer.leaf_count().unwrap());
        }

        // A tail whose content was altered after the host signed over it is a
        // root mismatch, caught against the running tree exactly as a full
        // replay catches it.
        let events = vec![Event::new("user_msg", b"honest".to_vec())];
        let marker = extend_and_sign(&writer, &events, &s).expect("sign");
        let mut tampered = events;
        tampered[0].payload[0] ^= 0xFF;
        tampered.push(marker);
        assert!(matches!(
            verify_extension_with_trust(&running, &tampered, &trust)
                .expect_err("a tampered tail must not verify"),
            IntegrityError::RootMismatch { .. }
        ));
    }

    #[test]
    fn mmr_rebuild_from_events_skips_marker_events() {
        let log = VerifiableLog::new();
        let s = signer();
        let events = vec![
            Event::new("user_msg", b"a".to_vec()),
            Event::new("output_msg", b"b".to_vec()),
        ];
        let marker = extend_and_sign(&log, &events, &s).expect("sign");
        let mut persisted = events;
        persisted.push(marker);

        let rebuilt = rebuild_from_events(&persisted).expect("rebuild");
        assert_eq!(rebuilt.leaf_count().unwrap(), 2, "markers are not leaves");
        assert_eq!(rebuilt.root().unwrap(), log.root().unwrap());
    }
}