Skip to main content

vela_protocol/
proposals.rs

1//! Proposal-first frontier writes and proof freshness tracking.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::path::{Path, PathBuf};
5
6use chrono::Utc;
7use serde::{Deserialize, Serialize};
8use serde_json::{Value, json};
9use sha2::{Digest, Sha256};
10
11use crate::bundle::{Annotation, Artifact, ConfidenceMethod, FindingBundle};
12use crate::canonical;
13use crate::events::{self, NULL_HASH, StateActor, StateEvent, StateTarget};
14use crate::project::{self, Project};
15use crate::propagate::{self, PropagationAction};
16use crate::repo;
17
18pub const PROPOSAL_SCHEMA: &str = "vela.proposal.v0.1";
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21pub struct StateProposal {
22    #[serde(default = "default_schema")]
23    pub schema: String,
24    pub id: String,
25    pub kind: String,
26    pub target: StateTarget,
27    pub actor: StateActor,
28    pub created_at: String,
29    /// v0.67: when an agent drafts a proposal long before the
30    /// reviewer accepts it, `drafted_at` records the draft moment.
31    /// `created_at` records the moment the proposal entered the
32    /// canonical store. The throughput dashboard reads against
33    /// `drafted_at` when present, falling back to `created_at`,
34    /// so the "median proposal-to-event latency" surfaces real
35    /// reviewer queue time rather than zero.
36    /// Backward-compatible: pre-v0.67 proposals load with `None`.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub drafted_at: Option<String>,
39    pub reason: String,
40    #[serde(default)]
41    pub payload: Value,
42    #[serde(default)]
43    pub source_refs: Vec<String>,
44    pub status: String,
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub reviewed_by: Option<String>,
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub reviewed_at: Option<String>,
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub decision_reason: Option<String>,
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub applied_event_id: Option<String>,
53    #[serde(default)]
54    pub caveats: Vec<String>,
55    /// v0.22 (Agent Inbox): when a proposal originates from a scoped
56    /// agent run (e.g. Literature Scout reading a PDF folder), this
57    /// captures the model, the run id, and the wall-clock window.
58    /// The substrate stays dumb — it does not know whether the
59    /// proposer was a human, a Claude run, a GPT run, or a lab
60    /// pipeline; this is informational provenance only, surfaced in
61    /// the Workbench Inbox so reviewers can judge what they're
62    /// looking at. Optional + skip-if-none so existing frontiers
63    /// without proposals serialize byte-identically.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub agent_run: Option<AgentRun>,
66}
67
68/// Agent provenance attached to a `StateProposal`.
69///
70/// Doctrine: the substrate stays model-agnostic. Agents — Literature
71/// Scout, Notes Compiler, Code Analyst, etc. — sit in the
72/// `vela-scientist` crate (or external code) and write proposals into
73/// a frontier through the existing protocol. This struct is the
74/// reviewer-facing record of *who proposed what, with what model,
75/// during which run* — never used as access control or trust
76/// assignment.
77#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
78pub struct AgentRun {
79    /// Stable agent name (e.g. "literature-scout"). Pairs with the
80    /// proposal's `actor.id == "agent:literature-scout"`.
81    pub agent: String,
82    /// Model identifier (e.g. "claude-sonnet-4-6"). Free-form so the
83    /// substrate never has to enumerate model names.
84    #[serde(default, skip_serializing_if = "String::is_empty")]
85    pub model: String,
86    /// Run identifier — typically a UUID or short hash. Lets the
87    /// reviewer group multiple proposals that came out of the same
88    /// agent invocation.
89    #[serde(default, skip_serializing_if = "String::is_empty")]
90    pub run_id: String,
91    /// ISO-8601 wall-clock start of the run.
92    #[serde(default, skip_serializing_if = "String::is_empty")]
93    pub started_at: String,
94    /// ISO-8601 wall-clock end. Optional because some agents stream.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub finished_at: Option<String>,
97    /// Free-form context the reviewer should see — e.g. the input
98    /// folder path, the count of papers processed, the prompt
99    /// version. Kept as a flat string map so it round-trips cleanly
100    /// through canonical JSON without imposing a schema.
101    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
102    pub context: BTreeMap<String, String>,
103    /// v0.49: explicit tool-call traces from this run. Each entry
104    /// records one tool invocation by content-addressable summary
105    /// (tool name + input hash + output hash + duration). Lets a
106    /// reviewer see what the agent actually called without bloating
107    /// the bundle with raw payloads. Optional + skip-if-empty so
108    /// existing frontiers round-trip byte-identically.
109    #[serde(default, skip_serializing_if = "Vec::is_empty")]
110    pub tool_calls: Vec<ToolCallTrace>,
111    /// v0.49: declared permission state for this run. Lists the
112    /// data sources the agent had read access to and the tools it
113    /// could invoke. Reviewers compare this declaration against
114    /// `tool_calls` to spot drift. Optional + skip-if-empty so
115    /// existing frontiers round-trip byte-identically.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub permissions: Option<PermissionState>,
118}
119
120/// One tool invocation made during an `AgentRun`. Stored as a
121/// content-addressable summary, never the raw payload — keeps the
122/// bundle bounded while preserving "did this happen, with what
123/// inputs, returning what outputs" for reviewer audit. v0.49.
124#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
125pub struct ToolCallTrace {
126    /// Tool identifier (e.g. "pubmed_search", "arxiv_fetch", "compile").
127    pub tool: String,
128    /// SHA-256 hex of the canonical-JSON input. 64-char.
129    #[serde(default, skip_serializing_if = "String::is_empty")]
130    pub input_sha256: String,
131    /// SHA-256 hex of the canonical-JSON output. 64-char. Optional
132    /// for tools whose output is opaque (a side effect, a navigation,
133    /// etc.).
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub output_sha256: Option<String>,
136    /// ISO-8601 wall-clock start of the call.
137    #[serde(default, skip_serializing_if = "String::is_empty")]
138    pub at: String,
139    /// Wall-clock duration in milliseconds.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub duration_ms: Option<u32>,
142    /// Optional non-error status string (e.g. "ok", "rate_limited",
143    /// "partial"). Kept free-form so a tool layer can emit whatever
144    /// taxonomy it wants without protocol bumps.
145    #[serde(default, skip_serializing_if = "String::is_empty")]
146    pub status: String,
147    /// Optional human-readable error detail when `status` indicates a
148    /// failure. Free-form so tool layers can carry a stack frame, an
149    /// HTTP response body, or a one-line summary — whatever a
150    /// reviewer needs to audit what went wrong without re-running the
151    /// agent. Skipped when empty so successful calls round-trip
152    /// byte-identically.
153    #[serde(default, skip_serializing_if = "String::is_empty")]
154    pub error_message: String,
155}
156
157/// Declared permission boundary for an `AgentRun`. Lists what the
158/// agent could read and which tools it could call. Reviewers can
159/// diff this against `tool_calls` to spot scope creep. v0.49.
160#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
161pub struct PermissionState {
162    /// Data sources the agent had read access to. Free-form URIs:
163    /// `pubmed:`, `dataset:`, `frontier:vfr_…`, `path:./papers/…`.
164    #[serde(default, skip_serializing_if = "Vec::is_empty")]
165    pub data_access: Vec<String>,
166    /// Tool identifiers the agent was allowed to call. Should be the
167    /// allow-list `tool_calls[*].tool` is checked against by the
168    /// runtime.
169    #[serde(default, skip_serializing_if = "Vec::is_empty")]
170    pub tool_access: Vec<String>,
171    /// Optional human-readable note explaining the scope (e.g.
172    /// "read-only access to BBB Flagship; can call pubmed search
173    /// and arxiv fetch only"). Reviewer affordance only.
174    #[serde(default, skip_serializing_if = "String::is_empty")]
175    pub note: String,
176}
177
178#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
179pub struct ProposalSummary {
180    pub total: usize,
181    pub pending_review: usize,
182    pub accepted: usize,
183    pub rejected: usize,
184    pub applied: usize,
185    #[serde(default)]
186    pub by_kind: BTreeMap<String, usize>,
187    #[serde(default)]
188    pub duplicate_ids: Vec<String>,
189    #[serde(default)]
190    pub invalid_targets: Vec<String>,
191}
192
193#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
194pub struct ProofState {
195    #[serde(default)]
196    pub latest_packet: ProofPacketState,
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub last_event_at_export: Option<String>,
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub stale_reason: Option<String>,
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
204pub struct ProofPacketState {
205    pub generated_at: Option<String>,
206    pub snapshot_hash: Option<String>,
207    pub event_log_hash: Option<String>,
208    pub packet_manifest_hash: Option<String>,
209    pub status: String,
210}
211
212impl Default for ProofPacketState {
213    fn default() -> Self {
214        Self {
215            generated_at: None,
216            snapshot_hash: None,
217            event_log_hash: None,
218            packet_manifest_hash: None,
219            status: "never_exported".to_string(),
220        }
221    }
222}
223
224#[derive(Debug, Clone)]
225pub struct CreateProposalResult {
226    pub proposal_id: String,
227    pub finding_id: String,
228    pub status: String,
229    pub applied_event_id: Option<String>,
230}
231
232#[derive(Debug, Clone, Default)]
233pub struct ImportProposalReport {
234    pub imported: usize,
235    pub applied: usize,
236    pub rejected: usize,
237    pub duplicates: usize,
238    pub wrote_to: String,
239}
240
241#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
242pub struct ProposalValidationReport {
243    pub ok: bool,
244    pub checked: usize,
245    pub valid: usize,
246    pub invalid: usize,
247    #[serde(default)]
248    pub errors: Vec<String>,
249    #[serde(default)]
250    pub proposal_ids: Vec<String>,
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
254pub struct ProposalPreview {
255    pub proposal_id: String,
256    pub kind: String,
257    pub target: StateTarget,
258    pub reviewer: String,
259    #[serde(default)]
260    pub changed_findings: Vec<String>,
261    #[serde(default)]
262    pub changed_artifacts: Vec<String>,
263    #[serde(default)]
264    pub new_event_ids: Vec<String>,
265    #[serde(default)]
266    pub event_kinds: Vec<String>,
267    pub findings_before: usize,
268    pub findings_after: usize,
269    pub findings_delta: isize,
270    pub artifacts_before: usize,
271    pub artifacts_after: usize,
272    pub artifacts_delta: isize,
273    pub events_before: usize,
274    pub events_after: usize,
275    pub events_delta: isize,
276    pub proof_would_be_stale: bool,
277    pub applied_event_id: String,
278}
279
280#[derive(Debug, Clone)]
281pub struct ProofPacketRecord {
282    pub generated_at: String,
283    pub snapshot_hash: String,
284    pub event_log_hash: String,
285    pub packet_manifest_hash: String,
286}
287
288fn default_schema() -> String {
289    PROPOSAL_SCHEMA.to_string()
290}
291
292#[allow(clippy::too_many_arguments)]
293pub fn new_proposal(
294    kind: impl Into<String>,
295    target: StateTarget,
296    actor_id: impl Into<String>,
297    actor_type: impl Into<String>,
298    reason: impl Into<String>,
299    payload: Value,
300    source_refs: Vec<String>,
301    caveats: Vec<String>,
302) -> StateProposal {
303    let created_at = Utc::now().to_rfc3339();
304    let mut proposal = StateProposal {
305        schema: PROPOSAL_SCHEMA.to_string(),
306        id: String::new(),
307        kind: kind.into(),
308        target,
309        actor: StateActor {
310            id: actor_id.into(),
311            r#type: actor_type.into(),
312        },
313        created_at,
314        drafted_at: None,
315        reason: reason.into(),
316        payload,
317        source_refs,
318        status: "pending_review".to_string(),
319        reviewed_by: None,
320        reviewed_at: None,
321        decision_reason: None,
322        applied_event_id: None,
323        caveats,
324        agent_run: None,
325    };
326    proposal.id = proposal_id(&proposal);
327    proposal
328}
329
330/// Phase P (v0.5): `vpr_…` is content-addressed over the *logical* proposal
331/// content only — `created_at` is excluded from the preimage. Identical
332/// logical proposals (same actor, target, kind, reason, payload) deterministically
333/// produce the same proposal_id regardless of when they were constructed.
334///
335/// This is the substrate property that makes agent retries idempotent.
336/// `created_at` stays on the proposal as non-canonical metadata; replay-attack
337/// detection layers on the signed envelope, not the content hash.
338pub fn proposal_id(proposal: &StateProposal) -> String {
339    let preimage = json!({
340        "schema": proposal.schema,
341        "kind": proposal.kind,
342        "target": proposal.target,
343        "actor": proposal.actor,
344        "reason": proposal.reason,
345        "payload": proposal.payload,
346        "source_refs": proposal.source_refs,
347        "caveats": proposal.caveats,
348    });
349    let bytes = canonical::to_canonical_bytes(&preimage).unwrap_or_default();
350    format!("vpr_{}", &hex::encode(Sha256::digest(bytes))[..16])
351}
352
353pub fn is_placeholder_reviewer(value: &str) -> bool {
354    let normalized = value.trim().to_ascii_lowercase();
355    normalized.is_empty()
356        || normalized == "local-reviewer"
357        || normalized == "local-user"
358        || normalized == "reviewer"
359        || normalized == "user"
360        || normalized == "unknown"
361        || normalized.starts_with("local-")
362}
363
364pub fn validate_reviewer_identity(value: &str) -> Result<(), String> {
365    if is_placeholder_reviewer(value) {
366        return Err(format!(
367            "Reviewer identity '{}' is missing or placeholder. Use a stable named reviewer id.",
368            value
369        ));
370    }
371    Ok(())
372}
373
374pub fn summary(frontier: &Project) -> ProposalSummary {
375    let mut out = ProposalSummary::default();
376    let mut seen = BTreeSet::new();
377    let finding_ids = frontier
378        .findings
379        .iter()
380        .map(|finding| finding.id.as_str())
381        .collect::<BTreeSet<_>>();
382    let artifact_ids = frontier
383        .artifacts
384        .iter()
385        .map(|artifact| artifact.id.as_str())
386        .collect::<BTreeSet<_>>();
387    for proposal in &frontier.proposals {
388        out.total += 1;
389        *out.by_kind.entry(proposal.kind.clone()).or_default() += 1;
390        match proposal.status.as_str() {
391            "pending_review" => out.pending_review += 1,
392            "accepted" => out.accepted += 1,
393            "rejected" => out.rejected += 1,
394            "applied" => out.applied += 1,
395            _ => {}
396        }
397        if !seen.insert(proposal.id.clone()) {
398            out.duplicate_ids.push(proposal.id.clone());
399        }
400        let target_known = match proposal.target.r#type.as_str() {
401            "finding" => {
402                proposal.kind == "finding.add" || finding_ids.contains(proposal.target.id.as_str())
403            }
404            "artifact" => {
405                proposal.kind == "artifact.assert"
406                    || artifact_ids.contains(proposal.target.id.as_str())
407            }
408            _ => true,
409        };
410        if !target_known {
411            out.invalid_targets.push(proposal.target.id.clone());
412        }
413    }
414    out.duplicate_ids.sort();
415    out.duplicate_ids.dedup();
416    out.invalid_targets.sort();
417    out.invalid_targets.dedup();
418    out
419}
420
421pub fn proposals_for_finding<'a>(
422    frontier: &'a Project,
423    finding_id: &str,
424) -> Vec<&'a StateProposal> {
425    frontier
426        .proposals
427        .iter()
428        .filter(|proposal| proposal.target.r#type == "finding" && proposal.target.id == finding_id)
429        .collect()
430}
431
432/// Phase P (v0.5): upsert by content address. If a proposal with the same
433/// `vpr_…` already exists in the frontier, return the existing record instead
434/// of inserting a duplicate. Combined with the `created_at`-free preimage,
435/// this makes agent retries idempotent at the substrate level.
436///
437/// `apply` semantics are also idempotent: if the same proposal+reviewer pair
438/// has already been applied (proposal.applied_event_id is set), return the
439/// existing event_id rather than emitting a duplicate canonical event.
440pub fn create_or_apply(
441    path: &Path,
442    proposal: StateProposal,
443    apply: bool,
444) -> Result<CreateProposalResult, String> {
445    let mut frontier = repo::load_from_path(path)?;
446    let finding_id = proposal.target.id.clone();
447    let proposal_id = proposal.id.clone();
448
449    // Idempotent insert: if a proposal with this content-addressed id already
450    // exists, skip insertion and treat the existing record as authoritative.
451    let existing_idx = frontier
452        .proposals
453        .iter()
454        .position(|existing| existing.id == proposal_id);
455    if existing_idx.is_none() {
456        validate_new_proposal(&frontier, &proposal)?;
457        frontier.proposals.push(proposal);
458    }
459
460    let applied_event_id = if apply {
461        // Idempotent apply: if the existing record was already applied, return
462        // its event_id rather than emitting a duplicate event.
463        if let Some(idx) = existing_idx
464            && let Some(existing_event) = frontier.proposals[idx].applied_event_id.clone()
465        {
466            Some(existing_event)
467        } else {
468            let reviewer = frontier
469                .proposals
470                .iter()
471                .find(|proposal| proposal.id == proposal_id)
472                .map(|proposal| proposal.actor.id.clone())
473                .ok_or_else(|| format!("Proposal not found after insertion: {proposal_id}"))?;
474            Some(accept_proposal_in_frontier(
475                &mut frontier,
476                &proposal_id,
477                &reviewer,
478                "Applied locally from proposal creation",
479            )?)
480        }
481    } else {
482        existing_idx.and_then(|idx| frontier.proposals[idx].applied_event_id.clone())
483    };
484
485    // v0.13: materialize source/evidence/condition projections after every
486    // applied proposal so the lint surface stops emitting `missing_source_record`
487    // for findings whose provenance derives a SourceRecord that wasn't yet in
488    // `frontier.sources`. Pre-v0.13, `vela normalize --write` was the only path
489    // to populate these — but normalize refuses on event-ful frontiers, so any
490    // frontier built via CLI proposals could never reach proof-ready state.
491    // Materializing inline at apply time keeps source_records in lockstep with
492    // findings; when no finding state changed (caveat/note/review on existing
493    // findings) the projection is idempotent and bytes don't churn.
494    if applied_event_id.is_some() {
495        crate::sources::materialize_project(&mut frontier);
496    } else {
497        project::recompute_stats(&mut frontier);
498    }
499    repo::save_to_path(path, &frontier)?;
500    Ok(CreateProposalResult {
501        proposal_id,
502        finding_id,
503        status: applied_event_id
504            .as_ref()
505            .map_or_else(|| "pending_review".to_string(), |_| "applied".to_string()),
506        applied_event_id,
507    })
508}
509
510pub fn list(frontier: &Project, status: Option<&str>) -> Vec<StateProposal> {
511    let mut proposals = frontier
512        .proposals
513        .iter()
514        .filter(|proposal| status.is_none_or(|wanted| proposal.status == wanted))
515        .cloned()
516        .collect::<Vec<_>>();
517    proposals.sort_by(|a, b| a.created_at.cmp(&b.created_at).then(a.id.cmp(&b.id)));
518    proposals
519}
520
521pub fn show<'a>(frontier: &'a Project, proposal_id: &str) -> Result<&'a StateProposal, String> {
522    frontier
523        .proposals
524        .iter()
525        .find(|proposal| proposal.id == proposal_id)
526        .ok_or_else(|| format!("Proposal not found: {proposal_id}"))
527}
528
529pub fn preview_at_path(
530    path: &Path,
531    proposal_id: &str,
532    reviewer: &str,
533) -> Result<ProposalPreview, String> {
534    validate_reviewer_identity(reviewer)?;
535    let frontier = repo::load_from_path(path)?;
536    preview_in_frontier(&frontier, proposal_id, reviewer)
537}
538
539pub fn preview_in_frontier(
540    frontier: &Project,
541    proposal_id: &str,
542    reviewer: &str,
543) -> Result<ProposalPreview, String> {
544    validate_reviewer_identity(reviewer)?;
545    let proposal = frontier
546        .proposals
547        .iter()
548        .find(|proposal| proposal.id == proposal_id)
549        .ok_or_else(|| format!("Proposal not found: {proposal_id}"))?
550        .clone();
551    if proposal.status == "applied" {
552        let applied_event_id = proposal
553            .applied_event_id
554            .clone()
555            .ok_or_else(|| format!("Proposal {} is applied but has no event id", proposal.id))?;
556        return Ok(ProposalPreview {
557            proposal_id: proposal.id,
558            kind: proposal.kind,
559            changed_findings: changed_targets_for_type(frontier, &proposal.target, "finding"),
560            changed_artifacts: changed_targets_for_type(frontier, &proposal.target, "artifact"),
561            new_event_ids: vec![applied_event_id.clone()],
562            event_kinds: frontier
563                .events
564                .iter()
565                .find(|event| event.id == applied_event_id)
566                .map(|event| vec![event.kind.clone()])
567                .unwrap_or_default(),
568            target: proposal.target,
569            reviewer: reviewer.to_string(),
570            findings_before: frontier.findings.len(),
571            findings_after: frontier.findings.len(),
572            findings_delta: 0,
573            artifacts_before: frontier.artifacts.len(),
574            artifacts_after: frontier.artifacts.len(),
575            artifacts_delta: 0,
576            events_before: frontier.events.len(),
577            events_after: frontier.events.len(),
578            events_delta: 0,
579            proof_would_be_stale: false,
580            applied_event_id,
581        });
582    }
583    if !matches!(proposal.status.as_str(), "pending_review" | "accepted") {
584        return Err(format!(
585            "Proposal {} cannot be previewed from status {}",
586            proposal.id, proposal.status
587        ));
588    }
589    let mut preview_state: Project = serde_json::from_value(
590        serde_json::to_value(frontier).map_err(|e| format!("serialize frontier preview: {e}"))?,
591    )
592    .map_err(|e| format!("clone frontier preview: {e}"))?;
593    let finding_ids_before = preview_state
594        .findings
595        .iter()
596        .map(|finding| finding.id.clone())
597        .collect::<BTreeSet<_>>();
598    let artifact_ids_before = preview_state
599        .artifacts
600        .iter()
601        .map(|artifact| artifact.id.clone())
602        .collect::<BTreeSet<_>>();
603    let findings_before = preview_state.findings.len();
604    let artifacts_before = preview_state.artifacts.len();
605    let events_before = preview_state.events.len();
606    let event_id = apply_proposal(
607        &mut preview_state,
608        &proposal,
609        reviewer,
610        "Preview proposal application",
611    )?;
612    let findings_after = preview_state.findings.len();
613    let artifacts_after = preview_state.artifacts.len();
614    let events_after = preview_state.events.len();
615    let new_events = preview_state
616        .events
617        .iter()
618        .skip(events_before)
619        .cloned()
620        .collect::<Vec<_>>();
621    Ok(ProposalPreview {
622        proposal_id: proposal.id,
623        kind: proposal.kind,
624        target: proposal.target,
625        reviewer: reviewer.to_string(),
626        changed_findings: changed_finding_ids(&preview_state, &finding_ids_before, &new_events),
627        changed_artifacts: changed_artifact_ids(&preview_state, &artifact_ids_before, &new_events),
628        new_event_ids: new_events.iter().map(|event| event.id.clone()).collect(),
629        event_kinds: new_events.iter().map(|event| event.kind.clone()).collect(),
630        findings_before,
631        findings_after,
632        findings_delta: findings_after as isize - findings_before as isize,
633        artifacts_before,
634        artifacts_after,
635        artifacts_delta: artifacts_after as isize - artifacts_before as isize,
636        events_before,
637        events_after,
638        events_delta: events_after as isize - events_before as isize,
639        proof_would_be_stale: true,
640        applied_event_id: event_id,
641    })
642}
643
644fn changed_targets_for_type(
645    frontier: &Project,
646    target: &StateTarget,
647    target_type: &str,
648) -> Vec<String> {
649    let known = match target_type {
650        "finding" => frontier
651            .findings
652            .iter()
653            .any(|finding| finding.id == target.id),
654        "artifact" => frontier
655            .artifacts
656            .iter()
657            .any(|artifact| artifact.id == target.id),
658        _ => false,
659    };
660    if target.r#type == target_type && known {
661        vec![target.id.clone()]
662    } else {
663        Vec::new()
664    }
665}
666
667fn changed_finding_ids(
668    preview_state: &Project,
669    finding_ids_before: &BTreeSet<String>,
670    new_events: &[StateEvent],
671) -> Vec<String> {
672    let mut ids = preview_state
673        .findings
674        .iter()
675        .filter(|finding| !finding_ids_before.contains(&finding.id))
676        .map(|finding| finding.id.clone())
677        .collect::<BTreeSet<_>>();
678    for event in new_events {
679        if event.target.r#type == "finding" {
680            ids.insert(event.target.id.clone());
681        }
682    }
683    ids.into_iter().collect()
684}
685
686fn changed_artifact_ids(
687    preview_state: &Project,
688    artifact_ids_before: &BTreeSet<String>,
689    new_events: &[StateEvent],
690) -> Vec<String> {
691    let mut ids = preview_state
692        .artifacts
693        .iter()
694        .filter(|artifact| !artifact_ids_before.contains(&artifact.id))
695        .map(|artifact| artifact.id.clone())
696        .collect::<BTreeSet<_>>();
697    for event in new_events {
698        if event.target.r#type == "artifact" {
699            ids.insert(event.target.id.clone());
700        }
701    }
702    ids.into_iter().collect()
703}
704
705pub fn import_from_path(path: &Path, source: &Path) -> Result<ImportProposalReport, String> {
706    let mut frontier = repo::load_from_path(path)?;
707    let proposals = load_proposals(source)?;
708    let wrote_to = path.display().to_string();
709    let mut report = ImportProposalReport {
710        wrote_to,
711        ..ImportProposalReport::default()
712    };
713    for proposal in proposals {
714        if frontier
715            .proposals
716            .iter()
717            .any(|existing| existing.id == proposal.id)
718        {
719            report.duplicates += 1;
720            continue;
721        }
722        validate_new_proposal(&frontier, &proposal)?;
723        frontier.proposals.push(proposal.clone());
724        report.imported += 1;
725        match proposal.status.as_str() {
726            "accepted" => {
727                let reviewer = proposal
728                    .reviewed_by
729                    .as_deref()
730                    .ok_or_else(|| {
731                        format!("Accepted proposal {} missing reviewed_by", proposal.id)
732                    })?
733                    .to_string();
734                let reason = proposal
735                    .decision_reason
736                    .clone()
737                    .unwrap_or_else(|| "Imported accepted proposal".to_string());
738                let _ =
739                    accept_proposal_in_frontier(&mut frontier, &proposal.id, &reviewer, &reason)?;
740                report.applied += 1;
741            }
742            "applied" => {
743                let reviewer = proposal
744                    .reviewed_by
745                    .as_deref()
746                    .ok_or_else(|| format!("Applied proposal {} missing reviewed_by", proposal.id))?
747                    .to_string();
748                let reason = proposal
749                    .decision_reason
750                    .clone()
751                    .unwrap_or_else(|| "Imported applied proposal".to_string());
752                let _ =
753                    accept_proposal_in_frontier(&mut frontier, &proposal.id, &reviewer, &reason)?;
754                report.applied += 1;
755            }
756            "rejected" => report.rejected += 1,
757            _ => {}
758        }
759    }
760    project::recompute_stats(&mut frontier);
761    repo::save_to_path(path, &frontier)?;
762    Ok(report)
763}
764
765pub fn validate_source(source: &Path) -> Result<ProposalValidationReport, String> {
766    let proposals = load_proposals(source)?;
767    let mut report = ProposalValidationReport {
768        checked: proposals.len(),
769        ..ProposalValidationReport::default()
770    };
771    let scratch = project::assemble("proposal-validation", Vec::new(), 0, 0, "validate");
772    let mut seen = BTreeSet::new();
773    for proposal in proposals {
774        if !seen.insert(proposal.id.clone()) {
775            report.invalid += 1;
776            report
777                .errors
778                .push(format!("Duplicate proposal id {}", proposal.id));
779            continue;
780        }
781        report.proposal_ids.push(proposal.id.clone());
782        match validate_standalone_proposal(&scratch, &proposal) {
783            Ok(()) => report.valid += 1,
784            Err(err) => {
785                report.invalid += 1;
786                report.errors.push(format!("{}: {}", proposal.id, err));
787            }
788        }
789    }
790    report.ok = report.invalid == 0;
791    Ok(report)
792}
793
794pub fn export_to_path(
795    frontier_path: &Path,
796    output: &Path,
797    status: Option<&str>,
798) -> Result<usize, String> {
799    let frontier = repo::load_from_path(frontier_path)?;
800    let proposals = list(&frontier, status);
801    let json = serde_json::to_string_pretty(&proposals)
802        .map_err(|e| format!("Failed to serialize proposals for export: {e}"))?;
803    std::fs::write(output, json).map_err(|e| {
804        format!(
805            "Failed to write proposal export '{}': {e}",
806            output.display()
807        )
808    })?;
809    Ok(proposals.len())
810}
811
812pub fn accept_at_path(
813    path: &Path,
814    proposal_id: &str,
815    reviewer: &str,
816    reason: &str,
817) -> Result<String, String> {
818    let mut frontier = repo::load_from_path(path)?;
819    let event_id = accept_proposal_in_frontier(&mut frontier, proposal_id, reviewer, reason)?;
820    project::recompute_stats(&mut frontier);
821    repo::save_to_path(path, &frontier)?;
822    Ok(event_id)
823}
824
825pub fn reject_at_path(
826    path: &Path,
827    proposal_id: &str,
828    reviewer: &str,
829    reason: &str,
830) -> Result<(), String> {
831    let mut frontier = repo::load_from_path(path)?;
832    reject_proposal_in_frontier(&mut frontier, proposal_id, reviewer, reason)?;
833    project::recompute_stats(&mut frontier);
834    repo::save_to_path(path, &frontier)?;
835    Ok(())
836}
837
838pub fn request_revision_at_path(
839    path: &Path,
840    proposal_id: &str,
841    reviewer: &str,
842    reason: &str,
843) -> Result<(), String> {
844    let mut frontier = repo::load_from_path(path)?;
845    request_revision_in_frontier(&mut frontier, proposal_id, reviewer, reason)?;
846    project::recompute_stats(&mut frontier);
847    repo::save_to_path(path, &frontier)?;
848    Ok(())
849}
850
851pub fn record_proof_export(frontier: &mut Project, record: ProofPacketRecord) {
852    frontier.proof_state.latest_packet = ProofPacketState {
853        generated_at: Some(record.generated_at),
854        snapshot_hash: Some(record.snapshot_hash),
855        event_log_hash: Some(record.event_log_hash),
856        packet_manifest_hash: Some(record.packet_manifest_hash),
857        status: "current".to_string(),
858    };
859    frontier.proof_state.last_event_at_export =
860        frontier.events.last().map(|event| event.timestamp.clone());
861    frontier.proof_state.stale_reason = None;
862}
863
864pub fn mark_proof_stale(frontier: &mut Project, reason: String) {
865    if frontier.proof_state.latest_packet.status != "never_exported" {
866        frontier.proof_state.latest_packet.status = "stale".to_string();
867        frontier.proof_state.stale_reason = Some(reason);
868    }
869}
870
871pub fn proof_state_json(proof_state: &ProofState) -> Value {
872    serde_json::to_value(proof_state).unwrap_or_else(|_| json!({"status": "never_exported"}))
873}
874
875pub fn proposal_state_hash(proposals: &[StateProposal]) -> String {
876    let bytes = canonical::to_canonical_bytes(proposals).unwrap_or_default();
877    hex::encode(Sha256::digest(bytes))
878}
879
880fn load_proposals(source: &Path) -> Result<Vec<StateProposal>, String> {
881    if source.is_file() {
882        let data = std::fs::read_to_string(source)
883            .map_err(|e| format!("Failed to read proposal file '{}': {e}", source.display()))?;
884        if let Ok(proposals) = serde_json::from_str::<Vec<StateProposal>>(&data) {
885            return Ok(proposals);
886        }
887        let proposal = serde_json::from_str::<StateProposal>(&data)
888            .map_err(|e| format!("Failed to parse proposal JSON '{}': {e}", source.display()))?;
889        return Ok(vec![proposal]);
890    }
891    if source.is_dir() {
892        let mut entries = std::fs::read_dir(source)
893            .map_err(|e| format!("Failed to read proposal dir '{}': {e}", source.display()))?
894            .filter_map(|entry| entry.ok().map(|entry| entry.path()))
895            .filter(|path| path.extension().is_some_and(|ext| ext == "json"))
896            .collect::<Vec<_>>();
897        entries.sort();
898        let mut proposals = Vec::new();
899        for path in entries {
900            proposals.extend(load_proposals(&path)?);
901        }
902        return Ok(proposals);
903    }
904    Err(format!(
905        "Proposal source does not exist: {}",
906        source.display()
907    ))
908}
909
910fn validate_new_proposal(frontier: &Project, proposal: &StateProposal) -> Result<(), String> {
911    if proposal.schema != PROPOSAL_SCHEMA {
912        return Err(format!("Unsupported proposal schema '{}'", proposal.schema));
913    }
914    if frontier
915        .proposals
916        .iter()
917        .any(|existing| existing.id == proposal.id)
918    {
919        return Err(format!("Duplicate proposal id {}", proposal.id));
920    }
921    validate_proposal_shape(frontier, proposal)?;
922    validate_decision_state(proposal)
923}
924
925fn validate_proposal_shape(frontier: &Project, proposal: &StateProposal) -> Result<(), String> {
926    // v0.52: relax the finding-only constraint so the agent inbox
927    // can deposit nulls and trajectories through the same review-
928    // gated flow as findings. The proposal-kind dispatch below
929    // enforces that target.type matches the kind family.
930    if !matches!(
931        proposal.target.r#type.as_str(),
932        "finding"
933            | "artifact"
934            | "negative_result"
935            | "trajectory"
936            | "evidence_atom"
937            | "frontier_observation"
938    ) {
939        return Err(format!(
940            "Unsupported proposal target type '{}'; valid: finding, artifact, negative_result, trajectory, evidence_atom, frontier_observation",
941            proposal.target.r#type
942        ));
943    }
944    if proposal.reason.trim().is_empty() {
945        return Err("Proposal reason must be non-empty".to_string());
946    }
947    if !matches!(
948        proposal.status.as_str(),
949        "pending_review" | "accepted" | "rejected" | "applied"
950    ) {
951        return Err(format!("Unsupported proposal status '{}'", proposal.status));
952    }
953    match proposal.kind.as_str() {
954        "finding.add" => {
955            let finding_value = proposal
956                .payload
957                .get("finding")
958                .ok_or("finding.add proposal missing payload.finding")?
959                .clone();
960            let finding: FindingBundle = serde_json::from_value(finding_value)
961                .map_err(|e| format!("Invalid finding.add payload: {e}"))?;
962            if finding.id != proposal.target.id {
963                return Err(format!(
964                    "finding.add target {} does not match payload finding {}",
965                    proposal.target.id, finding.id
966                ));
967            }
968            if frontier
969                .findings
970                .iter()
971                .any(|existing| existing.id == proposal.target.id)
972            {
973                return Err(format!(
974                    "Refusing to add duplicate finding with existing finding ID {}",
975                    proposal.target.id
976                ));
977            }
978        }
979        "finding.review" => {
980            require_existing_finding(frontier, &proposal.target.id)?;
981            let status = proposal
982                .payload
983                .get("status")
984                .and_then(Value::as_str)
985                .ok_or("finding.review proposal missing payload.status")?;
986            if !matches!(
987                status,
988                "accepted" | "approved" | "contested" | "needs_revision" | "rejected"
989            ) {
990                return Err(format!("Unsupported review proposal status '{status}'"));
991            }
992        }
993        "finding.caveat" => {
994            require_existing_finding(frontier, &proposal.target.id)?;
995            let text = proposal
996                .payload
997                .get("text")
998                .and_then(Value::as_str)
999                .ok_or("finding.caveat proposal missing payload.text")?;
1000            if text.trim().is_empty() {
1001                return Err("finding.caveat payload.text must be non-empty".to_string());
1002            }
1003        }
1004        "finding.note" => {
1005            require_existing_finding(frontier, &proposal.target.id)?;
1006            let text = proposal
1007                .payload
1008                .get("text")
1009                .and_then(Value::as_str)
1010                .ok_or("finding.note proposal missing payload.text")?;
1011            if text.trim().is_empty() {
1012                return Err("finding.note payload.text must be non-empty".to_string());
1013            }
1014        }
1015        "finding.confidence_revise" => {
1016            require_existing_finding(frontier, &proposal.target.id)?;
1017            let score = proposal
1018                .payload
1019                .get("confidence")
1020                .and_then(Value::as_f64)
1021                .ok_or("finding.confidence_revise proposal missing payload.confidence")?;
1022            if !(0.0..=1.0).contains(&score) {
1023                return Err(
1024                    "finding.confidence_revise confidence must be between 0.0 and 1.0".to_string(),
1025                );
1026            }
1027        }
1028        "finding.reject" => {
1029            require_existing_finding(frontier, &proposal.target.id)?;
1030        }
1031        "finding.retract" => {
1032            let idx = require_existing_finding(frontier, &proposal.target.id)?;
1033            if frontier.findings[idx].flags.retracted {
1034                return Err(format!(
1035                    "Finding {} is already retracted",
1036                    proposal.target.id
1037                ));
1038            }
1039        }
1040        "finding.supersede" => {
1041            let idx = require_existing_finding(frontier, &proposal.target.id)?;
1042            if frontier.findings[idx].flags.superseded {
1043                return Err(format!(
1044                    "Finding {} is already superseded",
1045                    proposal.target.id
1046                ));
1047            }
1048            let new_finding_value = proposal
1049                .payload
1050                .get("new_finding")
1051                .ok_or("finding.supersede proposal missing payload.new_finding")?
1052                .clone();
1053            let new_finding: FindingBundle = serde_json::from_value(new_finding_value)
1054                .map_err(|e| format!("Invalid finding.supersede payload.new_finding: {e}"))?;
1055            if new_finding.id == proposal.target.id {
1056                return Err(
1057                    "finding.supersede new_finding has same content address as the superseded target — change assertion text, type, or provenance to derive a distinct vf_…".to_string(),
1058                );
1059            }
1060            if frontier
1061                .findings
1062                .iter()
1063                .any(|existing| existing.id == new_finding.id)
1064            {
1065                return Err(format!(
1066                    "Refusing to add superseding finding with existing finding ID {}",
1067                    new_finding.id
1068                ));
1069            }
1070        }
1071        "artifact.assert" => {
1072            if proposal.target.r#type != "artifact" {
1073                return Err(format!(
1074                    "artifact.assert proposal target.type must be 'artifact', got '{}'",
1075                    proposal.target.r#type
1076                ));
1077            }
1078            let artifact_value = proposal
1079                .payload
1080                .get("artifact")
1081                .ok_or("artifact.assert proposal missing payload.artifact")?
1082                .clone();
1083            let artifact: Artifact = serde_json::from_value(artifact_value)
1084                .map_err(|e| format!("Invalid artifact.assert payload: {e}"))?;
1085            if artifact.id != proposal.target.id {
1086                return Err(format!(
1087                    "artifact.assert target {} does not match payload id {}",
1088                    proposal.target.id, artifact.id
1089                ));
1090            }
1091            if frontier.artifacts.iter().any(|a| a.id == artifact.id) {
1092                return Err(format!(
1093                    "Refusing to add duplicate artifact with existing id {}",
1094                    artifact.id
1095                ));
1096            }
1097        }
1098        // v0.52: NegativeResult deposit through the proposals
1099        // pipeline. Mirrors finding.add: payload.negative_result
1100        // carries the inline NegativeResult struct; target.id is the
1101        // resulting vnr_*. Validators here are the proposal-side
1102        // shape check; the canonical event validator in events.rs
1103        // re-checks at event-emit time.
1104        "negative_result.assert" => {
1105            if proposal.target.r#type != "negative_result" {
1106                return Err(format!(
1107                    "negative_result.assert proposal target.type must be 'negative_result', got '{}'",
1108                    proposal.target.r#type
1109                ));
1110            }
1111            let nr_value = proposal
1112                .payload
1113                .get("negative_result")
1114                .ok_or("negative_result.assert proposal missing payload.negative_result")?
1115                .clone();
1116            let nr: crate::bundle::NegativeResult = serde_json::from_value(nr_value)
1117                .map_err(|e| format!("Invalid negative_result.assert payload: {e}"))?;
1118            if nr.id != proposal.target.id {
1119                return Err(format!(
1120                    "negative_result.assert target {} does not match payload id {}",
1121                    proposal.target.id, nr.id
1122                ));
1123            }
1124            if frontier.negative_results.iter().any(|n| n.id == nr.id) {
1125                return Err(format!(
1126                    "Refusing to add duplicate negative_result with existing id {}",
1127                    nr.id
1128                ));
1129            }
1130        }
1131        // v0.52: Trajectory deposit through the proposals pipeline.
1132        // payload.trajectory carries the inline Trajectory (with
1133        // empty steps); steps land later via separate
1134        // `trajectory.step_append` proposals.
1135        "trajectory.create" => {
1136            if proposal.target.r#type != "trajectory" {
1137                return Err(format!(
1138                    "trajectory.create proposal target.type must be 'trajectory', got '{}'",
1139                    proposal.target.r#type
1140                ));
1141            }
1142            let traj_value = proposal
1143                .payload
1144                .get("trajectory")
1145                .ok_or("trajectory.create proposal missing payload.trajectory")?
1146                .clone();
1147            let traj: crate::bundle::Trajectory = serde_json::from_value(traj_value)
1148                .map_err(|e| format!("Invalid trajectory.create payload: {e}"))?;
1149            if traj.id != proposal.target.id {
1150                return Err(format!(
1151                    "trajectory.create target {} does not match payload id {}",
1152                    proposal.target.id, traj.id
1153                ));
1154            }
1155            if frontier.trajectories.iter().any(|t| t.id == traj.id) {
1156                return Err(format!(
1157                    "Refusing to add duplicate trajectory with existing id {}",
1158                    traj.id
1159                ));
1160            }
1161        }
1162        // v0.57: Mechanical finding-level span repair. Appends a
1163        // `{section, text}` span to the finding's evidence_spans.
1164        "finding.span_repair" => {
1165            if proposal.target.r#type != "finding" {
1166                return Err(format!(
1167                    "finding.span_repair target.type must be 'finding', got '{}'",
1168                    proposal.target.r#type
1169                ));
1170            }
1171            require_existing_finding(frontier, &proposal.target.id)?;
1172            let section = proposal
1173                .payload
1174                .get("section")
1175                .and_then(Value::as_str)
1176                .ok_or("finding.span_repair proposal missing payload.section")?;
1177            if section.trim().is_empty() {
1178                return Err("finding.span_repair payload.section must be non-empty".to_string());
1179            }
1180            let text = proposal
1181                .payload
1182                .get("text")
1183                .and_then(Value::as_str)
1184                .ok_or("finding.span_repair proposal missing payload.text")?;
1185            if text.trim().is_empty() {
1186                return Err("finding.span_repair payload.text must be non-empty".to_string());
1187            }
1188        }
1189        // v0.57: Entity resolution on a single named entity inside a
1190        // finding's assertion.entities. Sets canonical_id and
1191        // resolution metadata; clears needs_review.
1192        "finding.entity_resolve" => {
1193            if proposal.target.r#type != "finding" {
1194                return Err(format!(
1195                    "finding.entity_resolve target.type must be 'finding', got '{}'",
1196                    proposal.target.r#type
1197                ));
1198            }
1199            let f_idx = require_existing_finding(frontier, &proposal.target.id)?;
1200            let entity_name = proposal
1201                .payload
1202                .get("entity_name")
1203                .and_then(Value::as_str)
1204                .ok_or("finding.entity_resolve proposal missing payload.entity_name")?;
1205            if entity_name.trim().is_empty() {
1206                return Err(
1207                    "finding.entity_resolve payload.entity_name must be non-empty".to_string(),
1208                );
1209            }
1210            let _e_idx = frontier.findings[f_idx]
1211                .assertion
1212                .entities
1213                .iter()
1214                .position(|e| e.name == entity_name)
1215                .ok_or_else(|| {
1216                    format!(
1217                        "finding.entity_resolve entity_name '{entity_name}' not in finding {}",
1218                        proposal.target.id
1219                    )
1220                })?;
1221            let source = proposal
1222                .payload
1223                .get("source")
1224                .and_then(Value::as_str)
1225                .ok_or("finding.entity_resolve proposal missing payload.source")?;
1226            if source.trim().is_empty() {
1227                return Err("finding.entity_resolve payload.source must be non-empty".to_string());
1228            }
1229            let id = proposal
1230                .payload
1231                .get("id")
1232                .and_then(Value::as_str)
1233                .ok_or("finding.entity_resolve proposal missing payload.id")?;
1234            if id.trim().is_empty() {
1235                return Err("finding.entity_resolve payload.id must be non-empty".to_string());
1236            }
1237            let confidence = proposal
1238                .payload
1239                .get("confidence")
1240                .and_then(Value::as_f64)
1241                .ok_or("finding.entity_resolve proposal missing payload.confidence")?;
1242            if !(0.0..=1.0).contains(&confidence) {
1243                return Err(format!(
1244                    "finding.entity_resolve confidence {confidence} out of [0.0, 1.0]"
1245                ));
1246            }
1247        }
1248        // v0.79: Append a new entity tag to an existing finding.
1249        // Closes the v0.78.4 honest gap where reviewers had to
1250        // append new findings to add tags. Required payload:
1251        // {entity_name, entity_type, reason}; the proposal validates
1252        // that the target finding exists. The reducer's apply is
1253        // idempotent on (finding_id, entity_name): re-applying with
1254        // the same name + type is a no-op.
1255        "finding.entity_add" => {
1256            if proposal.target.r#type != "finding" {
1257                return Err(format!(
1258                    "finding.entity_add target.type must be 'finding', got '{}'",
1259                    proposal.target.r#type
1260                ));
1261            }
1262            let _f_idx = require_existing_finding(frontier, &proposal.target.id)?;
1263            let entity_name = proposal
1264                .payload
1265                .get("entity_name")
1266                .and_then(Value::as_str)
1267                .ok_or("finding.entity_add proposal missing payload.entity_name")?;
1268            if entity_name.trim().is_empty() {
1269                return Err("finding.entity_add payload.entity_name must be non-empty".to_string());
1270            }
1271            let entity_type = proposal
1272                .payload
1273                .get("entity_type")
1274                .and_then(Value::as_str)
1275                .ok_or("finding.entity_add proposal missing payload.entity_type")?;
1276            const VALID_ENTITY_TYPES: &[&str] = &[
1277                "gene",
1278                "protein",
1279                "compound",
1280                "disease",
1281                "cell_type",
1282                "organism",
1283                "pathway",
1284                "assay",
1285                "anatomical_structure",
1286                "particle",
1287                "instrument",
1288                "dataset",
1289                "quantity",
1290                "other",
1291            ];
1292            if !VALID_ENTITY_TYPES.contains(&entity_type) {
1293                return Err(format!(
1294                    "finding.entity_add payload.entity_type '{entity_type}' not in {VALID_ENTITY_TYPES:?}"
1295                ));
1296            }
1297            let reason_text = proposal
1298                .payload
1299                .get("reason")
1300                .and_then(Value::as_str)
1301                .ok_or("finding.entity_add proposal missing payload.reason")?;
1302            if reason_text.trim().is_empty() {
1303                return Err("finding.entity_add payload.reason must be non-empty".to_string());
1304            }
1305        }
1306        // v0.56: Mechanical evidence-atom locator repair. Targets one
1307        // evidence atom by id; payload carries the resolved locator
1308        // string and the parent source id it was derived from. The
1309        // proposal is mechanical: the locator is already present on
1310        // `frontier.sources[atom.source_id].locator`. Reviewer accepts
1311        // (or auto-accepts) and the canonical event lands the locator
1312        // on the atom while preserving the derivation in the payload.
1313        "evidence_atom.locator_repair" => {
1314            if proposal.target.r#type != "evidence_atom" {
1315                return Err(format!(
1316                    "evidence_atom.locator_repair target.type must be 'evidence_atom', got '{}'",
1317                    proposal.target.r#type
1318                ));
1319            }
1320            let atom_id = proposal.target.id.as_str();
1321            let atom = frontier
1322                .evidence_atoms
1323                .iter()
1324                .find(|atom| atom.id == atom_id)
1325                .ok_or_else(|| {
1326                    format!("evidence_atom.locator_repair targets unknown atom {atom_id}")
1327                })?;
1328            let locator = proposal
1329                .payload
1330                .get("locator")
1331                .and_then(Value::as_str)
1332                .ok_or("evidence_atom.locator_repair proposal missing payload.locator")?;
1333            if locator.trim().is_empty() {
1334                return Err(
1335                    "evidence_atom.locator_repair payload.locator must be non-empty".to_string(),
1336                );
1337            }
1338            let source_id = proposal
1339                .payload
1340                .get("source_id")
1341                .and_then(Value::as_str)
1342                .ok_or("evidence_atom.locator_repair proposal missing payload.source_id")?;
1343            if source_id.trim().is_empty() {
1344                return Err(
1345                    "evidence_atom.locator_repair payload.source_id must be non-empty".to_string(),
1346                );
1347            }
1348            if atom.source_id != source_id {
1349                return Err(format!(
1350                    "evidence_atom.locator_repair payload.source_id '{source_id}' does not match atom.source_id '{}'",
1351                    atom.source_id
1352                ));
1353            }
1354            // Refuse a no-op repair so the curation pipeline doesn't
1355            // emit empty events. An atom that already carries the same
1356            // locator should be filtered upstream.
1357            if let Some(existing) = &atom.locator
1358                && existing == locator
1359            {
1360                return Err(format!(
1361                    "evidence_atom {atom_id} already carries locator '{existing}'"
1362                ));
1363            }
1364            // Refuse a divergent overwrite. A different existing
1365            // locator is a chain-integrity issue, not a repair.
1366            if let Some(existing) = &atom.locator
1367                && existing != locator
1368            {
1369                return Err(format!(
1370                    "evidence_atom {atom_id} already carries locator '{existing}'; refusing to overwrite with '{locator}'"
1371                ));
1372            }
1373        }
1374        // v0.52: Append a step to an existing Trajectory through the
1375        // proposals pipeline. target.id is the parent vtr_*; payload
1376        // carries the inline TrajectoryStep.
1377        "trajectory.step_append" => {
1378            if proposal.target.r#type != "trajectory" {
1379                return Err(format!(
1380                    "trajectory.step_append proposal target.type must be 'trajectory', got '{}'",
1381                    proposal.target.r#type
1382                ));
1383            }
1384            let parent_id = proposal.target.id.as_str();
1385            let parent_idx = frontier
1386                .trajectories
1387                .iter()
1388                .position(|t| t.id == parent_id)
1389                .ok_or_else(|| {
1390                    format!("trajectory.step_append targets unknown trajectory {parent_id}")
1391                })?;
1392            let step_value = proposal
1393                .payload
1394                .get("step")
1395                .ok_or("trajectory.step_append proposal missing payload.step")?
1396                .clone();
1397            let step: crate::bundle::TrajectoryStep = serde_json::from_value(step_value)
1398                .map_err(|e| format!("Invalid trajectory.step_append payload.step: {e}"))?;
1399            if frontier.trajectories[parent_idx]
1400                .steps
1401                .iter()
1402                .any(|s| s.id == step.id)
1403            {
1404                return Err(format!(
1405                    "Refusing to add duplicate step with existing id {} on trajectory {}",
1406                    step.id, parent_id
1407                ));
1408            }
1409        }
1410        // v0.59: federation conflict resolution. Reviewer-driven
1411        // verdict on a previously emitted `frontier.conflict_detected`
1412        // event. The conflict event itself is not modified; this
1413        // proposal records the resolution as a paired event.
1414        "frontier.conflict_resolve" => {
1415            if proposal.target.r#type != "frontier_observation" {
1416                return Err(format!(
1417                    "frontier.conflict_resolve target.type must be 'frontier_observation', got '{}'",
1418                    proposal.target.r#type
1419                ));
1420            }
1421            let conflict_event_id = proposal
1422                .payload
1423                .get("conflict_event_id")
1424                .and_then(Value::as_str)
1425                .ok_or("frontier.conflict_resolve proposal missing payload.conflict_event_id")?;
1426            if conflict_event_id.trim().is_empty() {
1427                return Err(
1428                    "frontier.conflict_resolve payload.conflict_event_id must be non-empty"
1429                        .to_string(),
1430                );
1431            }
1432            // The named conflict event must actually be present on
1433            // this frontier. A reviewer can't resolve a conflict that
1434            // hasn't been detected.
1435            let conflict_event = frontier
1436                .events
1437                .iter()
1438                .find(|e| e.id == conflict_event_id)
1439                .ok_or_else(|| {
1440                    format!(
1441                        "frontier.conflict_resolve targets unknown event id '{conflict_event_id}'"
1442                    )
1443                })?;
1444            if conflict_event.kind != "frontier.conflict_detected" {
1445                return Err(format!(
1446                    "frontier.conflict_resolve target event '{conflict_event_id}' has kind '{}', expected 'frontier.conflict_detected'",
1447                    conflict_event.kind
1448                ));
1449            }
1450            // Refuse double-resolution: if a `frontier.conflict_resolved`
1451            // event already exists pointing at this conflict_event_id,
1452            // there's nothing to resolve.
1453            if frontier.events.iter().any(|e| {
1454                e.kind == "frontier.conflict_resolved"
1455                    && e.payload.get("conflict_event_id").and_then(Value::as_str)
1456                        == Some(conflict_event_id)
1457            }) {
1458                return Err(format!(
1459                    "Conflict event '{conflict_event_id}' already has a recorded resolution"
1460                ));
1461            }
1462            let note = proposal
1463                .payload
1464                .get("resolution_note")
1465                .and_then(Value::as_str)
1466                .ok_or("frontier.conflict_resolve proposal missing payload.resolution_note")?;
1467            if note.trim().is_empty() {
1468                return Err(
1469                    "frontier.conflict_resolve payload.resolution_note must be non-empty"
1470                        .to_string(),
1471                );
1472            }
1473            // winning_proposal_id is optional; some conflicts resolve
1474            // by reviewer judgment without picking a specific proposal.
1475            if let Some(value) = proposal.payload.get("winning_proposal_id")
1476                && !value.is_null()
1477                && value.as_str().is_none()
1478            {
1479                return Err(
1480                    "frontier.conflict_resolve payload.winning_proposal_id must be a string when present"
1481                        .to_string(),
1482                );
1483            }
1484        }
1485        other => {
1486            return Err(format!("Unsupported proposal kind '{other}'"));
1487        }
1488    }
1489    Ok(())
1490}
1491
1492fn validate_decision_state(proposal: &StateProposal) -> Result<(), String> {
1493    match proposal.status.as_str() {
1494        "pending_review" => Ok(()),
1495        "accepted" | "applied" | "rejected" => {
1496            let reviewer = proposal
1497                .reviewed_by
1498                .as_deref()
1499                .ok_or_else(|| format!("Proposal {} missing reviewed_by", proposal.id))?;
1500            validate_reviewer_identity(reviewer)?;
1501            if proposal
1502                .decision_reason
1503                .as_deref()
1504                .is_none_or(|reason| reason.trim().is_empty())
1505            {
1506                return Err(format!("Proposal {} missing decision_reason", proposal.id));
1507            }
1508            if proposal.status == "applied" && proposal.applied_event_id.is_none() {
1509                return Err(format!(
1510                    "Applied proposal {} missing applied_event_id",
1511                    proposal.id
1512                ));
1513            }
1514            Ok(())
1515        }
1516        other => Err(format!("Unsupported proposal status '{}'", other)),
1517    }
1518}
1519
1520fn validate_standalone_proposal(
1521    _frontier: &Project,
1522    proposal: &StateProposal,
1523) -> Result<(), String> {
1524    if proposal.schema != PROPOSAL_SCHEMA {
1525        return Err(format!("Unsupported proposal schema '{}'", proposal.schema));
1526    }
1527    if !matches!(
1528        proposal.target.r#type.as_str(),
1529        "finding" | "evidence_atom" | "frontier_observation"
1530    ) {
1531        return Err(
1532            "Only finding, evidence_atom, and frontier_observation proposals are supported in v0"
1533                .to_string(),
1534        );
1535    }
1536    if proposal.reason.trim().is_empty() {
1537        return Err("Proposal reason must be non-empty".to_string());
1538    }
1539    match proposal.kind.as_str() {
1540        "finding.add" => {
1541            let finding_value = proposal
1542                .payload
1543                .get("finding")
1544                .ok_or("finding.add proposal missing payload.finding")?
1545                .clone();
1546            let finding: FindingBundle = serde_json::from_value(finding_value)
1547                .map_err(|e| format!("Invalid finding.add payload: {e}"))?;
1548            if finding.id != proposal.target.id {
1549                return Err(format!(
1550                    "finding.add target {} does not match payload finding {}",
1551                    proposal.target.id, finding.id
1552                ));
1553            }
1554        }
1555        "finding.review" => {
1556            let status = proposal
1557                .payload
1558                .get("status")
1559                .and_then(Value::as_str)
1560                .ok_or("finding.review proposal missing payload.status")?;
1561            if !matches!(
1562                status,
1563                "accepted" | "approved" | "contested" | "needs_revision" | "rejected"
1564            ) {
1565                return Err(format!("Unsupported review proposal status '{status}'"));
1566            }
1567        }
1568        "finding.caveat" => {
1569            let text = proposal
1570                .payload
1571                .get("text")
1572                .and_then(Value::as_str)
1573                .ok_or("finding.caveat proposal missing payload.text")?;
1574            if text.trim().is_empty() {
1575                return Err("finding.caveat payload.text must be non-empty".to_string());
1576            }
1577        }
1578        "finding.note" => {
1579            let text = proposal
1580                .payload
1581                .get("text")
1582                .and_then(Value::as_str)
1583                .ok_or("finding.note proposal missing payload.text")?;
1584            if text.trim().is_empty() {
1585                return Err("finding.note payload.text must be non-empty".to_string());
1586            }
1587        }
1588        "finding.confidence_revise" => {
1589            let score = proposal
1590                .payload
1591                .get("confidence")
1592                .and_then(Value::as_f64)
1593                .ok_or("finding.confidence_revise proposal missing payload.confidence")?;
1594            if !(0.0..=1.0).contains(&score) {
1595                return Err(
1596                    "finding.confidence_revise confidence must be between 0.0 and 1.0".to_string(),
1597                );
1598            }
1599        }
1600        "finding.reject" | "finding.retract" => {}
1601        "finding.supersede" => {
1602            let new_finding_value = proposal
1603                .payload
1604                .get("new_finding")
1605                .ok_or("finding.supersede proposal missing payload.new_finding")?
1606                .clone();
1607            let new_finding: FindingBundle = serde_json::from_value(new_finding_value)
1608                .map_err(|e| format!("Invalid finding.supersede payload.new_finding: {e}"))?;
1609            if new_finding.id == proposal.target.id {
1610                return Err(
1611                    "finding.supersede new_finding has same content address as the superseded target"
1612                        .to_string(),
1613                );
1614            }
1615        }
1616        // v0.57: standalone validation of finding span-repair.
1617        "finding.span_repair" => {
1618            if proposal.target.r#type != "finding" {
1619                return Err(format!(
1620                    "finding.span_repair target.type must be 'finding', got '{}'",
1621                    proposal.target.r#type
1622                ));
1623            }
1624            let section = proposal
1625                .payload
1626                .get("section")
1627                .and_then(Value::as_str)
1628                .ok_or("finding.span_repair proposal missing payload.section")?;
1629            if section.trim().is_empty() {
1630                return Err("finding.span_repair payload.section must be non-empty".to_string());
1631            }
1632            let text = proposal
1633                .payload
1634                .get("text")
1635                .and_then(Value::as_str)
1636                .ok_or("finding.span_repair proposal missing payload.text")?;
1637            if text.trim().is_empty() {
1638                return Err("finding.span_repair payload.text must be non-empty".to_string());
1639            }
1640        }
1641        // v0.57: standalone validation of finding entity-resolve.
1642        "finding.entity_resolve" => {
1643            if proposal.target.r#type != "finding" {
1644                return Err(format!(
1645                    "finding.entity_resolve target.type must be 'finding', got '{}'",
1646                    proposal.target.r#type
1647                ));
1648            }
1649            let entity_name = proposal
1650                .payload
1651                .get("entity_name")
1652                .and_then(Value::as_str)
1653                .ok_or("finding.entity_resolve proposal missing payload.entity_name")?;
1654            if entity_name.trim().is_empty() {
1655                return Err(
1656                    "finding.entity_resolve payload.entity_name must be non-empty".to_string(),
1657                );
1658            }
1659            let source = proposal
1660                .payload
1661                .get("source")
1662                .and_then(Value::as_str)
1663                .ok_or("finding.entity_resolve proposal missing payload.source")?;
1664            if source.trim().is_empty() {
1665                return Err("finding.entity_resolve payload.source must be non-empty".to_string());
1666            }
1667            let id = proposal
1668                .payload
1669                .get("id")
1670                .and_then(Value::as_str)
1671                .ok_or("finding.entity_resolve proposal missing payload.id")?;
1672            if id.trim().is_empty() {
1673                return Err("finding.entity_resolve payload.id must be non-empty".to_string());
1674            }
1675            let confidence = proposal
1676                .payload
1677                .get("confidence")
1678                .and_then(Value::as_f64)
1679                .ok_or("finding.entity_resolve proposal missing payload.confidence")?;
1680            if !(0.0..=1.0).contains(&confidence) {
1681                return Err(format!(
1682                    "finding.entity_resolve confidence {confidence} out of [0.0, 1.0]"
1683                ));
1684            }
1685        }
1686        // v0.79: standalone validation of finding.entity_add. Same
1687        // payload shape as the contextual validator, sans
1688        // finding-existence check.
1689        "finding.entity_add" => {
1690            if proposal.target.r#type != "finding" {
1691                return Err(format!(
1692                    "finding.entity_add target.type must be 'finding', got '{}'",
1693                    proposal.target.r#type
1694                ));
1695            }
1696            let entity_name = proposal
1697                .payload
1698                .get("entity_name")
1699                .and_then(Value::as_str)
1700                .ok_or("finding.entity_add proposal missing payload.entity_name")?;
1701            if entity_name.trim().is_empty() {
1702                return Err("finding.entity_add payload.entity_name must be non-empty".to_string());
1703            }
1704            let entity_type = proposal
1705                .payload
1706                .get("entity_type")
1707                .and_then(Value::as_str)
1708                .ok_or("finding.entity_add proposal missing payload.entity_type")?;
1709            const VALID_ENTITY_TYPES: &[&str] = &[
1710                "gene",
1711                "protein",
1712                "compound",
1713                "disease",
1714                "cell_type",
1715                "organism",
1716                "pathway",
1717                "assay",
1718                "anatomical_structure",
1719                "particle",
1720                "instrument",
1721                "dataset",
1722                "quantity",
1723                "other",
1724            ];
1725            if !VALID_ENTITY_TYPES.contains(&entity_type) {
1726                return Err(format!(
1727                    "finding.entity_add payload.entity_type '{entity_type}' not in {VALID_ENTITY_TYPES:?}"
1728                ));
1729            }
1730            let reason = proposal
1731                .payload
1732                .get("reason")
1733                .and_then(Value::as_str)
1734                .ok_or("finding.entity_add proposal missing payload.reason")?;
1735            if reason.trim().is_empty() {
1736                return Err("finding.entity_add payload.reason must be non-empty".to_string());
1737            }
1738        }
1739        // v0.56: standalone validation of an evidence-atom locator
1740        // repair. Mirrors the contextual validator in
1741        // `validate_proposal_shape`, except without frontier-side
1742        // existence checks (the standalone validator runs over an
1743        // exported proposal before it is loaded into a frontier).
1744        "evidence_atom.locator_repair" => {
1745            if proposal.target.r#type != "evidence_atom" {
1746                return Err(format!(
1747                    "evidence_atom.locator_repair target.type must be 'evidence_atom', got '{}'",
1748                    proposal.target.r#type
1749                ));
1750            }
1751            let locator = proposal
1752                .payload
1753                .get("locator")
1754                .and_then(Value::as_str)
1755                .ok_or("evidence_atom.locator_repair proposal missing payload.locator")?;
1756            if locator.trim().is_empty() {
1757                return Err(
1758                    "evidence_atom.locator_repair payload.locator must be non-empty".to_string(),
1759                );
1760            }
1761            let source_id = proposal
1762                .payload
1763                .get("source_id")
1764                .and_then(Value::as_str)
1765                .ok_or("evidence_atom.locator_repair proposal missing payload.source_id")?;
1766            if source_id.trim().is_empty() {
1767                return Err(
1768                    "evidence_atom.locator_repair payload.source_id must be non-empty".to_string(),
1769                );
1770            }
1771        }
1772        // v0.59: federation conflict resolution (standalone shape;
1773        // no frontier-existence checks here, the apply step verifies
1774        // the conflict_event_id is present).
1775        "frontier.conflict_resolve" => {
1776            if proposal.target.r#type != "frontier_observation" {
1777                return Err(format!(
1778                    "frontier.conflict_resolve target.type must be 'frontier_observation', got '{}'",
1779                    proposal.target.r#type
1780                ));
1781            }
1782            let conflict_event_id = proposal
1783                .payload
1784                .get("conflict_event_id")
1785                .and_then(Value::as_str)
1786                .ok_or("frontier.conflict_resolve proposal missing payload.conflict_event_id")?;
1787            if conflict_event_id.trim().is_empty() {
1788                return Err(
1789                    "frontier.conflict_resolve payload.conflict_event_id must be non-empty"
1790                        .to_string(),
1791                );
1792            }
1793            let note = proposal
1794                .payload
1795                .get("resolution_note")
1796                .and_then(Value::as_str)
1797                .ok_or("frontier.conflict_resolve proposal missing payload.resolution_note")?;
1798            if note.trim().is_empty() {
1799                return Err(
1800                    "frontier.conflict_resolve payload.resolution_note must be non-empty"
1801                        .to_string(),
1802                );
1803            }
1804        }
1805        other => return Err(format!("Unsupported proposal kind '{other}'")),
1806    }
1807    validate_decision_state(proposal)
1808}
1809
1810fn require_existing_finding(frontier: &Project, finding_id: &str) -> Result<usize, String> {
1811    frontier
1812        .findings
1813        .iter()
1814        .position(|finding| finding.id == finding_id)
1815        .ok_or_else(|| format!("Finding not found: {finding_id}"))
1816}
1817
1818fn accept_proposal_in_frontier(
1819    frontier: &mut Project,
1820    proposal_id: &str,
1821    reviewer: &str,
1822    reason: &str,
1823) -> Result<String, String> {
1824    validate_reviewer_identity(reviewer)?;
1825    if reason.trim().is_empty() {
1826        return Err("Decision reason must be non-empty".to_string());
1827    }
1828    let index = frontier
1829        .proposals
1830        .iter()
1831        .position(|proposal| proposal.id == proposal_id)
1832        .ok_or_else(|| format!("Proposal not found: {proposal_id}"))?;
1833    let status = frontier.proposals[index].status.clone();
1834    if status == "rejected" {
1835        return Err(format!("Cannot accept rejected proposal {}", proposal_id));
1836    }
1837    if status == "applied" {
1838        return frontier.proposals[index]
1839            .applied_event_id
1840            .clone()
1841            .ok_or_else(|| format!("Proposal {} is applied but has no event id", proposal_id));
1842    }
1843    let proposal = frontier.proposals[index].clone();
1844    validate_proposal_shape(frontier, &proposal)?;
1845    frontier.proposals[index].status = "accepted".to_string();
1846    frontier.proposals[index].reviewed_by = Some(reviewer.to_string());
1847    frontier.proposals[index].reviewed_at = Some(Utc::now().to_rfc3339());
1848    frontier.proposals[index].decision_reason = Some(reason.to_string());
1849    let event_id = apply_proposal(frontier, &proposal, reviewer, reason)?;
1850    frontier.proposals[index].status = "applied".to_string();
1851    frontier.proposals[index].applied_event_id = Some(event_id.clone());
1852    Ok(event_id)
1853}
1854
1855fn reject_proposal_in_frontier(
1856    frontier: &mut Project,
1857    proposal_id: &str,
1858    reviewer: &str,
1859    reason: &str,
1860) -> Result<(), String> {
1861    validate_reviewer_identity(reviewer)?;
1862    if reason.trim().is_empty() {
1863        return Err("Decision reason must be non-empty".to_string());
1864    }
1865    let index = frontier
1866        .proposals
1867        .iter()
1868        .position(|proposal| proposal.id == proposal_id)
1869        .ok_or_else(|| format!("Proposal not found: {proposal_id}"))?;
1870    match frontier.proposals[index].status.as_str() {
1871        "pending_review" | "accepted" => {}
1872        "rejected" => {
1873            return Err(format!("Proposal {} is already rejected", proposal_id));
1874        }
1875        "applied" => {
1876            return Err(format!("Proposal {} is already applied", proposal_id));
1877        }
1878        other => {
1879            return Err(format!("Unsupported proposal status '{}'", other));
1880        }
1881    }
1882    frontier.proposals[index].status = "rejected".to_string();
1883    frontier.proposals[index].reviewed_by = Some(reviewer.to_string());
1884    frontier.proposals[index].reviewed_at = Some(Utc::now().to_rfc3339());
1885    frontier.proposals[index].decision_reason = Some(reason.to_string());
1886    Ok(())
1887}
1888
1889fn request_revision_in_frontier(
1890    frontier: &mut Project,
1891    proposal_id: &str,
1892    reviewer: &str,
1893    reason: &str,
1894) -> Result<(), String> {
1895    validate_reviewer_identity(reviewer)?;
1896    if reason.trim().is_empty() {
1897        return Err("Decision reason must be non-empty".to_string());
1898    }
1899    let index = frontier
1900        .proposals
1901        .iter()
1902        .position(|proposal| proposal.id == proposal_id)
1903        .ok_or_else(|| format!("Proposal not found: {proposal_id}"))?;
1904    match frontier.proposals[index].status.as_str() {
1905        "pending_review" => {}
1906        "needs_revision" => {
1907            return Err(format!("Proposal {} already needs revision", proposal_id));
1908        }
1909        "rejected" => {
1910            return Err(format!("Proposal {} is already rejected", proposal_id));
1911        }
1912        "applied" => {
1913            return Err(format!("Proposal {} is already applied", proposal_id));
1914        }
1915        other => {
1916            return Err(format!("Unsupported proposal status '{}'", other));
1917        }
1918    }
1919    frontier.proposals[index].status = "needs_revision".to_string();
1920    frontier.proposals[index].reviewed_by = Some(reviewer.to_string());
1921    frontier.proposals[index].reviewed_at = Some(Utc::now().to_rfc3339());
1922    frontier.proposals[index].decision_reason = Some(reason.to_string());
1923    Ok(())
1924}
1925
1926fn apply_proposal(
1927    frontier: &mut Project,
1928    proposal: &StateProposal,
1929    reviewer: &str,
1930    decision_reason: &str,
1931) -> Result<String, String> {
1932    // Phase L: retraction emits a fan of events — one for the source
1933    // and one `finding.dependency_invalidated` per dependent in BFS
1934    // order. apply_retract is responsible for pushing all of them in
1935    // sequence; this branch only assigns the primary event ID.
1936    if proposal.kind.as_str() == "finding.retract" {
1937        let events = apply_retract(frontier, proposal, reviewer, decision_reason)?;
1938        let primary_id = events
1939            .first()
1940            .map(|event| event.id.clone())
1941            .ok_or_else(|| "apply_retract returned no events".to_string())?;
1942        for event in events {
1943            frontier.events.push(event);
1944        }
1945        mark_proof_stale(
1946            frontier,
1947            format!("Applied proposal {} after latest proof export", proposal.id),
1948        );
1949        return Ok(primary_id);
1950    }
1951    // v0.55: confidence_revise can also fan out a cascade when the new
1952    // score crosses below the 0.5 propagation threshold. Same fan-out
1953    // pattern as retract.
1954    if proposal.kind.as_str() == "finding.confidence_revise" {
1955        let events = apply_confidence_revise(frontier, proposal, reviewer, decision_reason)?;
1956        let primary_id = events
1957            .first()
1958            .map(|event| event.id.clone())
1959            .ok_or_else(|| "apply_confidence_revise returned no events".to_string())?;
1960        for event in events {
1961            frontier.events.push(event);
1962        }
1963        mark_proof_stale(
1964            frontier,
1965            format!("Applied proposal {} after latest proof export", proposal.id),
1966        );
1967        return Ok(primary_id);
1968    }
1969    let event = match proposal.kind.as_str() {
1970        "finding.add" => apply_add(frontier, proposal, reviewer, decision_reason)?,
1971        "finding.review" => apply_review(frontier, proposal, reviewer, decision_reason)?,
1972        "finding.caveat" => apply_caveat(frontier, proposal, reviewer, decision_reason)?,
1973        "finding.note" => apply_note(frontier, proposal, reviewer, decision_reason)?,
1974        "finding.reject" => apply_reject(frontier, proposal, reviewer, decision_reason)?,
1975        "finding.supersede" => apply_supersede(frontier, proposal, reviewer, decision_reason)?,
1976        "artifact.assert" => apply_artifact_assert(frontier, proposal, reviewer, decision_reason)?,
1977        // v0.52: agent-inbox-deposited nulls and trajectories follow
1978        // the same review-gated path as findings.
1979        "negative_result.assert" => {
1980            apply_negative_result_assert(frontier, proposal, reviewer, decision_reason)?
1981        }
1982        "trajectory.create" => {
1983            apply_trajectory_create(frontier, proposal, reviewer, decision_reason)?
1984        }
1985        "trajectory.step_append" => {
1986            apply_trajectory_step_append(frontier, proposal, reviewer, decision_reason)?
1987        }
1988        // v0.56: mechanical evidence-atom locator repair.
1989        "evidence_atom.locator_repair" => {
1990            apply_evidence_atom_locator_repair(frontier, proposal, reviewer, decision_reason)?
1991        }
1992        // v0.57: mechanical finding-level span repair.
1993        "finding.span_repair" => {
1994            apply_finding_span_repair(frontier, proposal, reviewer, decision_reason)?
1995        }
1996        // v0.57: entity resolution.
1997        "finding.entity_resolve" => {
1998            apply_finding_entity_resolve(frontier, proposal, reviewer, decision_reason)?
1999        }
2000        // v0.79: append a new entity tag to an existing finding.
2001        // Closes the v0.78.4 honest gap.
2002        "finding.entity_add" => {
2003            apply_finding_entity_add(frontier, proposal, reviewer, decision_reason)?
2004        }
2005        // v0.59: federation conflict resolution.
2006        "frontier.conflict_resolve" => {
2007            apply_frontier_conflict_resolve(frontier, proposal, reviewer, decision_reason)?
2008        }
2009        other => return Err(format!("Unsupported proposal kind '{other}'")),
2010    };
2011    let event_id = event.id.clone();
2012    frontier.events.push(event);
2013    mark_proof_stale(
2014        frontier,
2015        format!("Applied proposal {} after latest proof export", proposal.id),
2016    );
2017    Ok(event_id)
2018}
2019
2020/// v0.14: `finding.supersede` — first-class flow for *changing a claim's text*.
2021///
2022/// Until v0.14 the only way to update a finding was to stack caveats/notes
2023/// on top, because the assertion text is part of the content address. The
2024/// substrate-correct path for a real correction is a *new* content-addressed
2025/// finding that explicitly supersedes the old one. This proposal kind:
2026///
2027/// 1. Validates the old finding exists and is not already superseded.
2028/// 2. Adds the new finding bundle (a fresh `vf_…` content address) to
2029///    `frontier.findings`.
2030/// 3. Auto-injects a `supersedes` link from the new finding's `links` to the
2031///    old finding's id (if not already present in the payload).
2032/// 4. Sets `flags.superseded = true` on the old finding.
2033/// 5. Emits a `finding.superseded` canonical event targeting the *old*
2034///    finding (since that's the state change). The new finding's existence
2035///    is recorded in the event payload as `new_finding_id`.
2036///
2037/// Both findings remain queryable; readers walk the supersedes chain via
2038/// the link or via the `flags.superseded` marker.
2039fn apply_supersede(
2040    frontier: &mut Project,
2041    proposal: &StateProposal,
2042    reviewer: &str,
2043    _decision_reason: &str,
2044) -> Result<StateEvent, String> {
2045    use crate::bundle::Link;
2046
2047    let old_id = proposal.target.id.clone();
2048    let new_finding_value = proposal
2049        .payload
2050        .get("new_finding")
2051        .ok_or("finding.supersede proposal missing payload.new_finding")?
2052        .clone();
2053    let mut new_finding: FindingBundle = serde_json::from_value(new_finding_value)
2054        .map_err(|e| format!("Invalid finding.supersede payload.new_finding: {e}"))?;
2055
2056    // Locate the old finding before mutating; capture before_hash for the event.
2057    let old_idx = find_finding_index(frontier, &old_id)?;
2058    if frontier.findings[old_idx].flags.superseded {
2059        return Err(format!(
2060            "Refusing to supersede already-superseded finding {old_id}"
2061        ));
2062    }
2063    if new_finding.id == old_id {
2064        return Err(
2065            "Refusing to supersede with a finding that has the same content address as the old finding (assertion / type / provenance_id are unchanged)".to_string(),
2066        );
2067    }
2068    if frontier
2069        .findings
2070        .iter()
2071        .any(|existing| existing.id == new_finding.id)
2072    {
2073        return Err(format!(
2074            "Refusing to add superseding finding with existing finding ID {}",
2075            new_finding.id
2076        ));
2077    }
2078    let before_hash = events::finding_hash(&frontier.findings[old_idx]);
2079
2080    // Auto-inject the supersedes link if the caller didn't already include it.
2081    let already_links_old = new_finding
2082        .links
2083        .iter()
2084        .any(|l| l.target == old_id && l.link_type == "supersedes");
2085    if !already_links_old {
2086        new_finding.links.push(Link {
2087            target: old_id.clone(),
2088            link_type: "supersedes".to_string(),
2089            note: format!(
2090                "Supersedes {old_id} via finding.supersede proposal {}.",
2091                proposal.id
2092            ),
2093            inferred_by: "reviewer".to_string(),
2094            created_at: Utc::now().to_rfc3339(),
2095            mechanism: None,
2096        });
2097    }
2098
2099    let new_finding_id = new_finding.id.clone();
2100    frontier.findings.push(new_finding);
2101    frontier.findings[old_idx].flags.superseded = true;
2102    let after_hash = events::finding_hash(&frontier.findings[old_idx]);
2103
2104    Ok(events::new_finding_event(events::FindingEventInput {
2105        kind: "finding.superseded",
2106        finding_id: &old_id,
2107        actor_id: reviewer,
2108        actor_type: "human",
2109        reason: &proposal.reason,
2110        before_hash: &before_hash,
2111        after_hash: &after_hash,
2112        payload: json!({
2113            "proposal_id": proposal.id,
2114            "new_finding_id": new_finding_id,
2115        }),
2116        caveats: proposal.caveats.clone(),
2117    }))
2118}
2119
2120fn apply_add(
2121    frontier: &mut Project,
2122    proposal: &StateProposal,
2123    reviewer: &str,
2124    _decision_reason: &str,
2125) -> Result<StateEvent, String> {
2126    let finding_value = proposal
2127        .payload
2128        .get("finding")
2129        .ok_or("finding.add proposal missing payload.finding")?
2130        .clone();
2131    let finding: FindingBundle = serde_json::from_value(finding_value)
2132        .map_err(|e| format!("Invalid finding.add payload: {e}"))?;
2133    let finding_id = finding.id.clone();
2134    if frontier
2135        .findings
2136        .iter()
2137        .any(|existing| existing.id == finding_id)
2138    {
2139        return Err(format!(
2140            "Refusing to add duplicate finding with existing finding ID {finding_id}"
2141        ));
2142    }
2143    frontier.findings.push(finding);
2144    let after_hash = events::finding_hash_by_id(frontier, &finding_id);
2145    Ok(events::new_finding_event(events::FindingEventInput {
2146        kind: "finding.asserted",
2147        finding_id: &finding_id,
2148        actor_id: reviewer,
2149        actor_type: "human",
2150        reason: &proposal.reason,
2151        before_hash: NULL_HASH,
2152        after_hash: &after_hash,
2153        payload: json!({
2154            "proposal_id": proposal.id,
2155        }),
2156        caveats: proposal.caveats.clone(),
2157    }))
2158}
2159
2160fn apply_artifact_assert(
2161    frontier: &mut Project,
2162    proposal: &StateProposal,
2163    reviewer: &str,
2164    _decision_reason: &str,
2165) -> Result<StateEvent, String> {
2166    let artifact_value = proposal
2167        .payload
2168        .get("artifact")
2169        .ok_or("artifact.assert proposal missing payload.artifact")?
2170        .clone();
2171    let artifact: Artifact = serde_json::from_value(artifact_value)
2172        .map_err(|e| format!("Invalid artifact.assert payload: {e}"))?;
2173    let artifact_id = artifact.id.clone();
2174    if frontier
2175        .artifacts
2176        .iter()
2177        .any(|existing| existing.id == artifact_id)
2178    {
2179        return Err(format!(
2180            "Refusing to add duplicate artifact with existing id {artifact_id}"
2181        ));
2182    }
2183    frontier.artifacts.push(artifact.clone());
2184    let mut event = StateEvent {
2185        schema: events::EVENT_SCHEMA.to_string(),
2186        id: String::new(),
2187        kind: events::EVENT_KIND_ARTIFACT_ASSERTED.to_string(),
2188        target: StateTarget {
2189            r#type: "artifact".to_string(),
2190            id: artifact_id,
2191        },
2192        actor: StateActor {
2193            id: reviewer.to_string(),
2194            r#type: if reviewer.starts_with("agent:") {
2195                "agent"
2196            } else {
2197                "human"
2198            }
2199            .to_string(),
2200        },
2201        timestamp: Utc::now().to_rfc3339(),
2202        reason: proposal.reason.clone(),
2203        before_hash: NULL_HASH.to_string(),
2204        after_hash: NULL_HASH.to_string(),
2205        payload: json!({
2206            "proposal_id": proposal.id,
2207            "artifact": artifact,
2208        }),
2209        caveats: proposal.caveats.clone(),
2210        signature: None,
2211        schema_artifact_id: None,
2212    };
2213    events::validate_event_payload(&event.kind, &event.payload)?;
2214    event.id = events::compute_event_id(&event);
2215    Ok(event)
2216}
2217
2218fn apply_review(
2219    frontier: &mut Project,
2220    proposal: &StateProposal,
2221    reviewer: &str,
2222    _decision_reason: &str,
2223) -> Result<StateEvent, String> {
2224    let finding_id = proposal.target.id.as_str();
2225    let idx = find_finding_index(frontier, finding_id)?;
2226    let before_hash = events::finding_hash(&frontier.findings[idx]);
2227    let status = proposal
2228        .payload
2229        .get("status")
2230        .and_then(Value::as_str)
2231        .ok_or("finding.review proposal missing payload.status")?;
2232    use crate::bundle::ReviewState;
2233    let new_state = match status {
2234        "accepted" | "approved" => ReviewState::Accepted,
2235        "contested" => ReviewState::Contested,
2236        "needs_revision" => ReviewState::NeedsRevision,
2237        "rejected" => ReviewState::Rejected,
2238        other => return Err(format!("Unknown review proposal status '{other}'")),
2239    };
2240    frontier.findings[idx].flags.contested = new_state.implies_contested();
2241    frontier.findings[idx].flags.review_state = Some(new_state);
2242    let after_hash = events::finding_hash(&frontier.findings[idx]);
2243    Ok(events::new_finding_event(events::FindingEventInput {
2244        kind: "finding.reviewed",
2245        finding_id,
2246        actor_id: reviewer,
2247        actor_type: "human",
2248        reason: &proposal.reason,
2249        before_hash: &before_hash,
2250        after_hash: &after_hash,
2251        payload: json!({
2252            "status": status,
2253            "proposal_id": proposal.id,
2254        }),
2255        caveats: proposal.caveats.clone(),
2256    }))
2257}
2258
2259fn apply_caveat(
2260    frontier: &mut Project,
2261    proposal: &StateProposal,
2262    reviewer: &str,
2263    _decision_reason: &str,
2264) -> Result<StateEvent, String> {
2265    let finding_id = proposal.target.id.as_str();
2266    let idx = find_finding_index(frontier, finding_id)?;
2267    let before_hash = events::finding_hash(&frontier.findings[idx]);
2268    let now = Utc::now().to_rfc3339();
2269    let text = proposal
2270        .payload
2271        .get("text")
2272        .and_then(Value::as_str)
2273        .ok_or("finding.caveat proposal missing payload.text")?;
2274    let provenance = extract_annotation_provenance(&proposal.payload);
2275    let annotation_id = annotation_id(finding_id, text, reviewer, &now);
2276    frontier.findings[idx].annotations.push(Annotation {
2277        id: annotation_id.clone(),
2278        text: text.to_string(),
2279        author: reviewer.to_string(),
2280        timestamp: now,
2281        provenance: provenance.clone(),
2282    });
2283    let after_hash = events::finding_hash(&frontier.findings[idx]);
2284    let mut payload = json!({
2285        "annotation_id": annotation_id,
2286        "text": text,
2287        "proposal_id": proposal.id,
2288    });
2289    if let Some(prov) = &provenance {
2290        payload["provenance"] = serde_json::to_value(prov).unwrap_or(Value::Null);
2291    }
2292    Ok(events::new_finding_event(events::FindingEventInput {
2293        kind: "finding.caveated",
2294        finding_id,
2295        actor_id: reviewer,
2296        actor_type: "human",
2297        reason: text,
2298        before_hash: &before_hash,
2299        after_hash: &after_hash,
2300        payload,
2301        caveats: proposal.caveats.clone(),
2302    }))
2303}
2304
2305fn apply_note(
2306    frontier: &mut Project,
2307    proposal: &StateProposal,
2308    reviewer: &str,
2309    _decision_reason: &str,
2310) -> Result<StateEvent, String> {
2311    let finding_id = proposal.target.id.as_str();
2312    let idx = find_finding_index(frontier, finding_id)?;
2313    let before_hash = events::finding_hash(&frontier.findings[idx]);
2314    let now = Utc::now().to_rfc3339();
2315    let text = proposal
2316        .payload
2317        .get("text")
2318        .and_then(Value::as_str)
2319        .ok_or("finding.note proposal missing payload.text")?;
2320    let provenance = extract_annotation_provenance(&proposal.payload);
2321    let annotation_id = annotation_id(finding_id, text, reviewer, &now);
2322    frontier.findings[idx].annotations.push(Annotation {
2323        id: annotation_id.clone(),
2324        text: text.to_string(),
2325        author: reviewer.to_string(),
2326        timestamp: now,
2327        provenance: provenance.clone(),
2328    });
2329    let after_hash = events::finding_hash(&frontier.findings[idx]);
2330    let mut payload = json!({
2331        "annotation_id": annotation_id,
2332        "text": text,
2333        "proposal_id": proposal.id,
2334    });
2335    if let Some(prov) = &provenance {
2336        payload["provenance"] = serde_json::to_value(prov).unwrap_or(Value::Null);
2337    }
2338    Ok(events::new_finding_event(events::FindingEventInput {
2339        kind: "finding.noted",
2340        finding_id,
2341        actor_id: reviewer,
2342        actor_type: "human",
2343        reason: text,
2344        before_hash: &before_hash,
2345        after_hash: &after_hash,
2346        payload,
2347        caveats: proposal.caveats.clone(),
2348    }))
2349}
2350
2351/// v0.57: Apply a `finding.entity_resolve` proposal. Sets canonical_id
2352/// + resolution metadata on the named entity inside the target finding's
2353/// assertion.entities array, and clears the entity's needs_review flag.
2354fn apply_finding_entity_resolve(
2355    frontier: &mut Project,
2356    proposal: &StateProposal,
2357    reviewer: &str,
2358    _decision_reason: &str,
2359) -> Result<StateEvent, String> {
2360    use crate::bundle::{ResolutionMethod, ResolvedId};
2361
2362    let finding_id = proposal.target.id.as_str();
2363    let entity_name = proposal
2364        .payload
2365        .get("entity_name")
2366        .and_then(Value::as_str)
2367        .ok_or("finding.entity_resolve proposal missing payload.entity_name")?
2368        .to_string();
2369    let source = proposal
2370        .payload
2371        .get("source")
2372        .and_then(Value::as_str)
2373        .ok_or("finding.entity_resolve proposal missing payload.source")?
2374        .to_string();
2375    let id = proposal
2376        .payload
2377        .get("id")
2378        .and_then(Value::as_str)
2379        .ok_or("finding.entity_resolve proposal missing payload.id")?
2380        .to_string();
2381    let confidence = proposal
2382        .payload
2383        .get("confidence")
2384        .and_then(Value::as_f64)
2385        .ok_or("finding.entity_resolve proposal missing payload.confidence")?;
2386    let matched_name = proposal
2387        .payload
2388        .get("matched_name")
2389        .and_then(Value::as_str)
2390        .map(str::to_string);
2391    let provenance = proposal
2392        .payload
2393        .get("resolution_provenance")
2394        .and_then(Value::as_str)
2395        .unwrap_or("delegated_human_curation")
2396        .to_string();
2397    let method_str = proposal
2398        .payload
2399        .get("resolution_method")
2400        .and_then(Value::as_str)
2401        .unwrap_or("manual");
2402    let method = match method_str {
2403        "exact_match" => ResolutionMethod::ExactMatch,
2404        "fuzzy_match" => ResolutionMethod::FuzzyMatch,
2405        "llm_inference" => ResolutionMethod::LlmInference,
2406        "manual" => ResolutionMethod::Manual,
2407        other => {
2408            return Err(format!(
2409                "finding.entity_resolve unknown resolution_method '{other}'"
2410            ));
2411        }
2412    };
2413
2414    let f_idx = find_finding_index(frontier, finding_id)?;
2415    let e_idx = frontier.findings[f_idx]
2416        .assertion
2417        .entities
2418        .iter()
2419        .position(|e| e.name == entity_name)
2420        .ok_or_else(|| {
2421            format!("finding.entity_resolve entity '{entity_name}' not in finding {finding_id}")
2422        })?;
2423
2424    let before_hash = events::finding_hash(&frontier.findings[f_idx]);
2425    let entity = &mut frontier.findings[f_idx].assertion.entities[e_idx];
2426    entity.canonical_id = Some(ResolvedId {
2427        source: source.clone(),
2428        id: id.clone(),
2429        confidence,
2430        matched_name: matched_name.clone(),
2431    });
2432    entity.resolution_method = Some(method);
2433    entity.resolution_provenance = Some(provenance.clone());
2434    entity.resolution_confidence = confidence;
2435    entity.needs_review = false;
2436    let after_hash = events::finding_hash(&frontier.findings[f_idx]);
2437
2438    let mut payload = json!({
2439        "proposal_id": proposal.id,
2440        "entity_name": entity_name,
2441        "source": source,
2442        "id": id,
2443        "confidence": confidence,
2444        "resolution_method": method_str,
2445        "resolution_provenance": provenance,
2446    });
2447    if let Some(m) = matched_name {
2448        payload["matched_name"] = serde_json::Value::String(m);
2449    }
2450
2451    Ok(events::new_finding_event(events::FindingEventInput {
2452        kind: "finding.entity_resolved",
2453        finding_id,
2454        actor_id: reviewer,
2455        actor_type: "human",
2456        reason: &proposal.reason,
2457        before_hash: &before_hash,
2458        after_hash: &after_hash,
2459        payload,
2460        caveats: proposal.caveats.clone(),
2461    }))
2462}
2463
2464/// v0.79: Apply a `finding.entity_add` proposal. Pushes a new
2465/// `Entity{name, type, ...}` onto `state.findings[i].assertion.entities`
2466/// and emits one signed `finding.entity_added` event. Idempotent on
2467/// `(finding_id, entity_name)`.
2468fn apply_finding_entity_add(
2469    frontier: &mut Project,
2470    proposal: &StateProposal,
2471    reviewer: &str,
2472    _decision_reason: &str,
2473) -> Result<StateEvent, String> {
2474    use crate::bundle::Entity;
2475
2476    let finding_id = proposal.target.id.as_str();
2477    let entity_name = proposal
2478        .payload
2479        .get("entity_name")
2480        .and_then(Value::as_str)
2481        .ok_or("finding.entity_add proposal missing payload.entity_name")?
2482        .to_string();
2483    let entity_type = proposal
2484        .payload
2485        .get("entity_type")
2486        .and_then(Value::as_str)
2487        .ok_or("finding.entity_add proposal missing payload.entity_type")?
2488        .to_string();
2489    let reason_text = proposal
2490        .payload
2491        .get("reason")
2492        .and_then(Value::as_str)
2493        .ok_or("finding.entity_add proposal missing payload.reason")?
2494        .to_string();
2495
2496    let idx = find_finding_index(frontier, finding_id)?;
2497    let already_present = frontier.findings[idx]
2498        .assertion
2499        .entities
2500        .iter()
2501        .any(|e| e.name == entity_name);
2502
2503    let before_hash = events::finding_hash(&frontier.findings[idx]);
2504    if !already_present {
2505        let entity = Entity {
2506            name: entity_name.clone(),
2507            entity_type: entity_type.clone(),
2508            identifiers: serde_json::Map::new(),
2509            canonical_id: None,
2510            candidates: Vec::new(),
2511            aliases: Vec::new(),
2512            resolution_provenance: None,
2513            resolution_confidence: 1.0,
2514            resolution_method: None,
2515            species_context: None,
2516            needs_review: false,
2517        };
2518        frontier.findings[idx].assertion.entities.push(entity);
2519    }
2520    let after_hash = events::finding_hash(&frontier.findings[idx]);
2521
2522    let payload = json!({
2523        "proposal_id": proposal.id,
2524        "entity_name": entity_name,
2525        "entity_type": entity_type,
2526        "reason": reason_text,
2527        "idempotent_noop": already_present,
2528    });
2529
2530    Ok(events::new_finding_event(events::FindingEventInput {
2531        kind: "finding.entity_added",
2532        finding_id,
2533        actor_id: reviewer,
2534        actor_type: "human",
2535        reason: &proposal.reason,
2536        before_hash: &before_hash,
2537        after_hash: &after_hash,
2538        payload,
2539        caveats: proposal.caveats.clone(),
2540    }))
2541}
2542
2543/// v0.57: Apply a `finding.span_repair` proposal. Appends a
2544/// `{section, text}` span to `state.findings[i].evidence.evidence_spans`
2545/// and emits one signed `finding.span_repaired` event.
2546fn apply_finding_span_repair(
2547    frontier: &mut Project,
2548    proposal: &StateProposal,
2549    reviewer: &str,
2550    _decision_reason: &str,
2551) -> Result<StateEvent, String> {
2552    let finding_id = proposal.target.id.as_str();
2553    let section = proposal
2554        .payload
2555        .get("section")
2556        .and_then(Value::as_str)
2557        .ok_or("finding.span_repair proposal missing payload.section")?
2558        .to_string();
2559    let text = proposal
2560        .payload
2561        .get("text")
2562        .and_then(Value::as_str)
2563        .ok_or("finding.span_repair proposal missing payload.text")?
2564        .to_string();
2565    let idx = find_finding_index(frontier, finding_id)?;
2566    let already_present = frontier.findings[idx]
2567        .evidence
2568        .evidence_spans
2569        .iter()
2570        .any(|existing| {
2571            existing.get("section").and_then(Value::as_str) == Some(section.as_str())
2572                && existing.get("text").and_then(Value::as_str) == Some(text.as_str())
2573        });
2574    if already_present {
2575        return Err(format!(
2576            "finding {finding_id} already carries an identical (section, text) span"
2577        ));
2578    }
2579    let before_hash = events::finding_hash(&frontier.findings[idx]);
2580    let span_value = json!({"section": section, "text": text});
2581    frontier.findings[idx]
2582        .evidence
2583        .evidence_spans
2584        .push(span_value);
2585    let after_hash = events::finding_hash(&frontier.findings[idx]);
2586    let payload = json!({
2587        "proposal_id": proposal.id,
2588        "section": section,
2589        "text": text,
2590    });
2591    Ok(events::new_finding_event(events::FindingEventInput {
2592        kind: "finding.span_repaired",
2593        finding_id,
2594        actor_id: reviewer,
2595        actor_type: "human",
2596        reason: &proposal.reason,
2597        before_hash: &before_hash,
2598        after_hash: &after_hash,
2599        payload,
2600        caveats: proposal.caveats.clone(),
2601    }))
2602}
2603
2604/// v0.56: Apply an `evidence_atom.locator_repair` proposal. Sets
2605/// `locator` on the named evidence atom, removes the
2606/// "missing evidence locator" caveat, and emits one signed
2607/// `evidence_atom.locator_repaired` canonical event. The before/after
2608/// hashes are over the canonical bytes of the named atom only, so a
2609/// chain validator can confirm the exact atom changed and exactly the
2610/// named repair was applied.
2611fn apply_evidence_atom_locator_repair(
2612    frontier: &mut Project,
2613    proposal: &StateProposal,
2614    reviewer: &str,
2615    _decision_reason: &str,
2616) -> Result<StateEvent, String> {
2617    let atom_id = proposal.target.id.as_str();
2618    let locator = proposal
2619        .payload
2620        .get("locator")
2621        .and_then(Value::as_str)
2622        .ok_or("evidence_atom.locator_repair proposal missing payload.locator")?
2623        .to_string();
2624    let source_id = proposal
2625        .payload
2626        .get("source_id")
2627        .and_then(Value::as_str)
2628        .ok_or("evidence_atom.locator_repair proposal missing payload.source_id")?
2629        .to_string();
2630
2631    let idx = frontier
2632        .evidence_atoms
2633        .iter()
2634        .position(|atom| atom.id == atom_id)
2635        .ok_or_else(|| format!("evidence_atom.locator_repair targets unknown atom {atom_id}"))?;
2636    if frontier.evidence_atoms[idx].source_id != source_id {
2637        return Err(format!(
2638            "evidence_atom.locator_repair payload.source_id '{source_id}' does not match atom.source_id '{}'",
2639            frontier.evidence_atoms[idx].source_id
2640        ));
2641    }
2642    if let Some(existing) = &frontier.evidence_atoms[idx].locator {
2643        if existing == &locator {
2644            return Err(format!(
2645                "evidence_atom {atom_id} already carries locator '{existing}'"
2646            ));
2647        }
2648        return Err(format!(
2649            "evidence_atom {atom_id} already carries locator '{existing}'; refusing to overwrite with '{locator}'"
2650        ));
2651    }
2652
2653    let before_hash = events::evidence_atom_hash(&frontier.evidence_atoms[idx]);
2654    frontier.evidence_atoms[idx].locator = Some(locator.clone());
2655    frontier.evidence_atoms[idx]
2656        .caveats
2657        .retain(|c| c != "missing evidence locator");
2658    let after_hash = events::evidence_atom_hash(&frontier.evidence_atoms[idx]);
2659
2660    let payload = json!({
2661        "proposal_id": proposal.id,
2662        "locator": locator,
2663        "source_id": source_id,
2664    });
2665
2666    Ok(events::new_evidence_atom_locator_repair_event(
2667        atom_id,
2668        reviewer,
2669        "human",
2670        &proposal.reason,
2671        &before_hash,
2672        &after_hash,
2673        payload,
2674        proposal.caveats.clone(),
2675    ))
2676}
2677
2678/// v0.59: apply a `frontier.conflict_resolve` proposal. Emits one
2679/// `frontier.conflict_resolved` event recording the reviewer's
2680/// verdict on a previously detected conflict. The conflict event
2681/// itself is not modified; consumers pair the two by matching
2682/// `payload.conflict_event_id` on the resolved event to the
2683/// detected event's id.
2684fn apply_frontier_conflict_resolve(
2685    frontier: &mut Project,
2686    proposal: &StateProposal,
2687    reviewer: &str,
2688    _decision_reason: &str,
2689) -> Result<StateEvent, String> {
2690    let conflict_event_id = proposal
2691        .payload
2692        .get("conflict_event_id")
2693        .and_then(Value::as_str)
2694        .ok_or("frontier.conflict_resolve proposal missing payload.conflict_event_id")?
2695        .to_string();
2696    let resolution_note = proposal
2697        .payload
2698        .get("resolution_note")
2699        .and_then(Value::as_str)
2700        .ok_or("frontier.conflict_resolve proposal missing payload.resolution_note")?
2701        .to_string();
2702    let winning_proposal_id = proposal
2703        .payload
2704        .get("winning_proposal_id")
2705        .and_then(Value::as_str)
2706        .map(|s| s.to_string());
2707
2708    // Confirm the conflict event exists and is the right kind.
2709    // Refuse double-resolution at apply time too (the validator
2710    // already checks but we check again because validation is best
2711    // effort against the live frontier and apply is the authority).
2712    let conflict_event = frontier
2713        .events
2714        .iter()
2715        .find(|e| e.id == conflict_event_id)
2716        .ok_or_else(|| {
2717            format!("frontier.conflict_resolve targets unknown event id '{conflict_event_id}'")
2718        })?
2719        .clone();
2720    if conflict_event.kind != "frontier.conflict_detected" {
2721        return Err(format!(
2722            "frontier.conflict_resolve target event '{conflict_event_id}' has kind '{}', expected 'frontier.conflict_detected'",
2723            conflict_event.kind
2724        ));
2725    }
2726    if frontier.events.iter().any(|e| {
2727        e.kind == "frontier.conflict_resolved"
2728            && e.payload.get("conflict_event_id").and_then(Value::as_str)
2729                == Some(&conflict_event_id)
2730    }) {
2731        return Err(format!(
2732            "Conflict event '{conflict_event_id}' already has a recorded resolution"
2733        ));
2734    }
2735
2736    let mut payload = json!({
2737        "proposal_id": proposal.id,
2738        "conflict_event_id": conflict_event_id,
2739        "resolved_by": reviewer,
2740        "resolution_note": resolution_note,
2741    });
2742    if let Some(wpid) = &winning_proposal_id {
2743        payload["winning_proposal_id"] = json!(wpid);
2744    }
2745
2746    let frontier_id = frontier.frontier_id();
2747    Ok(events::new_frontier_conflict_resolved_event(
2748        &frontier_id,
2749        reviewer,
2750        "human",
2751        &proposal.reason,
2752        payload,
2753        proposal.caveats.clone(),
2754    ))
2755}
2756
2757/// Phase β (v0.6): pull optional structured provenance off a note/caveat
2758/// proposal payload. The propose-* tools accept it; the validator gates
2759/// it; this helper threads it through to the materialized annotation
2760/// and the canonical event payload.
2761fn extract_annotation_provenance(payload: &Value) -> Option<crate::bundle::ProvenanceRef> {
2762    let prov = payload.get("provenance")?;
2763    let parsed: crate::bundle::ProvenanceRef = serde_json::from_value(prov.clone()).ok()?;
2764    if parsed.has_identifier() {
2765        Some(parsed)
2766    } else {
2767        None
2768    }
2769}
2770
2771fn apply_confidence_revise(
2772    frontier: &mut Project,
2773    proposal: &StateProposal,
2774    reviewer: &str,
2775    _decision_reason: &str,
2776) -> Result<Vec<StateEvent>, String> {
2777    let finding_id = proposal.target.id.as_str();
2778    let idx = find_finding_index(frontier, finding_id)?;
2779    let now = Utc::now().to_rfc3339();
2780    let previous = frontier.findings[idx].confidence.score;
2781    let new_score = proposal
2782        .payload
2783        .get("confidence")
2784        .and_then(Value::as_f64)
2785        .ok_or("finding.confidence_revise proposal missing payload.confidence")?;
2786
2787    // v0.55: when the revised confidence crosses the propagation threshold
2788    // (previous >= 0.5, new < 0.5), invoke the same cascade pattern that
2789    // `apply_retract` uses — emit `finding.dependency_invalidated` events for
2790    // each downstream supports/depends finding at depth ≤ MAX_DEPTH. Pre-v0.55
2791    // this path silently mutated confidence without firing the cascade, which
2792    // forced callers to chase a separate `vela propagate --reduce-confidence`
2793    // command for the substrate's signature feature.
2794    let cascade_threshold_crossed = previous >= 0.5 && new_score < 0.5;
2795
2796    let pre_cascade_hashes: std::collections::HashMap<String, String> = if cascade_threshold_crossed
2797    {
2798        frontier
2799            .findings
2800            .iter()
2801            .map(|finding| (finding.id.clone(), events::finding_hash(finding)))
2802            .collect()
2803    } else {
2804        std::collections::HashMap::new()
2805    };
2806
2807    let before_hash = events::finding_hash(&frontier.findings[idx]);
2808
2809    // Apply the local mutation first so propagate_correction sees the new
2810    // confidence on the source finding.
2811    frontier.findings[idx].confidence.score = new_score;
2812    frontier.findings[idx].confidence.basis = format!(
2813        "expert revision from {:.3} to {:.3}: {}",
2814        previous, new_score, proposal.reason
2815    );
2816    frontier.findings[idx].confidence.method = ConfidenceMethod::ExpertJudgment;
2817    frontier.findings[idx].updated = Some(now.clone());
2818
2819    let cascade = if cascade_threshold_crossed {
2820        Some(propagate::propagate_correction(
2821            frontier,
2822            finding_id,
2823            PropagationAction::ConfidenceReduced { new_score },
2824        ))
2825    } else {
2826        None
2827    };
2828
2829    let after_hash = events::finding_hash(&frontier.findings[idx]);
2830
2831    let source_event = events::new_finding_event(events::FindingEventInput {
2832        kind: "finding.confidence_revised",
2833        finding_id,
2834        actor_id: reviewer,
2835        actor_type: "human",
2836        reason: &proposal.reason,
2837        before_hash: &before_hash,
2838        after_hash: &after_hash,
2839        payload: json!({
2840            "previous_score": previous,
2841            "new_score": new_score,
2842            "updated_at": now,
2843            "proposal_id": proposal.id,
2844            "cascade_fired": cascade_threshold_crossed,
2845            "affected": cascade.as_ref().map(|c| c.affected).unwrap_or(0),
2846        }),
2847        caveats: proposal.caveats.clone(),
2848    });
2849
2850    let source_event_id = source_event.id.clone();
2851    let mut emitted = vec![source_event];
2852
2853    if let Some(cascade) = cascade {
2854        // Mirror apply_retract's per-dependent dependency_invalidated emission:
2855        // each affected dep at each depth gets a canonical event with the
2856        // before/after hash boundary so chain validation works downstream.
2857        for (depth_idx, level) in cascade.cascade.iter().enumerate() {
2858            let depth = (depth_idx as u32) + 1;
2859            for dep_id in level {
2860                let before = pre_cascade_hashes
2861                    .get(dep_id)
2862                    .cloned()
2863                    .unwrap_or_else(|| events::NULL_HASH.to_string());
2864                let after = events::finding_hash_by_id(frontier, dep_id);
2865                emitted.push(events::new_finding_event(events::FindingEventInput {
2866                    kind: "finding.dependency_invalidated",
2867                    finding_id: dep_id,
2868                    actor_id: reviewer,
2869                    actor_type: "human",
2870                    reason: &format!(
2871                        "Upstream finding {finding_id} confidence reduced to {new_score:.2}; cascade depth {depth}"
2872                    ),
2873                    before_hash: &before,
2874                    after_hash: &after,
2875                    payload: json!({
2876                        "upstream_finding_id": finding_id,
2877                        "upstream_event_id": source_event_id,
2878                        "depth": depth,
2879                        "new_score": new_score,
2880                        "previous_score": previous,
2881                        "proposal_id": proposal.id,
2882                    }),
2883                    caveats: vec![],
2884                }));
2885            }
2886        }
2887    }
2888
2889    Ok(emitted)
2890}
2891
2892fn apply_reject(
2893    frontier: &mut Project,
2894    proposal: &StateProposal,
2895    reviewer: &str,
2896    _decision_reason: &str,
2897) -> Result<StateEvent, String> {
2898    let finding_id = proposal.target.id.as_str();
2899    let idx = find_finding_index(frontier, finding_id)?;
2900    let before_hash = events::finding_hash(&frontier.findings[idx]);
2901    frontier.findings[idx].flags.contested = true;
2902    let after_hash = events::finding_hash(&frontier.findings[idx]);
2903    Ok(events::new_finding_event(events::FindingEventInput {
2904        kind: "finding.rejected",
2905        finding_id,
2906        actor_id: reviewer,
2907        actor_type: "human",
2908        reason: &proposal.reason,
2909        before_hash: &before_hash,
2910        after_hash: &after_hash,
2911        payload: json!({
2912            "proposal_id": proposal.id,
2913            "status": "rejected",
2914        }),
2915        caveats: proposal.caveats.clone(),
2916    }))
2917}
2918
2919fn apply_retract(
2920    frontier: &mut Project,
2921    proposal: &StateProposal,
2922    reviewer: &str,
2923    _decision_reason: &str,
2924) -> Result<Vec<StateEvent>, String> {
2925    let finding_id = proposal.target.id.as_str();
2926    let idx = find_finding_index(frontier, finding_id)?;
2927    if frontier.findings[idx].flags.retracted {
2928        return Err(format!("Finding {finding_id} is already retracted"));
2929    }
2930    // Phase L: capture every finding's pre-cascade hash so each emitted
2931    // `finding.dependency_invalidated` event can name a real before_hash
2932    // that matches whatever event last touched that dep.
2933    let pre_cascade_hashes: std::collections::HashMap<String, String> = frontier
2934        .findings
2935        .iter()
2936        .map(|finding| (finding.id.clone(), events::finding_hash(finding)))
2937        .collect();
2938
2939    let before_hash = events::finding_hash(&frontier.findings[idx]);
2940    let cascade =
2941        propagate::propagate_correction(frontier, finding_id, PropagationAction::Retracted);
2942    let after_hash = events::finding_hash_by_id(frontier, finding_id);
2943
2944    let source_event = events::new_finding_event(events::FindingEventInput {
2945        kind: "finding.retracted",
2946        finding_id,
2947        actor_id: reviewer,
2948        actor_type: "human",
2949        reason: &proposal.reason,
2950        before_hash: &before_hash,
2951        after_hash: &after_hash,
2952        payload: json!({
2953            "proposal_id": proposal.id,
2954            "affected": cascade.affected,
2955            "cascade": cascade.cascade,
2956        }),
2957        caveats: vec!["Retraction impact is simulated over declared dependency links.".to_string()],
2958    });
2959    let source_event_id = source_event.id.clone();
2960
2961    let mut emitted = vec![source_event];
2962
2963    // Phase L: emit one canonical `finding.dependency_invalidated`
2964    // event per affected dependent, in BFS depth order. Each event
2965    // carries the before/after hash boundary for that specific dep so
2966    // chain validation works downstream.
2967    for (depth_idx, level) in cascade.cascade.iter().enumerate() {
2968        let depth = (depth_idx as u32) + 1;
2969        for dep_id in level {
2970            let before = pre_cascade_hashes
2971                .get(dep_id)
2972                .cloned()
2973                .unwrap_or_else(|| events::NULL_HASH.to_string());
2974            let after = events::finding_hash_by_id(frontier, dep_id);
2975            emitted.push(events::new_finding_event(events::FindingEventInput {
2976                kind: "finding.dependency_invalidated",
2977                finding_id: dep_id,
2978                actor_id: reviewer,
2979                actor_type: "human",
2980                reason: &format!("Upstream finding {finding_id} retracted; cascade depth {depth}"),
2981                before_hash: &before,
2982                after_hash: &after,
2983                payload: json!({
2984                    "upstream_finding_id": finding_id,
2985                    "upstream_event_id": source_event_id,
2986                    "depth": depth,
2987                    "proposal_id": proposal.id,
2988                }),
2989                caveats: vec![],
2990            }));
2991        }
2992    }
2993
2994    Ok(emitted)
2995}
2996
2997fn find_finding_index(frontier: &Project, finding_id: &str) -> Result<usize, String> {
2998    frontier
2999        .findings
3000        .iter()
3001        .position(|finding| finding.id == finding_id)
3002        .ok_or_else(|| format!("Finding not found: {finding_id}"))
3003}
3004
3005/// v0.52: Apply a `negative_result.assert` proposal — push the
3006/// inline NegativeResult to state and emit a canonical
3007/// `negative_result.asserted` event. The event payload re-includes
3008/// the full NegativeResult so a fresh replay reconstructs
3009/// `state.negative_results` from the event log alone (matching the
3010/// direct `state::add_negative_result` path).
3011fn apply_negative_result_assert(
3012    frontier: &mut Project,
3013    proposal: &StateProposal,
3014    reviewer: &str,
3015    _decision_reason: &str,
3016) -> Result<StateEvent, String> {
3017    let nr_value = proposal
3018        .payload
3019        .get("negative_result")
3020        .ok_or("negative_result.assert proposal missing payload.negative_result")?
3021        .clone();
3022    let nr: crate::bundle::NegativeResult = serde_json::from_value(nr_value.clone())
3023        .map_err(|e| format!("Invalid negative_result.assert payload: {e}"))?;
3024    if frontier.negative_results.iter().any(|n| n.id == nr.id) {
3025        return Err(format!(
3026            "Refusing to add duplicate negative_result with existing id {}",
3027            nr.id
3028        ));
3029    }
3030    let nr_id = nr.id.clone();
3031    frontier.negative_results.push(nr);
3032
3033    let mut event = StateEvent {
3034        schema: events::EVENT_SCHEMA.to_string(),
3035        id: String::new(),
3036        kind: events::EVENT_KIND_NEGATIVE_RESULT_ASSERTED.to_string(),
3037        target: StateTarget {
3038            r#type: "negative_result".to_string(),
3039            id: nr_id,
3040        },
3041        actor: StateActor {
3042            id: reviewer.to_string(),
3043            r#type: "human".to_string(),
3044        },
3045        timestamp: Utc::now().to_rfc3339(),
3046        reason: proposal.reason.clone(),
3047        before_hash: NULL_HASH.to_string(),
3048        after_hash: NULL_HASH.to_string(),
3049        payload: json!({
3050            "proposal_id": proposal.id,
3051            "negative_result": nr_value,
3052        }),
3053        caveats: proposal.caveats.clone(),
3054        signature: None,
3055        schema_artifact_id: None,
3056    };
3057    event.id = events::compute_event_id(&event);
3058    Ok(event)
3059}
3060
3061/// v0.52: Apply a `trajectory.create` proposal — push the inline
3062/// Trajectory to state and emit a canonical `trajectory.created`
3063/// event. Steps land later via separate `trajectory.step_append`
3064/// proposals.
3065fn apply_trajectory_create(
3066    frontier: &mut Project,
3067    proposal: &StateProposal,
3068    reviewer: &str,
3069    _decision_reason: &str,
3070) -> Result<StateEvent, String> {
3071    let traj_value = proposal
3072        .payload
3073        .get("trajectory")
3074        .ok_or("trajectory.create proposal missing payload.trajectory")?
3075        .clone();
3076    let traj: crate::bundle::Trajectory = serde_json::from_value(traj_value.clone())
3077        .map_err(|e| format!("Invalid trajectory.create payload: {e}"))?;
3078    if frontier.trajectories.iter().any(|t| t.id == traj.id) {
3079        return Err(format!(
3080            "Refusing to add duplicate trajectory with existing id {}",
3081            traj.id
3082        ));
3083    }
3084    let traj_id = traj.id.clone();
3085    frontier.trajectories.push(traj);
3086
3087    let mut event = StateEvent {
3088        schema: events::EVENT_SCHEMA.to_string(),
3089        id: String::new(),
3090        kind: events::EVENT_KIND_TRAJECTORY_CREATED.to_string(),
3091        target: StateTarget {
3092            r#type: "trajectory".to_string(),
3093            id: traj_id,
3094        },
3095        actor: StateActor {
3096            id: reviewer.to_string(),
3097            r#type: "human".to_string(),
3098        },
3099        timestamp: Utc::now().to_rfc3339(),
3100        reason: proposal.reason.clone(),
3101        before_hash: NULL_HASH.to_string(),
3102        after_hash: NULL_HASH.to_string(),
3103        payload: json!({
3104            "proposal_id": proposal.id,
3105            "trajectory": traj_value,
3106        }),
3107        caveats: proposal.caveats.clone(),
3108        signature: None,
3109        schema_artifact_id: None,
3110    };
3111    event.id = events::compute_event_id(&event);
3112    Ok(event)
3113}
3114
3115/// v0.52: Apply a `trajectory.step_append` proposal — append the
3116/// inline TrajectoryStep to the parent trajectory's `steps` and emit
3117/// a canonical `trajectory.step_appended` event. Idempotent on
3118/// duplicate step content-addresses.
3119fn apply_trajectory_step_append(
3120    frontier: &mut Project,
3121    proposal: &StateProposal,
3122    reviewer: &str,
3123    _decision_reason: &str,
3124) -> Result<StateEvent, String> {
3125    let parent_id = proposal.target.id.clone();
3126    let parent_idx = frontier
3127        .trajectories
3128        .iter()
3129        .position(|t| t.id == parent_id)
3130        .ok_or_else(|| format!("trajectory.step_append targets unknown trajectory {parent_id}"))?;
3131    let step_value = proposal
3132        .payload
3133        .get("step")
3134        .ok_or("trajectory.step_append proposal missing payload.step")?
3135        .clone();
3136    let step: crate::bundle::TrajectoryStep = serde_json::from_value(step_value.clone())
3137        .map_err(|e| format!("Invalid trajectory.step_append payload.step: {e}"))?;
3138    if frontier.trajectories[parent_idx]
3139        .steps
3140        .iter()
3141        .any(|s| s.id == step.id)
3142    {
3143        return Err(format!(
3144            "Refusing to add duplicate step with existing id {} on trajectory {}",
3145            step.id, parent_id
3146        ));
3147    }
3148    frontier.trajectories[parent_idx].steps.push(step);
3149
3150    let mut event = StateEvent {
3151        schema: events::EVENT_SCHEMA.to_string(),
3152        id: String::new(),
3153        kind: events::EVENT_KIND_TRAJECTORY_STEP_APPENDED.to_string(),
3154        target: StateTarget {
3155            r#type: "trajectory".to_string(),
3156            id: parent_id.clone(),
3157        },
3158        actor: StateActor {
3159            id: reviewer.to_string(),
3160            r#type: "human".to_string(),
3161        },
3162        timestamp: Utc::now().to_rfc3339(),
3163        reason: proposal.reason.clone(),
3164        before_hash: NULL_HASH.to_string(),
3165        after_hash: NULL_HASH.to_string(),
3166        payload: json!({
3167            "proposal_id": proposal.id,
3168            "parent_trajectory_id": parent_id,
3169            "step": step_value,
3170        }),
3171        caveats: proposal.caveats.clone(),
3172        signature: None,
3173        schema_artifact_id: None,
3174    };
3175    event.id = events::compute_event_id(&event);
3176    Ok(event)
3177}
3178
3179fn annotation_id(finding_id: &str, text: &str, author: &str, timestamp: &str) -> String {
3180    let hash = Sha256::digest(format!("{finding_id}|{text}|{author}|{timestamp}").as_bytes());
3181    format!("ann_{}", &hex::encode(hash)[..16])
3182}
3183
3184pub fn manifest_hash(path: &Path) -> Result<String, String> {
3185    let bytes = std::fs::read(path)
3186        .map_err(|e| format!("Failed to read manifest '{}': {e}", path.display()))?;
3187    Ok(hex::encode(Sha256::digest(bytes)))
3188}
3189
3190pub fn repo_proposals_dir(root: &Path) -> PathBuf {
3191    root.join(".vela/proposals")
3192}
3193
3194#[cfg(test)]
3195mod tests {
3196    use super::*;
3197    use crate::bundle::{
3198        Assertion, Conditions, Confidence, ConfidenceKind, ConfidenceMethod, Entity, Evidence,
3199        Extraction, Flags, Provenance,
3200    };
3201    use crate::project;
3202    use tempfile::TempDir;
3203
3204    fn finding(id: &str) -> FindingBundle {
3205        FindingBundle {
3206            id: id.to_string(),
3207            version: 1,
3208            previous_version: None,
3209            assertion: Assertion {
3210                text: "Test finding".to_string(),
3211                assertion_type: "mechanism".to_string(),
3212                entities: vec![Entity {
3213                    name: "LRP1".to_string(),
3214                    entity_type: "protein".to_string(),
3215                    identifiers: serde_json::Map::new(),
3216                    canonical_id: None,
3217                    candidates: Vec::new(),
3218                    aliases: Vec::new(),
3219                    resolution_provenance: None,
3220                    resolution_confidence: 1.0,
3221                    resolution_method: None,
3222                    species_context: None,
3223                    needs_review: false,
3224                }],
3225                relation: None,
3226                direction: None,
3227                causal_claim: None,
3228                causal_evidence_grade: None,
3229            },
3230            evidence: Evidence {
3231                evidence_type: "experimental".to_string(),
3232                model_system: String::new(),
3233                species: None,
3234                method: "manual".to_string(),
3235                sample_size: None,
3236                effect_size: None,
3237                p_value: None,
3238                replicated: false,
3239                replication_count: None,
3240                evidence_spans: Vec::new(),
3241            },
3242            conditions: Conditions {
3243                text: "mouse".to_string(),
3244                species_verified: Vec::new(),
3245                species_unverified: Vec::new(),
3246                in_vitro: false,
3247                in_vivo: true,
3248                human_data: false,
3249                clinical_trial: false,
3250                concentration_range: None,
3251                duration: None,
3252                age_group: None,
3253                cell_type: None,
3254            },
3255            confidence: Confidence {
3256                kind: ConfidenceKind::FrontierEpistemic,
3257                score: 0.7,
3258                basis: "test".to_string(),
3259                method: ConfidenceMethod::ExpertJudgment,
3260                components: None,
3261                extraction_confidence: 1.0,
3262            },
3263            provenance: Provenance {
3264                source_type: "published_paper".to_string(),
3265                doi: None,
3266                pmid: None,
3267                pmc: None,
3268                openalex_id: None,
3269                url: None,
3270                title: "Test".to_string(),
3271                authors: Vec::new(),
3272                year: Some(2024),
3273                journal: None,
3274                license: None,
3275                publisher: None,
3276                funders: Vec::new(),
3277                extraction: Extraction::default(),
3278                review: None,
3279                citation_count: None,
3280            },
3281            flags: Flags {
3282                gap: false,
3283                negative_space: false,
3284                contested: false,
3285                retracted: false,
3286                declining: false,
3287                gravity_well: false,
3288                review_state: None,
3289                superseded: false,
3290                signature_threshold: None,
3291                jointly_accepted: false,
3292            },
3293            links: Vec::new(),
3294            annotations: Vec::new(),
3295            attachments: Vec::new(),
3296            created: "2026-04-23T00:00:00Z".to_string(),
3297            updated: None,
3298
3299            access_tier: crate::access_tier::AccessTier::Public,
3300        }
3301    }
3302
3303    #[test]
3304    fn pending_review_proposal_does_not_mutate_frontier() {
3305        let tmp = TempDir::new().unwrap();
3306        let path = tmp.path().join("frontier.json");
3307        let frontier = project::assemble("test", vec![finding("vf_test")], 0, 0, "test");
3308        repo::save_to_path(&path, &frontier).unwrap();
3309        let proposal = new_proposal(
3310            "finding.review",
3311            StateTarget {
3312                r#type: "finding".to_string(),
3313                id: "vf_test".to_string(),
3314            },
3315            "reviewer:test",
3316            "human",
3317            "Mouse-only evidence",
3318            json!({"status": "contested"}),
3319            Vec::new(),
3320            Vec::new(),
3321        );
3322        create_or_apply(&path, proposal, false).unwrap();
3323        let loaded = repo::load_from_path(&path).unwrap();
3324        assert_eq!(loaded.events.len(), 1); // genesis only (proposal pending)
3325        assert_eq!(loaded.proposals.len(), 1);
3326        assert!(!loaded.findings[0].flags.contested);
3327    }
3328
3329    #[test]
3330    fn applied_proposal_emits_event_and_stales_proof() {
3331        let tmp = TempDir::new().unwrap();
3332        let path = tmp.path().join("frontier.json");
3333        let mut frontier = project::assemble("test", vec![finding("vf_test")], 0, 0, "test");
3334        record_proof_export(
3335            &mut frontier,
3336            ProofPacketRecord {
3337                generated_at: "2026-04-23T00:00:00Z".to_string(),
3338                snapshot_hash: "a".repeat(64),
3339                event_log_hash: "b".repeat(64),
3340                packet_manifest_hash: "c".repeat(64),
3341            },
3342        );
3343        repo::save_to_path(&path, &frontier).unwrap();
3344        let proposal = new_proposal(
3345            "finding.review",
3346            StateTarget {
3347                r#type: "finding".to_string(),
3348                id: "vf_test".to_string(),
3349            },
3350            "reviewer:test",
3351            "human",
3352            "Mouse-only evidence",
3353            json!({"status": "contested"}),
3354            Vec::new(),
3355            Vec::new(),
3356        );
3357        create_or_apply(&path, proposal, true).unwrap();
3358        let loaded = repo::load_from_path(&path).unwrap();
3359        assert_eq!(loaded.events.len(), 2); // genesis + applied
3360        assert!(loaded.findings[0].flags.contested);
3361        assert_eq!(loaded.proposals[0].status, "applied");
3362        assert_eq!(loaded.proof_state.latest_packet.status, "stale");
3363    }
3364
3365    #[test]
3366    fn preview_reports_changed_objects_and_event_kind_without_mutation() {
3367        let tmp = TempDir::new().unwrap();
3368        let path = tmp.path().join("frontier.json");
3369        let frontier = project::assemble("test", vec![finding("vf_test")], 0, 0, "test");
3370        repo::save_to_path(&path, &frontier).unwrap();
3371        let proposal = new_proposal(
3372            "finding.review",
3373            StateTarget {
3374                r#type: "finding".to_string(),
3375                id: "vf_test".to_string(),
3376            },
3377            "reviewer:test",
3378            "human",
3379            "Mouse-only evidence",
3380            json!({"status": "contested"}),
3381            Vec::new(),
3382            Vec::new(),
3383        );
3384        let proposal_id = create_or_apply(&path, proposal, false).unwrap().proposal_id;
3385
3386        let preview = preview_at_path(&path, &proposal_id, "reviewer:test").unwrap();
3387
3388        assert_eq!(preview.changed_findings, vec!["vf_test"]);
3389        assert!(preview.changed_artifacts.is_empty());
3390        assert_eq!(preview.event_kinds, vec!["finding.reviewed"]);
3391        assert_eq!(
3392            preview.new_event_ids,
3393            vec![preview.applied_event_id.clone()]
3394        );
3395        assert_eq!(preview.events_delta, 1);
3396        let loaded = repo::load_from_path(&path).unwrap();
3397        assert_eq!(loaded.events.len(), 1, "preview must not mutate events");
3398        assert_eq!(
3399            loaded.proposals[0].status, "pending_review",
3400            "preview must not accept the proposal"
3401        );
3402    }
3403
3404    #[test]
3405    fn pending_note_proposal_does_not_mutate_annotations() {
3406        let tmp = TempDir::new().unwrap();
3407        let path = tmp.path().join("frontier.json");
3408        let frontier = project::assemble("test", vec![finding("vf_test")], 0, 0, "test");
3409        repo::save_to_path(&path, &frontier).unwrap();
3410        let proposal = new_proposal(
3411            "finding.note",
3412            StateTarget {
3413                r#type: "finding".to_string(),
3414                id: "vf_test".to_string(),
3415            },
3416            "reviewer:test",
3417            "human",
3418            "Track mouse-only evidence",
3419            json!({"text": "Track mouse-only evidence"}),
3420            Vec::new(),
3421            Vec::new(),
3422        );
3423        create_or_apply(&path, proposal, false).unwrap();
3424        let loaded = repo::load_from_path(&path).unwrap();
3425        assert_eq!(loaded.events.len(), 1); // genesis only
3426        assert_eq!(loaded.proposals.len(), 1);
3427        assert!(loaded.findings[0].annotations.is_empty());
3428        assert_eq!(loaded.proposals[0].kind, "finding.note");
3429    }
3430
3431    #[test]
3432    fn applied_note_emits_noted_event_and_stales_proof() {
3433        let tmp = TempDir::new().unwrap();
3434        let path = tmp.path().join("frontier.json");
3435        let mut frontier = project::assemble("test", vec![finding("vf_test")], 0, 0, "test");
3436        record_proof_export(
3437            &mut frontier,
3438            ProofPacketRecord {
3439                generated_at: "2026-04-23T00:00:00Z".to_string(),
3440                snapshot_hash: "a".repeat(64),
3441                event_log_hash: "b".repeat(64),
3442                packet_manifest_hash: "c".repeat(64),
3443            },
3444        );
3445        repo::save_to_path(&path, &frontier).unwrap();
3446        let proposal = new_proposal(
3447            "finding.note",
3448            StateTarget {
3449                r#type: "finding".to_string(),
3450                id: "vf_test".to_string(),
3451            },
3452            "reviewer:test",
3453            "human",
3454            "Track mouse-only evidence",
3455            json!({"text": "Track mouse-only evidence"}),
3456            Vec::new(),
3457            Vec::new(),
3458        );
3459        let result = create_or_apply(&path, proposal, true).unwrap();
3460        let loaded = repo::load_from_path(&path).unwrap();
3461        assert_eq!(loaded.events.len(), 2); // genesis + finding.noted
3462        assert_eq!(loaded.events[1].kind, "finding.noted");
3463        assert_eq!(loaded.findings[0].annotations.len(), 1);
3464        assert_eq!(loaded.proposals[0].status, "applied");
3465        assert_eq!(
3466            loaded.proposals[0].applied_event_id,
3467            result.applied_event_id
3468        );
3469        assert_eq!(loaded.proof_state.latest_packet.status, "stale");
3470    }
3471
3472    #[test]
3473    fn retract_emits_per_dependent_cascade_events() {
3474        // Phase L: a retraction must emit one canonical
3475        // `finding.dependency_invalidated` event per affected dependent
3476        // in BFS depth order. Build a tiny dependency chain:
3477        //   src  <-supports- dep1  <-depends- dep2
3478        // and assert that retracting `src` produces three events:
3479        // [retracted(src), dep_invalidated(dep1, depth=1),
3480        //  dep_invalidated(dep2, depth=2)] all carrying the source's
3481        // canonical event ID as `upstream_event_id`.
3482        let tmp = TempDir::new().unwrap();
3483        let path = tmp.path().join("frontier.json");
3484        let mut src = finding("vf_src");
3485        let mut dep1 = finding("vf_dep1");
3486        let mut dep2 = finding("vf_dep2");
3487        src.assertion.text = "src finding".into();
3488        dep1.assertion.text = "dep1 finding".into();
3489        dep2.assertion.text = "dep2 finding".into();
3490        // BFS edges flow from dependent → upstream via `target`.
3491        dep1.add_link("vf_src", "supports", "");
3492        dep2.add_link("vf_dep1", "depends", "");
3493        let frontier = project::assemble("test", vec![src, dep1, dep2], 0, 0, "test");
3494        repo::save_to_path(&path, &frontier).unwrap();
3495
3496        let proposal = new_proposal(
3497            "finding.retract",
3498            StateTarget {
3499                r#type: "finding".to_string(),
3500                id: "vf_src".to_string(),
3501            },
3502            "reviewer:test",
3503            "human",
3504            "Source paper retracted by publisher",
3505            json!({}),
3506            Vec::new(),
3507            Vec::new(),
3508        );
3509        create_or_apply(&path, proposal, true).unwrap();
3510        let loaded = repo::load_from_path(&path).unwrap();
3511
3512        // genesis + 1 source retract + 2 cascade events = 4 total.
3513        assert_eq!(loaded.events.len(), 4, "{:?}", loaded.events);
3514        let kinds: Vec<&str> = loaded.events.iter().map(|e| e.kind.as_str()).collect();
3515        assert_eq!(kinds[0], "frontier.created");
3516        assert_eq!(kinds[1], "finding.retracted");
3517        assert_eq!(kinds[2], "finding.dependency_invalidated");
3518        assert_eq!(kinds[3], "finding.dependency_invalidated");
3519
3520        let source_event_id = loaded.events[1].id.clone();
3521        let dep1_event = &loaded.events[2];
3522        let dep2_event = &loaded.events[3];
3523        assert_eq!(dep1_event.target.id, "vf_dep1");
3524        assert_eq!(dep2_event.target.id, "vf_dep2");
3525        assert_eq!(
3526            dep1_event
3527                .payload
3528                .get("upstream_event_id")
3529                .and_then(|v| v.as_str()),
3530            Some(source_event_id.as_str())
3531        );
3532        assert_eq!(
3533            dep1_event.payload.get("depth").and_then(|v| v.as_u64()),
3534            Some(1)
3535        );
3536        assert_eq!(
3537            dep2_event.payload.get("depth").and_then(|v| v.as_u64()),
3538            Some(2)
3539        );
3540        // Both dependents must end up contested in materialized state.
3541        let dep1 = loaded.findings.iter().find(|f| f.id == "vf_dep1").unwrap();
3542        let dep2 = loaded.findings.iter().find(|f| f.id == "vf_dep2").unwrap();
3543        assert!(dep1.flags.contested);
3544        assert!(dep2.flags.contested);
3545        let src = loaded.findings.iter().find(|f| f.id == "vf_src").unwrap();
3546        assert!(src.flags.retracted);
3547    }
3548
3549    #[test]
3550    fn proposal_id_is_content_addressed_independent_of_created_at() {
3551        // Phase P (v0.5): identical logical proposals constructed at different
3552        // times must produce the same `vpr_…`. This is the substrate property
3553        // that makes agent retries idempotent.
3554        let target = StateTarget {
3555            r#type: "finding".to_string(),
3556            id: "vf_test".to_string(),
3557        };
3558        let mut a = new_proposal(
3559            "finding.review",
3560            target.clone(),
3561            "reviewer:test",
3562            "human",
3563            "scope narrower than claim",
3564            json!({"status": "contested"}),
3565            Vec::new(),
3566            Vec::new(),
3567        );
3568        let mut b = new_proposal(
3569            "finding.review",
3570            target,
3571            "reviewer:test",
3572            "human",
3573            "scope narrower than claim",
3574            json!({"status": "contested"}),
3575            Vec::new(),
3576            Vec::new(),
3577        );
3578        // Force divergent timestamps; the IDs must still match.
3579        a.created_at = "2026-04-25T00:00:00Z".to_string();
3580        b.created_at = "2026-09-12T17:32:00Z".to_string();
3581        a.id = proposal_id(&a);
3582        b.id = proposal_id(&b);
3583        assert_eq!(a.id, b.id, "vpr_… must not depend on created_at");
3584    }
3585
3586    #[test]
3587    fn create_or_apply_is_idempotent_under_repeated_calls() {
3588        // Phase P: invoking create_or_apply twice with identical content must
3589        // not duplicate the proposal nor emit two events. The second call
3590        // returns the same proposal_id and applied_event_id as the first.
3591        let tmp = TempDir::new().unwrap();
3592        let path = tmp.path().join("frontier.json");
3593        let frontier = project::assemble("test", vec![finding("vf_test")], 0, 0, "test");
3594        repo::save_to_path(&path, &frontier).unwrap();
3595
3596        let make = || {
3597            new_proposal(
3598                "finding.review",
3599                StateTarget {
3600                    r#type: "finding".to_string(),
3601                    id: "vf_test".to_string(),
3602                },
3603                "reviewer:test",
3604                "human",
3605                "agent retry test",
3606                json!({"status": "contested"}),
3607                Vec::new(),
3608                Vec::new(),
3609            )
3610        };
3611
3612        let first = create_or_apply(&path, make(), true).unwrap();
3613        let second = create_or_apply(&path, make(), true).unwrap();
3614
3615        assert_eq!(first.proposal_id, second.proposal_id);
3616        assert_eq!(first.applied_event_id, second.applied_event_id);
3617
3618        let loaded = repo::load_from_path(&path).unwrap();
3619        assert_eq!(
3620            loaded.proposals.len(),
3621            1,
3622            "second create_or_apply must not insert a duplicate proposal"
3623        );
3624        // genesis + 1 applied review event = 2; not 3.
3625        assert_eq!(
3626            loaded.events.len(),
3627            2,
3628            "second create_or_apply must not emit a duplicate event"
3629        );
3630    }
3631
3632    #[test]
3633    fn accepting_applied_proposal_is_idempotent() {
3634        let tmp = TempDir::new().unwrap();
3635        let path = tmp.path().join("frontier.json");
3636        let frontier = project::assemble("test", vec![finding("vf_test")], 0, 0, "test");
3637        repo::save_to_path(&path, &frontier).unwrap();
3638        let proposal = new_proposal(
3639            "finding.review",
3640            StateTarget {
3641                r#type: "finding".to_string(),
3642                id: "vf_test".to_string(),
3643            },
3644            "reviewer:test",
3645            "human",
3646            "Mouse-only evidence",
3647            json!({"status": "contested"}),
3648            Vec::new(),
3649            Vec::new(),
3650        );
3651        let created = create_or_apply(&path, proposal, true).unwrap();
3652        let first_event = created.applied_event_id.clone().unwrap();
3653        let second_event =
3654            accept_at_path(&path, &created.proposal_id, "reviewer:test", "same").unwrap();
3655        assert_eq!(first_event, second_event);
3656    }
3657
3658    #[test]
3659    fn v0_13_apply_materializes_source_records_inline() {
3660        // Pre-v0.13: vela check --strict on a CLI-built frontier flagged
3661        // `missing_source_record` because source_records weren't populated
3662        // until vela normalize --write — and normalize refuses on event-ful
3663        // frontiers. v0.13 materializes inline at apply time so source_records
3664        // grow in lockstep with findings.
3665        let tmp = TempDir::new().unwrap();
3666        let path = tmp.path().join("frontier.json");
3667        let mut frontier = project::assemble("test", vec![], 0, 0, "test");
3668        repo::save_to_path(&path, &frontier).unwrap();
3669        // Add a finding via the standard finding.add proposal flow.
3670        let f = finding("vf_v013_inline_src");
3671        let proposal = new_proposal(
3672            "finding.add",
3673            StateTarget {
3674                r#type: "finding".to_string(),
3675                id: f.id.clone(),
3676            },
3677            "reviewer:test",
3678            "human",
3679            "Manual finding for v0.13 source-record materialization test",
3680            json!({"finding": f}),
3681            Vec::new(),
3682            Vec::new(),
3683        );
3684        create_or_apply(&path, proposal, true).unwrap();
3685        let loaded = repo::load_from_path(&path).unwrap();
3686        // Source records, evidence atoms, and condition records should all
3687        // be materialized — without any explicit normalize call.
3688        assert!(
3689            !loaded.sources.is_empty(),
3690            "v0.13: source_records should materialize inline at apply time"
3691        );
3692        assert!(
3693            !loaded.evidence_atoms.is_empty(),
3694            "v0.13: evidence_atoms should materialize inline at apply time"
3695        );
3696        assert!(
3697            !loaded.condition_records.is_empty(),
3698            "v0.13: condition_records should materialize inline at apply time"
3699        );
3700        // Sanity: stats reflect the new source registry.
3701        assert_eq!(loaded.stats.source_count, loaded.sources.len());
3702        // Suppress unused-mut warning when frontier isn't reused below.
3703        let _ = &mut frontier;
3704    }
3705
3706    fn make_supersede_payload(old_id: &str, new_text: &str) -> (FindingBundle, Value) {
3707        let mut new_finding = finding("vf_supersede_new");
3708        new_finding.assertion.text = new_text.to_string();
3709        // Re-derive id from the new assertion text + provenance. For the
3710        // test we just hand-pick a distinct id; the real CLI uses
3711        // `build_finding_bundle` which content-addresses correctly.
3712        new_finding.id = format!(
3713            "vf_{:0>16}",
3714            old_id
3715                .bytes()
3716                .fold(0u64, |acc, b| acc.wrapping_add(b as u64))
3717        );
3718        let payload = json!({"new_finding": new_finding.clone()});
3719        (new_finding, payload)
3720    }
3721
3722    #[test]
3723    fn v0_14_supersede_creates_new_finding_and_marks_old() {
3724        let tmp = TempDir::new().unwrap();
3725        let path = tmp.path().join("frontier.json");
3726        let mut frontier = project::assemble("test", vec![finding("vf_old")], 0, 0, "test");
3727        repo::save_to_path(&path, &frontier).unwrap();
3728        let (new_finding, payload) = make_supersede_payload("vf_old", "Newer claim");
3729        let proposal = new_proposal(
3730            "finding.supersede",
3731            StateTarget {
3732                r#type: "finding".to_string(),
3733                id: "vf_old".to_string(),
3734            },
3735            "reviewer:test",
3736            "human",
3737            "Newer evidence updates the wording",
3738            payload,
3739            Vec::new(),
3740            Vec::new(),
3741        );
3742        let result = create_or_apply(&path, proposal, true).unwrap();
3743        assert!(result.applied_event_id.is_some());
3744        let loaded = repo::load_from_path(&path).unwrap();
3745        // Old finding now flagged superseded.
3746        let old = loaded.findings.iter().find(|f| f.id == "vf_old").unwrap();
3747        assert!(
3748            old.flags.superseded,
3749            "old finding should be flagged superseded"
3750        );
3751        // New finding present, with auto-injected supersedes link back to old.
3752        let new_f = loaded
3753            .findings
3754            .iter()
3755            .find(|f| f.id == new_finding.id)
3756            .expect("new finding should be in frontier");
3757        assert!(
3758            new_f
3759                .links
3760                .iter()
3761                .any(|l| l.target == "vf_old" && l.link_type == "supersedes"),
3762            "new finding should have an auto-injected supersedes link to old finding"
3763        );
3764        // Event with kind finding.superseded targeting old, payload carries new_finding_id.
3765        let supersede_event = loaded
3766            .events
3767            .iter()
3768            .find(|e| e.kind == "finding.superseded")
3769            .expect("a finding.superseded event should be emitted");
3770        assert_eq!(supersede_event.target.id, "vf_old");
3771        assert_eq!(
3772            supersede_event.payload["new_finding_id"].as_str(),
3773            Some(new_finding.id.as_str())
3774        );
3775        // suppress unused warning
3776        let _ = &mut frontier;
3777    }
3778
3779    #[test]
3780    fn v0_14_supersede_refuses_already_superseded() {
3781        let tmp = TempDir::new().unwrap();
3782        let path = tmp.path().join("frontier.json");
3783        let mut old = finding("vf_already_done");
3784        old.flags.superseded = true;
3785        let frontier = project::assemble("test", vec![old], 0, 0, "test");
3786        repo::save_to_path(&path, &frontier).unwrap();
3787        let (_, payload) = make_supersede_payload("vf_already_done", "Newer wording");
3788        let proposal = new_proposal(
3789            "finding.supersede",
3790            StateTarget {
3791                r#type: "finding".to_string(),
3792                id: "vf_already_done".to_string(),
3793            },
3794            "reviewer:test",
3795            "human",
3796            "Attempt to double-supersede",
3797            payload,
3798            Vec::new(),
3799            Vec::new(),
3800        );
3801        let result = create_or_apply(&path, proposal, true);
3802        assert!(
3803            result.is_err(),
3804            "double-supersede should be refused; got {result:?}"
3805        );
3806    }
3807
3808    #[test]
3809    fn v0_14_supersede_refuses_same_content_address() {
3810        let tmp = TempDir::new().unwrap();
3811        let path = tmp.path().join("frontier.json");
3812        let frontier = project::assemble("test", vec![finding("vf_same")], 0, 0, "test");
3813        repo::save_to_path(&path, &frontier).unwrap();
3814        // new_finding.id == target.id should be refused at validate-time.
3815        let mut new_finding = finding("vf_same");
3816        new_finding.assertion.text = "Different text but reused id".to_string();
3817        let proposal = new_proposal(
3818            "finding.supersede",
3819            StateTarget {
3820                r#type: "finding".to_string(),
3821                id: "vf_same".to_string(),
3822            },
3823            "reviewer:test",
3824            "human",
3825            "Same id, should fail",
3826            json!({"new_finding": new_finding}),
3827            Vec::new(),
3828            Vec::new(),
3829        );
3830        let result = create_or_apply(&path, proposal, true);
3831        assert!(
3832            result.is_err(),
3833            "supersede with same content address should be refused; got {result:?}"
3834        );
3835    }
3836
3837    /// v0.22 byte-stability: a proposal with `agent_run = None`
3838    /// must serialize without an `agent_run` field, so existing
3839    /// frontiers (none of which have agent_run today) round-trip
3840    /// byte-identically. The whole substrate guarantee depends on
3841    /// canonical-JSON not silently gaining new keys.
3842    #[test]
3843    fn agent_run_none_skips_serialization() {
3844        let p = new_proposal(
3845            "finding.add",
3846            StateTarget {
3847                r#type: "finding".to_string(),
3848                id: "vf_test0000000000".to_string(),
3849            },
3850            "reviewer:will-blair",
3851            "human",
3852            "test",
3853            json!({}),
3854            Vec::new(),
3855            Vec::new(),
3856        );
3857        let bytes = canonical::to_canonical_bytes(&p).unwrap();
3858        let s = std::str::from_utf8(&bytes).unwrap();
3859        assert!(
3860            !s.contains("agent_run"),
3861            "proposal without agent_run leaked the field into canonical JSON: {s}"
3862        );
3863    }
3864
3865    /// And when `agent_run` *is* set, the same proposal id is
3866    /// produced regardless — `proposal_id`'s preimage explicitly
3867    /// excludes agent_run, so attaching provenance never changes
3868    /// the content address.
3869    #[test]
3870    fn agent_run_does_not_change_proposal_id() {
3871        let bare = new_proposal(
3872            "finding.add",
3873            StateTarget {
3874                r#type: "finding".to_string(),
3875                id: "vf_test0000000000".to_string(),
3876            },
3877            "agent:literature-scout",
3878            "agent",
3879            "scout extracted this from paper_014",
3880            json!({}),
3881            vec!["src_paper_014".to_string()],
3882            Vec::new(),
3883        );
3884        let id_bare = bare.id.clone();
3885
3886        let mut with_run = bare.clone();
3887        with_run.agent_run = Some(AgentRun {
3888            agent: "literature-scout".to_string(),
3889            model: "claude-opus-4-7".to_string(),
3890            run_id: "vrun_abc1234567890def".to_string(),
3891            started_at: "2026-04-26T01:23:45Z".to_string(),
3892            finished_at: Some("2026-04-26T01:24:10Z".to_string()),
3893            context: BTreeMap::from([
3894                ("input_folder".to_string(), "./papers".to_string()),
3895                ("pdf_count".to_string(), "12".to_string()),
3896            ]),
3897            tool_calls: Vec::new(),
3898            permissions: None,
3899        });
3900        let id_with_run = proposal_id(&with_run);
3901        assert_eq!(
3902            id_bare, id_with_run,
3903            "agent_run leaked into proposal_id preimage"
3904        );
3905    }
3906
3907    /// v0.49 byte-stability: tool_calls and permissions on AgentRun
3908    /// must skip serialization when empty/None, so existing frontiers
3909    /// (none of which carry these fields today) round-trip byte-
3910    /// identically through canonical JSON. Same invariant as
3911    /// agent_run itself in v0.22.
3912    #[test]
3913    fn agent_run_empty_tool_calls_and_permissions_skip_serialization() {
3914        let p = new_proposal(
3915            "finding.add",
3916            StateTarget {
3917                r#type: "finding".to_string(),
3918                id: "vf_test0000000000".to_string(),
3919            },
3920            "agent:scout",
3921            "agent",
3922            "test",
3923            json!({}),
3924            Vec::new(),
3925            Vec::new(),
3926        );
3927        let mut with_run = p.clone();
3928        with_run.agent_run = Some(AgentRun {
3929            agent: "scout".to_string(),
3930            model: "claude-opus-4-7".to_string(),
3931            run_id: "vrun_x".to_string(),
3932            started_at: "2026-04-26T01:00:00Z".to_string(),
3933            finished_at: None,
3934            context: BTreeMap::new(),
3935            tool_calls: Vec::new(),
3936            permissions: None,
3937        });
3938        let bytes = canonical::to_canonical_bytes(&with_run).unwrap();
3939        let s = std::str::from_utf8(&bytes).unwrap();
3940        assert!(
3941            !s.contains("tool_calls"),
3942            "empty tool_calls leaked into canonical JSON: {s}"
3943        );
3944        assert!(
3945            !s.contains("permissions"),
3946            "empty permissions leaked into canonical JSON: {s}"
3947        );
3948    }
3949
3950    /// v0.49: when populated, tool_calls and permissions DO serialize
3951    /// — this is the round-trip we want for new agent runs that
3952    /// actually carry tool traces.
3953    #[test]
3954    fn agent_run_populated_tool_calls_and_permissions_roundtrip() {
3955        let mut p = new_proposal(
3956            "finding.add",
3957            StateTarget {
3958                r#type: "finding".to_string(),
3959                id: "vf_test0000000000".to_string(),
3960            },
3961            "agent:scout",
3962            "agent",
3963            "test",
3964            json!({}),
3965            Vec::new(),
3966            Vec::new(),
3967        );
3968        p.agent_run = Some(AgentRun {
3969            agent: "scout".to_string(),
3970            model: "claude-opus-4-7".to_string(),
3971            run_id: "vrun_x".to_string(),
3972            started_at: "2026-04-26T01:00:00Z".to_string(),
3973            finished_at: None,
3974            context: BTreeMap::new(),
3975            tool_calls: vec![
3976                ToolCallTrace {
3977                    tool: "pubmed_search".to_string(),
3978                    input_sha256: "a".repeat(64),
3979                    output_sha256: Some("b".repeat(64)),
3980                    at: "2026-04-26T01:00:05Z".to_string(),
3981                    duration_ms: Some(842),
3982                    status: "ok".to_string(),
3983                    error_message: String::new(),
3984                },
3985                // v0.49: a failed tool call with an explanatory
3986                // error_message — the field a reviewer needs to audit
3987                // what went wrong without re-running the agent.
3988                ToolCallTrace {
3989                    tool: "arxiv_fetch".to_string(),
3990                    input_sha256: "c".repeat(64),
3991                    output_sha256: None,
3992                    at: "2026-04-26T01:00:18Z".to_string(),
3993                    duration_ms: Some(1200),
3994                    status: "error".to_string(),
3995                    error_message: "HTTP 503 from arxiv.org; retry budget exhausted".to_string(),
3996                },
3997            ],
3998            permissions: Some(PermissionState {
3999                data_access: vec!["pubmed:".to_string(), "frontier:vfr_bd91".to_string()],
4000                tool_access: vec!["pubmed_search".to_string(), "arxiv_fetch".to_string()],
4001                note: "read-only access to BBB Flagship".to_string(),
4002            }),
4003        });
4004        let bytes = canonical::to_canonical_bytes(&p).unwrap();
4005        let json: serde_json::Value =
4006            serde_json::from_slice(&bytes).expect("canonical bytes round-trip");
4007        assert_eq!(
4008            json["agent_run"]["tool_calls"][0]["tool"], "pubmed_search",
4009            "tool_calls did not survive the round trip: {json}"
4010        );
4011        assert_eq!(
4012            json["agent_run"]["permissions"]["data_access"][0], "pubmed:",
4013            "permissions did not survive the round trip: {json}"
4014        );
4015        // v0.49: a failed tool call with error_message carries the
4016        // explanation through canonical JSON. A reviewer can audit
4017        // exactly what failed without rerunning the agent.
4018        assert_eq!(
4019            json["agent_run"]["tool_calls"][1]["status"], "error",
4020            "failed tool call status did not survive: {json}"
4021        );
4022        assert_eq!(
4023            json["agent_run"]["tool_calls"][1]["error_message"],
4024            "HTTP 503 from arxiv.org; retry budget exhausted",
4025            "error_message did not survive the round trip: {json}"
4026        );
4027        // ...and successful calls still don't leak an empty
4028        // error_message into canonical bytes.
4029        let raw = std::str::from_utf8(&bytes).unwrap();
4030        let okay_call_block_end = raw.find("pubmed_search").unwrap();
4031        let until_first_call = &raw[..okay_call_block_end + 200];
4032        assert!(
4033            !until_first_call.contains("\"error_message\":\"\""),
4034            "successful tool call leaked an empty error_message: {until_first_call}"
4035        );
4036    }
4037}