Skip to main content

miden_note_schema/
schema.rs

1//! Resolved note storage schema model.
2
3use std::{
4    collections::{HashMap, HashSet},
5    sync::Arc,
6};
7
8use miden_mast_package::Package;
9use miden_protocol::MAX_NOTE_STORAGE_ITEMS;
10use midenc_frontend_wasm_metadata::{
11    PACKAGE_NOTE_STORAGE_SCHEMA_SECTION_ID, package_note_storage_schema_section_id,
12    trim_trailing_nuls,
13};
14use wit_parser::{Resolve, Type, TypeDefKind, TypeId, TypeOwner};
15
16use crate::{
17    CodecRegistry, DecodedValue, Error, NoteStorage, NoteStorageBuilder, Result, StandardLeaf,
18    codec::FELT_FQN,
19};
20
21/// Maximum bytes accepted in an embedded note storage schema section, including alignment padding.
22///
23/// The budget allows 64 bytes of schema description per protocol note-storage item. It is derived
24/// from [`MAX_NOTE_STORAGE_ITEMS`] so schema parsing remains bounded with the protocol surface.
25pub const MAX_NOTE_STORAGE_SCHEMA_BYTES: usize = MAX_NOTE_STORAGE_ITEMS * 64;
26
27/// Maximum number of resolved WIT type definitions in a note storage schema.
28pub const MAX_NOTE_STORAGE_SCHEMA_TYPES: usize = MAX_NOTE_STORAGE_ITEMS;
29
30/// Maximum structural nesting depth accepted while resolving a note storage schema.
31///
32/// Recursion is capped at one eighth of the protocol note-storage item limit, which leaves ample
33/// room for legitimate models without allowing an attacker-controlled parser stack to grow to the
34/// full storage width.
35pub const MAX_NOTE_STORAGE_SCHEMA_DEPTH: usize = MAX_NOTE_STORAGE_ITEMS / 8;
36
37/// Maximum number of felts in the root note storage layout.
38pub const MAX_NOTE_STORAGE_SCHEMA_FELTS: usize = MAX_NOTE_STORAGE_ITEMS;
39
40/// Maximum number of nodes in the expanded note storage schema tree.
41///
42/// The resolved model is a DAG, but every structural walk over it expands that DAG into a tree:
43/// decoding, builder validation, and Rust code generation all visit a shared type once per
44/// reference. A schema of zero-felt records that names one type per level stays below the byte,
45/// type, depth, and felt limits while the expanded tree doubles at each level, so this budget
46/// bounds the expanded tree directly. It allows four expanded nodes per protocol note-storage
47/// item, which is far above any practical model.
48pub const MAX_NOTE_STORAGE_SCHEMA_NODES: usize = MAX_NOTE_STORAGE_ITEMS * 4;
49
50/// Default maximum bytes accepted for one note codec component before Wasmtime compilation.
51///
52/// This is the producer's cap and the default of `CodecLimits::max_component_bytes`, the
53/// consumer-side policy struct behind the `codec-component` feature, so a package that builds is
54/// a package that consumers accept. A host may tighten its own consumer cap.
55pub const MAX_NOTE_CODEC_COMPONENT_BYTES: usize = 4 * 1024 * 1024;
56
57/// Rustflags the nested codec build pins, so a codec crate's own cargo config cannot enable
58/// `simd128` in the guest. Mirrors the VM event-handler plugin.
59///
60/// The pin covers one target feature only. The full policy is
61/// [`NOTE_CODEC_WASM_FEATURES`](crate::NOTE_CODEC_WASM_FEATURES), which the producer enforces at
62/// build time and every consumer enforces at load time.
63pub const NOTE_CODEC_GUEST_RUSTFLAGS: &str = "-C target-feature=-simd128";
64
65const _: () = assert!(MAX_NOTE_STORAGE_SCHEMA_DEPTH > 0);
66
67/// The minimum and maximum felt count for a schema type.
68#[derive(Clone, Copy, Debug, Eq, PartialEq)]
69pub struct FeltLayout {
70    minimum: usize,
71    maximum: usize,
72}
73
74impl FeltLayout {
75    /// Returns the minimum number of felts accepted by this layout.
76    pub const fn minimum(self) -> usize {
77        self.minimum
78    }
79
80    /// Returns the maximum number of felts accepted by this layout.
81    pub const fn maximum(self) -> usize {
82        self.maximum
83    }
84
85    /// Returns the fixed width, or `None` for a variable-width layout.
86    pub const fn fixed_width(self) -> Option<usize> {
87        if self.minimum == self.maximum {
88            Some(self.minimum)
89        } else {
90            None
91        }
92    }
93
94    /// Creates a fixed-width layout.
95    const fn fixed(width: usize) -> Self {
96        Self {
97            minimum: width,
98            maximum: width,
99        }
100    }
101
102    /// Adds two layouts in declaration order.
103    fn concatenate(self, other: Self) -> Result<Self> {
104        let minimum = self
105            .minimum
106            .checked_add(other.minimum)
107            .ok_or_else(|| Error::new("note storage layout minimum width is too large"))?;
108        let maximum = self
109            .maximum
110            .checked_add(other.maximum)
111            .ok_or_else(|| Error::new("note storage layout maximum width is too large"))?;
112        Self::bounded(minimum, maximum)
113    }
114
115    /// Creates a layout within the protocol note-storage width.
116    ///
117    /// Every composed layout passes through this function, so no resolved type, and no resolved
118    /// root, is wider than the protocol allows.
119    fn bounded(minimum: usize, maximum: usize) -> Result<Self> {
120        if maximum > MAX_NOTE_STORAGE_SCHEMA_FELTS {
121            return Err(Error::new(format!(
122                "note storage schema layout has maximum width {maximum} felts; the protocol limit \
123                 is {MAX_NOTE_STORAGE_SCHEMA_FELTS}"
124            )));
125        }
126        Ok(Self { minimum, maximum })
127    }
128}
129
130/// A supported primitive WIT type.
131#[derive(Clone, Copy, Debug, Eq, PartialEq)]
132pub enum PrimitiveType {
133    /// An unsigned 64-bit integer stored as low and high `u32` limbs.
134    U64,
135    /// An unsigned 32-bit integer stored in one felt.
136    U32,
137    /// An unsigned 8-bit integer stored in one felt.
138    U8,
139    /// A boolean stored as zero or one.
140    Bool,
141}
142
143/// The structural kind of a resolved schema type.
144#[derive(Clone, Debug, Eq, PartialEq)]
145pub enum SchemaTypeKind {
146    /// The one-felt `miden:base/core-types.felt` bedrock type.
147    Felt,
148    /// A supported WIT primitive.
149    Primitive(PrimitiveType),
150    /// A record with fields in declaration order.
151    Record(Vec<SchemaField>),
152    /// An optional payload stored after a tag felt.
153    Option(Arc<SchemaType>),
154    /// A variant with declaration-ordinal cases.
155    Variant(Vec<SchemaCase>),
156}
157
158/// A resolved WIT type used by note storage.
159#[derive(Clone, Debug, Eq, PartialEq)]
160pub struct SchemaType {
161    name: Option<String>,
162    fqn: Option<String>,
163    docs: Option<String>,
164    kind: SchemaTypeKind,
165    layout: FeltLayout,
166}
167
168impl SchemaType {
169    /// Returns the WIT type name when this is a named type.
170    pub fn name(&self) -> Option<&str> {
171        self.name.as_deref()
172    }
173
174    /// Returns the canonical fully-qualified WIT type name.
175    pub fn fqn(&self) -> Option<&str> {
176        self.fqn.as_deref()
177    }
178
179    /// Returns the resolved WIT documentation.
180    pub fn docs(&self) -> Option<&str> {
181        self.docs.as_deref()
182    }
183
184    /// Returns the structural type kind.
185    pub const fn kind(&self) -> &SchemaTypeKind {
186        &self.kind
187    }
188
189    /// Returns the felt layout for this type.
190    pub const fn layout(&self) -> FeltLayout {
191        self.layout
192    }
193
194    /// Classifies this type as a standard protocol leaf.
195    pub fn standard_leaf(&self) -> Option<StandardLeaf> {
196        self.fqn.as_deref().and_then(StandardLeaf::from_fqn)
197    }
198}
199
200/// A named record field in declaration order.
201#[derive(Clone, Debug, Eq, PartialEq)]
202pub struct SchemaField {
203    name: String,
204    docs: Option<String>,
205    ty: Arc<SchemaType>,
206}
207
208impl SchemaField {
209    /// Returns the field's kebab-case WIT name.
210    pub fn name(&self) -> &str {
211        &self.name
212    }
213
214    /// Returns the field-level WIT documentation.
215    pub fn docs(&self) -> Option<&str> {
216        self.docs.as_deref()
217    }
218
219    /// Returns the field type.
220    pub fn ty(&self) -> &SchemaType {
221        self.ty.as_ref()
222    }
223}
224
225/// A WIT variant case in declaration order.
226#[derive(Clone, Debug, Eq, PartialEq)]
227pub struct SchemaCase {
228    name: String,
229    docs: Option<String>,
230    payload: Option<Arc<SchemaType>>,
231}
232
233impl SchemaCase {
234    /// Returns the case's kebab-case WIT name.
235    pub fn name(&self) -> &str {
236        &self.name
237    }
238
239    /// Returns the case documentation.
240    pub fn docs(&self) -> Option<&str> {
241        self.docs.as_deref()
242    }
243
244    /// Returns the optional case payload.
245    pub fn payload(&self) -> Option<&SchemaType> {
246        self.payload.as_deref()
247    }
248}
249
250/// A resolved note storage schema with the standard codec registry.
251#[derive(Clone)]
252pub struct NoteStorageSchema {
253    wit_text: String,
254    root: Arc<SchemaType>,
255    codecs: CodecRegistry,
256}
257
258impl NoteStorageSchema {
259    /// Reads and resolves the note storage schema section from a Miden package.
260    pub fn from_package(package: &Package) -> Result<Self> {
261        let bytes = crate::section::unique_package_section(
262            package,
263            package_note_storage_schema_section_id(),
264            PACKAGE_NOTE_STORAGE_SCHEMA_SECTION_ID,
265        )?;
266        ensure_schema_byte_limit(bytes.len())?;
267        let text = core::str::from_utf8(trim_trailing_nuls(bytes)).map_err(|err| {
268            Error::new(format!("note storage schema section is not valid UTF-8: {err}"))
269        })?;
270        Self::from_wit_text(text)
271    }
272
273    /// Resolves a note storage schema from a WIT document.
274    pub fn from_wit_text(wit_text: &str) -> Result<Self> {
275        ensure_schema_byte_limit(wit_text.len())?;
276        let wit_text = wit_text.trim_end_matches('\0');
277        let mut resolve = Resolve::default();
278        let package_id = resolve.push_str("note-storage-schema.wit", wit_text).map_err(|err| {
279            Error::new(format!("failed to resolve note storage schema WIT: {err:#}"))
280        })?;
281        if resolve.types.len() > MAX_NOTE_STORAGE_SCHEMA_TYPES {
282            return Err(Error::new(format!(
283                "note storage schema defines {} WIT types; the limit is \
284                 {MAX_NOTE_STORAGE_SCHEMA_TYPES}",
285                resolve.types.len()
286            )));
287        }
288        let package = &resolve.packages[package_id];
289        let interface_id = package.interfaces.get("note-storage").copied().ok_or_else(|| {
290            Error::new(format!(
291                "schema package `{}` does not define the `note-storage` interface",
292                package.name
293            ))
294        })?;
295        let interface = &resolve.interfaces[interface_id];
296        let storage_id = interface.types.get("storage").copied().ok_or_else(|| {
297            Error::new("the `note-storage` interface does not define the `storage` type alias")
298        })?;
299        validate_resolved_core_types(&resolve)?;
300        let root = ModelBuilder::new(&resolve).build(Type::Id(storage_id))?;
301        if !matches!(root.kind(), SchemaTypeKind::Record(_)) {
302            return Err(Error::new(format!(
303                "the `note-storage.storage` alias must resolve to a record, found {}",
304                kind_name(root.kind())
305            )));
306        }
307        let schema = Self {
308            wit_text: wit_text.to_owned(),
309            root,
310            codecs: CodecRegistry::default(),
311        };
312        schema.validate_native_leaf_shapes()?;
313        Ok(schema)
314    }
315
316    /// Returns the unpadded WIT document.
317    pub fn wit_text(&self) -> &str {
318        &self.wit_text
319    }
320
321    /// Returns the root storage record.
322    pub fn root(&self) -> &SchemaType {
323        self.root.as_ref()
324    }
325
326    /// Verifies native host mappings against the pinned standard type shapes.
327    pub fn validate_native_leaf_shapes(&self) -> Result<()> {
328        validate_model_type_shapes(&self.root, &mut HashSet::new())
329    }
330
331    /// Returns all custom named types reachable from the storage root.
332    #[cfg(feature = "codec-component")]
333    pub(crate) fn custom_type_fqns(&self) -> HashSet<String> {
334        let mut fqns = HashSet::new();
335        collect_custom_type_fqns(&self.root, &mut HashSet::new(), &mut fqns);
336        fqns
337    }
338
339    /// Returns the root felt layout.
340    pub fn layout(&self) -> FeltLayout {
341        self.root.layout
342    }
343
344    /// Returns the schema's standard codec registry.
345    pub const fn codecs(&self) -> &CodecRegistry {
346        &self.codecs
347    }
348
349    /// Replaces the codec registry used by `builder` and `decode`.
350    pub fn with_codec_registry(mut self, codecs: CodecRegistry) -> Self {
351        self.codecs = codecs;
352        self
353    }
354
355    /// Creates a string-value builder with the schema's codec registry.
356    pub fn builder(&self) -> NoteStorageBuilder<'_> {
357        self.builder_with_registry(&self.codecs)
358    }
359
360    /// Creates a string-value builder with a caller-provided codec registry.
361    pub fn builder_with_registry<'a>(
362        &'a self,
363        registry: &'a CodecRegistry,
364    ) -> NoteStorageBuilder<'a> {
365        NoteStorageBuilder::new(self, registry)
366    }
367
368    /// Decodes note storage with the schema's codec registry.
369    pub fn decode(&self, storage: &NoteStorage) -> Result<DecodedValue> {
370        self.decode_with_registry(storage, &self.codecs)
371    }
372
373    /// Decodes note storage with a caller-provided codec registry.
374    pub fn decode_with_registry(
375        &self,
376        storage: &NoteStorage,
377        registry: &CodecRegistry,
378    ) -> Result<DecodedValue> {
379        crate::value::decode(&self.root, storage, registry)
380    }
381}
382
383/// Enforces the parser input budget before WIT resolution or component work begins.
384fn ensure_schema_byte_limit(byte_len: usize) -> Result<()> {
385    if byte_len > MAX_NOTE_STORAGE_SCHEMA_BYTES {
386        return Err(Error::new(format!(
387            "note storage schema section is {byte_len} bytes; the limit is \
388             {MAX_NOTE_STORAGE_SCHEMA_BYTES}"
389        )));
390    }
391    Ok(())
392}
393
394/// Enforces the expanded-tree budget on one resolved schema type.
395///
396/// The builder memoizes shared types, so resolution stays linear. Every consumer of the model
397/// walks it as a tree, so the budget is applied to the expanded node count of each type as it is
398/// resolved. The check therefore protects decoding, builder validation, and code generation.
399fn ensure_expanded_node_limit(ty: &SchemaType, expanded_nodes: usize) -> Result<()> {
400    let limit = MAX_NOTE_STORAGE_SCHEMA_NODES;
401    if expanded_nodes > limit {
402        let name = ty.fqn().or_else(|| ty.name()).unwrap_or("<anonymous>");
403        return Err(Error::new(format!(
404            "note storage type `{name}` expands to {expanded_nodes} nodes; the limit is {limit}"
405        )));
406    }
407    Ok(())
408}
409
410/// Verifies the raw embedded core-types definitions before the model applies native mappings.
411fn validate_resolved_core_types(resolve: &Resolve) -> Result<()> {
412    let Some((_, package_id)) = resolve.package_names.iter().find(|(name, _)| {
413        name.namespace == "miden"
414            && name.name == "base"
415            && name.version.as_ref().is_some_and(|version| version.to_string() == "1.0.0")
416    }) else {
417        return Ok(());
418    };
419    let package = &resolve.packages[*package_id];
420    let Some(interface_id) = package.interfaces.get("core-types").copied() else {
421        return Ok(());
422    };
423    let interface = &resolve.interfaces[interface_id];
424
425    for leaf in StandardLeaf::ALL {
426        let name = standard_leaf_name(leaf);
427        let fields = standard_leaf_fields(leaf);
428        let Some(type_id) = interface.types.get(name).copied() else {
429            continue;
430        };
431        let type_id = follow_resolved_aliases(resolve, type_id)?;
432        let TypeDefKind::Record(record) = &resolve.types[type_id].kind else {
433            return Err(core_shape_error(name, fields));
434        };
435        if record.fields.len() != fields.len()
436            || record
437                .fields
438                .iter()
439                .zip(fields)
440                .any(|(field, expected)| field.name != *expected)
441        {
442            return Err(core_shape_error(name, fields));
443        }
444        if leaf == StandardLeaf::Felt {
445            // Miden's canonical felt uses `f32` as its WIT-level placeholder representation.
446            if !resolves_to_primitive(resolve, record.fields[0].ty, Type::F32)? {
447                return Err(core_shape_error(name, fields));
448            }
449        } else {
450            for field in &record.fields {
451                if !resolves_to_fqn(resolve, field.ty, crate::FELT_FQN)? {
452                    return Err(core_shape_error(name, fields));
453                }
454            }
455        }
456    }
457    Ok(())
458}
459
460/// Follows raw WIT aliases to their structural definition.
461fn follow_resolved_aliases(resolve: &Resolve, mut id: TypeId) -> Result<TypeId> {
462    let mut visited = HashSet::new();
463    loop {
464        if !visited.insert(id) {
465            return Err(Error::new("cyclic WIT type aliases are not supported"));
466        }
467        match resolve.types[id].kind {
468            TypeDefKind::Type(Type::Id(next)) => id = next,
469            _ => return Ok(id),
470        }
471    }
472}
473
474/// Returns true when a raw WIT type resolves to one primitive.
475fn resolves_to_primitive(resolve: &Resolve, mut ty: Type, expected: Type) -> Result<bool> {
476    let mut visited = HashSet::new();
477    loop {
478        match ty {
479            Type::Id(id) => {
480                if !visited.insert(id) {
481                    return Err(Error::new("cyclic WIT type aliases are not supported"));
482                }
483                let TypeDefKind::Type(next) = resolve.types[id].kind else {
484                    return Ok(false);
485                };
486                ty = next;
487            }
488            primitive => return Ok(primitive == expected),
489        }
490    }
491}
492
493/// Returns true when a raw WIT type resolves to one canonical FQN.
494fn resolves_to_fqn(resolve: &Resolve, ty: Type, expected: &str) -> Result<bool> {
495    let Type::Id(id) = ty else {
496        return Ok(false);
497    };
498    let id = follow_resolved_aliases(resolve, id)?;
499    Ok(ModelBuilder::new(resolve).type_fqn(id)?.as_deref() == Some(expected))
500}
501
502/// Verifies mapped type shapes in the owned schema model.
503fn validate_model_type_shapes(ty: &SchemaType, seen: &mut HashSet<String>) -> Result<()> {
504    if let Some(fqn) = ty.fqn()
505        && !seen.insert(fqn.to_owned())
506    {
507        return Ok(());
508    }
509
510    match ty.standard_leaf() {
511        Some(StandardLeaf::Felt) if !matches!(ty.kind(), SchemaTypeKind::Felt) => {
512            return Err(core_shape_error(
513                standard_leaf_name(StandardLeaf::Felt),
514                standard_leaf_fields(StandardLeaf::Felt),
515            ));
516        }
517        Some(StandardLeaf::Felt) => {}
518        Some(leaf) => {
519            validate_model_record(
520                ty,
521                standard_leaf_name(leaf),
522                standard_leaf_fields(leaf),
523                crate::FELT_FQN,
524            )?;
525        }
526        None => {}
527    }
528
529    match ty.kind() {
530        SchemaTypeKind::Record(fields) => {
531            for field in fields {
532                validate_model_type_shapes(field.ty(), seen)?;
533            }
534        }
535        SchemaTypeKind::Option(payload) => validate_model_type_shapes(payload, seen)?,
536        SchemaTypeKind::Variant(cases) => {
537            for payload in cases.iter().filter_map(SchemaCase::payload) {
538                validate_model_type_shapes(payload, seen)?;
539            }
540        }
541        SchemaTypeKind::Felt | SchemaTypeKind::Primitive(_) => {}
542    }
543    Ok(())
544}
545
546/// Returns the terminal WIT name from a canonical standard-leaf FQN.
547fn standard_leaf_name(leaf: StandardLeaf) -> &'static str {
548    leaf.fqn()
549        .rsplit_once('.')
550        .expect("standard-leaf FQNs always contain an interface separator")
551        .1
552}
553
554/// Returns the canonical record field order for a standard leaf.
555fn standard_leaf_fields(leaf: StandardLeaf) -> &'static [&'static str] {
556    match leaf {
557        StandardLeaf::Felt | StandardLeaf::AssetAmount => &["inner"],
558        StandardLeaf::Word => &["a", "b", "c", "d"],
559        StandardLeaf::AccountId => &["prefix", "suffix"],
560    }
561}
562
563/// Verifies one mapped record in the owned schema model.
564fn validate_model_record(
565    ty: &SchemaType,
566    name: &str,
567    expected_fields: &[&str],
568    expected_field_fqn: &str,
569) -> Result<()> {
570    let SchemaTypeKind::Record(fields) = ty.kind() else {
571        return Err(core_shape_error(name, expected_fields));
572    };
573    if fields.len() != expected_fields.len()
574        || fields.iter().zip(expected_fields).any(|(field, expected)| {
575            field.name() != *expected || field.ty().fqn() != Some(expected_field_fqn)
576        })
577    {
578        return Err(core_shape_error(name, expected_fields));
579    }
580    Ok(())
581}
582
583/// Creates the canonical core-type shape diagnostic.
584fn core_shape_error(name: &str, fields: &[&str]) -> Error {
585    let field_shape = if name == "felt" {
586        "inner: f32".to_owned()
587    } else {
588        fields
589            .iter()
590            .map(|field| format!("{field}: felt"))
591            .collect::<Vec<_>>()
592            .join(", ")
593    };
594    Error::new(format!(
595        "embedded WIT type `miden:base/core-types@1.0.0.{name}` does not match the pinned \
596         canonical shape `record {name} {{ {field_shape} }}`"
597    ))
598}
599
600/// Collects schema-owned types and excludes only the canonical standard leaves.
601#[cfg(feature = "codec-component")]
602fn collect_custom_type_fqns(
603    ty: &SchemaType,
604    seen: &mut HashSet<*const SchemaType>,
605    fqns: &mut HashSet<String>,
606) {
607    // Pointer identity is sufficient because ModelBuilder memoizes exactly one Arc per TypeId.
608    if !seen.insert(core::ptr::from_ref(ty)) {
609        return;
610    }
611    if let Some(fqn) = ty.fqn()
612        && ty.standard_leaf().is_none()
613    {
614        fqns.insert(fqn.to_owned());
615    }
616    match ty.kind() {
617        SchemaTypeKind::Record(fields) => {
618            for field in fields {
619                collect_custom_type_fqns(field.ty(), seen, fqns);
620            }
621        }
622        SchemaTypeKind::Option(payload) => collect_custom_type_fqns(payload, seen, fqns),
623        SchemaTypeKind::Variant(cases) => {
624            for payload in cases.iter().filter_map(SchemaCase::payload) {
625                collect_custom_type_fqns(payload, seen, fqns);
626            }
627        }
628        SchemaTypeKind::Felt | SchemaTypeKind::Primitive(_) => {}
629    }
630}
631
632/// One memoized schema node, its maximum depth, and the size of its expanded subtree.
633#[derive(Clone)]
634struct MemoizedSchemaType {
635    /// The resolved node.
636    ty: Arc<SchemaType>,
637    /// Levels of nesting below this node.
638    ///
639    /// A memoized node is reused at a deeper position than the one it was resolved at, so the
640    /// depth limit is checked again on every reuse with this value added to the new depth.
641    maximum_subtree_depth: usize,
642    /// Number of nodes a structural walk visits below and including this node.
643    expanded_nodes: usize,
644}
645
646/// Builds a memoized schema graph from a resolved WIT graph.
647struct ModelBuilder<'a> {
648    /// The resolved WIT document the schema comes from.
649    resolve: &'a Resolve,
650    /// The types the walk is inside, which reports a recursive type.
651    active: HashSet<TypeId>,
652    /// The nodes already resolved, keyed by WIT type.
653    memo: HashMap<TypeId, MemoizedSchemaType>,
654}
655
656impl<'a> ModelBuilder<'a> {
657    /// Creates a model builder for one schema package.
658    fn new(resolve: &'a Resolve) -> Self {
659        Self {
660            resolve,
661            active: HashSet::new(),
662            memo: HashMap::new(),
663        }
664    }
665
666    /// Resolves one WIT type.
667    fn build(mut self, ty: Type) -> Result<Arc<SchemaType>> {
668        self.build_type(ty, 0).map(|memoized| memoized.ty)
669    }
670
671    /// Resolves a primitive or named type.
672    fn build_type(&mut self, ty: Type, depth: usize) -> Result<MemoizedSchemaType> {
673        if depth > MAX_NOTE_STORAGE_SCHEMA_DEPTH {
674            return Err(Error::new(format!(
675                "note storage schema nesting depth {depth} exceeds the limit of \
676                 {MAX_NOTE_STORAGE_SCHEMA_DEPTH}"
677            )));
678        }
679        match ty {
680            Type::Id(id) => self.build_type_id(id, depth),
681            Type::U64 => self.primitive(PrimitiveType::U64, None, None, None),
682            Type::U32 => self.primitive(PrimitiveType::U32, None, None, None),
683            Type::U8 => self.primitive(PrimitiveType::U8, None, None, None),
684            Type::Bool => self.primitive(PrimitiveType::Bool, None, None, None),
685            unsupported => Err(Error::new(format!(
686                "WIT primitive `{unsupported:?}` is not supported in note storage schemas"
687            ))),
688        }
689    }
690
691    /// Resolves aliases to the type definition that owns the structural type.
692    fn build_type_id(&mut self, id: TypeId, depth: usize) -> Result<MemoizedSchemaType> {
693        let id = self.follow_aliases(id)?;
694        if let Some(memoized) = self.memo.get(&id) {
695            let maximum_depth = depth.saturating_add(memoized.maximum_subtree_depth);
696            if maximum_depth > MAX_NOTE_STORAGE_SCHEMA_DEPTH {
697                return Err(Error::new(format!(
698                    "note storage schema nesting depth {maximum_depth} exceeds the limit of \
699                     {MAX_NOTE_STORAGE_SCHEMA_DEPTH}"
700                )));
701            }
702            return Ok(memoized.clone());
703        }
704        if !self.active.insert(id) {
705            return Err(Error::new(
706                "recursive WIT types are not supported in note storage schemas",
707            ));
708        }
709
710        let definition = self.resolve.types[id].clone();
711        let name = definition.name.clone();
712        let docs = definition.docs.contents.clone();
713        let fqn = self.type_fqn(id)?;
714        let result = if fqn.as_deref() == Some(FELT_FQN) {
715            Ok(MemoizedSchemaType {
716                ty: Arc::new(SchemaType {
717                    name,
718                    fqn,
719                    docs,
720                    kind: SchemaTypeKind::Felt,
721                    layout: FeltLayout::fixed(1),
722                }),
723                maximum_subtree_depth: 0,
724                expanded_nodes: 1,
725            })
726        } else {
727            match definition.kind {
728                TypeDefKind::Type(ty) => self.build_named_alias(ty, name, fqn, docs),
729                TypeDefKind::Record(record) => {
730                    let mut fields = Vec::with_capacity(record.fields.len());
731                    let mut layout = FeltLayout::fixed(0);
732                    let mut maximum_subtree_depth = 0;
733                    let mut expanded_nodes = 1usize;
734                    for field in record.fields {
735                        let memoized = self.build_type(field.ty, depth + 1)?;
736                        maximum_subtree_depth =
737                            maximum_subtree_depth.max(1 + memoized.maximum_subtree_depth);
738                        expanded_nodes = expanded_nodes.saturating_add(memoized.expanded_nodes);
739                        layout = layout.concatenate(memoized.ty.layout)?;
740                        fields.push(SchemaField {
741                            name: field.name,
742                            docs: field.docs.contents,
743                            ty: memoized.ty,
744                        });
745                    }
746                    Ok(MemoizedSchemaType {
747                        ty: Arc::new(SchemaType {
748                            name,
749                            fqn,
750                            docs,
751                            kind: SchemaTypeKind::Record(fields),
752                            layout,
753                        }),
754                        maximum_subtree_depth,
755                        expanded_nodes,
756                    })
757                }
758                TypeDefKind::Option(payload) => {
759                    let payload = self.build_type(payload, depth + 1)?;
760                    let maximum = 1usize
761                        .checked_add(payload.ty.layout.maximum)
762                        .ok_or_else(|| Error::new("option layout maximum width is too large"))?;
763                    let layout = FeltLayout::bounded(1, maximum)?;
764                    Ok(MemoizedSchemaType {
765                        maximum_subtree_depth: 1 + payload.maximum_subtree_depth,
766                        expanded_nodes: payload.expanded_nodes.saturating_add(1),
767                        ty: Arc::new(SchemaType {
768                            name,
769                            fqn,
770                            docs,
771                            kind: SchemaTypeKind::Option(payload.ty),
772                            layout,
773                        }),
774                    })
775                }
776                TypeDefKind::Variant(variant) => {
777                    let mut cases = Vec::with_capacity(variant.cases.len());
778                    let mut maximum_subtree_depth = 0;
779                    let mut expanded_nodes = 1usize;
780                    for case in variant.cases {
781                        let payload = match case.ty {
782                            Some(ty) => {
783                                let memoized = self.build_type(ty, depth + 1)?;
784                                maximum_subtree_depth =
785                                    maximum_subtree_depth.max(1 + memoized.maximum_subtree_depth);
786                                expanded_nodes =
787                                    expanded_nodes.saturating_add(memoized.expanded_nodes);
788                                Some(memoized.ty)
789                            }
790                            None => None,
791                        };
792                        cases.push(SchemaCase {
793                            name: case.name,
794                            docs: case.docs.contents,
795                            payload,
796                        });
797                    }
798                    let layout = variant_layout(&cases)?;
799                    Ok(MemoizedSchemaType {
800                        ty: Arc::new(SchemaType {
801                            name,
802                            fqn,
803                            docs,
804                            kind: SchemaTypeKind::Variant(cases),
805                            layout,
806                        }),
807                        maximum_subtree_depth,
808                        expanded_nodes,
809                    })
810                }
811                TypeDefKind::Enum(enum_) => {
812                    let cases = enum_
813                        .cases
814                        .into_iter()
815                        .map(|case| SchemaCase {
816                            name: case.name,
817                            docs: case.docs.contents,
818                            payload: None,
819                        })
820                        .collect::<Vec<_>>();
821                    let layout = variant_layout(&cases)?;
822                    Ok(MemoizedSchemaType {
823                        ty: Arc::new(SchemaType {
824                            name,
825                            fqn,
826                            docs,
827                            kind: SchemaTypeKind::Variant(cases),
828                            layout,
829                        }),
830                        maximum_subtree_depth: 0,
831                        expanded_nodes: 1,
832                    })
833                }
834                unsupported => Err(Error::new(format!(
835                    "WIT {} `{}` is not supported in note storage schemas",
836                    unsupported.as_str(),
837                    fqn.as_deref().or(name.as_deref()).unwrap_or("<anonymous>")
838                ))),
839            }
840        };
841        self.active.remove(&id);
842        if let Ok(memoized) = &result {
843            ensure_expanded_node_limit(&memoized.ty, memoized.expanded_nodes)?;
844            self.memo.insert(id, memoized.clone());
845        }
846        result
847    }
848
849    /// Resolves a primitive alias while preserving its name, FQN, and documentation.
850    ///
851    /// [`Self::build_type_id`] follows ID aliases before dispatching here.
852    fn build_named_alias(
853        &mut self,
854        ty: Type,
855        name: Option<String>,
856        fqn: Option<String>,
857        docs: Option<String>,
858    ) -> Result<MemoizedSchemaType> {
859        match ty {
860            Type::Id(_) => unreachable!("build_type_id must follow ID aliases first"),
861            Type::U64 => self.primitive(PrimitiveType::U64, name, fqn, docs),
862            Type::U32 => self.primitive(PrimitiveType::U32, name, fqn, docs),
863            Type::U8 => self.primitive(PrimitiveType::U8, name, fqn, docs),
864            Type::Bool => self.primitive(PrimitiveType::Bool, name, fqn, docs),
865            unsupported => Err(Error::new(format!(
866                "WIT primitive alias `{unsupported:?}` is not supported in note storage schemas"
867            ))),
868        }
869    }
870
871    /// Creates a supported primitive type.
872    fn primitive(
873        &self,
874        primitive: PrimitiveType,
875        name: Option<String>,
876        fqn: Option<String>,
877        docs: Option<String>,
878    ) -> Result<MemoizedSchemaType> {
879        let width = match primitive {
880            PrimitiveType::U64 => 2,
881            PrimitiveType::U32 | PrimitiveType::U8 | PrimitiveType::Bool => 1,
882        };
883        Ok(MemoizedSchemaType {
884            ty: Arc::new(SchemaType {
885                name,
886                fqn,
887                docs,
888                kind: SchemaTypeKind::Primitive(primitive),
889                layout: FeltLayout::fixed(width),
890            }),
891            maximum_subtree_depth: 0,
892            expanded_nodes: 1,
893        })
894    }
895
896    /// Follows `type = id` aliases to their defining type.
897    fn follow_aliases(&self, mut id: TypeId) -> Result<TypeId> {
898        let mut visited = HashSet::new();
899        loop {
900            if !visited.insert(id) {
901                return Err(Error::new("cyclic WIT type aliases are not supported"));
902            }
903            match self.resolve.types[id].kind {
904                TypeDefKind::Type(Type::Id(next)) => id = next,
905                _ => return Ok(id),
906            }
907        }
908    }
909
910    /// Reconstructs the canonical FQN for a named interface type.
911    fn type_fqn(&self, id: TypeId) -> Result<Option<String>> {
912        let definition = &self.resolve.types[id];
913        let Some(type_name) = definition.name.as_deref() else {
914            return Ok(None);
915        };
916        let TypeOwner::Interface(interface_id) = definition.owner else {
917            return Err(Error::new(format!(
918                "named WIT type `{type_name}` is not owned by an interface"
919            )));
920        };
921        let interface = &self.resolve.interfaces[interface_id];
922        let interface_name = interface.name.as_deref().ok_or_else(|| {
923            Error::new(format!("type `{type_name}` belongs to an unnamed interface"))
924        })?;
925        let package_id = interface.package.ok_or_else(|| {
926            Error::new(format!("interface `{interface_name}` does not belong to a package"))
927        })?;
928        let package_name = &self.resolve.packages[package_id].name;
929        let mut fqn =
930            format!("{}:{}/{}", package_name.namespace, package_name.name, interface_name);
931        if let Some(version) = &package_name.version {
932            fqn.push('@');
933            fqn.push_str(&version.to_string());
934        }
935        fqn.push('.');
936        fqn.push_str(type_name);
937        Ok(Some(fqn))
938    }
939}
940
941/// Returns a variable layout for declaration-ordinal cases.
942fn variant_layout(cases: &[SchemaCase]) -> Result<FeltLayout> {
943    if cases.is_empty() {
944        return Err(Error::new("a note storage variant must define at least one case"));
945    }
946    let minimum_payload = cases
947        .iter()
948        .map(|case| case.payload.as_ref().map_or(0, |ty| ty.layout.minimum))
949        .min()
950        .unwrap_or(0);
951    let maximum_payload = cases
952        .iter()
953        .map(|case| case.payload.as_ref().map_or(0, |ty| ty.layout.maximum))
954        .max()
955        .unwrap_or(0);
956    let minimum = 1usize
957        .checked_add(minimum_payload)
958        .ok_or_else(|| Error::new("variant layout minimum width is too large"))?;
959    let maximum = 1usize
960        .checked_add(maximum_payload)
961        .ok_or_else(|| Error::new("variant layout maximum width is too large"))?;
962    FeltLayout::bounded(minimum, maximum)
963}
964
965/// Returns a stable name for a model kind.
966fn kind_name(kind: &SchemaTypeKind) -> &'static str {
967    match kind {
968        SchemaTypeKind::Felt => "felt",
969        SchemaTypeKind::Primitive(_) => "primitive",
970        SchemaTypeKind::Record(_) => "record",
971        SchemaTypeKind::Option(_) => "option",
972        SchemaTypeKind::Variant(_) => "variant",
973    }
974}
975
976/// Normalizes one WIT path segment from snake case to kebab case.
977pub(crate) fn normalize_name(name: &str) -> String {
978    name.trim().replace('_', "-")
979}