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` as the marker a ROOT publisher's grant leaves
615/// ([`crate::authority::effective`]), or as a family a scoped publisher HELD under the
616/// declared one (a namespace grant: `…:run:team-*` or `…:run:team-a-*` under a step's
617/// `…:run:team-*`); otherwise a scoped publisher's grant holds the exact members it had.
618/// Attenuation keeps grants by exact name, so the marker is replaced here:
619///
620/// - a scoped runner: each grant the runner holds under the prefix that the host's ceiling
621///   allows (the publisher held the whole family). That includes a FAMILY the runner holds
622///   under it, which a module may honor as a grant: a runner holding exactly the script
623///   namespace grant `urn:cap:script:run:team-*` keeps it under the marker
624///   `urn:cap:script:run:team-*` (ledger #1222; it was dropped, and a step calling
625///   `urn:script:team-x:result` was refused). Never wider: a runner's grant ABOVE the
626///   prefix (`urn:cap:script:run:*` under `…:run:team-*`) is not under it, and a ceiling
627///   admits a runner's family only when one of its lines covers it as written (a ceiling
628///   naming only `…:run:team-x` admits no `…:run:team-*`, and attenuation cannot narrow
629///   a held family to a member the runner does not hold by name, so that run fails
630///   closed);
631/// - a root runner: each exact grant under the prefix that the ceiling NAMES, and, as a
632///   marker, the MEET of the family and the ceiling: the family itself when the ceiling
633///   allows it whole (no ceiling, or a `prefix*` line at or above it), and each narrower
634///   `prefix…*` line the ceiling holds under it (ledger #1220: gonk's
635///   `urn:cap:store:read:graph:urn:iki:ledger:graph:*` under a step's
636///   `urn:cap:store:read:graph:*`, which was dropped before, so the floor refused the step
637///   naming a scope it was never short of).
638///
639/// ★ **A marker is not a grant, and a root run cannot do better here.** A step's contract
640/// declares a family and never its member (`Description::requires` is static; the member is
641/// the target module's own naming: `urn:iki:ledger:next` needs `urn:cap:ledger:read:default`,
642/// a word its IRI does not contain), so the plan's step targets do not derive the exact
643/// grants, and root holds no list to pick them from. The kernel's floor (a presence test)
644/// admits a step under a marker; a module whose rule is a held prefix (a script namespace
645/// grant, `urn:cap:script:run:team-*`) honors it; a module that checks an exact token
646/// (`urn:cap:ledger:read:<name>`, `urn:cap:net:<host>`) refuses it, fail closed, and the run
647/// says why ([`markers`]). A host that wants such a step to run under root NAMES the members
648/// in the script's ceiling: the ceiling policy is asked on every run, so it can list what
649/// exists. Running root unattenuated instead would not be equivalent: a step declaring the
650/// same family can read by argument (the store's scoped query door declares exactly the
651/// family `next` declares), and only the members keep it inside the ceiling.
652pub fn expand_families(
653    keep: BTreeSet<String>,
654    runner: &Capability,
655    ceiling: &Ceiling,
656) -> BTreeSet<String> {
657    let mut out = BTreeSet::new();
658    for scope in keep {
659        let Some(prefix) = scope.strip_suffix('*').filter(|_| !is_deny_scope(&scope)) else {
660            out.insert(scope);
661            continue;
662        };
663        match runner.scopes() {
664            // Every grant the runner holds under the prefix, its own families included (a
665            // script namespace grant, `urn:cap:script:run:team-*`, is a grant: ledger #1222),
666            // when the ceiling admits it as written. Attenuation keeps it only because the
667            // runner holds exactly that string, so nothing wider than the runner survives.
668            Some(held) => out.extend(
669                held.iter()
670                    .filter(|s| s.starts_with(prefix) && !is_deny_scope(s))
671                    .filter(|s| ceiling.allows(s))
672                    .cloned(),
673            ),
674            None => {
675                if let Ceiling::Scopes(named) = ceiling {
676                    out.extend(
677                        named
678                            .iter()
679                            .filter(|s| s.starts_with(prefix) && !is_deny_scope(s))
680                            // An exact member, or a narrower family: the ceiling's own
681                            // `prefix…*` line is the meet. The family itself, below.
682                            .filter(|s| s.strip_suffix('*') != Some(prefix))
683                            .cloned(),
684                    );
685                }
686                if ceiling.allows(&scope) {
687                    out.insert(scope.clone());
688                }
689            }
690        }
691    }
692    out
693}
694
695/// The family markers a run's `keep` still carries after [`expand_families`]: what it holds
696/// only as "some grant under this prefix", never a member. Empty for a run every step of
697/// which can name its grants exactly. For a SCOPED runner every family left is one the
698/// runner holds itself (a namespace grant), so a host explaining a refusal asks this of a
699/// root run.
700pub fn markers(keep: &BTreeSet<String>) -> Vec<&str> {
701    keep.iter()
702        .filter(|s| s.ends_with('*') && !is_deny_scope(s))
703        .map(String::as_str)
704        .collect()
705}
706
707/// The request a run issues: `urn:plan:eval` with the plan as `in`, each given parameter by
708/// its own name, and `as` when the caller asked for a face.
709pub fn eval_request(
710    program: &str,
711    values: &BTreeMap<String, String>,
712    face: Option<&str>,
713) -> Request {
714    let mut request = Request::new(Verb::Source, Iri::parse(EVAL).expect("a constant IRI"))
715        .with_arg("in", ArgRef::Inline(program.as_bytes().to_vec()));
716    for (name, value) in values {
717        request = request.with_arg(name.clone(), ArgRef::Inline(value.as_bytes().to_vec()));
718    }
719    if let Some(face) = face {
720        request = request.with_arg("as", ArgRef::Inline(face.as_bytes().to_vec()));
721    }
722    request
723}
724
725#[cfg(test)]
726mod tests {
727    use super::*;
728
729    fn set(xs: &[&str]) -> BTreeSet<String> {
730        xs.iter().map(|s| s.to_string()).collect()
731    }
732
733    const TWO_STEPS: &str = r#"
734@prefix ik: <https://ikigai-rs.dev/ns#> .
735<urn:plan:p> a ik:Process ; ik:step <urn:plan:p:step:1> , <urn:plan:p:step:2> ;
736    ik:result <urn:plan:p:step:2> .
737<urn:plan:p:step:1> a ik:Step ; ik:verb "Source" ; ik:resolves <urn:a> .
738<urn:plan:p:step:2> ik:verb "Sink" ; ik:resolves <urn:b> ; ik:pipeFrom <urn:plan:p:step:1> .
739"#;
740
741    #[test]
742    fn a_sink_anywhere_makes_a_write_even_untyped() {
743        let analysis = analyze(TWO_STEPS).unwrap();
744        assert_eq!(analysis.steps.len(), 2);
745        assert!(analysis.mutates());
746        let read = TWO_STEPS.replace("\"Sink\"", "\"Exists\"");
747        assert!(!analyze(&read).unwrap().mutates());
748    }
749
750    #[test]
751    fn the_read_refuses_what_it_cannot_classify() {
752        for (plan, says) in [
753            ("not turtle at all {", "not valid Turtle"),
754            ("<urn:x> <urn:y> <urn:z> .", "no ik:Process"),
755            (
756                &TWO_STEPS.replace("\"Sink\"", "\"Launch\"") as &str,
757                "Launch",
758            ),
759            (
760                &TWO_STEPS.replace("ik:verb \"Sink\"", "ik:verb \"Sink\" , \"Source\""),
761                "more than one",
762            ),
763        ] {
764            match analyze(plan) {
765                Err(Error::InvalidArgument { name, detail }) => {
766                    assert_eq!(name, "content");
767                    assert!(detail.contains(says), "{says}: {detail}");
768                }
769                other => panic!("{says}: {other:?}"),
770            }
771        }
772        let huge = format!("{TWO_STEPS}#{}", "x".repeat(MAX_PLAN_BYTES));
773        assert!(analyze(&huge).is_err());
774    }
775
776    #[test]
777    fn a_reserved_parameter_is_refused() {
778        let plan = format!(
779            "{TWO_STEPS}<urn:plan:p> ik:input <urn:plan:p:input:in> .\n\
780             <urn:plan:p:input:in> ik:inputName \"in\" .\n"
781        );
782        assert!(matches!(analyze(&plan), Err(Error::InvalidArgument { .. })));
783    }
784
785    #[test]
786    fn a_scoped_runners_own_family_under_the_marker_is_kept_never_wider() {
787        let keep = set(&["urn:cap:script:run:team-*"]);
788        let runner = Capability::scoped([
789            "urn:cap:script:run:outer",
790            "urn:cap:script:run:team-*",
791            "urn:cap:script:run:team-a-*",
792            "urn:cap:script:run:team-b",
793            "urn:cap:script:run:*",
794            "urn:cap:script:run:-team-x",
795        ]);
796        // Ledger #1222: the runner's namespace grants under the marker are kept; its grant
797        // above the prefix (`run:*`) and outside it are not, nor is an exclusion (the runner's
798        // exclusions travel through attenuation, which keeps them anyway).
799        assert_eq!(
800            expand_families(keep.clone(), &runner, &Ceiling::Unbounded),
801            set(&[
802                "urn:cap:script:run:team-*",
803                "urn:cap:script:run:team-a-*",
804                "urn:cap:script:run:team-b"
805            ])
806        );
807        // The ceiling admits a runner's family only when a line covers it as written.
808        assert_eq!(
809            expand_families(
810                keep.clone(),
811                &runner,
812                &Ceiling::scoped(["urn:cap:script:run:team-a-*", "urn:cap:script:run:team-b"])
813            ),
814            set(&["urn:cap:script:run:team-a-*", "urn:cap:script:run:team-b"])
815        );
816        assert_eq!(
817            expand_families(
818                keep.clone(),
819                &runner,
820                &Ceiling::scoped(["urn:cap:script:run:team-x"])
821            ),
822            set(&[])
823        );
824        // A scoped runner's families are its own grants, not markers nobody named; the run
825        // that keeps them is no wider than the runner.
826        let kept = expand_families(keep, &runner, &Ceiling::scoped(["urn:cap:script:run:*"]));
827        assert!(kept.iter().all(|s| runner.allows(s)), "{kept:?}");
828    }
829
830    #[test]
831    fn a_root_publishers_family_becomes_the_runners_members() {
832        let keep = set(&["urn:cap:a", "urn:cap:net:*"]);
833        let runner = Capability::scoped([
834            "urn:cap:a",
835            "urn:cap:net:example.com",
836            "urn:cap:net:other.org",
837            "urn:cap:net:-example.com/admin",
838        ]);
839        let ceiling = Ceiling::scoped(["urn:cap:a", "urn:cap:net:example.com"]);
840        assert_eq!(
841            expand_families(keep.clone(), &runner, &ceiling),
842            set(&["urn:cap:a", "urn:cap:net:example.com"])
843        );
844        // Root runner: the members the ceiling names (and not the family it does not).
845        assert_eq!(
846            expand_families(keep.clone(), &Capability::root(), &ceiling),
847            set(&["urn:cap:a", "urn:cap:net:example.com"])
848        );
849        // Root runner, no ceiling: nothing to enumerate, so the bare family, which the
850        // floor admits and the module refuses.
851        assert_eq!(
852            expand_families(keep.clone(), &Capability::root(), &Ceiling::Unbounded),
853            set(&["urn:cap:a", "urn:cap:net:*"])
854        );
855        // A ceiling that allows none of the family keeps none of it.
856        assert_eq!(
857            expand_families(
858                keep.clone(),
859                &Capability::root(),
860                &Ceiling::scoped(["urn:cap:a"])
861            ),
862            set(&["urn:cap:a"])
863        );
864        // Root runner, a ceiling NARROWER than the family: the meet, the ceiling's own line
865        // (ledger #1220; it was dropped). A wider line keeps the family itself; an exact
866        // member stays exact; an exclusion is never a marker.
867        let narrower = Ceiling::scoped([
868            "urn:cap:a",
869            "urn:cap:net:example.*",
870            "urn:cap:net:other.org",
871            "urn:cap:net:-example.com/admin",
872        ]);
873        let expanded = expand_families(keep.clone(), &Capability::root(), &narrower);
874        assert_eq!(
875            expanded,
876            set(&[
877                "urn:cap:a",
878                "urn:cap:net:example.*",
879                "urn:cap:net:other.org"
880            ])
881        );
882        assert_eq!(markers(&expanded), ["urn:cap:net:example.*"]);
883        assert_eq!(
884            expand_families(
885                keep,
886                &Capability::root(),
887                &Ceiling::scoped(["urn:cap:a", "urn:cap:*"])
888            ),
889            set(&["urn:cap:a", "urn:cap:net:*"])
890        );
891    }
892}