Skip to main content

active_rpc/
domain.rs

1//! Domain operations over Active Infinity modules.
2//!
3//! A [`ModuleProvider`] is the provider behind a *module capability*. Every
4//! method maps to a real Active/Infinity operation:
5//!
6//! | Method | Behaviour |
7//! |--------|-----------|
8//! | `evaluate` | evaluate the module (or the subgraph at a path) with the Active engine under the capability budget |
9//! | `why` | transitive dependencies of a path, as labels |
10//! | `impact` | transitive dependents of a path, as labels |
11//! | `subgraph` | the dependency cone, encoded as a new canonical module |
12//! | `patch` | replace one node's content (refused on a read-only capability) |
13//! | `dependents` / `dependencies` | direct edges |
14//! | `content_hash` | the Blake3 content address |
15//! | `stats` | node/edge/secret counts |
16//! | `annotations` / `provenance` | metadata |
17//!
18//! Redaction: `why`/`impact` collapse the secret cone to one marker when the
19//! capability is redacting. Evaluation results never carry secret material in
20//! either mode — a secret is [`Value::Secret`].
21
22use std::collections::{BTreeMap, BTreeSet};
23
24use active_infinity::interop::{
25    dependencies, dependents, impact, secret_tainted_paths, subgraph, why,
26};
27use active_infinity::{decode, encode, AnnotationKind, InfinityModule, Inode, NodeId, Provenance};
28
29use crate::cap::CapId;
30use crate::error::{Error, Result};
31use crate::kernel::Provider;
32use crate::security;
33use crate::value::Response;
34use crate::value::{Arg, Call, Value};
35
36/// Methods a module capability can grant.
37pub const METHODS: &[&str] = &[
38    "evaluate",
39    "why",
40    "impact",
41    "subgraph",
42    "patch",
43    "dependents",
44    "dependencies",
45    "content_hash",
46    "stats",
47    "annotations",
48    "provenance",
49];
50
51/// A provider that serves domain operations on Infinity modules.
52pub struct ModuleProvider {
53    modules: BTreeMap<u64, InfinityModule>,
54    next_object: u64,
55    /// Drop notifications received (object id, capability).
56    pub drops: Vec<(u64, CapId)>,
57    /// Calls served, for tests and diagnostics.
58    pub calls: u64,
59}
60
61impl Default for ModuleProvider {
62    fn default() -> Self {
63        Self::new()
64    }
65}
66
67impl ModuleProvider {
68    pub fn new() -> Self {
69        Self {
70            modules: BTreeMap::new(),
71            next_object: 0,
72            drops: Vec::new(),
73            calls: 0,
74        }
75    }
76
77    /// Install a module and return the object id to mint capabilities for.
78    pub fn install(&mut self, module: InfinityModule) -> u64 {
79        let id = self.next_object;
80        self.next_object += 1;
81        self.modules.insert(id, module);
82        id
83    }
84
85    /// Read a module back (tests and local callers).
86    pub fn module(&self, object: u64) -> Option<&InfinityModule> {
87        self.modules.get(&object)
88    }
89
90    /// Number of modules installed (including patch results).
91    pub fn module_count(&self) -> usize {
92        self.modules.len()
93    }
94
95    fn require(&self, object: u64) -> Result<&InfinityModule> {
96        self.modules
97            .get(&object)
98            .ok_or(Error::InvalidArgument("unknown module"))
99    }
100
101    fn path_arg(call: &Call) -> Option<String> {
102        call.args.iter().find_map(|arg| match arg {
103            Arg::Value(Value::Path(path)) => Some(path.clone()),
104            _ => None,
105        })
106    }
107
108    fn resolve(&self, module: &InfinityModule, call: &Call) -> Result<(String, NodeId)> {
109        let path =
110            Self::path_arg(call).ok_or(Error::InvalidArgument("a path argument is required"))?;
111        let node = active_infinity::interop::resolve_path(module, &path)
112            .ok_or(Error::InvalidArgument("path does not resolve"))?;
113        Ok((path, node))
114    }
115}
116
117impl Provider for ModuleProvider {
118    fn name(&self) -> &'static str {
119        "module"
120    }
121
122    fn methods(&self) -> Vec<String> {
123        METHODS.iter().map(|method| method.to_string()).collect()
124    }
125
126    fn mutating(&self, method: &str) -> bool {
127        method == "patch"
128    }
129
130    fn invoke(&mut self, object: u64, call: &Call) -> Result<Response> {
131        self.calls += 1;
132        let module = self.require(object)?.clone();
133        let response = match call.method.as_str() {
134            "evaluate" => {
135                let module = match Self::path_arg(call) {
136                    Some(path) => {
137                        let node = active_infinity::interop::resolve_path(&module, &path)
138                            .ok_or(Error::InvalidArgument("path does not resolve"))?;
139                        restrict(&module, node)?
140                    }
141                    None => module,
142                };
143                let budget = call.budget.to_active_budget();
144                let (value, _diagnostics) = active_infinity::interop::evaluate(&module, budget)
145                    .map_err(|error| match &error {
146                        active_infinity::Error::Evaluation(code) => Error::Domain(code),
147                        other => Error::Payload(other.code().to_string()),
148                    })?;
149                Response::Value(security::from_active_value(&value))
150            }
151            "why" => {
152                let (_path, node) = self.resolve(&module, call)?;
153                let labels = why(&module, node);
154                Response::Labels(apply_redaction(&module, labels, call.redact))
155            }
156            "impact" => {
157                let (_path, node) = self.resolve(&module, call)?;
158                let labels = impact(&module, node);
159                Response::Labels(apply_redaction(&module, labels, call.redact))
160            }
161            "subgraph" => {
162                let (_path, node) = self.resolve(&module, call)?;
163                let sub = restrict(&module, node)?;
164                let bytes =
165                    encode(&sub).map_err(|error| Error::Payload(error.code().to_string()))?;
166                Response::Module(bytes)
167            }
168            "patch" => {
169                let (_path, node) = self.resolve(&module, call)?;
170                if call.read_only {
171                    return Err(Error::ReadOnly);
172                }
173                let replacement = call
174                    .args
175                    .iter()
176                    .find_map(|arg| match arg {
177                        // The first argument names the target path; the
178                        // replacement is the next literal.
179                        Arg::Value(Value::Path(_)) => None,
180                        Arg::Value(value) => literal_node(value),
181                        _ => None,
182                    })
183                    .ok_or(Error::InvalidArgument("patch needs a literal value"))?;
184                let mut patched = module.clone();
185                *patched.node_mut(node)? = replacement;
186                patched.set_provenance(Some(Provenance {
187                    producer: Some("active-rpc/patch".to_string()),
188                    timestamp_ms: 0,
189                    model: None,
190                    tool: Some("patch".to_string()),
191                    parent: None,
192                    entries: Vec::new(),
193                }));
194                let bytes =
195                    encode(&patched).map_err(|error| Error::Payload(error.code().to_string()))?;
196                // A patch advances the object in place: every holder of a
197                // capability for it observes the new version, which is what
198                // "patch returns the new module capability" means without a
199                // provider-side mint hook. Mint a fresh capability when
200                // isolation is required.
201                self.modules.insert(object, patched);
202                Response::Module(bytes)
203            }
204            "dependents" => {
205                let (_path, node) = self.resolve(&module, call)?;
206                let labels: Vec<String> = dependents(&module, node)
207                    .into_iter()
208                    .map(|id| active_infinity::interop::node_label(&module, id))
209                    .collect();
210                Response::Labels(apply_redaction(&module, labels, call.redact))
211            }
212            "dependencies" => {
213                let (_path, node) = self.resolve(&module, call)?;
214                let labels: Vec<String> = dependencies(&module, node)
215                    .into_iter()
216                    .map(|id| active_infinity::interop::node_label(&module, id))
217                    .collect();
218                Response::Labels(apply_redaction(&module, labels, call.redact))
219            }
220            "content_hash" => {
221                let bytes =
222                    encode(&module).map_err(|error| Error::Payload(error.code().to_string()))?;
223                let hash = active_infinity::content_hash(&bytes)
224                    .map_err(|error| Error::Payload(error.code().to_string()))?;
225                Response::Value(Value::Text(format!(
226                    "blake3:{}",
227                    active_infinity::ir::hash_hex(&hash)
228                )))
229            }
230            "stats" => {
231                let mut map = BTreeMap::new();
232                map.insert("nodes".to_string(), Value::Int(module.node_count() as i64));
233                map.insert("edges".to_string(), Value::Int(module.edges().len() as i64));
234                map.insert(
235                    "secrets".to_string(),
236                    Value::Int(
237                        module
238                            .nodes()
239                            .iter()
240                            .filter(|node| node.is_secret())
241                            .count() as i64,
242                    ),
243                );
244                map.insert(
245                    "has_provenance".to_string(),
246                    Value::Bool(module.provenance().is_some()),
247                );
248                Response::Value(Value::Map(map))
249            }
250            "annotations" => {
251                let mut out = Vec::new();
252                for annotation in module.annotations() {
253                    let (kind, text) = match &annotation.kind {
254                        AnnotationKind::Intent(text) => ("intent", text.clone()),
255                        AnnotationKind::Constraint { source, .. } => ("constraint", source.clone()),
256                        AnnotationKind::AiImmutable(flag) => ("ai_immutable", flag.to_string()),
257                    };
258                    let text = if call.redact && text.contains("***") {
259                        Value::Secret
260                    } else {
261                        Value::Text(text)
262                    };
263                    out.push(Value::Map(
264                        [
265                            ("node".to_string(), Value::Int(annotation.node.0 as i64)),
266                            ("kind".to_string(), Value::Text(kind.to_string())),
267                            ("text".to_string(), text),
268                        ]
269                        .into_iter()
270                        .collect(),
271                    ));
272                }
273                Response::Value(Value::List(out))
274            }
275            "provenance" => match module.provenance() {
276                None => Response::Value(Value::Null),
277                Some(prov) => {
278                    let mut map = BTreeMap::new();
279                    if let Some(producer) = &prov.producer {
280                        map.insert("producer".to_string(), Value::Text(producer.clone()));
281                    }
282                    if let Some(model) = &prov.model {
283                        map.insert("model".to_string(), Value::Text(model.clone()));
284                    }
285                    if let Some(tool) = &prov.tool {
286                        map.insert("tool".to_string(), Value::Text(tool.clone()));
287                    }
288                    map.insert(
289                        "timestamp_ms".to_string(),
290                        Value::Int(prov.timestamp_ms as i64),
291                    );
292                    Response::Value(Value::Map(map))
293                }
294            },
295            other => {
296                return Err(Error::MethodNotGranted {
297                    method: other.to_string(),
298                })
299            }
300        };
301        Ok(response)
302    }
303
304    fn on_drop(&mut self, object: u64, cap: CapId) {
305        self.drops.push((object, cap));
306    }
307
308    fn take_drop_notifications(&mut self) -> Vec<(u64, CapId)> {
309        std::mem::take(&mut self.drops)
310    }
311}
312
313fn apply_redaction(module: &InfinityModule, labels: Vec<String>, redact: bool) -> Vec<String> {
314    if !redact {
315        return labels;
316    }
317    let tainted = tainted_labels(module);
318    security::redact_cone_labels(labels, &tainted)
319}
320
321/// Labels of every node that is a secret or contains one, transitively.
322///
323/// `secret_tainted_paths` only names nodes that have a value path; a secret
324/// literal nested under a reference has none, so the cone is computed over node
325/// ids and then mapped to labels.
326fn tainted_labels(module: &InfinityModule) -> BTreeSet<String> {
327    let count = module.node_count();
328    if count == 0 {
329        return BTreeSet::new();
330    }
331    let mut tainted = vec![false; count];
332    // A secret kind and everything it references (the locator expression) is
333    // secret material.
334    let mut stack: Vec<usize> = Vec::new();
335    for (index, node) in module.nodes().iter().enumerate() {
336        if node.is_secret() {
337            tainted[index] = true;
338            stack.push(index);
339        }
340    }
341    while let Some(index) = stack.pop() {
342        if let Some(node) = module.nodes().get(index) {
343            for child in node.children() {
344                if !tainted[child.index()] {
345                    tainted[child.index()] = true;
346                    stack.push(child.index());
347                }
348            }
349        }
350    }
351    let mut changed = true;
352    while changed {
353        changed = false;
354        for (index, node) in module.nodes().iter().enumerate() {
355            if tainted[index] {
356                continue;
357            }
358            if node
359                .children()
360                .iter()
361                .any(|child| tainted.get(child.index()).copied().unwrap_or(false))
362            {
363                tainted[index] = true;
364                changed = true;
365            }
366        }
367    }
368    let mut labels = BTreeSet::new();
369    for (index, is_tainted) in tainted.iter().enumerate() {
370        if *is_tainted {
371            labels.insert(active_infinity::interop::node_label(
372                module,
373                NodeId(index as u32),
374            ));
375        }
376    }
377    // Keep the path-based answer too: it covers nodes with value paths whose
378    // label differs from the id-derived one.
379    labels.extend(secret_tainted_paths(module));
380    labels
381}
382
383/// Extract the subgraph rooted at `node` as a standalone module.
384///
385/// The cone is closed under dependencies by construction, so every child
386/// reference stays inside the new arena.
387fn restrict(module: &InfinityModule, node: NodeId) -> Result<InfinityModule> {
388    let cone = subgraph(module, &[node]);
389    let mut sub = InfinityModule::new();
390    let mut map = BTreeMap::new();
391    for id in &cone {
392        let cloned = module
393            .get(*id)
394            .cloned()
395            .ok_or(Error::InvalidArgument("subgraph node disappeared"))?;
396        map.insert(*id, sub.push(cloned));
397    }
398    for (old, new) in &map {
399        if let Some(node) = module.get(*old).cloned() {
400            let rewritten = remap_children(&node, &map)?;
401            *sub.node_mut(*new)? = rewritten;
402        }
403    }
404    sub.set_root(map.get(&node).copied());
405    Ok(sub)
406}
407
408/// Rewrite a node's child references through `map`.
409fn remap_children(node: &Inode, map: &BTreeMap<NodeId, NodeId>) -> Result<Inode> {
410    let resolve = |id: NodeId| -> Result<NodeId> {
411        map.get(&id)
412            .copied()
413            .ok_or(Error::InvalidArgument("subgraph is not closed"))
414    };
415    let rewritten = match node {
416        Inode::Array { elements } => Inode::Array {
417            elements: elements
418                .iter()
419                .map(|id| resolve(*id))
420                .collect::<Result<_>>()?,
421        },
422        Inode::Table { fields } => Inode::Table {
423            fields: fields
424                .iter()
425                .map(|(name, id)| Ok((name.clone(), resolve(*id)?)))
426                .collect::<Result<_>>()?,
427        },
428        Inode::Quantity { value, unit } => Inode::Quantity {
429            value: resolve(*value)?,
430            unit: unit.clone(),
431        },
432        Inode::Ref(id) => Inode::Ref(resolve(*id)?),
433        Inode::SecretRef { locator } => Inode::SecretRef {
434            locator: resolve(*locator)?,
435        },
436        other => other.clone(),
437    };
438    Ok(rewritten)
439}
440
441fn literal_node(value: &Value) -> Option<Inode> {
442    match value {
443        Value::Null => Some(Inode::Null),
444        Value::Bool(flag) => Some(Inode::Bool(*flag)),
445        Value::Int(number) => Some(Inode::Int(*number)),
446        Value::Float(number) => Some(Inode::Float(*number)),
447        Value::Text(text) => Some(Inode::Str(text.clone())),
448        Value::Path(path) => Some(Inode::Str(path.clone())),
449        // Composite values need a full node tree; `patch` accepts literals and
450        // callers that need structure send a module instead.
451        Value::List(_) | Value::Map(_) | Value::Secret | Value::Module(_) => None,
452    }
453}
454
455/// Decode a module returned by `subgraph`/`patch`.
456pub fn decode_module(bytes: &[u8]) -> Result<InfinityModule> {
457    decode(bytes).map_err(|error| Error::Payload(error.code().to_string()))
458}
459
460/// Build a module from a text document (test and example helper).
461pub fn module_from_source(source: &str, name: &str) -> Result<InfinityModule> {
462    active_infinity::interop::from_active_source(source, name)
463        .map_err(|error| Error::Payload(error.code().to_string()))
464}
465
466/// Resolve a dotted path in a module (test and example helper).
467pub fn resolve_path(module: &InfinityModule, path: &str) -> Option<NodeId> {
468    active_infinity::interop::resolve_path(module, path)
469}
470
471/// Node ids in a module's dependency cone, for tests.
472pub fn cone_ids(module: &InfinityModule, path: &str) -> Result<Vec<NodeId>> {
473    let node = active_infinity::interop::resolve_path(module, path)
474        .ok_or(Error::InvalidArgument("path does not resolve"))?;
475    Ok(subgraph(module, &[node]))
476}