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