Skip to main content

amberfork_record/
cassette.rs

1//! The cassette: the record path's output contract.
2//!
3//! A cassette is the full-content capture of one agent run at the provider HTTP boundary —
4//! every request the agent sent and every response it got back, in order. It is what makes
5//! the record path's two claims true: content is *guaranteed* (rather than opt-in and often
6//! absent, as on the passive path), and the run can be re-executed later, because a
7//! boundary-level recording is replayable in a way a telemetry photo is not.
8//!
9//! The wire shape is documented in `docs/cassette-format.md`; these types are its source of
10//! truth.
11
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15/// Version of the cassette contract.
16///
17/// Deliberately NOT [`amberfork_model::SchemaVersion`]: the trace format and the cassette are
18/// two independent contracts with two independent audiences, and sharing a version number
19/// would make a change to either one falsely announce a break in the other.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(transparent)]
22pub struct CassetteVersion(pub String);
23
24impl CassetteVersion {
25    /// The version this build writes and reads natively.
26    pub const CURRENT: &'static str = "0.1";
27
28    /// The current contract version.
29    #[must_use]
30    pub fn current() -> Self {
31        Self(Self::CURRENT.to_string())
32    }
33
34    /// Whether this cassette declares the version this build reads natively.
35    #[must_use]
36    pub fn is_current(&self) -> bool {
37        self.0 == Self::CURRENT
38    }
39}
40
41impl Default for CassetteVersion {
42    fn default() -> Self {
43        Self::current()
44    }
45}
46
47/// Request headers preserved in a cassette.
48///
49/// This is an **allowlist, and must stay one**. A cassette is a shareable artifact — committed
50/// as a fixture, attached to a bug report, pasted into an issue — and every provider spells
51/// its credential differently (`authorization`, `x-api-key`, `x-goog-api-key`,
52/// `anthropic-auth-token`, …). A denylist silently leaks the next provider's scheme the day it
53/// ships; an allowlist drops an unrecognized header instead. Replay keys on method, path, and
54/// body, so nothing here is load-bearing for correctness — which makes fail-closed free.
55const KEPT_REQUEST_HEADERS: &[&str] = &["content-type", "accept"];
56
57/// Response headers preserved in a cassette. Same allowlist reasoning: a response can carry
58/// `set-cookie`, and organization/project identifiers are not ours to redistribute.
59const KEPT_RESPONSE_HEADERS: &[&str] = &["content-type"];
60
61/// What the recorder writes when a body is not valid JSON. The cassette stays a JSON document,
62/// so a non-JSON body (an HTML error page from a proxy, a truncated stream) is preserved as
63/// text rather than dropped or allowed to fail the capture.
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
65#[serde(untagged)]
66pub enum Body {
67    /// A parsed JSON body — the normal case for a provider API.
68    Json(Value),
69    /// A body that did not parse as JSON, preserved verbatim.
70    Text(String),
71}
72
73impl Body {
74    /// Parse captured bytes into a body, falling back to lossy text.
75    ///
76    /// Capture never fails on a malformed body: a recording that dies because the provider
77    /// returned an HTML 502 is a recording you cannot debug, which is exactly when you needed
78    /// it. The fidelity loss is reported by shape (`Text` rather than `Json`), not hidden.
79    #[must_use]
80    pub fn from_bytes(bytes: &[u8]) -> Self {
81        serde_json::from_slice(bytes).map_or_else(
82            |_| Self::Text(String::from_utf8_lossy(bytes).into_owned()),
83            Self::Json,
84        )
85    }
86}
87
88/// One captured request, with full inputs.
89///
90/// Full **input** capture is the point (design doc, "Replay fidelity ceiling"): output-only
91/// logs leave ≥21% of cases unattributable, and capturing inputs buys +76% relative
92/// step-level accuracy. It is also what a counterfactual re-run needs — you cannot re-ask a
93/// question you did not record.
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
95pub struct CapturedRequest {
96    pub method: String,
97    /// Path and query as the agent sent them, upstream origin excluded — the origin belongs
98    /// to the recording session, not the exchange.
99    pub path: String,
100    /// Headers surviving [`KEPT_REQUEST_HEADERS`].
101    #[serde(default)]
102    pub headers: Vec<(String, String)>,
103    pub body: Body,
104}
105
106/// One captured response.
107#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
108pub struct CapturedResponse {
109    pub status: u16,
110    /// Headers surviving [`KEPT_RESPONSE_HEADERS`].
111    #[serde(default)]
112    pub headers: Vec<(String, String)>,
113    pub body: Body,
114}
115
116/// One request/response round trip at the provider boundary.
117#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
118pub struct Exchange {
119    /// 0-based capture order. Concurrent agent calls are ordered by *completion*, which is the
120    /// only order the proxy can observe; it is a record of what happened, not a causal claim.
121    pub idx: usize,
122    pub request: CapturedRequest,
123    pub response: CapturedResponse,
124}
125
126/// A full-content recording of one agent run at the provider boundary.
127#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
128pub struct Cassette {
129    pub cassette_version: CassetteVersion,
130    /// Unique recording id (any string).
131    pub id: String,
132    /// The exchanges, in capture order.
133    #[serde(default)]
134    pub exchanges: Vec<Exchange>,
135}
136
137impl Cassette {
138    /// An empty cassette stamped with the current contract version.
139    #[must_use]
140    pub fn new(id: impl Into<String>) -> Self {
141        Self {
142            cassette_version: CassetteVersion::current(),
143            id: id.into(),
144            exchanges: Vec::new(),
145        }
146    }
147}
148
149/// Filter headers down to an allowlist, lowercasing names so the contract does not inherit the
150/// caller's casing.
151pub(crate) fn retain_headers<'a>(
152    headers: impl Iterator<Item = (&'a str, &'a [u8])>,
153    allowed: &[&str],
154) -> Vec<(String, String)> {
155    headers
156        .filter_map(|(name, value)| {
157            let name = name.to_ascii_lowercase();
158            if !allowed.contains(&name.as_str()) {
159                return None;
160            }
161            // A kept header with a non-UTF-8 value is dropped rather than lossily mangled:
162            // the allowlist is small and textual, so this is a malformed-input case.
163            let value = std::str::from_utf8(value).ok()?;
164            Some((name, value.to_string()))
165        })
166        .collect()
167}
168
169/// Filter request headers to the recordable allowlist.
170///
171/// Public because the record path is not the only writer of a cassette: `amberfork-replay`'s
172/// loopback listener re-records the agent's requests at the *same* provider boundary, and its
173/// re-executed cassette is just as shareable as a recorded one. It reuses this function so there
174/// stays exactly one allowlist — the module doc's whole point is that a second, forked one would
175/// silently leak the next provider's credential scheme.
176pub fn retain_request_headers<'a>(
177    headers: impl Iterator<Item = (&'a str, &'a [u8])>,
178) -> Vec<(String, String)> {
179    retain_headers(headers, KEPT_REQUEST_HEADERS)
180}
181
182/// Filter response headers to the recordable allowlist.
183///
184/// Public for the same reason as [`retain_request_headers`]: `amberfork-replay`'s live relay builds
185/// a [`CapturedResponse`] from the provider's answer, and that response lands on the re-executed
186/// cassette — just as shareable as a recorded one. It reuses this function so there stays exactly
187/// one response allowlist, never a second that could leak a `set-cookie` or an org identifier.
188pub fn retain_response_headers<'a>(
189    headers: impl Iterator<Item = (&'a str, &'a [u8])>,
190) -> Vec<(String, String)> {
191    retain_headers(headers, KEPT_RESPONSE_HEADERS)
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn credential_headers_never_survive_capture() {
200        // The load-bearing test of this module. Every one of these is a real provider's
201        // credential scheme; the allowlist must drop all of them without naming any.
202        let headers: Vec<(&str, &[u8])> = vec![
203            ("content-type", b"application/json"),
204            ("authorization", b"Bearer sk-secret"),
205            ("x-api-key", b"sk-secret"),
206            ("x-goog-api-key", b"sk-secret"),
207            ("anthropic-auth-token", b"sk-secret"),
208            ("cookie", b"session=sk-secret"),
209        ];
210        let kept = retain_request_headers(headers.into_iter());
211        assert_eq!(
212            kept,
213            vec![("content-type".to_string(), "application/json".to_string())]
214        );
215    }
216
217    #[test]
218    fn unknown_headers_are_dropped_not_kept() {
219        // Fail-closed: the allowlist's whole purpose is that a header nobody anticipated —
220        // including the next provider's auth scheme — does not reach the cassette.
221        let headers: Vec<(&str, &[u8])> = vec![("x-some-future-credential", b"sk-secret")];
222        assert!(retain_request_headers(headers.into_iter()).is_empty());
223    }
224
225    #[test]
226    fn header_names_are_lowercased() {
227        let headers: Vec<(&str, &[u8])> = vec![("Content-Type", b"application/json")];
228        let kept = retain_request_headers(headers.into_iter());
229        assert_eq!(kept[0].0, "content-type");
230    }
231
232    #[test]
233    fn non_json_body_is_preserved_as_text() {
234        // A provider 502 is an HTML page. Capture must survive it — that is precisely the run
235        // you wanted recorded.
236        let body = Body::from_bytes(b"<html>502 Bad Gateway</html>");
237        assert_eq!(body, Body::Text("<html>502 Bad Gateway</html>".to_string()));
238    }
239
240    #[test]
241    fn json_body_round_trips() {
242        let body = Body::from_bytes(br#"{"model":"claude-sonnet-5"}"#);
243        let Body::Json(value) = &body else {
244            panic!("expected a JSON body, got {body:?}");
245        };
246        assert_eq!(value["model"], "claude-sonnet-5");
247    }
248
249    #[test]
250    fn cassette_version_serializes_transparently() {
251        let json = serde_json::to_string(&CassetteVersion::current()).unwrap();
252        assert_eq!(json, "\"0.1\"");
253    }
254}