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