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.80.1: Per-event attestation. Emit an
409/// `attestation.recorded` canonical event pointing at a target
410/// `vev_*` id, recording who attested it, the scope, and an
411/// optional Carina Proof primitive id + Ed25519 signature.
412/// Append-only: re-attesting the same target event by the same
413/// attester writes a new attestation event (each carries a
414/// unique id).
415pub fn record_attestation(
416    path: &Path,
417    target_event_id: &str,
418    attester_id: &str,
419    scope_note: &str,
420    proof_id: Option<&str>,
421    signature: Option<&str>,
422) -> Result<String, String> {
423    if !target_event_id.starts_with("vev_") {
424        return Err(format!(
425            "target_event_id must start with 'vev_', got '{target_event_id}'"
426        ));
427    }
428    if attester_id.trim().is_empty() {
429        return Err("attester_id must be non-empty".to_string());
430    }
431    if scope_note.trim().is_empty() {
432        return Err("scope_note must be non-empty".to_string());
433    }
434    if let Some(p) = proof_id
435        && !p.starts_with("vpf_")
436    {
437        return Err(format!(
438            "proof_id must start with 'vpf_' when present, got '{p}'"
439        ));
440    }
441    let mut frontier = repo::load_from_path(path)?;
442    // Verify target event exists (defensive; replay-only validators
443    // don't enforce existence but the emission path should).
444    if !frontier.events.iter().any(|e| e.id == target_event_id) {
445        return Err(format!(
446            "target event '{target_event_id}' not found in frontier"
447        ));
448    }
449    let mut payload = json!({
450        "target_event_id": target_event_id,
451        "attester_id": attester_id,
452        "scope_note": scope_note,
453        "signed_at": chrono::Utc::now().to_rfc3339(),
454    });
455    if let Some(p) = proof_id {
456        payload["proof_id"] = json!(p);
457    }
458    if let Some(s) = signature {
459        payload["signature"] = json!(s);
460    }
461    let actor_type = if attester_id.starts_with("agent:") {
462        "agent"
463    } else {
464        "human"
465    };
466    let mut event = events::StateEvent {
467        schema: events::EVENT_SCHEMA.to_string(),
468        id: String::new(),
469        kind: "attestation.recorded".to_string(),
470        target: events::StateTarget {
471            r#type: "event".to_string(),
472            id: target_event_id.to_string(),
473        },
474        actor: events::StateActor {
475            id: attester_id.to_string(),
476            r#type: actor_type.to_string(),
477        },
478        timestamp: chrono::Utc::now().to_rfc3339(),
479        reason: scope_note.to_string(),
480        before_hash: events::NULL_HASH.to_string(),
481        after_hash: events::NULL_HASH.to_string(),
482        payload,
483        caveats: Vec::new(),
484        signature: None,
485        schema_artifact_id: None,
486    };
487    event.id = events::compute_event_id(&event);
488    let event_id = event.id.clone();
489    frontier.events.push(event);
490    repo::save_to_path(path, &frontier)?;
491    Ok(event_id)
492}
493
494/// v0.79: Append a new entity tag to an existing finding. Lands as
495/// a signed `finding.entity_added` event. Idempotent on
496/// `(finding_id, entity_name)`: re-applying with the same name +
497/// type is a no-op so federation re-sync stays clean. Closes the
498/// v0.78.4 honest gap that forced reviewers to append new findings
499/// just to add a tag.
500#[allow(clippy::too_many_arguments)]
501pub fn add_finding_entity(
502    path: &Path,
503    finding_id: &str,
504    entity_name: &str,
505    entity_type: &str,
506    reviewer: &str,
507    reason: &str,
508    apply: bool,
509) -> Result<StateCommandReport, String> {
510    const VALID_ENTITY_TYPES: &[&str] = &[
511        "gene",
512        "protein",
513        "compound",
514        "disease",
515        "cell_type",
516        "organism",
517        "pathway",
518        "assay",
519        "anatomical_structure",
520        "particle",
521        "instrument",
522        "dataset",
523        "quantity",
524        "other",
525    ];
526    if !VALID_ENTITY_TYPES.contains(&entity_type) {
527        return Err(format!(
528            "--entity-type must be one of {VALID_ENTITY_TYPES:?}, got {entity_type:?}"
529        ));
530    }
531    let frontier_view = repo::load_from_path(path)?;
532    let _ = frontier_view
533        .findings
534        .iter()
535        .find(|f| f.id == finding_id)
536        .ok_or_else(|| format!("Finding not found: {finding_id}"))?;
537    let payload = json!({
538        "entity_name": entity_name,
539        "entity_type": entity_type,
540        "reason": reason,
541    });
542    let proposal = proposals::new_proposal(
543        "finding.entity_add",
544        events::StateTarget {
545            r#type: "finding".to_string(),
546            id: finding_id.to_string(),
547        },
548        reviewer,
549        "human",
550        reason,
551        payload,
552        Vec::new(),
553        Vec::new(),
554    );
555    let result = proposals::create_or_apply(path, proposal, apply)?;
556    Ok(StateCommandReport {
557        ok: true,
558        command: "entity-add".to_string(),
559        frontier: frontier_view.project.name,
560        finding_id: finding_id.to_string(),
561        proposal_id: result.proposal_id,
562        proposal_status: result.status,
563        applied_event_id: result.applied_event_id,
564        wrote_to: path.display().to_string(),
565        message: if apply {
566            "Entity-add proposal applied".to_string()
567        } else {
568            "Entity-add proposal recorded".to_string()
569        },
570    })
571}
572
573/// v0.57: Mechanically repair a missing evidence-span on a finding by
574/// appending a `{section, text}` span. The proposal lands as a
575/// `finding.span_repair` and the canonical event as
576/// `finding.span_repaired`.
577pub fn repair_finding_span(
578    path: &Path,
579    finding_id: &str,
580    section: &str,
581    text: &str,
582    reviewer: &str,
583    reason: &str,
584    apply: bool,
585) -> Result<StateCommandReport, String> {
586    let frontier_view = repo::load_from_path(path)?;
587    let _ = frontier_view
588        .findings
589        .iter()
590        .find(|f| f.id == finding_id)
591        .ok_or_else(|| format!("Finding not found: {finding_id}"))?;
592    let trimmed_section = section.trim();
593    let trimmed_text = text.trim();
594    if trimmed_section.is_empty() {
595        return Err("--section must be non-empty".to_string());
596    }
597    if trimmed_text.is_empty() {
598        return Err("--text must be non-empty".to_string());
599    }
600    let proposal = proposals::new_proposal(
601        "finding.span_repair",
602        events::StateTarget {
603            r#type: "finding".to_string(),
604            id: finding_id.to_string(),
605        },
606        reviewer,
607        "human",
608        reason,
609        json!({
610            "section": trimmed_section,
611            "text": trimmed_text,
612        }),
613        Vec::new(),
614        Vec::new(),
615    );
616    let result = proposals::create_or_apply(path, proposal, apply)?;
617    Ok(StateCommandReport {
618        ok: true,
619        command: "span-repair".to_string(),
620        frontier: frontier_view.project.name,
621        finding_id: finding_id.to_string(),
622        proposal_id: result.proposal_id,
623        proposal_status: result.status,
624        applied_event_id: result.applied_event_id,
625        wrote_to: path.display().to_string(),
626        message: if apply {
627            "Span repair applied".to_string()
628        } else {
629            "Span repair proposal recorded".to_string()
630        },
631    })
632}
633
634/// v0.56: Mechanically repair a missing evidence-atom locator by
635/// copying the locator from the parent source record. If `locator` is
636/// `None` the resolver pulls the value from `frontier.sources` for the
637/// atom's parent. The proposal carries both the resolved locator and
638/// the source id it was derived from so a fresh replay reconstructs
639/// the derivation without re-resolving.
640pub fn repair_evidence_atom_locator(
641    path: &Path,
642    atom_id: &str,
643    locator_override: Option<&str>,
644    reviewer: &str,
645    reason: &str,
646    apply: bool,
647) -> Result<StateCommandReport, String> {
648    let frontier_view = repo::load_from_path(path)?;
649    let atom = frontier_view
650        .evidence_atoms
651        .iter()
652        .find(|atom| atom.id == atom_id)
653        .ok_or_else(|| format!("Evidence atom not found: {atom_id}"))?;
654    if let Some(existing) = &atom.locator {
655        return Err(format!(
656            "Evidence atom {atom_id} already carries locator '{existing}'"
657        ));
658    }
659    let source_id = atom.source_id.clone();
660    let locator = match locator_override {
661        Some(value) => {
662            let trimmed = value.trim();
663            if trimmed.is_empty() {
664                return Err("--locator value must be non-empty".to_string());
665            }
666            trimmed.to_string()
667        }
668        None => {
669            let source = frontier_view
670                .sources
671                .iter()
672                .find(|source| source.id == source_id)
673                .ok_or_else(|| {
674                    format!(
675                        "Cannot resolve locator for atom {atom_id}: parent source {source_id} not in frontier"
676                    )
677                })?;
678            let trimmed = source.locator.trim();
679            if trimmed.is_empty() {
680                return Err(format!(
681                    "Cannot resolve locator for atom {atom_id}: parent source {source_id} has an empty locator"
682                ));
683            }
684            trimmed.to_string()
685        }
686    };
687    let proposal = proposals::new_proposal(
688        "evidence_atom.locator_repair",
689        events::StateTarget {
690            r#type: "evidence_atom".to_string(),
691            id: atom_id.to_string(),
692        },
693        reviewer,
694        "human",
695        reason,
696        json!({
697            "locator": locator,
698            "source_id": source_id,
699        }),
700        Vec::new(),
701        Vec::new(),
702    );
703    let result = proposals::create_or_apply(path, proposal, apply)?;
704    Ok(StateCommandReport {
705        ok: true,
706        command: "locator-repair".to_string(),
707        frontier: frontier_view.project.name,
708        finding_id: atom_id.to_string(),
709        proposal_id: result.proposal_id,
710        proposal_status: result.status,
711        applied_event_id: result.applied_event_id,
712        wrote_to: path.display().to_string(),
713        message: if apply {
714            "Locator repair applied".to_string()
715        } else {
716            "Locator repair proposal recorded".to_string()
717        },
718    })
719}
720
721/// v0.59: record a reviewer's verdict on a previously detected
722/// federation conflict. Pairs with the existing
723/// `frontier.conflict_detected` event by `conflict_event_id`. The
724/// conflict event itself is not modified; this helper appends a
725/// new `frontier.conflict_resolved` canonical event to the log.
726pub fn resolve_frontier_conflict(
727    path: &Path,
728    conflict_event_id: &str,
729    resolution_note: &str,
730    reviewer: &str,
731    winning_proposal_id: Option<&str>,
732    apply: bool,
733) -> Result<StateCommandReport, String> {
734    let frontier_view = repo::load_from_path(path)?;
735    let frontier_id = frontier_view.frontier_id();
736    let mut payload = json!({
737        "conflict_event_id": conflict_event_id,
738        "resolution_note": resolution_note,
739    });
740    if let Some(wpid) = winning_proposal_id {
741        payload["winning_proposal_id"] = json!(wpid);
742    }
743    let proposal = proposals::new_proposal(
744        "frontier.conflict_resolve",
745        events::StateTarget {
746            r#type: "frontier_observation".to_string(),
747            id: frontier_id,
748        },
749        reviewer,
750        "human",
751        format!("Conflict resolution: {resolution_note}"),
752        payload,
753        Vec::new(),
754        Vec::new(),
755    );
756    let result = proposals::create_or_apply(path, proposal, apply)?;
757    Ok(StateCommandReport {
758        ok: true,
759        command: "conflict-resolve".to_string(),
760        frontier: frontier_view.project.name,
761        finding_id: conflict_event_id.to_string(),
762        proposal_id: result.proposal_id,
763        proposal_status: result.status,
764        applied_event_id: result.applied_event_id,
765        wrote_to: path.display().to_string(),
766        message: if apply {
767            "Conflict resolution applied".to_string()
768        } else {
769            "Conflict resolution proposal recorded".to_string()
770        },
771    })
772}
773
774/// v0.70: deposit a Replication record onto the frontier as a
775/// signed canonical `replication.deposited` event. Idempotent under
776/// re-application: if the `vrep_*` id already exists on the
777/// frontier, the helper refuses with a clear error rather than
778/// silently no-op'ing. The event is appended to the canonical event
779/// log; the reducer arm projects it onto `Project.replications` on
780/// subsequent loads.
781pub fn deposit_replication(
782    path: &Path,
783    rep: crate::bundle::Replication,
784    actor_id: &str,
785    reason: &str,
786) -> Result<events::StateEvent, String> {
787    let mut project = repo::load_from_path(path)?;
788    if project.replications.iter().any(|r| r.id == rep.id) {
789        return Err(format!(
790            "Replication {} already exists on this frontier; refusing duplicate deposit",
791            rep.id
792        ));
793    }
794    let rep_value =
795        serde_json::to_value(&rep).map_err(|e| format!("serialize replication: {e}"))?;
796    let payload = json!({ "replication": rep_value });
797    let timestamp = Utc::now().to_rfc3339();
798    let mut event = events::StateEvent {
799        schema: events::EVENT_SCHEMA.to_string(),
800        id: String::new(),
801        kind: "replication.deposited".to_string(),
802        target: events::StateTarget {
803            r#type: "finding".to_string(),
804            id: rep.target_finding.clone(),
805        },
806        actor: events::StateActor {
807            id: actor_id.to_string(),
808            r#type: "human".to_string(),
809        },
810        timestamp,
811        reason: reason.to_string(),
812        before_hash: NULL_HASH.to_string(),
813        after_hash: NULL_HASH.to_string(),
814        payload,
815        caveats: Vec::new(),
816        signature: None,
817        schema_artifact_id: None,
818    };
819    event.id = events::compute_event_id(&event);
820    project.replications.push(rep);
821    project.events.push(event.clone());
822    repo::save_to_path(path, &project)?;
823    Ok(event)
824}
825
826/// v0.70: deposit a Prediction record onto the frontier as a
827/// signed canonical `prediction.deposited` event. Mirror of
828/// `deposit_replication` for the Prediction primitive.
829pub fn deposit_prediction(
830    path: &Path,
831    pred: crate::bundle::Prediction,
832    actor_id: &str,
833    reason: &str,
834) -> Result<events::StateEvent, String> {
835    let mut project = repo::load_from_path(path)?;
836    if project.predictions.iter().any(|p| p.id == pred.id) {
837        return Err(format!(
838            "Prediction {} already exists on this frontier; refusing duplicate deposit",
839            pred.id
840        ));
841    }
842    let pred_value =
843        serde_json::to_value(&pred).map_err(|e| format!("serialize prediction: {e}"))?;
844    let payload = json!({ "prediction": pred_value });
845    let timestamp = Utc::now().to_rfc3339();
846    let mut event = events::StateEvent {
847        schema: events::EVENT_SCHEMA.to_string(),
848        id: String::new(),
849        kind: "prediction.deposited".to_string(),
850        target: events::StateTarget {
851            r#type: "finding".to_string(),
852            id: pred.target_findings.first().cloned().unwrap_or_default(),
853        },
854        actor: events::StateActor {
855            id: actor_id.to_string(),
856            r#type: "human".to_string(),
857        },
858        timestamp,
859        reason: reason.to_string(),
860        before_hash: NULL_HASH.to_string(),
861        after_hash: NULL_HASH.to_string(),
862        payload,
863        caveats: Vec::new(),
864        signature: None,
865        schema_artifact_id: None,
866    };
867    event.id = events::compute_event_id(&event);
868    project.predictions.push(pred);
869    project.events.push(event.clone());
870    repo::save_to_path(path, &project)?;
871    Ok(event)
872}
873
874pub fn retract_finding(
875    path: &Path,
876    finding_id: &str,
877    reviewer: &str,
878    reason: &str,
879    apply: bool,
880) -> Result<StateCommandReport, String> {
881    let frontier = repo::load_from_path(path)?;
882    find_finding_index(&frontier, finding_id)?;
883    let proposal = proposals::new_proposal(
884        "finding.retract",
885        events::StateTarget {
886            r#type: "finding".to_string(),
887            id: finding_id.to_string(),
888        },
889        reviewer,
890        "human",
891        reason,
892        json!({}),
893        Vec::new(),
894        vec!["Retraction impact is simulated over declared dependency links.".to_string()],
895    );
896    let result = proposals::create_or_apply(path, proposal, apply)?;
897    Ok(StateCommandReport {
898        ok: true,
899        command: "retract".to_string(),
900        frontier: frontier.project.name,
901        finding_id: result.finding_id,
902        proposal_id: result.proposal_id,
903        proposal_status: result.status,
904        applied_event_id: result.applied_event_id,
905        wrote_to: path.display().to_string(),
906        message: if apply {
907            "Retraction proposal applied".to_string()
908        } else {
909            "Retraction proposal recorded".to_string()
910        },
911    })
912}
913
914/// v0.38: Set or revise a finding's `causal_claim` and (optionally)
915/// `causal_evidence_grade`. Appends an `assertion.reinterpreted_causal`
916/// event capturing the prior reading, the new reading, and the actor.
917/// Bypasses the proposal flow because (a) the mutation is local and
918/// reversible by another call, and (b) the schema layer ships ahead of
919/// the reasoning surface — the next milestone will route this through
920/// proposals once the do-calculus layer needs it.
921pub fn set_causal(
922    path: &Path,
923    finding_id: &str,
924    new_claim: &str,
925    new_grade: Option<&str>,
926    actor: &str,
927    reason: &str,
928) -> Result<StateCommandReport, String> {
929    use crate::bundle::{CausalClaim, CausalEvidenceGrade};
930
931    let mut frontier: Project = repo::load_from_path(path)?;
932    let idx = frontier
933        .findings
934        .iter()
935        .position(|f| f.id == finding_id)
936        .ok_or_else(|| format!("Finding not found: {finding_id}"))?;
937
938    // Capture the prior reading for the event payload.
939    let before = json!({
940        "claim": frontier.findings[idx].assertion.causal_claim,
941        "grade": frontier.findings[idx].assertion.causal_evidence_grade,
942    });
943
944    let parsed_claim = match new_claim {
945        "correlation" => CausalClaim::Correlation,
946        "mediation" => CausalClaim::Mediation,
947        "intervention" => CausalClaim::Intervention,
948        other => return Err(format!("invalid causal claim '{other}'")),
949    };
950    let parsed_grade = match new_grade {
951        None => None,
952        Some("rct") => Some(CausalEvidenceGrade::Rct),
953        Some("quasi_experimental") => Some(CausalEvidenceGrade::QuasiExperimental),
954        Some("observational") => Some(CausalEvidenceGrade::Observational),
955        Some("theoretical") => Some(CausalEvidenceGrade::Theoretical),
956        Some(other) => return Err(format!("invalid causal evidence grade '{other}'")),
957    };
958
959    let before_hash = events::finding_hash(&frontier.findings[idx]);
960    frontier.findings[idx].assertion.causal_claim = Some(parsed_claim);
961    if let Some(g) = parsed_grade {
962        frontier.findings[idx].assertion.causal_evidence_grade = Some(g);
963    }
964    let after_hash = events::finding_hash(&frontier.findings[idx]);
965
966    let after = json!({
967        "claim": new_claim,
968        "grade": new_grade,
969    });
970
971    // Synthesize a deterministic proposal_id over the mutation.
972    let proposal_id = format!(
973        "vpr_{}",
974        &hex::encode(Sha256::digest(
975            format!(
976                "{finding_id}|{actor}|{before_hash}|{after_hash}|{}",
977                Utc::now().to_rfc3339()
978            )
979            .as_bytes()
980        ))[..16]
981    );
982
983    let event = events::new_finding_event(events::FindingEventInput {
984        kind: "assertion.reinterpreted_causal",
985        finding_id,
986        actor_id: actor,
987        actor_type: "human",
988        reason,
989        before_hash: &before_hash,
990        after_hash: &after_hash,
991        payload: json!({
992            "proposal_id": proposal_id,
993            "before": before,
994            "after": after,
995        }),
996        caveats: Vec::new(),
997    });
998    let event_id = event.id.clone();
999    frontier.events.push(event);
1000
1001    repo::save_to_path(path, &frontier)?;
1002
1003    Ok(StateCommandReport {
1004        ok: true,
1005        command: "causal_set".to_string(),
1006        frontier: frontier.project.name,
1007        finding_id: finding_id.to_string(),
1008        proposal_id,
1009        proposal_status: "applied".to_string(),
1010        applied_event_id: Some(event_id),
1011        wrote_to: path.display().to_string(),
1012        message: format!("Causal claim set to {new_claim}"),
1013    })
1014}
1015
1016/// v0.49: Add a NegativeResult to the frontier, emitting a
1017/// `negative_result.asserted` canonical event.
1018///
1019/// Bypasses the proposal flow because (a) NegativeResult is parallel
1020/// to FindingBundle, not a mutation of one — the proposal-first
1021/// pipeline is finding-shaped and would force a duplicate target
1022/// type; (b) the v0.49 deposit path mirrors how `Replication`,
1023/// `Dataset`, and `Prediction` are added today (direct, with
1024/// emission). v0.50 will route these through proposals once the
1025/// agent inbox needs review-gated null deposits.
1026///
1027/// The full inline NegativeResult is carried on
1028/// `payload.negative_result` so a fresh `replay_from_genesis`
1029/// reconstructs `state.negative_results` from the event log alone.
1030/// `target_findings` are NOT cross-checked against existing findings
1031/// here; an exploratory deposit may legitimately reference no
1032/// finding, and a registered-trial deposit may bear against a finding
1033/// in a sibling frontier reachable through `vfr_*` cross-frontier
1034/// links. The depositor is responsible for the link's truthfulness.
1035pub fn add_negative_result(
1036    path: &Path,
1037    kind: NegativeResultKind,
1038    target_findings: Vec<String>,
1039    deposited_by: &str,
1040    conditions: Conditions,
1041    provenance: Provenance,
1042    notes: &str,
1043    reason: &str,
1044) -> Result<StateCommandReport, String> {
1045    if deposited_by.trim().is_empty() {
1046        return Err("deposited_by must be a non-empty actor id".to_string());
1047    }
1048    if reason.trim().is_empty() {
1049        return Err("reason must be non-empty".to_string());
1050    }
1051
1052    let mut frontier: Project = repo::load_from_path(path)?;
1053
1054    let nr = NegativeResult::new(
1055        kind,
1056        target_findings,
1057        deposited_by,
1058        conditions,
1059        provenance,
1060        notes,
1061    );
1062    let nr_id = nr.id.clone();
1063
1064    if frontier.negative_results.iter().any(|n| n.id == nr_id) {
1065        return Err(format!(
1066            "Refusing to add duplicate negative_result with existing id {nr_id}"
1067        ));
1068    }
1069
1070    let proposal_id = format!(
1071        "vpr_{}",
1072        &hex::encode(Sha256::digest(
1073            format!("{nr_id}|{deposited_by}|{}", Utc::now().to_rfc3339()).as_bytes()
1074        ))[..16]
1075    );
1076
1077    let nr_value = serde_json::to_value(&nr)
1078        .map_err(|e| format!("failed to serialize negative_result: {e}"))?;
1079
1080    let mut event = StateEvent {
1081        schema: events::EVENT_SCHEMA.to_string(),
1082        id: String::new(),
1083        kind: events::EVENT_KIND_NEGATIVE_RESULT_ASSERTED.to_string(),
1084        target: StateTarget {
1085            r#type: "negative_result".to_string(),
1086            id: nr_id.clone(),
1087        },
1088        actor: StateActor {
1089            id: deposited_by.to_string(),
1090            r#type: "human".to_string(),
1091        },
1092        timestamp: Utc::now().to_rfc3339(),
1093        reason: reason.to_string(),
1094        before_hash: NULL_HASH.to_string(),
1095        after_hash: NULL_HASH.to_string(),
1096        payload: json!({
1097            "proposal_id": proposal_id,
1098            "negative_result": nr_value,
1099        }),
1100        caveats: Vec::new(),
1101        signature: None,
1102        schema_artifact_id: None,
1103    };
1104    event.id = events::compute_event_id(&event);
1105    let event_id = event.id.clone();
1106
1107    // Validate before mutating state — a malformed event must not
1108    // poison the on-disk frontier.
1109    events::validate_event_payload(&event.kind, &event.payload)?;
1110    reducer::apply_event(&mut frontier, &event)?;
1111    frontier.events.push(event);
1112
1113    repo::save_to_path(path, &frontier)?;
1114
1115    Ok(StateCommandReport {
1116        ok: true,
1117        command: "negative_result.add".to_string(),
1118        frontier: frontier.project.name,
1119        finding_id: nr_id,
1120        proposal_id,
1121        proposal_status: "applied".to_string(),
1122        applied_event_id: Some(event_id),
1123        wrote_to: path.display().to_string(),
1124        message: "NegativeResult deposited".to_string(),
1125    })
1126}
1127
1128/// Deposit a generic content-addressed artifact and emit an
1129/// `artifact.asserted` canonical event. The full artifact is carried
1130/// inline on the event payload so a future replay reconstructs the
1131/// artifact table without reading `.vela/artifacts`.
1132pub fn add_artifact(
1133    path: &Path,
1134    artifact: Artifact,
1135    deposited_by: &str,
1136    reason: &str,
1137) -> Result<StateCommandReport, String> {
1138    if deposited_by.trim().is_empty() {
1139        return Err("deposited_by must be a non-empty actor id".to_string());
1140    }
1141    if reason.trim().is_empty() {
1142        return Err("reason must be non-empty".to_string());
1143    }
1144
1145    let mut frontier: Project = repo::load_from_path(path)?;
1146    let artifact_id = artifact.id.clone();
1147
1148    if frontier.artifacts.iter().any(|a| a.id == artifact_id) {
1149        return Err(format!(
1150            "Refusing to add duplicate artifact with existing id {artifact_id}"
1151        ));
1152    }
1153
1154    let proposal_id = format!(
1155        "vpr_{}",
1156        &hex::encode(Sha256::digest(
1157            format!("{artifact_id}|{deposited_by}|{}", Utc::now().to_rfc3339()).as_bytes()
1158        ))[..16]
1159    );
1160
1161    let artifact_value = serde_json::to_value(&artifact)
1162        .map_err(|e| format!("failed to serialize artifact: {e}"))?;
1163
1164    let mut event = StateEvent {
1165        schema: events::EVENT_SCHEMA.to_string(),
1166        id: String::new(),
1167        kind: events::EVENT_KIND_ARTIFACT_ASSERTED.to_string(),
1168        target: StateTarget {
1169            r#type: "artifact".to_string(),
1170            id: artifact_id.clone(),
1171        },
1172        actor: StateActor {
1173            id: deposited_by.to_string(),
1174            r#type: "human".to_string(),
1175        },
1176        timestamp: Utc::now().to_rfc3339(),
1177        reason: reason.to_string(),
1178        before_hash: NULL_HASH.to_string(),
1179        after_hash: NULL_HASH.to_string(),
1180        payload: json!({
1181            "proposal_id": proposal_id,
1182            "artifact": artifact_value,
1183        }),
1184        caveats: Vec::new(),
1185        signature: None,
1186        schema_artifact_id: None,
1187    };
1188    event.id = events::compute_event_id(&event);
1189    let event_id = event.id.clone();
1190
1191    events::validate_event_payload(&event.kind, &event.payload)?;
1192    reducer::apply_event(&mut frontier, &event)?;
1193    frontier.events.push(event);
1194
1195    repo::save_to_path(path, &frontier)?;
1196
1197    Ok(StateCommandReport {
1198        ok: true,
1199        command: "artifact.add".to_string(),
1200        frontier: frontier.project.name,
1201        finding_id: artifact_id,
1202        proposal_id,
1203        proposal_status: "applied".to_string(),
1204        applied_event_id: Some(event_id),
1205        wrote_to: path.display().to_string(),
1206        message: "Artifact deposited".to_string(),
1207    })
1208}
1209
1210/// v0.50: Open a new Trajectory and emit a `trajectory.created`
1211/// canonical event. Returns the new `vtr_*` id in the report's
1212/// `finding_id` field (the StateCommandReport schema reuses that
1213/// field for the primary mutated object id).
1214///
1215/// Steps are appended via `append_trajectory_step` rather than
1216/// supplied at creation — that keeps the search visible to readers as
1217/// it unfolds rather than only after the fact.
1218pub fn create_trajectory(
1219    path: &Path,
1220    target_findings: Vec<String>,
1221    deposited_by: &str,
1222    notes: &str,
1223    reason: &str,
1224) -> Result<StateCommandReport, String> {
1225    if deposited_by.trim().is_empty() {
1226        return Err("deposited_by must be a non-empty actor id".to_string());
1227    }
1228    if reason.trim().is_empty() {
1229        return Err("reason must be non-empty".to_string());
1230    }
1231
1232    let mut frontier: Project = repo::load_from_path(path)?;
1233
1234    let traj = Trajectory::new(target_findings, deposited_by, notes);
1235    let traj_id = traj.id.clone();
1236
1237    if frontier.trajectories.iter().any(|t| t.id == traj_id) {
1238        return Err(format!(
1239            "Refusing to create duplicate trajectory with existing id {traj_id}"
1240        ));
1241    }
1242
1243    let proposal_id = format!(
1244        "vpr_{}",
1245        &hex::encode(Sha256::digest(
1246            format!("{traj_id}|{deposited_by}|{}", Utc::now().to_rfc3339()).as_bytes()
1247        ))[..16]
1248    );
1249
1250    let traj_value =
1251        serde_json::to_value(&traj).map_err(|e| format!("failed to serialize trajectory: {e}"))?;
1252
1253    let mut event = StateEvent {
1254        schema: events::EVENT_SCHEMA.to_string(),
1255        id: String::new(),
1256        kind: events::EVENT_KIND_TRAJECTORY_CREATED.to_string(),
1257        target: StateTarget {
1258            r#type: "trajectory".to_string(),
1259            id: traj_id.clone(),
1260        },
1261        actor: StateActor {
1262            id: deposited_by.to_string(),
1263            r#type: "human".to_string(),
1264        },
1265        timestamp: Utc::now().to_rfc3339(),
1266        reason: reason.to_string(),
1267        before_hash: NULL_HASH.to_string(),
1268        after_hash: NULL_HASH.to_string(),
1269        payload: json!({
1270            "proposal_id": proposal_id,
1271            "trajectory": traj_value,
1272        }),
1273        caveats: Vec::new(),
1274        signature: None,
1275        schema_artifact_id: None,
1276    };
1277    event.id = events::compute_event_id(&event);
1278    let event_id = event.id.clone();
1279
1280    events::validate_event_payload(&event.kind, &event.payload)?;
1281    reducer::apply_event(&mut frontier, &event)?;
1282    frontier.events.push(event);
1283
1284    repo::save_to_path(path, &frontier)?;
1285
1286    Ok(StateCommandReport {
1287        ok: true,
1288        command: "trajectory.create".to_string(),
1289        frontier: frontier.project.name,
1290        finding_id: traj_id,
1291        proposal_id,
1292        proposal_status: "applied".to_string(),
1293        applied_event_id: Some(event_id),
1294        wrote_to: path.display().to_string(),
1295        message: "Trajectory opened".to_string(),
1296    })
1297}
1298
1299/// v0.50: Append a step to an existing Trajectory. Step kind one of
1300/// `hypothesis | tried | ruled_out | observed | refined`. Idempotent
1301/// on duplicate step content-addresses (so an agent that re-runs an
1302/// append after a crash doesn't double-append).
1303pub fn append_trajectory_step(
1304    path: &Path,
1305    trajectory_id: &str,
1306    kind: TrajectoryStepKind,
1307    description: &str,
1308    actor: &str,
1309    references: Vec<String>,
1310    reason: &str,
1311) -> Result<StateCommandReport, String> {
1312    if actor.trim().is_empty() {
1313        return Err("actor must be a non-empty id".to_string());
1314    }
1315    if description.trim().is_empty() {
1316        return Err("description must be non-empty".to_string());
1317    }
1318    if reason.trim().is_empty() {
1319        return Err("reason must be non-empty".to_string());
1320    }
1321
1322    let mut frontier: Project = repo::load_from_path(path)?;
1323    if !frontier.trajectories.iter().any(|t| t.id == trajectory_id) {
1324        return Err(format!("Trajectory not found: {trajectory_id}"));
1325    }
1326
1327    let step = TrajectoryStep::new(trajectory_id, kind, description, actor, None, references);
1328    let step_id = step.id.clone();
1329
1330    let proposal_id = format!(
1331        "vpr_{}",
1332        &hex::encode(Sha256::digest(
1333            format!("{trajectory_id}|{step_id}|{actor}").as_bytes()
1334        ))[..16]
1335    );
1336
1337    let step_value = serde_json::to_value(&step)
1338        .map_err(|e| format!("failed to serialize trajectory step: {e}"))?;
1339
1340    let mut event = StateEvent {
1341        schema: events::EVENT_SCHEMA.to_string(),
1342        id: String::new(),
1343        kind: events::EVENT_KIND_TRAJECTORY_STEP_APPENDED.to_string(),
1344        target: StateTarget {
1345            r#type: "trajectory".to_string(),
1346            id: trajectory_id.to_string(),
1347        },
1348        actor: StateActor {
1349            id: actor.to_string(),
1350            r#type: "human".to_string(),
1351        },
1352        timestamp: Utc::now().to_rfc3339(),
1353        reason: reason.to_string(),
1354        before_hash: NULL_HASH.to_string(),
1355        after_hash: NULL_HASH.to_string(),
1356        payload: json!({
1357            "proposal_id": proposal_id,
1358            "parent_trajectory_id": trajectory_id,
1359            "step": step_value,
1360        }),
1361        caveats: Vec::new(),
1362        signature: None,
1363        schema_artifact_id: None,
1364    };
1365    event.id = events::compute_event_id(&event);
1366    let event_id = event.id.clone();
1367
1368    events::validate_event_payload(&event.kind, &event.payload)?;
1369    reducer::apply_event(&mut frontier, &event)?;
1370    frontier.events.push(event);
1371
1372    repo::save_to_path(path, &frontier)?;
1373
1374    Ok(StateCommandReport {
1375        ok: true,
1376        command: "trajectory.step_append".to_string(),
1377        frontier: frontier.project.name,
1378        finding_id: step_id,
1379        proposal_id,
1380        proposal_status: "applied".to_string(),
1381        applied_event_id: Some(event_id),
1382        wrote_to: path.display().to_string(),
1383        message: "Trajectory step appended".to_string(),
1384    })
1385}
1386
1387/// v0.51: Re-classify the access tier of a finding / negative_result
1388/// / trajectory / artifact. Emits a `tier.set` canonical event so the
1389/// reclassification is replay-deterministic and auditable.
1390///
1391/// `object_type` must be one of `finding`, `negative_result`,
1392/// `trajectory`, or `artifact`. The function captures the object's previous tier
1393/// for the event payload so a downstream auditor reading the event
1394/// log can reconstruct the full classification history without
1395/// re-deriving it from prior state.
1396pub fn set_tier(
1397    path: &Path,
1398    object_type: &str,
1399    object_id: &str,
1400    new_tier: crate::access_tier::AccessTier,
1401    actor: &str,
1402    reason: &str,
1403) -> Result<StateCommandReport, String> {
1404    if actor.trim().is_empty() {
1405        return Err("actor must be a non-empty id".to_string());
1406    }
1407    if reason.trim().is_empty() {
1408        return Err("reason must be non-empty".to_string());
1409    }
1410    if !matches!(
1411        object_type,
1412        "finding" | "negative_result" | "trajectory" | "artifact"
1413    ) {
1414        return Err(format!(
1415            "object_type '{object_type}' must be one of finding, negative_result, trajectory, artifact"
1416        ));
1417    }
1418
1419    let mut frontier: Project = repo::load_from_path(path)?;
1420
1421    let previous_tier = match object_type {
1422        "finding" => {
1423            frontier
1424                .findings
1425                .iter()
1426                .find(|f| f.id == object_id)
1427                .ok_or_else(|| format!("Finding not found: {object_id}"))?
1428                .access_tier
1429        }
1430        "negative_result" => {
1431            frontier
1432                .negative_results
1433                .iter()
1434                .find(|n| n.id == object_id)
1435                .ok_or_else(|| format!("NegativeResult not found: {object_id}"))?
1436                .access_tier
1437        }
1438        "trajectory" => {
1439            frontier
1440                .trajectories
1441                .iter()
1442                .find(|t| t.id == object_id)
1443                .ok_or_else(|| format!("Trajectory not found: {object_id}"))?
1444                .access_tier
1445        }
1446        "artifact" => {
1447            frontier
1448                .artifacts
1449                .iter()
1450                .find(|a| a.id == object_id)
1451                .ok_or_else(|| format!("Artifact not found: {object_id}"))?
1452                .access_tier
1453        }
1454        _ => unreachable!("validated above"),
1455    };
1456
1457    let proposal_id = format!(
1458        "vpr_{}",
1459        &hex::encode(Sha256::digest(
1460            format!(
1461                "{object_type}|{object_id}|{actor}|{}|{}",
1462                new_tier.canonical(),
1463                Utc::now().to_rfc3339()
1464            )
1465            .as_bytes()
1466        ))[..16]
1467    );
1468
1469    let mut event = StateEvent {
1470        schema: events::EVENT_SCHEMA.to_string(),
1471        id: String::new(),
1472        kind: events::EVENT_KIND_TIER_SET.to_string(),
1473        target: StateTarget {
1474            r#type: object_type.to_string(),
1475            id: object_id.to_string(),
1476        },
1477        actor: StateActor {
1478            id: actor.to_string(),
1479            r#type: "human".to_string(),
1480        },
1481        timestamp: Utc::now().to_rfc3339(),
1482        reason: reason.to_string(),
1483        before_hash: NULL_HASH.to_string(),
1484        after_hash: NULL_HASH.to_string(),
1485        payload: json!({
1486            "proposal_id": proposal_id,
1487            "object_type": object_type,
1488            "object_id": object_id,
1489            "previous_tier": previous_tier.canonical(),
1490            "new_tier": new_tier.canonical(),
1491        }),
1492        caveats: Vec::new(),
1493        signature: None,
1494        schema_artifact_id: None,
1495    };
1496    event.id = events::compute_event_id(&event);
1497    let event_id = event.id.clone();
1498
1499    events::validate_event_payload(&event.kind, &event.payload)?;
1500    reducer::apply_event(&mut frontier, &event)?;
1501    frontier.events.push(event);
1502
1503    repo::save_to_path(path, &frontier)?;
1504
1505    Ok(StateCommandReport {
1506        ok: true,
1507        command: "tier.set".to_string(),
1508        frontier: frontier.project.name,
1509        finding_id: object_id.to_string(),
1510        proposal_id,
1511        proposal_status: "applied".to_string(),
1512        applied_event_id: Some(event_id),
1513        wrote_to: path.display().to_string(),
1514        message: format!("Tier set to {} on {object_type}", new_tier.canonical()),
1515    })
1516}
1517
1518pub fn history(path: &Path, finding_id: &str) -> Result<Value, String> {
1519    history_as_of(path, finding_id, None)
1520}
1521
1522/// v0.55: time-travel replay. When `as_of` is `Some(ts)`, the returned
1523/// `events` / `review_events` / `confidence_updates` are filtered to
1524/// records whose timestamp is `<= ts` (RFC3339 lexicographic compare),
1525/// the `confidence` field reports the **score at that time** (last
1526/// confidence update at-or-before cutoff, or genesis if none), and a
1527/// `replayed_at_score` field surfaces it explicitly so a caller doesn't
1528/// need to walk the updates array.
1529pub fn history_as_of(path: &Path, finding_id: &str, as_of: Option<&str>) -> Result<Value, String> {
1530    let frontier = repo::load_from_path(path)?;
1531    let context = finding_context(&frontier, finding_id)?;
1532    let finding = context
1533        .get("finding")
1534        .ok_or_else(|| format!("Finding not found: {finding_id}"))?;
1535
1536    let cutoff = as_of.map(|s| s.to_string());
1537    let filter_by_ts = |arr: Option<&Value>, ts_field: &str| -> Value {
1538        let Some(v) = arr else {
1539            return Value::Array(Vec::new());
1540        };
1541        let Some(items) = v.as_array() else {
1542            return Value::Array(Vec::new());
1543        };
1544        match &cutoff {
1545            None => Value::Array(items.clone()),
1546            Some(c) => Value::Array(
1547                items
1548                    .iter()
1549                    .filter(|item| {
1550                        item.get(ts_field)
1551                            .and_then(Value::as_str)
1552                            .map(|t| t <= c.as_str())
1553                            .unwrap_or(true)
1554                    })
1555                    .cloned()
1556                    .collect(),
1557            ),
1558        }
1559    };
1560
1561    let events_filtered = filter_by_ts(context.get("events"), "timestamp");
1562    let review_events_filtered = filter_by_ts(context.get("review_events"), "reviewed_at");
1563    let confidence_updates_filtered = filter_by_ts(context.get("confidence_updates"), "updated_at");
1564
1565    // Score at cutoff: last confidence update at-or-before cutoff. If the
1566    // finding is at its genesis confidence, fall back to the current score
1567    // from the bundle (it never changed).
1568    let score_at = if let Some(arr) = confidence_updates_filtered.as_array() {
1569        let mut sorted: Vec<&Value> = arr.iter().collect();
1570        sorted.sort_by(|a, b| {
1571            let ta = a.get("updated_at").and_then(Value::as_str).unwrap_or("");
1572            let tb = b.get("updated_at").and_then(Value::as_str).unwrap_or("");
1573            ta.cmp(tb)
1574        });
1575        sorted
1576            .last()
1577            .and_then(|u| u.get("new_score"))
1578            .cloned()
1579            .unwrap_or_else(|| {
1580                finding
1581                    .pointer("/confidence/score")
1582                    .cloned()
1583                    .unwrap_or(Value::Null)
1584            })
1585    } else {
1586        finding
1587            .pointer("/confidence/score")
1588            .cloned()
1589            .unwrap_or(Value::Null)
1590    };
1591
1592    Ok(json!({
1593        "ok": true,
1594        "command": "history",
1595        "frontier": frontier.project.name,
1596        "as_of": cutoff,
1597        "finding": {
1598            "id": finding.get("id"),
1599            "assertion": finding.pointer("/assertion/text"),
1600            "confidence": finding.pointer("/confidence/score"),
1601            "flags": finding.get("flags"),
1602            "annotations": finding.get("annotations"),
1603        },
1604        "replayed_at_score": score_at,
1605        "review_events": review_events_filtered,
1606        "confidence_updates": confidence_updates_filtered,
1607        "sources": context.get("sources"),
1608        "evidence_atoms": context.get("evidence_atoms"),
1609        "condition_records": context.get("condition_records"),
1610        "proposals": context.get("proposals"),
1611        "events": events_filtered,
1612        "proof_state": frontier.proof_state,
1613    }))
1614}
1615
1616pub fn finding_context(frontier: &Project, finding_id: &str) -> Result<Value, String> {
1617    let finding = frontier
1618        .findings
1619        .iter()
1620        .find(|finding| finding.id == finding_id)
1621        .ok_or_else(|| format!("Finding not found: {finding_id}"))?;
1622    let reviews = frontier
1623        .review_events
1624        .iter()
1625        .filter(|event| event.finding_id == finding_id)
1626        .collect::<Vec<_>>();
1627    let confidence_updates = frontier
1628        .confidence_updates
1629        .iter()
1630        .filter(|update| update.finding_id == finding_id)
1631        .collect::<Vec<_>>();
1632    let source_records = frontier
1633        .sources
1634        .iter()
1635        .filter(|source| source.finding_ids.iter().any(|id| id == finding_id))
1636        .collect::<Vec<_>>();
1637    let evidence_atoms = frontier
1638        .evidence_atoms
1639        .iter()
1640        .filter(|atom| atom.finding_id == finding_id)
1641        .collect::<Vec<_>>();
1642    let condition_records = frontier
1643        .condition_records
1644        .iter()
1645        .filter(|record| record.finding_id == finding_id)
1646        .collect::<Vec<_>>();
1647    Ok(json!({
1648        "finding": finding,
1649        "review_events": reviews,
1650        "confidence_updates": confidence_updates,
1651        "sources": source_records,
1652        "evidence_atoms": evidence_atoms,
1653        "condition_records": condition_records,
1654        "proposals": proposals::proposals_for_finding(frontier, finding_id),
1655        "events": events::events_for_finding(frontier, finding_id),
1656        "proof_state": frontier.proof_state,
1657    }))
1658}
1659
1660pub fn state_transitions(frontier: &Project) -> Value {
1661    let mut transitions = Vec::new();
1662    if !frontier.events.is_empty() {
1663        for event in &frontier.events {
1664            transitions.push(json!({
1665                "kind": event.kind,
1666                "id": event.id,
1667                "target": event.target,
1668                "actor": event.actor,
1669                "timestamp": event.timestamp,
1670                "reason": event.reason,
1671                "before_hash": event.before_hash,
1672                "after_hash": event.after_hash,
1673                "payload": event.payload,
1674                "caveats": event.caveats,
1675            }));
1676        }
1677        transitions.sort_by(|a, b| {
1678            a.get("timestamp")
1679                .and_then(Value::as_str)
1680                .cmp(&b.get("timestamp").and_then(Value::as_str))
1681        });
1682        return json!({
1683            "schema": "vela.state-transitions.v1",
1684            "frontier": frontier.project.name,
1685            "source": "canonical_events",
1686            "transitions": transitions,
1687        });
1688    }
1689    for event in &frontier.review_events {
1690        transitions.push(json!({
1691            "kind": "review_event",
1692            "id": event.id,
1693            "target": {"type": "finding", "id": event.finding_id},
1694            "actor": event.reviewer,
1695            "timestamp": event.reviewed_at,
1696            "action": event.action,
1697            "reason": event.reason,
1698            "state_change": event.state_change,
1699        }));
1700    }
1701    for update in &frontier.confidence_updates {
1702        transitions.push(json!({
1703            "kind": "confidence_update",
1704            "id": confidence_update_id(update),
1705            "target": {"type": "finding", "id": update.finding_id},
1706            "actor": update.updated_by,
1707            "timestamp": update.updated_at,
1708            "action": "confidence_revised",
1709            "reason": update.basis,
1710            "state_change": {
1711                "previous_score": update.previous_score,
1712                "new_score": update.new_score,
1713            },
1714        }));
1715    }
1716    transitions.sort_by(|a, b| {
1717        a.get("timestamp")
1718            .and_then(Value::as_str)
1719            .cmp(&b.get("timestamp").and_then(Value::as_str))
1720    });
1721    json!({
1722        "schema": "vela.state-transitions.v0",
1723        "frontier": frontier.project.name,
1724        "transitions": transitions,
1725    })
1726}
1727
1728/// Build a content-addressed FindingBundle from CLI-supplied options.
1729/// Shared by `finding.add` and v0.14 `finding.supersede`.
1730fn build_finding_bundle(options: &FindingDraftOptions) -> FindingBundle {
1731    let now = Utc::now().to_rfc3339();
1732    let assertion = Assertion {
1733        text: options.text.clone(),
1734        assertion_type: options.assertion_type.clone(),
1735        entities: options
1736            .entities
1737            .iter()
1738            .map(|(name, entity_type)| Entity {
1739                name: name.clone(),
1740                entity_type: entity_type.clone(),
1741                identifiers: serde_json::Map::new(),
1742                canonical_id: None,
1743                candidates: Vec::new(),
1744                aliases: Vec::new(),
1745                resolution_provenance: Some("manual_state_transition".to_string()),
1746                resolution_confidence: if options.entities_reviewed { 0.95 } else { 0.6 },
1747                resolution_method: if options.entities_reviewed {
1748                    Some(ResolutionMethod::Manual)
1749                } else {
1750                    None
1751                },
1752                species_context: None,
1753                needs_review: !options.entities_reviewed,
1754            })
1755            .collect(),
1756        relation: None,
1757        direction: None,
1758        causal_claim: None,
1759        causal_evidence_grade: None,
1760    };
1761    let evidence = Evidence {
1762        evidence_type: options.evidence_type.clone(),
1763        model_system: String::new(),
1764        species: options
1765            .species
1766            .first()
1767            .cloned()
1768            .or_else(|| options.human_data.then(|| "Homo sapiens".to_string())),
1769        method: if options.clinical_trial {
1770            "manual state transition; placebo-controlled clinical trial where source reports control arm"
1771                .to_string()
1772        } else if options.evidence_type == "experimental" {
1773            "manual state transition; control details require source inspection".to_string()
1774        } else {
1775            "manual state transition".to_string()
1776        },
1777        sample_size: None,
1778        effect_size: None,
1779        p_value: None,
1780        replicated: false,
1781        replication_count: None,
1782        evidence_spans: options.evidence_spans.clone(),
1783    };
1784    let conditions = Conditions {
1785        text: options.conditions_text.clone().unwrap_or_else(|| {
1786            "Manually added finding; requires evidence review before scientific use.".to_string()
1787        }),
1788        species_verified: options.species.clone(),
1789        species_unverified: Vec::new(),
1790        in_vitro: options.in_vitro,
1791        in_vivo: options.in_vivo,
1792        human_data: options.human_data,
1793        clinical_trial: options.clinical_trial,
1794        concentration_range: None,
1795        duration: None,
1796        age_group: None,
1797        cell_type: None,
1798    };
1799    let confidence = Confidence {
1800        kind: ConfidenceKind::FrontierEpistemic,
1801        score: options.confidence,
1802        basis: "operator-supplied frontier prior; review required".to_string(),
1803        method: ConfidenceMethod::ExpertJudgment,
1804        components: None,
1805        extraction_confidence: 1.0,
1806    };
1807    let source_authors = if options.source_authors.is_empty() {
1808        vec![Author {
1809            name: options.author.clone(),
1810            orcid: None,
1811        }]
1812    } else {
1813        options
1814            .source_authors
1815            .iter()
1816            .map(|name| Author {
1817                name: name.clone(),
1818                orcid: None,
1819            })
1820            .collect()
1821    };
1822    let provenance = Provenance {
1823        source_type: options.source_type.clone(),
1824        doi: options.doi.clone(),
1825        pmid: options.pmid.clone(),
1826        pmc: None,
1827        openalex_id: None,
1828        url: options.url.clone(),
1829        title: options.source.clone(),
1830        authors: source_authors,
1831        year: options.year,
1832        journal: options.journal.clone(),
1833        license: None,
1834        publisher: None,
1835        funders: Vec::new(),
1836        extraction: Extraction {
1837            method: "manual_curation".to_string(),
1838            model: None,
1839            model_version: None,
1840            extracted_at: now,
1841            extractor_version: project::VELA_COMPILER_VERSION.to_string(),
1842        },
1843        review: Some(Review {
1844            reviewed: false,
1845            reviewer: None,
1846            reviewed_at: None,
1847            corrections: Vec::new(),
1848        }),
1849        citation_count: None,
1850    };
1851    let flags = Flags {
1852        gap: options.gap,
1853        negative_space: options.negative_space,
1854        ..Default::default()
1855    };
1856    FindingBundle::new(
1857        assertion, evidence, conditions, confidence, provenance, flags,
1858    )
1859}
1860
1861/// v0.14: build the proposal that supersedes `old_id` with a new finding bundle.
1862pub fn supersede_finding(
1863    path: &Path,
1864    old_id: &str,
1865    reason: &str,
1866    options: FindingDraftOptions,
1867    apply: bool,
1868) -> Result<StateCommandReport, String> {
1869    validate_score(options.confidence)?;
1870    if reason.trim().is_empty() {
1871        return Err("--reason is required for finding supersede".to_string());
1872    }
1873    let new_finding = build_finding_bundle(&options);
1874    if new_finding.id == old_id {
1875        return Err(
1876            "supersede new assertion must produce a different content address than the old finding (change assertion text, type, or provenance to derive a distinct vf_…)"
1877                .to_string(),
1878        );
1879    }
1880    let proposal = proposals::new_proposal(
1881        "finding.supersede",
1882        events::StateTarget {
1883            r#type: "finding".to_string(),
1884            id: old_id.to_string(),
1885        },
1886        options.author.clone(),
1887        "human",
1888        reason.to_string(),
1889        json!({"new_finding": new_finding}),
1890        Vec::new(),
1891        Vec::new(),
1892    );
1893    let result = proposals::create_or_apply(path, proposal, apply)?;
1894    let frontier = repo::load_from_path(path)?;
1895    Ok(StateCommandReport {
1896        ok: true,
1897        command: "finding.supersede".to_string(),
1898        frontier: frontier.project.name,
1899        finding_id: result.finding_id,
1900        proposal_id: result.proposal_id,
1901        proposal_status: result.status.clone(),
1902        applied_event_id: result.applied_event_id,
1903        wrote_to: path.display().to_string(),
1904        message: if result.status == "applied" {
1905            "Supersede proposal applied".to_string()
1906        } else {
1907            "Supersede proposal recorded".to_string()
1908        },
1909    })
1910}
1911
1912fn build_add_finding_proposal(options: FindingDraftOptions) -> Result<StateProposal, String> {
1913    let now = Utc::now().to_rfc3339();
1914    let assertion = Assertion {
1915        text: options.text.clone(),
1916        assertion_type: options.assertion_type.clone(),
1917        entities: options
1918            .entities
1919            .iter()
1920            .map(|(name, entity_type)| Entity {
1921                name: name.clone(),
1922                entity_type: entity_type.clone(),
1923                identifiers: serde_json::Map::new(),
1924                canonical_id: None,
1925                candidates: Vec::new(),
1926                aliases: Vec::new(),
1927                resolution_provenance: Some("manual_state_transition".to_string()),
1928                resolution_confidence: if options.entities_reviewed { 0.95 } else { 0.6 },
1929                resolution_method: if options.entities_reviewed {
1930                    Some(ResolutionMethod::Manual)
1931                } else {
1932                    None
1933                },
1934                species_context: None,
1935                needs_review: !options.entities_reviewed,
1936            })
1937            .collect(),
1938        relation: None,
1939        direction: None,
1940        causal_claim: None,
1941        causal_evidence_grade: None,
1942    };
1943    let evidence = Evidence {
1944        evidence_type: options.evidence_type.clone(),
1945        model_system: String::new(),
1946        species: options
1947            .species
1948            .first()
1949            .cloned()
1950            .or_else(|| options.human_data.then(|| "Homo sapiens".to_string())),
1951        method: if options.clinical_trial {
1952            "manual state transition; placebo-controlled clinical trial where source reports control arm"
1953                .to_string()
1954        } else if options.evidence_type == "experimental" {
1955            "manual state transition; control details require source inspection".to_string()
1956        } else {
1957            "manual state transition".to_string()
1958        },
1959        sample_size: None,
1960        effect_size: None,
1961        p_value: None,
1962        replicated: false,
1963        replication_count: None,
1964        evidence_spans: options.evidence_spans.clone(),
1965    };
1966    // v0.11: conditions text falls back to the v0.10 placeholder only when
1967    // the caller didn't supply --conditions-text. The placeholder is a
1968    // signal to a reviewer that scope needs to be added; once a real
1969    // conditions string is provided, the placeholder isn't useful.
1970    let conditions = Conditions {
1971        text: options.conditions_text.clone().unwrap_or_else(|| {
1972            "Manually added finding; requires evidence review before scientific use.".to_string()
1973        }),
1974        species_verified: options.species.clone(),
1975        species_unverified: Vec::new(),
1976        in_vitro: options.in_vitro,
1977        in_vivo: options.in_vivo,
1978        human_data: options.human_data,
1979        clinical_trial: options.clinical_trial,
1980        concentration_range: None,
1981        duration: None,
1982        age_group: None,
1983        cell_type: None,
1984    };
1985    let confidence = Confidence {
1986        kind: ConfidenceKind::FrontierEpistemic,
1987        score: options.confidence,
1988        basis: "operator-supplied frontier prior; review required".to_string(),
1989        method: ConfidenceMethod::ExpertJudgment,
1990        components: None,
1991        extraction_confidence: 1.0,
1992    };
1993    // v0.11: structured provenance. Source authors (the paper's authors)
1994    // are distinct from the Vela actor that curated the finding. When
1995    // --authors is omitted, fall back to the curator-as-author shape used
1996    // pre-v0.11 so existing scripts keep working.
1997    let source_authors = if options.source_authors.is_empty() {
1998        vec![Author {
1999            name: options.author.clone(),
2000            orcid: None,
2001        }]
2002    } else {
2003        options
2004            .source_authors
2005            .iter()
2006            .map(|name| Author {
2007                name: name.clone(),
2008                orcid: None,
2009            })
2010            .collect()
2011    };
2012    let provenance = Provenance {
2013        source_type: options.source_type.clone(),
2014        doi: options.doi.clone(),
2015        pmid: options.pmid.clone(),
2016        pmc: None,
2017        openalex_id: None,
2018        url: options.url.clone(),
2019        title: options.source.clone(),
2020        authors: source_authors,
2021        year: options.year,
2022        journal: options.journal.clone(),
2023        license: None,
2024        publisher: None,
2025        funders: Vec::new(),
2026        extraction: Extraction {
2027            method: "manual_curation".to_string(),
2028            model: None,
2029            model_version: None,
2030            extracted_at: now.clone(),
2031            extractor_version: project::VELA_COMPILER_VERSION.to_string(),
2032        },
2033        review: Some(Review {
2034            reviewed: false,
2035            reviewer: None,
2036            reviewed_at: None,
2037            corrections: Vec::new(),
2038        }),
2039        citation_count: None,
2040    };
2041    let flags = Flags {
2042        gap: options.gap,
2043        negative_space: options.negative_space,
2044        ..Default::default()
2045    };
2046    let finding = FindingBundle::new(
2047        assertion, evidence, conditions, confidence, provenance, flags,
2048    );
2049    let finding_id = finding.id.clone();
2050    Ok(proposals::new_proposal(
2051        "finding.add",
2052        events::StateTarget {
2053            r#type: "finding".to_string(),
2054            id: finding_id,
2055        },
2056        options.author,
2057        "human",
2058        "Manual finding added to frontier state",
2059        json!({"finding": finding}),
2060        Vec::new(),
2061        vec!["Manual findings require evidence review before scientific use.".to_string()],
2062    ))
2063}
2064
2065fn find_finding_index(frontier: &Project, finding_id: &str) -> Result<usize, String> {
2066    frontier
2067        .findings
2068        .iter()
2069        .position(|finding| finding.id == finding_id)
2070        .ok_or_else(|| format!("Finding not found: {finding_id}"))
2071}
2072
2073fn confidence_update_id(update: &crate::bundle::ConfidenceUpdate) -> String {
2074    let hash = Sha256::digest(
2075        format!(
2076            "{}|{}|{}|{}|{}",
2077            update.finding_id,
2078            update.previous_score,
2079            update.new_score,
2080            update.updated_by,
2081            update.updated_at
2082        )
2083        .as_bytes(),
2084    );
2085    format!("cu_{}", &hex::encode(hash)[..16])
2086}
2087
2088fn validate_score(score: f64) -> Result<(), String> {
2089    if (0.0..=1.0).contains(&score) {
2090        Ok(())
2091    } else {
2092        Err("--confidence must be between 0.0 and 1.0".to_string())
2093    }
2094}
2095
2096#[cfg(test)]
2097mod v0_11_finding_tests {
2098    use super::*;
2099    use crate::bundle;
2100
2101    fn base_options() -> FindingDraftOptions {
2102        FindingDraftOptions {
2103            text: "Test claim".to_string(),
2104            assertion_type: "mechanism".to_string(),
2105            source: "Test 2024".to_string(),
2106            source_type: "published_paper".to_string(),
2107            author: "reviewer:test".to_string(),
2108            confidence: 0.5,
2109            evidence_type: "experimental".to_string(),
2110            entities: Vec::new(),
2111            doi: None,
2112            pmid: None,
2113            year: None,
2114            journal: None,
2115            url: None,
2116            source_authors: Vec::new(),
2117            conditions_text: None,
2118            species: Vec::new(),
2119            in_vivo: false,
2120            in_vitro: false,
2121            human_data: false,
2122            clinical_trial: false,
2123            entities_reviewed: false,
2124            evidence_spans: Vec::new(),
2125            gap: false,
2126            negative_space: false,
2127        }
2128    }
2129
2130    #[test]
2131    fn provenance_flags_populate_structured_fields() {
2132        let mut opts = base_options();
2133        opts.doi = Some("10.1056/NEJMoa2212948".to_string());
2134        opts.pmid = Some("36449413".to_string());
2135        opts.year = Some(2023);
2136        opts.journal = Some("NEJM".to_string());
2137        opts.url = Some("https://nejm.org/...".to_string());
2138        opts.source_authors = vec!["van Dyck CH".to_string(), "Swanson CJ".to_string()];
2139        let proposal = build_add_finding_proposal(opts).unwrap();
2140        let finding: bundle::FindingBundle =
2141            serde_json::from_value(proposal.payload["finding"].clone()).unwrap();
2142        assert_eq!(
2143            finding.provenance.doi.as_deref(),
2144            Some("10.1056/NEJMoa2212948")
2145        );
2146        assert_eq!(finding.provenance.pmid.as_deref(), Some("36449413"));
2147        assert_eq!(finding.provenance.year, Some(2023));
2148        assert_eq!(finding.provenance.journal.as_deref(), Some("NEJM"));
2149        assert_eq!(
2150            finding.provenance.url.as_deref(),
2151            Some("https://nejm.org/...")
2152        );
2153        assert_eq!(
2154            finding
2155                .provenance
2156                .authors
2157                .iter()
2158                .map(|a| a.name.as_str())
2159                .collect::<Vec<_>>(),
2160            vec!["van Dyck CH", "Swanson CJ"],
2161        );
2162    }
2163
2164    #[test]
2165    fn conditions_flags_populate_structured_fields() {
2166        let mut opts = base_options();
2167        opts.conditions_text = Some("Phase 3 RCT, 18 mo".to_string());
2168        opts.species = vec!["Homo sapiens".to_string()];
2169        opts.in_vivo = true;
2170        opts.human_data = true;
2171        opts.clinical_trial = true;
2172        let proposal = build_add_finding_proposal(opts).unwrap();
2173        let finding: bundle::FindingBundle =
2174            serde_json::from_value(proposal.payload["finding"].clone()).unwrap();
2175        assert_eq!(finding.conditions.text, "Phase 3 RCT, 18 mo");
2176        assert_eq!(
2177            finding.conditions.species_verified,
2178            vec!["Homo sapiens".to_string()]
2179        );
2180        assert!(finding.conditions.in_vivo);
2181        assert!(finding.conditions.human_data);
2182        assert!(finding.conditions.clinical_trial);
2183    }
2184
2185    #[test]
2186    fn reviewed_entities_spans_and_gap_flags_populate_structured_fields() {
2187        let mut opts = base_options();
2188        opts.entities = vec![("lecanemab".to_string(), "drug".to_string())];
2189        opts.entities_reviewed = true;
2190        opts.evidence_spans = vec![json!({
2191            "section": "abstract",
2192            "text": "Lecanemab slowed decline under early symptomatic AD trial conditions."
2193        })];
2194        opts.gap = true;
2195        opts.negative_space = true;
2196
2197        let proposal = build_add_finding_proposal(opts).unwrap();
2198        let finding: bundle::FindingBundle =
2199            serde_json::from_value(proposal.payload["finding"].clone()).unwrap();
2200
2201        assert_eq!(finding.assertion.entities.len(), 1);
2202        assert!(!finding.assertion.entities[0].needs_review);
2203        assert_eq!(
2204            finding.assertion.entities[0].resolution_method,
2205            Some(bundle::ResolutionMethod::Manual)
2206        );
2207        assert_eq!(finding.evidence.evidence_spans.len(), 1);
2208        assert_eq!(
2209            finding.evidence.evidence_spans[0]["section"].as_str(),
2210            Some("abstract")
2211        );
2212        assert!(finding.flags.gap);
2213        assert!(finding.flags.negative_space);
2214    }
2215
2216    #[test]
2217    fn omitted_flags_fall_back_to_pre_v011_shape() {
2218        let proposal = build_add_finding_proposal(base_options()).unwrap();
2219        let finding: bundle::FindingBundle =
2220            serde_json::from_value(proposal.payload["finding"].clone()).unwrap();
2221        // Pre-v0.11 placeholder remains when --conditions-text is omitted.
2222        assert!(
2223            finding
2224                .conditions
2225                .text
2226                .starts_with("Manually added finding")
2227        );
2228        // No --source-authors → curator fills the authors slot, as in v0.10.
2229        assert_eq!(finding.provenance.authors.len(), 1);
2230        assert_eq!(finding.provenance.authors[0].name, "reviewer:test");
2231        // None of the new optional provenance fields populated.
2232        assert!(finding.provenance.doi.is_none());
2233        assert!(finding.provenance.year.is_none());
2234        assert!(finding.provenance.url.is_none());
2235    }
2236}
2237
2238#[cfg(test)]
2239mod v0_38_causal_tests {
2240    use super::*;
2241    use crate::bundle::{CausalClaim, CausalEvidenceGrade};
2242    use tempfile::tempdir;
2243
2244    fn seed_frontier(dir: &Path) -> std::path::PathBuf {
2245        let path = dir.join("frontier.json");
2246        let opts = FindingDraftOptions {
2247            text: "X causes Y".to_string(),
2248            assertion_type: "mechanism".to_string(),
2249            source: "test".to_string(),
2250            source_type: "published_paper".to_string(),
2251            author: "reviewer:test".to_string(),
2252            confidence: 0.5,
2253            evidence_type: "experimental".to_string(),
2254            entities: Vec::new(),
2255            doi: None,
2256            pmid: None,
2257            year: Some(2025),
2258            journal: None,
2259            url: None,
2260            source_authors: Vec::new(),
2261            conditions_text: None,
2262            species: Vec::new(),
2263            in_vivo: false,
2264            in_vitro: false,
2265            human_data: false,
2266            clinical_trial: false,
2267            entities_reviewed: false,
2268            evidence_spans: Vec::new(),
2269            gap: false,
2270            negative_space: false,
2271        };
2272        let proposal = build_add_finding_proposal(opts).unwrap();
2273        let finding: FindingBundle =
2274            serde_json::from_value(proposal.payload["finding"].clone()).unwrap();
2275        let project = project::assemble("Test", vec![finding], 1, 0, "test causal frontier");
2276        repo::save_to_path(&path, &project).unwrap();
2277        path
2278    }
2279
2280    #[test]
2281    fn set_causal_writes_fields_and_appends_event() {
2282        let dir = tempdir().unwrap();
2283        let path = seed_frontier(dir.path());
2284        let project = repo::load_from_path(&path).unwrap();
2285        let finding_id = project.findings[0].id.clone();
2286
2287        let report = set_causal(
2288            &path,
2289            &finding_id,
2290            "intervention",
2291            Some("rct"),
2292            "reviewer:test",
2293            "phase 3 RCT supports do(X=x) reading",
2294        )
2295        .unwrap();
2296        assert!(report.applied_event_id.is_some());
2297
2298        let after = repo::load_from_path(&path).unwrap();
2299        let f = &after.findings[0];
2300        assert_eq!(f.assertion.causal_claim, Some(CausalClaim::Intervention));
2301        assert_eq!(
2302            f.assertion.causal_evidence_grade,
2303            Some(CausalEvidenceGrade::Rct)
2304        );
2305
2306        let last_event = after.events.last().expect("an event was appended");
2307        assert_eq!(last_event.kind, "assertion.reinterpreted_causal");
2308        assert_eq!(last_event.target.id, finding_id);
2309        assert_eq!(last_event.payload["after"]["claim"], "intervention");
2310        assert_eq!(last_event.payload["after"]["grade"], "rct");
2311    }
2312
2313    #[test]
2314    fn set_causal_rejects_invalid_claim() {
2315        let dir = tempdir().unwrap();
2316        let path = seed_frontier(dir.path());
2317        let project = repo::load_from_path(&path).unwrap();
2318        let finding_id = project.findings[0].id.clone();
2319        let err =
2320            set_causal(&path, &finding_id, "magic", None, "reviewer:test", "test").unwrap_err();
2321        assert!(err.contains("invalid causal claim"));
2322    }
2323
2324    #[test]
2325    fn set_causal_preserves_grade_when_only_claim_changes() {
2326        let dir = tempdir().unwrap();
2327        let path = seed_frontier(dir.path());
2328        let project = repo::load_from_path(&path).unwrap();
2329        let finding_id = project.findings[0].id.clone();
2330
2331        // First set both.
2332        set_causal(
2333            &path,
2334            &finding_id,
2335            "correlation",
2336            Some("observational"),
2337            "reviewer:test",
2338            "initial reading",
2339        )
2340        .unwrap();
2341        // Then revise just the claim. Grade should persist.
2342        set_causal(
2343            &path,
2344            &finding_id,
2345            "mediation",
2346            None,
2347            "reviewer:test",
2348            "refined reading",
2349        )
2350        .unwrap();
2351        let after = repo::load_from_path(&path).unwrap();
2352        let f = &after.findings[0];
2353        assert_eq!(f.assertion.causal_claim, Some(CausalClaim::Mediation));
2354        assert_eq!(
2355            f.assertion.causal_evidence_grade,
2356            Some(CausalEvidenceGrade::Observational)
2357        );
2358        // Two events appended (one per call).
2359        let causal_events: usize = after
2360            .events
2361            .iter()
2362            .filter(|e| e.kind == "assertion.reinterpreted_causal")
2363            .count();
2364        assert_eq!(causal_events, 2);
2365    }
2366}