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