polyc-eventlog-model 2026.8.1

Storage-agnostic event, integrity, trust, and navigation model for Polychrome journals.
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
//! 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(())
}

#[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 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());
    }
}