Skip to main content

car_server_core/
wire_schema.rs

1//! Deterministic release wire-schema generation and the `server.schema` payload.
2//!
3//! The committed `docs/wire-schema.json` is generated from the very Rust types
4//! the daemon serializes at each covered boundary. The daemon embeds that exact
5//! release artifact instead of regenerating at runtime, so a release binary and
6//! its published file cannot describe different contracts.
7//!
8//! ## Every covered surface is emitted from the type it is generated from
9//!
10//! The point of the artifact is that a consumer pinned to a digest can trust it
11//! after an upgrade. A schema transcribed from an inline `serde_json::json!`
12//! literal cannot deliver that: adding a field to the literal leaves the
13//! document and the digest unchanged, which is exactly the 0.53.0
14//! `server.handshake` break this file exists to prevent. So there are no
15//! schema-only mirrors here. Each type below is the value a handler actually
16//! returns:
17//!
18//! | Schema key | Emitter |
19//! |---|---|
20//! | `rpc.server.handshake.result` | [`ServerHandshakeResult`], returned by `handler::handle_server_handshake` |
21//! | `rpc.tools.*.result` | [`ToolsListResult`] and the other typed tool results below, returned by the matching `handler::handle_tools_*` function |
22//! | `rpc.state.*.result` | the typed state results below, returned by the matching `handler::handle_state_*` function |
23//! | `rpc.capabilities.list.result` | [`CapabilitiesListResult`], returned by `handler::handle_capabilities_list` |
24//! | `rpc.server.schema.result` | [`ServerSchemaResult`], returned by [`committed_payload`] |
25//! | `cli.car_inspect.result` | [`ManagedAgentListRow`] from `handler::handle_agents_list`, and [`DeclarativeAgentRow`] from `coder::rpc::declarative_row` |
26//! | `journal.event` | [`car_eventlog::Event`] |
27//! | `rpc.infer.result` | [`car_inference::InferenceResult`] |
28//! | `rpc.models.catalog_snapshot.result` | [`car_inference::catalog_identity::CatalogSnapshot`] |
29//! | `type.action_result` | [`car_ir::ActionResult`] |
30//!
31//! Adding a field to any of those is therefore a change to the generated
32//! document and to the digest, and is a compile error to do by any other route.
33//!
34//! ## Why `required` is still written out by hand, and how it is checked
35//!
36//! schemars derives `required` from serde's *input* rules: a field with
37//! `#[serde(default)]` is optional, and an `Option<T>` is optional. CAR's output
38//! rules are different — plenty of fields are always emitted (as `null` when
39//! absent) precisely so a consumer can tell "not measured" from "your protocol
40//! version has no such field". `#[schemars(required)]` does not bridge the gap:
41//! it is inert on a `serde(default)` field, and on an `Option` it *removes* the
42//! `null` from the type, which would be a second lie. So [`document`] states the
43//! always-emitted set explicitly — and
44//! `tests::required_lists_exactly_the_fields_a_minimal_value_emits` asserts
45//! each list against a value whose every `Option` is `None`, where the emitted
46//! key set *is* the always-emitted set. The list cannot drift in either
47//! direction without a red test.
48//!
49//! ## The positive control is a source change, not a document edit
50//!
51//! Mutating the already-generated document only proves SHA-256 is sensitive to
52//! bytes. To prove the *pipeline* is sensitive to a wire change, add a field to
53//! one of the emitter types above in a scratch checkout, run
54//! `bash scripts/build-wire-schema.sh`, confirm `docs/wire-schema.sha256`
55//! changed, and revert. `tests::every_covered_schema_validates_its_real_emitted_value`
56//! is the standing guard between those runs: it compiles each generated schema
57//! and validates a real serialized value against it, so `additionalProperties:
58//! false` turns any emitter/schema divergence into a failing test.
59//!
60//! ## The release version is deliberately outside the digested bytes
61//!
62//! `docs/wire-schema.json` carries no version string. `scripts/release.sh`
63//! rewrites the workspace version in its bump commit and does not regenerate
64//! this pair; if the version were digested, that commit would fail the required
65//! `test` check and, if forced past, publish an artifact naming the previous
66//! release. The version a caller needs is reported at serve time instead, in
67//! [`ServerSchemaResult::car_version`], read from the running binary.
68
69use std::collections::{BTreeMap, BTreeSet};
70
71use schemars::{schema::RootSchema, schema_for, JsonSchema};
72use serde::Serialize;
73use serde_json::{json, Value};
74use sha2::{Digest, Sha256};
75
76pub const FORMAT: &str = "car.wire-schema.v1";
77pub const DIGEST_ALGORITHM: &str = "sha256";
78pub const FOLLOW_UP_BEAD: &str = "car-86cq.1";
79
80// Read from the CRATE, not from `docs/`. `include_str!` reaching outside the
81// package root compiles fine in the workspace and then fails
82// `cargo package --verify`, whose tarball can only contain files under this
83// directory:
84//
85//   error: couldn't read `src/../../../../docs/wire-schema.json`
86//   error: failed to verify package tarball
87//
88// That made car-server-core unpublishable from the moment #1598 introduced
89// these constants, and because crates.io is the last channel a release
90// touches, it surfaced only when v0.55.0 reached the post-CI tail.
91// `docs/wire-schema.json` remains the published copy; both are written by
92// scripts/build-wire-schema.sh and compared by
93// scripts/check-wire-schema-freshness.sh, so they cannot drift.
94const COMMITTED_SCHEMA: &str = include_str!("../wire-schema.json");
95const COMMITTED_DIGEST: &str = include_str!("../wire-schema.sha256");
96
97/// The `server.handshake` result. Built and serialized by
98/// `handler::handle_server_handshake`; nothing else may write that reply.
99#[derive(Serialize, JsonSchema)]
100pub(crate) struct ServerHandshakeResult {
101    pub protocol_version: u32,
102    pub server_version: String,
103    pub client_protocol_version: u64,
104    pub client_version: String,
105    pub negotiated_capabilities: Vec<String>,
106    pub assistant_name: String,
107    pub assistant_aliases: Vec<String>,
108    pub assistant_brand: String,
109}
110
111/// The `tools.list` result. Built and serialized by `handler::handle_tools_list`.
112#[derive(Serialize, JsonSchema)]
113pub(crate) struct ToolsListResult {
114    pub tools: Vec<car_ir::ToolSchema>,
115    pub count: usize,
116}
117
118/// The numeric `tools.register` result.
119#[derive(Serialize, JsonSchema)]
120#[serde(transparent)]
121pub(crate) struct ToolsRegisterResult(pub usize);
122
123/// The `tools.unregister` result.
124#[derive(Serialize, JsonSchema)]
125pub(crate) struct ToolsUnregisterResult {
126    pub unregistered: String,
127    pub removed: u32,
128}
129
130/// The `tools.cancel` result.
131#[derive(Serialize, JsonSchema)]
132pub(crate) struct ToolsCancelResult {
133    pub cancelled: bool,
134}
135
136/// The `tools.stream.subscribe` result.
137#[derive(Serialize, JsonSchema)]
138pub(crate) struct ToolsStreamSubscribeResult {
139    pub subscribed: bool,
140}
141
142/// The exact string returned by `state.set`.
143#[derive(Serialize, JsonSchema)]
144#[serde(rename_all = "lowercase")]
145pub(crate) enum StateSetResult {
146    Ok,
147}
148
149/// The boolean returned by `state.exists`.
150#[derive(Serialize, JsonSchema)]
151#[serde(transparent)]
152pub(crate) struct StateExistsResult(pub bool);
153
154/// The string array returned by `state.keys`.
155#[derive(Serialize, JsonSchema)]
156#[serde(transparent)]
157pub(crate) struct StateKeysResult(pub Vec<String>);
158
159/// The arbitrary-value map returned by `state.snapshot`.
160#[derive(Serialize, JsonSchema)]
161#[serde(transparent)]
162pub(crate) struct StateSnapshotResult(
163    #[schemars(with = "BTreeMap<String, Value>")] pub serde_json::Map<String, Value>,
164);
165
166/// The closed caller roles emitted by `capabilities.list`.
167#[derive(Clone, Copy, Serialize, JsonSchema)]
168#[serde(rename_all = "snake_case")]
169pub(crate) enum CapabilityRole {
170    Agent,
171    Owner,
172    Operator,
173    Host,
174}
175
176impl CapabilityRole {
177    pub(crate) fn from_manifest(role: &str) -> Self {
178        match role {
179            "agent" => Self::Agent,
180            "owner" => Self::Owner,
181            "operator" => Self::Operator,
182            "host" => Self::Host,
183            other => panic!("generated RPC capability has unknown role `{other}`"),
184        }
185    }
186}
187
188/// One source-derived method row in `capabilities.list`.
189#[derive(Serialize, JsonSchema)]
190pub(crate) struct CapabilityMethodRow {
191    pub method: String,
192    pub role: CapabilityRole,
193}
194
195/// The `capabilities.list` result.
196#[derive(Serialize, JsonSchema)]
197pub(crate) struct CapabilitiesListResult {
198    pub caller_role: CapabilityRole,
199    pub count: usize,
200    pub methods: Vec<CapabilityMethodRow>,
201}
202
203/// The `server.schema` result. Built and serialized by [`committed_payload`].
204#[derive(Serialize, JsonSchema)]
205pub(crate) struct ServerSchemaResult {
206    pub schema: Value,
207    pub digest: String,
208    pub digest_algorithm: DigestAlgorithm,
209    /// The release this daemon binary is, read at serve time. Deliberately not
210    /// part of the digested document — see the module header.
211    pub car_version: String,
212}
213
214#[derive(Serialize, JsonSchema)]
215#[serde(rename_all = "lowercase")]
216pub(crate) enum DigestAlgorithm {
217    Sha256,
218}
219
220/// A lifecycle-managed agent as the daemon publishes it: the supervisor's
221/// `ManagedAgent` minus its per-agent token.
222///
223/// The redaction lives in this projection rather than on `AgentSpec::token`
224/// itself because `AgentSpec` is also the on-disk `agents.json` format, and
225/// `skip_serializing` there would blank every token on the next manifest write.
226#[derive(Serialize, JsonSchema)]
227pub(crate) struct ManagedAgentWire {
228    pub id: String,
229    pub name: String,
230    pub command: String,
231    pub args: Vec<String>,
232    pub cwd: Option<String>,
233    pub env: BTreeMap<String, String>,
234    pub restart: car_registry::supervisor::RestartPolicy,
235    pub max_restarts: u32,
236    pub backoff_secs: u64,
237    pub auto_start: bool,
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub method_allowlist: Option<Vec<String>>,
240    pub capabilities: Vec<String>,
241    pub status: car_registry::supervisor::AgentStatus,
242    pub pid: Option<u32>,
243    pub last_exit_code: Option<i32>,
244    pub restart_count: u32,
245    pub started_at: Option<i64>,
246    #[serde(skip_serializing_if = "Option::is_none")]
247    pub blocked_by_pid: Option<i32>,
248}
249
250impl ManagedAgentWire {
251    pub(crate) fn from_managed(agent: &car_registry::supervisor::ManagedAgent) -> Self {
252        let spec = &agent.spec;
253        Self {
254            id: spec.id.clone(),
255            name: spec.name.clone(),
256            command: spec.command.clone(),
257            args: spec.args.clone(),
258            cwd: spec
259                .cwd
260                .as_ref()
261                .map(|path| path.to_string_lossy().into_owned()),
262            env: spec.env.clone(),
263            restart: spec.restart,
264            max_restarts: spec.max_restarts,
265            backoff_secs: spec.backoff_secs,
266            auto_start: spec.auto_start,
267            method_allowlist: spec.method_allowlist.clone(),
268            capabilities: spec.capabilities.clone(),
269            status: agent.status,
270            pid: agent.pid,
271            last_exit_code: agent.last_exit_code,
272            restart_count: agent.restart_count,
273            started_at: agent.started_at,
274            blocked_by_pid: agent.blocked_by_pid,
275        }
276    }
277}
278
279/// One `agents.list` row: the redacted agent plus the decorations only the
280/// daemon holding the connection can supply.
281#[derive(Serialize, JsonSchema)]
282pub(crate) struct ManagedAgentListRow {
283    #[serde(flatten)]
284    pub agent: ManagedAgentWire,
285    /// Whether the supervised process has called `session.auth { agent_id }`
286    /// and bound a WebSocket connection to this daemon.
287    pub attached: bool,
288    /// The attached agent's current model-visible tool names, when it supports
289    /// the bounded `agent.chat.tools` reverse query. Omitted for detached or
290    /// older supervised agents rather than guessing from broad capabilities.
291    #[serde(skip_serializing_if = "Option::is_none")]
292    pub tools: Option<Vec<String>>,
293    pub manifest_path: String,
294    pub log_path: String,
295    pub stderr_log_path: String,
296    #[serde(skip_serializing_if = "Option::is_none")]
297    pub session_id: Option<String>,
298}
299
300/// A declarative (in-daemon) agent rendered as an `agents.list` row. Built and
301/// serialized by `coder::rpc::declarative_row`.
302#[derive(Serialize, JsonSchema)]
303pub(crate) struct DeclarativeAgentRow {
304    pub id: String,
305    pub name: String,
306    pub kind: DeclarativeAgentKind,
307    pub enabled: bool,
308    pub capabilities: Vec<String>,
309    pub description: String,
310    pub tools: Vec<String>,
311    pub goal: Option<car_registry::declarative::DeclarativeGoal>,
312    pub scenarios: usize,
313}
314
315impl DeclarativeAgentRow {
316    pub(crate) fn from_spec(spec: &car_registry::declarative::DeclarativeAgentSpec) -> Self {
317        let description = if spec.standing_goal.trim().is_empty() {
318            spec.identity.trim()
319        } else {
320            spec.standing_goal.trim()
321        };
322        Self {
323            id: spec.id.clone(),
324            name: spec.name.clone(),
325            kind: DeclarativeAgentKind::Declarative,
326            enabled: spec.enabled,
327            capabilities: vec!["chat".to_string()],
328            description: description.to_string(),
329            tools: spec.tools.clone(),
330            goal: spec.goal.clone(),
331            scenarios: spec.scenarios.len(),
332        }
333    }
334}
335
336#[derive(Serialize, JsonSchema)]
337#[serde(rename_all = "snake_case")]
338pub(crate) enum DeclarativeAgentKind {
339    Declarative,
340}
341
342/// `car inspect` and `agents.list` return either kind of row, untagged.
343// The declarative arm is constructed through `declarative_row`, which returns a
344// `Value`; this enum exists to generate the union both arms are validated
345// against.
346#[allow(dead_code)]
347#[derive(Serialize, JsonSchema)]
348#[serde(untagged)]
349pub(crate) enum CarInspectResult {
350    Managed(ManagedAgentListRow),
351    Declarative(DeclarativeAgentRow),
352}
353
354fn closed_schema<T: JsonSchema>() -> Value {
355    let root: RootSchema = schema_for!(T);
356    let mut value = serde_json::to_value(root).expect("RootSchema serialization is infallible");
357    close_declared_objects(&mut value);
358    value
359}
360
361/// Every client-to-daemon result surface, derived from the generated dispatch
362/// inventory plus the pre-dispatch handshake.
363fn rpc_method_inventory() -> BTreeSet<&'static str> {
364    let mut methods = BTreeSet::from(["server.handshake"]);
365    for (method, _) in crate::generated_rpc_capabilities::RPC_CAPABILITIES {
366        assert!(
367            methods.insert(method),
368            "duplicate daemon RPC method in source inventory: {method}"
369        );
370    }
371    methods
372}
373
374fn insert_rpc_schema(
375    schemas: &mut BTreeMap<String, Value>,
376    covered_rpc_methods: &mut BTreeSet<&'static str>,
377    method: &'static str,
378    schema: Value,
379) {
380    assert!(
381        rpc_method_inventory().contains(method),
382        "wire schema covers unknown daemon RPC method: {method}"
383    );
384    assert!(
385        covered_rpc_methods.insert(method),
386        "wire schema covers daemon RPC method twice: {method}"
387    );
388    let key = format!("rpc.{method}.result");
389    assert!(
390        schemas.insert(key.clone(), schema).is_none(),
391        "duplicate wire schema key: {key}"
392    );
393}
394
395/// Read the closed strings from the generated `EventKind` schema. This keeps
396/// the payload coverage inventory source-derived: adding an enum variant makes
397/// the generated list grow even before that variant gains a typed payload.
398fn event_kind_inventory(event_schema: &Value) -> BTreeSet<String> {
399    fn collect(value: &Value, out: &mut BTreeSet<String>) {
400        match value {
401            Value::Array(values) => {
402                for value in values {
403                    collect(value, out);
404                }
405            }
406            Value::Object(map) => {
407                if let Some(values) = map.get("enum").and_then(Value::as_array) {
408                    for value in values {
409                        if let Some(value) = value.as_str() {
410                            out.insert(value.to_string());
411                        }
412                    }
413                }
414                for value in map.values() {
415                    collect(value, out);
416                }
417            }
418            _ => {}
419        }
420    }
421
422    let mut kinds = BTreeSet::new();
423    collect(&event_schema["definitions"]["EventKind"], &mut kinds);
424    assert!(!kinds.is_empty(), "generated EventKind inventory is empty");
425    kinds
426}
427
428fn require_fields(schema: &mut Value, fields: &[&str]) {
429    schema["required"] = Value::Array(
430        fields
431            .iter()
432            .map(|field| Value::String((*field).to_string()))
433            .collect(),
434    );
435}
436
437/// Mark every declared property of every object in `value` as required.
438///
439/// **Precondition: no type in the subtree carries `skip_serializing_if`.** This
440/// is used only for the model-catalog subtree, where it holds today and where
441/// annotating ~40 fields across `ModelSchema`, `ModelSource`, `CostModel` and
442/// friends would be the alternative. It becomes silently wrong the moment
443/// someone adds a conditional field, so the precondition is asserted, not
444/// assumed: `tests::catalog_snapshot_declares_no_conditionally_emitted_field`
445/// serializes a catalog row whose every `Option` is `None` and validates it
446/// against this schema, which fails the moment a field stops being emitted.
447fn require_all_declared_fields(value: &mut Value) {
448    match value {
449        Value::Array(values) => {
450            for value in values {
451                require_all_declared_fields(value);
452            }
453        }
454        Value::Object(map) => {
455            for value in map.values_mut() {
456                require_all_declared_fields(value);
457            }
458            if let Some(Value::Object(properties)) = map.get("properties") {
459                let fields = properties.keys().cloned().map(Value::String).collect();
460                map.insert("required".into(), Value::Array(fields));
461            }
462        }
463        _ => {}
464    }
465}
466
467/// Apply a required list to every occurrence of a derived type in `schema`.
468///
469/// Located by NAME rather than by a fixed `definitions["X"]` path, because
470/// whether schemars hoists a type into `definitions` or inlines it at the
471/// property is an implementation detail that moves with the field's attributes
472/// — `Option<DeclarativeGoal>` hoists, the same field with a `schemars`
473/// attribute inlines. A hard-coded path panics the day that flips. Hoisted
474/// definitions are keyed by the type name and carry no `title`; a root or
475/// inlined copy carries `title` and no key. Both are matched, and every copy is
476/// updated when a type appears more than once.
477fn require_fields_for(schema: &mut Value, title: &str, fields: &[&str]) {
478    fn walk(
479        value: &mut Value,
480        title: &str,
481        fields: &[&str],
482        applied: &mut bool,
483        own_name: Option<&str>,
484        entries_are_definitions: bool,
485    ) {
486        match value {
487            Value::Array(values) => {
488                for value in values {
489                    walk(value, title, fields, applied, None, false);
490                }
491            }
492            Value::Object(map) => {
493                let named = own_name == Some(title)
494                    || map.get("title").and_then(Value::as_str) == Some(title);
495                if named && map.contains_key("properties") {
496                    let required = fields
497                        .iter()
498                        .map(|field| Value::String((*field).to_string()))
499                        .collect();
500                    map.insert("required".into(), Value::Array(required));
501                    *applied = true;
502                }
503                for (key, child) in map.iter_mut() {
504                    let child_name = entries_are_definitions.then(|| key.clone());
505                    let child_defines = !entries_are_definitions && key == "definitions";
506                    walk(
507                        child,
508                        title,
509                        fields,
510                        applied,
511                        child_name.as_deref(),
512                        child_defines,
513                    );
514                }
515            }
516            _ => {}
517        }
518    }
519
520    let mut applied = false;
521    walk(schema, title, fields, &mut applied, None, false);
522    assert!(
523        applied,
524        "generated schema has no object named {title} to require fields on"
525    );
526}
527
528/// Output schemas describe exact emitted objects. Any schema object that
529/// declares named properties and is not already a map receives
530/// `additionalProperties: false`; HashMap/JSON Value fields retain their own
531/// explicitly permissive shape.
532fn close_declared_objects(value: &mut Value) {
533    match value {
534        Value::Array(values) => {
535            for value in values {
536                close_declared_objects(value);
537            }
538        }
539        Value::Object(map) => {
540            for value in map.values_mut() {
541                close_declared_objects(value);
542            }
543            // Schemars evaluates serde default functions. `Utc::now` would put
544            // generation time into an OUTPUT contract and change the digest on
545            // every run; defaults are input semantics, so remove them all.
546            map.remove("default");
547            if map.contains_key("properties") && !map.contains_key("additionalProperties") {
548                map.insert("additionalProperties".into(), Value::Bool(false));
549            }
550        }
551        _ => {}
552    }
553}
554
555/// Build the deterministic schema document from the canonical Rust wire types.
556///
557/// Property sets, types and enum values come from the emitter types themselves.
558/// `required` is stated here because serde's input rules and CAR's output rules
559/// disagree — see the module header, and the test that checks every list below
560/// against a real minimal value.
561pub fn document() -> Value {
562    let mut schemas = BTreeMap::new();
563    let mut covered_rpc_methods = BTreeSet::new();
564
565    let mut inspect = closed_schema::<CarInspectResult>();
566    require_fields_for(
567        &mut inspect,
568        "ManagedAgentListRow",
569        MANAGED_AGENT_LIST_ROW_REQUIRED,
570    );
571    require_fields_for(
572        &mut inspect,
573        "DeclarativeAgentRow",
574        DECLARATIVE_AGENT_ROW_REQUIRED,
575    );
576    require_fields_for(&mut inspect, "DeclarativeGoal", DECLARATIVE_GOAL_REQUIRED);
577    schemas.insert("cli.car_inspect.result".to_string(), inspect);
578
579    let mut event = closed_schema::<car_eventlog::Event>();
580    require_fields(&mut event, EVENT_REQUIRED);
581    let all_event_kinds = event_kind_inventory(&event);
582    schemas.insert("journal.event".to_string(), event);
583
584    insert_rpc_schema(
585        &mut schemas,
586        &mut covered_rpc_methods,
587        "capabilities.list",
588        closed_schema::<CapabilitiesListResult>(),
589    );
590
591    let mut inference = closed_schema::<car_inference::InferenceResult>();
592    require_fields(&mut inference, INFERENCE_RESULT_REQUIRED);
593    require_fields_for(&mut inference, "TokenUsage", TOKEN_USAGE_REQUIRED);
594    require_fields_for(&mut inference, "ToolCall", TOOL_CALL_REQUIRED);
595    require_fields_for(&mut inference, "ThinkingBlock", THINKING_BLOCK_REQUIRED);
596    require_fields_for(&mut inference, "BoundingBox", BOUNDING_BOX_REQUIRED);
597    require_fields_for(&mut inference, "FallbackFrom", FALLBACK_FROM_REQUIRED);
598    insert_rpc_schema(&mut schemas, &mut covered_rpc_methods, "infer", inference);
599
600    let mut catalog = closed_schema::<car_inference::catalog_identity::CatalogSnapshot>();
601    require_all_declared_fields(&mut catalog);
602    // `Quantization`'s hand-written `Serialize` omits `bits` and `group_size`
603    // when the label already carries them, so it is the one member of the
604    // catalog subtree the blanket pass above must not close over.
605    require_fields_for(
606        &mut catalog,
607        "QuantizationObjectWireSchema",
608        QUANTIZATION_OBJECT_REQUIRED,
609    );
610    insert_rpc_schema(
611        &mut schemas,
612        &mut covered_rpc_methods,
613        "models.catalog_snapshot",
614        catalog,
615    );
616
617    // The handshake and schema replies carry no `Option` and no serde default,
618    // so schemars already requires every field; nothing to state here.
619    insert_rpc_schema(
620        &mut schemas,
621        &mut covered_rpc_methods,
622        "server.handshake",
623        closed_schema::<ServerHandshakeResult>(),
624    );
625    insert_rpc_schema(
626        &mut schemas,
627        &mut covered_rpc_methods,
628        "server.schema",
629        closed_schema::<ServerSchemaResult>(),
630    );
631
632    insert_rpc_schema(
633        &mut schemas,
634        &mut covered_rpc_methods,
635        "state.get",
636        closed_schema::<Value>(),
637    );
638    insert_rpc_schema(
639        &mut schemas,
640        &mut covered_rpc_methods,
641        "state.set",
642        closed_schema::<StateSetResult>(),
643    );
644    insert_rpc_schema(
645        &mut schemas,
646        &mut covered_rpc_methods,
647        "state.exists",
648        closed_schema::<StateExistsResult>(),
649    );
650    insert_rpc_schema(
651        &mut schemas,
652        &mut covered_rpc_methods,
653        "state.keys",
654        closed_schema::<StateKeysResult>(),
655    );
656    insert_rpc_schema(
657        &mut schemas,
658        &mut covered_rpc_methods,
659        "state.snapshot",
660        closed_schema::<StateSnapshotResult>(),
661    );
662
663    insert_rpc_schema(
664        &mut schemas,
665        &mut covered_rpc_methods,
666        "tools.register",
667        closed_schema::<ToolsRegisterResult>(),
668    );
669    let mut tools = closed_schema::<ToolsListResult>();
670    require_fields_for(&mut tools, "ToolSchema", TOOL_SCHEMA_REQUIRED);
671    insert_rpc_schema(&mut schemas, &mut covered_rpc_methods, "tools.list", tools);
672    insert_rpc_schema(
673        &mut schemas,
674        &mut covered_rpc_methods,
675        "tools.unregister",
676        closed_schema::<ToolsUnregisterResult>(),
677    );
678    let mut poll = closed_schema::<Option<car_engine::tool_handles::ToolPollResult>>();
679    require_fields_for(&mut poll, "ToolPollResult", TOOL_POLL_RESULT_REQUIRED);
680    insert_rpc_schema(&mut schemas, &mut covered_rpc_methods, "tools.poll", poll);
681    insert_rpc_schema(
682        &mut schemas,
683        &mut covered_rpc_methods,
684        "tools.cancel",
685        closed_schema::<ToolsCancelResult>(),
686    );
687    insert_rpc_schema(
688        &mut schemas,
689        &mut covered_rpc_methods,
690        "tools.stream.subscribe",
691        closed_schema::<ToolsStreamSubscribeResult>(),
692    );
693
694    let mut action_result = closed_schema::<car_ir::ActionResult>();
695    require_fields(&mut action_result, ACTION_RESULT_REQUIRED);
696    schemas.insert("type.action_result".to_string(), action_result);
697
698    let all_rpc_methods = rpc_method_inventory();
699    let uncovered_rpc_methods: Vec<&str> = all_rpc_methods
700        .difference(&covered_rpc_methods)
701        .copied()
702        .collect();
703    let covered_event_kinds: BTreeSet<String> = BTreeSet::new();
704    let uncovered_event_kinds: Vec<&str> = all_event_kinds
705        .difference(&covered_event_kinds)
706        .map(String::as_str)
707        .collect();
708    let complete = uncovered_rpc_methods.is_empty() && uncovered_event_kinds.is_empty();
709
710    json!({
711        "format": FORMAT,
712        "json_schema_draft": "http://json-schema.org/draft-07/schema#",
713        "digest": {
714            "algorithm": DIGEST_ALGORITHM,
715            "scope": "exact UTF-8 bytes of docs/wire-schema.json"
716        },
717        "coverage": {
718            "complete": complete,
719            "covered": schemas.keys().collect::<Vec<_>>(),
720            "rpc_results": {
721                "total": all_rpc_methods.len(),
722                "covered": covered_rpc_methods,
723                "uncovered": uncovered_rpc_methods
724            },
725            "journal_event_payloads": {
726                "total": all_event_kinds.len(),
727                "covered": covered_event_kinds,
728                "uncovered": uncovered_event_kinds
729            },
730            "limitations": [
731                "journal.event covers the exact envelope and closed EventKind enum; coverage.journal_event_payloads.uncovered names every kind whose Event.data remains an open JSON object",
732                "coverage.rpc_results.uncovered is derived from the daemon dispatch inventory and names every result that still emits inline or otherwise lacks a schema from its real Rust type",
733                "the release version is reported at serve time in the server.schema result and is deliberately absent from these digested bytes, so the digest tracks wire shape alone"
734            ],
735            "follow_up": FOLLOW_UP_BEAD
736        },
737        "schemas": schemas
738    })
739}
740
741/// The always-emitted field sets. Each is asserted against a real minimal value
742/// by `tests::required_lists_exactly_the_fields_a_minimal_value_emits`.
743const MANAGED_AGENT_LIST_ROW_REQUIRED: &[&str] = &[
744    "id",
745    "name",
746    "command",
747    "args",
748    "cwd",
749    "env",
750    "restart",
751    "max_restarts",
752    "backoff_secs",
753    "auto_start",
754    "capabilities",
755    "status",
756    "pid",
757    "last_exit_code",
758    "restart_count",
759    "started_at",
760    "attached",
761    "manifest_path",
762    "log_path",
763    "stderr_log_path",
764];
765const DECLARATIVE_AGENT_ROW_REQUIRED: &[&str] = &[
766    "id",
767    "name",
768    "kind",
769    "enabled",
770    "capabilities",
771    "description",
772    "tools",
773    "goal",
774    "scenarios",
775];
776const DECLARATIVE_GOAL_REQUIRED: &[&str] = &["check", "max_iterations"];
777const EVENT_REQUIRED: &[&str] = &["kind", "data", "timestamp"];
778const INFERENCE_RESULT_REQUIRED: &[&str] = &[
779    "text",
780    "tool_calls",
781    "trace_id",
782    "model_used",
783    "requested_model_id",
784    "resolved_model_id",
785    "row_digest",
786    "catalog_revision",
787    "latency_ms",
788    "time_to_first_token_ms",
789    "usage",
790    "stop_reason",
791];
792const TOKEN_USAGE_REQUIRED: &[&str] = &[
793    "prompt_tokens",
794    "completion_tokens",
795    "total_tokens",
796    "context_window",
797    "cache_read_input_tokens",
798    "cache_creation_input_tokens",
799];
800const TOOL_CALL_REQUIRED: &[&str] = &["name", "arguments"];
801const THINKING_BLOCK_REQUIRED: &[&str] = &["text"];
802const BOUNDING_BOX_REQUIRED: &[&str] = &["x1", "y1", "x2", "y2"];
803const FALLBACK_FROM_REQUIRED: &[&str] = &["candidate", "reason"];
804const TOOL_SCHEMA_REQUIRED: &[&str] =
805    &["name", "source", "description", "parameters", "idempotent"];
806const TOOL_POLL_RESULT_REQUIRED: &[&str] = &["handle", "tool", "action_id", "status", "chunks"];
807const ACTION_RESULT_REQUIRED: &[&str] = &["action_id", "status", "state_changes", "timestamp"];
808const QUANTIZATION_OBJECT_REQUIRED: &[&str] = &["scheme", "label"];
809
810/// Pretty, LF-terminated bytes committed and published with each release.
811pub fn rendered_document() -> Vec<u8> {
812    let mut bytes = serde_json::to_vec_pretty(&document()).expect("wire schema serializes");
813    bytes.push(b'\n');
814    bytes
815}
816
817pub fn sha256_hex(bytes: &[u8]) -> String {
818    format!("{:x}", Sha256::digest(bytes))
819}
820
821pub fn rendered_digest_file(schema_bytes: &[u8]) -> Vec<u8> {
822    format!("{}  wire-schema.json\n", sha256_hex(schema_bytes)).into_bytes()
823}
824
825fn committed_digest() -> Result<&'static str, String> {
826    let mut fields = COMMITTED_DIGEST.split_whitespace();
827    let digest = fields
828        .next()
829        .ok_or("committed wire schema digest is empty")?;
830    let filename = fields
831        .next()
832        .ok_or("committed wire schema digest omits its filename")?;
833    if fields.next().is_some() || filename != "wire-schema.json" {
834        return Err("committed wire schema digest must be '<sha256>  wire-schema.json'".into());
835    }
836    if digest.len() != 64
837        || !digest
838            .bytes()
839            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
840    {
841        return Err("committed wire schema digest is not lowercase SHA-256".into());
842    }
843    Ok(digest)
844}
845
846/// Return the exact schema document embedded in this daemon release, the
847/// SHA-256 digest published beside it, and the release this binary is.
848pub fn committed_payload() -> Result<Value, String> {
849    let digest = committed_digest()?;
850    let actual = sha256_hex(COMMITTED_SCHEMA.as_bytes());
851    if actual != digest {
852        return Err(format!(
853            "embedded wire schema digest mismatch: expected {digest}, got {actual}"
854        ));
855    }
856    let schema: Value = serde_json::from_str(COMMITTED_SCHEMA)
857        .map_err(|error| format!("embedded wire schema is invalid JSON: {error}"))?;
858    serde_json::to_value(ServerSchemaResult {
859        schema,
860        digest: digest.to_string(),
861        digest_algorithm: DigestAlgorithm::Sha256,
862        car_version: env!("CARGO_PKG_VERSION").to_string(),
863    })
864    .map_err(|error| format!("server.schema payload does not serialize: {error}"))
865}
866
867#[cfg(test)]
868mod tests {
869    use super::*;
870    use std::collections::HashMap;
871
872    #[test]
873    fn repeated_generation_is_byte_identical() {
874        assert_eq!(rendered_document(), rendered_document());
875        assert_eq!(
876            rendered_digest_file(&rendered_document()),
877            rendered_digest_file(&rendered_document())
878        );
879    }
880
881    /// A version bump must not touch the digested bytes.
882    ///
883    /// `scripts/release.sh` rewrites the workspace version and commits without
884    /// regenerating this pair. If the version reached these bytes, that commit
885    /// would turn the required `test` check red and, forced past, would publish
886    /// a document naming the previous release. The only compile-time source of
887    /// the version is `CARGO_PKG_VERSION`, so proving the rendered bytes do not
888    /// contain it proves a bump cannot change the digest.
889    #[test]
890    fn a_version_bump_alone_cannot_change_the_digest() {
891        let document = document();
892        assert!(
893            document.get("car_version").is_none(),
894            "the digested document must carry no release version"
895        );
896        let rendered = String::from_utf8(rendered_document()).expect("schema is UTF-8");
897        assert!(
898            !rendered.contains(env!("CARGO_PKG_VERSION")),
899            "the crate version leaked into the digested bytes, so every release bump \
900             would change the digest and fail freshness on the bump commit"
901        );
902    }
903
904    /// The release version is still reachable — at serve time, from the binary.
905    #[test]
906    fn server_schema_reports_the_running_release() {
907        let payload = committed_payload().expect("committed payload");
908        assert_eq!(payload["car_version"], env!("CARGO_PKG_VERSION"));
909    }
910
911    #[test]
912    fn committed_artifacts_match_generation_and_rpc_payload() {
913        let generated = rendered_document();
914        assert_eq!(COMMITTED_SCHEMA.as_bytes(), generated);
915        assert_eq!(
916            COMMITTED_DIGEST.as_bytes(),
917            rendered_digest_file(&generated)
918        );
919
920        let payload = committed_payload().expect("committed payload");
921        assert_eq!(
922            crate::handler::handle_server_schema().expect("server.schema RPC payload"),
923            payload
924        );
925        assert_eq!(payload["schema"], document());
926        assert_eq!(payload["digest"], sha256_hex(&generated));
927        assert_eq!(payload["digest_algorithm"], DIGEST_ALGORITHM);
928    }
929
930    fn managed_agent() -> car_registry::supervisor::ManagedAgent {
931        car_registry::supervisor::ManagedAgent {
932            spec: car_registry::supervisor::AgentSpec {
933                id: "trader".into(),
934                name: "Trader".into(),
935                command: "/usr/local/bin/node".into(),
936                args: vec!["index.js".into()],
937                cwd: None,
938                env: BTreeMap::new(),
939                restart: car_registry::supervisor::RestartPolicy::OnFailure,
940                max_restarts: 10,
941                backoff_secs: 5,
942                auto_start: false,
943                token: "secret".into(),
944                method_allowlist: None,
945                capabilities: Vec::new(),
946            },
947            status: car_registry::supervisor::AgentStatus::Stopped,
948            pid: None,
949            last_exit_code: None,
950            restart_count: 0,
951            started_at: None,
952            blocked_by_pid: None,
953        }
954    }
955
956    fn declarative_spec() -> car_registry::declarative::DeclarativeAgentSpec {
957        car_registry::declarative::DeclarativeAgentSpec {
958            id: "newsroom".into(),
959            name: "Newsroom".into(),
960            identity: "You watch the journal.".into(),
961            tools: vec!["fs.read".into()],
962            denied_tools: Vec::new(),
963            standing_goal: String::new(),
964            goal: None,
965            cadence: None,
966            scenarios: Vec::new(),
967            builder_draft: None,
968            previous: None,
969            enabled: true,
970            context: Default::default(),
971        }
972    }
973
974    fn model_schema() -> car_inference::schema::ModelSchema {
975        // Every `Option` is `None` on purpose: that is what makes this value a
976        // detector for a newly added `skip_serializing_if` anywhere in the
977        // catalog subtree.
978        car_inference::schema::ModelSchema {
979            id: "qwen/qwen3-4b".into(),
980            name: "Qwen3 4B".into(),
981            provider: "qwen".into(),
982            family: "qwen3".into(),
983            version: String::new(),
984            capabilities: vec![car_inference::schema::ModelCapability::Generate],
985            context_length: 32_768,
986            max_output_tokens: None,
987            param_count: String::new(),
988            quantization: None,
989            performance: Default::default(),
990            cost: Default::default(),
991            source: car_inference::schema::ModelSource::Ollama {
992                model_tag: "qwen3:4b".into(),
993                host: "http://localhost:11434".into(),
994            },
995            tags: Vec::new(),
996            supported_params: Vec::new(),
997            public_benchmarks: Vec::new(),
998            trust_tier: Default::default(),
999            deprecated: false,
1000            available: false,
1001            weights_ready: false,
1002        }
1003    }
1004
1005    fn inference_result() -> car_inference::InferenceResult {
1006        car_inference::InferenceResult {
1007            text: "hello".into(),
1008            tool_calls: Vec::new(),
1009            bounding_boxes: Vec::new(),
1010            trace_id: "trace-1".into(),
1011            model_used: "qwen/qwen3-4b".into(),
1012            model_identity: Default::default(),
1013            latency_ms: 12,
1014            time_to_first_token_ms: None,
1015            usage: None,
1016            provider_output_items: Vec::new(),
1017            thinking: Vec::new(),
1018            stop_reason: None,
1019            auth_fallback_from: None,
1020            local_last_resort: false,
1021            fallback_from: Vec::new(),
1022        }
1023    }
1024
1025    fn validate(document: &Value, key: &str, emitted: &Value) {
1026        let schema = &document["schemas"][key];
1027        let validator =
1028            jsonschema::validator_for(schema).unwrap_or_else(|e| panic!("{key} compiles: {e}"));
1029        if let Err(error) = validator.validate(emitted) {
1030            panic!(
1031                "the value {key} actually emits does not satisfy its generated schema: {error}\n\
1032                 emitted: {}",
1033                serde_json::to_string_pretty(emitted).unwrap()
1034            );
1035        }
1036    }
1037
1038    /// Every covered schema is checked against a value built the way the daemon
1039    /// builds it, through the same type.
1040    ///
1041    /// This is the guard that a hand-transcribed schema cannot pass: with
1042    /// `additionalProperties: false` everywhere, a field the emitter gains and
1043    /// the schema does not fails here, and a field the schema requires and the
1044    /// emitter drops fails here too.
1045    #[test]
1046    fn every_covered_schema_validates_its_real_emitted_value() {
1047        let document = document();
1048        let mut checked: Vec<&str> = Vec::new();
1049
1050        let mut check = |key: &'static str, emitted: Value| {
1051            validate(&document, key, &emitted);
1052            checked.push(key);
1053        };
1054
1055        check(
1056            "cli.car_inspect.result",
1057            serde_json::to_value(CarInspectResult::Managed(managed_list_row())).unwrap(),
1058        );
1059        check(
1060            "journal.event",
1061            serde_json::to_value(minimal_event()).unwrap(),
1062        );
1063        check(
1064            "rpc.capabilities.list.result",
1065            serde_json::to_value(capabilities_list_result()).unwrap(),
1066        );
1067        check(
1068            "rpc.infer.result",
1069            serde_json::to_value(inference_result()).unwrap(),
1070        );
1071        check(
1072            "rpc.models.catalog_snapshot.result",
1073            serde_json::to_value(
1074                car_inference::catalog_identity::CatalogSnapshot::new([model_schema()])
1075                    .expect("catalog snapshot"),
1076            )
1077            .unwrap(),
1078        );
1079        check(
1080            "rpc.server.handshake.result",
1081            serde_json::to_value(handshake_result()).unwrap(),
1082        );
1083        check(
1084            "rpc.server.schema.result",
1085            committed_payload().expect("committed payload"),
1086        );
1087        check(
1088            "rpc.state.get.result",
1089            json!({"arbitrary": [true, null, 3]}),
1090        );
1091        check(
1092            "rpc.state.set.result",
1093            serde_json::to_value(StateSetResult::Ok).unwrap(),
1094        );
1095        check(
1096            "rpc.state.exists.result",
1097            serde_json::to_value(StateExistsResult(false)).unwrap(),
1098        );
1099        check(
1100            "rpc.state.keys.result",
1101            serde_json::to_value(StateKeysResult(vec!["ready".into()])).unwrap(),
1102        );
1103        check(
1104            "rpc.state.snapshot.result",
1105            serde_json::to_value(StateSnapshotResult(serde_json::Map::from_iter([(
1106                "ready".into(),
1107                Value::Bool(true),
1108            )])))
1109            .unwrap(),
1110        );
1111        check(
1112            "rpc.tools.register.result",
1113            serde_json::to_value(ToolsRegisterResult(1)).unwrap(),
1114        );
1115        check(
1116            "rpc.tools.list.result",
1117            serde_json::to_value(ToolsListResult {
1118                tools: vec![minimal_tool_schema()],
1119                count: 1,
1120            })
1121            .unwrap(),
1122        );
1123        check(
1124            "rpc.tools.unregister.result",
1125            serde_json::to_value(ToolsUnregisterResult {
1126                unregistered: "fs.read".into(),
1127                removed: 1,
1128            })
1129            .unwrap(),
1130        );
1131        check(
1132            "rpc.tools.poll.result",
1133            serde_json::to_value(Some(minimal_tool_poll_result())).unwrap(),
1134        );
1135        check(
1136            "rpc.tools.cancel.result",
1137            serde_json::to_value(ToolsCancelResult { cancelled: true }).unwrap(),
1138        );
1139        check(
1140            "rpc.tools.stream.subscribe.result",
1141            serde_json::to_value(ToolsStreamSubscribeResult { subscribed: true }).unwrap(),
1142        );
1143        check(
1144            "type.action_result",
1145            serde_json::to_value(minimal_action_result()).unwrap(),
1146        );
1147
1148        // A new entry in `coverage.covered` without a case above would leave
1149        // that surface unproven, so the two sets must match exactly.
1150        let covered: Vec<String> = document["coverage"]["covered"]
1151            .as_array()
1152            .expect("covered list")
1153            .iter()
1154            .map(|value| value.as_str().expect("covered key").to_string())
1155            .collect();
1156        let mut checked: Vec<String> = checked.into_iter().map(str::to_string).collect();
1157        checked.sort();
1158        assert_eq!(
1159            covered, checked,
1160            "every covered schema needs a real emitted value checked against it"
1161        );
1162    }
1163
1164    /// The declarative row is the second arm of `cli.car_inspect.result`.
1165    #[test]
1166    fn declarative_rows_validate_against_the_inspect_schema() {
1167        let document = document();
1168        let spec = declarative_spec();
1169        validate(
1170            &document,
1171            "cli.car_inspect.result",
1172            &crate::coder::rpc::declarative_row(&spec),
1173        );
1174
1175        let mut with_goal = spec;
1176        with_goal.goal = Some(car_registry::declarative::DeclarativeGoal {
1177            check: "cargo test".into(),
1178            max_iterations: 8,
1179        });
1180        validate(
1181            &document,
1182            "cli.car_inspect.result",
1183            &crate::coder::rpc::declarative_row(&with_goal),
1184        );
1185    }
1186
1187    /// Asserts the precondition [`require_all_declared_fields`] depends on.
1188    ///
1189    /// The blanket pass marks every declared catalog property required, which
1190    /// is a lie the moment a catalog type gains a `skip_serializing_if`. The
1191    /// snapshot below has every `Option` set to `None`, so the first such field
1192    /// disappears from the serialized value and fails this validation.
1193    #[test]
1194    fn catalog_snapshot_declares_no_conditionally_emitted_field() {
1195        let document = document();
1196        let snapshot = car_inference::catalog_identity::CatalogSnapshot::new([model_schema()])
1197            .expect("catalog snapshot");
1198        validate(
1199            &document,
1200            "rpc.models.catalog_snapshot.result",
1201            &serde_json::to_value(&snapshot).unwrap(),
1202        );
1203    }
1204
1205    #[test]
1206    fn coverage_inventory_accounts_for_every_daemon_result_and_event_kind() {
1207        let document = document();
1208        let coverage = &document["coverage"];
1209
1210        let strings = |value: &Value| -> BTreeSet<String> {
1211            value
1212                .as_array()
1213                .expect("coverage list")
1214                .iter()
1215                .map(|value| value.as_str().expect("coverage name").to_string())
1216                .collect()
1217        };
1218
1219        let all_rpc: BTreeSet<String> = rpc_method_inventory()
1220            .into_iter()
1221            .map(str::to_string)
1222            .collect();
1223        let covered_rpc = strings(&coverage["rpc_results"]["covered"]);
1224        let uncovered_rpc = strings(&coverage["rpc_results"]["uncovered"]);
1225        assert!(covered_rpc.is_disjoint(&uncovered_rpc));
1226        assert_eq!(
1227            all_rpc,
1228            covered_rpc.union(&uncovered_rpc).cloned().collect(),
1229            "the source-derived daemon method inventory must be partitioned exactly"
1230        );
1231        assert_eq!(coverage["rpc_results"]["total"], Value::from(all_rpc.len()));
1232        for method in &covered_rpc {
1233            assert!(
1234                document["schemas"]
1235                    .get(format!("rpc.{method}.result"))
1236                    .is_some(),
1237                "covered RPC method has no result schema: {method}"
1238            );
1239        }
1240
1241        let event_schema = &document["schemas"]["journal.event"];
1242        let all_events = event_kind_inventory(event_schema);
1243        let covered_events = strings(&coverage["journal_event_payloads"]["covered"]);
1244        let uncovered_events = strings(&coverage["journal_event_payloads"]["uncovered"]);
1245        assert!(covered_events.is_disjoint(&uncovered_events));
1246        assert_eq!(
1247            all_events,
1248            covered_events.union(&uncovered_events).cloned().collect(),
1249            "the source-derived EventKind inventory must be partitioned exactly"
1250        );
1251        assert_eq!(
1252            coverage["journal_event_payloads"]["total"],
1253            Value::from(all_events.len())
1254        );
1255
1256        assert_eq!(
1257            coverage["complete"],
1258            Value::Bool(uncovered_rpc.is_empty() && uncovered_events.is_empty()),
1259            "coverage.complete may be true only when both source inventories are exhausted"
1260        );
1261    }
1262
1263    #[test]
1264    fn priority_enums_are_closed_and_objects_reject_unknown_fields() {
1265        let document = document();
1266        let event_kind = &document["schemas"]["journal.event"]["definitions"]["EventKind"];
1267        let variants = event_kind["oneOf"]
1268            .as_array()
1269            .expect("documented EventKind variants");
1270        assert!(!variants.is_empty());
1271        assert!(variants.iter().all(|variant| variant["enum"].is_array()));
1272        assert_eq!(
1273            document["schemas"]["type.action_result"]["additionalProperties"],
1274            false
1275        );
1276        assert_eq!(
1277            document["schemas"]["rpc.tools.list.result"]["additionalProperties"],
1278            false
1279        );
1280
1281        let validator = jsonschema::validator_for(&document["schemas"]["type.action_result"])
1282            .expect("ActionResult schema compiles");
1283        let valid = json!({
1284            "action_id": "action-1",
1285            "status": "succeeded",
1286            "state_changes": {},
1287            "timestamp": "2026-09-15T00:00:00Z"
1288        });
1289        assert!(validator.is_valid(&valid));
1290        let mut unknown = valid;
1291        unknown["unexpected"] = Value::Bool(true);
1292        assert!(!validator.is_valid(&unknown));
1293    }
1294
1295    fn managed_list_row() -> ManagedAgentListRow {
1296        ManagedAgentListRow {
1297            agent: ManagedAgentWire::from_managed(&managed_agent()),
1298            attached: false,
1299            tools: None,
1300            manifest_path: "/tmp/agents/trader/manifest.toml".into(),
1301            log_path: "/tmp/logs/trader.stdout.log".into(),
1302            stderr_log_path: "/tmp/logs/trader.stderr.log".into(),
1303            session_id: None,
1304        }
1305    }
1306
1307    fn capabilities_list_result() -> CapabilitiesListResult {
1308        CapabilitiesListResult {
1309            caller_role: CapabilityRole::Operator,
1310            count: 1,
1311            methods: vec![CapabilityMethodRow {
1312                method: "capabilities.list".into(),
1313                role: CapabilityRole::Operator,
1314            }],
1315        }
1316    }
1317
1318    fn minimal_tool_schema() -> car_ir::ToolSchema {
1319        car_ir::ToolSchema {
1320            name: "fs.read".into(),
1321            source: car_ir::ToolSourceKind::Builtin,
1322            description: "Read a file".into(),
1323            parameters: json!({"type": "object"}),
1324            returns: None,
1325            idempotent: true,
1326            cache_ttl_secs: None,
1327            rate_limit: None,
1328        }
1329    }
1330
1331    fn minimal_tool_poll_result() -> car_engine::tool_handles::ToolPollResult {
1332        car_engine::tool_handles::ToolPollResult {
1333            handle: "tool-1".into(),
1334            tool: "fs.read".into(),
1335            action_id: "action-1".into(),
1336            status: car_ir::ToolStatus::Running,
1337            chunks: Vec::new(),
1338            dropped_chunks: 0,
1339            result: None,
1340            error: None,
1341        }
1342    }
1343
1344    fn minimal_event() -> car_eventlog::Event {
1345        car_eventlog::Event {
1346            kind: car_eventlog::EventKind::ActionSucceeded,
1347            run_id: None,
1348            client_id: None,
1349            policy_session_id: None,
1350            action_id: None,
1351            proposal_id: None,
1352            data: HashMap::new(),
1353            timestamp: chrono::Utc::now(),
1354            prev_hash: None,
1355            hash: None,
1356        }
1357    }
1358
1359    fn minimal_action_result() -> car_ir::ActionResult {
1360        car_ir::ActionResult {
1361            action_id: "action-1".into(),
1362            status: car_ir::ActionStatus::Succeeded,
1363            output: None,
1364            error: None,
1365            terminal: false,
1366            rolled_back: false,
1367            state_changes: HashMap::new(),
1368            duration_ms: None,
1369            timestamp: chrono::Utc::now(),
1370        }
1371    }
1372
1373    fn handshake_result() -> ServerHandshakeResult {
1374        ServerHandshakeResult {
1375            protocol_version: car_proto::PROTOCOL_VERSION,
1376            server_version: env!("CARGO_PKG_VERSION").to_string(),
1377            client_protocol_version: u64::from(car_proto::PROTOCOL_VERSION),
1378            client_version: "unknown".into(),
1379            negotiated_capabilities: Vec::new(),
1380            assistant_name: "Parslee".into(),
1381            assistant_aliases: vec!["parslee".into()],
1382            assistant_brand: car_identity::BRAND_NAME.to_string(),
1383        }
1384    }
1385
1386    /// Find the generated schema object for a derived type by name.
1387    ///
1388    /// Mirrors `require_fields_for`'s lookup, and for the same reason: a
1389    /// hoisted definition is keyed by the type name, an inlined or root copy
1390    /// carries a `title`, and which one you get moves with the field's
1391    /// attributes.
1392    fn by_title<'a>(schema: &'a Value, title: &str) -> &'a Value {
1393        fn walk<'a>(
1394            value: &'a Value,
1395            title: &str,
1396            own_name: Option<&str>,
1397            entries_are_definitions: bool,
1398        ) -> Option<&'a Value> {
1399            match value {
1400                Value::Array(values) => values
1401                    .iter()
1402                    .find_map(|value| walk(value, title, None, false)),
1403                Value::Object(map) => {
1404                    let named = own_name == Some(title)
1405                        || map.get("title").and_then(Value::as_str) == Some(title);
1406                    if named && map.contains_key("properties") {
1407                        return Some(value);
1408                    }
1409                    map.iter().find_map(|(key, child)| {
1410                        let child_name = entries_are_definitions.then_some(key.as_str());
1411                        let child_defines = !entries_are_definitions && key == "definitions";
1412                        walk(child, title, child_name, child_defines)
1413                    })
1414                }
1415                _ => None,
1416            }
1417        }
1418        walk(schema, title, None, false)
1419            .unwrap_or_else(|| panic!("no generated object named {title}"))
1420    }
1421
1422    /// The contract every hand-written `required` list must satisfy.
1423    ///
1424    /// `sample` must be MINIMAL: every `Option` `None`, every collection empty,
1425    /// every `skip_serializing_if` predicate true. Its key set is then exactly
1426    /// the set of fields CAR always emits, which is exactly what `required`
1427    /// must name. Comparing the two catches both a list that over-claims (a
1428    /// field the emitter can omit) and one that under-claims (a guarantee the
1429    /// consumer is not given).
1430    fn assert_required_matches_emitted(node: &Value, sample: &Value, label: &str) {
1431        let required: std::collections::BTreeSet<&str> = node["required"]
1432            .as_array()
1433            .unwrap_or_else(|| panic!("{label} declares no required list"))
1434            .iter()
1435            .map(|value| value.as_str().expect("required entries are strings"))
1436            .collect();
1437        let emitted: std::collections::BTreeSet<&str> = sample
1438            .as_object()
1439            .unwrap_or_else(|| panic!("{label} sample is not an object"))
1440            .keys()
1441            .map(String::as_str)
1442            .collect();
1443        assert_eq!(
1444            required, emitted,
1445            "{label}: `required` must name exactly the fields a minimal value emits"
1446        );
1447    }
1448
1449    #[test]
1450    fn required_lists_exactly_the_fields_a_minimal_value_emits() {
1451        let document = document();
1452        fn value<T: Serialize>(item: T) -> Value {
1453            serde_json::to_value(item).expect("wire value serializes")
1454        }
1455
1456        let inspect = &document["schemas"]["cli.car_inspect.result"];
1457        assert_required_matches_emitted(
1458            by_title(inspect, "ManagedAgentListRow"),
1459            &value(managed_list_row()),
1460            "ManagedAgentListRow",
1461        );
1462        assert_required_matches_emitted(
1463            by_title(inspect, "DeclarativeAgentRow"),
1464            &crate::coder::rpc::declarative_row(&declarative_spec()),
1465            "DeclarativeAgentRow",
1466        );
1467        assert_required_matches_emitted(
1468            by_title(inspect, "DeclarativeGoal"),
1469            &value(car_registry::declarative::DeclarativeGoal {
1470                check: "cargo test".into(),
1471                max_iterations: 8,
1472            }),
1473            "DeclarativeGoal",
1474        );
1475
1476        let event = &document["schemas"]["journal.event"];
1477        assert_required_matches_emitted(event, &value(minimal_event()), "Event");
1478
1479        let capabilities = &document["schemas"]["rpc.capabilities.list.result"];
1480        assert_required_matches_emitted(
1481            capabilities,
1482            &value(capabilities_list_result()),
1483            "CapabilitiesListResult",
1484        );
1485        assert_required_matches_emitted(
1486            by_title(capabilities, "CapabilityMethodRow"),
1487            &value(CapabilityMethodRow {
1488                method: "capabilities.list".into(),
1489                role: CapabilityRole::Operator,
1490            }),
1491            "CapabilityMethodRow",
1492        );
1493
1494        let inference = &document["schemas"]["rpc.infer.result"];
1495        assert_required_matches_emitted(inference, &value(inference_result()), "InferenceResult");
1496        assert_required_matches_emitted(
1497            by_title(inference, "TokenUsage"),
1498            &value(car_inference::TokenUsage::default()),
1499            "TokenUsage",
1500        );
1501        assert_required_matches_emitted(
1502            by_title(inference, "ToolCall"),
1503            &value(car_inference::tasks::generate::ToolCall {
1504                id: None,
1505                name: "fs.read".into(),
1506                arguments: HashMap::new(),
1507            }),
1508            "ToolCall",
1509        );
1510        assert_required_matches_emitted(
1511            by_title(inference, "ThinkingBlock"),
1512            &value(car_inference::tasks::generate::ThinkingBlock::default()),
1513            "ThinkingBlock",
1514        );
1515        assert_required_matches_emitted(
1516            by_title(inference, "BoundingBox"),
1517            &value(car_inference::tasks::grounding::BoundingBox {
1518                x1: 0,
1519                y1: 0,
1520                x2: 1,
1521                y2: 1,
1522                label: String::new(),
1523                confidence: None,
1524            }),
1525            "BoundingBox",
1526        );
1527        assert_required_matches_emitted(
1528            by_title(inference, "FallbackFrom"),
1529            &value(car_inference::FallbackFrom {
1530                candidate: "gpt-5".into(),
1531                reason: car_inference::FallbackReason::Failed,
1532            }),
1533            "FallbackFrom",
1534        );
1535
1536        let tools = &document["schemas"]["rpc.tools.list.result"];
1537        assert_required_matches_emitted(
1538            by_title(tools, "ToolSchema"),
1539            &value(minimal_tool_schema()),
1540            "ToolSchema",
1541        );
1542        let tool_poll = &document["schemas"]["rpc.tools.poll.result"];
1543        assert_required_matches_emitted(
1544            by_title(tool_poll, "ToolPollResult"),
1545            &value(minimal_tool_poll_result()),
1546            "ToolPollResult",
1547        );
1548
1549        let action_result = &document["schemas"]["type.action_result"];
1550        assert_required_matches_emitted(
1551            action_result,
1552            &value(minimal_action_result()),
1553            "ActionResult",
1554        );
1555
1556        let handshake = &document["schemas"]["rpc.server.handshake.result"];
1557        assert_required_matches_emitted(
1558            handshake,
1559            &value(handshake_result()),
1560            "ServerHandshakeResult",
1561        );
1562
1563        let schema_result = &document["schemas"]["rpc.server.schema.result"];
1564        assert_required_matches_emitted(
1565            schema_result,
1566            &committed_payload().expect("committed payload"),
1567            "ServerSchemaResult",
1568        );
1569    }
1570
1571    /// The one catalog member the blanket required pass must not close over.
1572    #[test]
1573    fn the_structured_quantization_form_omits_the_fields_its_label_carries() {
1574        let document = document();
1575        let catalog = &document["schemas"]["rpc.models.catalog_snapshot.result"];
1576        let quantization = car_inference::schema::Quantization {
1577            bits: None,
1578            scheme: car_inference::schema::QuantScheme::AffineGroupInt,
1579            group_size: None,
1580            label: "an-unparseable-label".into(),
1581        };
1582        let emitted = serde_json::to_value(&quantization).expect("quantization serializes");
1583        assert!(
1584            emitted.is_object(),
1585            "this label must take the structured form for the assertion to mean anything"
1586        );
1587        assert_required_matches_emitted(
1588            by_title(catalog, "QuantizationObjectWireSchema"),
1589            &emitted,
1590            "QuantizationObjectWireSchema",
1591        );
1592    }
1593
1594    /// The token that authenticates an agent must never reach the wire.
1595    #[test]
1596    fn the_managed_agent_projection_drops_the_agent_token() {
1597        let wire = serde_json::to_value(ManagedAgentWire::from_managed(&managed_agent())).unwrap();
1598        assert!(wire.get("token").is_none());
1599        assert!(!document()["schemas"]["cli.car_inspect.result"]
1600            .to_string()
1601            .contains("\"token\""));
1602    }
1603}