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