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