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