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}