crtx-ledger 0.1.0

Append-only event log, hash chain, trace assembly, and audit records.
Documentation
//! Bridge between an [`Event`] persisted in the JSONL mirror and the
//! canonical [`AttestationPreimage`] that gets signed (T-3.D.6,
//! ADR 0010 §1-§2).
//!
//! ## Why a separate module
//!
//! `cortex-core` owns the canonical encoder. `cortex-ledger` owns the
//! JSONL mirror. This module is the seam: it takes a row's [`Event`] +
//! the **previous** row's signature bytes and produces the deterministic
//! [`AttestationPreimage`] whose canonical bytes go into Ed25519.
//!
//! ## Per-row preimage shape (ADR 0010 §1, §2)
//!
//! The signed preimage `P_n` for row n includes:
//!
//! - `schema_version` — pinned to
//!   [`cortex_core::canonical::SCHEMA_VERSION_ATTESTATION`] (currently 1).
//! - `source` — derived from `event.source` (User / ChildAgent / Tool /
//!   Runtime / ExternalOutcome / ManualCorrection). For ChildAgent rows
//!   the optional sub-fields (`agent_id`, `parent_session_id`,
//!   `delegation_id`) are not present on `Event` in v0; we set them to
//!   the empty string so the canonical encoder still produces stable
//!   bytes. They are reserved for a follow-up lane that propagates child-
//!   agent identity through the JSONL mirror.
//! - `event_id` — `event.id` rendered as its canonical string form.
//! - `payload_hash` — hex `payload_hash` already on `event`.
//! - `session_id` — `event.session_id.unwrap_or_default()`.
//! - `ledger_id` — supplied by the caller (the JSONL log path's
//!   logical ledger identifier; the file path stem in v0).
//! - `lineage` — `LineageBinding::PreviousHash(hex(prev_signature))` where
//!   `prev_signature` is `S_{n-1}` for n >= 1, or [`GENESIS_PREV_SIGNATURE`]
//!   for n = 0 (a 32-byte all-zero sentinel that lives in its own
//!   distinct hex form so a captured genesis row cannot be replayed
//!   into a non-genesis position).
//! - `signed_at` — supplied by the signing call (taken from the attestor's
//!   wall-clock at sign time; embedded into the preimage so the verifier
//!   knows what to reconstruct).
//! - `key_id` — public-key fingerprint of the signing operator.
//!
//! ## Identity rotation event payload (ADR 0010 §6)
//!
//! Lane 3.D.6 cannot extend the [`cortex_core::EventType`] enum (out of
//! crate scope), so `identity.rotate` events are persisted as
//! [`cortex_core::EventType::SystemNote`] rows whose `payload` matches
//! [`IDENTITY_ROTATE_PAYLOAD_KIND`]. The verifier inspects every
//! `SystemNote` row's payload; if the `kind` field equals
//! `"identity.rotate"` and the embedded [`RotationPayload`] envelope
//! verifies under the **current** active pubkey, the verifier switches
//! the active pubkey to the envelope's `new_pubkey` for all subsequent
//! rows.
//!
//! Switching keys mid-chain is the **only** way the active pubkey changes
//! during verification; a chain that begins under key A and contains no
//! `identity.rotate` row signed by A cannot later present a row signed
//! by key B.

use chrono::{DateTime, Utc};
use cortex_core::{
    canonical::{AttestationPreimage, LineageBinding, SourceIdentity, SCHEMA_VERSION_ATTESTATION},
    Event, EventSource,
};
use serde::{Deserialize, Serialize};

/// Genesis sentinel for the very first row in a chain (n = 0). The bytes
/// are 32 zero octets — distinct from any real Ed25519 signature, and
/// hex-encodable so they fit the existing `LineageBinding::PreviousHash`
/// variant without a schema bump.
///
/// A captured genesis row cannot be replayed into a non-genesis position
/// because (a) its preimage carries this exact sentinel and (b) the
/// verifier reconstructs the lineage from the on-disk previous row's
/// actual signature bytes, which differ.
pub const GENESIS_PREV_SIGNATURE: [u8; 32] = [0u8; 32];

/// `payload.kind` value identifying an `identity.rotate` event written
/// as a [`cortex_core::EventType::SystemNote`] row.
pub const IDENTITY_ROTATE_PAYLOAD_KIND: &str = "identity.rotate";

/// Canonical shape of the `identity.rotate` event payload.
///
/// Stored at `event.payload` as `{ "kind": "identity.rotate", "envelope":
/// <serialized RotationEnvelope> }`. The verifier extracts and verifies
/// the envelope before adopting `new_pubkey`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RotationPayload {
    /// Always [`IDENTITY_ROTATE_PAYLOAD_KIND`] — gives a fast, free
    /// discriminator on the JSON without committing to a `cortex-core`
    /// enum extension.
    pub kind: String,
    /// The signed rotation envelope (old → new pubkey, signed by old).
    pub envelope: cortex_core::attestor::RotationEnvelope,
}

impl RotationPayload {
    /// Wrap a freshly-signed envelope in the canonical payload shape.
    #[must_use]
    pub fn new(envelope: cortex_core::attestor::RotationEnvelope) -> Self {
        Self {
            kind: IDENTITY_ROTATE_PAYLOAD_KIND.to_string(),
            envelope,
        }
    }
}

/// Hex-encode bytes (lowercase, no separator). Local helper so the crate
/// does not pull in `hex` for one chain coupling field.
#[must_use]
pub fn hex_lower(bytes: &[u8]) -> String {
    let mut s = String::with_capacity(bytes.len() * 2);
    for b in bytes {
        s.push_str(&format!("{b:02x}"));
    }
    s
}

/// Build the canonical [`AttestationPreimage`] for one JSONL row.
///
/// `prev_signature` is `S_{n-1}` (the on-disk prior row's Ed25519
/// signature bytes) or [`GENESIS_PREV_SIGNATURE`] for the first row.
/// `ledger_id` namespaces this chain (the JSONL path stem in v0).
/// `key_id` is the active operator's public-key fingerprint.
#[must_use]
pub fn row_preimage(
    event: &Event,
    prev_signature: &[u8; 32],
    ledger_id: &str,
    key_id: &str,
    signed_at: DateTime<Utc>,
) -> AttestationPreimage {
    AttestationPreimage {
        schema_version: SCHEMA_VERSION_ATTESTATION,
        source: source_identity_for(&event.source),
        event_id: event.id.to_string(),
        payload_hash: event.payload_hash.clone(),
        session_id: event.session_id.clone().unwrap_or_default(),
        ledger_id: ledger_id.to_string(),
        lineage: LineageBinding::PreviousHash(hex_lower(prev_signature)),
        signed_at,
        key_id: key_id.to_string(),
    }
}

/// Map `cortex-core::EventSource` to the canonical encoder's
/// [`SourceIdentity`].
///
/// `EventSource::ChildAgent { model }` does not carry agent_id /
/// parent_session_id / delegation_id at the JSONL layer in v0; we fill
/// them with empty strings so the canonical bytes remain stable. A
/// follow-up lane that propagates richer agent identity through the
/// mirror can populate them; that change requires a bump of
/// [`SCHEMA_VERSION_ATTESTATION`] **only** if it changes the bytes
/// produced for existing rows (it does not, since old rows will keep
/// re-encoding with empty strings).
#[must_use]
pub fn source_identity_for(source: &EventSource) -> SourceIdentity {
    match source {
        EventSource::User => SourceIdentity::User,
        EventSource::ChildAgent { model } => SourceIdentity::ChildAgent {
            agent_id: String::new(),
            parent_session_id: String::new(),
            delegation_id: String::new(),
            model: model.clone(),
        },
        EventSource::Tool { name } => SourceIdentity::Tool { name: name.clone() },
        EventSource::Runtime => SourceIdentity::Runtime,
        EventSource::ExternalOutcome => SourceIdentity::ExternalOutcome,
        EventSource::ManualCorrection => SourceIdentity::ManualCorrection,
    }
}

/// True iff `event` is an `identity.rotate` row (JSON shape only — does
/// not validate the embedded envelope).
#[must_use]
pub fn is_identity_rotate(event: &Event) -> bool {
    matches!(event.event_type, cortex_core::EventType::SystemNote)
        && event
            .payload
            .as_object()
            .and_then(|o| o.get("kind"))
            .and_then(|v| v.as_str())
            == Some(IDENTITY_ROTATE_PAYLOAD_KIND)
}

/// Extract the [`RotationPayload`] from an `identity.rotate` row, or
/// `None` if the row is not a rotation or the payload does not parse.
#[must_use]
pub fn extract_rotation_payload(event: &Event) -> Option<RotationPayload> {
    if !is_identity_rotate(event) {
        return None;
    }
    serde_json::from_value(event.payload.clone()).ok()
}

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::TimeZone;
    use cortex_core::{
        attestor::{sign_rotation, Attestor, InMemoryAttestor},
        Event, EventId, EventType, SCHEMA_VERSION,
    };

    fn fresh_attestor(seed: u8) -> InMemoryAttestor {
        InMemoryAttestor::from_seed(&[seed; 32])
    }

    fn fixture_event() -> Event {
        Event {
            id: EventId::new(),
            schema_version: SCHEMA_VERSION,
            observed_at: Utc.with_ymd_and_hms(2026, 5, 3, 12, 0, 0).unwrap(),
            recorded_at: Utc.with_ymd_and_hms(2026, 5, 3, 12, 0, 1).unwrap(),
            source: EventSource::User,
            event_type: EventType::UserMessage,
            trace_id: None,
            session_id: Some("s-001".into()),
            domain_tags: vec![],
            payload: serde_json::json!({"text": "hi"}),
            payload_hash: "deadbeef".into(),
            prev_event_hash: None,
            event_hash: "feedface".into(),
        }
    }

    #[test]
    fn genesis_preimage_uses_zero_sentinel() {
        let event = fixture_event();
        let signed_at = Utc.with_ymd_and_hms(2026, 5, 3, 12, 0, 2).unwrap();
        let p = row_preimage(
            &event,
            &GENESIS_PREV_SIGNATURE,
            "ledger-test",
            "fp:abc",
            signed_at,
        );
        match p.lineage {
            LineageBinding::PreviousHash(s) => assert_eq!(s, "0".repeat(64)),
            other => panic!("expected PreviousHash for genesis sentinel, got {other:?}"),
        }
    }

    #[test]
    fn preimage_changes_with_prev_signature() {
        let event = fixture_event();
        let signed_at = Utc.with_ymd_and_hms(2026, 5, 3, 12, 0, 2).unwrap();
        let mut prev_a = [0u8; 32];
        prev_a[0] = 0xAA;
        let mut prev_b = [0u8; 32];
        prev_b[0] = 0xBB;
        let pa = row_preimage(&event, &prev_a, "ledger-test", "fp:abc", signed_at);
        let pb = row_preimage(&event, &prev_b, "ledger-test", "fp:abc", signed_at);
        assert_ne!(pa.lineage, pb.lineage);
    }

    #[test]
    fn rotation_payload_round_trips() {
        let old = fresh_attestor(1);
        let new = fresh_attestor(2);
        let signed_at = Utc.with_ymd_and_hms(2026, 5, 3, 12, 0, 0).unwrap();
        let env = sign_rotation(&old.verifying_key(), &new.verifying_key(), signed_at, &old);
        let rp = RotationPayload::new(env.clone());
        let json = serde_json::to_value(&rp).unwrap();
        assert_eq!(json["kind"], "identity.rotate");
        let back: RotationPayload = serde_json::from_value(json).unwrap();
        assert_eq!(back.envelope, env);
    }

    #[test]
    fn is_identity_rotate_true_only_for_systemnote_with_kind() {
        let mut e = fixture_event();
        assert!(!is_identity_rotate(&e));
        e.event_type = EventType::SystemNote;
        e.payload = serde_json::json!({"kind": "identity.rotate", "envelope": {}});
        assert!(is_identity_rotate(&e));
        e.payload = serde_json::json!({"kind": "other"});
        assert!(!is_identity_rotate(&e));
    }
}