1use std::collections::{BTreeMap, BTreeSet};
10use std::fs;
11use std::path::Path;
12
13use serde::{Deserialize, Serialize};
14use serde_json::{Value, json};
15use sha2::{Digest, Sha256};
16
17use crate::access_tier::AccessTier;
18use crate::bundle::{
19 Artifact, Assertion, Author, Conditions, Confidence, ConfidenceKind, ConfidenceMethod, Entity,
20 Evidence, Extraction, FindingBundle, Flags, Provenance, Review, valid_artifact_kind,
21};
22use crate::events::StateTarget;
23use crate::project;
24use crate::proposals::{self, AgentRun, StateProposal};
25
26pub const ARTIFACT_PACKET_SCHEMA: &str = "carina.artifact_packet.v0.1";
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
29pub struct ArtifactPacket {
30 pub schema: String,
31 pub packet_id: String,
32 pub producer: PacketProducer,
33 pub topic: String,
34 pub created_at: String,
35 #[serde(default)]
36 pub artifacts: Vec<PacketArtifact>,
37 #[serde(default)]
38 pub candidate_claims: Vec<PacketCandidateClaim>,
39 #[serde(default)]
40 pub open_needs: Vec<PacketOpenNeed>,
41 #[serde(default)]
42 pub caveats: Vec<String>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
46pub struct PacketProducer {
47 pub kind: String,
48 pub id: String,
49 pub name: String,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
53pub struct PacketArtifact {
54 pub id: String,
55 #[serde(alias = "artifact_type")]
56 pub kind: String,
57 #[serde(alias = "name")]
58 pub title: String,
59 pub locator: String,
60 pub content_hash: String,
61 #[serde(default)]
62 pub parents: Vec<String>,
63 #[serde(default)]
64 pub metadata: BTreeMap<String, Value>,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
68pub struct PacketCandidateClaim {
69 pub id: String,
70 pub assertion: String,
71 pub assertion_type: String,
72 #[serde(default)]
73 pub evidence_artifact_ids: Vec<String>,
74 #[serde(default)]
75 pub source_refs: Vec<String>,
76 #[serde(default)]
77 pub conditions: Vec<String>,
78 pub confidence: f64,
79 #[serde(default)]
80 pub caveats: Vec<String>,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
84pub struct PacketOpenNeed {
85 pub id: String,
86 pub question: String,
87 pub rationale: String,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
91pub struct ImportIdempotency {
92 pub packet_hash: String,
93 pub duplicate_packet: bool,
94 #[serde(default)]
95 pub skipped_existing_proposals: Vec<String>,
96 #[serde(default)]
97 pub skipped_existing_artifacts: Vec<String>,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
101pub struct ArtifactToStateReport {
102 pub ok: bool,
103 pub command: String,
104 pub packet_id: String,
105 pub frontier: String,
106 pub artifact_proposals: usize,
107 pub finding_proposals: usize,
108 pub gap_proposals: usize,
109 pub applied_artifact_events: usize,
110 pub pending_truth_proposals: usize,
111 pub proposal_ids: Vec<String>,
112 pub applied_event_ids: Vec<String>,
113 pub idempotency: ImportIdempotency,
114 pub trusted_state_effect: String,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
118pub struct BridgeKitValidationReport {
119 pub ok: bool,
120 pub command: String,
121 pub source: String,
122 pub packet_count: usize,
123 pub valid_packet_count: usize,
124 pub invalid_packet_count: usize,
125 #[serde(default)]
126 pub errors: Vec<String>,
127 pub packets: Vec<BridgeKitPacketReport>,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
131pub struct BridgeKitPacketReport {
132 pub path: String,
133 pub ok: bool,
134 pub packet_id: Option<String>,
135 pub producer_id: Option<String>,
136 pub artifact_count: usize,
137 pub candidate_claim_count: usize,
138 pub open_need_count: usize,
139 #[serde(default)]
140 pub errors: Vec<String>,
141}
142
143impl ArtifactPacket {
144 pub fn from_path(path: &Path) -> Result<Self, String> {
145 let bytes = fs::read(path).map_err(|e| format!("read {}: {e}", path.display()))?;
146 serde_json::from_slice(&bytes)
147 .map_err(|e| format!("parse artifact packet {}: {e}", path.display()))
148 }
149
150 pub fn validate(self) -> Result<Self, String> {
151 if self.schema != ARTIFACT_PACKET_SCHEMA {
152 return Err(format!(
153 "Unsupported artifact packet schema '{}'",
154 self.schema
155 ));
156 }
157 if !self.packet_id.starts_with("cap_") {
158 return Err("packet_id must start with cap_".to_string());
159 }
160 if self.producer.id.trim().is_empty() {
161 return Err("producer.id must be non-empty".to_string());
162 }
163 if self.topic.trim().is_empty() {
164 return Err("topic must be non-empty".to_string());
165 }
166 if self.created_at.trim().is_empty() {
167 return Err("created_at must be non-empty".to_string());
168 }
169 if self.artifacts.is_empty() {
170 return Err("artifact packet must include at least one artifact".to_string());
171 }
172
173 let mut artifact_ids = BTreeSet::new();
174 for artifact in &self.artifacts {
175 if !artifact_ids.insert(artifact.id.clone()) {
176 return Err(format!("duplicate artifact id {}", artifact.id));
177 }
178 if artifact.id.trim().is_empty() {
179 return Err("artifact.id must be non-empty".to_string());
180 }
181 if !valid_artifact_kind(&artifact.kind) {
182 return Err(format!(
183 "artifact {} has unsupported kind '{}'",
184 artifact.id, artifact.kind
185 ));
186 }
187 if artifact.title.trim().is_empty() {
188 return Err(format!("artifact {} title must be non-empty", artifact.id));
189 }
190 if artifact.locator.trim().is_empty() {
191 return Err(format!(
192 "artifact {} locator must be non-empty",
193 artifact.id
194 ));
195 }
196 normalize_packet_hash(&artifact.content_hash)?;
197 }
198
199 for artifact in &self.artifacts {
200 for parent in &artifact.parents {
201 if !artifact_ids.contains(parent) {
202 return Err(format!(
203 "artifact {} references unknown parent {}",
204 artifact.id, parent
205 ));
206 }
207 if parent == &artifact.id {
208 return Err(format!("artifact {} cannot parent itself", artifact.id));
209 }
210 }
211 }
212
213 for claim in &self.candidate_claims {
214 if claim.id.trim().is_empty() {
215 return Err("candidate_claim.id must be non-empty".to_string());
216 }
217 if claim.assertion.trim().is_empty() {
218 return Err(format!("candidate claim {} assertion is empty", claim.id));
219 }
220 if !(0.0..=1.0).contains(&claim.confidence) {
221 return Err(format!(
222 "candidate claim {} confidence must be between 0.0 and 1.0",
223 claim.id
224 ));
225 }
226 if claim.evidence_artifact_ids.is_empty() {
227 return Err(format!(
228 "candidate claim {} must reference at least one artifact",
229 claim.id
230 ));
231 }
232 for artifact_id in &claim.evidence_artifact_ids {
233 if !artifact_ids.contains(artifact_id) {
234 return Err(format!(
235 "candidate claim {} references unknown artifact {}",
236 claim.id, artifact_id
237 ));
238 }
239 }
240 }
241
242 for need in &self.open_needs {
243 if need.id.trim().is_empty() {
244 return Err("open_need.id must be non-empty".to_string());
245 }
246 if need.question.trim().is_empty() || need.rationale.trim().is_empty() {
247 return Err(format!(
248 "open need {} requires question and rationale",
249 need.id
250 ));
251 }
252 }
253
254 Ok(self)
255 }
256}
257
258pub fn validate_bridge_kit_path(path: &Path) -> BridgeKitValidationReport {
259 let mut errors = Vec::new();
260 let mut packet_paths = Vec::new();
261
262 if path.is_dir() {
263 match fs::read_dir(path) {
264 Ok(entries) => {
265 for entry in entries.flatten() {
266 let candidate = entry.path();
267 if candidate.extension().and_then(|ext| ext.to_str()) == Some("json") {
268 packet_paths.push(candidate);
269 }
270 }
271 packet_paths.sort();
272 if packet_paths.is_empty() {
273 errors.push(format!("no JSON packet files found in {}", path.display()));
274 }
275 }
276 Err(e) => errors.push(format!("read directory {}: {e}", path.display())),
277 }
278 } else {
279 packet_paths.push(path.to_path_buf());
280 }
281
282 let packets = packet_paths
283 .iter()
284 .map(|packet_path| validate_bridge_kit_packet(packet_path))
285 .collect::<Vec<_>>();
286 let packet_count = packets.len();
287 let valid_packet_count = packets.iter().filter(|packet| packet.ok).count();
288 let invalid_packet_count = packets.iter().filter(|packet| !packet.ok).count();
289 let ok = errors.is_empty() && packet_count > 0 && invalid_packet_count == 0;
290
291 BridgeKitValidationReport {
292 ok,
293 command: "bridge-kit.validate".to_string(),
294 source: path.display().to_string(),
295 packet_count,
296 valid_packet_count,
297 invalid_packet_count,
298 errors,
299 packets,
300 }
301}
302
303fn validate_bridge_kit_packet(path: &Path) -> BridgeKitPacketReport {
304 match ArtifactPacket::from_path(path).and_then(|packet| packet.validate()) {
305 Ok(packet) => BridgeKitPacketReport {
306 path: path.display().to_string(),
307 ok: true,
308 packet_id: Some(packet.packet_id),
309 producer_id: Some(packet.producer.id),
310 artifact_count: packet.artifacts.len(),
311 candidate_claim_count: packet.candidate_claims.len(),
312 open_need_count: packet.open_needs.len(),
313 errors: Vec::new(),
314 },
315 Err(e) => BridgeKitPacketReport {
316 path: path.display().to_string(),
317 ok: false,
318 packet_id: None,
319 producer_id: None,
320 artifact_count: 0,
321 candidate_claim_count: 0,
322 open_need_count: 0,
323 errors: vec![e],
324 },
325 }
326}
327
328pub fn import_packet_at_path(
329 frontier_path: &Path,
330 packet_path: &Path,
331 actor_id: &str,
332 apply_artifacts: bool,
333) -> Result<ArtifactToStateReport, String> {
334 if actor_id.trim().is_empty() {
335 return Err("actor must be non-empty".to_string());
336 }
337 let packet = ArtifactPacket::from_path(packet_path)?.validate()?;
338 let packet_hash = packet_hash(&packet);
339 let before_frontier = crate::repo::load_from_path(frontier_path)?;
340 let existing_proposals = before_frontier
341 .proposals
342 .iter()
343 .map(|proposal| proposal.id.clone())
344 .collect::<BTreeSet<_>>();
345 let existing_artifacts = before_frontier
346 .artifacts
347 .iter()
348 .map(|artifact| artifact.id.clone())
349 .collect::<BTreeSet<_>>();
350 let mut proposal_ids = Vec::new();
351 let mut applied_event_ids = Vec::new();
352 let mut skipped_existing_proposals = Vec::new();
353 let mut skipped_existing_artifacts = Vec::new();
354 let mut artifact_proposals = 0usize;
355 let mut finding_proposals = 0usize;
356 let mut gap_proposals = 0usize;
357 let mut artifact_targets: BTreeMap<String, Vec<String>> = BTreeMap::new();
358 if !apply_artifacts {
359 for claim in &packet.candidate_claims {
360 let finding_id = claim_to_finding(&packet, claim, false)?.id;
361 for artifact_id in &claim.evidence_artifact_ids {
362 artifact_targets
363 .entry(artifact_id.clone())
364 .or_default()
365 .push(finding_id.clone());
366 }
367 }
368 }
369
370 for artifact in &packet.artifacts {
371 let target_findings = artifact_targets
372 .get(&artifact.id)
373 .cloned()
374 .unwrap_or_else(|| artifact_metadata_target_findings(artifact));
375 let proposal = artifact_proposal(&packet, artifact, actor_id, &target_findings)?;
376 if existing_proposals.contains(&proposal.id) {
377 skipped_existing_proposals.push(proposal.id.clone());
378 }
379 if existing_artifacts.contains(&proposal.target.id) {
380 skipped_existing_artifacts.push(proposal.target.id.clone());
381 }
382 artifact_proposals += 1;
383 let result = proposals::create_or_apply(frontier_path, proposal, apply_artifacts)?;
384 proposal_ids.push(result.proposal_id);
385 if let Some(event_id) = result.applied_event_id {
386 applied_event_ids.push(event_id);
387 }
388 }
389
390 for claim in &packet.candidate_claims {
391 let proposal = claim_proposal(&packet, claim, actor_id)?;
392 if existing_proposals.contains(&proposal.id) {
393 skipped_existing_proposals.push(proposal.id.clone());
394 }
395 finding_proposals += 1;
396 let result = proposals::create_or_apply(frontier_path, proposal, false)?;
397 proposal_ids.push(result.proposal_id);
398 }
399
400 for need in &packet.open_needs {
401 let proposal = need_proposal(&packet, need, actor_id)?;
402 if existing_proposals.contains(&proposal.id) {
403 skipped_existing_proposals.push(proposal.id.clone());
404 }
405 gap_proposals += 1;
406 let result = proposals::create_or_apply(frontier_path, proposal, false)?;
407 proposal_ids.push(result.proposal_id);
408 }
409
410 let frontier = crate::repo::load_from_path(frontier_path)?;
411 skipped_existing_proposals.sort();
412 skipped_existing_proposals.dedup();
413 skipped_existing_artifacts.sort();
414 skipped_existing_artifacts.dedup();
415 let generated_proposals = artifact_proposals + finding_proposals + gap_proposals;
416 let trusted_state_effect = if applied_event_ids.is_empty() {
417 "none"
418 } else {
419 "artifact_only"
420 }
421 .to_string();
422 Ok(ArtifactToStateReport {
423 ok: true,
424 command: "artifact-to-state".to_string(),
425 packet_id: packet.packet_id,
426 frontier: frontier.project.name,
427 artifact_proposals,
428 finding_proposals,
429 gap_proposals,
430 applied_artifact_events: applied_event_ids.len(),
431 pending_truth_proposals: finding_proposals + gap_proposals,
432 proposal_ids,
433 applied_event_ids,
434 idempotency: ImportIdempotency {
435 packet_hash,
436 duplicate_packet: generated_proposals > 0
437 && skipped_existing_proposals.len() == generated_proposals,
438 skipped_existing_proposals,
439 skipped_existing_artifacts,
440 },
441 trusted_state_effect,
442 })
443}
444
445fn packet_hash(packet: &ArtifactPacket) -> String {
446 let bytes = crate::canonical::to_canonical_bytes(packet).unwrap_or_default();
447 format!("sha256:{}", hex::encode(Sha256::digest(bytes)))
448}
449
450fn artifact_proposal(
451 packet: &ArtifactPacket,
452 artifact: &PacketArtifact,
453 actor_id: &str,
454 target_findings: &[String],
455) -> Result<StateProposal, String> {
456 let vela_artifact = to_vela_artifact(packet, artifact, target_findings)?;
457 let artifact_id = vela_artifact.id.clone();
458 let mut proposal = proposals::new_proposal(
459 "artifact.assert",
460 StateTarget {
461 r#type: "artifact".to_string(),
462 id: artifact_id,
463 },
464 actor_id,
465 actor_type(&packet.producer.kind),
466 format!(
467 "Import artifact {} from artifact packet {}",
468 artifact.id, packet.packet_id
469 ),
470 json!({
471 "artifact": vela_artifact,
472 "artifact_packet": packet_reference(packet),
473 "external_artifact_id": artifact.id,
474 "parent_artifact_ids": artifact.parents,
475 }),
476 source_refs_for_artifact(packet, artifact),
477 packet.caveats.clone(),
478 );
479 proposal.agent_run = Some(agent_run(packet));
480 Ok(proposal)
481}
482
483fn claim_proposal(
484 packet: &ArtifactPacket,
485 claim: &PacketCandidateClaim,
486 actor_id: &str,
487) -> Result<StateProposal, String> {
488 let finding = claim_to_finding(packet, claim, false)?;
489 let finding_id = finding.id.clone();
490 let mut caveats = packet.caveats.clone();
491 caveats.extend(claim.caveats.clone());
492 caveats.push("Agent output is source material until reviewer acceptance.".to_string());
493 let mut source_refs = claim.source_refs.clone();
494 source_refs.push(format!("artifact_packet:{}", packet.packet_id));
495 source_refs.extend(
496 claim
497 .evidence_artifact_ids
498 .iter()
499 .map(|id| format!("packet_artifact:{id}")),
500 );
501 source_refs.sort();
502 source_refs.dedup();
503
504 let mut proposal = proposals::new_proposal(
505 "finding.add",
506 StateTarget {
507 r#type: "finding".to_string(),
508 id: finding_id,
509 },
510 actor_id,
511 actor_type(&packet.producer.kind),
512 format!(
513 "Candidate claim {} imported from artifact packet {}",
514 claim.id, packet.packet_id
515 ),
516 json!({
517 "finding": finding,
518 "artifact_packet": packet_reference(packet),
519 "candidate_claim_id": claim.id,
520 "evidence_artifact_ids": claim.evidence_artifact_ids,
521 }),
522 source_refs,
523 caveats,
524 );
525 proposal.agent_run = Some(agent_run(packet));
526 Ok(proposal)
527}
528
529fn need_proposal(
530 packet: &ArtifactPacket,
531 need: &PacketOpenNeed,
532 actor_id: &str,
533) -> Result<StateProposal, String> {
534 let finding = need_to_gap_finding(packet, need)?;
535 let finding_id = finding.id.clone();
536 let mut caveats = packet.caveats.clone();
537 caveats
538 .push("Open need imported as a gap proposal; it is not an answered finding.".to_string());
539 let mut proposal = proposals::new_proposal(
540 "finding.add",
541 StateTarget {
542 r#type: "finding".to_string(),
543 id: finding_id,
544 },
545 actor_id,
546 actor_type(&packet.producer.kind),
547 format!(
548 "Open need {} imported from artifact packet {}",
549 need.id, packet.packet_id
550 ),
551 json!({
552 "finding": finding,
553 "artifact_packet": packet_reference(packet),
554 "open_need_id": need.id,
555 }),
556 vec![format!("artifact_packet:{}", packet.packet_id)],
557 caveats,
558 );
559 proposal.agent_run = Some(agent_run(packet));
560 Ok(proposal)
561}
562
563fn to_vela_artifact(
564 packet: &ArtifactPacket,
565 artifact: &PacketArtifact,
566 target_findings: &[String],
567) -> Result<Artifact, String> {
568 let mut metadata = artifact.metadata.clone();
569 metadata.insert("external_artifact_id".to_string(), json!(artifact.id));
570 metadata.insert("artifact_packet_id".to_string(), json!(packet.packet_id));
571 metadata.insert("producer_agent".to_string(), json!(packet.producer.id));
572 metadata.insert("parent_artifact_ids".to_string(), json!(artifact.parents));
573 metadata.insert("topic".to_string(), json!(packet.topic));
574
575 let mut artifact = Artifact::new(
576 artifact.kind.clone(),
577 artifact.title.clone(),
578 artifact.content_hash.clone(),
579 None,
580 Some("application/json".to_string()),
581 "remote",
582 Some(artifact.locator.clone()),
583 Some(artifact.locator.clone()),
584 Some("public source locator; no restricted bytes deposited".to_string()),
585 target_findings.to_vec(),
586 packet_provenance(
587 packet,
588 &artifact.title,
589 Some(artifact.locator.clone()),
590 source_type_for_artifact(&artifact.kind),
591 ),
592 metadata,
593 AccessTier::Public,
594 )?;
595 artifact.created = packet.created_at.clone();
596 Ok(artifact)
597}
598
599fn claim_to_finding(
600 packet: &ArtifactPacket,
601 claim: &PacketCandidateClaim,
602 gap: bool,
603) -> Result<FindingBundle, String> {
604 let evidence_spans = claim
605 .evidence_artifact_ids
606 .iter()
607 .map(|artifact_id| {
608 json!({
609 "artifact_packet_id": packet.packet_id,
610 "artifact_id": artifact_id,
611 "candidate_claim_id": claim.id,
612 })
613 })
614 .collect::<Vec<_>>();
615 let mut finding = FindingBundle::new(
616 Assertion {
617 text: claim.assertion.clone(),
618 assertion_type: claim.assertion_type.clone(),
619 entities: Vec::<Entity>::new(),
620 relation: None,
621 direction: None,
622 causal_claim: None,
623 causal_evidence_grade: None,
624 },
625 Evidence {
626 evidence_type: "computational".to_string(),
627 model_system: "agent artifact packet".to_string(),
628 species: None,
629 method: "ScienceClaw-shaped artifact packet import".to_string(),
630 sample_size: None,
631 effect_size: None,
632 p_value: None,
633 replicated: false,
634 replication_count: None,
635 evidence_spans,
636 },
637 Conditions {
638 text: if claim.conditions.is_empty() {
639 "Agent-imported candidate claim; scope requires review.".to_string()
640 } else {
641 claim.conditions.join("; ")
642 },
643 species_verified: Vec::new(),
644 species_unverified: Vec::new(),
645 in_vitro: false,
646 in_vivo: false,
647 human_data: false,
648 clinical_trial: false,
649 concentration_range: None,
650 duration: None,
651 age_group: None,
652 cell_type: None,
653 },
654 Confidence {
655 kind: ConfidenceKind::FrontierEpistemic,
656 score: claim.confidence,
657 basis: "agent-imported candidate claim; reviewer acceptance required".to_string(),
658 method: ConfidenceMethod::ExpertJudgment,
659 components: None,
660 extraction_confidence: 0.7,
661 },
662 packet_provenance(
663 packet,
664 &claim.id,
665 claim.source_refs.first().cloned(),
666 "model_output",
667 ),
668 Flags {
669 gap,
670 ..Default::default()
671 },
672 );
673 finding.created = packet.created_at.clone();
674 Ok(finding)
675}
676
677fn need_to_gap_finding(
678 packet: &ArtifactPacket,
679 need: &PacketOpenNeed,
680) -> Result<FindingBundle, String> {
681 let claim = PacketCandidateClaim {
682 id: need.id.clone(),
683 assertion: need.question.clone(),
684 assertion_type: "open_question".to_string(),
685 evidence_artifact_ids: packet
686 .artifacts
687 .first()
688 .map(|a| vec![a.id.clone()])
689 .unwrap_or_default(),
690 source_refs: vec![format!("artifact_packet:{}", packet.packet_id)],
691 conditions: vec![need.rationale.clone()],
692 confidence: 0.4,
693 caveats: vec!["Open need, not an accepted result.".to_string()],
694 };
695 claim_to_finding(packet, &claim, true)
696}
697
698fn packet_provenance(
699 packet: &ArtifactPacket,
700 title: &str,
701 url: Option<String>,
702 source_type: &str,
703) -> Provenance {
704 Provenance {
705 source_type: source_type.to_string(),
706 doi: None,
707 pmid: None,
708 pmc: None,
709 openalex_id: None,
710 url,
711 title: format!("{} ยท {}", packet.packet_id, title),
712 authors: vec![Author {
713 name: packet.producer.name.clone(),
714 orcid: None,
715 }],
716 year: None,
717 journal: None,
718 license: None,
719 publisher: Some("artifact packet".to_string()),
720 funders: Vec::new(),
721 extraction: Extraction {
722 method: "artifact_to_state_import".to_string(),
723 model: Some(packet.producer.id.clone()),
724 model_version: None,
725 extracted_at: packet.created_at.clone(),
726 extractor_version: project::VELA_COMPILER_VERSION.to_string(),
727 },
728 review: Some(Review {
729 reviewed: false,
730 reviewer: None,
731 reviewed_at: None,
732 corrections: Vec::new(),
733 }),
734 citation_count: None,
735 }
736}
737
738fn packet_reference(packet: &ArtifactPacket) -> Value {
739 json!({
740 "schema": packet.schema,
741 "packet_id": packet.packet_id,
742 "producer": packet.producer,
743 "topic": packet.topic,
744 "created_at": packet.created_at,
745 })
746}
747
748fn source_refs_for_artifact(packet: &ArtifactPacket, artifact: &PacketArtifact) -> Vec<String> {
749 let mut refs = vec![
750 format!("artifact_packet:{}", packet.packet_id),
751 artifact.locator.clone(),
752 ];
753 refs.extend(
754 artifact
755 .parents
756 .iter()
757 .map(|id| format!("parent_artifact:{id}")),
758 );
759 refs.sort();
760 refs.dedup();
761 refs
762}
763
764fn artifact_metadata_target_findings(artifact: &PacketArtifact) -> Vec<String> {
765 artifact
766 .metadata
767 .get("target_findings")
768 .and_then(Value::as_array)
769 .map(|values| {
770 values
771 .iter()
772 .filter_map(Value::as_str)
773 .filter(|id| id.starts_with("vf_"))
774 .map(str::to_string)
775 .collect::<Vec<_>>()
776 })
777 .unwrap_or_default()
778}
779
780fn agent_run(packet: &ArtifactPacket) -> AgentRun {
781 let mut context = BTreeMap::new();
782 context.insert("artifact_packet_id".to_string(), packet.packet_id.clone());
783 context.insert("topic".to_string(), packet.topic.clone());
784 context.insert("producer_name".to_string(), packet.producer.name.clone());
785 AgentRun {
786 agent: packet.producer.id.clone(),
787 model: "external-artifact-runtime".to_string(),
788 run_id: packet.packet_id.clone(),
789 started_at: packet.created_at.clone(),
790 finished_at: None,
791 context,
792 tool_calls: Vec::new(),
793 permissions: None,
794 }
795}
796
797fn source_type_for_artifact(kind: &str) -> &'static str {
798 match kind {
799 "clinical_trial_record" => "clinical_trial",
800 "registry_record" => "database_record",
801 "model_output" | "table" | "figure" | "code" | "notebook" => "model_output",
802 "dataset" => "data_release",
803 "protocol" | "supplement" | "source_file" | "lab_file" | "other" => "database_record",
804 _ => "database_record",
805 }
806}
807
808fn actor_type(kind: &str) -> &'static str {
809 match kind {
810 "human" | "reviewer" => "human",
811 _ => "agent",
812 }
813}
814
815fn normalize_packet_hash(value: &str) -> Result<String, String> {
816 let trimmed = value.trim();
817 let hex = trimmed.strip_prefix("sha256:").unwrap_or(trimmed);
818 if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
819 return Err(format!(
820 "content_hash must be sha256:<64hex> or 64 hex chars, got {trimmed:?}"
821 ));
822 }
823 Ok(format!("sha256:{}", hex.to_ascii_lowercase()))
824}