Skip to main content

vela_protocol/
state.rs

1//! Non-interactive frontier state transitions.
2//!
3//! Write commands are proposal-first. Pending proposals are review artifacts;
4//! accepted proposals become canonical state events through one reducer.
5
6use std::path::Path;
7
8use chrono::Utc;
9use serde::Serialize;
10use serde_json::{Value, json};
11use sha2::{Digest, Sha256};
12
13use crate::bundle::{
14    Artifact, Assertion, Author, Conditions, Confidence, ConfidenceKind, ConfidenceMethod, Entity,
15    Evidence, Extraction, FindingBundle, Flags, NegativeResult, NegativeResultKind, Provenance,
16    ResolutionMethod, Review, Trajectory, TrajectoryStep, TrajectoryStepKind,
17};
18use crate::events::{self, NULL_HASH, StateActor, StateEvent, StateTarget};
19use crate::project::{self, Project};
20use crate::proposals::{self, StateProposal};
21use crate::reducer;
22use crate::repo;
23
24#[derive(Debug, Clone, Serialize)]
25pub struct StateCommandReport {
26    pub ok: bool,
27    pub command: String,
28    pub frontier: String,
29    pub finding_id: String,
30    pub proposal_id: String,
31    pub proposal_status: String,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub applied_event_id: Option<String>,
34    pub wrote_to: String,
35    pub message: String,
36}
37
38#[derive(Debug, Clone)]
39pub struct FindingDraftOptions {
40    pub text: String,
41    pub assertion_type: String,
42    pub source: String,
43    pub source_type: String,
44    pub author: String,
45    pub confidence: f64,
46    pub evidence_type: String,
47    pub entities: Vec<(String, String)>,
48    /// v0.11: structured provenance — populates the existing `Provenance`
49    /// fields instead of jamming everything into `title`. Each is optional
50    /// so `vela finding add` callers don't have to know all of them up front;
51    /// the substrate has the fields, the CLI just exposes them.
52    #[allow(dead_code)] // populated by CLI; consumed by build_add_finding_proposal
53    pub doi: Option<String>,
54    #[allow(dead_code)]
55    pub pmid: Option<String>,
56    #[allow(dead_code)]
57    pub year: Option<i32>,
58    #[allow(dead_code)]
59    pub journal: Option<String>,
60    #[allow(dead_code)]
61    pub url: Option<String>,
62    /// Authors of the source artifact (the paper/preprint/etc).
63    /// Distinct from `author` above, which is the Vela actor doing the curation.
64    #[allow(dead_code)]
65    pub source_authors: Vec<String>,
66    /// v0.11: structured conditions — replaces the placeholder
67    /// "Manually added finding; requires evidence review…" that was on
68    /// every manually-added finding in v0.10. Each field independently optional.
69    #[allow(dead_code)]
70    pub conditions_text: Option<String>,
71    #[allow(dead_code)]
72    pub species: Vec<String>,
73    #[allow(dead_code)]
74    pub in_vivo: bool,
75    #[allow(dead_code)]
76    pub in_vitro: bool,
77    #[allow(dead_code)]
78    pub human_data: bool,
79    #[allow(dead_code)]
80    pub clinical_trial: bool,
81    #[allow(dead_code)]
82    pub entities_reviewed: bool,
83    #[allow(dead_code)]
84    pub evidence_spans: Vec<Value>,
85    #[allow(dead_code)]
86    pub gap: bool,
87    #[allow(dead_code)]
88    pub negative_space: bool,
89}
90
91#[derive(Debug, Clone)]
92pub struct ReviewOptions {
93    pub status: String,
94    pub reason: String,
95    pub reviewer: String,
96}
97
98#[derive(Debug, Clone)]
99pub struct ReviseOptions {
100    pub confidence: f64,
101    pub reason: String,
102    pub reviewer: String,
103}
104
105pub fn add_finding(
106    path: &Path,
107    options: FindingDraftOptions,
108    apply: bool,
109) -> Result<StateCommandReport, String> {
110    validate_score(options.confidence)?;
111    let proposal = build_add_finding_proposal(options)?;
112    let result = proposals::create_or_apply(path, proposal, apply)?;
113    let frontier = repo::load_from_path(path)?;
114    Ok(StateCommandReport {
115        ok: true,
116        command: "finding.add".to_string(),
117        frontier: frontier.project.name,
118        finding_id: result.finding_id,
119        proposal_id: result.proposal_id,
120        proposal_status: result.status.clone(),
121        applied_event_id: result.applied_event_id,
122        wrote_to: path.display().to_string(),
123        message: if result.status == "applied" {
124            "Finding proposal applied".to_string()
125        } else {
126            "Finding proposal recorded".to_string()
127        },
128    })
129}
130
131pub fn review_finding(
132    path: &Path,
133    finding_id: &str,
134    options: ReviewOptions,
135    apply: bool,
136) -> Result<StateCommandReport, String> {
137    let proposal = proposals::new_proposal(
138        "finding.review",
139        events::StateTarget {
140            r#type: "finding".to_string(),
141            id: finding_id.to_string(),
142        },
143        options.reviewer.clone(),
144        "human",
145        options.reason.clone(),
146        json!({"status": options.status}),
147        Vec::new(),
148        Vec::new(),
149    );
150    let result = proposals::create_or_apply(path, proposal, apply)?;
151    let frontier = repo::load_from_path(path)?;
152    Ok(StateCommandReport {
153        ok: true,
154        command: "review".to_string(),
155        frontier: frontier.project.name,
156        finding_id: result.finding_id,
157        proposal_id: result.proposal_id,
158        proposal_status: result.status,
159        applied_event_id: result.applied_event_id,
160        wrote_to: path.display().to_string(),
161        message: if apply {
162            "Review proposal applied".to_string()
163        } else {
164            "Review proposal recorded".to_string()
165        },
166    })
167}
168
169pub fn add_note(
170    path: &Path,
171    finding_id: &str,
172    text: &str,
173    author: &str,
174    apply: bool,
175) -> Result<StateCommandReport, String> {
176    let proposal = proposals::new_proposal(
177        "finding.note",
178        events::StateTarget {
179            r#type: "finding".to_string(),
180            id: finding_id.to_string(),
181        },
182        author.to_string(),
183        "human",
184        text.to_string(),
185        json!({"text": text}),
186        Vec::new(),
187        Vec::new(),
188    );
189    let result = proposals::create_or_apply(path, proposal, apply)?;
190    let frontier = repo::load_from_path(path)?;
191    Ok(StateCommandReport {
192        ok: true,
193        command: "note".to_string(),
194        frontier: frontier.project.name,
195        finding_id: result.finding_id,
196        proposal_id: result.proposal_id,
197        proposal_status: result.status,
198        applied_event_id: result.applied_event_id,
199        wrote_to: path.display().to_string(),
200        message: if apply {
201            "Note proposal applied".to_string()
202        } else {
203            "Note proposal recorded".to_string()
204        },
205    })
206}
207
208pub fn caveat_finding(
209    path: &Path,
210    finding_id: &str,
211    text: &str,
212    author: &str,
213    apply: bool,
214) -> Result<StateCommandReport, String> {
215    let proposal = proposals::new_proposal(
216        "finding.caveat",
217        events::StateTarget {
218            r#type: "finding".to_string(),
219            id: finding_id.to_string(),
220        },
221        author.to_string(),
222        "human",
223        text.to_string(),
224        json!({"text": text}),
225        Vec::new(),
226        Vec::new(),
227    );
228    let result = proposals::create_or_apply(path, proposal, apply)?;
229    let frontier = repo::load_from_path(path)?;
230    Ok(StateCommandReport {
231        ok: true,
232        command: "caveat".to_string(),
233        frontier: frontier.project.name,
234        finding_id: result.finding_id,
235        proposal_id: result.proposal_id,
236        proposal_status: result.status,
237        applied_event_id: result.applied_event_id,
238        wrote_to: path.display().to_string(),
239        message: if apply {
240            "Caveat proposal applied".to_string()
241        } else {
242            "Caveat proposal recorded".to_string()
243        },
244    })
245}
246
247pub fn revise_confidence(
248    path: &Path,
249    finding_id: &str,
250    options: ReviseOptions,
251    apply: bool,
252) -> Result<StateCommandReport, String> {
253    validate_score(options.confidence)?;
254    let proposal = proposals::new_proposal(
255        "finding.confidence_revise",
256        events::StateTarget {
257            r#type: "finding".to_string(),
258            id: finding_id.to_string(),
259        },
260        options.reviewer.clone(),
261        "human",
262        options.reason.clone(),
263        json!({"confidence": options.confidence}),
264        Vec::new(),
265        Vec::new(),
266    );
267    let result = proposals::create_or_apply(path, proposal, apply)?;
268    let frontier = repo::load_from_path(path)?;
269    Ok(StateCommandReport {
270        ok: true,
271        command: "revise".to_string(),
272        frontier: frontier.project.name,
273        finding_id: result.finding_id,
274        proposal_id: result.proposal_id,
275        proposal_status: result.status,
276        applied_event_id: result.applied_event_id,
277        wrote_to: path.display().to_string(),
278        message: if apply {
279            "Confidence revision applied".to_string()
280        } else {
281            "Confidence revision proposal recorded".to_string()
282        },
283    })
284}
285
286pub fn reject_finding(
287    path: &Path,
288    finding_id: &str,
289    reviewer: &str,
290    reason: &str,
291    apply: bool,
292) -> Result<StateCommandReport, String> {
293    let proposal = proposals::new_proposal(
294        "finding.reject",
295        events::StateTarget {
296            r#type: "finding".to_string(),
297            id: finding_id.to_string(),
298        },
299        reviewer.to_string(),
300        "human",
301        reason.to_string(),
302        json!({"status": "rejected"}),
303        Vec::new(),
304        Vec::new(),
305    );
306    let result = proposals::create_or_apply(path, proposal, apply)?;
307    let frontier = repo::load_from_path(path)?;
308    Ok(StateCommandReport {
309        ok: true,
310        command: "reject".to_string(),
311        frontier: frontier.project.name,
312        finding_id: result.finding_id,
313        proposal_id: result.proposal_id,
314        proposal_status: result.status,
315        applied_event_id: result.applied_event_id,
316        wrote_to: path.display().to_string(),
317        message: if apply {
318            "Rejection proposal applied".to_string()
319        } else {
320            "Rejection proposal recorded".to_string()
321        },
322    })
323}
324
325/// v0.57: Resolve a single named entity inside a finding's
326/// assertion.entities to a canonical id with resolution metadata.
327/// Clears the entity's needs_review flag. Lands as a signed
328/// `finding.entity_resolved` event.
329#[allow(clippy::too_many_arguments)]
330pub fn resolve_finding_entity(
331    path: &Path,
332    finding_id: &str,
333    entity_name: &str,
334    source: &str,
335    id: &str,
336    confidence: f64,
337    matched_name: Option<&str>,
338    resolution_method: &str,
339    reviewer: &str,
340    reason: &str,
341    apply: bool,
342) -> Result<StateCommandReport, String> {
343    let frontier_view = repo::load_from_path(path)?;
344    let f = frontier_view
345        .findings
346        .iter()
347        .find(|f| f.id == finding_id)
348        .ok_or_else(|| format!("Finding not found: {finding_id}"))?;
349    if !f.assertion.entities.iter().any(|e| e.name == entity_name) {
350        return Err(format!(
351            "Finding {finding_id} has no entity named {entity_name:?}"
352        ));
353    }
354    if !(0.0..=1.0).contains(&confidence) {
355        return Err(format!(
356            "--confidence must be in [0.0, 1.0], got {confidence}"
357        ));
358    }
359    if !matches!(
360        resolution_method,
361        "exact_match" | "fuzzy_match" | "llm_inference" | "manual"
362    ) {
363        return Err(format!(
364            "--resolution-method must be one of exact_match|fuzzy_match|llm_inference|manual, got {resolution_method:?}"
365        ));
366    }
367    let mut payload = json!({
368        "entity_name": entity_name,
369        "source": source,
370        "id": id,
371        "confidence": confidence,
372        "resolution_method": resolution_method,
373    });
374    if let Some(m) = matched_name {
375        payload["matched_name"] = json!(m);
376    }
377    let proposal = proposals::new_proposal(
378        "finding.entity_resolve",
379        events::StateTarget {
380            r#type: "finding".to_string(),
381            id: finding_id.to_string(),
382        },
383        reviewer,
384        "human",
385        reason,
386        payload,
387        Vec::new(),
388        Vec::new(),
389    );
390    let result = proposals::create_or_apply(path, proposal, apply)?;
391    Ok(StateCommandReport {
392        ok: true,
393        command: "entity-resolve".to_string(),
394        frontier: frontier_view.project.name,
395        finding_id: finding_id.to_string(),
396        proposal_id: result.proposal_id,
397        proposal_status: result.status,
398        applied_event_id: result.applied_event_id,
399        wrote_to: path.display().to_string(),
400        message: if apply {
401            "Entity resolution applied".to_string()
402        } else {
403            "Entity resolution proposal recorded".to_string()
404        },
405    })
406}
407
408/// v0.57: Mechanically repair a missing evidence-span on a finding by
409/// appending a `{section, text}` span. The proposal lands as a
410/// `finding.span_repair` and the canonical event as
411/// `finding.span_repaired`.
412pub fn repair_finding_span(
413    path: &Path,
414    finding_id: &str,
415    section: &str,
416    text: &str,
417    reviewer: &str,
418    reason: &str,
419    apply: bool,
420) -> Result<StateCommandReport, String> {
421    let frontier_view = repo::load_from_path(path)?;
422    let _ = frontier_view
423        .findings
424        .iter()
425        .find(|f| f.id == finding_id)
426        .ok_or_else(|| format!("Finding not found: {finding_id}"))?;
427    let trimmed_section = section.trim();
428    let trimmed_text = text.trim();
429    if trimmed_section.is_empty() {
430        return Err("--section must be non-empty".to_string());
431    }
432    if trimmed_text.is_empty() {
433        return Err("--text must be non-empty".to_string());
434    }
435    let proposal = proposals::new_proposal(
436        "finding.span_repair",
437        events::StateTarget {
438            r#type: "finding".to_string(),
439            id: finding_id.to_string(),
440        },
441        reviewer,
442        "human",
443        reason,
444        json!({
445            "section": trimmed_section,
446            "text": trimmed_text,
447        }),
448        Vec::new(),
449        Vec::new(),
450    );
451    let result = proposals::create_or_apply(path, proposal, apply)?;
452    Ok(StateCommandReport {
453        ok: true,
454        command: "span-repair".to_string(),
455        frontier: frontier_view.project.name,
456        finding_id: finding_id.to_string(),
457        proposal_id: result.proposal_id,
458        proposal_status: result.status,
459        applied_event_id: result.applied_event_id,
460        wrote_to: path.display().to_string(),
461        message: if apply {
462            "Span repair applied".to_string()
463        } else {
464            "Span repair proposal recorded".to_string()
465        },
466    })
467}
468
469/// v0.56: Mechanically repair a missing evidence-atom locator by
470/// copying the locator from the parent source record. If `locator` is
471/// `None` the resolver pulls the value from `frontier.sources` for the
472/// atom's parent. The proposal carries both the resolved locator and
473/// the source id it was derived from so a fresh replay reconstructs
474/// the derivation without re-resolving.
475pub fn repair_evidence_atom_locator(
476    path: &Path,
477    atom_id: &str,
478    locator_override: Option<&str>,
479    reviewer: &str,
480    reason: &str,
481    apply: bool,
482) -> Result<StateCommandReport, String> {
483    let frontier_view = repo::load_from_path(path)?;
484    let atom = frontier_view
485        .evidence_atoms
486        .iter()
487        .find(|atom| atom.id == atom_id)
488        .ok_or_else(|| format!("Evidence atom not found: {atom_id}"))?;
489    if let Some(existing) = &atom.locator {
490        return Err(format!(
491            "Evidence atom {atom_id} already carries locator '{existing}'"
492        ));
493    }
494    let source_id = atom.source_id.clone();
495    let locator = match locator_override {
496        Some(value) => {
497            let trimmed = value.trim();
498            if trimmed.is_empty() {
499                return Err("--locator value must be non-empty".to_string());
500            }
501            trimmed.to_string()
502        }
503        None => {
504            let source = frontier_view
505                .sources
506                .iter()
507                .find(|source| source.id == source_id)
508                .ok_or_else(|| {
509                    format!(
510                        "Cannot resolve locator for atom {atom_id}: parent source {source_id} not in frontier"
511                    )
512                })?;
513            let trimmed = source.locator.trim();
514            if trimmed.is_empty() {
515                return Err(format!(
516                    "Cannot resolve locator for atom {atom_id}: parent source {source_id} has an empty locator"
517                ));
518            }
519            trimmed.to_string()
520        }
521    };
522    let proposal = proposals::new_proposal(
523        "evidence_atom.locator_repair",
524        events::StateTarget {
525            r#type: "evidence_atom".to_string(),
526            id: atom_id.to_string(),
527        },
528        reviewer,
529        "human",
530        reason,
531        json!({
532            "locator": locator,
533            "source_id": source_id,
534        }),
535        Vec::new(),
536        Vec::new(),
537    );
538    let result = proposals::create_or_apply(path, proposal, apply)?;
539    Ok(StateCommandReport {
540        ok: true,
541        command: "locator-repair".to_string(),
542        frontier: frontier_view.project.name,
543        finding_id: atom_id.to_string(),
544        proposal_id: result.proposal_id,
545        proposal_status: result.status,
546        applied_event_id: result.applied_event_id,
547        wrote_to: path.display().to_string(),
548        message: if apply {
549            "Locator repair applied".to_string()
550        } else {
551            "Locator repair proposal recorded".to_string()
552        },
553    })
554}
555
556/// v0.59: record a reviewer's verdict on a previously detected
557/// federation conflict. Pairs with the existing
558/// `frontier.conflict_detected` event by `conflict_event_id`. The
559/// conflict event itself is not modified; this helper appends a
560/// new `frontier.conflict_resolved` canonical event to the log.
561pub fn resolve_frontier_conflict(
562    path: &Path,
563    conflict_event_id: &str,
564    resolution_note: &str,
565    reviewer: &str,
566    winning_proposal_id: Option<&str>,
567    apply: bool,
568) -> Result<StateCommandReport, String> {
569    let frontier_view = repo::load_from_path(path)?;
570    let frontier_id = frontier_view.frontier_id();
571    let mut payload = json!({
572        "conflict_event_id": conflict_event_id,
573        "resolution_note": resolution_note,
574    });
575    if let Some(wpid) = winning_proposal_id {
576        payload["winning_proposal_id"] = json!(wpid);
577    }
578    let proposal = proposals::new_proposal(
579        "frontier.conflict_resolve",
580        events::StateTarget {
581            r#type: "frontier_observation".to_string(),
582            id: frontier_id,
583        },
584        reviewer,
585        "human",
586        format!("Conflict resolution: {resolution_note}"),
587        payload,
588        Vec::new(),
589        Vec::new(),
590    );
591    let result = proposals::create_or_apply(path, proposal, apply)?;
592    Ok(StateCommandReport {
593        ok: true,
594        command: "conflict-resolve".to_string(),
595        frontier: frontier_view.project.name,
596        finding_id: conflict_event_id.to_string(),
597        proposal_id: result.proposal_id,
598        proposal_status: result.status,
599        applied_event_id: result.applied_event_id,
600        wrote_to: path.display().to_string(),
601        message: if apply {
602            "Conflict resolution applied".to_string()
603        } else {
604            "Conflict resolution proposal recorded".to_string()
605        },
606    })
607}
608
609/// v0.70: deposit a Replication record onto the frontier as a
610/// signed canonical `replication.deposited` event. Idempotent under
611/// re-application: if the `vrep_*` id already exists on the
612/// frontier, the helper refuses with a clear error rather than
613/// silently no-op'ing. The event is appended to the canonical event
614/// log; the reducer arm projects it onto `Project.replications` on
615/// subsequent loads.
616pub fn deposit_replication(
617    path: &Path,
618    rep: crate::bundle::Replication,
619    actor_id: &str,
620    reason: &str,
621) -> Result<events::StateEvent, String> {
622    let mut project = repo::load_from_path(path)?;
623    if project.replications.iter().any(|r| r.id == rep.id) {
624        return Err(format!(
625            "Replication {} already exists on this frontier; refusing duplicate deposit",
626            rep.id
627        ));
628    }
629    let rep_value =
630        serde_json::to_value(&rep).map_err(|e| format!("serialize replication: {e}"))?;
631    let payload = json!({ "replication": rep_value });
632    let timestamp = Utc::now().to_rfc3339();
633    let mut event = events::StateEvent {
634        schema: events::EVENT_SCHEMA.to_string(),
635        id: String::new(),
636        kind: "replication.deposited".to_string(),
637        target: events::StateTarget {
638            r#type: "finding".to_string(),
639            id: rep.target_finding.clone(),
640        },
641        actor: events::StateActor {
642            id: actor_id.to_string(),
643            r#type: "human".to_string(),
644        },
645        timestamp,
646        reason: reason.to_string(),
647        before_hash: NULL_HASH.to_string(),
648        after_hash: NULL_HASH.to_string(),
649        payload,
650        caveats: Vec::new(),
651        signature: None,
652    };
653    event.id = events::compute_event_id(&event);
654    project.replications.push(rep);
655    project.events.push(event.clone());
656    repo::save_to_path(path, &project)?;
657    Ok(event)
658}
659
660/// v0.70: deposit a Prediction record onto the frontier as a
661/// signed canonical `prediction.deposited` event. Mirror of
662/// `deposit_replication` for the Prediction primitive.
663pub fn deposit_prediction(
664    path: &Path,
665    pred: crate::bundle::Prediction,
666    actor_id: &str,
667    reason: &str,
668) -> Result<events::StateEvent, String> {
669    let mut project = repo::load_from_path(path)?;
670    if project.predictions.iter().any(|p| p.id == pred.id) {
671        return Err(format!(
672            "Prediction {} already exists on this frontier; refusing duplicate deposit",
673            pred.id
674        ));
675    }
676    let pred_value =
677        serde_json::to_value(&pred).map_err(|e| format!("serialize prediction: {e}"))?;
678    let payload = json!({ "prediction": pred_value });
679    let timestamp = Utc::now().to_rfc3339();
680    let mut event = events::StateEvent {
681        schema: events::EVENT_SCHEMA.to_string(),
682        id: String::new(),
683        kind: "prediction.deposited".to_string(),
684        target: events::StateTarget {
685            r#type: "finding".to_string(),
686            id: pred.target_findings.first().cloned().unwrap_or_default(),
687        },
688        actor: events::StateActor {
689            id: actor_id.to_string(),
690            r#type: "human".to_string(),
691        },
692        timestamp,
693        reason: reason.to_string(),
694        before_hash: NULL_HASH.to_string(),
695        after_hash: NULL_HASH.to_string(),
696        payload,
697        caveats: Vec::new(),
698        signature: None,
699    };
700    event.id = events::compute_event_id(&event);
701    project.predictions.push(pred);
702    project.events.push(event.clone());
703    repo::save_to_path(path, &project)?;
704    Ok(event)
705}
706
707pub fn retract_finding(
708    path: &Path,
709    finding_id: &str,
710    reviewer: &str,
711    reason: &str,
712    apply: bool,
713) -> Result<StateCommandReport, String> {
714    let frontier = repo::load_from_path(path)?;
715    find_finding_index(&frontier, finding_id)?;
716    let proposal = proposals::new_proposal(
717        "finding.retract",
718        events::StateTarget {
719            r#type: "finding".to_string(),
720            id: finding_id.to_string(),
721        },
722        reviewer,
723        "human",
724        reason,
725        json!({}),
726        Vec::new(),
727        vec!["Retraction impact is simulated over declared dependency links.".to_string()],
728    );
729    let result = proposals::create_or_apply(path, proposal, apply)?;
730    Ok(StateCommandReport {
731        ok: true,
732        command: "retract".to_string(),
733        frontier: frontier.project.name,
734        finding_id: result.finding_id,
735        proposal_id: result.proposal_id,
736        proposal_status: result.status,
737        applied_event_id: result.applied_event_id,
738        wrote_to: path.display().to_string(),
739        message: if apply {
740            "Retraction proposal applied".to_string()
741        } else {
742            "Retraction proposal recorded".to_string()
743        },
744    })
745}
746
747/// v0.38: Set or revise a finding's `causal_claim` and (optionally)
748/// `causal_evidence_grade`. Appends an `assertion.reinterpreted_causal`
749/// event capturing the prior reading, the new reading, and the actor.
750/// Bypasses the proposal flow because (a) the mutation is local and
751/// reversible by another call, and (b) the schema layer ships ahead of
752/// the reasoning surface — the next milestone will route this through
753/// proposals once the do-calculus layer needs it.
754pub fn set_causal(
755    path: &Path,
756    finding_id: &str,
757    new_claim: &str,
758    new_grade: Option<&str>,
759    actor: &str,
760    reason: &str,
761) -> Result<StateCommandReport, String> {
762    use crate::bundle::{CausalClaim, CausalEvidenceGrade};
763
764    let mut frontier: Project = repo::load_from_path(path)?;
765    let idx = frontier
766        .findings
767        .iter()
768        .position(|f| f.id == finding_id)
769        .ok_or_else(|| format!("Finding not found: {finding_id}"))?;
770
771    // Capture the prior reading for the event payload.
772    let before = json!({
773        "claim": frontier.findings[idx].assertion.causal_claim,
774        "grade": frontier.findings[idx].assertion.causal_evidence_grade,
775    });
776
777    let parsed_claim = match new_claim {
778        "correlation" => CausalClaim::Correlation,
779        "mediation" => CausalClaim::Mediation,
780        "intervention" => CausalClaim::Intervention,
781        other => return Err(format!("invalid causal claim '{other}'")),
782    };
783    let parsed_grade = match new_grade {
784        None => None,
785        Some("rct") => Some(CausalEvidenceGrade::Rct),
786        Some("quasi_experimental") => Some(CausalEvidenceGrade::QuasiExperimental),
787        Some("observational") => Some(CausalEvidenceGrade::Observational),
788        Some("theoretical") => Some(CausalEvidenceGrade::Theoretical),
789        Some(other) => return Err(format!("invalid causal evidence grade '{other}'")),
790    };
791
792    let before_hash = events::finding_hash(&frontier.findings[idx]);
793    frontier.findings[idx].assertion.causal_claim = Some(parsed_claim);
794    if let Some(g) = parsed_grade {
795        frontier.findings[idx].assertion.causal_evidence_grade = Some(g);
796    }
797    let after_hash = events::finding_hash(&frontier.findings[idx]);
798
799    let after = json!({
800        "claim": new_claim,
801        "grade": new_grade,
802    });
803
804    // Synthesize a deterministic proposal_id over the mutation.
805    let proposal_id = format!(
806        "vpr_{}",
807        &hex::encode(Sha256::digest(
808            format!(
809                "{finding_id}|{actor}|{before_hash}|{after_hash}|{}",
810                Utc::now().to_rfc3339()
811            )
812            .as_bytes()
813        ))[..16]
814    );
815
816    let event = events::new_finding_event(events::FindingEventInput {
817        kind: "assertion.reinterpreted_causal",
818        finding_id,
819        actor_id: actor,
820        actor_type: "human",
821        reason,
822        before_hash: &before_hash,
823        after_hash: &after_hash,
824        payload: json!({
825            "proposal_id": proposal_id,
826            "before": before,
827            "after": after,
828        }),
829        caveats: Vec::new(),
830    });
831    let event_id = event.id.clone();
832    frontier.events.push(event);
833
834    repo::save_to_path(path, &frontier)?;
835
836    Ok(StateCommandReport {
837        ok: true,
838        command: "causal_set".to_string(),
839        frontier: frontier.project.name,
840        finding_id: finding_id.to_string(),
841        proposal_id,
842        proposal_status: "applied".to_string(),
843        applied_event_id: Some(event_id),
844        wrote_to: path.display().to_string(),
845        message: format!("Causal claim set to {new_claim}"),
846    })
847}
848
849/// v0.49: Add a NegativeResult to the frontier, emitting a
850/// `negative_result.asserted` canonical event.
851///
852/// Bypasses the proposal flow because (a) NegativeResult is parallel
853/// to FindingBundle, not a mutation of one — the proposal-first
854/// pipeline is finding-shaped and would force a duplicate target
855/// type; (b) the v0.49 deposit path mirrors how `Replication`,
856/// `Dataset`, and `Prediction` are added today (direct, with
857/// emission). v0.50 will route these through proposals once the
858/// agent inbox needs review-gated null deposits.
859///
860/// The full inline NegativeResult is carried on
861/// `payload.negative_result` so a fresh `replay_from_genesis`
862/// reconstructs `state.negative_results` from the event log alone.
863/// `target_findings` are NOT cross-checked against existing findings
864/// here; an exploratory deposit may legitimately reference no
865/// finding, and a registered-trial deposit may bear against a finding
866/// in a sibling frontier reachable through `vfr_*` cross-frontier
867/// links. The depositor is responsible for the link's truthfulness.
868pub fn add_negative_result(
869    path: &Path,
870    kind: NegativeResultKind,
871    target_findings: Vec<String>,
872    deposited_by: &str,
873    conditions: Conditions,
874    provenance: Provenance,
875    notes: &str,
876    reason: &str,
877) -> Result<StateCommandReport, String> {
878    if deposited_by.trim().is_empty() {
879        return Err("deposited_by must be a non-empty actor id".to_string());
880    }
881    if reason.trim().is_empty() {
882        return Err("reason must be non-empty".to_string());
883    }
884
885    let mut frontier: Project = repo::load_from_path(path)?;
886
887    let nr = NegativeResult::new(
888        kind,
889        target_findings,
890        deposited_by,
891        conditions,
892        provenance,
893        notes,
894    );
895    let nr_id = nr.id.clone();
896
897    if frontier.negative_results.iter().any(|n| n.id == nr_id) {
898        return Err(format!(
899            "Refusing to add duplicate negative_result with existing id {nr_id}"
900        ));
901    }
902
903    let proposal_id = format!(
904        "vpr_{}",
905        &hex::encode(Sha256::digest(
906            format!("{nr_id}|{deposited_by}|{}", Utc::now().to_rfc3339()).as_bytes()
907        ))[..16]
908    );
909
910    let nr_value = serde_json::to_value(&nr)
911        .map_err(|e| format!("failed to serialize negative_result: {e}"))?;
912
913    let mut event = StateEvent {
914        schema: events::EVENT_SCHEMA.to_string(),
915        id: String::new(),
916        kind: events::EVENT_KIND_NEGATIVE_RESULT_ASSERTED.to_string(),
917        target: StateTarget {
918            r#type: "negative_result".to_string(),
919            id: nr_id.clone(),
920        },
921        actor: StateActor {
922            id: deposited_by.to_string(),
923            r#type: "human".to_string(),
924        },
925        timestamp: Utc::now().to_rfc3339(),
926        reason: reason.to_string(),
927        before_hash: NULL_HASH.to_string(),
928        after_hash: NULL_HASH.to_string(),
929        payload: json!({
930            "proposal_id": proposal_id,
931            "negative_result": nr_value,
932        }),
933        caveats: Vec::new(),
934        signature: None,
935    };
936    event.id = events::compute_event_id(&event);
937    let event_id = event.id.clone();
938
939    // Validate before mutating state — a malformed event must not
940    // poison the on-disk frontier.
941    events::validate_event_payload(&event.kind, &event.payload)?;
942    reducer::apply_event(&mut frontier, &event)?;
943    frontier.events.push(event);
944
945    repo::save_to_path(path, &frontier)?;
946
947    Ok(StateCommandReport {
948        ok: true,
949        command: "negative_result.add".to_string(),
950        frontier: frontier.project.name,
951        finding_id: nr_id,
952        proposal_id,
953        proposal_status: "applied".to_string(),
954        applied_event_id: Some(event_id),
955        wrote_to: path.display().to_string(),
956        message: "NegativeResult deposited".to_string(),
957    })
958}
959
960/// Deposit a generic content-addressed artifact and emit an
961/// `artifact.asserted` canonical event. The full artifact is carried
962/// inline on the event payload so a future replay reconstructs the
963/// artifact table without reading `.vela/artifacts`.
964pub fn add_artifact(
965    path: &Path,
966    artifact: Artifact,
967    deposited_by: &str,
968    reason: &str,
969) -> Result<StateCommandReport, String> {
970    if deposited_by.trim().is_empty() {
971        return Err("deposited_by must be a non-empty actor id".to_string());
972    }
973    if reason.trim().is_empty() {
974        return Err("reason must be non-empty".to_string());
975    }
976
977    let mut frontier: Project = repo::load_from_path(path)?;
978    let artifact_id = artifact.id.clone();
979
980    if frontier.artifacts.iter().any(|a| a.id == artifact_id) {
981        return Err(format!(
982            "Refusing to add duplicate artifact with existing id {artifact_id}"
983        ));
984    }
985
986    let proposal_id = format!(
987        "vpr_{}",
988        &hex::encode(Sha256::digest(
989            format!("{artifact_id}|{deposited_by}|{}", Utc::now().to_rfc3339()).as_bytes()
990        ))[..16]
991    );
992
993    let artifact_value = serde_json::to_value(&artifact)
994        .map_err(|e| format!("failed to serialize artifact: {e}"))?;
995
996    let mut event = StateEvent {
997        schema: events::EVENT_SCHEMA.to_string(),
998        id: String::new(),
999        kind: events::EVENT_KIND_ARTIFACT_ASSERTED.to_string(),
1000        target: StateTarget {
1001            r#type: "artifact".to_string(),
1002            id: artifact_id.clone(),
1003        },
1004        actor: StateActor {
1005            id: deposited_by.to_string(),
1006            r#type: "human".to_string(),
1007        },
1008        timestamp: Utc::now().to_rfc3339(),
1009        reason: reason.to_string(),
1010        before_hash: NULL_HASH.to_string(),
1011        after_hash: NULL_HASH.to_string(),
1012        payload: json!({
1013            "proposal_id": proposal_id,
1014            "artifact": artifact_value,
1015        }),
1016        caveats: Vec::new(),
1017        signature: None,
1018    };
1019    event.id = events::compute_event_id(&event);
1020    let event_id = event.id.clone();
1021
1022    events::validate_event_payload(&event.kind, &event.payload)?;
1023    reducer::apply_event(&mut frontier, &event)?;
1024    frontier.events.push(event);
1025
1026    repo::save_to_path(path, &frontier)?;
1027
1028    Ok(StateCommandReport {
1029        ok: true,
1030        command: "artifact.add".to_string(),
1031        frontier: frontier.project.name,
1032        finding_id: artifact_id,
1033        proposal_id,
1034        proposal_status: "applied".to_string(),
1035        applied_event_id: Some(event_id),
1036        wrote_to: path.display().to_string(),
1037        message: "Artifact deposited".to_string(),
1038    })
1039}
1040
1041/// v0.50: Open a new Trajectory and emit a `trajectory.created`
1042/// canonical event. Returns the new `vtr_*` id in the report's
1043/// `finding_id` field (the StateCommandReport schema reuses that
1044/// field for the primary mutated object id).
1045///
1046/// Steps are appended via `append_trajectory_step` rather than
1047/// supplied at creation — that keeps the search visible to readers as
1048/// it unfolds rather than only after the fact.
1049pub fn create_trajectory(
1050    path: &Path,
1051    target_findings: Vec<String>,
1052    deposited_by: &str,
1053    notes: &str,
1054    reason: &str,
1055) -> Result<StateCommandReport, String> {
1056    if deposited_by.trim().is_empty() {
1057        return Err("deposited_by must be a non-empty actor id".to_string());
1058    }
1059    if reason.trim().is_empty() {
1060        return Err("reason must be non-empty".to_string());
1061    }
1062
1063    let mut frontier: Project = repo::load_from_path(path)?;
1064
1065    let traj = Trajectory::new(target_findings, deposited_by, notes);
1066    let traj_id = traj.id.clone();
1067
1068    if frontier.trajectories.iter().any(|t| t.id == traj_id) {
1069        return Err(format!(
1070            "Refusing to create duplicate trajectory with existing id {traj_id}"
1071        ));
1072    }
1073
1074    let proposal_id = format!(
1075        "vpr_{}",
1076        &hex::encode(Sha256::digest(
1077            format!("{traj_id}|{deposited_by}|{}", Utc::now().to_rfc3339()).as_bytes()
1078        ))[..16]
1079    );
1080
1081    let traj_value =
1082        serde_json::to_value(&traj).map_err(|e| format!("failed to serialize trajectory: {e}"))?;
1083
1084    let mut event = StateEvent {
1085        schema: events::EVENT_SCHEMA.to_string(),
1086        id: String::new(),
1087        kind: events::EVENT_KIND_TRAJECTORY_CREATED.to_string(),
1088        target: StateTarget {
1089            r#type: "trajectory".to_string(),
1090            id: traj_id.clone(),
1091        },
1092        actor: StateActor {
1093            id: deposited_by.to_string(),
1094            r#type: "human".to_string(),
1095        },
1096        timestamp: Utc::now().to_rfc3339(),
1097        reason: reason.to_string(),
1098        before_hash: NULL_HASH.to_string(),
1099        after_hash: NULL_HASH.to_string(),
1100        payload: json!({
1101            "proposal_id": proposal_id,
1102            "trajectory": traj_value,
1103        }),
1104        caveats: Vec::new(),
1105        signature: None,
1106    };
1107    event.id = events::compute_event_id(&event);
1108    let event_id = event.id.clone();
1109
1110    events::validate_event_payload(&event.kind, &event.payload)?;
1111    reducer::apply_event(&mut frontier, &event)?;
1112    frontier.events.push(event);
1113
1114    repo::save_to_path(path, &frontier)?;
1115
1116    Ok(StateCommandReport {
1117        ok: true,
1118        command: "trajectory.create".to_string(),
1119        frontier: frontier.project.name,
1120        finding_id: traj_id,
1121        proposal_id,
1122        proposal_status: "applied".to_string(),
1123        applied_event_id: Some(event_id),
1124        wrote_to: path.display().to_string(),
1125        message: "Trajectory opened".to_string(),
1126    })
1127}
1128
1129/// v0.50: Append a step to an existing Trajectory. Step kind one of
1130/// `hypothesis | tried | ruled_out | observed | refined`. Idempotent
1131/// on duplicate step content-addresses (so an agent that re-runs an
1132/// append after a crash doesn't double-append).
1133pub fn append_trajectory_step(
1134    path: &Path,
1135    trajectory_id: &str,
1136    kind: TrajectoryStepKind,
1137    description: &str,
1138    actor: &str,
1139    references: Vec<String>,
1140    reason: &str,
1141) -> Result<StateCommandReport, String> {
1142    if actor.trim().is_empty() {
1143        return Err("actor must be a non-empty id".to_string());
1144    }
1145    if description.trim().is_empty() {
1146        return Err("description must be non-empty".to_string());
1147    }
1148    if reason.trim().is_empty() {
1149        return Err("reason must be non-empty".to_string());
1150    }
1151
1152    let mut frontier: Project = repo::load_from_path(path)?;
1153    if !frontier.trajectories.iter().any(|t| t.id == trajectory_id) {
1154        return Err(format!("Trajectory not found: {trajectory_id}"));
1155    }
1156
1157    let step = TrajectoryStep::new(trajectory_id, kind, description, actor, None, references);
1158    let step_id = step.id.clone();
1159
1160    let proposal_id = format!(
1161        "vpr_{}",
1162        &hex::encode(Sha256::digest(
1163            format!("{trajectory_id}|{step_id}|{actor}").as_bytes()
1164        ))[..16]
1165    );
1166
1167    let step_value = serde_json::to_value(&step)
1168        .map_err(|e| format!("failed to serialize trajectory step: {e}"))?;
1169
1170    let mut event = StateEvent {
1171        schema: events::EVENT_SCHEMA.to_string(),
1172        id: String::new(),
1173        kind: events::EVENT_KIND_TRAJECTORY_STEP_APPENDED.to_string(),
1174        target: StateTarget {
1175            r#type: "trajectory".to_string(),
1176            id: trajectory_id.to_string(),
1177        },
1178        actor: StateActor {
1179            id: actor.to_string(),
1180            r#type: "human".to_string(),
1181        },
1182        timestamp: Utc::now().to_rfc3339(),
1183        reason: reason.to_string(),
1184        before_hash: NULL_HASH.to_string(),
1185        after_hash: NULL_HASH.to_string(),
1186        payload: json!({
1187            "proposal_id": proposal_id,
1188            "parent_trajectory_id": trajectory_id,
1189            "step": step_value,
1190        }),
1191        caveats: Vec::new(),
1192        signature: None,
1193    };
1194    event.id = events::compute_event_id(&event);
1195    let event_id = event.id.clone();
1196
1197    events::validate_event_payload(&event.kind, &event.payload)?;
1198    reducer::apply_event(&mut frontier, &event)?;
1199    frontier.events.push(event);
1200
1201    repo::save_to_path(path, &frontier)?;
1202
1203    Ok(StateCommandReport {
1204        ok: true,
1205        command: "trajectory.step_append".to_string(),
1206        frontier: frontier.project.name,
1207        finding_id: step_id,
1208        proposal_id,
1209        proposal_status: "applied".to_string(),
1210        applied_event_id: Some(event_id),
1211        wrote_to: path.display().to_string(),
1212        message: "Trajectory step appended".to_string(),
1213    })
1214}
1215
1216/// v0.51: Re-classify the access tier of a finding / negative_result
1217/// / trajectory / artifact. Emits a `tier.set` canonical event so the
1218/// reclassification is replay-deterministic and auditable.
1219///
1220/// `object_type` must be one of `finding`, `negative_result`,
1221/// `trajectory`, or `artifact`. The function captures the object's previous tier
1222/// for the event payload so a downstream auditor reading the event
1223/// log can reconstruct the full classification history without
1224/// re-deriving it from prior state.
1225pub fn set_tier(
1226    path: &Path,
1227    object_type: &str,
1228    object_id: &str,
1229    new_tier: crate::access_tier::AccessTier,
1230    actor: &str,
1231    reason: &str,
1232) -> Result<StateCommandReport, String> {
1233    if actor.trim().is_empty() {
1234        return Err("actor must be a non-empty id".to_string());
1235    }
1236    if reason.trim().is_empty() {
1237        return Err("reason must be non-empty".to_string());
1238    }
1239    if !matches!(
1240        object_type,
1241        "finding" | "negative_result" | "trajectory" | "artifact"
1242    ) {
1243        return Err(format!(
1244            "object_type '{object_type}' must be one of finding, negative_result, trajectory, artifact"
1245        ));
1246    }
1247
1248    let mut frontier: Project = repo::load_from_path(path)?;
1249
1250    let previous_tier = match object_type {
1251        "finding" => {
1252            frontier
1253                .findings
1254                .iter()
1255                .find(|f| f.id == object_id)
1256                .ok_or_else(|| format!("Finding not found: {object_id}"))?
1257                .access_tier
1258        }
1259        "negative_result" => {
1260            frontier
1261                .negative_results
1262                .iter()
1263                .find(|n| n.id == object_id)
1264                .ok_or_else(|| format!("NegativeResult not found: {object_id}"))?
1265                .access_tier
1266        }
1267        "trajectory" => {
1268            frontier
1269                .trajectories
1270                .iter()
1271                .find(|t| t.id == object_id)
1272                .ok_or_else(|| format!("Trajectory not found: {object_id}"))?
1273                .access_tier
1274        }
1275        "artifact" => {
1276            frontier
1277                .artifacts
1278                .iter()
1279                .find(|a| a.id == object_id)
1280                .ok_or_else(|| format!("Artifact not found: {object_id}"))?
1281                .access_tier
1282        }
1283        _ => unreachable!("validated above"),
1284    };
1285
1286    let proposal_id = format!(
1287        "vpr_{}",
1288        &hex::encode(Sha256::digest(
1289            format!(
1290                "{object_type}|{object_id}|{actor}|{}|{}",
1291                new_tier.canonical(),
1292                Utc::now().to_rfc3339()
1293            )
1294            .as_bytes()
1295        ))[..16]
1296    );
1297
1298    let mut event = StateEvent {
1299        schema: events::EVENT_SCHEMA.to_string(),
1300        id: String::new(),
1301        kind: events::EVENT_KIND_TIER_SET.to_string(),
1302        target: StateTarget {
1303            r#type: object_type.to_string(),
1304            id: object_id.to_string(),
1305        },
1306        actor: StateActor {
1307            id: actor.to_string(),
1308            r#type: "human".to_string(),
1309        },
1310        timestamp: Utc::now().to_rfc3339(),
1311        reason: reason.to_string(),
1312        before_hash: NULL_HASH.to_string(),
1313        after_hash: NULL_HASH.to_string(),
1314        payload: json!({
1315            "proposal_id": proposal_id,
1316            "object_type": object_type,
1317            "object_id": object_id,
1318            "previous_tier": previous_tier.canonical(),
1319            "new_tier": new_tier.canonical(),
1320        }),
1321        caveats: Vec::new(),
1322        signature: None,
1323    };
1324    event.id = events::compute_event_id(&event);
1325    let event_id = event.id.clone();
1326
1327    events::validate_event_payload(&event.kind, &event.payload)?;
1328    reducer::apply_event(&mut frontier, &event)?;
1329    frontier.events.push(event);
1330
1331    repo::save_to_path(path, &frontier)?;
1332
1333    Ok(StateCommandReport {
1334        ok: true,
1335        command: "tier.set".to_string(),
1336        frontier: frontier.project.name,
1337        finding_id: object_id.to_string(),
1338        proposal_id,
1339        proposal_status: "applied".to_string(),
1340        applied_event_id: Some(event_id),
1341        wrote_to: path.display().to_string(),
1342        message: format!("Tier set to {} on {object_type}", new_tier.canonical()),
1343    })
1344}
1345
1346pub fn history(path: &Path, finding_id: &str) -> Result<Value, String> {
1347    history_as_of(path, finding_id, None)
1348}
1349
1350/// v0.55: time-travel replay. When `as_of` is `Some(ts)`, the returned
1351/// `events` / `review_events` / `confidence_updates` are filtered to
1352/// records whose timestamp is `<= ts` (RFC3339 lexicographic compare),
1353/// the `confidence` field reports the **score at that time** (last
1354/// confidence update at-or-before cutoff, or genesis if none), and a
1355/// `replayed_at_score` field surfaces it explicitly so a caller doesn't
1356/// need to walk the updates array.
1357pub fn history_as_of(path: &Path, finding_id: &str, as_of: Option<&str>) -> Result<Value, String> {
1358    let frontier = repo::load_from_path(path)?;
1359    let context = finding_context(&frontier, finding_id)?;
1360    let finding = context
1361        .get("finding")
1362        .ok_or_else(|| format!("Finding not found: {finding_id}"))?;
1363
1364    let cutoff = as_of.map(|s| s.to_string());
1365    let filter_by_ts = |arr: Option<&Value>, ts_field: &str| -> Value {
1366        let Some(v) = arr else {
1367            return Value::Array(Vec::new());
1368        };
1369        let Some(items) = v.as_array() else {
1370            return Value::Array(Vec::new());
1371        };
1372        match &cutoff {
1373            None => Value::Array(items.clone()),
1374            Some(c) => Value::Array(
1375                items
1376                    .iter()
1377                    .filter(|item| {
1378                        item.get(ts_field)
1379                            .and_then(Value::as_str)
1380                            .map(|t| t <= c.as_str())
1381                            .unwrap_or(true)
1382                    })
1383                    .cloned()
1384                    .collect(),
1385            ),
1386        }
1387    };
1388
1389    let events_filtered = filter_by_ts(context.get("events"), "timestamp");
1390    let review_events_filtered = filter_by_ts(context.get("review_events"), "reviewed_at");
1391    let confidence_updates_filtered = filter_by_ts(context.get("confidence_updates"), "updated_at");
1392
1393    // Score at cutoff: last confidence update at-or-before cutoff. If the
1394    // finding is at its genesis confidence, fall back to the current score
1395    // from the bundle (it never changed).
1396    let score_at = if let Some(arr) = confidence_updates_filtered.as_array() {
1397        let mut sorted: Vec<&Value> = arr.iter().collect();
1398        sorted.sort_by(|a, b| {
1399            let ta = a.get("updated_at").and_then(Value::as_str).unwrap_or("");
1400            let tb = b.get("updated_at").and_then(Value::as_str).unwrap_or("");
1401            ta.cmp(tb)
1402        });
1403        sorted
1404            .last()
1405            .and_then(|u| u.get("new_score"))
1406            .cloned()
1407            .unwrap_or_else(|| {
1408                finding
1409                    .pointer("/confidence/score")
1410                    .cloned()
1411                    .unwrap_or(Value::Null)
1412            })
1413    } else {
1414        finding
1415            .pointer("/confidence/score")
1416            .cloned()
1417            .unwrap_or(Value::Null)
1418    };
1419
1420    Ok(json!({
1421        "ok": true,
1422        "command": "history",
1423        "frontier": frontier.project.name,
1424        "as_of": cutoff,
1425        "finding": {
1426            "id": finding.get("id"),
1427            "assertion": finding.pointer("/assertion/text"),
1428            "confidence": finding.pointer("/confidence/score"),
1429            "flags": finding.get("flags"),
1430            "annotations": finding.get("annotations"),
1431        },
1432        "replayed_at_score": score_at,
1433        "review_events": review_events_filtered,
1434        "confidence_updates": confidence_updates_filtered,
1435        "sources": context.get("sources"),
1436        "evidence_atoms": context.get("evidence_atoms"),
1437        "condition_records": context.get("condition_records"),
1438        "proposals": context.get("proposals"),
1439        "events": events_filtered,
1440        "proof_state": frontier.proof_state,
1441    }))
1442}
1443
1444pub fn finding_context(frontier: &Project, finding_id: &str) -> Result<Value, String> {
1445    let finding = frontier
1446        .findings
1447        .iter()
1448        .find(|finding| finding.id == finding_id)
1449        .ok_or_else(|| format!("Finding not found: {finding_id}"))?;
1450    let reviews = frontier
1451        .review_events
1452        .iter()
1453        .filter(|event| event.finding_id == finding_id)
1454        .collect::<Vec<_>>();
1455    let confidence_updates = frontier
1456        .confidence_updates
1457        .iter()
1458        .filter(|update| update.finding_id == finding_id)
1459        .collect::<Vec<_>>();
1460    let source_records = frontier
1461        .sources
1462        .iter()
1463        .filter(|source| source.finding_ids.iter().any(|id| id == finding_id))
1464        .collect::<Vec<_>>();
1465    let evidence_atoms = frontier
1466        .evidence_atoms
1467        .iter()
1468        .filter(|atom| atom.finding_id == finding_id)
1469        .collect::<Vec<_>>();
1470    let condition_records = frontier
1471        .condition_records
1472        .iter()
1473        .filter(|record| record.finding_id == finding_id)
1474        .collect::<Vec<_>>();
1475    Ok(json!({
1476        "finding": finding,
1477        "review_events": reviews,
1478        "confidence_updates": confidence_updates,
1479        "sources": source_records,
1480        "evidence_atoms": evidence_atoms,
1481        "condition_records": condition_records,
1482        "proposals": proposals::proposals_for_finding(frontier, finding_id),
1483        "events": events::events_for_finding(frontier, finding_id),
1484        "proof_state": frontier.proof_state,
1485    }))
1486}
1487
1488pub fn state_transitions(frontier: &Project) -> Value {
1489    let mut transitions = Vec::new();
1490    if !frontier.events.is_empty() {
1491        for event in &frontier.events {
1492            transitions.push(json!({
1493                "kind": event.kind,
1494                "id": event.id,
1495                "target": event.target,
1496                "actor": event.actor,
1497                "timestamp": event.timestamp,
1498                "reason": event.reason,
1499                "before_hash": event.before_hash,
1500                "after_hash": event.after_hash,
1501                "payload": event.payload,
1502                "caveats": event.caveats,
1503            }));
1504        }
1505        transitions.sort_by(|a, b| {
1506            a.get("timestamp")
1507                .and_then(Value::as_str)
1508                .cmp(&b.get("timestamp").and_then(Value::as_str))
1509        });
1510        return json!({
1511            "schema": "vela.state-transitions.v1",
1512            "frontier": frontier.project.name,
1513            "source": "canonical_events",
1514            "transitions": transitions,
1515        });
1516    }
1517    for event in &frontier.review_events {
1518        transitions.push(json!({
1519            "kind": "review_event",
1520            "id": event.id,
1521            "target": {"type": "finding", "id": event.finding_id},
1522            "actor": event.reviewer,
1523            "timestamp": event.reviewed_at,
1524            "action": event.action,
1525            "reason": event.reason,
1526            "state_change": event.state_change,
1527        }));
1528    }
1529    for update in &frontier.confidence_updates {
1530        transitions.push(json!({
1531            "kind": "confidence_update",
1532            "id": confidence_update_id(update),
1533            "target": {"type": "finding", "id": update.finding_id},
1534            "actor": update.updated_by,
1535            "timestamp": update.updated_at,
1536            "action": "confidence_revised",
1537            "reason": update.basis,
1538            "state_change": {
1539                "previous_score": update.previous_score,
1540                "new_score": update.new_score,
1541            },
1542        }));
1543    }
1544    transitions.sort_by(|a, b| {
1545        a.get("timestamp")
1546            .and_then(Value::as_str)
1547            .cmp(&b.get("timestamp").and_then(Value::as_str))
1548    });
1549    json!({
1550        "schema": "vela.state-transitions.v0",
1551        "frontier": frontier.project.name,
1552        "transitions": transitions,
1553    })
1554}
1555
1556/// Build a content-addressed FindingBundle from CLI-supplied options.
1557/// Shared by `finding.add` and v0.14 `finding.supersede`.
1558fn build_finding_bundle(options: &FindingDraftOptions) -> FindingBundle {
1559    let now = Utc::now().to_rfc3339();
1560    let assertion = Assertion {
1561        text: options.text.clone(),
1562        assertion_type: options.assertion_type.clone(),
1563        entities: options
1564            .entities
1565            .iter()
1566            .map(|(name, entity_type)| Entity {
1567                name: name.clone(),
1568                entity_type: entity_type.clone(),
1569                identifiers: serde_json::Map::new(),
1570                canonical_id: None,
1571                candidates: Vec::new(),
1572                aliases: Vec::new(),
1573                resolution_provenance: Some("manual_state_transition".to_string()),
1574                resolution_confidence: if options.entities_reviewed { 0.95 } else { 0.6 },
1575                resolution_method: if options.entities_reviewed {
1576                    Some(ResolutionMethod::Manual)
1577                } else {
1578                    None
1579                },
1580                species_context: None,
1581                needs_review: !options.entities_reviewed,
1582            })
1583            .collect(),
1584        relation: None,
1585        direction: None,
1586        causal_claim: None,
1587        causal_evidence_grade: None,
1588    };
1589    let evidence = Evidence {
1590        evidence_type: options.evidence_type.clone(),
1591        model_system: String::new(),
1592        species: options
1593            .species
1594            .first()
1595            .cloned()
1596            .or_else(|| options.human_data.then(|| "Homo sapiens".to_string())),
1597        method: if options.clinical_trial {
1598            "manual state transition; placebo-controlled clinical trial where source reports control arm"
1599                .to_string()
1600        } else if options.evidence_type == "experimental" {
1601            "manual state transition; control details require source inspection".to_string()
1602        } else {
1603            "manual state transition".to_string()
1604        },
1605        sample_size: None,
1606        effect_size: None,
1607        p_value: None,
1608        replicated: false,
1609        replication_count: None,
1610        evidence_spans: options.evidence_spans.clone(),
1611    };
1612    let conditions = Conditions {
1613        text: options.conditions_text.clone().unwrap_or_else(|| {
1614            "Manually added finding; requires evidence review before scientific use.".to_string()
1615        }),
1616        species_verified: options.species.clone(),
1617        species_unverified: Vec::new(),
1618        in_vitro: options.in_vitro,
1619        in_vivo: options.in_vivo,
1620        human_data: options.human_data,
1621        clinical_trial: options.clinical_trial,
1622        concentration_range: None,
1623        duration: None,
1624        age_group: None,
1625        cell_type: None,
1626    };
1627    let confidence = Confidence {
1628        kind: ConfidenceKind::FrontierEpistemic,
1629        score: options.confidence,
1630        basis: "operator-supplied frontier prior; review required".to_string(),
1631        method: ConfidenceMethod::ExpertJudgment,
1632        components: None,
1633        extraction_confidence: 1.0,
1634    };
1635    let source_authors = if options.source_authors.is_empty() {
1636        vec![Author {
1637            name: options.author.clone(),
1638            orcid: None,
1639        }]
1640    } else {
1641        options
1642            .source_authors
1643            .iter()
1644            .map(|name| Author {
1645                name: name.clone(),
1646                orcid: None,
1647            })
1648            .collect()
1649    };
1650    let provenance = Provenance {
1651        source_type: options.source_type.clone(),
1652        doi: options.doi.clone(),
1653        pmid: options.pmid.clone(),
1654        pmc: None,
1655        openalex_id: None,
1656        url: options.url.clone(),
1657        title: options.source.clone(),
1658        authors: source_authors,
1659        year: options.year,
1660        journal: options.journal.clone(),
1661        license: None,
1662        publisher: None,
1663        funders: Vec::new(),
1664        extraction: Extraction {
1665            method: "manual_curation".to_string(),
1666            model: None,
1667            model_version: None,
1668            extracted_at: now,
1669            extractor_version: project::VELA_COMPILER_VERSION.to_string(),
1670        },
1671        review: Some(Review {
1672            reviewed: false,
1673            reviewer: None,
1674            reviewed_at: None,
1675            corrections: Vec::new(),
1676        }),
1677        citation_count: None,
1678    };
1679    let flags = Flags {
1680        gap: options.gap,
1681        negative_space: options.negative_space,
1682        ..Default::default()
1683    };
1684    FindingBundle::new(
1685        assertion, evidence, conditions, confidence, provenance, flags,
1686    )
1687}
1688
1689/// v0.14: build the proposal that supersedes `old_id` with a new finding bundle.
1690pub fn supersede_finding(
1691    path: &Path,
1692    old_id: &str,
1693    reason: &str,
1694    options: FindingDraftOptions,
1695    apply: bool,
1696) -> Result<StateCommandReport, String> {
1697    validate_score(options.confidence)?;
1698    if reason.trim().is_empty() {
1699        return Err("--reason is required for finding supersede".to_string());
1700    }
1701    let new_finding = build_finding_bundle(&options);
1702    if new_finding.id == old_id {
1703        return Err(
1704            "supersede new assertion must produce a different content address than the old finding (change assertion text, type, or provenance to derive a distinct vf_…)"
1705                .to_string(),
1706        );
1707    }
1708    let proposal = proposals::new_proposal(
1709        "finding.supersede",
1710        events::StateTarget {
1711            r#type: "finding".to_string(),
1712            id: old_id.to_string(),
1713        },
1714        options.author.clone(),
1715        "human",
1716        reason.to_string(),
1717        json!({"new_finding": new_finding}),
1718        Vec::new(),
1719        Vec::new(),
1720    );
1721    let result = proposals::create_or_apply(path, proposal, apply)?;
1722    let frontier = repo::load_from_path(path)?;
1723    Ok(StateCommandReport {
1724        ok: true,
1725        command: "finding.supersede".to_string(),
1726        frontier: frontier.project.name,
1727        finding_id: result.finding_id,
1728        proposal_id: result.proposal_id,
1729        proposal_status: result.status.clone(),
1730        applied_event_id: result.applied_event_id,
1731        wrote_to: path.display().to_string(),
1732        message: if result.status == "applied" {
1733            "Supersede proposal applied".to_string()
1734        } else {
1735            "Supersede proposal recorded".to_string()
1736        },
1737    })
1738}
1739
1740fn build_add_finding_proposal(options: FindingDraftOptions) -> Result<StateProposal, String> {
1741    let now = Utc::now().to_rfc3339();
1742    let assertion = Assertion {
1743        text: options.text.clone(),
1744        assertion_type: options.assertion_type.clone(),
1745        entities: options
1746            .entities
1747            .iter()
1748            .map(|(name, entity_type)| Entity {
1749                name: name.clone(),
1750                entity_type: entity_type.clone(),
1751                identifiers: serde_json::Map::new(),
1752                canonical_id: None,
1753                candidates: Vec::new(),
1754                aliases: Vec::new(),
1755                resolution_provenance: Some("manual_state_transition".to_string()),
1756                resolution_confidence: if options.entities_reviewed { 0.95 } else { 0.6 },
1757                resolution_method: if options.entities_reviewed {
1758                    Some(ResolutionMethod::Manual)
1759                } else {
1760                    None
1761                },
1762                species_context: None,
1763                needs_review: !options.entities_reviewed,
1764            })
1765            .collect(),
1766        relation: None,
1767        direction: None,
1768        causal_claim: None,
1769        causal_evidence_grade: None,
1770    };
1771    let evidence = Evidence {
1772        evidence_type: options.evidence_type.clone(),
1773        model_system: String::new(),
1774        species: options
1775            .species
1776            .first()
1777            .cloned()
1778            .or_else(|| options.human_data.then(|| "Homo sapiens".to_string())),
1779        method: if options.clinical_trial {
1780            "manual state transition; placebo-controlled clinical trial where source reports control arm"
1781                .to_string()
1782        } else if options.evidence_type == "experimental" {
1783            "manual state transition; control details require source inspection".to_string()
1784        } else {
1785            "manual state transition".to_string()
1786        },
1787        sample_size: None,
1788        effect_size: None,
1789        p_value: None,
1790        replicated: false,
1791        replication_count: None,
1792        evidence_spans: options.evidence_spans.clone(),
1793    };
1794    // v0.11: conditions text falls back to the v0.10 placeholder only when
1795    // the caller didn't supply --conditions-text. The placeholder is a
1796    // signal to a reviewer that scope needs to be added; once a real
1797    // conditions string is provided, the placeholder isn't useful.
1798    let conditions = Conditions {
1799        text: options.conditions_text.clone().unwrap_or_else(|| {
1800            "Manually added finding; requires evidence review before scientific use.".to_string()
1801        }),
1802        species_verified: options.species.clone(),
1803        species_unverified: Vec::new(),
1804        in_vitro: options.in_vitro,
1805        in_vivo: options.in_vivo,
1806        human_data: options.human_data,
1807        clinical_trial: options.clinical_trial,
1808        concentration_range: None,
1809        duration: None,
1810        age_group: None,
1811        cell_type: None,
1812    };
1813    let confidence = Confidence {
1814        kind: ConfidenceKind::FrontierEpistemic,
1815        score: options.confidence,
1816        basis: "operator-supplied frontier prior; review required".to_string(),
1817        method: ConfidenceMethod::ExpertJudgment,
1818        components: None,
1819        extraction_confidence: 1.0,
1820    };
1821    // v0.11: structured provenance. Source authors (the paper's authors)
1822    // are distinct from the Vela actor that curated the finding. When
1823    // --authors is omitted, fall back to the curator-as-author shape used
1824    // pre-v0.11 so existing scripts keep working.
1825    let source_authors = if options.source_authors.is_empty() {
1826        vec![Author {
1827            name: options.author.clone(),
1828            orcid: None,
1829        }]
1830    } else {
1831        options
1832            .source_authors
1833            .iter()
1834            .map(|name| Author {
1835                name: name.clone(),
1836                orcid: None,
1837            })
1838            .collect()
1839    };
1840    let provenance = Provenance {
1841        source_type: options.source_type.clone(),
1842        doi: options.doi.clone(),
1843        pmid: options.pmid.clone(),
1844        pmc: None,
1845        openalex_id: None,
1846        url: options.url.clone(),
1847        title: options.source.clone(),
1848        authors: source_authors,
1849        year: options.year,
1850        journal: options.journal.clone(),
1851        license: None,
1852        publisher: None,
1853        funders: Vec::new(),
1854        extraction: Extraction {
1855            method: "manual_curation".to_string(),
1856            model: None,
1857            model_version: None,
1858            extracted_at: now.clone(),
1859            extractor_version: project::VELA_COMPILER_VERSION.to_string(),
1860        },
1861        review: Some(Review {
1862            reviewed: false,
1863            reviewer: None,
1864            reviewed_at: None,
1865            corrections: Vec::new(),
1866        }),
1867        citation_count: None,
1868    };
1869    let flags = Flags {
1870        gap: options.gap,
1871        negative_space: options.negative_space,
1872        ..Default::default()
1873    };
1874    let finding = FindingBundle::new(
1875        assertion, evidence, conditions, confidence, provenance, flags,
1876    );
1877    let finding_id = finding.id.clone();
1878    Ok(proposals::new_proposal(
1879        "finding.add",
1880        events::StateTarget {
1881            r#type: "finding".to_string(),
1882            id: finding_id,
1883        },
1884        options.author,
1885        "human",
1886        "Manual finding added to frontier state",
1887        json!({"finding": finding}),
1888        Vec::new(),
1889        vec!["Manual findings require evidence review before scientific use.".to_string()],
1890    ))
1891}
1892
1893fn find_finding_index(frontier: &Project, finding_id: &str) -> Result<usize, String> {
1894    frontier
1895        .findings
1896        .iter()
1897        .position(|finding| finding.id == finding_id)
1898        .ok_or_else(|| format!("Finding not found: {finding_id}"))
1899}
1900
1901fn confidence_update_id(update: &crate::bundle::ConfidenceUpdate) -> String {
1902    let hash = Sha256::digest(
1903        format!(
1904            "{}|{}|{}|{}|{}",
1905            update.finding_id,
1906            update.previous_score,
1907            update.new_score,
1908            update.updated_by,
1909            update.updated_at
1910        )
1911        .as_bytes(),
1912    );
1913    format!("cu_{}", &hex::encode(hash)[..16])
1914}
1915
1916fn validate_score(score: f64) -> Result<(), String> {
1917    if (0.0..=1.0).contains(&score) {
1918        Ok(())
1919    } else {
1920        Err("--confidence must be between 0.0 and 1.0".to_string())
1921    }
1922}
1923
1924#[cfg(test)]
1925mod v0_11_finding_tests {
1926    use super::*;
1927    use crate::bundle;
1928
1929    fn base_options() -> FindingDraftOptions {
1930        FindingDraftOptions {
1931            text: "Test claim".to_string(),
1932            assertion_type: "mechanism".to_string(),
1933            source: "Test 2024".to_string(),
1934            source_type: "published_paper".to_string(),
1935            author: "reviewer:test".to_string(),
1936            confidence: 0.5,
1937            evidence_type: "experimental".to_string(),
1938            entities: Vec::new(),
1939            doi: None,
1940            pmid: None,
1941            year: None,
1942            journal: None,
1943            url: None,
1944            source_authors: Vec::new(),
1945            conditions_text: None,
1946            species: Vec::new(),
1947            in_vivo: false,
1948            in_vitro: false,
1949            human_data: false,
1950            clinical_trial: false,
1951            entities_reviewed: false,
1952            evidence_spans: Vec::new(),
1953            gap: false,
1954            negative_space: false,
1955        }
1956    }
1957
1958    #[test]
1959    fn provenance_flags_populate_structured_fields() {
1960        let mut opts = base_options();
1961        opts.doi = Some("10.1056/NEJMoa2212948".to_string());
1962        opts.pmid = Some("36449413".to_string());
1963        opts.year = Some(2023);
1964        opts.journal = Some("NEJM".to_string());
1965        opts.url = Some("https://nejm.org/...".to_string());
1966        opts.source_authors = vec!["van Dyck CH".to_string(), "Swanson CJ".to_string()];
1967        let proposal = build_add_finding_proposal(opts).unwrap();
1968        let finding: bundle::FindingBundle =
1969            serde_json::from_value(proposal.payload["finding"].clone()).unwrap();
1970        assert_eq!(
1971            finding.provenance.doi.as_deref(),
1972            Some("10.1056/NEJMoa2212948")
1973        );
1974        assert_eq!(finding.provenance.pmid.as_deref(), Some("36449413"));
1975        assert_eq!(finding.provenance.year, Some(2023));
1976        assert_eq!(finding.provenance.journal.as_deref(), Some("NEJM"));
1977        assert_eq!(
1978            finding.provenance.url.as_deref(),
1979            Some("https://nejm.org/...")
1980        );
1981        assert_eq!(
1982            finding
1983                .provenance
1984                .authors
1985                .iter()
1986                .map(|a| a.name.as_str())
1987                .collect::<Vec<_>>(),
1988            vec!["van Dyck CH", "Swanson CJ"],
1989        );
1990    }
1991
1992    #[test]
1993    fn conditions_flags_populate_structured_fields() {
1994        let mut opts = base_options();
1995        opts.conditions_text = Some("Phase 3 RCT, 18 mo".to_string());
1996        opts.species = vec!["Homo sapiens".to_string()];
1997        opts.in_vivo = true;
1998        opts.human_data = true;
1999        opts.clinical_trial = true;
2000        let proposal = build_add_finding_proposal(opts).unwrap();
2001        let finding: bundle::FindingBundle =
2002            serde_json::from_value(proposal.payload["finding"].clone()).unwrap();
2003        assert_eq!(finding.conditions.text, "Phase 3 RCT, 18 mo");
2004        assert_eq!(
2005            finding.conditions.species_verified,
2006            vec!["Homo sapiens".to_string()]
2007        );
2008        assert!(finding.conditions.in_vivo);
2009        assert!(finding.conditions.human_data);
2010        assert!(finding.conditions.clinical_trial);
2011    }
2012
2013    #[test]
2014    fn reviewed_entities_spans_and_gap_flags_populate_structured_fields() {
2015        let mut opts = base_options();
2016        opts.entities = vec![("lecanemab".to_string(), "drug".to_string())];
2017        opts.entities_reviewed = true;
2018        opts.evidence_spans = vec![json!({
2019            "section": "abstract",
2020            "text": "Lecanemab slowed decline under early symptomatic AD trial conditions."
2021        })];
2022        opts.gap = true;
2023        opts.negative_space = true;
2024
2025        let proposal = build_add_finding_proposal(opts).unwrap();
2026        let finding: bundle::FindingBundle =
2027            serde_json::from_value(proposal.payload["finding"].clone()).unwrap();
2028
2029        assert_eq!(finding.assertion.entities.len(), 1);
2030        assert!(!finding.assertion.entities[0].needs_review);
2031        assert_eq!(
2032            finding.assertion.entities[0].resolution_method,
2033            Some(bundle::ResolutionMethod::Manual)
2034        );
2035        assert_eq!(finding.evidence.evidence_spans.len(), 1);
2036        assert_eq!(
2037            finding.evidence.evidence_spans[0]["section"].as_str(),
2038            Some("abstract")
2039        );
2040        assert!(finding.flags.gap);
2041        assert!(finding.flags.negative_space);
2042    }
2043
2044    #[test]
2045    fn omitted_flags_fall_back_to_pre_v011_shape() {
2046        let proposal = build_add_finding_proposal(base_options()).unwrap();
2047        let finding: bundle::FindingBundle =
2048            serde_json::from_value(proposal.payload["finding"].clone()).unwrap();
2049        // Pre-v0.11 placeholder remains when --conditions-text is omitted.
2050        assert!(
2051            finding
2052                .conditions
2053                .text
2054                .starts_with("Manually added finding")
2055        );
2056        // No --source-authors → curator fills the authors slot, as in v0.10.
2057        assert_eq!(finding.provenance.authors.len(), 1);
2058        assert_eq!(finding.provenance.authors[0].name, "reviewer:test");
2059        // None of the new optional provenance fields populated.
2060        assert!(finding.provenance.doi.is_none());
2061        assert!(finding.provenance.year.is_none());
2062        assert!(finding.provenance.url.is_none());
2063    }
2064}
2065
2066#[cfg(test)]
2067mod v0_38_causal_tests {
2068    use super::*;
2069    use crate::bundle::{CausalClaim, CausalEvidenceGrade};
2070    use tempfile::tempdir;
2071
2072    fn seed_frontier(dir: &Path) -> std::path::PathBuf {
2073        let path = dir.join("frontier.json");
2074        let opts = FindingDraftOptions {
2075            text: "X causes Y".to_string(),
2076            assertion_type: "mechanism".to_string(),
2077            source: "test".to_string(),
2078            source_type: "published_paper".to_string(),
2079            author: "reviewer:test".to_string(),
2080            confidence: 0.5,
2081            evidence_type: "experimental".to_string(),
2082            entities: Vec::new(),
2083            doi: None,
2084            pmid: None,
2085            year: Some(2025),
2086            journal: None,
2087            url: None,
2088            source_authors: Vec::new(),
2089            conditions_text: None,
2090            species: Vec::new(),
2091            in_vivo: false,
2092            in_vitro: false,
2093            human_data: false,
2094            clinical_trial: false,
2095            entities_reviewed: false,
2096            evidence_spans: Vec::new(),
2097            gap: false,
2098            negative_space: false,
2099        };
2100        let proposal = build_add_finding_proposal(opts).unwrap();
2101        let finding: FindingBundle =
2102            serde_json::from_value(proposal.payload["finding"].clone()).unwrap();
2103        let project = project::assemble("Test", vec![finding], 1, 0, "test causal frontier");
2104        repo::save_to_path(&path, &project).unwrap();
2105        path
2106    }
2107
2108    #[test]
2109    fn set_causal_writes_fields_and_appends_event() {
2110        let dir = tempdir().unwrap();
2111        let path = seed_frontier(dir.path());
2112        let project = repo::load_from_path(&path).unwrap();
2113        let finding_id = project.findings[0].id.clone();
2114
2115        let report = set_causal(
2116            &path,
2117            &finding_id,
2118            "intervention",
2119            Some("rct"),
2120            "reviewer:test",
2121            "phase 3 RCT supports do(X=x) reading",
2122        )
2123        .unwrap();
2124        assert!(report.applied_event_id.is_some());
2125
2126        let after = repo::load_from_path(&path).unwrap();
2127        let f = &after.findings[0];
2128        assert_eq!(f.assertion.causal_claim, Some(CausalClaim::Intervention));
2129        assert_eq!(
2130            f.assertion.causal_evidence_grade,
2131            Some(CausalEvidenceGrade::Rct)
2132        );
2133
2134        let last_event = after.events.last().expect("an event was appended");
2135        assert_eq!(last_event.kind, "assertion.reinterpreted_causal");
2136        assert_eq!(last_event.target.id, finding_id);
2137        assert_eq!(last_event.payload["after"]["claim"], "intervention");
2138        assert_eq!(last_event.payload["after"]["grade"], "rct");
2139    }
2140
2141    #[test]
2142    fn set_causal_rejects_invalid_claim() {
2143        let dir = tempdir().unwrap();
2144        let path = seed_frontier(dir.path());
2145        let project = repo::load_from_path(&path).unwrap();
2146        let finding_id = project.findings[0].id.clone();
2147        let err =
2148            set_causal(&path, &finding_id, "magic", None, "reviewer:test", "test").unwrap_err();
2149        assert!(err.contains("invalid causal claim"));
2150    }
2151
2152    #[test]
2153    fn set_causal_preserves_grade_when_only_claim_changes() {
2154        let dir = tempdir().unwrap();
2155        let path = seed_frontier(dir.path());
2156        let project = repo::load_from_path(&path).unwrap();
2157        let finding_id = project.findings[0].id.clone();
2158
2159        // First set both.
2160        set_causal(
2161            &path,
2162            &finding_id,
2163            "correlation",
2164            Some("observational"),
2165            "reviewer:test",
2166            "initial reading",
2167        )
2168        .unwrap();
2169        // Then revise just the claim. Grade should persist.
2170        set_causal(
2171            &path,
2172            &finding_id,
2173            "mediation",
2174            None,
2175            "reviewer:test",
2176            "refined reading",
2177        )
2178        .unwrap();
2179        let after = repo::load_from_path(&path).unwrap();
2180        let f = &after.findings[0];
2181        assert_eq!(f.assertion.causal_claim, Some(CausalClaim::Mediation));
2182        assert_eq!(
2183            f.assertion.causal_evidence_grade,
2184            Some(CausalEvidenceGrade::Observational)
2185        );
2186        // Two events appended (one per call).
2187        let causal_events: usize = after
2188            .events
2189            .iter()
2190            .filter(|e| e.kind == "assertion.reinterpreted_causal")
2191            .count();
2192        assert_eq!(causal_events, 2);
2193    }
2194}