Skip to main content

harn_vm/
session_bundle.rs

1//! Canonical session bundle export/import support.
2//!
3//! A session bundle is the portable JSON envelope that downstream hosts use
4//! for local exports, sanitized shares, and replay-only handoffs. The bundle
5//! is intentionally built from existing Harn run-record surfaces so replay,
6//! transcripts, tool calls, HITL questions, and observability do not fork into
7//! a second persistence model.
8
9use std::borrow::Cow;
10use std::collections::BTreeMap;
11use std::fmt;
12
13use serde::{Deserialize, Serialize};
14use serde_json::{json, Value as JsonValue};
15
16use crate::orchestration::{
17    new_id, now_rfc3339, ReplayFixture, RunCheckpointRecord, RunHitlQuestionRecord, RunRecord,
18    RunTraceSpanRecord, RunTransitionRecord, ToolCallRecord,
19};
20use crate::redact::{RedactionPolicy, REDACTED_PLACEHOLDER};
21use crate::workspace_anchor::{anchor_from_transcript_metadata_json, MountedRoot, WorkspaceAnchor};
22
23mod schema;
24pub use schema::{session_bundle_schema, session_bundle_schema_pretty};
25
26#[cfg(test)]
27mod tests;
28
29pub const SESSION_BUNDLE_TYPE: &str = "harn_session_bundle";
30pub const SESSION_BUNDLE_SCHEMA_VERSION: u32 = 1;
31pub const SESSION_BUNDLE_SCHEMA_ID: &str = "https://harnlang.com/schemas/session-bundle.v1.json";
32pub const REPLAY_ONLY_PLACEHOLDER: &str = "[withheld]";
33
34#[derive(Clone, Copy, Debug, Eq, PartialEq)]
35pub enum SessionBundleExportMode {
36    Local,
37    Sanitized,
38    ReplayOnly,
39}
40
41impl SessionBundleExportMode {
42    pub fn as_str(self) -> &'static str {
43        match self {
44            Self::Local => "local",
45            Self::Sanitized => "sanitized",
46            Self::ReplayOnly => "replay_only",
47        }
48    }
49}
50
51#[derive(Clone, Debug)]
52pub struct SessionBundleExportOptions {
53    pub mode: SessionBundleExportMode,
54    pub include_attachments: bool,
55    pub redaction_policy: RedactionPolicy,
56}
57
58impl Default for SessionBundleExportOptions {
59    fn default() -> Self {
60        Self {
61            mode: SessionBundleExportMode::Sanitized,
62            include_attachments: false,
63            redaction_policy: RedactionPolicy::default(),
64        }
65    }
66}
67
68#[derive(Clone, Debug, Default)]
69pub struct SessionBundleValidationOptions {
70    pub allow_unsafe_secret_markers: bool,
71    pub redaction_policy: RedactionPolicy,
72}
73
74#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
75#[serde(default)]
76pub struct SessionBundle {
77    #[serde(rename = "_type")]
78    pub type_name: String,
79    pub schema_version: u32,
80    pub bundle_id: String,
81    pub created_at: String,
82    pub producer: BundleProducer,
83    pub source: BundleSource,
84    pub runtime: BundleRuntime,
85    pub workspace: Option<BundleWorkspace>,
86    pub transcript: BundleTranscript,
87    pub tools: BundleTools,
88    pub permissions: Vec<BundlePermission>,
89    pub replay: BundleReplay,
90    pub redaction: RedactionManifest,
91    pub attachments: Vec<BundleAttachment>,
92    pub metadata: BTreeMap<String, JsonValue>,
93}
94
95impl Default for SessionBundle {
96    fn default() -> Self {
97        Self {
98            type_name: SESSION_BUNDLE_TYPE.to_string(),
99            schema_version: SESSION_BUNDLE_SCHEMA_VERSION,
100            bundle_id: String::new(),
101            created_at: String::new(),
102            producer: BundleProducer::default(),
103            source: BundleSource::default(),
104            runtime: BundleRuntime::default(),
105            workspace: None,
106            transcript: BundleTranscript::default(),
107            tools: BundleTools::default(),
108            permissions: Vec::new(),
109            replay: BundleReplay::default(),
110            redaction: RedactionManifest::default(),
111            attachments: Vec::new(),
112            metadata: BTreeMap::new(),
113        }
114    }
115}
116
117#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
118#[serde(default)]
119pub struct BundleProducer {
120    pub name: String,
121    pub version: String,
122    pub schema_id: String,
123}
124
125#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
126#[serde(default)]
127pub struct BundleSource {
128    pub kind: String,
129    pub run_record_id: String,
130    pub workflow_id: String,
131    pub workflow_name: Option<String>,
132    pub task: String,
133    pub status: String,
134    pub started_at: String,
135    pub finished_at: Option<String>,
136    pub persisted_path: Option<String>,
137    pub root_run_id: Option<String>,
138    pub parent_run_id: Option<String>,
139    pub child_run_count: usize,
140}
141
142#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
143#[serde(default)]
144pub struct BundleRuntime {
145    pub harn_version: String,
146    pub provider_models: Vec<String>,
147    pub usage: Option<BundleUsage>,
148    pub metadata: BTreeMap<String, JsonValue>,
149}
150
151#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
152#[serde(default)]
153pub struct BundleUsage {
154    pub input_tokens: i64,
155    pub output_tokens: i64,
156    pub call_count: i64,
157    pub total_duration_ms: i64,
158    pub total_cost: f64,
159    pub models: Vec<String>,
160}
161
162#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
163#[serde(default)]
164pub struct BundleWorkspace {
165    /// Primary workspace path the session is anchored against.
166    pub primary: Option<String>,
167    /// Additional roots mounted alongside the primary anchor.
168    #[serde(default)]
169    pub additional_roots: Vec<BundleMountedRoot>,
170    /// RFC3339 timestamp when the anchor was set.
171    pub anchored_at: Option<String>,
172    /// Bundle workspace policy label. Currently always
173    /// `"safe_identity_only"`; future revisions may tie this to mount
174    /// modes once the PathScope matcher (#2216) lands.
175    pub policy: String,
176}
177
178#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
179#[serde(default)]
180pub struct BundleMountedRoot {
181    pub path: String,
182    pub mount_mode: String,
183    pub mounted_at: String,
184}
185
186impl From<&MountedRoot> for BundleMountedRoot {
187    fn from(root: &MountedRoot) -> Self {
188        Self {
189            path: root.path.to_string_lossy().into_owned(),
190            mount_mode: root.mount_mode.as_str().to_string(),
191            mounted_at: root.mounted_at.clone(),
192        }
193    }
194}
195
196impl From<&WorkspaceAnchor> for BundleWorkspace {
197    fn from(anchor: &WorkspaceAnchor) -> Self {
198        Self {
199            primary: Some(anchor.primary.to_string_lossy().into_owned()),
200            additional_roots: anchor.additional_roots.iter().map(Into::into).collect(),
201            anchored_at: Some(anchor.anchored_at.clone()),
202            policy: "safe_identity_only".to_string(),
203        }
204    }
205}
206
207#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
208#[serde(default)]
209pub struct BundleTranscript {
210    pub sections: Vec<BundleTranscriptSection>,
211}
212
213#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
214#[serde(default)]
215pub struct BundleTranscriptSection {
216    pub id: String,
217    pub label: String,
218    pub scope: String,
219    pub location: String,
220    pub summary: Option<String>,
221    pub messages: Vec<JsonValue>,
222    pub events: Vec<JsonValue>,
223    pub assets: Vec<JsonValue>,
224    pub metadata: BTreeMap<String, JsonValue>,
225}
226
227#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
228#[serde(default)]
229pub struct BundleTools {
230    pub schemas: Vec<BundleJsonEntry>,
231    pub calls: Vec<BundleToolCall>,
232}
233
234#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
235#[serde(default)]
236pub struct BundleJsonEntry {
237    pub source: String,
238    pub index: usize,
239    pub value: JsonValue,
240}
241
242#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
243#[serde(default)]
244pub struct BundleToolCall {
245    pub tool_name: String,
246    pub tool_use_id: String,
247    pub args_hash: String,
248    pub result: String,
249    pub is_rejected: bool,
250    pub duration_ms: u64,
251    pub iteration: usize,
252    pub timestamp: String,
253}
254
255impl From<&ToolCallRecord> for BundleToolCall {
256    fn from(record: &ToolCallRecord) -> Self {
257        Self {
258            tool_name: record.tool_name.clone(),
259            tool_use_id: record.tool_use_id.clone(),
260            args_hash: record.args_hash.clone(),
261            result: record.result.clone(),
262            is_rejected: record.is_rejected,
263            duration_ms: record.duration_ms,
264            iteration: record.iteration,
265            timestamp: record.timestamp.clone(),
266        }
267    }
268}
269
270#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
271#[serde(default)]
272pub struct BundlePermission {
273    pub kind: String,
274    pub source: String,
275    pub request_id: Option<String>,
276    pub agent: Option<String>,
277    pub payload: JsonValue,
278}
279
280#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
281#[serde(default)]
282pub struct BundleReplay {
283    pub replay_fixture: Option<ReplayFixture>,
284    pub run_record: Option<JsonValue>,
285    pub event_log_pointers: Vec<BundleEventLogPointer>,
286    pub transitions: Vec<RunTransitionRecord>,
287    pub checkpoints: Vec<RunCheckpointRecord>,
288    pub trace_spans: Vec<RunTraceSpanRecord>,
289    pub deterministic_events: Vec<BundleJsonEntry>,
290}
291
292#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
293#[serde(default)]
294pub struct BundleEventLogPointer {
295    pub kind: String,
296    pub topic: Option<String>,
297    pub path: Option<String>,
298    pub location: String,
299    pub available: bool,
300}
301
302#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
303#[serde(default)]
304pub struct RedactionManifest {
305    pub mode: String,
306    pub policy: String,
307    pub placeholder: String,
308    pub entries: Vec<RedactionEntry>,
309    pub unsafe_secret_markers_rejected: bool,
310}
311
312#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
313#[serde(default)]
314pub struct RedactionEntry {
315    pub path: String,
316    pub class: String,
317    pub action: String,
318    pub replacement: Option<String>,
319}
320
321#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
322#[serde(default)]
323pub struct BundleAttachment {
324    pub id: String,
325    pub kind: String,
326    pub title: Option<String>,
327    pub stage: Option<String>,
328    pub text: Option<String>,
329    pub data: Option<JsonValue>,
330    pub metadata: BTreeMap<String, JsonValue>,
331}
332
333#[derive(Debug, Clone, PartialEq, Eq)]
334pub enum SessionBundleError {
335    Decode(String),
336    Encode(String),
337    MissingRequired(String),
338    UnsupportedSchemaVersion { found: u64, supported: u32 },
339    InvalidType { path: String, expected: String },
340    UnsafeSecretMarker { path: String, excerpt: String },
341    MissingRunRecord,
342}
343
344impl fmt::Display for SessionBundleError {
345    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
346        match self {
347            Self::Decode(error) => write!(f, "failed to decode session bundle: {error}"),
348            Self::Encode(error) => write!(f, "failed to encode session bundle: {error}"),
349            Self::MissingRequired(path) => {
350                write!(f, "session bundle is missing required field {path}")
351            }
352            Self::UnsupportedSchemaVersion { found, supported } => write!(
353                f,
354                "unsupported session bundle schema_version {found}; this build supports <= {supported}"
355            ),
356            Self::InvalidType { path, expected } => {
357                write!(f, "session bundle field {path} must be {expected}")
358            }
359            Self::UnsafeSecretMarker { path, excerpt } => write!(
360                f,
361                "session bundle contains an unsafe unredacted secret marker at {path}: {excerpt}"
362            ),
363            Self::MissingRunRecord => write!(f, "session bundle does not include an importable run record"),
364        }
365    }
366}
367
368impl std::error::Error for SessionBundleError {}
369
370pub fn export_run_record_bundle(
371    run: &RunRecord,
372    options: &SessionBundleExportOptions,
373) -> Result<SessionBundle, SessionBundleError> {
374    let run_record_value =
375        serde_json::to_value(run).map_err(|error| SessionBundleError::Encode(error.to_string()))?;
376    let mut bundle = raw_bundle_from_run(run, run_record_value, options.include_attachments)?;
377    let mut bundle_value = serde_json::to_value(&bundle)
378        .map_err(|error| SessionBundleError::Encode(error.to_string()))?;
379
380    let mut manifest = RedactionManifest {
381        mode: options.mode.as_str().to_string(),
382        policy: if matches!(options.mode, SessionBundleExportMode::Local) {
383            "none".to_string()
384        } else {
385            "harn_vm::redact::RedactionPolicy::default+session_bundle_local_paths".to_string()
386        },
387        placeholder: REDACTED_PLACEHOLDER.to_string(),
388        entries: Vec::new(),
389        unsafe_secret_markers_rejected: !matches!(options.mode, SessionBundleExportMode::Local),
390    };
391
392    if !matches!(options.mode, SessionBundleExportMode::Local) {
393        let redaction_policy = bundle_redaction_policy(&options.redaction_policy);
394        redact_json_with_manifest(
395            &mut bundle_value,
396            "$",
397            &redaction_policy,
398            &mut manifest.entries,
399        );
400        redact_bundle_pointer_paths_json(&mut bundle_value, "$", &mut manifest.entries);
401    }
402    if matches!(options.mode, SessionBundleExportMode::ReplayOnly) {
403        withhold_replay_only_json(&mut bundle_value, "$", &mut manifest.entries);
404    }
405    set_json_path(
406        &mut bundle_value,
407        &["redaction"],
408        serde_json::to_value(&manifest)
409            .map_err(|error| SessionBundleError::Encode(error.to_string()))?,
410    );
411    bundle = serde_json::from_value(bundle_value)
412        .map_err(|error| SessionBundleError::Decode(error.to_string()))?;
413    Ok(bundle)
414}
415
416pub fn validate_session_bundle_value(
417    value: &JsonValue,
418    options: &SessionBundleValidationOptions,
419) -> Result<SessionBundle, SessionBundleError> {
420    require_field(value, "_type")?;
421    require_field(value, "schema_version")?;
422    require_field(value, "bundle_id")?;
423    require_field(value, "created_at")?;
424    require_field(value, "producer")?;
425    require_field(value, "source")?;
426    require_field(value, "runtime")?;
427    require_field(value, "transcript")?;
428    require_field(value, "tools")?;
429    require_field(value, "permissions")?;
430    require_field(value, "replay")?;
431    require_field(value, "redaction")?;
432    require_field(value, "attachments")?;
433    require_nested_field(value, &["producer", "name"])?;
434    require_nested_field(value, &["producer", "version"])?;
435    require_nested_field(value, &["producer", "schema_id"])?;
436    require_nested_field(value, &["source", "kind"])?;
437    require_nested_field(value, &["source", "run_record_id"])?;
438    require_nested_field(value, &["source", "workflow_id"])?;
439    require_nested_field(value, &["source", "task"])?;
440    require_nested_field(value, &["source", "status"])?;
441    require_nested_field(value, &["runtime", "harn_version"])?;
442    require_nested_field(value, &["runtime", "provider_models"])?;
443    require_nested_field(value, &["transcript", "sections"])?;
444    require_nested_field(value, &["tools", "schemas"])?;
445    require_nested_field(value, &["tools", "calls"])?;
446    require_nested_field(value, &["replay", "event_log_pointers"])?;
447    require_nested_field(value, &["replay", "transitions"])?;
448    require_nested_field(value, &["replay", "checkpoints"])?;
449    require_nested_field(value, &["replay", "trace_spans"])?;
450    require_nested_field(value, &["replay", "deterministic_events"])?;
451    require_nested_field(value, &["redaction", "mode"])?;
452    require_nested_field(value, &["redaction", "policy"])?;
453    require_nested_field(value, &["redaction", "placeholder"])?;
454    require_nested_field(value, &["redaction", "entries"])?;
455    require_nested_field(value, &["redaction", "unsafe_secret_markers_rejected"])?;
456
457    let type_name = value
458        .get("_type")
459        .and_then(JsonValue::as_str)
460        .ok_or_else(|| SessionBundleError::InvalidType {
461            path: "$._type".to_string(),
462            expected: "string".to_string(),
463        })?;
464    if type_name != SESSION_BUNDLE_TYPE {
465        return Err(SessionBundleError::InvalidType {
466            path: "$._type".to_string(),
467            expected: format!("\"{SESSION_BUNDLE_TYPE}\""),
468        });
469    }
470
471    let version = value
472        .get("schema_version")
473        .and_then(JsonValue::as_u64)
474        .ok_or_else(|| SessionBundleError::InvalidType {
475            path: "$.schema_version".to_string(),
476            expected: "positive integer".to_string(),
477        })?;
478    if version == 0 || version > u64::from(SESSION_BUNDLE_SCHEMA_VERSION) {
479        return Err(SessionBundleError::UnsupportedSchemaVersion {
480            found: version,
481            supported: SESSION_BUNDLE_SCHEMA_VERSION,
482        });
483    }
484
485    if !options.allow_unsafe_secret_markers {
486        reject_unredacted_secret_markers(value, "$", &options.redaction_policy)?;
487    }
488
489    serde_json::from_value::<SessionBundle>(value.clone())
490        .map_err(|error| SessionBundleError::Decode(error.to_string()))
491}
492
493pub fn validate_session_bundle_str(
494    content: &str,
495    options: &SessionBundleValidationOptions,
496) -> Result<SessionBundle, SessionBundleError> {
497    let value: JsonValue = serde_json::from_str(content)
498        .map_err(|error| SessionBundleError::Decode(error.to_string()))?;
499    validate_session_bundle_value(&value, options)
500}
501
502pub fn import_run_record_value(bundle: &SessionBundle) -> Result<JsonValue, SessionBundleError> {
503    if let Some(run_record) = bundle.replay.run_record.clone() {
504        return Ok(run_record);
505    }
506    if let Some(fixture) = &bundle.replay.replay_fixture {
507        let transcript = bundle.transcript.sections.first().map(|section| {
508            json!({
509                "_type": "transcript",
510                "messages": section.messages.clone(),
511                "events": section.events.clone(),
512                "assets": section.assets.clone(),
513                "summary": section.summary.clone(),
514                "metadata": section.metadata.clone(),
515            })
516        });
517        let hitl_questions = bundle
518            .permissions
519            .iter()
520            .filter(|permission| permission.kind == "hitl_question")
521            .map(|permission| permission.payload.clone())
522            .collect::<Vec<_>>();
523        return Ok(json!({
524            "_type": "run_record",
525            "id": bundle.source.run_record_id.clone(),
526            "workflow_id": bundle.source.workflow_id.clone(),
527            "workflow_name": bundle.source.workflow_name.clone(),
528            "task": bundle.source.task.clone(),
529            "status": bundle.source.status.clone(),
530            "started_at": bundle.source.started_at.clone(),
531            "finished_at": bundle.source.finished_at.clone(),
532            "stages": [],
533            "transitions": bundle.replay.transitions.clone(),
534            "checkpoints": bundle.replay.checkpoints.clone(),
535            "pending_nodes": [],
536            "completed_nodes": [],
537            "child_runs": [],
538            "artifacts": [],
539            "handoffs": [],
540            "policy": {},
541            "transcript": transcript,
542            "usage": bundle.runtime.usage.clone(),
543            "replay_fixture": fixture,
544            "trace_spans": bundle.replay.trace_spans.clone(),
545            "tool_recordings": bundle.tools.calls.clone(),
546            "hitl_questions": hitl_questions,
547            "persona_runtime": [],
548            "metadata": {
549                "imported_from_session_bundle": bundle.bundle_id.clone(),
550                "session_bundle_schema_version": bundle.schema_version,
551            }
552        }));
553    }
554    Err(SessionBundleError::MissingRunRecord)
555}
556
557fn raw_bundle_from_run(
558    run: &RunRecord,
559    run_record_value: JsonValue,
560    include_attachments: bool,
561) -> Result<SessionBundle, SessionBundleError> {
562    let mut bundle = SessionBundle {
563        bundle_id: new_id("bundle"),
564        created_at: now_rfc3339(),
565        producer: BundleProducer {
566            name: "harn".to_string(),
567            version: env!("CARGO_PKG_VERSION").to_string(),
568            schema_id: SESSION_BUNDLE_SCHEMA_ID.to_string(),
569        },
570        source: BundleSource {
571            kind: "run_record".to_string(),
572            run_record_id: run.id.clone(),
573            workflow_id: run.workflow_id.clone(),
574            workflow_name: run.workflow_name.clone(),
575            task: run.task.clone(),
576            status: run.status.clone(),
577            started_at: run.started_at.clone(),
578            finished_at: run.finished_at.clone(),
579            persisted_path: run.persisted_path.clone(),
580            root_run_id: run.root_run_id.clone(),
581            parent_run_id: run.parent_run_id.clone(),
582            child_run_count: run.child_runs.len(),
583        },
584        runtime: BundleRuntime {
585            harn_version: env!("CARGO_PKG_VERSION").to_string(),
586            provider_models: run
587                .usage
588                .as_ref()
589                .map(|usage| usage.models.clone())
590                .unwrap_or_default(),
591            usage: run.usage.as_ref().map(|usage| BundleUsage {
592                input_tokens: usage.input_tokens,
593                output_tokens: usage.output_tokens,
594                call_count: usage.call_count,
595                total_duration_ms: usage.total_duration_ms,
596                total_cost: usage.total_cost,
597                models: usage.models.clone(),
598            }),
599            metadata: BTreeMap::new(),
600        },
601        workspace: workspace_from_run(run),
602        transcript: transcript_from_run(run),
603        tools: BundleTools {
604            schemas: tool_schema_entries(run),
605            calls: run
606                .tool_recordings
607                .iter()
608                .map(BundleToolCall::from)
609                .collect(),
610        },
611        permissions: permissions_from_run(run),
612        replay: BundleReplay {
613            replay_fixture: run.replay_fixture.clone(),
614            run_record: Some(run_record_value),
615            event_log_pointers: event_log_pointers_from_run(run),
616            transitions: run.transitions.clone(),
617            checkpoints: run.checkpoints.clone(),
618            trace_spans: run.trace_spans.clone(),
619            deterministic_events: deterministic_events_from_run(run)?,
620        },
621        redaction: RedactionManifest {
622            mode: "sanitized".to_string(),
623            policy: "harn_vm::redact::RedactionPolicy::default+session_bundle_local_paths"
624                .to_string(),
625            placeholder: REDACTED_PLACEHOLDER.to_string(),
626            entries: Vec::new(),
627            unsafe_secret_markers_rejected: true,
628        },
629        attachments: if include_attachments {
630            attachments_from_run(run)
631        } else {
632            Vec::new()
633        },
634        ..SessionBundle::default()
635    };
636    bundle.metadata.insert(
637        "format_note".to_string(),
638        json!("Session bundles are portable JSON envelopes; hosted share links should reference sanitized bundles rather than raw run records."),
639    );
640    Ok(bundle)
641}
642
643fn bundle_redaction_policy(base: &RedactionPolicy) -> RedactionPolicy {
644    base.clone()
645        .with_extra_field("persisted_path")
646        .with_extra_field("primary")
647        .with_extra_field("run_path")
648        .with_extra_field("snapshot_path")
649}
650
651fn workspace_from_run(run: &RunRecord) -> Option<BundleWorkspace> {
652    let anchor = run
653        .transcript
654        .as_ref()
655        .and_then(|transcript| transcript.get("metadata"))
656        .and_then(anchor_from_transcript_metadata_json)?;
657    Some(BundleWorkspace::from(&anchor))
658}
659
660fn transcript_from_run(run: &RunRecord) -> BundleTranscript {
661    let mut sections = Vec::new();
662    if let Some(transcript) = &run.transcript {
663        sections.push(transcript_section(
664            "run",
665            "Run transcript",
666            "run",
667            "$.transcript",
668            transcript,
669        ));
670    }
671    for (index, stage) in run.stages.iter().enumerate() {
672        if let Some(transcript) = &stage.transcript {
673            sections.push(transcript_section(
674                &stage.id,
675                &format!("Stage {}", stage.node_id),
676                "stage",
677                &format!("$.stages[{index}].transcript"),
678                transcript,
679            ));
680        }
681    }
682    BundleTranscript { sections }
683}
684
685fn transcript_section(
686    id: &str,
687    label: &str,
688    scope: &str,
689    location: &str,
690    transcript: &JsonValue,
691) -> BundleTranscriptSection {
692    BundleTranscriptSection {
693        id: id.to_string(),
694        label: label.to_string(),
695        scope: scope.to_string(),
696        location: location.to_string(),
697        summary: transcript
698            .get("summary")
699            .and_then(JsonValue::as_str)
700            .map(str::to_string),
701        messages: json_array(transcript.get("messages")),
702        events: json_array(transcript.get("events")),
703        assets: json_array(transcript.get("assets")),
704        metadata: transcript
705            .get("metadata")
706            .and_then(JsonValue::as_object)
707            .map(|map| {
708                map.iter()
709                    .map(|(key, value)| (key.clone(), value.clone()))
710                    .collect()
711            })
712            .unwrap_or_default(),
713    }
714}
715
716fn json_array(value: Option<&JsonValue>) -> Vec<JsonValue> {
717    value
718        .and_then(JsonValue::as_array)
719        .cloned()
720        .unwrap_or_default()
721}
722
723fn tool_schema_entries(run: &RunRecord) -> Vec<BundleJsonEntry> {
724    let mut entries = Vec::new();
725    collect_tool_schema_entries_from_transcript(&mut entries, "run.transcript", &run.transcript);
726    for stage in &run.stages {
727        collect_tool_schema_entries_from_transcript(
728            &mut entries,
729            &format!("stage.{}.transcript", stage.node_id),
730            &stage.transcript,
731        );
732        if let Some(tools) = stage
733            .metadata
734            .get("tool_schemas")
735            .or_else(|| stage.metadata.get("tools"))
736        {
737            entries.push(BundleJsonEntry {
738                source: format!("stage.{}.metadata", stage.node_id),
739                index: entries.len(),
740                value: tools.clone(),
741            });
742        }
743    }
744    entries
745}
746
747fn collect_tool_schema_entries_from_transcript(
748    entries: &mut Vec<BundleJsonEntry>,
749    source: &str,
750    transcript: &Option<JsonValue>,
751) {
752    let Some(transcript) = transcript else {
753        return;
754    };
755    for event in transcript
756        .get("events")
757        .and_then(JsonValue::as_array)
758        .into_iter()
759        .flatten()
760    {
761        let kind = event
762            .get("type")
763            .or_else(|| event.get("kind"))
764            .and_then(JsonValue::as_str)
765            .unwrap_or_default();
766        if kind == "tool_schemas" || kind == "tool_schema" {
767            entries.push(BundleJsonEntry {
768                source: source.to_string(),
769                index: entries.len(),
770                value: event.clone(),
771            });
772        }
773    }
774}
775
776fn permissions_from_run(run: &RunRecord) -> Vec<BundlePermission> {
777    let mut permissions = run
778        .hitl_questions
779        .iter()
780        .map(permission_from_hitl_question)
781        .collect::<Vec<_>>();
782    collect_permission_events(&mut permissions, "run.transcript", &run.transcript);
783    for stage in &run.stages {
784        collect_permission_events(
785            &mut permissions,
786            &format!("stage.{}.transcript", stage.node_id),
787            &stage.transcript,
788        );
789        if let Some(worker) = stage.metadata.get("worker") {
790            if let Some(policy) = worker
791                .get("audit")
792                .and_then(|audit| audit.get("approval_policy"))
793            {
794                permissions.push(BundlePermission {
795                    kind: "approval_policy".to_string(),
796                    source: format!("stage.{}.worker.audit", stage.node_id),
797                    request_id: None,
798                    agent: worker
799                        .get("name")
800                        .and_then(JsonValue::as_str)
801                        .map(str::to_string),
802                    payload: policy.clone(),
803                });
804            }
805        }
806    }
807    permissions
808}
809
810fn permission_from_hitl_question(question: &RunHitlQuestionRecord) -> BundlePermission {
811    BundlePermission {
812        kind: "hitl_question".to_string(),
813        source: "run.hitl_questions".to_string(),
814        request_id: Some(question.request_id.clone()),
815        agent: if question.agent.is_empty() {
816            None
817        } else {
818            Some(question.agent.clone())
819        },
820        payload: serde_json::to_value(question).unwrap_or(JsonValue::Null),
821    }
822}
823
824fn collect_permission_events(
825    permissions: &mut Vec<BundlePermission>,
826    source: &str,
827    transcript: &Option<JsonValue>,
828) {
829    let Some(transcript) = transcript else {
830        return;
831    };
832    for event in transcript
833        .get("events")
834        .and_then(JsonValue::as_array)
835        .into_iter()
836        .flatten()
837    {
838        let kind = event
839            .get("type")
840            .or_else(|| event.get("kind"))
841            .and_then(JsonValue::as_str)
842            .unwrap_or_default();
843        if kind.contains("permission") || kind.contains("approval") || kind.starts_with("hitl_") {
844            permissions.push(BundlePermission {
845                kind: kind.to_string(),
846                source: source.to_string(),
847                request_id: event
848                    .get("request_id")
849                    .or_else(|| event.get("id"))
850                    .and_then(JsonValue::as_str)
851                    .map(str::to_string),
852                agent: event
853                    .get("agent")
854                    .and_then(JsonValue::as_str)
855                    .map(str::to_string),
856                payload: event.clone(),
857            });
858        }
859    }
860}
861
862fn event_log_pointers_from_run(run: &RunRecord) -> Vec<BundleEventLogPointer> {
863    let mut pointers = Vec::new();
864    if let Some(observability) = &run.observability {
865        for pointer in &observability.transcript_pointers {
866            pointers.push(BundleEventLogPointer {
867                kind: pointer.kind.clone(),
868                topic: None,
869                path: pointer.path.clone(),
870                location: pointer.location.clone(),
871                available: pointer.available,
872            });
873        }
874        for worker in &observability.worker_lineage {
875            if let Some(session_id) = &worker.session_id {
876                pointers.push(BundleEventLogPointer {
877                    kind: "agent_events".to_string(),
878                    topic: Some(format!("observability.agent_events.{session_id}")),
879                    path: worker.snapshot_path.clone(),
880                    location: format!("worker.{}.session", worker.worker_id),
881                    available: worker.snapshot_path.is_some(),
882                });
883            }
884        }
885    }
886    pointers
887}
888
889fn deterministic_events_from_run(
890    run: &RunRecord,
891) -> Result<Vec<BundleJsonEntry>, SessionBundleError> {
892    let mut events = Vec::new();
893    for (index, transition) in run.transitions.iter().enumerate() {
894        events.push(BundleJsonEntry {
895            source: "run.transitions".to_string(),
896            index,
897            value: serde_json::to_value(transition)
898                .map_err(|error| SessionBundleError::Encode(error.to_string()))?,
899        });
900    }
901    for (index, checkpoint) in run.checkpoints.iter().enumerate() {
902        events.push(BundleJsonEntry {
903            source: "run.checkpoints".to_string(),
904            index,
905            value: serde_json::to_value(checkpoint)
906                .map_err(|error| SessionBundleError::Encode(error.to_string()))?,
907        });
908    }
909    Ok(events)
910}
911
912fn attachments_from_run(run: &RunRecord) -> Vec<BundleAttachment> {
913    run.artifacts
914        .iter()
915        .map(|artifact| BundleAttachment {
916            id: artifact.id.clone(),
917            kind: artifact.kind.clone(),
918            title: artifact.title.clone(),
919            stage: artifact.stage.clone(),
920            text: artifact.text.clone(),
921            data: artifact.data.clone(),
922            metadata: artifact.metadata.clone(),
923        })
924        .collect()
925}
926
927fn redact_json_with_manifest(
928    value: &mut JsonValue,
929    path: &str,
930    policy: &RedactionPolicy,
931    entries: &mut Vec<RedactionEntry>,
932) {
933    match value {
934        JsonValue::Object(map) => {
935            let keys = map.keys().cloned().collect::<Vec<_>>();
936            for key in keys {
937                let child_path = json_path_child(path, &key);
938                if policy.field_is_sensitive(&key) {
939                    map.insert(key, JsonValue::String(REDACTED_PLACEHOLDER.to_string()));
940                    entries.push(RedactionEntry {
941                        path: child_path,
942                        class: "sensitive_field".to_string(),
943                        action: "replaced".to_string(),
944                        replacement: Some(REDACTED_PLACEHOLDER.to_string()),
945                    });
946                } else if let Some(child) = map.get_mut(&key) {
947                    redact_json_with_manifest(child, &child_path, policy, entries);
948                }
949            }
950        }
951        JsonValue::Array(items) => {
952            for (index, item) in items.iter_mut().enumerate() {
953                redact_json_with_manifest(item, &format!("{path}[{index}]"), policy, entries);
954            }
955        }
956        JsonValue::String(text) => {
957            let redacted = policy.redact_string(text);
958            if let Cow::Owned(replacement) = redacted {
959                // Record the actual replacement string (now a named
960                // `<redacted:<pattern>:<len>>` placeholder from the
961                // OA-06 catalog) in the manifest so audit consumers
962                // can attribute the leak to a specific provider.
963                let manifest_replacement = replacement.clone();
964                *text = replacement;
965                entries.push(RedactionEntry {
966                    path: path.to_string(),
967                    class: "secret_pattern_or_url".to_string(),
968                    action: "replaced".to_string(),
969                    replacement: Some(manifest_replacement),
970                });
971            }
972        }
973        _ => {}
974    }
975}
976
977fn redact_bundle_pointer_paths_json(
978    value: &mut JsonValue,
979    path: &str,
980    entries: &mut Vec<RedactionEntry>,
981) {
982    match value {
983        JsonValue::Object(map) => {
984            let keys = map.keys().cloned().collect::<Vec<_>>();
985            for key in keys {
986                let child_path = json_path_child(path, &key);
987                if key == "path" && bundle_pointer_path_should_redact(&child_path) {
988                    if !map.get(&key).is_some_and(JsonValue::is_null) {
989                        map.insert(key, JsonValue::String(REDACTED_PLACEHOLDER.to_string()));
990                        entries.push(RedactionEntry {
991                            path: child_path,
992                            class: "local_pointer_path".to_string(),
993                            action: "replaced".to_string(),
994                            replacement: Some(REDACTED_PLACEHOLDER.to_string()),
995                        });
996                    }
997                } else if let Some(child) = map.get_mut(&key) {
998                    redact_bundle_pointer_paths_json(child, &child_path, entries);
999                }
1000            }
1001        }
1002        JsonValue::Array(items) => {
1003            for (index, item) in items.iter_mut().enumerate() {
1004                redact_bundle_pointer_paths_json(item, &format!("{path}[{index}]"), entries);
1005            }
1006        }
1007        _ => {}
1008    }
1009}
1010
1011fn bundle_pointer_path_should_redact(path: &str) -> bool {
1012    path.contains(".event_log_pointers[") || path.contains(".transcript_pointers[")
1013}
1014
1015fn withhold_replay_only_json(value: &mut JsonValue, path: &str, entries: &mut Vec<RedactionEntry>) {
1016    match value {
1017        JsonValue::Object(map) => {
1018            let keys = map.keys().cloned().collect::<Vec<_>>();
1019            for key in keys {
1020                let child_path = json_path_child(path, &key);
1021                if replay_only_field_is_prompt_payload(&key) {
1022                    if !map.get(&key).is_some_and(JsonValue::is_null) {
1023                        map.insert(key, JsonValue::String(REPLAY_ONLY_PLACEHOLDER.to_string()));
1024                        entries.push(RedactionEntry {
1025                            path: child_path,
1026                            class: "prompt_or_tool_payload".to_string(),
1027                            action: "withheld".to_string(),
1028                            replacement: Some(REPLAY_ONLY_PLACEHOLDER.to_string()),
1029                        });
1030                    }
1031                } else if let Some(child) = map.get_mut(&key) {
1032                    withhold_replay_only_json(child, &child_path, entries);
1033                }
1034            }
1035        }
1036        JsonValue::Array(items) => {
1037            for (index, item) in items.iter_mut().enumerate() {
1038                withhold_replay_only_json(item, &format!("{path}[{index}]"), entries);
1039            }
1040        }
1041        _ => {}
1042    }
1043}
1044
1045fn replay_only_field_is_prompt_payload(key: &str) -> bool {
1046    matches!(
1047        key,
1048        "args"
1049            | "arguments"
1050            | "blocks"
1051            | "content"
1052            | "data"
1053            | "private_reasoning"
1054            | "prompt"
1055            | "raw_input"
1056            | "raw_output"
1057            | "result"
1058            | "response_text"
1059            | "summary"
1060            | "system"
1061            | "system_prompt"
1062            | "task"
1063            | "text"
1064            | "thinking"
1065            | "visible_text"
1066    )
1067}
1068
1069fn set_json_path(value: &mut JsonValue, path: &[&str], replacement: JsonValue) {
1070    let Some((head, tail)) = path.split_first() else {
1071        *value = replacement;
1072        return;
1073    };
1074    if tail.is_empty() {
1075        if let JsonValue::Object(map) = value {
1076            map.insert((*head).to_string(), replacement);
1077        }
1078        return;
1079    }
1080    if let JsonValue::Object(map) = value {
1081        if let Some(child) = map.get_mut(*head) {
1082            set_json_path(child, tail, replacement);
1083        }
1084    }
1085}
1086
1087fn require_field(value: &JsonValue, field: &str) -> Result<(), SessionBundleError> {
1088    if value.get(field).is_some() {
1089        Ok(())
1090    } else {
1091        Err(SessionBundleError::MissingRequired(format!("$.{field}")))
1092    }
1093}
1094
1095fn require_nested_field(value: &JsonValue, path: &[&str]) -> Result<(), SessionBundleError> {
1096    let mut current = value;
1097    for segment in path {
1098        current = current
1099            .get(*segment)
1100            .ok_or_else(|| SessionBundleError::MissingRequired(json_path_from_segments(path)))?;
1101    }
1102    Ok(())
1103}
1104
1105fn json_path_from_segments(path: &[&str]) -> String {
1106    path.iter().fold("$".to_string(), |parent, segment| {
1107        json_path_child(&parent, segment)
1108    })
1109}
1110
1111fn reject_unredacted_secret_markers(
1112    value: &JsonValue,
1113    path: &str,
1114    policy: &RedactionPolicy,
1115) -> Result<(), SessionBundleError> {
1116    match value {
1117        JsonValue::Object(map) => {
1118            for (key, child) in map {
1119                reject_unredacted_secret_markers(child, &json_path_child(path, key), policy)?;
1120            }
1121        }
1122        JsonValue::Array(items) => {
1123            for (index, item) in items.iter().enumerate() {
1124                reject_unredacted_secret_markers(item, &format!("{path}[{index}]"), policy)?;
1125            }
1126        }
1127        JsonValue::String(text) => {
1128            if matches!(policy.redact_string(text), Cow::Owned(_)) {
1129                return Err(SessionBundleError::UnsafeSecretMarker {
1130                    path: path.to_string(),
1131                    excerpt: secret_excerpt(text),
1132                });
1133            }
1134        }
1135        _ => {}
1136    }
1137    Ok(())
1138}
1139
1140fn secret_excerpt(text: &str) -> String {
1141    let excerpt = text.chars().take(80).collect::<String>();
1142    if text.chars().count() > 80 {
1143        format!("{excerpt}...")
1144    } else {
1145        excerpt
1146    }
1147}
1148
1149fn json_path_child(parent: &str, key: &str) -> String {
1150    if key
1151        .chars()
1152        .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
1153    {
1154        format!("{parent}.{key}")
1155    } else {
1156        format!(
1157            "{parent}[{}]",
1158            serde_json::to_string(key).unwrap_or_default()
1159        )
1160    }
1161}