Skip to main content

runx_contracts/
schema.rs

1//! Type-driven JSON Schema for runx contracts (Phase 1 of
2//! `rust-contract-pipeline-inversion`).
3//!
4//! A contract type that derives [`RunxSchema`] emits its own wire-conformant
5//! JSON Schema document, so the Rust type is the single source of truth and the
6//! parallel TypeScript schema sources stay deleted. The emitted document
7//! reproduces the committed shape: fully inlined, closed string enums as
8//! `anyOf` of `const`, `additionalProperties: false`, and the `$id` /
9//! `x-runx-schema` identity.
10// rust-style-allow: large-file - the schema emitter keeps shared JSON Schema
11// construction helpers and primitive type impls together so generated contract
12// shapes are reviewable as one boundary.
13
14use std::collections::BTreeMap;
15
16use serde::de::{self, Deserializer};
17use serde::{Deserialize, Serialize};
18use serde_json::{Map, Value, json};
19
20pub use runx_contracts_derive::RunxSchema;
21
22/// Deserialize a boolean field that MUST be `true`, failing closed otherwise.
23///
24/// The single forcing primitive shared by every proposal-only / human-gated
25/// authority block (e.g. `OperationalProposalAuthority::proposal_only`): a
26/// proposal can never claim an authority it was not granted, so the wire value
27/// is rejected unless it is exactly `true`. Wire as
28/// `#[serde(deserialize_with = "crate::schema::deserialize_true_bool")]`.
29pub fn deserialize_true_bool<'de, D>(deserializer: D) -> Result<bool, D::Error>
30where
31    D: Deserializer<'de>,
32{
33    let value = bool::deserialize(deserializer)?;
34    if value {
35        Ok(true)
36    } else {
37        Err(de::Error::custom("value must be true"))
38    }
39}
40
41/// Deserialize a boolean field that MUST be `false`, failing closed otherwise.
42///
43/// The forcing primitive's mirror: the granted-authority flags on a
44/// proposal-only block must be exactly `false` so a reviewable handoff can never
45/// deserialize into a self-granted consequence. Wire as
46/// `#[serde(deserialize_with = "crate::schema::deserialize_false_bool")]`.
47pub fn deserialize_false_bool<'de, D>(deserializer: D) -> Result<bool, D::Error>
48where
49    D: Deserializer<'de>,
50{
51    let value = bool::deserialize(deserializer)?;
52    if value {
53        Err(de::Error::custom("value must be false"))
54    } else {
55        Ok(false)
56    }
57}
58
59/// A type that can emit its own JSON Schema document.
60pub trait RunxSchema {
61    /// The inlined JSON Schema for this type.
62    fn json_schema() -> Value;
63}
64
65/// One object property: its wire name, schema, and whether it is required.
66pub struct Property {
67    pub name: &'static str,
68    pub schema: Value,
69    pub required: bool,
70}
71
72impl Property {
73    pub fn new(name: &'static str, schema: Value, required: bool) -> Self {
74        Self {
75            name,
76            schema,
77            required,
78        }
79    }
80}
81
82/// The top-level identity envelope a contract document carries.
83///
84/// Most contracts are `Runx { logical }`: a `schemas.runx.ai` `$id` derived
85/// from the logical name, the `x-runx-schema` marker, and an optional `schema`
86/// const discriminant. A handful of legacy contracts carry only a bare `$id`
87/// (the `runx.ai/spec` and `runx.ai/schemas` documents) with no
88/// `x-runx-schema` and no injected `schema` discriminant; those use `BareId`.
89pub enum Identity<'a> {
90    /// Logical-name identity: `schemas.runx.ai` `$id`, `x-runx-schema` marker,
91    /// and an injected optional `schema` const. The `$id` is `url` when given
92    /// (for the few logical names whose canonical `$id` does not match the
93    /// mechanical [`schema_id_url`] transform), otherwise derived.
94    Runx {
95        logical: &'a str,
96        url: Option<&'a str>,
97    },
98    /// A bare `$id` with no `x-runx-schema` marker and no injected `schema`
99    /// discriminant (the `runx.ai/spec` / `runx.ai/schemas` documents).
100    BareId { url: &'a str },
101}
102
103/// Assemble an object schema in the committed shape. When `identity` is set the
104/// document carries the top-level envelope; nested objects pass `None`.
105pub fn object_schema(
106    properties: Vec<Property>,
107    deny_unknown: bool,
108    identity: Option<Identity<'_>>,
109) -> Value {
110    let mut required: Vec<Value> = Vec::new();
111    let mut props = Map::new();
112    for property in properties {
113        if property.required {
114            required.push(Value::String(property.name.to_owned()));
115        }
116        props.insert(property.name.to_owned(), property.schema);
117    }
118
119    let mut schema = Map::new();
120    if let Some(identity) = identity {
121        schema.insert(
122            "$schema".to_owned(),
123            json!("https://json-schema.org/draft/2020-12/schema"),
124        );
125        match identity {
126            Identity::Runx { logical, url } => {
127                let id = url
128                    .map(str::to_owned)
129                    .unwrap_or_else(|| schema_id_url(logical));
130                schema.insert("$id".to_owned(), json!(id));
131                schema.insert("x-runx-schema".to_owned(), json!(logical));
132                // Every top-level contract carries an optional `schema`
133                // discriminant whose const equals its logical name. Emit it
134                // from the identity so no type needs a redundant marker field.
135                props
136                    .entry("schema".to_owned())
137                    .or_insert_with(|| const_string(logical));
138            }
139            Identity::BareId { url } => {
140                schema.insert("$id".to_owned(), json!(url));
141            }
142        }
143    }
144    schema.insert("additionalProperties".to_owned(), json!(!deny_unknown));
145    schema.insert("type".to_owned(), json!("object"));
146    if !required.is_empty() {
147        schema.insert("required".to_owned(), Value::Array(required));
148    }
149    schema.insert("properties".to_owned(), Value::Object(props));
150    Value::Object(schema)
151}
152
153/// Assemble an object schema, merging any `#[serde(flatten)]` fields. Each
154/// entry in `flattened` is the emitted object schema of a flattened field's
155/// type; its `properties` and `required` entries are lifted into the parent, as
156/// serde does on the wire. A flattened object's own `additionalProperties` and
157/// identity keys are dropped (only the parent's `deny_unknown` and `identity`
158/// apply). `flattened` entries that are not plain objects (e.g. a flattened
159/// map) relax the parent to accept additional properties, matching serde's
160/// open-ended flatten capture.
161// rust-style-allow: long-function - flatten merging mirrors serde's object
162// projection rules and is easier to audit as one schema-construction path.
163pub fn object_schema_with_flatten(
164    properties: Vec<Property>,
165    flattened: Vec<Value>,
166    deny_unknown: bool,
167    identity: Option<Identity<'_>>,
168) -> Value {
169    let mut required: Vec<Value> = Vec::new();
170    let mut props = Map::new();
171    for property in properties {
172        if property.required {
173            required.push(Value::String(property.name.to_owned()));
174        }
175        props.insert(property.name.to_owned(), property.schema);
176    }
177
178    // A flattened map (or any non-object schema) captures arbitrary extra keys,
179    // so the parent can no longer be closed.
180    let mut deny_unknown = deny_unknown;
181    for flat in flattened {
182        let is_object = flat.get("type").and_then(Value::as_str) == Some("object");
183        match flat.get("properties").and_then(Value::as_object) {
184            Some(inner_props) if is_object => {
185                let inner_required: Vec<&str> = flat
186                    .get("required")
187                    .and_then(Value::as_array)
188                    .map(|items| items.iter().filter_map(Value::as_str).collect())
189                    .unwrap_or_default();
190                for (name, schema) in inner_props {
191                    if inner_required.contains(&name.as_str()) {
192                        required.push(Value::String(name.clone()));
193                    }
194                    props.insert(name.clone(), schema.clone());
195                }
196            }
197            _ => {
198                // Non-object flatten (e.g. a `BTreeMap` capture) opens the
199                // object to additional properties.
200                deny_unknown = false;
201            }
202        }
203    }
204
205    let mut schema = Map::new();
206    if let Some(identity) = identity {
207        schema.insert(
208            "$schema".to_owned(),
209            json!("https://json-schema.org/draft/2020-12/schema"),
210        );
211        match identity {
212            Identity::Runx { logical, url } => {
213                let id = url
214                    .map(str::to_owned)
215                    .unwrap_or_else(|| schema_id_url(logical));
216                schema.insert("$id".to_owned(), json!(id));
217                schema.insert("x-runx-schema".to_owned(), json!(logical));
218                props
219                    .entry("schema".to_owned())
220                    .or_insert_with(|| const_string(logical));
221            }
222            Identity::BareId { url } => {
223                schema.insert("$id".to_owned(), json!(url));
224            }
225        }
226    }
227    schema.insert("additionalProperties".to_owned(), json!(!deny_unknown));
228    schema.insert("type".to_owned(), json!("object"));
229    if !required.is_empty() {
230        schema.insert("required".to_owned(), Value::Array(required));
231    }
232    schema.insert("properties".to_owned(), Value::Object(props));
233    Value::Object(schema)
234}
235
236/// Assemble an open-map ("dictionary") document in the committed shape: an
237/// object whose values all match `value_schema`, rendered with the committed
238/// `patternProperties: { "^(.*)$": <value schema> }` form. When `identity` is
239/// set the document carries the top-level envelope (the `output.schema.json`
240/// document is a bare-`$id` map of this kind). No `additionalProperties` and no
241/// injected `schema` discriminant are emitted; the pattern alone constrains the
242/// values.
243pub fn object_map_schema(value_schema: Value, identity: Option<Identity<'_>>) -> Value {
244    let mut schema = Map::new();
245    if let Some(identity) = identity {
246        schema.insert(
247            "$schema".to_owned(),
248            json!("https://json-schema.org/draft/2020-12/schema"),
249        );
250        match identity {
251            Identity::Runx { logical, url } => {
252                let id = url
253                    .map(str::to_owned)
254                    .unwrap_or_else(|| schema_id_url(logical));
255                schema.insert("$id".to_owned(), json!(id));
256                schema.insert("x-runx-schema".to_owned(), json!(logical));
257            }
258            Identity::BareId { url } => {
259                schema.insert("$id".to_owned(), json!(url));
260            }
261        }
262    }
263    schema.insert("type".to_owned(), json!("object"));
264    schema.insert(
265        "patternProperties".to_owned(),
266        json!({ "^(.*)$": value_schema }),
267    );
268    Value::Object(schema)
269}
270
271/// A closed string enum rendered as `anyOf` of `const` leaves, the committed
272/// shape (the schemas never use JSON Schema `enum`).
273pub fn string_enum(variants: &[&str]) -> Value {
274    let any_of: Vec<Value> = variants
275        .iter()
276        .map(|variant| const_string(variant))
277        .collect();
278    json!({ "anyOf": any_of })
279}
280
281/// A union of subschemas rendered as `{ "anyOf": [...] }`, the committed shape
282/// for data-carrying enums (externally-tagged, internally-tagged, and untagged
283/// representations all collapse to an `anyOf` of variant subschemas).
284pub fn any_of(variants: Vec<Value>) -> Value {
285    json!({ "anyOf": variants })
286}
287
288/// An `anyOf` union of variant subschemas carrying a top-level identity
289/// envelope. Used by data-carrying enums that are themselves a contract
290/// document (e.g. the `runx.ai/spec` documents emitted as a bare-`$id`
291/// `anyOf`). The identity keys (`$schema`, `$id`, and for [`Identity::Runx`]
292/// also `x-runx-schema`) sit alongside the `anyOf`. Unlike [`object_schema`],
293/// no injected `schema` discriminant property is added: the union variants own
294/// their own shape.
295pub fn any_of_with_identity(variants: Vec<Value>, identity: Option<Identity<'_>>) -> Value {
296    let mut schema = Map::new();
297    if let Some(identity) = identity {
298        schema.insert(
299            "$schema".to_owned(),
300            json!("https://json-schema.org/draft/2020-12/schema"),
301        );
302        match identity {
303            Identity::Runx { logical, url } => {
304                let id = url
305                    .map(str::to_owned)
306                    .unwrap_or_else(|| schema_id_url(logical));
307                schema.insert("$id".to_owned(), json!(id));
308                schema.insert("x-runx-schema".to_owned(), json!(logical));
309            }
310            Identity::BareId { url } => {
311                schema.insert("$id".to_owned(), json!(url));
312            }
313        }
314    }
315    schema.insert("anyOf".to_owned(), Value::Array(variants));
316    Value::Object(schema)
317}
318
319/// A required-but-nullable property schema: the inner type's schema unioned with
320/// `null`. Matches the committed shape for an `Option<T>` field that has no
321/// `skip_serializing_if` (it must be present on the wire but may be `null`):
322/// `{ "anyOf": [<T schema>, { "type": "null" }] }`.
323pub fn nullable(inner: Value) -> Value {
324    json!({ "anyOf": [inner, { "type": "null" }] })
325}
326
327/// An externally-tagged data variant: a single-key object `{ "<tag>": <inner> }`
328/// where the key is the variant's wire name and the value is its payload schema.
329/// Matches serde's default (externally-tagged) struct/tuple-variant encoding.
330pub fn externally_tagged_variant(tag: &'static str, inner: Value) -> Value {
331    object_schema(vec![Property::new(tag, inner, true)], true, None)
332}
333
334/// A single string literal leaf: `{ "const": <s>, "type": "string" }`.
335pub fn const_string(value: &str) -> Value {
336    json!({ "const": value, "type": "string" })
337}
338
339/// Map a logical schema name (`runx.reference.v1`) to its canonical `$id` URL
340/// (`https://schemas.runx.ai/runx/reference/v1.json`). Each dot-delimited
341/// segment is path-joined with `/`, and underscores within a segment become
342/// hyphens (`runx.external_adapter.response.v1` ->
343/// `.../runx/external-adapter/response/v1.json`).
344pub fn schema_id_url(logical: &str) -> String {
345    let path = logical
346        .split('.')
347        .map(|segment| segment.replace('_', "-"))
348        .collect::<Vec<_>>()
349        .join("/");
350    format!("https://schemas.runx.ai/{path}.json")
351}
352
353impl RunxSchema for String {
354    fn json_schema() -> Value {
355        json!({ "type": "string" })
356    }
357}
358
359impl RunxSchema for bool {
360    fn json_schema() -> Value {
361        json!({ "type": "boolean" })
362    }
363}
364
365impl RunxSchema for f64 {
366    fn json_schema() -> Value {
367        json!({ "type": "number" })
368    }
369}
370
371macro_rules! integer_schema {
372    ($($ty:ty),+) => {
373        $(impl RunxSchema for $ty {
374            fn json_schema() -> Value {
375                json!({ "type": "integer" })
376            }
377        })+
378    };
379}
380integer_schema!(i8, i16, i32, i64, isize, u8, u16, u32, u64, usize);
381
382impl<T: RunxSchema> RunxSchema for Vec<T> {
383    fn json_schema() -> Value {
384        json!({ "type": "array", "items": T::json_schema() })
385    }
386}
387
388/// A non-empty array (`minItems: 1`) for contract fields where an empty list
389/// would erase the proof-bearing edge the record exists to carry.
390#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
391#[serde(transparent)]
392pub struct NonEmptyVec<T>(Vec<T>);
393
394impl<T> NonEmptyVec<T> {
395    pub fn new(value: Vec<T>) -> Option<Self> {
396        if value.is_empty() {
397            None
398        } else {
399            Some(Self(value))
400        }
401    }
402
403    pub fn as_slice(&self) -> &[T] {
404        &self.0
405    }
406
407    pub fn into_vec(self) -> Vec<T> {
408        self.0
409    }
410}
411
412impl<T> From<Vec<T>> for NonEmptyVec<T> {
413    fn from(value: Vec<T>) -> Self {
414        debug_assert!(
415            !value.is_empty(),
416            "NonEmptyVec::from received an empty outbound value"
417        );
418        Self(value)
419    }
420}
421
422impl<T> std::ops::Deref for NonEmptyVec<T> {
423    type Target = [T];
424
425    fn deref(&self) -> &Self::Target {
426        &self.0
427    }
428}
429
430impl<'de, T> Deserialize<'de> for NonEmptyVec<T>
431where
432    T: Deserialize<'de>,
433{
434    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
435    where
436        D: serde::Deserializer<'de>,
437    {
438        let value = Vec::<T>::deserialize(deserializer)?;
439        Self::new(value)
440            .ok_or_else(|| serde::de::Error::custom("array must contain at least one item"))
441    }
442}
443
444impl<T: RunxSchema> RunxSchema for NonEmptyVec<T> {
445    fn json_schema() -> Value {
446        let mut schema = Vec::<T>::json_schema();
447        if let Some(object) = schema.as_object_mut() {
448            object.insert("minItems".to_owned(), json!(1));
449        }
450        schema
451    }
452}
453
454impl<T: RunxSchema> RunxSchema for Option<T> {
455    fn json_schema() -> Value {
456        T::json_schema()
457    }
458}
459
460impl<T: RunxSchema> RunxSchema for Box<T> {
461    fn json_schema() -> Value {
462        T::json_schema()
463    }
464}
465
466impl<T: RunxSchema> RunxSchema for BTreeMap<String, T> {
467    fn json_schema() -> Value {
468        json!({ "type": "object", "additionalProperties": T::json_schema() })
469    }
470}
471
472/// A non-empty string (`minLength: 1`), the ubiquitous contract constraint. It
473/// validates on deserialization so an empty value cannot cross the wire
474/// boundary, and emits `{ minLength: 1, type: string }`.
475#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
476#[serde(transparent)]
477pub struct NonEmptyString(String);
478
479impl NonEmptyString {
480    /// Construct from any string-like, returning `None` for an empty value.
481    pub fn new(value: impl Into<String>) -> Option<Self> {
482        let value = value.into();
483        if value.is_empty() {
484            None
485        } else {
486            Some(Self(value))
487        }
488    }
489
490    pub fn as_str(&self) -> &str {
491        &self.0
492    }
493
494    pub fn into_string(self) -> String {
495        self.0
496    }
497}
498
499// Infallible wraps for ergonomics: the wire-in guarantee (non-empty) is
500// enforced on deserialization, where untrusted input crosses the boundary.
501impl From<String> for NonEmptyString {
502    fn from(value: String) -> Self {
503        debug_assert!(
504            !value.is_empty(),
505            "NonEmptyString::from received an empty outbound value"
506        );
507        Self(value)
508    }
509}
510
511impl From<&str> for NonEmptyString {
512    fn from(value: &str) -> Self {
513        debug_assert!(
514            !value.is_empty(),
515            "NonEmptyString::from received an empty outbound value"
516        );
517        Self(value.to_owned())
518    }
519}
520
521impl PartialEq<String> for NonEmptyString {
522    fn eq(&self, other: &String) -> bool {
523        &self.0 == other
524    }
525}
526
527impl PartialEq<&str> for NonEmptyString {
528    fn eq(&self, other: &&str) -> bool {
529        self.0 == *other
530    }
531}
532
533impl PartialEq<NonEmptyString> for String {
534    fn eq(&self, other: &NonEmptyString) -> bool {
535        self == &other.0
536    }
537}
538
539impl PartialEq<NonEmptyString> for str {
540    fn eq(&self, other: &NonEmptyString) -> bool {
541        self == other.0.as_str()
542    }
543}
544
545impl std::ops::Deref for NonEmptyString {
546    type Target = str;
547    fn deref(&self) -> &str {
548        &self.0
549    }
550}
551
552impl AsRef<str> for NonEmptyString {
553    fn as_ref(&self) -> &str {
554        &self.0
555    }
556}
557
558impl std::fmt::Display for NonEmptyString {
559    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
560        formatter.write_str(&self.0)
561    }
562}
563
564impl PartialEq<str> for NonEmptyString {
565    fn eq(&self, other: &str) -> bool {
566        self.0 == other
567    }
568}
569
570impl<'de> Deserialize<'de> for NonEmptyString {
571    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
572    where
573        D: serde::Deserializer<'de>,
574    {
575        let value = String::deserialize(deserializer)?;
576        Self::new(value).ok_or_else(|| serde::de::Error::custom("string must be non-empty"))
577    }
578}
579
580impl RunxSchema for NonEmptyString {
581    fn json_schema() -> Value {
582        json!({ "minLength": 1, "type": "string" })
583    }
584}
585
586/// The ISO-8601 datetime pattern the contracts commit to (`...Z`, optional
587/// fractional seconds).
588pub const ISO_DATETIME_PATTERN: &str = r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$";
589
590/// A non-empty ISO-8601 datetime string. Emits `{ minLength: 1, pattern, type }`.
591/// Validation of the pattern itself stays at the schema layer (the wire
592/// contract); this newtype only guarantees non-emptiness in Rust.
593#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
594#[serde(transparent)]
595pub struct IsoDateTime(String);
596
597impl IsoDateTime {
598    pub fn new(value: impl Into<String>) -> Option<Self> {
599        let value = value.into();
600        if value.is_empty() {
601            None
602        } else {
603            Some(Self(value))
604        }
605    }
606
607    pub fn as_str(&self) -> &str {
608        &self.0
609    }
610
611    pub fn into_string(self) -> String {
612        self.0
613    }
614}
615
616impl From<String> for IsoDateTime {
617    fn from(value: String) -> Self {
618        Self(value)
619    }
620}
621
622impl From<&str> for IsoDateTime {
623    fn from(value: &str) -> Self {
624        Self(value.to_owned())
625    }
626}
627
628impl std::ops::Deref for IsoDateTime {
629    type Target = str;
630    fn deref(&self) -> &str {
631        &self.0
632    }
633}
634
635impl AsRef<str> for IsoDateTime {
636    fn as_ref(&self) -> &str {
637        &self.0
638    }
639}
640
641impl PartialEq<String> for IsoDateTime {
642    fn eq(&self, other: &String) -> bool {
643        &self.0 == other
644    }
645}
646
647impl PartialEq<&str> for IsoDateTime {
648    fn eq(&self, other: &&str) -> bool {
649        self.0 == *other
650    }
651}
652
653impl PartialEq<str> for IsoDateTime {
654    fn eq(&self, other: &str) -> bool {
655        self.0 == other
656    }
657}
658
659impl PartialEq<IsoDateTime> for String {
660    fn eq(&self, other: &IsoDateTime) -> bool {
661        self == &other.0
662    }
663}
664
665impl PartialEq<IsoDateTime> for str {
666    fn eq(&self, other: &IsoDateTime) -> bool {
667        self == other.0.as_str()
668    }
669}
670
671impl std::fmt::Display for IsoDateTime {
672    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
673        formatter.write_str(&self.0)
674    }
675}
676
677impl<'de> Deserialize<'de> for IsoDateTime {
678    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
679    where
680        D: serde::Deserializer<'de>,
681    {
682        let value = String::deserialize(deserializer)?;
683        Self::new(value).ok_or_else(|| serde::de::Error::custom("datetime must be non-empty"))
684    }
685}
686
687impl RunxSchema for IsoDateTime {
688    fn json_schema() -> Value {
689        json!({ "minLength": 1, "pattern": ISO_DATETIME_PATTERN, "type": "string" })
690    }
691}