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