Skip to main content

active_infinity/
ir.rs

1//! Active Infinity's in-memory module model.
2//!
3//! This is the *canonical* IR: the encoder is a pure function of it, and the
4//! decoder reproduces it exactly. It deliberately does **not** mirror the
5//! `active-core` arena layout: the wire format is versioned independently of
6//! any dependency's semver (see ADR-0002). Interop with the text language IR
7//! lives in [`crate::interop`] behind the `active-core` feature.
8//!
9//! Structural invariants upheld by [`InfinityModule::validate_structure`]:
10//!
11//! - every referenced node id exists (no dangling references);
12//! - table fields, binding names and array nodes are unique where required;
13//! - the dependency graph is acyclic (Active rejects cycles before any value
14//!   exists);
15//! - nesting depth stays within the caller's budget.
16//!
17//! Secret material is held in [`SecretBytes`], which has **no** `Display`,
18//! `Debug`, `Serialize` or equality path that can reveal it.
19
20// The variant and field names in this module are the wire contract itself and
21// are documented normatively in `docs/spec/layout.md`; per-variant rustdoc would
22// duplicate that table without adding information.
23#![allow(missing_docs)]
24
25use std::collections::HashMap;
26use std::fmt;
27
28/// Index into the module arena.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
30pub struct NodeId(pub u32);
31
32impl NodeId {
33    /// Sentinel for "no node".
34    pub const NONE: NodeId = NodeId(u32::MAX);
35
36    pub fn index(self) -> usize {
37        self.0 as usize
38    }
39
40    pub fn is_none(self) -> bool {
41        self == NodeId::NONE
42    }
43}
44
45impl fmt::Display for NodeId {
46    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47        write!(f, "n{}", self.0)
48    }
49}
50
51/// A secret string held structurally.
52///
53/// The type exposes no way to read the bytes back through a public API: the
54/// wire codec and the `active-core` bridge are the only in-crate readers, and
55/// both are audited. `Debug`, `Display`, `PartialEq`, `Ord` and `Hash` are all
56/// deliberately absent or redacting, so a secret can never leak through
57/// logging, assertion output, sorting or map keys, and can never be compared
58/// for equality (the anti-oracle rule).
59#[derive(Clone)]
60pub struct SecretBytes(Vec<u8>);
61
62impl SecretBytes {
63    /// Wrap raw bytes as a secret.
64    pub fn new(bytes: impl Into<Vec<u8>>) -> Self {
65        SecretBytes(bytes.into())
66    }
67
68    /// Number of bytes — length is not secret material and is used by
69    /// budgets and diagnostics.
70    pub fn len(&self) -> usize {
71        self.0.len()
72    }
73
74    pub fn is_empty(&self) -> bool {
75        self.0.is_empty()
76    }
77
78    /// Read the raw bytes. Crate-internal: only the audited codec and the
79    /// `active-core` bridge call this. There is no public unlock API.
80    pub(crate) fn expose(&self) -> &[u8] {
81        &self.0
82    }
83}
84
85impl fmt::Debug for SecretBytes {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        f.write_str("Secret(***)")
88    }
89}
90
91impl fmt::Display for SecretBytes {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        f.write_str("***SECRET***")
94    }
95}
96
97impl PartialEq for SecretBytes {
98    /// Anti-oracle: a secret never equals anything, including itself.
99    fn eq(&self, _other: &Self) -> bool {
100        false
101    }
102}
103
104impl From<&str> for SecretBytes {
105    fn from(value: &str) -> Self {
106        SecretBytes(value.as_bytes().to_vec())
107    }
108}
109
110impl From<String> for SecretBytes {
111    fn from(value: String) -> Self {
112        SecretBytes(value.into_bytes())
113    }
114}
115
116/// Binary operators, with permanent wire codes.
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
118pub enum BinOp {
119    Add,
120    Sub,
121    Mul,
122    Div,
123    Eq,
124    Ne,
125    Lt,
126    Le,
127    Gt,
128    Ge,
129    And,
130    Or,
131}
132
133impl BinOp {
134    pub const fn code(self) -> u32 {
135        match self {
136            BinOp::Add => 0,
137            BinOp::Sub => 1,
138            BinOp::Mul => 2,
139            BinOp::Div => 3,
140            BinOp::Eq => 4,
141            BinOp::Ne => 5,
142            BinOp::Lt => 6,
143            BinOp::Le => 7,
144            BinOp::Gt => 8,
145            BinOp::Ge => 9,
146            BinOp::And => 10,
147            BinOp::Or => 11,
148        }
149    }
150
151    pub const fn from_code(code: u32) -> Option<Self> {
152        let op = match code {
153            0 => BinOp::Add,
154            1 => BinOp::Sub,
155            2 => BinOp::Mul,
156            3 => BinOp::Div,
157            4 => BinOp::Eq,
158            5 => BinOp::Ne,
159            6 => BinOp::Lt,
160            7 => BinOp::Le,
161            8 => BinOp::Gt,
162            9 => BinOp::Ge,
163            10 => BinOp::And,
164            11 => BinOp::Or,
165            _ => return None,
166        };
167        Some(op)
168    }
169
170    pub const fn as_str(self) -> &'static str {
171        match self {
172            BinOp::Add => "+",
173            BinOp::Sub => "-",
174            BinOp::Mul => "*",
175            BinOp::Div => "/",
176            BinOp::Eq => "==",
177            BinOp::Ne => "!=",
178            BinOp::Lt => "<",
179            BinOp::Le => "<=",
180            BinOp::Gt => ">",
181            BinOp::Ge => ">=",
182            BinOp::And => "and",
183            BinOp::Or => "or",
184        }
185    }
186}
187
188/// Unary operators, with permanent wire codes.
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
190pub enum UnOp {
191    Neg,
192    Not,
193}
194
195impl UnOp {
196    pub const fn code(self) -> u32 {
197        match self {
198            UnOp::Neg => 0,
199            UnOp::Not => 1,
200        }
201    }
202
203    pub const fn from_code(code: u32) -> Option<Self> {
204        match code {
205            0 => Some(UnOp::Neg),
206            1 => Some(UnOp::Not),
207            _ => None,
208        }
209    }
210
211    pub const fn as_str(self) -> &'static str {
212        match self {
213            UnOp::Neg => "-",
214            UnOp::Not => "!",
215        }
216    }
217}
218
219/// Semantic tag for a multi-line string block.
220#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
221pub enum SemanticTag {
222    Prompt,
223    Policy,
224    Template,
225}
226
227impl SemanticTag {
228    pub const fn code(self) -> u32 {
229        match self {
230            SemanticTag::Prompt => 0,
231            SemanticTag::Policy => 1,
232            SemanticTag::Template => 2,
233        }
234    }
235
236    pub const fn from_code(code: u32) -> Option<Self> {
237        match code {
238            0 => Some(SemanticTag::Prompt),
239            1 => Some(SemanticTag::Policy),
240            2 => Some(SemanticTag::Template),
241            _ => None,
242        }
243    }
244
245    pub const fn as_str(self) -> &'static str {
246        match self {
247            SemanticTag::Prompt => "prompt",
248            SemanticTag::Policy => "policy",
249            SemanticTag::Template => "template",
250        }
251    }
252}
253
254/// One interpolation part.
255#[derive(Debug, Clone, PartialEq)]
256pub enum InterpPart {
257    Literal(String),
258    Expr(NodeId),
259}
260
261/// A runtime constraint attached to a node (`@constraint` predicates and
262/// schema bounds).
263#[derive(Debug, Clone, PartialEq)]
264pub enum Constraint {
265    MinMax { min: Option<i64>, max: Option<i64> },
266    Enum(Vec<String>),
267    Pattern(String),
268    Odd,
269    Custom(String),
270}
271
272/// Declared value type of a schema field.
273#[derive(Debug, Clone, PartialEq)]
274pub enum TypeSpec {
275    Null,
276    Bool,
277    Int,
278    Float,
279    String,
280    Duration,
281    Bytes,
282    Table { closed: bool },
283    Enum(Vec<String>),
284    Secret,
285    Any,
286    Blob,
287}
288
289impl TypeSpec {
290    pub const fn code(&self) -> u32 {
291        match self {
292            TypeSpec::Null => 0,
293            TypeSpec::Bool => 1,
294            TypeSpec::Int => 2,
295            TypeSpec::Float => 3,
296            TypeSpec::String => 4,
297            TypeSpec::Duration => 5,
298            TypeSpec::Bytes => 6,
299            TypeSpec::Table { .. } => 7,
300            TypeSpec::Enum(_) => 8,
301            TypeSpec::Secret => 9,
302            TypeSpec::Any => 10,
303            TypeSpec::Blob => 11,
304        }
305    }
306
307    /// Human-readable name for diagnostics (mirrors `active-core`).
308    pub fn name(&self) -> String {
309        match self {
310            TypeSpec::Null => "Null".into(),
311            TypeSpec::Bool => "Bool".into(),
312            TypeSpec::Int => "Int".into(),
313            TypeSpec::Float => "Float".into(),
314            TypeSpec::String => "String".into(),
315            TypeSpec::Duration => "Duration".into(),
316            TypeSpec::Bytes => "Bytes".into(),
317            TypeSpec::Table { closed } => {
318                if *closed {
319                    "Table(closed)".into()
320                } else {
321                    "Table".into()
322                }
323            }
324            TypeSpec::Enum(values) => format!("Enum({})", values.join(", ")),
325            TypeSpec::Secret => "Secret".into(),
326            TypeSpec::Any => "Any".into(),
327            TypeSpec::Blob => "Blob".into(),
328        }
329    }
330}
331
332/// A structural patch operation.
333#[derive(Debug, Clone, PartialEq)]
334pub enum PatchOp {
335    Set { path: String, value: NodeId },
336    Remove { path: String },
337    Rename { from: String, to: String },
338}
339
340/// One schema field's constraints.
341#[derive(Debug, Clone, PartialEq)]
342pub struct SchemaFieldSpec {
343    pub typ: TypeSpec,
344    pub min: Option<NodeId>,
345    pub max: Option<NodeId>,
346    pub min_len: Option<NodeId>,
347    pub max_len: Option<NodeId>,
348    pub pattern: Option<NodeId>,
349    pub default: Option<NodeId>,
350    pub deprecated: Option<String>,
351}
352
353impl SchemaFieldSpec {
354    pub fn bare(typ: TypeSpec) -> Self {
355        Self {
356            typ,
357            min: None,
358            max: None,
359            min_len: None,
360            max_len: None,
361            pattern: None,
362            default: None,
363            deprecated: None,
364        }
365    }
366}
367
368/// A module node.
369///
370/// Every variant is a value of the Active IR after name resolution; `Ident`
371/// is retained only because decoded third-party modules may still contain
372/// one (the encoder never emits it for canonical modules produced from
373/// `active-core`).
374///
375/// `Debug` is written by hand (never derived): blob payloads are summarized
376/// by identity and secrets render as `Secret(***)`, so logging a module can
377/// never dump payload or secret bytes.
378#[derive(Clone, PartialEq)]
379pub enum Inode {
380    Null,
381    Bool(bool),
382    Int(i64),
383    Float(f64),
384    Str(String),
385    DurationMs(u64),
386    Quantity {
387        value: NodeId,
388        unit: super::UnitTag,
389    },
390    Array {
391        elements: Vec<NodeId>,
392    },
393    Table {
394        fields: Vec<(String, NodeId)>,
395    },
396    /// A structural secret literal. Never equals anything.
397    Secret(SecretBytes),
398    /// A secret derived from a locator node (`secret.env(NAME)`,
399    /// `secret(<expr>)`).
400    SecretRef {
401        locator: NodeId,
402    },
403    Ref(NodeId),
404    Env {
405        name: String,
406        default: Option<NodeId>,
407    },
408    Ident(String),
409    Interpolate {
410        parts: Vec<InterpPart>,
411    },
412    Binary {
413        op: BinOp,
414        lhs: NodeId,
415        rhs: NodeId,
416    },
417    Unary {
418        op: UnOp,
419        expr: NodeId,
420    },
421    Constrained {
422        target: NodeId,
423        constraint: Constraint,
424    },
425    Patch(PatchOp),
426    SchemaField(SchemaFieldSpec),
427    SchemaTable {
428        fields: Vec<(String, NodeId)>,
429        closed: bool,
430    },
431    SchemaArray {
432        element: NodeId,
433    },
434    Semantic {
435        tag: SemanticTag,
436        inner: NodeId,
437    },
438    /// `blob(media_type, data)` construction site (reserved kind 24).
439    ContentAddressed {
440        media_type: NodeId,
441        data: NodeId,
442    },
443    /// A materialised content-addressed blob identity.
444    ///
445    /// `payload` is present only for untainted blobs that were materialised;
446    /// a tainted blob never carries bytes, and the codec refuses a tainted
447    /// blob entry whose payload is non-empty.
448    Blob {
449        hash: [u8; 32],
450        size: u64,
451        tainted: bool,
452        media_type: String,
453        payload: Option<Vec<u8>>,
454    },
455}
456
457impl fmt::Debug for Inode {
458    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
459        match self {
460            Inode::Null => f.write_str("Null"),
461            Inode::Bool(value) => write!(f, "Bool({value})"),
462            Inode::Int(value) => write!(f, "Int({value})"),
463            Inode::Float(value) => write!(f, "Float({value})"),
464            Inode::Str(value) => write!(f, "Str({value:?})"),
465            Inode::DurationMs(ms) => write!(f, "DurationMs({ms})"),
466            Inode::Quantity { value, unit } => write!(f, "Quantity({value}, {unit})"),
467            Inode::Array { elements } => write!(f, "Array({elements:?})"),
468            Inode::Table { fields } => write!(f, "Table({fields:?})"),
469            Inode::Secret(_) => f.write_str("Secret(***)"),
470            Inode::SecretRef { locator } => write!(f, "SecretRef({locator})"),
471            Inode::Ref(id) => write!(f, "Ref({id})"),
472            Inode::Env { name, default } => write!(f, "Env({name:?}, {default:?})"),
473            Inode::Ident(name) => write!(f, "Ident({name:?})"),
474            Inode::Interpolate { parts } => write!(f, "Interpolate({parts:?})"),
475            Inode::Binary { op, lhs, rhs } => write!(f, "Binary({}, {lhs}, {rhs})", op.as_str()),
476            Inode::Unary { op, expr } => write!(f, "Unary({}, {expr})", op.as_str()),
477            Inode::Constrained { target, constraint } => {
478                write!(f, "Constrained({target}, {constraint:?})")
479            }
480            Inode::Patch(op) => write!(f, "Patch({op:?})"),
481            Inode::SchemaField(spec) => write!(f, "SchemaField({spec:?})"),
482            Inode::SchemaTable { fields, closed } => {
483                write!(f, "SchemaTable({fields:?}, closed={closed})")
484            }
485            Inode::SchemaArray { element } => write!(f, "SchemaArray({element})"),
486            Inode::Semantic { tag, inner } => write!(f, "Semantic({}, {inner})", tag.as_str()),
487            Inode::ContentAddressed { media_type, data } => {
488                write!(f, "ContentAddressed({media_type}, {data})")
489            }
490            Inode::Blob {
491                hash,
492                size,
493                tainted,
494                media_type,
495                payload,
496            } => write!(
497                f,
498                "Blob(blake3:{}, size={size}, media={media_type:?}, tainted={tainted}, payload={})",
499                hash_hex(hash),
500                match payload {
501                    Some(bytes) => format!("{} bytes", bytes.len()),
502                    None => "absent".to_string(),
503                }
504            ),
505        }
506    }
507}
508
509/// Lowercase hex of a 32-byte digest.
510pub fn hash_hex(hash: &[u8; 32]) -> String {
511    let mut out = String::with_capacity(64);
512    for byte in hash {
513        out.push_str(&format!("{byte:02x}"));
514    }
515    out
516}
517
518/// Parse a 64-character hex digest.
519pub fn parse_hash_hex(text: &str) -> Option<[u8; 32]> {
520    if text.len() != 64 {
521        return None;
522    }
523    let mut out = [0u8; 32];
524    for (index, pair) in text.as_bytes().chunks_exact(2).enumerate() {
525        let hi = (pair[0] as char).to_digit(16)?;
526        let lo = (pair[1] as char).to_digit(16)?;
527        out[index] = (hi * 16 + lo) as u8;
528    }
529    Some(out)
530}
531
532/// Stable node-kind identity, usable without depending on wire ids.
533///
534/// Downstream tools (the RPC layer, projections, diagnostics) name kinds through
535/// this enum; `code()` is the permanent wire discriminant.
536#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
537#[allow(missing_docs)]
538pub enum SerializedKind {
539    Null,
540    Bool,
541    Int,
542    Float,
543    Str,
544    DurationMs,
545    Quantity,
546    Array,
547    Table,
548    Secret,
549    SecretRef,
550    Ref,
551    Env,
552    Ident,
553    Interpolate,
554    Binary,
555    Unary,
556    Constrained,
557    Patch,
558    SchemaField,
559    SchemaTable,
560    SchemaArray,
561    Semantic,
562    Blob,
563    ContentAddressed,
564}
565
566impl SerializedKind {
567    pub const fn code(self) -> u8 {
568        match self {
569            SerializedKind::Null => 0,
570            SerializedKind::Bool => 1,
571            SerializedKind::Int => 2,
572            SerializedKind::Float => 3,
573            SerializedKind::Str => 4,
574            SerializedKind::DurationMs => 5,
575            SerializedKind::Quantity => 6,
576            SerializedKind::Array => 7,
577            SerializedKind::Table => 8,
578            SerializedKind::Secret => 9,
579            SerializedKind::SecretRef => 10,
580            SerializedKind::Ref => 11,
581            SerializedKind::Env => 12,
582            SerializedKind::Ident => 13,
583            SerializedKind::Interpolate => 14,
584            SerializedKind::Binary => 15,
585            SerializedKind::Unary => 16,
586            SerializedKind::Constrained => 17,
587            SerializedKind::Patch => 18,
588            SerializedKind::SchemaField => 19,
589            SerializedKind::SchemaTable => 20,
590            SerializedKind::SchemaArray => 21,
591            SerializedKind::Semantic => 22,
592            SerializedKind::Blob => 23,
593            SerializedKind::ContentAddressed => 24,
594        }
595    }
596
597    pub const fn from_code(code: u8) -> Option<Self> {
598        let kind = match code {
599            0 => SerializedKind::Null,
600            1 => SerializedKind::Bool,
601            2 => SerializedKind::Int,
602            3 => SerializedKind::Float,
603            4 => SerializedKind::Str,
604            5 => SerializedKind::DurationMs,
605            6 => SerializedKind::Quantity,
606            7 => SerializedKind::Array,
607            8 => SerializedKind::Table,
608            9 => SerializedKind::Secret,
609            10 => SerializedKind::SecretRef,
610            11 => SerializedKind::Ref,
611            12 => SerializedKind::Env,
612            13 => SerializedKind::Ident,
613            14 => SerializedKind::Interpolate,
614            15 => SerializedKind::Binary,
615            16 => SerializedKind::Unary,
616            17 => SerializedKind::Constrained,
617            18 => SerializedKind::Patch,
618            19 => SerializedKind::SchemaField,
619            20 => SerializedKind::SchemaTable,
620            21 => SerializedKind::SchemaArray,
621            22 => SerializedKind::Semantic,
622            23 => SerializedKind::Blob,
623            24 => SerializedKind::ContentAddressed,
624            _ => return None,
625        };
626        Some(kind)
627    }
628
629    pub const fn name(self) -> &'static str {
630        crate::view::node_kind_name(self.code())
631    }
632}
633
634impl Inode {
635    /// Stable kind identity of this node.
636    pub const fn kind(&self) -> SerializedKind {
637        match self {
638            Inode::Null => SerializedKind::Null,
639            Inode::Bool(_) => SerializedKind::Bool,
640            Inode::Int(_) => SerializedKind::Int,
641            Inode::Float(_) => SerializedKind::Float,
642            Inode::Str(_) => SerializedKind::Str,
643            Inode::DurationMs(_) => SerializedKind::DurationMs,
644            Inode::Quantity { .. } => SerializedKind::Quantity,
645            Inode::Array { .. } => SerializedKind::Array,
646            Inode::Table { .. } => SerializedKind::Table,
647            Inode::Secret(_) => SerializedKind::Secret,
648            Inode::SecretRef { .. } => SerializedKind::SecretRef,
649            Inode::Ref(_) => SerializedKind::Ref,
650            Inode::Env { .. } => SerializedKind::Env,
651            Inode::Ident(_) => SerializedKind::Ident,
652            Inode::Interpolate { .. } => SerializedKind::Interpolate,
653            Inode::Binary { .. } => SerializedKind::Binary,
654            Inode::Unary { .. } => SerializedKind::Unary,
655            Inode::Constrained { .. } => SerializedKind::Constrained,
656            Inode::Patch(_) => SerializedKind::Patch,
657            Inode::SchemaField(_) => SerializedKind::SchemaField,
658            Inode::SchemaTable { .. } => SerializedKind::SchemaTable,
659            Inode::SchemaArray { .. } => SerializedKind::SchemaArray,
660            Inode::Semantic { .. } => SerializedKind::Semantic,
661            Inode::Blob { .. } => SerializedKind::Blob,
662            Inode::ContentAddressed { .. } => SerializedKind::ContentAddressed,
663        }
664    }
665}
666
667impl Inode {
668    /// Stable kind discriminant (wire contract, `docs/spec/layout.md` §5.1).
669    pub const fn kind_code(&self) -> u8 {
670        match self {
671            Inode::Null => 0,
672            Inode::Bool(_) => 1,
673            Inode::Int(_) => 2,
674            Inode::Float(_) => 3,
675            Inode::Str(_) => 4,
676            Inode::DurationMs(_) => 5,
677            Inode::Quantity { .. } => 6,
678            Inode::Array { .. } => 7,
679            Inode::Table { .. } => 8,
680            Inode::Secret(_) => 9,
681            Inode::SecretRef { .. } => 10,
682            Inode::Ref(_) => 11,
683            Inode::Env { .. } => 12,
684            Inode::Ident(_) => 13,
685            Inode::Interpolate { .. } => 14,
686            Inode::Binary { .. } => 15,
687            Inode::Unary { .. } => 16,
688            Inode::Constrained { .. } => 17,
689            Inode::Patch(_) => 18,
690            Inode::SchemaField(_) => 19,
691            Inode::SchemaTable { .. } => 20,
692            Inode::SchemaArray { .. } => 21,
693            Inode::Semantic { .. } => 22,
694            Inode::Blob { .. } => 23,
695            Inode::ContentAddressed { .. } => 24,
696        }
697    }
698
699    /// Whether this node carries secret material or a secret locator.
700    pub const fn is_secret(&self) -> bool {
701        matches!(self, Inode::Secret(_) | Inode::SecretRef { .. })
702    }
703
704    /// Node references in canonical child order.
705    ///
706    /// This is the single source of truth for the dependency edges, the
707    /// canonical numbering traversal, cycle detection and graph queries. It
708    /// must list *every* node reference the variant holds.
709    pub fn children(&self) -> Vec<NodeId> {
710        match self {
711            Inode::Quantity { value, .. } => vec![*value],
712            Inode::Array { elements } => elements.clone(),
713            Inode::Table { fields } => fields.iter().map(|(_, id)| *id).collect(),
714            Inode::SecretRef { locator } => vec![*locator],
715            Inode::Ref(id) => vec![*id],
716            Inode::Env { default, .. } => default.iter().copied().collect(),
717            Inode::Interpolate { parts } => parts
718                .iter()
719                .filter_map(|part| match part {
720                    InterpPart::Expr(id) => Some(*id),
721                    InterpPart::Literal(_) => None,
722                })
723                .collect(),
724            Inode::Binary { lhs, rhs, .. } => vec![*lhs, *rhs],
725            Inode::Unary { expr, .. } => vec![*expr],
726            Inode::Constrained { target, .. } => vec![*target],
727            Inode::Patch(op) => match op {
728                PatchOp::Set { value, .. } => vec![*value],
729                PatchOp::Remove { .. } | PatchOp::Rename { .. } => Vec::new(),
730            },
731            Inode::SchemaField(spec) => {
732                let mut out = Vec::new();
733                for id in [
734                    spec.min,
735                    spec.max,
736                    spec.min_len,
737                    spec.max_len,
738                    spec.pattern,
739                    spec.default,
740                ]
741                .into_iter()
742                .flatten()
743                {
744                    out.push(id);
745                }
746                out
747            }
748            Inode::SchemaTable { fields, .. } => fields.iter().map(|(_, id)| *id).collect(),
749            Inode::SchemaArray { element } => vec![*element],
750            Inode::Semantic { inner, .. } => vec![*inner],
751            Inode::ContentAddressed { media_type, data } => vec![*media_type, *data],
752            Inode::Null
753            | Inode::Bool(_)
754            | Inode::Int(_)
755            | Inode::Float(_)
756            | Inode::Str(_)
757            | Inode::DurationMs(_)
758            | Inode::Secret(_)
759            | Inode::Ident(_)
760            | Inode::Blob { .. } => Vec::new(),
761        }
762    }
763
764    /// Every string this node owns, for heap construction.
765    pub fn strings(&self, out: &mut Vec<String>) {
766        match self {
767            Inode::Str(s) | Inode::Ident(s) => out.push(s.clone()),
768            Inode::Table { fields } => {
769                for (name, _) in fields {
770                    out.push(name.clone());
771                }
772            }
773            Inode::Env { name, .. } => out.push(name.clone()),
774            Inode::Interpolate { parts } => {
775                for part in parts {
776                    if let InterpPart::Literal(s) = part {
777                        out.push(s.clone());
778                    }
779                }
780            }
781            Inode::Constrained { constraint, .. } => match constraint {
782                Constraint::Enum(items) => out.extend(items.iter().cloned()),
783                Constraint::Pattern(s) | Constraint::Custom(s) => out.push(s.clone()),
784                Constraint::MinMax { .. } | Constraint::Odd => {}
785            },
786            Inode::Patch(op) => match op {
787                PatchOp::Set { path, .. } | PatchOp::Remove { path } => out.push(path.clone()),
788                PatchOp::Rename { from, to } => {
789                    out.push(from.clone());
790                    out.push(to.clone());
791                }
792            },
793            Inode::SchemaField(spec) => {
794                if let TypeSpec::Enum(items) = &spec.typ {
795                    out.extend(items.iter().cloned());
796                }
797                if let Some(deprecated) = &spec.deprecated {
798                    out.push(deprecated.clone());
799                }
800            }
801            Inode::SchemaTable { fields, .. } => {
802                for (name, _) in fields {
803                    out.push(name.clone());
804                }
805            }
806            Inode::Blob { media_type, .. } => out.push(media_type.clone()),
807            Inode::Null
808            | Inode::Bool(_)
809            | Inode::Int(_)
810            | Inode::Float(_)
811            | Inode::DurationMs(_)
812            | Inode::Quantity { .. }
813            | Inode::Array { .. }
814            | Inode::Secret(_)
815            | Inode::SecretRef { .. }
816            | Inode::Ref(_)
817            | Inode::Unary { .. }
818            | Inode::Binary { .. }
819            | Inode::SchemaArray { .. }
820            | Inode::Semantic { .. }
821            | Inode::ContentAddressed { .. } => {}
822        }
823    }
824}
825
826/// Annotation kinds (spec/annotations.md §2–§4).
827#[derive(Debug, Clone, PartialEq)]
828pub enum AnnotationKind {
829    /// Documentation only, never enforced.
830    Intent(String),
831    /// A constraint predicate attached to a node.
832    Constraint { source: String, expr: NodeId },
833    /// Agent-immutable flag.
834    AiImmutable(bool),
835}
836
837/// One annotation attached to a node.
838#[derive(Debug, Clone, PartialEq)]
839pub struct Annotation {
840    pub node: NodeId,
841    pub kind: AnnotationKind,
842}
843
844/// Kinds of provenance entries.
845#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
846pub enum ProvKind {
847    Text,
848    Int,
849    Flag,
850    Hash,
851    /// Input module content hash (delta/derivation lineage).
852    Input,
853}
854
855impl ProvKind {
856    pub const fn code(self) -> u32 {
857        match self {
858            ProvKind::Text => 0,
859            ProvKind::Int => 1,
860            ProvKind::Flag => 2,
861            ProvKind::Hash => 3,
862            ProvKind::Input => 4,
863        }
864    }
865
866    pub const fn from_code(code: u32) -> Option<Self> {
867        match code {
868            0 => Some(ProvKind::Text),
869            1 => Some(ProvKind::Int),
870            2 => Some(ProvKind::Flag),
871            3 => Some(ProvKind::Hash),
872            4 => Some(ProvKind::Input),
873            _ => None,
874        }
875    }
876}
877
878/// A provenance value.
879#[derive(Debug, Clone, PartialEq)]
880pub enum ProvValue {
881    Text(String),
882    Int(i64),
883    Flag(bool),
884    Hash([u8; 32]),
885}
886
887/// One provenance entry: a typed key/value pair.
888#[derive(Debug, Clone, PartialEq)]
889pub struct ProvEntry {
890    pub key: String,
891    pub value: ProvValue,
892}
893
894impl ProvEntry {
895    pub fn kind(&self) -> ProvKind {
896        match self.value {
897            ProvValue::Text(_) => ProvKind::Text,
898            ProvValue::Int(_) => ProvKind::Int,
899            ProvValue::Flag(_) => ProvKind::Flag,
900            ProvValue::Hash(_) => ProvKind::Hash,
901        }
902    }
903}
904
905/// Module provenance: who produced this module, when, with what, from what.
906#[derive(Debug, Clone, PartialEq, Default)]
907pub struct Provenance {
908    /// Producer identity (agent, tool, human, pipeline).
909    pub producer: Option<String>,
910    /// Unix epoch milliseconds; `0` means "unrecorded".
911    pub timestamp_ms: u64,
912    /// Model identity when an AI produced the module.
913    pub model: Option<String>,
914    /// Tool identity.
915    pub tool: Option<String>,
916    /// Content hash of the parent module this one derives from.
917    pub parent: Option<[u8; 32]>,
918    /// Free-form typed entries, sorted by key in canonical form.
919    pub entries: Vec<ProvEntry>,
920}
921
922/// A canonical Active Infinity module.
923///
924/// The struct is an arena of [`Inode`] plus roots, bindings, annotations and
925/// provenance. Edges are *derived* from node children, so a module can never
926/// hold an edge table inconsistent with its nodes.
927#[derive(Debug, Clone, Default)]
928pub struct InfinityModule {
929    nodes: Vec<Inode>,
930    root: Option<NodeId>,
931    bindings: Vec<(String, NodeId)>,
932    annotations: Vec<Annotation>,
933    provenance: Option<Provenance>,
934}
935
936impl PartialEq for InfinityModule {
937    /// Canonical structural equality: two modules are equal when their
938    /// canonical forms match, so arena order, table field order and blob
939    /// numbering are irrelevant.
940    ///
941    /// Secret *contents* are never compared — only the fact that a secret is
942    /// there — so equality can never become a guessing oracle, and a module
943    /// holding a secret is equal to a structurally identical copy of itself.
944    /// Use [`SecretBytes`] directly when you need the anti-oracle behaviour of
945    /// two secret values.
946    fn eq(&self, other: &Self) -> bool {
947        self.structurally_eq(other)
948    }
949}
950
951/// Compare two canonical nodes, blinding secret payloads.
952fn node_eq(left: &Inode, right: &Inode) -> bool {
953    match (left, right) {
954        (Inode::Secret(_), Inode::Secret(_)) => true,
955        (Inode::Secret(_), _) | (_, Inode::Secret(_)) => false,
956        // Canonicalization normalizes floats; compare their bits so a NaN is
957        // equal to itself.
958        (Inode::Float(a), Inode::Float(b)) => a.to_bits() == b.to_bits(),
959        _ => left == right,
960    }
961}
962
963impl InfinityModule {
964    pub fn new() -> Self {
965        Self::default()
966    }
967
968    /// Add a node, returning its id.
969    pub fn push(&mut self, node: Inode) -> NodeId {
970        let id = NodeId(u32::try_from(self.nodes.len()).expect("arena exceeds u32 ids"));
971        self.nodes.push(node);
972        id
973    }
974
975    /// Alias for [`InfinityModule::push`], mirroring `Module::add_node`.
976    pub fn add_node(&mut self, node: Inode) -> NodeId {
977        self.push(node)
978    }
979
980    pub fn get(&self, id: NodeId) -> Option<&Inode> {
981        self.nodes.get(id.index())
982    }
983
984    pub fn get_mut(&mut self, id: NodeId) -> Option<&mut Inode> {
985        self.nodes.get_mut(id.index())
986    }
987
988    pub fn node_mut(&mut self, id: NodeId) -> Result<&mut Inode, super::Error> {
989        self.nodes
990            .get_mut(id.index())
991            .ok_or(super::Error::NodeRef(id.0))
992    }
993
994    pub fn nodes(&self) -> &[Inode] {
995        &self.nodes
996    }
997
998    pub fn node_count(&self) -> usize {
999        self.nodes.len()
1000    }
1001
1002    pub fn is_empty(&self) -> bool {
1003        self.nodes.is_empty()
1004    }
1005
1006    pub fn root(&self) -> Option<NodeId> {
1007        self.root
1008    }
1009
1010    pub fn set_root(&mut self, root: Option<NodeId>) {
1011        self.root = root;
1012    }
1013
1014    pub fn bindings(&self) -> &[(String, NodeId)] {
1015        &self.bindings
1016    }
1017
1018    /// Bind a name to a node. Replaces an existing binding with the same name.
1019    pub fn bind(&mut self, name: impl Into<String>, id: NodeId) {
1020        let name = name.into();
1021        if let Some(slot) = self.bindings.iter_mut().find(|(n, _)| *n == name) {
1022            slot.1 = id;
1023        } else {
1024            self.bindings.push((name, id));
1025        }
1026    }
1027
1028    pub fn annotations(&self) -> &[Annotation] {
1029        &self.annotations
1030    }
1031
1032    pub fn annotate(&mut self, node: NodeId, kind: AnnotationKind) {
1033        self.annotations.push(Annotation { node, kind });
1034    }
1035
1036    /// Drop every annotation.
1037    pub fn clear_annotations(&mut self) {
1038        self.annotations.clear();
1039    }
1040
1041    /// Remove a binding by name; returns the old target when present.
1042    pub fn remove_binding(&mut self, name: &str) -> Option<NodeId> {
1043        let position = self.bindings.iter().position(|(key, _)| key == name)?;
1044        Some(self.bindings.remove(position).1)
1045    }
1046
1047    pub fn provenance(&self) -> Option<&Provenance> {
1048        self.provenance.as_ref()
1049    }
1050
1051    /// True when the module holds a structural secret literal.
1052    pub fn has_secret(&self) -> bool {
1053        self.nodes
1054            .iter()
1055            .any(|node| matches!(node, Inode::Secret(_)))
1056    }
1057
1058    /// Canonical structural equality, blinding secret payloads.
1059    ///
1060    /// This is what [`PartialEq`] implements; it is also available by name for
1061    /// readers who want the intent spelled out.
1062    pub fn structurally_eq(&self, other: &Self) -> bool {
1063        let (Ok(left), Ok(right)) = (
1064            crate::canonical::prepare(self),
1065            crate::canonical::prepare(other),
1066        ) else {
1067            return false;
1068        };
1069        left.root == right.root
1070            && left.bindings == right.bindings
1071            && left.annotations == right.annotations
1072            && left.provenance == right.provenance
1073            && left.strings == right.strings
1074            && left.units == right.units
1075            && left.blobs == right.blobs
1076            && left.hashes == right.hashes
1077            && left.numbers == right.numbers
1078            && left.secrets.len() == right.secrets.len()
1079            && left.types.len() == right.types.len()
1080            && left
1081                .types
1082                .iter()
1083                .zip(right.types.iter())
1084                .all(|(a, b)| a.spec == b.spec && a.aux == b.aux)
1085            && left.nodes.len() == right.nodes.len()
1086            && left
1087                .nodes
1088                .iter()
1089                .zip(right.nodes.iter())
1090                .all(|(a, b)| node_eq(a, b))
1091    }
1092
1093    pub fn set_provenance(&mut self, provenance: Option<Provenance>) {
1094        // Provenance is a map keyed by name: the last value for a key wins, so
1095        // the stored form can never contain the duplicate keys that validation
1096        // (correctly) rejects. Entries stay sorted for canonical equality.
1097        self.provenance = provenance.map(|prov| {
1098            let mut by_key: std::collections::BTreeMap<String, ProvValue> =
1099                std::collections::BTreeMap::new();
1100            for entry in prov.entries {
1101                by_key.insert(entry.key, entry.value);
1102            }
1103            let entries: Vec<ProvEntry> = by_key
1104                .into_iter()
1105                .map(|(key, value)| ProvEntry { key, value })
1106                .collect();
1107            Provenance { entries, ..prov }
1108        });
1109    }
1110
1111    /// Roots used to define the reachable graph: the module root, every
1112    /// binding, every annotation subject, and provenance-only references.
1113    pub fn roots(&self) -> Vec<NodeId> {
1114        let mut roots = Vec::new();
1115        if let Some(root) = self.root {
1116            roots.push(root);
1117        }
1118        let mut bindings: Vec<&(String, NodeId)> = self.bindings.iter().collect();
1119        bindings.sort_by(|a, b| a.0.cmp(&b.0));
1120        roots.extend(bindings.into_iter().map(|(_, id)| *id));
1121        for annotation in &self.annotations {
1122            roots.push(annotation.node);
1123            if let AnnotationKind::Constraint { expr, .. } = &annotation.kind {
1124                roots.push(*expr);
1125            }
1126        }
1127        roots
1128    }
1129
1130    /// Derived dependency edges, direction dependency → dependent, sorted and
1131    /// deduplicated. Nodes that reference where they are referenced from are
1132    /// normal; the graph is a DAG after [`Self::validate_structure`].
1133    pub fn edges(&self) -> Vec<(NodeId, NodeId)> {
1134        let mut edges = Vec::new();
1135        for (index, node) in self.nodes.iter().enumerate() {
1136            let dependent = NodeId(index as u32);
1137            for child in node.children() {
1138                edges.push((child, dependent));
1139            }
1140        }
1141        edges.sort_unstable();
1142        edges.dedup();
1143        edges
1144    }
1145
1146    /// Reverse adjacency (dependent → dependencies).
1147    pub fn dependency_map(&self) -> HashMap<NodeId, Vec<NodeId>> {
1148        let mut map: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
1149        for (index, node) in self.nodes.iter().enumerate() {
1150            map.insert(NodeId(index as u32), node.children());
1151        }
1152        map
1153    }
1154
1155    /// Check every structural invariant the wire format also enforces.
1156    ///
1157    /// `max_nodes` and `max_depth` bound the work so a hostile module cannot
1158    /// make this function hang or recurse without limit.
1159    pub fn validate_structure(&self, max_nodes: u64, max_depth: u32) -> Result<(), super::Error> {
1160        use super::Error;
1161        if self.nodes.len() as u64 > max_nodes {
1162            return Err(Error::Limit {
1163                kind: "nodes",
1164                limit: max_nodes,
1165            });
1166        }
1167        let count = self.nodes.len() as u32;
1168        let check = |id: NodeId| -> Result<(), Error> {
1169            if id.0 >= count {
1170                return Err(Error::NodeRef(id.0));
1171            }
1172            Ok(())
1173        };
1174        for (index, node) in self.nodes.iter().enumerate() {
1175            let id = NodeId(index as u32);
1176            for child in node.children() {
1177                check(child)?;
1178            }
1179            match node {
1180                Inode::Table { fields } | Inode::SchemaTable { fields, .. } => {
1181                    // O(k) duplicate detection: a 256-field table is common and
1182                    // the quadratic form dominated encoding time.
1183                    let mut seen = std::collections::HashSet::with_capacity(fields.len());
1184                    for (name, _) in fields {
1185                        if !seen.insert(name.as_str()) {
1186                            return Err(Error::DuplicateField { node: id.0 });
1187                        }
1188                    }
1189                }
1190                Inode::Float(value)
1191                    if value.is_nan() && !crate::canonical::is_canonical_nan(*value) =>
1192                {
1193                    return Err(Error::Node {
1194                        id: id.0,
1195                        reason: "non-canonical NaN",
1196                    });
1197                }
1198                Inode::Constrained { constraint, .. } => match constraint {
1199                    Constraint::MinMax {
1200                        min: Some(min),
1201                        max: Some(max),
1202                    } if min > max => {
1203                        return Err(Error::Node {
1204                            id: id.0,
1205                            reason: "constraint min exceeds max",
1206                        });
1207                    }
1208                    Constraint::Enum(items) if items.is_empty() => {
1209                        return Err(Error::Node {
1210                            id: id.0,
1211                            reason: "empty enum constraint",
1212                        });
1213                    }
1214                    _ => {}
1215                },
1216                _ => {}
1217            }
1218        }
1219        if let Some(root) = self.root {
1220            check(root)?;
1221        }
1222        let mut seen = std::collections::HashSet::new();
1223        for (name, id) in &self.bindings {
1224            check(*id)?;
1225            if !seen.insert(name.as_str()) {
1226                return Err(Error::DuplicateBinding);
1227            }
1228        }
1229        for annotation in &self.annotations {
1230            check(annotation.node)?;
1231            if let AnnotationKind::Constraint { expr, .. } = &annotation.kind {
1232                check(*expr)?;
1233            }
1234        }
1235        self.check_acyclic(count)?;
1236        self.check_depth(max_depth)?;
1237        Ok(())
1238    }
1239
1240    /// Iterative cycle detection over the derived dependency graph.
1241    ///
1242    /// Explicit stack: a hostile 4M-node chain must not overflow the thread
1243    /// stack (`active-core` treats cycles as fatal, so this is a hard
1244    /// invariant, not a hint).
1245    pub fn check_acyclic(&self, count: u32) -> Result<(), super::Error> {
1246        use super::Error;
1247        // 0 = unvisited, 1 = on stack, 2 = done.
1248        let mut state = vec![0u8; count as usize];
1249        let mut stack: Vec<(u32, usize)> = Vec::new();
1250        for start in 0..count {
1251            if state[start as usize] != 0 {
1252                continue;
1253            }
1254            stack.push((start, 0));
1255            state[start as usize] = 1;
1256            while let Some((node, child_index)) = stack.pop() {
1257                let children = self.nodes[node as usize].children();
1258                if child_index < children.len() {
1259                    stack.push((node, child_index + 1));
1260                    let next = children[child_index].0;
1261                    match state[next as usize] {
1262                        0 => {
1263                            state[next as usize] = 1;
1264                            stack.push((next, 0));
1265                        }
1266                        1 => return Err(Error::Cycle { node: next }),
1267                        _ => {}
1268                    }
1269                } else {
1270                    state[node as usize] = 2;
1271                }
1272            }
1273        }
1274        Ok(())
1275    }
1276
1277    /// Depth check over the derived dependency graph (iterative).
1278    pub fn check_depth(&self, max_depth: u32) -> Result<(), super::Error> {
1279        use super::Error;
1280        if max_depth == 0 {
1281            return Ok(());
1282        }
1283        let count = self.nodes.len();
1284        let mut depth = vec![0u32; count];
1285        let mut stack: Vec<(usize, usize)> = Vec::new();
1286        for start in 0..count {
1287            if depth[start] != 0 {
1288                continue;
1289            }
1290            stack.push((start, 0));
1291            depth[start] = 1;
1292            while let Some((node, child_index)) = stack.pop() {
1293                let children = self.nodes[node].children();
1294                if child_index < children.len() {
1295                    stack.push((node, child_index + 1));
1296                    let next = children[child_index].index();
1297                    let candidate = depth[node].saturating_add(1);
1298                    if candidate > max_depth {
1299                        return Err(Error::Depth { limit: max_depth });
1300                    }
1301                    if candidate > depth[next] {
1302                        depth[next] = candidate;
1303                        stack.push((next, 0));
1304                    }
1305                }
1306            }
1307        }
1308        Ok(())
1309    }
1310
1311    /// Total string payload bytes (heap size before deduplication).
1312    pub fn string_bytes(&self) -> u64 {
1313        let mut strings = Vec::new();
1314        for node in &self.nodes {
1315            node.strings(&mut strings);
1316        }
1317        strings.iter().map(|s| s.len() as u64).sum()
1318    }
1319
1320    /// Rebuild a module from decoded parts (used by the decoder and the
1321    /// stream reassembler).
1322    pub fn from_parts(
1323        nodes: Vec<Inode>,
1324        root: Option<NodeId>,
1325        bindings: Vec<(String, NodeId)>,
1326        mut annotations: Vec<Annotation>,
1327        provenance: Option<Provenance>,
1328    ) -> Self {
1329        annotations.sort_by(|a, b| {
1330            (a.node.0, annotation_order(&a.kind)).cmp(&(b.node.0, annotation_order(&b.kind)))
1331        });
1332        InfinityModule {
1333            nodes,
1334            root,
1335            bindings,
1336            annotations,
1337            provenance,
1338        }
1339    }
1340}
1341
1342fn annotation_order(kind: &AnnotationKind) -> u32 {
1343    match kind {
1344        AnnotationKind::Intent(_) => 0,
1345        AnnotationKind::Constraint { .. } => 1,
1346        AnnotationKind::AiImmutable(_) => 2,
1347    }
1348}
1349
1350/// Builder helpers that keep the node graph consistent while constructing a
1351/// module by hand or from another IR.
1352#[derive(Debug, Default)]
1353pub struct ModuleBuilder {
1354    module: InfinityModule,
1355}
1356
1357impl ModuleBuilder {
1358    pub fn new() -> Self {
1359        Self::default()
1360    }
1361
1362    pub fn push(&mut self, node: Inode) -> NodeId {
1363        self.module.push(node)
1364    }
1365
1366    pub fn root(&mut self, id: NodeId) -> &mut Self {
1367        self.module.set_root(Some(id));
1368        self
1369    }
1370
1371    pub fn bind(&mut self, name: impl Into<String>, id: NodeId) -> &mut Self {
1372        self.module.bind(name, id);
1373        self
1374    }
1375
1376    pub fn annotate(&mut self, node: NodeId, kind: AnnotationKind) -> &mut Self {
1377        self.module.annotate(node, kind);
1378        self
1379    }
1380
1381    pub fn provenance(&mut self, provenance: Provenance) -> &mut Self {
1382        self.module.set_provenance(Some(provenance));
1383        self
1384    }
1385
1386    pub fn build(self) -> InfinityModule {
1387        self.module
1388    }
1389}