Skip to main content

tapes_client/core/models/
coverage.rs

1//! The schema-coverage gate.
2//!
3//! # What this gate is for
4//!
5//! [`crate::core::coverage`] catches an operation the contract grew and the
6//! client never exposed. This one catches the quieter half of the same failure:
7//! an operation whose *shape* grew — a field added to `SessionItem`, a schema
8//! added beside it — while the models kept decoding happily, dropping the new
9//! data on the floor. Nothing fails at runtime when that happens. The response
10//! still parses; the field is simply never seen again.
11//!
12//! So the models are held to the vendored document mechanically:
13//!
14//! 1. **Every schema is accounted for.** Modelled, or allow-listed with the
15//!    reason it is not — the same partition, and the same failure, as the
16//!    operation gate.
17//! 2. **Every property survives a round trip.** A document synthesised from the
18//!    schema is decoded into the model and re-serialised; anything the model
19//!    does not carry comes back missing, and is reported by path.
20//! 3. **The decoding rules hold.** A schema's optional properties really are
21//!    optional (the whole document decodes from `{}`), a required one really is
22//!    required, and a composite property really does tolerate `null` — see
23//!    [`super`] for why each of those matters.
24//!
25//! The synthesised document is the trick that makes this work without a
26//! hand-written description of each model. A hand-written one would be a second
27//! copy of the contract, kept by hand, which is the thing being prevented.
28//! Serde is the description: what the model can carry is exactly what survives
29//! decoding and re-encoding.
30//!
31//! # Why the tables live here and not with the consumer
32//!
33//! Deliberately the opposite of [`crate::core::coverage`], and for the same
34//! reason. Coverage of *operations* is a statement about one client's surface,
35//! so sharing the tables would make the gate report on a union and protect
36//! nobody. Coverage of *schemas* is a statement about these models, which ship
37//! in this crate — so the tables ship with them, and a consumer gets the gate
38//! by depending on the crate rather than by maintaining a copy of it.
39
40use std::collections::BTreeSet;
41use std::fmt;
42
43use serde_json::{Map, Value, json};
44
45use super::ContractModel;
46use super::params::{ContractEnum, ContractParams};
47use crate::core::contract::{TAPES_API_YAML, core};
48use crate::error::{Result, error};
49use snafu::OptionExt;
50
51/// A coverage table: schema name paired with prose for the reviewer.
52pub type Table<'a> = &'a [(&'a str, &'a str)];
53
54/// Schemas this crate deliberately does not model, and why.
55///
56/// The cassette surface models the discovery document itself — partially and on
57/// purpose, since a deployment's configuration is not part of the generated
58/// command surface. Modelling it a second time here is exactly the duplication
59/// this crate exists to end, so these are allow-listed rather than copied.
60pub const UNMODELLED: Table<'static> = &[
61    (
62        "Discovery",
63        "modelled by the cassette surface, which reads only the fields it acts on",
64    ),
65    (
66        "DiscoveryEntry",
67        "part of the discovery document; see Discovery",
68    ),
69    (
70        "DiscoveryDepends",
71        "part of the discovery document; see Discovery",
72    ),
73    (
74        "DiscoverySetting",
75        "part of the discovery document; see Discovery",
76    ),
77    ("Rejection", "part of the discovery document; see Discovery"),
78    (
79        "AdvertisedEntity",
80        "part of the discovery document; see Discovery",
81    ),
82    (
83        "EntityRelation",
84        "part of the discovery document; see Discovery",
85    ),
86];
87
88/// One modelled schema, and the checks its model can be put through.
89#[derive(Clone, Copy)]
90pub struct Entry {
91    schema: &'static str,
92    run: fn(&Value, &Map<String, Value>) -> Vec<String>,
93}
94
95impl Entry {
96    /// Register one model against the schema it claims.
97    #[must_use]
98    pub fn of<M: ContractModel>() -> Self {
99        Self {
100            schema: M::SCHEMA,
101            run: audit::<M>,
102        }
103    }
104
105    /// The schema this entry covers.
106    #[must_use]
107    pub fn schema(&self) -> &'static str {
108        self.schema
109    }
110}
111
112impl fmt::Debug for Entry {
113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114        f.debug_struct("Entry")
115            .field("schema", &self.schema)
116            .finish()
117    }
118}
119
120/// Every schema this crate models, in one table.
121///
122/// The registry is a function rather than a `const` so a model is registered by
123/// naming its type — `Entry::of::<SessionItem>()` — which cannot disagree with
124/// the type's own [`ContractModel::SCHEMA`] the way a repeated string could.
125#[must_use]
126pub fn registry() -> Vec<Entry> {
127    use super::{admin, protocol, raw_turn, session, span, trace};
128    vec![
129        Entry::of::<session::SessionItem>(),
130        Entry::of::<session::SessionRollup>(),
131        Entry::of::<session::SessionUsage>(),
132        Entry::of::<session::ModelUsage>(),
133        Entry::of::<session::TreeTask>(),
134        Entry::of::<session::SessionListResponse>(),
135        Entry::of::<session::SessionDetailResponse>(),
136        Entry::of::<session::SessionTracesResponse>(),
137        Entry::of::<session::SessionUpdateRequest>(),
138        Entry::of::<trace::TraceItem>(),
139        Entry::of::<trace::TraceUsage>(),
140        Entry::of::<trace::MainUsage>(),
141        Entry::of::<trace::TraceDetail>(),
142        Entry::of::<trace::StandaloneTraceDetail>(),
143        Entry::of::<trace::TraceListResponse>(),
144        Entry::of::<span::SpanItem>(),
145        Entry::of::<span::SpanLinkItem>(),
146        Entry::of::<raw_turn::RawTurnHeaderItem>(),
147        Entry::of::<raw_turn::RawTurnListResponse>(),
148        Entry::of::<raw_turn::RawTurnAttribution>(),
149        Entry::of::<raw_turn::RawTurnAttributionRepairRequest>(),
150        Entry::of::<raw_turn::RawTurnAttributionRepairResult>(),
151        Entry::of::<raw_turn::RepairPendingSession>(),
152        Entry::of::<admin::SeedResult>(),
153        Entry::of::<admin::SeedDemoRequest>(),
154        Entry::of::<admin::DeriveRunResponse>(),
155        Entry::of::<admin::RederiveReport>(),
156        Entry::of::<admin::ReconcileStats>(),
157        Entry::of::<admin::TranscriptProjectionStats>(),
158        Entry::of::<admin::StatsResponse>(),
159        Entry::of::<protocol::ErrorResponse>(),
160        Entry::of::<protocol::McpRequest>(),
161        Entry::of::<protocol::McpResponse>(),
162        Entry::of::<protocol::McpError>(),
163    ]
164}
165
166/// What a coverage run found wrong.
167///
168/// Every category is reported at once, because a gate that surfaces one problem
169/// per run turns a contract bump into a sequence of runs.
170#[derive(Debug, Default, PartialEq, Eq)]
171pub struct SchemaReport {
172    /// Schemas in the contract that are neither modelled nor allow-listed.
173    pub unmodelled: Vec<String>,
174    /// Schemas named by a table that the contract does not have.
175    pub stale: Vec<String>,
176    /// Schemas both modelled and allow-listed.
177    pub contradictory: Vec<String>,
178    /// Ways a model disagreed with the schema it claims.
179    pub disagreements: Vec<String>,
180}
181
182impl SchemaReport {
183    /// Whether the models and the contract agree.
184    #[must_use]
185    pub fn is_clean(&self) -> bool {
186        self.unmodelled.is_empty()
187            && self.stale.is_empty()
188            && self.contradictory.is_empty()
189            && self.disagreements.is_empty()
190    }
191}
192
193impl fmt::Display for SchemaReport {
194    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
195        if !self.unmodelled.is_empty() {
196            write!(
197                f,
198                "schemas in the vendored tapes-api contract that this crate neither models nor \
199                 allow-lists: {:?} — add a model (and register it) or allow-list it with the \
200                 reason it stays unmodelled. ",
201                self.unmodelled,
202            )?;
203        }
204        if !self.stale.is_empty() {
205            write!(
206                f,
207                "schemas named by a coverage table that the vendored contract does not have: \
208                 {:?} — the contract dropped or renamed them, and the models must move in the \
209                 same change. ",
210                self.stale,
211            )?;
212        }
213        if !self.contradictory.is_empty() {
214            write!(
215                f,
216                "schemas both modelled and allow-listed: {:?}. ",
217                self.contradictory
218            )?;
219        }
220        for disagreement in &self.disagreements {
221            write!(f, "{disagreement} ")?;
222        }
223        Ok(())
224    }
225}
226
227/// Compare this crate's models against the vendored contract's schemas.
228///
229/// Returns the report whether or not it is clean; [`check`] is the assertion
230/// form.
231///
232/// # Errors
233///
234/// Fails only when the vendored contract cannot be read at all, which this
235/// crate's contract tests catch long before.
236pub fn report(modelled: &[Entry], unmodelled: Table<'_>) -> Result<SchemaReport> {
237    let schemas = schemas()?;
238    let known: BTreeSet<&str> = schemas.keys().map(String::as_str).collect();
239    let modelled_ids: BTreeSet<&str> = modelled.iter().map(|entry| entry.schema).collect();
240    let unmodelled_ids: BTreeSet<&str> = unmodelled.iter().map(|(id, _)| *id).collect();
241
242    let owned =
243        |ids: BTreeSet<&str>| -> Vec<String> { ids.into_iter().map(ToOwned::to_owned).collect() };
244
245    let mut disagreements = Vec::new();
246    for entry in modelled {
247        let Some(schema) = schemas.get(entry.schema) else {
248            continue; // reported as stale below
249        };
250        disagreements.extend((entry.run)(schema, &schemas));
251    }
252
253    Ok(SchemaReport {
254        unmodelled: owned(
255            known
256                .iter()
257                .filter(|id| !modelled_ids.contains(*id) && !unmodelled_ids.contains(*id))
258                .copied()
259                .collect(),
260        ),
261        stale: owned(
262            modelled_ids
263                .union(&unmodelled_ids)
264                .filter(|id| !known.contains(*id))
265                .copied()
266                .collect(),
267        ),
268        contradictory: owned(
269            modelled_ids
270                .intersection(&unmodelled_ids)
271                .copied()
272                .collect(),
273        ),
274        disagreements,
275    })
276}
277
278/// The assertion form of [`report`], over this crate's own tables.
279///
280/// # Errors
281///
282/// The rendered report, when the models and the contract disagree.
283pub fn check() -> std::result::Result<(), String> {
284    let report = report(&registry(), UNMODELLED).map_err(|error| error.to_string())?;
285    if report.is_clean() {
286        return Ok(());
287    }
288    Err(report.to_string())
289}
290
291/// Hold one operation's parameter type to the parameters the contract declares.
292///
293/// `params` must be fully populated: the check is two-directional, so a value
294/// left unset reads as a parameter the type cannot express.
295///
296/// # Errors
297///
298/// A message naming every parameter the type sends that the contract does not
299/// declare, and every non-path parameter the contract declares that the type
300/// cannot send.
301pub fn check_params<P: ContractParams>(params: &P) -> std::result::Result<(), String> {
302    let surface = core().map_err(|error| error.to_string())?;
303    let method = surface.method(P::OPERATION).map_err(|e| e.to_string())?;
304    let declared: BTreeSet<&str> = method
305        .params
306        .iter()
307        .filter(|param| param.location != crate::cassettes::spec::Location::Path)
308        .map(|param| param.wire.as_str())
309        .collect();
310    let sent: BTreeSet<&str> = params.values().into_iter().map(|(wire, _)| wire).collect();
311
312    let undeclared: Vec<&str> = sent.difference(&declared).copied().collect();
313    let unsendable: Vec<&str> = declared.difference(&sent).copied().collect();
314    if undeclared.is_empty() && unsendable.is_empty() {
315        return Ok(());
316    }
317    Err(format!(
318        "{} parameters disagree with the contract: sends {undeclared:?} which the contract does \
319         not declare; cannot send {unsendable:?} which it does.",
320        P::OPERATION,
321    ))
322}
323
324/// One Rust enum's claim on a contract-declared value set.
325#[derive(Debug, Clone, Copy)]
326pub struct ClaimedEnum {
327    declared_by: &'static [(&'static str, &'static str)],
328    values: &'static [&'static str],
329}
330
331impl ClaimedEnum {
332    /// Register one parameter enum.
333    #[must_use]
334    pub fn of<E: ContractEnum>() -> Self {
335        Self {
336            declared_by: E::DECLARED_BY,
337            values: E::VALUES,
338        }
339    }
340}
341
342/// Hold the parameter enums to the value sets the contract closes.
343///
344/// Two-directional, like the schema gate: a value the contract added and the
345/// Rust enum lacks is unreachable from a typed call site, and a
346/// contract-declared set that no enum claims is a parameter still spelled by
347/// hand.
348///
349/// # Errors
350///
351/// A message naming every disagreement.
352pub fn check_enums(claimed: &[ClaimedEnum]) -> std::result::Result<(), String> {
353    let document = document().map_err(|error| error.to_string())?;
354    let mut problems = Vec::new();
355    let mut covered: BTreeSet<(&str, &str)> = BTreeSet::new();
356
357    for claim in claimed {
358        for (operation, parameter) in claim.declared_by {
359            covered.insert((operation, parameter));
360            problems.extend(compare_enum(&document, claim, operation, parameter));
361        }
362    }
363
364    for (operation, parameter, _) in every_declared_enum(&document) {
365        if !covered.contains(&(operation.as_str(), parameter.as_str())) {
366            problems.push(format!(
367                "{operation}'s {parameter} closes a value set that no typed enum claims."
368            ));
369        }
370    }
371
372    if problems.is_empty() {
373        return Ok(());
374    }
375    Err(problems.join(" "))
376}
377
378/// The vendored document, parsed.
379fn document() -> Result<Value> {
380    serde_yaml::from_str(TAPES_API_YAML)
381        .ok()
382        .context(error::VendoredContractSnafu {
383            surface: "tapes-api",
384        })
385}
386
387/// The vendored document's `components.schemas`.
388fn schemas() -> Result<Map<String, Value>> {
389    let document = document()?;
390    document
391        .get("components")
392        .and_then(|components| components.get("schemas"))
393        .and_then(Value::as_object)
394        .cloned()
395        .context(error::VendoredContractSnafu {
396            surface: "tapes-api",
397        })
398}
399
400/// One claim against one declaration, as a problem or nothing.
401fn compare_enum(
402    document: &Value,
403    claim: &ClaimedEnum,
404    operation: &str,
405    parameter: &str,
406) -> Option<String> {
407    let Some(values) = declared_enum(document, operation, parameter) else {
408        return Some(format!(
409            "{operation}'s {parameter} is claimed as a closed set, but the contract declares no \
410             enum for it."
411        ));
412    };
413    let ours: BTreeSet<&str> = claim.values.iter().copied().collect();
414    let theirs: BTreeSet<&str> = values.iter().map(String::as_str).collect();
415    if ours == theirs {
416        return None;
417    }
418    Some(format!(
419        "{operation}'s {parameter} accepts {theirs:?} but the typed enum offers {ours:?}."
420    ))
421}
422
423/// The `enum` a document declares for one operation's parameter, if any.
424fn declared_enum(document: &Value, operation: &str, parameter: &str) -> Option<Vec<String>> {
425    every_declared_enum(document)
426        .into_iter()
427        .find(|(op, name, _)| op == operation && name == parameter)
428        .map(|(_, _, values)| values)
429}
430
431/// Every `(operation, parameter, values)` the document closes with an `enum`.
432fn every_declared_enum(document: &Value) -> Vec<(String, String, Vec<String>)> {
433    let mut found = Vec::new();
434    for (operation, _, params) in operations(document) {
435        for param in params {
436            let Some(name) = param.get("name").and_then(Value::as_str) else {
437                continue;
438            };
439            let Some(values) = param
440                .get("schema")
441                .and_then(|schema| schema.get("enum"))
442                .and_then(Value::as_array)
443            else {
444                continue;
445            };
446            found.push((
447                operation.clone(),
448                name.to_owned(),
449                values
450                    .iter()
451                    .filter_map(Value::as_str)
452                    .map(ToOwned::to_owned)
453                    .collect(),
454            ));
455        }
456    }
457    found
458}
459
460/// `(operationId, path, parameters)` for every operation in the document.
461fn operations(document: &Value) -> Vec<(String, String, Vec<Value>)> {
462    let mut found = Vec::new();
463    let Some(paths) = document.get("paths").and_then(Value::as_object) else {
464        return found;
465    };
466    for (path, item) in paths {
467        let Some(item) = item.as_object() else {
468            continue;
469        };
470        for operation in item.values() {
471            let Some(id) = operation_id(operation) else {
472                continue;
473            };
474            let params = operation
475                .get("parameters")
476                .and_then(Value::as_array)
477                .cloned()
478                .unwrap_or_default();
479            found.push((id, path.clone(), params));
480        }
481    }
482    found
483}
484
485fn operation_id(operation: &Value) -> Option<String> {
486    operation
487        .get("operationId")
488        .and_then(Value::as_str)
489        .map(ToOwned::to_owned)
490}
491
492/// Put one model through every check the schema supports.
493fn audit<M: ContractModel>(schema: &Value, schemas: &Map<String, Value>) -> Vec<String> {
494    let name = M::SCHEMA;
495    let mut problems = Vec::new();
496    let populated = sample(schema, schemas, 0);
497
498    // 1. Everything the schema declares survives decode + re-encode.
499    match serde_json::from_value::<M>(populated.clone()) {
500        Err(error) => problems.push(format!(
501            "{name} does not decode a document built from its own schema: {error}.",
502        )),
503        Ok(model) => match serde_json::to_value(&model) {
504            Err(error) => problems.push(format!("{name} does not re-encode: {error}.")),
505            Ok(encoded) => survived(name, &populated, &encoded, &mut problems),
506        },
507    }
508
509    // 2. Optional properties really are optional; required ones really are
510    //    required. The contract declares required-ness per schema, so this
511    //    reads it rather than assuming today's answer (which is "none").
512    let required: Vec<&str> = schema
513        .get("required")
514        .and_then(Value::as_array)
515        .map(|names| names.iter().filter_map(Value::as_str).collect())
516        .unwrap_or_default();
517    let mut minimal = Map::new();
518    for property in &required {
519        if let Some(value) = populated.get(*property) {
520            minimal.insert((*property).to_owned(), value.clone());
521        }
522    }
523    if serde_json::from_value::<M>(Value::Object(minimal)).is_err() {
524        problems.push(format!(
525            "{name} does not decode a document carrying only the properties the contract \
526             requires; an optional property is modelled as mandatory.",
527        ));
528    }
529    for property in &required {
530        let mut without = populated.as_object().cloned().unwrap_or_default();
531        without.remove(*property);
532        if serde_json::from_value::<M>(Value::Object(without)).is_ok() {
533            problems.push(format!(
534                "{name}.{property} is required by the contract but decodes when absent.",
535            ));
536        }
537    }
538
539    // 3. A composite property tolerates an explicit null.
540    for (property, declared) in properties(schema) {
541        if !is_composite(declared) {
542            continue;
543        }
544        let mut nulled = populated.as_object().cloned().unwrap_or_default();
545        nulled.insert(property.clone(), Value::Null);
546        if serde_json::from_value::<M>(Value::Object(nulled)).is_err() {
547            problems.push(format!(
548                "{name}.{property} does not tolerate a null; a nil map, slice, or struct pointer \
549                 the server did not omit would blank the whole response.",
550            ));
551        }
552    }
553
554    problems
555}
556
557/// Report every value that did not survive the round trip, by path.
558fn survived(path: &str, sent: &Value, back: &Value, problems: &mut Vec<String>) {
559    match (sent, back) {
560        (Value::Object(sent), Value::Object(back)) => {
561            for (key, value) in sent {
562                match back.get(key) {
563                    None => problems.push(format!(
564                        "{path}.{key} is in the contract but not carried by the model.",
565                    )),
566                    Some(got) => survived(&format!("{path}.{key}"), value, got, problems),
567                }
568            }
569        }
570        (Value::Array(sent), Value::Array(back)) => {
571            for (index, value) in sent.iter().enumerate() {
572                match back.get(index) {
573                    None => problems.push(format!("{path}[{index}] was dropped by the model.")),
574                    Some(got) => survived(&format!("{path}[{index}]"), value, got, problems),
575                }
576            }
577        }
578        (sent, back) if sent != back => {
579            problems.push(format!("{path} decoded as {back} rather than {sent}."));
580        }
581        _ => {}
582    }
583}
584
585/// A schema's declared properties, resolving one level of `$ref`.
586fn properties(schema: &Value) -> Vec<(String, &Value)> {
587    schema
588        .get("properties")
589        .and_then(Value::as_object)
590        .map(|props| props.iter().map(|(k, v)| (k.clone(), v)).collect())
591        .unwrap_or_default()
592}
593
594/// Whether a property is one of the positions a `null` can legitimately arrive
595/// in: an array, a map, an object, or another schema.
596fn is_composite(schema: &Value) -> bool {
597    if schema.get("$ref").is_some() {
598        return true;
599    }
600    match schema.get("type").and_then(Value::as_str) {
601        Some("array" | "object") => true,
602        Some(_) => false,
603        // An untyped schema accepts anything, `null` included.
604        None => true,
605    }
606}
607
608fn resolve<'a>(schema: &Value, schemas: &'a Map<String, Value>) -> Option<&'a Value> {
609    let name = schema.get("$ref")?.as_str()?.rsplit('/').next()?;
610    schemas.get(name)
611}
612
613/// Build a document that exercises every property a schema declares.
614///
615/// Values are chosen to be exactly representable after a JSON round trip, so a
616/// faithful model returns them unchanged and the comparison stays a statement
617/// about the model rather than about float formatting.
618fn sample(schema: &Value, schemas: &Map<String, Value>, depth: usize) -> Value {
619    // The document nests about ten deep at its worst (a listing, of sessions,
620    // of rollups, of per-model spend). The cap is well past that and exists
621    // only so a schema that ever references itself terminates — loudly, as a
622    // decode failure, rather than by recursing until the stack ends.
623    if depth > 24 {
624        return Value::Null;
625    }
626    if let Some(target) = resolve(schema, schemas) {
627        return sample(target, schemas, depth + 1);
628    }
629    match schema.get("type").and_then(Value::as_str) {
630        Some("string") => match schema.get("format").and_then(Value::as_str) {
631            Some("date-time") => json!("2020-01-02T03:04:05Z"),
632            _ => json!("sample"),
633        },
634        Some("boolean") => json!(true),
635        Some("integer") => json!(1),
636        Some("number") => json!(1.5),
637        Some("array") => {
638            let items = schema.get("items").cloned().unwrap_or_else(|| json!({}));
639            json!([sample(&items, schemas, depth + 1)])
640        }
641        Some("object") | None => {
642            if let Some(props) = schema.get("properties").and_then(Value::as_object) {
643                let mut object = Map::new();
644                for (name, declared) in props {
645                    object.insert(name.clone(), sample(declared, schemas, depth + 1));
646                }
647                return Value::Object(object);
648            }
649            match schema.get("additionalProperties") {
650                Some(additional) if additional.as_object().is_some_and(Map::is_empty) => {
651                    json!({"key": "sample"})
652                }
653                Some(additional) => json!({"key": sample(additional, schemas, depth + 1)}),
654                None if schema.get("type").is_none() => json!("sample"),
655                None => json!({}),
656            }
657        }
658        Some(_) => json!("sample"),
659    }
660}
661
662#[cfg(test)]
663#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
664mod tests {
665    use super::*;
666    use crate::core::models::params::{
667        PayloadDetail, RawTurnListParams, SessionListParams, SessionTracesParams, SortDirection,
668        StatsParams, TraceListParams, TraceParams,
669    };
670    use serde::{Deserialize, Serialize};
671
672    #[test]
673    fn the_models_cover_the_vendored_contracts_schemas() {
674        // The gate itself. A contract bump that adds a schema, adds a field to
675        // one, or changes a field's type fails here — at build time, where
676        // somebody can decide about it — rather than by quietly dropping data.
677        assert_eq!(check(), Ok(()));
678    }
679
680    #[test]
681    fn a_schema_in_neither_table_is_reported_as_unmodelled() {
682        let report = report(&[Entry::of::<super::super::SessionItem>()], &[]).unwrap();
683        assert!(!report.is_clean());
684        assert!(
685            report.unmodelled.contains(&"SpanItem".to_owned()),
686            "got: {report:?}",
687        );
688    }
689
690    #[test]
691    fn a_table_entry_the_contract_does_not_have_is_reported_as_stale() {
692        let report = report(&[], &[("LaunchCodes", "nowhere")]).unwrap();
693        assert_eq!(report.stale, vec!["LaunchCodes".to_owned()]);
694    }
695
696    #[test]
697    fn a_model_that_drops_a_contract_field_is_reported_by_path() {
698        // The perturbation this gate exists to catch, pinned as a test rather
699        // than as a claim: a model missing one property of its schema names
700        // that property in the failure.
701        #[derive(Debug, Default, Serialize, Deserialize)]
702        #[serde(default)]
703        struct HalfASession {
704            id: String,
705        }
706        impl ContractModel for HalfASession {
707            const SCHEMA: &'static str = "SessionItem";
708        }
709
710        let report = report(&[Entry::of::<HalfASession>()], UNMODELLED).unwrap();
711        assert!(
712            report
713                .disagreements
714                .iter()
715                .any(|problem| problem.contains("SessionItem.display_title")
716                    && problem.contains("not carried by the model")),
717            "got: {report:?}",
718        );
719    }
720
721    #[test]
722    fn an_omittable_field_still_has_to_carry_its_property() {
723        // The partial-update bodies omit an unset field from the wire, which is
724        // exactly what a dropped property looks like to the round trip. So the
725        // gate has to keep telling the two apart, and this is where that is
726        // pinned: the model below carries one of a two-property schema's
727        // fields as an omittable `Option` and simply lacks the other. The one
728        // it models is populated by the sample and survives; the one it does
729        // not is reported by name, exactly as a plain missing field would be.
730        let schema = json!({
731            "type": "object",
732            "properties": {
733                "name": {"type": "string"},
734                "description": {"type": "string"},
735            },
736        });
737        let schemas = Map::new();
738
739        #[derive(Debug, Default, Serialize, Deserialize)]
740        #[serde(default)]
741        struct HalfAnUpdate {
742            #[serde(skip_serializing_if = "Option::is_none")]
743            name: Option<String>,
744        }
745        impl ContractModel for HalfAnUpdate {
746            const SCHEMA: &'static str = "Synthetic";
747        }
748
749        let problems = audit::<HalfAnUpdate>(&schema, &schemas);
750        assert!(
751            problems.iter().any(|problem| {
752                problem.contains("Synthetic.description")
753                    && problem.contains("not carried by the model")
754            }),
755            "the dropped property must be reported; got: {problems:?}",
756        );
757        assert!(
758            !problems
759                .iter()
760                .any(|problem| problem.contains("Synthetic.name")),
761            "an Option field the sample populates is carried, not missing; got: {problems:?}",
762        );
763    }
764
765    #[test]
766    fn a_model_that_mistypes_a_field_is_reported_as_a_decode_failure() {
767        #[derive(Debug, Default, Serialize, Deserialize)]
768        #[serde(default)]
769        struct MistypedUsage {
770            input_tokens: String,
771        }
772        impl ContractModel for MistypedUsage {
773            const SCHEMA: &'static str = "SessionUsage";
774        }
775
776        let report = report(&[Entry::of::<MistypedUsage>()], UNMODELLED).unwrap();
777        assert!(
778            report
779                .disagreements
780                .iter()
781                .any(|problem| problem.contains("does not decode a document built from its own")),
782            "got: {report:?}",
783        );
784    }
785
786    #[test]
787    fn a_composite_that_refuses_a_null_is_reported() {
788        // The rule that keeps one nil projection from costing a caller the
789        // whole document.
790        #[derive(Debug, Default, Serialize, Deserialize)]
791        #[serde(default)]
792        struct StrictItems {
793            items: Vec<Value>,
794        }
795        impl ContractModel for StrictItems {
796            const SCHEMA: &'static str = "RawTurnListResponse";
797        }
798
799        let report = report(&[Entry::of::<StrictItems>()], UNMODELLED).unwrap();
800        assert!(
801            report
802                .disagreements
803                .iter()
804                .any(|problem| problem.contains("RawTurnListResponse.items")
805                    && problem.contains("does not tolerate a null")),
806            "got: {report:?}",
807        );
808    }
809
810    #[test]
811    fn a_required_property_modelled_as_optional_is_reported() {
812        // The contract requires exactly one property today
813        // (`StandaloneTraceDetail.session_id`), which the gate holds strict
814        // through the registry above. The rule is read from the document
815        // rather than assumed, so this pins the branch on a synthetic schema
816        // where the model is deliberately lenient.
817        let schema = json!({
818            "type": "object",
819            "required": ["id"],
820            "properties": {"id": {"type": "string"}},
821        });
822        let schemas = Map::new();
823
824        #[derive(Debug, Default, Serialize, Deserialize)]
825        #[serde(default)]
826        struct Lenient {
827            id: String,
828        }
829        impl ContractModel for Lenient {
830            const SCHEMA: &'static str = "Synthetic";
831        }
832
833        let problems = audit::<Lenient>(&schema, &schemas);
834        assert!(
835            problems
836                .iter()
837                .any(|problem| problem.contains("required by the contract but decodes when absent")),
838            "got: {problems:?}",
839        );
840    }
841
842    #[test]
843    fn every_typed_parameter_set_matches_the_contracts_declaration() {
844        // Two-directional, and the struct literals are exhaustive: a parameter
845        // added to the contract fails the check, and a field added to one of
846        // these structs fails the compile until it is decided about here.
847        check_params(&SessionListParams {
848            limit: Some(1),
849            cursor: Some("c".to_owned()),
850            sort: Some("last_active".to_owned()),
851            direction: Some(SortDirection::Desc),
852            since: Some("2020-01-01T00:00:00Z".to_owned()),
853            until: Some("2020-01-02T00:00:00Z".to_owned()),
854            harness_id: Some("claude".to_owned()),
855            harness_session_id: Some("hs-1".to_owned()),
856            auth_subject: Some("user".to_owned()),
857            // Claimed pairs are runtime data the contract cannot declare;
858            // they travel outside `values()`, so populating one proves the
859            // declared-parameter agreement is judged without them.
860            claimed: vec![("flavor".to_owned(), "grape".to_owned())],
861        })
862        .unwrap();
863        check_params(&SessionTracesParams {
864            payload: Some(PayloadDetail::Full),
865            limit: Some(50),
866            cursor: Some("c".to_owned()),
867        })
868        .unwrap();
869        check_params(&TraceParams {
870            payload: Some(PayloadDetail::Preview),
871            limit: Some(200),
872            cursor: Some("c".to_owned()),
873        })
874        .unwrap();
875        check_params(&RawTurnListParams {
876            limit: Some(200),
877            cursor: Some("c".to_owned()),
878        })
879        .unwrap();
880        check_params(&TraceListParams {
881            session_id: "s-1".to_owned(),
882        })
883        .unwrap();
884        check_params(&StatsParams {
885            since: Some("2020-01-01T00:00:00Z".to_owned()),
886            until: Some("2020-01-02T00:00:00Z".to_owned()),
887            auth_subject: Some("user".to_owned()),
888        })
889        .unwrap();
890    }
891
892    #[test]
893    fn a_parameter_the_contract_does_not_declare_is_reported() {
894        struct Typo;
895        impl ContractParams for Typo {
896            const OPERATION: &'static str = "getSessionTraces";
897            fn values(&self) -> Vec<(&'static str, String)> {
898                vec![("payolad", "full".to_owned())]
899            }
900        }
901        let err = check_params(&Typo).unwrap_err();
902        assert!(err.contains("payolad"), "got: {err}");
903    }
904
905    #[test]
906    fn every_closed_value_set_in_the_contract_has_a_typed_enum() {
907        assert_eq!(
908            check_enums(&[
909                ClaimedEnum::of::<PayloadDetail>(),
910                ClaimedEnum::of::<SortDirection>(),
911            ]),
912            Ok(())
913        );
914    }
915
916    #[test]
917    fn a_value_set_no_typed_enum_claims_is_reported() {
918        let err = check_enums(&[]).unwrap_err();
919        assert!(err.contains("no typed enum claims"), "got: {err}");
920    }
921}