Skip to main content

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(&notification).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}