tapes_client/core/models/mod.rs
1//! The sealed contract's response and request shapes, as Rust types.
2//!
3//! # Why the crate holds these at all
4//!
5//! [`crate::decode`] takes no view on what a response decodes into, and that
6//! was right while nothing in this crate knew the shape of an answer. It is
7//! wrong once the shape is *sealed*: `SessionItem` is not a consumer's opinion,
8//! it is a published contract vendored into this crate byte-for-byte. Every
9//! consumer that modelled it separately was maintaining a private copy of a
10//! shared fact — and private copies of a shared fact drift silently, which is
11//! the failure this whole crate exists to end.
12//!
13//! So the typed surface is the **default**: [`crate::core::CoreClient`]'s named
14//! methods return these types. The generic seam stays exactly where it was —
15//! [`crate::core::CoreClient::call`] is still generic in its response type, and
16//! [`crate::decode::typed`] still decodes into whatever a caller names. That is
17//! the escape hatch, and it is the right tool for the fidelity operations: an
18//! archive written from a typed decode is an archive of the fields this build
19//! happened to know about.
20//!
21//! # The decoding rules, and why each one is what it is
22//!
23//! The contract's schemas declare almost no required properties: the server
24//! omits an empty field rather than sending it. Every rule below follows from
25//! that, and from one more: **an additive server change must never break a
26//! consumer.**
27//!
28//! - **Unknown fields pass silently.** No `deny_unknown_fields`, anywhere. A
29//! field this build has never heard of is a newer server, not a malformed
30//! response, and refusing the document would turn a routine deploy into an
31//! outage for every older client. What catches the addition instead is the
32//! [`coverage`] gate, at build time, where a human can decide about it.
33//! - **An absent field decodes to its default.** Container-level
34//! `#[serde(default)]`, on every model — except where the contract marks a
35//! property `required`, which today is exactly one field
36//! ([`StandaloneTraceDetail::session_id`]). There the model defaults every
37//! *other* field and leaves that one strict, so a document missing the
38//! guarantee the schema publishes is refused rather than read as `""`.
39//! - **A null in a composite position decodes to its default too.** A nil map,
40//! slice, or struct pointer that is not omitted arrives as `null`, and a
41//! model that errored on one would let a single empty projection blank an
42//! entire page. Scalars stay strict: the contract declares no nullable
43//! scalar, so a null in one is a real disagreement worth surfacing.
44//! - **Response models are `#[non_exhaustive]`, request models are not.** A
45//! response is the server's to grow; a request body is the caller's to build,
46//! and a body nobody outside this crate could construct would be useless.
47//! This holds for the *components* of a request body too — an inner struct
48//! marked `non_exhaustive` makes its fields unreachable just as surely as
49//! marking the outer one would, and leaves a caller with nothing but
50//! `Default`. A test outside the crate constructs every request body by
51//! struct literal, which is the only place the marker's effect is visible.
52//! - **A request field whose absence means something is an [`Option`] that is
53//! omitted.** The partial-update bodies are the reason: the server applies
54//! the properties a `PUT`/`PATCH` body carries and leaves the rest alone, so
55//! a model that always serialized every field would turn every one-field
56//! update into a wipe of the others. `#[serde(skip_serializing_if =
57//! "Option::is_none")]` is what makes an unset field genuinely absent from
58//! the bytes rather than present and empty. Where absence carries no
59//! distinct meaning — a create body, whose fields land on a fresh record —
60//! the field stays plain, because an `Option` there would be ceremony
61//! without a distinction behind it.
62//!
63//! # What is deliberately not typed further
64//!
65//! - **Timestamps stay `String`.** The contract says `string`/`date-time`, and
66//! parsing one into a datetime type would make an unparseable value a decode
67//! failure at the *response* level — one odd timestamp blanking a whole page
68//! — in exchange for a convenience every consumer can add itself.
69//! - **Enumerable strings stay `String`.** `status`, `kind`, `call_kind`,
70//! `verdict` and their kin are declared as plain strings; the document names
71//! no closed set. A Rust enum here would invent a contract the server never
72//! made, and would fail exactly when the server added a variant.
73//! - **Opaque objects stay [`serde_json::Value`].** Where the contract says
74//! `type: object` with no properties — a span's content blocks, a raw turn's
75//! metadata — there is nothing to model, and inventing a shape would be the
76//! drift this module exists to prevent.
77//!
78//! # The gate
79//!
80//! [`coverage`] walks the vendored document's schemas and holds these types to
81//! them: every schema is modelled or deliberately allow-listed, every property
82//! survives a round trip through its model, and the decoding rules above are
83//! asserted rather than assumed. A contract bump that adds a field fails the
84//! build, the same way one that adds an operation fails [`crate::core::coverage`].
85
86pub mod admin;
87pub mod coverage;
88pub mod params;
89pub mod protocol;
90pub mod raw_turn;
91pub mod session;
92pub mod span;
93pub mod trace;
94
95use serde::{Deserialize, Deserializer};
96
97pub use admin::{
98 DeriveRunResponse, ReconcileStats, RederiveReport, SeedDemoRequest, SeedResult, StatsResponse,
99 TranscriptProjectionStats,
100};
101pub use params::{
102 PayloadDetail, RawTurnListParams, SessionListParams, SessionTracesParams, SortDirection,
103 StatsParams, TraceListParams, TraceParams,
104};
105pub use protocol::{ErrorResponse, McpError, McpRequest, McpResponse};
106pub use raw_turn::{
107 RawTurnAttribution, RawTurnAttributionRepairRequest, RawTurnAttributionRepairResult,
108 RawTurnHeaderItem, RawTurnListResponse, RepairPendingSession,
109};
110pub use session::{
111 ModelUsage, SessionDetailResponse, SessionItem, SessionListResponse, SessionRollup,
112 SessionTracesResponse, SessionUpdateRequest, SessionUsage, TreeTask,
113};
114pub use span::{SpanItem, SpanLinkItem};
115pub use trace::{
116 MainUsage, StandaloneTraceDetail, TraceDetail, TraceItem, TraceListResponse, TraceUsage,
117};
118
119/// A type that models one named schema of the vendored contract.
120///
121/// The association is what makes the [`coverage`] gate possible: without a
122/// declared schema name, "is every response schema modelled?" would be a
123/// question only a human could answer, and the answer would rot. It is also the
124/// documentation a reader wants — which published shape *is* this type.
125pub trait ContractModel: serde::Serialize + serde::de::DeserializeOwned {
126 /// The schema's own name in `contracts/tapes-api.yaml`.
127 const SCHEMA: &'static str;
128}
129
130/// Decode `null` as the type's default rather than as a failure.
131///
132/// Applied to every composite field — see the module docs for why a null
133/// arrives at all, and why scalars deliberately do not get this treatment.
134///
135/// # Errors
136///
137/// Propagates the underlying decode failure for anything that is neither
138/// `null` nor a valid value of the field's type.
139pub fn null_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
140where
141 D: Deserializer<'de>,
142 T: Deserialize<'de> + Default,
143{
144 Ok(Option::<T>::deserialize(deserializer)?.unwrap_or_default())
145}
146
147#[cfg(test)]
148#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
149mod tests {
150 use std::collections::BTreeMap;
151
152 use super::*;
153 use serde_json::json;
154
155 #[test]
156 fn an_absent_field_decodes_to_its_default_rather_than_failing() {
157 // The contract requires nothing, so `{}` is a legal answer for every
158 // shape in it — and a model that refused one would fail on a session
159 // the deriver has not reached yet.
160 let session: SessionItem = serde_json::from_value(json!({})).unwrap();
161 assert_eq!(session.id, "");
162 assert_eq!(session.rollup.turn_count, 0);
163 }
164
165 #[test]
166 fn an_unknown_field_passes_rather_than_failing_the_document() {
167 // A newer server, not a malformed response. The build-time gate is
168 // what reports the addition; the runtime must not.
169 let session: SessionItem =
170 serde_json::from_value(json!({"id": "s-1", "a_field_from_the_future": 7})).unwrap();
171 assert_eq!(session.id, "s-1");
172 }
173
174 #[test]
175 fn a_null_composite_decodes_to_empty_rather_than_blanking_the_response() {
176 // A nil map or slice that is not omitted arrives as `null`. One of
177 // them must not cost the caller the whole document.
178 let session: SessionItem = serde_json::from_value(json!({
179 "id": "s-1",
180 "harness_metadata": null,
181 "rollup": null,
182 }))
183 .unwrap();
184 assert_eq!(session.id, "s-1");
185 assert!(session.harness_metadata.is_empty());
186 assert_eq!(session.rollup, SessionRollup::default());
187 }
188
189 #[test]
190 fn an_empty_partial_update_sends_an_empty_document() {
191 // Nothing set means nothing said — not an empty property, which is
192 // the same erasure spelled with a default constructor.
193 assert_eq!(
194 serde_json::to_value(SessionUpdateRequest::default()).unwrap(),
195 json!({}),
196 );
197 }
198
199 #[test]
200 fn a_rename_body_tells_clearing_apart_from_not_touching() {
201 // The contract gives the two states different outcomes — an absent
202 // field is a 400 (nothing to update), an empty one clears the rename
203 // back to the auto-derived title — so the type has to be able to say
204 // both, and say them differently.
205 let clear = SessionUpdateRequest {
206 display_name: Some(String::new()),
207 };
208 let untouched = SessionUpdateRequest::default();
209
210 assert_eq!(
211 serde_json::to_value(&clear).unwrap(),
212 json!({"display_name": ""}),
213 );
214 assert_eq!(serde_json::to_value(&untouched).unwrap(), json!({}));
215 }
216
217 #[test]
218 fn a_notification_frame_carries_no_id() {
219 // JSON-RPC reads a present id as "answer me", so an empty-string id
220 // would make every notification a request awaiting a response.
221 let notification = McpRequest {
222 id: None,
223 jsonrpc: "2.0".to_owned(),
224 method: "notifications/initialized".to_owned(),
225 params: BTreeMap::new(),
226 };
227 let sent = serde_json::to_value(¬ification).unwrap();
228
229 assert_eq!(sent.get("id"), None, "got: {sent}");
230 assert_eq!(sent["method"], "notifications/initialized");
231 }
232
233 #[test]
234 fn a_nullable_object_keeps_the_distinction_the_contract_draws() {
235 // `verdict` is the one property the document marks nullable, and the
236 // null is meaningful: it says the span was not judged.
237 let judged: SpanItem =
238 serde_json::from_value(json!({"verdict": {"decision": "allow"}})).unwrap();
239 let unjudged: SpanItem = serde_json::from_value(json!({"verdict": null})).unwrap();
240 assert!(judged.verdict.is_some());
241 assert!(unjudged.verdict.is_none());
242 }
243}