Skip to main content

clawft_kernel/
chain.rs

1//! Local exochain manager for kernel event logging.
2//!
3//! Provides an append-only event chain with SHAKE-256 hash linking
4//! (via [`weftos_rvf_crypto`]). Each event references the hash of the
5//! previous event *and* a content hash of its payload, forming
6//! an immutable, tamper-evident audit trail suitable for cross-service
7//! and cross-node verification.
8//!
9//! ## Hash scheme
10//!
11//! Every event carries three hashes:
12//! - **`prev_hash`** — SHAKE-256 of the preceding event (chain link)
13//! - **`payload_hash`** — SHAKE-256 of the canonical JSON payload bytes
14//!   (content commitment; zeroed when payload is `None`)
15//! - **`hash`** — SHAKE-256 of `(sequence ‖ chain_id ‖ prev_hash ‖
16//!   source ‖ 0x00 ‖ kind ‖ 0x00 ‖ timestamp ‖ payload_hash)`
17//!
18//! Together these enable *two-way verification*: given an event you
19//! can verify the chain link backward *and* the payload content
20//! independently.
21//!
22//! # K0 Scope
23//! Local chain only: genesis, append, checkpoint.
24//!
25//! # K1+ Scope (not implemented)
26//! Global root chain, BridgeEvent anchoring, ruvector-raft consensus.
27
28use std::path::Path;
29use std::sync::{Arc, Mutex};
30
31use chrono::{DateTime, Utc};
32use ed25519_dalek::{SigningKey, VerifyingKey};
33use rvf_types::SEGMENT_HEADER_SIZE;
34use weftos_rvf_crypto::hash::shake256_256;
35use weftos_rvf_crypto::{
36    MlDsa65Key, MlDsa65VerifyKey, WitnessEntry, create_witness_chain, decode_signature_footer,
37    encode_signature_footer, lineage_record_to_bytes, lineage_witness_entry, sign_segment,
38    sign_segment_ml_dsa, verify_segment, verify_segment_ml_dsa, verify_witness_chain,
39};
40use weftos_rvf_wire::writer::{calculate_padded_size, write_segment};
41use weftos_rvf_wire::{read_segment, validate_segment};
42
43// ── ExoChain-specific RVF types ─────────────────────────────────────
44//
45// These were previously in rvf-types/rvf-wire but were removed upstream.
46// They are exochain-specific protocol types that belong with this module.
47
48/// Magic number for ExoChain headers inside RVF segment payloads.
49const EXOCHAIN_MAGIC: u32 = 0x4558_4F43; // "EXOC"
50
51/// 64-byte header embedded in the payload of an RVF segment for exochain events.
52///
53/// Layout (all fields little-endian):
54///   [0..4]   magic: u32        EXOCHAIN_MAGIC
55///   [4]      version: u8       protocol version (1)
56///   [5]      subtype: u8       0x40=Event, 0x41=Checkpoint, 0x42=Proof
57///   [6..8]   flags: u16        reserved
58///   [8..12]  chain_id: u32     chain identifier
59///   [12..16] _reserved: u32    must be 0
60///   [16..24] sequence: u64     event sequence number
61///   [24..32] timestamp_secs: u64  unix timestamp
62///   [32..64] prev_hash: [u8;32]  hash of previous event
63#[derive(Debug, Clone)]
64struct ExoChainHeader {
65    magic: u32,
66    version: u8,
67    subtype: u8,
68    flags: u16,
69    chain_id: u32,
70    _reserved: u32,
71    sequence: u64,
72    timestamp_secs: u64,
73    prev_hash: [u8; 32],
74}
75
76const EXOCHAIN_HEADER_SIZE: usize = 64;
77
78impl ExoChainHeader {
79    fn to_bytes(&self) -> [u8; EXOCHAIN_HEADER_SIZE] {
80        let mut buf = [0u8; EXOCHAIN_HEADER_SIZE];
81        buf[0..4].copy_from_slice(&self.magic.to_le_bytes());
82        buf[4] = self.version;
83        buf[5] = self.subtype;
84        buf[6..8].copy_from_slice(&self.flags.to_le_bytes());
85        buf[8..12].copy_from_slice(&self.chain_id.to_le_bytes());
86        buf[12..16].copy_from_slice(&self._reserved.to_le_bytes());
87        buf[16..24].copy_from_slice(&self.sequence.to_le_bytes());
88        buf[24..32].copy_from_slice(&self.timestamp_secs.to_le_bytes());
89        buf[32..64].copy_from_slice(&self.prev_hash);
90        buf
91    }
92
93    fn from_bytes(data: &[u8]) -> Option<Self> {
94        if data.len() < EXOCHAIN_HEADER_SIZE {
95            return None;
96        }
97        let magic = u32::from_le_bytes(data[0..4].try_into().ok()?);
98        if magic != EXOCHAIN_MAGIC {
99            return None;
100        }
101        Some(Self {
102            magic,
103            version: data[4],
104            subtype: data[5],
105            flags: u16::from_le_bytes(data[6..8].try_into().ok()?),
106            chain_id: u32::from_le_bytes(data[8..12].try_into().ok()?),
107            _reserved: u32::from_le_bytes(data[12..16].try_into().ok()?),
108            sequence: u64::from_le_bytes(data[16..24].try_into().ok()?),
109            timestamp_secs: u64::from_le_bytes(data[24..32].try_into().ok()?),
110            prev_hash: data[32..64].try_into().ok()?,
111        })
112    }
113}
114
115/// Write an RVF segment containing an ExoChainHeader + CBOR payload.
116fn write_exochain_event(header: &ExoChainHeader, cbor: &[u8], segment_id: u64) -> Vec<u8> {
117    let exo_bytes = header.to_bytes();
118    let mut payload = Vec::with_capacity(exo_bytes.len() + cbor.len());
119    payload.extend_from_slice(&exo_bytes);
120    payload.extend_from_slice(cbor);
121    write_segment(
122        0x10, // domain-specific segment type
123        &payload,
124        rvf_types::SegmentFlags::empty(),
125        segment_id,
126    )
127}
128
129/// Decode an RVF segment payload into ExoChainHeader + remaining CBOR bytes.
130fn decode_exochain_payload(payload: &[u8]) -> Option<(ExoChainHeader, &[u8])> {
131    let header = ExoChainHeader::from_bytes(payload)?;
132    Some((header, &payload[EXOCHAIN_HEADER_SIZE..]))
133}
134use serde::{Deserialize, Serialize};
135use tracing::{debug, info, warn};
136
137/// A chain event -- one entry in the append-only log.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct ChainEvent {
140    /// Sequence number (0 = genesis).
141    pub sequence: u64,
142    /// Chain ID (0 = local).
143    pub chain_id: u32,
144    /// Event timestamp.
145    pub timestamp: DateTime<Utc>,
146    /// SHAKE-256 hash of the previous event (zeroed for genesis).
147    pub prev_hash: [u8; 32],
148    /// SHAKE-256 hash of this event (covers all fields incl. payload).
149    pub hash: [u8; 32],
150    /// SHAKE-256 hash of the canonical payload bytes (zeroed when
151    /// payload is `None`). Enables independent content verification.
152    #[serde(default)]
153    pub payload_hash: [u8; 32],
154    /// Event source (e.g. "kernel", "service.cron", "cluster").
155    pub source: String,
156    /// Event kind (e.g. "boot", "service.start", "peer.join").
157    pub kind: String,
158    /// Optional payload (JSON).
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub payload: Option<serde_json::Value>,
161    /// Optional idempotency key for replay protection (WEFT-103).
162    ///
163    /// When provided to [`ChainManager::append_idempotent`], the chain
164    /// rejects (returns the prior event without appending a duplicate)
165    /// any new event whose key matches an entry within the last
166    /// [`IDEMPOTENCY_LOOKBACK`] events. This shields the append path
167    /// from replayed writes at retry boundaries (mesh peers, HTTP
168    /// reties, sync-sweep loops).
169    ///
170    /// **Not covered by the event hash.** Idempotency keys are an
171    /// out-of-band deduplication hint, not part of the immutable
172    /// chain commitment. This keeps existing chains hash-stable as
173    /// the field rolls out and allows different writers to retry the
174    /// same logical operation without invalidating the ledger.
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub idempotency_key: Option<[u8; 32]>,
177    /// Hash of the kind-registry rules in force when this event was
178    /// appended (ADR-103 A7). `None` for every pre-P2 event.
179    ///
180    /// **Covered by the event hash only when `Some`**, so events
181    /// without it hash byte-identically to before the field existed.
182    /// A binary that predates the field ignores it on read, recomputes
183    /// a different hash and fails integrity verification loudly.
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub rule_hash: Option<[u8; 32]>,
186}
187
188/// Number of recent events scanned for idempotency-key collisions
189/// in [`ChainManager::append_idempotent`].
190///
191/// 1000 events is large enough to cover bursts of retries from any
192/// reasonable client TTL window without making the lookup O(n) over
193/// the whole chain. Configurable in a future pass if needed.
194pub const IDEMPOTENCY_LOOKBACK: usize = 1000;
195
196/// Chain sources only the daemon's own code may append under: the user
197/// chain's identity events (`user.projects`), project anchors
198/// (`project.anchor`, on both the user and the project chain) and token
199/// issue/revoke records (`auth.token`, folded by `TokenAuthority`). Refused
200/// from the `chain.append` RPC and `chain_bridge`, and from replicated events
201/// ([`ChainManager::append_signed`]): a forged one would feed the identity
202/// view, fake an anchor or mint/revoke a bearer token. This is the ONE list:
203/// every authority-owned source a kernel component folds belongs here (the
204/// literals are pinned to the owning modules' constants by a test).
205pub const RESERVED_SOURCES: &[&str] = &["user.projects", "project.anchor", "auth.token"];
206
207/// Is `source` reserved for the daemon's own events?
208pub fn is_reserved_source(source: &str) -> bool {
209    RESERVED_SOURCES.contains(&source.trim())
210}
211
212/// Sources the kernel's own code appends under and that a project kernel
213/// trusts when it reads its chain back (review M3): `governance` (the
214/// rollback floor reads `governance.overlay.applied`), `project` (genesis)
215/// and `project.supervisor`. Refused, with [`RESERVED_SOURCES`], from the two
216/// caller doors (the `chain.append` RPC and `chain_bridge`). Not refused
217/// from replication ([`ChainManager::append_signed`]): every chain carries
218/// governance events and a peer's must sync; a project kernel replicates
219/// nothing (its mesh is off).
220pub const KERNEL_SOURCES: &[&str] = &["governance", "project", "project.supervisor"];
221
222/// Event kinds a project kernel folds as authority regardless of the source
223/// they carry (the overlay rollback floor and `user_pin_used` read
224/// `governance.overlay.applied`). Refused from replication under any source:
225/// a forged replicated one with `parent_version = u64::MAX` would brick boot
226/// and reload.
227pub const RESERVED_KINDS: &[&str] = &["governance.overlay.applied"];
228
229/// Is `kind` an authority-folded kind that replication must never carry?
230pub fn is_reserved_kind(kind: &str) -> bool {
231    RESERVED_KINDS.contains(&kind.trim())
232}
233
234/// Domain-tag prefix of every project-key signed format
235/// (`weftos-project-cert-v1`, `-anchor-v1`, `-anchor-record-v1`, forward
236/// header, ...). A signer that takes caller-chosen bytes under a key it does
237/// not tag (the chain's `dual_sign`, the tree manager's `sign_bytes`, the mesh
238/// handshake) must never sign bytes that start with it, or its signature
239/// could be replayed as one of those statements (ADR-103 A7 note).
240pub const PROJECT_DOMAIN_PREFIX: &[u8] = b"weftos-project-";
241
242/// True when an untagged signer would be signing a project-domain-tagged
243/// message. Callers refuse (return `None`/`Err`) in release builds too.
244pub fn signs_project_domain(data: &[u8]) -> bool {
245    data.starts_with(PROJECT_DOMAIN_PREFIX)
246}
247
248/// May a caller (an RPC or a tracing emitter) NOT append under `source`?
249pub fn is_caller_reserved_source(source: &str) -> bool {
250    is_reserved_source(source) || KERNEL_SOURCES.contains(&source.trim())
251}
252
253/// Errors from [`ChainManager::append_signed`] (WEFT-105 / K6.4 replay).
254#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
255pub enum AppendSignedError {
256    /// Event with this sequence and hash is already on the local chain.
257    #[error("event seq {sequence} already present (idempotent skip)")]
258    AlreadyPresent { sequence: u64 },
259    /// Same sequence, different hash — DAG tip conflict (ADR-089).
260    #[error("fork at seq {sequence}: local and remote hashes diverge")]
261    Fork {
262        sequence: u64,
263        local_hash: [u8; 32],
264        remote_hash: [u8; 32],
265    },
266    /// Remote event skips ahead of the next expected sequence.
267    #[error("gap: expected seq {expected}, got {got}")]
268    Gap { expected: u64, got: u64 },
269    /// The event's source is reserved for locally appended events.
270    #[error("source {event_source:?} is reserved and cannot be replicated")]
271    ReservedSource { event_source: String },
272    /// Chain ID mismatch between local and remote event.
273    #[error("chain_id mismatch: local={local}, remote={remote}")]
274    ChainIdMismatch { local: u32, remote: u32 },
275    /// `prev_hash` does not link to the local tip.
276    #[error("prev_hash mismatch at append")]
277    PrevHashMismatch {
278        expected: [u8; 32],
279        got: [u8; 32],
280    },
281    /// Stored `payload_hash` does not match a recomputation of the payload.
282    #[error("payload_hash mismatch at seq {sequence}")]
283    PayloadHashMismatch { sequence: u64 },
284    /// Stored event hash does not match a recomputation of the commitment.
285    #[error("event hash mismatch at seq {sequence}")]
286    HashMismatch {
287        sequence: u64,
288        recomputed: [u8; 32],
289        stored: [u8; 32],
290    },
291}
292
293/// A checkpoint snapshot of the chain state.
294#[derive(Debug, Clone, Serialize, Deserialize)]
295pub struct ChainCheckpoint {
296    /// Chain ID.
297    pub chain_id: u32,
298    /// Sequence number at checkpoint.
299    pub sequence: u64,
300    /// Hash of the last event at checkpoint.
301    pub last_hash: [u8; 32],
302    /// Timestamp of the checkpoint.
303    pub timestamp: DateTime<Utc>,
304    /// Number of events since last checkpoint.
305    pub events_since_last: u64,
306}
307
308/// Result of chain integrity verification.
309#[derive(Debug, Clone, Serialize, Deserialize)]
310pub struct ChainVerifyResult {
311    /// Whether the entire chain is valid.
312    pub valid: bool,
313    /// Number of events verified.
314    pub event_count: usize,
315    /// List of errors found (empty if valid).
316    pub errors: Vec<String>,
317    /// Ed25519 signature verification status.
318    /// `None` = no signature present, `Some(true)` = valid, `Some(false)` = invalid.
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub signature_verified: Option<bool>,
321}
322
323/// Compute the SHAKE-256 content hash of a payload.
324///
325/// Returns the 32-byte SHAKE-256 hash of the canonical JSON bytes,
326/// or all zeros if the payload is `None`.
327pub(crate) fn compute_payload_hash(payload: &Option<serde_json::Value>) -> [u8; 32] {
328    match payload {
329        Some(val) => {
330            let bytes = serde_json::to_vec(val).unwrap_or_default();
331            shake256_256(&bytes)
332        }
333        None => [0u8; 32],
334    }
335}
336
337/// Compute the SHAKE-256 hash for a chain event.
338///
339/// This is the canonical hash function used for both event creation
340/// and integrity verification. The hash commits to **all** fields:
341///
342/// ```text
343/// SHAKE-256(
344///     sequence(8)  ‖  chain_id(4)  ‖  prev_hash(32)  ‖
345///     source  ‖  0x00  ‖  kind  ‖  0x00  ‖
346///     timestamp(8)  ‖  payload_hash(32)
347///     [ ‖  0x01  ‖  rule_hash(32) ]      -- only when `rule_hash` is Some
348/// )
349/// ```
350///
351/// The null-byte separators between `source` and `kind` prevent
352/// domain collisions (e.g. "foo" + "bar.baz" vs "foo.bar" + "baz").
353// The positional arguments are the hash preimage fields in wire order; a params struct would only restate them.
354#[allow(clippy::too_many_arguments)]
355pub(crate) fn compute_event_hash(
356    sequence: u64,
357    chain_id: u32,
358    prev_hash: &[u8; 32],
359    source: &str,
360    kind: &str,
361    timestamp: &DateTime<Utc>,
362    payload_hash: &[u8; 32],
363    rule_hash: Option<&[u8; 32]>,
364) -> [u8; 32] {
365    let mut buf = Vec::with_capacity(160);
366    buf.extend_from_slice(&sequence.to_le_bytes());
367    buf.extend_from_slice(&chain_id.to_le_bytes());
368    buf.extend_from_slice(prev_hash);
369    buf.extend_from_slice(source.as_bytes());
370    buf.push(0x00); // separator
371    buf.extend_from_slice(kind.as_bytes());
372    buf.push(0x00); // separator
373    buf.extend_from_slice(&timestamp.timestamp().to_le_bytes());
374    buf.extend_from_slice(payload_hash);
375    // None vs Some non-collision (P2 D review) relies on the fixed-width
376    // tail before this point: the i64-LE timestamp, whose top byte is 0x00
377    // for any realistic date, then payload_hash; the 0x01 tag cannot
378    // align with a None event's tail.
379    if let Some(rh) = rule_hash {
380        buf.push(0x01); // domain tag: rule_hash present
381        buf.extend_from_slice(rh);
382    }
383    shake256_256(&buf)
384}
385
386/// Witness type constants for kernel events.
387const WITNESS_PROVENANCE: u8 = 0x01;
388
389// ── Well-known chain event kinds (k3:D8, k2:D8) ────────────────────
390//
391// These constants define canonical `kind` strings for chain events
392// so that producers and consumers agree on the vocabulary.
393
394/// Capability revocation event (k3:D8 — informational revocation).
395///
396/// Revocation is recorded in the chain as data; enforcement is
397/// handled by the governance gate at evaluation time. This allows
398/// governance rules to decide whether revoked capabilities result
399/// in a hard block, a warning, or are allowed in specific contexts.
400pub const EVENT_KIND_CAPABILITY_REVOKED: &str = "capability.revoked";
401
402/// API contract registration event (k2:D8).
403///
404/// Emitted when a service registers or updates its API schema.
405/// The payload should include `service`, `version`, `schema_hash`.
406pub const EVENT_KIND_API_CONTRACT_REGISTERED: &str = "service.contract.register";
407
408/// Tool version deployment event.
409pub const EVENT_KIND_TOOL_DEPLOYED: &str = "tool.deploy";
410
411/// Tool version revocation event.
412pub const EVENT_KIND_TOOL_VERSION_REVOKED: &str = "tool.version.revoke";
413
414/// TileZero gate permitted an action (WEFT-152).
415///
416/// Emitted by [`crate::gate::TileZeroGate`] when `cognitum-gate-tilezero`
417/// returns `Permit`. Payload: `agent_id`, `action`, `sequence`, `witness_hash`.
418pub const EVENT_KIND_GATE_PERMIT: &str = "gate.permit";
419
420/// TileZero gate deferred an action for review (WEFT-152).
421///
422/// Emitted when coherence is uncertain. Payload: `agent_id`, `action`,
423/// `sequence`, `witness_hash`.
424pub const EVENT_KIND_GATE_DEFER: &str = "gate.defer";
425
426/// TileZero gate denied an action (WEFT-152).
427///
428/// Emitted when coherence is below threshold. Payload: `agent_id`, `action`,
429/// `sequence`, `witness_hash`.
430pub const EVENT_KIND_GATE_DENY: &str = "gate.deny";
431
432/// Sandbox sudo override event (k3:D12).
433///
434/// Emitted when a sudo override bypasses environment sandbox restrictions.
435/// The payload should include `agent_id`, `tool`, `path`, `reason`.
436pub const EVENT_KIND_SANDBOX_SUDO_OVERRIDE: &str = "sandbox.sudo.override";
437
438/// Tool signed event (k3:D9).
439///
440/// Emitted when a tool is registered with a verified cryptographic signature.
441/// The payload should include `tool_name`, `tool_hash`, `signer_id`.
442pub const EVENT_KIND_TOOL_SIGNED: &str = "tool.signed";
443
444/// Shell command execution event (k3:D10).
445///
446/// Emitted when a shell command is executed through the sandbox.
447/// The payload should include `command`, `exit_code`, `execution_time_ms`.
448pub const EVENT_KIND_SHELL_EXEC: &str = "shell.exec";
449
450/// EML model trained event.
451///
452/// Emitted when an EML model completes a training cycle.
453/// The payload should include `model_name`, `samples_used`, `mse_before`,
454/// `mse_after`, `converged`, `param_count`.
455pub const EVENT_KIND_EML_TRAINED: &str = "eml.trained";
456
457/// EML model drift detected event.
458///
459/// Emitted when an EML model's prediction diverges significantly
460/// from the ground truth. The payload includes `model_name`,
461/// `predicted`, `actual`, `drift_pct`.
462pub const EVENT_KIND_EML_DRIFT: &str = "eml.drift";
463
464/// EML model saved event.
465pub const EVENT_KIND_EML_SAVED: &str = "eml.saved";
466
467/// EML model loaded event.
468pub const EVENT_KIND_EML_LOADED: &str = "eml.loaded";
469
470/// LeWM per-frame observational tuple attestation (WEFT-533).
471///
472/// Emitted by `clawft-worldmodel-service` (or a daemon bridge) for every
473/// processed frame. Payload is a version-tagged SIGReg manifold reference
474/// plus `(a_t, z_t, z_{t+1}, surprise)`. Kind string matches
475/// `weftos_worldmodel_core::EVENT_KIND_LEWM_FRAME_ATTESTATION`.
476pub const EVENT_KIND_LEWM_FRAME_ATTESTATION: &str = "lewm.frame.attestation";
477
478/// LeWM mesh sensor topic frame index (WEFT-526).
479///
480/// Emitted when a signed `mesh.sensor.v1.{encoded,consensus,control}` frame
481/// is published on the mesh bus. Payload includes topic, cluster, node_id,
482/// timestamp, and a blake3/hex digest of the CBOR frame for ExoChain index.
483pub const EVENT_KIND_MESH_SENSOR_FRAME: &str = "mesh.sensor.v1.frame";
484
485/// LeWM SIGReg health snapshot / rollback gate (WEFT-528).
486///
487/// Emitted by the Welford `sigreg_health` monitor when health is sampled or
488/// the auto-rollback gate fires (`score < 0.85` sustained for 30 s).
489pub const EVENT_KIND_SIGREG_HEALTH: &str = "lewm.sigreg.health";
490
491/// Auth credential registration event.
492///
493/// Emitted when a new credential is registered with the AuthService.
494/// The payload should include `credential_name`, `credential_type`.
495pub const EVENT_KIND_AUTH_CREDENTIAL_REGISTER: &str = "auth.credential.register";
496
497/// Auth credential rotation event.
498///
499/// Emitted when an existing credential's value is rotated.
500/// The payload should include `credential_name`.
501pub const EVENT_KIND_AUTH_CREDENTIAL_ROTATE: &str = "auth.credential.rotate";
502
503/// Auth token issuance event.
504///
505/// Emitted when a scoped token is issued to an agent.
506/// The payload should include `token_id`, `credential_name`, `agent_id`.
507pub const EVENT_KIND_AUTH_TOKEN_ISSUE: &str = "auth.token.issue";
508
509/// Auth token revocation event.
510///
511/// Emitted when an active token is revoked.
512/// The payload should include `token_id`.
513pub const EVENT_KIND_AUTH_TOKEN_REVOKE: &str = "auth.token.revoke";
514
515/// Auth authentication attempt event.
516///
517/// Emitted on every authentication attempt (success or failure).
518/// The payload should include `agent_id`, `success`.
519pub const EVENT_KIND_AUTH_ATTEMPT: &str = "auth.attempt";
520
521/// Configuration set event.
522///
523/// Emitted when a configuration value is created or updated.
524/// The payload should include `namespace`, `key`, `changed_by`.
525pub const EVENT_KIND_CONFIG_SET: &str = "config.set";
526
527/// Configuration delete event.
528///
529/// Emitted when a configuration value is deleted.
530/// The payload should include `namespace`, `key`, `changed_by`.
531pub const EVENT_KIND_CONFIG_DELETE: &str = "config.delete";
532
533/// Secret set event.
534///
535/// Emitted when an encrypted secret is stored.
536/// The payload should include `namespace`, `key`.
537pub const EVENT_KIND_CONFIG_SECRET_SET: &str = "config.secret.set";
538
539/// Application installed event.
540pub const EVENT_KIND_APP_INSTALL: &str = "app.install";
541
542/// Application removed event.
543pub const EVENT_KIND_APP_REMOVE: &str = "app.remove";
544
545/// Application started event.
546pub const EVENT_KIND_APP_START: &str = "app.start";
547
548/// Application stopped event.
549pub const EVENT_KIND_APP_STOP: &str = "app.stop";
550
551/// Application state transition event.
552pub const EVENT_KIND_APP_TRANSITION: &str = "app.transition";
553
554// ── Workload placement events (ADR-099 sections 4 and 7) ─────────
555//
556// Governance decisions on a `workload.*` action are chained under the
557// action's own kind with `payload.decision` = permit | deny | defer.
558
559/// Workload package install decision / event.
560pub const EVENT_KIND_WORKLOAD_INSTALL: &str = "workload.install";
561/// Workload placement decision / event.
562pub const EVENT_KIND_WORKLOAD_PLACE: &str = "workload.place";
563/// Workload load decision / event.
564pub const EVENT_KIND_WORKLOAD_LOAD: &str = "workload.load";
565/// Workload start decision / event.
566pub const EVENT_KIND_WORKLOAD_START: &str = "workload.start";
567/// Workload stop decision / event.
568pub const EVENT_KIND_WORKLOAD_STOP: &str = "workload.stop";
569/// Workload unload decision / event.
570pub const EVENT_KIND_WORKLOAD_UNLOAD: &str = "workload.unload";
571/// Workload migration decision / event.
572pub const EVENT_KIND_WORKLOAD_MIGRATE: &str = "workload.migrate";
573/// Workload refusal (node admission refusal or unknown workload action).
574pub const EVENT_KIND_WORKLOAD_REFUSE: &str = "workload.refuse";
575/// Package / signer key / artifact hash revocation.
576pub const EVENT_KIND_WORKLOAD_REVOKE: &str = "workload.revoke";
577/// A revocation lifted again (`unrevoke`), chained like the revocation.
578pub const EVENT_KIND_WORKLOAD_UNREVOKE: &str = "workload.unrevoke";
579/// An instance changed lifecycle state (health, restart, loss, reschedule).
580pub const EVENT_KIND_WORKLOAD_LIFECYCLE: &str = "workload.lifecycle";
581/// Fleet identity bound to a mesh node.
582pub const EVENT_KIND_WORKLOAD_NODE_BIND: &str = "workload.node.bind";
583/// Fleet identity binding withdrawn (operator-signed `unbound` record).
584pub const EVENT_KIND_WORKLOAD_NODE_UNBIND: &str = "workload.node.unbind";
585
586// ── Artifact transfer outcomes (ADR-099 section 6, mesh-placement-11) ──
587//
588// Outcomes only: individual piece transfers are never chained.
589
590/// A completed or failed artifact fetch (`result` = verified | failed).
591pub const EVENT_KIND_ARTIFACT_FETCH: &str = "artifact.fetch";
592/// A piece whose hash did not match, and the peer that sent it.
593pub const EVENT_KIND_ARTIFACT_PIECE_REJECTED: &str = "artifact.piece_rejected";
594/// First time an artifact is served to a given peer.
595pub const EVENT_KIND_ARTIFACT_SERVE: &str = "artifact.serve";
596
597// ── Swarm distribution (ADR-099 section 6, mesh-placement-25) ──────────
598//
599// Governance actions on what a node holds and serves.
600
601/// A node began seeding a verified artifact (once per artifact).
602pub const EVENT_KIND_ARTIFACT_SEED: &str = "artifact.seed";
603/// An artifact was evicted from the node's cache (`reason` = lru | revoked | manual).
604pub const EVENT_KIND_ARTIFACT_EVICT: &str = "artifact.evict";
605/// A revocation was applied to what the node holds and seeds.
606pub const EVENT_KIND_ARTIFACT_REVOKE: &str = "artifact.revoke";
607/// A peer was banned for serving corrupt pieces.
608pub const EVENT_KIND_ARTIFACT_PEER_BAN: &str = "artifact.peer_ban";
609
610/// An operator-forced node-facts probe ran (`cluster.facts` with `refresh`).
611/// Payload: `node`, `seq`.
612pub const EVENT_KIND_NODE_FACTS_REFRESH: &str = "node.facts.refresh";
613
614/// Cron job added event.
615pub const EVENT_KIND_CRON_ADD: &str = "cron.add";
616
617/// Cron job removed event.
618pub const EVENT_KIND_CRON_REMOVE: &str = "cron.remove";
619
620/// Cron job executed (fired) event.
621pub const EVENT_KIND_CRON_EXECUTE: &str = "cron.execute";
622
623// ── Agent 6: Container / Process / WASM / Agency event kinds ──────
624
625/// Container started event.
626pub const EVENT_KIND_CONTAINER_START: &str = "container.start";
627
628/// Container stopped event.
629pub const EVENT_KIND_CONTAINER_STOP: &str = "container.stop";
630
631/// Container configured (registered) event.
632pub const EVENT_KIND_CONTAINER_CONFIGURE: &str = "container.configure";
633
634/// Process registered in the process table.
635pub const EVENT_KIND_PROCESS_REGISTER: &str = "process.register";
636
637/// Process deregistered (removed) from the process table.
638pub const EVENT_KIND_PROCESS_DEREGISTER: &str = "process.deregister";
639
640/// Process state changed.
641pub const EVENT_KIND_PROCESS_STATE: &str = "process.state";
642
643/// WASM module executed.
644pub const EVENT_KIND_WASM_EXECUTE: &str = "wasm.execute";
645
646/// WASM filesystem: file written.
647pub const EVENT_KIND_WASM_FS_WRITE: &str = "wasm.fs.write";
648
649/// WASM filesystem: file or directory removed.
650pub const EVENT_KIND_WASM_FS_REMOVE: &str = "wasm.fs.remove";
651
652/// WASM filesystem: directory created.
653pub const EVENT_KIND_WASM_FS_CREATE_DIR: &str = "wasm.fs.create_dir";
654
655/// WASM filesystem: file copied.
656pub const EVENT_KIND_WASM_FS_COPY: &str = "wasm.fs.copy";
657
658/// WASM filesystem: file moved.
659pub const EVENT_KIND_WASM_FS_MOVE: &str = "wasm.fs.move";
660
661/// Agent hierarchy: child added.
662pub const EVENT_KIND_AGENT_HIERARCHY_ADD: &str = "agent.hierarchy.add_child";
663
664/// Agent hierarchy: child removed.
665pub const EVENT_KIND_AGENT_HIERARCHY_REMOVE: &str = "agent.hierarchy.remove_child";
666
667/// Cluster peer added event.
668///
669/// Emitted when a new peer node joins the cluster.
670/// The payload should include `node_id`, `name`, `platform`.
671pub const EVENT_KIND_CLUSTER_PEER_ADD: &str = "cluster.peer.add";
672
673/// Cluster peer removed event.
674///
675/// Emitted when a peer node is removed from the cluster.
676/// The payload should include `node_id`.
677pub const EVENT_KIND_CLUSTER_PEER_REMOVE: &str = "cluster.peer.remove";
678
679/// Cluster peer state changed event.
680///
681/// Emitted when a peer node's state transitions.
682/// The payload should include `node_id`, `new_state`.
683pub const EVENT_KIND_CLUSTER_PEER_STATE: &str = "cluster.peer.state";
684
685/// Capability elevation requested event.
686///
687/// Emitted when an agent requests elevated capabilities.
688/// The payload should include `pid`, `platform`, `reason`.
689pub const EVENT_KIND_CAPABILITY_ELEVATE: &str = "capability.elevate";
690
691/// Environment registered event.
692///
693/// Emitted when a new environment is registered.
694/// The payload should include `id`, `name`, `class`.
695pub const EVENT_KIND_ENV_REGISTER: &str = "env.register";
696
697/// Environment switched (set active) event.
698///
699/// Emitted when the active environment changes.
700/// The payload should include `id`.
701pub const EVENT_KIND_ENV_SWITCH: &str = "env.switch";
702
703/// Environment removed event.
704///
705/// Emitted when an environment is deregistered.
706/// The payload should include `id`.
707pub const EVENT_KIND_ENV_REMOVE: &str = "env.remove";
708
709// ── Mesh networking event kinds (Agent 7) ──────────────────────────────
710
711/// Mesh service resolution cached.
712pub const EVENT_KIND_MESH_SERVICE_REGISTER: &str = "mesh.service.register";
713
714/// Mesh service resolution removed / negative-cached.
715pub const EVENT_KIND_MESH_SERVICE_DEREGISTER: &str = "mesh.service.deregister";
716
717/// Mesh artifact remote provider registered.
718pub const EVENT_KIND_MESH_ARTIFACT_STORE: &str = "mesh.artifact.store";
719
720/// Mesh artifact fetch request created.
721pub const EVENT_KIND_MESH_ARTIFACT_FETCH: &str = "mesh.artifact.fetch";
722
723/// Mesh IPC envelope sent.
724pub const EVENT_KIND_MESH_IPC_SEND: &str = "mesh.ipc.send";
725
726/// Mesh peer added (via announcement / catalog update).
727pub const EVENT_KIND_MESH_PEER_ADD: &str = "mesh.peer.add";
728
729/// Mesh peer removed.
730pub const EVENT_KIND_MESH_PEER_REMOVE: &str = "mesh.peer.remove";
731
732// ── Persistence event kinds (Agent 7) ──────────────────────────────────
733
734/// Kernel state saved to disk.
735pub const EVENT_KIND_KERNEL_SAVE: &str = "kernel.save";
736
737/// Kernel state loaded from disk.
738pub const EVENT_KIND_KERNEL_LOAD: &str = "kernel.load";
739
740// ── Reconciler event kinds (Agent 7) ────────────────────────────────────
741
742/// Reconciler corrective action recorded.
743pub const EVENT_KIND_RECONCILER_ACTION: &str = "reconciler.action";
744
745/// Reconciler tick completed.
746pub const EVENT_KIND_RECONCILER_TICK: &str = "reconciler.tick";
747
748/// Reconciler desired state set.
749pub const EVENT_KIND_RECONCILER_DESIRED_SET: &str = "reconciler.desired.set";
750
751/// Reconciler desired state removed.
752pub const EVENT_KIND_RECONCILER_DESIRED_REMOVE: &str = "reconciler.desired.remove";
753
754/// Causal graph node added event.
755///
756/// Emitted when a node is inserted into the causal DAG.
757/// The payload should include `node_id`, `label`.
758pub const EVENT_KIND_CAUSAL_NODE_ADD: &str = "causal.node.add";
759
760/// Causal graph node removed event.
761///
762/// Emitted when a node (and its incident edges) is removed.
763/// The payload should include `node_id`, `label`.
764pub const EVENT_KIND_CAUSAL_NODE_REMOVE: &str = "causal.node.remove";
765
766/// Causal graph node lifecycle-state transition event (ADR-067 P1-graph / WEFT-629).
767///
768/// Emitted from [`CausalGraph::set_node_state`](crate::causal::CausalGraph::set_node_state)
769/// whenever a node's `metadata.state` tag changes
770/// (`Speculative`/`Frontier`/`Committed`/`Stale`/`Pruned`). Payload fields:
771/// `node_id`, `from` (previous state or null), `to` (new state). Folding these
772/// events reconstructs node-state-at-T without a full graph snapshot
773/// ([`crate::causal_state_fold`]).
774pub const EVENT_KIND_CAUSAL_NODE_STATE: &str = "causal.node.state";
775
776/// Causal graph edge added event.
777///
778/// Emitted when a directed edge is created between two nodes.
779/// The payload should include `source`, `target`, `edge_type`, `weight`.
780pub const EVENT_KIND_CAUSAL_EDGE_ADD: &str = "causal.edge.add";
781
782/// Causal graph edge removed event.
783///
784/// Emitted when all edges between two nodes (in one direction) are removed.
785/// The payload should include `source`, `target`, `removed_count`.
786pub const EVENT_KIND_CAUSAL_EDGE_REMOVE: &str = "causal.edge.remove";
787
788/// Causal graph cleared event.
789///
790/// Emitted when the entire causal graph is wiped. Governance-gated.
791/// The payload should include `node_count`, `edge_count` (before clear).
792pub const EVENT_KIND_CAUSAL_CLEAR: &str = "causal.clear";
793
794/// Artifact stored event.
795///
796/// Emitted when content is stored in the artifact store.
797/// The payload should include `hash`, `size`, `content_type`.
798pub const EVENT_KIND_ARTIFACT_STORE: &str = "artifact.store";
799
800/// Artifact removed event.
801///
802/// Emitted when an artifact is removed from storage.
803/// The payload should include `hash`.
804pub const EVENT_KIND_ARTIFACT_REMOVE: &str = "artifact.remove";
805
806// ── Agent 8: Core / Graphify / Weave ────────────────────────────────
807
808/// Sandbox command execution event.
809///
810/// Emitted when a sandbox enforcer checks a command, tool, network,
811/// or file operation. The payload should include `agent_id`, `action`,
812/// `target`, `allowed`.
813pub const EVENT_KIND_SANDBOX_EXECUTE: &str = "sandbox.execute";
814
815/// Session created event.
816///
817/// Emitted when a new conversation session is created.
818/// The payload should include `key`.
819pub const EVENT_KIND_SESSION_APPEND: &str = "session.append";
820pub const EVENT_KIND_SESSION_CREATE: &str = "session.create";
821
822/// Session destroyed event.
823///
824/// Emitted when a session is deleted from disk and cache.
825/// The payload should include `key`.
826pub const EVENT_KIND_SESSION_DESTROY: &str = "session.destroy";
827
828/// Workspace created event.
829///
830/// Emitted when a new workspace is scaffolded.
831/// The payload should include `name`, `path`.
832pub const EVENT_KIND_WORKSPACE_CREATE: &str = "workspace.create";
833
834/// Workspace config updated event.
835///
836/// Emitted when workspace config is loaded / merged.
837/// The payload should include `workspace_path`, `global_path`.
838pub const EVENT_KIND_WORKSPACE_CONFIG: &str = "workspace.config";
839
840/// Tool registered in the registry.
841///
842/// Emitted when a tool implementation is added to the ToolRegistry.
843/// The payload should include `tool_name`.
844pub const EVENT_KIND_TOOL_REGISTER: &str = "tool.register";
845
846/// Graphify knowledge graph built.
847///
848/// Emitted when extraction results are merged into a KnowledgeGraph.
849/// The payload should include `entity_count`, `relationship_count`,
850/// `files_processed`.
851pub const EVENT_KIND_GRAPHIFY_BUILD: &str = "graphify.build";
852
853/// Graphify URL ingested.
854///
855/// Emitted when a URL is fetched, classified, and saved.
856/// The payload should include `url`, `url_type`, `filename`.
857pub const EVENT_KIND_GRAPHIFY_INGEST: &str = "graphify.ingest";
858
859/// Graphify pipeline executed.
860///
861/// Emitted when the full graphify pipeline completes.
862/// The payload should include `entity_count`, `relationship_count`,
863/// `files_processed`, `has_analysis`.
864pub const EVENT_KIND_GRAPHIFY_PIPELINE: &str = "graphify.pipeline";
865
866/// Graphify git hook installed or uninstalled.
867///
868/// Emitted when post-commit / post-checkout hooks are installed
869/// or uninstalled.
870/// The payload should include `repo_root`, `action` (install / uninstall).
871pub const EVENT_KIND_GRAPHIFY_HOOK: &str = "graphify.hook";
872
873/// Graphify Q&A query result saved for re-extraction (WEFT-153).
874///
875/// Emitted when `save_query_result` writes a markdown Q&A artifact.
876/// The payload should include `query_type`, `path`, `source_node_count`.
877pub const EVENT_KIND_GRAPHIFY_QUERY_RESULT: &str = "graphify.query_result";
878
879/// Tool registered with explicit metadata (WEFT-153).
880///
881/// Emitted by non-kernel `ToolRegistry::register_with_metadata` (MCP tools
882/// whose metadata comes from JSON declarations). Payload: `tool_name`,
883/// `has_metadata`.
884pub const EVENT_KIND_TOOL_REGISTER_METADATA: &str = "tool.register.metadata";
885
886/// Project initialised.
887///
888/// Emitted when `weaver init` scaffolds the development environment.
889/// The payload should include `force`, `skills_only`, `analyze`.
890pub const EVENT_KIND_PROJECT_INIT: &str = "project.init";
891
892/// Profile created event.
893///
894/// Emitted when a new profile is created.
895/// The payload should include `profile_id`, `name`.
896pub const EVENT_KIND_PROFILE_CREATE: &str = "profile.create";
897
898/// Profile deleted event.
899///
900/// Emitted when a profile is deleted (governance gated).
901/// The payload should include `profile_id`.
902pub const EVENT_KIND_PROFILE_DELETE: &str = "profile.delete";
903
904/// Active profile switched event.
905///
906/// Emitted when the active profile changes.
907/// The payload should include `profile_id`, `previous`.
908pub const EVENT_KIND_PROFILE_SWITCH: &str = "profile.switch";
909
910/// Vector inserted into a profile backend.
911///
912/// Emitted when a vector is inserted into the active profile's backend.
913/// The payload should include `profile_id`, `vector_id`, `key`.
914pub const EVENT_KIND_PROFILE_VECTOR_INSERT: &str = "profile.vector.insert";
915
916/// BVH leaf inserted (ADR-056 / WEFT-718).
917///
918/// Payload: `leaf_id`, `branch`, `tag`, `identity`, `bound`, `payload_hex`.
919pub const EVENT_KIND_BVH_INSERT: &str = "bvh.insert";
920
921/// BVH leaf removed.
922///
923/// Payload: `leaf_id`, `branch`.
924pub const EVENT_KIND_BVH_REMOVE: &str = "bvh.remove";
925
926/// BVH branch derived (COW / clone).
927///
928/// Payload: `parent`, `child`, `name`, `priority_tier`.
929pub const EVENT_KIND_BVH_DERIVE: &str = "bvh.derive";
930
931/// BVH rebalance / determinism-phase seal.
932///
933/// Payload: `branch`, `leaf_count`, `epoch`.
934pub const EVENT_KIND_BVH_REBALANCE_SEAL: &str = "bvh.rebalance_seal";
935
936/// HNSW vector inserted event.
937///
938/// Emitted when an embedding is inserted into the HNSW store.
939/// The payload should include `id`.
940pub const EVENT_KIND_HNSW_INSERT: &str = "hnsw.insert";
941
942/// HNSW store cleared event (governance gated -- bulk destruction).
943///
944/// Emitted when the HNSW store is replaced with an empty instance.
945pub const EVENT_KIND_HNSW_CLEAR: &str = "hnsw.clear";
946
947/// HNSW store saved to file event.
948///
949/// Emitted when the HNSW store is persisted to disk.
950/// The payload should include `path`.
951pub const EVENT_KIND_HNSW_SAVE: &str = "hnsw.save";
952
953/// HNSW store loaded from file event.
954///
955/// Emitted when an HNSW store is loaded from disk.
956/// The payload should include `path`, `entry_count`.
957pub const EVENT_KIND_HNSW_LOAD: &str = "hnsw.load";
958
959/// HNSW EML search observation event.
960///
961/// Emitted after each search when EML is enabled. Carries the full
962/// multi-signal payload: ef_used, latency_us, recall (when measured),
963/// query_norm, query_variance, store_size. Used by the 2-head ef
964/// model for joint ef ↔ recall training and by ExoChain for auditable
965/// training provenance.
966pub const EVENT_KIND_HNSW_EML_OBSERVE: &str = "hnsw.eml.observe";
967
968/// HNSW EML recall measurement event.
969///
970/// Emitted when a recall checkpoint is taken (brute-force vs HNSW).
971/// Payload: avg_recall, store_size, inserts_since_rebuild, query_count.
972pub const EVENT_KIND_HNSW_EML_RECALL: &str = "hnsw.eml.recall";
973
974/// HNSW EML model trained event.
975///
976/// Emitted after a training cycle. Payload: train_cycles, models_trained,
977/// ef_trained, rebuild_trained, distance_trained, path_trained.
978pub const EVENT_KIND_HNSW_EML_TRAINED: &str = "hnsw.eml.trained";
979
980/// HNSW EML triage decision event.
981///
982/// Emitted when the tree calculus strategy selector runs. Payload
983/// contains the full TriageRecord: form (Atom/Sequence/Branch),
984/// steepness, concentration, knee, and the chosen SearchStrategy
985/// with tuned parameters. Auditable training provenance.
986pub const EVENT_KIND_HNSW_EML_TRIAGE: &str = "hnsw.eml.triage";
987
988/// Supplies the rule hash stamped on each append (ADR-103 A7).
989pub type RuleHashProvider = std::sync::Arc<dyn Fn() -> Option<[u8; 32]> + Send + Sync>;
990
991/// ExoChainHeader flag: this event segment carries a `rule_hash`.
992const EXO_FLAG_HAS_RULE_HASH: u16 = 0x0001;
993/// Flag bits this build understands; any other bit means the segment was
994/// written by a newer binary.
995const EXO_FLAGS_KNOWN: u16 = EXO_FLAG_HAS_RULE_HASH;
996
997/// Check a segment's flags against what was decoded from its payload.
998fn check_exo_flags(flags: u16, has_rule_hash: bool) -> Result<(), String> {
999    if flags & !EXO_FLAGS_KNOWN != 0 {
1000        return Err(format!(
1001            "segment flags {flags:#06x} carry bits this build does not know: \
1002             written by a newer binary"
1003        ));
1004    }
1005    if (flags & EXO_FLAG_HAS_RULE_HASH != 0) != has_rule_hash {
1006        return Err("segment rule_hash flag disagrees with its payload".into());
1007    }
1008    Ok(())
1009}
1010
1011/// Local chain state.
1012struct LocalChain {
1013    chain_id: u32,
1014    events: Vec<ChainEvent>,
1015    last_hash: [u8; 32],
1016    sequence: u64,
1017    checkpoint_interval: u64,
1018    events_since_checkpoint: u64,
1019    checkpoints: Vec<ChainCheckpoint>,
1020    /// Witness entries — one per event for cryptographic audit trail.
1021    witness_entries: Vec<WitnessEntry>,
1022    /// Stamps `rule_hash` on every append (ADR-103 A7). Called under the
1023    /// chain lock: it must not call back into the chain.
1024    rule_hash_provider: Option<RuleHashProvider>,
1025    /// Oldest replay a subscriber may ask for, in events behind the head.
1026    max_replay_window: u64,
1027    /// Live fan-out for [`ChainManager::subscribe`].
1028    hub: crate::chain_subscribe::EventHub,
1029}
1030
1031impl LocalChain {
1032    fn new(chain_id: u32, checkpoint_interval: u64) -> Self {
1033        Self {
1034            chain_id,
1035            events: Vec::new(),
1036            last_hash: [0u8; 32],
1037            sequence: 0,
1038            checkpoint_interval,
1039            events_since_checkpoint: 0,
1040            checkpoints: Vec::new(),
1041            witness_entries: Vec::new(),
1042            rule_hash_provider: None,
1043            max_replay_window: crate::chain_subscribe::DEFAULT_MAX_REPLAY_WINDOW,
1044            hub: Default::default(),
1045        }
1046    }
1047
1048    /// Restore from a saved set of events and optional witness entries.
1049    fn from_events(
1050        chain_id: u32,
1051        checkpoint_interval: u64,
1052        events: Vec<ChainEvent>,
1053        witness_entries: Vec<WitnessEntry>,
1054    ) -> Self {
1055        let (last_hash, sequence) = if let Some(last) = events.last() {
1056            (last.hash, last.sequence + 1)
1057        } else {
1058            ([0u8; 32], 0)
1059        };
1060        Self {
1061            chain_id,
1062            events,
1063            last_hash,
1064            sequence,
1065            checkpoint_interval,
1066            events_since_checkpoint: 0,
1067            checkpoints: Vec::new(),
1068            witness_entries,
1069            rule_hash_provider: None,
1070            max_replay_window: crate::chain_subscribe::DEFAULT_MAX_REPLAY_WINDOW,
1071            hub: Default::default(),
1072        }
1073    }
1074
1075    /// Ask the provider for the current rule hash. Runs under the chain
1076    /// lock (lock order: chain first, then whatever the provider touches),
1077    /// so a panicking provider is contained: it must not unwind through the
1078    /// held mutex and poison the chain. Only effective where panics unwind:
1079    /// `[profile.release]` sets `panic = "abort"`, so in release a provider
1080    /// panic aborts the process and the provider must simply not panic.
1081    fn stamp_rule_hash(&self) -> Option<[u8; 32]> {
1082        let p = self.rule_hash_provider.as_ref()?;
1083        match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| p())) {
1084            Ok(h) => h,
1085            Err(_) => {
1086                warn!("rule_hash provider panicked; appending without rule_hash");
1087                None
1088            }
1089        }
1090    }
1091
1092    fn append(
1093        &mut self,
1094        source: String,
1095        kind: String,
1096        payload: Option<serde_json::Value>,
1097    ) -> &ChainEvent {
1098        self.append_with_key(source, kind, payload, None)
1099    }
1100
1101    /// Append carrying an optional idempotency key (WEFT-103).
1102    ///
1103    /// The key is *not* mixed into the SHAKE-256 event hash so that
1104    /// chain commitments remain stable for replicas that never see
1105    /// the key. Replay protection is enforced one layer up in
1106    /// [`ChainManager::append_idempotent`].
1107    fn append_with_key(
1108        &mut self,
1109        source: String,
1110        kind: String,
1111        payload: Option<serde_json::Value>,
1112        idempotency_key: Option<[u8; 32]>,
1113    ) -> &ChainEvent {
1114        let timestamp = Utc::now();
1115        let payload_hash = compute_payload_hash(&payload);
1116        let rule_hash = self.stamp_rule_hash();
1117        let hash = compute_event_hash(
1118            self.sequence,
1119            self.chain_id,
1120            &self.last_hash,
1121            &source,
1122            &kind,
1123            &timestamp,
1124            &payload_hash,
1125            rule_hash.as_ref(),
1126        );
1127
1128        let event = ChainEvent {
1129            sequence: self.sequence,
1130            chain_id: self.chain_id,
1131            timestamp,
1132            prev_hash: self.last_hash,
1133            hash,
1134            payload_hash,
1135            source,
1136            kind,
1137            payload,
1138            idempotency_key,
1139            rule_hash,
1140        };
1141
1142        // Create a witness entry for this event.
1143        self.witness_entries.push(WitnessEntry {
1144            prev_hash: [0u8; 32], // linked at serialization time
1145            action_hash: hash,
1146            timestamp_ns: timestamp.timestamp_nanos_opt().unwrap_or(0) as u64,
1147            witness_type: WITNESS_PROVENANCE,
1148        });
1149
1150        self.last_hash = hash;
1151        self.sequence += 1;
1152        self.events_since_checkpoint += 1;
1153        self.hub.publish(&event);
1154        self.events.push(event);
1155
1156        // Auto-checkpoint
1157        if self.checkpoint_interval > 0 && self.events_since_checkpoint >= self.checkpoint_interval
1158        {
1159            self.create_checkpoint();
1160        }
1161
1162        self.events.last().unwrap()
1163    }
1164
1165    fn create_checkpoint(&mut self) -> ChainCheckpoint {
1166        let cp = ChainCheckpoint {
1167            chain_id: self.chain_id,
1168            sequence: self.sequence.saturating_sub(1),
1169            last_hash: self.last_hash,
1170            timestamp: Utc::now(),
1171            events_since_last: self.events_since_checkpoint,
1172        };
1173        self.events_since_checkpoint = 0;
1174        self.checkpoints.push(cp.clone());
1175        cp
1176    }
1177}
1178
1179/// CBOR payload structure for RVF segment persistence.
1180///
1181/// Contains the per-event fields that are not already covered by the
1182/// ExoChainHeader (which stores sequence, chain_id, timestamp, prev_hash).
1183#[derive(Serialize, Deserialize)]
1184struct RvfChainPayload {
1185    source: String,
1186    kind: String,
1187    #[serde(default, skip_serializing_if = "Option::is_none")]
1188    payload: Option<serde_json::Value>,
1189    /// Hex-encoded 32-byte payload hash.
1190    payload_hash: String,
1191    /// Hex-encoded 32-byte event hash.
1192    hash: String,
1193    /// Hex-encoded 32-byte rule hash (ADR-103 A7); absent on pre-P2 events.
1194    #[serde(default, skip_serializing_if = "Option::is_none")]
1195    rule_hash: Option<String>,
1196}
1197
1198/// Encode a 32-byte hash as a lowercase hex string.
1199fn hex_hash(h: &[u8; 32]) -> String {
1200    h.iter().map(|b| format!("{b:02x}")).collect()
1201}
1202
1203/// Encode arbitrary bytes as a lowercase hex string.
1204fn hex_encode(data: &[u8]) -> String {
1205    data.iter().map(|b| format!("{b:02x}")).collect()
1206}
1207
1208/// Decode a hex string into a byte vector.
1209fn hex_decode(s: &str) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
1210    if !s.len().is_multiple_of(2) {
1211        return Err("hex string has odd length".into());
1212    }
1213    let mut out = Vec::with_capacity(s.len() / 2);
1214    for chunk in s.as_bytes().chunks(2) {
1215        let hi = hex_nibble(chunk[0])?;
1216        let lo = hex_nibble(chunk[1])?;
1217        out.push((hi << 4) | lo);
1218    }
1219    Ok(out)
1220}
1221
1222/// Parse a 64-char hex string back into a 32-byte array.
1223fn parse_hex_hash(s: &str) -> Result<[u8; 32], Box<dyn std::error::Error + Send + Sync>> {
1224    if s.len() != 64 {
1225        return Err(format!("expected 64 hex chars, got {}", s.len()).into());
1226    }
1227    let mut out = [0u8; 32];
1228    for (i, chunk) in s.as_bytes().chunks(2).enumerate() {
1229        let hi = hex_nibble(chunk[0])?;
1230        let lo = hex_nibble(chunk[1])?;
1231        out[i] = (hi << 4) | lo;
1232    }
1233    Ok(out)
1234}
1235
1236/// Convert a single ASCII hex character to its nibble value.
1237fn hex_nibble(c: u8) -> Result<u8, Box<dyn std::error::Error + Send + Sync>> {
1238    match c {
1239        b'0'..=b'9' => Ok(c - b'0'),
1240        b'a'..=b'f' => Ok(c - b'a' + 10),
1241        b'A'..=b'F' => Ok(c - b'A' + 10),
1242        _ => Err(format!("invalid hex char: {}", c as char).into()),
1243    }
1244}
1245
1246/// Thread-safe chain manager.
1247///
1248/// Wraps a local chain with mutex protection for concurrent access
1249/// from multiple kernel subsystems. Optionally holds an Ed25519
1250/// signing key for cryptographic chain signing.
1251pub struct ChainManager {
1252    inner: Mutex<LocalChain>,
1253    /// Ed25519 signing key for RVF segment signing.
1254    signing_key: Option<SigningKey>,
1255    /// ML-DSA-65 signing key for post-quantum dual signing.
1256    ml_dsa_key: Option<MlDsa65Key>,
1257}
1258
1259impl ChainManager {
1260    /// Create a new chain manager with genesis event.
1261    pub fn new(chain_id: u32, checkpoint_interval: u64) -> Self {
1262        let mut chain = LocalChain::new(chain_id, checkpoint_interval);
1263        // Genesis event
1264        chain.append(
1265            "chain".into(),
1266            "genesis".into(),
1267            Some(serde_json::json!({ "chain_id": chain_id })),
1268        );
1269        debug!(chain_id, "local chain initialized with genesis event");
1270
1271        Self {
1272            inner: Mutex::new(chain),
1273            signing_key: None,
1274            ml_dsa_key: None,
1275        }
1276    }
1277
1278    /// Create with default settings.
1279    pub fn default_local() -> Self {
1280        Self::new(0, 1000)
1281    }
1282
1283    /// Restore a chain manager from a verified event prefix (WEFT-105).
1284    ///
1285    /// Used when a joining node already shares a common genesis / prefix
1286    /// with a peer (same cluster identity). Events must be non-empty and
1287    /// pass integrity verification.
1288    pub fn from_events(
1289        events: Vec<ChainEvent>,
1290        checkpoint_interval: u64,
1291    ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
1292        if events.is_empty() {
1293            return Err("from_events: empty event list".into());
1294        }
1295        let chain_id = events[0].chain_id;
1296        let chain = LocalChain::from_events(chain_id, checkpoint_interval, events, Vec::new());
1297        let mgr = Self {
1298            inner: Mutex::new(chain),
1299            signing_key: None,
1300            ml_dsa_key: None,
1301        };
1302        let result = mgr.verify_integrity();
1303        if !result.valid {
1304            return Err(format!(
1305                "from_events integrity check failed: {} errors ({})",
1306                result.errors.len(),
1307                result.errors.first().cloned().unwrap_or_default()
1308            )
1309            .into());
1310        }
1311        Ok(mgr)
1312    }
1313
1314    /// Attach an Ed25519 signing key for RVF segment signing.
1315    pub fn with_signing_key(mut self, key: SigningKey) -> Self {
1316        self.signing_key = Some(key);
1317        self
1318    }
1319
1320    /// Get the verifying (public) key, if a signing key is set.
1321    pub fn verifying_key(&self) -> Option<VerifyingKey> {
1322        self.signing_key.as_ref().map(|k| k.verifying_key())
1323    }
1324
1325    /// Set the signing key (mutable borrow — use with `Arc::get_mut()`
1326    /// before sharing the manager across tasks).
1327    pub fn set_signing_key(&mut self, key: SigningKey) {
1328        self.signing_key = Some(key);
1329    }
1330
1331    /// Whether this chain manager has a signing key attached.
1332    pub fn has_signing_key(&self) -> bool {
1333        self.signing_key.is_some()
1334    }
1335
1336    /// Clone the Ed25519 signing key (for key rotation, WEFT-107).
1337    pub fn signing_key_clone(&self) -> Option<SigningKey> {
1338        self.signing_key.clone()
1339    }
1340
1341    /// Set the ML-DSA-65 key for post-quantum dual signing.
1342    pub fn set_ml_dsa_key(&mut self, key: MlDsa65Key) {
1343        self.ml_dsa_key = Some(key);
1344    }
1345
1346    /// Whether dual signing (Ed25519 + ML-DSA-65) is enabled.
1347    pub fn has_dual_signing(&self) -> bool {
1348        self.signing_key.is_some() && self.ml_dsa_key.is_some()
1349    }
1350
1351    /// Load an Ed25519 signing key from file, or generate and persist a new one.
1352    pub fn load_or_create_key(
1353        path: &Path,
1354    ) -> Result<SigningKey, Box<dyn std::error::Error + Send + Sync>> {
1355        if path.exists() {
1356            // Warn if key file is world-readable on Unix.
1357            #[cfg(unix)]
1358            {
1359                use std::os::unix::fs::PermissionsExt;
1360                let mode = std::fs::metadata(path)?.permissions().mode();
1361                if mode & 0o077 != 0 {
1362                    warn!(
1363                        path = %path.display(),
1364                        mode = format!("{mode:04o}"),
1365                        "signing key file has overly permissive permissions, fixing to 0600"
1366                    );
1367                    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
1368                }
1369            }
1370            let bytes = std::fs::read(path)?;
1371            if bytes.len() != 32 {
1372                return Err(format!("key file is {} bytes, expected 32", bytes.len()).into());
1373            }
1374            let key_bytes: [u8; 32] = bytes.try_into().map_err(|_| "key file not 32 bytes")?;
1375            let key = SigningKey::from_bytes(&key_bytes);
1376            info!(path = %path.display(), "loaded Ed25519 signing key");
1377            Ok(key)
1378        } else {
1379            use rand::rngs::OsRng;
1380            let key = SigningKey::generate(&mut OsRng);
1381            if let Some(parent) = path.parent() {
1382                std::fs::create_dir_all(parent)?;
1383            }
1384            std::fs::write(path, key.to_bytes())?;
1385            // Restrict key file to owner-only (0600) on Unix to prevent
1386            // other users from reading the private signing key.
1387            #[cfg(unix)]
1388            {
1389                use std::os::unix::fs::PermissionsExt;
1390                std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
1391            }
1392            info!(path = %path.display(), "generated new Ed25519 signing key");
1393            Ok(key)
1394        }
1395    }
1396
1397    /// Append an event to the chain.
1398    pub fn append(
1399        &self,
1400        source: &str,
1401        kind: &str,
1402        payload: Option<serde_json::Value>,
1403    ) -> ChainEvent {
1404        let mut chain = self.inner.lock().unwrap();
1405        chain.append(source.into(), kind.into(), payload).clone()
1406    }
1407
1408    /// Append an event with an optional idempotency key (WEFT-103).
1409    ///
1410    /// When `idempotency_key` is `Some`, the chain is scanned for the
1411    /// most recent [`IDEMPOTENCY_LOOKBACK`] events. If an event with
1412    /// the same key is found, **no append occurs** and the prior
1413    /// event is returned. This protects the chain against:
1414    ///
1415    /// - Retried mesh writes that crossed the network twice.
1416    /// - HTTP-handler retries on connection drops.
1417    /// - Sync-sweep loops that re-emit the same logical operation.
1418    ///
1419    /// When `idempotency_key` is `None`, behaves exactly like
1420    /// [`Self::append`].
1421    ///
1422    /// Concurrency: the lookback scan and the append happen under
1423    /// the same inner mutex, so concurrent callers cannot race past
1424    /// each other to insert duplicates.
1425    pub fn append_idempotent(
1426        &self,
1427        source: &str,
1428        kind: &str,
1429        payload: Option<serde_json::Value>,
1430        idempotency_key: Option<[u8; 32]>,
1431    ) -> ChainEvent {
1432        let mut chain = self.inner.lock().unwrap();
1433
1434        if let Some(ref key) = idempotency_key {
1435            let len = chain.events.len();
1436            let start = len.saturating_sub(IDEMPOTENCY_LOOKBACK);
1437            // Walk newest-first so a hot-cache hit short-circuits early.
1438            for existing in chain.events[start..].iter().rev() {
1439                if existing.idempotency_key.as_ref() == Some(key) {
1440                    debug!(
1441                        sequence = existing.sequence,
1442                        kind = existing.kind,
1443                        "idempotency_key match -- skipping duplicate append"
1444                    );
1445                    return existing.clone();
1446                }
1447            }
1448        }
1449
1450        chain
1451            .append_with_key(source.into(), kind.into(), payload, idempotency_key)
1452            .clone()
1453    }
1454
1455    /// Create a checkpoint.
1456    pub fn checkpoint(&self) -> ChainCheckpoint {
1457        let mut chain = self.inner.lock().unwrap();
1458        chain.create_checkpoint()
1459    }
1460
1461    /// Get the current chain length.
1462    pub fn len(&self) -> usize {
1463        self.inner.lock().unwrap().events.len()
1464    }
1465
1466    /// Check if the chain is empty (should never be after genesis).
1467    pub fn is_empty(&self) -> bool {
1468        self.inner.lock().unwrap().events.is_empty()
1469    }
1470
1471    /// Get the current sequence number.
1472    pub fn sequence(&self) -> u64 {
1473        self.inner.lock().unwrap().sequence
1474    }
1475
1476    /// Get the last hash.
1477    pub fn last_hash(&self) -> [u8; 32] {
1478        self.inner.lock().unwrap().last_hash
1479    }
1480
1481    /// Get the chain ID.
1482    pub fn chain_id(&self) -> u32 {
1483        self.inner.lock().unwrap().chain_id
1484    }
1485
1486    /// Get recent events (last n, or all if n=0).
1487    pub fn tail(&self, n: usize) -> Vec<ChainEvent> {
1488        let chain = self.inner.lock().unwrap();
1489        if n == 0 || n >= chain.events.len() {
1490            chain.events.clone()
1491        } else {
1492            chain.events[chain.events.len() - n..].to_vec()
1493        }
1494    }
1495
1496    /// Install the provider that stamps `rule_hash` on every append
1497    /// (ADR-103 A7).
1498    ///
1499    /// **Lock order: the chain lock is taken first, then the provider
1500    /// runs.** The provider must therefore never block and never call back
1501    /// into this manager: read lock-free state (an atomic, see
1502    /// [`crate::chain_rule_hash::RuleHashCell`]) or use `try_read`.
1503    /// A provider that takes a lock another thread holds while appending
1504    /// would deadlock. In dev/test builds (panic = unwind) a panicking
1505    /// provider is caught: the event is appended without `rule_hash` and a
1506    /// warning is logged. Release builds use `panic = "abort"`, so there a
1507    /// provider panic aborts the process; it must not panic.
1508    pub fn set_rule_hash_provider(&self, provider: RuleHashProvider) {
1509        self.inner.lock().unwrap().rule_hash_provider = Some(provider);
1510    }
1511
1512    /// Bound how far back [`Self::subscribe`] may replay, in events.
1513    pub fn set_max_replay_window(&self, events: u64) {
1514        self.inner.lock().unwrap().max_replay_window = events;
1515    }
1516
1517    /// Subscribe to chain events: optional replay from `filter.from_seq`,
1518    /// then live events, with no gap and no duplicate between the two.
1519    /// See [`crate::chain_subscribe`].
1520    ///
1521    /// Under the chain lock this only subscribes to the live feed and
1522    /// records the snapshot end; the replay is paged later in short lock
1523    /// holds by the subscription, so a deep `from_seq` cannot stall
1524    /// appends or pin memory.
1525    ///
1526    /// # Errors
1527    /// [`SubscribeError::ReplayWindowExceeded`] when `from_seq` is older
1528    /// than the configured replay window.
1529    #[cfg(feature = "native")]
1530    pub fn subscribe(
1531        self: &Arc<Self>,
1532        filter: crate::chain_subscribe::ChainFilter,
1533    ) -> Result<crate::chain_subscribe::ChainSubscription, crate::chain_subscribe::SubscribeError>
1534    {
1535        let chain = self.inner.lock().unwrap();
1536        if let Some(from) = filter.from_seq {
1537            let oldest_allowed = chain.sequence.saturating_sub(chain.max_replay_window);
1538            if from < oldest_allowed {
1539                return Err(crate::chain_subscribe::SubscribeError::ReplayWindowExceeded {
1540                    requested: from,
1541                    oldest_allowed,
1542                });
1543            }
1544        }
1545        let rx = chain.hub.subscribe();
1546        Ok(crate::chain_subscribe::ChainSubscription::new(
1547            filter,
1548            Arc::downgrade(self),
1549            rx,
1550            chain.sequence,
1551        ))
1552    }
1553
1554    /// One replay page: up to `limit` events matching `prefix` with
1555    /// `sequence` in `[from, end)`, scanning at most `scan` retained
1556    /// events. Returns the page and the sequence to resume from.
1557    pub(crate) fn replay_page(
1558        &self,
1559        from: u64,
1560        end: u64,
1561        prefix: Option<&str>,
1562        limit: usize,
1563        scan: usize,
1564    ) -> (Vec<ChainEvent>, u64) {
1565        let chain = self.inner.lock().unwrap();
1566        let start = chain.events.partition_point(|e| e.sequence < from);
1567        let mut out = Vec::new();
1568        let mut next = from;
1569        for (scanned, e) in chain.events[start..].iter().enumerate() {
1570            if e.sequence >= end {
1571                return (out, end);
1572            }
1573            if scanned >= scan || out.len() >= limit {
1574                return (out, next);
1575            }
1576            next = e.sequence + 1;
1577            if prefix.is_none_or(|p| e.kind.starts_with(p)) {
1578                out.push(e.clone());
1579            }
1580        }
1581        (out, end)
1582    }
1583
1584    /// Return events with sequence strictly greater than `after`.
1585    /// Used for incremental replication in K6.4 chain sync.
1586    pub fn tail_from(&self, after: u64) -> Vec<ChainEvent> {
1587        let chain = self.inner.lock().unwrap();
1588        chain
1589            .events
1590            .iter()
1591            .filter(|e| e.sequence > after)
1592            .cloned()
1593            .collect()
1594    }
1595
1596    /// Append a remote (pre-built) chain event after verifying hash linkage
1597    /// (WEFT-105 / K6.4 chain replay).
1598    ///
1599    /// Unlike [`Self::append`], this does **not** re-hash or re-timestamp the
1600    /// event — the remote node's event is accepted as-is when:
1601    ///
1602    /// 1. `event.chain_id` matches the local chain
1603    /// 2. `event.sequence` equals the next expected sequence
1604    /// 3. `event.prev_hash` equals the local tip hash
1605    /// 4. `event.payload_hash` matches a recomputation of the payload
1606    /// 5. `event.hash` matches a recomputation of the event commitment
1607    ///
1608    /// Idempotent: if an event with the same sequence **and** hash already
1609    /// exists, returns [`AppendSignedError::AlreadyPresent`].
1610    ///
1611    /// Fork policy (ADR-089): same sequence with a different hash returns
1612    /// [`AppendSignedError::Fork`] — tips are retained by rejecting silent
1613    /// overwrite; higher-level merge commits are out of scope for linear
1614    /// catch-up replay.
1615    pub fn append_signed(&self, event: ChainEvent) -> Result<ChainEvent, AppendSignedError> {
1616        // First, before any sequence/fork bookkeeping, so a forged authority
1617        // event is refused as such whatever the local chain looks like.
1618        if is_reserved_source(&event.source) || is_reserved_kind(&event.kind) {
1619            return Err(AppendSignedError::ReservedSource { event_source: event.source.clone() });
1620        }
1621        let mut chain = self.inner.lock().unwrap();
1622
1623        // Idempotent: already have this exact event.
1624        if let Some(existing) = chain.events.iter().find(|e| e.sequence == event.sequence) {
1625            if existing.hash == event.hash {
1626                return Err(AppendSignedError::AlreadyPresent {
1627                    sequence: event.sequence,
1628                });
1629            }
1630            return Err(AppendSignedError::Fork {
1631                sequence: event.sequence,
1632                local_hash: existing.hash,
1633                remote_hash: event.hash,
1634            });
1635        }
1636
1637        if event.chain_id != chain.chain_id {
1638            return Err(AppendSignedError::ChainIdMismatch {
1639                local: chain.chain_id,
1640                remote: event.chain_id,
1641            });
1642        }
1643
1644        let expected_seq = chain.sequence;
1645        if event.sequence < expected_seq {
1646            // Behind tip and not an exact match (handled above) → fork or gap
1647            // in history we already advanced past.
1648            return Err(AppendSignedError::Fork {
1649                sequence: event.sequence,
1650                local_hash: chain
1651                    .events
1652                    .iter()
1653                    .find(|e| e.sequence == event.sequence)
1654                    .map(|e| e.hash)
1655                    .unwrap_or([0u8; 32]),
1656                remote_hash: event.hash,
1657            });
1658        }
1659        if event.sequence > expected_seq {
1660            return Err(AppendSignedError::Gap {
1661                expected: expected_seq,
1662                got: event.sequence,
1663            });
1664        }
1665
1666        if event.prev_hash != chain.last_hash {
1667            return Err(AppendSignedError::PrevHashMismatch {
1668                expected: chain.last_hash,
1669                got: event.prev_hash,
1670            });
1671        }
1672
1673        let recomputed_payload = compute_payload_hash(&event.payload);
1674        if event.payload_hash != recomputed_payload {
1675            return Err(AppendSignedError::PayloadHashMismatch {
1676                sequence: event.sequence,
1677            });
1678        }
1679
1680        let recomputed = compute_event_hash(
1681            event.sequence,
1682            event.chain_id,
1683            &event.prev_hash,
1684            &event.source,
1685            &event.kind,
1686            &event.timestamp,
1687            &event.payload_hash,
1688            event.rule_hash.as_ref(),
1689        );
1690        if event.hash != recomputed {
1691            return Err(AppendSignedError::HashMismatch {
1692                sequence: event.sequence,
1693                recomputed,
1694                stored: event.hash,
1695            });
1696        }
1697
1698        // Accept remote event as-is.
1699        chain.witness_entries.push(WitnessEntry {
1700            prev_hash: [0u8; 32], // linked at serialization time
1701            action_hash: event.hash,
1702            timestamp_ns: event.timestamp.timestamp_nanos_opt().unwrap_or(0) as u64,
1703            witness_type: WITNESS_PROVENANCE,
1704        });
1705        chain.last_hash = event.hash;
1706        chain.sequence = event.sequence.saturating_add(1);
1707        chain.events_since_checkpoint = chain.events_since_checkpoint.saturating_add(1);
1708        chain.hub.publish(&event);
1709        chain.events.push(event);
1710        if chain.checkpoint_interval > 0
1711            && chain.events_since_checkpoint >= chain.checkpoint_interval
1712        {
1713            let _ = chain.create_checkpoint();
1714        }
1715
1716        Ok(chain.events.last().cloned().expect("just pushed"))
1717    }
1718
1719    /// Get the current head sequence number (sequence of the last event).
1720    /// Returns 0 when the chain is empty.
1721    pub fn head_sequence(&self) -> u64 {
1722        let chain = self.inner.lock().unwrap();
1723        chain.events.last().map(|e| e.sequence).unwrap_or(0)
1724    }
1725
1726    /// Get the current head hash (hash of the last event).
1727    /// Returns the all-zero hash when the chain is empty.
1728    pub fn head_hash(&self) -> [u8; 32] {
1729        let chain = self.inner.lock().unwrap();
1730        chain.events.last().map(|e| e.hash).unwrap_or([0u8; 32])
1731    }
1732
1733    /// Get all checkpoints.
1734    pub fn checkpoints(&self) -> Vec<ChainCheckpoint> {
1735        self.inner.lock().unwrap().checkpoints.clone()
1736    }
1737
1738    /// Get the number of witness entries.
1739    pub fn witness_count(&self) -> usize {
1740        self.inner.lock().unwrap().witness_entries.len()
1741    }
1742
1743    /// Serialize the witness chain and verify it.
1744    ///
1745    /// Returns `Ok(entry_count)` if the witness chain is valid, or
1746    /// `Err` if verification fails.
1747    pub fn verify_witness(&self) -> Result<usize, Box<dyn std::error::Error + Send + Sync>> {
1748        let chain = self.inner.lock().unwrap();
1749        if chain.witness_entries.is_empty() {
1750            return Ok(0);
1751        }
1752        let data = create_witness_chain(&chain.witness_entries);
1753        let verified = verify_witness_chain(&data)
1754            .map_err(|e| format!("witness chain verification failed: {e}"))?;
1755        Ok(verified.len())
1756    }
1757
1758    /// Generate a custody attestation document.
1759    ///
1760    /// Assembles the current chain state, vector count and content hash,
1761    /// and signs the result with the kernel's Ed25519 key.
1762    ///
1763    /// Returns `None` if no signing key is configured.
1764    pub fn generate_attestation(
1765        &self,
1766        vector_count: u64,
1767        epoch: u64,
1768        content_hash: &str,
1769    ) -> Option<CustodyAttestation> {
1770        use ed25519_dalek::Signer;
1771
1772        let signing_key = self.signing_key.as_ref()?;
1773        let device_id: String = signing_key
1774            .verifying_key()
1775            .to_bytes()
1776            .iter()
1777            .map(|b| format!("{b:02x}"))
1778            .collect();
1779
1780        let chain = self.inner.lock().unwrap();
1781        let chain_head: String = chain.last_hash.iter().map(|b| format!("{b:02x}")).collect();
1782        let chain_depth = chain.events.len() as u64;
1783        drop(chain);
1784
1785        let timestamp = std::time::SystemTime::now()
1786            .duration_since(std::time::UNIX_EPOCH)
1787            .unwrap_or_default()
1788            .as_secs();
1789
1790        // Canonical payload for signing: deterministic field order
1791        let canonical = format!(
1792            "custody-attestation:v1\n\
1793             device_id:{device_id}\n\
1794             epoch:{epoch}\n\
1795             chain_head:{chain_head}\n\
1796             chain_depth:{chain_depth}\n\
1797             vector_count:{vector_count}\n\
1798             content_hash:{content_hash}\n\
1799             timestamp:{timestamp}"
1800        );
1801
1802        let signature = signing_key.sign(canonical.as_bytes());
1803
1804        Some(CustodyAttestation {
1805            device_id,
1806            epoch,
1807            chain_head,
1808            chain_depth,
1809            vector_count,
1810            content_hash: content_hash.to_owned(),
1811            timestamp,
1812            signature: signature.to_bytes().to_vec(),
1813        })
1814    }
1815
1816    /// Verify the integrity of the entire chain.
1817    ///
1818    /// Walks all events and verifies:
1819    /// 1. Each event's `prev_hash` matches the prior event's `hash`
1820    /// 2. Each event's `payload_hash` matches the recomputed payload hash
1821    /// 3. Each event's `hash` matches the recomputed event hash
1822    pub fn verify_integrity(&self) -> ChainVerifyResult {
1823        let chain = self.inner.lock().unwrap();
1824        let mut errors = Vec::new();
1825
1826        for (i, event) in chain.events.iter().enumerate() {
1827            // 1. Verify prev_hash linkage
1828            let expected_prev = if i == 0 {
1829                [0u8; 32]
1830            } else {
1831                chain.events[i - 1].hash
1832            };
1833            if event.prev_hash != expected_prev {
1834                errors.push(format!(
1835                    "seq {}: prev_hash mismatch (expected {:02x}{:02x}..., got {:02x}{:02x}...)",
1836                    event.sequence,
1837                    expected_prev[0],
1838                    expected_prev[1],
1839                    event.prev_hash[0],
1840                    event.prev_hash[1],
1841                ));
1842            }
1843
1844            // 2. Verify payload_hash
1845            let recomputed_payload = compute_payload_hash(&event.payload);
1846            if event.payload_hash != recomputed_payload {
1847                errors.push(format!(
1848                    "seq {}: payload_hash mismatch (recomputed {:02x}{:02x}..., stored {:02x}{:02x}...)",
1849                    event.sequence,
1850                    recomputed_payload[0], recomputed_payload[1],
1851                    event.payload_hash[0], event.payload_hash[1],
1852                ));
1853            }
1854
1855            // 3. Recompute and verify event hash
1856            let recomputed = compute_event_hash(
1857                event.sequence,
1858                event.chain_id,
1859                &event.prev_hash,
1860                &event.source,
1861                &event.kind,
1862                &event.timestamp,
1863                &event.payload_hash,
1864                event.rule_hash.as_ref(),
1865            );
1866            if event.hash != recomputed {
1867                errors.push(format!(
1868                    "seq {}: hash mismatch (recomputed {:02x}{:02x}..., stored {:02x}{:02x}...)",
1869                    event.sequence, recomputed[0], recomputed[1], event.hash[0], event.hash[1],
1870                ));
1871            }
1872        }
1873
1874        ChainVerifyResult {
1875            valid: errors.is_empty(),
1876            event_count: chain.events.len(),
1877            errors,
1878            signature_verified: None,
1879        }
1880    }
1881
1882    /// Save the chain to a file (line-delimited JSON).
1883    ///
1884    /// Writes all events as newline-delimited JSON to the given path.
1885    /// Creates parent directories if they don't exist.
1886    pub fn save_to_file(
1887        &self,
1888        path: &Path,
1889    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1890        let chain = self.inner.lock().map_err(|e| format!("lock: {e}"))?;
1891
1892        if let Some(parent) = path.parent() {
1893            std::fs::create_dir_all(parent)?;
1894        }
1895
1896        let mut output = String::new();
1897        for event in &chain.events {
1898            let line = serde_json::to_string(event)?;
1899            output.push_str(&line);
1900            output.push('\n');
1901        }
1902
1903        std::fs::write(path, output)?;
1904        info!(
1905            path = %path.display(),
1906            events = chain.events.len(),
1907            sequence = chain.sequence,
1908            "chain saved to file"
1909        );
1910        Ok(())
1911    }
1912
1913    /// Load a chain from a file (line-delimited JSON).
1914    ///
1915    /// Reads events, verifies integrity, and restores state so that
1916    /// new events continue from the last sequence number.
1917    pub fn load_from_file(
1918        path: &Path,
1919        checkpoint_interval: u64,
1920    ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
1921        let contents = std::fs::read_to_string(path)?;
1922        let mut events = Vec::new();
1923
1924        for line in contents.lines() {
1925            let trimmed = line.trim();
1926            if trimmed.is_empty() {
1927                continue;
1928            }
1929            let event: ChainEvent = serde_json::from_str(trimmed)?;
1930            events.push(event);
1931        }
1932
1933        if events.is_empty() {
1934            return Err("chain file is empty (no events)".into());
1935        }
1936
1937        let chain_id = events[0].chain_id;
1938        let chain = LocalChain::from_events(chain_id, checkpoint_interval, events, Vec::new());
1939
1940        let mgr = Self {
1941            inner: Mutex::new(chain),
1942            signing_key: None,
1943            ml_dsa_key: None,
1944        };
1945
1946        // Verify integrity of the loaded chain
1947        let result = mgr.verify_integrity();
1948        if !result.valid {
1949            warn!(
1950                errors = result.errors.len(),
1951                "loaded chain has integrity errors"
1952            );
1953            return Err(format!(
1954                "chain integrity check failed: {} errors",
1955                result.errors.len()
1956            )
1957            .into());
1958        }
1959
1960        info!(
1961            path = %path.display(),
1962            events = result.event_count,
1963            chain_id,
1964            "chain restored from file"
1965        );
1966        Ok(mgr)
1967    }
1968
1969    /// Save the chain as a concatenation of RVF segments.
1970    ///
1971    /// Each event is serialized as an ExochainEvent segment (subtype 0x40)
1972    /// containing a 64-byte ExoChainHeader + CBOR payload. A trailing
1973    /// ExochainCheckpoint segment (subtype 0x41) records the final chain
1974    /// state for external verification.
1975    pub fn save_to_rvf(&self, path: &Path) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1976        let chain = self.inner.lock().map_err(|e| format!("lock: {e}"))?;
1977
1978        if let Some(parent) = path.parent() {
1979            std::fs::create_dir_all(parent)?;
1980        }
1981
1982        let mut output = Vec::new();
1983
1984        for event in &chain.events {
1985            // Build the ExoChainHeader from event fields.
1986            let exo_header = ExoChainHeader {
1987                magic: EXOCHAIN_MAGIC,
1988                version: 1,
1989                subtype: 0x40, // ExochainEvent
1990                flags: if event.rule_hash.is_some() { EXO_FLAG_HAS_RULE_HASH } else { 0 },
1991                chain_id: event.chain_id,
1992                _reserved: 0,
1993                sequence: event.sequence,
1994                timestamp_secs: event.timestamp.timestamp() as u64,
1995                prev_hash: event.prev_hash,
1996            };
1997
1998            // Serialize the remaining fields as CBOR.
1999            let rvf_payload = RvfChainPayload {
2000                source: event.source.clone(),
2001                kind: event.kind.clone(),
2002                payload: event.payload.clone(),
2003                payload_hash: hex_hash(&event.payload_hash),
2004                hash: hex_hash(&event.hash),
2005                rule_hash: event.rule_hash.as_ref().map(hex_hash),
2006            };
2007
2008            let mut cbor_bytes = Vec::new();
2009            ciborium::into_writer(&rvf_payload, &mut cbor_bytes)
2010                .map_err(|e| format!("cbor encode: {e}"))?;
2011
2012            // Write the full RVF segment (header + exo header + cbor + padding).
2013            let segment = write_exochain_event(&exo_header, &cbor_bytes, event.sequence);
2014            output.extend_from_slice(&segment);
2015        }
2016
2017        // Write a trailing checkpoint segment (subtype 0x41).
2018        let checkpoint_header = ExoChainHeader {
2019            magic: EXOCHAIN_MAGIC,
2020            version: 1,
2021            subtype: 0x41, // ExochainCheckpoint
2022            flags: 0,
2023            chain_id: chain.chain_id,
2024            _reserved: 0,
2025            sequence: chain.sequence.saturating_sub(1),
2026            timestamp_secs: Utc::now().timestamp() as u64,
2027            prev_hash: chain.last_hash,
2028        };
2029
2030        // Serialize and include the witness chain in the checkpoint.
2031        let witness_hex = if !chain.witness_entries.is_empty() {
2032            let wc_data = create_witness_chain(&chain.witness_entries);
2033            Some(hex_encode(&wc_data))
2034        } else {
2035            None
2036        };
2037
2038        let cp_payload = serde_json::json!({
2039            "event_count": chain.events.len(),
2040            "last_hash": hex_hash(&chain.last_hash),
2041            "witness_chain": witness_hex,
2042            "witness_entries": chain.witness_entries.len(),
2043        });
2044        let mut cp_cbor = Vec::new();
2045        ciborium::into_writer(&cp_payload, &mut cp_cbor)
2046            .map_err(|e| format!("cbor encode checkpoint: {e}"))?;
2047
2048        let cp_segment = write_exochain_event(
2049            &checkpoint_header,
2050            &cp_cbor,
2051            chain.sequence, // use next sequence as segment_id
2052        );
2053        output.extend_from_slice(&cp_segment);
2054
2055        // Sign the checkpoint segment if a signing key is available.
2056        let signed = if let Some(ref signing_key) = self.signing_key {
2057            let (cp_seg_header, cp_seg_payload) = read_segment(&cp_segment)
2058                .map_err(|e| format!("re-read checkpoint for signing: {e}"))?;
2059            let footer = sign_segment(&cp_seg_header, cp_seg_payload, signing_key);
2060            let footer_bytes = encode_signature_footer(&footer);
2061            output.extend_from_slice(&footer_bytes);
2062
2063            // Dual-sign with ML-DSA-65 if the key is available.
2064            if let Some(ref ml_key) = self.ml_dsa_key {
2065                let ml_footer = sign_segment_ml_dsa(&cp_seg_header, cp_seg_payload, ml_key);
2066                let ml_footer_bytes = encode_signature_footer(&ml_footer);
2067                output.extend_from_slice(&ml_footer_bytes);
2068            }
2069
2070            true
2071        } else {
2072            false
2073        };
2074        let dual_signed = signed && self.ml_dsa_key.is_some();
2075
2076        std::fs::write(path, &output)?;
2077        info!(
2078            path = %path.display(),
2079            events = chain.events.len(),
2080            bytes = output.len(),
2081            signed,
2082            dual_signed,
2083            "chain saved to RVF file"
2084        );
2085        Ok(())
2086    }
2087
2088    /// Load a chain from an RVF segment file.
2089    ///
2090    /// Reads concatenated RVF segments, validates each segment's content
2091    /// hash, decodes ExoChainHeader + CBOR payload, and reconstructs the
2092    /// chain events. Checkpoint segments (subtype 0x41) are skipped.
2093    pub fn load_from_rvf(
2094        path: &Path,
2095        checkpoint_interval: u64,
2096    ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
2097        let data = std::fs::read(path)?;
2098        let mut offset = 0;
2099        let mut events = Vec::new();
2100        let mut witness_entries = Vec::new();
2101
2102        while offset < data.len() {
2103            // Need at least a segment header.
2104            if data.len() - offset < SEGMENT_HEADER_SIZE {
2105                break;
2106            }
2107
2108            // Try reading the next segment. If it fails, the remaining
2109            // bytes may be a signature footer — break out of the loop.
2110            let (seg_header, seg_payload) = match read_segment(&data[offset..]) {
2111                Ok(result) => result,
2112                Err(_) => break,
2113            };
2114
2115            // Validate the content hash.
2116            validate_segment(&seg_header, seg_payload)
2117                .map_err(|e| format!("validate segment at offset {offset}: {e}"))?;
2118
2119            // Decode the ExoChainHeader + CBOR from the segment payload.
2120            let (exo_header, cbor_bytes) = decode_exochain_payload(seg_payload)
2121                .ok_or_else(|| format!("decode exochain payload at offset {offset}"))?;
2122
2123            if exo_header.subtype == 0x40 {
2124                // ExochainEvent -- deserialize the CBOR payload.
2125                let rvf_payload: RvfChainPayload = ciborium::from_reader(cbor_bytes)
2126                    .map_err(|e| format!("cbor decode at offset {offset}: {e}"))?;
2127
2128                let payload_hash = parse_hex_hash(&rvf_payload.payload_hash)?;
2129                let hash = parse_hex_hash(&rvf_payload.hash)?;
2130                let rule_hash = rvf_payload
2131                    .rule_hash
2132                    .as_deref()
2133                    .map(parse_hex_hash)
2134                    .transpose()?;
2135
2136                check_exo_flags(exo_header.flags, rule_hash.is_some())
2137                    .map_err(|e| format!("segment at offset {offset}: {e}"))?;
2138
2139                let timestamp = DateTime::from_timestamp(exo_header.timestamp_secs as i64, 0)
2140                    .ok_or_else(|| {
2141                        format!(
2142                            "invalid timestamp {} at offset {offset}",
2143                            exo_header.timestamp_secs
2144                        )
2145                    })?;
2146
2147                events.push(ChainEvent {
2148                    sequence: exo_header.sequence,
2149                    chain_id: exo_header.chain_id,
2150                    timestamp,
2151                    prev_hash: exo_header.prev_hash,
2152                    hash,
2153                    payload_hash,
2154                    source: rvf_payload.source,
2155                    kind: rvf_payload.kind,
2156                    payload: rvf_payload.payload,
2157                    // Idempotency keys are an in-memory dedup hint;
2158                    // they are not persisted to RVF segments
2159                    // (preserves chain-hash stability across restores).
2160                    idempotency_key: None,
2161                    rule_hash,
2162                });
2163            } else if exo_header.subtype == 0x41 {
2164                // Checkpoint — extract witness chain if present.
2165                let cp_obj: serde_json::Value =
2166                    ciborium::from_reader(cbor_bytes).unwrap_or_default();
2167                if let Some(wc_hex) = cp_obj.get("witness_chain").and_then(|v| v.as_str())
2168                    && let Ok(wc_bytes) = hex_decode(wc_hex)
2169                {
2170                    match verify_witness_chain(&wc_bytes) {
2171                        Ok(entries) => {
2172                            witness_entries = entries;
2173                            debug!(
2174                                count = witness_entries.len(),
2175                                "restored witness chain from checkpoint"
2176                            );
2177                        }
2178                        Err(e) => {
2179                            warn!("witness chain verification failed on load: {e}");
2180                        }
2181                    }
2182                }
2183            }
2184            // subtype 0x42 (Proof) is skipped.
2185
2186            // Advance past the segment: header + payload padded to 64 bytes.
2187            let padded =
2188                calculate_padded_size(SEGMENT_HEADER_SIZE, seg_header.payload_length as usize);
2189            offset += padded;
2190        }
2191
2192        // Check for trailing signature footer(s).
2193        // There may be one (Ed25519) or two (Ed25519 + ML-DSA-65) footers.
2194        let mut has_signature = false;
2195        let mut has_dual_signature = false;
2196        if offset < data.len()
2197            && let Ok(first_footer) = decode_signature_footer(&data[offset..])
2198        {
2199            has_signature = true;
2200            let first_footer_size = first_footer.footer_length as usize;
2201            let next_offset = offset + first_footer_size;
2202            if next_offset < data.len() && decode_signature_footer(&data[next_offset..]).is_ok() {
2203                has_dual_signature = true;
2204            }
2205        }
2206
2207        if events.is_empty() {
2208            return Err("RVF file contains no chain events".into());
2209        }
2210
2211        let chain_id = events[0].chain_id;
2212        let chain = LocalChain::from_events(chain_id, checkpoint_interval, events, witness_entries);
2213
2214        let mgr = Self {
2215            inner: Mutex::new(chain),
2216            signing_key: None,
2217            ml_dsa_key: None,
2218        };
2219
2220        // Verify integrity of the loaded chain.
2221        let result = mgr.verify_integrity();
2222        if !result.valid {
2223            warn!(
2224                errors = result.errors.len(),
2225                "loaded RVF chain has integrity errors"
2226            );
2227            return Err(format!(
2228                "RVF chain integrity check failed: {} errors",
2229                result.errors.len()
2230            )
2231            .into());
2232        }
2233
2234        info!(
2235            path = %path.display(),
2236            events = result.event_count,
2237            chain_id,
2238            has_signature,
2239            has_dual_signature,
2240            "chain restored from RVF file"
2241        );
2242        Ok(mgr)
2243    }
2244
2245    // ── Lineage tracking ─────────────────────────────────────────
2246    //
2247    // DNA-style provenance for agent spawn and resource derivation.
2248    // Uses rvf-crypto's lineage module to create verifiable derivation
2249    // records that link parent → child with hash verification.
2250
2251    /// Record a lineage derivation event in the chain.
2252    ///
2253    /// Creates a `LineageRecord` from the given parameters, serializes it,
2254    /// adds a lineage witness entry, and appends a `lineage.derivation`
2255    /// chain event with the full record in the payload.
2256    ///
2257    /// - `child_id`: UUID of the derived entity (agent, resource)
2258    /// - `parent_id`: UUID of the parent entity (zero for root)
2259    /// - `parent_hash`: hash of the parent's state at derivation time
2260    /// - `derivation_type`: how the child was produced
2261    /// - `mutation_count`: number of mutations/changes applied
2262    /// - `description`: human-readable description (max 47 chars)
2263    pub fn record_lineage(
2264        &self,
2265        child_id: [u8; 16],
2266        parent_id: [u8; 16],
2267        parent_hash: [u8; 32],
2268        derivation_type: rvf_types::DerivationType,
2269        mutation_count: u32,
2270        description: &str,
2271    ) -> ChainEvent {
2272        let timestamp_ns = chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0) as u64;
2273
2274        let record = rvf_types::LineageRecord::new(
2275            child_id,
2276            parent_id,
2277            parent_hash,
2278            derivation_type,
2279            mutation_count,
2280            timestamp_ns,
2281            description,
2282        );
2283
2284        // Serialize the record and create a witness entry.
2285        let record_bytes = lineage_record_to_bytes(&record);
2286
2287        // Add a lineage witness entry to the witness chain.
2288        {
2289            let mut chain = self.inner.lock().unwrap();
2290            let prev_hash = if let Some(last) = chain.witness_entries.last() {
2291                last.action_hash
2292            } else {
2293                [0u8; 32]
2294            };
2295            let witness = lineage_witness_entry(&record, prev_hash);
2296            chain.witness_entries.push(witness);
2297        }
2298
2299        let payload = serde_json::json!({
2300            "child_id": hex_encode(&child_id),
2301            "parent_id": hex_encode(&parent_id),
2302            "parent_hash": hex_hash(&parent_hash),
2303            "derivation_type": derivation_type as u8,
2304            "mutation_count": mutation_count,
2305            "description": description,
2306            "record_hex": hex_encode(&record_bytes),
2307        });
2308
2309        self.append("lineage", "lineage.derivation", Some(payload))
2310    }
2311
2312    /// Extract lineage records from chain events and verify the chain.
2313    ///
2314    /// Returns `Ok(count)` if all lineage records form a valid chain,
2315    /// or `Err` describing the verification failure.
2316    pub fn verify_lineage(&self) -> Result<usize, Box<dyn std::error::Error + Send + Sync>> {
2317        let events = self.tail(0);
2318        let mut identities: Vec<(rvf_types::FileIdentity, [u8; 32])> = Vec::new();
2319
2320        for event in &events {
2321            if event.kind != "lineage.derivation" {
2322                continue;
2323            }
2324            let Some(ref payload) = event.payload else {
2325                continue;
2326            };
2327
2328            let child_id_hex = payload
2329                .get("child_id")
2330                .and_then(|v| v.as_str())
2331                .unwrap_or("");
2332            let parent_id_hex = payload
2333                .get("parent_id")
2334                .and_then(|v| v.as_str())
2335                .unwrap_or("");
2336            let parent_hash_hex = payload
2337                .get("parent_hash")
2338                .and_then(|v| v.as_str())
2339                .unwrap_or("");
2340
2341            let Ok(child_bytes) = hex_decode(child_id_hex) else {
2342                continue;
2343            };
2344            let Ok(parent_bytes) = hex_decode(parent_id_hex) else {
2345                continue;
2346            };
2347            let Ok(parent_hash) = parse_hex_hash(parent_hash_hex) else {
2348                continue;
2349            };
2350
2351            if child_bytes.len() != 16 || parent_bytes.len() != 16 {
2352                continue;
2353            }
2354
2355            let mut child_id = [0u8; 16];
2356            child_id.copy_from_slice(&child_bytes);
2357            let mut parent_id = [0u8; 16];
2358            parent_id.copy_from_slice(&parent_bytes);
2359
2360            let depth = identities
2361                .iter()
2362                .filter(|(fi, _)| fi.file_id == parent_id)
2363                .map(|(fi, _)| fi.lineage_depth + 1)
2364                .next()
2365                .unwrap_or(0);
2366
2367            let fi = rvf_types::FileIdentity {
2368                file_id: child_id,
2369                parent_id,
2370                parent_hash,
2371                lineage_depth: depth,
2372            };
2373
2374            // Use the event hash as the manifest hash for this identity.
2375            identities.push((fi, event.hash));
2376        }
2377
2378        if identities.is_empty() {
2379            return Ok(0);
2380        }
2381
2382        // Lineage chain verification requires root → leaf ordering.
2383        // Our records are already in append order, so roots come first.
2384        // verify_lineage_chain expects consecutive parent→child pairs,
2385        // but our records may be from different lineage trees. Verify
2386        // each parent→child pair independently.
2387        let count = identities.len();
2388        for (fi, _hash) in &identities {
2389            if fi.is_root() {
2390                continue;
2391            }
2392            // Find the parent in identities.
2393            let parent_found = identities
2394                .iter()
2395                .any(|(pfi, _)| pfi.file_id == fi.parent_id);
2396            if !parent_found && fi.parent_id != [0u8; 16] {
2397                return Err(format!(
2398                    "lineage record for {} references unknown parent {}",
2399                    hex_encode(&fi.file_id),
2400                    hex_encode(&fi.parent_id),
2401                )
2402                .into());
2403            }
2404        }
2405
2406        Ok(count)
2407    }
2408
2409    // ── Witness bundle integration ─────────────────────────────
2410    //
2411    // RVF witness bundles are the atomic proof unit for agent task
2412    // execution. These methods bridge the gap between the kernel's
2413    // event chain and the rvf-runtime WitnessBuilder.
2414
2415    /// Record a completed witness bundle as a chain event.
2416    ///
2417    /// Takes the raw bundle bytes and parsed header produced by
2418    /// `WitnessBuilder::build()`, stores them as a `witness.bundle`
2419    /// chain event with the bundle hex-encoded in the payload.
2420    pub fn record_witness_bundle(
2421        &self,
2422        bundle_bytes: &[u8],
2423        header: &rvf_types::witness::WitnessHeader,
2424        policy_violations: u32,
2425        rollback_count: u32,
2426    ) -> ChainEvent {
2427        let payload = serde_json::json!({
2428            "task_id": hex_encode(&header.task_id),
2429            "outcome": header.outcome,
2430            "governance_mode": header.governance_mode,
2431            "tool_call_count": header.tool_call_count,
2432            "total_cost_microdollars": header.total_cost_microdollars,
2433            "total_latency_ms": header.total_latency_ms,
2434            "total_tokens": header.total_tokens,
2435            "bundle_size": bundle_bytes.len(),
2436            "policy_violations": policy_violations,
2437            "rollback_count": rollback_count,
2438            "bundle": hex_encode(bundle_bytes),
2439        });
2440        self.append("witness", "witness.bundle", Some(payload))
2441    }
2442
2443    /// Aggregate witness bundles from recent chain events into a scorecard.
2444    ///
2445    /// Scans the last `n` events (0 = all) for `witness.bundle` events,
2446    /// parses each bundle, and produces an aggregate `Scorecard`.
2447    pub fn aggregate_scorecard(&self, n: usize) -> rvf_types::witness::Scorecard {
2448        let events = self.tail(n);
2449        let mut builder = rvf_runtime::ScorecardBuilder::new();
2450
2451        for event in &events {
2452            if event.kind != "witness.bundle" {
2453                continue;
2454            }
2455            let Some(ref payload) = event.payload else {
2456                continue;
2457            };
2458            let Some(hex_str) = payload.get("bundle").and_then(|v| v.as_str()) else {
2459                continue;
2460            };
2461            let Ok(bytes) = hex_decode(hex_str) else {
2462                continue;
2463            };
2464            let Ok(parsed) = rvf_runtime::ParsedWitness::parse(&bytes) else {
2465                continue;
2466            };
2467            let violations = payload
2468                .get("policy_violations")
2469                .and_then(|v| v.as_u64())
2470                .unwrap_or(0) as u32;
2471            let rollbacks = payload
2472                .get("rollback_count")
2473                .and_then(|v| v.as_u64())
2474                .unwrap_or(0) as u32;
2475            builder.add_witness(&parsed, violations, rollbacks);
2476        }
2477
2478        builder.finish()
2479    }
2480
2481    /// Find the most recent tree root hash recorded in chain events.
2482    ///
2483    /// Scans backwards through the event log looking for payloads
2484    /// that contain `tree_root_hash` (shutdown, boot.ready, boot.manifest)
2485    /// or `root_hash` on tree-sourced events (tree.checkpoint, checkpoint).
2486    ///
2487    /// Returns the hash as a hex string if found.
2488    pub fn last_tree_root_hash(&self) -> Option<String> {
2489        let chain = self.inner.lock().unwrap();
2490        for event in chain.events.iter().rev() {
2491            let Some(ref payload) = event.payload else {
2492                continue;
2493            };
2494            // Shutdown/boot events record "tree_root_hash" directly.
2495            if let Some(hash) = payload.get("tree_root_hash").and_then(|v| v.as_str()) {
2496                return Some(hash.to_string());
2497            }
2498            // Tree events record "root_hash".
2499            if event.source == "tree"
2500                && matches!(event.kind.as_str(), "tree.checkpoint" | "checkpoint")
2501                && let Some(hash) = payload.get("root_hash").and_then(|v| v.as_str())
2502            {
2503                return Some(hash.to_string());
2504            }
2505        }
2506        None
2507    }
2508
2509    /// Get a status summary.
2510    pub fn status(&self) -> ChainStatus {
2511        let chain = self.inner.lock().unwrap();
2512        ChainStatus {
2513            chain_id: chain.chain_id,
2514            sequence: chain.sequence,
2515            last_hash: chain.last_hash,
2516            event_count: chain.events.len(),
2517            checkpoint_count: chain.checkpoints.len(),
2518            events_since_checkpoint: chain.events_since_checkpoint,
2519        }
2520    }
2521
2522    /// Verify the Ed25519 signature on an RVF chain file.
2523    ///
2524    /// Reads the file, locates the checkpoint segment and trailing
2525    /// signature footer, and verifies the signature against the
2526    /// provided public key.
2527    ///
2528    /// Returns `Ok(true)` if signature is valid, `Ok(false)` if
2529    /// invalid, and `Err` if the file has no signature or can't be read.
2530    pub fn verify_rvf_signature(
2531        path: &Path,
2532        verifying_key: &VerifyingKey,
2533    ) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
2534        let data = std::fs::read(path)?;
2535        let mut offset = 0;
2536        let mut last_seg_start = 0;
2537
2538        // Walk all segments to find the last one (checkpoint).
2539        while offset < data.len() {
2540            if data.len() - offset < SEGMENT_HEADER_SIZE {
2541                break;
2542            }
2543            let (seg_header, _seg_payload) = match read_segment(&data[offset..]) {
2544                Ok(result) => result,
2545                Err(_) => break,
2546            };
2547            last_seg_start = offset;
2548            let padded =
2549                calculate_padded_size(SEGMENT_HEADER_SIZE, seg_header.payload_length as usize);
2550            offset += padded;
2551        }
2552
2553        // Remaining bytes should be the signature footer.
2554        if offset >= data.len() {
2555            return Err("no signature footer found in RVF file".into());
2556        }
2557        let footer = decode_signature_footer(&data[offset..])
2558            .map_err(|e| format!("decode signature footer: {e}"))?;
2559
2560        // Re-read the checkpoint segment for verification.
2561        let (cp_seg_header, cp_seg_payload) = read_segment(&data[last_seg_start..])
2562            .map_err(|e| format!("re-read checkpoint segment: {e}"))?;
2563
2564        Ok(verify_segment(
2565            &cp_seg_header,
2566            cp_seg_payload,
2567            &footer,
2568            verifying_key,
2569        ))
2570    }
2571
2572    /// Verify dual (Ed25519 + ML-DSA-65) signatures on an RVF chain file.
2573    ///
2574    /// Returns `Ok(true)` if both signatures are valid, `Ok(false)` if
2575    /// either is invalid, and `Err` if the file has no dual signature.
2576    pub fn verify_rvf_dual_signature(
2577        path: &Path,
2578        ed_key: &VerifyingKey,
2579        ml_key: &MlDsa65VerifyKey,
2580    ) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
2581        let data = std::fs::read(path)?;
2582        let mut offset = 0;
2583        let mut last_seg_start = 0;
2584
2585        while offset < data.len() {
2586            if data.len() - offset < SEGMENT_HEADER_SIZE {
2587                break;
2588            }
2589            let (seg_header, _) = match read_segment(&data[offset..]) {
2590                Ok(result) => result,
2591                Err(_) => break,
2592            };
2593            last_seg_start = offset;
2594            let padded =
2595                calculate_padded_size(SEGMENT_HEADER_SIZE, seg_header.payload_length as usize);
2596            offset += padded;
2597        }
2598
2599        if offset >= data.len() {
2600            return Err("no signature footer found in RVF file".into());
2601        }
2602
2603        let ed_footer = decode_signature_footer(&data[offset..])
2604            .map_err(|e| format!("decode Ed25519 signature footer: {e}"))?;
2605        let ed_footer_size = ed_footer.footer_length as usize;
2606        let ml_offset = offset + ed_footer_size;
2607
2608        if ml_offset >= data.len() {
2609            return Err("no ML-DSA-65 signature footer found (single-signed file)".into());
2610        }
2611        let ml_footer = decode_signature_footer(&data[ml_offset..])
2612            .map_err(|e| format!("decode ML-DSA-65 signature footer: {e}"))?;
2613
2614        let (cp_seg_header, cp_seg_payload) = read_segment(&data[last_seg_start..])
2615            .map_err(|e| format!("re-read checkpoint segment: {e}"))?;
2616
2617        let ed_ok = verify_segment(&cp_seg_header, cp_seg_payload, &ed_footer, ed_key);
2618        let ml_ok = verify_segment_ml_dsa(&cp_seg_header, cp_seg_payload, &ml_footer, ml_key);
2619
2620        Ok(ed_ok && ml_ok)
2621    }
2622
2623    // ── Dual signing for cross-node chain events ───────────────
2624
2625    /// Sign data with both Ed25519 and ML-DSA-65 (if configured).
2626    ///
2627    /// Returns `Some(DualSignature)` when at least the Ed25519 key is
2628    /// present. The ML-DSA-65 half is populated only when the ML-DSA key
2629    /// has been set via [`set_ml_dsa_key`].
2630    pub fn dual_sign(&self, data: &[u8]) -> Option<DualSignature> {
2631        use ed25519_dalek::Signer;
2632
2633        if signs_project_domain(data) {
2634            tracing::error!("dual_sign refused bytes tagged for a project statement");
2635            return None;
2636        }
2637        let signing_key = self.signing_key.as_ref()?;
2638        let ed_sig = signing_key.sign(data);
2639        let ed_bytes = ed_sig.to_bytes().to_vec();
2640
2641        let ml_sig = self.ml_dsa_key.as_ref().map(|ml_key| {
2642            // Use SHAKE-256 HMAC-like construction matching rvf-crypto placeholder.
2643            ml_dsa_sign_raw(ml_key, data)
2644        });
2645
2646        Some(DualSignature {
2647            ed25519: ed_bytes,
2648            ml_dsa65: ml_sig,
2649        })
2650    }
2651
2652    /// Verify a dual signature against the given data and public keys.
2653    ///
2654    /// Ed25519 verification is mandatory. ML-DSA-65 verification is
2655    /// performed only when both the signature and verification key are
2656    /// present. Returns `false` if any present signature is invalid.
2657    pub fn verify_dual_signature(
2658        data: &[u8],
2659        sig: &DualSignature,
2660        ed25519_pubkey: &VerifyingKey,
2661        ml_dsa_pubkey: Option<&MlDsa65VerifyKey>,
2662    ) -> bool {
2663        use ed25519_dalek::{Signature, Verifier};
2664
2665        // Ed25519 is mandatory.
2666        if sig.ed25519.len() != 64 {
2667            return false;
2668        }
2669        let ed_sig = Signature::from_bytes(sig.ed25519.as_slice().try_into().unwrap_or(&[0u8; 64]));
2670        if ed25519_pubkey.verify(data, &ed_sig).is_err() {
2671            return false;
2672        }
2673
2674        // ML-DSA-65 when both signature and key are present.
2675        if let (Some(ml_sig), Some(ml_key)) = (&sig.ml_dsa65, ml_dsa_pubkey)
2676            && !ml_dsa_verify_raw(ml_key, data, ml_sig)
2677        {
2678            return false;
2679        }
2680
2681        true
2682    }
2683}
2684
2685// ── Raw ML-DSA-65 placeholder signing for arbitrary data ──────────
2686//
2687// These mirror the HMAC-SHA3-256 placeholder in rvf-crypto's dual_sign
2688// module but operate on raw byte slices instead of RVF segments.
2689
2690/// ML-DSA-65 placeholder signature length (FIPS 204).
2691const ML_DSA_RAW_SIG_LEN: usize = 3309;
2692
2693/// Sign arbitrary data with the ML-DSA-65 placeholder (HMAC-SHAKE-256).
2694fn ml_dsa_sign_raw(key: &MlDsa65Key, data: &[u8]) -> Vec<u8> {
2695    // Extract 32-byte key material via verifying_key round-trip.
2696    let vk = key.verifying_key();
2697    let key_bytes = ml_dsa_vk_bytes(&vk);
2698
2699    let mut input = Vec::with_capacity(32 + data.len() + 32);
2700    input.extend_from_slice(&key_bytes);
2701    input.extend_from_slice(data);
2702    input.extend_from_slice(&key_bytes);
2703
2704    let mut sig = Vec::with_capacity(ML_DSA_RAW_SIG_LEN);
2705    let mut block = shake256_256(&input);
2706    while sig.len() < ML_DSA_RAW_SIG_LEN {
2707        sig.extend_from_slice(&block);
2708        let mut next = Vec::with_capacity(64);
2709        next.extend_from_slice(&block);
2710        next.extend_from_slice(&key_bytes);
2711        block = shake256_256(&next);
2712    }
2713    sig.truncate(ML_DSA_RAW_SIG_LEN);
2714    sig
2715}
2716
2717/// Verify an ML-DSA-65 placeholder signature on arbitrary data.
2718fn ml_dsa_verify_raw(pubkey: &MlDsa65VerifyKey, data: &[u8], sig: &[u8]) -> bool {
2719    let key_bytes = ml_dsa_vk_bytes(pubkey);
2720
2721    let mut input = Vec::with_capacity(32 + data.len() + 32);
2722    input.extend_from_slice(&key_bytes);
2723    input.extend_from_slice(data);
2724    input.extend_from_slice(&key_bytes);
2725
2726    let mut expected = Vec::with_capacity(ML_DSA_RAW_SIG_LEN);
2727    let mut block = shake256_256(&input);
2728    while expected.len() < ML_DSA_RAW_SIG_LEN {
2729        expected.extend_from_slice(&block);
2730        let mut next = Vec::with_capacity(64);
2731        next.extend_from_slice(&block);
2732        next.extend_from_slice(&key_bytes);
2733        block = shake256_256(&next);
2734    }
2735    expected.truncate(ML_DSA_RAW_SIG_LEN);
2736
2737    sig.len() == ML_DSA_RAW_SIG_LEN && sig == expected.as_slice()
2738}
2739
2740/// Extract the 32-byte key material from an `MlDsa65VerifyKey`.
2741///
2742/// Since `MlDsa65VerifyKey` does not expose its inner bytes directly,
2743/// we regenerate via the same seed path used in generate(). In
2744/// placeholder mode the signing key and verify key share the same
2745/// 32-byte SHAKE-256 digest, so `verifying_key()` round-trips cleanly.
2746fn ml_dsa_vk_bytes(vk: &MlDsa65VerifyKey) -> [u8; 32] {
2747    // MlDsa65VerifyKey is Clone; generate a fresh one from a known seed
2748    // and compare — but we cannot peek inside. Instead we use a simple
2749    // workaround: the test-time key generation uses `generate(seed)` which
2750    // produces `key = SHAKE-256(seed)`. The verify key holds the same bytes.
2751    // Since the struct is opaque, we sign a sentinel and derive the key
2752    // bytes from the output (the first 32 bytes of the HMAC chain are
2753    // `SHAKE-256(key || sentinel || key)` which IS deterministic).
2754    //
2755    // However, the simplest correct approach: we know in placeholder mode
2756    // signing_key bytes == verify_key bytes. The MlDsa65Key::generate
2757    // returns both with the same 32-byte value. So we reconstruct via
2758    // a zero-length sign and extract the block. This works because the
2759    // HMAC construction in rvf-crypto uses the key bytes directly.
2760    //
2761    // For a clean API, we add a compile-time assertion that verifying_key
2762    // is 32 bytes and access it through the known memory layout.
2763    //
2764    // In practice, both MlDsa65Key and MlDsa65VerifyKey wrap `key: [u8; 32]`.
2765    // We use unsafe transmute in a controlled, size-asserted way.
2766    assert_eq!(
2767        std::mem::size_of::<MlDsa65VerifyKey>(),
2768        32,
2769        "MlDsa65VerifyKey must be exactly 32 bytes"
2770    );
2771    // SAFETY: MlDsa65VerifyKey is a repr(Rust) struct containing only
2772    // `key: [u8; 32]`. We assert the size matches before transmuting.
2773    unsafe { std::mem::transmute_copy(vk) }
2774}
2775
2776// ── Cross-node dual signature types ───────────────────────────────
2777
2778/// Configuration for dual Ed25519 + ML-DSA-65 signing.
2779pub struct DualSigningConfig {
2780    /// Ed25519 signing key.
2781    pub ed25519_key: SigningKey,
2782    /// ML-DSA-65 signing key (if available).
2783    pub ml_dsa_key: Option<MlDsa65Key>,
2784}
2785
2786/// A dual signature (Ed25519 + optional ML-DSA-65).
2787#[derive(Debug, Clone, Serialize, Deserialize)]
2788pub struct DualSignature {
2789    /// Ed25519 signature (64 bytes).
2790    pub ed25519: Vec<u8>,
2791    /// ML-DSA-65 signature (optional, ~3309 bytes).
2792    pub ml_dsa65: Option<Vec<u8>>,
2793}
2794
2795/// Chain status summary.
2796#[derive(Debug, Clone, Serialize, Deserialize)]
2797pub struct ChainStatus {
2798    pub chain_id: u32,
2799    pub sequence: u64,
2800    pub last_hash: [u8; 32],
2801    pub event_count: usize,
2802    pub checkpoint_count: usize,
2803    pub events_since_checkpoint: u64,
2804}
2805
2806/// A single signed proof of system state.
2807///
2808/// Assembles chain state, vector store metrics, and a content hash
2809/// into a document signed by the kernel's Ed25519 key.
2810#[derive(Debug, Clone, Serialize, Deserialize)]
2811pub struct CustodyAttestation {
2812    /// Ed25519 public key hex (device identity).
2813    pub device_id: String,
2814    /// Current vector store epoch.
2815    pub epoch: u64,
2816    /// Hash of the latest chain event (hex).
2817    pub chain_head: String,
2818    /// Number of chain events.
2819    pub chain_depth: u64,
2820    /// Number of vectors in the store.
2821    pub vector_count: u64,
2822    /// BLAKE3 hash of all vector IDs (hex, or "none" if no backend).
2823    pub content_hash: String,
2824    /// Unix timestamp (seconds).
2825    pub timestamp: u64,
2826    /// Ed25519 signature over the canonical attestation payload.
2827    pub signature: Vec<u8>,
2828}
2829
2830impl std::fmt::Debug for ChainManager {
2831    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2832        let status = self.status();
2833        f.debug_struct("ChainManager")
2834            .field("chain_id", &status.chain_id)
2835            .field("sequence", &status.sequence)
2836            .field("event_count", &status.event_count)
2837            .finish()
2838    }
2839}
2840
2841// ---------------------------------------------------------------------------
2842// K4 G1: ChainAnchor trait — external anchoring abstraction (K2 C7)
2843// ---------------------------------------------------------------------------
2844
2845/// Receipt returned by a successful [`ChainAnchor::anchor`] call.
2846#[derive(Debug, Clone, Serialize, Deserialize)]
2847pub struct AnchorReceipt {
2848    /// The hash that was anchored.
2849    pub hash: [u8; 32],
2850    /// Backend-specific transaction/receipt ID.
2851    pub tx_id: String,
2852    /// Timestamp of the anchoring operation.
2853    pub anchored_at: DateTime<Utc>,
2854}
2855
2856/// Trait for anchoring chain state to an external ledger or store.
2857///
2858/// Implementations:
2859/// - [`MockAnchor`] — always-succeeds (tests)
2860/// - [`crate::chain_anchor::FileLedgerAnchor`] — append-only hash-linked
2861///   local ledger (production-useful default; WEFT-137)
2862/// - [`crate::chain_anchor::ExternalLedgerAnchor`] — external-ledger stub
2863///   with intent log + endpoint wiring (until a public ledger is chosen)
2864///
2865/// Frequency policy and config wiring live in
2866/// [`crate::chain_anchor::AnchoringController`].
2867pub trait ChainAnchor: Send + Sync {
2868    /// Anchor the given hash to the external backend.
2869    fn anchor(&self, hash: &[u8; 32]) -> Result<AnchorReceipt, String>;
2870
2871    /// Verify a previously anchored hash against its receipt.
2872    fn verify(&self, receipt: &AnchorReceipt) -> Result<bool, String>;
2873
2874    /// Return the backend name for display/logging.
2875    fn backend_name(&self) -> &str;
2876}
2877
2878/// A mock anchor that always succeeds (for testing).
2879pub struct MockAnchor;
2880
2881impl ChainAnchor for MockAnchor {
2882    fn anchor(&self, hash: &[u8; 32]) -> Result<AnchorReceipt, String> {
2883        Ok(AnchorReceipt {
2884            hash: *hash,
2885            tx_id: format!(
2886                "mock-{}",
2887                hex_hash(hash).chars().take(16).collect::<String>()
2888            ),
2889            anchored_at: Utc::now(),
2890        })
2891    }
2892
2893    fn verify(&self, _receipt: &AnchorReceipt) -> Result<bool, String> {
2894        Ok(true)
2895    }
2896
2897    fn backend_name(&self) -> &str {
2898        "mock"
2899    }
2900}
2901
2902// ── ChainLoggable trait ─────────────────────────────────────────────
2903
2904/// Trait for types that can be logged to the ExoChain audit trail.
2905///
2906/// Implementors define how they map to a chain event kind string and
2907/// a JSON payload. The [`ChainManager::append_loggable`] convenience
2908/// method uses these to append an event without the caller needing to
2909/// hand-craft the source/kind/payload triple.
2910pub trait ChainLoggable {
2911    /// The source subsystem (e.g. "supervisor", "governance", "ipc").
2912    fn chain_event_source(&self) -> &str;
2913
2914    /// The event kind string (e.g. "agent.restart", "governance.deny").
2915    fn chain_event_kind(&self) -> &str;
2916
2917    /// Build the JSON payload for the chain event.
2918    fn chain_event_payload(&self) -> serde_json::Value;
2919}
2920
2921impl ChainManager {
2922    /// Append an event from any [`ChainLoggable`] implementor.
2923    pub fn append_loggable(&self, event: &dyn ChainLoggable) -> ChainEvent {
2924        self.append(
2925            event.chain_event_source(),
2926            event.chain_event_kind(),
2927            Some(event.chain_event_payload()),
2928        )
2929    }
2930}
2931
2932// ── ChainLoggable implementations ───────────────────────────────────
2933
2934/// A restart event suitable for chain logging.
2935///
2936/// Created by the supervisor after successfully restarting an agent.
2937pub struct RestartEvent {
2938    /// The agent identifier that was restarted.
2939    pub agent_id: String,
2940    /// PID of the process that crashed / was stopped.
2941    pub old_pid: u64,
2942    /// PID of the newly spawned replacement process.
2943    pub new_pid: u64,
2944    /// Exit code that triggered the restart.
2945    pub exit_code: i32,
2946    /// Restart strategy that was applied.
2947    pub strategy: String,
2948    /// Backoff delay in milliseconds before the restart was attempted.
2949    pub backoff_ms: u64,
2950    /// Timestamp of the restart.
2951    pub timestamp: DateTime<Utc>,
2952}
2953
2954impl ChainLoggable for RestartEvent {
2955    fn chain_event_source(&self) -> &str {
2956        "supervisor"
2957    }
2958
2959    fn chain_event_kind(&self) -> &str {
2960        "supervisor.restart"
2961    }
2962
2963    fn chain_event_payload(&self) -> serde_json::Value {
2964        serde_json::json!({
2965            "agent_id": self.agent_id,
2966            "old_pid": self.old_pid,
2967            "new_pid": self.new_pid,
2968            "exit_code": self.exit_code,
2969            "strategy": self.strategy,
2970            "backoff_ms": self.backoff_ms,
2971            "timestamp": self.timestamp.to_rfc3339(),
2972        })
2973    }
2974}
2975
2976/// A governance decision event suitable for chain logging.
2977///
2978/// Captures the result of `GovernanceEngine::evaluate` for audit.
2979pub struct GovernanceDecisionEvent {
2980    /// Agent that made the request.
2981    pub agent_id: String,
2982    /// Action that was evaluated.
2983    pub action: String,
2984    /// The governance decision outcome.
2985    pub decision: String,
2986    /// Effect vector magnitude.
2987    pub effect_magnitude: f64,
2988    /// Whether the risk threshold was exceeded.
2989    pub threshold_exceeded: bool,
2990    /// Rules that were evaluated.
2991    pub evaluated_rules: Vec<String>,
2992    /// Timestamp of the evaluation.
2993    pub timestamp: DateTime<Utc>,
2994    /// Attributed principal for the verdict (WEFT-636). Optional for
2995    /// backward-compat with older constructors; when set, appears in the
2996    /// chain payload for audit attribution.
2997    pub principal: Option<crate::governance::GatePrincipal>,
2998}
2999
3000impl ChainLoggable for GovernanceDecisionEvent {
3001    fn chain_event_source(&self) -> &str {
3002        "governance"
3003    }
3004
3005    fn chain_event_kind(&self) -> &str {
3006        match self.decision.as_str() {
3007            "Permit" => "governance.permit",
3008            "PermitWithWarning" => "governance.warn",
3009            "EscalateToHuman" => "governance.defer",
3010            "Deny" => "governance.deny",
3011            _ => "governance.unknown",
3012        }
3013    }
3014
3015    fn chain_event_payload(&self) -> serde_json::Value {
3016        let mut payload = serde_json::json!({
3017            "agent_id": self.agent_id,
3018            "action": self.action,
3019            "decision": self.decision,
3020            "effect_magnitude": self.effect_magnitude,
3021            "threshold_exceeded": self.threshold_exceeded,
3022            "evaluated_rules": self.evaluated_rules,
3023            "timestamp": self.timestamp.to_rfc3339(),
3024        });
3025        if let Some(p) = &self.principal
3026            && let Some(obj) = payload.as_object_mut()
3027        {
3028            obj.insert(
3029                "principal".into(),
3030                serde_json::to_value(p).unwrap_or(serde_json::Value::Null),
3031            );
3032        }
3033        payload
3034    }
3035}
3036
3037/// An IPC dead-letter event suitable for chain logging.
3038///
3039/// Captures when a message is routed to the dead letter queue.
3040pub struct IpcDeadLetterEvent {
3041    /// Original message ID.
3042    pub message_id: String,
3043    /// Sender PID.
3044    pub from_pid: u64,
3045    /// Target description (formatted from MessageTarget).
3046    pub target: String,
3047    /// Payload type name.
3048    pub payload_type: String,
3049    /// Reason delivery failed.
3050    pub reason: String,
3051    /// Timestamp of the dead-lettering.
3052    pub timestamp: DateTime<Utc>,
3053}
3054
3055impl ChainLoggable for IpcDeadLetterEvent {
3056    fn chain_event_source(&self) -> &str {
3057        "ipc"
3058    }
3059
3060    fn chain_event_kind(&self) -> &str {
3061        "ipc.dead_letter"
3062    }
3063
3064    fn chain_event_payload(&self) -> serde_json::Value {
3065        serde_json::json!({
3066            "message_id": self.message_id,
3067            "from_pid": self.from_pid,
3068            "target": self.target,
3069            "payload_type": self.payload_type,
3070            "reason": self.reason,
3071            "timestamp": self.timestamp.to_rfc3339(),
3072        })
3073    }
3074}
3075
3076#[cfg(test)]
3077mod tests {
3078    use super::*;
3079
3080    #[test]
3081    fn genesis_event() {
3082        let cm = ChainManager::new(0, 1000);
3083        assert_eq!(cm.len(), 1);
3084        assert_eq!(cm.sequence(), 1); // genesis consumed seq 0
3085        let events = cm.tail(0);
3086        assert_eq!(events[0].kind, "genesis");
3087        assert_eq!(events[0].sequence, 0);
3088        assert_eq!(events[0].prev_hash, [0u8; 32]);
3089    }
3090
3091    #[test]
3092    fn append_links_hashes() {
3093        let cm = ChainManager::new(0, 1000);
3094        let genesis_hash = cm.last_hash();
3095
3096        let e1 = cm.append("test", "event.one", None);
3097        assert_eq!(e1.prev_hash, genesis_hash);
3098        assert_ne!(e1.hash, [0u8; 32]);
3099
3100        let e2 = cm.append(
3101            "test",
3102            "event.two",
3103            Some(serde_json::json!({"key": "value"})),
3104        );
3105        assert_eq!(e2.prev_hash, e1.hash);
3106    }
3107
3108    /// WEFT-103: an idempotency_key match within the lookback window
3109    /// must short-circuit the append and return the prior event
3110    /// rather than producing a duplicate ledger entry.
3111    #[test]
3112    fn append_idempotent_dedups_within_window() {
3113        let cm = ChainManager::new(0, 1000);
3114        let key = [0xABu8; 32];
3115
3116        let starting_len = cm.len();
3117        let first = cm.append_idempotent(
3118            "test",
3119            "idempotent.event",
3120            Some(serde_json::json!({"n": 1})),
3121            Some(key),
3122        );
3123        assert_eq!(cm.len(), starting_len + 1);
3124        assert_eq!(first.idempotency_key, Some(key));
3125
3126        // Replay -- payload differs but key matches: must be a no-op.
3127        let replay = cm.append_idempotent(
3128            "test",
3129            "idempotent.event",
3130            Some(serde_json::json!({"n": 2})),
3131            Some(key),
3132        );
3133        assert_eq!(cm.len(), starting_len + 1, "replay must not append");
3134        assert_eq!(replay.sequence, first.sequence);
3135        assert_eq!(replay.hash, first.hash);
3136    }
3137
3138    /// WEFT-103: distinct keys must each produce a fresh event, and
3139    /// an absent key must always append (default-on behaviour).
3140    #[test]
3141    fn append_idempotent_distinct_keys_append() {
3142        let cm = ChainManager::new(0, 1000);
3143        let starting_len = cm.len();
3144
3145        let _ = cm.append_idempotent("test", "k.a", None, Some([0x01u8; 32]));
3146        let _ = cm.append_idempotent("test", "k.b", None, Some([0x02u8; 32]));
3147        // No key -- always appends.
3148        let _ = cm.append_idempotent("test", "k.none", None, None);
3149        let _ = cm.append_idempotent("test", "k.none", None, None);
3150
3151        assert_eq!(cm.len(), starting_len + 4);
3152    }
3153
3154    #[test]
3155    fn checkpoint() {
3156        let cm = ChainManager::new(0, 1000);
3157        cm.append("test", "event", None);
3158
3159        let cp = cm.checkpoint();
3160        assert_eq!(cp.chain_id, 0);
3161        assert_eq!(cp.sequence, 1);
3162        assert_eq!(cm.checkpoints().len(), 1);
3163    }
3164
3165    #[test]
3166    fn tail_from_zero_returns_all() {
3167        let cm = ChainManager::new(0, 1000);
3168        cm.append("test", "event.one", None);
3169        cm.append("test", "event.two", None);
3170
3171        // tail_from(0) should skip genesis (seq 0) and return seq 1, 2
3172        // But actually: genesis is seq 0 so tail_from(0) returns events
3173        // with sequence > 0 i.e. the two appended events.
3174        let all = cm.tail_from(0);
3175        // We have genesis(0), event.one(1), event.two(2) — 2 events after 0
3176        assert_eq!(all.len(), 2);
3177    }
3178
3179    #[test]
3180    fn tail_from_n_returns_after() {
3181        let cm = ChainManager::new(0, 1000);
3182        let e1 = cm.append("test", "event.one", None);
3183        let _e2 = cm.append("test", "event.two", None);
3184
3185        let after = cm.tail_from(e1.sequence);
3186        assert_eq!(after.len(), 1);
3187        assert_eq!(after[0].kind, "event.two");
3188    }
3189
3190    #[test]
3191    fn tail_from_head_returns_empty() {
3192        let cm = ChainManager::new(0, 1000);
3193        cm.append("test", "event.one", None);
3194
3195        let head_seq = cm.head_sequence();
3196        let after = cm.tail_from(head_seq);
3197        assert!(after.is_empty());
3198    }
3199
3200    /// WEFT-105: remote events apply via append_signed with hash verification.
3201    /// Sink shares the source genesis (same cluster prefix).
3202    #[test]
3203    fn append_signed_replays_remote_events() {
3204        let source = ChainManager::new(0, 1000);
3205        source.append("test", "a", Some(serde_json::json!({"n": 1})));
3206        source.append("test", "b", Some(serde_json::json!({"n": 2})));
3207        let remote_events = source.tail_from(0);
3208        assert_eq!(remote_events.len(), 2);
3209
3210        let genesis = source.tail(0)[0].clone();
3211        let sink = ChainManager::from_events(vec![genesis], 1000).unwrap();
3212
3213        for ev in remote_events {
3214            sink.append_signed(ev).unwrap();
3215        }
3216        assert_eq!(sink.head_sequence(), source.head_sequence());
3217        assert_eq!(sink.head_hash(), source.head_hash());
3218        assert_eq!(sink.len(), source.len());
3219    }
3220
3221    #[test]
3222    fn append_signed_idempotent_and_rejects_tamper() {
3223        let source = ChainManager::new(0, 1000);
3224        let e = source.append("test", "x", None);
3225
3226        let genesis = source.tail(0)[0].clone();
3227        let sink = ChainManager::from_events(vec![genesis], 1000).unwrap();
3228        sink.append_signed(e.clone()).unwrap();
3229        let err = sink.append_signed(e.clone()).unwrap_err();
3230        assert!(matches!(err, AppendSignedError::AlreadyPresent { .. }));
3231
3232        let mut tampered = e;
3233        tampered.hash[0] ^= 0xFF;
3234        // sequence already present with different hash → fork
3235        let err = sink.append_signed(tampered).unwrap_err();
3236        assert!(matches!(err, AppendSignedError::Fork { .. }));
3237    }
3238
3239    #[test]
3240    fn append_signed_gap_when_skip_sequence() {
3241        let source = ChainManager::new(0, 1000);
3242        source.append("test", "a", None);
3243        let e2 = source.append("test", "b", None);
3244
3245        let genesis = source.tail(0)[0].clone();
3246        let sink = ChainManager::from_events(vec![genesis], 1000).unwrap();
3247        // Skip event "a" (seq 1), try to append "b" (seq 2)
3248        let err = sink.append_signed(e2).unwrap_err();
3249        assert!(matches!(err, AppendSignedError::Gap { expected: 1, got: 2 }));
3250    }
3251
3252    #[test]
3253    fn head_sequence_empty_chain() {
3254        // ChainManager::new always creates a genesis event, so we
3255        // verify head_sequence returns the genesis sequence (0).
3256        let cm = ChainManager::new(0, 1000);
3257        assert_eq!(cm.head_sequence(), 0);
3258    }
3259
3260    #[test]
3261    fn head_sequence_after_appends() {
3262        let cm = ChainManager::new(0, 1000);
3263        cm.append("test", "a", None);
3264        cm.append("test", "b", None);
3265        // genesis=0, a=1, b=2
3266        assert_eq!(cm.head_sequence(), 2);
3267    }
3268
3269    #[test]
3270    fn head_hash_matches_last_event() {
3271        let cm = ChainManager::new(0, 1000);
3272        let e = cm.append("test", "event", None);
3273        assert_eq!(cm.head_hash(), e.hash);
3274        assert_ne!(cm.head_hash(), [0u8; 32]);
3275    }
3276
3277    #[test]
3278    fn auto_checkpoint() {
3279        let cm = ChainManager::new(0, 5); // checkpoint every 5 events
3280        // Genesis is event 0 (1 event since checkpoint)
3281        for i in 0..4 {
3282            cm.append("test", &format!("event.{i}"), None);
3283        }
3284        // 5 total events (genesis + 4) -> should auto-checkpoint
3285        assert_eq!(cm.checkpoints().len(), 1);
3286    }
3287
3288    #[test]
3289    fn status() {
3290        let cm = ChainManager::new(0, 1000);
3291        cm.append("test", "event", None);
3292        let status = cm.status();
3293        assert_eq!(status.chain_id, 0);
3294        assert_eq!(status.sequence, 2);
3295        assert_eq!(status.event_count, 2);
3296    }
3297
3298    #[test]
3299    fn verify_integrity_valid() {
3300        let cm = ChainManager::new(0, 1000);
3301        cm.append("kernel", "boot.init", None);
3302        cm.append("tree", "bootstrap", Some(serde_json::json!({"nodes": 8})));
3303        cm.append("kernel", "boot.ready", None);
3304
3305        let result = cm.verify_integrity();
3306        assert!(result.valid);
3307        assert_eq!(result.event_count, 4); // genesis + 3
3308        assert!(result.errors.is_empty());
3309    }
3310
3311    #[test]
3312    fn save_and_load_roundtrip() {
3313        let cm = ChainManager::new(0, 1000);
3314        cm.append("kernel", "boot.init", None);
3315        cm.append("tree", "bootstrap", Some(serde_json::json!({"nodes": 8})));
3316        cm.append("kernel", "boot.ready", None);
3317
3318        let original_seq = cm.sequence();
3319        let original_hash = cm.last_hash();
3320        let original_len = cm.len();
3321
3322        let dir = std::env::temp_dir().join("clawft-chain-test");
3323        let path = dir.join("test-chain.json");
3324        cm.save_to_file(&path).unwrap();
3325
3326        let restored = ChainManager::load_from_file(&path, 1000).unwrap();
3327        assert_eq!(restored.sequence(), original_seq);
3328        assert_eq!(restored.last_hash(), original_hash);
3329        assert_eq!(restored.len(), original_len);
3330        assert_eq!(restored.chain_id(), 0);
3331
3332        // Verify restored chain integrity
3333        let result = restored.verify_integrity();
3334        assert!(result.valid);
3335
3336        // New events continue from restored state
3337        let new_event = restored.append("test", "after.restore", None);
3338        assert_eq!(new_event.sequence, original_seq);
3339        assert_eq!(new_event.prev_hash, original_hash);
3340
3341        // Clean up
3342        let _ = std::fs::remove_dir_all(&dir);
3343    }
3344
3345    #[test]
3346    fn load_from_nonexistent_file_fails() {
3347        let result = ChainManager::load_from_file(
3348            &std::path::PathBuf::from("/tmp/nonexistent-chain-file.json"),
3349            1000,
3350        );
3351        assert!(result.is_err());
3352    }
3353
3354    #[test]
3355    fn tail() {
3356        let cm = ChainManager::new(0, 1000);
3357        cm.append("a", "1", None);
3358        cm.append("b", "2", None);
3359        cm.append("c", "3", None);
3360
3361        let last2 = cm.tail(2);
3362        assert_eq!(last2.len(), 2);
3363        assert_eq!(last2[0].kind, "2");
3364        assert_eq!(last2[1].kind, "3");
3365
3366        let all = cm.tail(0);
3367        assert_eq!(all.len(), 4); // genesis + 3
3368    }
3369
3370    #[test]
3371    fn save_and_load_rvf_roundtrip() {
3372        let cm = ChainManager::new(0, 1000);
3373        cm.append("kernel", "boot.init", None);
3374        cm.append("tree", "bootstrap", Some(serde_json::json!({"nodes": 8})));
3375        cm.append("kernel", "boot.ready", None);
3376
3377        let original_seq = cm.sequence();
3378        let original_hash = cm.last_hash();
3379        let original_len = cm.len();
3380
3381        let dir = std::env::temp_dir().join("clawft-chain-rvf-test");
3382        let path = dir.join("test-chain.rvf");
3383        cm.save_to_rvf(&path).unwrap();
3384
3385        let restored = ChainManager::load_from_rvf(&path, 1000).unwrap();
3386        assert_eq!(restored.sequence(), original_seq);
3387        assert_eq!(restored.last_hash(), original_hash);
3388        assert_eq!(restored.len(), original_len);
3389        assert_eq!(restored.chain_id(), 0);
3390
3391        // Verify restored chain integrity.
3392        let result = restored.verify_integrity();
3393        assert!(result.valid, "integrity errors: {:?}", result.errors);
3394        assert_eq!(result.event_count, original_len);
3395
3396        // New events continue from restored state.
3397        let new_event = restored.append("test", "after.rvf.restore", None);
3398        assert_eq!(new_event.sequence, original_seq);
3399        assert_eq!(new_event.prev_hash, original_hash);
3400
3401        // Clean up.
3402        let _ = std::fs::remove_dir_all(&dir);
3403    }
3404
3405    #[test]
3406    fn rvf_validates_on_load() {
3407        let cm = ChainManager::new(0, 1000);
3408        cm.append("kernel", "boot", None);
3409
3410        let dir = std::env::temp_dir().join("clawft-chain-rvf-validate");
3411        let path = dir.join("corrupt.rvf");
3412        cm.save_to_rvf(&path).unwrap();
3413
3414        // Corrupt a byte in the first segment's payload area.
3415        let mut data = std::fs::read(&path).unwrap();
3416        // The payload starts at SEGMENT_HEADER_SIZE (64). Flip a byte
3417        // inside the ExoChainHeader portion of the payload.
3418        if data.len() > SEGMENT_HEADER_SIZE + 10 {
3419            data[SEGMENT_HEADER_SIZE + 10] ^= 0xFF;
3420        }
3421        std::fs::write(&path, &data).unwrap();
3422
3423        let result = ChainManager::load_from_rvf(&path, 1000);
3424        assert!(
3425            result.is_err(),
3426            "expected validation error on corrupted RVF"
3427        );
3428
3429        // Clean up.
3430        let _ = std::fs::remove_dir_all(&dir);
3431    }
3432
3433    #[test]
3434    fn rvf_migration_from_json() {
3435        // Create a chain via the normal API.
3436        let cm = ChainManager::new(0, 1000);
3437        cm.append("kernel", "boot.init", None);
3438        cm.append(
3439            "tree",
3440            "bootstrap",
3441            Some(serde_json::json!({"nodes": 4, "name": "test"})),
3442        );
3443        cm.append("kernel", "boot.ready", None);
3444
3445        let dir = std::env::temp_dir().join("clawft-chain-migrate-test");
3446        let json_path = dir.join("chain.json");
3447        let rvf_path = dir.join("chain.rvf");
3448
3449        // Save as JSON, load as JSON.
3450        cm.save_to_file(&json_path).unwrap();
3451        let from_json = ChainManager::load_from_file(&json_path, 1000).unwrap();
3452
3453        // Save the JSON-loaded chain as RVF.
3454        from_json.save_to_rvf(&rvf_path).unwrap();
3455
3456        // Load from RVF and compare.
3457        let from_rvf = ChainManager::load_from_rvf(&rvf_path, 1000).unwrap();
3458
3459        assert_eq!(from_rvf.sequence(), cm.sequence());
3460        assert_eq!(from_rvf.last_hash(), cm.last_hash());
3461        assert_eq!(from_rvf.len(), cm.len());
3462        assert_eq!(from_rvf.chain_id(), cm.chain_id());
3463
3464        // Compare event-by-event.
3465        let original_events = cm.tail(0);
3466        let rvf_events = from_rvf.tail(0);
3467        assert_eq!(original_events.len(), rvf_events.len());
3468        for (orig, loaded) in original_events.iter().zip(rvf_events.iter()) {
3469            assert_eq!(orig.sequence, loaded.sequence);
3470            assert_eq!(orig.chain_id, loaded.chain_id);
3471            assert_eq!(orig.hash, loaded.hash);
3472            assert_eq!(orig.prev_hash, loaded.prev_hash);
3473            assert_eq!(orig.payload_hash, loaded.payload_hash);
3474            assert_eq!(orig.source, loaded.source);
3475            assert_eq!(orig.kind, loaded.kind);
3476            assert_eq!(orig.payload, loaded.payload);
3477        }
3478
3479        // Verify integrity of the RVF-loaded chain.
3480        let result = from_rvf.verify_integrity();
3481        assert!(result.valid, "integrity errors: {:?}", result.errors);
3482
3483        // Clean up.
3484        let _ = std::fs::remove_dir_all(&dir);
3485    }
3486
3487    #[test]
3488    fn ed25519_signed_rvf_roundtrip() {
3489        use ed25519_dalek::SigningKey;
3490        use rand::rngs::OsRng;
3491
3492        let key = SigningKey::generate(&mut OsRng);
3493
3494        let cm = ChainManager::new(0, 1000).with_signing_key(key.clone());
3495        assert!(cm.has_signing_key());
3496        cm.append("kernel", "boot.init", None);
3497        cm.append("tree", "bootstrap", Some(serde_json::json!({"nodes": 8})));
3498        cm.append("kernel", "boot.ready", None);
3499
3500        let original_seq = cm.sequence();
3501        let original_hash = cm.last_hash();
3502        let original_len = cm.len();
3503
3504        let dir = std::env::temp_dir().join("clawft-chain-signed-test");
3505        let path = dir.join("signed-chain.rvf");
3506        cm.save_to_rvf(&path).unwrap();
3507
3508        // File should be larger than unsigned (72 extra bytes for Ed25519 footer).
3509        let _file_size = std::fs::metadata(&path).unwrap().len();
3510
3511        // Load the signed file (should work even without a key).
3512        let restored = ChainManager::load_from_rvf(&path, 1000).unwrap();
3513        assert_eq!(restored.sequence(), original_seq);
3514        assert_eq!(restored.last_hash(), original_hash);
3515        assert_eq!(restored.len(), original_len);
3516
3517        let result = restored.verify_integrity();
3518        assert!(result.valid, "integrity errors: {:?}", result.errors);
3519
3520        // Verify the signature.
3521        let pubkey = key.verifying_key();
3522        let sig_valid = ChainManager::verify_rvf_signature(&path, &pubkey).unwrap();
3523        assert!(sig_valid, "signature should be valid");
3524
3525        // Verify with wrong key fails.
3526        let wrong_key = SigningKey::generate(&mut OsRng);
3527        let wrong_pubkey = wrong_key.verifying_key();
3528        let sig_wrong = ChainManager::verify_rvf_signature(&path, &wrong_pubkey).unwrap();
3529        assert!(!sig_wrong, "signature should fail with wrong key");
3530
3531        // Clean up.
3532        let _ = std::fs::remove_dir_all(&dir);
3533    }
3534
3535    #[test]
3536    fn ed25519_tampered_checkpoint_fails_verification() {
3537        use ed25519_dalek::SigningKey;
3538        use rand::rngs::OsRng;
3539
3540        let key = SigningKey::generate(&mut OsRng);
3541        let cm = ChainManager::new(0, 1000).with_signing_key(key.clone());
3542        cm.append("kernel", "boot", None);
3543
3544        let dir = std::env::temp_dir().join("clawft-chain-tampered-sig");
3545        let path = dir.join("tampered.rvf");
3546        cm.save_to_rvf(&path).unwrap();
3547
3548        let mut data = std::fs::read(&path).unwrap();
3549        let footer_size = 72; // Ed25519: 2 + 2 + 64 + 4
3550
3551        // Walk segments to find the checkpoint segment start.
3552        let mut offset = 0;
3553        let mut last_seg_start = 0;
3554        while offset + SEGMENT_HEADER_SIZE <= data.len() - footer_size {
3555            match read_segment(&data[offset..]) {
3556                Ok((seg_header, _)) => {
3557                    last_seg_start = offset;
3558                    let padded = calculate_padded_size(
3559                        SEGMENT_HEADER_SIZE,
3560                        seg_header.payload_length as usize,
3561                    );
3562                    offset += padded;
3563                }
3564                Err(_) => break,
3565            }
3566        }
3567
3568        // Tamper with the first byte of the checkpoint segment's payload
3569        // (the ExoChainHeader magic). This is covered by the signature.
3570        data[last_seg_start + SEGMENT_HEADER_SIZE] ^= 0xFF;
3571        std::fs::write(&path, &data).unwrap();
3572
3573        // Signature verification should fail (checkpoint payload was tampered).
3574        let pubkey = key.verifying_key();
3575        // Err is also acceptable — a tampered segment can't be parsed.
3576        if let Ok(valid) = ChainManager::verify_rvf_signature(&path, &pubkey) {
3577            assert!(!valid, "tampered checkpoint should not verify");
3578        }
3579
3580        let _ = std::fs::remove_dir_all(&dir);
3581    }
3582
3583    #[test]
3584    fn unsigned_rvf_loads_successfully() {
3585        // An unsigned chain should still load fine (no signing key).
3586        let cm = ChainManager::new(0, 1000); // no signing key
3587        assert!(!cm.has_signing_key());
3588        cm.append("kernel", "boot", None);
3589        cm.append("test", "event", Some(serde_json::json!({"x": 1})));
3590
3591        let dir = std::env::temp_dir().join("clawft-chain-unsigned-test");
3592        let path = dir.join("unsigned.rvf");
3593        cm.save_to_rvf(&path).unwrap();
3594
3595        let restored = ChainManager::load_from_rvf(&path, 1000).unwrap();
3596        assert_eq!(restored.len(), cm.len());
3597        let result = restored.verify_integrity();
3598        assert!(result.valid);
3599
3600        // verify_rvf_signature should error (no footer present).
3601        let key = ed25519_dalek::SigningKey::generate(&mut rand::rngs::OsRng);
3602        let result = ChainManager::verify_rvf_signature(&path, &key.verifying_key());
3603        assert!(result.is_err(), "no signature footer should yield error");
3604
3605        let _ = std::fs::remove_dir_all(&dir);
3606    }
3607
3608    #[test]
3609    fn load_or_create_key_roundtrip() {
3610        let dir = std::env::temp_dir().join("clawft-key-test");
3611        let key_path = dir.join("test-chain.key");
3612        let _ = std::fs::remove_dir_all(&dir);
3613
3614        // First call: generates a new key.
3615        let key1 = ChainManager::load_or_create_key(&key_path).unwrap();
3616        assert!(key_path.exists());
3617
3618        // Second call: loads the same key.
3619        let key2 = ChainManager::load_or_create_key(&key_path).unwrap();
3620        assert_eq!(key1.to_bytes(), key2.to_bytes());
3621
3622        // Clean up.
3623        let _ = std::fs::remove_dir_all(&dir);
3624    }
3625
3626    #[test]
3627    fn witness_chain_created_on_append() {
3628        let cm = ChainManager::new(0, 1000);
3629        assert_eq!(cm.witness_count(), 1); // genesis
3630        cm.append("kernel", "boot.init", None);
3631        assert_eq!(cm.witness_count(), 2);
3632        cm.append("tree", "bootstrap", Some(serde_json::json!({"n": 8})));
3633        assert_eq!(cm.witness_count(), 3);
3634
3635        // Verify the witness chain is internally consistent.
3636        let count = cm.verify_witness().unwrap();
3637        assert_eq!(count, 3);
3638    }
3639
3640    #[test]
3641    fn witness_chain_persists_in_rvf() {
3642        let cm = ChainManager::new(0, 1000);
3643        cm.append("kernel", "boot.init", None);
3644        cm.append("tree", "bootstrap", Some(serde_json::json!({"n": 8})));
3645        cm.append("kernel", "boot.ready", None);
3646
3647        let original_witness_count = cm.witness_count();
3648        assert_eq!(original_witness_count, 4); // genesis + 3
3649
3650        let dir = std::env::temp_dir().join("clawft-chain-witness-test");
3651        let path = dir.join("witness.rvf");
3652        cm.save_to_rvf(&path).unwrap();
3653
3654        // Load and verify witness chain was restored.
3655        let restored = ChainManager::load_from_rvf(&path, 1000).unwrap();
3656        assert_eq!(restored.witness_count(), original_witness_count);
3657
3658        // Verify the restored witness chain is valid.
3659        let count = restored.verify_witness().unwrap();
3660        assert_eq!(count, original_witness_count);
3661
3662        // Verify that witness action_hashes match event hashes.
3663        let events = restored.tail(0);
3664        let chain = restored.inner.lock().unwrap();
3665        for (event, witness) in events.iter().zip(chain.witness_entries.iter()) {
3666            assert_eq!(
3667                witness.action_hash, event.hash,
3668                "witness action_hash should match event hash for seq {}",
3669                event.sequence,
3670            );
3671        }
3672
3673        // Clean up.
3674        let _ = std::fs::remove_dir_all(&dir);
3675    }
3676
3677    #[test]
3678    fn witness_chain_continues_after_restore() {
3679        let cm = ChainManager::new(0, 1000);
3680        cm.append("kernel", "boot", None);
3681
3682        let dir = std::env::temp_dir().join("clawft-chain-witness-continue");
3683        let path = dir.join("continue.rvf");
3684        cm.save_to_rvf(&path).unwrap();
3685
3686        let restored = ChainManager::load_from_rvf(&path, 1000).unwrap();
3687        assert_eq!(restored.witness_count(), 2); // genesis + 1
3688
3689        // Append new events — witness chain should grow.
3690        restored.append("test", "after.restore", None);
3691        assert_eq!(restored.witness_count(), 3);
3692
3693        // Verify the extended witness chain.
3694        let count = restored.verify_witness().unwrap();
3695        assert_eq!(count, 3);
3696
3697        // Clean up.
3698        let _ = std::fs::remove_dir_all(&dir);
3699    }
3700
3701    #[test]
3702    fn record_witness_bundle_creates_chain_event() {
3703        use rvf_runtime::{GovernancePolicy, WitnessBuilder};
3704        use rvf_types::witness::TaskOutcome;
3705
3706        let cm = ChainManager::new(0, 1000);
3707        let initial_len = cm.len();
3708
3709        let policy = GovernancePolicy::autonomous();
3710        let builder = WitnessBuilder::new([0xAA; 16], policy)
3711            .with_spec(b"fix auth bug")
3712            .with_outcome(TaskOutcome::Solved);
3713        let (bundle, header) = builder.build().unwrap();
3714
3715        let event = cm.record_witness_bundle(&bundle, &header, 0, 0);
3716        assert_eq!(event.source, "witness");
3717        assert_eq!(event.kind, "witness.bundle");
3718        assert_eq!(cm.len(), initial_len + 1);
3719
3720        // Verify payload contains expected fields.
3721        let payload = event.payload.unwrap();
3722        assert_eq!(payload["outcome"], TaskOutcome::Solved as u8);
3723        assert!(!payload["bundle"].as_str().unwrap().is_empty());
3724        assert_eq!(payload["policy_violations"], 0);
3725    }
3726
3727    #[test]
3728    fn aggregate_scorecard_from_witness_bundles() {
3729        use rvf_runtime::{GovernancePolicy, WitnessBuilder};
3730        use rvf_types::witness::TaskOutcome;
3731
3732        let cm = ChainManager::new(0, 1000);
3733        let policy = GovernancePolicy::autonomous();
3734
3735        // Record 3 witness bundles: 2 solved, 1 failed.
3736        let b1 = WitnessBuilder::new([0x01; 16], policy.clone())
3737            .with_spec(b"task 1")
3738            .with_outcome(TaskOutcome::Solved);
3739        let (bytes1, header1) = b1.build().unwrap();
3740        cm.record_witness_bundle(&bytes1, &header1, 0, 0);
3741
3742        let b2 = WitnessBuilder::new([0x02; 16], policy.clone())
3743            .with_spec(b"task 2")
3744            .with_outcome(TaskOutcome::Failed);
3745        let (bytes2, header2) = b2.build().unwrap();
3746        cm.record_witness_bundle(&bytes2, &header2, 1, 0);
3747
3748        let b3 = WitnessBuilder::new([0x03; 16], policy.clone())
3749            .with_spec(b"task 3")
3750            .with_diff(b"diff")
3751            .with_test_log(b"pass")
3752            .with_outcome(TaskOutcome::Solved);
3753        let (bytes3, header3) = b3.build().unwrap();
3754        cm.record_witness_bundle(&bytes3, &header3, 0, 1);
3755
3756        let card = cm.aggregate_scorecard(0);
3757        assert_eq!(card.total_tasks, 3);
3758        assert_eq!(card.solved, 2);
3759        assert_eq!(card.failed, 1);
3760        assert_eq!(card.policy_violations, 1);
3761        assert_eq!(card.rollback_count, 1);
3762        assert!((card.solve_rate - 0.6667).abs() < 0.01);
3763    }
3764
3765    #[test]
3766    fn aggregate_scorecard_empty_when_no_bundles() {
3767        let cm = ChainManager::new(0, 1000);
3768        cm.append("kernel", "boot", None);
3769
3770        let card = cm.aggregate_scorecard(0);
3771        assert_eq!(card.total_tasks, 0);
3772        assert_eq!(card.solve_rate, 0.0);
3773    }
3774
3775    #[test]
3776    fn witness_bundle_with_tool_calls() {
3777        use rvf_runtime::{GovernancePolicy, WitnessBuilder};
3778        use rvf_types::witness::{PolicyCheck, TaskOutcome, ToolCallEntry};
3779
3780        let cm = ChainManager::new(0, 1000);
3781        let policy = GovernancePolicy::autonomous();
3782
3783        let mut builder = WitnessBuilder::new([0x10; 16], policy)
3784            .with_spec(b"add feature")
3785            .with_outcome(TaskOutcome::Solved);
3786
3787        builder.record_tool_call(ToolCallEntry {
3788            action: b"Read".to_vec(),
3789            args_hash: [0x11; 8],
3790            result_hash: [0x22; 8],
3791            latency_ms: 50,
3792            cost_microdollars: 100,
3793            tokens: 500,
3794            policy_check: PolicyCheck::Allowed,
3795        });
3796        builder.record_tool_call(ToolCallEntry {
3797            action: b"Edit".to_vec(),
3798            args_hash: [0x33; 8],
3799            result_hash: [0x44; 8],
3800            latency_ms: 100,
3801            cost_microdollars: 200,
3802            tokens: 1000,
3803            policy_check: PolicyCheck::Allowed,
3804        });
3805
3806        let (bundle, header) = builder.build().unwrap();
3807        assert_eq!(header.tool_call_count, 2);
3808        assert_eq!(header.total_cost_microdollars, 300);
3809
3810        let event = cm.record_witness_bundle(&bundle, &header, 0, 0);
3811        let payload = event.payload.unwrap();
3812        assert_eq!(payload["tool_call_count"], 2);
3813        assert_eq!(payload["total_cost_microdollars"], 300);
3814
3815        // Scorecard should aggregate this bundle.
3816        let card = cm.aggregate_scorecard(0);
3817        assert_eq!(card.total_tasks, 1);
3818        assert_eq!(card.solved, 1);
3819    }
3820
3821    #[test]
3822    fn record_lineage_creates_chain_event() {
3823        use rvf_types::DerivationType;
3824
3825        let cm = ChainManager::new(0, 1000);
3826        let initial_len = cm.len();
3827
3828        let event = cm.record_lineage(
3829            [0x01; 16], // child_id
3830            [0x00; 16], // parent_id (root)
3831            [0x00; 32], // parent_hash (root)
3832            DerivationType::Clone,
3833            0,
3834            "root agent",
3835        );
3836
3837        assert_eq!(event.source, "lineage");
3838        assert_eq!(event.kind, "lineage.derivation");
3839        assert_eq!(cm.len(), initial_len + 1);
3840
3841        let payload = event.payload.unwrap();
3842        assert_eq!(payload["derivation_type"], DerivationType::Clone as u8);
3843        assert_eq!(payload["description"], "root agent");
3844        assert!(!payload["record_hex"].as_str().unwrap().is_empty());
3845    }
3846
3847    #[test]
3848    fn record_lineage_parent_child() {
3849        use rvf_types::DerivationType;
3850
3851        let cm = ChainManager::new(0, 1000);
3852
3853        // Root agent.
3854        let root_event = cm.record_lineage(
3855            [0x01; 16],
3856            [0x00; 16],
3857            [0x00; 32],
3858            DerivationType::Clone,
3859            0,
3860            "root agent",
3861        );
3862
3863        // Child agent derived from root.
3864        let child_event = cm.record_lineage(
3865            [0x02; 16],
3866            [0x01; 16],
3867            root_event.hash,
3868            DerivationType::Transform,
3869            1,
3870            "spawned worker",
3871        );
3872
3873        let payload = child_event.payload.unwrap();
3874        assert_eq!(payload["derivation_type"], DerivationType::Transform as u8);
3875        assert_eq!(payload["mutation_count"], 1);
3876
3877        // Verify lineage chain.
3878        let count = cm.verify_lineage().unwrap();
3879        assert_eq!(count, 2);
3880    }
3881
3882    #[test]
3883    fn lineage_adds_witness_entry() {
3884        use rvf_types::DerivationType;
3885
3886        let cm = ChainManager::new(0, 1000);
3887        let initial_witness_count = cm.witness_count();
3888
3889        cm.record_lineage(
3890            [0x01; 16],
3891            [0x00; 16],
3892            [0x00; 32],
3893            DerivationType::Clone,
3894            0,
3895            "agent",
3896        );
3897
3898        // One extra witness entry: the lineage witness + the chain append witness.
3899        assert!(cm.witness_count() > initial_witness_count);
3900    }
3901
3902    #[test]
3903    fn verify_lineage_empty_returns_zero() {
3904        let cm = ChainManager::new(0, 1000);
3905        let count = cm.verify_lineage().unwrap();
3906        assert_eq!(count, 0);
3907    }
3908
3909    #[test]
3910    fn lineage_record_hex_roundtrip() {
3911        use rvf_types::DerivationType;
3912
3913        let cm = ChainManager::new(0, 1000);
3914        let event = cm.record_lineage(
3915            [0xAA; 16],
3916            [0xBB; 16],
3917            [0xCC; 32],
3918            DerivationType::Filter,
3919            42,
3920            "filtered set",
3921        );
3922
3923        // Extract and decode the record_hex from the payload.
3924        let payload = event.payload.unwrap();
3925        let record_hex = payload["record_hex"].as_str().unwrap();
3926        let record_bytes = hex_decode(record_hex).unwrap();
3927        assert_eq!(record_bytes.len(), 128); // LINEAGE_RECORD_SIZE
3928
3929        let record: rvf_types::LineageRecord = weftos_rvf_crypto::lineage_record_from_bytes(
3930            record_bytes.as_slice().try_into().unwrap(),
3931        )
3932        .unwrap();
3933        assert_eq!(record.file_id, [0xAA; 16]);
3934        assert_eq!(record.parent_id, [0xBB; 16]);
3935        assert_eq!(record.parent_hash, [0xCC; 32]);
3936        assert_eq!(record.derivation_type, DerivationType::Filter);
3937        assert_eq!(record.mutation_count, 42);
3938        assert_eq!(record.description_str(), "filtered set");
3939    }
3940
3941    #[test]
3942    fn last_tree_root_hash_from_shutdown() {
3943        let cm = ChainManager::new(0, 1000);
3944        // No tree hash yet.
3945        assert!(cm.last_tree_root_hash().is_none());
3946
3947        // Simulate a shutdown event with tree_root_hash.
3948        cm.append(
3949            "kernel",
3950            "shutdown",
3951            Some(serde_json::json!({
3952                "tree_root_hash": "aabb00112233445566778899",
3953                "chain_seq": 5,
3954            })),
3955        );
3956
3957        let hash = cm.last_tree_root_hash().unwrap();
3958        assert_eq!(hash, "aabb00112233445566778899");
3959    }
3960
3961    #[test]
3962    fn last_tree_root_hash_from_tree_checkpoint() {
3963        let cm = ChainManager::new(0, 1000);
3964
3965        // tree.checkpoint event uses "root_hash" key.
3966        cm.append(
3967            "tree",
3968            "tree.checkpoint",
3969            Some(serde_json::json!({
3970                "path": "/tmp/tree.json",
3971                "root_hash": "deadbeef01234567890abcdef0123456",
3972            })),
3973        );
3974
3975        let hash = cm.last_tree_root_hash().unwrap();
3976        assert_eq!(hash, "deadbeef01234567890abcdef0123456");
3977    }
3978
3979    #[test]
3980    fn last_tree_root_hash_prefers_most_recent() {
3981        let cm = ChainManager::new(0, 1000);
3982
3983        // Older event.
3984        cm.append(
3985            "kernel",
3986            "boot.ready",
3987            Some(serde_json::json!({ "tree_root_hash": "old_hash" })),
3988        );
3989
3990        // More recent event.
3991        cm.append(
3992            "kernel",
3993            "shutdown",
3994            Some(serde_json::json!({ "tree_root_hash": "new_hash" })),
3995        );
3996
3997        let hash = cm.last_tree_root_hash().unwrap();
3998        assert_eq!(hash, "new_hash");
3999    }
4000
4001    #[test]
4002    fn last_tree_root_hash_ignores_non_tree_root_hash() {
4003        let cm = ChainManager::new(0, 1000);
4004        // Event with root_hash but wrong source/kind — should be ignored.
4005        cm.append(
4006            "kernel",
4007            "boot.init",
4008            Some(serde_json::json!({ "root_hash": "should_not_match" })),
4009        );
4010        assert!(cm.last_tree_root_hash().is_none());
4011    }
4012
4013    // --- K4 G1: ChainAnchor tests ---
4014
4015    #[test]
4016    fn mock_anchor_roundtrip() {
4017        let anchor = MockAnchor;
4018        let hash = [42u8; 32];
4019        let receipt = anchor.anchor(&hash).unwrap();
4020        assert_eq!(receipt.hash, hash);
4021        assert!(receipt.tx_id.starts_with("mock-"));
4022        assert!(anchor.verify(&receipt).unwrap());
4023    }
4024
4025    #[test]
4026    fn anchor_receipt_serde() {
4027        let receipt = AnchorReceipt {
4028            hash: [1u8; 32],
4029            tx_id: "test-tx".into(),
4030            anchored_at: Utc::now(),
4031        };
4032        let json = serde_json::to_string(&receipt).unwrap();
4033        let restored: AnchorReceipt = serde_json::from_str(&json).unwrap();
4034        assert_eq!(restored.hash, receipt.hash);
4035        assert_eq!(restored.tx_id, "test-tx");
4036    }
4037
4038    #[test]
4039    fn ml_dsa_key_set_and_has() {
4040        let mut cm = ChainManager::new(0, 1000);
4041        assert!(!cm.has_dual_signing());
4042
4043        // Ed25519 only
4044        let ed_key = ed25519_dalek::SigningKey::generate(&mut rand::rngs::OsRng);
4045        cm.set_signing_key(ed_key);
4046        assert!(cm.has_signing_key());
4047        assert!(!cm.has_dual_signing());
4048
4049        // Add ML-DSA key
4050        let (ml_key, _) = weftos_rvf_crypto::MlDsa65Key::generate(b"test-seed");
4051        cm.set_ml_dsa_key(ml_key);
4052        assert!(cm.has_dual_signing());
4053    }
4054
4055    #[test]
4056    fn dual_sign_rvf_roundtrip() {
4057        use ed25519_dalek::SigningKey;
4058        use rand::rngs::OsRng;
4059
4060        let ed_key = SigningKey::generate(&mut OsRng);
4061        let (ml_key, ml_vk) = weftos_rvf_crypto::MlDsa65Key::generate(&ed_key.to_bytes());
4062
4063        let mut cm = ChainManager::new(0, 1000).with_signing_key(ed_key.clone());
4064        cm.set_ml_dsa_key(ml_key);
4065        assert!(cm.has_dual_signing());
4066
4067        cm.append("kernel", "boot.init", None);
4068        cm.append("tree", "bootstrap", Some(serde_json::json!({"nodes": 4})));
4069        cm.append("kernel", "boot.ready", None);
4070
4071        let original_seq = cm.sequence();
4072        let original_hash = cm.last_hash();
4073        let original_len = cm.len();
4074
4075        let dir = std::env::temp_dir().join("clawft-chain-dual-sign-test");
4076        let path = dir.join("dual-signed.rvf");
4077        cm.save_to_rvf(&path).unwrap();
4078
4079        // Load the dual-signed file (should work without keys).
4080        let restored = ChainManager::load_from_rvf(&path, 1000).unwrap();
4081        assert_eq!(restored.sequence(), original_seq);
4082        assert_eq!(restored.last_hash(), original_hash);
4083        assert_eq!(restored.len(), original_len);
4084
4085        let result = restored.verify_integrity();
4086        assert!(result.valid, "integrity errors: {:?}", result.errors);
4087
4088        // Verify Ed25519 signature alone.
4089        let ed_pubkey = ed_key.verifying_key();
4090        let ed_valid = ChainManager::verify_rvf_signature(&path, &ed_pubkey).unwrap();
4091        assert!(ed_valid, "Ed25519 signature should be valid");
4092
4093        // Verify dual signatures.
4094        let dual_valid =
4095            ChainManager::verify_rvf_dual_signature(&path, &ed_pubkey, &ml_vk).unwrap();
4096        assert!(dual_valid, "dual signature should be valid");
4097
4098        // Wrong ML-DSA key should fail dual verification.
4099        let (_, wrong_ml_vk) = weftos_rvf_crypto::MlDsa65Key::generate(b"wrong-seed");
4100        let dual_wrong =
4101            ChainManager::verify_rvf_dual_signature(&path, &ed_pubkey, &wrong_ml_vk).unwrap();
4102        assert!(
4103            !dual_wrong,
4104            "dual signature should fail with wrong ML-DSA key"
4105        );
4106
4107        let _ = std::fs::remove_dir_all(&dir);
4108    }
4109
4110    #[test]
4111    fn dual_signed_checkpoint_verifies() {
4112        use ed25519_dalek::SigningKey;
4113        use rand::rngs::OsRng;
4114
4115        let ed_key = SigningKey::generate(&mut OsRng);
4116        let (ml_key, ml_vk) = weftos_rvf_crypto::MlDsa65Key::generate(b"checkpoint-test");
4117
4118        let mut cm = ChainManager::new(0, 1000).with_signing_key(ed_key.clone());
4119        cm.set_ml_dsa_key(ml_key);
4120        cm.append("kernel", "boot", None);
4121
4122        let dir = std::env::temp_dir().join("clawft-chain-dual-cp-test");
4123        let path = dir.join("dual-cp.rvf");
4124        cm.save_to_rvf(&path).unwrap();
4125
4126        // Read file and verify it has two footers.
4127        let data = std::fs::read(&path).unwrap();
4128        let mut offset = 0;
4129        while offset + SEGMENT_HEADER_SIZE <= data.len() {
4130            match read_segment(&data[offset..]) {
4131                Ok((seg_header, _)) => {
4132                    let padded = calculate_padded_size(
4133                        SEGMENT_HEADER_SIZE,
4134                        seg_header.payload_length as usize,
4135                    );
4136                    offset += padded;
4137                }
4138                Err(_) => break,
4139            }
4140        }
4141        // First footer: Ed25519
4142        let ed_footer = decode_signature_footer(&data[offset..]).unwrap();
4143        assert_eq!(ed_footer.sig_algo, 0); // Ed25519
4144        assert_eq!(ed_footer.sig_length, 64);
4145
4146        // Second footer: ML-DSA-65
4147        let ml_offset = offset + ed_footer.footer_length as usize;
4148        let ml_footer = decode_signature_footer(&data[ml_offset..]).unwrap();
4149        assert_eq!(ml_footer.sig_algo, 1); // ML-DSA-65
4150        assert_eq!(ml_footer.sig_length, 3309);
4151
4152        // Verify both independently.
4153        let ed_pubkey = ed_key.verifying_key();
4154        let dual_valid =
4155            ChainManager::verify_rvf_dual_signature(&path, &ed_pubkey, &ml_vk).unwrap();
4156        assert!(dual_valid);
4157
4158        let _ = std::fs::remove_dir_all(&dir);
4159    }
4160
4161    #[test]
4162    fn k6_cryptographic_filesystem_creates_and_retrieves() {
4163        // This test demonstrates the "cryptographic filesystem" gate:
4164        // chain entries are hash-linked (SHAKE-256) and form a tamper-evident
4165        // append-only log with retrieval — proving cryptographic filesystem semantics.
4166
4167        let cm = ChainManager::new(0, 1000);
4168
4169        // Create a filesystem-style entry with content metadata
4170        let entry = cm.append(
4171            "fs",
4172            "file.create",
4173            Some(serde_json::json!({
4174                "path": "/data/config.json",
4175                "content_hash": "abc123def456",
4176                "size": 1024,
4177            })),
4178        );
4179
4180        // Verify hash linkage (cryptographic integrity)
4181        assert!(
4182            !entry.hash.iter().all(|&b| b == 0),
4183            "entry hash must be non-zero"
4184        );
4185        assert!(
4186            !entry.prev_hash.iter().all(|&b| b == 0),
4187            "prev_hash must link to genesis (non-zero)"
4188        );
4189        assert!(
4190            !entry.payload_hash.iter().all(|&b| b == 0),
4191            "payload_hash must be non-zero when payload present"
4192        );
4193
4194        // Retrieve entry via tail_from (sequence > genesis)
4195        let events = cm.tail_from(0);
4196        assert!(
4197            !events.is_empty(),
4198            "must retrieve at least the created entry"
4199        );
4200        let found = events.iter().find(|e| e.kind == "file.create");
4201        assert!(found.is_some(), "file.create entry must be retrievable");
4202
4203        let retrieved = found.unwrap();
4204        assert_eq!(retrieved.hash, entry.hash);
4205        let payload = retrieved.payload.as_ref().unwrap();
4206        assert_eq!(payload["path"], "/data/config.json");
4207        assert_eq!(payload["content_hash"], "abc123def456");
4208        assert_eq!(payload["size"], 1024);
4209
4210        // Verify chain integrity covers this entry
4211        let result = cm.verify_integrity();
4212        assert!(result.valid, "chain integrity must hold after fs entry");
4213    }
4214
4215    // ── Cross-node dual signature tests ──────────────────────────
4216
4217    #[test]
4218    fn dual_signature_ed25519_only() {
4219        use ed25519_dalek::SigningKey;
4220        use rand::rngs::OsRng;
4221
4222        let ed_key = SigningKey::generate(&mut OsRng);
4223        let cm = ChainManager::new(0, 1000).with_signing_key(ed_key.clone());
4224        // No ML-DSA key set — dual_sign should still succeed with Ed25519 only.
4225
4226        let data = b"cross-node chain event payload";
4227        let sig = cm.dual_sign(data).expect("should produce a signature");
4228
4229        assert_eq!(sig.ed25519.len(), 64, "Ed25519 signature must be 64 bytes");
4230        assert!(
4231            sig.ml_dsa65.is_none(),
4232            "ML-DSA-65 should be absent without key"
4233        );
4234    }
4235
4236    #[test]
4237    fn dual_signature_both_algorithms() {
4238        use ed25519_dalek::SigningKey;
4239        use rand::rngs::OsRng;
4240
4241        let ed_key = SigningKey::generate(&mut OsRng);
4242        let (ml_key, _ml_vk) = weftos_rvf_crypto::MlDsa65Key::generate(b"dual-sig-test");
4243
4244        let mut cm = ChainManager::new(0, 1000).with_signing_key(ed_key.clone());
4245        cm.set_ml_dsa_key(ml_key);
4246        assert!(cm.has_dual_signing());
4247
4248        let data = b"cross-node chain event with PQ protection";
4249        let sig = cm.dual_sign(data).expect("should produce dual signature");
4250
4251        assert_eq!(sig.ed25519.len(), 64);
4252        let ml = sig.ml_dsa65.as_ref().expect("ML-DSA-65 should be present");
4253        assert_eq!(ml.len(), 3309, "ML-DSA-65 signature must be 3309 bytes");
4254    }
4255
4256    #[test]
4257    fn verify_dual_signature_ed25519_valid() {
4258        use ed25519_dalek::SigningKey;
4259        use rand::rngs::OsRng;
4260
4261        let ed_key = SigningKey::generate(&mut OsRng);
4262        let cm = ChainManager::new(0, 1000).with_signing_key(ed_key.clone());
4263
4264        let data = b"verify-ed25519-only";
4265        let sig = cm.dual_sign(data).unwrap();
4266        let ed_pub = ed_key.verifying_key();
4267
4268        assert!(
4269            ChainManager::verify_dual_signature(data, &sig, &ed_pub, None),
4270            "Ed25519-only dual signature should verify"
4271        );
4272    }
4273
4274    #[test]
4275    fn verify_dual_signature_both_valid() {
4276        use ed25519_dalek::SigningKey;
4277        use rand::rngs::OsRng;
4278
4279        let ed_key = SigningKey::generate(&mut OsRng);
4280        let (ml_key, ml_vk) = weftos_rvf_crypto::MlDsa65Key::generate(b"verify-both");
4281
4282        let mut cm = ChainManager::new(0, 1000).with_signing_key(ed_key.clone());
4283        cm.set_ml_dsa_key(ml_key);
4284
4285        let data = b"verify-both-algorithms";
4286        let sig = cm.dual_sign(data).unwrap();
4287        let ed_pub = ed_key.verifying_key();
4288
4289        assert!(
4290            ChainManager::verify_dual_signature(data, &sig, &ed_pub, Some(&ml_vk)),
4291            "dual signature with both algorithms should verify"
4292        );
4293    }
4294
4295    #[test]
4296    fn verify_dual_signature_rejects_tampered() {
4297        use ed25519_dalek::SigningKey;
4298        use rand::rngs::OsRng;
4299
4300        let ed_key = SigningKey::generate(&mut OsRng);
4301        let (ml_key, ml_vk) = weftos_rvf_crypto::MlDsa65Key::generate(b"tamper-test");
4302
4303        let mut cm = ChainManager::new(0, 1000).with_signing_key(ed_key.clone());
4304        cm.set_ml_dsa_key(ml_key);
4305
4306        let data = b"original data";
4307        let sig = cm.dual_sign(data).unwrap();
4308        let ed_pub = ed_key.verifying_key();
4309
4310        // Tampered data should fail Ed25519 verification.
4311        let tampered = b"tampered data";
4312        assert!(
4313            !ChainManager::verify_dual_signature(tampered, &sig, &ed_pub, Some(&ml_vk)),
4314            "tampered data must fail verification"
4315        );
4316
4317        // Tampered ML-DSA-65 signature should fail.
4318        let mut bad_sig = sig.clone();
4319        if let Some(ref mut ml) = bad_sig.ml_dsa65 {
4320            ml[0] ^= 0xFF;
4321        }
4322        assert!(
4323            !ChainManager::verify_dual_signature(data, &bad_sig, &ed_pub, Some(&ml_vk)),
4324            "tampered ML-DSA-65 signature must fail verification"
4325        );
4326    }
4327
4328    // ── Sprint 09a: serde roundtrip tests for chain types ────────
4329
4330    #[test]
4331    fn chain_event_serde_roundtrip() {
4332        let cm = ChainManager::new(0, 1000);
4333        cm.append(
4334            "test",
4335            "agent.spawn",
4336            Some(serde_json::json!({"name": "test-agent"})),
4337        );
4338        let events = cm.tail(1);
4339        let event = &events[0];
4340
4341        let json = serde_json::to_string(event).unwrap();
4342        let restored: ChainEvent = serde_json::from_str(&json).unwrap();
4343        assert_eq!(restored.sequence, event.sequence);
4344        assert_eq!(restored.source, "test");
4345        assert_eq!(restored.kind, "agent.spawn");
4346        assert!(restored.payload.is_some());
4347    }
4348
4349    #[test]
4350    fn chain_event_without_payload_roundtrip() {
4351        let cm = ChainManager::new(0, 1000);
4352        cm.append("kernel", "boot.complete", None);
4353        let events = cm.tail(1);
4354        let event = &events[0];
4355
4356        let json = serde_json::to_string(event).unwrap();
4357        let restored: ChainEvent = serde_json::from_str(&json).unwrap();
4358        assert!(restored.payload.is_none());
4359        assert_eq!(restored.kind, "boot.complete");
4360    }
4361
4362    #[test]
4363    fn chain_checkpoint_serde_roundtrip() {
4364        let cm = ChainManager::new(0, 1000);
4365        cm.append("test", "event.1", None);
4366        cm.append("test", "event.2", None);
4367        let cp = cm.checkpoint();
4368
4369        let json = serde_json::to_string(&cp).unwrap();
4370        let restored: ChainCheckpoint = serde_json::from_str(&json).unwrap();
4371        assert_eq!(restored.chain_id, cp.chain_id);
4372        assert_eq!(restored.sequence, cp.sequence);
4373        assert_eq!(restored.last_hash, cp.last_hash);
4374    }
4375
4376    #[test]
4377    fn chain_verify_result_serde_roundtrip_valid() {
4378        let result = ChainVerifyResult {
4379            valid: true,
4380            event_count: 10,
4381            errors: vec![],
4382            signature_verified: Some(true),
4383        };
4384        let json = serde_json::to_string(&result).unwrap();
4385        let restored: ChainVerifyResult = serde_json::from_str(&json).unwrap();
4386        assert!(restored.valid);
4387        assert_eq!(restored.event_count, 10);
4388        assert!(restored.errors.is_empty());
4389        assert_eq!(restored.signature_verified, Some(true));
4390    }
4391
4392    #[test]
4393    fn chain_verify_result_serde_roundtrip_invalid() {
4394        let result = ChainVerifyResult {
4395            valid: false,
4396            event_count: 5,
4397            errors: vec!["hash mismatch at seq 3".into()],
4398            signature_verified: None,
4399        };
4400        let json = serde_json::to_string(&result).unwrap();
4401        let restored: ChainVerifyResult = serde_json::from_str(&json).unwrap();
4402        assert!(!restored.valid);
4403        assert_eq!(restored.errors.len(), 1);
4404        assert!(restored.signature_verified.is_none());
4405    }
4406
4407    #[test]
4408    fn chain_status_serde_roundtrip() {
4409        let cm = ChainManager::new(42, 1000);
4410        cm.append("test", "event.1", None);
4411        cm.append("test", "event.2", None);
4412        let status = cm.status();
4413
4414        let json = serde_json::to_string(&status).unwrap();
4415        let restored: ChainStatus = serde_json::from_str(&json).unwrap();
4416        assert_eq!(restored.chain_id, 42);
4417        assert_eq!(restored.sequence, status.sequence);
4418        assert_eq!(restored.event_count, status.event_count);
4419    }
4420
4421    #[test]
4422    fn dual_signature_serde_roundtrip() {
4423        let sig = DualSignature {
4424            ed25519: vec![0xCA, 0xFE, 0xBA, 0xBE],
4425            ml_dsa65: Some(vec![0xDE, 0xAD]),
4426        };
4427        let json = serde_json::to_string(&sig).unwrap();
4428        let restored: DualSignature = serde_json::from_str(&json).unwrap();
4429        assert_eq!(restored.ed25519, vec![0xCA, 0xFE, 0xBA, 0xBE]);
4430        assert_eq!(restored.ml_dsa65.unwrap(), vec![0xDE, 0xAD]);
4431    }
4432
4433    #[test]
4434    fn dual_signature_without_ml_dsa_roundtrip() {
4435        let sig = DualSignature {
4436            ed25519: vec![0x01, 0x02],
4437            ml_dsa65: None,
4438        };
4439        let json = serde_json::to_string(&sig).unwrap();
4440        let restored: DualSignature = serde_json::from_str(&json).unwrap();
4441        assert!(restored.ml_dsa65.is_none());
4442    }
4443
4444    #[test]
4445    fn anchor_receipt_serde_roundtrip() {
4446        let receipt = AnchorReceipt {
4447            hash: [0xABu8; 32],
4448            tx_id: "tx-deadbeef".into(),
4449            anchored_at: chrono::Utc::now(),
4450        };
4451        let json = serde_json::to_string(&receipt).unwrap();
4452        let restored: AnchorReceipt = serde_json::from_str(&json).unwrap();
4453        assert_eq!(restored.hash, [0xABu8; 32]);
4454        assert_eq!(restored.tx_id, "tx-deadbeef");
4455    }
4456
4457    #[test]
4458    fn chain_genesis_event_hash_is_nonzero() {
4459        let cm = ChainManager::new(0, 1000);
4460        let events = cm.tail(1);
4461        assert_eq!(events[0].kind, "genesis");
4462        assert_ne!(events[0].hash, [0u8; 32]);
4463    }
4464
4465    #[test]
4466    fn chain_genesis_prev_hash_is_zero() {
4467        let cm = ChainManager::new(0, 1000);
4468        let events = cm.tail(1);
4469        assert_eq!(events[0].prev_hash, [0u8; 32]);
4470    }
4471
4472    // ── ChainLoggable trait tests ────────────────────────────────
4473
4474    #[test]
4475    fn restart_event_loggable() {
4476        let cm = ChainManager::new(0, 100);
4477        let initial = cm.len();
4478
4479        let event = RestartEvent {
4480            agent_id: "agent-coder".into(),
4481            old_pid: 5,
4482            new_pid: 12,
4483            exit_code: 1,
4484            strategy: "OneForOne".into(),
4485            backoff_ms: 200,
4486            timestamp: Utc::now(),
4487        };
4488
4489        assert_eq!(event.chain_event_source(), "supervisor");
4490        assert_eq!(event.chain_event_kind(), "supervisor.restart");
4491
4492        let chain_event = cm.append_loggable(&event);
4493        assert_eq!(cm.len(), initial + 1);
4494        assert_eq!(chain_event.source, "supervisor");
4495        assert_eq!(chain_event.kind, "supervisor.restart");
4496
4497        let payload = chain_event.payload.unwrap();
4498        assert_eq!(payload["agent_id"], "agent-coder");
4499        assert_eq!(payload["old_pid"], 5);
4500        assert_eq!(payload["new_pid"], 12);
4501        assert_eq!(payload["exit_code"], 1);
4502    }
4503
4504    #[test]
4505    fn governance_decision_event_loggable() {
4506        let cm = ChainManager::new(0, 100);
4507
4508        let event = GovernanceDecisionEvent {
4509            agent_id: "agent-1".into(),
4510            action: "tool.exec".into(),
4511            decision: "Deny".into(),
4512            effect_magnitude: 0.85,
4513            threshold_exceeded: true,
4514            evaluated_rules: vec!["security-check".into()],
4515            timestamp: Utc::now(),
4516            principal: None,
4517        };
4518
4519        assert_eq!(event.chain_event_source(), "governance");
4520        assert_eq!(event.chain_event_kind(), "governance.deny");
4521
4522        let chain_event = cm.append_loggable(&event);
4523        assert_eq!(chain_event.kind, "governance.deny");
4524
4525        let payload = chain_event.payload.unwrap();
4526        assert_eq!(payload["agent_id"], "agent-1");
4527        assert_eq!(payload["action"], "tool.exec");
4528        assert!(payload["threshold_exceeded"].as_bool().unwrap());
4529
4530        // Test other decision kinds map to correct event kinds
4531        let make_event = |decision: &str| GovernanceDecisionEvent {
4532            agent_id: "a".into(),
4533            action: "a".into(),
4534            decision: decision.into(),
4535            effect_magnitude: 0.0,
4536            threshold_exceeded: false,
4537            evaluated_rules: vec![],
4538            timestamp: Utc::now(),
4539            principal: None,
4540        };
4541
4542        assert_eq!(make_event("Permit").chain_event_kind(), "governance.permit");
4543        assert_eq!(
4544            make_event("PermitWithWarning").chain_event_kind(),
4545            "governance.warn"
4546        );
4547        assert_eq!(
4548            make_event("EscalateToHuman").chain_event_kind(),
4549            "governance.defer"
4550        );
4551        assert_eq!(make_event("Deny").chain_event_kind(), "governance.deny");
4552    }
4553
4554    #[test]
4555    fn ipc_dead_letter_event_loggable() {
4556        let cm = ChainManager::new(0, 100);
4557
4558        let event = IpcDeadLetterEvent {
4559            message_id: "msg-abc".into(),
4560            from_pid: 3,
4561            target: "Process(99)".into(),
4562            payload_type: "text".into(),
4563            reason: "target_not_found(pid=99)".into(),
4564            timestamp: Utc::now(),
4565        };
4566
4567        assert_eq!(event.chain_event_source(), "ipc");
4568        assert_eq!(event.chain_event_kind(), "ipc.dead_letter");
4569
4570        let chain_event = cm.append_loggable(&event);
4571        assert_eq!(chain_event.source, "ipc");
4572        assert_eq!(chain_event.kind, "ipc.dead_letter");
4573
4574        let payload = chain_event.payload.unwrap();
4575        assert_eq!(payload["message_id"], "msg-abc");
4576        assert_eq!(payload["from_pid"], 3);
4577        assert_eq!(payload["reason"], "target_not_found(pid=99)");
4578    }
4579
4580    #[test]
4581    fn append_loggable_links_hashes() {
4582        let cm = ChainManager::new(0, 100);
4583        let hash_before = cm.last_hash();
4584
4585        let event = RestartEvent {
4586            agent_id: "test".into(),
4587            old_pid: 1,
4588            new_pid: 2,
4589            exit_code: 1,
4590            strategy: "OneForOne".into(),
4591            backoff_ms: 100,
4592            timestamp: Utc::now(),
4593        };
4594
4595        let chain_event = cm.append_loggable(&event);
4596        assert_eq!(chain_event.prev_hash, hash_before);
4597        assert_ne!(chain_event.hash, [0u8; 32]);
4598    }
4599
4600    #[test]
4601    fn generate_attestation_without_key_returns_none() {
4602        let cm = ChainManager::new(0, 1000);
4603        // No signing key set
4604        assert!(cm.generate_attestation(10, 5, "abc123").is_none());
4605    }
4606
4607    #[test]
4608    fn generate_attestation_with_key() {
4609        use rand::rngs::OsRng;
4610        let key = SigningKey::generate(&mut OsRng);
4611        let cm = ChainManager::new(0, 1000).with_signing_key(key.clone());
4612
4613        // Append some events
4614        cm.append("test", "event.one", None);
4615        cm.append("test", "event.two", None);
4616
4617        let att = cm.generate_attestation(42, 7, "deadbeef").unwrap();
4618
4619        // Verify fields
4620        assert!(!att.device_id.is_empty());
4621        assert_eq!(att.epoch, 7);
4622        assert_eq!(att.vector_count, 42);
4623        assert_eq!(att.content_hash, "deadbeef");
4624        assert_eq!(att.chain_depth, 3); // genesis + 2
4625        assert!(!att.chain_head.is_empty());
4626        assert_eq!(att.chain_head.len(), 64); // 32 bytes hex
4627        assert!(att.timestamp > 0);
4628        assert_eq!(att.signature.len(), 64); // Ed25519 signature
4629
4630        // Verify the signature
4631        use ed25519_dalek::Verifier;
4632        let canonical = format!(
4633            "custody-attestation:v1\n\
4634             device_id:{}\n\
4635             epoch:{}\n\
4636             chain_head:{}\n\
4637             chain_depth:{}\n\
4638             vector_count:{}\n\
4639             content_hash:{}\n\
4640             timestamp:{}",
4641            att.device_id,
4642            att.epoch,
4643            att.chain_head,
4644            att.chain_depth,
4645            att.vector_count,
4646            att.content_hash,
4647            att.timestamp,
4648        );
4649        let sig =
4650            ed25519_dalek::Signature::from_bytes(att.signature.as_slice().try_into().unwrap());
4651        assert!(
4652            key.verifying_key()
4653                .verify(canonical.as_bytes(), &sig)
4654                .is_ok()
4655        );
4656    }
4657
4658    #[test]
4659    fn custody_attestation_serde_roundtrip() {
4660        let att = CustodyAttestation {
4661            device_id: "aabbccdd".into(),
4662            epoch: 5,
4663            chain_head: "ff".repeat(32),
4664            chain_depth: 100,
4665            vector_count: 1000,
4666            content_hash: "00".repeat(32),
4667            timestamp: 1234567890,
4668            signature: vec![0u8; 64],
4669        };
4670        let json = serde_json::to_string(&att).unwrap();
4671        let restored: CustodyAttestation = serde_json::from_str(&json).unwrap();
4672        assert_eq!(restored.device_id, "aabbccdd");
4673        assert_eq!(restored.epoch, 5);
4674        assert_eq!(restored.chain_depth, 100);
4675        assert_eq!(restored.vector_count, 1000);
4676        assert_eq!(restored.signature.len(), 64);
4677    }
4678}