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.56: Mechanical evidence-atom locator repair. Targets one
1249        // evidence atom by id; payload carries the resolved locator
1250        // string and the parent source id it was derived from. The
1251        // proposal is mechanical: the locator is already present on
1252        // `frontier.sources[atom.source_id].locator`. Reviewer accepts
1253        // (or auto-accepts) and the canonical event lands the locator
1254        // on the atom while preserving the derivation in the payload.
1255        "evidence_atom.locator_repair" => {
1256            if proposal.target.r#type != "evidence_atom" {
1257                return Err(format!(
1258                    "evidence_atom.locator_repair target.type must be 'evidence_atom', got '{}'",
1259                    proposal.target.r#type
1260                ));
1261            }
1262            let atom_id = proposal.target.id.as_str();
1263            let atom = frontier
1264                .evidence_atoms
1265                .iter()
1266                .find(|atom| atom.id == atom_id)
1267                .ok_or_else(|| {
1268                    format!("evidence_atom.locator_repair targets unknown atom {atom_id}")
1269                })?;
1270            let locator = proposal
1271                .payload
1272                .get("locator")
1273                .and_then(Value::as_str)
1274                .ok_or("evidence_atom.locator_repair proposal missing payload.locator")?;
1275            if locator.trim().is_empty() {
1276                return Err(
1277                    "evidence_atom.locator_repair payload.locator must be non-empty".to_string(),
1278                );
1279            }
1280            let source_id = proposal
1281                .payload
1282                .get("source_id")
1283                .and_then(Value::as_str)
1284                .ok_or("evidence_atom.locator_repair proposal missing payload.source_id")?;
1285            if source_id.trim().is_empty() {
1286                return Err(
1287                    "evidence_atom.locator_repair payload.source_id must be non-empty".to_string(),
1288                );
1289            }
1290            if atom.source_id != source_id {
1291                return Err(format!(
1292                    "evidence_atom.locator_repair payload.source_id '{source_id}' does not match atom.source_id '{}'",
1293                    atom.source_id
1294                ));
1295            }
1296            // Refuse a no-op repair so the curation pipeline doesn't
1297            // emit empty events. An atom that already carries the same
1298            // locator should be filtered upstream.
1299            if let Some(existing) = &atom.locator
1300                && existing == locator
1301            {
1302                return Err(format!(
1303                    "evidence_atom {atom_id} already carries locator '{existing}'"
1304                ));
1305            }
1306            // Refuse a divergent overwrite. A different existing
1307            // locator is a chain-integrity issue, not a repair.
1308            if let Some(existing) = &atom.locator
1309                && existing != locator
1310            {
1311                return Err(format!(
1312                    "evidence_atom {atom_id} already carries locator '{existing}'; refusing to overwrite with '{locator}'"
1313                ));
1314            }
1315        }
1316        // v0.52: Append a step to an existing Trajectory through the
1317        // proposals pipeline. target.id is the parent vtr_*; payload
1318        // carries the inline TrajectoryStep.
1319        "trajectory.step_append" => {
1320            if proposal.target.r#type != "trajectory" {
1321                return Err(format!(
1322                    "trajectory.step_append proposal target.type must be 'trajectory', got '{}'",
1323                    proposal.target.r#type
1324                ));
1325            }
1326            let parent_id = proposal.target.id.as_str();
1327            let parent_idx = frontier
1328                .trajectories
1329                .iter()
1330                .position(|t| t.id == parent_id)
1331                .ok_or_else(|| {
1332                    format!("trajectory.step_append targets unknown trajectory {parent_id}")
1333                })?;
1334            let step_value = proposal
1335                .payload
1336                .get("step")
1337                .ok_or("trajectory.step_append proposal missing payload.step")?
1338                .clone();
1339            let step: crate::bundle::TrajectoryStep = serde_json::from_value(step_value)
1340                .map_err(|e| format!("Invalid trajectory.step_append payload.step: {e}"))?;
1341            if frontier.trajectories[parent_idx]
1342                .steps
1343                .iter()
1344                .any(|s| s.id == step.id)
1345            {
1346                return Err(format!(
1347                    "Refusing to add duplicate step with existing id {} on trajectory {}",
1348                    step.id, parent_id
1349                ));
1350            }
1351        }
1352        // v0.59: federation conflict resolution. Reviewer-driven
1353        // verdict on a previously emitted `frontier.conflict_detected`
1354        // event. The conflict event itself is not modified; this
1355        // proposal records the resolution as a paired event.
1356        "frontier.conflict_resolve" => {
1357            if proposal.target.r#type != "frontier_observation" {
1358                return Err(format!(
1359                    "frontier.conflict_resolve target.type must be 'frontier_observation', got '{}'",
1360                    proposal.target.r#type
1361                ));
1362            }
1363            let conflict_event_id = proposal
1364                .payload
1365                .get("conflict_event_id")
1366                .and_then(Value::as_str)
1367                .ok_or("frontier.conflict_resolve proposal missing payload.conflict_event_id")?;
1368            if conflict_event_id.trim().is_empty() {
1369                return Err(
1370                    "frontier.conflict_resolve payload.conflict_event_id must be non-empty"
1371                        .to_string(),
1372                );
1373            }
1374            // The named conflict event must actually be present on
1375            // this frontier. A reviewer can't resolve a conflict that
1376            // hasn't been detected.
1377            let conflict_event = frontier
1378                .events
1379                .iter()
1380                .find(|e| e.id == conflict_event_id)
1381                .ok_or_else(|| {
1382                    format!(
1383                        "frontier.conflict_resolve targets unknown event id '{conflict_event_id}'"
1384                    )
1385                })?;
1386            if conflict_event.kind != "frontier.conflict_detected" {
1387                return Err(format!(
1388                    "frontier.conflict_resolve target event '{conflict_event_id}' has kind '{}', expected 'frontier.conflict_detected'",
1389                    conflict_event.kind
1390                ));
1391            }
1392            // Refuse double-resolution: if a `frontier.conflict_resolved`
1393            // event already exists pointing at this conflict_event_id,
1394            // there's nothing to resolve.
1395            if frontier.events.iter().any(|e| {
1396                e.kind == "frontier.conflict_resolved"
1397                    && e.payload.get("conflict_event_id").and_then(Value::as_str)
1398                        == Some(conflict_event_id)
1399            }) {
1400                return Err(format!(
1401                    "Conflict event '{conflict_event_id}' already has a recorded resolution"
1402                ));
1403            }
1404            let note = proposal
1405                .payload
1406                .get("resolution_note")
1407                .and_then(Value::as_str)
1408                .ok_or("frontier.conflict_resolve proposal missing payload.resolution_note")?;
1409            if note.trim().is_empty() {
1410                return Err(
1411                    "frontier.conflict_resolve payload.resolution_note must be non-empty"
1412                        .to_string(),
1413                );
1414            }
1415            // winning_proposal_id is optional; some conflicts resolve
1416            // by reviewer judgment without picking a specific proposal.
1417            if let Some(value) = proposal.payload.get("winning_proposal_id")
1418                && !value.is_null()
1419                && value.as_str().is_none()
1420            {
1421                return Err(
1422                    "frontier.conflict_resolve payload.winning_proposal_id must be a string when present"
1423                        .to_string(),
1424                );
1425            }
1426        }
1427        other => {
1428            return Err(format!("Unsupported proposal kind '{other}'"));
1429        }
1430    }
1431    Ok(())
1432}
1433
1434fn validate_decision_state(proposal: &StateProposal) -> Result<(), String> {
1435    match proposal.status.as_str() {
1436        "pending_review" => Ok(()),
1437        "accepted" | "applied" | "rejected" => {
1438            let reviewer = proposal
1439                .reviewed_by
1440                .as_deref()
1441                .ok_or_else(|| format!("Proposal {} missing reviewed_by", proposal.id))?;
1442            validate_reviewer_identity(reviewer)?;
1443            if proposal
1444                .decision_reason
1445                .as_deref()
1446                .is_none_or(|reason| reason.trim().is_empty())
1447            {
1448                return Err(format!("Proposal {} missing decision_reason", proposal.id));
1449            }
1450            if proposal.status == "applied" && proposal.applied_event_id.is_none() {
1451                return Err(format!(
1452                    "Applied proposal {} missing applied_event_id",
1453                    proposal.id
1454                ));
1455            }
1456            Ok(())
1457        }
1458        other => Err(format!("Unsupported proposal status '{}'", other)),
1459    }
1460}
1461
1462fn validate_standalone_proposal(
1463    _frontier: &Project,
1464    proposal: &StateProposal,
1465) -> Result<(), String> {
1466    if proposal.schema != PROPOSAL_SCHEMA {
1467        return Err(format!("Unsupported proposal schema '{}'", proposal.schema));
1468    }
1469    if !matches!(
1470        proposal.target.r#type.as_str(),
1471        "finding" | "evidence_atom" | "frontier_observation"
1472    ) {
1473        return Err(
1474            "Only finding, evidence_atom, and frontier_observation proposals are supported in v0"
1475                .to_string(),
1476        );
1477    }
1478    if proposal.reason.trim().is_empty() {
1479        return Err("Proposal reason must be non-empty".to_string());
1480    }
1481    match proposal.kind.as_str() {
1482        "finding.add" => {
1483            let finding_value = proposal
1484                .payload
1485                .get("finding")
1486                .ok_or("finding.add proposal missing payload.finding")?
1487                .clone();
1488            let finding: FindingBundle = serde_json::from_value(finding_value)
1489                .map_err(|e| format!("Invalid finding.add payload: {e}"))?;
1490            if finding.id != proposal.target.id {
1491                return Err(format!(
1492                    "finding.add target {} does not match payload finding {}",
1493                    proposal.target.id, finding.id
1494                ));
1495            }
1496        }
1497        "finding.review" => {
1498            let status = proposal
1499                .payload
1500                .get("status")
1501                .and_then(Value::as_str)
1502                .ok_or("finding.review proposal missing payload.status")?;
1503            if !matches!(
1504                status,
1505                "accepted" | "approved" | "contested" | "needs_revision" | "rejected"
1506            ) {
1507                return Err(format!("Unsupported review proposal status '{status}'"));
1508            }
1509        }
1510        "finding.caveat" => {
1511            let text = proposal
1512                .payload
1513                .get("text")
1514                .and_then(Value::as_str)
1515                .ok_or("finding.caveat proposal missing payload.text")?;
1516            if text.trim().is_empty() {
1517                return Err("finding.caveat payload.text must be non-empty".to_string());
1518            }
1519        }
1520        "finding.note" => {
1521            let text = proposal
1522                .payload
1523                .get("text")
1524                .and_then(Value::as_str)
1525                .ok_or("finding.note proposal missing payload.text")?;
1526            if text.trim().is_empty() {
1527                return Err("finding.note payload.text must be non-empty".to_string());
1528            }
1529        }
1530        "finding.confidence_revise" => {
1531            let score = proposal
1532                .payload
1533                .get("confidence")
1534                .and_then(Value::as_f64)
1535                .ok_or("finding.confidence_revise proposal missing payload.confidence")?;
1536            if !(0.0..=1.0).contains(&score) {
1537                return Err(
1538                    "finding.confidence_revise confidence must be between 0.0 and 1.0".to_string(),
1539                );
1540            }
1541        }
1542        "finding.reject" | "finding.retract" => {}
1543        "finding.supersede" => {
1544            let new_finding_value = proposal
1545                .payload
1546                .get("new_finding")
1547                .ok_or("finding.supersede proposal missing payload.new_finding")?
1548                .clone();
1549            let new_finding: FindingBundle = serde_json::from_value(new_finding_value)
1550                .map_err(|e| format!("Invalid finding.supersede payload.new_finding: {e}"))?;
1551            if new_finding.id == proposal.target.id {
1552                return Err(
1553                    "finding.supersede new_finding has same content address as the superseded target"
1554                        .to_string(),
1555                );
1556            }
1557        }
1558        // v0.57: standalone validation of finding span-repair.
1559        "finding.span_repair" => {
1560            if proposal.target.r#type != "finding" {
1561                return Err(format!(
1562                    "finding.span_repair target.type must be 'finding', got '{}'",
1563                    proposal.target.r#type
1564                ));
1565            }
1566            let section = proposal
1567                .payload
1568                .get("section")
1569                .and_then(Value::as_str)
1570                .ok_or("finding.span_repair proposal missing payload.section")?;
1571            if section.trim().is_empty() {
1572                return Err("finding.span_repair payload.section must be non-empty".to_string());
1573            }
1574            let text = proposal
1575                .payload
1576                .get("text")
1577                .and_then(Value::as_str)
1578                .ok_or("finding.span_repair proposal missing payload.text")?;
1579            if text.trim().is_empty() {
1580                return Err("finding.span_repair payload.text must be non-empty".to_string());
1581            }
1582        }
1583        // v0.57: standalone validation of finding entity-resolve.
1584        "finding.entity_resolve" => {
1585            if proposal.target.r#type != "finding" {
1586                return Err(format!(
1587                    "finding.entity_resolve target.type must be 'finding', got '{}'",
1588                    proposal.target.r#type
1589                ));
1590            }
1591            let entity_name = proposal
1592                .payload
1593                .get("entity_name")
1594                .and_then(Value::as_str)
1595                .ok_or("finding.entity_resolve proposal missing payload.entity_name")?;
1596            if entity_name.trim().is_empty() {
1597                return Err(
1598                    "finding.entity_resolve payload.entity_name must be non-empty".to_string(),
1599                );
1600            }
1601            let source = proposal
1602                .payload
1603                .get("source")
1604                .and_then(Value::as_str)
1605                .ok_or("finding.entity_resolve proposal missing payload.source")?;
1606            if source.trim().is_empty() {
1607                return Err("finding.entity_resolve payload.source must be non-empty".to_string());
1608            }
1609            let id = proposal
1610                .payload
1611                .get("id")
1612                .and_then(Value::as_str)
1613                .ok_or("finding.entity_resolve proposal missing payload.id")?;
1614            if id.trim().is_empty() {
1615                return Err("finding.entity_resolve payload.id must be non-empty".to_string());
1616            }
1617            let confidence = proposal
1618                .payload
1619                .get("confidence")
1620                .and_then(Value::as_f64)
1621                .ok_or("finding.entity_resolve proposal missing payload.confidence")?;
1622            if !(0.0..=1.0).contains(&confidence) {
1623                return Err(format!(
1624                    "finding.entity_resolve confidence {confidence} out of [0.0, 1.0]"
1625                ));
1626            }
1627        }
1628        // v0.56: standalone validation of an evidence-atom locator
1629        // repair. Mirrors the contextual validator in
1630        // `validate_proposal_shape`, except without frontier-side
1631        // existence checks (the standalone validator runs over an
1632        // exported proposal before it is loaded into a frontier).
1633        "evidence_atom.locator_repair" => {
1634            if proposal.target.r#type != "evidence_atom" {
1635                return Err(format!(
1636                    "evidence_atom.locator_repair target.type must be 'evidence_atom', got '{}'",
1637                    proposal.target.r#type
1638                ));
1639            }
1640            let locator = proposal
1641                .payload
1642                .get("locator")
1643                .and_then(Value::as_str)
1644                .ok_or("evidence_atom.locator_repair proposal missing payload.locator")?;
1645            if locator.trim().is_empty() {
1646                return Err(
1647                    "evidence_atom.locator_repair payload.locator must be non-empty".to_string(),
1648                );
1649            }
1650            let source_id = proposal
1651                .payload
1652                .get("source_id")
1653                .and_then(Value::as_str)
1654                .ok_or("evidence_atom.locator_repair proposal missing payload.source_id")?;
1655            if source_id.trim().is_empty() {
1656                return Err(
1657                    "evidence_atom.locator_repair payload.source_id must be non-empty".to_string(),
1658                );
1659            }
1660        }
1661        // v0.59: federation conflict resolution (standalone shape;
1662        // no frontier-existence checks here, the apply step verifies
1663        // the conflict_event_id is present).
1664        "frontier.conflict_resolve" => {
1665            if proposal.target.r#type != "frontier_observation" {
1666                return Err(format!(
1667                    "frontier.conflict_resolve target.type must be 'frontier_observation', got '{}'",
1668                    proposal.target.r#type
1669                ));
1670            }
1671            let conflict_event_id = proposal
1672                .payload
1673                .get("conflict_event_id")
1674                .and_then(Value::as_str)
1675                .ok_or("frontier.conflict_resolve proposal missing payload.conflict_event_id")?;
1676            if conflict_event_id.trim().is_empty() {
1677                return Err(
1678                    "frontier.conflict_resolve payload.conflict_event_id must be non-empty"
1679                        .to_string(),
1680                );
1681            }
1682            let note = proposal
1683                .payload
1684                .get("resolution_note")
1685                .and_then(Value::as_str)
1686                .ok_or("frontier.conflict_resolve proposal missing payload.resolution_note")?;
1687            if note.trim().is_empty() {
1688                return Err(
1689                    "frontier.conflict_resolve payload.resolution_note must be non-empty"
1690                        .to_string(),
1691                );
1692            }
1693        }
1694        other => return Err(format!("Unsupported proposal kind '{other}'")),
1695    }
1696    validate_decision_state(proposal)
1697}
1698
1699fn require_existing_finding(frontier: &Project, finding_id: &str) -> Result<usize, String> {
1700    frontier
1701        .findings
1702        .iter()
1703        .position(|finding| finding.id == finding_id)
1704        .ok_or_else(|| format!("Finding not found: {finding_id}"))
1705}
1706
1707fn accept_proposal_in_frontier(
1708    frontier: &mut Project,
1709    proposal_id: &str,
1710    reviewer: &str,
1711    reason: &str,
1712) -> Result<String, String> {
1713    validate_reviewer_identity(reviewer)?;
1714    if reason.trim().is_empty() {
1715        return Err("Decision reason must be non-empty".to_string());
1716    }
1717    let index = frontier
1718        .proposals
1719        .iter()
1720        .position(|proposal| proposal.id == proposal_id)
1721        .ok_or_else(|| format!("Proposal not found: {proposal_id}"))?;
1722    let status = frontier.proposals[index].status.clone();
1723    if status == "rejected" {
1724        return Err(format!("Cannot accept rejected proposal {}", proposal_id));
1725    }
1726    if status == "applied" {
1727        return frontier.proposals[index]
1728            .applied_event_id
1729            .clone()
1730            .ok_or_else(|| format!("Proposal {} is applied but has no event id", proposal_id));
1731    }
1732    let proposal = frontier.proposals[index].clone();
1733    validate_proposal_shape(frontier, &proposal)?;
1734    frontier.proposals[index].status = "accepted".to_string();
1735    frontier.proposals[index].reviewed_by = Some(reviewer.to_string());
1736    frontier.proposals[index].reviewed_at = Some(Utc::now().to_rfc3339());
1737    frontier.proposals[index].decision_reason = Some(reason.to_string());
1738    let event_id = apply_proposal(frontier, &proposal, reviewer, reason)?;
1739    frontier.proposals[index].status = "applied".to_string();
1740    frontier.proposals[index].applied_event_id = Some(event_id.clone());
1741    Ok(event_id)
1742}
1743
1744fn reject_proposal_in_frontier(
1745    frontier: &mut Project,
1746    proposal_id: &str,
1747    reviewer: &str,
1748    reason: &str,
1749) -> Result<(), String> {
1750    validate_reviewer_identity(reviewer)?;
1751    if reason.trim().is_empty() {
1752        return Err("Decision reason must be non-empty".to_string());
1753    }
1754    let index = frontier
1755        .proposals
1756        .iter()
1757        .position(|proposal| proposal.id == proposal_id)
1758        .ok_or_else(|| format!("Proposal not found: {proposal_id}"))?;
1759    match frontier.proposals[index].status.as_str() {
1760        "pending_review" | "accepted" => {}
1761        "rejected" => {
1762            return Err(format!("Proposal {} is already rejected", proposal_id));
1763        }
1764        "applied" => {
1765            return Err(format!("Proposal {} is already applied", proposal_id));
1766        }
1767        other => {
1768            return Err(format!("Unsupported proposal status '{}'", other));
1769        }
1770    }
1771    frontier.proposals[index].status = "rejected".to_string();
1772    frontier.proposals[index].reviewed_by = Some(reviewer.to_string());
1773    frontier.proposals[index].reviewed_at = Some(Utc::now().to_rfc3339());
1774    frontier.proposals[index].decision_reason = Some(reason.to_string());
1775    Ok(())
1776}
1777
1778fn request_revision_in_frontier(
1779    frontier: &mut Project,
1780    proposal_id: &str,
1781    reviewer: &str,
1782    reason: &str,
1783) -> Result<(), String> {
1784    validate_reviewer_identity(reviewer)?;
1785    if reason.trim().is_empty() {
1786        return Err("Decision reason must be non-empty".to_string());
1787    }
1788    let index = frontier
1789        .proposals
1790        .iter()
1791        .position(|proposal| proposal.id == proposal_id)
1792        .ok_or_else(|| format!("Proposal not found: {proposal_id}"))?;
1793    match frontier.proposals[index].status.as_str() {
1794        "pending_review" => {}
1795        "needs_revision" => {
1796            return Err(format!("Proposal {} already needs revision", proposal_id));
1797        }
1798        "rejected" => {
1799            return Err(format!("Proposal {} is already rejected", proposal_id));
1800        }
1801        "applied" => {
1802            return Err(format!("Proposal {} is already applied", proposal_id));
1803        }
1804        other => {
1805            return Err(format!("Unsupported proposal status '{}'", other));
1806        }
1807    }
1808    frontier.proposals[index].status = "needs_revision".to_string();
1809    frontier.proposals[index].reviewed_by = Some(reviewer.to_string());
1810    frontier.proposals[index].reviewed_at = Some(Utc::now().to_rfc3339());
1811    frontier.proposals[index].decision_reason = Some(reason.to_string());
1812    Ok(())
1813}
1814
1815fn apply_proposal(
1816    frontier: &mut Project,
1817    proposal: &StateProposal,
1818    reviewer: &str,
1819    decision_reason: &str,
1820) -> Result<String, String> {
1821    // Phase L: retraction emits a fan of events — one for the source
1822    // and one `finding.dependency_invalidated` per dependent in BFS
1823    // order. apply_retract is responsible for pushing all of them in
1824    // sequence; this branch only assigns the primary event ID.
1825    if proposal.kind.as_str() == "finding.retract" {
1826        let events = apply_retract(frontier, proposal, reviewer, decision_reason)?;
1827        let primary_id = events
1828            .first()
1829            .map(|event| event.id.clone())
1830            .ok_or_else(|| "apply_retract returned no events".to_string())?;
1831        for event in events {
1832            frontier.events.push(event);
1833        }
1834        mark_proof_stale(
1835            frontier,
1836            format!("Applied proposal {} after latest proof export", proposal.id),
1837        );
1838        return Ok(primary_id);
1839    }
1840    // v0.55: confidence_revise can also fan out a cascade when the new
1841    // score crosses below the 0.5 propagation threshold. Same fan-out
1842    // pattern as retract.
1843    if proposal.kind.as_str() == "finding.confidence_revise" {
1844        let events = apply_confidence_revise(frontier, proposal, reviewer, decision_reason)?;
1845        let primary_id = events
1846            .first()
1847            .map(|event| event.id.clone())
1848            .ok_or_else(|| "apply_confidence_revise returned no events".to_string())?;
1849        for event in events {
1850            frontier.events.push(event);
1851        }
1852        mark_proof_stale(
1853            frontier,
1854            format!("Applied proposal {} after latest proof export", proposal.id),
1855        );
1856        return Ok(primary_id);
1857    }
1858    let event = match proposal.kind.as_str() {
1859        "finding.add" => apply_add(frontier, proposal, reviewer, decision_reason)?,
1860        "finding.review" => apply_review(frontier, proposal, reviewer, decision_reason)?,
1861        "finding.caveat" => apply_caveat(frontier, proposal, reviewer, decision_reason)?,
1862        "finding.note" => apply_note(frontier, proposal, reviewer, decision_reason)?,
1863        "finding.reject" => apply_reject(frontier, proposal, reviewer, decision_reason)?,
1864        "finding.supersede" => apply_supersede(frontier, proposal, reviewer, decision_reason)?,
1865        "artifact.assert" => apply_artifact_assert(frontier, proposal, reviewer, decision_reason)?,
1866        // v0.52: agent-inbox-deposited nulls and trajectories follow
1867        // the same review-gated path as findings.
1868        "negative_result.assert" => {
1869            apply_negative_result_assert(frontier, proposal, reviewer, decision_reason)?
1870        }
1871        "trajectory.create" => {
1872            apply_trajectory_create(frontier, proposal, reviewer, decision_reason)?
1873        }
1874        "trajectory.step_append" => {
1875            apply_trajectory_step_append(frontier, proposal, reviewer, decision_reason)?
1876        }
1877        // v0.56: mechanical evidence-atom locator repair.
1878        "evidence_atom.locator_repair" => {
1879            apply_evidence_atom_locator_repair(frontier, proposal, reviewer, decision_reason)?
1880        }
1881        // v0.57: mechanical finding-level span repair.
1882        "finding.span_repair" => {
1883            apply_finding_span_repair(frontier, proposal, reviewer, decision_reason)?
1884        }
1885        // v0.57: entity resolution.
1886        "finding.entity_resolve" => {
1887            apply_finding_entity_resolve(frontier, proposal, reviewer, decision_reason)?
1888        }
1889        // v0.59: federation conflict resolution.
1890        "frontier.conflict_resolve" => {
1891            apply_frontier_conflict_resolve(frontier, proposal, reviewer, decision_reason)?
1892        }
1893        other => return Err(format!("Unsupported proposal kind '{other}'")),
1894    };
1895    let event_id = event.id.clone();
1896    frontier.events.push(event);
1897    mark_proof_stale(
1898        frontier,
1899        format!("Applied proposal {} after latest proof export", proposal.id),
1900    );
1901    Ok(event_id)
1902}
1903
1904/// v0.14: `finding.supersede` — first-class flow for *changing a claim's text*.
1905///
1906/// Until v0.14 the only way to update a finding was to stack caveats/notes
1907/// on top, because the assertion text is part of the content address. The
1908/// substrate-correct path for a real correction is a *new* content-addressed
1909/// finding that explicitly supersedes the old one. This proposal kind:
1910///
1911/// 1. Validates the old finding exists and is not already superseded.
1912/// 2. Adds the new finding bundle (a fresh `vf_…` content address) to
1913///    `frontier.findings`.
1914/// 3. Auto-injects a `supersedes` link from the new finding's `links` to the
1915///    old finding's id (if not already present in the payload).
1916/// 4. Sets `flags.superseded = true` on the old finding.
1917/// 5. Emits a `finding.superseded` canonical event targeting the *old*
1918///    finding (since that's the state change). The new finding's existence
1919///    is recorded in the event payload as `new_finding_id`.
1920///
1921/// Both findings remain queryable; readers walk the supersedes chain via
1922/// the link or via the `flags.superseded` marker.
1923fn apply_supersede(
1924    frontier: &mut Project,
1925    proposal: &StateProposal,
1926    reviewer: &str,
1927    _decision_reason: &str,
1928) -> Result<StateEvent, String> {
1929    use crate::bundle::Link;
1930
1931    let old_id = proposal.target.id.clone();
1932    let new_finding_value = proposal
1933        .payload
1934        .get("new_finding")
1935        .ok_or("finding.supersede proposal missing payload.new_finding")?
1936        .clone();
1937    let mut new_finding: FindingBundle = serde_json::from_value(new_finding_value)
1938        .map_err(|e| format!("Invalid finding.supersede payload.new_finding: {e}"))?;
1939
1940    // Locate the old finding before mutating; capture before_hash for the event.
1941    let old_idx = find_finding_index(frontier, &old_id)?;
1942    if frontier.findings[old_idx].flags.superseded {
1943        return Err(format!(
1944            "Refusing to supersede already-superseded finding {old_id}"
1945        ));
1946    }
1947    if new_finding.id == old_id {
1948        return Err(
1949            "Refusing to supersede with a finding that has the same content address as the old finding (assertion / type / provenance_id are unchanged)".to_string(),
1950        );
1951    }
1952    if frontier
1953        .findings
1954        .iter()
1955        .any(|existing| existing.id == new_finding.id)
1956    {
1957        return Err(format!(
1958            "Refusing to add superseding finding with existing finding ID {}",
1959            new_finding.id
1960        ));
1961    }
1962    let before_hash = events::finding_hash(&frontier.findings[old_idx]);
1963
1964    // Auto-inject the supersedes link if the caller didn't already include it.
1965    let already_links_old = new_finding
1966        .links
1967        .iter()
1968        .any(|l| l.target == old_id && l.link_type == "supersedes");
1969    if !already_links_old {
1970        new_finding.links.push(Link {
1971            target: old_id.clone(),
1972            link_type: "supersedes".to_string(),
1973            note: format!(
1974                "Supersedes {old_id} via finding.supersede proposal {}.",
1975                proposal.id
1976            ),
1977            inferred_by: "reviewer".to_string(),
1978            created_at: Utc::now().to_rfc3339(),
1979            mechanism: None,
1980        });
1981    }
1982
1983    let new_finding_id = new_finding.id.clone();
1984    frontier.findings.push(new_finding);
1985    frontier.findings[old_idx].flags.superseded = true;
1986    let after_hash = events::finding_hash(&frontier.findings[old_idx]);
1987
1988    Ok(events::new_finding_event(events::FindingEventInput {
1989        kind: "finding.superseded",
1990        finding_id: &old_id,
1991        actor_id: reviewer,
1992        actor_type: "human",
1993        reason: &proposal.reason,
1994        before_hash: &before_hash,
1995        after_hash: &after_hash,
1996        payload: json!({
1997            "proposal_id": proposal.id,
1998            "new_finding_id": new_finding_id,
1999        }),
2000        caveats: proposal.caveats.clone(),
2001    }))
2002}
2003
2004fn apply_add(
2005    frontier: &mut Project,
2006    proposal: &StateProposal,
2007    reviewer: &str,
2008    _decision_reason: &str,
2009) -> Result<StateEvent, String> {
2010    let finding_value = proposal
2011        .payload
2012        .get("finding")
2013        .ok_or("finding.add proposal missing payload.finding")?
2014        .clone();
2015    let finding: FindingBundle = serde_json::from_value(finding_value)
2016        .map_err(|e| format!("Invalid finding.add payload: {e}"))?;
2017    let finding_id = finding.id.clone();
2018    if frontier
2019        .findings
2020        .iter()
2021        .any(|existing| existing.id == finding_id)
2022    {
2023        return Err(format!(
2024            "Refusing to add duplicate finding with existing finding ID {finding_id}"
2025        ));
2026    }
2027    frontier.findings.push(finding);
2028    let after_hash = events::finding_hash_by_id(frontier, &finding_id);
2029    Ok(events::new_finding_event(events::FindingEventInput {
2030        kind: "finding.asserted",
2031        finding_id: &finding_id,
2032        actor_id: reviewer,
2033        actor_type: "human",
2034        reason: &proposal.reason,
2035        before_hash: NULL_HASH,
2036        after_hash: &after_hash,
2037        payload: json!({
2038            "proposal_id": proposal.id,
2039        }),
2040        caveats: proposal.caveats.clone(),
2041    }))
2042}
2043
2044fn apply_artifact_assert(
2045    frontier: &mut Project,
2046    proposal: &StateProposal,
2047    reviewer: &str,
2048    _decision_reason: &str,
2049) -> Result<StateEvent, String> {
2050    let artifact_value = proposal
2051        .payload
2052        .get("artifact")
2053        .ok_or("artifact.assert proposal missing payload.artifact")?
2054        .clone();
2055    let artifact: Artifact = serde_json::from_value(artifact_value)
2056        .map_err(|e| format!("Invalid artifact.assert payload: {e}"))?;
2057    let artifact_id = artifact.id.clone();
2058    if frontier
2059        .artifacts
2060        .iter()
2061        .any(|existing| existing.id == artifact_id)
2062    {
2063        return Err(format!(
2064            "Refusing to add duplicate artifact with existing id {artifact_id}"
2065        ));
2066    }
2067    frontier.artifacts.push(artifact.clone());
2068    let mut event = StateEvent {
2069        schema: events::EVENT_SCHEMA.to_string(),
2070        id: String::new(),
2071        kind: events::EVENT_KIND_ARTIFACT_ASSERTED.to_string(),
2072        target: StateTarget {
2073            r#type: "artifact".to_string(),
2074            id: artifact_id,
2075        },
2076        actor: StateActor {
2077            id: reviewer.to_string(),
2078            r#type: if reviewer.starts_with("agent:") {
2079                "agent"
2080            } else {
2081                "human"
2082            }
2083            .to_string(),
2084        },
2085        timestamp: Utc::now().to_rfc3339(),
2086        reason: proposal.reason.clone(),
2087        before_hash: NULL_HASH.to_string(),
2088        after_hash: NULL_HASH.to_string(),
2089        payload: json!({
2090            "proposal_id": proposal.id,
2091            "artifact": artifact,
2092        }),
2093        caveats: proposal.caveats.clone(),
2094        signature: None,
2095    };
2096    events::validate_event_payload(&event.kind, &event.payload)?;
2097    event.id = events::compute_event_id(&event);
2098    Ok(event)
2099}
2100
2101fn apply_review(
2102    frontier: &mut Project,
2103    proposal: &StateProposal,
2104    reviewer: &str,
2105    _decision_reason: &str,
2106) -> Result<StateEvent, String> {
2107    let finding_id = proposal.target.id.as_str();
2108    let idx = find_finding_index(frontier, finding_id)?;
2109    let before_hash = events::finding_hash(&frontier.findings[idx]);
2110    let status = proposal
2111        .payload
2112        .get("status")
2113        .and_then(Value::as_str)
2114        .ok_or("finding.review proposal missing payload.status")?;
2115    use crate::bundle::ReviewState;
2116    let new_state = match status {
2117        "accepted" | "approved" => ReviewState::Accepted,
2118        "contested" => ReviewState::Contested,
2119        "needs_revision" => ReviewState::NeedsRevision,
2120        "rejected" => ReviewState::Rejected,
2121        other => return Err(format!("Unknown review proposal status '{other}'")),
2122    };
2123    frontier.findings[idx].flags.contested = new_state.implies_contested();
2124    frontier.findings[idx].flags.review_state = Some(new_state);
2125    let after_hash = events::finding_hash(&frontier.findings[idx]);
2126    Ok(events::new_finding_event(events::FindingEventInput {
2127        kind: "finding.reviewed",
2128        finding_id,
2129        actor_id: reviewer,
2130        actor_type: "human",
2131        reason: &proposal.reason,
2132        before_hash: &before_hash,
2133        after_hash: &after_hash,
2134        payload: json!({
2135            "status": status,
2136            "proposal_id": proposal.id,
2137        }),
2138        caveats: proposal.caveats.clone(),
2139    }))
2140}
2141
2142fn apply_caveat(
2143    frontier: &mut Project,
2144    proposal: &StateProposal,
2145    reviewer: &str,
2146    _decision_reason: &str,
2147) -> Result<StateEvent, String> {
2148    let finding_id = proposal.target.id.as_str();
2149    let idx = find_finding_index(frontier, finding_id)?;
2150    let before_hash = events::finding_hash(&frontier.findings[idx]);
2151    let now = Utc::now().to_rfc3339();
2152    let text = proposal
2153        .payload
2154        .get("text")
2155        .and_then(Value::as_str)
2156        .ok_or("finding.caveat proposal missing payload.text")?;
2157    let provenance = extract_annotation_provenance(&proposal.payload);
2158    let annotation_id = annotation_id(finding_id, text, reviewer, &now);
2159    frontier.findings[idx].annotations.push(Annotation {
2160        id: annotation_id.clone(),
2161        text: text.to_string(),
2162        author: reviewer.to_string(),
2163        timestamp: now,
2164        provenance: provenance.clone(),
2165    });
2166    let after_hash = events::finding_hash(&frontier.findings[idx]);
2167    let mut payload = json!({
2168        "annotation_id": annotation_id,
2169        "text": text,
2170        "proposal_id": proposal.id,
2171    });
2172    if let Some(prov) = &provenance {
2173        payload["provenance"] = serde_json::to_value(prov).unwrap_or(Value::Null);
2174    }
2175    Ok(events::new_finding_event(events::FindingEventInput {
2176        kind: "finding.caveated",
2177        finding_id,
2178        actor_id: reviewer,
2179        actor_type: "human",
2180        reason: text,
2181        before_hash: &before_hash,
2182        after_hash: &after_hash,
2183        payload,
2184        caveats: proposal.caveats.clone(),
2185    }))
2186}
2187
2188fn apply_note(
2189    frontier: &mut Project,
2190    proposal: &StateProposal,
2191    reviewer: &str,
2192    _decision_reason: &str,
2193) -> Result<StateEvent, String> {
2194    let finding_id = proposal.target.id.as_str();
2195    let idx = find_finding_index(frontier, finding_id)?;
2196    let before_hash = events::finding_hash(&frontier.findings[idx]);
2197    let now = Utc::now().to_rfc3339();
2198    let text = proposal
2199        .payload
2200        .get("text")
2201        .and_then(Value::as_str)
2202        .ok_or("finding.note proposal missing payload.text")?;
2203    let provenance = extract_annotation_provenance(&proposal.payload);
2204    let annotation_id = annotation_id(finding_id, text, reviewer, &now);
2205    frontier.findings[idx].annotations.push(Annotation {
2206        id: annotation_id.clone(),
2207        text: text.to_string(),
2208        author: reviewer.to_string(),
2209        timestamp: now,
2210        provenance: provenance.clone(),
2211    });
2212    let after_hash = events::finding_hash(&frontier.findings[idx]);
2213    let mut payload = json!({
2214        "annotation_id": annotation_id,
2215        "text": text,
2216        "proposal_id": proposal.id,
2217    });
2218    if let Some(prov) = &provenance {
2219        payload["provenance"] = serde_json::to_value(prov).unwrap_or(Value::Null);
2220    }
2221    Ok(events::new_finding_event(events::FindingEventInput {
2222        kind: "finding.noted",
2223        finding_id,
2224        actor_id: reviewer,
2225        actor_type: "human",
2226        reason: text,
2227        before_hash: &before_hash,
2228        after_hash: &after_hash,
2229        payload,
2230        caveats: proposal.caveats.clone(),
2231    }))
2232}
2233
2234/// v0.57: Apply a `finding.entity_resolve` proposal. Sets canonical_id
2235/// + resolution metadata on the named entity inside the target finding's
2236/// assertion.entities array, and clears the entity's needs_review flag.
2237fn apply_finding_entity_resolve(
2238    frontier: &mut Project,
2239    proposal: &StateProposal,
2240    reviewer: &str,
2241    _decision_reason: &str,
2242) -> Result<StateEvent, String> {
2243    use crate::bundle::{ResolutionMethod, ResolvedId};
2244
2245    let finding_id = proposal.target.id.as_str();
2246    let entity_name = proposal
2247        .payload
2248        .get("entity_name")
2249        .and_then(Value::as_str)
2250        .ok_or("finding.entity_resolve proposal missing payload.entity_name")?
2251        .to_string();
2252    let source = proposal
2253        .payload
2254        .get("source")
2255        .and_then(Value::as_str)
2256        .ok_or("finding.entity_resolve proposal missing payload.source")?
2257        .to_string();
2258    let id = proposal
2259        .payload
2260        .get("id")
2261        .and_then(Value::as_str)
2262        .ok_or("finding.entity_resolve proposal missing payload.id")?
2263        .to_string();
2264    let confidence = proposal
2265        .payload
2266        .get("confidence")
2267        .and_then(Value::as_f64)
2268        .ok_or("finding.entity_resolve proposal missing payload.confidence")?;
2269    let matched_name = proposal
2270        .payload
2271        .get("matched_name")
2272        .and_then(Value::as_str)
2273        .map(str::to_string);
2274    let provenance = proposal
2275        .payload
2276        .get("resolution_provenance")
2277        .and_then(Value::as_str)
2278        .unwrap_or("delegated_human_curation")
2279        .to_string();
2280    let method_str = proposal
2281        .payload
2282        .get("resolution_method")
2283        .and_then(Value::as_str)
2284        .unwrap_or("manual");
2285    let method = match method_str {
2286        "exact_match" => ResolutionMethod::ExactMatch,
2287        "fuzzy_match" => ResolutionMethod::FuzzyMatch,
2288        "llm_inference" => ResolutionMethod::LlmInference,
2289        "manual" => ResolutionMethod::Manual,
2290        other => {
2291            return Err(format!(
2292                "finding.entity_resolve unknown resolution_method '{other}'"
2293            ));
2294        }
2295    };
2296
2297    let f_idx = find_finding_index(frontier, finding_id)?;
2298    let e_idx = frontier.findings[f_idx]
2299        .assertion
2300        .entities
2301        .iter()
2302        .position(|e| e.name == entity_name)
2303        .ok_or_else(|| {
2304            format!("finding.entity_resolve entity '{entity_name}' not in finding {finding_id}")
2305        })?;
2306
2307    let before_hash = events::finding_hash(&frontier.findings[f_idx]);
2308    let entity = &mut frontier.findings[f_idx].assertion.entities[e_idx];
2309    entity.canonical_id = Some(ResolvedId {
2310        source: source.clone(),
2311        id: id.clone(),
2312        confidence,
2313        matched_name: matched_name.clone(),
2314    });
2315    entity.resolution_method = Some(method);
2316    entity.resolution_provenance = Some(provenance.clone());
2317    entity.resolution_confidence = confidence;
2318    entity.needs_review = false;
2319    let after_hash = events::finding_hash(&frontier.findings[f_idx]);
2320
2321    let mut payload = json!({
2322        "proposal_id": proposal.id,
2323        "entity_name": entity_name,
2324        "source": source,
2325        "id": id,
2326        "confidence": confidence,
2327        "resolution_method": method_str,
2328        "resolution_provenance": provenance,
2329    });
2330    if let Some(m) = matched_name {
2331        payload["matched_name"] = serde_json::Value::String(m);
2332    }
2333
2334    Ok(events::new_finding_event(events::FindingEventInput {
2335        kind: "finding.entity_resolved",
2336        finding_id,
2337        actor_id: reviewer,
2338        actor_type: "human",
2339        reason: &proposal.reason,
2340        before_hash: &before_hash,
2341        after_hash: &after_hash,
2342        payload,
2343        caveats: proposal.caveats.clone(),
2344    }))
2345}
2346
2347/// v0.57: Apply a `finding.span_repair` proposal. Appends a
2348/// `{section, text}` span to `state.findings[i].evidence.evidence_spans`
2349/// and emits one signed `finding.span_repaired` event.
2350fn apply_finding_span_repair(
2351    frontier: &mut Project,
2352    proposal: &StateProposal,
2353    reviewer: &str,
2354    _decision_reason: &str,
2355) -> Result<StateEvent, String> {
2356    let finding_id = proposal.target.id.as_str();
2357    let section = proposal
2358        .payload
2359        .get("section")
2360        .and_then(Value::as_str)
2361        .ok_or("finding.span_repair proposal missing payload.section")?
2362        .to_string();
2363    let text = proposal
2364        .payload
2365        .get("text")
2366        .and_then(Value::as_str)
2367        .ok_or("finding.span_repair proposal missing payload.text")?
2368        .to_string();
2369    let idx = find_finding_index(frontier, finding_id)?;
2370    let already_present = frontier.findings[idx]
2371        .evidence
2372        .evidence_spans
2373        .iter()
2374        .any(|existing| {
2375            existing.get("section").and_then(Value::as_str) == Some(section.as_str())
2376                && existing.get("text").and_then(Value::as_str) == Some(text.as_str())
2377        });
2378    if already_present {
2379        return Err(format!(
2380            "finding {finding_id} already carries an identical (section, text) span"
2381        ));
2382    }
2383    let before_hash = events::finding_hash(&frontier.findings[idx]);
2384    let span_value = json!({"section": section, "text": text});
2385    frontier.findings[idx]
2386        .evidence
2387        .evidence_spans
2388        .push(span_value);
2389    let after_hash = events::finding_hash(&frontier.findings[idx]);
2390    let payload = json!({
2391        "proposal_id": proposal.id,
2392        "section": section,
2393        "text": text,
2394    });
2395    Ok(events::new_finding_event(events::FindingEventInput {
2396        kind: "finding.span_repaired",
2397        finding_id,
2398        actor_id: reviewer,
2399        actor_type: "human",
2400        reason: &proposal.reason,
2401        before_hash: &before_hash,
2402        after_hash: &after_hash,
2403        payload,
2404        caveats: proposal.caveats.clone(),
2405    }))
2406}
2407
2408/// v0.56: Apply an `evidence_atom.locator_repair` proposal. Sets
2409/// `locator` on the named evidence atom, removes the
2410/// "missing evidence locator" caveat, and emits one signed
2411/// `evidence_atom.locator_repaired` canonical event. The before/after
2412/// hashes are over the canonical bytes of the named atom only, so a
2413/// chain validator can confirm the exact atom changed and exactly the
2414/// named repair was applied.
2415fn apply_evidence_atom_locator_repair(
2416    frontier: &mut Project,
2417    proposal: &StateProposal,
2418    reviewer: &str,
2419    _decision_reason: &str,
2420) -> Result<StateEvent, String> {
2421    let atom_id = proposal.target.id.as_str();
2422    let locator = proposal
2423        .payload
2424        .get("locator")
2425        .and_then(Value::as_str)
2426        .ok_or("evidence_atom.locator_repair proposal missing payload.locator")?
2427        .to_string();
2428    let source_id = proposal
2429        .payload
2430        .get("source_id")
2431        .and_then(Value::as_str)
2432        .ok_or("evidence_atom.locator_repair proposal missing payload.source_id")?
2433        .to_string();
2434
2435    let idx = frontier
2436        .evidence_atoms
2437        .iter()
2438        .position(|atom| atom.id == atom_id)
2439        .ok_or_else(|| format!("evidence_atom.locator_repair targets unknown atom {atom_id}"))?;
2440    if frontier.evidence_atoms[idx].source_id != source_id {
2441        return Err(format!(
2442            "evidence_atom.locator_repair payload.source_id '{source_id}' does not match atom.source_id '{}'",
2443            frontier.evidence_atoms[idx].source_id
2444        ));
2445    }
2446    if let Some(existing) = &frontier.evidence_atoms[idx].locator {
2447        if existing == &locator {
2448            return Err(format!(
2449                "evidence_atom {atom_id} already carries locator '{existing}'"
2450            ));
2451        }
2452        return Err(format!(
2453            "evidence_atom {atom_id} already carries locator '{existing}'; refusing to overwrite with '{locator}'"
2454        ));
2455    }
2456
2457    let before_hash = events::evidence_atom_hash(&frontier.evidence_atoms[idx]);
2458    frontier.evidence_atoms[idx].locator = Some(locator.clone());
2459    frontier.evidence_atoms[idx]
2460        .caveats
2461        .retain(|c| c != "missing evidence locator");
2462    let after_hash = events::evidence_atom_hash(&frontier.evidence_atoms[idx]);
2463
2464    let payload = json!({
2465        "proposal_id": proposal.id,
2466        "locator": locator,
2467        "source_id": source_id,
2468    });
2469
2470    Ok(events::new_evidence_atom_locator_repair_event(
2471        atom_id,
2472        reviewer,
2473        "human",
2474        &proposal.reason,
2475        &before_hash,
2476        &after_hash,
2477        payload,
2478        proposal.caveats.clone(),
2479    ))
2480}
2481
2482/// v0.59: apply a `frontier.conflict_resolve` proposal. Emits one
2483/// `frontier.conflict_resolved` event recording the reviewer's
2484/// verdict on a previously detected conflict. The conflict event
2485/// itself is not modified; consumers pair the two by matching
2486/// `payload.conflict_event_id` on the resolved event to the
2487/// detected event's id.
2488fn apply_frontier_conflict_resolve(
2489    frontier: &mut Project,
2490    proposal: &StateProposal,
2491    reviewer: &str,
2492    _decision_reason: &str,
2493) -> Result<StateEvent, String> {
2494    let conflict_event_id = proposal
2495        .payload
2496        .get("conflict_event_id")
2497        .and_then(Value::as_str)
2498        .ok_or("frontier.conflict_resolve proposal missing payload.conflict_event_id")?
2499        .to_string();
2500    let resolution_note = proposal
2501        .payload
2502        .get("resolution_note")
2503        .and_then(Value::as_str)
2504        .ok_or("frontier.conflict_resolve proposal missing payload.resolution_note")?
2505        .to_string();
2506    let winning_proposal_id = proposal
2507        .payload
2508        .get("winning_proposal_id")
2509        .and_then(Value::as_str)
2510        .map(|s| s.to_string());
2511
2512    // Confirm the conflict event exists and is the right kind.
2513    // Refuse double-resolution at apply time too (the validator
2514    // already checks but we check again because validation is best
2515    // effort against the live frontier and apply is the authority).
2516    let conflict_event = frontier
2517        .events
2518        .iter()
2519        .find(|e| e.id == conflict_event_id)
2520        .ok_or_else(|| {
2521            format!("frontier.conflict_resolve targets unknown event id '{conflict_event_id}'")
2522        })?
2523        .clone();
2524    if conflict_event.kind != "frontier.conflict_detected" {
2525        return Err(format!(
2526            "frontier.conflict_resolve target event '{conflict_event_id}' has kind '{}', expected 'frontier.conflict_detected'",
2527            conflict_event.kind
2528        ));
2529    }
2530    if frontier.events.iter().any(|e| {
2531        e.kind == "frontier.conflict_resolved"
2532            && e.payload.get("conflict_event_id").and_then(Value::as_str)
2533                == Some(&conflict_event_id)
2534    }) {
2535        return Err(format!(
2536            "Conflict event '{conflict_event_id}' already has a recorded resolution"
2537        ));
2538    }
2539
2540    let mut payload = json!({
2541        "proposal_id": proposal.id,
2542        "conflict_event_id": conflict_event_id,
2543        "resolved_by": reviewer,
2544        "resolution_note": resolution_note,
2545    });
2546    if let Some(wpid) = &winning_proposal_id {
2547        payload["winning_proposal_id"] = json!(wpid);
2548    }
2549
2550    let frontier_id = frontier.frontier_id();
2551    Ok(events::new_frontier_conflict_resolved_event(
2552        &frontier_id,
2553        reviewer,
2554        "human",
2555        &proposal.reason,
2556        payload,
2557        proposal.caveats.clone(),
2558    ))
2559}
2560
2561/// Phase β (v0.6): pull optional structured provenance off a note/caveat
2562/// proposal payload. The propose-* tools accept it; the validator gates
2563/// it; this helper threads it through to the materialized annotation
2564/// and the canonical event payload.
2565fn extract_annotation_provenance(payload: &Value) -> Option<crate::bundle::ProvenanceRef> {
2566    let prov = payload.get("provenance")?;
2567    let parsed: crate::bundle::ProvenanceRef = serde_json::from_value(prov.clone()).ok()?;
2568    if parsed.has_identifier() {
2569        Some(parsed)
2570    } else {
2571        None
2572    }
2573}
2574
2575fn apply_confidence_revise(
2576    frontier: &mut Project,
2577    proposal: &StateProposal,
2578    reviewer: &str,
2579    _decision_reason: &str,
2580) -> Result<Vec<StateEvent>, String> {
2581    let finding_id = proposal.target.id.as_str();
2582    let idx = find_finding_index(frontier, finding_id)?;
2583    let now = Utc::now().to_rfc3339();
2584    let previous = frontier.findings[idx].confidence.score;
2585    let new_score = proposal
2586        .payload
2587        .get("confidence")
2588        .and_then(Value::as_f64)
2589        .ok_or("finding.confidence_revise proposal missing payload.confidence")?;
2590
2591    // v0.55: when the revised confidence crosses the propagation threshold
2592    // (previous >= 0.5, new < 0.5), invoke the same cascade pattern that
2593    // `apply_retract` uses — emit `finding.dependency_invalidated` events for
2594    // each downstream supports/depends finding at depth ≤ MAX_DEPTH. Pre-v0.55
2595    // this path silently mutated confidence without firing the cascade, which
2596    // forced callers to chase a separate `vela propagate --reduce-confidence`
2597    // command for the substrate's signature feature.
2598    let cascade_threshold_crossed = previous >= 0.5 && new_score < 0.5;
2599
2600    let pre_cascade_hashes: std::collections::HashMap<String, String> = if cascade_threshold_crossed
2601    {
2602        frontier
2603            .findings
2604            .iter()
2605            .map(|finding| (finding.id.clone(), events::finding_hash(finding)))
2606            .collect()
2607    } else {
2608        std::collections::HashMap::new()
2609    };
2610
2611    let before_hash = events::finding_hash(&frontier.findings[idx]);
2612
2613    // Apply the local mutation first so propagate_correction sees the new
2614    // confidence on the source finding.
2615    frontier.findings[idx].confidence.score = new_score;
2616    frontier.findings[idx].confidence.basis = format!(
2617        "expert revision from {:.3} to {:.3}: {}",
2618        previous, new_score, proposal.reason
2619    );
2620    frontier.findings[idx].confidence.method = ConfidenceMethod::ExpertJudgment;
2621    frontier.findings[idx].updated = Some(now.clone());
2622
2623    let cascade = if cascade_threshold_crossed {
2624        Some(propagate::propagate_correction(
2625            frontier,
2626            finding_id,
2627            PropagationAction::ConfidenceReduced { new_score },
2628        ))
2629    } else {
2630        None
2631    };
2632
2633    let after_hash = events::finding_hash(&frontier.findings[idx]);
2634
2635    let source_event = events::new_finding_event(events::FindingEventInput {
2636        kind: "finding.confidence_revised",
2637        finding_id,
2638        actor_id: reviewer,
2639        actor_type: "human",
2640        reason: &proposal.reason,
2641        before_hash: &before_hash,
2642        after_hash: &after_hash,
2643        payload: json!({
2644            "previous_score": previous,
2645            "new_score": new_score,
2646            "updated_at": now,
2647            "proposal_id": proposal.id,
2648            "cascade_fired": cascade_threshold_crossed,
2649            "affected": cascade.as_ref().map(|c| c.affected).unwrap_or(0),
2650        }),
2651        caveats: proposal.caveats.clone(),
2652    });
2653
2654    let source_event_id = source_event.id.clone();
2655    let mut emitted = vec![source_event];
2656
2657    if let Some(cascade) = cascade {
2658        // Mirror apply_retract's per-dependent dependency_invalidated emission:
2659        // each affected dep at each depth gets a canonical event with the
2660        // before/after hash boundary so chain validation works downstream.
2661        for (depth_idx, level) in cascade.cascade.iter().enumerate() {
2662            let depth = (depth_idx as u32) + 1;
2663            for dep_id in level {
2664                let before = pre_cascade_hashes
2665                    .get(dep_id)
2666                    .cloned()
2667                    .unwrap_or_else(|| events::NULL_HASH.to_string());
2668                let after = events::finding_hash_by_id(frontier, dep_id);
2669                emitted.push(events::new_finding_event(events::FindingEventInput {
2670                    kind: "finding.dependency_invalidated",
2671                    finding_id: dep_id,
2672                    actor_id: reviewer,
2673                    actor_type: "human",
2674                    reason: &format!(
2675                        "Upstream finding {finding_id} confidence reduced to {new_score:.2}; cascade depth {depth}"
2676                    ),
2677                    before_hash: &before,
2678                    after_hash: &after,
2679                    payload: json!({
2680                        "upstream_finding_id": finding_id,
2681                        "upstream_event_id": source_event_id,
2682                        "depth": depth,
2683                        "new_score": new_score,
2684                        "previous_score": previous,
2685                        "proposal_id": proposal.id,
2686                    }),
2687                    caveats: vec![],
2688                }));
2689            }
2690        }
2691    }
2692
2693    Ok(emitted)
2694}
2695
2696fn apply_reject(
2697    frontier: &mut Project,
2698    proposal: &StateProposal,
2699    reviewer: &str,
2700    _decision_reason: &str,
2701) -> Result<StateEvent, String> {
2702    let finding_id = proposal.target.id.as_str();
2703    let idx = find_finding_index(frontier, finding_id)?;
2704    let before_hash = events::finding_hash(&frontier.findings[idx]);
2705    frontier.findings[idx].flags.contested = true;
2706    let after_hash = events::finding_hash(&frontier.findings[idx]);
2707    Ok(events::new_finding_event(events::FindingEventInput {
2708        kind: "finding.rejected",
2709        finding_id,
2710        actor_id: reviewer,
2711        actor_type: "human",
2712        reason: &proposal.reason,
2713        before_hash: &before_hash,
2714        after_hash: &after_hash,
2715        payload: json!({
2716            "proposal_id": proposal.id,
2717            "status": "rejected",
2718        }),
2719        caveats: proposal.caveats.clone(),
2720    }))
2721}
2722
2723fn apply_retract(
2724    frontier: &mut Project,
2725    proposal: &StateProposal,
2726    reviewer: &str,
2727    _decision_reason: &str,
2728) -> Result<Vec<StateEvent>, String> {
2729    let finding_id = proposal.target.id.as_str();
2730    let idx = find_finding_index(frontier, finding_id)?;
2731    if frontier.findings[idx].flags.retracted {
2732        return Err(format!("Finding {finding_id} is already retracted"));
2733    }
2734    // Phase L: capture every finding's pre-cascade hash so each emitted
2735    // `finding.dependency_invalidated` event can name a real before_hash
2736    // that matches whatever event last touched that dep.
2737    let pre_cascade_hashes: std::collections::HashMap<String, String> = frontier
2738        .findings
2739        .iter()
2740        .map(|finding| (finding.id.clone(), events::finding_hash(finding)))
2741        .collect();
2742
2743    let before_hash = events::finding_hash(&frontier.findings[idx]);
2744    let cascade =
2745        propagate::propagate_correction(frontier, finding_id, PropagationAction::Retracted);
2746    let after_hash = events::finding_hash_by_id(frontier, finding_id);
2747
2748    let source_event = events::new_finding_event(events::FindingEventInput {
2749        kind: "finding.retracted",
2750        finding_id,
2751        actor_id: reviewer,
2752        actor_type: "human",
2753        reason: &proposal.reason,
2754        before_hash: &before_hash,
2755        after_hash: &after_hash,
2756        payload: json!({
2757            "proposal_id": proposal.id,
2758            "affected": cascade.affected,
2759            "cascade": cascade.cascade,
2760        }),
2761        caveats: vec!["Retraction impact is simulated over declared dependency links.".to_string()],
2762    });
2763    let source_event_id = source_event.id.clone();
2764
2765    let mut emitted = vec![source_event];
2766
2767    // Phase L: emit one canonical `finding.dependency_invalidated`
2768    // event per affected dependent, in BFS depth order. Each event
2769    // carries the before/after hash boundary for that specific dep so
2770    // chain validation works downstream.
2771    for (depth_idx, level) in cascade.cascade.iter().enumerate() {
2772        let depth = (depth_idx as u32) + 1;
2773        for dep_id in level {
2774            let before = pre_cascade_hashes
2775                .get(dep_id)
2776                .cloned()
2777                .unwrap_or_else(|| events::NULL_HASH.to_string());
2778            let after = events::finding_hash_by_id(frontier, dep_id);
2779            emitted.push(events::new_finding_event(events::FindingEventInput {
2780                kind: "finding.dependency_invalidated",
2781                finding_id: dep_id,
2782                actor_id: reviewer,
2783                actor_type: "human",
2784                reason: &format!("Upstream finding {finding_id} retracted; cascade depth {depth}"),
2785                before_hash: &before,
2786                after_hash: &after,
2787                payload: json!({
2788                    "upstream_finding_id": finding_id,
2789                    "upstream_event_id": source_event_id,
2790                    "depth": depth,
2791                    "proposal_id": proposal.id,
2792                }),
2793                caveats: vec![],
2794            }));
2795        }
2796    }
2797
2798    Ok(emitted)
2799}
2800
2801fn find_finding_index(frontier: &Project, finding_id: &str) -> Result<usize, String> {
2802    frontier
2803        .findings
2804        .iter()
2805        .position(|finding| finding.id == finding_id)
2806        .ok_or_else(|| format!("Finding not found: {finding_id}"))
2807}
2808
2809/// v0.52: Apply a `negative_result.assert` proposal — push the
2810/// inline NegativeResult to state and emit a canonical
2811/// `negative_result.asserted` event. The event payload re-includes
2812/// the full NegativeResult so a fresh replay reconstructs
2813/// `state.negative_results` from the event log alone (matching the
2814/// direct `state::add_negative_result` path).
2815fn apply_negative_result_assert(
2816    frontier: &mut Project,
2817    proposal: &StateProposal,
2818    reviewer: &str,
2819    _decision_reason: &str,
2820) -> Result<StateEvent, String> {
2821    let nr_value = proposal
2822        .payload
2823        .get("negative_result")
2824        .ok_or("negative_result.assert proposal missing payload.negative_result")?
2825        .clone();
2826    let nr: crate::bundle::NegativeResult = serde_json::from_value(nr_value.clone())
2827        .map_err(|e| format!("Invalid negative_result.assert payload: {e}"))?;
2828    if frontier.negative_results.iter().any(|n| n.id == nr.id) {
2829        return Err(format!(
2830            "Refusing to add duplicate negative_result with existing id {}",
2831            nr.id
2832        ));
2833    }
2834    let nr_id = nr.id.clone();
2835    frontier.negative_results.push(nr);
2836
2837    let mut event = StateEvent {
2838        schema: events::EVENT_SCHEMA.to_string(),
2839        id: String::new(),
2840        kind: events::EVENT_KIND_NEGATIVE_RESULT_ASSERTED.to_string(),
2841        target: StateTarget {
2842            r#type: "negative_result".to_string(),
2843            id: nr_id,
2844        },
2845        actor: StateActor {
2846            id: reviewer.to_string(),
2847            r#type: "human".to_string(),
2848        },
2849        timestamp: Utc::now().to_rfc3339(),
2850        reason: proposal.reason.clone(),
2851        before_hash: NULL_HASH.to_string(),
2852        after_hash: NULL_HASH.to_string(),
2853        payload: json!({
2854            "proposal_id": proposal.id,
2855            "negative_result": nr_value,
2856        }),
2857        caveats: proposal.caveats.clone(),
2858        signature: None,
2859    };
2860    event.id = events::compute_event_id(&event);
2861    Ok(event)
2862}
2863
2864/// v0.52: Apply a `trajectory.create` proposal — push the inline
2865/// Trajectory to state and emit a canonical `trajectory.created`
2866/// event. Steps land later via separate `trajectory.step_append`
2867/// proposals.
2868fn apply_trajectory_create(
2869    frontier: &mut Project,
2870    proposal: &StateProposal,
2871    reviewer: &str,
2872    _decision_reason: &str,
2873) -> Result<StateEvent, String> {
2874    let traj_value = proposal
2875        .payload
2876        .get("trajectory")
2877        .ok_or("trajectory.create proposal missing payload.trajectory")?
2878        .clone();
2879    let traj: crate::bundle::Trajectory = serde_json::from_value(traj_value.clone())
2880        .map_err(|e| format!("Invalid trajectory.create payload: {e}"))?;
2881    if frontier.trajectories.iter().any(|t| t.id == traj.id) {
2882        return Err(format!(
2883            "Refusing to add duplicate trajectory with existing id {}",
2884            traj.id
2885        ));
2886    }
2887    let traj_id = traj.id.clone();
2888    frontier.trajectories.push(traj);
2889
2890    let mut event = StateEvent {
2891        schema: events::EVENT_SCHEMA.to_string(),
2892        id: String::new(),
2893        kind: events::EVENT_KIND_TRAJECTORY_CREATED.to_string(),
2894        target: StateTarget {
2895            r#type: "trajectory".to_string(),
2896            id: traj_id,
2897        },
2898        actor: StateActor {
2899            id: reviewer.to_string(),
2900            r#type: "human".to_string(),
2901        },
2902        timestamp: Utc::now().to_rfc3339(),
2903        reason: proposal.reason.clone(),
2904        before_hash: NULL_HASH.to_string(),
2905        after_hash: NULL_HASH.to_string(),
2906        payload: json!({
2907            "proposal_id": proposal.id,
2908            "trajectory": traj_value,
2909        }),
2910        caveats: proposal.caveats.clone(),
2911        signature: None,
2912    };
2913    event.id = events::compute_event_id(&event);
2914    Ok(event)
2915}
2916
2917/// v0.52: Apply a `trajectory.step_append` proposal — append the
2918/// inline TrajectoryStep to the parent trajectory's `steps` and emit
2919/// a canonical `trajectory.step_appended` event. Idempotent on
2920/// duplicate step content-addresses.
2921fn apply_trajectory_step_append(
2922    frontier: &mut Project,
2923    proposal: &StateProposal,
2924    reviewer: &str,
2925    _decision_reason: &str,
2926) -> Result<StateEvent, String> {
2927    let parent_id = proposal.target.id.clone();
2928    let parent_idx = frontier
2929        .trajectories
2930        .iter()
2931        .position(|t| t.id == parent_id)
2932        .ok_or_else(|| format!("trajectory.step_append targets unknown trajectory {parent_id}"))?;
2933    let step_value = proposal
2934        .payload
2935        .get("step")
2936        .ok_or("trajectory.step_append proposal missing payload.step")?
2937        .clone();
2938    let step: crate::bundle::TrajectoryStep = serde_json::from_value(step_value.clone())
2939        .map_err(|e| format!("Invalid trajectory.step_append payload.step: {e}"))?;
2940    if frontier.trajectories[parent_idx]
2941        .steps
2942        .iter()
2943        .any(|s| s.id == step.id)
2944    {
2945        return Err(format!(
2946            "Refusing to add duplicate step with existing id {} on trajectory {}",
2947            step.id, parent_id
2948        ));
2949    }
2950    frontier.trajectories[parent_idx].steps.push(step);
2951
2952    let mut event = StateEvent {
2953        schema: events::EVENT_SCHEMA.to_string(),
2954        id: String::new(),
2955        kind: events::EVENT_KIND_TRAJECTORY_STEP_APPENDED.to_string(),
2956        target: StateTarget {
2957            r#type: "trajectory".to_string(),
2958            id: parent_id.clone(),
2959        },
2960        actor: StateActor {
2961            id: reviewer.to_string(),
2962            r#type: "human".to_string(),
2963        },
2964        timestamp: Utc::now().to_rfc3339(),
2965        reason: proposal.reason.clone(),
2966        before_hash: NULL_HASH.to_string(),
2967        after_hash: NULL_HASH.to_string(),
2968        payload: json!({
2969            "proposal_id": proposal.id,
2970            "parent_trajectory_id": parent_id,
2971            "step": step_value,
2972        }),
2973        caveats: proposal.caveats.clone(),
2974        signature: None,
2975    };
2976    event.id = events::compute_event_id(&event);
2977    Ok(event)
2978}
2979
2980fn annotation_id(finding_id: &str, text: &str, author: &str, timestamp: &str) -> String {
2981    let hash = Sha256::digest(format!("{finding_id}|{text}|{author}|{timestamp}").as_bytes());
2982    format!("ann_{}", &hex::encode(hash)[..16])
2983}
2984
2985pub fn manifest_hash(path: &Path) -> Result<String, String> {
2986    let bytes = std::fs::read(path)
2987        .map_err(|e| format!("Failed to read manifest '{}': {e}", path.display()))?;
2988    Ok(hex::encode(Sha256::digest(bytes)))
2989}
2990
2991pub fn repo_proposals_dir(root: &Path) -> PathBuf {
2992    root.join(".vela/proposals")
2993}
2994
2995#[cfg(test)]
2996mod tests {
2997    use super::*;
2998    use crate::bundle::{
2999        Assertion, Conditions, Confidence, ConfidenceKind, ConfidenceMethod, Entity, Evidence,
3000        Extraction, Flags, Provenance,
3001    };
3002    use crate::project;
3003    use tempfile::TempDir;
3004
3005    fn finding(id: &str) -> FindingBundle {
3006        FindingBundle {
3007            id: id.to_string(),
3008            version: 1,
3009            previous_version: None,
3010            assertion: Assertion {
3011                text: "Test finding".to_string(),
3012                assertion_type: "mechanism".to_string(),
3013                entities: vec![Entity {
3014                    name: "LRP1".to_string(),
3015                    entity_type: "protein".to_string(),
3016                    identifiers: serde_json::Map::new(),
3017                    canonical_id: None,
3018                    candidates: Vec::new(),
3019                    aliases: Vec::new(),
3020                    resolution_provenance: None,
3021                    resolution_confidence: 1.0,
3022                    resolution_method: None,
3023                    species_context: None,
3024                    needs_review: false,
3025                }],
3026                relation: None,
3027                direction: None,
3028                causal_claim: None,
3029                causal_evidence_grade: None,
3030            },
3031            evidence: Evidence {
3032                evidence_type: "experimental".to_string(),
3033                model_system: String::new(),
3034                species: None,
3035                method: "manual".to_string(),
3036                sample_size: None,
3037                effect_size: None,
3038                p_value: None,
3039                replicated: false,
3040                replication_count: None,
3041                evidence_spans: Vec::new(),
3042            },
3043            conditions: Conditions {
3044                text: "mouse".to_string(),
3045                species_verified: Vec::new(),
3046                species_unverified: Vec::new(),
3047                in_vitro: false,
3048                in_vivo: true,
3049                human_data: false,
3050                clinical_trial: false,
3051                concentration_range: None,
3052                duration: None,
3053                age_group: None,
3054                cell_type: None,
3055            },
3056            confidence: Confidence {
3057                kind: ConfidenceKind::FrontierEpistemic,
3058                score: 0.7,
3059                basis: "test".to_string(),
3060                method: ConfidenceMethod::ExpertJudgment,
3061                components: None,
3062                extraction_confidence: 1.0,
3063            },
3064            provenance: Provenance {
3065                source_type: "published_paper".to_string(),
3066                doi: None,
3067                pmid: None,
3068                pmc: None,
3069                openalex_id: None,
3070                url: None,
3071                title: "Test".to_string(),
3072                authors: Vec::new(),
3073                year: Some(2024),
3074                journal: None,
3075                license: None,
3076                publisher: None,
3077                funders: Vec::new(),
3078                extraction: Extraction::default(),
3079                review: None,
3080                citation_count: None,
3081            },
3082            flags: Flags {
3083                gap: false,
3084                negative_space: false,
3085                contested: false,
3086                retracted: false,
3087                declining: false,
3088                gravity_well: false,
3089                review_state: None,
3090                superseded: false,
3091                signature_threshold: None,
3092                jointly_accepted: false,
3093            },
3094            links: Vec::new(),
3095            annotations: Vec::new(),
3096            attachments: Vec::new(),
3097            created: "2026-04-23T00:00:00Z".to_string(),
3098            updated: None,
3099
3100            access_tier: crate::access_tier::AccessTier::Public,
3101        }
3102    }
3103
3104    #[test]
3105    fn pending_review_proposal_does_not_mutate_frontier() {
3106        let tmp = TempDir::new().unwrap();
3107        let path = tmp.path().join("frontier.json");
3108        let frontier = project::assemble("test", vec![finding("vf_test")], 0, 0, "test");
3109        repo::save_to_path(&path, &frontier).unwrap();
3110        let proposal = new_proposal(
3111            "finding.review",
3112            StateTarget {
3113                r#type: "finding".to_string(),
3114                id: "vf_test".to_string(),
3115            },
3116            "reviewer:test",
3117            "human",
3118            "Mouse-only evidence",
3119            json!({"status": "contested"}),
3120            Vec::new(),
3121            Vec::new(),
3122        );
3123        create_or_apply(&path, proposal, false).unwrap();
3124        let loaded = repo::load_from_path(&path).unwrap();
3125        assert_eq!(loaded.events.len(), 1); // genesis only (proposal pending)
3126        assert_eq!(loaded.proposals.len(), 1);
3127        assert!(!loaded.findings[0].flags.contested);
3128    }
3129
3130    #[test]
3131    fn applied_proposal_emits_event_and_stales_proof() {
3132        let tmp = TempDir::new().unwrap();
3133        let path = tmp.path().join("frontier.json");
3134        let mut frontier = project::assemble("test", vec![finding("vf_test")], 0, 0, "test");
3135        record_proof_export(
3136            &mut frontier,
3137            ProofPacketRecord {
3138                generated_at: "2026-04-23T00:00:00Z".to_string(),
3139                snapshot_hash: "a".repeat(64),
3140                event_log_hash: "b".repeat(64),
3141                packet_manifest_hash: "c".repeat(64),
3142            },
3143        );
3144        repo::save_to_path(&path, &frontier).unwrap();
3145        let proposal = new_proposal(
3146            "finding.review",
3147            StateTarget {
3148                r#type: "finding".to_string(),
3149                id: "vf_test".to_string(),
3150            },
3151            "reviewer:test",
3152            "human",
3153            "Mouse-only evidence",
3154            json!({"status": "contested"}),
3155            Vec::new(),
3156            Vec::new(),
3157        );
3158        create_or_apply(&path, proposal, true).unwrap();
3159        let loaded = repo::load_from_path(&path).unwrap();
3160        assert_eq!(loaded.events.len(), 2); // genesis + applied
3161        assert!(loaded.findings[0].flags.contested);
3162        assert_eq!(loaded.proposals[0].status, "applied");
3163        assert_eq!(loaded.proof_state.latest_packet.status, "stale");
3164    }
3165
3166    #[test]
3167    fn preview_reports_changed_objects_and_event_kind_without_mutation() {
3168        let tmp = TempDir::new().unwrap();
3169        let path = tmp.path().join("frontier.json");
3170        let frontier = project::assemble("test", vec![finding("vf_test")], 0, 0, "test");
3171        repo::save_to_path(&path, &frontier).unwrap();
3172        let proposal = new_proposal(
3173            "finding.review",
3174            StateTarget {
3175                r#type: "finding".to_string(),
3176                id: "vf_test".to_string(),
3177            },
3178            "reviewer:test",
3179            "human",
3180            "Mouse-only evidence",
3181            json!({"status": "contested"}),
3182            Vec::new(),
3183            Vec::new(),
3184        );
3185        let proposal_id = create_or_apply(&path, proposal, false).unwrap().proposal_id;
3186
3187        let preview = preview_at_path(&path, &proposal_id, "reviewer:test").unwrap();
3188
3189        assert_eq!(preview.changed_findings, vec!["vf_test"]);
3190        assert!(preview.changed_artifacts.is_empty());
3191        assert_eq!(preview.event_kinds, vec!["finding.reviewed"]);
3192        assert_eq!(
3193            preview.new_event_ids,
3194            vec![preview.applied_event_id.clone()]
3195        );
3196        assert_eq!(preview.events_delta, 1);
3197        let loaded = repo::load_from_path(&path).unwrap();
3198        assert_eq!(loaded.events.len(), 1, "preview must not mutate events");
3199        assert_eq!(
3200            loaded.proposals[0].status, "pending_review",
3201            "preview must not accept the proposal"
3202        );
3203    }
3204
3205    #[test]
3206    fn pending_note_proposal_does_not_mutate_annotations() {
3207        let tmp = TempDir::new().unwrap();
3208        let path = tmp.path().join("frontier.json");
3209        let frontier = project::assemble("test", vec![finding("vf_test")], 0, 0, "test");
3210        repo::save_to_path(&path, &frontier).unwrap();
3211        let proposal = new_proposal(
3212            "finding.note",
3213            StateTarget {
3214                r#type: "finding".to_string(),
3215                id: "vf_test".to_string(),
3216            },
3217            "reviewer:test",
3218            "human",
3219            "Track mouse-only evidence",
3220            json!({"text": "Track mouse-only evidence"}),
3221            Vec::new(),
3222            Vec::new(),
3223        );
3224        create_or_apply(&path, proposal, false).unwrap();
3225        let loaded = repo::load_from_path(&path).unwrap();
3226        assert_eq!(loaded.events.len(), 1); // genesis only
3227        assert_eq!(loaded.proposals.len(), 1);
3228        assert!(loaded.findings[0].annotations.is_empty());
3229        assert_eq!(loaded.proposals[0].kind, "finding.note");
3230    }
3231
3232    #[test]
3233    fn applied_note_emits_noted_event_and_stales_proof() {
3234        let tmp = TempDir::new().unwrap();
3235        let path = tmp.path().join("frontier.json");
3236        let mut frontier = project::assemble("test", vec![finding("vf_test")], 0, 0, "test");
3237        record_proof_export(
3238            &mut frontier,
3239            ProofPacketRecord {
3240                generated_at: "2026-04-23T00:00:00Z".to_string(),
3241                snapshot_hash: "a".repeat(64),
3242                event_log_hash: "b".repeat(64),
3243                packet_manifest_hash: "c".repeat(64),
3244            },
3245        );
3246        repo::save_to_path(&path, &frontier).unwrap();
3247        let proposal = new_proposal(
3248            "finding.note",
3249            StateTarget {
3250                r#type: "finding".to_string(),
3251                id: "vf_test".to_string(),
3252            },
3253            "reviewer:test",
3254            "human",
3255            "Track mouse-only evidence",
3256            json!({"text": "Track mouse-only evidence"}),
3257            Vec::new(),
3258            Vec::new(),
3259        );
3260        let result = create_or_apply(&path, proposal, true).unwrap();
3261        let loaded = repo::load_from_path(&path).unwrap();
3262        assert_eq!(loaded.events.len(), 2); // genesis + finding.noted
3263        assert_eq!(loaded.events[1].kind, "finding.noted");
3264        assert_eq!(loaded.findings[0].annotations.len(), 1);
3265        assert_eq!(loaded.proposals[0].status, "applied");
3266        assert_eq!(
3267            loaded.proposals[0].applied_event_id,
3268            result.applied_event_id
3269        );
3270        assert_eq!(loaded.proof_state.latest_packet.status, "stale");
3271    }
3272
3273    #[test]
3274    fn retract_emits_per_dependent_cascade_events() {
3275        // Phase L: a retraction must emit one canonical
3276        // `finding.dependency_invalidated` event per affected dependent
3277        // in BFS depth order. Build a tiny dependency chain:
3278        //   src  <-supports- dep1  <-depends- dep2
3279        // and assert that retracting `src` produces three events:
3280        // [retracted(src), dep_invalidated(dep1, depth=1),
3281        //  dep_invalidated(dep2, depth=2)] all carrying the source's
3282        // canonical event ID as `upstream_event_id`.
3283        let tmp = TempDir::new().unwrap();
3284        let path = tmp.path().join("frontier.json");
3285        let mut src = finding("vf_src");
3286        let mut dep1 = finding("vf_dep1");
3287        let mut dep2 = finding("vf_dep2");
3288        src.assertion.text = "src finding".into();
3289        dep1.assertion.text = "dep1 finding".into();
3290        dep2.assertion.text = "dep2 finding".into();
3291        // BFS edges flow from dependent → upstream via `target`.
3292        dep1.add_link("vf_src", "supports", "");
3293        dep2.add_link("vf_dep1", "depends", "");
3294        let frontier = project::assemble("test", vec![src, dep1, dep2], 0, 0, "test");
3295        repo::save_to_path(&path, &frontier).unwrap();
3296
3297        let proposal = new_proposal(
3298            "finding.retract",
3299            StateTarget {
3300                r#type: "finding".to_string(),
3301                id: "vf_src".to_string(),
3302            },
3303            "reviewer:test",
3304            "human",
3305            "Source paper retracted by publisher",
3306            json!({}),
3307            Vec::new(),
3308            Vec::new(),
3309        );
3310        create_or_apply(&path, proposal, true).unwrap();
3311        let loaded = repo::load_from_path(&path).unwrap();
3312
3313        // genesis + 1 source retract + 2 cascade events = 4 total.
3314        assert_eq!(loaded.events.len(), 4, "{:?}", loaded.events);
3315        let kinds: Vec<&str> = loaded.events.iter().map(|e| e.kind.as_str()).collect();
3316        assert_eq!(kinds[0], "frontier.created");
3317        assert_eq!(kinds[1], "finding.retracted");
3318        assert_eq!(kinds[2], "finding.dependency_invalidated");
3319        assert_eq!(kinds[3], "finding.dependency_invalidated");
3320
3321        let source_event_id = loaded.events[1].id.clone();
3322        let dep1_event = &loaded.events[2];
3323        let dep2_event = &loaded.events[3];
3324        assert_eq!(dep1_event.target.id, "vf_dep1");
3325        assert_eq!(dep2_event.target.id, "vf_dep2");
3326        assert_eq!(
3327            dep1_event
3328                .payload
3329                .get("upstream_event_id")
3330                .and_then(|v| v.as_str()),
3331            Some(source_event_id.as_str())
3332        );
3333        assert_eq!(
3334            dep1_event.payload.get("depth").and_then(|v| v.as_u64()),
3335            Some(1)
3336        );
3337        assert_eq!(
3338            dep2_event.payload.get("depth").and_then(|v| v.as_u64()),
3339            Some(2)
3340        );
3341        // Both dependents must end up contested in materialized state.
3342        let dep1 = loaded.findings.iter().find(|f| f.id == "vf_dep1").unwrap();
3343        let dep2 = loaded.findings.iter().find(|f| f.id == "vf_dep2").unwrap();
3344        assert!(dep1.flags.contested);
3345        assert!(dep2.flags.contested);
3346        let src = loaded.findings.iter().find(|f| f.id == "vf_src").unwrap();
3347        assert!(src.flags.retracted);
3348    }
3349
3350    #[test]
3351    fn proposal_id_is_content_addressed_independent_of_created_at() {
3352        // Phase P (v0.5): identical logical proposals constructed at different
3353        // times must produce the same `vpr_…`. This is the substrate property
3354        // that makes agent retries idempotent.
3355        let target = StateTarget {
3356            r#type: "finding".to_string(),
3357            id: "vf_test".to_string(),
3358        };
3359        let mut a = new_proposal(
3360            "finding.review",
3361            target.clone(),
3362            "reviewer:test",
3363            "human",
3364            "scope narrower than claim",
3365            json!({"status": "contested"}),
3366            Vec::new(),
3367            Vec::new(),
3368        );
3369        let mut b = new_proposal(
3370            "finding.review",
3371            target,
3372            "reviewer:test",
3373            "human",
3374            "scope narrower than claim",
3375            json!({"status": "contested"}),
3376            Vec::new(),
3377            Vec::new(),
3378        );
3379        // Force divergent timestamps; the IDs must still match.
3380        a.created_at = "2026-04-25T00:00:00Z".to_string();
3381        b.created_at = "2026-09-12T17:32:00Z".to_string();
3382        a.id = proposal_id(&a);
3383        b.id = proposal_id(&b);
3384        assert_eq!(a.id, b.id, "vpr_… must not depend on created_at");
3385    }
3386
3387    #[test]
3388    fn create_or_apply_is_idempotent_under_repeated_calls() {
3389        // Phase P: invoking create_or_apply twice with identical content must
3390        // not duplicate the proposal nor emit two events. The second call
3391        // returns the same proposal_id and applied_event_id as the first.
3392        let tmp = TempDir::new().unwrap();
3393        let path = tmp.path().join("frontier.json");
3394        let frontier = project::assemble("test", vec![finding("vf_test")], 0, 0, "test");
3395        repo::save_to_path(&path, &frontier).unwrap();
3396
3397        let make = || {
3398            new_proposal(
3399                "finding.review",
3400                StateTarget {
3401                    r#type: "finding".to_string(),
3402                    id: "vf_test".to_string(),
3403                },
3404                "reviewer:test",
3405                "human",
3406                "agent retry test",
3407                json!({"status": "contested"}),
3408                Vec::new(),
3409                Vec::new(),
3410            )
3411        };
3412
3413        let first = create_or_apply(&path, make(), true).unwrap();
3414        let second = create_or_apply(&path, make(), true).unwrap();
3415
3416        assert_eq!(first.proposal_id, second.proposal_id);
3417        assert_eq!(first.applied_event_id, second.applied_event_id);
3418
3419        let loaded = repo::load_from_path(&path).unwrap();
3420        assert_eq!(
3421            loaded.proposals.len(),
3422            1,
3423            "second create_or_apply must not insert a duplicate proposal"
3424        );
3425        // genesis + 1 applied review event = 2; not 3.
3426        assert_eq!(
3427            loaded.events.len(),
3428            2,
3429            "second create_or_apply must not emit a duplicate event"
3430        );
3431    }
3432
3433    #[test]
3434    fn accepting_applied_proposal_is_idempotent() {
3435        let tmp = TempDir::new().unwrap();
3436        let path = tmp.path().join("frontier.json");
3437        let frontier = project::assemble("test", vec![finding("vf_test")], 0, 0, "test");
3438        repo::save_to_path(&path, &frontier).unwrap();
3439        let proposal = new_proposal(
3440            "finding.review",
3441            StateTarget {
3442                r#type: "finding".to_string(),
3443                id: "vf_test".to_string(),
3444            },
3445            "reviewer:test",
3446            "human",
3447            "Mouse-only evidence",
3448            json!({"status": "contested"}),
3449            Vec::new(),
3450            Vec::new(),
3451        );
3452        let created = create_or_apply(&path, proposal, true).unwrap();
3453        let first_event = created.applied_event_id.clone().unwrap();
3454        let second_event =
3455            accept_at_path(&path, &created.proposal_id, "reviewer:test", "same").unwrap();
3456        assert_eq!(first_event, second_event);
3457    }
3458
3459    #[test]
3460    fn v0_13_apply_materializes_source_records_inline() {
3461        // Pre-v0.13: vela check --strict on a CLI-built frontier flagged
3462        // `missing_source_record` because source_records weren't populated
3463        // until vela normalize --write — and normalize refuses on event-ful
3464        // frontiers. v0.13 materializes inline at apply time so source_records
3465        // grow in lockstep with findings.
3466        let tmp = TempDir::new().unwrap();
3467        let path = tmp.path().join("frontier.json");
3468        let mut frontier = project::assemble("test", vec![], 0, 0, "test");
3469        repo::save_to_path(&path, &frontier).unwrap();
3470        // Add a finding via the standard finding.add proposal flow.
3471        let f = finding("vf_v013_inline_src");
3472        let proposal = new_proposal(
3473            "finding.add",
3474            StateTarget {
3475                r#type: "finding".to_string(),
3476                id: f.id.clone(),
3477            },
3478            "reviewer:test",
3479            "human",
3480            "Manual finding for v0.13 source-record materialization test",
3481            json!({"finding": f}),
3482            Vec::new(),
3483            Vec::new(),
3484        );
3485        create_or_apply(&path, proposal, true).unwrap();
3486        let loaded = repo::load_from_path(&path).unwrap();
3487        // Source records, evidence atoms, and condition records should all
3488        // be materialized — without any explicit normalize call.
3489        assert!(
3490            !loaded.sources.is_empty(),
3491            "v0.13: source_records should materialize inline at apply time"
3492        );
3493        assert!(
3494            !loaded.evidence_atoms.is_empty(),
3495            "v0.13: evidence_atoms should materialize inline at apply time"
3496        );
3497        assert!(
3498            !loaded.condition_records.is_empty(),
3499            "v0.13: condition_records should materialize inline at apply time"
3500        );
3501        // Sanity: stats reflect the new source registry.
3502        assert_eq!(loaded.stats.source_count, loaded.sources.len());
3503        // Suppress unused-mut warning when frontier isn't reused below.
3504        let _ = &mut frontier;
3505    }
3506
3507    fn make_supersede_payload(old_id: &str, new_text: &str) -> (FindingBundle, Value) {
3508        let mut new_finding = finding("vf_supersede_new");
3509        new_finding.assertion.text = new_text.to_string();
3510        // Re-derive id from the new assertion text + provenance. For the
3511        // test we just hand-pick a distinct id; the real CLI uses
3512        // `build_finding_bundle` which content-addresses correctly.
3513        new_finding.id = format!(
3514            "vf_{:0>16}",
3515            old_id
3516                .bytes()
3517                .fold(0u64, |acc, b| acc.wrapping_add(b as u64))
3518        );
3519        let payload = json!({"new_finding": new_finding.clone()});
3520        (new_finding, payload)
3521    }
3522
3523    #[test]
3524    fn v0_14_supersede_creates_new_finding_and_marks_old() {
3525        let tmp = TempDir::new().unwrap();
3526        let path = tmp.path().join("frontier.json");
3527        let mut frontier = project::assemble("test", vec![finding("vf_old")], 0, 0, "test");
3528        repo::save_to_path(&path, &frontier).unwrap();
3529        let (new_finding, payload) = make_supersede_payload("vf_old", "Newer claim");
3530        let proposal = new_proposal(
3531            "finding.supersede",
3532            StateTarget {
3533                r#type: "finding".to_string(),
3534                id: "vf_old".to_string(),
3535            },
3536            "reviewer:test",
3537            "human",
3538            "Newer evidence updates the wording",
3539            payload,
3540            Vec::new(),
3541            Vec::new(),
3542        );
3543        let result = create_or_apply(&path, proposal, true).unwrap();
3544        assert!(result.applied_event_id.is_some());
3545        let loaded = repo::load_from_path(&path).unwrap();
3546        // Old finding now flagged superseded.
3547        let old = loaded.findings.iter().find(|f| f.id == "vf_old").unwrap();
3548        assert!(
3549            old.flags.superseded,
3550            "old finding should be flagged superseded"
3551        );
3552        // New finding present, with auto-injected supersedes link back to old.
3553        let new_f = loaded
3554            .findings
3555            .iter()
3556            .find(|f| f.id == new_finding.id)
3557            .expect("new finding should be in frontier");
3558        assert!(
3559            new_f
3560                .links
3561                .iter()
3562                .any(|l| l.target == "vf_old" && l.link_type == "supersedes"),
3563            "new finding should have an auto-injected supersedes link to old finding"
3564        );
3565        // Event with kind finding.superseded targeting old, payload carries new_finding_id.
3566        let supersede_event = loaded
3567            .events
3568            .iter()
3569            .find(|e| e.kind == "finding.superseded")
3570            .expect("a finding.superseded event should be emitted");
3571        assert_eq!(supersede_event.target.id, "vf_old");
3572        assert_eq!(
3573            supersede_event.payload["new_finding_id"].as_str(),
3574            Some(new_finding.id.as_str())
3575        );
3576        // suppress unused warning
3577        let _ = &mut frontier;
3578    }
3579
3580    #[test]
3581    fn v0_14_supersede_refuses_already_superseded() {
3582        let tmp = TempDir::new().unwrap();
3583        let path = tmp.path().join("frontier.json");
3584        let mut old = finding("vf_already_done");
3585        old.flags.superseded = true;
3586        let frontier = project::assemble("test", vec![old], 0, 0, "test");
3587        repo::save_to_path(&path, &frontier).unwrap();
3588        let (_, payload) = make_supersede_payload("vf_already_done", "Newer wording");
3589        let proposal = new_proposal(
3590            "finding.supersede",
3591            StateTarget {
3592                r#type: "finding".to_string(),
3593                id: "vf_already_done".to_string(),
3594            },
3595            "reviewer:test",
3596            "human",
3597            "Attempt to double-supersede",
3598            payload,
3599            Vec::new(),
3600            Vec::new(),
3601        );
3602        let result = create_or_apply(&path, proposal, true);
3603        assert!(
3604            result.is_err(),
3605            "double-supersede should be refused; got {result:?}"
3606        );
3607    }
3608
3609    #[test]
3610    fn v0_14_supersede_refuses_same_content_address() {
3611        let tmp = TempDir::new().unwrap();
3612        let path = tmp.path().join("frontier.json");
3613        let frontier = project::assemble("test", vec![finding("vf_same")], 0, 0, "test");
3614        repo::save_to_path(&path, &frontier).unwrap();
3615        // new_finding.id == target.id should be refused at validate-time.
3616        let mut new_finding = finding("vf_same");
3617        new_finding.assertion.text = "Different text but reused id".to_string();
3618        let proposal = new_proposal(
3619            "finding.supersede",
3620            StateTarget {
3621                r#type: "finding".to_string(),
3622                id: "vf_same".to_string(),
3623            },
3624            "reviewer:test",
3625            "human",
3626            "Same id, should fail",
3627            json!({"new_finding": new_finding}),
3628            Vec::new(),
3629            Vec::new(),
3630        );
3631        let result = create_or_apply(&path, proposal, true);
3632        assert!(
3633            result.is_err(),
3634            "supersede with same content address should be refused; got {result:?}"
3635        );
3636    }
3637
3638    /// v0.22 byte-stability: a proposal with `agent_run = None`
3639    /// must serialize without an `agent_run` field, so existing
3640    /// frontiers (none of which have agent_run today) round-trip
3641    /// byte-identically. The whole substrate guarantee depends on
3642    /// canonical-JSON not silently gaining new keys.
3643    #[test]
3644    fn agent_run_none_skips_serialization() {
3645        let p = new_proposal(
3646            "finding.add",
3647            StateTarget {
3648                r#type: "finding".to_string(),
3649                id: "vf_test0000000000".to_string(),
3650            },
3651            "reviewer:will-blair",
3652            "human",
3653            "test",
3654            json!({}),
3655            Vec::new(),
3656            Vec::new(),
3657        );
3658        let bytes = canonical::to_canonical_bytes(&p).unwrap();
3659        let s = std::str::from_utf8(&bytes).unwrap();
3660        assert!(
3661            !s.contains("agent_run"),
3662            "proposal without agent_run leaked the field into canonical JSON: {s}"
3663        );
3664    }
3665
3666    /// And when `agent_run` *is* set, the same proposal id is
3667    /// produced regardless — `proposal_id`'s preimage explicitly
3668    /// excludes agent_run, so attaching provenance never changes
3669    /// the content address.
3670    #[test]
3671    fn agent_run_does_not_change_proposal_id() {
3672        let bare = new_proposal(
3673            "finding.add",
3674            StateTarget {
3675                r#type: "finding".to_string(),
3676                id: "vf_test0000000000".to_string(),
3677            },
3678            "agent:literature-scout",
3679            "agent",
3680            "scout extracted this from paper_014",
3681            json!({}),
3682            vec!["src_paper_014".to_string()],
3683            Vec::new(),
3684        );
3685        let id_bare = bare.id.clone();
3686
3687        let mut with_run = bare.clone();
3688        with_run.agent_run = Some(AgentRun {
3689            agent: "literature-scout".to_string(),
3690            model: "claude-opus-4-7".to_string(),
3691            run_id: "vrun_abc1234567890def".to_string(),
3692            started_at: "2026-04-26T01:23:45Z".to_string(),
3693            finished_at: Some("2026-04-26T01:24:10Z".to_string()),
3694            context: BTreeMap::from([
3695                ("input_folder".to_string(), "./papers".to_string()),
3696                ("pdf_count".to_string(), "12".to_string()),
3697            ]),
3698            tool_calls: Vec::new(),
3699            permissions: None,
3700        });
3701        let id_with_run = proposal_id(&with_run);
3702        assert_eq!(
3703            id_bare, id_with_run,
3704            "agent_run leaked into proposal_id preimage"
3705        );
3706    }
3707
3708    /// v0.49 byte-stability: tool_calls and permissions on AgentRun
3709    /// must skip serialization when empty/None, so existing frontiers
3710    /// (none of which carry these fields today) round-trip byte-
3711    /// identically through canonical JSON. Same invariant as
3712    /// agent_run itself in v0.22.
3713    #[test]
3714    fn agent_run_empty_tool_calls_and_permissions_skip_serialization() {
3715        let p = new_proposal(
3716            "finding.add",
3717            StateTarget {
3718                r#type: "finding".to_string(),
3719                id: "vf_test0000000000".to_string(),
3720            },
3721            "agent:scout",
3722            "agent",
3723            "test",
3724            json!({}),
3725            Vec::new(),
3726            Vec::new(),
3727        );
3728        let mut with_run = p.clone();
3729        with_run.agent_run = Some(AgentRun {
3730            agent: "scout".to_string(),
3731            model: "claude-opus-4-7".to_string(),
3732            run_id: "vrun_x".to_string(),
3733            started_at: "2026-04-26T01:00:00Z".to_string(),
3734            finished_at: None,
3735            context: BTreeMap::new(),
3736            tool_calls: Vec::new(),
3737            permissions: None,
3738        });
3739        let bytes = canonical::to_canonical_bytes(&with_run).unwrap();
3740        let s = std::str::from_utf8(&bytes).unwrap();
3741        assert!(
3742            !s.contains("tool_calls"),
3743            "empty tool_calls leaked into canonical JSON: {s}"
3744        );
3745        assert!(
3746            !s.contains("permissions"),
3747            "empty permissions leaked into canonical JSON: {s}"
3748        );
3749    }
3750
3751    /// v0.49: when populated, tool_calls and permissions DO serialize
3752    /// — this is the round-trip we want for new agent runs that
3753    /// actually carry tool traces.
3754    #[test]
3755    fn agent_run_populated_tool_calls_and_permissions_roundtrip() {
3756        let mut p = new_proposal(
3757            "finding.add",
3758            StateTarget {
3759                r#type: "finding".to_string(),
3760                id: "vf_test0000000000".to_string(),
3761            },
3762            "agent:scout",
3763            "agent",
3764            "test",
3765            json!({}),
3766            Vec::new(),
3767            Vec::new(),
3768        );
3769        p.agent_run = Some(AgentRun {
3770            agent: "scout".to_string(),
3771            model: "claude-opus-4-7".to_string(),
3772            run_id: "vrun_x".to_string(),
3773            started_at: "2026-04-26T01:00:00Z".to_string(),
3774            finished_at: None,
3775            context: BTreeMap::new(),
3776            tool_calls: vec![
3777                ToolCallTrace {
3778                    tool: "pubmed_search".to_string(),
3779                    input_sha256: "a".repeat(64),
3780                    output_sha256: Some("b".repeat(64)),
3781                    at: "2026-04-26T01:00:05Z".to_string(),
3782                    duration_ms: Some(842),
3783                    status: "ok".to_string(),
3784                    error_message: String::new(),
3785                },
3786                // v0.49: a failed tool call with an explanatory
3787                // error_message — the field a reviewer needs to audit
3788                // what went wrong without re-running the agent.
3789                ToolCallTrace {
3790                    tool: "arxiv_fetch".to_string(),
3791                    input_sha256: "c".repeat(64),
3792                    output_sha256: None,
3793                    at: "2026-04-26T01:00:18Z".to_string(),
3794                    duration_ms: Some(1200),
3795                    status: "error".to_string(),
3796                    error_message: "HTTP 503 from arxiv.org; retry budget exhausted".to_string(),
3797                },
3798            ],
3799            permissions: Some(PermissionState {
3800                data_access: vec!["pubmed:".to_string(), "frontier:vfr_bd91".to_string()],
3801                tool_access: vec!["pubmed_search".to_string(), "arxiv_fetch".to_string()],
3802                note: "read-only access to BBB Flagship".to_string(),
3803            }),
3804        });
3805        let bytes = canonical::to_canonical_bytes(&p).unwrap();
3806        let json: serde_json::Value =
3807            serde_json::from_slice(&bytes).expect("canonical bytes round-trip");
3808        assert_eq!(
3809            json["agent_run"]["tool_calls"][0]["tool"], "pubmed_search",
3810            "tool_calls did not survive the round trip: {json}"
3811        );
3812        assert_eq!(
3813            json["agent_run"]["permissions"]["data_access"][0], "pubmed:",
3814            "permissions did not survive the round trip: {json}"
3815        );
3816        // v0.49: a failed tool call with error_message carries the
3817        // explanation through canonical JSON. A reviewer can audit
3818        // exactly what failed without rerunning the agent.
3819        assert_eq!(
3820            json["agent_run"]["tool_calls"][1]["status"], "error",
3821            "failed tool call status did not survive: {json}"
3822        );
3823        assert_eq!(
3824            json["agent_run"]["tool_calls"][1]["error_message"],
3825            "HTTP 503 from arxiv.org; retry budget exhausted",
3826            "error_message did not survive the round trip: {json}"
3827        );
3828        // ...and successful calls still don't leak an empty
3829        // error_message into canonical bytes.
3830        let raw = std::str::from_utf8(&bytes).unwrap();
3831        let okay_call_block_end = raw.find("pubmed_search").unwrap();
3832        let until_first_call = &raw[..okay_call_block_end + 200];
3833        assert!(
3834            !until_first_call.contains("\"error_message\":\"\""),
3835            "successful tool call leaked an empty error_message: {until_first_call}"
3836        );
3837    }
3838}