tapes_harnesses/transcript/payload.rs
1//! The transcript-lane wire payload.
2//!
3//! Extracted from a daemon client's transcript uploader, minus the HTTP call.
4//! The shape is a cross-language contract with tapes' Go server
5//! (`ingest.TranscriptPayload` / `pkg/sessions.IngestEnvelope`) and the Go
6//! reference client (`pkg/backfill/transcript_upload.go`):
7//!
8//! ```json
9//! {
10//! "session": {
11//! "org_id": "",
12//! "auth_subject": "",
13//! "harness_id": "claude",
14//! "harness_session_id": "<sid>",
15//! "harness_version": "2.1.161",
16//! "cwd": "/Users/me/src/repo"
17//! },
18//! "agent_id": "<id>", // subagent files only
19//! "agent_type": "...", // from agent-<id>.meta.json
20//! "description": "...",
21//! "tool_use_id": "toolu_...", // the fork edge
22//! "kind": "interacted", // anchor re-entry rows only
23//! "records": [ ...verbatim JSONL lines... ]
24//! }
25//! ```
26//!
27//! `org_id` and `auth_subject` serialize as empty strings rather than being
28//! omitted, matching Go's non-`omitempty` fields — so they are non-optional
29//! `&str` here and a caller with nothing to say passes `""`.
30//!
31//! The endpoint is idempotent: the server keys raw rows on
32//! `transcript:<sid>:<agent|main>:<sha256(records)[..8]>`, so re-pushing
33//! unchanged files answers `{"deduped": true}` and grown files append a new
34//! version. That is what makes the eager trigger in [`super::trigger`] and
35//! sweep-on-start in [`super::sweep`](mod@super::sweep) safe.
36//!
37//! # What stays with the client
38//!
39//! Delivery: the HTTP client, the request timeout, the response and dedup-flag
40//! parsing, and above all **auth**. A client fronted by its own cloud edge rides
41//! a bespoke auth header of its own so that edge admits the request; a
42//! standalone client authenticates differently or not at all. None of that is
43//! harness knowledge, and no such consumer-private header is part of the tapes
44//! contract.
45
46use serde::Serialize;
47use serde_json::value::RawValue;
48
49use super::files::TranscriptFile;
50
51/// Path of the transcript-ingest endpoint, joined onto a client's base URL.
52pub const INGEST_PATH: &str = "/v1/ingest/transcript";
53
54/// The session a transcript belongs to, as the ingest lane needs it.
55///
56/// A client's own session registry will carry more than this (a pid to watch,
57/// bookkeeping for backoff); this is the subset that reaches the wire. Kept
58/// owned rather than borrowed so a client can build one from a swept transcript
59/// (see [`super::sweep`](mod@super::sweep)) as easily as from a live registry
60/// entry.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct TranscriptSession {
63 /// Which harness produced it — `claude` today. Should match the
64 /// `X-Tapes-Harness-Id` the same session's wire traffic carries, so the
65 /// deriver fuses both sources under one session row.
66 pub harness_id: String,
67 /// The harness session id. **Required** by the server for transcripts; it is
68 /// what names `<sid>.jsonl` and keys the upload.
69 pub harness_session_id: String,
70 /// Harness version, when known.
71 pub harness_version: Option<String>,
72 /// Working directory the harness ran in, when known.
73 pub cwd: Option<String>,
74 /// Organization id, or empty.
75 ///
76 /// The server's envelope validation requires a UUID here, so a client whose
77 /// own organization identifier is some other shape leaves this empty
78 /// deliberately rather than sending a value that would be rejected. That is
79 /// not a loss: on a deployment with an authenticating edge, the wire-capture
80 /// path derives org identity from the validated credential rather than from
81 /// this envelope.
82 pub org_id: String,
83 /// Acting subject, or empty. A standalone client conventionally sets
84 /// `local:<os-username>`; on the platform the cloud edge stamps it from
85 /// validated JWT claims. Nothing parses the prefix — see
86 /// [`crate::attribution::Attribution::auth_subject`].
87 pub auth_subject: String,
88}
89
90impl TranscriptSession {
91 /// A session envelope with only the required fields set.
92 pub fn new(harness_id: impl Into<String>, harness_session_id: impl Into<String>) -> Self {
93 Self {
94 harness_id: harness_id.into(),
95 harness_session_id: harness_session_id.into(),
96 harness_version: None,
97 cwd: None,
98 org_id: String::new(),
99 auth_subject: String::new(),
100 }
101 }
102
103 /// Set the harness version.
104 #[must_use]
105 pub fn with_harness_version(mut self, version: Option<String>) -> Self {
106 self.harness_version = version;
107 self
108 }
109
110 /// Set the working directory.
111 #[must_use]
112 pub fn with_cwd(mut self, cwd: Option<String>) -> Self {
113 self.cwd = cwd;
114 self
115 }
116
117 /// Set the acting subject.
118 #[must_use]
119 pub fn with_auth_subject(mut self, auth_subject: impl Into<String>) -> Self {
120 self.auth_subject = auth_subject.into();
121 self
122 }
123}
124
125/// Session envelope, field-for-field with tapes' `pkg/sessions.IngestEnvelope`
126/// (the subset the transcript lane populates).
127#[derive(Debug, Serialize)]
128pub struct IngestEnvelope<'a> {
129 /// Empty string when unknown — never omitted; see the module docs.
130 pub org_id: &'a str,
131 /// Empty string when unknown — never omitted; see the module docs.
132 pub auth_subject: &'a str,
133 /// Which harness produced the transcript.
134 pub harness_id: &'a str,
135 /// The harness session id; REQUIRED by the server for transcripts.
136 pub harness_session_id: &'a str,
137 /// Harness version, omitted when unknown.
138 #[serde(skip_serializing_if = "Option::is_none")]
139 pub harness_version: Option<&'a str>,
140 /// Working directory, omitted when unknown.
141 #[serde(skip_serializing_if = "Option::is_none")]
142 pub cwd: Option<&'a str>,
143}
144
145/// Ingest body for one transcript file, mirroring tapes'
146/// `ingest.TranscriptPayload`.
147#[derive(Debug, Serialize)]
148pub struct TranscriptPayload<'a> {
149 /// Session the transcript belongs to.
150 pub session: IngestEnvelope<'a>,
151 /// Absent for the main transcript; the subagent id otherwise. Matches Go's
152 /// `omitempty` by omitting `None`.
153 #[serde(skip_serializing_if = "Option::is_none")]
154 pub agent_id: Option<&'a str>,
155 /// Subagent type from `agent-<id>.meta.json`, omitted when empty.
156 #[serde(skip_serializing_if = "Option::is_none")]
157 pub agent_type: Option<&'a str>,
158 /// Task description from `agent-<id>.meta.json`, omitted when empty.
159 #[serde(skip_serializing_if = "Option::is_none")]
160 pub description: Option<&'a str>,
161 /// The `Task` tool_use that forked this subagent — the causal edge the tapes
162 /// deriver attaches. Omitted when empty.
163 #[serde(skip_serializing_if = "Option::is_none")]
164 pub tool_use_id: Option<&'a str>,
165 /// Lifecycle qualifier for a Codex `sub_agent_activity` anchor row.
166 ///
167 /// `Some("interacted")` marks a re-entry record — a `send_message` or
168 /// `followup_task` aimed at an already-spawned thread, with `agent_id` the
169 /// target thread and `tool_use_id` the triggering call. `None` means spawn
170 /// evidence, which is the legacy default and the only thing a transcript
171 /// file ever is: [`build_payload`] therefore always leaves it unset, and an
172 /// anchor builder sets it through this field directly.
173 ///
174 /// The server keys a raw row's dedup on the payload bytes and reads the
175 /// latest version per (session, agent, lifecycle kind), so an `interacted`
176 /// row versions separately from the `started` anchor it shares an
177 /// `agent_id` with rather than superseding it. That is also why the field
178 /// is `Option` with `skip_serializing_if` rather than an empty-string
179 /// sentinel, and why it sits *after* `tool_use_id`: a spawn row's bytes
180 /// must stay identical to what earlier builds already ingested.
181 #[serde(skip_serializing_if = "Option::is_none")]
182 pub kind: Option<&'a str>,
183 /// The transcript's JSONL content as a JSON array, verbatim.
184 pub records: &'a RawValue,
185}
186
187/// The [`TranscriptPayload::kind`] value marking a re-entry anchor row.
188pub const KIND_INTERACTED: &str = "interacted";
189
190/// Assemble the payload for one transcript file.
191///
192/// `records` is the output of [`super::files::jsonl_to_records`] wrapped in a
193/// [`RawValue`] so the bytes embed verbatim — re-serializing them would change
194/// the server's dedup hash and register identical content as a new version.
195#[must_use]
196pub fn build_payload<'a>(
197 session: &'a TranscriptSession,
198 file: &'a TranscriptFile,
199 records: &'a RawValue,
200) -> TranscriptPayload<'a> {
201 // Empty meta fields are omitted, matching Go's `omitempty`.
202 let some_nonempty = |s: &'a str| (!s.is_empty()).then_some(s);
203 TranscriptPayload {
204 session: IngestEnvelope {
205 org_id: &session.org_id,
206 auth_subject: &session.auth_subject,
207 harness_id: &session.harness_id,
208 harness_session_id: &session.harness_session_id,
209 harness_version: session.harness_version.as_deref(),
210 cwd: session.cwd.as_deref(),
211 },
212 agent_id: file.agent_id.as_deref(),
213 agent_type: some_nonempty(&file.meta.agent_type),
214 description: some_nonempty(&file.meta.description),
215 tool_use_id: some_nonempty(&file.meta.tool_use_id),
216 // A transcript file is always spawn evidence; only an anchor row
217 // qualifies its lifecycle. See [`TranscriptPayload::kind`].
218 kind: None,
219 records,
220 }
221}
222
223#[cfg(test)]
224#[allow(clippy::unwrap_used, clippy::expect_used)]
225mod tests {
226 use super::*;
227 use crate::transcript::files::SubagentMeta;
228
229 fn session() -> TranscriptSession {
230 TranscriptSession::new(
231 tapes_capture::envelope::HARNESS_ID_CLAUDE,
232 "0ea3c2cc-fe9d-41ff-aab1-4134ad00c350",
233 )
234 .with_harness_version(Some("2.1.161".to_owned()))
235 .with_cwd(Some("/Users/me/src/repo".to_owned()))
236 }
237
238 fn main_file() -> TranscriptFile {
239 TranscriptFile {
240 path: "/tmp/x.jsonl".into(),
241 agent_id: None,
242 meta: SubagentMeta::default(),
243 }
244 }
245
246 fn subagent_file() -> TranscriptFile {
247 TranscriptFile {
248 path: "/tmp/agent-abc.jsonl".into(),
249 agent_id: Some("abc".to_owned()),
250 meta: SubagentMeta {
251 tool_use_id: "toolu_01".to_owned(),
252 agent_type: "general-purpose".to_owned(),
253 description: "dig".to_owned(),
254 },
255 }
256 }
257
258 /// Golden-pin the exact JSON the Go reference client would produce for the
259 /// same inputs: empty-string identity fields present, agent fields absent,
260 /// records verbatim.
261 ///
262 /// Carried over from the extracted uploader's shape test — same expected
263 /// bytes.
264 #[test]
265 fn main_payload_matches_go_client_shape() {
266 let session = session();
267 let file = main_file();
268 let records = RawValue::from_string(r#"[{"b":1,"a":2}]"#.to_owned()).unwrap();
269 let payload = build_payload(&session, &file, &records);
270 let got = serde_json::to_string(&payload).unwrap();
271 assert_eq!(
272 got,
273 r#"{"session":{"org_id":"","auth_subject":"","harness_id":"claude","harness_session_id":"0ea3c2cc-fe9d-41ff-aab1-4134ad00c350","harness_version":"2.1.161","cwd":"/Users/me/src/repo"},"records":[{"b":1,"a":2}]}"#,
274 );
275 }
276
277 /// Carried over from the extracted uploader's fork-metadata test.
278 #[test]
279 fn subagent_payload_carries_fork_metadata() {
280 let session = session();
281 let file = subagent_file();
282 let records = RawValue::from_string("[]".to_owned()).unwrap();
283 let payload = build_payload(&session, &file, &records);
284 let got: serde_json::Value =
285 serde_json::from_str(&serde_json::to_string(&payload).unwrap()).unwrap();
286 assert_eq!(got["agent_id"], "abc");
287 assert_eq!(got["agent_type"], "general-purpose");
288 assert_eq!(got["description"], "dig");
289 assert_eq!(got["tool_use_id"], "toolu_01");
290 }
291
292 /// Missing `meta.json` degrades to empty strings; Go's `omitempty` drops
293 /// them and so must we.
294 ///
295 /// Carried over from the extracted uploader's empty-meta test.
296 #[test]
297 fn subagent_payload_omits_empty_meta_fields() {
298 let session = session();
299 let mut file = subagent_file();
300 file.meta = SubagentMeta::default();
301 let records = RawValue::from_string("[]".to_owned()).unwrap();
302 let payload = build_payload(&session, &file, &records);
303 let got = serde_json::to_string(&payload).unwrap();
304 assert!(!got.contains("agent_type"), "got: {got}");
305 assert!(!got.contains("tool_use_id"), "got: {got}");
306 assert!(!got.contains("description"), "got: {got}");
307 assert!(got.contains(r#""agent_id":"abc""#), "got: {got}");
308 }
309
310 /// A caller that *does* have identity to declare gets it on the wire — the
311 /// standalone-client case the extracted uploader never exercised. The fields
312 /// stay present either way, which is what the Go server's non-`omitempty`
313 /// decode expects.
314 #[test]
315 fn identity_fields_are_always_present_and_carry_a_subject_when_set() {
316 let session = TranscriptSession::new("claude", "sid").with_auth_subject("local:alice");
317 let file = main_file();
318 let records = RawValue::from_string("[]".to_owned()).unwrap();
319 let got = serde_json::to_string(&build_payload(&session, &file, &records)).unwrap();
320 assert!(
321 got.contains(r#""auth_subject":"local:alice""#),
322 "got: {got}"
323 );
324 assert!(got.contains(r#""org_id":"""#), "got: {got}");
325 // Unknown optionals stay omitted rather than becoming null.
326 assert!(!got.contains("harness_version"), "got: {got}");
327 assert!(!got.contains("cwd"), "got: {got}");
328 }
329
330 /// An unset `kind` must not appear on the wire *at all*. This is the
331 /// stability constraint the whole field is shaped around: spawn-evidence
332 /// rows were ingested by builds that predate `kind`, and the server's raw
333 /// dedup keys on the payload bytes, so a `"kind":null` — or any reordering
334 /// that moved an existing field — would re-ingest every already-stored row
335 /// as a new version.
336 #[test]
337 fn an_unset_kind_leaves_the_payload_bytes_unchanged() {
338 let session = session();
339 let file = subagent_file();
340 let records = RawValue::from_string("[]".to_owned()).unwrap();
341 let payload = build_payload(&session, &file, &records);
342 assert!(payload.kind.is_none(), "build_payload never sets kind");
343 let got = serde_json::to_string(&payload).unwrap();
344 assert!(!got.contains("kind"), "got: {got}");
345 assert_eq!(
346 got,
347 r#"{"session":{"org_id":"","auth_subject":"","harness_id":"claude","harness_session_id":"0ea3c2cc-fe9d-41ff-aab1-4134ad00c350","harness_version":"2.1.161","cwd":"/Users/me/src/repo"},"agent_id":"abc","agent_type":"general-purpose","description":"dig","tool_use_id":"toolu_01","records":[]}"#,
348 );
349 }
350
351 /// A caller that *does* qualify the row — the Codex anchor lane — gets
352 /// `kind` between `tool_use_id` and `records`. Field order is part of the
353 /// contract, not an accident: it keeps a spawn row's bytes a strict prefix
354 /// of the shape an interacted row extends.
355 #[test]
356 fn an_anchor_kind_serializes_after_tool_use_id() {
357 let session = session();
358 let file = subagent_file();
359 let records = RawValue::from_string("[]".to_owned()).unwrap();
360 let payload = TranscriptPayload {
361 kind: Some(KIND_INTERACTED),
362 ..build_payload(&session, &file, &records)
363 };
364 let got = serde_json::to_string(&payload).unwrap();
365 assert!(
366 got.contains(r#""tool_use_id":"toolu_01","kind":"interacted","records":[]"#),
367 "got: {got}",
368 );
369 }
370
371 /// The records bytes embed verbatim, including key order and interior
372 /// spacing — the server's dedup hash is computed over exactly these bytes.
373 #[test]
374 fn records_embed_verbatim() {
375 let session = session();
376 let file = main_file();
377 let raw = r#"[{"z":1,"a": 2},{"b":[3, 4]}]"#;
378 let records = RawValue::from_string(raw.to_owned()).unwrap();
379 let got = serde_json::to_string(&build_payload(&session, &file, &records)).unwrap();
380 assert!(
381 got.ends_with(&format!(r#","records":{raw}}}"#)),
382 "got: {got}",
383 );
384 }
385}