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