1use 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 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub agent_run: Option<AgentRun>,
66}
67
68#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
78pub struct AgentRun {
79 pub agent: String,
82 #[serde(default, skip_serializing_if = "String::is_empty")]
85 pub model: String,
86 #[serde(default, skip_serializing_if = "String::is_empty")]
90 pub run_id: String,
91 #[serde(default, skip_serializing_if = "String::is_empty")]
93 pub started_at: String,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub finished_at: Option<String>,
97 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
102 pub context: BTreeMap<String, String>,
103 #[serde(default, skip_serializing_if = "Vec::is_empty")]
110 pub tool_calls: Vec<ToolCallTrace>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub permissions: Option<PermissionState>,
118}
119
120#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
125pub struct ToolCallTrace {
126 pub tool: String,
128 #[serde(default, skip_serializing_if = "String::is_empty")]
130 pub input_sha256: String,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub output_sha256: Option<String>,
136 #[serde(default, skip_serializing_if = "String::is_empty")]
138 pub at: String,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub duration_ms: Option<u32>,
142 #[serde(default, skip_serializing_if = "String::is_empty")]
146 pub status: String,
147 #[serde(default, skip_serializing_if = "String::is_empty")]
154 pub error_message: String,
155}
156
157#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
161pub struct PermissionState {
162 #[serde(default, skip_serializing_if = "Vec::is_empty")]
165 pub data_access: Vec<String>,
166 #[serde(default, skip_serializing_if = "Vec::is_empty")]
170 pub tool_access: Vec<String>,
171 #[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
330pub 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
432pub 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 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 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 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 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 "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 "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 "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 "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 "finding.entity_add" => {
1256 if proposal.target.r#type != "finding" {
1257 return Err(format!(
1258 "finding.entity_add target.type must be 'finding', got '{}'",
1259 proposal.target.r#type
1260 ));
1261 }
1262 let _f_idx = require_existing_finding(frontier, &proposal.target.id)?;
1263 let entity_name = proposal
1264 .payload
1265 .get("entity_name")
1266 .and_then(Value::as_str)
1267 .ok_or("finding.entity_add proposal missing payload.entity_name")?;
1268 if entity_name.trim().is_empty() {
1269 return Err("finding.entity_add payload.entity_name must be non-empty".to_string());
1270 }
1271 let entity_type = proposal
1272 .payload
1273 .get("entity_type")
1274 .and_then(Value::as_str)
1275 .ok_or("finding.entity_add proposal missing payload.entity_type")?;
1276 const VALID_ENTITY_TYPES: &[&str] = &[
1277 "gene",
1278 "protein",
1279 "compound",
1280 "disease",
1281 "cell_type",
1282 "organism",
1283 "pathway",
1284 "assay",
1285 "anatomical_structure",
1286 "particle",
1287 "instrument",
1288 "dataset",
1289 "quantity",
1290 "other",
1291 ];
1292 if !VALID_ENTITY_TYPES.contains(&entity_type) {
1293 return Err(format!(
1294 "finding.entity_add payload.entity_type '{entity_type}' not in {VALID_ENTITY_TYPES:?}"
1295 ));
1296 }
1297 let reason_text = proposal
1298 .payload
1299 .get("reason")
1300 .and_then(Value::as_str)
1301 .ok_or("finding.entity_add proposal missing payload.reason")?;
1302 if reason_text.trim().is_empty() {
1303 return Err("finding.entity_add payload.reason must be non-empty".to_string());
1304 }
1305 }
1306 "evidence_atom.locator_repair" => {
1314 if proposal.target.r#type != "evidence_atom" {
1315 return Err(format!(
1316 "evidence_atom.locator_repair target.type must be 'evidence_atom', got '{}'",
1317 proposal.target.r#type
1318 ));
1319 }
1320 let atom_id = proposal.target.id.as_str();
1321 let atom = frontier
1322 .evidence_atoms
1323 .iter()
1324 .find(|atom| atom.id == atom_id)
1325 .ok_or_else(|| {
1326 format!("evidence_atom.locator_repair targets unknown atom {atom_id}")
1327 })?;
1328 let locator = proposal
1329 .payload
1330 .get("locator")
1331 .and_then(Value::as_str)
1332 .ok_or("evidence_atom.locator_repair proposal missing payload.locator")?;
1333 if locator.trim().is_empty() {
1334 return Err(
1335 "evidence_atom.locator_repair payload.locator must be non-empty".to_string(),
1336 );
1337 }
1338 let source_id = proposal
1339 .payload
1340 .get("source_id")
1341 .and_then(Value::as_str)
1342 .ok_or("evidence_atom.locator_repair proposal missing payload.source_id")?;
1343 if source_id.trim().is_empty() {
1344 return Err(
1345 "evidence_atom.locator_repair payload.source_id must be non-empty".to_string(),
1346 );
1347 }
1348 if atom.source_id != source_id {
1349 return Err(format!(
1350 "evidence_atom.locator_repair payload.source_id '{source_id}' does not match atom.source_id '{}'",
1351 atom.source_id
1352 ));
1353 }
1354 if let Some(existing) = &atom.locator
1358 && existing == locator
1359 {
1360 return Err(format!(
1361 "evidence_atom {atom_id} already carries locator '{existing}'"
1362 ));
1363 }
1364 if let Some(existing) = &atom.locator
1367 && existing != locator
1368 {
1369 return Err(format!(
1370 "evidence_atom {atom_id} already carries locator '{existing}'; refusing to overwrite with '{locator}'"
1371 ));
1372 }
1373 }
1374 "trajectory.step_append" => {
1378 if proposal.target.r#type != "trajectory" {
1379 return Err(format!(
1380 "trajectory.step_append proposal target.type must be 'trajectory', got '{}'",
1381 proposal.target.r#type
1382 ));
1383 }
1384 let parent_id = proposal.target.id.as_str();
1385 let parent_idx = frontier
1386 .trajectories
1387 .iter()
1388 .position(|t| t.id == parent_id)
1389 .ok_or_else(|| {
1390 format!("trajectory.step_append targets unknown trajectory {parent_id}")
1391 })?;
1392 let step_value = proposal
1393 .payload
1394 .get("step")
1395 .ok_or("trajectory.step_append proposal missing payload.step")?
1396 .clone();
1397 let step: crate::bundle::TrajectoryStep = serde_json::from_value(step_value)
1398 .map_err(|e| format!("Invalid trajectory.step_append payload.step: {e}"))?;
1399 if frontier.trajectories[parent_idx]
1400 .steps
1401 .iter()
1402 .any(|s| s.id == step.id)
1403 {
1404 return Err(format!(
1405 "Refusing to add duplicate step with existing id {} on trajectory {}",
1406 step.id, parent_id
1407 ));
1408 }
1409 }
1410 "frontier.conflict_resolve" => {
1415 if proposal.target.r#type != "frontier_observation" {
1416 return Err(format!(
1417 "frontier.conflict_resolve target.type must be 'frontier_observation', got '{}'",
1418 proposal.target.r#type
1419 ));
1420 }
1421 let conflict_event_id = proposal
1422 .payload
1423 .get("conflict_event_id")
1424 .and_then(Value::as_str)
1425 .ok_or("frontier.conflict_resolve proposal missing payload.conflict_event_id")?;
1426 if conflict_event_id.trim().is_empty() {
1427 return Err(
1428 "frontier.conflict_resolve payload.conflict_event_id must be non-empty"
1429 .to_string(),
1430 );
1431 }
1432 let conflict_event = frontier
1436 .events
1437 .iter()
1438 .find(|e| e.id == conflict_event_id)
1439 .ok_or_else(|| {
1440 format!(
1441 "frontier.conflict_resolve targets unknown event id '{conflict_event_id}'"
1442 )
1443 })?;
1444 if conflict_event.kind != "frontier.conflict_detected" {
1445 return Err(format!(
1446 "frontier.conflict_resolve target event '{conflict_event_id}' has kind '{}', expected 'frontier.conflict_detected'",
1447 conflict_event.kind
1448 ));
1449 }
1450 if frontier.events.iter().any(|e| {
1454 e.kind == "frontier.conflict_resolved"
1455 && e.payload.get("conflict_event_id").and_then(Value::as_str)
1456 == Some(conflict_event_id)
1457 }) {
1458 return Err(format!(
1459 "Conflict event '{conflict_event_id}' already has a recorded resolution"
1460 ));
1461 }
1462 let note = proposal
1463 .payload
1464 .get("resolution_note")
1465 .and_then(Value::as_str)
1466 .ok_or("frontier.conflict_resolve proposal missing payload.resolution_note")?;
1467 if note.trim().is_empty() {
1468 return Err(
1469 "frontier.conflict_resolve payload.resolution_note must be non-empty"
1470 .to_string(),
1471 );
1472 }
1473 if let Some(value) = proposal.payload.get("winning_proposal_id")
1476 && !value.is_null()
1477 && value.as_str().is_none()
1478 {
1479 return Err(
1480 "frontier.conflict_resolve payload.winning_proposal_id must be a string when present"
1481 .to_string(),
1482 );
1483 }
1484 }
1485 other => {
1486 return Err(format!("Unsupported proposal kind '{other}'"));
1487 }
1488 }
1489 Ok(())
1490}
1491
1492fn validate_decision_state(proposal: &StateProposal) -> Result<(), String> {
1493 match proposal.status.as_str() {
1494 "pending_review" => Ok(()),
1495 "accepted" | "applied" | "rejected" => {
1496 let reviewer = proposal
1497 .reviewed_by
1498 .as_deref()
1499 .ok_or_else(|| format!("Proposal {} missing reviewed_by", proposal.id))?;
1500 validate_reviewer_identity(reviewer)?;
1501 if proposal
1502 .decision_reason
1503 .as_deref()
1504 .is_none_or(|reason| reason.trim().is_empty())
1505 {
1506 return Err(format!("Proposal {} missing decision_reason", proposal.id));
1507 }
1508 if proposal.status == "applied" && proposal.applied_event_id.is_none() {
1509 return Err(format!(
1510 "Applied proposal {} missing applied_event_id",
1511 proposal.id
1512 ));
1513 }
1514 Ok(())
1515 }
1516 other => Err(format!("Unsupported proposal status '{}'", other)),
1517 }
1518}
1519
1520fn validate_standalone_proposal(
1521 _frontier: &Project,
1522 proposal: &StateProposal,
1523) -> Result<(), String> {
1524 if proposal.schema != PROPOSAL_SCHEMA {
1525 return Err(format!("Unsupported proposal schema '{}'", proposal.schema));
1526 }
1527 if !matches!(
1528 proposal.target.r#type.as_str(),
1529 "finding" | "evidence_atom" | "frontier_observation"
1530 ) {
1531 return Err(
1532 "Only finding, evidence_atom, and frontier_observation proposals are supported in v0"
1533 .to_string(),
1534 );
1535 }
1536 if proposal.reason.trim().is_empty() {
1537 return Err("Proposal reason must be non-empty".to_string());
1538 }
1539 match proposal.kind.as_str() {
1540 "finding.add" => {
1541 let finding_value = proposal
1542 .payload
1543 .get("finding")
1544 .ok_or("finding.add proposal missing payload.finding")?
1545 .clone();
1546 let finding: FindingBundle = serde_json::from_value(finding_value)
1547 .map_err(|e| format!("Invalid finding.add payload: {e}"))?;
1548 if finding.id != proposal.target.id {
1549 return Err(format!(
1550 "finding.add target {} does not match payload finding {}",
1551 proposal.target.id, finding.id
1552 ));
1553 }
1554 }
1555 "finding.review" => {
1556 let status = proposal
1557 .payload
1558 .get("status")
1559 .and_then(Value::as_str)
1560 .ok_or("finding.review proposal missing payload.status")?;
1561 if !matches!(
1562 status,
1563 "accepted" | "approved" | "contested" | "needs_revision" | "rejected"
1564 ) {
1565 return Err(format!("Unsupported review proposal status '{status}'"));
1566 }
1567 }
1568 "finding.caveat" => {
1569 let text = proposal
1570 .payload
1571 .get("text")
1572 .and_then(Value::as_str)
1573 .ok_or("finding.caveat proposal missing payload.text")?;
1574 if text.trim().is_empty() {
1575 return Err("finding.caveat payload.text must be non-empty".to_string());
1576 }
1577 }
1578 "finding.note" => {
1579 let text = proposal
1580 .payload
1581 .get("text")
1582 .and_then(Value::as_str)
1583 .ok_or("finding.note proposal missing payload.text")?;
1584 if text.trim().is_empty() {
1585 return Err("finding.note payload.text must be non-empty".to_string());
1586 }
1587 }
1588 "finding.confidence_revise" => {
1589 let score = proposal
1590 .payload
1591 .get("confidence")
1592 .and_then(Value::as_f64)
1593 .ok_or("finding.confidence_revise proposal missing payload.confidence")?;
1594 if !(0.0..=1.0).contains(&score) {
1595 return Err(
1596 "finding.confidence_revise confidence must be between 0.0 and 1.0".to_string(),
1597 );
1598 }
1599 }
1600 "finding.reject" | "finding.retract" => {}
1601 "finding.supersede" => {
1602 let new_finding_value = proposal
1603 .payload
1604 .get("new_finding")
1605 .ok_or("finding.supersede proposal missing payload.new_finding")?
1606 .clone();
1607 let new_finding: FindingBundle = serde_json::from_value(new_finding_value)
1608 .map_err(|e| format!("Invalid finding.supersede payload.new_finding: {e}"))?;
1609 if new_finding.id == proposal.target.id {
1610 return Err(
1611 "finding.supersede new_finding has same content address as the superseded target"
1612 .to_string(),
1613 );
1614 }
1615 }
1616 "finding.span_repair" => {
1618 if proposal.target.r#type != "finding" {
1619 return Err(format!(
1620 "finding.span_repair target.type must be 'finding', got '{}'",
1621 proposal.target.r#type
1622 ));
1623 }
1624 let section = proposal
1625 .payload
1626 .get("section")
1627 .and_then(Value::as_str)
1628 .ok_or("finding.span_repair proposal missing payload.section")?;
1629 if section.trim().is_empty() {
1630 return Err("finding.span_repair payload.section must be non-empty".to_string());
1631 }
1632 let text = proposal
1633 .payload
1634 .get("text")
1635 .and_then(Value::as_str)
1636 .ok_or("finding.span_repair proposal missing payload.text")?;
1637 if text.trim().is_empty() {
1638 return Err("finding.span_repair payload.text must be non-empty".to_string());
1639 }
1640 }
1641 "finding.entity_resolve" => {
1643 if proposal.target.r#type != "finding" {
1644 return Err(format!(
1645 "finding.entity_resolve target.type must be 'finding', got '{}'",
1646 proposal.target.r#type
1647 ));
1648 }
1649 let entity_name = proposal
1650 .payload
1651 .get("entity_name")
1652 .and_then(Value::as_str)
1653 .ok_or("finding.entity_resolve proposal missing payload.entity_name")?;
1654 if entity_name.trim().is_empty() {
1655 return Err(
1656 "finding.entity_resolve payload.entity_name must be non-empty".to_string(),
1657 );
1658 }
1659 let source = proposal
1660 .payload
1661 .get("source")
1662 .and_then(Value::as_str)
1663 .ok_or("finding.entity_resolve proposal missing payload.source")?;
1664 if source.trim().is_empty() {
1665 return Err("finding.entity_resolve payload.source must be non-empty".to_string());
1666 }
1667 let id = proposal
1668 .payload
1669 .get("id")
1670 .and_then(Value::as_str)
1671 .ok_or("finding.entity_resolve proposal missing payload.id")?;
1672 if id.trim().is_empty() {
1673 return Err("finding.entity_resolve payload.id must be non-empty".to_string());
1674 }
1675 let confidence = proposal
1676 .payload
1677 .get("confidence")
1678 .and_then(Value::as_f64)
1679 .ok_or("finding.entity_resolve proposal missing payload.confidence")?;
1680 if !(0.0..=1.0).contains(&confidence) {
1681 return Err(format!(
1682 "finding.entity_resolve confidence {confidence} out of [0.0, 1.0]"
1683 ));
1684 }
1685 }
1686 "finding.entity_add" => {
1690 if proposal.target.r#type != "finding" {
1691 return Err(format!(
1692 "finding.entity_add target.type must be 'finding', got '{}'",
1693 proposal.target.r#type
1694 ));
1695 }
1696 let entity_name = proposal
1697 .payload
1698 .get("entity_name")
1699 .and_then(Value::as_str)
1700 .ok_or("finding.entity_add proposal missing payload.entity_name")?;
1701 if entity_name.trim().is_empty() {
1702 return Err("finding.entity_add payload.entity_name must be non-empty".to_string());
1703 }
1704 let entity_type = proposal
1705 .payload
1706 .get("entity_type")
1707 .and_then(Value::as_str)
1708 .ok_or("finding.entity_add proposal missing payload.entity_type")?;
1709 const VALID_ENTITY_TYPES: &[&str] = &[
1710 "gene",
1711 "protein",
1712 "compound",
1713 "disease",
1714 "cell_type",
1715 "organism",
1716 "pathway",
1717 "assay",
1718 "anatomical_structure",
1719 "particle",
1720 "instrument",
1721 "dataset",
1722 "quantity",
1723 "other",
1724 ];
1725 if !VALID_ENTITY_TYPES.contains(&entity_type) {
1726 return Err(format!(
1727 "finding.entity_add payload.entity_type '{entity_type}' not in {VALID_ENTITY_TYPES:?}"
1728 ));
1729 }
1730 let reason = proposal
1731 .payload
1732 .get("reason")
1733 .and_then(Value::as_str)
1734 .ok_or("finding.entity_add proposal missing payload.reason")?;
1735 if reason.trim().is_empty() {
1736 return Err("finding.entity_add payload.reason must be non-empty".to_string());
1737 }
1738 }
1739 "evidence_atom.locator_repair" => {
1745 if proposal.target.r#type != "evidence_atom" {
1746 return Err(format!(
1747 "evidence_atom.locator_repair target.type must be 'evidence_atom', got '{}'",
1748 proposal.target.r#type
1749 ));
1750 }
1751 let locator = proposal
1752 .payload
1753 .get("locator")
1754 .and_then(Value::as_str)
1755 .ok_or("evidence_atom.locator_repair proposal missing payload.locator")?;
1756 if locator.trim().is_empty() {
1757 return Err(
1758 "evidence_atom.locator_repair payload.locator must be non-empty".to_string(),
1759 );
1760 }
1761 let source_id = proposal
1762 .payload
1763 .get("source_id")
1764 .and_then(Value::as_str)
1765 .ok_or("evidence_atom.locator_repair proposal missing payload.source_id")?;
1766 if source_id.trim().is_empty() {
1767 return Err(
1768 "evidence_atom.locator_repair payload.source_id must be non-empty".to_string(),
1769 );
1770 }
1771 }
1772 "frontier.conflict_resolve" => {
1776 if proposal.target.r#type != "frontier_observation" {
1777 return Err(format!(
1778 "frontier.conflict_resolve target.type must be 'frontier_observation', got '{}'",
1779 proposal.target.r#type
1780 ));
1781 }
1782 let conflict_event_id = proposal
1783 .payload
1784 .get("conflict_event_id")
1785 .and_then(Value::as_str)
1786 .ok_or("frontier.conflict_resolve proposal missing payload.conflict_event_id")?;
1787 if conflict_event_id.trim().is_empty() {
1788 return Err(
1789 "frontier.conflict_resolve payload.conflict_event_id must be non-empty"
1790 .to_string(),
1791 );
1792 }
1793 let note = proposal
1794 .payload
1795 .get("resolution_note")
1796 .and_then(Value::as_str)
1797 .ok_or("frontier.conflict_resolve proposal missing payload.resolution_note")?;
1798 if note.trim().is_empty() {
1799 return Err(
1800 "frontier.conflict_resolve payload.resolution_note must be non-empty"
1801 .to_string(),
1802 );
1803 }
1804 }
1805 other => return Err(format!("Unsupported proposal kind '{other}'")),
1806 }
1807 validate_decision_state(proposal)
1808}
1809
1810fn require_existing_finding(frontier: &Project, finding_id: &str) -> Result<usize, String> {
1811 frontier
1812 .findings
1813 .iter()
1814 .position(|finding| finding.id == finding_id)
1815 .ok_or_else(|| format!("Finding not found: {finding_id}"))
1816}
1817
1818fn accept_proposal_in_frontier(
1819 frontier: &mut Project,
1820 proposal_id: &str,
1821 reviewer: &str,
1822 reason: &str,
1823) -> Result<String, String> {
1824 validate_reviewer_identity(reviewer)?;
1825 if reason.trim().is_empty() {
1826 return Err("Decision reason must be non-empty".to_string());
1827 }
1828 let index = frontier
1829 .proposals
1830 .iter()
1831 .position(|proposal| proposal.id == proposal_id)
1832 .ok_or_else(|| format!("Proposal not found: {proposal_id}"))?;
1833 let status = frontier.proposals[index].status.clone();
1834 if status == "rejected" {
1835 return Err(format!("Cannot accept rejected proposal {}", proposal_id));
1836 }
1837 if status == "applied" {
1838 return frontier.proposals[index]
1839 .applied_event_id
1840 .clone()
1841 .ok_or_else(|| format!("Proposal {} is applied but has no event id", proposal_id));
1842 }
1843 let proposal = frontier.proposals[index].clone();
1844 validate_proposal_shape(frontier, &proposal)?;
1845 frontier.proposals[index].status = "accepted".to_string();
1846 frontier.proposals[index].reviewed_by = Some(reviewer.to_string());
1847 frontier.proposals[index].reviewed_at = Some(Utc::now().to_rfc3339());
1848 frontier.proposals[index].decision_reason = Some(reason.to_string());
1849 let event_id = apply_proposal(frontier, &proposal, reviewer, reason)?;
1850 frontier.proposals[index].status = "applied".to_string();
1851 frontier.proposals[index].applied_event_id = Some(event_id.clone());
1852 Ok(event_id)
1853}
1854
1855fn reject_proposal_in_frontier(
1856 frontier: &mut Project,
1857 proposal_id: &str,
1858 reviewer: &str,
1859 reason: &str,
1860) -> Result<(), String> {
1861 validate_reviewer_identity(reviewer)?;
1862 if reason.trim().is_empty() {
1863 return Err("Decision reason must be non-empty".to_string());
1864 }
1865 let index = frontier
1866 .proposals
1867 .iter()
1868 .position(|proposal| proposal.id == proposal_id)
1869 .ok_or_else(|| format!("Proposal not found: {proposal_id}"))?;
1870 match frontier.proposals[index].status.as_str() {
1871 "pending_review" | "accepted" => {}
1872 "rejected" => {
1873 return Err(format!("Proposal {} is already rejected", proposal_id));
1874 }
1875 "applied" => {
1876 return Err(format!("Proposal {} is already applied", proposal_id));
1877 }
1878 other => {
1879 return Err(format!("Unsupported proposal status '{}'", other));
1880 }
1881 }
1882 frontier.proposals[index].status = "rejected".to_string();
1883 frontier.proposals[index].reviewed_by = Some(reviewer.to_string());
1884 frontier.proposals[index].reviewed_at = Some(Utc::now().to_rfc3339());
1885 frontier.proposals[index].decision_reason = Some(reason.to_string());
1886 Ok(())
1887}
1888
1889fn request_revision_in_frontier(
1890 frontier: &mut Project,
1891 proposal_id: &str,
1892 reviewer: &str,
1893 reason: &str,
1894) -> Result<(), String> {
1895 validate_reviewer_identity(reviewer)?;
1896 if reason.trim().is_empty() {
1897 return Err("Decision reason must be non-empty".to_string());
1898 }
1899 let index = frontier
1900 .proposals
1901 .iter()
1902 .position(|proposal| proposal.id == proposal_id)
1903 .ok_or_else(|| format!("Proposal not found: {proposal_id}"))?;
1904 match frontier.proposals[index].status.as_str() {
1905 "pending_review" => {}
1906 "needs_revision" => {
1907 return Err(format!("Proposal {} already needs revision", proposal_id));
1908 }
1909 "rejected" => {
1910 return Err(format!("Proposal {} is already rejected", proposal_id));
1911 }
1912 "applied" => {
1913 return Err(format!("Proposal {} is already applied", proposal_id));
1914 }
1915 other => {
1916 return Err(format!("Unsupported proposal status '{}'", other));
1917 }
1918 }
1919 frontier.proposals[index].status = "needs_revision".to_string();
1920 frontier.proposals[index].reviewed_by = Some(reviewer.to_string());
1921 frontier.proposals[index].reviewed_at = Some(Utc::now().to_rfc3339());
1922 frontier.proposals[index].decision_reason = Some(reason.to_string());
1923 Ok(())
1924}
1925
1926fn apply_proposal(
1927 frontier: &mut Project,
1928 proposal: &StateProposal,
1929 reviewer: &str,
1930 decision_reason: &str,
1931) -> Result<String, String> {
1932 if proposal.kind.as_str() == "finding.retract" {
1937 let events = apply_retract(frontier, proposal, reviewer, decision_reason)?;
1938 let primary_id = events
1939 .first()
1940 .map(|event| event.id.clone())
1941 .ok_or_else(|| "apply_retract returned no events".to_string())?;
1942 for event in events {
1943 frontier.events.push(event);
1944 }
1945 mark_proof_stale(
1946 frontier,
1947 format!("Applied proposal {} after latest proof export", proposal.id),
1948 );
1949 return Ok(primary_id);
1950 }
1951 if proposal.kind.as_str() == "finding.confidence_revise" {
1955 let events = apply_confidence_revise(frontier, proposal, reviewer, decision_reason)?;
1956 let primary_id = events
1957 .first()
1958 .map(|event| event.id.clone())
1959 .ok_or_else(|| "apply_confidence_revise returned no events".to_string())?;
1960 for event in events {
1961 frontier.events.push(event);
1962 }
1963 mark_proof_stale(
1964 frontier,
1965 format!("Applied proposal {} after latest proof export", proposal.id),
1966 );
1967 return Ok(primary_id);
1968 }
1969 let event = match proposal.kind.as_str() {
1970 "finding.add" => apply_add(frontier, proposal, reviewer, decision_reason)?,
1971 "finding.review" => apply_review(frontier, proposal, reviewer, decision_reason)?,
1972 "finding.caveat" => apply_caveat(frontier, proposal, reviewer, decision_reason)?,
1973 "finding.note" => apply_note(frontier, proposal, reviewer, decision_reason)?,
1974 "finding.reject" => apply_reject(frontier, proposal, reviewer, decision_reason)?,
1975 "finding.supersede" => apply_supersede(frontier, proposal, reviewer, decision_reason)?,
1976 "artifact.assert" => apply_artifact_assert(frontier, proposal, reviewer, decision_reason)?,
1977 "negative_result.assert" => {
1980 apply_negative_result_assert(frontier, proposal, reviewer, decision_reason)?
1981 }
1982 "trajectory.create" => {
1983 apply_trajectory_create(frontier, proposal, reviewer, decision_reason)?
1984 }
1985 "trajectory.step_append" => {
1986 apply_trajectory_step_append(frontier, proposal, reviewer, decision_reason)?
1987 }
1988 "evidence_atom.locator_repair" => {
1990 apply_evidence_atom_locator_repair(frontier, proposal, reviewer, decision_reason)?
1991 }
1992 "finding.span_repair" => {
1994 apply_finding_span_repair(frontier, proposal, reviewer, decision_reason)?
1995 }
1996 "finding.entity_resolve" => {
1998 apply_finding_entity_resolve(frontier, proposal, reviewer, decision_reason)?
1999 }
2000 "finding.entity_add" => {
2003 apply_finding_entity_add(frontier, proposal, reviewer, decision_reason)?
2004 }
2005 "frontier.conflict_resolve" => {
2007 apply_frontier_conflict_resolve(frontier, proposal, reviewer, decision_reason)?
2008 }
2009 other => return Err(format!("Unsupported proposal kind '{other}'")),
2010 };
2011 let event_id = event.id.clone();
2012 frontier.events.push(event);
2013 mark_proof_stale(
2014 frontier,
2015 format!("Applied proposal {} after latest proof export", proposal.id),
2016 );
2017 Ok(event_id)
2018}
2019
2020fn apply_supersede(
2040 frontier: &mut Project,
2041 proposal: &StateProposal,
2042 reviewer: &str,
2043 _decision_reason: &str,
2044) -> Result<StateEvent, String> {
2045 use crate::bundle::Link;
2046
2047 let old_id = proposal.target.id.clone();
2048 let new_finding_value = proposal
2049 .payload
2050 .get("new_finding")
2051 .ok_or("finding.supersede proposal missing payload.new_finding")?
2052 .clone();
2053 let mut new_finding: FindingBundle = serde_json::from_value(new_finding_value)
2054 .map_err(|e| format!("Invalid finding.supersede payload.new_finding: {e}"))?;
2055
2056 let old_idx = find_finding_index(frontier, &old_id)?;
2058 if frontier.findings[old_idx].flags.superseded {
2059 return Err(format!(
2060 "Refusing to supersede already-superseded finding {old_id}"
2061 ));
2062 }
2063 if new_finding.id == old_id {
2064 return Err(
2065 "Refusing to supersede with a finding that has the same content address as the old finding (assertion / type / provenance_id are unchanged)".to_string(),
2066 );
2067 }
2068 if frontier
2069 .findings
2070 .iter()
2071 .any(|existing| existing.id == new_finding.id)
2072 {
2073 return Err(format!(
2074 "Refusing to add superseding finding with existing finding ID {}",
2075 new_finding.id
2076 ));
2077 }
2078 let before_hash = events::finding_hash(&frontier.findings[old_idx]);
2079
2080 let already_links_old = new_finding
2082 .links
2083 .iter()
2084 .any(|l| l.target == old_id && l.link_type == "supersedes");
2085 if !already_links_old {
2086 new_finding.links.push(Link {
2087 target: old_id.clone(),
2088 link_type: "supersedes".to_string(),
2089 note: format!(
2090 "Supersedes {old_id} via finding.supersede proposal {}.",
2091 proposal.id
2092 ),
2093 inferred_by: "reviewer".to_string(),
2094 created_at: Utc::now().to_rfc3339(),
2095 mechanism: None,
2096 });
2097 }
2098
2099 let new_finding_id = new_finding.id.clone();
2100 frontier.findings.push(new_finding);
2101 frontier.findings[old_idx].flags.superseded = true;
2102 let after_hash = events::finding_hash(&frontier.findings[old_idx]);
2103
2104 Ok(events::new_finding_event(events::FindingEventInput {
2105 kind: "finding.superseded",
2106 finding_id: &old_id,
2107 actor_id: reviewer,
2108 actor_type: "human",
2109 reason: &proposal.reason,
2110 before_hash: &before_hash,
2111 after_hash: &after_hash,
2112 payload: json!({
2113 "proposal_id": proposal.id,
2114 "new_finding_id": new_finding_id,
2115 }),
2116 caveats: proposal.caveats.clone(),
2117 }))
2118}
2119
2120fn apply_add(
2121 frontier: &mut Project,
2122 proposal: &StateProposal,
2123 reviewer: &str,
2124 _decision_reason: &str,
2125) -> Result<StateEvent, String> {
2126 let finding_value = proposal
2127 .payload
2128 .get("finding")
2129 .ok_or("finding.add proposal missing payload.finding")?
2130 .clone();
2131 let finding: FindingBundle = serde_json::from_value(finding_value)
2132 .map_err(|e| format!("Invalid finding.add payload: {e}"))?;
2133 let finding_id = finding.id.clone();
2134 if frontier
2135 .findings
2136 .iter()
2137 .any(|existing| existing.id == finding_id)
2138 {
2139 return Err(format!(
2140 "Refusing to add duplicate finding with existing finding ID {finding_id}"
2141 ));
2142 }
2143 frontier.findings.push(finding);
2144 let after_hash = events::finding_hash_by_id(frontier, &finding_id);
2145 Ok(events::new_finding_event(events::FindingEventInput {
2146 kind: "finding.asserted",
2147 finding_id: &finding_id,
2148 actor_id: reviewer,
2149 actor_type: "human",
2150 reason: &proposal.reason,
2151 before_hash: NULL_HASH,
2152 after_hash: &after_hash,
2153 payload: json!({
2154 "proposal_id": proposal.id,
2155 }),
2156 caveats: proposal.caveats.clone(),
2157 }))
2158}
2159
2160fn apply_artifact_assert(
2161 frontier: &mut Project,
2162 proposal: &StateProposal,
2163 reviewer: &str,
2164 _decision_reason: &str,
2165) -> Result<StateEvent, String> {
2166 let artifact_value = proposal
2167 .payload
2168 .get("artifact")
2169 .ok_or("artifact.assert proposal missing payload.artifact")?
2170 .clone();
2171 let artifact: Artifact = serde_json::from_value(artifact_value)
2172 .map_err(|e| format!("Invalid artifact.assert payload: {e}"))?;
2173 let artifact_id = artifact.id.clone();
2174 if frontier
2175 .artifacts
2176 .iter()
2177 .any(|existing| existing.id == artifact_id)
2178 {
2179 return Err(format!(
2180 "Refusing to add duplicate artifact with existing id {artifact_id}"
2181 ));
2182 }
2183 frontier.artifacts.push(artifact.clone());
2184 let mut event = StateEvent {
2185 schema: events::EVENT_SCHEMA.to_string(),
2186 id: String::new(),
2187 kind: events::EVENT_KIND_ARTIFACT_ASSERTED.to_string(),
2188 target: StateTarget {
2189 r#type: "artifact".to_string(),
2190 id: artifact_id,
2191 },
2192 actor: StateActor {
2193 id: reviewer.to_string(),
2194 r#type: if reviewer.starts_with("agent:") {
2195 "agent"
2196 } else {
2197 "human"
2198 }
2199 .to_string(),
2200 },
2201 timestamp: Utc::now().to_rfc3339(),
2202 reason: proposal.reason.clone(),
2203 before_hash: NULL_HASH.to_string(),
2204 after_hash: NULL_HASH.to_string(),
2205 payload: json!({
2206 "proposal_id": proposal.id,
2207 "artifact": artifact,
2208 }),
2209 caveats: proposal.caveats.clone(),
2210 signature: None,
2211 schema_artifact_id: None,
2212 };
2213 events::validate_event_payload(&event.kind, &event.payload)?;
2214 event.id = events::compute_event_id(&event);
2215 Ok(event)
2216}
2217
2218fn apply_review(
2219 frontier: &mut Project,
2220 proposal: &StateProposal,
2221 reviewer: &str,
2222 _decision_reason: &str,
2223) -> Result<StateEvent, String> {
2224 let finding_id = proposal.target.id.as_str();
2225 let idx = find_finding_index(frontier, finding_id)?;
2226 let before_hash = events::finding_hash(&frontier.findings[idx]);
2227 let status = proposal
2228 .payload
2229 .get("status")
2230 .and_then(Value::as_str)
2231 .ok_or("finding.review proposal missing payload.status")?;
2232 use crate::bundle::ReviewState;
2233 let new_state = match status {
2234 "accepted" | "approved" => ReviewState::Accepted,
2235 "contested" => ReviewState::Contested,
2236 "needs_revision" => ReviewState::NeedsRevision,
2237 "rejected" => ReviewState::Rejected,
2238 other => return Err(format!("Unknown review proposal status '{other}'")),
2239 };
2240 frontier.findings[idx].flags.contested = new_state.implies_contested();
2241 frontier.findings[idx].flags.review_state = Some(new_state);
2242 let after_hash = events::finding_hash(&frontier.findings[idx]);
2243 Ok(events::new_finding_event(events::FindingEventInput {
2244 kind: "finding.reviewed",
2245 finding_id,
2246 actor_id: reviewer,
2247 actor_type: "human",
2248 reason: &proposal.reason,
2249 before_hash: &before_hash,
2250 after_hash: &after_hash,
2251 payload: json!({
2252 "status": status,
2253 "proposal_id": proposal.id,
2254 }),
2255 caveats: proposal.caveats.clone(),
2256 }))
2257}
2258
2259fn apply_caveat(
2260 frontier: &mut Project,
2261 proposal: &StateProposal,
2262 reviewer: &str,
2263 _decision_reason: &str,
2264) -> Result<StateEvent, String> {
2265 let finding_id = proposal.target.id.as_str();
2266 let idx = find_finding_index(frontier, finding_id)?;
2267 let before_hash = events::finding_hash(&frontier.findings[idx]);
2268 let now = Utc::now().to_rfc3339();
2269 let text = proposal
2270 .payload
2271 .get("text")
2272 .and_then(Value::as_str)
2273 .ok_or("finding.caveat proposal missing payload.text")?;
2274 let provenance = extract_annotation_provenance(&proposal.payload);
2275 let annotation_id = annotation_id(finding_id, text, reviewer, &now);
2276 frontier.findings[idx].annotations.push(Annotation {
2277 id: annotation_id.clone(),
2278 text: text.to_string(),
2279 author: reviewer.to_string(),
2280 timestamp: now,
2281 provenance: provenance.clone(),
2282 });
2283 let after_hash = events::finding_hash(&frontier.findings[idx]);
2284 let mut payload = json!({
2285 "annotation_id": annotation_id,
2286 "text": text,
2287 "proposal_id": proposal.id,
2288 });
2289 if let Some(prov) = &provenance {
2290 payload["provenance"] = serde_json::to_value(prov).unwrap_or(Value::Null);
2291 }
2292 Ok(events::new_finding_event(events::FindingEventInput {
2293 kind: "finding.caveated",
2294 finding_id,
2295 actor_id: reviewer,
2296 actor_type: "human",
2297 reason: text,
2298 before_hash: &before_hash,
2299 after_hash: &after_hash,
2300 payload,
2301 caveats: proposal.caveats.clone(),
2302 }))
2303}
2304
2305fn apply_note(
2306 frontier: &mut Project,
2307 proposal: &StateProposal,
2308 reviewer: &str,
2309 _decision_reason: &str,
2310) -> Result<StateEvent, String> {
2311 let finding_id = proposal.target.id.as_str();
2312 let idx = find_finding_index(frontier, finding_id)?;
2313 let before_hash = events::finding_hash(&frontier.findings[idx]);
2314 let now = Utc::now().to_rfc3339();
2315 let text = proposal
2316 .payload
2317 .get("text")
2318 .and_then(Value::as_str)
2319 .ok_or("finding.note proposal missing payload.text")?;
2320 let provenance = extract_annotation_provenance(&proposal.payload);
2321 let annotation_id = annotation_id(finding_id, text, reviewer, &now);
2322 frontier.findings[idx].annotations.push(Annotation {
2323 id: annotation_id.clone(),
2324 text: text.to_string(),
2325 author: reviewer.to_string(),
2326 timestamp: now,
2327 provenance: provenance.clone(),
2328 });
2329 let after_hash = events::finding_hash(&frontier.findings[idx]);
2330 let mut payload = json!({
2331 "annotation_id": annotation_id,
2332 "text": text,
2333 "proposal_id": proposal.id,
2334 });
2335 if let Some(prov) = &provenance {
2336 payload["provenance"] = serde_json::to_value(prov).unwrap_or(Value::Null);
2337 }
2338 Ok(events::new_finding_event(events::FindingEventInput {
2339 kind: "finding.noted",
2340 finding_id,
2341 actor_id: reviewer,
2342 actor_type: "human",
2343 reason: text,
2344 before_hash: &before_hash,
2345 after_hash: &after_hash,
2346 payload,
2347 caveats: proposal.caveats.clone(),
2348 }))
2349}
2350
2351fn apply_finding_entity_resolve(
2355 frontier: &mut Project,
2356 proposal: &StateProposal,
2357 reviewer: &str,
2358 _decision_reason: &str,
2359) -> Result<StateEvent, String> {
2360 use crate::bundle::{ResolutionMethod, ResolvedId};
2361
2362 let finding_id = proposal.target.id.as_str();
2363 let entity_name = proposal
2364 .payload
2365 .get("entity_name")
2366 .and_then(Value::as_str)
2367 .ok_or("finding.entity_resolve proposal missing payload.entity_name")?
2368 .to_string();
2369 let source = proposal
2370 .payload
2371 .get("source")
2372 .and_then(Value::as_str)
2373 .ok_or("finding.entity_resolve proposal missing payload.source")?
2374 .to_string();
2375 let id = proposal
2376 .payload
2377 .get("id")
2378 .and_then(Value::as_str)
2379 .ok_or("finding.entity_resolve proposal missing payload.id")?
2380 .to_string();
2381 let confidence = proposal
2382 .payload
2383 .get("confidence")
2384 .and_then(Value::as_f64)
2385 .ok_or("finding.entity_resolve proposal missing payload.confidence")?;
2386 let matched_name = proposal
2387 .payload
2388 .get("matched_name")
2389 .and_then(Value::as_str)
2390 .map(str::to_string);
2391 let provenance = proposal
2392 .payload
2393 .get("resolution_provenance")
2394 .and_then(Value::as_str)
2395 .unwrap_or("delegated_human_curation")
2396 .to_string();
2397 let method_str = proposal
2398 .payload
2399 .get("resolution_method")
2400 .and_then(Value::as_str)
2401 .unwrap_or("manual");
2402 let method = match method_str {
2403 "exact_match" => ResolutionMethod::ExactMatch,
2404 "fuzzy_match" => ResolutionMethod::FuzzyMatch,
2405 "llm_inference" => ResolutionMethod::LlmInference,
2406 "manual" => ResolutionMethod::Manual,
2407 other => {
2408 return Err(format!(
2409 "finding.entity_resolve unknown resolution_method '{other}'"
2410 ));
2411 }
2412 };
2413
2414 let f_idx = find_finding_index(frontier, finding_id)?;
2415 let e_idx = frontier.findings[f_idx]
2416 .assertion
2417 .entities
2418 .iter()
2419 .position(|e| e.name == entity_name)
2420 .ok_or_else(|| {
2421 format!("finding.entity_resolve entity '{entity_name}' not in finding {finding_id}")
2422 })?;
2423
2424 let before_hash = events::finding_hash(&frontier.findings[f_idx]);
2425 let entity = &mut frontier.findings[f_idx].assertion.entities[e_idx];
2426 entity.canonical_id = Some(ResolvedId {
2427 source: source.clone(),
2428 id: id.clone(),
2429 confidence,
2430 matched_name: matched_name.clone(),
2431 });
2432 entity.resolution_method = Some(method);
2433 entity.resolution_provenance = Some(provenance.clone());
2434 entity.resolution_confidence = confidence;
2435 entity.needs_review = false;
2436 let after_hash = events::finding_hash(&frontier.findings[f_idx]);
2437
2438 let mut payload = json!({
2439 "proposal_id": proposal.id,
2440 "entity_name": entity_name,
2441 "source": source,
2442 "id": id,
2443 "confidence": confidence,
2444 "resolution_method": method_str,
2445 "resolution_provenance": provenance,
2446 });
2447 if let Some(m) = matched_name {
2448 payload["matched_name"] = serde_json::Value::String(m);
2449 }
2450
2451 Ok(events::new_finding_event(events::FindingEventInput {
2452 kind: "finding.entity_resolved",
2453 finding_id,
2454 actor_id: reviewer,
2455 actor_type: "human",
2456 reason: &proposal.reason,
2457 before_hash: &before_hash,
2458 after_hash: &after_hash,
2459 payload,
2460 caveats: proposal.caveats.clone(),
2461 }))
2462}
2463
2464fn apply_finding_entity_add(
2469 frontier: &mut Project,
2470 proposal: &StateProposal,
2471 reviewer: &str,
2472 _decision_reason: &str,
2473) -> Result<StateEvent, String> {
2474 use crate::bundle::Entity;
2475
2476 let finding_id = proposal.target.id.as_str();
2477 let entity_name = proposal
2478 .payload
2479 .get("entity_name")
2480 .and_then(Value::as_str)
2481 .ok_or("finding.entity_add proposal missing payload.entity_name")?
2482 .to_string();
2483 let entity_type = proposal
2484 .payload
2485 .get("entity_type")
2486 .and_then(Value::as_str)
2487 .ok_or("finding.entity_add proposal missing payload.entity_type")?
2488 .to_string();
2489 let reason_text = proposal
2490 .payload
2491 .get("reason")
2492 .and_then(Value::as_str)
2493 .ok_or("finding.entity_add proposal missing payload.reason")?
2494 .to_string();
2495
2496 let idx = find_finding_index(frontier, finding_id)?;
2497 let already_present = frontier.findings[idx]
2498 .assertion
2499 .entities
2500 .iter()
2501 .any(|e| e.name == entity_name);
2502
2503 let before_hash = events::finding_hash(&frontier.findings[idx]);
2504 if !already_present {
2505 let entity = Entity {
2506 name: entity_name.clone(),
2507 entity_type: entity_type.clone(),
2508 identifiers: serde_json::Map::new(),
2509 canonical_id: None,
2510 candidates: Vec::new(),
2511 aliases: Vec::new(),
2512 resolution_provenance: None,
2513 resolution_confidence: 1.0,
2514 resolution_method: None,
2515 species_context: None,
2516 needs_review: false,
2517 };
2518 frontier.findings[idx].assertion.entities.push(entity);
2519 }
2520 let after_hash = events::finding_hash(&frontier.findings[idx]);
2521
2522 let payload = json!({
2523 "proposal_id": proposal.id,
2524 "entity_name": entity_name,
2525 "entity_type": entity_type,
2526 "reason": reason_text,
2527 "idempotent_noop": already_present,
2528 });
2529
2530 Ok(events::new_finding_event(events::FindingEventInput {
2531 kind: "finding.entity_added",
2532 finding_id,
2533 actor_id: reviewer,
2534 actor_type: "human",
2535 reason: &proposal.reason,
2536 before_hash: &before_hash,
2537 after_hash: &after_hash,
2538 payload,
2539 caveats: proposal.caveats.clone(),
2540 }))
2541}
2542
2543fn apply_finding_span_repair(
2547 frontier: &mut Project,
2548 proposal: &StateProposal,
2549 reviewer: &str,
2550 _decision_reason: &str,
2551) -> Result<StateEvent, String> {
2552 let finding_id = proposal.target.id.as_str();
2553 let section = proposal
2554 .payload
2555 .get("section")
2556 .and_then(Value::as_str)
2557 .ok_or("finding.span_repair proposal missing payload.section")?
2558 .to_string();
2559 let text = proposal
2560 .payload
2561 .get("text")
2562 .and_then(Value::as_str)
2563 .ok_or("finding.span_repair proposal missing payload.text")?
2564 .to_string();
2565 let idx = find_finding_index(frontier, finding_id)?;
2566 let already_present = frontier.findings[idx]
2567 .evidence
2568 .evidence_spans
2569 .iter()
2570 .any(|existing| {
2571 existing.get("section").and_then(Value::as_str) == Some(section.as_str())
2572 && existing.get("text").and_then(Value::as_str) == Some(text.as_str())
2573 });
2574 if already_present {
2575 return Err(format!(
2576 "finding {finding_id} already carries an identical (section, text) span"
2577 ));
2578 }
2579 let before_hash = events::finding_hash(&frontier.findings[idx]);
2580 let span_value = json!({"section": section, "text": text});
2581 frontier.findings[idx]
2582 .evidence
2583 .evidence_spans
2584 .push(span_value);
2585 let after_hash = events::finding_hash(&frontier.findings[idx]);
2586 let payload = json!({
2587 "proposal_id": proposal.id,
2588 "section": section,
2589 "text": text,
2590 });
2591 Ok(events::new_finding_event(events::FindingEventInput {
2592 kind: "finding.span_repaired",
2593 finding_id,
2594 actor_id: reviewer,
2595 actor_type: "human",
2596 reason: &proposal.reason,
2597 before_hash: &before_hash,
2598 after_hash: &after_hash,
2599 payload,
2600 caveats: proposal.caveats.clone(),
2601 }))
2602}
2603
2604fn apply_evidence_atom_locator_repair(
2612 frontier: &mut Project,
2613 proposal: &StateProposal,
2614 reviewer: &str,
2615 _decision_reason: &str,
2616) -> Result<StateEvent, String> {
2617 let atom_id = proposal.target.id.as_str();
2618 let locator = proposal
2619 .payload
2620 .get("locator")
2621 .and_then(Value::as_str)
2622 .ok_or("evidence_atom.locator_repair proposal missing payload.locator")?
2623 .to_string();
2624 let source_id = proposal
2625 .payload
2626 .get("source_id")
2627 .and_then(Value::as_str)
2628 .ok_or("evidence_atom.locator_repair proposal missing payload.source_id")?
2629 .to_string();
2630
2631 let idx = frontier
2632 .evidence_atoms
2633 .iter()
2634 .position(|atom| atom.id == atom_id)
2635 .ok_or_else(|| format!("evidence_atom.locator_repair targets unknown atom {atom_id}"))?;
2636 if frontier.evidence_atoms[idx].source_id != source_id {
2637 return Err(format!(
2638 "evidence_atom.locator_repair payload.source_id '{source_id}' does not match atom.source_id '{}'",
2639 frontier.evidence_atoms[idx].source_id
2640 ));
2641 }
2642 if let Some(existing) = &frontier.evidence_atoms[idx].locator {
2643 if existing == &locator {
2644 return Err(format!(
2645 "evidence_atom {atom_id} already carries locator '{existing}'"
2646 ));
2647 }
2648 return Err(format!(
2649 "evidence_atom {atom_id} already carries locator '{existing}'; refusing to overwrite with '{locator}'"
2650 ));
2651 }
2652
2653 let before_hash = events::evidence_atom_hash(&frontier.evidence_atoms[idx]);
2654 frontier.evidence_atoms[idx].locator = Some(locator.clone());
2655 frontier.evidence_atoms[idx]
2656 .caveats
2657 .retain(|c| c != "missing evidence locator");
2658 let after_hash = events::evidence_atom_hash(&frontier.evidence_atoms[idx]);
2659
2660 let payload = json!({
2661 "proposal_id": proposal.id,
2662 "locator": locator,
2663 "source_id": source_id,
2664 });
2665
2666 Ok(events::new_evidence_atom_locator_repair_event(
2667 atom_id,
2668 reviewer,
2669 "human",
2670 &proposal.reason,
2671 &before_hash,
2672 &after_hash,
2673 payload,
2674 proposal.caveats.clone(),
2675 ))
2676}
2677
2678fn apply_frontier_conflict_resolve(
2685 frontier: &mut Project,
2686 proposal: &StateProposal,
2687 reviewer: &str,
2688 _decision_reason: &str,
2689) -> Result<StateEvent, String> {
2690 let conflict_event_id = proposal
2691 .payload
2692 .get("conflict_event_id")
2693 .and_then(Value::as_str)
2694 .ok_or("frontier.conflict_resolve proposal missing payload.conflict_event_id")?
2695 .to_string();
2696 let resolution_note = proposal
2697 .payload
2698 .get("resolution_note")
2699 .and_then(Value::as_str)
2700 .ok_or("frontier.conflict_resolve proposal missing payload.resolution_note")?
2701 .to_string();
2702 let winning_proposal_id = proposal
2703 .payload
2704 .get("winning_proposal_id")
2705 .and_then(Value::as_str)
2706 .map(|s| s.to_string());
2707
2708 let conflict_event = frontier
2713 .events
2714 .iter()
2715 .find(|e| e.id == conflict_event_id)
2716 .ok_or_else(|| {
2717 format!("frontier.conflict_resolve targets unknown event id '{conflict_event_id}'")
2718 })?
2719 .clone();
2720 if conflict_event.kind != "frontier.conflict_detected" {
2721 return Err(format!(
2722 "frontier.conflict_resolve target event '{conflict_event_id}' has kind '{}', expected 'frontier.conflict_detected'",
2723 conflict_event.kind
2724 ));
2725 }
2726 if frontier.events.iter().any(|e| {
2727 e.kind == "frontier.conflict_resolved"
2728 && e.payload.get("conflict_event_id").and_then(Value::as_str)
2729 == Some(&conflict_event_id)
2730 }) {
2731 return Err(format!(
2732 "Conflict event '{conflict_event_id}' already has a recorded resolution"
2733 ));
2734 }
2735
2736 let mut payload = json!({
2737 "proposal_id": proposal.id,
2738 "conflict_event_id": conflict_event_id,
2739 "resolved_by": reviewer,
2740 "resolution_note": resolution_note,
2741 });
2742 if let Some(wpid) = &winning_proposal_id {
2743 payload["winning_proposal_id"] = json!(wpid);
2744 }
2745
2746 let frontier_id = frontier.frontier_id();
2747 Ok(events::new_frontier_conflict_resolved_event(
2748 &frontier_id,
2749 reviewer,
2750 "human",
2751 &proposal.reason,
2752 payload,
2753 proposal.caveats.clone(),
2754 ))
2755}
2756
2757fn extract_annotation_provenance(payload: &Value) -> Option<crate::bundle::ProvenanceRef> {
2762 let prov = payload.get("provenance")?;
2763 let parsed: crate::bundle::ProvenanceRef = serde_json::from_value(prov.clone()).ok()?;
2764 if parsed.has_identifier() {
2765 Some(parsed)
2766 } else {
2767 None
2768 }
2769}
2770
2771fn apply_confidence_revise(
2772 frontier: &mut Project,
2773 proposal: &StateProposal,
2774 reviewer: &str,
2775 _decision_reason: &str,
2776) -> Result<Vec<StateEvent>, String> {
2777 let finding_id = proposal.target.id.as_str();
2778 let idx = find_finding_index(frontier, finding_id)?;
2779 let now = Utc::now().to_rfc3339();
2780 let previous = frontier.findings[idx].confidence.score;
2781 let new_score = proposal
2782 .payload
2783 .get("confidence")
2784 .and_then(Value::as_f64)
2785 .ok_or("finding.confidence_revise proposal missing payload.confidence")?;
2786
2787 let cascade_threshold_crossed = previous >= 0.5 && new_score < 0.5;
2795
2796 let pre_cascade_hashes: std::collections::HashMap<String, String> = if cascade_threshold_crossed
2797 {
2798 frontier
2799 .findings
2800 .iter()
2801 .map(|finding| (finding.id.clone(), events::finding_hash(finding)))
2802 .collect()
2803 } else {
2804 std::collections::HashMap::new()
2805 };
2806
2807 let before_hash = events::finding_hash(&frontier.findings[idx]);
2808
2809 frontier.findings[idx].confidence.score = new_score;
2812 frontier.findings[idx].confidence.basis = format!(
2813 "expert revision from {:.3} to {:.3}: {}",
2814 previous, new_score, proposal.reason
2815 );
2816 frontier.findings[idx].confidence.method = ConfidenceMethod::ExpertJudgment;
2817 frontier.findings[idx].updated = Some(now.clone());
2818
2819 let cascade = if cascade_threshold_crossed {
2820 Some(propagate::propagate_correction(
2821 frontier,
2822 finding_id,
2823 PropagationAction::ConfidenceReduced { new_score },
2824 ))
2825 } else {
2826 None
2827 };
2828
2829 let after_hash = events::finding_hash(&frontier.findings[idx]);
2830
2831 let source_event = events::new_finding_event(events::FindingEventInput {
2832 kind: "finding.confidence_revised",
2833 finding_id,
2834 actor_id: reviewer,
2835 actor_type: "human",
2836 reason: &proposal.reason,
2837 before_hash: &before_hash,
2838 after_hash: &after_hash,
2839 payload: json!({
2840 "previous_score": previous,
2841 "new_score": new_score,
2842 "updated_at": now,
2843 "proposal_id": proposal.id,
2844 "cascade_fired": cascade_threshold_crossed,
2845 "affected": cascade.as_ref().map(|c| c.affected).unwrap_or(0),
2846 }),
2847 caveats: proposal.caveats.clone(),
2848 });
2849
2850 let source_event_id = source_event.id.clone();
2851 let mut emitted = vec![source_event];
2852
2853 if let Some(cascade) = cascade {
2854 for (depth_idx, level) in cascade.cascade.iter().enumerate() {
2858 let depth = (depth_idx as u32) + 1;
2859 for dep_id in level {
2860 let before = pre_cascade_hashes
2861 .get(dep_id)
2862 .cloned()
2863 .unwrap_or_else(|| events::NULL_HASH.to_string());
2864 let after = events::finding_hash_by_id(frontier, dep_id);
2865 emitted.push(events::new_finding_event(events::FindingEventInput {
2866 kind: "finding.dependency_invalidated",
2867 finding_id: dep_id,
2868 actor_id: reviewer,
2869 actor_type: "human",
2870 reason: &format!(
2871 "Upstream finding {finding_id} confidence reduced to {new_score:.2}; cascade depth {depth}"
2872 ),
2873 before_hash: &before,
2874 after_hash: &after,
2875 payload: json!({
2876 "upstream_finding_id": finding_id,
2877 "upstream_event_id": source_event_id,
2878 "depth": depth,
2879 "new_score": new_score,
2880 "previous_score": previous,
2881 "proposal_id": proposal.id,
2882 }),
2883 caveats: vec![],
2884 }));
2885 }
2886 }
2887 }
2888
2889 Ok(emitted)
2890}
2891
2892fn apply_reject(
2893 frontier: &mut Project,
2894 proposal: &StateProposal,
2895 reviewer: &str,
2896 _decision_reason: &str,
2897) -> Result<StateEvent, String> {
2898 let finding_id = proposal.target.id.as_str();
2899 let idx = find_finding_index(frontier, finding_id)?;
2900 let before_hash = events::finding_hash(&frontier.findings[idx]);
2901 frontier.findings[idx].flags.contested = true;
2902 let after_hash = events::finding_hash(&frontier.findings[idx]);
2903 Ok(events::new_finding_event(events::FindingEventInput {
2904 kind: "finding.rejected",
2905 finding_id,
2906 actor_id: reviewer,
2907 actor_type: "human",
2908 reason: &proposal.reason,
2909 before_hash: &before_hash,
2910 after_hash: &after_hash,
2911 payload: json!({
2912 "proposal_id": proposal.id,
2913 "status": "rejected",
2914 }),
2915 caveats: proposal.caveats.clone(),
2916 }))
2917}
2918
2919fn apply_retract(
2920 frontier: &mut Project,
2921 proposal: &StateProposal,
2922 reviewer: &str,
2923 _decision_reason: &str,
2924) -> Result<Vec<StateEvent>, String> {
2925 let finding_id = proposal.target.id.as_str();
2926 let idx = find_finding_index(frontier, finding_id)?;
2927 if frontier.findings[idx].flags.retracted {
2928 return Err(format!("Finding {finding_id} is already retracted"));
2929 }
2930 let pre_cascade_hashes: std::collections::HashMap<String, String> = frontier
2934 .findings
2935 .iter()
2936 .map(|finding| (finding.id.clone(), events::finding_hash(finding)))
2937 .collect();
2938
2939 let before_hash = events::finding_hash(&frontier.findings[idx]);
2940 let cascade =
2941 propagate::propagate_correction(frontier, finding_id, PropagationAction::Retracted);
2942 let after_hash = events::finding_hash_by_id(frontier, finding_id);
2943
2944 let source_event = events::new_finding_event(events::FindingEventInput {
2945 kind: "finding.retracted",
2946 finding_id,
2947 actor_id: reviewer,
2948 actor_type: "human",
2949 reason: &proposal.reason,
2950 before_hash: &before_hash,
2951 after_hash: &after_hash,
2952 payload: json!({
2953 "proposal_id": proposal.id,
2954 "affected": cascade.affected,
2955 "cascade": cascade.cascade,
2956 }),
2957 caveats: vec!["Retraction impact is simulated over declared dependency links.".to_string()],
2958 });
2959 let source_event_id = source_event.id.clone();
2960
2961 let mut emitted = vec![source_event];
2962
2963 for (depth_idx, level) in cascade.cascade.iter().enumerate() {
2968 let depth = (depth_idx as u32) + 1;
2969 for dep_id in level {
2970 let before = pre_cascade_hashes
2971 .get(dep_id)
2972 .cloned()
2973 .unwrap_or_else(|| events::NULL_HASH.to_string());
2974 let after = events::finding_hash_by_id(frontier, dep_id);
2975 emitted.push(events::new_finding_event(events::FindingEventInput {
2976 kind: "finding.dependency_invalidated",
2977 finding_id: dep_id,
2978 actor_id: reviewer,
2979 actor_type: "human",
2980 reason: &format!("Upstream finding {finding_id} retracted; cascade depth {depth}"),
2981 before_hash: &before,
2982 after_hash: &after,
2983 payload: json!({
2984 "upstream_finding_id": finding_id,
2985 "upstream_event_id": source_event_id,
2986 "depth": depth,
2987 "proposal_id": proposal.id,
2988 }),
2989 caveats: vec![],
2990 }));
2991 }
2992 }
2993
2994 Ok(emitted)
2995}
2996
2997fn find_finding_index(frontier: &Project, finding_id: &str) -> Result<usize, String> {
2998 frontier
2999 .findings
3000 .iter()
3001 .position(|finding| finding.id == finding_id)
3002 .ok_or_else(|| format!("Finding not found: {finding_id}"))
3003}
3004
3005fn apply_negative_result_assert(
3012 frontier: &mut Project,
3013 proposal: &StateProposal,
3014 reviewer: &str,
3015 _decision_reason: &str,
3016) -> Result<StateEvent, String> {
3017 let nr_value = proposal
3018 .payload
3019 .get("negative_result")
3020 .ok_or("negative_result.assert proposal missing payload.negative_result")?
3021 .clone();
3022 let nr: crate::bundle::NegativeResult = serde_json::from_value(nr_value.clone())
3023 .map_err(|e| format!("Invalid negative_result.assert payload: {e}"))?;
3024 if frontier.negative_results.iter().any(|n| n.id == nr.id) {
3025 return Err(format!(
3026 "Refusing to add duplicate negative_result with existing id {}",
3027 nr.id
3028 ));
3029 }
3030 let nr_id = nr.id.clone();
3031 frontier.negative_results.push(nr);
3032
3033 let mut event = StateEvent {
3034 schema: events::EVENT_SCHEMA.to_string(),
3035 id: String::new(),
3036 kind: events::EVENT_KIND_NEGATIVE_RESULT_ASSERTED.to_string(),
3037 target: StateTarget {
3038 r#type: "negative_result".to_string(),
3039 id: nr_id,
3040 },
3041 actor: StateActor {
3042 id: reviewer.to_string(),
3043 r#type: "human".to_string(),
3044 },
3045 timestamp: Utc::now().to_rfc3339(),
3046 reason: proposal.reason.clone(),
3047 before_hash: NULL_HASH.to_string(),
3048 after_hash: NULL_HASH.to_string(),
3049 payload: json!({
3050 "proposal_id": proposal.id,
3051 "negative_result": nr_value,
3052 }),
3053 caveats: proposal.caveats.clone(),
3054 signature: None,
3055 schema_artifact_id: None,
3056 };
3057 event.id = events::compute_event_id(&event);
3058 Ok(event)
3059}
3060
3061fn apply_trajectory_create(
3066 frontier: &mut Project,
3067 proposal: &StateProposal,
3068 reviewer: &str,
3069 _decision_reason: &str,
3070) -> Result<StateEvent, String> {
3071 let traj_value = proposal
3072 .payload
3073 .get("trajectory")
3074 .ok_or("trajectory.create proposal missing payload.trajectory")?
3075 .clone();
3076 let traj: crate::bundle::Trajectory = serde_json::from_value(traj_value.clone())
3077 .map_err(|e| format!("Invalid trajectory.create payload: {e}"))?;
3078 if frontier.trajectories.iter().any(|t| t.id == traj.id) {
3079 return Err(format!(
3080 "Refusing to add duplicate trajectory with existing id {}",
3081 traj.id
3082 ));
3083 }
3084 let traj_id = traj.id.clone();
3085 frontier.trajectories.push(traj);
3086
3087 let mut event = StateEvent {
3088 schema: events::EVENT_SCHEMA.to_string(),
3089 id: String::new(),
3090 kind: events::EVENT_KIND_TRAJECTORY_CREATED.to_string(),
3091 target: StateTarget {
3092 r#type: "trajectory".to_string(),
3093 id: traj_id,
3094 },
3095 actor: StateActor {
3096 id: reviewer.to_string(),
3097 r#type: "human".to_string(),
3098 },
3099 timestamp: Utc::now().to_rfc3339(),
3100 reason: proposal.reason.clone(),
3101 before_hash: NULL_HASH.to_string(),
3102 after_hash: NULL_HASH.to_string(),
3103 payload: json!({
3104 "proposal_id": proposal.id,
3105 "trajectory": traj_value,
3106 }),
3107 caveats: proposal.caveats.clone(),
3108 signature: None,
3109 schema_artifact_id: None,
3110 };
3111 event.id = events::compute_event_id(&event);
3112 Ok(event)
3113}
3114
3115fn apply_trajectory_step_append(
3120 frontier: &mut Project,
3121 proposal: &StateProposal,
3122 reviewer: &str,
3123 _decision_reason: &str,
3124) -> Result<StateEvent, String> {
3125 let parent_id = proposal.target.id.clone();
3126 let parent_idx = frontier
3127 .trajectories
3128 .iter()
3129 .position(|t| t.id == parent_id)
3130 .ok_or_else(|| format!("trajectory.step_append targets unknown trajectory {parent_id}"))?;
3131 let step_value = proposal
3132 .payload
3133 .get("step")
3134 .ok_or("trajectory.step_append proposal missing payload.step")?
3135 .clone();
3136 let step: crate::bundle::TrajectoryStep = serde_json::from_value(step_value.clone())
3137 .map_err(|e| format!("Invalid trajectory.step_append payload.step: {e}"))?;
3138 if frontier.trajectories[parent_idx]
3139 .steps
3140 .iter()
3141 .any(|s| s.id == step.id)
3142 {
3143 return Err(format!(
3144 "Refusing to add duplicate step with existing id {} on trajectory {}",
3145 step.id, parent_id
3146 ));
3147 }
3148 frontier.trajectories[parent_idx].steps.push(step);
3149
3150 let mut event = StateEvent {
3151 schema: events::EVENT_SCHEMA.to_string(),
3152 id: String::new(),
3153 kind: events::EVENT_KIND_TRAJECTORY_STEP_APPENDED.to_string(),
3154 target: StateTarget {
3155 r#type: "trajectory".to_string(),
3156 id: parent_id.clone(),
3157 },
3158 actor: StateActor {
3159 id: reviewer.to_string(),
3160 r#type: "human".to_string(),
3161 },
3162 timestamp: Utc::now().to_rfc3339(),
3163 reason: proposal.reason.clone(),
3164 before_hash: NULL_HASH.to_string(),
3165 after_hash: NULL_HASH.to_string(),
3166 payload: json!({
3167 "proposal_id": proposal.id,
3168 "parent_trajectory_id": parent_id,
3169 "step": step_value,
3170 }),
3171 caveats: proposal.caveats.clone(),
3172 signature: None,
3173 schema_artifact_id: None,
3174 };
3175 event.id = events::compute_event_id(&event);
3176 Ok(event)
3177}
3178
3179fn annotation_id(finding_id: &str, text: &str, author: &str, timestamp: &str) -> String {
3180 let hash = Sha256::digest(format!("{finding_id}|{text}|{author}|{timestamp}").as_bytes());
3181 format!("ann_{}", &hex::encode(hash)[..16])
3182}
3183
3184pub fn manifest_hash(path: &Path) -> Result<String, String> {
3185 let bytes = std::fs::read(path)
3186 .map_err(|e| format!("Failed to read manifest '{}': {e}", path.display()))?;
3187 Ok(hex::encode(Sha256::digest(bytes)))
3188}
3189
3190pub fn repo_proposals_dir(root: &Path) -> PathBuf {
3191 root.join(".vela/proposals")
3192}
3193
3194#[cfg(test)]
3195mod tests {
3196 use super::*;
3197 use crate::bundle::{
3198 Assertion, Conditions, Confidence, ConfidenceKind, ConfidenceMethod, Entity, Evidence,
3199 Extraction, Flags, Provenance,
3200 };
3201 use crate::project;
3202 use tempfile::TempDir;
3203
3204 fn finding(id: &str) -> FindingBundle {
3205 FindingBundle {
3206 id: id.to_string(),
3207 version: 1,
3208 previous_version: None,
3209 assertion: Assertion {
3210 text: "Test finding".to_string(),
3211 assertion_type: "mechanism".to_string(),
3212 entities: vec![Entity {
3213 name: "LRP1".to_string(),
3214 entity_type: "protein".to_string(),
3215 identifiers: serde_json::Map::new(),
3216 canonical_id: None,
3217 candidates: Vec::new(),
3218 aliases: Vec::new(),
3219 resolution_provenance: None,
3220 resolution_confidence: 1.0,
3221 resolution_method: None,
3222 species_context: None,
3223 needs_review: false,
3224 }],
3225 relation: None,
3226 direction: None,
3227 causal_claim: None,
3228 causal_evidence_grade: None,
3229 },
3230 evidence: Evidence {
3231 evidence_type: "experimental".to_string(),
3232 model_system: String::new(),
3233 species: None,
3234 method: "manual".to_string(),
3235 sample_size: None,
3236 effect_size: None,
3237 p_value: None,
3238 replicated: false,
3239 replication_count: None,
3240 evidence_spans: Vec::new(),
3241 },
3242 conditions: Conditions {
3243 text: "mouse".to_string(),
3244 species_verified: Vec::new(),
3245 species_unverified: Vec::new(),
3246 in_vitro: false,
3247 in_vivo: true,
3248 human_data: false,
3249 clinical_trial: false,
3250 concentration_range: None,
3251 duration: None,
3252 age_group: None,
3253 cell_type: None,
3254 },
3255 confidence: Confidence {
3256 kind: ConfidenceKind::FrontierEpistemic,
3257 score: 0.7,
3258 basis: "test".to_string(),
3259 method: ConfidenceMethod::ExpertJudgment,
3260 components: None,
3261 extraction_confidence: 1.0,
3262 },
3263 provenance: Provenance {
3264 source_type: "published_paper".to_string(),
3265 doi: None,
3266 pmid: None,
3267 pmc: None,
3268 openalex_id: None,
3269 url: None,
3270 title: "Test".to_string(),
3271 authors: Vec::new(),
3272 year: Some(2024),
3273 journal: None,
3274 license: None,
3275 publisher: None,
3276 funders: Vec::new(),
3277 extraction: Extraction::default(),
3278 review: None,
3279 citation_count: None,
3280 },
3281 flags: Flags {
3282 gap: false,
3283 negative_space: false,
3284 contested: false,
3285 retracted: false,
3286 declining: false,
3287 gravity_well: false,
3288 review_state: None,
3289 superseded: false,
3290 signature_threshold: None,
3291 jointly_accepted: false,
3292 },
3293 links: Vec::new(),
3294 annotations: Vec::new(),
3295 attachments: Vec::new(),
3296 created: "2026-04-23T00:00:00Z".to_string(),
3297 updated: None,
3298
3299 access_tier: crate::access_tier::AccessTier::Public,
3300 }
3301 }
3302
3303 #[test]
3304 fn pending_review_proposal_does_not_mutate_frontier() {
3305 let tmp = TempDir::new().unwrap();
3306 let path = tmp.path().join("frontier.json");
3307 let frontier = project::assemble("test", vec![finding("vf_test")], 0, 0, "test");
3308 repo::save_to_path(&path, &frontier).unwrap();
3309 let proposal = new_proposal(
3310 "finding.review",
3311 StateTarget {
3312 r#type: "finding".to_string(),
3313 id: "vf_test".to_string(),
3314 },
3315 "reviewer:test",
3316 "human",
3317 "Mouse-only evidence",
3318 json!({"status": "contested"}),
3319 Vec::new(),
3320 Vec::new(),
3321 );
3322 create_or_apply(&path, proposal, false).unwrap();
3323 let loaded = repo::load_from_path(&path).unwrap();
3324 assert_eq!(loaded.events.len(), 1); assert_eq!(loaded.proposals.len(), 1);
3326 assert!(!loaded.findings[0].flags.contested);
3327 }
3328
3329 #[test]
3330 fn applied_proposal_emits_event_and_stales_proof() {
3331 let tmp = TempDir::new().unwrap();
3332 let path = tmp.path().join("frontier.json");
3333 let mut frontier = project::assemble("test", vec![finding("vf_test")], 0, 0, "test");
3334 record_proof_export(
3335 &mut frontier,
3336 ProofPacketRecord {
3337 generated_at: "2026-04-23T00:00:00Z".to_string(),
3338 snapshot_hash: "a".repeat(64),
3339 event_log_hash: "b".repeat(64),
3340 packet_manifest_hash: "c".repeat(64),
3341 },
3342 );
3343 repo::save_to_path(&path, &frontier).unwrap();
3344 let proposal = new_proposal(
3345 "finding.review",
3346 StateTarget {
3347 r#type: "finding".to_string(),
3348 id: "vf_test".to_string(),
3349 },
3350 "reviewer:test",
3351 "human",
3352 "Mouse-only evidence",
3353 json!({"status": "contested"}),
3354 Vec::new(),
3355 Vec::new(),
3356 );
3357 create_or_apply(&path, proposal, true).unwrap();
3358 let loaded = repo::load_from_path(&path).unwrap();
3359 assert_eq!(loaded.events.len(), 2); assert!(loaded.findings[0].flags.contested);
3361 assert_eq!(loaded.proposals[0].status, "applied");
3362 assert_eq!(loaded.proof_state.latest_packet.status, "stale");
3363 }
3364
3365 #[test]
3366 fn preview_reports_changed_objects_and_event_kind_without_mutation() {
3367 let tmp = TempDir::new().unwrap();
3368 let path = tmp.path().join("frontier.json");
3369 let frontier = project::assemble("test", vec![finding("vf_test")], 0, 0, "test");
3370 repo::save_to_path(&path, &frontier).unwrap();
3371 let proposal = new_proposal(
3372 "finding.review",
3373 StateTarget {
3374 r#type: "finding".to_string(),
3375 id: "vf_test".to_string(),
3376 },
3377 "reviewer:test",
3378 "human",
3379 "Mouse-only evidence",
3380 json!({"status": "contested"}),
3381 Vec::new(),
3382 Vec::new(),
3383 );
3384 let proposal_id = create_or_apply(&path, proposal, false).unwrap().proposal_id;
3385
3386 let preview = preview_at_path(&path, &proposal_id, "reviewer:test").unwrap();
3387
3388 assert_eq!(preview.changed_findings, vec!["vf_test"]);
3389 assert!(preview.changed_artifacts.is_empty());
3390 assert_eq!(preview.event_kinds, vec!["finding.reviewed"]);
3391 assert_eq!(
3392 preview.new_event_ids,
3393 vec![preview.applied_event_id.clone()]
3394 );
3395 assert_eq!(preview.events_delta, 1);
3396 let loaded = repo::load_from_path(&path).unwrap();
3397 assert_eq!(loaded.events.len(), 1, "preview must not mutate events");
3398 assert_eq!(
3399 loaded.proposals[0].status, "pending_review",
3400 "preview must not accept the proposal"
3401 );
3402 }
3403
3404 #[test]
3405 fn pending_note_proposal_does_not_mutate_annotations() {
3406 let tmp = TempDir::new().unwrap();
3407 let path = tmp.path().join("frontier.json");
3408 let frontier = project::assemble("test", vec![finding("vf_test")], 0, 0, "test");
3409 repo::save_to_path(&path, &frontier).unwrap();
3410 let proposal = new_proposal(
3411 "finding.note",
3412 StateTarget {
3413 r#type: "finding".to_string(),
3414 id: "vf_test".to_string(),
3415 },
3416 "reviewer:test",
3417 "human",
3418 "Track mouse-only evidence",
3419 json!({"text": "Track mouse-only evidence"}),
3420 Vec::new(),
3421 Vec::new(),
3422 );
3423 create_or_apply(&path, proposal, false).unwrap();
3424 let loaded = repo::load_from_path(&path).unwrap();
3425 assert_eq!(loaded.events.len(), 1); assert_eq!(loaded.proposals.len(), 1);
3427 assert!(loaded.findings[0].annotations.is_empty());
3428 assert_eq!(loaded.proposals[0].kind, "finding.note");
3429 }
3430
3431 #[test]
3432 fn applied_note_emits_noted_event_and_stales_proof() {
3433 let tmp = TempDir::new().unwrap();
3434 let path = tmp.path().join("frontier.json");
3435 let mut frontier = project::assemble("test", vec![finding("vf_test")], 0, 0, "test");
3436 record_proof_export(
3437 &mut frontier,
3438 ProofPacketRecord {
3439 generated_at: "2026-04-23T00:00:00Z".to_string(),
3440 snapshot_hash: "a".repeat(64),
3441 event_log_hash: "b".repeat(64),
3442 packet_manifest_hash: "c".repeat(64),
3443 },
3444 );
3445 repo::save_to_path(&path, &frontier).unwrap();
3446 let proposal = new_proposal(
3447 "finding.note",
3448 StateTarget {
3449 r#type: "finding".to_string(),
3450 id: "vf_test".to_string(),
3451 },
3452 "reviewer:test",
3453 "human",
3454 "Track mouse-only evidence",
3455 json!({"text": "Track mouse-only evidence"}),
3456 Vec::new(),
3457 Vec::new(),
3458 );
3459 let result = create_or_apply(&path, proposal, true).unwrap();
3460 let loaded = repo::load_from_path(&path).unwrap();
3461 assert_eq!(loaded.events.len(), 2); assert_eq!(loaded.events[1].kind, "finding.noted");
3463 assert_eq!(loaded.findings[0].annotations.len(), 1);
3464 assert_eq!(loaded.proposals[0].status, "applied");
3465 assert_eq!(
3466 loaded.proposals[0].applied_event_id,
3467 result.applied_event_id
3468 );
3469 assert_eq!(loaded.proof_state.latest_packet.status, "stale");
3470 }
3471
3472 #[test]
3473 fn retract_emits_per_dependent_cascade_events() {
3474 let tmp = TempDir::new().unwrap();
3483 let path = tmp.path().join("frontier.json");
3484 let mut src = finding("vf_src");
3485 let mut dep1 = finding("vf_dep1");
3486 let mut dep2 = finding("vf_dep2");
3487 src.assertion.text = "src finding".into();
3488 dep1.assertion.text = "dep1 finding".into();
3489 dep2.assertion.text = "dep2 finding".into();
3490 dep1.add_link("vf_src", "supports", "");
3492 dep2.add_link("vf_dep1", "depends", "");
3493 let frontier = project::assemble("test", vec![src, dep1, dep2], 0, 0, "test");
3494 repo::save_to_path(&path, &frontier).unwrap();
3495
3496 let proposal = new_proposal(
3497 "finding.retract",
3498 StateTarget {
3499 r#type: "finding".to_string(),
3500 id: "vf_src".to_string(),
3501 },
3502 "reviewer:test",
3503 "human",
3504 "Source paper retracted by publisher",
3505 json!({}),
3506 Vec::new(),
3507 Vec::new(),
3508 );
3509 create_or_apply(&path, proposal, true).unwrap();
3510 let loaded = repo::load_from_path(&path).unwrap();
3511
3512 assert_eq!(loaded.events.len(), 4, "{:?}", loaded.events);
3514 let kinds: Vec<&str> = loaded.events.iter().map(|e| e.kind.as_str()).collect();
3515 assert_eq!(kinds[0], "frontier.created");
3516 assert_eq!(kinds[1], "finding.retracted");
3517 assert_eq!(kinds[2], "finding.dependency_invalidated");
3518 assert_eq!(kinds[3], "finding.dependency_invalidated");
3519
3520 let source_event_id = loaded.events[1].id.clone();
3521 let dep1_event = &loaded.events[2];
3522 let dep2_event = &loaded.events[3];
3523 assert_eq!(dep1_event.target.id, "vf_dep1");
3524 assert_eq!(dep2_event.target.id, "vf_dep2");
3525 assert_eq!(
3526 dep1_event
3527 .payload
3528 .get("upstream_event_id")
3529 .and_then(|v| v.as_str()),
3530 Some(source_event_id.as_str())
3531 );
3532 assert_eq!(
3533 dep1_event.payload.get("depth").and_then(|v| v.as_u64()),
3534 Some(1)
3535 );
3536 assert_eq!(
3537 dep2_event.payload.get("depth").and_then(|v| v.as_u64()),
3538 Some(2)
3539 );
3540 let dep1 = loaded.findings.iter().find(|f| f.id == "vf_dep1").unwrap();
3542 let dep2 = loaded.findings.iter().find(|f| f.id == "vf_dep2").unwrap();
3543 assert!(dep1.flags.contested);
3544 assert!(dep2.flags.contested);
3545 let src = loaded.findings.iter().find(|f| f.id == "vf_src").unwrap();
3546 assert!(src.flags.retracted);
3547 }
3548
3549 #[test]
3550 fn proposal_id_is_content_addressed_independent_of_created_at() {
3551 let target = StateTarget {
3555 r#type: "finding".to_string(),
3556 id: "vf_test".to_string(),
3557 };
3558 let mut a = new_proposal(
3559 "finding.review",
3560 target.clone(),
3561 "reviewer:test",
3562 "human",
3563 "scope narrower than claim",
3564 json!({"status": "contested"}),
3565 Vec::new(),
3566 Vec::new(),
3567 );
3568 let mut b = new_proposal(
3569 "finding.review",
3570 target,
3571 "reviewer:test",
3572 "human",
3573 "scope narrower than claim",
3574 json!({"status": "contested"}),
3575 Vec::new(),
3576 Vec::new(),
3577 );
3578 a.created_at = "2026-04-25T00:00:00Z".to_string();
3580 b.created_at = "2026-09-12T17:32:00Z".to_string();
3581 a.id = proposal_id(&a);
3582 b.id = proposal_id(&b);
3583 assert_eq!(a.id, b.id, "vpr_… must not depend on created_at");
3584 }
3585
3586 #[test]
3587 fn create_or_apply_is_idempotent_under_repeated_calls() {
3588 let tmp = TempDir::new().unwrap();
3592 let path = tmp.path().join("frontier.json");
3593 let frontier = project::assemble("test", vec![finding("vf_test")], 0, 0, "test");
3594 repo::save_to_path(&path, &frontier).unwrap();
3595
3596 let make = || {
3597 new_proposal(
3598 "finding.review",
3599 StateTarget {
3600 r#type: "finding".to_string(),
3601 id: "vf_test".to_string(),
3602 },
3603 "reviewer:test",
3604 "human",
3605 "agent retry test",
3606 json!({"status": "contested"}),
3607 Vec::new(),
3608 Vec::new(),
3609 )
3610 };
3611
3612 let first = create_or_apply(&path, make(), true).unwrap();
3613 let second = create_or_apply(&path, make(), true).unwrap();
3614
3615 assert_eq!(first.proposal_id, second.proposal_id);
3616 assert_eq!(first.applied_event_id, second.applied_event_id);
3617
3618 let loaded = repo::load_from_path(&path).unwrap();
3619 assert_eq!(
3620 loaded.proposals.len(),
3621 1,
3622 "second create_or_apply must not insert a duplicate proposal"
3623 );
3624 assert_eq!(
3626 loaded.events.len(),
3627 2,
3628 "second create_or_apply must not emit a duplicate event"
3629 );
3630 }
3631
3632 #[test]
3633 fn accepting_applied_proposal_is_idempotent() {
3634 let tmp = TempDir::new().unwrap();
3635 let path = tmp.path().join("frontier.json");
3636 let frontier = project::assemble("test", vec![finding("vf_test")], 0, 0, "test");
3637 repo::save_to_path(&path, &frontier).unwrap();
3638 let proposal = new_proposal(
3639 "finding.review",
3640 StateTarget {
3641 r#type: "finding".to_string(),
3642 id: "vf_test".to_string(),
3643 },
3644 "reviewer:test",
3645 "human",
3646 "Mouse-only evidence",
3647 json!({"status": "contested"}),
3648 Vec::new(),
3649 Vec::new(),
3650 );
3651 let created = create_or_apply(&path, proposal, true).unwrap();
3652 let first_event = created.applied_event_id.clone().unwrap();
3653 let second_event =
3654 accept_at_path(&path, &created.proposal_id, "reviewer:test", "same").unwrap();
3655 assert_eq!(first_event, second_event);
3656 }
3657
3658 #[test]
3659 fn v0_13_apply_materializes_source_records_inline() {
3660 let tmp = TempDir::new().unwrap();
3666 let path = tmp.path().join("frontier.json");
3667 let mut frontier = project::assemble("test", vec![], 0, 0, "test");
3668 repo::save_to_path(&path, &frontier).unwrap();
3669 let f = finding("vf_v013_inline_src");
3671 let proposal = new_proposal(
3672 "finding.add",
3673 StateTarget {
3674 r#type: "finding".to_string(),
3675 id: f.id.clone(),
3676 },
3677 "reviewer:test",
3678 "human",
3679 "Manual finding for v0.13 source-record materialization test",
3680 json!({"finding": f}),
3681 Vec::new(),
3682 Vec::new(),
3683 );
3684 create_or_apply(&path, proposal, true).unwrap();
3685 let loaded = repo::load_from_path(&path).unwrap();
3686 assert!(
3689 !loaded.sources.is_empty(),
3690 "v0.13: source_records should materialize inline at apply time"
3691 );
3692 assert!(
3693 !loaded.evidence_atoms.is_empty(),
3694 "v0.13: evidence_atoms should materialize inline at apply time"
3695 );
3696 assert!(
3697 !loaded.condition_records.is_empty(),
3698 "v0.13: condition_records should materialize inline at apply time"
3699 );
3700 assert_eq!(loaded.stats.source_count, loaded.sources.len());
3702 let _ = &mut frontier;
3704 }
3705
3706 fn make_supersede_payload(old_id: &str, new_text: &str) -> (FindingBundle, Value) {
3707 let mut new_finding = finding("vf_supersede_new");
3708 new_finding.assertion.text = new_text.to_string();
3709 new_finding.id = format!(
3713 "vf_{:0>16}",
3714 old_id
3715 .bytes()
3716 .fold(0u64, |acc, b| acc.wrapping_add(b as u64))
3717 );
3718 let payload = json!({"new_finding": new_finding.clone()});
3719 (new_finding, payload)
3720 }
3721
3722 #[test]
3723 fn v0_14_supersede_creates_new_finding_and_marks_old() {
3724 let tmp = TempDir::new().unwrap();
3725 let path = tmp.path().join("frontier.json");
3726 let mut frontier = project::assemble("test", vec![finding("vf_old")], 0, 0, "test");
3727 repo::save_to_path(&path, &frontier).unwrap();
3728 let (new_finding, payload) = make_supersede_payload("vf_old", "Newer claim");
3729 let proposal = new_proposal(
3730 "finding.supersede",
3731 StateTarget {
3732 r#type: "finding".to_string(),
3733 id: "vf_old".to_string(),
3734 },
3735 "reviewer:test",
3736 "human",
3737 "Newer evidence updates the wording",
3738 payload,
3739 Vec::new(),
3740 Vec::new(),
3741 );
3742 let result = create_or_apply(&path, proposal, true).unwrap();
3743 assert!(result.applied_event_id.is_some());
3744 let loaded = repo::load_from_path(&path).unwrap();
3745 let old = loaded.findings.iter().find(|f| f.id == "vf_old").unwrap();
3747 assert!(
3748 old.flags.superseded,
3749 "old finding should be flagged superseded"
3750 );
3751 let new_f = loaded
3753 .findings
3754 .iter()
3755 .find(|f| f.id == new_finding.id)
3756 .expect("new finding should be in frontier");
3757 assert!(
3758 new_f
3759 .links
3760 .iter()
3761 .any(|l| l.target == "vf_old" && l.link_type == "supersedes"),
3762 "new finding should have an auto-injected supersedes link to old finding"
3763 );
3764 let supersede_event = loaded
3766 .events
3767 .iter()
3768 .find(|e| e.kind == "finding.superseded")
3769 .expect("a finding.superseded event should be emitted");
3770 assert_eq!(supersede_event.target.id, "vf_old");
3771 assert_eq!(
3772 supersede_event.payload["new_finding_id"].as_str(),
3773 Some(new_finding.id.as_str())
3774 );
3775 let _ = &mut frontier;
3777 }
3778
3779 #[test]
3780 fn v0_14_supersede_refuses_already_superseded() {
3781 let tmp = TempDir::new().unwrap();
3782 let path = tmp.path().join("frontier.json");
3783 let mut old = finding("vf_already_done");
3784 old.flags.superseded = true;
3785 let frontier = project::assemble("test", vec![old], 0, 0, "test");
3786 repo::save_to_path(&path, &frontier).unwrap();
3787 let (_, payload) = make_supersede_payload("vf_already_done", "Newer wording");
3788 let proposal = new_proposal(
3789 "finding.supersede",
3790 StateTarget {
3791 r#type: "finding".to_string(),
3792 id: "vf_already_done".to_string(),
3793 },
3794 "reviewer:test",
3795 "human",
3796 "Attempt to double-supersede",
3797 payload,
3798 Vec::new(),
3799 Vec::new(),
3800 );
3801 let result = create_or_apply(&path, proposal, true);
3802 assert!(
3803 result.is_err(),
3804 "double-supersede should be refused; got {result:?}"
3805 );
3806 }
3807
3808 #[test]
3809 fn v0_14_supersede_refuses_same_content_address() {
3810 let tmp = TempDir::new().unwrap();
3811 let path = tmp.path().join("frontier.json");
3812 let frontier = project::assemble("test", vec![finding("vf_same")], 0, 0, "test");
3813 repo::save_to_path(&path, &frontier).unwrap();
3814 let mut new_finding = finding("vf_same");
3816 new_finding.assertion.text = "Different text but reused id".to_string();
3817 let proposal = new_proposal(
3818 "finding.supersede",
3819 StateTarget {
3820 r#type: "finding".to_string(),
3821 id: "vf_same".to_string(),
3822 },
3823 "reviewer:test",
3824 "human",
3825 "Same id, should fail",
3826 json!({"new_finding": new_finding}),
3827 Vec::new(),
3828 Vec::new(),
3829 );
3830 let result = create_or_apply(&path, proposal, true);
3831 assert!(
3832 result.is_err(),
3833 "supersede with same content address should be refused; got {result:?}"
3834 );
3835 }
3836
3837 #[test]
3843 fn agent_run_none_skips_serialization() {
3844 let p = new_proposal(
3845 "finding.add",
3846 StateTarget {
3847 r#type: "finding".to_string(),
3848 id: "vf_test0000000000".to_string(),
3849 },
3850 "reviewer:will-blair",
3851 "human",
3852 "test",
3853 json!({}),
3854 Vec::new(),
3855 Vec::new(),
3856 );
3857 let bytes = canonical::to_canonical_bytes(&p).unwrap();
3858 let s = std::str::from_utf8(&bytes).unwrap();
3859 assert!(
3860 !s.contains("agent_run"),
3861 "proposal without agent_run leaked the field into canonical JSON: {s}"
3862 );
3863 }
3864
3865 #[test]
3870 fn agent_run_does_not_change_proposal_id() {
3871 let bare = new_proposal(
3872 "finding.add",
3873 StateTarget {
3874 r#type: "finding".to_string(),
3875 id: "vf_test0000000000".to_string(),
3876 },
3877 "agent:literature-scout",
3878 "agent",
3879 "scout extracted this from paper_014",
3880 json!({}),
3881 vec!["src_paper_014".to_string()],
3882 Vec::new(),
3883 );
3884 let id_bare = bare.id.clone();
3885
3886 let mut with_run = bare.clone();
3887 with_run.agent_run = Some(AgentRun {
3888 agent: "literature-scout".to_string(),
3889 model: "claude-opus-4-7".to_string(),
3890 run_id: "vrun_abc1234567890def".to_string(),
3891 started_at: "2026-04-26T01:23:45Z".to_string(),
3892 finished_at: Some("2026-04-26T01:24:10Z".to_string()),
3893 context: BTreeMap::from([
3894 ("input_folder".to_string(), "./papers".to_string()),
3895 ("pdf_count".to_string(), "12".to_string()),
3896 ]),
3897 tool_calls: Vec::new(),
3898 permissions: None,
3899 });
3900 let id_with_run = proposal_id(&with_run);
3901 assert_eq!(
3902 id_bare, id_with_run,
3903 "agent_run leaked into proposal_id preimage"
3904 );
3905 }
3906
3907 #[test]
3913 fn agent_run_empty_tool_calls_and_permissions_skip_serialization() {
3914 let p = new_proposal(
3915 "finding.add",
3916 StateTarget {
3917 r#type: "finding".to_string(),
3918 id: "vf_test0000000000".to_string(),
3919 },
3920 "agent:scout",
3921 "agent",
3922 "test",
3923 json!({}),
3924 Vec::new(),
3925 Vec::new(),
3926 );
3927 let mut with_run = p.clone();
3928 with_run.agent_run = Some(AgentRun {
3929 agent: "scout".to_string(),
3930 model: "claude-opus-4-7".to_string(),
3931 run_id: "vrun_x".to_string(),
3932 started_at: "2026-04-26T01:00:00Z".to_string(),
3933 finished_at: None,
3934 context: BTreeMap::new(),
3935 tool_calls: Vec::new(),
3936 permissions: None,
3937 });
3938 let bytes = canonical::to_canonical_bytes(&with_run).unwrap();
3939 let s = std::str::from_utf8(&bytes).unwrap();
3940 assert!(
3941 !s.contains("tool_calls"),
3942 "empty tool_calls leaked into canonical JSON: {s}"
3943 );
3944 assert!(
3945 !s.contains("permissions"),
3946 "empty permissions leaked into canonical JSON: {s}"
3947 );
3948 }
3949
3950 #[test]
3954 fn agent_run_populated_tool_calls_and_permissions_roundtrip() {
3955 let mut p = new_proposal(
3956 "finding.add",
3957 StateTarget {
3958 r#type: "finding".to_string(),
3959 id: "vf_test0000000000".to_string(),
3960 },
3961 "agent:scout",
3962 "agent",
3963 "test",
3964 json!({}),
3965 Vec::new(),
3966 Vec::new(),
3967 );
3968 p.agent_run = Some(AgentRun {
3969 agent: "scout".to_string(),
3970 model: "claude-opus-4-7".to_string(),
3971 run_id: "vrun_x".to_string(),
3972 started_at: "2026-04-26T01:00:00Z".to_string(),
3973 finished_at: None,
3974 context: BTreeMap::new(),
3975 tool_calls: vec![
3976 ToolCallTrace {
3977 tool: "pubmed_search".to_string(),
3978 input_sha256: "a".repeat(64),
3979 output_sha256: Some("b".repeat(64)),
3980 at: "2026-04-26T01:00:05Z".to_string(),
3981 duration_ms: Some(842),
3982 status: "ok".to_string(),
3983 error_message: String::new(),
3984 },
3985 ToolCallTrace {
3989 tool: "arxiv_fetch".to_string(),
3990 input_sha256: "c".repeat(64),
3991 output_sha256: None,
3992 at: "2026-04-26T01:00:18Z".to_string(),
3993 duration_ms: Some(1200),
3994 status: "error".to_string(),
3995 error_message: "HTTP 503 from arxiv.org; retry budget exhausted".to_string(),
3996 },
3997 ],
3998 permissions: Some(PermissionState {
3999 data_access: vec!["pubmed:".to_string(), "frontier:vfr_bd91".to_string()],
4000 tool_access: vec!["pubmed_search".to_string(), "arxiv_fetch".to_string()],
4001 note: "read-only access to BBB Flagship".to_string(),
4002 }),
4003 });
4004 let bytes = canonical::to_canonical_bytes(&p).unwrap();
4005 let json: serde_json::Value =
4006 serde_json::from_slice(&bytes).expect("canonical bytes round-trip");
4007 assert_eq!(
4008 json["agent_run"]["tool_calls"][0]["tool"], "pubmed_search",
4009 "tool_calls did not survive the round trip: {json}"
4010 );
4011 assert_eq!(
4012 json["agent_run"]["permissions"]["data_access"][0], "pubmed:",
4013 "permissions did not survive the round trip: {json}"
4014 );
4015 assert_eq!(
4019 json["agent_run"]["tool_calls"][1]["status"], "error",
4020 "failed tool call status did not survive: {json}"
4021 );
4022 assert_eq!(
4023 json["agent_run"]["tool_calls"][1]["error_message"],
4024 "HTTP 503 from arxiv.org; retry budget exhausted",
4025 "error_message did not survive the round trip: {json}"
4026 );
4027 let raw = std::str::from_utf8(&bytes).unwrap();
4030 let okay_call_block_end = raw.find("pubmed_search").unwrap();
4031 let until_first_call = &raw[..okay_call_block_end + 200];
4032 assert!(
4033 !until_first_call.contains("\"error_message\":\"\""),
4034 "successful tool call leaked an empty error_message: {until_first_call}"
4035 );
4036 }
4037}