Skip to main content

ikigai_script/
plan.rs

1//! **Plans as a script language**: a stored `ik:Process` graph is a script whose language is
2//! `plan`.
3//!
4//! A plan is the most analyzable script there is: finite (no conditionals, no loops; a
5//! branch is a step that calls a language), so it is validated before it is stored, every
6//! step's target is known in advance, and **the capability it needs is DERIVED from its
7//! steps' own contracts** rather than declared by its author. It gets everything a Lisp or
8//! SPARQL script gets (versions, the three authorities, run records, the manifold) and three
9//! things of its own, none of which is ever taken from a caller's argument:
10//!
11//! - **validation at publish**: the plan is a sub-request to `urn:plan:validate` (the SHACL
12//!   shapes of the process vocabulary plus the executor's own checks), and one that does not
13//!   conform is refused, naming the shape it broke. See [`validate()`].
14//! - **derived authority**: a sub-request to `urn:plan:requires`, which reads each step's
15//!   target contract (`Description::required_scopes` for the step's verb) without invoking
16//!   it. The union is stored with the version; a `requires=` that says anything else is
17//!   refused, naming the derived set. See [`derive()`].
18//! - **read or write, from the steps**: a plan whose every step is a `Source`, `Exists` or
19//!   `Meta` runs as a READ at `…:result`; one with any `Sink` or `Delete` step anywhere in the
20//!   graph is a WRITE, run at `…:runs`. See [`Analysis::mutates`].
21//!
22//! A run is a sub-request to `urn:plan:eval` with the plan as `in` and the plan's own
23//! parameters (`ik:input`) by name, issued under the runner's capability narrowed exactly as
24//! every script's is (see [`crate::authority`]). The three names are the HOST's: this crate
25//! links no plan runner, as it links no Lisp interpreter and no SPARQL evaluator.
26//!
27//! # The shallow read
28//!
29//! [`analyze`] reads a plan with a Turtle parser for exactly three things this crate must
30//! decide itself and cannot ask a resource for without running something: the process node,
31//! the verb of every step (read or write), and the declared parameters (the arguments of the
32//! script's own catalog entry). Everything about whether the plan is well formed is
33//! `urn:plan:validate`'s, and publish asks it first.
34//!
35//! ```
36//! use ikigai_script::plan::analyze;
37//!
38//! let plan = r#"
39//! @prefix ik: <https://ikigai-rs.dev/ns#> .
40//! <urn:plan:hello> a ik:Process ;
41//!     ik:input <urn:plan:hello:input:who> ;
42//!     ik:step <urn:plan:hello:step:1> ;
43//!     ik:result <urn:plan:hello:step:1> .
44//! <urn:plan:hello:input:who> ik:inputName "who" ; ik:required false ; ik:default "world" .
45//! <urn:plan:hello:step:1> a ik:Step ; ik:verb "Source" ; ik:resolves <urn:example:greet> .
46//! "#;
47//! let analysis = analyze(plan).unwrap();
48//! assert_eq!(analysis.process, "urn:plan:hello");
49//! assert!(!analysis.mutates()); // every step a Source: a read, at `…:result`
50//! assert_eq!(analysis.parameters[0].name, "who");
51//! assert_eq!(analysis.parameters[0].default.as_deref(), Some("world"));
52//! ```
53//!
54//! # Preference order
55//!
56//! When assembling anything: **a query, then a plan, then Lisp.** A query and a plan both
57//! derive their authority and can be checked before they run; Lisp declares its authority
58//! and can do anything its capability allows in any order. Use Lisp for control flow a plan
59//! cannot express, and preferably as ONE step inside a plan.
60
61use std::collections::{BTreeMap, BTreeSet};
62
63use ikigai_core::{
64    is_deny_scope, ArgRef, Capability, Error, Invocation, Iri, Request, Result, Verb,
65};
66use oxrdf::{NamedOrBlankNode, Term, Triple};
67use oxttl::TurtleParser;
68use serde::{Deserialize, Serialize};
69
70use crate::authority::Ceiling;
71
72/// The host's plan evaluator: runs a plan's steps as sub-requests under the caller's
73/// capability.
74pub const EVAL: &str = "urn:plan:eval";
75/// The host's plan validator: the SHACL report plus the executor's checks.
76pub const VALIDATE: &str = "urn:plan:validate";
77/// The host's derivation of a plan's capability from its steps' contracts.
78pub const REQUIRES: &str = "urn:plan:requires";
79
80/// The longest plan accepted, in bytes. A bound on the work a publish does (two
81/// sub-requests and a parse over the text), refused rather than truncated. The Turtle
82/// parser keeps an explicit stack, so nesting is not a claim on the thread's stack.
83pub const MAX_PLAN_BYTES: usize = 1 << 20;
84
85/// Parameter names the doors already use: `in` and `as` are `urn:plan:eval`'s own, `name`
86/// is the script's binding, `content` is `…:runs`' piped body, and the rest are the
87/// transports' stamps ([`crate::TRANSPORT_ARGUMENTS`]), which a run ignores. A plan
88/// declaring one could never be given it.
89pub const RESERVED_PARAMETERS: [&str; 8] = [
90    "in",
91    "as",
92    "name",
93    "content",
94    "received",
95    "client",
96    "principal",
97    "content-type",
98];
99
100const IK: &str = "https://ikigai-rs.dev/ns#";
101const RDF_TYPE: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
102const RDFS_COMMENT: &str = "http://www.w3.org/2000/01/rdf-schema#comment";
103const SH: &str = "http://www.w3.org/ns/shacl#";
104const TURTLE: &str = "text/turtle";
105/// Where `urn:plan:requires` names its outcomes.
106const OUTCOME: &str = "urn:ikigai:plan:requires:outcome:";
107
108/// One step, as far as this crate reads it.
109#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
110pub struct Step {
111    /// The step's IRI.
112    pub iri: String,
113    /// Its verb, as the vocabulary spells it: `Source`, `Sink`, `Exists`, `Delete`, `Meta`.
114    pub verb: String,
115    /// The IRI it resolves.
116    pub resolves: String,
117}
118
119impl Step {
120    /// Whether the step changes anything: a `Sink` or a `Delete`.
121    pub fn mutates(&self) -> bool {
122        matches!(self.verb.as_str(), "Sink" | "Delete")
123    }
124}
125
126/// One of a plan's declared parameters (an `ik:input` ArgSpec node).
127#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
128pub struct Parameter {
129    /// `ik:inputName`: the argument it arrives as.
130    pub name: String,
131    /// `ik:required` (an ArgSpec is required unless it says otherwise).
132    pub required: bool,
133    /// `ik:default`.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub default: Option<String>,
136    /// `ik:class`.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub class: Option<String>,
139    /// `ik:summary`.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub summary: Option<String>,
142}
143
144impl Parameter {
145    /// Whether a run must be given it: required, with no default to fall back on.
146    pub fn must_be_given(&self) -> bool {
147        self.required && self.default.is_none()
148    }
149}
150
151/// What a plan says about itself, read from its graph.
152#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
153pub struct Analysis {
154    /// The `ik:Process` node: `urn:plan:{id}`.
155    pub process: String,
156    /// Every step in the graph, by IRI.
157    pub steps: Vec<Step>,
158    /// Its declared parameters, by name.
159    pub parameters: Vec<Parameter>,
160}
161
162impl Analysis {
163    /// Whether the plan is a WRITE: any `Sink` or `Delete` step ANYWHERE in the graph,
164    /// including one the result does not depend on. Conservative on purpose: deciding from
165    /// the steps a run reaches would make the answer depend on the executor's reachability
166    /// rule, and calling a read a write only costs a run record.
167    pub fn mutates(&self) -> bool {
168        self.steps.iter().any(Step::mutates)
169    }
170}
171
172// ---------------------------------------------------------------------------------------
173// The shallow read
174// ---------------------------------------------------------------------------------------
175
176/// A parsed Turtle graph, queried by subject and predicate.
177struct Graph {
178    triples: Vec<Triple>,
179}
180
181/// A subject or object node, keyed so the two can be compared: an IRI as itself, a blank
182/// node as `_:{id}`.
183fn key(node: &NamedOrBlankNode) -> String {
184    match node {
185        NamedOrBlankNode::NamedNode(n) => n.as_str().to_string(),
186        NamedOrBlankNode::BlankNode(b) => format!("_:{}", b.as_str()),
187    }
188}
189
190fn object_key(term: &Term) -> Option<String> {
191    if let Term::NamedNode(n) = term {
192        return Some(n.as_str().to_string());
193    }
194    if let Term::BlankNode(b) = term {
195        return Some(format!("_:{}", b.as_str()));
196    }
197    None
198}
199
200fn object_literal(term: &Term) -> Option<&str> {
201    if let Term::Literal(l) = term {
202        return Some(l.value());
203    }
204    None
205}
206
207impl Graph {
208    fn parse(text: &str, arg: &str, what: &str) -> Result<Graph> {
209        let triples = TurtleParser::new()
210            .for_slice(text.as_bytes())
211            .collect::<std::result::Result<Vec<_>, _>>()
212            .map_err(|e| Error::InvalidArgument {
213                name: arg.to_string(),
214                detail: format!("{what} is not valid Turtle: {e}"),
215            })?;
216        Ok(Graph { triples })
217    }
218
219    fn objects<'a>(
220        &'a self,
221        subject: &'a str,
222        predicate: &'a str,
223    ) -> impl Iterator<Item = &'a Term> {
224        self.triples
225            .iter()
226            .filter(move |t| key(&t.subject) == subject && t.predicate.as_str() == predicate)
227            .map(|t| &t.object)
228    }
229
230    fn of_class(&self, class: &str) -> BTreeSet<String> {
231        self.triples
232            .iter()
233            .filter(|t| {
234                t.predicate.as_str() == RDF_TYPE && object_key(&t.object).as_deref() == Some(class)
235            })
236            .map(|t| key(&t.subject))
237            .collect()
238    }
239
240    fn with_predicate(&self, predicate: &str) -> BTreeSet<String> {
241        self.triples
242            .iter()
243            .filter(|t| t.predicate.as_str() == predicate)
244            .map(|t| key(&t.subject))
245            .collect()
246    }
247
248    /// The one literal of `subject predicate`, or `None` when there is none. Two is a
249    /// refusal: the read cannot choose between them.
250    fn literal(&self, subject: &str, predicate: &str, arg: &str) -> Result<Option<String>> {
251        let mut values = self.objects(subject, predicate);
252        let first = values.next();
253        if values.next().is_some() {
254            return Err(Error::InvalidArgument {
255                name: arg.to_string(),
256                detail: format!("<{subject}> has more than one <{predicate}>"),
257            });
258        }
259        match first {
260            None => Ok(None),
261            Some(term) => object_literal(term)
262                .map(|v| Some(v.to_string()))
263                .ok_or_else(|| Error::InvalidArgument {
264                    name: arg.to_string(),
265                    detail: format!("<{subject}> <{predicate}> is not a literal"),
266                }),
267        }
268    }
269
270    fn iri(&self, subject: &str, predicate: &str, arg: &str) -> Result<Option<String>> {
271        let mut values = self.objects(subject, predicate);
272        let first = values.next();
273        if values.next().is_some() {
274            return Err(Error::InvalidArgument {
275                name: arg.to_string(),
276                detail: format!("<{subject}> has more than one <{predicate}>"),
277            });
278        }
279        match first {
280            None => Ok(None),
281            Some(term) => object_key(term)
282                .map(Some)
283                .ok_or_else(|| Error::InvalidArgument {
284                    name: arg.to_string(),
285                    detail: format!("<{subject}> <{predicate}> is not a node"),
286                }),
287        }
288    }
289}
290
291/// Refuse a plan over [`MAX_PLAN_BYTES`] (never truncate it), before anything is parsed or
292/// sent anywhere.
293pub fn check_bound(source: &str) -> Result<()> {
294    if source.len() > MAX_PLAN_BYTES {
295        return Err(Error::InvalidArgument {
296            name: "content".to_string(),
297            detail: format!(
298                "the plan is {} bytes; a plan is at most {MAX_PLAN_BYTES}",
299                source.len()
300            ),
301        });
302    }
303    Ok(())
304}
305
306fn ik(term: &str) -> String {
307    format!("{IK}{term}")
308}
309
310/// Read a plan for what this crate decides itself: its process node, the verb of every step
311/// and its declared parameters. Every refusal is an `InvalidArgument` on `content`.
312///
313/// A step is any node typed `ik:Step` or carrying an `ik:verb`: the wider of the two, so a
314/// mutating step cannot hide from the read/write decision by leaving off its type.
315pub fn analyze(source: &str) -> Result<Analysis> {
316    const ARG: &str = "content";
317    check_bound(source)?;
318    let graph = Graph::parse(source, ARG, "the plan")?;
319    let processes = graph.of_class(&ik("Process"));
320    let process = match processes.len() {
321        1 => processes.into_iter().next().expect("one"),
322        0 => {
323            return Err(Error::InvalidArgument {
324                name: ARG.to_string(),
325                detail: "no ik:Process in this graph: it is not a plan".to_string(),
326            })
327        }
328        n => {
329            return Err(Error::InvalidArgument {
330                name: ARG.to_string(),
331                detail: format!("{n} ik:Process nodes in one graph; a plan holds exactly one"),
332            })
333        }
334    };
335
336    let mut step_iris = graph.of_class(&ik("Step"));
337    step_iris.extend(graph.with_predicate(&ik("verb")));
338    let mut steps = Vec::with_capacity(step_iris.len());
339    for iri in step_iris {
340        let verb =
341            graph
342                .literal(&iri, &ik("verb"), ARG)?
343                .ok_or_else(|| Error::InvalidArgument {
344                    name: ARG.to_string(),
345                    detail: format!("<{iri}> is a step with no ik:verb"),
346                })?;
347        if !matches!(
348            verb.as_str(),
349            "Source" | "Sink" | "Exists" | "Delete" | "Meta"
350        ) {
351            return Err(Error::InvalidArgument {
352                name: ARG.to_string(),
353                detail: format!(
354                    "<{iri}> issues \"{verb}\"; a step issues one of Source, Sink, Exists, \
355                     Delete, Meta"
356                ),
357            });
358        }
359        let resolves = graph.iri(&iri, &ik("resolves"), ARG)?.unwrap_or_default();
360        steps.push(Step {
361            iri,
362            verb,
363            resolves,
364        });
365    }
366
367    let mut parameters = Vec::new();
368    for node in graph.objects(&process, &ik("input")) {
369        let Some(node) = object_key(node) else {
370            return Err(Error::InvalidArgument {
371                name: ARG.to_string(),
372                detail: format!("<{process}> ik:input is not a node"),
373            });
374        };
375        let name = graph
376            .literal(&node, &ik("inputName"), ARG)?
377            .ok_or_else(|| Error::InvalidArgument {
378                name: ARG.to_string(),
379                detail: format!("<{node}> is a parameter with no ik:inputName"),
380            })?;
381        if RESERVED_PARAMETERS.contains(&name.as_str()) {
382            return Err(Error::InvalidArgument {
383                name: ARG.to_string(),
384                detail: format!(
385                    "the parameter `{name}` would never arrive: {} are taken by the doors \
386                     a plan runs through",
387                    RESERVED_PARAMETERS.join(", ")
388                ),
389            });
390        }
391        let required = match graph.literal(&node, &ik("required"), ARG)?.as_deref() {
392            None | Some("true") | Some("1") => true,
393            Some("false") | Some("0") => false,
394            Some(other) => {
395                return Err(Error::InvalidArgument {
396                    name: ARG.to_string(),
397                    detail: format!("<{node}> ik:required \"{other}\" is not a boolean"),
398                })
399            }
400        };
401        parameters.push(Parameter {
402            name,
403            required,
404            default: graph.literal(&node, &ik("default"), ARG)?,
405            class: graph.iri(&node, &ik("class"), ARG)?,
406            summary: graph.literal(&node, &ik("summary"), ARG)?,
407        });
408    }
409    parameters.sort_by(|a, b| a.name.cmp(&b.name));
410    if let Some(pair) = parameters.windows(2).find(|w| w[0].name == w[1].name) {
411        return Err(Error::InvalidArgument {
412            name: ARG.to_string(),
413            detail: format!("the parameter `{}` is declared twice", pair[0].name),
414        });
415    }
416    Ok(Analysis {
417        process,
418        steps,
419        parameters,
420    })
421}
422
423// ---------------------------------------------------------------------------------------
424// Publish: validate, then derive
425// ---------------------------------------------------------------------------------------
426
427fn door_request(door: &str, plan: &str) -> Request {
428    Request::new(Verb::Source, Iri::parse(door).expect("a constant IRI"))
429        .with_arg("in", ArgRef::Inline(plan.as_bytes().to_vec()))
430        .with_arg("as", ArgRef::Inline(TURTLE.as_bytes().to_vec()))
431}
432
433/// A door's refusal of the plan, said about the script's `content` (what the publisher
434/// sent), and a door that is not there said as what it is: this host takes no plans.
435fn from_door(door: &str, error: Error) -> Error {
436    match error {
437        Error::InvalidArgument { name, detail } if name == "in" => Error::InvalidArgument {
438            name: "content".to_string(),
439            detail: format!("{door}: {detail}"),
440        },
441        Error::Unresolved(_) => Error::InvalidArgument {
442            name: "language".to_string(),
443            detail: format!(
444                "this host takes no plans: {door} does not resolve here (a host offers plans \
445                 by binding the plan evaluator's three doors, {EVAL}, {VALIDATE} and \
446                 {REQUIRES})"
447            ),
448        },
449        other => other,
450    }
451}
452
453/// Validate a plan through the host's `urn:plan:validate`, under the publisher's
454/// capability. A plan that does not conform is refused with `InvalidArgument` on `content`,
455/// naming every shape (or executor check) it broke, the node, and the shape's message.
456pub async fn validate(inv: &Invocation<'_>, plan: &str) -> Result<()> {
457    let report = inv
458        .issue(door_request(VALIDATE, plan))
459        .await
460        .map_err(|e| from_door(VALIDATE, e))?;
461    let text = String::from_utf8_lossy(&report.bytes);
462    let graph = Graph::parse(&text, "content", &format!("{VALIDATE}'s report"))
463        .map_err(|e| Error::Endpoint(e.to_string()))?;
464    let sh = |term: &str| format!("{SH}{term}");
465    let reports = graph.of_class(&sh("ValidationReport"));
466    let Some(report_node) = reports.iter().next() else {
467        return Err(Error::Endpoint(format!(
468            "{VALIDATE} answered a report with no sh:ValidationReport"
469        )));
470    };
471    let conforms = graph
472        .objects(report_node, &sh("conforms"))
473        .any(|o| object_literal(o) == Some("true"));
474    if conforms {
475        return Ok(());
476    }
477    let mut problems = Vec::new();
478    for result in graph.objects(report_node, &sh("result")) {
479        let Some(node) = object_key(result) else {
480            continue;
481        };
482        let first = |predicate: &str| -> Option<String> {
483            graph.objects(&node, &sh(predicate)).next().map(|o| {
484                object_key(o)
485                    .or_else(|| object_literal(o).map(str::to_string))
486                    .unwrap_or_default()
487            })
488        };
489        let rule = first("sourceConstraint")
490            .or_else(|| first("sourceShape"))
491            .or_else(|| first("sourceConstraintComponent"))
492            .unwrap_or_else(|| "a SHACL constraint".to_string());
493        let focus = first("focusNode").unwrap_or_default();
494        let message = first("resultMessage").unwrap_or_default();
495        problems.push(format!("{rule} at <{focus}>: {message}"));
496    }
497    problems.sort();
498    Err(Error::InvalidArgument {
499        name: "content".to_string(),
500        detail: format!(
501            "the plan does not validate ({VALIDATE}), so it is not stored: {}",
502            if problems.is_empty() {
503                "the report names no result".to_string()
504            } else {
505                problems.join("; ")
506            }
507        ),
508    })
509}
510
511/// A plan's capability, DERIVED by the host's `urn:plan:requires` from its steps' contracts
512/// (read with `Meta`, so nothing is invoked).
513///
514/// Refused, with `InvalidArgument` on `content`, when the derivation is incomplete: a step
515/// whose target resolves nowhere (or whose contract cannot be read) has no known
516/// requirement, and storing the rest would be storing a floor as the requirement. Refused
517/// on `requires` when the publisher states a `requires` that is not exactly the derived set,
518/// naming it: an author can neither under- nor over-declare.
519pub async fn derive(
520    inv: &Invocation<'_>,
521    plan: &str,
522    declared: Option<&str>,
523) -> Result<BTreeSet<String>> {
524    let answer = inv
525        .issue(door_request(REQUIRES, plan))
526        .await
527        .map_err(|e| from_door(REQUIRES, e))?;
528    let text = String::from_utf8_lossy(&answer.bytes);
529    let graph = Graph::parse(&text, "content", &format!("{REQUIRES}'s answer"))
530        .map_err(|e| Error::Endpoint(e.to_string()))?;
531    let processes = graph.of_class(&ik("Process"));
532    let [process] = processes.iter().collect::<Vec<_>>()[..] else {
533        return Err(Error::Endpoint(format!(
534            "{REQUIRES} answered {} ik:Process nodes; it describes exactly one plan",
535            processes.len()
536        )));
537    };
538    let outcome = graph.iri(process, &ik("outcome"), "content")?;
539    if outcome.as_deref() != Some(&format!("{OUTCOME}complete")) {
540        let mut unresolved = Vec::new();
541        for step in graph.of_class(&ik("Step")) {
542            let resolved = graph.iri(&step, &ik("outcome"), "content")?;
543            if resolved.as_deref() == Some(&format!("{OUTCOME}resolved")) {
544                continue;
545            }
546            let reasons: Vec<String> = graph
547                .objects(&step, RDFS_COMMENT)
548                .filter_map(object_literal)
549                .map(str::to_string)
550                .collect();
551            unresolved.push(format!(
552                "<{step}> ({})",
553                if reasons.is_empty() {
554                    "no reason given".to_string()
555                } else {
556                    reasons.join("; ")
557                }
558            ));
559        }
560        return Err(Error::InvalidArgument {
561            name: "content".to_string(),
562            detail: format!(
563                "the plan's authority cannot be derived, so it is not stored: {REQUIRES} could \
564                 not read every step's contract, and a partial set would be a floor, not the \
565                 requirement. Unresolved: {}",
566                if unresolved.is_empty() {
567                    format!("{REQUIRES} answered {outcome:?} and named no step")
568                } else {
569                    unresolved.join(", ")
570                }
571            ),
572        });
573    }
574    let derived: BTreeSet<String> = graph
575        .objects(process, &ik("requires"))
576        .filter_map(object_key)
577        .collect();
578    if let Some(bad) = derived.iter().find(|s| is_deny_scope(s)) {
579        return Err(Error::Endpoint(format!(
580            "{REQUIRES} derived the exclusion `{bad}`, which is not a requirement"
581        )));
582    }
583    if let Some(said) = declared {
584        let said: BTreeSet<String> = said.split_whitespace().map(str::to_string).collect();
585        if said != derived {
586            let listed = |set: &BTreeSet<String>| {
587                if set.is_empty() {
588                    "nothing".to_string()
589                } else {
590                    set.iter().cloned().collect::<Vec<_>>().join(" ")
591                }
592            };
593            return Err(Error::InvalidArgument {
594                name: "requires".to_string(),
595                detail: format!(
596                    "a plan's authority is derived from its steps' contracts, not declared, \
597                     and this one derives {}; the `requires` given says {}. Omit `requires`",
598                    listed(&derived),
599                    listed(&said)
600                ),
601            });
602        }
603    }
604    Ok(derived)
605}
606
607// ---------------------------------------------------------------------------------------
608// Running
609// ---------------------------------------------------------------------------------------
610
611/// The scopes a plan run is issued under, with every derived FAMILY (`prefix*`) turned
612/// into exact grants.
613///
614/// A family reaches a run's `keep` only as the marker a ROOT publisher's grant leaves
615/// ([`crate::authority::effective`]); a scoped publisher's grant already holds the exact
616/// members it had. Attenuation keeps grants by exact name, so the marker is replaced here:
617///
618/// - a scoped runner: each grant the runner holds under the prefix that the host's ceiling
619///   allows (the publisher, root, held all of them);
620/// - a root runner: each exact grant under the prefix that the ceiling NAMES, and the
621///   family itself only when the ceiling allows the whole family (no ceiling, or a
622///   `prefix*` line). Root cannot be enumerated, so then the run keeps the bare family: the kernel's floor (a presence test) admits the step, and
623///   the target module's own rule (`urn:cap:net:<host>`, `urn:cap:fs:read:<path>`) refuses
624///   it, fail closed. A host that wants a root-published plan with such a step to run under
625///   root lists the members in the script's ceiling.
626pub fn expand_families(
627    keep: BTreeSet<String>,
628    runner: &Capability,
629    ceiling: &Ceiling,
630) -> BTreeSet<String> {
631    let mut out = BTreeSet::new();
632    for scope in keep {
633        let Some(prefix) = scope.strip_suffix('*').filter(|_| !is_deny_scope(&scope)) else {
634            out.insert(scope);
635            continue;
636        };
637        match runner.scopes() {
638            Some(held) => out.extend(
639                held.iter()
640                    .filter(|s| s.starts_with(prefix) && !s.ends_with('*') && !is_deny_scope(s))
641                    .filter(|s| ceiling.allows(s))
642                    .cloned(),
643            ),
644            None => {
645                if let Ceiling::Scopes(named) = ceiling {
646                    out.extend(
647                        named
648                            .iter()
649                            .filter(|s| {
650                                s.starts_with(prefix) && !s.ends_with('*') && !is_deny_scope(s)
651                            })
652                            .cloned(),
653                    );
654                }
655                if ceiling.allows(&scope) {
656                    out.insert(scope.clone());
657                }
658            }
659        }
660    }
661    out
662}
663
664/// The request a run issues: `urn:plan:eval` with the plan as `in`, each given parameter by
665/// its own name, and `as` when the caller asked for a face.
666pub fn eval_request(
667    program: &str,
668    values: &BTreeMap<String, String>,
669    face: Option<&str>,
670) -> Request {
671    let mut request = Request::new(Verb::Source, Iri::parse(EVAL).expect("a constant IRI"))
672        .with_arg("in", ArgRef::Inline(program.as_bytes().to_vec()));
673    for (name, value) in values {
674        request = request.with_arg(name.clone(), ArgRef::Inline(value.as_bytes().to_vec()));
675    }
676    if let Some(face) = face {
677        request = request.with_arg("as", ArgRef::Inline(face.as_bytes().to_vec()));
678    }
679    request
680}
681
682#[cfg(test)]
683mod tests {
684    use super::*;
685
686    fn set(xs: &[&str]) -> BTreeSet<String> {
687        xs.iter().map(|s| s.to_string()).collect()
688    }
689
690    const TWO_STEPS: &str = r#"
691@prefix ik: <https://ikigai-rs.dev/ns#> .
692<urn:plan:p> a ik:Process ; ik:step <urn:plan:p:step:1> , <urn:plan:p:step:2> ;
693    ik:result <urn:plan:p:step:2> .
694<urn:plan:p:step:1> a ik:Step ; ik:verb "Source" ; ik:resolves <urn:a> .
695<urn:plan:p:step:2> ik:verb "Sink" ; ik:resolves <urn:b> ; ik:pipeFrom <urn:plan:p:step:1> .
696"#;
697
698    #[test]
699    fn a_sink_anywhere_makes_a_write_even_untyped() {
700        let analysis = analyze(TWO_STEPS).unwrap();
701        assert_eq!(analysis.steps.len(), 2);
702        assert!(analysis.mutates());
703        let read = TWO_STEPS.replace("\"Sink\"", "\"Exists\"");
704        assert!(!analyze(&read).unwrap().mutates());
705    }
706
707    #[test]
708    fn the_read_refuses_what_it_cannot_classify() {
709        for (plan, says) in [
710            ("not turtle at all {", "not valid Turtle"),
711            ("<urn:x> <urn:y> <urn:z> .", "no ik:Process"),
712            (
713                &TWO_STEPS.replace("\"Sink\"", "\"Launch\"") as &str,
714                "Launch",
715            ),
716            (
717                &TWO_STEPS.replace("ik:verb \"Sink\"", "ik:verb \"Sink\" , \"Source\""),
718                "more than one",
719            ),
720        ] {
721            match analyze(plan) {
722                Err(Error::InvalidArgument { name, detail }) => {
723                    assert_eq!(name, "content");
724                    assert!(detail.contains(says), "{says}: {detail}");
725                }
726                other => panic!("{says}: {other:?}"),
727            }
728        }
729        let huge = format!("{TWO_STEPS}#{}", "x".repeat(MAX_PLAN_BYTES));
730        assert!(analyze(&huge).is_err());
731    }
732
733    #[test]
734    fn a_reserved_parameter_is_refused() {
735        let plan = format!(
736            "{TWO_STEPS}<urn:plan:p> ik:input <urn:plan:p:input:in> .\n\
737             <urn:plan:p:input:in> ik:inputName \"in\" .\n"
738        );
739        assert!(matches!(analyze(&plan), Err(Error::InvalidArgument { .. })));
740    }
741
742    #[test]
743    fn a_root_publishers_family_becomes_the_runners_members() {
744        let keep = set(&["urn:cap:a", "urn:cap:net:*"]);
745        let runner = Capability::scoped([
746            "urn:cap:a",
747            "urn:cap:net:example.com",
748            "urn:cap:net:other.org",
749            "urn:cap:net:-example.com/admin",
750        ]);
751        let ceiling = Ceiling::scoped(["urn:cap:a", "urn:cap:net:example.com"]);
752        assert_eq!(
753            expand_families(keep.clone(), &runner, &ceiling),
754            set(&["urn:cap:a", "urn:cap:net:example.com"])
755        );
756        // Root runner: the members the ceiling names (and not the family it does not).
757        assert_eq!(
758            expand_families(keep.clone(), &Capability::root(), &ceiling),
759            set(&["urn:cap:a", "urn:cap:net:example.com"])
760        );
761        // Root runner, no ceiling: nothing to enumerate, so the bare family, which the
762        // floor admits and the module refuses.
763        assert_eq!(
764            expand_families(keep.clone(), &Capability::root(), &Ceiling::Unbounded),
765            set(&["urn:cap:a", "urn:cap:net:*"])
766        );
767        // A ceiling that allows none of the family keeps none of it.
768        assert_eq!(
769            expand_families(keep, &Capability::root(), &Ceiling::scoped(["urn:cap:a"])),
770            set(&["urn:cap:a"])
771        );
772    }
773}