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
951fn prov_rank(value: &ProvValue) -> u32 {
952    match value {
953        ProvValue::Text(_) => 0,
954        ProvValue::Int(_) => 1,
955        ProvValue::Flag(_) => 2,
956        ProvValue::Hash(_) => 3,
957    }
958}
959
960/// Compare two canonical nodes, blinding secret payloads.
961fn node_eq(left: &Inode, right: &Inode) -> bool {
962    match (left, right) {
963        (Inode::Secret(_), Inode::Secret(_)) => true,
964        (Inode::Secret(_), _) | (_, Inode::Secret(_)) => false,
965        // Canonicalization normalizes floats; compare their bits so a NaN is
966        // equal to itself.
967        (Inode::Float(a), Inode::Float(b)) => a.to_bits() == b.to_bits(),
968        _ => left == right,
969    }
970}
971
972impl InfinityModule {
973    pub fn new() -> Self {
974        Self::default()
975    }
976
977    /// Add a node, returning its id.
978    pub fn push(&mut self, node: Inode) -> NodeId {
979        let id = NodeId(u32::try_from(self.nodes.len()).expect("arena exceeds u32 ids"));
980        self.nodes.push(node);
981        id
982    }
983
984    /// Alias for [`InfinityModule::push`], mirroring `Module::add_node`.
985    pub fn add_node(&mut self, node: Inode) -> NodeId {
986        self.push(node)
987    }
988
989    pub fn get(&self, id: NodeId) -> Option<&Inode> {
990        self.nodes.get(id.index())
991    }
992
993    pub fn get_mut(&mut self, id: NodeId) -> Option<&mut Inode> {
994        self.nodes.get_mut(id.index())
995    }
996
997    pub fn node_mut(&mut self, id: NodeId) -> Result<&mut Inode, super::Error> {
998        self.nodes
999            .get_mut(id.index())
1000            .ok_or(super::Error::NodeRef(id.0))
1001    }
1002
1003    pub fn nodes(&self) -> &[Inode] {
1004        &self.nodes
1005    }
1006
1007    pub fn node_count(&self) -> usize {
1008        self.nodes.len()
1009    }
1010
1011    pub fn is_empty(&self) -> bool {
1012        self.nodes.is_empty()
1013    }
1014
1015    pub fn root(&self) -> Option<NodeId> {
1016        self.root
1017    }
1018
1019    pub fn set_root(&mut self, root: Option<NodeId>) {
1020        self.root = root;
1021    }
1022
1023    pub fn bindings(&self) -> &[(String, NodeId)] {
1024        &self.bindings
1025    }
1026
1027    /// Bind a name to a node. Replaces an existing binding with the same name.
1028    pub fn bind(&mut self, name: impl Into<String>, id: NodeId) {
1029        let name = name.into();
1030        if let Some(slot) = self.bindings.iter_mut().find(|(n, _)| *n == name) {
1031            slot.1 = id;
1032        } else {
1033            self.bindings.push((name, id));
1034        }
1035    }
1036
1037    pub fn annotations(&self) -> &[Annotation] {
1038        &self.annotations
1039    }
1040
1041    pub fn annotate(&mut self, node: NodeId, kind: AnnotationKind) {
1042        self.annotations.push(Annotation { node, kind });
1043    }
1044
1045    /// Drop every annotation.
1046    pub fn clear_annotations(&mut self) {
1047        self.annotations.clear();
1048    }
1049
1050    /// Remove a binding by name; returns the old target when present.
1051    pub fn remove_binding(&mut self, name: &str) -> Option<NodeId> {
1052        let position = self.bindings.iter().position(|(key, _)| key == name)?;
1053        Some(self.bindings.remove(position).1)
1054    }
1055
1056    pub fn provenance(&self) -> Option<&Provenance> {
1057        self.provenance.as_ref()
1058    }
1059
1060    /// True when the module holds a structural secret literal.
1061    pub fn has_secret(&self) -> bool {
1062        self.nodes
1063            .iter()
1064            .any(|node| matches!(node, Inode::Secret(_)))
1065    }
1066
1067    /// Canonical structural equality, blinding secret payloads.
1068    ///
1069    /// This is what [`PartialEq`] implements; it is also available by name for
1070    /// readers who want the intent spelled out.
1071    pub fn structurally_eq(&self, other: &Self) -> bool {
1072        let (Ok(left), Ok(right)) = (
1073            crate::canonical::prepare(self),
1074            crate::canonical::prepare(other),
1075        ) else {
1076            return false;
1077        };
1078        left.root == right.root
1079            && left.bindings == right.bindings
1080            && left.annotations == right.annotations
1081            && left.provenance == right.provenance
1082            && left.strings == right.strings
1083            && left.units == right.units
1084            && left.blobs == right.blobs
1085            && left.hashes == right.hashes
1086            && left.numbers == right.numbers
1087            && left.secrets.len() == right.secrets.len()
1088            && left.types.len() == right.types.len()
1089            && left
1090                .types
1091                .iter()
1092                .zip(right.types.iter())
1093                .all(|(a, b)| a.spec == b.spec && a.aux == b.aux)
1094            && left.nodes.len() == right.nodes.len()
1095            && left
1096                .nodes
1097                .iter()
1098                .zip(right.nodes.iter())
1099                .all(|(a, b)| node_eq(a, b))
1100    }
1101
1102    pub fn set_provenance(&mut self, provenance: Option<Provenance>) {
1103        // Store provenance in canonical order so module state equals decoded
1104        // state without a re-encode round trip.
1105        self.provenance = provenance.map(|mut prov| {
1106            prov.entries.sort_by(|a, b| {
1107                (a.key.as_str(), prov_rank(&a.value)).cmp(&(b.key.as_str(), prov_rank(&b.value)))
1108            });
1109            prov.entries.dedup();
1110            prov
1111        });
1112    }
1113
1114    /// Roots used to define the reachable graph: the module root, every
1115    /// binding, every annotation subject, and provenance-only references.
1116    pub fn roots(&self) -> Vec<NodeId> {
1117        let mut roots = Vec::new();
1118        if let Some(root) = self.root {
1119            roots.push(root);
1120        }
1121        let mut bindings: Vec<&(String, NodeId)> = self.bindings.iter().collect();
1122        bindings.sort_by(|a, b| a.0.cmp(&b.0));
1123        roots.extend(bindings.into_iter().map(|(_, id)| *id));
1124        for annotation in &self.annotations {
1125            roots.push(annotation.node);
1126            if let AnnotationKind::Constraint { expr, .. } = &annotation.kind {
1127                roots.push(*expr);
1128            }
1129        }
1130        roots
1131    }
1132
1133    /// Derived dependency edges, direction dependency → dependent, sorted and
1134    /// deduplicated. Nodes that reference where they are referenced from are
1135    /// normal; the graph is a DAG after [`Self::validate_structure`].
1136    pub fn edges(&self) -> Vec<(NodeId, NodeId)> {
1137        let mut edges = Vec::new();
1138        for (index, node) in self.nodes.iter().enumerate() {
1139            let dependent = NodeId(index as u32);
1140            for child in node.children() {
1141                edges.push((child, dependent));
1142            }
1143        }
1144        edges.sort_unstable();
1145        edges.dedup();
1146        edges
1147    }
1148
1149    /// Reverse adjacency (dependent → dependencies).
1150    pub fn dependency_map(&self) -> HashMap<NodeId, Vec<NodeId>> {
1151        let mut map: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
1152        for (index, node) in self.nodes.iter().enumerate() {
1153            map.insert(NodeId(index as u32), node.children());
1154        }
1155        map
1156    }
1157
1158    /// Check every structural invariant the wire format also enforces.
1159    ///
1160    /// `max_nodes` and `max_depth` bound the work so a hostile module cannot
1161    /// make this function hang or recurse without limit.
1162    pub fn validate_structure(&self, max_nodes: u64, max_depth: u32) -> Result<(), super::Error> {
1163        use super::Error;
1164        if self.nodes.len() as u64 > max_nodes {
1165            return Err(Error::Limit {
1166                kind: "nodes",
1167                limit: max_nodes,
1168            });
1169        }
1170        let count = self.nodes.len() as u32;
1171        let check = |id: NodeId| -> Result<(), Error> {
1172            if id.0 >= count {
1173                return Err(Error::NodeRef(id.0));
1174            }
1175            Ok(())
1176        };
1177        for (index, node) in self.nodes.iter().enumerate() {
1178            let id = NodeId(index as u32);
1179            for child in node.children() {
1180                check(child)?;
1181            }
1182            match node {
1183                Inode::Table { fields } | Inode::SchemaTable { fields, .. } => {
1184                    // O(k) duplicate detection: a 256-field table is common and
1185                    // the quadratic form dominated encoding time.
1186                    let mut seen = std::collections::HashSet::with_capacity(fields.len());
1187                    for (name, _) in fields {
1188                        if !seen.insert(name.as_str()) {
1189                            return Err(Error::DuplicateField { node: id.0 });
1190                        }
1191                    }
1192                }
1193                Inode::Float(value)
1194                    if value.is_nan() && !crate::canonical::is_canonical_nan(*value) =>
1195                {
1196                    return Err(Error::Node {
1197                        id: id.0,
1198                        reason: "non-canonical NaN",
1199                    });
1200                }
1201                Inode::Constrained { constraint, .. } => match constraint {
1202                    Constraint::MinMax {
1203                        min: Some(min),
1204                        max: Some(max),
1205                    } if min > max => {
1206                        return Err(Error::Node {
1207                            id: id.0,
1208                            reason: "constraint min exceeds max",
1209                        });
1210                    }
1211                    Constraint::Enum(items) if items.is_empty() => {
1212                        return Err(Error::Node {
1213                            id: id.0,
1214                            reason: "empty enum constraint",
1215                        });
1216                    }
1217                    _ => {}
1218                },
1219                _ => {}
1220            }
1221        }
1222        if let Some(root) = self.root {
1223            check(root)?;
1224        }
1225        let mut seen = std::collections::HashSet::new();
1226        for (name, id) in &self.bindings {
1227            check(*id)?;
1228            if !seen.insert(name.as_str()) {
1229                return Err(Error::DuplicateBinding);
1230            }
1231        }
1232        for annotation in &self.annotations {
1233            check(annotation.node)?;
1234            if let AnnotationKind::Constraint { expr, .. } = &annotation.kind {
1235                check(*expr)?;
1236            }
1237        }
1238        self.check_acyclic(count)?;
1239        self.check_depth(max_depth)?;
1240        Ok(())
1241    }
1242
1243    /// Iterative cycle detection over the derived dependency graph.
1244    ///
1245    /// Explicit stack: a hostile 4M-node chain must not overflow the thread
1246    /// stack (`active-core` treats cycles as fatal, so this is a hard
1247    /// invariant, not a hint).
1248    pub fn check_acyclic(&self, count: u32) -> Result<(), super::Error> {
1249        use super::Error;
1250        // 0 = unvisited, 1 = on stack, 2 = done.
1251        let mut state = vec![0u8; count as usize];
1252        let mut stack: Vec<(u32, usize)> = Vec::new();
1253        for start in 0..count {
1254            if state[start as usize] != 0 {
1255                continue;
1256            }
1257            stack.push((start, 0));
1258            state[start as usize] = 1;
1259            while let Some((node, child_index)) = stack.pop() {
1260                let children = self.nodes[node as usize].children();
1261                if child_index < children.len() {
1262                    stack.push((node, child_index + 1));
1263                    let next = children[child_index].0;
1264                    match state[next as usize] {
1265                        0 => {
1266                            state[next as usize] = 1;
1267                            stack.push((next, 0));
1268                        }
1269                        1 => return Err(Error::Cycle { node: next }),
1270                        _ => {}
1271                    }
1272                } else {
1273                    state[node as usize] = 2;
1274                }
1275            }
1276        }
1277        Ok(())
1278    }
1279
1280    /// Depth check over the derived dependency graph (iterative).
1281    pub fn check_depth(&self, max_depth: u32) -> Result<(), super::Error> {
1282        use super::Error;
1283        if max_depth == 0 {
1284            return Ok(());
1285        }
1286        let count = self.nodes.len();
1287        let mut depth = vec![0u32; count];
1288        let mut stack: Vec<(usize, usize)> = Vec::new();
1289        for start in 0..count {
1290            if depth[start] != 0 {
1291                continue;
1292            }
1293            stack.push((start, 0));
1294            depth[start] = 1;
1295            while let Some((node, child_index)) = stack.pop() {
1296                let children = self.nodes[node].children();
1297                if child_index < children.len() {
1298                    stack.push((node, child_index + 1));
1299                    let next = children[child_index].index();
1300                    let candidate = depth[node].saturating_add(1);
1301                    if candidate > max_depth {
1302                        return Err(Error::Depth { limit: max_depth });
1303                    }
1304                    if candidate > depth[next] {
1305                        depth[next] = candidate;
1306                        stack.push((next, 0));
1307                    }
1308                }
1309            }
1310        }
1311        Ok(())
1312    }
1313
1314    /// Total string payload bytes (heap size before deduplication).
1315    pub fn string_bytes(&self) -> u64 {
1316        let mut strings = Vec::new();
1317        for node in &self.nodes {
1318            node.strings(&mut strings);
1319        }
1320        strings.iter().map(|s| s.len() as u64).sum()
1321    }
1322
1323    /// Rebuild a module from decoded parts (used by the decoder and the
1324    /// stream reassembler).
1325    pub fn from_parts(
1326        nodes: Vec<Inode>,
1327        root: Option<NodeId>,
1328        bindings: Vec<(String, NodeId)>,
1329        mut annotations: Vec<Annotation>,
1330        provenance: Option<Provenance>,
1331    ) -> Self {
1332        annotations.sort_by(|a, b| {
1333            (a.node.0, annotation_order(&a.kind)).cmp(&(b.node.0, annotation_order(&b.kind)))
1334        });
1335        InfinityModule {
1336            nodes,
1337            root,
1338            bindings,
1339            annotations,
1340            provenance,
1341        }
1342    }
1343}
1344
1345fn annotation_order(kind: &AnnotationKind) -> u32 {
1346    match kind {
1347        AnnotationKind::Intent(_) => 0,
1348        AnnotationKind::Constraint { .. } => 1,
1349        AnnotationKind::AiImmutable(_) => 2,
1350    }
1351}
1352
1353/// Builder helpers that keep the node graph consistent while constructing a
1354/// module by hand or from another IR.
1355#[derive(Debug, Default)]
1356pub struct ModuleBuilder {
1357    module: InfinityModule,
1358}
1359
1360impl ModuleBuilder {
1361    pub fn new() -> Self {
1362        Self::default()
1363    }
1364
1365    pub fn push(&mut self, node: Inode) -> NodeId {
1366        self.module.push(node)
1367    }
1368
1369    pub fn root(&mut self, id: NodeId) -> &mut Self {
1370        self.module.set_root(Some(id));
1371        self
1372    }
1373
1374    pub fn bind(&mut self, name: impl Into<String>, id: NodeId) -> &mut Self {
1375        self.module.bind(name, id);
1376        self
1377    }
1378
1379    pub fn annotate(&mut self, node: NodeId, kind: AnnotationKind) -> &mut Self {
1380        self.module.annotate(node, kind);
1381        self
1382    }
1383
1384    pub fn provenance(&mut self, provenance: Provenance) -> &mut Self {
1385        self.module.set_provenance(Some(provenance));
1386        self
1387    }
1388
1389    pub fn build(self) -> InfinityModule {
1390        self.module
1391    }
1392}