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