polyc-eventlog-model 2026.8.0

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

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