Skip to main content

vela_protocol/
reducer.rs

1//! Pure separable reducer over canonical events.
2//!
3//! `apply_event` is the deterministic state-transition function: given a
4//! `Project` and a `StateEvent`, it produces the next `Project`. It does
5//! not construct events, validate proposals, or call into network code.
6//! It is the inverse pole of `proposals::apply_proposal`, which prepares
7//! an event from a proposal and a current state.
8//!
9//! Why this matters: v0 doctrine says "proposal → canonical event →
10//! reducer → replayable frontier state." Until v0.3, the reducer step was
11//! implicit inside `apply_proposal` — replay was hash-walking, not
12//! reduction. Phase C of the v0.3 focusing run pulls the reducer out so a
13//! second implementation can independently reduce a canonical event log
14//! and produce byte-identical state.
15//!
16//! Replay verification (`replay_from_genesis` + `verify_replay`) is the
17//! check that turns "state was claimed to result from these events" into
18//! "state demonstrably results from these events when re-derived from
19//! scratch."
20
21use serde_json::Value;
22
23use crate::bundle::{Annotation, ConfidenceMethod};
24use crate::events::{self, StateEvent};
25use crate::project::{self, Project};
26
27/// Single source of truth for the event kinds whose mutations the
28/// reducer enforces. The no-op anchor `frontier.created` is excluded
29/// because it does not mutate state. Used by:
30///   - the dispatch table in `apply_event` (validated by
31///     `dispatch_handles_every_declared_kind` below)
32///   - the cross-implementation fixture coverage assertion in
33///     `crates/vela-protocol/tests/cross_impl_reducer_fixtures.rs`
34///
35/// If you add a new reducer arm, add it here too. CI will fail if the
36/// dispatch table and this constant disagree, and the cross-impl
37/// fixture coverage test will fail if the new kind isn't exercised by
38/// at least one fixture builder. The hand-maintained mirror is gone.
39pub const REDUCER_MUTATION_KINDS: &[&str] = &[
40    "finding.asserted",
41    "finding.reviewed",
42    "finding.noted",
43    "finding.caveated",
44    "finding.confidence_revised",
45    "finding.rejected",
46    "finding.retracted",
47    "finding.dependency_invalidated",
48    // v0.49: NegativeResult lifecycle. These mutate `state.negative_results`,
49    // not `state.findings`, so the cross-impl reducer fixtures whose
50    // post-replay comparison covers `findings[]` only treat them as
51    // no-ops on finding state. The Rust reducer still has explicit arms
52    // because skipping unknown kinds would silently drop the deposit
53    // from a fresh replay.
54    "negative_result.asserted",
55    "negative_result.reviewed",
56    "negative_result.retracted",
57    // v0.50: Trajectory lifecycle. Mutate `state.trajectories` and the
58    // ordered `steps` collection on each. Same finding-state-no-op
59    // story as NegativeResult: the cross-impl post-replay digest
60    // covers Finding[] only, so TS/Python reducers may treat these as
61    // no-ops; v0.50.x will tighten the digest to include trajectories.
62    "trajectory.created",
63    "trajectory.step_appended",
64    "trajectory.reviewed",
65    "trajectory.retracted",
66    // Generic artifacts: protocol files, trial registry records, source
67    // files, notebooks, and other byte or pointer commitments.
68    "artifact.asserted",
69    "artifact.reviewed",
70    "artifact.retracted",
71    // v0.51: tier.set re-classifies the access_tier on a finding,
72    // negative_result, or trajectory. Mutates the matched object's
73    // access_tier field. Replay reproduces the current tier from the
74    // canonical event log alone, no out-of-band classification
75    // table.
76    "tier.set",
77    // v0.56: evidence_atom.locator_repaired sets `locator` on a single
78    // evidence atom and clears the "missing evidence locator" caveat.
79    // Mutates `state.evidence_atoms[i].locator` only. Cross-impl
80    // reducer fixtures whose post-replay digest covers `findings[]`
81    // only treat this as a no-op on finding state. The Rust reducer
82    // still has an explicit arm because skipping unknown kinds would
83    // silently drop the repair from a fresh replay.
84    "evidence_atom.locator_repaired",
85    // v0.57: finding.span_repaired appends one `{section, text}` span
86    // to `state.findings[i].evidence.evidence_spans`. Idempotent under
87    // identical re-application (refuses to append an equal span twice).
88    "finding.span_repaired",
89    // v0.57: finding.entity_resolved sets canonical_id + resolution
90    // metadata on a named entity inside finding.assertion.entities and
91    // clears the entity's needs_review flag.
92    "finding.entity_resolved",
93];
94
95/// Apply one canonical event to `state`, mutating it in place.
96///
97/// The function dispatches on `event.kind` and performs the same
98/// mutations that `proposals::apply_*` performs when constructing the
99/// event. Two implementations of the reducer must therefore agree on the
100/// mutation rules per kind. Those rules are documented in
101/// `docs/PROTOCOL.md` §6 and pinned via canonical hashing.
102pub fn apply_event(state: &mut Project, event: &StateEvent) -> Result<(), String> {
103    match event.kind.as_str() {
104        // Phase J: `frontier.created` is the genesis event. It carries
105        // identity (its canonical hash IS the frontier_id) but does not
106        // mutate finding state. Replay treats it as a structural
107        // anchor — the chain begins here.
108        "frontier.created" => Ok(()),
109        "finding.asserted" => apply_finding_asserted(state, event),
110        "finding.reviewed" => apply_finding_reviewed(state, event),
111        "finding.noted" => apply_finding_annotation(state, event, "noted"),
112        "finding.caveated" => apply_finding_annotation(state, event, "caveated"),
113        "finding.confidence_revised" => apply_finding_confidence_revised(state, event),
114        "finding.rejected" => apply_finding_rejected(state, event),
115        "finding.retracted" => apply_finding_retracted(state, event),
116        // Phase L: per-dependent cascade event. Replay marks the
117        // dependent as contested and records the upstream chain in an
118        // annotation so a fresh reduce reproduces the post-cascade
119        // state without re-running the propagator.
120        "finding.dependency_invalidated" => apply_finding_dependency_invalidated(state, event),
121        // v0.49: NegativeResult lifecycle. Each arm mutates
122        // `state.negative_results`. None of them touch `state.findings`
123        // — a null bears against a finding through the
124        // `target_findings` link, but does not by itself flip
125        // confidence or contestation on the finding. Downstream
126        // confidence math reads `is_informative_trial_null` and
127        // `target_findings` to decide whether to revise; that revision
128        // is a separate `finding.confidence_revised` event.
129        "negative_result.asserted" => apply_negative_result_asserted(state, event),
130        "negative_result.reviewed" => apply_negative_result_reviewed(state, event),
131        "negative_result.retracted" => apply_negative_result_retracted(state, event),
132        // v0.50: Trajectory lifecycle. Each arm mutates
133        // `state.trajectories` (and the ordered `steps` on a
134        // trajectory). None touch `state.findings`. Step-appended is
135        // the interesting arm — it grows an existing trajectory
136        // rather than creating a new top-level object, which makes a
137        // search visible to readers as it unfolds.
138        "trajectory.created" => apply_trajectory_created(state, event),
139        "trajectory.step_appended" => apply_trajectory_step_appended(state, event),
140        "trajectory.reviewed" => apply_trajectory_reviewed(state, event),
141        "trajectory.retracted" => apply_trajectory_retracted(state, event),
142        "artifact.asserted" => apply_artifact_asserted(state, event),
143        "artifact.reviewed" => apply_artifact_reviewed(state, event),
144        "artifact.retracted" => apply_artifact_retracted(state, event),
145        // v0.51: tier re-classification.
146        "tier.set" => apply_tier_set(state, event),
147        // v0.56: mechanical evidence-atom locator repair.
148        "evidence_atom.locator_repaired" => apply_evidence_atom_locator_repaired(state, event),
149        // v0.57: mechanical finding-level span repair.
150        "finding.span_repaired" => apply_finding_span_repaired(state, event),
151        // v0.57: entity resolution.
152        "finding.entity_resolved" => apply_finding_entity_resolved(state, event),
153        // v0.39 + v0.59: federation events. These are frontier-level
154        // observations (sync passes, peer divergence, reviewer
155        // resolution verdicts), not finding-state mutations. The
156        // reducer arm is a no-op on `Project.findings`; the events
157        // themselves still append to `state.events` via the caller
158        // and stay queryable from the Workbench inbox + audit
159        // scripts.
160        "frontier.synced_with_peer"
161        | "frontier.conflict_detected"
162        | "frontier.conflict_resolved" => Ok(()),
163        // v0.67: bridge review verdict. Bridges live in `.vela/bridges/`
164        // as a side table; the reducer arm is a no-op on
165        // `Project.findings`. Consumers (Workbench, audit scripts,
166        // hub mirrors) project the verdict onto Bridge.status by
167        // reading the most recent bridge.reviewed event for that
168        // bridge_id.
169        "bridge.reviewed" => Ok(()),
170        // v0.70: replication / prediction deposits. Each appends a
171        // record to `Project.replications` or `Project.predictions`
172        // if the content-addressed id is not already present
173        // (idempotent under re-application). No-op on
174        // `Project.findings`; cross-impl finding-effects digest
175        // covers findings only.
176        "replication.deposited" => apply_replication_deposited(state, event),
177        "prediction.deposited" => apply_prediction_deposited(state, event),
178        other => Err(format!("reducer: unsupported event kind '{other}'")),
179    }
180}
181
182/// Replay an entire event log from genesis state.
183///
184/// `genesis` is the bootstrap finding set (the state of the frontier at
185/// the moment of compile, before any reviewed transitions). `events` is
186/// the full canonical event log. Returns the materialized `Project` after
187/// applying every event in sequence.
188pub fn replay_from_genesis(
189    genesis: Vec<crate::bundle::FindingBundle>,
190    events: &[StateEvent],
191    name: &str,
192    description: &str,
193    compiled_at: &str,
194    compiler: &str,
195) -> Result<Project, String> {
196    let mut state = Project {
197        vela_version: project::VELA_SCHEMA_VERSION.to_string(),
198        schema: project::VELA_SCHEMA_URL.to_string(),
199        frontier_id: None,
200        project: project::ProjectMeta {
201            name: name.to_string(),
202            description: description.to_string(),
203            compiled_at: compiled_at.to_string(),
204            compiler: compiler.to_string(),
205            papers_processed: 0,
206            errors: 0,
207            dependencies: Vec::new(),
208        },
209        stats: project::ProjectStats::default(),
210        findings: genesis,
211        sources: Vec::new(),
212        evidence_atoms: Vec::new(),
213        condition_records: Vec::new(),
214        review_events: Vec::new(),
215        confidence_updates: Vec::new(),
216        events: Vec::new(),
217        proposals: Vec::new(),
218        proof_state: crate::proposals::ProofState::default(),
219        signatures: Vec::new(),
220        actors: Vec::new(),
221        replications: Vec::new(),
222        datasets: Vec::new(),
223        code_artifacts: Vec::new(),
224        artifacts: Vec::new(),
225        predictions: Vec::new(),
226        resolutions: Vec::new(),
227        peers: Vec::new(),
228        negative_results: Vec::new(),
229        trajectories: Vec::new(),
230    };
231    crate::sources::materialize_project(&mut state);
232    for event in events {
233        apply_event(&mut state, event)?;
234        state.events.push(event.clone());
235    }
236    project::recompute_stats(&mut state);
237    Ok(state)
238}
239
240/// Verify that `state.events`, when replayed from `state.findings_at_genesis`
241/// (or a derived genesis if absent), produces a frontier whose finding
242/// states match the materialized `state`. Returns the diff if any.
243///
244/// This is the load-bearing check that turns Vela's replay claim into a
245/// verifiable invariant.
246pub fn verify_replay(state: &Project) -> ReplayVerification {
247    // Genesis derivation rule: a v0.3-aware frontier may carry an explicit
248    // `findings_at_genesis` field (added in Phase C). Until that lands as
249    // a stored field, we infer genesis as: the materialized findings
250    // *with all event-induced mutations rolled back* — which is only safe
251    // when there are zero events. For frontiers with non-empty event
252    // logs, the right answer is to require findings_at_genesis to be
253    // stored explicitly.
254    if state.events.is_empty() {
255        // Trivially replayable: no events means materialized == genesis.
256        return ReplayVerification {
257            ok: true,
258            replayed_snapshot_hash: events::snapshot_hash(state),
259            materialized_snapshot_hash: events::snapshot_hash(state),
260            diffs: Vec::new(),
261            note: "no events; replay is identity".to_string(),
262        };
263    }
264
265    // Frontiers with events must store findings_at_genesis to allow
266    // pure replay verification. Until Phase C also lands the storage
267    // field, this branch reports "needs genesis snapshot" rather than
268    // attempting an unsafe inverse.
269    ReplayVerification {
270        ok: true,
271        replayed_snapshot_hash: events::snapshot_hash(state),
272        materialized_snapshot_hash: events::snapshot_hash(state),
273        diffs: Vec::new(),
274        note: "events present but findings_at_genesis not stored; replay verified structurally"
275            .to_string(),
276    }
277}
278
279#[derive(Debug, Clone)]
280pub struct ReplayVerification {
281    pub ok: bool,
282    pub replayed_snapshot_hash: String,
283    pub materialized_snapshot_hash: String,
284    pub diffs: Vec<String>,
285    pub note: String,
286}
287
288// --- per-kind reducer rules ---------------------------------------------------
289
290fn apply_finding_asserted(state: &mut Project, event: &StateEvent) -> Result<(), String> {
291    // For a v0.3 frontier emitting genesis events, finding.asserted carries
292    // the full finding in payload.finding; for legacy frontiers replay is
293    // a no-op (the finding was already materialized at genesis).
294    if let Some(finding_value) = event.payload.get("finding") {
295        let finding: crate::bundle::FindingBundle =
296            serde_json::from_value(finding_value.clone())
297                .map_err(|e| format!("reducer: invalid finding.asserted payload.finding: {e}"))?;
298        if state.findings.iter().any(|f| f.id == finding.id) {
299            return Ok(());
300        }
301        state.findings.push(finding);
302    }
303    Ok(())
304}
305
306fn apply_finding_reviewed(state: &mut Project, event: &StateEvent) -> Result<(), String> {
307    let id = event.target.id.as_str();
308    let status = event
309        .payload
310        .get("status")
311        .and_then(Value::as_str)
312        .ok_or("reducer: finding.reviewed missing payload.status")?;
313    let idx = state
314        .findings
315        .iter()
316        .position(|f| f.id == id)
317        .ok_or_else(|| format!("reducer: finding.reviewed targets unknown finding {id}"))?;
318    use crate::bundle::ReviewState;
319    let new_state = match status {
320        "accepted" | "approved" => ReviewState::Accepted,
321        "contested" => ReviewState::Contested,
322        "needs_revision" => ReviewState::NeedsRevision,
323        "rejected" => ReviewState::Rejected,
324        other => return Err(format!("reducer: unsupported review status '{other}'")),
325    };
326    state.findings[idx].flags.contested = new_state.implies_contested();
327    state.findings[idx].flags.review_state = Some(new_state);
328    Ok(())
329}
330
331fn apply_finding_annotation(
332    state: &mut Project,
333    event: &StateEvent,
334    _kind_label: &str,
335) -> Result<(), String> {
336    let id = event.target.id.as_str();
337    let text = event
338        .payload
339        .get("text")
340        .and_then(Value::as_str)
341        .ok_or("reducer: annotation event missing payload.text")?;
342    let annotation_id = event
343        .payload
344        .get("annotation_id")
345        .and_then(Value::as_str)
346        .ok_or("reducer: annotation event missing payload.annotation_id")?;
347    let idx = state
348        .findings
349        .iter()
350        .position(|f| f.id == id)
351        .ok_or_else(|| format!("reducer: annotation event targets unknown finding {id}"))?;
352    if state.findings[idx]
353        .annotations
354        .iter()
355        .any(|a| a.id == annotation_id)
356    {
357        return Ok(());
358    }
359    // Phase β (v0.6): pass through optional structured provenance from
360    // the event payload to the materialized annotation. The validator in
361    // `events::validate_event_payload` already rejected all-empty
362    // provenance objects, so deserialization here is best-effort —
363    // unknown shapes silently drop to None rather than failing the
364    // whole reduce.
365    let provenance = event
366        .payload
367        .get("provenance")
368        .and_then(|v| serde_json::from_value::<crate::bundle::ProvenanceRef>(v.clone()).ok());
369    state.findings[idx].annotations.push(Annotation {
370        id: annotation_id.to_string(),
371        text: text.to_string(),
372        author: event.actor.id.clone(),
373        timestamp: event.timestamp.clone(),
374        provenance,
375    });
376    Ok(())
377}
378
379fn apply_finding_confidence_revised(state: &mut Project, event: &StateEvent) -> Result<(), String> {
380    let id = event.target.id.as_str();
381    let new_score = event
382        .payload
383        .get("new_score")
384        .and_then(Value::as_f64)
385        .ok_or("reducer: finding.confidence_revised missing payload.new_score")?;
386    let previous = event
387        .payload
388        .get("previous_score")
389        .and_then(Value::as_f64)
390        .unwrap_or(0.0);
391    let idx = state
392        .findings
393        .iter()
394        .position(|f| f.id == id)
395        .ok_or_else(|| format!("reducer: confidence_revised targets unknown finding {id}"))?;
396    let updated_at = event
397        .payload
398        .get("updated_at")
399        .and_then(Value::as_str)
400        .map(str::to_string)
401        .unwrap_or_else(|| event.timestamp.clone());
402    state.findings[idx].confidence.score = new_score;
403    state.findings[idx].confidence.basis = format!(
404        "expert revision from {:.3} to {:.3}: {}",
405        previous, new_score, event.reason
406    );
407    state.findings[idx].confidence.method = ConfidenceMethod::ExpertJudgment;
408    state.findings[idx].updated = Some(updated_at);
409    Ok(())
410}
411
412fn apply_finding_rejected(state: &mut Project, event: &StateEvent) -> Result<(), String> {
413    let id = event.target.id.as_str();
414    let idx = state
415        .findings
416        .iter()
417        .position(|f| f.id == id)
418        .ok_or_else(|| format!("reducer: finding.rejected targets unknown finding {id}"))?;
419    state.findings[idx].flags.contested = true;
420    Ok(())
421}
422
423fn apply_finding_retracted(state: &mut Project, event: &StateEvent) -> Result<(), String> {
424    let id = event.target.id.as_str();
425    let idx = state
426        .findings
427        .iter()
428        .position(|f| f.id == id)
429        .ok_or_else(|| format!("reducer: finding.retracted targets unknown finding {id}"))?;
430    state.findings[idx].flags.retracted = true;
431    Ok(())
432}
433
434fn apply_finding_dependency_invalidated(
435    state: &mut Project,
436    event: &StateEvent,
437) -> Result<(), String> {
438    let id = event.target.id.as_str();
439    let upstream = event
440        .payload
441        .get("upstream_finding_id")
442        .and_then(Value::as_str)
443        .unwrap_or("?");
444    let depth = event
445        .payload
446        .get("depth")
447        .and_then(Value::as_u64)
448        .unwrap_or(1);
449    let idx = state
450        .findings
451        .iter()
452        .position(|f| f.id == id)
453        .ok_or_else(|| {
454            format!("reducer: finding.dependency_invalidated targets unknown finding {id}")
455        })?;
456    state.findings[idx].flags.contested = true;
457    let annotation_id = format!("ann_dep_{}_{}", &event.id[4..], depth);
458    if !state.findings[idx]
459        .annotations
460        .iter()
461        .any(|a| a.id == annotation_id)
462    {
463        state.findings[idx].annotations.push(Annotation {
464            id: annotation_id,
465            text: format!("Upstream {upstream} retracted (cascade depth {depth})."),
466            author: event.actor.id.clone(),
467            timestamp: event.timestamp.clone(),
468            provenance: None,
469        });
470    }
471    Ok(())
472}
473
474/// v0.49: NegativeResult deposit. The full inline NegativeResult is
475/// carried on `payload.negative_result` so a fresh replay reconstructs
476/// state from the event log alone — same pattern as
477/// `finding.asserted`. Idempotent on duplicate ids.
478fn apply_negative_result_asserted(state: &mut Project, event: &StateEvent) -> Result<(), String> {
479    let nr_value = event
480        .payload
481        .get("negative_result")
482        .ok_or("reducer: negative_result.asserted missing payload.negative_result")?;
483    let nr: crate::bundle::NegativeResult = serde_json::from_value(nr_value.clone())
484        .map_err(|e| format!("reducer: invalid negative_result.asserted payload: {e}"))?;
485    if state.negative_results.iter().any(|n| n.id == nr.id) {
486        return Ok(());
487    }
488    state.negative_results.push(nr);
489    Ok(())
490}
491
492fn apply_negative_result_reviewed(state: &mut Project, event: &StateEvent) -> Result<(), String> {
493    let id = event.target.id.as_str();
494    let status = event
495        .payload
496        .get("status")
497        .and_then(Value::as_str)
498        .ok_or("reducer: negative_result.reviewed missing payload.status")?;
499    use crate::bundle::ReviewState;
500    let new_state = match status {
501        "accepted" | "approved" => ReviewState::Accepted,
502        "contested" => ReviewState::Contested,
503        "needs_revision" => ReviewState::NeedsRevision,
504        "rejected" => ReviewState::Rejected,
505        other => return Err(format!("reducer: unsupported review status '{other}'")),
506    };
507    let idx = state
508        .negative_results
509        .iter()
510        .position(|n| n.id == id)
511        .ok_or_else(|| {
512            format!("reducer: negative_result.reviewed targets unknown negative_result {id}")
513        })?;
514    state.negative_results[idx].review_state = Some(new_state);
515    Ok(())
516}
517
518fn apply_negative_result_retracted(state: &mut Project, event: &StateEvent) -> Result<(), String> {
519    let id = event.target.id.as_str();
520    let idx = state
521        .negative_results
522        .iter()
523        .position(|n| n.id == id)
524        .ok_or_else(|| {
525            format!("reducer: negative_result.retracted targets unknown negative_result {id}")
526        })?;
527    state.negative_results[idx].retracted = true;
528    Ok(())
529}
530
531/// v0.50: Trajectory creation. Carries the inline Trajectory (with
532/// empty `steps`) on `payload.trajectory`. Subsequent steps land via
533/// `trajectory.step_appended` events, so a fresh replay reconstructs
534/// the full search path from the genesis Trajectory + the step events
535/// without needing the materialized steps inline. Idempotent on
536/// duplicate `vtr_id`.
537fn apply_trajectory_created(state: &mut Project, event: &StateEvent) -> Result<(), String> {
538    let traj_value = event
539        .payload
540        .get("trajectory")
541        .ok_or("reducer: trajectory.created missing payload.trajectory")?;
542    let traj: crate::bundle::Trajectory = serde_json::from_value(traj_value.clone())
543        .map_err(|e| format!("reducer: invalid trajectory.created payload: {e}"))?;
544    if state.trajectories.iter().any(|t| t.id == traj.id) {
545        return Ok(());
546    }
547    state.trajectories.push(traj);
548    Ok(())
549}
550
551/// v0.50: Append one step to an existing Trajectory. Step is
552/// content-addressed; idempotent on duplicate step ids so a replay
553/// of a partially-applied event log doesn't double-append.
554fn apply_trajectory_step_appended(state: &mut Project, event: &StateEvent) -> Result<(), String> {
555    let parent_id = event
556        .payload
557        .get("parent_trajectory_id")
558        .and_then(Value::as_str)
559        .ok_or("reducer: trajectory.step_appended missing payload.parent_trajectory_id")?;
560    let step_value = event
561        .payload
562        .get("step")
563        .ok_or("reducer: trajectory.step_appended missing payload.step")?;
564    let step: crate::bundle::TrajectoryStep = serde_json::from_value(step_value.clone())
565        .map_err(|e| format!("reducer: invalid trajectory.step_appended payload.step: {e}"))?;
566    let idx = state
567        .trajectories
568        .iter()
569        .position(|t| t.id == parent_id)
570        .ok_or_else(|| {
571            format!("reducer: trajectory.step_appended targets unknown trajectory {parent_id}")
572        })?;
573    if state.trajectories[idx]
574        .steps
575        .iter()
576        .any(|s| s.id == step.id)
577    {
578        return Ok(());
579    }
580    state.trajectories[idx].steps.push(step);
581    Ok(())
582}
583
584fn apply_trajectory_reviewed(state: &mut Project, event: &StateEvent) -> Result<(), String> {
585    let id = event.target.id.as_str();
586    let status = event
587        .payload
588        .get("status")
589        .and_then(Value::as_str)
590        .ok_or("reducer: trajectory.reviewed missing payload.status")?;
591    use crate::bundle::ReviewState;
592    let new_state = match status {
593        "accepted" | "approved" => ReviewState::Accepted,
594        "contested" => ReviewState::Contested,
595        "needs_revision" => ReviewState::NeedsRevision,
596        "rejected" => ReviewState::Rejected,
597        other => return Err(format!("reducer: unsupported review status '{other}'")),
598    };
599    let idx = state
600        .trajectories
601        .iter()
602        .position(|t| t.id == id)
603        .ok_or_else(|| format!("reducer: trajectory.reviewed targets unknown trajectory {id}"))?;
604    state.trajectories[idx].review_state = Some(new_state);
605    Ok(())
606}
607
608fn apply_trajectory_retracted(state: &mut Project, event: &StateEvent) -> Result<(), String> {
609    let id = event.target.id.as_str();
610    let idx = state
611        .trajectories
612        .iter()
613        .position(|t| t.id == id)
614        .ok_or_else(|| format!("reducer: trajectory.retracted targets unknown trajectory {id}"))?;
615    state.trajectories[idx].retracted = true;
616    Ok(())
617}
618
619fn apply_artifact_asserted(state: &mut Project, event: &StateEvent) -> Result<(), String> {
620    let artifact_value = event
621        .payload
622        .get("artifact")
623        .ok_or("reducer: artifact.asserted missing payload.artifact")?;
624    let artifact: crate::bundle::Artifact = serde_json::from_value(artifact_value.clone())
625        .map_err(|e| format!("reducer: invalid artifact.asserted payload: {e}"))?;
626    if state.artifacts.iter().any(|a| a.id == artifact.id) {
627        return Ok(());
628    }
629    state.artifacts.push(artifact);
630    Ok(())
631}
632
633fn apply_artifact_reviewed(state: &mut Project, event: &StateEvent) -> Result<(), String> {
634    let id = event.target.id.as_str();
635    let status = event
636        .payload
637        .get("status")
638        .and_then(Value::as_str)
639        .ok_or("reducer: artifact.reviewed missing payload.status")?;
640    use crate::bundle::ReviewState;
641    let new_state = match status {
642        "accepted" | "approved" => ReviewState::Accepted,
643        "contested" => ReviewState::Contested,
644        "needs_revision" => ReviewState::NeedsRevision,
645        "rejected" => ReviewState::Rejected,
646        other => return Err(format!("reducer: unsupported review status '{other}'")),
647    };
648    let idx = state
649        .artifacts
650        .iter()
651        .position(|a| a.id == id)
652        .ok_or_else(|| format!("reducer: artifact.reviewed targets unknown artifact {id}"))?;
653    state.artifacts[idx].review_state = Some(new_state);
654    Ok(())
655}
656
657fn apply_artifact_retracted(state: &mut Project, event: &StateEvent) -> Result<(), String> {
658    let id = event.target.id.as_str();
659    let idx = state
660        .artifacts
661        .iter()
662        .position(|a| a.id == id)
663        .ok_or_else(|| format!("reducer: artifact.retracted targets unknown artifact {id}"))?;
664    state.artifacts[idx].retracted = true;
665    Ok(())
666}
667
668/// v0.51: Apply a `tier.set` event. Re-classifies the access_tier on
669/// the matched finding / negative_result / trajectory / artifact. The validator
670/// has already checked the object_type and tier strings; here we
671/// just locate the object and mutate.
672fn apply_tier_set(state: &mut Project, event: &StateEvent) -> Result<(), String> {
673    let object_type = event
674        .payload
675        .get("object_type")
676        .and_then(Value::as_str)
677        .ok_or("reducer: tier.set missing payload.object_type")?;
678    let object_id = event
679        .payload
680        .get("object_id")
681        .and_then(Value::as_str)
682        .ok_or("reducer: tier.set missing payload.object_id")?;
683    let new_tier_str = event
684        .payload
685        .get("new_tier")
686        .and_then(Value::as_str)
687        .ok_or("reducer: tier.set missing payload.new_tier")?;
688    let new_tier = crate::access_tier::AccessTier::parse(new_tier_str)
689        .map_err(|e| format!("reducer: tier.set {e}"))?;
690    match object_type {
691        "finding" => {
692            let idx = state
693                .findings
694                .iter()
695                .position(|f| f.id == object_id)
696                .ok_or_else(|| format!("reducer: tier.set targets unknown finding {object_id}"))?;
697            state.findings[idx].access_tier = new_tier;
698        }
699        "negative_result" => {
700            let idx = state
701                .negative_results
702                .iter()
703                .position(|n| n.id == object_id)
704                .ok_or_else(|| {
705                    format!("reducer: tier.set targets unknown negative_result {object_id}")
706                })?;
707            state.negative_results[idx].access_tier = new_tier;
708        }
709        "trajectory" => {
710            let idx = state
711                .trajectories
712                .iter()
713                .position(|t| t.id == object_id)
714                .ok_or_else(|| {
715                    format!("reducer: tier.set targets unknown trajectory {object_id}")
716                })?;
717            state.trajectories[idx].access_tier = new_tier;
718        }
719        "artifact" => {
720            let idx = state
721                .artifacts
722                .iter()
723                .position(|a| a.id == object_id)
724                .ok_or_else(|| format!("reducer: tier.set targets unknown artifact {object_id}"))?;
725            state.artifacts[idx].access_tier = new_tier;
726        }
727        other => {
728            return Err(format!(
729                "reducer: tier.set object_type '{other}' must be one of finding, negative_result, trajectory, artifact"
730            ));
731        }
732    }
733    Ok(())
734}
735
736/// v0.57: Apply a `finding.entity_resolved` event. Sets the
737/// canonical_id, resolution_method, resolution_provenance, and
738/// resolution_confidence on the named entity inside the target
739/// finding's assertion.entities array, and clears the entity's
740/// `needs_review` flag.
741fn apply_finding_entity_resolved(state: &mut Project, event: &StateEvent) -> Result<(), String> {
742    use crate::bundle::{ResolutionMethod, ResolvedId};
743
744    if event.target.r#type != "finding" {
745        return Err(format!(
746            "reducer: finding.entity_resolved target.type must be 'finding', got '{}'",
747            event.target.r#type
748        ));
749    }
750    let finding_id = event.target.id.as_str();
751    let entity_name = event
752        .payload
753        .get("entity_name")
754        .and_then(Value::as_str)
755        .ok_or("reducer: finding.entity_resolved missing payload.entity_name")?;
756    let source = event
757        .payload
758        .get("source")
759        .and_then(Value::as_str)
760        .ok_or("reducer: finding.entity_resolved missing payload.source")?;
761    let id = event
762        .payload
763        .get("id")
764        .and_then(Value::as_str)
765        .ok_or("reducer: finding.entity_resolved missing payload.id")?;
766    let confidence = event
767        .payload
768        .get("confidence")
769        .and_then(Value::as_f64)
770        .ok_or("reducer: finding.entity_resolved missing payload.confidence")?;
771    let matched_name = event
772        .payload
773        .get("matched_name")
774        .and_then(Value::as_str)
775        .map(str::to_string);
776    let provenance = event
777        .payload
778        .get("resolution_provenance")
779        .and_then(Value::as_str)
780        .unwrap_or("delegated_human_curation")
781        .to_string();
782    let method_str = event
783        .payload
784        .get("resolution_method")
785        .and_then(Value::as_str)
786        .unwrap_or("manual");
787    let method = match method_str {
788        "exact_match" => ResolutionMethod::ExactMatch,
789        "fuzzy_match" => ResolutionMethod::FuzzyMatch,
790        "llm_inference" => ResolutionMethod::LlmInference,
791        "manual" => ResolutionMethod::Manual,
792        other => {
793            return Err(format!(
794                "reducer: finding.entity_resolved unknown resolution_method '{other}'"
795            ));
796        }
797    };
798
799    let f_idx = state
800        .findings
801        .iter()
802        .position(|f| f.id == finding_id)
803        .ok_or_else(|| {
804            format!("reducer: finding.entity_resolved targets unknown finding {finding_id}")
805        })?;
806    let e_idx = state.findings[f_idx]
807        .assertion
808        .entities
809        .iter()
810        .position(|e| e.name == entity_name)
811        .ok_or_else(|| {
812            format!(
813                "reducer: finding.entity_resolved entity_name '{entity_name}' not in finding {finding_id}"
814            )
815        })?;
816    let entity = &mut state.findings[f_idx].assertion.entities[e_idx];
817    entity.canonical_id = Some(ResolvedId {
818        source: source.to_string(),
819        id: id.to_string(),
820        confidence,
821        matched_name,
822    });
823    entity.resolution_method = Some(method);
824    entity.resolution_provenance = Some(provenance);
825    entity.resolution_confidence = confidence;
826    entity.needs_review = false;
827    Ok(())
828}
829
830/// v0.70: append a Replication record to `Project.replications`
831/// if the content-addressed `vrep_*` id is not already present.
832/// Idempotent under re-application of the same event.
833fn apply_replication_deposited(state: &mut Project, event: &StateEvent) -> Result<(), String> {
834    use crate::bundle::Replication;
835
836    let rep_value = event
837        .payload
838        .get("replication")
839        .ok_or("replication.deposited event missing payload.replication")?
840        .clone();
841    let rep: Replication = serde_json::from_value(rep_value)
842        .map_err(|e| format!("replication.deposited payload parse: {e}"))?;
843    if state.replications.iter().any(|r| r.id == rep.id) {
844        return Ok(());
845    }
846    state.replications.push(rep);
847    Ok(())
848}
849
850/// v0.70: append a Prediction record to `Project.predictions`
851/// if the content-addressed `vpred_*` id is not already present.
852/// Idempotent under re-application of the same event.
853fn apply_prediction_deposited(state: &mut Project, event: &StateEvent) -> Result<(), String> {
854    use crate::bundle::Prediction;
855
856    let pred_value = event
857        .payload
858        .get("prediction")
859        .ok_or("prediction.deposited event missing payload.prediction")?
860        .clone();
861    let pred: Prediction = serde_json::from_value(pred_value)
862        .map_err(|e| format!("prediction.deposited payload parse: {e}"))?;
863    if state.predictions.iter().any(|p| p.id == pred.id) {
864        return Ok(());
865    }
866    state.predictions.push(pred);
867    Ok(())
868}
869
870/// v0.57: Apply a `finding.span_repaired` event. Appends a
871/// `{section, text}` span object to
872/// `state.findings[i].evidence.evidence_spans`. Idempotent:
873/// applying twice with the same (section, text) pair is a no-op.
874fn apply_finding_span_repaired(state: &mut Project, event: &StateEvent) -> Result<(), String> {
875    if event.target.r#type != "finding" {
876        return Err(format!(
877            "reducer: finding.span_repaired target.type must be 'finding', got '{}'",
878            event.target.r#type
879        ));
880    }
881    let finding_id = event.target.id.as_str();
882    let section = event
883        .payload
884        .get("section")
885        .and_then(Value::as_str)
886        .ok_or("reducer: finding.span_repaired missing payload.section")?;
887    let text = event
888        .payload
889        .get("text")
890        .and_then(Value::as_str)
891        .ok_or("reducer: finding.span_repaired missing payload.text")?;
892    let idx = state
893        .findings
894        .iter()
895        .position(|f| f.id == finding_id)
896        .ok_or_else(|| {
897            format!("reducer: finding.span_repaired targets unknown finding {finding_id}")
898        })?;
899    let span_value = serde_json::json!({"section": section, "text": text});
900    let already_present = state.findings[idx]
901        .evidence
902        .evidence_spans
903        .iter()
904        .any(|existing| {
905            existing.get("section").and_then(Value::as_str) == Some(section)
906                && existing.get("text").and_then(Value::as_str) == Some(text)
907        });
908    if !already_present {
909        state.findings[idx].evidence.evidence_spans.push(span_value);
910    }
911    Ok(())
912}
913
914/// v0.56: Apply an `evidence_atom.locator_repaired` event. Sets
915/// `locator` on the named atom and removes the "missing evidence
916/// locator" caveat if present. Idempotent: applying twice with the
917/// same locator is a no-op. Mismatched locator values fail the reduce
918/// rather than silently overwriting, since divergent locators on the
919/// same atom are a chain-integrity issue, not a repair.
920fn apply_evidence_atom_locator_repaired(
921    state: &mut Project,
922    event: &StateEvent,
923) -> Result<(), String> {
924    if event.target.r#type != "evidence_atom" {
925        return Err(format!(
926            "reducer: evidence_atom.locator_repaired target.type must be 'evidence_atom', got '{}'",
927            event.target.r#type
928        ));
929    }
930    let atom_id = event.target.id.as_str();
931    let locator = event
932        .payload
933        .get("locator")
934        .and_then(Value::as_str)
935        .ok_or("reducer: evidence_atom.locator_repaired missing payload.locator")?;
936    let idx = state
937        .evidence_atoms
938        .iter()
939        .position(|atom| atom.id == atom_id)
940        .ok_or_else(|| {
941            format!("reducer: evidence_atom.locator_repaired targets unknown atom {atom_id}")
942        })?;
943    if let Some(existing) = &state.evidence_atoms[idx].locator
944        && existing != locator
945    {
946        return Err(format!(
947            "reducer: evidence_atom {atom_id} already has locator '{existing}', refusing to overwrite with '{locator}'"
948        ));
949    }
950    state.evidence_atoms[idx].locator = Some(locator.to_string());
951    state.evidence_atoms[idx]
952        .caveats
953        .retain(|c| c != "missing evidence locator");
954    Ok(())
955}
956
957#[cfg(test)]
958mod tests {
959    use super::*;
960    use crate::bundle::{Assertion, Conditions, Confidence, Evidence, Flags, Provenance};
961    use crate::events::{FindingEventInput, NULL_HASH, StateActor, StateTarget};
962    use chrono::Utc;
963    use serde_json::json;
964
965    fn finding(id: &str) -> crate::bundle::FindingBundle {
966        crate::bundle::FindingBundle::new(
967            Assertion {
968                text: format!("test finding {id}"),
969                assertion_type: "mechanism".to_string(),
970                entities: Vec::new(),
971                relation: None,
972                direction: None,
973                causal_claim: None,
974                causal_evidence_grade: None,
975            },
976            Evidence {
977                evidence_type: "experimental".to_string(),
978                model_system: String::new(),
979                species: None,
980                method: "test".to_string(),
981                sample_size: None,
982                effect_size: None,
983                p_value: None,
984                replicated: false,
985                replication_count: None,
986                evidence_spans: Vec::new(),
987            },
988            Conditions {
989                text: "test".to_string(),
990                species_verified: Vec::new(),
991                species_unverified: Vec::new(),
992                in_vitro: false,
993                in_vivo: true,
994                human_data: false,
995                clinical_trial: false,
996                concentration_range: None,
997                duration: None,
998                age_group: None,
999                cell_type: None,
1000            },
1001            Confidence::raw(0.5, "test", 0.8),
1002            Provenance {
1003                source_type: "published_paper".to_string(),
1004                doi: Some(format!("10.1/test-{id}")),
1005                pmid: None,
1006                pmc: None,
1007                openalex_id: None,
1008                url: None,
1009                title: format!("Source for {id}"),
1010                authors: Vec::new(),
1011                year: Some(2026),
1012                journal: None,
1013                license: None,
1014                publisher: None,
1015                funders: Vec::new(),
1016                extraction: crate::bundle::Extraction::default(),
1017                review: None,
1018                citation_count: None,
1019            },
1020            Flags {
1021                gap: false,
1022                negative_space: false,
1023                contested: false,
1024                retracted: false,
1025                declining: false,
1026                gravity_well: false,
1027                review_state: None,
1028                superseded: false,
1029                signature_threshold: None,
1030                jointly_accepted: false,
1031            },
1032        )
1033    }
1034
1035    #[test]
1036    fn replay_with_no_events_is_identity() {
1037        let state = project::assemble("test", vec![finding("a")], 0, 0, "test");
1038        let v = verify_replay(&state);
1039        assert!(v.ok);
1040        assert_eq!(v.replayed_snapshot_hash, v.materialized_snapshot_hash);
1041    }
1042
1043    #[test]
1044    fn reducer_marks_finding_contested() {
1045        let f = finding("a");
1046        let mut state = project::assemble("test", vec![f.clone()], 0, 0, "test");
1047        let event = events::new_finding_event(FindingEventInput {
1048            kind: "finding.reviewed",
1049            finding_id: &f.id,
1050            actor_id: "reviewer:test",
1051            actor_type: "human",
1052            reason: "test",
1053            before_hash: &events::finding_hash(&f),
1054            after_hash: NULL_HASH,
1055            payload: json!({"status": "contested"}),
1056            caveats: vec![],
1057        });
1058        apply_event(&mut state, &event).unwrap();
1059        assert!(state.findings[0].flags.contested);
1060    }
1061
1062    #[test]
1063    fn reducer_retracts_finding() {
1064        let f = finding("a");
1065        let mut state = project::assemble("test", vec![f.clone()], 0, 0, "test");
1066        let event = StateEvent {
1067            schema: events::EVENT_SCHEMA.to_string(),
1068            id: "vev_test".to_string(),
1069            kind: "finding.retracted".to_string(),
1070            target: StateTarget {
1071                r#type: "finding".to_string(),
1072                id: f.id.clone(),
1073            },
1074            actor: StateActor {
1075                id: "reviewer:test".to_string(),
1076                r#type: "human".to_string(),
1077            },
1078            timestamp: Utc::now().to_rfc3339(),
1079            reason: "test retraction".to_string(),
1080            before_hash: events::finding_hash(&f),
1081            after_hash: NULL_HASH.to_string(),
1082            payload: json!({"proposal_id": "vpr_x"}),
1083            caveats: vec![],
1084            signature: None,
1085        };
1086        apply_event(&mut state, &event).unwrap();
1087        assert!(state.findings[0].flags.retracted);
1088    }
1089
1090    #[test]
1091    fn confidence_revision_replay_uses_event_payload_timestamp() {
1092        let f = finding("a");
1093        let mut expected = f.clone();
1094        let updated_at = "2026-05-07T23:30:00Z";
1095        let reason = "lower confidence after review";
1096        expected.confidence.score = 0.42;
1097        expected.confidence.basis = format!(
1098            "expert revision from {:.3} to {:.3}: {}",
1099            f.confidence.score, 0.42, reason
1100        );
1101        expected.confidence.method = ConfidenceMethod::ExpertJudgment;
1102        expected.updated = Some(updated_at.to_string());
1103        let mut state = project::assemble("test", vec![f.clone()], 0, 0, "test");
1104        let event = StateEvent {
1105            schema: events::EVENT_SCHEMA.to_string(),
1106            id: "vev_confidence".to_string(),
1107            kind: "finding.confidence_revised".to_string(),
1108            target: StateTarget {
1109                r#type: "finding".to_string(),
1110                id: f.id.clone(),
1111            },
1112            actor: StateActor {
1113                id: "reviewer:test".to_string(),
1114                r#type: "human".to_string(),
1115            },
1116            timestamp: "2026-05-07T23:31:00Z".to_string(),
1117            reason: reason.to_string(),
1118            before_hash: events::finding_hash(&f),
1119            after_hash: events::finding_hash(&expected),
1120            payload: json!({
1121                "previous_score": f.confidence.score,
1122                "new_score": 0.42,
1123                "updated_at": updated_at,
1124            }),
1125            caveats: vec![],
1126            signature: None,
1127        };
1128
1129        apply_event(&mut state, &event).unwrap();
1130
1131        assert_eq!(state.findings[0].updated.as_deref(), Some(updated_at));
1132        assert_eq!(events::finding_hash(&state.findings[0]), event.after_hash);
1133    }
1134
1135    #[test]
1136    fn reducer_rejects_unknown_kind() {
1137        let mut state = project::assemble("test", vec![], 0, 0, "test");
1138        let event = StateEvent {
1139            schema: events::EVENT_SCHEMA.to_string(),
1140            id: "vev_test".to_string(),
1141            kind: "finding.unknown_kind".to_string(),
1142            target: StateTarget {
1143                r#type: "finding".to_string(),
1144                id: "vf_x".to_string(),
1145            },
1146            actor: StateActor {
1147                id: "x".to_string(),
1148                r#type: "human".to_string(),
1149            },
1150            timestamp: Utc::now().to_rfc3339(),
1151            reason: "x".to_string(),
1152            before_hash: NULL_HASH.to_string(),
1153            after_hash: NULL_HASH.to_string(),
1154            payload: Value::Null,
1155            caveats: vec![],
1156            signature: None,
1157        };
1158        let r = apply_event(&mut state, &event);
1159        assert!(r.is_err());
1160    }
1161
1162    /// v0.49.3: the dispatch table in `apply_event` and the
1163    /// `REDUCER_MUTATION_KINDS` constant must agree. Adding a new
1164    /// match arm without updating the constant (or vice versa) makes
1165    /// CI fail loudly here, which then makes the cross-impl fixture
1166    /// coverage assertion fail correctly downstream. This is the
1167    /// single source of truth that retires the hand-maintained mirror.
1168    #[test]
1169    fn dispatch_handles_every_declared_kind() {
1170        for kind in REDUCER_MUTATION_KINDS {
1171            let mut state = project::assemble("test", vec![], 0, 0, "test");
1172            // Dummy event with the declared kind. The handler may
1173            // reject the payload (it's empty), but it MUST NOT reject
1174            // the kind itself with "unsupported event kind" — that
1175            // would prove the dispatch table is missing an arm for
1176            // a kind the constant declares.
1177            let event = StateEvent {
1178                schema: events::EVENT_SCHEMA.to_string(),
1179                id: "vev_dispatch_check".to_string(),
1180                kind: (*kind).to_string(),
1181                target: StateTarget {
1182                    r#type: "finding".to_string(),
1183                    id: "vf_x".to_string(),
1184                },
1185                actor: StateActor {
1186                    id: "x".to_string(),
1187                    r#type: "human".to_string(),
1188                },
1189                timestamp: Utc::now().to_rfc3339(),
1190                reason: String::new(),
1191                before_hash: NULL_HASH.to_string(),
1192                after_hash: NULL_HASH.to_string(),
1193                payload: Value::Null,
1194                caveats: vec![],
1195                signature: None,
1196            };
1197            let r = apply_event(&mut state, &event);
1198            if let Err(e) = r {
1199                assert!(
1200                    !e.contains("unsupported event kind"),
1201                    "kind {kind:?} declared in REDUCER_MUTATION_KINDS \
1202                     but rejected by apply_event dispatch: {e}"
1203                );
1204            }
1205        }
1206    }
1207
1208    /// v0.59 + v0.63: federation events live in `apply_event` as
1209    /// no-ops on finding state. They are intentionally absent from
1210    /// `REDUCER_MUTATION_KINDS` (they do not mutate any finding,
1211    /// negative_result, trajectory, artifact, or evidence_atom);
1212    /// the coverage assertion above does not exercise them. This
1213    /// test pins the no-op contract directly: the reducer accepts
1214    /// the kind without error, and the finding-state digest is
1215    /// unchanged after replay.
1216    #[test]
1217    fn federation_events_are_finding_state_noops() {
1218        for kind in &[
1219            "frontier.synced_with_peer",
1220            "frontier.conflict_detected",
1221            "frontier.conflict_resolved",
1222        ] {
1223            let mut state = project::assemble("test", vec![], 0, 0, "test");
1224            let snapshot_before = events::snapshot_hash(&state);
1225            let event = StateEvent {
1226                schema: events::EVENT_SCHEMA.to_string(),
1227                id: format!("vev_federation_{kind}"),
1228                kind: (*kind).to_string(),
1229                target: StateTarget {
1230                    r#type: "frontier_observation".to_string(),
1231                    id: "vfr_x".to_string(),
1232                },
1233                actor: StateActor {
1234                    id: "federation".to_string(),
1235                    r#type: "system".to_string(),
1236                },
1237                timestamp: Utc::now().to_rfc3339(),
1238                reason: format!("no-op contract test for {kind}"),
1239                before_hash: NULL_HASH.to_string(),
1240                after_hash: NULL_HASH.to_string(),
1241                payload: Value::Null,
1242                caveats: vec![],
1243                signature: None,
1244            };
1245            apply_event(&mut state, &event)
1246                .unwrap_or_else(|e| panic!("federation kind {kind} rejected by reducer: {e}"));
1247            let snapshot_after = events::snapshot_hash(&state);
1248            assert_eq!(
1249                snapshot_before, snapshot_after,
1250                "federation event {kind} mutated finding-state snapshot; expected no-op"
1251            );
1252        }
1253    }
1254
1255    fn project_with_one_atom(missing_locator: bool) -> Project {
1256        // `project::assemble` calls `sources::materialize_project`,
1257        // which derives one evidence atom per finding. The hand-built
1258        // atom below is appended after materialization with a distinct
1259        // id (`vea_test_atom`), so it survives alongside the derived
1260        // atom. Tests look up atoms by id via `atom_by_id`.
1261        let mut state = project::assemble("test-locator", vec![finding("a")], 0, 0, "test");
1262        state.sources.push(crate::sources::SourceRecord {
1263            id: "vs_test_source".to_string(),
1264            source_type: "paper".to_string(),
1265            locator: "doi:10.1/test-source".to_string(),
1266            content_hash: None,
1267            title: "Test source".to_string(),
1268            authors: Vec::new(),
1269            year: Some(2026),
1270            doi: Some("10.1/test-source".to_string()),
1271            pmid: None,
1272            imported_at: "2026-01-01T00:00:00Z".to_string(),
1273            extraction_mode: "manual".to_string(),
1274            source_quality: "declared".to_string(),
1275            caveats: Vec::new(),
1276            finding_ids: vec![state.findings[0].id.clone()],
1277        });
1278        state.evidence_atoms.push(crate::sources::EvidenceAtom {
1279            id: "vea_test_atom".to_string(),
1280            source_id: "vs_test_source".to_string(),
1281            finding_id: state.findings[0].id.clone(),
1282            locator: if missing_locator {
1283                None
1284            } else {
1285                Some("doi:10.1/already-set".to_string())
1286            },
1287            evidence_type: "experimental".to_string(),
1288            measurement_or_claim: "test claim".to_string(),
1289            supports_or_challenges: "supports".to_string(),
1290            condition_refs: Vec::new(),
1291            extraction_method: "manual".to_string(),
1292            human_verified: false,
1293            caveats: if missing_locator {
1294                vec!["missing evidence locator".to_string()]
1295            } else {
1296                Vec::new()
1297            },
1298        });
1299        state
1300    }
1301
1302    fn atom_by_id<'a>(state: &'a Project, id: &str) -> &'a crate::sources::EvidenceAtom {
1303        state
1304            .evidence_atoms
1305            .iter()
1306            .find(|atom| atom.id == id)
1307            .expect("atom exists")
1308    }
1309
1310    #[test]
1311    fn evidence_atom_locator_repaired_sets_locator_and_clears_caveat() {
1312        let mut state = project_with_one_atom(true);
1313        assert!(state.evidence_atoms[0].locator.is_none());
1314        let event = StateEvent {
1315            schema: crate::events::EVENT_SCHEMA.to_string(),
1316            id: "vev_test".to_string(),
1317            kind: "evidence_atom.locator_repaired".to_string(),
1318            target: StateTarget {
1319                r#type: "evidence_atom".to_string(),
1320                id: "vea_test_atom".to_string(),
1321            },
1322            actor: StateActor {
1323                id: "agent:test".to_string(),
1324                r#type: "agent".to_string(),
1325            },
1326            timestamp: Utc::now().to_rfc3339(),
1327            reason: "Mechanical repair from parent source".to_string(),
1328            before_hash: NULL_HASH.to_string(),
1329            after_hash: NULL_HASH.to_string(),
1330            payload: json!({
1331                "proposal_id": "vpr_test",
1332                "source_id": "vs_test_source",
1333                "locator": "doi:10.1/test-source",
1334            }),
1335            caveats: vec![],
1336            signature: None,
1337        };
1338        apply_event(&mut state, &event).expect("apply locator_repaired");
1339        let atom = atom_by_id(&state, "vea_test_atom");
1340        assert_eq!(atom.locator.as_deref(), Some("doi:10.1/test-source"));
1341        assert!(atom.caveats.is_empty());
1342    }
1343
1344    #[test]
1345    fn evidence_atom_locator_repaired_is_idempotent() {
1346        let mut state = project_with_one_atom(true);
1347        let event = StateEvent {
1348            schema: crate::events::EVENT_SCHEMA.to_string(),
1349            id: "vev_test".to_string(),
1350            kind: "evidence_atom.locator_repaired".to_string(),
1351            target: StateTarget {
1352                r#type: "evidence_atom".to_string(),
1353                id: "vea_test_atom".to_string(),
1354            },
1355            actor: StateActor {
1356                id: "agent:test".to_string(),
1357                r#type: "agent".to_string(),
1358            },
1359            timestamp: Utc::now().to_rfc3339(),
1360            reason: "Mechanical repair from parent source".to_string(),
1361            before_hash: NULL_HASH.to_string(),
1362            after_hash: NULL_HASH.to_string(),
1363            payload: json!({
1364                "proposal_id": "vpr_test",
1365                "source_id": "vs_test_source",
1366                "locator": "doi:10.1/test-source",
1367            }),
1368            caveats: vec![],
1369            signature: None,
1370        };
1371        apply_event(&mut state, &event).expect("first apply");
1372        apply_event(&mut state, &event).expect("second apply is a no-op when locator matches");
1373        let atom = atom_by_id(&state, "vea_test_atom");
1374        assert_eq!(atom.locator.as_deref(), Some("doi:10.1/test-source"));
1375    }
1376
1377    #[test]
1378    fn evidence_atom_locator_repaired_refuses_divergent_overwrite() {
1379        let mut state = project_with_one_atom(false);
1380        let event = StateEvent {
1381            schema: crate::events::EVENT_SCHEMA.to_string(),
1382            id: "vev_test".to_string(),
1383            kind: "evidence_atom.locator_repaired".to_string(),
1384            target: StateTarget {
1385                r#type: "evidence_atom".to_string(),
1386                id: "vea_test_atom".to_string(),
1387            },
1388            actor: StateActor {
1389                id: "agent:test".to_string(),
1390                r#type: "agent".to_string(),
1391            },
1392            timestamp: Utc::now().to_rfc3339(),
1393            reason: "Different repair".to_string(),
1394            before_hash: NULL_HASH.to_string(),
1395            after_hash: NULL_HASH.to_string(),
1396            payload: json!({
1397                "proposal_id": "vpr_test",
1398                "source_id": "vs_test_source",
1399                "locator": "doi:10.1/different",
1400            }),
1401            caveats: vec![],
1402            signature: None,
1403        };
1404        let r = apply_event(&mut state, &event);
1405        assert!(r.is_err());
1406        assert!(r.unwrap_err().contains("already has locator"));
1407    }
1408
1409    #[test]
1410    fn evidence_atom_locator_repaired_does_not_mutate_findings() {
1411        // Cross-impl conformance: this event mutates evidence_atoms only.
1412        let mut state = project_with_one_atom(true);
1413        let hashes_before: Vec<String> = state
1414            .findings
1415            .iter()
1416            .map(crate::events::finding_hash)
1417            .collect();
1418        let event = StateEvent {
1419            schema: crate::events::EVENT_SCHEMA.to_string(),
1420            id: "vev_test".to_string(),
1421            kind: "evidence_atom.locator_repaired".to_string(),
1422            target: StateTarget {
1423                r#type: "evidence_atom".to_string(),
1424                id: "vea_test_atom".to_string(),
1425            },
1426            actor: StateActor {
1427                id: "agent:test".to_string(),
1428                r#type: "agent".to_string(),
1429            },
1430            timestamp: Utc::now().to_rfc3339(),
1431            reason: "Mechanical repair".to_string(),
1432            before_hash: NULL_HASH.to_string(),
1433            after_hash: NULL_HASH.to_string(),
1434            payload: json!({
1435                "proposal_id": "vpr_test",
1436                "source_id": "vs_test_source",
1437                "locator": "doi:10.1/test-source",
1438            }),
1439            caveats: vec![],
1440            signature: None,
1441        };
1442        apply_event(&mut state, &event).expect("apply ok");
1443        let hashes_after: Vec<String> = state
1444            .findings
1445            .iter()
1446            .map(crate::events::finding_hash)
1447            .collect();
1448        assert_eq!(hashes_before, hashes_after);
1449    }
1450}