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