Skip to main content

cortex_ledger/
anchor_chain.rs

1//! Bridge between an [`Event`] persisted in the JSONL mirror and the
2//! canonical [`AttestationPreimage`] that gets signed (T-3.D.6,
3//! ADR 0010 §1-§2).
4//!
5//! ## Why a separate module
6//!
7//! `cortex-core` owns the canonical encoder. `cortex-ledger` owns the
8//! JSONL mirror. This module is the seam: it takes a row's [`Event`] +
9//! the **previous** row's signature bytes and produces the deterministic
10//! [`AttestationPreimage`] whose canonical bytes go into Ed25519.
11//!
12//! ## Per-row preimage shape (ADR 0010 §1, §2)
13//!
14//! The signed preimage `P_n` for row n includes:
15//!
16//! - `schema_version` — pinned to
17//!   [`cortex_core::canonical::SCHEMA_VERSION_ATTESTATION`] (currently 1).
18//! - `source` — derived from `event.source` (User / ChildAgent / Tool /
19//!   Runtime / ExternalOutcome / ManualCorrection). For ChildAgent rows
20//!   the optional sub-fields (`agent_id`, `parent_session_id`,
21//!   `delegation_id`) are not present on `Event` in v0; we set them to
22//!   the empty string so the canonical encoder still produces stable
23//!   bytes. They are reserved for a follow-up lane that propagates child-
24//!   agent identity through the JSONL mirror.
25//! - `event_id` — `event.id` rendered as its canonical string form.
26//! - `payload_hash` — hex `payload_hash` already on `event`.
27//! - `session_id` — `event.session_id.unwrap_or_default()`.
28//! - `ledger_id` — supplied by the caller (the JSONL log path's
29//!   logical ledger identifier; the file path stem in v0).
30//! - `lineage` — `LineageBinding::PreviousHash(hex(prev_signature))` where
31//!   `prev_signature` is `S_{n-1}` for n >= 1, or [`GENESIS_PREV_SIGNATURE`]
32//!   for n = 0 (a 32-byte all-zero sentinel that lives in its own
33//!   distinct hex form so a captured genesis row cannot be replayed
34//!   into a non-genesis position).
35//! - `signed_at` — supplied by the signing call (taken from the attestor's
36//!   wall-clock at sign time; embedded into the preimage so the verifier
37//!   knows what to reconstruct).
38//! - `key_id` — public-key fingerprint of the signing operator.
39//!
40//! ## Identity rotation event payload (ADR 0010 §6)
41//!
42//! Lane 3.D.6 cannot extend the [`cortex_core::EventType`] enum (out of
43//! crate scope), so `identity.rotate` events are persisted as
44//! [`cortex_core::EventType::SystemNote`] rows whose `payload` matches
45//! [`IDENTITY_ROTATE_PAYLOAD_KIND`]. The verifier inspects every
46//! `SystemNote` row's payload; if the `kind` field equals
47//! `"identity.rotate"` and the embedded [`RotationPayload`] envelope
48//! verifies under the **current** active pubkey, the verifier switches
49//! the active pubkey to the envelope's `new_pubkey` for all subsequent
50//! rows.
51//!
52//! Switching keys mid-chain is the **only** way the active pubkey changes
53//! during verification; a chain that begins under key A and contains no
54//! `identity.rotate` row signed by A cannot later present a row signed
55//! by key B.
56
57use chrono::{DateTime, Utc};
58use cortex_core::{
59    canonical::{AttestationPreimage, LineageBinding, SourceIdentity, SCHEMA_VERSION_ATTESTATION},
60    Event, EventSource,
61};
62use serde::{Deserialize, Serialize};
63
64/// Genesis sentinel for the very first row in a chain (n = 0). The bytes
65/// are 32 zero octets — distinct from any real Ed25519 signature, and
66/// hex-encodable so they fit the existing `LineageBinding::PreviousHash`
67/// variant without a schema bump.
68///
69/// A captured genesis row cannot be replayed into a non-genesis position
70/// because (a) its preimage carries this exact sentinel and (b) the
71/// verifier reconstructs the lineage from the on-disk previous row's
72/// actual signature bytes, which differ.
73pub const GENESIS_PREV_SIGNATURE: [u8; 32] = [0u8; 32];
74
75/// `payload.kind` value identifying an `identity.rotate` event written
76/// as a [`cortex_core::EventType::SystemNote`] row.
77pub const IDENTITY_ROTATE_PAYLOAD_KIND: &str = "identity.rotate";
78
79/// Canonical shape of the `identity.rotate` event payload.
80///
81/// Stored at `event.payload` as `{ "kind": "identity.rotate", "envelope":
82/// <serialized RotationEnvelope> }`. The verifier extracts and verifies
83/// the envelope before adopting `new_pubkey`.
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85pub struct RotationPayload {
86    /// Always [`IDENTITY_ROTATE_PAYLOAD_KIND`] — gives a fast, free
87    /// discriminator on the JSON without committing to a `cortex-core`
88    /// enum extension.
89    pub kind: String,
90    /// The signed rotation envelope (old → new pubkey, signed by old).
91    pub envelope: cortex_core::attestor::RotationEnvelope,
92}
93
94impl RotationPayload {
95    /// Wrap a freshly-signed envelope in the canonical payload shape.
96    #[must_use]
97    pub fn new(envelope: cortex_core::attestor::RotationEnvelope) -> Self {
98        Self {
99            kind: IDENTITY_ROTATE_PAYLOAD_KIND.to_string(),
100            envelope,
101        }
102    }
103}
104
105/// Hex-encode bytes (lowercase, no separator). Local helper so the crate
106/// does not pull in `hex` for one chain coupling field.
107#[must_use]
108pub fn hex_lower(bytes: &[u8]) -> String {
109    let mut s = String::with_capacity(bytes.len() * 2);
110    for b in bytes {
111        s.push_str(&format!("{b:02x}"));
112    }
113    s
114}
115
116/// Build the canonical [`AttestationPreimage`] for one JSONL row.
117///
118/// `prev_signature` is `S_{n-1}` (the on-disk prior row's Ed25519
119/// signature bytes) or [`GENESIS_PREV_SIGNATURE`] for the first row.
120/// `ledger_id` namespaces this chain (the JSONL path stem in v0).
121/// `key_id` is the active operator's public-key fingerprint.
122#[must_use]
123pub fn row_preimage(
124    event: &Event,
125    prev_signature: &[u8; 32],
126    ledger_id: &str,
127    key_id: &str,
128    signed_at: DateTime<Utc>,
129) -> AttestationPreimage {
130    AttestationPreimage {
131        schema_version: SCHEMA_VERSION_ATTESTATION,
132        source: source_identity_for(&event.source),
133        event_id: event.id.to_string(),
134        payload_hash: event.payload_hash.clone(),
135        session_id: event.session_id.clone().unwrap_or_default(),
136        ledger_id: ledger_id.to_string(),
137        lineage: LineageBinding::PreviousHash(hex_lower(prev_signature)),
138        signed_at,
139        key_id: key_id.to_string(),
140    }
141}
142
143/// Map `cortex-core::EventSource` to the canonical encoder's
144/// [`SourceIdentity`].
145///
146/// `EventSource::ChildAgent { model }` does not carry agent_id /
147/// parent_session_id / delegation_id at the JSONL layer in v0; we fill
148/// them with empty strings so the canonical bytes remain stable. A
149/// follow-up lane that propagates richer agent identity through the
150/// mirror can populate them; that change requires a bump of
151/// [`SCHEMA_VERSION_ATTESTATION`] **only** if it changes the bytes
152/// produced for existing rows (it does not, since old rows will keep
153/// re-encoding with empty strings).
154#[must_use]
155pub fn source_identity_for(source: &EventSource) -> SourceIdentity {
156    match source {
157        EventSource::User => SourceIdentity::User,
158        EventSource::ChildAgent { model } => SourceIdentity::ChildAgent {
159            agent_id: String::new(),
160            parent_session_id: String::new(),
161            delegation_id: String::new(),
162            model: model.clone(),
163        },
164        EventSource::Tool { name } => SourceIdentity::Tool { name: name.clone() },
165        EventSource::Runtime => SourceIdentity::Runtime,
166        EventSource::ExternalOutcome => SourceIdentity::ExternalOutcome,
167        EventSource::ManualCorrection => SourceIdentity::ManualCorrection,
168    }
169}
170
171/// True iff `event` is an `identity.rotate` row (JSON shape only — does
172/// not validate the embedded envelope).
173#[must_use]
174pub fn is_identity_rotate(event: &Event) -> bool {
175    matches!(event.event_type, cortex_core::EventType::SystemNote)
176        && event
177            .payload
178            .as_object()
179            .and_then(|o| o.get("kind"))
180            .and_then(|v| v.as_str())
181            == Some(IDENTITY_ROTATE_PAYLOAD_KIND)
182}
183
184/// Extract the [`RotationPayload`] from an `identity.rotate` row, or
185/// `None` if the row is not a rotation or the payload does not parse.
186#[must_use]
187pub fn extract_rotation_payload(event: &Event) -> Option<RotationPayload> {
188    if !is_identity_rotate(event) {
189        return None;
190    }
191    serde_json::from_value(event.payload.clone()).ok()
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use chrono::TimeZone;
198    use cortex_core::{
199        attestor::{sign_rotation, Attestor, InMemoryAttestor},
200        Event, EventId, EventType, SCHEMA_VERSION,
201    };
202
203    fn fresh_attestor(seed: u8) -> InMemoryAttestor {
204        InMemoryAttestor::from_seed(&[seed; 32])
205    }
206
207    fn fixture_event() -> Event {
208        Event {
209            id: EventId::new(),
210            schema_version: SCHEMA_VERSION,
211            observed_at: Utc.with_ymd_and_hms(2026, 5, 3, 12, 0, 0).unwrap(),
212            recorded_at: Utc.with_ymd_and_hms(2026, 5, 3, 12, 0, 1).unwrap(),
213            source: EventSource::User,
214            event_type: EventType::UserMessage,
215            trace_id: None,
216            session_id: Some("s-001".into()),
217            domain_tags: vec![],
218            payload: serde_json::json!({"text": "hi"}),
219            payload_hash: "deadbeef".into(),
220            prev_event_hash: None,
221            event_hash: "feedface".into(),
222        }
223    }
224
225    #[test]
226    fn genesis_preimage_uses_zero_sentinel() {
227        let event = fixture_event();
228        let signed_at = Utc.with_ymd_and_hms(2026, 5, 3, 12, 0, 2).unwrap();
229        let p = row_preimage(
230            &event,
231            &GENESIS_PREV_SIGNATURE,
232            "ledger-test",
233            "fp:abc",
234            signed_at,
235        );
236        match p.lineage {
237            LineageBinding::PreviousHash(s) => assert_eq!(s, "0".repeat(64)),
238            other => panic!("expected PreviousHash for genesis sentinel, got {other:?}"),
239        }
240    }
241
242    #[test]
243    fn preimage_changes_with_prev_signature() {
244        let event = fixture_event();
245        let signed_at = Utc.with_ymd_and_hms(2026, 5, 3, 12, 0, 2).unwrap();
246        let mut prev_a = [0u8; 32];
247        prev_a[0] = 0xAA;
248        let mut prev_b = [0u8; 32];
249        prev_b[0] = 0xBB;
250        let pa = row_preimage(&event, &prev_a, "ledger-test", "fp:abc", signed_at);
251        let pb = row_preimage(&event, &prev_b, "ledger-test", "fp:abc", signed_at);
252        assert_ne!(pa.lineage, pb.lineage);
253    }
254
255    #[test]
256    fn rotation_payload_round_trips() {
257        let old = fresh_attestor(1);
258        let new = fresh_attestor(2);
259        let signed_at = Utc.with_ymd_and_hms(2026, 5, 3, 12, 0, 0).unwrap();
260        let env = sign_rotation(&old.verifying_key(), &new.verifying_key(), signed_at, &old);
261        let rp = RotationPayload::new(env.clone());
262        let json = serde_json::to_value(&rp).unwrap();
263        assert_eq!(json["kind"], "identity.rotate");
264        let back: RotationPayload = serde_json::from_value(json).unwrap();
265        assert_eq!(back.envelope, env);
266    }
267
268    #[test]
269    fn is_identity_rotate_true_only_for_systemnote_with_kind() {
270        let mut e = fixture_event();
271        assert!(!is_identity_rotate(&e));
272        e.event_type = EventType::SystemNote;
273        e.payload = serde_json::json!({"kind": "identity.rotate", "envelope": {}});
274        assert!(is_identity_rotate(&e));
275        e.payload = serde_json::json!({"kind": "other"});
276        assert!(!is_identity_rotate(&e));
277    }
278}