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