Skip to main content

vela_protocol/
artifact_to_state.rs

1//! Import agent-produced artifact packets as reviewable frontier state.
2//!
3//! The adapter is intentionally local and schema-driven. It accepts a
4//! ScienceClaw-shaped artifact DAG packet, converts artifacts into
5//! reviewable `artifact.assert` proposals, and converts candidate
6//! claims/open needs into `finding.add` proposals. Agent output is
7//! source material until reviewers accept the resulting proposals.
8
9use 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}