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