Skip to main content

active_infinity/
interop.rs

1//! Interoperability with the Active text language and with legacy consumers.
2//!
3//! Three surfaces live here:
4//!
5//! 1. **`active-core` bridge** (`from_active_core` / `to_active_core`): parse
6//!    text Active with `active_core`, hand the IR to Infinity, and bring it
7//!    back for evaluation. The bridge is deliberately one-directional in what
8//!    it guarantees: every `active-core` 1.4.0 node kind is representable in
9//!    Infinity, while Infinity's blob identity has no `active-core` 1.4.0
10//!    counterpart and is reported as an explicit error instead of being
11//!    silently degraded (ADR-0002).
12//! 2. **Redacted projections** (`to_redacted_json`): human and legacy
13//!    consumption. Secrets are replaced by the marker at every depth, and any
14//!    plain string equal to a secret literal is redacted too, so a copy of a
15//!    secret cannot leave through the projection.
16//! 3. **Agent graph queries** (`why`, `impact`, `dependents`, `dependencies`,
17//!    `secret_tainted_paths`): the binary form of the questions agents ask,
18//!    answered with labels only — never values.
19
20use std::collections::{HashMap, HashSet, VecDeque};
21
22use crate::error::{Error, Result};
23use crate::ir::{
24    AnnotationKind, InfinityModule, Inode, InterpPart, NodeId, ProvEntry, ProvValue, Provenance,
25};
26#[cfg(feature = "active-core")]
27use crate::ir::{BinOp, Constraint, PatchOp, SchemaFieldSpec, SemanticTag, TypeSpec, UnOp};
28#[cfg(feature = "active-core")]
29use crate::unit::UnitTag;
30
31/// Marker substituted for secret material in every projection.
32pub const SECRET_MARKER: &str = "***SECRET***";
33
34/// Every secret literal in a module, as text.
35///
36/// Used by the text projections (`provenance_lines`, `intents`) so a copy of a
37/// secret inside metadata cannot leave through them.
38pub fn secret_texts(module: &InfinityModule) -> Vec<String> {
39    module
40        .nodes()
41        .iter()
42        .filter_map(|node| match node {
43            Inode::Secret(secret) => Some(String::from_utf8_lossy(secret.expose()).into_owned()),
44            _ => None,
45        })
46        .collect()
47}
48
49/// Replace every occurrence of a secret in `text` with the marker.
50pub fn redact_text(text: &str, secrets: &[String]) -> String {
51    let mut out = text.to_string();
52    for secret in secrets {
53        if secret.is_empty() || !out.contains(secret.as_str()) {
54            continue;
55        }
56        // Whole-value matches always redact; substring spreading applies to
57        // secrets of four bytes or more (the text language's rule).
58        if out == *secret || secret.len() >= 4 {
59            out = out.replace(secret.as_str(), SECRET_MARKER);
60        }
61    }
62    out
63}
64
65// ---------------------------------------------------------------------------
66// active-core bridge
67// ---------------------------------------------------------------------------
68
69#[cfg(feature = "active-core")]
70mod bridge {
71    use super::*;
72    use active_core::ir::{
73        Annotation as CoreAnnotation, AnnotationKind as CoreAnnotationKind, Module as CoreModule,
74        Node as CoreNode, Value as CoreValue,
75    };
76    use active_core::types::{
77        ActiveType as CoreType, Constraint as CoreConstraint, Unit as CoreUnit,
78    };
79
80    /// Convert a parsed `active-core` module into an Infinity module.
81    pub fn from_active_core(module: &CoreModule) -> Result<InfinityModule> {
82        let mut out = InfinityModule::new();
83        let mut map: HashMap<u32, NodeId> = HashMap::new();
84        // Ids are allocated in arena order so the conversion is deterministic
85        // and diagnostics that reference `n<id>` stay interpretable.
86        for index in 0..module.arena.len() {
87            let core_id = active_core::NodeId(index as u32);
88            if module.get(core_id).is_none() {
89                continue;
90            }
91            let node = lower_node(module, core_id, &mut out, &mut map)?;
92            map.insert(core_id.0, node);
93        }
94        for (name, core_id) in &module.bindings {
95            if let Some(id) = map.get(&core_id.0) {
96                out.bind(name.clone(), *id);
97            }
98        }
99        if let Some(root) = module.root {
100            out.set_root(map.get(&root.0).copied());
101        }
102        let mut annotations: Vec<(NodeId, AnnotationKind)> = Vec::new();
103        for (core_id, list) in &module.annotations {
104            let Some(node) = map.get(&core_id.0).copied() else {
105                continue;
106            };
107            for annotation in list {
108                let kind = match &annotation.kind {
109                    CoreAnnotationKind::Intent(text) => AnnotationKind::Intent(text.clone()),
110                    CoreAnnotationKind::Constraint { source, expr } => {
111                        let expr = map
112                            .get(&expr.0)
113                            .copied()
114                            .ok_or(Error::EncodeDangling(expr.0))?;
115                        AnnotationKind::Constraint {
116                            source: source.clone(),
117                            expr,
118                        }
119                    }
120                    CoreAnnotationKind::AiImmutable(flag) => AnnotationKind::AiImmutable(*flag),
121                };
122                annotations.push((node, kind));
123            }
124        }
125        for (node, kind) in annotations {
126            out.annotate(node, kind);
127        }
128        out.validate_structure(u32::MAX as u64, 0)?;
129        Ok(out)
130    }
131
132    fn lower_node(
133        module: &CoreModule,
134        id: active_core::NodeId,
135        out: &mut InfinityModule,
136        map: &mut HashMap<u32, NodeId>,
137    ) -> Result<NodeId> {
138        let node = module.get(id).ok_or(Error::EncodeDangling(id.0))?;
139        // Literals lower recursively into composed nodes (arrays and tables are
140        // node graphs in the canonical form).
141        if let CoreNode::Literal(value) = node {
142            return push_value(value, out, map);
143        }
144        let lowered = match node {
145            CoreNode::Literal(_) => unreachable!("handled before the match"),
146            CoreNode::Env { name, default } => {
147                let default = match default {
148                    Some(default) => Some(lower_ref(module, *default, out, map)?),
149                    None => None,
150                };
151                Inode::Env {
152                    name: name.clone(),
153                    default,
154                }
155            }
156            CoreNode::Secret { locator } => Inode::SecretRef {
157                locator: lower_ref(module, *locator, out, map)?,
158            },
159            CoreNode::Ref(target) => Inode::Ref(lower_ref(module, *target, out, map)?),
160            CoreNode::Ident(name) => Inode::Ident(name.clone()),
161            CoreNode::Interpolate { parts } => Inode::Interpolate {
162                parts: parts
163                    .iter()
164                    .map(|part| match part {
165                        active_core::InterpPart::Literal(text) => {
166                            Ok(InterpPart::Literal(text.clone()))
167                        }
168                        active_core::InterpPart::Expr(id) => {
169                            Ok(InterpPart::Expr(lower_ref(module, *id, out, map)?))
170                        }
171                    })
172                    .collect::<Result<Vec<_>>>()?,
173            },
174            CoreNode::Binary { op, lhs, rhs } => Inode::Binary {
175                op: lower_binop(*op),
176                lhs: lower_ref(module, *lhs, out, map)?,
177                rhs: lower_ref(module, *rhs, out, map)?,
178            },
179            CoreNode::Unary { op, expr } => Inode::Unary {
180                op: lower_unop(*op),
181                expr: lower_ref(module, *expr, out, map)?,
182            },
183            CoreNode::Array { elements } => Inode::Array {
184                elements: elements
185                    .iter()
186                    .map(|id| lower_ref(module, *id, out, map))
187                    .collect::<Result<Vec<_>>>()?,
188            },
189            CoreNode::Table { fields } => Inode::Table {
190                fields: fields
191                    .iter()
192                    .map(|(name, id)| Ok((name.clone(), lower_ref(module, *id, out, map)?)))
193                    .collect::<Result<Vec<_>>>()?,
194            },
195            CoreNode::Quantity { value, unit } => Inode::Quantity {
196                value: lower_ref(module, *value, out, map)?,
197                unit: UnitTag::from(unit),
198            },
199            CoreNode::ApplyConstraint { target, constraint } => Inode::Constrained {
200                target: lower_ref(module, *target, out, map)?,
201                constraint: lower_constraint(constraint)?,
202            },
203            CoreNode::PatchDirective { op } => Inode::Patch(match op {
204                active_core::ir::PatchOp::Set { path, value } => PatchOp::Set {
205                    path: path.clone(),
206                    value: lower_ref(module, *value, out, map)?,
207                },
208                active_core::ir::PatchOp::Remove { path } => PatchOp::Remove { path: path.clone() },
209                active_core::ir::PatchOp::Rename { from, to } => PatchOp::Rename {
210                    from: from.clone(),
211                    to: to.clone(),
212                },
213            }),
214            CoreNode::SchemaField { field } => Inode::SchemaField(SchemaFieldSpec {
215                typ: lower_type(&field.typ)?,
216                min: lower_opt(module, field.min, out, map)?,
217                max: lower_opt(module, field.max, out, map)?,
218                min_len: lower_opt(module, field.min_len, out, map)?,
219                max_len: lower_opt(module, field.max_len, out, map)?,
220                pattern: lower_opt(module, field.pattern, out, map)?,
221                default: lower_opt(module, field.default, out, map)?,
222                deprecated: field.deprecated.clone(),
223            }),
224            CoreNode::SchemaTable { fields, closed } => Inode::SchemaTable {
225                fields: fields
226                    .iter()
227                    .map(|(name, id)| Ok((name.clone(), lower_ref(module, *id, out, map)?)))
228                    .collect::<Result<Vec<_>>>()?,
229                closed: *closed,
230            },
231            CoreNode::SchemaArray { element } => Inode::SchemaArray {
232                element: lower_ref(module, *element, out, map)?,
233            },
234            CoreNode::Semantic { tag, inner } => Inode::Semantic {
235                tag: match tag {
236                    active_core::ir::SemanticTag::Prompt => SemanticTag::Prompt,
237                    active_core::ir::SemanticTag::Policy => SemanticTag::Policy,
238                    active_core::ir::SemanticTag::Template => SemanticTag::Template,
239                },
240                inner: lower_ref(module, *inner, out, map)?,
241            },
242        };
243        let id = out.push(lowered);
244        Ok(id)
245    }
246
247    /// Values lower into nodes; arrays and tables become composed nodes so the
248    /// canonical form has a single representation for every shape.
249    fn lower_value(value: &CoreValue) -> Inode {
250        match value {
251            CoreValue::Null => Inode::Null,
252            CoreValue::Bool(flag) => Inode::Bool(*flag),
253            CoreValue::Int(number) => Inode::Int(*number),
254            CoreValue::Float(number) => Inode::Float(*number),
255            CoreValue::String(text) => Inode::Str(text.clone()),
256            CoreValue::DurationMs(ms) => Inode::DurationMs(*ms),
257            CoreValue::Secret(text) => Inode::Secret(crate::ir::SecretBytes::from(text.clone())),
258            // Quantity, arrays and tables need child nodes and are handled in
259            // `push_value`, which owns the recursion.
260            CoreValue::Quantity(..) | CoreValue::Array(_) | CoreValue::Table(_) => {
261                unreachable!("composed values are lowered by push_value")
262            }
263        }
264    }
265
266    fn push_value(
267        value: &CoreValue,
268        out: &mut InfinityModule,
269        map: &mut HashMap<u32, NodeId>,
270    ) -> Result<NodeId> {
271        let _ = map;
272        let node = match value {
273            CoreValue::Quantity(amount, unit) => {
274                let number = out.push(Inode::Float(*amount));
275                Inode::Quantity {
276                    value: number,
277                    unit: UnitTag::from(unit),
278                }
279            }
280            CoreValue::Array(items) => {
281                let mut elements = Vec::with_capacity(items.len());
282                for item in items {
283                    elements.push(push_value(item, out, map)?);
284                }
285                Inode::Array { elements }
286            }
287            CoreValue::Table(entries) => {
288                let mut fields = Vec::with_capacity(entries.len());
289                for (name, item) in entries {
290                    fields.push((name.clone(), push_value(item, out, map)?));
291                }
292                Inode::Table { fields }
293            }
294            other => lower_value(other),
295        };
296        Ok(out.push(node))
297    }
298
299    fn lower_ref(
300        module: &CoreModule,
301        id: active_core::NodeId,
302        out: &mut InfinityModule,
303        map: &mut HashMap<u32, NodeId>,
304    ) -> Result<NodeId> {
305        if let Some(mapped) = map.get(&id.0) {
306            return Ok(*mapped);
307        }
308        let lowered = lower_node(module, id, out, map)?;
309        map.insert(id.0, lowered);
310        Ok(lowered)
311    }
312
313    fn lower_opt(
314        module: &CoreModule,
315        id: Option<active_core::NodeId>,
316        out: &mut InfinityModule,
317        map: &mut HashMap<u32, NodeId>,
318    ) -> Result<Option<NodeId>> {
319        match id {
320            Some(id) => Ok(Some(lower_ref(module, id, out, map)?)),
321            None => Ok(None),
322        }
323    }
324
325    fn lower_binop(op: active_core::ir::BinOp) -> BinOp {
326        match op {
327            active_core::ir::BinOp::Add => BinOp::Add,
328            active_core::ir::BinOp::Sub => BinOp::Sub,
329            active_core::ir::BinOp::Mul => BinOp::Mul,
330            active_core::ir::BinOp::Div => BinOp::Div,
331            active_core::ir::BinOp::Eq => BinOp::Eq,
332            active_core::ir::BinOp::Ne => BinOp::Ne,
333            active_core::ir::BinOp::Lt => BinOp::Lt,
334            active_core::ir::BinOp::Le => BinOp::Le,
335            active_core::ir::BinOp::Gt => BinOp::Gt,
336            active_core::ir::BinOp::Ge => BinOp::Ge,
337            active_core::ir::BinOp::And => BinOp::And,
338            active_core::ir::BinOp::Or => BinOp::Or,
339        }
340    }
341
342    fn lower_unop(op: active_core::ir::UnOp) -> UnOp {
343        match op {
344            active_core::ir::UnOp::Neg => UnOp::Neg,
345            active_core::ir::UnOp::Not => UnOp::Not,
346        }
347    }
348
349    fn lower_constraint(constraint: &CoreConstraint) -> Result<Constraint> {
350        let converted = match constraint {
351            CoreConstraint::MinMax { min, max } => Constraint::MinMax {
352                min: *min,
353                max: *max,
354            },
355            CoreConstraint::Enum(items) => Constraint::Enum(items.clone()),
356            CoreConstraint::Pattern(pattern) => Constraint::Pattern(pattern.clone()),
357            CoreConstraint::Odd => Constraint::Odd,
358            CoreConstraint::Custom(text) => Constraint::Custom(text.clone()),
359        };
360        Ok(converted)
361    }
362
363    fn lower_type(typ: &CoreType) -> Result<TypeSpec> {
364        let spec = match typ {
365            CoreType::Null => TypeSpec::Null,
366            CoreType::Bool => TypeSpec::Bool,
367            CoreType::Int => TypeSpec::Int,
368            CoreType::Float => TypeSpec::Float,
369            CoreType::String => TypeSpec::String,
370            CoreType::Duration => TypeSpec::Duration,
371            CoreType::Bytes => TypeSpec::Bytes,
372            CoreType::Table { closed } => TypeSpec::Table { closed: *closed },
373            CoreType::Enum(items) => TypeSpec::Enum(items.clone()),
374            CoreType::Secret => TypeSpec::Secret,
375            CoreType::Any => TypeSpec::Any,
376        };
377        Ok(spec)
378    }
379
380    /// Convert an Infinity module back into an `active-core` module.
381    ///
382    /// Blob identity has no `active-core` 1.4.0 representation; a module that
383    /// carries a blob node is refused with a stable error rather than silently
384    /// dropping the fingerprint.
385    pub fn to_active_core(module: &InfinityModule) -> Result<CoreModule> {
386        let mut out = CoreModule::new("infinity");
387        for node in module.nodes() {
388            out.add_node(convert_node(node)?);
389        }
390        // Rebuild the dependency graph exactly as `active-core` does.
391        for (index, node) in module.nodes().iter().enumerate() {
392            let dependent = active_core::NodeId(index as u32);
393            for child in node.children() {
394                out.add_edge(active_core::NodeId(child.0), dependent);
395            }
396        }
397        for (name, id) in module.bindings() {
398            out.bindings.insert(name.clone(), active_core::NodeId(id.0));
399        }
400        out.root = module.root().map(|id| active_core::NodeId(id.0));
401        for annotation in module.annotations() {
402            let core = CoreAnnotation {
403                kind: convert_annotation(&annotation.kind)?,
404                path: format!("node{}", annotation.node.0),
405                span: active_core::diag::Span {
406                    line: 0,
407                    column: 0,
408                    end_line: 0,
409                    end_column: 0,
410                },
411            };
412            out.annotations
413                .entry(active_core::NodeId(annotation.node.0))
414                .or_default()
415                .push(core);
416        }
417        Ok(out)
418    }
419
420    fn convert_annotation(kind: &AnnotationKind) -> Result<CoreAnnotationKind> {
421        Ok(match kind {
422            AnnotationKind::Intent(text) => CoreAnnotationKind::Intent(text.clone()),
423            AnnotationKind::Constraint { source, expr } => CoreAnnotationKind::Constraint {
424                source: source.clone(),
425                expr: active_core::NodeId(expr.0),
426            },
427            AnnotationKind::AiImmutable(flag) => CoreAnnotationKind::AiImmutable(*flag),
428        })
429    }
430
431    fn convert_node(node: &Inode) -> Result<CoreNode> {
432        let converted = match node {
433            Inode::Null => CoreNode::Literal(CoreValue::Null),
434            Inode::Bool(flag) => CoreNode::Literal(CoreValue::Bool(*flag)),
435            Inode::Int(number) => CoreNode::Literal(CoreValue::Int(*number)),
436            Inode::Float(number) => CoreNode::Literal(CoreValue::Float(*number)),
437            Inode::Str(text) => CoreNode::Literal(CoreValue::String(text.clone())),
438            Inode::DurationMs(ms) => CoreNode::Literal(CoreValue::DurationMs(*ms)),
439            Inode::Quantity { value, unit } => CoreNode::Quantity {
440                value: active_core::NodeId(value.0),
441                unit: CoreUnit::from(unit),
442            },
443            Inode::Array { elements } => CoreNode::Array {
444                elements: elements
445                    .iter()
446                    .map(|id| active_core::NodeId(id.0))
447                    .collect(),
448            },
449            Inode::Table { fields } => CoreNode::Table {
450                fields: fields
451                    .iter()
452                    .map(|(name, id)| (name.clone(), active_core::NodeId(id.0)))
453                    .collect(),
454            },
455            Inode::Secret(secret) => CoreNode::Literal(CoreValue::Secret(
456                String::from_utf8_lossy(secret.expose()).into_owned(),
457            )),
458            Inode::SecretRef { locator } => CoreNode::Secret {
459                locator: active_core::NodeId(locator.0),
460            },
461            Inode::Ref(id) => CoreNode::Ref(active_core::NodeId(id.0)),
462            Inode::Env { name, default } => CoreNode::Env {
463                name: name.clone(),
464                default: default.map(|id| active_core::NodeId(id.0)),
465            },
466            Inode::Ident(name) => CoreNode::Ident(name.clone()),
467            Inode::Interpolate { parts } => CoreNode::Interpolate {
468                parts: parts
469                    .iter()
470                    .map(|part| match part {
471                        InterpPart::Literal(text) => active_core::InterpPart::Literal(text.clone()),
472                        InterpPart::Expr(id) => {
473                            active_core::InterpPart::Expr(active_core::NodeId(id.0))
474                        }
475                    })
476                    .collect(),
477            },
478            Inode::Binary { op, lhs, rhs } => CoreNode::Binary {
479                op: convert_binop(*op),
480                lhs: active_core::NodeId(lhs.0),
481                rhs: active_core::NodeId(rhs.0),
482            },
483            Inode::Unary { op, expr } => CoreNode::Unary {
484                op: match op {
485                    UnOp::Neg => active_core::ir::UnOp::Neg,
486                    UnOp::Not => active_core::ir::UnOp::Not,
487                },
488                expr: active_core::NodeId(expr.0),
489            },
490            Inode::Constrained { target, constraint } => CoreNode::ApplyConstraint {
491                target: active_core::NodeId(target.0),
492                constraint: convert_constraint(constraint),
493            },
494            Inode::Patch(op) => CoreNode::PatchDirective {
495                op: match op {
496                    PatchOp::Set { path, value } => active_core::ir::PatchOp::Set {
497                        path: path.clone(),
498                        value: active_core::NodeId(value.0),
499                    },
500                    PatchOp::Remove { path } => {
501                        active_core::ir::PatchOp::Remove { path: path.clone() }
502                    }
503                    PatchOp::Rename { from, to } => active_core::ir::PatchOp::Rename {
504                        from: from.clone(),
505                        to: to.clone(),
506                    },
507                },
508            },
509            Inode::SchemaField(spec) => CoreNode::SchemaField {
510                field: active_core::SchemaField {
511                    typ: convert_type(&spec.typ)?,
512                    min: spec.min.map(|id| active_core::NodeId(id.0)),
513                    max: spec.max.map(|id| active_core::NodeId(id.0)),
514                    min_len: spec.min_len.map(|id| active_core::NodeId(id.0)),
515                    max_len: spec.max_len.map(|id| active_core::NodeId(id.0)),
516                    pattern: spec.pattern.map(|id| active_core::NodeId(id.0)),
517                    default: spec.default.map(|id| active_core::NodeId(id.0)),
518                    deprecated: spec.deprecated.clone(),
519                    span: None,
520                },
521            },
522            Inode::SchemaTable { fields, closed } => CoreNode::SchemaTable {
523                fields: fields
524                    .iter()
525                    .map(|(name, id)| (name.clone(), active_core::NodeId(id.0)))
526                    .collect(),
527                closed: *closed,
528            },
529            Inode::SchemaArray { element } => CoreNode::SchemaArray {
530                element: active_core::NodeId(element.0),
531            },
532            Inode::Semantic { tag, inner } => CoreNode::Semantic {
533                tag: match tag {
534                    SemanticTag::Prompt => active_core::ir::SemanticTag::Prompt,
535                    SemanticTag::Policy => active_core::ir::SemanticTag::Policy,
536                    SemanticTag::Template => active_core::ir::SemanticTag::Template,
537                },
538                inner: active_core::NodeId(inner.0),
539            },
540            Inode::ContentAddressed { .. } => {
541                return Err(Error::Encode(
542                    "active-core 1.4.0 has no ContentAddressed node kind",
543                ))
544            }
545            Inode::Blob { .. } => {
546                // Documented limitation (ADR-0002): `active-core` 1.4.0 has no
547                // blob value, so the bridge refuses instead of degrading the
548                // identity into an ordinary table. Keep the module in Infinity.
549                return Err(Error::Encode(
550                    "active-core 1.4.0 has no blob value; keep the module in Infinity",
551                ));
552            }
553        };
554        Ok(converted)
555    }
556
557    fn convert_binop(op: BinOp) -> active_core::ir::BinOp {
558        match op {
559            BinOp::Add => active_core::ir::BinOp::Add,
560            BinOp::Sub => active_core::ir::BinOp::Sub,
561            BinOp::Mul => active_core::ir::BinOp::Mul,
562            BinOp::Div => active_core::ir::BinOp::Div,
563            BinOp::Eq => active_core::ir::BinOp::Eq,
564            BinOp::Ne => active_core::ir::BinOp::Ne,
565            BinOp::Lt => active_core::ir::BinOp::Lt,
566            BinOp::Le => active_core::ir::BinOp::Le,
567            BinOp::Gt => active_core::ir::BinOp::Gt,
568            BinOp::Ge => active_core::ir::BinOp::Ge,
569            BinOp::And => active_core::ir::BinOp::And,
570            BinOp::Or => active_core::ir::BinOp::Or,
571        }
572    }
573
574    fn convert_constraint(constraint: &Constraint) -> CoreConstraint {
575        match constraint {
576            Constraint::MinMax { min, max } => CoreConstraint::MinMax {
577                min: *min,
578                max: *max,
579            },
580            Constraint::Enum(items) => CoreConstraint::Enum(items.clone()),
581            Constraint::Pattern(pattern) => CoreConstraint::Pattern(pattern.clone()),
582            Constraint::Odd => CoreConstraint::Odd,
583            Constraint::Custom(text) => CoreConstraint::Custom(text.clone()),
584        }
585    }
586
587    fn convert_type(typ: &TypeSpec) -> Result<CoreType> {
588        let spec = match typ {
589            TypeSpec::Null => CoreType::Null,
590            TypeSpec::Bool => CoreType::Bool,
591            TypeSpec::Int => CoreType::Int,
592            TypeSpec::Float => CoreType::Float,
593            TypeSpec::String => CoreType::String,
594            TypeSpec::Duration => CoreType::Duration,
595            TypeSpec::Bytes => CoreType::Bytes,
596            TypeSpec::Table { closed } => CoreType::Table { closed: *closed },
597            TypeSpec::Enum(items) => CoreType::Enum(items.clone()),
598            TypeSpec::Secret => CoreType::Secret,
599            TypeSpec::Any => CoreType::Any,
600            TypeSpec::Blob => {
601                // Documented limitation (ADR-0002): the published `active-core`
602                // has no `Blob` schema type; refuse rather than approximate.
603                return Err(Error::Encode("active-core 1.4.0 has no Blob type"));
604            }
605        };
606        Ok(spec)
607    }
608
609    /// Evaluate a module with the Active engine, honoring a budget.
610    pub fn evaluate(
611        module: &InfinityModule,
612        budget: active_core::Budget,
613    ) -> Result<(active_core::Value, Vec<active_core::Diagnostic>)> {
614        let core = to_active_core(module)?;
615        let mut engine = active_core::Engine::new();
616        engine.budget = budget;
617        engine.evaluate(&core).map_err(|error| {
618            // Keep the machine-readable Active class; the payload is a code with
619            // no values in it (spec/secrets.md §4).
620            Error::Evaluation(active_error_code(error.code()))
621        })
622    }
623
624    fn active_error_code(code: &'static str) -> &'static str {
625        match code {
626            "S001" => "active/S001-cycle",
627            "S002" => "active/S002-missing-root",
628            "S003" => "active/S003-missing-node",
629            "T001" => "active/T001-type",
630            "X001" => "active/X001-node-budget",
631            "X002" => "active/X002-wall-clock",
632            "X003" => "active/X003-depth",
633            "X004" => "active/X004-array-length",
634            "X005" => "active/X005-keys",
635            "X006" => "active/X006-value-size",
636            _ => "active/eval-error",
637        }
638    }
639}
640
641#[cfg(feature = "active-core")]
642pub use bridge::{evaluate, from_active_core, to_active_core};
643
644// ---------------------------------------------------------------------------
645// Shared redacted projection tree
646// ---------------------------------------------------------------------------
647
648/// Deepest graph the projection will follow. A validated module is at most
649/// `Limits::max_depth` deep; this is a second, independent stop so a
650/// hand-built module cannot exhaust the stack.
651const PROJECTION_DEPTH: usize = 1024;
652
653/// A redacted projection of a module's value tree.
654///
655/// Both the JSON and the Protobuf projection render this tree. Building it once
656/// keeps the two from drifting, which matters because the redaction rules are
657/// security invariants and every renderer has to reproduce them exactly.
658#[derive(Debug, Clone, PartialEq)]
659pub enum Projected {
660    /// No value (a missing root or a dangling reference).
661    Null,
662    /// A boolean.
663    Bool(bool),
664    /// A signed integer.
665    Int(i64),
666    /// A double-precision float.
667    Float(f64),
668    /// Text with secret material already removed or replaced by the marker.
669    Text(String),
670    /// A duration in milliseconds.
671    DurationMs(u64),
672    /// A value with a unit: the inner value plus the unit's name.
673    Quantity(Box<Projected>, String),
674    /// Ordered list elements.
675    Array(Vec<Projected>),
676    /// A table, in canonical field order. Keys are already redacted.
677    Record(Vec<(String, Projected)>),
678    /// A blob by identity. Payloads are never projected.
679    Blob {
680        /// Blake3-256 of the payload.
681        hash: [u8; 32],
682        /// Payload size in bytes.
683        size: u64,
684        /// Media type, redacted when the blob is tainted.
685        media_type: String,
686        /// Whether the blob carries secret-derived bytes.
687        tainted: bool,
688    },
689    /// An environment lookup, by name.
690    Env(String),
691    /// A node kind that has no data projection (an operator, a schema, a patch).
692    Expr(&'static str),
693}
694
695/// Project a module's root value with every secret redacted.
696///
697/// A projection walks the graph recursively, so a module with cycles or
698/// dangling references is refused instead of risking the stack. The Protobuf
699/// projection starts from [`project_node_with`] because it already holds the
700/// secret table and the encoded image.
701#[cfg(feature = "json")]
702pub(crate) fn project_root(module: &InfinityModule) -> Result<Projected> {
703    module.validate_structure(u32::MAX as u64, 4096)?;
704    let secrets = secret_texts(module);
705    build_projected(module, module.root(), &secrets, 0)
706}
707
708/// The module's secret literals, for a caller that projects many nodes.
709pub(crate) fn secret_table(module: &InfinityModule) -> Vec<String> {
710    secret_texts(module)
711}
712
713/// Project one node with an already-computed secret table.
714pub(crate) fn project_node_with(
715    module: &InfinityModule,
716    id: NodeId,
717    secrets: &[String],
718) -> Result<Projected> {
719    build_projected(module, Some(id), secrets, 0)
720}
721
722/// A value-position string: the whole text becomes the marker as soon as it
723/// contains a secret literal, so a partial copy cannot leak.
724fn redacted_value(text: &str, secrets: &[String]) -> String {
725    if secrets
726        .iter()
727        .any(|secret| !secret.is_empty() && text.contains(secret.as_str()))
728    {
729        SECRET_MARKER.to_string()
730    } else {
731        text.to_string()
732    }
733}
734
735/// A key-position string: every occurrence of every secret is replaced, so a
736/// key that merely contains a copy of a secret does not leak it.
737pub(crate) fn redacted_key(text: &str, secrets: &[String]) -> String {
738    let mut out = text.to_string();
739    for secret in secrets {
740        if secret.is_empty() || !out.contains(secret.as_str()) {
741            continue;
742        }
743        out = out.replace(secret.as_str(), SECRET_MARKER);
744    }
745    out
746}
747
748fn is_secret(module: &InfinityModule, id: NodeId) -> bool {
749    module.get(id).map(|node| node.is_secret()).unwrap_or(false)
750}
751
752fn build_projected(
753    module: &InfinityModule,
754    id: Option<NodeId>,
755    secrets: &[String],
756    depth: usize,
757) -> Result<Projected> {
758    if depth > PROJECTION_DEPTH {
759        return Err(Error::Encode("projection nesting exceeds the limit"));
760    }
761    let value = match id.and_then(|id| module.get(id)) {
762        None => Projected::Null,
763        Some(node) => match node {
764            Inode::Null => Projected::Null,
765            Inode::Bool(flag) => Projected::Bool(*flag),
766            Inode::Int(number) => Projected::Int(*number),
767            Inode::Float(number) => Projected::Float(*number),
768            Inode::Str(text) | Inode::Ident(text) => Projected::Text(redacted_value(text, secrets)),
769            Inode::DurationMs(ms) => Projected::DurationMs(*ms),
770            Inode::Quantity { value, unit } => Projected::Quantity(
771                Box::new(build_projected(module, Some(*value), secrets, depth + 1)?),
772                redacted_value(unit.name(), secrets),
773            ),
774            Inode::Array { elements } => Projected::Array(
775                elements
776                    .iter()
777                    .map(|id| build_projected(module, Some(*id), secrets, depth + 1))
778                    .collect::<Result<Vec<_>>>()?,
779            ),
780            Inode::Table { fields } => {
781                let mut projected = Vec::with_capacity(fields.len());
782                for (name, id) in fields {
783                    projected.push((
784                        redacted_key(name, secrets),
785                        build_projected(module, Some(*id), secrets, depth + 1)?,
786                    ));
787                }
788                Projected::Record(projected)
789            }
790            Inode::Secret(_) | Inode::SecretRef { .. } => {
791                Projected::Text(SECRET_MARKER.to_string())
792            }
793            Inode::Ref(id) => build_projected(module, Some(*id), secrets, depth + 1)?,
794            Inode::Env { name, .. } => Projected::Env(redacted_value(name, secrets)),
795            Inode::Interpolate { parts } => {
796                let mut text = String::new();
797                let mut tainted = false;
798                for part in parts {
799                    match part {
800                        InterpPart::Literal(literal) => text.push_str(literal),
801                        InterpPart::Expr(id) => match id.index() {
802                            _ if is_secret(module, *id) => tainted = true,
803                            _ => text.push_str("${}"),
804                        },
805                    }
806                }
807                if tainted {
808                    Projected::Text(SECRET_MARKER.to_string())
809                } else {
810                    Projected::Text(redacted_value(&text, secrets))
811                }
812            }
813            Inode::Binary { .. }
814            | Inode::Unary { .. }
815            | Inode::Constrained { .. }
816            | Inode::Patch(_)
817            | Inode::SchemaField(_)
818            | Inode::SchemaTable { .. }
819            | Inode::SchemaArray { .. }
820            | Inode::ContentAddressed { .. } => {
821                Projected::Expr(crate::view::node_kind_name(node.kind_code()))
822            }
823            Inode::Semantic { inner, .. } => {
824                build_projected(module, Some(*inner), secrets, depth + 1)?
825            }
826            Inode::Blob {
827                hash,
828                size,
829                tainted,
830                media_type,
831                ..
832            } => Projected::Blob {
833                hash: *hash,
834                size: *size,
835                media_type: if *tainted {
836                    SECRET_MARKER.to_string()
837                } else {
838                    redacted_key(media_type, secrets)
839                },
840                tainted: *tainted,
841            },
842        },
843    };
844    Ok(value)
845}
846
847// ---------------------------------------------------------------------------
848// Redacted JSON projection
849// ---------------------------------------------------------------------------
850
851#[cfg(feature = "json")]
852mod projection {
853    use super::*;
854
855    /// Serialize a module as redacted JSON.
856    ///
857    /// Properties that legacy consumers rely on and auditors check:
858    /// - secrets (and strings equal to a secret literal) become [`SECRET_MARKER`];
859    /// - blobs project their identity only (hash, size, media type, taint);
860    /// - quantities project `{"value": n, "unit": "<name>"}`;
861    /// - annotations and provenance project as data, never as executable text.
862    pub fn to_redacted_json(module: &InfinityModule) -> Result<String> {
863        let value = to_serde(&project_root(module)?);
864        serde_json::to_string(&value).map_err(|_| Error::Encode("json projection failed"))
865    }
866
867    /// Pretty-printed form of [`to_redacted_json`].
868    pub fn to_redacted_json_pretty(module: &InfinityModule) -> Result<String> {
869        let value = to_serde(&project_root(module)?);
870        serde_json::to_string_pretty(&value).map_err(|_| Error::Encode("json projection failed"))
871    }
872
873    /// Every secret literal in the module, as text. Used to redact copies.
874    pub fn secret_literals(module: &InfinityModule) -> Vec<String> {
875        secret_texts(module)
876    }
877
878    /// Render a projected value as JSON.
879    fn to_serde(value: &Projected) -> serde_json::Value {
880        match value {
881            Projected::Null => serde_json::Value::Null,
882            Projected::Bool(flag) => serde_json::Value::from(*flag),
883            Projected::Int(number) => serde_json::Value::from(*number),
884            Projected::Float(number) => serde_json::Value::from(*number),
885            Projected::Text(text) => serde_json::Value::from(text.clone()),
886            Projected::DurationMs(ms) => serde_json::json!({ "ms": ms }),
887            Projected::Quantity(value, unit) => serde_json::json!({
888                "value": to_serde(value),
889                "unit": unit,
890            }),
891            Projected::Array(items) => {
892                serde_json::Value::Array(items.iter().map(to_serde).collect())
893            }
894            Projected::Record(fields) => {
895                let mut map = serde_json::Map::new();
896                for (name, value) in fields {
897                    map.insert(name.clone(), to_serde(value));
898                }
899                serde_json::Value::Object(map)
900            }
901            Projected::Blob {
902                hash,
903                size,
904                media_type,
905                tainted,
906            } => serde_json::json!({
907                "$active.blob": {
908                    "hash": format!("blake3:{}", crate::ir::hash_hex(hash)),
909                    "size": size,
910                    "media_type": media_type,
911                    "tainted": tainted,
912                }
913            }),
914            Projected::Env(name) => serde_json::json!({ "$env": name }),
915            Projected::Expr(kind) => serde_json::json!({ "$expr": kind }),
916        }
917    }
918
919    /// Project provenance as JSON, redacting every text field and key against
920    /// `secrets` (the module's secret literals).
921    pub fn provenance_json(prov: &Provenance, secrets: &[String]) -> serde_json::Value {
922        let text = |value: &str| serde_json::Value::from(redacted_value(value, secrets));
923        let mut map = serde_json::Map::new();
924        if let Some(producer) = &prov.producer {
925            map.insert("producer".into(), text(producer));
926        }
927        if prov.timestamp_ms != 0 {
928            map.insert(
929                "timestamp_ms".into(),
930                serde_json::Value::from(prov.timestamp_ms),
931            );
932        }
933        if let Some(model) = &prov.model {
934            map.insert("model".into(), text(model));
935        }
936        if let Some(tool) = &prov.tool {
937            map.insert("tool".into(), text(tool));
938        }
939        if let Some(parent) = prov.parent {
940            map.insert(
941                "parent".into(),
942                serde_json::Value::from(crate::ir::hash_hex(&parent)),
943            );
944        }
945        let mut entries = serde_json::Map::new();
946        for entry in &prov.entries {
947            let value = match &entry.value {
948                ProvValue::Text(t) => text(t),
949                ProvValue::Int(number) => serde_json::Value::from(*number),
950                ProvValue::Flag(flag) => serde_json::Value::from(*flag),
951                ProvValue::Hash(hash) => serde_json::Value::from(crate::ir::hash_hex(hash)),
952            };
953            entries.insert(redacted_key(&entry.key, secrets), value);
954        }
955        map.insert("entries".into(), serde_json::Value::Object(entries));
956        serde_json::Value::Object(map)
957    }
958
959    /// Project a module's provenance, collecting its secret table first.
960    pub fn provenance_json_for(module: &InfinityModule) -> Option<serde_json::Value> {
961        let secrets = secret_literals(module);
962        module
963            .provenance()
964            .map(|prov| provenance_json(prov, &secrets))
965    }
966}
967
968#[cfg(feature = "json")]
969pub use projection::{
970    provenance_json, provenance_json_for, secret_literals, to_redacted_json,
971    to_redacted_json_pretty,
972};
973
974// ---------------------------------------------------------------------------
975// Agent graph queries
976// ---------------------------------------------------------------------------
977
978/// Dotted paths for every named node (bindings plus table paths from the root).
979pub fn value_paths(module: &InfinityModule) -> HashMap<NodeId, String> {
980    let mut paths: HashMap<NodeId, String> = HashMap::new();
981    for (name, id) in module.bindings() {
982        paths.insert(*id, name.clone());
983    }
984    if let Some(root) = module.root() {
985        let mut stack = vec![(root, String::new())];
986        let mut expanded = std::collections::HashSet::new();
987        while let Some((id, path)) = stack.pop() {
988            // A cyclic hand-built module must not hang the query.
989            if !expanded.insert(id) {
990                continue;
991            }
992            match module.get(id) {
993                Some(Inode::Table { fields }) => {
994                    for (key, child) in fields {
995                        let child_path = if path.is_empty() {
996                            key.clone()
997                        } else {
998                            format!("{path}.{key}")
999                        };
1000                        paths.entry(*child).or_insert_with(|| child_path.clone());
1001                        stack.push((*child, child_path));
1002                    }
1003                }
1004                Some(Inode::SchemaTable { fields, .. }) => {
1005                    for (key, child) in fields {
1006                        let child_path = if path.is_empty() {
1007                            key.clone()
1008                        } else {
1009                            format!("{path}.{key}")
1010                        };
1011                        paths.entry(*child).or_insert_with(|| child_path.clone());
1012                        stack.push((*child, child_path));
1013                    }
1014                }
1015                _ => {}
1016            }
1017        }
1018    }
1019    paths
1020}
1021
1022/// Stable label for a node: dotted path, binding, or `Kind[id]`.
1023///
1024/// Unmapped internal nodes (literals inside expressions, schema arguments) get a
1025/// kind-and-id label instead of a bare number so an agent's `why`/`impact`
1026/// output stays readable. Values are never included: a label must be safe to
1027/// print for a module that holds secrets.
1028pub fn node_label(module: &InfinityModule, id: NodeId) -> String {
1029    if let Some(path) = value_paths(module).get(&id) {
1030        return path.clone();
1031    }
1032    match module.get(id) {
1033        Some(node) => format!("{}[{}]", node.kind().name(), id.0),
1034        None => format!("node{}", id.0),
1035    }
1036}
1037
1038/// Resolve a dotted path the way the evaluator does (table fields, then
1039/// single-segment bindings).
1040pub fn resolve_path(module: &InfinityModule, path: &str) -> Option<NodeId> {
1041    if let Some(root) = module.root() {
1042        let mut current = root;
1043        let mut found = true;
1044        for segment in path.split('.') {
1045            match module.get(current) {
1046                Some(Inode::Table { fields }) | Some(Inode::SchemaTable { fields, .. }) => {
1047                    match fields.iter().find(|(name, _)| name == segment) {
1048                        Some((_, child)) => current = *child,
1049                        None => {
1050                            found = false;
1051                            break;
1052                        }
1053                    }
1054                }
1055                _ => {
1056                    found = false;
1057                    break;
1058                }
1059            }
1060        }
1061        if found {
1062            return Some(current);
1063        }
1064    }
1065    if !path.contains('.') {
1066        return module
1067            .bindings()
1068            .iter()
1069            .find(|(name, _)| name == path)
1070            .map(|(_, id)| *id);
1071    }
1072    None
1073}
1074
1075fn adjacency(module: &InfinityModule) -> (Vec<Vec<NodeId>>, Vec<Vec<NodeId>>) {
1076    let count = module.node_count();
1077    let mut deps = vec![Vec::new(); count];
1078    let mut dependents = vec![Vec::new(); count];
1079    for (index, node) in module.nodes().iter().enumerate() {
1080        for child in node.children() {
1081            if child.index() < count {
1082                deps[index].push(child);
1083                dependents[child.index()].push(NodeId(index as u32));
1084            }
1085        }
1086    }
1087    (deps, dependents)
1088}
1089
1090fn walk(adjacency: &[Vec<NodeId>], start: NodeId) -> Vec<NodeId> {
1091    let mut seen = HashSet::new();
1092    let mut out = Vec::new();
1093    let mut stack = vec![start];
1094    seen.insert(start);
1095    while let Some(id) = stack.pop() {
1096        for next in &adjacency[id.index()] {
1097            if seen.insert(*next) {
1098                out.push(*next);
1099                stack.push(*next);
1100            }
1101        }
1102    }
1103    out
1104}
1105
1106/// Transitive dependents (nodes affected by a change), sorted by label.
1107///
1108/// Parent tables are included: an agent asking "what does changing this node
1109/// affect?" needs the tables that must be re-evaluated as much as the leaves.
1110pub fn impact(module: &InfinityModule, id: NodeId) -> Vec<String> {
1111    let (_, dependents) = adjacency(module);
1112    let mut labels: Vec<String> = walk(&dependents, id)
1113        .into_iter()
1114        .map(|node| node_label(module, node))
1115        .collect();
1116    labels.sort();
1117    labels.dedup();
1118    labels
1119}
1120
1121/// Transitive dependencies (what a node is built from), sorted by label.
1122pub fn why(module: &InfinityModule, id: NodeId) -> Vec<String> {
1123    let (deps, _) = adjacency(module);
1124    let mut labels: Vec<String> = walk(&deps, id)
1125        .into_iter()
1126        .map(|node| node_label(module, node))
1127        .collect();
1128    labels.sort();
1129    labels.dedup();
1130    labels
1131}
1132
1133/// Direct dependents, sorted by node id.
1134pub fn dependents(module: &InfinityModule, id: NodeId) -> Vec<NodeId> {
1135    let (_, dependents) = adjacency(module);
1136    let mut out = dependents.get(id.index()).cloned().unwrap_or_default();
1137    out.sort_unstable();
1138    out.dedup();
1139    out
1140}
1141
1142/// Direct dependencies, sorted by node id.
1143pub fn dependencies(module: &InfinityModule, id: NodeId) -> Vec<NodeId> {
1144    let mut out = module
1145        .get(id)
1146        .map(|node| node.children())
1147        .unwrap_or_default();
1148    out.sort_unstable();
1149    out.dedup();
1150    out
1151}
1152
1153/// Nodes in the subgraph rooted at `roots` (the cone they depend on).
1154pub fn subgraph(module: &InfinityModule, roots: &[NodeId]) -> Vec<NodeId> {
1155    let (deps, _) = adjacency(module);
1156    let mut seen: HashSet<NodeId> = HashSet::new();
1157    let mut order = Vec::new();
1158    let mut queue: VecDeque<NodeId> = VecDeque::new();
1159    for root in roots {
1160        if module.get(*root).is_some() && seen.insert(*root) {
1161            order.push(*root);
1162            queue.push_back(*root);
1163        }
1164    }
1165    while let Some(node) = queue.pop_front() {
1166        for dep in &deps[node.index()] {
1167            if seen.insert(*dep) {
1168                order.push(*dep);
1169                queue.push_back(*dep);
1170            }
1171        }
1172    }
1173    order
1174}
1175
1176/// Dotted paths of the secret-tainted cone: every path whose subtree holds a
1177/// secret, including ancestors, plus the secret node's own path. Labels only.
1178pub fn secret_tainted_paths(module: &InfinityModule) -> Vec<String> {
1179    let mut tainted: Vec<bool> = vec![false; module.node_count()];
1180    // Fixpoint over the DAG: a node is tainted when it is a secret or any
1181    // child is tainted. Iterate to a fixed point (bounded by node count).
1182    let order = subgraph(module, &root_ids(module));
1183    let mut changed = true;
1184    while changed {
1185        changed = false;
1186        for id in &order {
1187            let Some(node) = module.get(*id) else {
1188                continue;
1189            };
1190            let value = node.is_secret()
1191                || node
1192                    .children()
1193                    .iter()
1194                    .any(|child| tainted.get(child.index()).copied().unwrap_or(false));
1195            if value && !tainted[id.index()] {
1196                tainted[id.index()] = true;
1197                changed = true;
1198            }
1199        }
1200    }
1201    let paths = value_paths(module);
1202    let mut out: Vec<String> = module
1203        .nodes()
1204        .iter()
1205        .enumerate()
1206        .filter(|(index, _)| tainted[*index])
1207        .filter_map(|(index, _)| paths.get(&NodeId(index as u32)).cloned())
1208        .collect();
1209    out.sort();
1210    out.dedup();
1211    out
1212}
1213
1214fn root_ids(module: &InfinityModule) -> Vec<NodeId> {
1215    let mut roots = Vec::new();
1216    if let Some(root) = module.root() {
1217        roots.push(root);
1218    }
1219    roots.extend(module.bindings().iter().map(|(_, id)| *id));
1220    roots
1221}
1222
1223/// Render provenance as stable `key=value` lines (values redacted when they
1224/// contain a secret literal).
1225pub fn provenance_lines(module: &InfinityModule) -> Vec<String> {
1226    let Some(prov) = module.provenance() else {
1227        return Vec::new();
1228    };
1229    let secrets: Vec<String> = module
1230        .nodes()
1231        .iter()
1232        .filter_map(|node| match node {
1233            Inode::Secret(secret) => Some(String::from_utf8_lossy(secret.expose()).into_owned()),
1234            _ => None,
1235        })
1236        .collect();
1237    let redact = |text: &str| -> String {
1238        if secrets
1239            .iter()
1240            .any(|secret| !secret.is_empty() && text.contains(secret.as_str()))
1241        {
1242            SECRET_MARKER.to_string()
1243        } else {
1244            text.to_string()
1245        }
1246    };
1247    let mut lines = Vec::new();
1248    if let Some(producer) = &prov.producer {
1249        lines.push(format!("producer={}", redact(producer)));
1250    }
1251    if prov.timestamp_ms != 0 {
1252        lines.push(format!("timestamp_ms={}", prov.timestamp_ms));
1253    }
1254    if let Some(model) = &prov.model {
1255        lines.push(format!("model={}", redact(model)));
1256    }
1257    if let Some(tool) = &prov.tool {
1258        lines.push(format!("tool={}", redact(tool)));
1259    }
1260    if let Some(parent) = prov.parent {
1261        lines.push(format!("parent=blake3:{}", crate::ir::hash_hex(&parent)));
1262    }
1263    for entry in &prov.entries {
1264        let value = match &entry.value {
1265            ProvValue::Text(text) => redact(text),
1266            ProvValue::Int(number) => number.to_string(),
1267            ProvValue::Flag(flag) => flag.to_string(),
1268            ProvValue::Hash(hash) => format!("blake3:{}", crate::ir::hash_hex(hash)),
1269        };
1270        lines.push(format!("{}={value}", redact(&entry.key)));
1271    }
1272    lines
1273}
1274
1275/// Intent annotations attached to a node (agent-facing helper).
1276///
1277/// Text is redacted against the module's secret literals: canonicalization also
1278/// redacts annotation text at encode time, but this helper must be safe on an
1279/// in-memory module that has not been through the codec yet.
1280pub fn intents(module: &InfinityModule, id: NodeId) -> Vec<String> {
1281    let secrets = secret_texts(module);
1282    module
1283        .annotations()
1284        .iter()
1285        .filter(|annotation| annotation.node == id)
1286        .filter_map(|annotation| match &annotation.kind {
1287            AnnotationKind::Intent(text) => Some(redact_text(text, &secrets)),
1288            _ => None,
1289        })
1290        .collect()
1291}
1292
1293/// Nodes flagged `@ai_immutable = true` (agents must not rewrite them).
1294pub fn immutable_nodes(module: &InfinityModule) -> Vec<NodeId> {
1295    let mut out: Vec<NodeId> = module
1296        .annotations()
1297        .iter()
1298        .filter_map(|annotation| match &annotation.kind {
1299            AnnotationKind::AiImmutable(true) => Some(annotation.node),
1300            _ => None,
1301        })
1302        .collect();
1303    out.sort_unstable();
1304    out.dedup();
1305    out
1306}
1307
1308/// Provenance entries as typed pairs (sorted by key).
1309pub fn provenance_pairs(prov: &Provenance) -> Vec<&ProvEntry> {
1310    let mut entries: Vec<&ProvEntry> = prov.entries.iter().collect();
1311    entries.sort_by(|a, b| a.key.cmp(&b.key));
1312    entries
1313}
1314
1315/// Build a module from a parsed text document (feature `active-core`).
1316///
1317/// This is the convenience entry point used by agents: parse Active source,
1318/// then hand the module to the codec.
1319#[cfg(feature = "active-core")]
1320pub fn from_active_source(source: &str, name: &str) -> Result<InfinityModule> {
1321    let core = active_core::parse(source, name)
1322        .map_err(|_| Error::Encode("Active source failed to parse"))?;
1323    from_active_core(&core)
1324}