Skip to main content

golem_schema/schema/render/
json_schema.rs

1// Copyright 2024-2026 Golem Cloud
2//
3// Licensed under the Golem Source License v1.1 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://license.golem.cloud/LICENSE
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Renderer that produces a JSON Schema document from a `SchemaGraph`/
16//! `SchemaType`.
17
18use crate::schema::graph::SchemaGraph;
19use crate::schema::metadata::{MetadataEnvelope, TypeId};
20use crate::schema::schema_type::{
21    BinaryRestrictions, DiscriminatorRule, NumericBound, NumericRestrictions, PathSpec,
22    PermissionCardSpec, QuantitySpec, QuantityValue, QuotaTokenSpec, ResultSpec, SchemaType,
23    SecretSpec, TextRestrictions, UnionBranch, UnionSpec, UrlRestrictions, VariantCaseType,
24};
25use serde_json::{Map, Number, Value};
26use std::collections::{HashMap, HashSet};
27
28const JSON_SCHEMA_DRAFT: &str = "https://json-schema.org/draft/2020-12/schema";
29const MIME_TYPE_PATTERN: &str = "^[A-Za-z0-9!#$&^_.+\\-]+\\/[A-Za-z0-9!#$&^_.+\\-]+$";
30const BASE64URL_PATTERN: &str =
31    "^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-][AQgw]|[A-Za-z0-9_-]{2}[AEIMQUYcgkosw048])?$";
32
33/// Configuration for the JSON Schema renderer.
34///
35/// The public constants select the trusted canonical representation; boundary
36/// renderers additionally select their host-managed capability policy.
37#[derive(Clone, Copy, Debug)]
38pub struct JsonSchemaConfig {
39    /// Emit the `$schema` JSON Schema draft marker at the document root.
40    pub include_draft_marker: bool,
41    host_managed: HostManagedSchemaPolicy,
42    unsupported: UnsupportedLeafSchemaPolicy,
43}
44
45#[derive(Clone, Copy, Debug)]
46enum HostManagedSchemaPolicy {
47    TrustedSnapshot,
48    Reject,
49    Redact,
50}
51
52#[derive(Clone, Copy, Debug)]
53enum UnsupportedLeafSchemaPolicy {
54    Placeholder,
55    Reject,
56}
57
58impl JsonSchemaConfig {
59    /// Canonical standalone JSON Schema document (includes the `$schema`
60    /// draft marker).
61    pub const CANONICAL: Self = Self {
62        include_draft_marker: true,
63        host_managed: HostManagedSchemaPolicy::TrustedSnapshot,
64        unsupported: UnsupportedLeafSchemaPolicy::Placeholder,
65    };
66
67    /// Canonical JSON Schema document without the `$schema` draft marker, for
68    /// consumers that embed the schema elsewhere (e.g. tool/resource schemas).
69    pub const WITHOUT_DRAFT_MARKER: Self = Self {
70        include_draft_marker: false,
71        host_managed: HostManagedSchemaPolicy::TrustedSnapshot,
72        unsupported: UnsupportedLeafSchemaPolicy::Placeholder,
73    };
74
75    pub(crate) const EXTERNAL_INPUT: Self = Self {
76        include_draft_marker: false,
77        host_managed: HostManagedSchemaPolicy::Reject,
78        unsupported: UnsupportedLeafSchemaPolicy::Placeholder,
79    };
80
81    pub(crate) const EXTERNAL_OUTPUT: Self = Self {
82        include_draft_marker: false,
83        host_managed: HostManagedSchemaPolicy::Redact,
84        unsupported: UnsupportedLeafSchemaPolicy::Placeholder,
85    };
86
87    /// Reflection JSON adapters cannot pack or unpack capabilities, futures,
88    /// or streams, so their projected schemas must reject every JSON value.
89    pub const REFLECTION: Self = Self {
90        include_draft_marker: true,
91        host_managed: HostManagedSchemaPolicy::Reject,
92        unsupported: UnsupportedLeafSchemaPolicy::Reject,
93    };
94}
95
96/// Render `(graph, ty)` to a canonical JSON Schema document (includes the
97/// `$schema` draft marker). See [`to_json_schema_with_config`] for the
98/// configurable form.
99pub fn to_json_schema(graph: &SchemaGraph, ty: &SchemaType) -> Value {
100    to_json_schema_with_config(graph, ty, JsonSchemaConfig::CANONICAL)
101}
102
103/// Render `(graph, ty)` to a JSON Schema document. When `ty` is a
104/// `Ref(TypeId)` the document is `{ "$defs": {…}, "$ref": "#/$defs/<id>" }`;
105/// otherwise the root schema is emitted inline with `$defs` carrying every
106/// named definition from the graph plus any union per-branch synthesised
107/// schemas under tag-derived keys (see [`BranchNameTable`]).
108///
109/// `config.include_draft_marker` controls whether the `$schema` draft marker
110/// is added at the document root.
111pub fn to_json_schema_with_config(
112    graph: &SchemaGraph,
113    ty: &SchemaType,
114    config: JsonSchemaConfig,
115) -> Value {
116    let table = build_branch_name_table(graph, ty);
117    let mut root = render_type(graph, ty, true, &table, config);
118    let mut defs = render_defs(graph, &table, config);
119    add_union_branch_defs(graph, ty, &mut defs, &table, config);
120    if !defs.is_empty() {
121        if let Some(obj) = root.as_object_mut() {
122            obj.insert("$defs".to_string(), Value::Object(defs));
123        } else {
124            let mut wrapper = Map::new();
125            wrapper.insert("$defs".to_string(), Value::Object(defs));
126            wrapper.insert("allOf".to_string(), Value::Array(vec![root.clone()]));
127            root = Value::Object(wrapper);
128        }
129    }
130    if config.include_draft_marker
131        && let Some(obj) = root.as_object_mut()
132    {
133        // Insert the JSON Schema draft marker at the top of the produced
134        // root schema. OpenAPI removes this; see `super::openapi`.
135        let mut with_schema = Map::with_capacity(obj.len() + 1);
136        with_schema.insert(
137            "$schema".to_string(),
138            Value::String(JSON_SCHEMA_DRAFT.to_string()),
139        );
140        for (k, v) in obj.iter() {
141            with_schema.insert(k.clone(), v.clone());
142        }
143        return Value::Object(with_schema);
144    }
145    root
146}
147
148/// Render a JSON Schema for untrusted external input. Host-managed capability
149/// leaves cannot be constructed by callers.
150pub fn to_external_input_json_schema(
151    graph: &SchemaGraph,
152    ty: &SchemaType,
153    include_draft_marker: bool,
154) -> Value {
155    to_json_schema_with_config(
156        graph,
157        ty,
158        JsonSchemaConfig {
159            include_draft_marker,
160            ..JsonSchemaConfig::EXTERNAL_INPUT
161        },
162    )
163}
164
165/// Render a JSON Schema for externally visible output. Host-managed capability
166/// leaves expose only their redacted placeholders.
167pub fn to_external_output_json_schema(
168    graph: &SchemaGraph,
169    ty: &SchemaType,
170    include_draft_marker: bool,
171) -> Value {
172    to_json_schema_with_config(
173        graph,
174        ty,
175        JsonSchemaConfig {
176            include_draft_marker,
177            ..JsonSchemaConfig::EXTERNAL_OUTPUT
178        },
179    )
180}
181
182/// Render the JSON domain accepted by reflection `pack_json`/`unpack_json`.
183/// Capability, future, and stream leaves are unsatisfiable because those
184/// adapters have no JSON representation for them.
185pub fn to_reflection_json_schema(
186    graph: &SchemaGraph,
187    ty: &SchemaType,
188    include_draft_marker: bool,
189) -> Value {
190    to_json_schema_with_config(
191        graph,
192        ty,
193        JsonSchemaConfig {
194            include_draft_marker,
195            ..JsonSchemaConfig::REFLECTION
196        },
197    )
198}
199
200/// Whether `ty`, after following any `Ref` chain against `graph`, is an
201/// `option<…>`. Used to decide whether an input parameter is required.
202fn resolves_to_option(graph: &SchemaGraph, ty: &SchemaType) -> bool {
203    let mut current = ty;
204    let mut visited: HashSet<TypeId> = HashSet::new();
205    loop {
206        match current {
207            SchemaType::Option { .. } => return true,
208            SchemaType::Ref { id, .. } => {
209                if !visited.insert(id.clone()) {
210                    return false;
211                }
212                match graph.lookup(id) {
213                    Some(def) => current = &def.body,
214                    None => return false,
215                }
216            }
217            _ => return false,
218        }
219    }
220}
221
222/// Build a `$defs` object covering every named definition in the graph.
223///
224/// Per RFC 6901 §4, JSON Pointer escaping (`~0`/`~1`) applies to the
225/// *pointer string*, not to the resolved object member name. The map key
226/// is therefore the **raw** `TypeId.0` string; the escaped form is only
227/// used inside `$ref` pointers (see [`ref_pointer`]).
228pub(super) fn render_defs(
229    graph: &SchemaGraph,
230    table: &BranchNameTable,
231    config: JsonSchemaConfig,
232) -> Map<String, Value> {
233    let mut defs = Map::new();
234    for def in &graph.defs {
235        // The def's metadata now lives on `def.body` directly; `render_type`
236        // already attaches inline-node metadata, so no extra `attach_metadata`
237        // call is required here.
238        let mut body = render_type(graph, &def.body, false, table, config);
239        if let Some(name) = &def.name
240            && let Some(obj) = body.as_object_mut()
241        {
242            obj.entry("title").or_insert(Value::String(name.clone()));
243        }
244        defs.insert(def.id.0.clone(), body);
245    }
246    defs
247}
248
249/// Walk every union under the graph and synthesize per-branch `$defs`
250/// entries so discriminator-mapping pointers always resolve.
251pub(super) fn add_union_branch_defs(
252    graph: &SchemaGraph,
253    root_ty: &SchemaType,
254    defs: &mut Map<String, Value>,
255    table: &BranchNameTable,
256    config: JsonSchemaConfig,
257) {
258    let mut emitted = HashSet::new();
259    collect_union_branch_defs(graph, root_ty, defs, &mut emitted, table, config);
260    for def in &graph.defs {
261        collect_union_branch_defs(graph, &def.body, defs, &mut emitted, table, config);
262    }
263}
264
265fn collect_union_branch_defs(
266    graph: &SchemaGraph,
267    ty: &SchemaType,
268    defs: &mut Map<String, Value>,
269    emitted: &mut HashSet<String>,
270    table: &BranchNameTable,
271    config: JsonSchemaConfig,
272) {
273    match ty {
274        SchemaType::Union { spec, .. } => {
275            for branch in spec.branches.iter() {
276                let key = table.name_for(branch).to_string();
277                if emitted.insert(key.clone()) {
278                    let mut body = render_type(graph, &branch.body, false, table, config);
279                    attach_metadata(&mut body, &branch.metadata);
280                    defs.insert(
281                        key,
282                        obj_inline([(
283                            "allOf",
284                            Value::Array(vec![
285                                body,
286                                discriminator_constraint(&branch.discriminator),
287                            ]),
288                        )]),
289                    );
290                }
291                collect_union_branch_defs(graph, &branch.body, defs, emitted, table, config);
292            }
293        }
294        SchemaType::Record { fields, .. } => {
295            for f in fields {
296                collect_union_branch_defs(graph, &f.body, defs, emitted, table, config);
297            }
298        }
299        SchemaType::Variant { cases, .. } => {
300            for case in cases {
301                if let Some(p) = &case.payload {
302                    collect_union_branch_defs(graph, p, defs, emitted, table, config);
303                }
304            }
305        }
306        SchemaType::Tuple { elements, .. } => {
307            for e in elements {
308                collect_union_branch_defs(graph, e, defs, emitted, table, config);
309            }
310        }
311        SchemaType::List { element, .. }
312        | SchemaType::FixedList { element, .. }
313        | SchemaType::Option { inner: element, .. } => {
314            collect_union_branch_defs(graph, element, defs, emitted, table, config);
315        }
316        SchemaType::Map { key, value, .. } => {
317            collect_union_branch_defs(graph, key, defs, emitted, table, config);
318            collect_union_branch_defs(graph, value, defs, emitted, table, config);
319        }
320        SchemaType::Result { spec, .. } => {
321            if let Some(t) = &spec.ok {
322                collect_union_branch_defs(graph, t, defs, emitted, table, config);
323            }
324            if let Some(t) = &spec.err {
325                collect_union_branch_defs(graph, t, defs, emitted, table, config);
326            }
327        }
328        SchemaType::Future { inner, .. } | SchemaType::Stream { inner, .. } => {
329            if let Some(t) = inner {
330                collect_union_branch_defs(graph, t, defs, emitted, table, config);
331            }
332        }
333        _ => {}
334    }
335}
336
337/// Stable, tag-preserving `$defs` / `components.schemas` keys for every
338/// union branch reachable from a render root.
339///
340/// Built once per render via [`build_branch_name_table`]. The names are
341/// derived primarily from each branch's `tag` (sanitised to
342/// `UpperCamelCase`) so that types in a generated OpenAPI client carry
343/// human-meaningful names rather than opaque content hashes.
344///
345/// Collisions (two structurally distinct branches sharing a tag) are
346/// resolved by progressively prepending segments from the schema-graph
347/// path that reached each branch, mirroring the algorithm used by
348/// `bridge_gen::type_naming`. As a last resort — when no contextual
349/// disambiguation works — a short hash suffix is appended.
350///
351/// Determinism: the walk visits branches in source order; collision
352/// resolution iterates the resulting group deterministically. The
353/// canonical structural key used internally is a `blake3` hash of the
354/// branch's deterministic JSON serialisation.
355pub(super) struct BranchNameTable {
356    names: HashMap<String, String>,
357}
358
359impl BranchNameTable {
360    pub(super) fn name_for(&self, branch: &UnionBranch) -> &str {
361        let key = canonical_branch_key(branch);
362        self.names.get(&key).map(String::as_str).expect(
363            "BranchNameTable must contain every union branch reachable from the render root \
364                 — `build_branch_name_table` is the source of truth for this invariant",
365        )
366    }
367}
368
369pub(super) fn build_branch_name_table(
370    graph: &SchemaGraph,
371    root_ty: &SchemaType,
372) -> BranchNameTable {
373    let mut collector = BranchCollector::default();
374    collector.walk_type(root_ty);
375    for def in &graph.defs {
376        // Each named def starts a fresh path rooted at its name (or
377        // TypeId fallback); this lets disambiguation lift names through
378        // the named-def boundary when needed.
379        collector.path.clear();
380        let seg = def.name.clone().unwrap_or_else(|| def.id.0.clone());
381        collector.path.push(seg);
382        collector.walk_type(&def.body);
383    }
384    collector.path.clear();
385    // Pre-seed `taken` with every named def's TypeId so branch names
386    // never silently overwrite a real graph def in `$defs`.
387    let taken: HashSet<String> = graph.defs.iter().map(|d| d.id.0.clone()).collect();
388    collector.into_table(taken)
389}
390
391/// Deterministic structural key for a `UnionBranch`. Internal only;
392/// never appears in rendered output.
393fn canonical_branch_key(branch: &UnionBranch) -> String {
394    let bytes = serde_json::to_vec(branch).expect("UnionBranch serializes deterministically");
395    let hex = blake3::hash(&bytes).to_hex();
396    // 128 bits of hash output — sufficient to uniquely identify a branch
397    // body within any practical schema document.
398    hex.as_str()[..32].to_string()
399}
400
401#[derive(Default)]
402struct BranchCollector {
403    /// Canonical key for every encountered branch, in walk order, with no
404    /// duplicates. Used both for membership and for deterministic iteration
405    /// during name resolution.
406    keys: Vec<String>,
407    occurrences: HashMap<String, Occurrence>,
408    /// Path of segments describing the current position in the schema
409    /// graph (record-field names, variant-case names, "key"/"value",
410    /// "ok"/"err", outer branch tags, …).
411    path: Vec<String>,
412}
413
414struct Occurrence {
415    tag: String,
416    /// First path at which this branch was reached. Used to disambiguate
417    /// colliding preferred names.
418    path: Vec<String>,
419}
420
421impl BranchCollector {
422    fn record(&mut self, branch: &UnionBranch) {
423        let key = canonical_branch_key(branch);
424        if let std::collections::hash_map::Entry::Vacant(slot) = self.occurrences.entry(key.clone())
425        {
426            slot.insert(Occurrence {
427                tag: branch.tag.clone(),
428                path: self.path.clone(),
429            });
430            self.keys.push(key);
431        }
432    }
433
434    /// Walk a `SchemaType` subtree, pushing/popping path segments and
435    /// recording every encountered `UnionBranch`.
436    ///
437    /// `Ref(TypeId)` nodes are not followed: the named def they point
438    /// at is walked separately from `build_branch_name_table` with its
439    /// own fresh path, so following refs here would record duplicate
440    /// occurrences and pollute the disambiguation path.
441    fn walk_type(&mut self, ty: &SchemaType) {
442        match ty {
443            SchemaType::Union { spec, .. } => {
444                for branch in &spec.branches {
445                    self.record(branch);
446                    self.path.push(branch.tag.clone());
447                    self.walk_type(&branch.body);
448                    self.path.pop();
449                }
450            }
451            SchemaType::Record { fields, .. } => {
452                for f in fields {
453                    self.path.push(f.name.clone());
454                    self.walk_type(&f.body);
455                    self.path.pop();
456                }
457            }
458            SchemaType::Variant { cases, .. } => {
459                for case in cases {
460                    if let Some(p) = &case.payload {
461                        self.path.push(case.name.clone());
462                        self.walk_type(p);
463                        self.path.pop();
464                    }
465                }
466            }
467            SchemaType::Tuple { elements, .. } => {
468                for (i, e) in elements.iter().enumerate() {
469                    self.path.push(format!("item{i}"));
470                    self.walk_type(e);
471                    self.path.pop();
472                }
473            }
474            SchemaType::List { element, .. } | SchemaType::FixedList { element, .. } => {
475                self.path.push("item".to_string());
476                self.walk_type(element);
477                self.path.pop();
478            }
479            SchemaType::Option { inner, .. } => {
480                self.path.push("inner".to_string());
481                self.walk_type(inner);
482                self.path.pop();
483            }
484            SchemaType::Map { key, value, .. } => {
485                self.path.push("key".to_string());
486                self.walk_type(key);
487                self.path.pop();
488                self.path.push("value".to_string());
489                self.walk_type(value);
490                self.path.pop();
491            }
492            SchemaType::Result { spec, .. } => {
493                if let Some(t) = &spec.ok {
494                    self.path.push("ok".to_string());
495                    self.walk_type(t);
496                    self.path.pop();
497                }
498                if let Some(t) = &spec.err {
499                    self.path.push("err".to_string());
500                    self.walk_type(t);
501                    self.path.pop();
502                }
503            }
504            SchemaType::Future { inner, .. } | SchemaType::Stream { inner, .. } => {
505                if let Some(t) = inner {
506                    self.path.push("inner".to_string());
507                    self.walk_type(t);
508                    self.path.pop();
509                }
510            }
511            _ => {}
512        }
513    }
514
515    fn into_table(self, mut taken: HashSet<String>) -> BranchNameTable {
516        // Compute each occurrence's preferred name (from its `tag`) and
517        // group by it.
518        let mut groups: Vec<(String, Vec<String>)> = Vec::new();
519        for key in &self.keys {
520            let occ = &self.occurrences[key];
521            let preferred = sanitise_to_upper_camel(&occ.tag);
522            match groups.iter_mut().find(|(name, _)| name == &preferred) {
523                Some((_, members)) => members.push(key.clone()),
524                None => groups.push((preferred, vec![key.clone()])),
525            }
526        }
527
528        let mut names = HashMap::<String, String>::new();
529        for (preferred, members) in groups {
530            if members.len() == 1 && !taken.contains(&preferred) {
531                // Unique preferred name and not colliding with a graph
532                // def TypeId — use it verbatim.
533                let only = members.into_iter().next().unwrap();
534                taken.insert(preferred.clone());
535                names.insert(only, preferred);
536            } else {
537                // Real collision (or shadows a graph def): every member
538                // is forced through location-based disambiguation so the
539                // assigned names are symmetric.
540                for key in members {
541                    let occ = &self.occurrences[&key];
542                    let assigned = disambiguate(&preferred, &occ.path, &taken, &key);
543                    taken.insert(assigned.clone());
544                    names.insert(key, assigned);
545                }
546            }
547        }
548
549        BranchNameTable { names }
550    }
551}
552
553/// Find a unique name by progressively prepending sanitised path
554/// segments (innermost → outermost) to `base`. Falls back to a short
555/// canonical-key suffix if no contextual disambiguation works.
556fn disambiguate(base: &str, path: &[String], taken: &HashSet<String>, canonical: &str) -> String {
557    let mut candidate = base.to_string();
558    for seg in path.iter().rev() {
559        let seg_camel = sanitise_to_upper_camel(seg);
560        if seg_camel.is_empty() {
561            continue;
562        }
563        candidate = format!("{seg_camel}{candidate}");
564        if !taken.contains(&candidate) {
565            return candidate;
566        }
567    }
568    let suffix_len = 6.min(canonical.len());
569    format!("{base}_{}", &canonical[..suffix_len])
570}
571
572/// Sanitise an arbitrary string to a non-empty UpperCamelCase identifier
573/// suitable for both JSON Pointer member names and OpenAPI schema names
574/// (alphabet `[A-Za-z0-9]`). Non-alphanumerics are dropped and treated
575/// as word separators; a leading digit is prefixed with `_`.
576fn sanitise_to_upper_camel(s: &str) -> String {
577    let mut out = String::with_capacity(s.len());
578    let mut upper_next = true;
579    for ch in s.chars() {
580        if ch.is_ascii_alphanumeric() {
581            if upper_next {
582                for u in ch.to_uppercase() {
583                    out.push(u);
584                }
585                upper_next = false;
586            } else {
587                out.push(ch);
588            }
589        } else {
590            upper_next = true;
591        }
592    }
593    if out.is_empty() {
594        return "Branch".to_string();
595    }
596    if out.starts_with(|c: char| c.is_ascii_digit()) {
597        format!("_{out}")
598    } else {
599        out
600    }
601}
602
603fn discriminator_constraint(rule: &DiscriminatorRule) -> Value {
604    let mut obj = Map::new();
605    match rule {
606        DiscriminatorRule::Prefix { prefix } => {
607            obj.insert(
608                "pattern".to_string(),
609                Value::String(format!("^{}", regex_escape(prefix))),
610            );
611        }
612        DiscriminatorRule::Suffix { suffix } => {
613            obj.insert(
614                "pattern".to_string(),
615                Value::String(format!("{}$", regex_escape(suffix))),
616            );
617        }
618        DiscriminatorRule::Contains { substring } => {
619            obj.insert(
620                "pattern".to_string(),
621                Value::String(regex_escape(substring)),
622            );
623        }
624        DiscriminatorRule::Regex { regex } => {
625            obj.insert("pattern".to_string(), Value::String(regex.clone()));
626        }
627        DiscriminatorRule::FieldEquals(disc) => {
628            // Constrain the field's value with `const` if a literal is set;
629            // otherwise just require the field to be present.
630            obj.insert(
631                "required".to_string(),
632                Value::Array(vec![Value::String(disc.field_name.clone())]),
633            );
634            if let Some(lit) = &disc.literal {
635                let props = obj
636                    .entry("properties")
637                    .or_insert_with(|| Value::Object(Map::new()))
638                    .as_object_mut()
639                    .expect("properties is object");
640                let field = props
641                    .entry(disc.field_name.clone())
642                    .or_insert_with(|| obj_inline([("type", Value::String("string".to_string()))]));
643                if let Some(field_obj) = field.as_object_mut() {
644                    field_obj.insert("const".to_string(), Value::String(lit.clone()));
645                }
646            }
647        }
648        DiscriminatorRule::FieldAbsent { field_name } => {
649            // Express absence via `not: { required: [field] }`.
650            let not = obj_inline([(
651                "required",
652                Value::Array(vec![Value::String(field_name.clone())]),
653            )]);
654            obj.insert("not".to_string(), not);
655        }
656    }
657    Value::Object(obj)
658}
659
660pub(super) fn render_type(
661    graph: &SchemaGraph,
662    ty: &SchemaType,
663    root: bool,
664    table: &BranchNameTable,
665    config: JsonSchemaConfig,
666) -> Value {
667    let mut rendered = match ty {
668        SchemaType::Ref { id, .. } => obj([("$ref", Value::String(ref_pointer(id, root)))]),
669
670        SchemaType::Bool { .. } => obj([("type", Value::String("boolean".to_string()))]),
671        SchemaType::S8 { restrictions, .. } => {
672            signed_integer_schema(i8::MIN as i64, i8::MAX as i64, restrictions.as_ref())
673        }
674        SchemaType::S16 { restrictions, .. } => {
675            signed_integer_schema(i16::MIN as i64, i16::MAX as i64, restrictions.as_ref())
676        }
677        SchemaType::S32 { restrictions, .. } => {
678            signed_integer_schema(i32::MIN as i64, i32::MAX as i64, restrictions.as_ref())
679        }
680        SchemaType::S64 { restrictions, .. } => signed_64_schema(restrictions.as_ref()),
681        SchemaType::U8 { restrictions, .. } => {
682            unsigned_integer_schema(0, u8::MAX as u64, restrictions.as_ref())
683        }
684        SchemaType::U16 { restrictions, .. } => {
685            unsigned_integer_schema(0, u16::MAX as u64, restrictions.as_ref())
686        }
687        SchemaType::U32 { restrictions, .. } => {
688            unsigned_integer_schema(0, u32::MAX as u64, restrictions.as_ref())
689        }
690        SchemaType::U64 { restrictions, .. } => unsigned_64_schema(restrictions.as_ref()),
691        SchemaType::F32 { restrictions, .. } => {
692            float_schema(-(f32::MAX as f64), f32::MAX as f64, restrictions.as_ref())
693        }
694        SchemaType::F64 { restrictions, .. } => {
695            float_schema(-f64::MAX, f64::MAX, restrictions.as_ref())
696        }
697        SchemaType::Char { .. } => obj([
698            ("type", Value::String("string".to_string())),
699            ("minLength", Value::Number(1.into())),
700            ("maxLength", Value::Number(1.into())),
701        ]),
702        SchemaType::String { .. } => obj([("type", Value::String("string".to_string()))]),
703
704        SchemaType::Record { fields, .. } => {
705            let mut props = Map::new();
706            let mut required = Vec::with_capacity(fields.len());
707            for field in fields {
708                let mut field_schema = render_type(graph, &field.body, false, table, config);
709                attach_metadata(&mut field_schema, &field.metadata);
710                props.insert(field.name.clone(), field_schema);
711                // `option<…>` fields are not required: the field may be omitted
712                // entirely, and an explicit `null` is still accepted by the
713                // option's `oneOf [null, T]` schema.
714                if !resolves_to_option(graph, &field.body) {
715                    required.push(Value::String(field.name.clone()));
716                }
717            }
718            obj([
719                ("type", Value::String("object".to_string())),
720                ("properties", Value::Object(props)),
721                ("required", Value::Array(required)),
722                ("additionalProperties", Value::Bool(false)),
723            ])
724        }
725
726        SchemaType::Variant { cases, .. } => {
727            Value::Object(variant_schema(graph, cases, table, config))
728        }
729
730        SchemaType::Enum { cases, .. } => obj([
731            ("type", Value::String("string".to_string())),
732            (
733                "enum",
734                Value::Array(cases.iter().cloned().map(Value::String).collect()),
735            ),
736        ]),
737
738        SchemaType::Flags { flags, .. } => obj([
739            ("type", Value::String("array".to_string())),
740            (
741                "items",
742                obj([
743                    ("type", Value::String("string".to_string())),
744                    (
745                        "enum",
746                        Value::Array(flags.iter().cloned().map(Value::String).collect()),
747                    ),
748                ]),
749            ),
750            ("uniqueItems", Value::Bool(true)),
751        ]),
752
753        SchemaType::Tuple { elements, .. } => {
754            if elements.is_empty() {
755                // JSON Schema 2020-12 requires `prefixItems` to be a
756                // non-empty array, so the empty-tuple shape uses
757                // `maxItems`/`minItems` only.
758                obj([
759                    ("type", Value::String("array".to_string())),
760                    ("minItems", Value::Number(0u64.into())),
761                    ("maxItems", Value::Number(0u64.into())),
762                ])
763            } else {
764                obj([
765                    ("type", Value::String("array".to_string())),
766                    (
767                        "prefixItems",
768                        Value::Array(
769                            elements
770                                .iter()
771                                .map(|e| render_type(graph, e, false, table, config))
772                                .collect(),
773                        ),
774                    ),
775                    ("items", Value::Bool(false)),
776                    ("minItems", Value::Number((elements.len() as u64).into())),
777                ])
778            }
779        }
780
781        SchemaType::List { element, .. } => obj([
782            ("type", Value::String("array".to_string())),
783            ("items", render_type(graph, element, false, table, config)),
784        ]),
785
786        SchemaType::FixedList {
787            element, length, ..
788        } => obj([
789            ("type", Value::String("array".to_string())),
790            ("items", render_type(graph, element, false, table, config)),
791            ("minItems", Value::Number((*length).into())),
792            ("maxItems", Value::Number((*length).into())),
793        ]),
794
795        SchemaType::Map { key, value, .. } => {
796            let pair = obj([
797                ("type", Value::String("array".to_string())),
798                (
799                    "prefixItems",
800                    Value::Array(vec![
801                        render_type(graph, key, false, table, config),
802                        render_type(graph, value, false, table, config),
803                    ]),
804                ),
805                ("items", Value::Bool(false)),
806                ("minItems", Value::Number(2.into())),
807                ("maxItems", Value::Number(2.into())),
808            ]);
809            obj([
810                ("type", Value::String("array".to_string())),
811                ("items", pair),
812            ])
813        }
814
815        SchemaType::Option { inner, .. } => obj([(
816            "oneOf",
817            Value::Array(vec![
818                obj([("type", Value::String("null".to_string()))]),
819                render_type(graph, inner, false, table, config),
820            ]),
821        )]),
822
823        SchemaType::Result { spec, .. } => Value::Object(result_schema(graph, spec, table, config)),
824
825        SchemaType::Text { restrictions, .. } => Value::Object(text_schema(restrictions)),
826        SchemaType::Binary { restrictions, .. } => Value::Object(binary_schema(restrictions)),
827        SchemaType::Path { spec, .. } => Value::Object(path_schema(spec)),
828        SchemaType::Url { restrictions, .. } => Value::Object(url_schema(restrictions)),
829        SchemaType::Uuid { .. } => obj([
830            ("type", Value::String("string".to_string())),
831            ("format", Value::String("uuid".to_string())),
832        ]),
833        SchemaType::Datetime { .. } => obj([
834            ("type", Value::String("string".to_string())),
835            ("format", Value::String("date-time".to_string())),
836        ]),
837        SchemaType::Duration { .. } => obj([
838            ("type", Value::String("object".to_string())),
839            ("properties", obj([("nanoseconds", signed_64_schema(None))])),
840            (
841                "required",
842                Value::Array(vec![Value::String("nanoseconds".to_string())]),
843            ),
844            ("additionalProperties", Value::Bool(false)),
845            (
846                "title",
847                Value::String("Duration in nanoseconds".to_string()),
848            ),
849        ]),
850        SchemaType::Quantity { spec, .. } => Value::Object(quantity_schema(spec)),
851
852        SchemaType::Union { spec, .. } => Value::Object(union_schema(graph, spec, table, config)),
853
854        SchemaType::Secret { spec, .. } => {
855            host_managed_schema(config.host_managed, "secret", || {
856                Value::Object(secret_schema(spec))
857            })
858        }
859        SchemaType::QuotaToken { spec, .. } => {
860            host_managed_schema(config.host_managed, "quota-token", || {
861                Value::Object(quota_token_schema(spec))
862            })
863        }
864        SchemaType::PermissionCard { spec, .. } => {
865            host_managed_schema(config.host_managed, "permission-card", || {
866                Value::Object(permission_card_schema(spec))
867            })
868        }
869
870        SchemaType::Future { .. } | SchemaType::Stream { .. } => match config.unsupported {
871            UnsupportedLeafSchemaPolicy::Placeholder => obj([
872                ("type", Value::String("null".to_string())),
873                (
874                    "description",
875                    Value::String("WASI P3 placeholder".to_string()),
876                ),
877            ]),
878            UnsupportedLeafSchemaPolicy::Reject => obj([("not", Value::Object(Map::new()))]),
879        },
880    };
881
882    // Per-node metadata: attach docs / examples / deprecated for every
883    // SchemaType node so inline-typed positions (record fields, list
884    // elements, etc.) propagate their metadata into the generated JSON
885    // Schema, not only named definitions.
886    attach_metadata(&mut rendered, ty.metadata());
887    rendered
888}
889
890fn host_managed_schema(
891    policy: HostManagedSchemaPolicy,
892    kind: &str,
893    trusted: impl FnOnce() -> Value,
894) -> Value {
895    match policy {
896        HostManagedSchemaPolicy::TrustedSnapshot => trusted(),
897        HostManagedSchemaPolicy::Reject => obj([
898            ("not", Value::Object(Map::new())),
899            (
900                "description",
901                Value::String(format!(
902                    "Host-managed {kind} capabilities cannot be supplied externally"
903                )),
904            ),
905        ]),
906        HostManagedSchemaPolicy::Redact => obj([
907            ("type", Value::String("string".to_string())),
908            ("const", Value::String(format!("<redacted: {kind}>"))),
909            (
910                "description",
911                Value::String(format!(
912                    "Host-managed {kind} capability values are redacted"
913                )),
914            ),
915        ]),
916    }
917}
918
919fn ref_pointer(id: &TypeId, _root: bool) -> String {
920    ref_to_def_key(&id.0)
921}
922
923/// Build the `$ref` pointer string for a raw `$defs` member key.
924///
925/// `key` is the raw (un-escaped) member name; this helper applies
926/// RFC 6901 JSON Pointer escaping when embedding it in the pointer path.
927pub(super) fn ref_to_def_key(key: &str) -> String {
928    format!("#/$defs/{}", escape_pointer_token(key))
929}
930
931fn signed_integer_schema(min: i64, max: i64, restrictions: Option<&NumericRestrictions>) -> Value {
932    let minimum = restrictions
933        .and_then(|value| value.min)
934        .and_then(|bound| match bound {
935            NumericBound::Signed(value) => Some(value),
936            _ => None,
937        })
938        .unwrap_or(min)
939        .max(min);
940    let maximum = restrictions
941        .and_then(|value| value.max)
942        .and_then(|bound| match bound {
943            NumericBound::Signed(value) => Some(value),
944            _ => None,
945        })
946        .unwrap_or(max)
947        .min(max);
948    obj([
949        ("type", Value::String("integer".to_string())),
950        ("minimum", Value::Number(Number::from(minimum))),
951        ("maximum", Value::Number(Number::from(maximum))),
952    ])
953}
954
955fn unsigned_integer_schema(
956    min: u64,
957    max: u64,
958    restrictions: Option<&NumericRestrictions>,
959) -> Value {
960    let minimum = restrictions
961        .and_then(|value| value.min)
962        .and_then(|bound| match bound {
963            NumericBound::Unsigned(value) => Some(value),
964            _ => None,
965        })
966        .unwrap_or(min)
967        .max(min);
968    let maximum = restrictions
969        .and_then(|value| value.max)
970        .and_then(|bound| match bound {
971            NumericBound::Unsigned(value) => Some(value),
972            _ => None,
973        })
974        .unwrap_or(max)
975        .min(max);
976    obj([
977        ("type", Value::String("integer".to_string())),
978        ("minimum", Value::Number(Number::from(minimum))),
979        ("maximum", Value::Number(Number::from(maximum))),
980    ])
981}
982
983fn float_schema(min: f64, max: f64, restrictions: Option<&NumericRestrictions>) -> Value {
984    let mut schema = Map::new();
985    schema.insert("type".to_string(), Value::String("number".to_string()));
986    if let Some(NumericBound::FloatBits(bits)) = restrictions.and_then(|value| value.min)
987        && let Some(value) = Number::from_f64(f64::from_bits(bits).max(min))
988    {
989        schema.insert("minimum".to_string(), Value::Number(value));
990    }
991    if let Some(NumericBound::FloatBits(bits)) = restrictions.and_then(|value| value.max)
992        && let Some(value) = Number::from_f64(f64::from_bits(bits).min(max))
993    {
994        schema.insert("maximum".to_string(), Value::Number(value));
995    }
996    Value::Object(schema)
997}
998
999fn unsigned_64_schema(restrictions: Option<&NumericRestrictions>) -> Value {
1000    let minimum = restrictions
1001        .and_then(|value| value.min)
1002        .and_then(|bound| match bound {
1003            NumericBound::Unsigned(value) => Some(value),
1004            _ => None,
1005        })
1006        .unwrap_or(0);
1007    let maximum = restrictions
1008        .and_then(|value| value.max)
1009        .and_then(|bound| match bound {
1010            NumericBound::Unsigned(value) => Some(value),
1011            _ => None,
1012        })
1013        .unwrap_or(u64::MAX);
1014    obj([
1015        ("type", Value::String("string".to_string())),
1016        ("format", Value::String("uint64".to_string())),
1017        ("pattern", Value::String("^(?:0|[1-9][0-9]*)$".to_string())),
1018        ("x-golem-minimum", Value::String(minimum.to_string())),
1019        ("x-golem-maximum", Value::String(maximum.to_string())),
1020    ])
1021}
1022
1023fn signed_64_schema(restrictions: Option<&NumericRestrictions>) -> Value {
1024    let minimum = restrictions
1025        .and_then(|value| value.min)
1026        .and_then(|bound| match bound {
1027            NumericBound::Signed(value) => Some(value),
1028            _ => None,
1029        })
1030        .unwrap_or(i64::MIN);
1031    let maximum = restrictions
1032        .and_then(|value| value.max)
1033        .and_then(|bound| match bound {
1034            NumericBound::Signed(value) => Some(value),
1035            _ => None,
1036        })
1037        .unwrap_or(i64::MAX);
1038    obj([
1039        ("type", Value::String("string".to_string())),
1040        ("format", Value::String("int64".to_string())),
1041        (
1042            "pattern",
1043            Value::String("^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$".to_string()),
1044        ),
1045        ("x-golem-minimum", Value::String(minimum.to_string())),
1046        ("x-golem-maximum", Value::String(maximum.to_string())),
1047    ])
1048}
1049
1050fn variant_schema(
1051    graph: &SchemaGraph,
1052    cases: &[VariantCaseType],
1053    table: &BranchNameTable,
1054    config: JsonSchemaConfig,
1055) -> Map<String, Value> {
1056    let one_of: Vec<Value> = cases
1057        .iter()
1058        .map(|case| match &case.payload {
1059            None => obj([("const", Value::String(case.name.clone()))]),
1060            Some(payload_ty) => {
1061                let mut props = Map::new();
1062                props.insert(
1063                    case.name.clone(),
1064                    render_type(graph, payload_ty, false, table, config),
1065                );
1066                obj([
1067                    ("type", Value::String("object".to_string())),
1068                    ("properties", Value::Object(props)),
1069                    (
1070                        "required",
1071                        Value::Array(vec![Value::String(case.name.clone())]),
1072                    ),
1073                    ("additionalProperties", Value::Bool(false)),
1074                ])
1075            }
1076        })
1077        .collect();
1078    let mut out = Map::new();
1079    out.insert("oneOf".to_string(), Value::Array(one_of));
1080    out
1081}
1082
1083fn result_schema(
1084    graph: &SchemaGraph,
1085    spec: &ResultSpec,
1086    table: &BranchNameTable,
1087    config: JsonSchemaConfig,
1088) -> Map<String, Value> {
1089    let ok_inner = spec
1090        .ok
1091        .as_deref()
1092        .map(|t| render_type(graph, t, false, table, config))
1093        .unwrap_or_else(|| obj([("type", Value::String("null".to_string()))]));
1094    let err_inner = spec
1095        .err
1096        .as_deref()
1097        .map(|t| render_type(graph, t, false, table, config))
1098        .unwrap_or_else(|| obj([("type", Value::String("null".to_string()))]));
1099    let one_of = vec![
1100        obj([
1101            ("type", Value::String("object".to_string())),
1102            (
1103                "properties",
1104                Value::Object({
1105                    let mut m = Map::new();
1106                    m.insert("ok".to_string(), ok_inner);
1107                    m
1108                }),
1109            ),
1110            ("required", Value::Array(vec![Value::String("ok".into())])),
1111            ("additionalProperties", Value::Bool(false)),
1112        ]),
1113        obj([
1114            ("type", Value::String("object".to_string())),
1115            (
1116                "properties",
1117                Value::Object({
1118                    let mut m = Map::new();
1119                    m.insert("err".to_string(), err_inner);
1120                    m
1121                }),
1122            ),
1123            ("required", Value::Array(vec![Value::String("err".into())])),
1124            ("additionalProperties", Value::Bool(false)),
1125        ]),
1126    ];
1127    let mut out = Map::new();
1128    out.insert("oneOf".to_string(), Value::Array(one_of));
1129    out
1130}
1131
1132fn text_schema(restrictions: &TextRestrictions) -> Map<String, Value> {
1133    // Canonical Text JSON shape: `{ text: string, language?: string }` with
1134    // length / pattern constraints lifted into the `text` field.
1135    let mut text_field = Map::new();
1136    text_field.insert("type".to_string(), Value::String("string".to_string()));
1137    if let Some(min) = restrictions.min_length {
1138        text_field.insert("minLength".to_string(), Value::Number(min.into()));
1139    }
1140    if let Some(max) = restrictions.max_length {
1141        text_field.insert("maxLength".to_string(), Value::Number(max.into()));
1142    }
1143    if let Some(regex) = &restrictions.regex {
1144        text_field.insert("pattern".to_string(), Value::String(regex.clone()));
1145    }
1146    let mut properties = Map::new();
1147    properties.insert("text".to_string(), Value::Object(text_field));
1148    let mut language_field = Map::new();
1149    language_field.insert("type".to_string(), Value::String("string".to_string()));
1150    if let Some(langs) = &restrictions.languages {
1151        language_field.insert(
1152            "enum".to_string(),
1153            Value::Array(langs.iter().cloned().map(Value::String).collect()),
1154        );
1155    }
1156    properties.insert("language".to_string(), Value::Object(language_field));
1157    let mut m = Map::new();
1158    m.insert("type".to_string(), Value::String("object".to_string()));
1159    m.insert("properties".to_string(), Value::Object(properties));
1160    m.insert(
1161        "required".to_string(),
1162        Value::Array(vec![Value::String("text".to_string())]),
1163    );
1164    m.insert("additionalProperties".to_string(), Value::Bool(false));
1165    m
1166}
1167
1168fn binary_schema(restrictions: &BinaryRestrictions) -> Map<String, Value> {
1169    // Canonical Binary JSON shape: `{ bytes: base64url-string, mimeType?: string }`.
1170    // `min_bytes` / `max_bytes` count *raw* bytes; the JSON field is
1171    // base64url-no-pad-encoded, so the on-wire string length is
1172    // `base64url_no_pad_len(n) = 4*(n/3) + match n%3 { 0=>0, 1=>2, 2=>3 }`.
1173    let mut bytes_field = Map::new();
1174    bytes_field.insert("type".to_string(), Value::String("string".to_string()));
1175    bytes_field.insert(
1176        "contentEncoding".to_string(),
1177        Value::String("base64url".to_string()),
1178    );
1179    bytes_field.insert(
1180        "pattern".to_string(),
1181        Value::String(BASE64URL_PATTERN.to_string()),
1182    );
1183    if let Some(min) = restrictions.min_bytes {
1184        bytes_field.insert(
1185            "minLength".to_string(),
1186            Value::Number(base64url_no_pad_len(min).into()),
1187        );
1188    }
1189    if let Some(max) = restrictions.max_bytes {
1190        bytes_field.insert(
1191            "maxLength".to_string(),
1192            Value::Number(base64url_no_pad_len(max).into()),
1193        );
1194    }
1195    let mut mime_field = Map::new();
1196    mime_field.insert("type".to_string(), Value::String("string".to_string()));
1197    mime_field.insert(
1198        "pattern".to_string(),
1199        Value::String(MIME_TYPE_PATTERN.to_string()),
1200    );
1201    if let Some(mimes) = &restrictions.mime_types {
1202        mime_field.insert(
1203            "enum".to_string(),
1204            Value::Array(mimes.iter().cloned().map(Value::String).collect()),
1205        );
1206    }
1207    let mut properties = Map::new();
1208    properties.insert("bytes".to_string(), Value::Object(bytes_field));
1209    properties.insert("mimeType".to_string(), Value::Object(mime_field));
1210    let mut m = Map::new();
1211    m.insert("type".to_string(), Value::String("object".to_string()));
1212    m.insert("properties".to_string(), Value::Object(properties));
1213    m.insert(
1214        "required".to_string(),
1215        Value::Array(vec![Value::String("bytes".to_string())]),
1216    );
1217    m.insert("additionalProperties".to_string(), Value::Bool(false));
1218    m
1219}
1220
1221fn path_schema(spec: &PathSpec) -> Map<String, Value> {
1222    let mut m = Map::new();
1223    m.insert("type".to_string(), Value::String("string".to_string()));
1224    m.insert("format".to_string(), Value::String("file-path".to_string()));
1225    let kind = match spec.kind {
1226        crate::schema::schema_type::PathKind::File => "file",
1227        crate::schema::schema_type::PathKind::Directory => "directory",
1228        crate::schema::schema_type::PathKind::Any => "any",
1229    };
1230    let direction = match spec.direction {
1231        crate::schema::schema_type::PathDirection::Input => "input",
1232        crate::schema::schema_type::PathDirection::Output => "output",
1233        crate::schema::schema_type::PathDirection::InOut => "inout",
1234    };
1235    m.insert(
1236        "title".to_string(),
1237        Value::String(format!("{direction} {kind} path")),
1238    );
1239    let mut description = Vec::new();
1240    if let Some(exts) = &spec.allowed_extensions {
1241        description.push(format!("Allowed extensions: {}", exts.join(", ")));
1242    }
1243    if let Some(mimes) = &spec.allowed_mime_types {
1244        description.push(format!("Allowed MIME types: {}", mimes.join(", ")));
1245    }
1246    if !description.is_empty() {
1247        m.insert(
1248            "description".to_string(),
1249            Value::String(description.join("; ")),
1250        );
1251    }
1252    m
1253}
1254
1255fn url_schema(restrictions: &UrlRestrictions) -> Map<String, Value> {
1256    let mut m = Map::new();
1257    m.insert("type".to_string(), Value::String("string".to_string()));
1258    m.insert("format".to_string(), Value::String("uri".to_string()));
1259    m.insert("title".to_string(), Value::String("URL".to_string()));
1260    let mut description = Vec::new();
1261    if let Some(schemes) = &restrictions.allowed_schemes {
1262        description.push(format!("Allowed schemes: {}", schemes.join(", ")));
1263    }
1264    if let Some(hosts) = &restrictions.allowed_hosts {
1265        description.push(format!("Allowed hosts: {}", hosts.join(", ")));
1266    }
1267    if !description.is_empty() {
1268        m.insert(
1269            "description".to_string(),
1270            Value::String(description.join("; ")),
1271        );
1272    }
1273    m
1274}
1275
1276fn quantity_schema(spec: &QuantitySpec) -> Map<String, Value> {
1277    let mut props = Map::new();
1278    props.insert("mantissa".to_string(), signed_64_schema(None));
1279    props.insert(
1280        "scale".to_string(),
1281        obj([("type", Value::String("integer".to_string()))]),
1282    );
1283    props.insert(
1284        "unit".to_string(),
1285        obj([("type", Value::String("string".to_string()))]),
1286    );
1287    let mut m = Map::new();
1288    m.insert("type".to_string(), Value::String("object".to_string()));
1289    m.insert("properties".to_string(), Value::Object(props));
1290    m.insert(
1291        "required".to_string(),
1292        Value::Array(vec![
1293            Value::String("mantissa".to_string()),
1294            Value::String("scale".to_string()),
1295            Value::String("unit".to_string()),
1296        ]),
1297    );
1298    m.insert("additionalProperties".to_string(), Value::Bool(false));
1299    m.insert(
1300        "title".to_string(),
1301        Value::String(format!("Quantity ({})", spec.base_unit)),
1302    );
1303    let mut description = Vec::new();
1304    if let Some(min) = &spec.min {
1305        description.push(format!("min: {}", render_quantity(min)));
1306    }
1307    if let Some(max) = &spec.max {
1308        description.push(format!("max: {}", render_quantity(max)));
1309    }
1310    if !description.is_empty() {
1311        m.insert(
1312            "description".to_string(),
1313            Value::String(description.join("; ")),
1314        );
1315    }
1316    m
1317}
1318
1319fn render_quantity(q: &QuantityValue) -> String {
1320    format!("{}e-{} {}", q.mantissa, q.scale, q.unit)
1321}
1322
1323fn secret_schema(_spec: &SecretSpec) -> Map<String, Value> {
1324    // Canonical Secret JSON shape: see canonical/secret.rs.
1325    let secret_id = obj_inline([
1326        ("type", Value::String("string".to_string())),
1327        ("format", Value::String("uuid".to_string())),
1328    ]);
1329    let config_key = obj_inline([
1330        ("type", Value::String("array".to_string())),
1331        (
1332            "items",
1333            obj_inline([("type", Value::String("string".to_string()))]),
1334        ),
1335    ]);
1336    let version = obj_inline([
1337        ("type", Value::String("integer".to_string())),
1338        ("minimum", Value::Number(0.into())),
1339    ]);
1340    let resolved_at = obj_inline([
1341        ("type", Value::String("string".to_string())),
1342        ("format", Value::String("date-time".to_string())),
1343    ]);
1344    let category = obj_inline([("type", Value::String("string".to_string()))]);
1345    let mut properties = Map::new();
1346    properties.insert("secretId".to_string(), secret_id);
1347    properties.insert("configKey".to_string(), config_key);
1348    properties.insert("version".to_string(), version);
1349    properties.insert("resolvedAt".to_string(), resolved_at);
1350    properties.insert("category".to_string(), category);
1351    let mut m = Map::new();
1352    m.insert("type".to_string(), Value::String("object".to_string()));
1353    m.insert("properties".to_string(), Value::Object(properties));
1354    m.insert(
1355        "required".to_string(),
1356        Value::Array(vec![
1357            Value::String("secretId".to_string()),
1358            Value::String("version".to_string()),
1359            Value::String("resolvedAt".to_string()),
1360        ]),
1361    );
1362    m.insert("additionalProperties".to_string(), Value::Bool(false));
1363    m
1364}
1365
1366fn quota_token_schema(_spec: &QuotaTokenSpec) -> Map<String, Value> {
1367    // Canonical QuotaToken JSON shape: see canonical/quota_token.rs.
1368    let env_id = obj_inline([
1369        ("type", Value::String("string".to_string())),
1370        ("format", Value::String("uuid".to_string())),
1371    ]);
1372    let resource_name = obj_inline([("type", Value::String("string".to_string()))]);
1373    let expected_use = obj_inline([(
1374        "oneOf",
1375        Value::Array(vec![
1376            obj_inline([
1377                ("type", Value::String("string".to_string())),
1378                ("pattern", Value::String("^[0-9]+$".to_string())),
1379            ]),
1380            obj_inline([
1381                ("type", Value::String("integer".to_string())),
1382                ("minimum", Value::Number(0u64.into())),
1383            ]),
1384        ]),
1385    )]);
1386    let last_credit = obj_inline([(
1387        "oneOf",
1388        Value::Array(vec![
1389            obj_inline([
1390                ("type", Value::String("string".to_string())),
1391                ("pattern", Value::String("^-?[0-9]+$".to_string())),
1392            ]),
1393            obj_inline([("type", Value::String("integer".to_string()))]),
1394        ]),
1395    )]);
1396    let last_credit_at = obj_inline([
1397        ("type", Value::String("string".to_string())),
1398        ("format", Value::String("date-time".to_string())),
1399    ]);
1400    let mut properties = Map::new();
1401    properties.insert("environmentId".to_string(), env_id);
1402    properties.insert("resourceName".to_string(), resource_name);
1403    properties.insert("expectedUse".to_string(), expected_use);
1404    properties.insert("lastCredit".to_string(), last_credit);
1405    properties.insert("lastCreditAt".to_string(), last_credit_at);
1406    let mut m = Map::new();
1407    m.insert("type".to_string(), Value::String("object".to_string()));
1408    m.insert("properties".to_string(), Value::Object(properties));
1409    m.insert(
1410        "required".to_string(),
1411        Value::Array(vec![
1412            Value::String("environmentId".to_string()),
1413            Value::String("resourceName".to_string()),
1414            Value::String("expectedUse".to_string()),
1415            Value::String("lastCredit".to_string()),
1416            Value::String("lastCreditAt".to_string()),
1417        ]),
1418    );
1419    m.insert("additionalProperties".to_string(), Value::Bool(false));
1420    m
1421}
1422
1423fn permission_card_schema(_spec: &PermissionCardSpec) -> Map<String, Value> {
1424    // Permission-card values are opaque capability handles. The transported
1425    // snapshot carries card_id (authoritative), parent_ids, expires_at, and
1426    // polymorphic as trusted cache. See canonical/permission_card.rs.
1427    let card_id = obj_inline([
1428        ("type", Value::String("string".to_string())),
1429        ("format", Value::String("uuid".to_string())),
1430    ]);
1431    let parent_ids = obj_inline([
1432        ("type", Value::String("array".to_string())),
1433        (
1434            "items",
1435            obj_inline([
1436                ("type", Value::String("string".to_string())),
1437                ("format", Value::String("uuid".to_string())),
1438            ]),
1439        ),
1440    ]);
1441    let expires_at = obj_inline([
1442        ("type", Value::String("string".to_string())),
1443        ("format", Value::String("date-time".to_string())),
1444    ]);
1445    let polymorphic = obj_inline([("type", Value::String("boolean".to_string()))]);
1446    let mut properties = Map::new();
1447    properties.insert("cardId".to_string(), card_id);
1448    properties.insert("parentIds".to_string(), parent_ids);
1449    properties.insert("expiresAt".to_string(), expires_at);
1450    properties.insert("polymorphic".to_string(), polymorphic);
1451    let mut m = Map::new();
1452    m.insert("type".to_string(), Value::String("object".to_string()));
1453    m.insert("properties".to_string(), Value::Object(properties));
1454    m.insert(
1455        "required".to_string(),
1456        Value::Array(vec![
1457            Value::String("cardId".to_string()),
1458            Value::String("polymorphic".to_string()),
1459        ]),
1460    );
1461    m.insert("additionalProperties".to_string(), Value::Bool(false));
1462    m
1463}
1464
1465fn union_schema(
1466    graph: &SchemaGraph,
1467    spec: &UnionSpec,
1468    table: &BranchNameTable,
1469    config: JsonSchemaConfig,
1470) -> Map<String, Value> {
1471    // Each branch gets a per-branch reference into `$defs` (synthesised
1472    // by `add_union_branch_defs`), so the `oneOf` and any discriminator
1473    // mapping resolves against schemas the renderer actually emits. The
1474    // branch key is resolved through `BranchNameTable` so two unrelated
1475    // unions sharing a tag get disambiguated names.
1476    let one_of: Vec<Value> = spec
1477        .branches
1478        .iter()
1479        .map(|b| obj([("$ref", Value::String(ref_to_def_key(table.name_for(b))))]))
1480        .collect();
1481    let mut m = Map::new();
1482    m.insert("oneOf".to_string(), Value::Array(one_of));
1483    if let Some(disc_field) = openapi_discriminator(spec) {
1484        let mut mapping = Map::new();
1485        for branch in spec.branches.iter() {
1486            let literal = match &branch.discriminator {
1487                DiscriminatorRule::FieldEquals(disc) => disc.literal.clone(),
1488                _ => None,
1489            };
1490            if let Some(lit) = literal {
1491                mapping.insert(lit, Value::String(ref_to_def_key(table.name_for(branch))));
1492            }
1493        }
1494        let mut d = Map::new();
1495        d.insert("propertyName".to_string(), Value::String(disc_field));
1496        if !mapping.is_empty() {
1497            d.insert("mapping".to_string(), Value::Object(mapping));
1498        }
1499        m.insert("discriminator".to_string(), Value::Object(d));
1500    }
1501    let _ = graph;
1502    let _ = config;
1503    m
1504}
1505
1506fn openapi_discriminator(spec: &UnionSpec) -> Option<String> {
1507    let mut field: Option<String> = None;
1508    for branch in spec.branches.iter() {
1509        match &branch.discriminator {
1510            DiscriminatorRule::FieldEquals(disc) => {
1511                let _ = disc.literal.as_ref()?;
1512                match &field {
1513                    Some(prev) if prev != &disc.field_name => return None,
1514                    Some(_) => {}
1515                    None => field = Some(disc.field_name.clone()),
1516                }
1517            }
1518            _ => return None,
1519        }
1520    }
1521    field
1522}
1523
1524fn attach_metadata(target: &mut Value, metadata: &MetadataEnvelope) {
1525    if metadata.is_empty() {
1526        return;
1527    }
1528    let Some(obj) = target.as_object_mut() else {
1529        return;
1530    };
1531    if let Some(doc) = &metadata.doc {
1532        obj.entry("description")
1533            .or_insert(Value::String(doc.clone()));
1534    }
1535    if !metadata.examples.is_empty() {
1536        obj.entry("examples").or_insert_with(|| {
1537            Value::Array(
1538                metadata
1539                    .examples
1540                    .iter()
1541                    .map(|e| Value::String(e.clone()))
1542                    .collect(),
1543            )
1544        });
1545    }
1546    if let Some(dep) = &metadata.deprecated {
1547        obj.entry("deprecated").or_insert(Value::Bool(true));
1548        obj.entry("x-golem-deprecation-note")
1549            .or_insert(Value::String(dep.clone()));
1550    }
1551}
1552
1553/// Number of base64url-no-pad characters required to encode `n` raw bytes.
1554/// Matches the alphabet used by [`crate::schema::canonical::binary`] which
1555/// uses `base64::engine::general_purpose::URL_SAFE_NO_PAD`.
1556fn base64url_no_pad_len(n: u32) -> u64 {
1557    let n = n as u64;
1558    4 * (n / 3)
1559        + match n % 3 {
1560            0 => 0,
1561            1 => 2,
1562            2 => 3,
1563            _ => unreachable!(),
1564        }
1565}
1566
1567/// Escape a string for use as a single JSON-Pointer token: `~` becomes `~0`
1568/// and `/` becomes `~1` per RFC 6901.
1569fn escape_pointer_token(s: &str) -> String {
1570    let mut out = String::with_capacity(s.len());
1571    for ch in s.chars() {
1572        match ch {
1573            '~' => out.push_str("~0"),
1574            '/' => out.push_str("~1"),
1575            other => out.push(other),
1576        }
1577    }
1578    out
1579}
1580
1581/// Escape a string for inclusion as a literal in a basic regex pattern.
1582fn regex_escape(s: &str) -> String {
1583    let specials: &[char] = &[
1584        '\\', '^', '$', '.', '|', '?', '*', '+', '(', ')', '[', ']', '{', '}',
1585    ];
1586    let mut out = String::with_capacity(s.len());
1587    for ch in s.chars() {
1588        if specials.contains(&ch) {
1589            out.push('\\');
1590        }
1591        out.push(ch);
1592    }
1593    out
1594}
1595
1596fn obj<I: IntoIterator<Item = (&'static str, Value)>>(entries: I) -> Value {
1597    let mut map = Map::new();
1598    for (k, v) in entries {
1599        map.insert(k.to_string(), v);
1600    }
1601    Value::Object(map)
1602}
1603
1604fn obj_inline<I: IntoIterator<Item = (&'static str, Value)>>(entries: I) -> Value {
1605    obj(entries)
1606}