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