Skip to main content

vela_protocol/
events.rs

1//! Canonical replayable frontier events.
2//!
3//! Events are the authoritative record for user-visible state transitions in
4//! the finding-centered v0 kernel. Frontier snapshots remain the convenient
5//! materialized state, but checks and proof packets can validate the event log.
6
7use std::collections::{BTreeMap, BTreeSet};
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use serde_json::{Value, json};
12use sha2::{Digest, Sha256};
13
14use crate::bundle::FindingBundle;
15use crate::canonical;
16use crate::project::Project;
17
18pub const EVENT_SCHEMA: &str = "vela.event.v0.1";
19pub const NULL_HASH: &str = "sha256:null";
20
21/// v0.49: explicit event kind for actor-key revocation. Coalition
22/// governance promises that key compromise is handled by a signed
23/// `RevocationEvent` that names the key, the moment of compromise,
24/// and the recommended replacement. This constant pairs with
25/// `RevocationPayload` and `new_revocation_event` below.
26///
27/// Existing signed history stays valid as a record of what was
28/// signed when; clients that re-verify against the post-revocation
29/// actor list flag any signature whose `signed_at` is after the
30/// `revoked_at` moment. The hub is transport, not authority — it
31/// stores the revocation alongside the entries that referenced the
32/// revoked key, lets readers decide.
33pub const EVENT_KIND_KEY_REVOKE: &str = "key.revoke";
34
35/// v0.49: NegativeResult lifecycle event kinds. Pair with the
36/// `NegativeResult` first-class object in `bundle.rs`. The substrate
37/// records nulls through the same proposal -> canonical event ->
38/// reducer pipeline as findings, so an underpowered Phase III readout
39/// and a confirmatory replication-failure both leave the same kind of
40/// auditable trace.
41pub const EVENT_KIND_NEGATIVE_RESULT_ASSERTED: &str = "negative_result.asserted";
42pub const EVENT_KIND_NEGATIVE_RESULT_REVIEWED: &str = "negative_result.reviewed";
43pub const EVENT_KIND_NEGATIVE_RESULT_RETRACTED: &str = "negative_result.retracted";
44
45/// v0.50: Trajectory lifecycle event kinds. Pair with the
46/// `Trajectory` first-class object in `bundle.rs`. The substrate
47/// records search paths through the same proposal -> canonical event
48/// -> reducer pipeline as findings, so an agent that explored five
49/// branches before arriving at a finding leaves a step-by-step audit
50/// the next agent can read instead of re-deriving.
51pub const EVENT_KIND_TRAJECTORY_CREATED: &str = "trajectory.created";
52pub const EVENT_KIND_TRAJECTORY_STEP_APPENDED: &str = "trajectory.step_appended";
53pub const EVENT_KIND_TRAJECTORY_REVIEWED: &str = "trajectory.reviewed";
54pub const EVENT_KIND_TRAJECTORY_RETRACTED: &str = "trajectory.retracted";
55
56/// Generic artifact lifecycle. Carries the full `Artifact` inline on
57/// `payload.artifact` so protocol snapshots can be replayed without
58/// resolving a sidecar file first.
59pub const EVENT_KIND_ARTIFACT_ASSERTED: &str = "artifact.asserted";
60pub const EVENT_KIND_ARTIFACT_REVIEWED: &str = "artifact.reviewed";
61pub const EVENT_KIND_ARTIFACT_RETRACTED: &str = "artifact.retracted";
62
63/// v0.51: Re-classify a finding/negative_result/trajectory's read-side
64/// access tier. Audit-trail event for the dual-use channel: the
65/// fact that an object's tier changed (and who changed it, when, with
66/// what reason) is itself part of the substrate's accountability
67/// surface and must replay deterministically.
68pub const EVENT_KIND_TIER_SET: &str = "tier.set";
69
70/// v0.56: Mechanical evidence-atom locator repair. Targets a single
71/// evidence atom by id and sets its `locator` field to the value
72/// resolved from the parent source's locator. Carries the resolved
73/// locator string and the source id it was derived from on the event
74/// payload so a fresh replay reconstructs the atom's locator without
75/// needing to re-resolve the source.
76///
77/// This event mutates `state.evidence_atoms[i].locator` and clears the
78/// "missing evidence locator" caveat on the same atom. It does not
79/// touch `state.findings`, so cross-impl reducer fixtures whose
80/// post-replay digest covers `findings[]` only treat this event as a
81/// no-op on finding state. The Rust reducer still has an explicit arm
82/// to avoid silently dropping the repair from a fresh replay.
83pub const EVENT_KIND_EVIDENCE_ATOM_LOCATOR_REPAIRED: &str = "evidence_atom.locator_repaired";
84
85/// v0.57: Mechanical evidence-span repair on a finding. Appends one
86/// `{section, text}` span to `state.findings[i].evidence.evidence_spans`.
87/// Required payload: `{proposal_id, section, text}`. The reducer arm
88/// is idempotent under identical re-application (refuses to append an
89/// equal span twice on the same finding).
90pub const EVENT_KIND_FINDING_SPAN_REPAIRED: &str = "finding.span_repaired";
91
92/// v0.57: Entity resolution on a finding. Sets the canonical_id,
93/// resolution_method, resolution_provenance, and resolution_confidence
94/// fields on a single entity inside `state.findings[i].assertion.entities`,
95/// and clears the entity's `needs_review` flag.
96/// Required payload: `{proposal_id, entity_name, source, id, confidence}`
97/// plus optional `matched_name`, `resolution_method`, `resolution_provenance`.
98pub const EVENT_KIND_FINDING_ENTITY_RESOLVED: &str = "finding.entity_resolved";
99
100/// v0.70: Replication deposit. The substrate has had `Replication`
101/// as a first-class kernel object since v0.32 + `vela replicate`
102/// CLI, but the side-table mutation happened via direct file write.
103/// v0.70 makes the deposit event-driven so federation sync can
104/// propagate it. Reducer arm appends to `Project.replications` if
105/// the `vrep_*` id is not already present (idempotent under
106/// re-application). The CLI + Workbench paths emit this event;
107/// raw `vrep_<id>` entries on `Project.replications` from
108/// pre-v0.70 frontiers continue to load without an event.
109/// Required payload: `{replication}` (the full Replication record).
110pub const EVENT_KIND_REPLICATION_DEPOSITED: &str = "replication.deposited";
111
112/// v0.70: Prediction deposit. Same shape as `replication.deposited`
113/// for `Project.predictions`. Deposits a `Prediction` record onto
114/// the frontier; reducer arm appends if `vpred_*` id is new.
115/// Required payload: `{prediction}` (the full Prediction record).
116pub const EVENT_KIND_PREDICTION_DEPOSITED: &str = "prediction.deposited";
117
118/// v0.67: Bridge review verdict. A reviewer confirms or refutes a
119/// `vbr_*` cross-frontier bridge by emitting this canonical event,
120/// which the reducer applies by setting the bridge's status field.
121/// Pre-v0.67 bridge status was mutated by file write only; v0.67
122/// makes the verdict an immutable canonical event so federation
123/// sync propagates it. Required payload: `{bridge_id, status,
124/// note}`. `status` must be one of `confirmed` or `refuted`.
125pub const EVENT_KIND_BRIDGE_REVIEWED: &str = "bridge.reviewed";
126
127/// v0.59: Federation conflict resolution. Pairs with the existing
128/// `frontier.conflict_detected` event. The conflict event itself
129/// stays in the log unchanged (immutable history); the resolved
130/// event records the reviewer's verdict, the conflict it pertains
131/// to, and an optional pointer at the winning proposal.
132///
133/// Like its sibling `frontier.conflict_detected` and
134/// `frontier.synced_with_peer`, this is a frontier-level
135/// observation, not a finding-state mutation: the reducer arm is
136/// a no-op on `Project.findings`. Consumers (Workbench inbox,
137/// audit scripts, hub mirrors) pair a `conflict_detected` with
138/// its `conflict_resolved` by matching `conflict_event_id` to the
139/// detected event's id on read.
140///
141/// Required payload: `{conflict_event_id, resolved_by,
142/// resolution_note}`. Optional: `winning_proposal_id`.
143pub const EVENT_KIND_FRONTIER_CONFLICT_RESOLVED: &str = "frontier.conflict_resolved";
144
145#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
146pub struct StateTarget {
147    pub r#type: String,
148    pub id: String,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
152pub struct StateActor {
153    pub id: String,
154    pub r#type: String,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct StateEvent {
159    #[serde(default = "default_schema")]
160    pub schema: String,
161    pub id: String,
162    pub kind: String,
163    pub target: StateTarget,
164    pub actor: StateActor,
165    pub timestamp: String,
166    pub reason: String,
167    pub before_hash: String,
168    pub after_hash: String,
169    #[serde(default)]
170    pub payload: Value,
171    #[serde(default)]
172    pub caveats: Vec<String>,
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub signature: Option<String>,
175}
176
177pub struct FindingEventInput<'a> {
178    pub kind: &'a str,
179    pub finding_id: &'a str,
180    pub actor_id: &'a str,
181    pub actor_type: &'a str,
182    pub reason: &'a str,
183    pub before_hash: &'a str,
184    pub after_hash: &'a str,
185    pub payload: Value,
186    pub caveats: Vec<String>,
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct EventLogSummary {
191    pub count: usize,
192    pub kinds: BTreeMap<String, usize>,
193    pub first_timestamp: Option<String>,
194    pub last_timestamp: Option<String>,
195    pub duplicate_ids: Vec<String>,
196    pub orphan_targets: Vec<String>,
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct ReplayReport {
201    pub ok: bool,
202    pub status: String,
203    pub event_log: EventLogSummary,
204    pub source_hash: String,
205    pub event_log_hash: String,
206    pub replayed_hash: String,
207    pub current_hash: String,
208    pub conflicts: Vec<String>,
209}
210
211fn default_schema() -> String {
212    EVENT_SCHEMA.to_string()
213}
214
215pub fn new_finding_event(input: FindingEventInput<'_>) -> StateEvent {
216    let timestamp = Utc::now().to_rfc3339();
217    let mut event = StateEvent {
218        schema: EVENT_SCHEMA.to_string(),
219        id: String::new(),
220        kind: input.kind.to_string(),
221        target: StateTarget {
222            r#type: "finding".to_string(),
223            id: input.finding_id.to_string(),
224        },
225        actor: StateActor {
226            id: input.actor_id.to_string(),
227            r#type: input.actor_type.to_string(),
228        },
229        timestamp,
230        reason: input.reason.to_string(),
231        before_hash: input.before_hash.to_string(),
232        after_hash: input.after_hash.to_string(),
233        payload: input.payload,
234        caveats: input.caveats,
235        signature: None,
236    };
237    event.id = event_id(&event);
238    event
239}
240
241/// Payload of an `EVENT_KIND_KEY_REVOKE` event. Carries the
242/// revoked Ed25519 pubkey (hex-encoded), the moment compromise was
243/// detected (ISO-8601), an optional replacement pubkey the actor is
244/// migrating to, and a free-form reason string. Stored on the event's
245/// `payload` field; the event's `actor` is the actor whose key is
246/// being revoked, and the event itself must be signed by a key that
247/// was authoritative *before* the revocation (typically a co-signer
248/// or the actor's prior key — never the revoked key itself).
249#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
250pub struct RevocationPayload {
251    /// The Ed25519 pubkey being revoked, hex-encoded (64 chars).
252    pub revoked_pubkey: String,
253    /// ISO-8601 moment when compromise was detected. Signatures
254    /// whose `signed_at` falls after this should be flagged on
255    /// re-verification.
256    pub revoked_at: String,
257    /// Optional replacement pubkey the actor is now signing with,
258    /// hex-encoded. Reviewers re-verifying signed history use this
259    /// to walk forward to the new key.
260    #[serde(default, skip_serializing_if = "String::is_empty")]
261    pub replacement_pubkey: String,
262    /// Free-form reason — "key file leaked", "stolen device",
263    /// "scheduled rotation", etc. Reviewer-facing only; the
264    /// substrate doesn't enumerate.
265    #[serde(default, skip_serializing_if = "String::is_empty")]
266    pub reason: String,
267}
268
269/// Construct a signed-shape `key.revoke` event for the given actor.
270/// Mirrors `new_finding_event` in shape but targets an actor and
271/// carries a `RevocationPayload` in `payload`. The returned event is
272/// unsigned (caller signs it); `event.id` is the canonical content
273/// address of the unsigned shape.
274pub fn new_revocation_event(
275    actor_id: &str,
276    actor_type: &str,
277    payload: RevocationPayload,
278    reason: &str,
279    before_hash: &str,
280    after_hash: &str,
281) -> StateEvent {
282    let timestamp = Utc::now().to_rfc3339();
283    let payload_value =
284        serde_json::to_value(&payload).expect("RevocationPayload serializes to a JSON object");
285    let mut event = StateEvent {
286        schema: EVENT_SCHEMA.to_string(),
287        id: String::new(),
288        kind: EVENT_KIND_KEY_REVOKE.to_string(),
289        target: StateTarget {
290            r#type: "actor".to_string(),
291            id: actor_id.to_string(),
292        },
293        actor: StateActor {
294            id: actor_id.to_string(),
295            r#type: actor_type.to_string(),
296        },
297        timestamp,
298        reason: reason.to_string(),
299        before_hash: before_hash.to_string(),
300        after_hash: after_hash.to_string(),
301        payload: payload_value,
302        caveats: Vec::new(),
303        signature: None,
304    };
305    event.id = event_id(&event);
306    event
307}
308
309/// Construct an `evidence_atom.locator_repaired` event targeting an
310/// evidence atom by id. The payload carries the resolved locator and
311/// the parent source id so a fresh replay can both apply the repair
312/// and reconstruct its derivation. Returned event is unsigned; the
313/// caller signs it before persisting.
314pub fn new_evidence_atom_locator_repair_event(
315    atom_id: &str,
316    actor_id: &str,
317    actor_type: &str,
318    reason: &str,
319    before_hash: &str,
320    after_hash: &str,
321    payload: Value,
322    caveats: Vec<String>,
323) -> StateEvent {
324    let timestamp = Utc::now().to_rfc3339();
325    let mut event = StateEvent {
326        schema: EVENT_SCHEMA.to_string(),
327        id: String::new(),
328        kind: EVENT_KIND_EVIDENCE_ATOM_LOCATOR_REPAIRED.to_string(),
329        target: StateTarget {
330            r#type: "evidence_atom".to_string(),
331            id: atom_id.to_string(),
332        },
333        actor: StateActor {
334            id: actor_id.to_string(),
335            r#type: actor_type.to_string(),
336        },
337        timestamp,
338        reason: reason.to_string(),
339        before_hash: before_hash.to_string(),
340        after_hash: after_hash.to_string(),
341        payload,
342        caveats,
343        signature: None,
344    };
345    event.id = event_id(&event);
346    event
347}
348
349/// v0.59: build a `frontier.conflict_resolved` event. Frontier-level
350/// observation; the target is the conflict's frontier_id (same
351/// shape as `frontier.synced_with_peer` and
352/// `frontier.conflict_detected`).
353pub fn new_frontier_conflict_resolved_event(
354    frontier_id: &str,
355    actor_id: &str,
356    actor_type: &str,
357    reason: &str,
358    payload: Value,
359    caveats: Vec<String>,
360) -> StateEvent {
361    let timestamp = Utc::now().to_rfc3339();
362    let mut event = StateEvent {
363        schema: EVENT_SCHEMA.to_string(),
364        id: String::new(),
365        kind: EVENT_KIND_FRONTIER_CONFLICT_RESOLVED.to_string(),
366        target: StateTarget {
367            r#type: "frontier_observation".to_string(),
368            id: frontier_id.to_string(),
369        },
370        actor: StateActor {
371            id: actor_id.to_string(),
372            r#type: actor_type.to_string(),
373        },
374        timestamp,
375        reason: reason.to_string(),
376        before_hash: NULL_HASH.to_string(),
377        after_hash: NULL_HASH.to_string(),
378        payload,
379        caveats,
380        signature: None,
381    };
382    event.id = event_id(&event);
383    event
384}
385
386/// v0.67: build a `bridge.reviewed` event. Target is the bridge's
387/// content-addressed id (`vbr_*`). Reducer arm projects the verdict
388/// onto the bridge's status field on read.
389pub fn new_bridge_reviewed_event(
390    bridge_id: &str,
391    actor_id: &str,
392    actor_type: &str,
393    reason: &str,
394    payload: Value,
395    caveats: Vec<String>,
396) -> StateEvent {
397    let timestamp = Utc::now().to_rfc3339();
398    let mut event = StateEvent {
399        schema: EVENT_SCHEMA.to_string(),
400        id: String::new(),
401        kind: EVENT_KIND_BRIDGE_REVIEWED.to_string(),
402        target: StateTarget {
403            r#type: "bridge".to_string(),
404            id: bridge_id.to_string(),
405        },
406        actor: StateActor {
407            id: actor_id.to_string(),
408            r#type: actor_type.to_string(),
409        },
410        timestamp,
411        reason: reason.to_string(),
412        before_hash: NULL_HASH.to_string(),
413        after_hash: NULL_HASH.to_string(),
414        payload,
415        caveats,
416        signature: None,
417    };
418    event.id = event_id(&event);
419    event
420}
421
422/// Canonical hash of one evidence atom. Mirrors `finding_hash` for the
423/// before/after pair on locator-repair events so a chain validator can
424/// confirm that exactly the named atom changed and exactly the named
425/// repair was applied.
426pub fn evidence_atom_hash(atom: &crate::sources::EvidenceAtom) -> String {
427    let bytes = canonical::to_canonical_bytes(atom).unwrap_or_default();
428    format!("sha256:{}", hex::encode(Sha256::digest(bytes)))
429}
430
431pub fn evidence_atom_hash_by_id(frontier: &Project, atom_id: &str) -> String {
432    frontier
433        .evidence_atoms
434        .iter()
435        .find(|atom| atom.id == atom_id)
436        .map(evidence_atom_hash)
437        .unwrap_or_else(|| NULL_HASH.to_string())
438}
439
440pub fn finding_hash(finding: &FindingBundle) -> String {
441    // Per Protocol §5, links are "review surfaces" — typed relationships
442    // between findings inferred at compile or review time, NOT part of the
443    // finding's content commitment. They are mutable: `vela link add`
444    // appends links without emitting a state-event (links don't change
445    // what the finding asserts; they change which findings know about
446    // each other). For event-replay validity the finding hash must therefore
447    // exclude `links`, otherwise any CLI-added link breaks the asserted-event
448    // chain. v0.12: hash a links-cleared copy. State-changing events
449    // (caveat/note/review/revise/retract) still mutate annotations/flags/
450    // confidence — those remain in the hash and chain through events properly.
451    let mut hashable = finding.clone();
452    hashable.links.clear();
453    let bytes = canonical::to_canonical_bytes(&hashable).unwrap_or_default();
454    format!("sha256:{}", hex::encode(Sha256::digest(bytes)))
455}
456
457pub fn finding_hash_by_id(frontier: &Project, finding_id: &str) -> String {
458    frontier
459        .findings
460        .iter()
461        .find(|finding| finding.id == finding_id)
462        .map(finding_hash)
463        .unwrap_or_else(|| NULL_HASH.to_string())
464}
465
466pub fn event_log_hash(events: &[StateEvent]) -> String {
467    let bytes = canonical::to_canonical_bytes(events).unwrap_or_default();
468    hex::encode(Sha256::digest(bytes))
469}
470
471pub fn snapshot_hash(frontier: &Project) -> String {
472    let value = serde_json::to_value(frontier).unwrap_or(Value::Null);
473    let mut value = value;
474    if let Value::Object(map) = &mut value {
475        map.remove("events");
476        map.remove("signatures");
477        map.remove("proof_state");
478    }
479    let bytes = canonical::to_canonical_bytes(&value).unwrap_or_default();
480    hex::encode(Sha256::digest(bytes))
481}
482
483pub fn events_for_finding<'a>(frontier: &'a Project, finding_id: &str) -> Vec<&'a StateEvent> {
484    frontier
485        .events
486        .iter()
487        .filter(|event| event.target.r#type == "finding" && event.target.id == finding_id)
488        .collect()
489}
490
491pub fn replay_report(frontier: &Project) -> ReplayReport {
492    let event_log = summarize(frontier);
493    let mut conflicts = Vec::new();
494
495    if frontier.events.is_empty() {
496        let current_hash = snapshot_hash(frontier);
497        return ReplayReport {
498            ok: true,
499            status: "no_events".to_string(),
500            event_log,
501            source_hash: current_hash.clone(),
502            event_log_hash: event_log_hash(&frontier.events),
503            replayed_hash: current_hash.clone(),
504            current_hash,
505            conflicts,
506        };
507    }
508
509    for duplicate in &event_log.duplicate_ids {
510        conflicts.push(format!("duplicate event id: {duplicate}"));
511    }
512    for orphan in &event_log.orphan_targets {
513        conflicts.push(format!("orphan event target: {orphan}"));
514    }
515
516    let mut chains = BTreeMap::<String, Vec<&StateEvent>>::new();
517    for event in &frontier.events {
518        if event.schema != EVENT_SCHEMA {
519            conflicts.push(format!(
520                "unsupported event schema for {}: {}",
521                event.id, event.schema
522            ));
523        }
524        if event.reason.trim().is_empty() {
525            conflicts.push(format!("event {} has empty reason", event.id));
526        }
527        if event.before_hash.trim().is_empty() || event.after_hash.trim().is_empty() {
528            conflicts.push(format!("event {} has empty hash boundary", event.id));
529        }
530        // Phase E: per-kind payload schema validation. Each event kind has
531        // a normative payload shape documented in `docs/PROTOCOL.md` §6;
532        // payloads that don't match are conformance failures, not just
533        // "weird optional content."
534        if let Err(err) = validate_event_payload(&event.kind, &event.payload) {
535            conflicts.push(format!("event {} payload invalid: {err}", event.id));
536        }
537        chains
538            .entry(format!("{}:{}", event.target.r#type, event.target.id))
539            .or_default()
540            .push(event);
541    }
542
543    for (target, events) in chains {
544        let mut sorted = events;
545        sorted.sort_by(|a, b| a.timestamp.cmp(&b.timestamp).then(a.id.cmp(&b.id)));
546        for pair in sorted.windows(2) {
547            let previous = pair[0];
548            let next = pair[1];
549            if previous.after_hash != next.before_hash {
550                conflicts.push(format!(
551                    "event chain break for {target}: {} after_hash does not match {} before_hash",
552                    previous.id, next.id
553                ));
554            }
555        }
556        if let Some(last) = sorted.last()
557            && last.target.r#type == "finding"
558        {
559            let current = finding_hash_by_id(frontier, &last.target.id);
560            if current != last.after_hash {
561                conflicts.push(format!(
562                    "materialized finding {} hash does not match last event {}",
563                    last.target.id, last.id
564                ));
565            }
566        }
567    }
568
569    let current_hash = snapshot_hash(frontier);
570    let ok = conflicts.is_empty();
571    ReplayReport {
572        ok,
573        status: if ok { "ok" } else { "conflict" }.to_string(),
574        event_log,
575        source_hash: current_hash.clone(),
576        event_log_hash: event_log_hash(&frontier.events),
577        replayed_hash: if ok {
578            current_hash.clone()
579        } else {
580            "unavailable".to_string()
581        },
582        current_hash,
583        conflicts,
584    }
585}
586
587pub fn replay_report_json(frontier: &Project) -> Value {
588    serde_json::to_value(replay_report(frontier)).unwrap_or_else(|_| json!({"ok": false}))
589}
590
591pub fn summarize(frontier: &Project) -> EventLogSummary {
592    let mut kinds = BTreeMap::<String, usize>::new();
593    let mut seen = BTreeSet::<String>::new();
594    let mut duplicate_ids = BTreeSet::<String>::new();
595    let finding_ids = frontier
596        .findings
597        .iter()
598        .map(|finding| finding.id.as_str())
599        .collect::<BTreeSet<_>>();
600    let mut orphan_targets = BTreeSet::<String>::new();
601    let mut timestamps = Vec::<String>::new();
602
603    for event in &frontier.events {
604        *kinds.entry(event.kind.clone()).or_default() += 1;
605        if !seen.insert(event.id.clone()) {
606            duplicate_ids.insert(event.id.clone());
607        }
608        if event.target.r#type == "finding"
609            && !finding_ids.contains(event.target.id.as_str())
610            && event.kind != "finding.retracted"
611        {
612            orphan_targets.insert(event.target.id.clone());
613        }
614        timestamps.push(event.timestamp.clone());
615    }
616    timestamps.sort();
617
618    EventLogSummary {
619        count: frontier.events.len(),
620        kinds,
621        first_timestamp: timestamps.first().cloned(),
622        last_timestamp: timestamps.last().cloned(),
623        duplicate_ids: duplicate_ids.into_iter().collect(),
624        orphan_targets: orphan_targets.into_iter().collect(),
625    }
626}
627
628/// Validate a canonical event's payload against its per-kind schema.
629///
630/// Each event kind has a normative payload shape. Phase E pins those
631/// shapes so a second implementation can reject malformed events
632/// without per-kind ad-hoc parsing. The schemas are documented in
633/// `docs/PROTOCOL.md` §6 and conformance-checked at the v0.3 level.
634///
635/// Unknown kinds are rejected so future-event-kind reads from older
636/// implementations fail fast rather than silently accepting opaque
637/// content.
638fn validate_sha256_commitment(field: &str, value: &str) -> Result<(), String> {
639    let hex = value.strip_prefix("sha256:").unwrap_or(value);
640    if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
641        return Err(format!("{field} must be sha256:<64hex>"));
642    }
643    Ok(())
644}
645
646pub fn validate_event_payload(kind: &str, payload: &Value) -> Result<(), String> {
647    let object = payload.as_object().ok_or_else(|| {
648        if matches!(payload, Value::Null) {
649            "payload must be a JSON object (got null)".to_string()
650        } else {
651            "payload must be a JSON object".to_string()
652        }
653    })?;
654    let require_str = |key: &str| -> Result<&str, String> {
655        object
656            .get(key)
657            .and_then(Value::as_str)
658            .ok_or_else(|| format!("missing required string field '{key}'"))
659    };
660    let require_f64 = |key: &str| -> Result<f64, String> {
661        object
662            .get(key)
663            .and_then(Value::as_f64)
664            .ok_or_else(|| format!("missing required number field '{key}'"))
665    };
666    match kind {
667        "finding.asserted" => {
668            // proposal_id required; optional `finding` for v0.3 genesis
669            // events that carry the bootstrap finding inline.
670            require_str("proposal_id")?;
671        }
672        "finding.reviewed" => {
673            require_str("proposal_id")?;
674            let status = require_str("status")?;
675            if !matches!(
676                status,
677                "accepted" | "approved" | "contested" | "needs_revision" | "rejected"
678            ) {
679                return Err(format!("invalid review status '{status}'"));
680            }
681        }
682        "finding.noted" | "finding.caveated" => {
683            require_str("proposal_id")?;
684            require_str("annotation_id")?;
685            let text = require_str("text")?;
686            if text.trim().is_empty() {
687                return Err("payload.text must be non-empty".to_string());
688            }
689            // Phase β (v0.6): optional structured `provenance` block.
690            // When present, MUST be an object and MUST carry at least one
691            // identifying field (doi/pmid/title). An all-empty
692            // `provenance: {}` is a contract violation, not a tolerable
693            // default — agents that pass the field are expected to mean it.
694            if let Some(prov) = object.get("provenance") {
695                let prov_obj = prov
696                    .as_object()
697                    .ok_or("payload.provenance must be a JSON object when present")?;
698                let has_id = prov_obj
699                    .get("doi")
700                    .and_then(Value::as_str)
701                    .is_some_and(|s| !s.trim().is_empty())
702                    || prov_obj
703                        .get("pmid")
704                        .and_then(Value::as_str)
705                        .is_some_and(|s| !s.trim().is_empty())
706                    || prov_obj
707                        .get("title")
708                        .and_then(Value::as_str)
709                        .is_some_and(|s| !s.trim().is_empty());
710                if !has_id {
711                    return Err(
712                        "payload.provenance must include at least one of doi/pmid/title"
713                            .to_string(),
714                    );
715                }
716            }
717        }
718        "finding.confidence_revised" => {
719            require_str("proposal_id")?;
720            let new_score = require_f64("new_score")?;
721            if !(0.0..=1.0).contains(&new_score) {
722                return Err(format!("new_score {new_score} out of [0.0, 1.0]"));
723            }
724            let _ = require_f64("previous_score")?;
725        }
726        "finding.rejected" => {
727            require_str("proposal_id")?;
728        }
729        "finding.superseded" => {
730            require_str("proposal_id")?;
731            require_str("new_finding_id")?;
732        }
733        "finding.retracted" => {
734            require_str("proposal_id")?;
735            // affected and cascade are summary fields; optional but if
736            // present, affected must be a non-negative integer.
737            if let Some(affected) = object.get("affected") {
738                let _ = affected
739                    .as_u64()
740                    .ok_or("affected must be a non-negative integer")?;
741            }
742        }
743        // Phase L: per-dependent cascade events. Each one names the
744        // upstream retraction it descends from, the cascade depth, and
745        // the canonical event ID of the source retraction so a replay
746        // can reconstruct the cascade without trusting summary fields.
747        "finding.dependency_invalidated" => {
748            require_str("upstream_finding_id")?;
749            require_str("upstream_event_id")?;
750            let depth = object
751                .get("depth")
752                .and_then(Value::as_u64)
753                .ok_or("missing required positive integer 'depth'")?;
754            if depth == 0 {
755                return Err("depth must be >= 1 (genesis is the source retraction)".to_string());
756            }
757            // proposal_id present for cascade-source traceability.
758            require_str("proposal_id")?;
759        }
760        // Phase H will introduce frontier.created. For v0.3 it accepts
761        // a name + creator pair; left here for forward compatibility.
762        "frontier.created" => {
763            require_str("name")?;
764            require_str("creator")?;
765        }
766        // v0.40.1: prediction expired without resolution. Emitted by
767        // `calibration::expire_overdue_predictions` when a prediction's
768        // `resolves_by` is in the past and no Resolution targets it.
769        // Closing the prediction this way does not generate a
770        // synthesized Resolution — the predictor failed to commit
771        // either way, and calibration tracks it as a separate count.
772        "prediction.expired_unresolved" => {
773            require_str("prediction_id")?;
774            require_str("resolves_by")?;
775            require_str("expired_at")?;
776        }
777        // v0.39: federation events. Both record interactions with a
778        // peer hub registered in `Project.peers`. The actual sync
779        // runtime (HTTP fetch + manifest verification) ships in
780        // v0.39.1+; v0.39.0 only validates the event schema so a
781        // hand-emitted sync record can already be replay-checked.
782        "frontier.synced_with_peer" => {
783            require_str("peer_id")?;
784            require_str("peer_snapshot_hash")?;
785            require_str("our_snapshot_hash")?;
786            let _ = object
787                .get("divergence_count")
788                .and_then(Value::as_u64)
789                .ok_or("missing required non-negative integer 'divergence_count'")?;
790        }
791        "frontier.conflict_detected" => {
792            require_str("peer_id")?;
793            require_str("finding_id")?;
794            let kind = require_str("kind")?;
795            // The conflict kind is open-ended for now; v0.39.1+ will
796            // tighten this enum once the sync runtime lands. For
797            // v0.39.0 we only require it to be non-empty so a replay
798            // can group conflicts by category.
799            if kind.trim().is_empty() {
800                return Err("payload.kind must be a non-empty string".to_string());
801            }
802        }
803        // v0.59: paired resolution event for a previously emitted
804        // `frontier.conflict_detected`. The conflict event itself
805        // remains in the log; this is an append-only verdict trail.
806        "frontier.conflict_resolved" => {
807            let conflict_event_id = require_str("conflict_event_id")?;
808            if conflict_event_id.trim().is_empty() {
809                return Err("payload.conflict_event_id must be a non-empty string".to_string());
810            }
811            let resolved_by = require_str("resolved_by")?;
812            if resolved_by.trim().is_empty() {
813                return Err("payload.resolved_by must be a non-empty string".to_string());
814            }
815            let note = require_str("resolution_note")?;
816            if note.trim().is_empty() {
817                return Err("payload.resolution_note must be a non-empty string".to_string());
818            }
819            // winning_proposal_id is optional; some conflicts resolve
820            // by reviewer judgment without picking a specific proposal
821            // (for example "neither side is the canonical wording").
822            if let Some(value) = object.get("winning_proposal_id")
823                && !value.is_null()
824                && !value.is_string()
825            {
826                return Err("payload.winning_proposal_id must be a string when present".to_string());
827            }
828        }
829        // v0.70: Replication deposit. Required payload field
830        // `replication` is the full Replication record (object).
831        // The reducer + apply layer enforce content-addressing
832        // and idempotency.
833        "replication.deposited" => {
834            let rep = object
835                .get("replication")
836                .ok_or("payload.replication is required")?;
837            if !rep.is_object() {
838                return Err("payload.replication must be an object".to_string());
839            }
840            let id = rep
841                .get("id")
842                .and_then(Value::as_str)
843                .ok_or("payload.replication.id is required (vrep_<hex>)")?;
844            if !id.starts_with("vrep_") {
845                return Err(format!(
846                    "payload.replication.id must start with 'vrep_', got '{id}'"
847                ));
848            }
849        }
850        // v0.70: Prediction deposit. Same pattern as
851        // replication.deposited; payload field `prediction`.
852        "prediction.deposited" => {
853            let pred = object
854                .get("prediction")
855                .ok_or("payload.prediction is required")?;
856            if !pred.is_object() {
857                return Err("payload.prediction must be an object".to_string());
858            }
859            let id = pred
860                .get("id")
861                .and_then(Value::as_str)
862                .ok_or("payload.prediction.id is required (vpred_<hex>)")?;
863            if !id.starts_with("vpred_") {
864                return Err(format!(
865                    "payload.prediction.id must start with 'vpred_', got '{id}'"
866                ));
867            }
868        }
869        // v0.67: Bridge review verdict. Confirms or refutes a
870        // cross-frontier bridge identified by `vbr_*`. Reducer arm
871        // sets `Bridge.status` to the named status. Status must be
872        // one of "confirmed" or "refuted"; "derived" is the genesis
873        // state and can not be re-asserted via this event (use
874        // bridges derive instead). Note is optional but encouraged.
875        "bridge.reviewed" => {
876            let bridge_id = require_str("bridge_id")?;
877            if !bridge_id.starts_with("vbr_") {
878                return Err(format!(
879                    "payload.bridge_id must start with 'vbr_', got '{bridge_id}'"
880                ));
881            }
882            let status = require_str("status")?;
883            if !matches!(status, "confirmed" | "refuted") {
884                return Err(format!(
885                    "payload.status must be 'confirmed' or 'refuted', got '{status}'"
886                ));
887            }
888            // note is optional; if present, must be a string.
889            if let Some(value) = object.get("note")
890                && !value.is_null()
891                && !value.is_string()
892            {
893                return Err("payload.note must be a string when present".to_string());
894            }
895        }
896        // v0.38: causal-typing reinterpretation. The substrate doesn't
897        // erase the prior reading; it appends a new event recording who
898        // re-graded the claim and why. `before` and `after` payloads
899        // each carry `claim` (correlation|mediation|intervention) and
900        // optionally `grade` (rct|quasi_experimental|observational|
901        // theoretical). Pre-v0.38 findings carried neither, so a
902        // reinterpretation may originate from a block with both fields
903        // absent or null.
904        "assertion.reinterpreted_causal" => {
905            require_str("proposal_id")?;
906            let check_block = |block_name: &str| -> Result<(), String> {
907                let block = object
908                    .get(block_name)
909                    .and_then(Value::as_object)
910                    .ok_or_else(|| format!("payload.{block_name} must be an object"))?;
911                if let Some(claim) = block.get("claim").and_then(Value::as_str)
912                    && !crate::bundle::VALID_CAUSAL_CLAIMS.contains(&claim)
913                {
914                    return Err(format!(
915                        "{block_name}.claim '{claim}' not in {:?}",
916                        crate::bundle::VALID_CAUSAL_CLAIMS
917                    ));
918                }
919                if let Some(grade) = block.get("grade").and_then(Value::as_str)
920                    && !crate::bundle::VALID_CAUSAL_EVIDENCE_GRADES.contains(&grade)
921                {
922                    return Err(format!(
923                        "{block_name}.grade '{grade}' not in {:?}",
924                        crate::bundle::VALID_CAUSAL_EVIDENCE_GRADES
925                    ));
926                }
927                Ok(())
928            };
929            check_block("before")?;
930            check_block("after")?;
931        }
932        // v0.37: multi-sig kernel events. `threshold_set` records the
933        // policy attached to a finding (k unique valid signatures
934        // required); `threshold_met` records the moment the k-th
935        // signature lands. Both are content-addressed under the same
936        // canonical-JSON discipline as every other event kind.
937        "finding.threshold_set" => {
938            let threshold = object
939                .get("threshold")
940                .and_then(Value::as_u64)
941                .ok_or("missing required positive integer 'threshold'")?;
942            if threshold == 0 {
943                return Err("threshold must be >= 1".to_string());
944            }
945        }
946        "finding.threshold_met" => {
947            let count = object
948                .get("signature_count")
949                .and_then(Value::as_u64)
950                .ok_or("missing required positive integer 'signature_count'")?;
951            let threshold = object
952                .get("threshold")
953                .and_then(Value::as_u64)
954                .ok_or("missing required positive integer 'threshold'")?;
955            if count < threshold {
956                return Err(format!(
957                    "signature_count {count} below threshold {threshold}"
958                ));
959            }
960        }
961        // v0.49: key revocation event. Carries the revoked Ed25519
962        // pubkey (hex-encoded 64 chars), the ISO-8601 moment compromise
963        // was detected, and an optional replacement pubkey + reason.
964        // Validating here keeps a hand-emitted or peer-fetched
965        // revocation honest at the event-pipeline boundary so a
966        // malformed revocation can't slip through replay and silently
967        // re-trust the compromised key.
968        EVENT_KIND_KEY_REVOKE => {
969            let revoked = require_str("revoked_pubkey")?;
970            if revoked.len() != 64 || !revoked.chars().all(|c| c.is_ascii_hexdigit()) {
971                return Err(format!(
972                    "revoked_pubkey must be 64 hex chars (Ed25519 pubkey), got {} chars",
973                    revoked.len()
974                ));
975            }
976            let revoked_at = require_str("revoked_at")?;
977            if revoked_at.trim().is_empty() {
978                return Err("revoked_at must be a non-empty ISO-8601 timestamp".to_string());
979            }
980            // v0.49.1: parse as RFC-3339 / ISO-8601 so a typo'd value
981            // ("yesterday", "x", "2026-13-99T...") fails at the
982            // validator boundary rather than poisoning re-verification
983            // of post-revocation signatures further downstream.
984            if DateTime::parse_from_rfc3339(revoked_at).is_err() {
985                return Err(format!(
986                    "revoked_at must parse as RFC-3339/ISO-8601, got {revoked_at:?}"
987                ));
988            }
989            // replacement_pubkey is optional but if present must be a
990            // valid hex pubkey of the same shape — a typo here would
991            // strand the actor's identity at the wrong forward key.
992            if let Some(replacement) = object.get("replacement_pubkey")
993                && let Some(rep_str) = replacement.as_str()
994                && !rep_str.is_empty()
995                && (rep_str.len() != 64 || !rep_str.chars().all(|c| c.is_ascii_hexdigit()))
996            {
997                return Err(format!(
998                    "replacement_pubkey must be 64 hex chars when present, got {} chars",
999                    rep_str.len()
1000                ));
1001            }
1002            // The revoked key cannot also be the replacement; that
1003            // would be a self-rotation that revokes nothing.
1004            if let Some(replacement) = object.get("replacement_pubkey").and_then(Value::as_str)
1005                && !replacement.is_empty()
1006                && replacement.eq_ignore_ascii_case(revoked)
1007            {
1008                return Err("replacement_pubkey must differ from revoked_pubkey".to_string());
1009            }
1010        }
1011        // v0.49: NegativeResult deposit. Carries the full inline
1012        // negative_result object on payload.negative_result so a fresh
1013        // replay reconstructs state from the event log alone. Validation
1014        // here is the boundary check: kind-specific required fields,
1015        // power on [0,1], n_enrolled non-negative. The full deserialize
1016        // is reducer-side (apply_negative_result_asserted) so a malformed
1017        // shape fails replay loudly rather than silently dropping the
1018        // deposit.
1019        EVENT_KIND_NEGATIVE_RESULT_ASSERTED => {
1020            require_str("proposal_id")?;
1021            let nr = object
1022                .get("negative_result")
1023                .and_then(Value::as_object)
1024                .ok_or("payload.negative_result must be a JSON object")?;
1025            let nr_kind = nr
1026                .get("kind")
1027                .and_then(|k| k.as_object())
1028                .and_then(|k| k.get("kind"))
1029                .and_then(Value::as_str)
1030                .ok_or(
1031                    "payload.negative_result.kind.kind must be 'registered_trial' or 'exploratory'",
1032                )?;
1033            match nr_kind {
1034                "registered_trial" => {
1035                    let kind_obj = nr
1036                        .get("kind")
1037                        .and_then(Value::as_object)
1038                        .expect("checked above");
1039                    for k in ["endpoint", "intervention", "comparator", "population"] {
1040                        let v = kind_obj
1041                            .get(k)
1042                            .and_then(Value::as_str)
1043                            .ok_or_else(|| format!("registered_trial.{k} must be a string"))?;
1044                        if v.trim().is_empty() {
1045                            return Err(format!("registered_trial.{k} must be non-empty"));
1046                        }
1047                    }
1048                    let _ = kind_obj
1049                        .get("n_enrolled")
1050                        .and_then(Value::as_u64)
1051                        .ok_or("registered_trial.n_enrolled must be a non-negative integer")?;
1052                    let power = kind_obj
1053                        .get("power")
1054                        .and_then(Value::as_f64)
1055                        .ok_or("registered_trial.power must be a number on [0, 1]")?;
1056                    if !(0.0..=1.0).contains(&power) {
1057                        return Err(format!("registered_trial.power {power} out of [0.0, 1.0]"));
1058                    }
1059                    let ci = kind_obj
1060                        .get("effect_size_ci")
1061                        .and_then(Value::as_array)
1062                        .ok_or("registered_trial.effect_size_ci must be a 2-element array [lower, upper]")?;
1063                    if ci.len() != 2 {
1064                        return Err(format!(
1065                            "registered_trial.effect_size_ci must have length 2, got {}",
1066                            ci.len()
1067                        ));
1068                    }
1069                    let lower = ci[0]
1070                        .as_f64()
1071                        .ok_or("registered_trial.effect_size_ci[0] must be a number")?;
1072                    let upper = ci[1]
1073                        .as_f64()
1074                        .ok_or("registered_trial.effect_size_ci[1] must be a number")?;
1075                    if upper < lower {
1076                        return Err(format!(
1077                            "registered_trial.effect_size_ci upper {upper} below lower {lower}"
1078                        ));
1079                    }
1080                }
1081                "exploratory" => {
1082                    let kind_obj = nr
1083                        .get("kind")
1084                        .and_then(Value::as_object)
1085                        .expect("checked above");
1086                    for k in ["reagent", "observation"] {
1087                        let v = kind_obj
1088                            .get(k)
1089                            .and_then(Value::as_str)
1090                            .ok_or_else(|| format!("exploratory.{k} must be a string"))?;
1091                        if v.trim().is_empty() {
1092                            return Err(format!("exploratory.{k} must be non-empty"));
1093                        }
1094                    }
1095                    let attempts = kind_obj
1096                        .get("attempts")
1097                        .and_then(Value::as_u64)
1098                        .ok_or("exploratory.attempts must be a positive integer")?;
1099                    if attempts == 0 {
1100                        return Err("exploratory.attempts must be >= 1".to_string());
1101                    }
1102                }
1103                other => {
1104                    return Err(format!(
1105                        "negative_result.kind.kind '{other}' must be 'registered_trial' or 'exploratory'"
1106                    ));
1107                }
1108            }
1109            let depositor = nr
1110                .get("deposited_by")
1111                .and_then(Value::as_str)
1112                .ok_or("payload.negative_result.deposited_by must be a non-empty string")?;
1113            if depositor.trim().is_empty() {
1114                return Err("payload.negative_result.deposited_by must be non-empty".to_string());
1115            }
1116        }
1117        EVENT_KIND_NEGATIVE_RESULT_REVIEWED => {
1118            require_str("proposal_id")?;
1119            let status = require_str("status")?;
1120            if !matches!(
1121                status,
1122                "accepted" | "approved" | "contested" | "needs_revision" | "rejected"
1123            ) {
1124                return Err(format!("invalid review status '{status}'"));
1125            }
1126        }
1127        EVENT_KIND_NEGATIVE_RESULT_RETRACTED => {
1128            require_str("proposal_id")?;
1129        }
1130        // v0.50: Trajectory lifecycle. trajectory.created carries the
1131        // initial Trajectory inline on payload.trajectory (with empty
1132        // steps). trajectory.step_appended carries the new step on
1133        // payload.step plus parent_trajectory_id. trajectory.reviewed
1134        // and trajectory.retracted target an existing vtr_*.
1135        EVENT_KIND_TRAJECTORY_CREATED => {
1136            require_str("proposal_id")?;
1137            let traj = object
1138                .get("trajectory")
1139                .and_then(Value::as_object)
1140                .ok_or("payload.trajectory must be a JSON object")?;
1141            let depositor = traj
1142                .get("deposited_by")
1143                .and_then(Value::as_str)
1144                .ok_or("payload.trajectory.deposited_by must be a non-empty string")?;
1145            if depositor.trim().is_empty() {
1146                return Err("payload.trajectory.deposited_by must be non-empty".to_string());
1147            }
1148            let id = traj
1149                .get("id")
1150                .and_then(Value::as_str)
1151                .ok_or("payload.trajectory.id must be a vtr_<hex>")?;
1152            if !id.starts_with("vtr_") {
1153                return Err(format!(
1154                    "payload.trajectory.id must start with 'vtr_', got '{id}'"
1155                ));
1156            }
1157        }
1158        EVENT_KIND_TRAJECTORY_STEP_APPENDED => {
1159            require_str("proposal_id")?;
1160            let parent = require_str("parent_trajectory_id")?;
1161            if !parent.starts_with("vtr_") {
1162                return Err(format!(
1163                    "parent_trajectory_id must start with 'vtr_', got '{parent}'"
1164                ));
1165            }
1166            let step = object
1167                .get("step")
1168                .and_then(Value::as_object)
1169                .ok_or("payload.step must be a JSON object")?;
1170            let kind_str = step.get("kind").and_then(Value::as_str).ok_or(
1171                "payload.step.kind must be one of hypothesis|tried|ruled_out|observed|refined",
1172            )?;
1173            if !matches!(
1174                kind_str,
1175                "hypothesis" | "tried" | "ruled_out" | "observed" | "refined"
1176            ) {
1177                return Err(format!(
1178                    "payload.step.kind '{kind_str}' must be one of hypothesis|tried|ruled_out|observed|refined"
1179                ));
1180            }
1181            let description = step
1182                .get("description")
1183                .and_then(Value::as_str)
1184                .ok_or("payload.step.description must be a non-empty string")?;
1185            if description.trim().is_empty() {
1186                return Err("payload.step.description must be non-empty".to_string());
1187            }
1188            let actor = step
1189                .get("actor")
1190                .and_then(Value::as_str)
1191                .ok_or("payload.step.actor must be a non-empty string")?;
1192            if actor.trim().is_empty() {
1193                return Err("payload.step.actor must be non-empty".to_string());
1194            }
1195        }
1196        EVENT_KIND_TRAJECTORY_REVIEWED => {
1197            require_str("proposal_id")?;
1198            let status = require_str("status")?;
1199            if !matches!(
1200                status,
1201                "accepted" | "approved" | "contested" | "needs_revision" | "rejected"
1202            ) {
1203                return Err(format!("invalid review status '{status}'"));
1204            }
1205        }
1206        EVENT_KIND_TRAJECTORY_RETRACTED => {
1207            require_str("proposal_id")?;
1208        }
1209        EVENT_KIND_ARTIFACT_ASSERTED => {
1210            require_str("proposal_id")?;
1211            let artifact = object
1212                .get("artifact")
1213                .and_then(Value::as_object)
1214                .ok_or("payload.artifact must be a JSON object")?;
1215            let id = artifact
1216                .get("id")
1217                .and_then(Value::as_str)
1218                .ok_or("payload.artifact.id must be a va_<hex>")?;
1219            if !id.starts_with("va_") {
1220                return Err(format!(
1221                    "payload.artifact.id must start with 'va_', got '{id}'"
1222                ));
1223            }
1224            let id_hex = id.trim_start_matches("va_");
1225            if id_hex.len() != 16 || !id_hex.chars().all(|c| c.is_ascii_hexdigit()) {
1226                return Err("payload.artifact.id must be va_<16hex>".to_string());
1227            }
1228            let kind = artifact
1229                .get("kind")
1230                .and_then(Value::as_str)
1231                .ok_or("payload.artifact.kind must be a string")?;
1232            if !crate::bundle::valid_artifact_kind(kind) {
1233                return Err(format!("payload.artifact.kind '{kind}' is not supported"));
1234            }
1235            for key in ["name", "content_hash", "storage_mode"] {
1236                let value = artifact
1237                    .get(key)
1238                    .and_then(Value::as_str)
1239                    .ok_or_else(|| format!("payload.artifact.{key} must be a string"))?;
1240                if value.trim().is_empty() {
1241                    return Err(format!("payload.artifact.{key} must be non-empty"));
1242                }
1243            }
1244            let content_hash = artifact
1245                .get("content_hash")
1246                .and_then(Value::as_str)
1247                .expect("content_hash checked above");
1248            validate_sha256_commitment("payload.artifact.content_hash", content_hash)?;
1249        }
1250        EVENT_KIND_ARTIFACT_REVIEWED => {
1251            require_str("proposal_id")?;
1252            let status = require_str("status")?;
1253            if !matches!(
1254                status,
1255                "accepted" | "approved" | "contested" | "needs_revision" | "rejected"
1256            ) {
1257                return Err(format!("invalid review status '{status}'"));
1258            }
1259        }
1260        EVENT_KIND_ARTIFACT_RETRACTED => {
1261            require_str("proposal_id")?;
1262        }
1263        // v0.51: Re-classify an object's read-side access tier.
1264        // Validates target.r#type up front so a `tier.set` event for
1265        // a non-tiered kernel object (replication, dataset, etc.)
1266        // fails at the validator boundary rather than silently
1267        // succeeding under the reducer's match-or-noop.
1268        EVENT_KIND_TIER_SET => {
1269            require_str("proposal_id")?;
1270            let object_type = require_str("object_type")?;
1271            if !matches!(
1272                object_type,
1273                "finding" | "negative_result" | "trajectory" | "artifact"
1274            ) {
1275                return Err(format!(
1276                    "tier.set object_type '{object_type}' must be one of finding, negative_result, trajectory, artifact"
1277                ));
1278            }
1279            require_str("object_id")?;
1280            let new_tier = require_str("new_tier")?;
1281            crate::access_tier::AccessTier::parse(new_tier)?;
1282            // previous_tier is optional but if present must parse to
1283            // a valid tier so a stale or hand-edited event log can't
1284            // smuggle in `"prev"` strings the reducer would later
1285            // reject.
1286            if let Some(prev) = object.get("previous_tier").and_then(Value::as_str) {
1287                crate::access_tier::AccessTier::parse(prev)?;
1288            }
1289        }
1290        // v0.56: Mechanical evidence-atom locator repair. Required
1291        // payload shape: {proposal_id, source_id, locator}. The locator
1292        // string lands on the atom's `locator` field and the source_id
1293        // is recorded for traceability so a reader can reconstruct the
1294        // derivation without re-resolving the source registry.
1295        EVENT_KIND_EVIDENCE_ATOM_LOCATOR_REPAIRED => {
1296            require_str("proposal_id")?;
1297            let source_id = require_str("source_id")?;
1298            if source_id.trim().is_empty() {
1299                return Err("payload.source_id must be non-empty".to_string());
1300            }
1301            let locator = require_str("locator")?;
1302            if locator.trim().is_empty() {
1303                return Err("payload.locator must be non-empty".to_string());
1304            }
1305        }
1306        // v0.57: Append a `{section, text}` span to a finding's
1307        // evidence_spans. Required payload: {proposal_id, section, text}.
1308        EVENT_KIND_FINDING_SPAN_REPAIRED => {
1309            require_str("proposal_id")?;
1310            let section = require_str("section")?;
1311            if section.trim().is_empty() {
1312                return Err("payload.section must be non-empty".to_string());
1313            }
1314            let text = require_str("text")?;
1315            if text.trim().is_empty() {
1316                return Err("payload.text must be non-empty".to_string());
1317            }
1318        }
1319        // v0.57: Set canonical_id + resolution metadata on a single
1320        // entity inside finding.assertion.entities. Required payload:
1321        // {proposal_id, entity_name, source, id, confidence}.
1322        EVENT_KIND_FINDING_ENTITY_RESOLVED => {
1323            require_str("proposal_id")?;
1324            let entity_name = require_str("entity_name")?;
1325            if entity_name.trim().is_empty() {
1326                return Err("payload.entity_name must be non-empty".to_string());
1327            }
1328            let source = require_str("source")?;
1329            if source.trim().is_empty() {
1330                return Err("payload.source must be non-empty".to_string());
1331            }
1332            let id = require_str("id")?;
1333            if id.trim().is_empty() {
1334                return Err("payload.id must be non-empty".to_string());
1335            }
1336            let confidence = require_f64("confidence")?;
1337            if !(0.0..=1.0).contains(&confidence) {
1338                return Err(format!("payload.confidence {confidence} out of [0.0, 1.0]"));
1339            }
1340        }
1341        other => return Err(format!("unknown event kind '{other}'")),
1342    }
1343    Ok(())
1344}
1345
1346/// v0.73: state-aware tightening of the `bridge.reviewed` validator.
1347///
1348/// `validate_event_payload` is signature-pure; it rejects bad payload
1349/// shapes but cannot verify that the target bridge actually exists in
1350/// the frontier's bridge table. This second pass closes that gap. It
1351/// takes the payload and a slice of `vbr_*` ids known to the local
1352/// frontier, and rejects events whose `payload.bridge_id` is not
1353/// present.
1354///
1355/// Call sites:
1356/// - CLI `vela bridges confirm` / `bridges refute` before emission.
1357/// - Federation intake paths that ingest `bridge.reviewed` events from
1358///   peers.
1359///
1360/// The function is intentionally separate from `validate_event_payload`
1361/// so the shape-only validator stays project-agnostic and can be
1362/// reused by tooling that does not have a frontier in hand.
1363pub fn validate_bridge_reviewed_against_state(
1364    payload: &Value,
1365    known_bridge_ids: &[String],
1366) -> Result<(), String> {
1367    let object = payload
1368        .as_object()
1369        .ok_or_else(|| "payload must be a JSON object".to_string())?;
1370    let bridge_id = object
1371        .get("bridge_id")
1372        .and_then(Value::as_str)
1373        .ok_or_else(|| "missing required string field 'bridge_id'".to_string())?;
1374    if !known_bridge_ids.iter().any(|id| id == bridge_id) {
1375        return Err(format!(
1376            "bridge_id '{bridge_id}' not present on this frontier (no matching .vela/bridges/<id>.json)"
1377        ));
1378    }
1379    Ok(())
1380}
1381
1382/// Public form of `event_id` so callers building non-finding events
1383/// (e.g. the `frontier.created` genesis event in `project::assemble`)
1384/// can compute the canonical event ID with the same canonical-JSON
1385/// preimage shape as `new_finding_event`.
1386pub fn compute_event_id(event: &StateEvent) -> String {
1387    event_id(event)
1388}
1389
1390fn event_id(event: &StateEvent) -> String {
1391    let content = json!({
1392        "schema": event.schema,
1393        "kind": event.kind,
1394        "target": event.target,
1395        "actor": event.actor,
1396        "timestamp": event.timestamp,
1397        "reason": event.reason,
1398        "before_hash": event.before_hash,
1399        "after_hash": event.after_hash,
1400        "payload": event.payload,
1401        "caveats": event.caveats,
1402    });
1403    let bytes = canonical::to_canonical_bytes(&content).unwrap_or_default();
1404    format!("vev_{}", &hex::encode(Sha256::digest(bytes))[..16])
1405}
1406
1407#[cfg(test)]
1408mod tests {
1409    use super::*;
1410    use crate::bundle::{
1411        Assertion, Conditions, Confidence, Evidence, Extraction, FindingBundle, Flags, Provenance,
1412    };
1413    use crate::project;
1414
1415    fn finding() -> FindingBundle {
1416        FindingBundle::new(
1417            Assertion {
1418                text: "LRP1 clears amyloid beta at the BBB".to_string(),
1419                assertion_type: "mechanism".to_string(),
1420                entities: Vec::new(),
1421                relation: None,
1422                direction: None,
1423                causal_claim: None,
1424                causal_evidence_grade: None,
1425            },
1426            Evidence {
1427                evidence_type: "experimental".to_string(),
1428                model_system: "mouse".to_string(),
1429                species: Some("Mus musculus".to_string()),
1430                method: "assay".to_string(),
1431                sample_size: None,
1432                effect_size: None,
1433                p_value: None,
1434                replicated: false,
1435                replication_count: None,
1436                evidence_spans: Vec::new(),
1437            },
1438            Conditions {
1439                text: "mouse model".to_string(),
1440                species_verified: Vec::new(),
1441                species_unverified: Vec::new(),
1442                in_vitro: false,
1443                in_vivo: true,
1444                human_data: false,
1445                clinical_trial: false,
1446                concentration_range: None,
1447                duration: None,
1448                age_group: None,
1449                cell_type: None,
1450            },
1451            Confidence::raw(0.6, "test", 0.8),
1452            Provenance {
1453                source_type: "published_paper".to_string(),
1454                doi: None,
1455                pmid: None,
1456                pmc: None,
1457                openalex_id: None,
1458                url: None,
1459                title: "Test source".to_string(),
1460                authors: Vec::new(),
1461                year: Some(2026),
1462                journal: None,
1463                license: None,
1464                publisher: None,
1465                funders: Vec::new(),
1466                extraction: Extraction::default(),
1467                review: None,
1468                citation_count: None,
1469            },
1470            Flags {
1471                gap: false,
1472                negative_space: false,
1473                contested: false,
1474                retracted: false,
1475                declining: false,
1476                gravity_well: false,
1477                review_state: None,
1478                superseded: false,
1479                signature_threshold: None,
1480                jointly_accepted: false,
1481            },
1482        )
1483    }
1484
1485    #[test]
1486    fn event_id_is_deterministic_for_content() {
1487        let event = new_finding_event(FindingEventInput {
1488            kind: "finding.reviewed",
1489            finding_id: "vf_test",
1490            actor_id: "reviewer",
1491            actor_type: "human",
1492            reason: "checked",
1493            before_hash: NULL_HASH,
1494            after_hash: "sha256:abc",
1495            payload: json!({"status": "accepted", "proposal_id": "vpr_test"}),
1496            caveats: vec![],
1497        });
1498        let mut same = event.clone();
1499        same.id = String::new();
1500        same.id = super::event_id(&same);
1501        assert_eq!(event.id, same.id);
1502    }
1503
1504    #[test]
1505    fn genesis_only_event_log_replays_ok() {
1506        // Phase J: assemble() emits a `frontier.created` genesis event,
1507        // so a freshly compiled frontier never has an empty event log.
1508        // Replay over genesis-only must succeed with status "ok" and the
1509        // single event accounted for.
1510        let frontier = project::assemble("test", Vec::new(), 0, 0, "test");
1511        let report = replay_report(&frontier);
1512        assert!(report.ok, "{:?}", report.conflicts);
1513        assert_eq!(report.event_log.count, 1);
1514        assert_eq!(report.event_log.kinds.get("frontier.created"), Some(&1));
1515    }
1516
1517    #[test]
1518    fn replay_detects_duplicate_event_ids() {
1519        let finding = finding();
1520        let after_hash = finding_hash(&finding);
1521        let event = new_finding_event(FindingEventInput {
1522            kind: "finding.reviewed",
1523            finding_id: &finding.id,
1524            actor_id: "reviewer",
1525            actor_type: "human",
1526            reason: "checked",
1527            before_hash: &after_hash,
1528            after_hash: &after_hash,
1529            payload: json!({"status": "accepted", "proposal_id": "vpr_test"}),
1530            caveats: vec![],
1531        });
1532        let mut frontier = project::assemble("test", vec![finding], 0, 0, "test");
1533        frontier.events = vec![event.clone(), event];
1534
1535        let report = replay_report(&frontier);
1536        assert!(!report.ok);
1537        assert_eq!(report.status, "conflict");
1538        assert!(!report.event_log.duplicate_ids.is_empty());
1539    }
1540
1541    #[test]
1542    fn replay_detects_orphan_targets() {
1543        let mut frontier = project::assemble("test", Vec::new(), 0, 0, "test");
1544        frontier.events.push(new_finding_event(FindingEventInput {
1545            kind: "finding.reviewed",
1546            finding_id: "vf_missing",
1547            actor_id: "reviewer",
1548            actor_type: "human",
1549            reason: "checked",
1550            before_hash: NULL_HASH,
1551            after_hash: "sha256:abc",
1552            payload: json!({"status": "accepted", "proposal_id": "vpr_test"}),
1553            caveats: vec![],
1554        }));
1555
1556        let report = replay_report(&frontier);
1557        assert!(!report.ok);
1558        assert_eq!(report.event_log.orphan_targets, vec!["vf_missing"]);
1559    }
1560
1561    #[test]
1562    fn replay_accepts_current_hash_boundary() {
1563        let finding = finding();
1564        let hash = finding_hash(&finding);
1565        let event = new_finding_event(FindingEventInput {
1566            kind: "finding.reviewed",
1567            finding_id: &finding.id,
1568            actor_id: "reviewer",
1569            actor_type: "human",
1570            reason: "checked",
1571            before_hash: &hash,
1572            after_hash: &hash,
1573            payload: json!({"status": "accepted", "proposal_id": "vpr_test"}),
1574            caveats: vec![],
1575        });
1576        let mut frontier = project::assemble("test", vec![finding], 0, 0, "test");
1577        frontier.events.push(event);
1578
1579        let report = replay_report(&frontier);
1580        assert!(report.ok, "{:?}", report.conflicts);
1581        assert_eq!(report.status, "ok");
1582    }
1583
1584    // v0.39 — federation event validation
1585    #[test]
1586    fn validates_synced_with_peer_payload() {
1587        // OK: full payload.
1588        assert!(
1589            validate_event_payload(
1590                "frontier.synced_with_peer",
1591                &json!({
1592                    "peer_id": "hub:peer",
1593                    "peer_snapshot_hash": "abc",
1594                    "our_snapshot_hash": "def",
1595                    "divergence_count": 3,
1596                }),
1597            )
1598            .is_ok()
1599        );
1600        // FAIL: missing divergence_count.
1601        assert!(
1602            validate_event_payload(
1603                "frontier.synced_with_peer",
1604                &json!({
1605                    "peer_id": "hub:peer",
1606                    "peer_snapshot_hash": "abc",
1607                    "our_snapshot_hash": "def",
1608                }),
1609            )
1610            .is_err()
1611        );
1612        // FAIL: missing peer_id.
1613        assert!(
1614            validate_event_payload(
1615                "frontier.synced_with_peer",
1616                &json!({
1617                    "peer_snapshot_hash": "abc",
1618                    "our_snapshot_hash": "def",
1619                    "divergence_count": 0,
1620                }),
1621            )
1622            .is_err()
1623        );
1624    }
1625
1626    #[test]
1627    fn validates_conflict_detected_payload() {
1628        // OK: full payload.
1629        assert!(
1630            validate_event_payload(
1631                "frontier.conflict_detected",
1632                &json!({
1633                    "peer_id": "hub:peer",
1634                    "finding_id": "vf_xyz",
1635                    "kind": "different_review_verdict",
1636                }),
1637            )
1638            .is_ok()
1639        );
1640        // FAIL: empty kind.
1641        assert!(
1642            validate_event_payload(
1643                "frontier.conflict_detected",
1644                &json!({
1645                    "peer_id": "hub:peer",
1646                    "finding_id": "vf_xyz",
1647                    "kind": "  ",
1648                }),
1649            )
1650            .is_err()
1651        );
1652        // FAIL: missing finding_id.
1653        assert!(
1654            validate_event_payload(
1655                "frontier.conflict_detected",
1656                &json!({
1657                    "peer_id": "hub:peer",
1658                    "kind": "missing_in_peer",
1659                }),
1660            )
1661            .is_err()
1662        );
1663    }
1664
1665    #[test]
1666    fn validates_artifact_asserted_payload() {
1667        let good_hash = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
1668        assert!(
1669            validate_event_payload(
1670                EVENT_KIND_ARTIFACT_ASSERTED,
1671                &json!({
1672                    "proposal_id": "vpr_test",
1673                    "artifact": {
1674                        "id": "va_1234567890abcdef",
1675                        "kind": "clinical_trial_record",
1676                        "name": "NCT test record",
1677                        "content_hash": good_hash,
1678                        "storage_mode": "embedded",
1679                    },
1680                }),
1681            )
1682            .is_ok()
1683        );
1684        assert!(
1685            validate_event_payload(
1686                EVENT_KIND_ARTIFACT_ASSERTED,
1687                &json!({
1688                    "proposal_id": "vpr_test",
1689                    "artifact": {
1690                        "id": "va_123",
1691                        "kind": "clinical_trial_record",
1692                        "name": "NCT test record",
1693                        "content_hash": good_hash,
1694                        "storage_mode": "embedded",
1695                    },
1696                }),
1697            )
1698            .is_err()
1699        );
1700        assert!(
1701            validate_event_payload(
1702                EVENT_KIND_ARTIFACT_ASSERTED,
1703                &json!({
1704                    "proposal_id": "vpr_test",
1705                    "artifact": {
1706                        "id": "va_1234567890abcdef",
1707                        "kind": "clinical_trial_record",
1708                        "name": "NCT test record",
1709                        "content_hash": "sha256:not-a-real-hash",
1710                        "storage_mode": "embedded",
1711                    },
1712                }),
1713            )
1714            .is_err()
1715        );
1716    }
1717
1718    // v0.38 — causal-typing event validation
1719    #[test]
1720    fn validates_reinterpreted_causal_payload() {
1721        // OK: missing claim/grade is fine (None means no prior reading).
1722        assert!(
1723            validate_event_payload(
1724                "assertion.reinterpreted_causal",
1725                &json!({
1726                    "proposal_id": "vpr_test",
1727                    "before": {},
1728                    "after": { "claim": "intervention", "grade": "rct" },
1729                }),
1730            )
1731            .is_ok()
1732        );
1733        // OK: pure claim revision, no grade.
1734        assert!(
1735            validate_event_payload(
1736                "assertion.reinterpreted_causal",
1737                &json!({
1738                    "proposal_id": "vpr_test",
1739                    "before": { "claim": "correlation" },
1740                    "after": { "claim": "mediation" },
1741                }),
1742            )
1743            .is_ok()
1744        );
1745        // FAIL: invalid claim.
1746        assert!(
1747            validate_event_payload(
1748                "assertion.reinterpreted_causal",
1749                &json!({
1750                    "proposal_id": "vpr_test",
1751                    "before": {},
1752                    "after": { "claim": "magic" },
1753                }),
1754            )
1755            .is_err()
1756        );
1757        // FAIL: invalid grade.
1758        assert!(
1759            validate_event_payload(
1760                "assertion.reinterpreted_causal",
1761                &json!({
1762                    "proposal_id": "vpr_test",
1763                    "before": {},
1764                    "after": { "claim": "intervention", "grade": "vibes" },
1765                }),
1766            )
1767            .is_err()
1768        );
1769        // FAIL: missing proposal_id.
1770        assert!(
1771            validate_event_payload(
1772                "assertion.reinterpreted_causal",
1773                &json!({
1774                    "before": {},
1775                    "after": { "claim": "intervention" },
1776                }),
1777            )
1778            .is_err()
1779        );
1780    }
1781
1782    /// v0.49: a `key.revoke` event names the revoked pubkey, the
1783    /// moment of compromise, and (optionally) the replacement key.
1784    /// Empty optional fields skip canonical-JSON serialization so
1785    /// existing event logs round-trip byte-identically.
1786    #[test]
1787    fn revocation_event_canonical_shape() {
1788        use crate::canonical;
1789        let payload = RevocationPayload {
1790            revoked_pubkey: "4892f93877e637b5f59af31d9ec6704814842fb278cacb0eb94704baef99455e"
1791                .to_string(),
1792            revoked_at: "2026-05-01T17:00:00Z".to_string(),
1793            replacement_pubkey: "8891a2ab35ca2ed2182ed4e46b6567ce8dacc9985eb496d895578201272a1cd9"
1794                .to_string(),
1795            reason: "key file leaked from CI cache".to_string(),
1796        };
1797        let event = new_revocation_event(
1798            "reviewer:will-blair",
1799            "human",
1800            payload,
1801            "rotating compromised key",
1802            NULL_HASH,
1803            NULL_HASH,
1804        );
1805        assert_eq!(event.kind, EVENT_KIND_KEY_REVOKE);
1806        assert_eq!(event.target.r#type, "actor");
1807        assert!(event.id.starts_with("vev_"));
1808        let bytes = canonical::to_canonical_bytes(&event).unwrap();
1809        let s = std::str::from_utf8(&bytes).unwrap();
1810        assert!(
1811            s.contains("\"revoked_pubkey\""),
1812            "canonical bytes missing revoked_pubkey: {s}"
1813        );
1814        assert!(
1815            s.contains("\"revoked_at\""),
1816            "canonical bytes missing revoked_at: {s}"
1817        );
1818        assert!(
1819            s.contains("\"replacement_pubkey\""),
1820            "canonical bytes missing replacement_pubkey: {s}"
1821        );
1822
1823        // Empty replacement_pubkey skips serialization.
1824        let payload_minimal = RevocationPayload {
1825            revoked_pubkey: "a".repeat(64),
1826            revoked_at: "2026-05-01T17:00:00Z".to_string(),
1827            replacement_pubkey: String::new(),
1828            reason: String::new(),
1829        };
1830        let minimal_event = new_revocation_event(
1831            "reviewer:will-blair",
1832            "human",
1833            payload_minimal,
1834            "scheduled rotation",
1835            NULL_HASH,
1836            NULL_HASH,
1837        );
1838        let minimal_bytes = canonical::to_canonical_bytes(&minimal_event).unwrap();
1839        let minimal_s = std::str::from_utf8(&minimal_bytes).unwrap();
1840        assert!(
1841            !minimal_s.contains("\"replacement_pubkey\""),
1842            "empty replacement_pubkey leaked into canonical JSON: {minimal_s}"
1843        );
1844        assert!(
1845            !minimal_s.contains("\"reason\":\"\""),
1846            "empty payload reason leaked into canonical JSON: {minimal_s}"
1847        );
1848    }
1849
1850    /// v0.49: validate_event_payload now recognises `key.revoke`.
1851    /// Tests cover the four real failure modes plus the happy path.
1852    #[test]
1853    fn revocation_payload_validation() {
1854        let good_pubkey = "4892f93877e637b5f59af31d9ec6704814842fb278cacb0eb94704baef99455e";
1855        let other_pubkey = "8891a2ab35ca2ed2182ed4e46b6567ce8dacc9985eb496d895578201272a1cd9";
1856
1857        // OK: minimal valid payload.
1858        assert!(
1859            validate_event_payload(
1860                EVENT_KIND_KEY_REVOKE,
1861                &json!({
1862                    "revoked_pubkey": good_pubkey,
1863                    "revoked_at": "2026-05-01T17:00:00Z",
1864                }),
1865            )
1866            .is_ok()
1867        );
1868
1869        // OK: full payload with replacement and reason.
1870        assert!(
1871            validate_event_payload(
1872                EVENT_KIND_KEY_REVOKE,
1873                &json!({
1874                    "revoked_pubkey": good_pubkey,
1875                    "revoked_at": "2026-05-01T17:00:00Z",
1876                    "replacement_pubkey": other_pubkey,
1877                    "reason": "key file leaked",
1878                }),
1879            )
1880            .is_ok()
1881        );
1882
1883        // FAIL: revoked_pubkey wrong length (32 bytes ASCII, not 64 hex).
1884        assert!(
1885            validate_event_payload(
1886                EVENT_KIND_KEY_REVOKE,
1887                &json!({
1888                    "revoked_pubkey": "abc123",
1889                    "revoked_at": "2026-05-01T17:00:00Z",
1890                }),
1891            )
1892            .is_err()
1893        );
1894
1895        // FAIL: revoked_pubkey contains non-hex chars.
1896        assert!(
1897            validate_event_payload(
1898                EVENT_KIND_KEY_REVOKE,
1899                &json!({
1900                    "revoked_pubkey": "ZZ".repeat(32),
1901                    "revoked_at": "2026-05-01T17:00:00Z",
1902                }),
1903            )
1904            .is_err()
1905        );
1906
1907        // FAIL: missing revoked_at.
1908        assert!(
1909            validate_event_payload(
1910                EVENT_KIND_KEY_REVOKE,
1911                &json!({
1912                    "revoked_pubkey": good_pubkey,
1913                }),
1914            )
1915            .is_err()
1916        );
1917
1918        // FAIL: replacement_pubkey wrong length.
1919        assert!(
1920            validate_event_payload(
1921                EVENT_KIND_KEY_REVOKE,
1922                &json!({
1923                    "revoked_pubkey": good_pubkey,
1924                    "revoked_at": "2026-05-01T17:00:00Z",
1925                    "replacement_pubkey": "deadbeef",
1926                }),
1927            )
1928            .is_err()
1929        );
1930
1931        // FAIL: replacement equals revoked (no-op rotation).
1932        assert!(
1933            validate_event_payload(
1934                EVENT_KIND_KEY_REVOKE,
1935                &json!({
1936                    "revoked_pubkey": good_pubkey,
1937                    "revoked_at": "2026-05-01T17:00:00Z",
1938                    "replacement_pubkey": good_pubkey,
1939                }),
1940            )
1941            .is_err()
1942        );
1943
1944        // FAIL: revoked_at is non-empty but not a valid ISO-8601 stamp.
1945        // The v0.49.1 validator parses it as RFC-3339 so typos can't
1946        // reach replay verification.
1947        // chrono's parse_from_rfc3339 is intentionally lenient on the
1948        // `T` vs space separator (RFC-3339 §5.6), so we don't include
1949        // that case here — chronologically nonsensical strings still
1950        // fail, which is the bar we care about.
1951        for bad in [
1952            "yesterday",
1953            "2026-13-01T00:00:00Z", // month 13
1954            "2026-05-01",           // date only, no time
1955            "x",
1956        ] {
1957            assert!(
1958                validate_event_payload(
1959                    EVENT_KIND_KEY_REVOKE,
1960                    &json!({
1961                        "revoked_pubkey": good_pubkey,
1962                        "revoked_at": bad,
1963                    }),
1964                )
1965                .is_err(),
1966                "expected revoked_at {bad:?} to fail validation"
1967            );
1968        }
1969    }
1970
1971    /// v0.73: state-aware bridge.reviewed tightening. The
1972    /// signature-pure validator only checks payload shape; this
1973    /// second-pass function rejects events whose bridge_id is not
1974    /// present on the local frontier.
1975    #[test]
1976    fn bridge_reviewed_state_aware_rejects_unknown_id() {
1977        let known: Vec<String> = vec!["vbr_aaaaaaaaaaaaaaaa".to_string()];
1978
1979        // PASS: bridge_id matches a known bridge.
1980        assert!(
1981            validate_bridge_reviewed_against_state(
1982                &json!({
1983                    "bridge_id": "vbr_aaaaaaaaaaaaaaaa",
1984                    "status": "confirmed",
1985                }),
1986                &known,
1987            )
1988            .is_ok()
1989        );
1990
1991        // FAIL: bridge_id is well-formed but absent from the frontier.
1992        let err = validate_bridge_reviewed_against_state(
1993            &json!({
1994                "bridge_id": "vbr_bbbbbbbbbbbbbbbb",
1995                "status": "confirmed",
1996            }),
1997            &known,
1998        )
1999        .expect_err("expected unknown bridge_id to be rejected");
2000        assert!(
2001            err.contains("not present on this frontier"),
2002            "error should explain the gap: {err}"
2003        );
2004
2005        // FAIL: missing bridge_id (defensive; signature-pure layer
2006        // catches this too, but the state-aware layer must not panic
2007        // on malformed input).
2008        assert!(
2009            validate_bridge_reviewed_against_state(
2010                &json!({
2011                    "status": "confirmed",
2012                }),
2013                &known,
2014            )
2015            .is_err()
2016        );
2017
2018        // FAIL: empty known list. Real frontiers may have zero
2019        // bridges; an event referencing any id must be rejected.
2020        assert!(
2021            validate_bridge_reviewed_against_state(
2022                &json!({
2023                    "bridge_id": "vbr_aaaaaaaaaaaaaaaa",
2024                    "status": "confirmed",
2025                }),
2026                &[],
2027            )
2028            .is_err()
2029        );
2030    }
2031}