Skip to main content

runtime/
transcript.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! What one run actually saw, written beside its room when it freezes.
7//!
8//! One `ChatMessage` per line, in the serde form the wire uses, so a
9//! line number `search` reports is a message index `read` continues
10//! from. The frozen prefix is not here: it is the half of every request
11//! that never changes, and the ledger's `prompt_assembled` already
12//! holds its hash.
13//!
14//! **This is not the ledger, and it must not point at it.** The ledger
15//! lives under the reserved subtree `read` refuses, and it is one chain
16//! for the whole city — handing it to one resident hands over a
17//! confidential building's events as well. A transcript is one run's,
18//! written in that run's room, and who may read it is the read bound's
19//! answer for that room: `read` and `search` ask it of a transcript's
20//! path as of any other, so a confidential building's transcripts stay
21//! inside it (`crates/city/Spec.lean` §8-2).
22//!
23//! Three steps in a fixed order: scanned for credentials, pinned into
24//! the CAS, then materialised. Scanned before pinned, because a key that
25//! reached the content store cannot be taken back out.
26
27use std::path::Path;
28
29use kernel::layout::TRANSCRIPT_EXT;
30use kernel::{Address, AxCode, AxError, Locator, RunId};
31use serde_json::Value;
32use storage::Cas;
33
34use crate::conversation::Conversation;
35use crate::redact;
36
37/// The messages one run's model saw, one JSON line each, already
38/// scanned. Private fields: a transcript that skipped the scan cannot
39/// be assembled a line at a time.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct Transcript {
42    run: RunId,
43    lines: Vec<String>,
44    redacted: u32,
45}
46
47/// Where a transcript landed, and the way back to its bytes.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct TranscriptRecord {
50    pub address: Address,
51    pub original: Locator,
52    pub redacted: u32,
53}
54
55impl Transcript {
56    /// Renders and scans every message of the window.
57    ///
58    /// # Errors
59    /// Refuses a message that will not serialise, which is a defect in
60    /// the wire types rather than in the conversation.
61    pub fn of(run: RunId, conversation: &Conversation) -> Result<Transcript, AxError> {
62        let mut lines = Vec::with_capacity(conversation.messages().len());
63        let mut redacted: u32 = 0;
64        for message in conversation.messages() {
65            let value = serde_json::to_value(message).map_err(|err| {
66                AxError::failure(
67                    AxCode::InvalidArgs,
68                    "render a transcript line",
69                    err.to_string(),
70                )
71                .with_recovery(
72                    "report this against runtime::transcript: a conversation message is text \
73                     and content blocks, and JSON refuses neither",
74                )
75            })?;
76            let Value::Object(map) = value else {
77                return Err(AxError::failure(
78                    AxCode::InvalidArgs,
79                    "render a transcript line",
80                    "a message is an object",
81                )
82                .with_recovery(
83                    "report this against runtime::transcript: a conversation message encodes \
84                     as a JSON object and this one encoded as something else",
85                ));
86            };
87            let (scanned, hits) = redact::redact(&map);
88            redacted = redacted.saturating_add(hits);
89            let line = serde_json::to_string(&Value::Object(scanned)).map_err(|err| {
90                AxError::failure(
91                    AxCode::InvalidArgs,
92                    "render a transcript line",
93                    err.to_string(),
94                )
95                .with_recovery(
96                    "report this against runtime::transcript: redaction returns the \
97                     same JSON object with some strings replaced",
98                )
99            })?;
100            lines.push(line);
101        }
102        Ok(Transcript {
103            run,
104            lines,
105            redacted,
106        })
107    }
108
109    /// `<room>/<run-id>.jsonl`. Pure, so a handoff can name the address
110    /// before the run it belongs to has taken a turn.
111    ///
112    /// # Errors
113    /// Propagates a room whose address cannot take another segment.
114    pub fn address(room: &Address, run: RunId) -> Result<Address, AxError> {
115        Address::parse(&format!("{}/{run}.{TRANSCRIPT_EXT}", room.as_str()))
116    }
117
118    pub fn run(&self) -> RunId {
119        self.run
120    }
121
122    pub fn lines(&self) -> &[String] {
123        &self.lines
124    }
125
126    /// How many secret-shaped spans the scan replaced.
127    pub fn redacted(&self) -> u32 {
128        self.redacted
129    }
130
131    /// The bytes of the file: every line newline-terminated.
132    pub fn bytes(&self) -> Vec<u8> {
133        let mut out = Vec::new();
134        for line in &self.lines {
135            out.extend_from_slice(line.as_bytes());
136            out.push(b'\n');
137        }
138        out
139    }
140
141    /// Pins the transcript in the store, then writes it read-only at
142    /// its address under `city_root`.
143    ///
144    /// Writing the same transcript twice is writing it once: the store
145    /// deduplicates by content, and a file already at the address is
146    /// left as it is.
147    ///
148    /// # Errors
149    /// Propagates a store that will not take the bytes and a room that
150    /// will not take the file, each naming the path.
151    pub fn materialise(
152        &self,
153        cas: &mut Cas,
154        city_root: &Path,
155        room: &Address,
156    ) -> Result<TranscriptRecord, AxError> {
157        let bytes = self.bytes();
158        let origin = storage::BlockOrigin {
159            run: self.run,
160            building: room.clone(),
161        };
162        let hash = cas
163            .put_for(&bytes, &origin)
164            .map_err(storage::StorageError::into_ax)?;
165        let original = Locator::cas(hash);
166        let address = Transcript::address(room, self.run)?;
167        let path = city_root.join(address.as_str());
168        let io = |err: std::io::Error| {
169            AxError::failure(
170                AxCode::StorageFatal,
171                "materialise a transcript",
172                format!("{}: {err}", path.display()),
173            )
174            .with_recovery("check the room is writable; the transcript is still in the store")
175        };
176        if !path.exists() {
177            std::fs::write(&path, &bytes).map_err(io)?;
178            let mut perms = std::fs::metadata(&path).map_err(io)?.permissions();
179            perms.set_readonly(true);
180            std::fs::set_permissions(&path, perms).map_err(io)?;
181        }
182        Ok(TranscriptRecord {
183            address,
184            original,
185            redacted: self.redacted,
186        })
187    }
188}
189
190#[cfg(test)]
191#[allow(
192    clippy::unwrap_used,
193    clippy::expect_used,
194    clippy::panic,
195    clippy::indexing_slicing,
196    reason = "test code"
197)]
198mod tests {
199    use super::*;
200    use kernel::ContentBlock;
201
202    fn run() -> RunId {
203        RunId::parse("0198f6a2-7c4a-7bbb-9d1e-000000000009").unwrap()
204    }
205
206    fn window_with_a_call() -> Conversation {
207        let mut conversation = Conversation::new();
208        conversation.push_task_lines("look", "one call", crate::conversation::Opening::FromJob);
209        conversation.push_assistant(vec![ContentBlock::ToolUse {
210            id: "tu_1".to_owned(),
211            name: kernel::ToolName::parse("status").unwrap(),
212            input: kernel::Payload::empty(),
213        }]);
214        conversation.push_tool_results(vec![ContentBlock::ToolResult {
215            tool_use_id: "tu_1".to_owned(),
216            content: "{\"ok\":true}".to_owned(),
217            is_error: false,
218            attachments: Vec::new(),
219        }]);
220        conversation
221    }
222
223    #[test]
224    fn one_line_per_message_in_the_wire_shape() {
225        let transcript = Transcript::of(run(), &window_with_a_call()).unwrap();
226        assert_eq!(transcript.lines().len(), 3);
227        let first: Value = serde_json::from_str(&transcript.lines()[0]).unwrap();
228        assert_eq!(first["role"], "user");
229        assert!(transcript.lines()[1].contains("tu_1"));
230        assert!(transcript.lines()[2].contains("tu_1"));
231        assert_eq!(transcript.redacted(), 0);
232    }
233
234    #[test]
235    fn a_key_the_model_saw_does_not_reach_the_file() {
236        let key = format!("sk-ant-api03-{}", "A".repeat(80));
237        let mut conversation = Conversation::new();
238        conversation.push_tool_results(vec![ContentBlock::ToolResult {
239            tool_use_id: "tu_1".to_owned(),
240            content: format!("token={key}"),
241            is_error: false,
242            attachments: Vec::new(),
243        }]);
244        let transcript = Transcript::of(run(), &conversation).unwrap();
245        assert!(
246            !transcript.lines()[0].contains(&key),
247            "{:?}",
248            transcript.lines()
249        );
250        assert!(transcript.redacted() >= 1);
251    }
252
253    #[test]
254    fn the_address_is_the_room_and_the_run_and_never_the_reserved_subtree() {
255        let room = Address::parse("lab/room1").unwrap();
256        let address = Transcript::address(&room, run()).unwrap();
257        assert_eq!(
258            address.as_str(),
259            "lab/room1/0198f6a2-7c4a-7bbb-9d1e-000000000009.jsonl"
260        );
261        assert!(!address.is_reserved());
262    }
263
264    #[test]
265    fn materialising_pins_then_writes_read_only_and_repeats_are_idempotent() {
266        let dir = tempfile::tempdir().unwrap();
267        let mut cas = Cas::open(&dir.path().join("cas")).unwrap();
268        let room = Address::parse("lab/room1").unwrap();
269        std::fs::create_dir_all(dir.path().join("lab/room1")).unwrap();
270        let transcript = Transcript::of(run(), &window_with_a_call()).unwrap();
271        let record = transcript.materialise(&mut cas, dir.path(), &room).unwrap();
272        let path = dir.path().join(record.address.as_str());
273        assert_eq!(std::fs::read(&path).unwrap(), transcript.bytes());
274        assert!(std::fs::metadata(&path).unwrap().permissions().readonly());
275        assert!(record.original.to_string().starts_with("cas:b3-"));
276        let again = transcript.materialise(&mut cas, dir.path(), &room).unwrap();
277        assert_eq!(again, record);
278    }
279}