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                Response::Module(redacted_module_bytes(&sub)?)
165            }
166            "patch" => {
167                let (_path, node) = self.resolve(&module, call)?;
168                if call.read_only {
169                    return Err(Error::ReadOnly);
170                }
171                let replacement = call
172                    .args
173                    .iter()
174                    .find_map(|arg| match arg {
175                        // The first argument names the target path; the
176                        // replacement is the next literal.
177                        Arg::Value(Value::Path(_)) => None,
178                        Arg::Value(value) => literal_node(value),
179                        _ => None,
180                    })
181                    .ok_or(Error::InvalidArgument("patch needs a literal value"))?;
182                let mut patched = module.clone();
183                *patched.node_mut(node)? = replacement;
184                patched.set_provenance(Some(Provenance {
185                    producer: Some("active-rpc/patch".to_string()),
186                    timestamp_ms: 0,
187                    model: None,
188                    tool: Some("patch".to_string()),
189                    parent: None,
190                    entries: Vec::new(),
191                }));
192                let bytes = redacted_module_bytes(&patched)?;
193                // A patch advances the object in place: every holder of a
194                // capability for it observes the new version, which is what
195                // "patch returns the new module capability" means without a
196                // provider-side mint hook. Mint a fresh capability when
197                // isolation is required.
198                self.modules.insert(object, patched);
199                Response::Module(bytes)
200            }
201            "dependents" => {
202                let (_path, node) = self.resolve(&module, call)?;
203                let labels: Vec<String> = dependents(&module, node)
204                    .into_iter()
205                    .map(|id| active_infinity::interop::node_label(&module, id))
206                    .collect();
207                Response::Labels(apply_redaction(&module, labels, call.redact))
208            }
209            "dependencies" => {
210                let (_path, node) = self.resolve(&module, call)?;
211                let labels: Vec<String> = dependencies(&module, node)
212                    .into_iter()
213                    .map(|id| active_infinity::interop::node_label(&module, id))
214                    .collect();
215                Response::Labels(apply_redaction(&module, labels, call.redact))
216            }
217            "content_hash" => {
218                let bytes =
219                    encode(&module).map_err(|error| Error::Payload(error.code().to_string()))?;
220                let hash = active_infinity::content_hash(&bytes)
221                    .map_err(|error| Error::Payload(error.code().to_string()))?;
222                Response::Value(Value::Text(format!(
223                    "blake3:{}",
224                    active_infinity::ir::hash_hex(&hash)
225                )))
226            }
227            "stats" => {
228                let mut map = BTreeMap::new();
229                map.insert("nodes".to_string(), Value::Int(module.node_count() as i64));
230                map.insert("edges".to_string(), Value::Int(module.edges().len() as i64));
231                map.insert(
232                    "secrets".to_string(),
233                    Value::Int(
234                        module
235                            .nodes()
236                            .iter()
237                            .filter(|node| node.is_secret())
238                            .count() as i64,
239                    ),
240                );
241                map.insert(
242                    "has_provenance".to_string(),
243                    Value::Bool(module.provenance().is_some()),
244                );
245                Response::Value(Value::Map(map))
246            }
247            "annotations" => {
248                let tainted_ids = tainted_nodes(&module);
249                let mut out = Vec::new();
250                for annotation in module.annotations() {
251                    let (kind, text) = match &annotation.kind {
252                        AnnotationKind::Intent(text) => ("intent", text.clone()),
253                        AnnotationKind::Constraint { source, .. } => ("constraint", source.clone()),
254                        AnnotationKind::AiImmutable(flag) => ("ai_immutable", flag.to_string()),
255                    };
256                    // A redacting capability hides anything attached to the
257                    // secret cone; a non-redacting one still only sees text that
258                    // canonicalization already redacted.
259                    let hidden = (call.redact && tainted_ids.contains(&annotation.node))
260                        || text.contains(security::SECRET_MARKER);
261                    let text = if hidden {
262                        Value::Secret
263                    } else {
264                        Value::Text(text)
265                    };
266                    out.push(Value::Map(
267                        [
268                            ("node".to_string(), Value::Int(annotation.node.0 as i64)),
269                            ("kind".to_string(), Value::Text(kind.to_string())),
270                            ("text".to_string(), text),
271                        ]
272                        .into_iter()
273                        .collect(),
274                    ));
275                }
276                Response::Value(Value::List(out))
277            }
278            "provenance" => match module.provenance() {
279                None => Response::Value(Value::Null),
280                Some(prov) => {
281                    let mut map = BTreeMap::new();
282                    if let Some(producer) = &prov.producer {
283                        map.insert("producer".to_string(), Value::Text(producer.clone()));
284                    }
285                    if let Some(model) = &prov.model {
286                        map.insert("model".to_string(), Value::Text(model.clone()));
287                    }
288                    if let Some(tool) = &prov.tool {
289                        map.insert("tool".to_string(), Value::Text(tool.clone()));
290                    }
291                    map.insert(
292                        "timestamp_ms".to_string(),
293                        Value::Int(prov.timestamp_ms as i64),
294                    );
295                    Response::Value(Value::Map(map))
296                }
297            },
298            other => {
299                return Err(Error::MethodNotGranted {
300                    method: other.to_string(),
301                })
302            }
303        };
304        Ok(response)
305    }
306
307    fn on_drop(&mut self, object: u64, cap: CapId) {
308        self.drops.push((object, cap));
309    }
310
311    fn take_drop_notifications(&mut self) -> Vec<(u64, CapId)> {
312        std::mem::take(&mut self.drops)
313    }
314}
315
316fn apply_redaction(module: &InfinityModule, labels: Vec<String>, redact: bool) -> Vec<String> {
317    if !redact {
318        return labels;
319    }
320    let tainted = tainted_labels(module);
321    security::redact_cone_labels(labels, &tainted)
322}
323
324/// Labels of every node that is a secret or contains one, transitively.
325///
326/// `secret_tainted_paths` only names nodes that have a value path; a secret
327/// literal nested under a reference has none, so the cone is computed over node
328/// ids and then mapped to labels.
329fn tainted_labels(module: &InfinityModule) -> BTreeSet<String> {
330    let count = module.node_count();
331    if count == 0 {
332        return BTreeSet::new();
333    }
334    let mut tainted = vec![false; count];
335    // A secret kind and everything it references (the locator expression) is
336    // secret material.
337    let mut stack: Vec<usize> = Vec::new();
338    for (index, node) in module.nodes().iter().enumerate() {
339        if node.is_secret() {
340            tainted[index] = true;
341            stack.push(index);
342        }
343    }
344    while let Some(index) = stack.pop() {
345        if let Some(node) = module.nodes().get(index) {
346            for child in node.children() {
347                if !tainted[child.index()] {
348                    tainted[child.index()] = true;
349                    stack.push(child.index());
350                }
351            }
352        }
353    }
354    let mut changed = true;
355    while changed {
356        changed = false;
357        for (index, node) in module.nodes().iter().enumerate() {
358            if tainted[index] {
359                continue;
360            }
361            if node
362                .children()
363                .iter()
364                .any(|child| tainted.get(child.index()).copied().unwrap_or(false))
365            {
366                tainted[index] = true;
367                changed = true;
368            }
369        }
370    }
371    let mut labels = BTreeSet::new();
372    for (index, is_tainted) in tainted.iter().enumerate() {
373        if *is_tainted {
374            labels.insert(active_infinity::interop::node_label(
375                module,
376                NodeId(index as u32),
377            ));
378        }
379    }
380    // Keep the path-based answer too: it covers nodes with value paths whose
381    // label differs from the id-derived one.
382    labels.extend(secret_tainted_paths(module));
383    labels
384}
385
386/// Encode a module for an RPC payload with every secret replaced by the marker.
387///
388/// The RPC wire has no tag that carries secret bytes (`docs/spec/wire.md` §2.2),
389/// so a module returned by `subgraph` or `patch` is a redacted projection: the
390/// structure survives, the material does not. A caller that needs the raw module
391/// moves it through a transport payload, not through a capability result.
392fn redacted_module_bytes(module: &InfinityModule) -> Result<Vec<u8>> {
393    let mut redacted = module.clone();
394    for index in 0..redacted.node_count() {
395        let id = NodeId(index as u32);
396        let is_secret = redacted
397            .get(id)
398            .map(|node| node.is_secret())
399            .unwrap_or(false);
400        if is_secret {
401            *redacted.node_mut(id)? = Inode::Str(security::SECRET_MARKER.to_string());
402        }
403    }
404    let bytes = encode(&redacted).map_err(|error| Error::Payload(error.code().to_string()))?;
405    security::check_module_payload(&bytes)?;
406    Ok(bytes)
407}
408
409/// Node ids that are secrets or contain one, transitively.
410fn tainted_nodes(module: &InfinityModule) -> BTreeSet<NodeId> {
411    let mut tainted = BTreeSet::new();
412    let mut stack: Vec<NodeId> = Vec::new();
413    for (index, node) in module.nodes().iter().enumerate() {
414        if node.is_secret() {
415            let id = NodeId(index as u32);
416            tainted.insert(id);
417            stack.push(id);
418        }
419    }
420    while let Some(id) = stack.pop() {
421        if let Some(node) = module.get(id) {
422            for child in node.children() {
423                if tainted.insert(child) {
424                    stack.push(child);
425                }
426            }
427        }
428    }
429    // Containment: ancestors of a secret are tainted too.
430    let mut changed = true;
431    while changed {
432        changed = false;
433        for (index, node) in module.nodes().iter().enumerate() {
434            let id = NodeId(index as u32);
435            if tainted.contains(&id) {
436                continue;
437            }
438            if node.children().iter().any(|child| tainted.contains(child)) {
439                tainted.insert(id);
440                changed = true;
441            }
442        }
443    }
444    tainted
445}
446
447/// Extract the subgraph rooted at `node` as a standalone module.
448///
449/// The cone is closed under dependencies by construction, so every child
450/// reference stays inside the new arena.
451fn restrict(module: &InfinityModule, node: NodeId) -> Result<InfinityModule> {
452    let cone = subgraph(module, &[node]);
453    let mut sub = InfinityModule::new();
454    let mut map = BTreeMap::new();
455    for id in &cone {
456        let cloned = module
457            .get(*id)
458            .cloned()
459            .ok_or(Error::InvalidArgument("subgraph node disappeared"))?;
460        map.insert(*id, sub.push(cloned));
461    }
462    for (old, new) in &map {
463        if let Some(node) = module.get(*old).cloned() {
464            let rewritten = remap_children(&node, &map)?;
465            *sub.node_mut(*new)? = rewritten;
466        }
467    }
468    sub.set_root(map.get(&node).copied());
469    Ok(sub)
470}
471
472/// Rewrite a node's child references through `map`.
473fn remap_children(node: &Inode, map: &BTreeMap<NodeId, NodeId>) -> Result<Inode> {
474    let resolve = |id: NodeId| -> Result<NodeId> {
475        map.get(&id)
476            .copied()
477            .ok_or(Error::InvalidArgument("subgraph is not closed"))
478    };
479    let rewritten = match node {
480        Inode::Array { elements } => Inode::Array {
481            elements: elements
482                .iter()
483                .map(|id| resolve(*id))
484                .collect::<Result<_>>()?,
485        },
486        Inode::Table { fields } => Inode::Table {
487            fields: fields
488                .iter()
489                .map(|(name, id)| Ok((name.clone(), resolve(*id)?)))
490                .collect::<Result<_>>()?,
491        },
492        Inode::Quantity { value, unit } => Inode::Quantity {
493            value: resolve(*value)?,
494            unit: unit.clone(),
495        },
496        Inode::Ref(id) => Inode::Ref(resolve(*id)?),
497        Inode::SecretRef { locator } => Inode::SecretRef {
498            locator: resolve(*locator)?,
499        },
500        other => other.clone(),
501    };
502    Ok(rewritten)
503}
504
505fn literal_node(value: &Value) -> Option<Inode> {
506    match value {
507        Value::Null => Some(Inode::Null),
508        Value::Bool(flag) => Some(Inode::Bool(*flag)),
509        Value::Int(number) => Some(Inode::Int(*number)),
510        Value::Float(number) => Some(Inode::Float(*number)),
511        Value::Text(text) => Some(Inode::Str(text.clone())),
512        Value::Path(path) => Some(Inode::Str(path.clone())),
513        // Composite values need a full node tree; `patch` accepts literals and
514        // callers that need structure send a module instead.
515        Value::List(_) | Value::Map(_) | Value::Secret | Value::Module(_) => None,
516    }
517}
518
519/// Decode a module returned by `subgraph`/`patch`.
520pub fn decode_module(bytes: &[u8]) -> Result<InfinityModule> {
521    decode(bytes).map_err(|error| Error::Payload(error.code().to_string()))
522}
523
524/// Build a module from a text document (test and example helper).
525pub fn module_from_source(source: &str, name: &str) -> Result<InfinityModule> {
526    active_infinity::interop::from_active_source(source, name)
527        .map_err(|error| Error::Payload(error.code().to_string()))
528}
529
530/// Resolve a dotted path in a module (test and example helper).
531pub fn resolve_path(module: &InfinityModule, path: &str) -> Option<NodeId> {
532    active_infinity::interop::resolve_path(module, path)
533}
534
535/// Node ids in a module's dependency cone, for tests.
536pub fn cone_ids(module: &InfinityModule, path: &str) -> Result<Vec<NodeId>> {
537    let node = active_infinity::interop::resolve_path(module, path)
538        .ok_or(Error::InvalidArgument("path does not resolve"))?;
539    Ok(subgraph(module, &[node]))
540}