Skip to main content

vela_protocol/
runtime_adapters.rs

1//! External runtime adapters that normalize upstream artifacts into Carina packets.
2//!
3//! Runtime systems can generate artifacts, posts, comments, and reviews. Vela
4//! treats those records as source material: adapters convert them into
5//! `carina.artifact_packet.v0.1`, then route through the existing
6//! artifact-to-state proposal path. Artifact records may be applied immediately;
7//! truth-changing finding, gap, and review-note proposals remain reviewable.
8
9use std::collections::BTreeMap;
10use std::fs;
11use std::path::{Path, PathBuf};
12
13use chrono::Utc;
14use serde::{Deserialize, Serialize};
15use serde_json::{Value, json};
16use sha2::{Digest, Sha256};
17
18use crate::artifact_to_state::{
19    ARTIFACT_PACKET_SCHEMA, ArtifactPacket, ImportIdempotency, PacketArtifact,
20    PacketCandidateClaim, PacketOpenNeed, PacketProducer,
21};
22use crate::canonical;
23use crate::events::StateTarget;
24use crate::proposals::{self, AgentRun};
25use crate::{artifact_to_state, repo};
26
27pub const SCIENCECLAW_ARTIFACT_V1: &str = "scienceclaw-artifact-v1";
28pub const AGENT_DISCOURSE_V1: &str = "agent-discourse-v1";
29
30/// v0.76.2: Agent4Science-shape review packet adapter (stub).
31///
32/// The Gowers (2026-05-08) post argues for a path where AI-produced
33/// results land in a venue moderated by human certification. This
34/// adapter is the wire format for ingesting one Agent4Science-style
35/// review packet (`carina.review_packet.v0.1`) as a proposal queued
36/// for human-reviewer adjudication. The substrate does not auto-apply
37/// the verdict; a reviewer signs an accept event under their own key.
38///
39/// See `docs/RELAY.md` for the contract and `docs/AI_ATTRIBUTION.md`
40/// for the doctrine.
41pub const AGENT4SCIENCE_REVIEW_V1: &str = "agent4science-review-v1";
42
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
44pub struct RuntimeAdapterRunOptions {
45    pub adapter: String,
46    pub input: PathBuf,
47    pub actor: String,
48    #[serde(default)]
49    pub dry_run: bool,
50    #[serde(default)]
51    pub apply_artifacts: bool,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
55pub struct RuntimeAdapterRunReport {
56    pub ok: bool,
57    pub command: String,
58    pub adapter: String,
59    pub run_id: String,
60    pub frontier: String,
61    pub input: String,
62    pub dry_run: bool,
63    pub artifact_proposals: usize,
64    pub finding_proposals: usize,
65    pub gap_proposals: usize,
66    #[serde(default)]
67    pub review_note_proposals: usize,
68    pub applied_artifact_events: usize,
69    pub pending_truth_proposals: usize,
70    pub proposal_ids: Vec<String>,
71    #[serde(default)]
72    pub review_proposal_ids: Vec<String>,
73    pub applied_event_ids: Vec<String>,
74    pub idempotency: ImportIdempotency,
75    pub trusted_state_effect: String,
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub packet_id: Option<String>,
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub packet_path: Option<PathBuf>,
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub run_path: Option<PathBuf>,
82}
83
84#[derive(Debug, Clone, Deserialize)]
85struct ScienceClawExport {
86    schema: String,
87    #[serde(default)]
88    run_id: String,
89    producer: PacketProducer,
90    topic: String,
91    created_at: String,
92    #[serde(default)]
93    artifacts: Vec<PacketArtifact>,
94    #[serde(default)]
95    candidate_claims: Vec<PacketCandidateClaim>,
96    #[serde(default)]
97    open_needs: Vec<PacketOpenNeed>,
98    #[serde(default)]
99    caveats: Vec<String>,
100}
101
102#[derive(Debug, Clone, Deserialize)]
103struct AgentDiscourseExport {
104    schema: String,
105    thread_id: String,
106    runtime: DiscourseRuntime,
107    topic: String,
108    created_at: String,
109    #[serde(default)]
110    posts: Vec<DiscoursePost>,
111    #[serde(default)]
112    comments: Vec<DiscourseComment>,
113    #[serde(default)]
114    reviews: Vec<DiscourseReview>,
115    #[serde(default)]
116    open_needs: Vec<PacketOpenNeed>,
117}
118
119#[derive(Debug, Clone, Deserialize)]
120struct DiscourseRuntime {
121    id: String,
122    name: String,
123}
124
125#[derive(Debug, Clone, Deserialize)]
126struct DiscoursePost {
127    id: String,
128    title: String,
129    assertion: String,
130    #[serde(default)]
131    body: String,
132    locator: String,
133    content_hash: String,
134    #[serde(default)]
135    conditions: Vec<String>,
136    #[serde(default)]
137    confidence: Option<f64>,
138    #[serde(default)]
139    source_refs: Vec<String>,
140    #[serde(default)]
141    target_finding_id: Option<String>,
142}
143
144#[derive(Debug, Clone, Deserialize)]
145struct DiscourseComment {
146    id: String,
147    post_id: String,
148    body: String,
149    locator: String,
150    content_hash: String,
151    #[serde(default)]
152    target_finding_id: Option<String>,
153}
154
155#[derive(Debug, Clone, Deserialize)]
156struct DiscourseReview {
157    id: String,
158    post_id: String,
159    decision: String,
160    body: String,
161    locator: String,
162    content_hash: String,
163    #[serde(default)]
164    target_finding_id: Option<String>,
165}
166
167#[derive(Debug, Clone)]
168struct ReviewSignal {
169    external_id: String,
170    parent_id: String,
171    target_finding_id: String,
172    locator: String,
173    body: String,
174    decision: Option<String>,
175}
176
177#[derive(Debug, Clone)]
178struct NormalizedRuntimePacket {
179    packet: ArtifactPacket,
180    review_signals: Vec<ReviewSignal>,
181}
182
183pub fn run(
184    frontier_path: &Path,
185    options: RuntimeAdapterRunOptions,
186) -> Result<RuntimeAdapterRunReport, String> {
187    if options.actor.trim().is_empty() {
188        return Err("actor must be non-empty".to_string());
189    }
190
191    let frontier = repo::load_from_path(frontier_path)?;
192    let input_path = resolve_input_path(&options.input)?;
193    let input_value = read_json(&input_path)?;
194    let run_id = run_id(&options.adapter, &input_value);
195    let normalized = normalize_packet(&options.adapter, input_value, &run_id)?;
196    let packet = normalized.packet.validate()?;
197    let frontier_name = frontier.project.name.clone();
198
199    if options.dry_run {
200        return Ok(RuntimeAdapterRunReport {
201            ok: true,
202            command: "runtime-adapter.run".to_string(),
203            adapter: options.adapter,
204            run_id,
205            frontier: frontier_name,
206            input: input_path.display().to_string(),
207            dry_run: true,
208            artifact_proposals: 0,
209            finding_proposals: 0,
210            gap_proposals: 0,
211            review_note_proposals: 0,
212            applied_artifact_events: 0,
213            pending_truth_proposals: 0,
214            proposal_ids: Vec::new(),
215            review_proposal_ids: Vec::new(),
216            applied_event_ids: Vec::new(),
217            idempotency: ImportIdempotency {
218                packet_hash: packet_hash(&packet),
219                duplicate_packet: false,
220                skipped_existing_proposals: Vec::new(),
221                skipped_existing_artifacts: Vec::new(),
222            },
223            trusted_state_effect: "none".to_string(),
224            packet_id: Some(packet.packet_id),
225            packet_path: None,
226            run_path: None,
227        });
228    }
229
230    let run_dir = runtime_runs_dir(frontier_path)?.join(&run_id);
231    fs::create_dir_all(&run_dir).map_err(|e| {
232        format!(
233            "create runtime adapter run dir '{}': {e}",
234            run_dir.display()
235        )
236    })?;
237    fs::write(
238        run_dir.join("input.json"),
239        serde_json::to_vec_pretty(&read_json(&input_path)?)
240            .map_err(|e| format!("serialize runtime adapter input: {e}"))?,
241    )
242    .map_err(|e| {
243        format!(
244            "write runtime adapter input '{}': {e}",
245            input_path.display()
246        )
247    })?;
248
249    let packet_path = run_dir.join("artifact-packet.json");
250    fs::write(
251        &packet_path,
252        serde_json::to_vec_pretty(&packet).map_err(|e| format!("serialize packet: {e}"))?,
253    )
254    .map_err(|e| format!("write artifact packet '{}': {e}", packet_path.display()))?;
255
256    let import_report = artifact_to_state::import_packet_at_path(
257        frontier_path,
258        &packet_path,
259        &options.actor,
260        options.apply_artifacts,
261    )?;
262    update_import_agent_runs(
263        frontier_path,
264        &import_report.proposal_ids,
265        &options.adapter,
266        &run_id,
267        &packet.packet_id,
268        &input_path,
269    )?;
270    let review_proposal_ids = create_review_note_proposals(
271        frontier_path,
272        &options,
273        &run_id,
274        &packet.packet_id,
275        &normalized.review_signals,
276    )?;
277    let mut proposal_ids = import_report.proposal_ids.clone();
278    proposal_ids.extend(review_proposal_ids.clone());
279
280    let final_run = json!({
281        "schema": "vela.runtime-adapter-run.v1",
282        "run_id": run_id,
283        "adapter": options.adapter,
284        "frontier": frontier_name,
285        "input": input_path.display().to_string(),
286        "started_at": packet.created_at,
287        "packet_id": packet.packet_id,
288        "packet_path": "artifact-packet.json",
289        "artifact_proposals": import_report.artifact_proposals,
290        "finding_proposals": import_report.finding_proposals,
291        "gap_proposals": import_report.gap_proposals,
292        "review_note_proposals": review_proposal_ids.len(),
293        "proposal_ids": proposal_ids,
294        "review_proposal_ids": review_proposal_ids,
295        "applied_event_ids": import_report.applied_event_ids,
296        "idempotency": import_report.idempotency,
297        "trusted_state_effect": import_report.trusted_state_effect,
298        "external_runtime": external_runtime_summary(&packet),
299    });
300    fs::write(
301        run_dir.join("run.json"),
302        serde_json::to_vec_pretty(&final_run).map_err(|e| format!("serialize run: {e}"))?,
303    )
304    .map_err(|e| format!("write runtime adapter run '{}': {e}", run_dir.display()))?;
305
306    Ok(RuntimeAdapterRunReport {
307        ok: true,
308        command: "runtime-adapter.run".to_string(),
309        adapter: options.adapter,
310        run_id,
311        frontier: frontier_name,
312        input: input_path.display().to_string(),
313        dry_run: false,
314        artifact_proposals: import_report.artifact_proposals,
315        finding_proposals: import_report.finding_proposals,
316        gap_proposals: import_report.gap_proposals,
317        review_note_proposals: review_proposal_ids.len(),
318        applied_artifact_events: import_report.applied_artifact_events,
319        pending_truth_proposals: import_report.pending_truth_proposals,
320        proposal_ids,
321        review_proposal_ids,
322        applied_event_ids: import_report.applied_event_ids,
323        idempotency: import_report.idempotency,
324        trusted_state_effect: import_report.trusted_state_effect,
325        packet_id: Some(packet.packet_id),
326        packet_path: Some(packet_path),
327        run_path: Some(run_dir),
328    })
329}
330
331fn packet_hash(packet: &ArtifactPacket) -> String {
332    let bytes = canonical::to_canonical_bytes(packet).unwrap_or_default();
333    format!("sha256:{}", hex::encode(Sha256::digest(bytes)))
334}
335
336fn normalize_packet(
337    adapter: &str,
338    input: Value,
339    run_id: &str,
340) -> Result<NormalizedRuntimePacket, String> {
341    match adapter {
342        SCIENCECLAW_ARTIFACT_V1 => normalize_scienceclaw(input, run_id),
343        AGENT_DISCOURSE_V1 => normalize_agent_discourse(input, run_id),
344        AGENT4SCIENCE_REVIEW_V1 => normalize_agent4science_review(input, run_id),
345        _ => Err(format!("unsupported runtime adapter '{adapter}'")),
346    }
347}
348
349/// v0.76.2: Agent4Science review-packet shape.
350///
351/// ```json
352/// {
353///   "schema": "carina.review_packet.v0.1",
354///   "review_id": "rev_<hex>",
355///   "target_finding_id": "vf_<hex>",
356///   "verdict": "accepted | needs_revision | contested | rejected",
357///   "reasoning": "...",
358///   "reviewer": {"id": "reviewer:...", "type": "human" | "agent"},
359///   "evidence": [{"locator": "...", "span": "..."}],
360///   "signature": "ed25519:..."
361/// }
362/// ```
363///
364/// Stub: parses the shape, validates required fields, and emits one
365/// ReviewSignal pointing at the target finding so the existing
366/// `create_review_note_proposals` pipeline writes a `finding.note`
367/// proposal recording the verdict. The substrate does not turn the
368/// verdict into an accept event; a reviewer must sign that
369/// separately. See `docs/AI_ATTRIBUTION.md`.
370#[derive(Debug, Clone, serde::Deserialize)]
371struct Agent4ScienceReviewPacket {
372    schema: String,
373    review_id: String,
374    target_finding_id: String,
375    verdict: String,
376    reasoning: String,
377    reviewer: Agent4ScienceReviewer,
378    #[serde(default)]
379    evidence: Vec<Agent4ScienceEvidence>,
380    #[serde(default)]
381    signature: Option<String>,
382}
383
384#[derive(Debug, Clone, serde::Deserialize)]
385struct Agent4ScienceReviewer {
386    id: String,
387    #[serde(rename = "type")]
388    actor_type: String,
389}
390
391#[derive(Debug, Clone, serde::Deserialize)]
392struct Agent4ScienceEvidence {
393    locator: String,
394    /// Optional source-span text. The stub doesn't propagate it
395    /// downstream yet; future revisions can carry it onto the
396    /// generated proposal's evidence_spans.
397    #[serde(default)]
398    #[allow(dead_code)]
399    span: Option<String>,
400}
401
402const AGENT4SCIENCE_REVIEW_SCHEMA: &str = "carina.review_packet.v0.1";
403
404fn normalize_agent4science_review(
405    input: Value,
406    run_id: &str,
407) -> Result<NormalizedRuntimePacket, String> {
408    let packet: Agent4ScienceReviewPacket = serde_json::from_value(input)
409        .map_err(|e| format!("parse agent4science review packet: {e}"))?;
410    if packet.schema != AGENT4SCIENCE_REVIEW_SCHEMA {
411        return Err(format!(
412            "unsupported agent4science review schema '{}', expected '{AGENT4SCIENCE_REVIEW_SCHEMA}'",
413            packet.schema
414        ));
415    }
416    if !packet.target_finding_id.starts_with("vf_") {
417        return Err(format!(
418            "target_finding_id must start with 'vf_', got '{}'",
419            packet.target_finding_id
420        ));
421    }
422    if !["accepted", "needs_revision", "contested", "rejected"].contains(&packet.verdict.as_str()) {
423        return Err(format!("verdict '{}' not in allowlist", packet.verdict));
424    }
425    if !["human", "agent"].contains(&packet.reviewer.actor_type.as_str()) {
426        return Err(format!(
427            "reviewer.type '{}' must be 'human' or 'agent'",
428            packet.reviewer.actor_type
429        ));
430    }
431
432    // The stub maps the verdict + reasoning into a single review signal
433    // so the existing pipeline drafts a finding.note proposal under
434    // `agent:agent4science-review-bot` (or the supplied reviewer id).
435    // A human reviewer adjudicates the verdict by writing a separate
436    // `finding.review` proposal + accept event.
437    let body = format!(
438        "Agent4Science review {}: {}. Reasoning: {}.",
439        packet.review_id, packet.verdict, packet.reasoning
440    );
441    let locator = packet
442        .evidence
443        .first()
444        .map(|e| e.locator.clone())
445        .unwrap_or_else(|| format!("agent4science:review:{}", packet.review_id));
446
447    let mut metadata = BTreeMap::new();
448    metadata.insert("external_object_kind".to_string(), json!("review_packet"));
449    metadata.insert(
450        "external_object_id".to_string(),
451        json!(packet.review_id.clone()),
452    );
453    metadata.insert("verdict".to_string(), json!(packet.verdict.clone()));
454    metadata.insert("reviewer_id".to_string(), json!(packet.reviewer.id.clone()));
455    metadata.insert(
456        "reviewer_type".to_string(),
457        json!(packet.reviewer.actor_type.clone()),
458    );
459    metadata.insert(
460        "target_findings".to_string(),
461        json!([packet.target_finding_id.clone()]),
462    );
463    if let Some(sig) = &packet.signature {
464        metadata.insert("signature".to_string(), json!(sig));
465    }
466
467    let content_hash = format!("sha256:{}", hex::encode(Sha256::digest(body.as_bytes())));
468
469    let artifact = PacketArtifact {
470        id: packet.review_id.clone(),
471        // Use `source_file` (one of VALID_ARTIFACT_KINDS in
472        // bundle.rs); the AGENT_DISCOURSE_V1 adapter does the same
473        // for review records. The agent4science-specific shape is
474        // captured in metadata.external_object_kind.
475        kind: "source_file".to_string(),
476        title: format!("Agent4Science review {}", packet.review_id),
477        locator: locator.clone(),
478        content_hash,
479        parents: Vec::new(),
480        metadata,
481    };
482
483    let review_signals = vec![ReviewSignal {
484        external_id: packet.review_id.clone(),
485        parent_id: packet.review_id.clone(),
486        target_finding_id: packet.target_finding_id.clone(),
487        locator,
488        body: body.clone(),
489        decision: Some(packet.verdict.clone()),
490    }];
491
492    let inner_packet = ArtifactPacket {
493        schema: ARTIFACT_PACKET_SCHEMA.to_string(),
494        packet_id: packet_id(AGENT4SCIENCE_REVIEW_V1, run_id, &packet.review_id),
495        producer: PacketProducer {
496            kind: packet.reviewer.actor_type.clone(),
497            id: packet.reviewer.id.clone(),
498            name: format!("agent4science:{}", packet.reviewer.id),
499        },
500        topic: "agent4science.review".to_string(),
501        created_at: chrono::Utc::now().to_rfc3339(),
502        artifacts: vec![artifact],
503        candidate_claims: Vec::new(),
504        open_needs: Vec::new(),
505        caveats: bridge_caveats(vec![
506            "Agent4Science review packets are review signals, not canonical truth. A human reviewer must sign an accept event."
507                .to_string(),
508        ]),
509    };
510
511    Ok(NormalizedRuntimePacket {
512        packet: with_runtime_metadata(inner_packet, AGENT4SCIENCE_REVIEW_V1, run_id),
513        review_signals,
514    })
515}
516
517fn normalize_scienceclaw(input: Value, run_id: &str) -> Result<NormalizedRuntimePacket, String> {
518    if input.get("schema").and_then(Value::as_str) == Some(ARTIFACT_PACKET_SCHEMA) {
519        let packet: ArtifactPacket =
520            serde_json::from_value(input).map_err(|e| format!("parse artifact packet: {e}"))?;
521        return Ok(NormalizedRuntimePacket {
522            packet: with_runtime_metadata(packet, SCIENCECLAW_ARTIFACT_V1, run_id),
523            review_signals: Vec::new(),
524        });
525    }
526
527    let export: ScienceClawExport =
528        serde_json::from_value(input).map_err(|e| format!("parse ScienceClaw export: {e}"))?;
529    if export.schema != "scienceclaw.artifact_export.v1" {
530        return Err(format!(
531            "unsupported ScienceClaw export schema '{}'",
532            export.schema
533        ));
534    }
535    let packet = ArtifactPacket {
536        schema: ARTIFACT_PACKET_SCHEMA.to_string(),
537        packet_id: packet_id(SCIENCECLAW_ARTIFACT_V1, run_id, &export.run_id),
538        producer: export.producer,
539        topic: export.topic,
540        created_at: export.created_at,
541        artifacts: export.artifacts,
542        candidate_claims: export.candidate_claims,
543        open_needs: export.open_needs,
544        caveats: bridge_caveats(export.caveats),
545    };
546    Ok(NormalizedRuntimePacket {
547        packet: with_runtime_metadata(packet, SCIENCECLAW_ARTIFACT_V1, run_id),
548        review_signals: Vec::new(),
549    })
550}
551
552fn normalize_agent_discourse(
553    input: Value,
554    run_id: &str,
555) -> Result<NormalizedRuntimePacket, String> {
556    let export: AgentDiscourseExport =
557        serde_json::from_value(input).map_err(|e| format!("parse agent discourse export: {e}"))?;
558    if export.schema != "agent_discourse.v1" {
559        return Err(format!(
560            "unsupported agent discourse export schema '{}'",
561            export.schema
562        ));
563    }
564
565    let mut artifacts = Vec::new();
566    let mut candidate_claims = Vec::new();
567    let mut review_signals = Vec::new();
568
569    for post in &export.posts {
570        let mut metadata = BTreeMap::new();
571        metadata.insert("external_object_kind".to_string(), json!("post"));
572        metadata.insert("external_object_id".to_string(), json!(post.id));
573        metadata.insert("body".to_string(), json!(post.body));
574        if let Some(target) = &post.target_finding_id {
575            metadata.insert("target_findings".to_string(), json!([target]));
576        }
577        artifacts.push(PacketArtifact {
578            id: post.id.clone(),
579            kind: "model_output".to_string(),
580            title: post.title.clone(),
581            locator: post.locator.clone(),
582            content_hash: post.content_hash.clone(),
583            parents: Vec::new(),
584            metadata,
585        });
586        candidate_claims.push(PacketCandidateClaim {
587            id: format!("claim_{}", post.id),
588            assertion: post.assertion.clone(),
589            assertion_type: "therapeutic".to_string(),
590            evidence_artifact_ids: vec![post.id.clone()],
591            source_refs: source_refs_with_locator(&post.source_refs, &post.locator),
592            conditions: post.conditions.clone(),
593            confidence: post.confidence.unwrap_or(0.5),
594            caveats: vec![
595                "Agent discourse post is a candidate claim; reviewer acceptance required."
596                    .to_string(),
597            ],
598        });
599    }
600
601    for comment in &export.comments {
602        let mut metadata = BTreeMap::new();
603        metadata.insert("external_object_kind".to_string(), json!("comment"));
604        metadata.insert("external_object_id".to_string(), json!(comment.id));
605        metadata.insert("body".to_string(), json!(comment.body));
606        if let Some(target) = &comment.target_finding_id {
607            metadata.insert("target_findings".to_string(), json!([target]));
608            review_signals.push(ReviewSignal {
609                external_id: comment.id.clone(),
610                parent_id: comment.post_id.clone(),
611                target_finding_id: target.clone(),
612                locator: comment.locator.clone(),
613                body: comment.body.clone(),
614                decision: None,
615            });
616        }
617        artifacts.push(PacketArtifact {
618            id: comment.id.clone(),
619            kind: "source_file".to_string(),
620            title: format!("Discourse comment {}", comment.id),
621            locator: comment.locator.clone(),
622            content_hash: comment.content_hash.clone(),
623            parents: vec![comment.post_id.clone()],
624            metadata,
625        });
626    }
627
628    for review in &export.reviews {
629        let mut metadata = BTreeMap::new();
630        metadata.insert("external_object_kind".to_string(), json!("review"));
631        metadata.insert("external_object_id".to_string(), json!(review.id));
632        metadata.insert("decision".to_string(), json!(review.decision));
633        metadata.insert("body".to_string(), json!(review.body));
634        if let Some(target) = &review.target_finding_id {
635            metadata.insert("target_findings".to_string(), json!([target]));
636            review_signals.push(ReviewSignal {
637                external_id: review.id.clone(),
638                parent_id: review.post_id.clone(),
639                target_finding_id: target.clone(),
640                locator: review.locator.clone(),
641                body: review.body.clone(),
642                decision: Some(review.decision.clone()),
643            });
644        }
645        artifacts.push(PacketArtifact {
646            id: review.id.clone(),
647            kind: "source_file".to_string(),
648            title: format!("Discourse review {}", review.id),
649            locator: review.locator.clone(),
650            content_hash: review.content_hash.clone(),
651            parents: vec![review.post_id.clone()],
652            metadata,
653        });
654    }
655
656    let packet = ArtifactPacket {
657        schema: ARTIFACT_PACKET_SCHEMA.to_string(),
658        packet_id: packet_id(AGENT_DISCOURSE_V1, run_id, &export.thread_id),
659        producer: PacketProducer {
660            kind: "agent".to_string(),
661            id: format!("agent:{}", export.runtime.id),
662            name: export.runtime.name,
663        },
664        topic: export.topic,
665        created_at: export.created_at,
666        artifacts,
667        candidate_claims,
668        open_needs: export.open_needs,
669        caveats: bridge_caveats(vec![
670            "Agent discourse is upstream review signal, not canonical truth.".to_string(),
671        ]),
672    };
673    Ok(NormalizedRuntimePacket {
674        packet: with_runtime_metadata(packet, AGENT_DISCOURSE_V1, run_id),
675        review_signals,
676    })
677}
678
679fn create_review_note_proposals(
680    frontier_path: &Path,
681    options: &RuntimeAdapterRunOptions,
682    run_id: &str,
683    packet_id: &str,
684    review_signals: &[ReviewSignal],
685) -> Result<Vec<String>, String> {
686    let mut ids = Vec::new();
687    for signal in review_signals {
688        let text = match &signal.decision {
689            Some(decision) => format!(
690                "External runtime review {} on {} recorded decision '{}': {}. Treat this as review signal until a Vela reviewer accepts a state transition.",
691                signal.external_id, signal.parent_id, decision, signal.body
692            ),
693            None => format!(
694                "External runtime comment {} on {}: {}. Treat this as review signal until a Vela reviewer accepts a state transition.",
695                signal.external_id, signal.parent_id, signal.body
696            ),
697        };
698        let mut proposal = proposals::new_proposal(
699            "finding.note",
700            StateTarget {
701                r#type: "finding".to_string(),
702                id: signal.target_finding_id.clone(),
703            },
704            options.actor.clone(),
705            actor_type(&options.actor),
706            format!(
707                "Import external runtime review signal {} from packet {}",
708                signal.external_id, packet_id
709            ),
710            json!({
711                "text": text,
712                "runtime_adapter": options.adapter,
713                "runtime_adapter_run_id": run_id,
714                "artifact_packet_id": packet_id,
715                "external_object_id": signal.external_id,
716                "parent_external_object_id": signal.parent_id,
717                "decision": signal.decision,
718                "locator": signal.locator,
719            }),
720            vec![
721                signal.locator.clone(),
722                format!("runtime_adapter_run:{run_id}"),
723                format!("runtime_packet:{packet_id}"),
724            ],
725            bridge_caveats(vec![
726                "External comments and reviews are not canonical attestations.".to_string(),
727            ]),
728        );
729        proposal.agent_run = Some(agent_run(&options.adapter, run_id, packet_id));
730        let result = proposals::create_or_apply(frontier_path, proposal, false)?;
731        ids.push(result.proposal_id);
732    }
733    Ok(ids)
734}
735
736fn update_import_agent_runs(
737    frontier_path: &Path,
738    proposal_ids: &[String],
739    adapter: &str,
740    run_id: &str,
741    packet_id: &str,
742    input_path: &Path,
743) -> Result<(), String> {
744    let mut frontier = repo::load_from_path(frontier_path)?;
745    for proposal in &mut frontier.proposals {
746        if proposal_ids.iter().any(|id| id == &proposal.id) {
747            let mut run = proposal
748                .agent_run
749                .clone()
750                .unwrap_or_else(|| agent_run(adapter, run_id, packet_id));
751            run.model = format!("runtime-adapter:{adapter}");
752            run.run_id = run_id.to_string();
753            run.context
754                .insert("runtime_adapter".to_string(), adapter.to_string());
755            run.context
756                .insert("runtime_adapter_run_id".to_string(), run_id.to_string());
757            run.context
758                .insert("artifact_packet_id".to_string(), packet_id.to_string());
759            run.context
760                .insert("input".to_string(), input_path.display().to_string());
761            proposal.agent_run = Some(run);
762        }
763    }
764    repo::save_to_path(frontier_path, &frontier)
765}
766
767fn with_runtime_metadata(
768    mut packet: ArtifactPacket,
769    adapter: &str,
770    run_id: &str,
771) -> ArtifactPacket {
772    for artifact in &mut packet.artifacts {
773        artifact
774            .metadata
775            .insert("runtime_adapter".to_string(), json!(adapter));
776        artifact
777            .metadata
778            .insert("runtime_adapter_run_id".to_string(), json!(run_id));
779        artifact
780            .metadata
781            .insert("external_runtime".to_string(), json!(packet.producer.name));
782    }
783    packet
784}
785
786fn bridge_caveats(mut caveats: Vec<String>) -> Vec<String> {
787    caveats
788        .push("External runtime output is source material until reviewer acceptance.".to_string());
789    caveats.push(
790        "External upvotes, comments, reviews, and agent confidence do not grant canonical authority."
791            .to_string(),
792    );
793    caveats.sort();
794    caveats.dedup();
795    caveats
796}
797
798fn source_refs_with_locator(source_refs: &[String], locator: &str) -> Vec<String> {
799    let mut refs = source_refs.to_vec();
800    refs.push(locator.to_string());
801    refs.sort();
802    refs.dedup();
803    refs
804}
805
806fn external_runtime_summary(packet: &ArtifactPacket) -> Value {
807    json!({
808        "producer": packet.producer,
809        "topic": packet.topic,
810        "artifact_count": packet.artifacts.len(),
811        "candidate_claim_count": packet.candidate_claims.len(),
812        "open_need_count": packet.open_needs.len(),
813    })
814}
815
816fn actor_type(actor: &str) -> String {
817    if actor.starts_with("agent:") {
818        "agent".to_string()
819    } else {
820        "human".to_string()
821    }
822}
823
824fn agent_run(adapter: &str, run_id: &str, packet_id: &str) -> AgentRun {
825    let mut context = BTreeMap::new();
826    context.insert("runtime_adapter".to_string(), adapter.to_string());
827    context.insert("runtime_adapter_run_id".to_string(), run_id.to_string());
828    context.insert("artifact_packet_id".to_string(), packet_id.to_string());
829    AgentRun {
830        agent: adapter.to_string(),
831        model: format!("runtime-adapter:{adapter}"),
832        run_id: run_id.to_string(),
833        started_at: Utc::now().to_rfc3339(),
834        finished_at: None,
835        context,
836        tool_calls: Vec::new(),
837        permissions: None,
838    }
839}
840
841fn runtime_runs_dir(frontier_path: &Path) -> Result<PathBuf, String> {
842    match repo::detect(frontier_path)? {
843        repo::VelaSource::VelaRepo(root) => Ok(root.join("ingest").join("runtime-runs")),
844        repo::VelaSource::ProjectFile(path) => path
845            .parent()
846            .map(|parent| parent.join("ingest").join("runtime-runs"))
847            .ok_or_else(|| format!("frontier file '{}' has no parent", path.display())),
848        repo::VelaSource::PacketDir(dir) => Ok(dir.join("ingest").join("runtime-runs")),
849    }
850}
851
852fn resolve_input_path(input: &Path) -> Result<PathBuf, String> {
853    if input.is_file() {
854        return Ok(input.to_path_buf());
855    }
856    if !input.is_dir() {
857        return Err(format!(
858            "runtime adapter input '{}' not found",
859            input.display()
860        ));
861    }
862    let default = input.join("runtime-export.json");
863    if default.is_file() {
864        return Ok(default);
865    }
866    let mut candidates = fs::read_dir(input)
867        .map_err(|e| format!("read runtime adapter input dir '{}': {e}", input.display()))?
868        .flatten()
869        .map(|entry| entry.path())
870        .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("json"))
871        .collect::<Vec<_>>();
872    candidates.sort();
873    candidates.into_iter().next().ok_or_else(|| {
874        format!(
875            "runtime adapter input dir '{}' has no JSON files",
876            input.display()
877        )
878    })
879}
880
881fn read_json(path: &Path) -> Result<Value, String> {
882    let bytes = fs::read(path).map_err(|e| format!("read {}: {e}", path.display()))?;
883    serde_json::from_slice(&bytes).map_err(|e| format!("parse {}: {e}", path.display()))
884}
885
886fn run_id(adapter: &str, value: &Value) -> String {
887    let bytes = canonical::to_canonical_bytes(&json!({
888        "adapter": adapter,
889        "input": value,
890    }))
891    .unwrap_or_default();
892    format!("rir_{}", &hex::encode(Sha256::digest(bytes))[..16])
893}
894
895fn packet_id(adapter: &str, run_id: &str, external_id: &str) -> String {
896    let bytes = canonical::to_canonical_bytes(&json!({
897        "adapter": adapter,
898        "run_id": run_id,
899        "external_id": external_id,
900    }))
901    .unwrap_or_default();
902    format!("cap_{}", &hex::encode(Sha256::digest(bytes))[..16])
903}