Skip to main content

ikigai_script/
endpoints.rs

1//! The resources, as endpoints.
2//!
3//! Only two of them hold state — the script (its head and versions) and a run — and they
4//! hold it through the host's [`Backend`]. Everything else is composed: `…:result` and
5//! `…:runs` read `…:compiled` through the kernel and hand the program to the language's
6//! evaluator as a sub-request; `urn:script:eval` saves a draft by sinking to the script
7//! like any other writer.
8
9use std::collections::{BTreeMap, BTreeSet, HashMap};
10use std::sync::{Arc, Mutex};
11
12use async_trait::async_trait;
13use ikigai_core::{
14    ActionSpec, ArgRef, ArgSpec, Description, Endpoint, EndpointSpace, Error, Exact, Invocation,
15    Iri, ReprType, Representation, Request, Resolution, Result, Scope, Space, SpaceEntry, Topology,
16    UriTemplate, Verb,
17};
18use serde::{Deserialize, Serialize};
19
20use crate::authority::{
21    self, cap_delete, cap_read, cap_run, cap_write, Ceiling, CeilingPolicy, PrincipalStamper,
22    CAP_ANY, CAP_DELETE, CAP_LISP, CAP_READ, CAP_READ_PUBLIC, CAP_RUN, CAP_RUN_PUBLIC, CAP_WRITE,
23};
24use crate::backend::Backend;
25use crate::model::{
26    self, Event, Head, Language, Outcome, Run, State, Version, MAX_RECORDED_RESULT, SCHEMA,
27};
28use crate::name::{self, CATALOG_IRI, EVAL_IRI};
29use crate::plan;
30use crate::sparql::{self, Analysis, Form, Parameter, SparqlDoor, Value, CAP_READ_GRAPH};
31
32const PLAIN: &str = "text/plain";
33const JSON: &str = "application/json";
34const XSD_STRING: &str = "http://www.w3.org/2001/XMLSchema#string";
35const XSD_BOOLEAN: &str = "http://www.w3.org/2001/XMLSchema#boolean";
36const XSD_INTEGER: &str = "http://www.w3.org/2001/XMLSchema#integer";
37const TURTLE: &str = "text/turtle";
38/// The faces of a read: the source (or a summary) for people, the record for machines.
39const READ_FACES: [&str; 2] = [PLAIN, JSON];
40/// The faces of the catalog and a run record: those, and the graph (see [`crate::graph`]).
41const RECORD_FACES: [&str; 3] = [PLAIN, JSON, TURTLE];
42
43/// What a host decides when it mounts scripts: where they live, the ceiling it allows each
44/// one, and who a request comes from. Built with [`SpaceConfig::new`] and handed to
45/// [`space`].
46///
47/// There is no default backend and no default ceiling, on purpose: both are the host's
48/// decision, and a library that guessed either would be guessing about authority.
49#[derive(Clone)]
50#[non_exhaustive]
51pub struct SpaceConfig {
52    backend: Arc<dyn Backend>,
53    ceiling: CeilingPolicy,
54    principal: PrincipalStamper,
55    sparql: Option<SparqlDoor>,
56    on_change: Option<ChangeHook>,
57    /// Each published SPARQL script's own endpoints (its contract), built once per head and
58    /// dropped by every publish or retire through this crate. `None`: the template's.
59    described: Arc<Mutex<HashMap<String, Option<PerScript>>>>,
60}
61
62/// What a host runs when a script's head changes through this crate: see
63/// [`SpaceConfig::on_change`].
64pub type ChangeHook = Arc<dyn Fn(&str) + Send + Sync>;
65
66impl SpaceConfig {
67    /// Scripts kept in `backend`, each run under the ceiling `ceiling` answers for it.
68    pub fn new(backend: Arc<dyn Backend>, ceiling: CeilingPolicy) -> SpaceConfig {
69        SpaceConfig {
70            backend,
71            ceiling,
72            principal: authority::unstamped(),
73            sparql: None,
74            on_change: None,
75            described: Arc::new(Mutex::new(HashMap::new())),
76        }
77    }
78
79    /// How the principal recorded on every publish and run is decided: by the HOST, from
80    /// the invocation. The default records [`authority::UNSTAMPED`].
81    ///
82    /// It also decides who sees a draft: a version never published is visible only to the
83    /// principal stamped when it was written (and to root), so under the default, which
84    /// cannot tell callers apart, a draft is root's alone. See [`authority::is_identity`].
85    pub fn principal(mut self, stamper: PrincipalStamper) -> SpaceConfig {
86        self.principal = stamper;
87        self
88    }
89
90    /// Accept SPARQL scripts, evaluated through `door` (the store's graph-scoped doors).
91    /// Without it, publishing `language=sparql` is refused: which store a query runs
92    /// against is the host's decision, as the evaluator a Lisp script reaches is.
93    pub fn sparql(mut self, door: SparqlDoor) -> SpaceConfig {
94        self.sparql = Some(door);
95        self
96    }
97
98    /// Run `hook` with the script's name after every publish or retire through this crate.
99    ///
100    /// ★ **Wire it to `kernel.cut(ikigai_core::BINDINGS_THREAD)`.** A published SPARQL
101    /// script is its own entry in the catalog and the action manifold (`…:result` and
102    /// `…:runs` with ITS parameters as arguments), and the kernel caches every description
103    /// and Meta answer under `urn:kernel:bindings`, which it cannot know a publish changed.
104    /// The library holds no kernel and an endpoint may not cut that thread without
105    /// `urn:cap:kernel:cut`, so the host, which holds both, makes the cut. Unwired, a new
106    /// query is runnable at once but the catalog, MCP and the engine's argument routing
107    /// describe the old set until something else cuts the thread.
108    pub fn on_change(mut self, hook: ChangeHook) -> SpaceConfig {
109        self.on_change = Some(hook);
110        self
111    }
112
113    /// A script's head changed through this crate: forget its contract, tell the host.
114    fn changed(&self, name: &str) {
115        self.described
116            .lock()
117            .expect("the described-script memo")
118            .remove(name);
119        if let Some(hook) = &self.on_change {
120            hook(name);
121        }
122    }
123}
124
125/// Bind every script resource.
126///
127/// ⚠ **Bind order is resolution order** (`EndpointSpace` takes the first grammar that
128/// matches), and `urn:script:{name}`'s template captures the rest of an IRI, so the two
129/// exact names go first and the bare script last. A name cannot contain `:`, so
130/// `urn:script:x:compiled` can only be the compiled form of `x`.
131pub fn space(config: SpaceConfig) -> ScriptSpace {
132    let shared = Arc::new(config);
133    let template = |t: &str| UriTemplate::parse(t).expect("a constant template");
134    let inner = EndpointSpace::new()
135        .bind(Exact::new(EVAL_IRI), EvalEndpoint)
136        .bind(
137            Exact::new(CATALOG_IRI),
138            CatalogEndpoint {
139                shared: Arc::clone(&shared),
140            },
141        )
142        .bind(
143            template("urn:script:{name}:version:{digest}"),
144            VersionEndpoint {
145                shared: Arc::clone(&shared),
146            },
147        )
148        .bind(
149            template("urn:script:{name}:run:{id}"),
150            RunEndpoint {
151                shared: Arc::clone(&shared),
152            },
153        )
154        .bind(
155            template("urn:script:{name}:compiled"),
156            CompiledEndpoint {
157                shared: Arc::clone(&shared),
158            },
159        )
160        .bind(
161            template("urn:script:{name}:result"),
162            ResultEndpoint {
163                shared: Arc::clone(&shared),
164            },
165        )
166        .bind(
167            template("urn:script:{name}:runs"),
168            RunsEndpoint {
169                shared: Arc::clone(&shared),
170            },
171        )
172        .bind(
173            template("urn:script:{name}"),
174            ScriptEndpoint {
175                shared: Arc::clone(&shared),
176            },
177        );
178    ScriptSpace { inner, shared }
179}
180
181// ---------------------------------------------------------------------------------------
182// Gates
183// ---------------------------------------------------------------------------------------
184
185fn holds(inv: &Invocation<'_>, scope: &str) -> bool {
186    inv.capability.allows(scope)
187}
188
189/// Public and published: what the `…:public` grants reach.
190fn is_public(head: &Head) -> bool {
191    head.public && head.state == State::Published
192}
193
194/// The head of `name`, if the caller may read it.
195///
196/// ★ No existence oracle: a caller without the script's own read grant is told `Denied`
197/// whether the script exists or not, unless it is public and they hold
198/// [`CAP_READ_PUBLIC`]. Only a caller who may read it learns that it is absent.
199fn readable_head(inv: &Invocation<'_>, shared: &SpaceConfig, name: &str) -> Result<Option<Head>> {
200    gated_head(inv, shared, name, &[cap_read(name)], &[CAP_READ_PUBLIC])
201}
202
203/// The head of `name` if the caller holds one of `exact`, or the script is public and they
204/// hold one of `public`. Otherwise `Denied`, naming both.
205fn gated_head(
206    inv: &Invocation<'_>,
207    shared: &SpaceConfig,
208    name: &str,
209    exact: &[String],
210    public: &[&str],
211) -> Result<Option<Head>> {
212    if exact.iter().any(|s| holds(inv, s)) {
213        return shared.backend.head(name);
214    }
215    if let Ok(Some(head)) = shared.backend.head(name) {
216        if is_public(&head) && public.iter().any(|s| holds(inv, s)) {
217            return Ok(Some(head));
218        }
219    }
220    Err(Error::Denied(format!(
221        "this capability holds none of {} — nor, for a public script, {}. A script grant \
222         names exactly one script",
223        exact.join(", "),
224        public.join(", ")
225    )))
226}
227
228fn require(inv: &Invocation<'_>, scope: &str, doing: &str) -> Result<()> {
229    if holds(inv, scope) {
230        Ok(())
231    } else {
232        Err(Error::Denied(format!(
233            "{doing} needs `{scope}`, which this capability does not hold. A script grant \
234             names exactly one script"
235        )))
236    }
237}
238
239fn found(head: Option<Head>, name: &str) -> Result<Head> {
240    head.ok_or_else(|| Error::NotFound(format!("no script is published at urn:script:{name}")))
241}
242
243// ---------------------------------------------------------------------------------------
244// Drafts: a version never published is its author's alone
245// ---------------------------------------------------------------------------------------
246
247/// Who may see one version of a script, beyond the read (or run) grant that gates it.
248///
249/// ★ **A draft is visible only to its author until it is published.** The author is the
250/// principal the HOST stamped when the draft was written ([`SpaceConfig::principal`]), the
251/// same value a publish records as the script's publisher, compared with what the host
252/// stamps for the reader now. Never an argument, so no caller can name itself the author.
253/// This only ever NARROWS what a grant reaches: seeing a draft needs the grant AND
254/// authorship, so it can give no one more than their capability.
255#[derive(Clone, Copy, Debug, PartialEq, Eq)]
256enum Sight {
257    /// Published content, or a root caller: an answer that depends on the capability alone,
258    /// cached as it always was.
259    Everyone,
260    /// A version never published, and the caller is its author. Answered, but **never
261    /// cached**: the kernel keys its cache on the capability, and two people holding the
262    /// same capability are two principals, so a cached copy of the author's answer would be
263    /// served to the other.
264    Author,
265    /// A version never published, and the caller is not its author: absent, told exactly
266    /// as a name nobody wrote is (`NotFound`, `false`), so its existence does not leak.
267    /// Not cached either, for the same reason.
268    Hidden,
269}
270
271impl Sight {
272    /// Whether an answer computed under this sight may be cached.
273    fn cacheable(self) -> bool {
274        self == Sight::Everyone
275    }
276}
277
278/// Whether `digest` was ever published under this head: the head published at it, or any
279/// change in its history did. Once published, always: history only grows.
280fn ever_published(head: &Head, digest: &str) -> bool {
281    (head.version == digest && head.state == State::Published)
282        || head
283            .history
284            .iter()
285            .any(|event| event.action == "publish" && event.version == digest)
286}
287
288/// What the caller may see of version `digest` of `head`'s script.
289///
290/// Root sees everything: it is the host's own authority, not a principal's, and holds the
291/// backend the draft is stored in. Its cached answers are root's alone (the cache keys on
292/// the capability).
293fn sight(inv: &Invocation<'_>, shared: &SpaceConfig, head: &Head, digest: &str) -> Sight {
294    if ever_published(head, digest) || inv.capability.is_root() {
295        return Sight::Everyone;
296    }
297    let me = (shared.principal)(inv);
298    let wrote_it = authority::is_identity(&me)
299        && ((head.version == digest && head.publisher == me)
300            || head
301                .history
302                .iter()
303                .any(|e| e.action == "draft" && e.version == digest && e.principal == me));
304    if wrote_it {
305        Sight::Author
306    } else {
307        Sight::Hidden
308    }
309}
310
311/// `found`'s NotFound for a name the caller may read but cannot see: the same words as for
312/// a name nobody wrote, plus, for a caller the host did not stamp (which says nothing about
313/// any one name), why that caller can see no draft at all.
314fn absent(inv: &Invocation<'_>, shared: &SpaceConfig, name: &str) -> Error {
315    let mut message = format!("no script is published at urn:script:{name}");
316    if (shared.principal)(inv) == authority::UNSTAMPED && !inv.capability.is_root() {
317        message.push_str(
318            " (this host stamps no principal, so no caller can be shown to be a draft's \
319             author: a draft is visible to root alone until it is published)",
320        );
321    }
322    Error::NotFound(message)
323}
324
325/// The head of `name`, from a gate's answer, if the caller may also SEE the version it
326/// points at; and whether an answer built from it may be cached.
327fn seen_head(
328    inv: &Invocation<'_>,
329    shared: &SpaceConfig,
330    name: &str,
331    gated: Option<Head>,
332) -> Result<(Head, Sight)> {
333    let Some(head) = gated else {
334        return Err(absent(inv, shared, name));
335    };
336    match sight(inv, shared, &head, &head.version) {
337        Sight::Hidden => Err(absent(inv, shared, name)),
338        seen => Ok((head, seen)),
339    }
340}
341
342/// `repr`, cached only when `seen` allows it.
343fn cached_if(repr: Representation, seen: Sight) -> Representation {
344    if seen.cacheable() {
345        repr.cacheable()
346    } else {
347        repr
348    }
349}
350
351fn head_version(shared: &SpaceConfig, head: &Head) -> Result<Version> {
352    shared
353        .backend
354        .version(&head.name, &head.version)?
355        .ok_or_else(|| {
356            Error::Endpoint(format!(
357                "urn:script:{} points at version {}, which its backend does not hold",
358                head.name, head.version
359            ))
360        })
361}
362
363// ---------------------------------------------------------------------------------------
364// Arguments and faces
365// ---------------------------------------------------------------------------------------
366
367/// An optional inline argument; present-but-not-UTF-8 is an error, absent is `None`.
368fn optional<'a>(inv: &'a Invocation<'_>, name: &str) -> Result<Option<&'a str>> {
369    match inv.inline_str(name) {
370        Ok(value) => Ok(Some(value)),
371        Err(Error::MissingArgument(_)) => Ok(None),
372        Err(other) => Err(other),
373    }
374}
375
376fn parse_bool(name: &str, text: &str) -> Result<bool> {
377    match text.trim() {
378        "true" => Ok(true),
379        "false" => Ok(false),
380        other => Err(Error::InvalidArgument {
381            name: name.to_string(),
382            detail: format!("`{other}` is not `true` or `false`"),
383        }),
384    }
385}
386
387/// Which face was asked for: [`READ_FACES`]. One this resource cannot serve is refused,
388/// never substituted.
389fn wanted_face(inv: &Invocation<'_>) -> Result<&'static str> {
390    face_of(inv, &READ_FACES)
391}
392
393/// Which of `faces` was asked for (the first when none was).
394fn face_of(inv: &Invocation<'_>, faces: &[&'static str]) -> Result<&'static str> {
395    match optional(inv, "as")? {
396        None => Ok(faces[0]),
397        Some(asked) => {
398            let bare = asked.split(';').next().unwrap_or(asked).trim();
399            faces
400                .iter()
401                .find(|face| **face == bare)
402                .copied()
403                .ok_or_else(|| Error::InvalidArgument {
404                    name: "as".to_string(),
405                    detail: format!(
406                        "`{asked}` is not a face this resource serves; one of {}",
407                        faces.join(", ")
408                    ),
409                })
410        }
411    }
412}
413
414fn plain(text: impl Into<String>) -> Representation {
415    Representation::new(
416        ReprType::new(PLAIN).with_param("charset", "utf-8"),
417        text.into().into_bytes(),
418    )
419}
420
421fn json<T: Serialize>(value: &T) -> Result<Representation> {
422    let mut bytes = serde_json::to_vec_pretty(value)
423        .map_err(|e| Error::Endpoint(format!("could not serialize a record: {e}")))?;
424    bytes.push(b'\n');
425    Ok(Representation::new(ReprType::new(JSON), bytes))
426}
427
428fn iri(text: &str) -> Result<Iri> {
429    Iri::parse(text).map_err(|e| Error::Endpoint(format!("`{text}` is not an IRI: {e}")))
430}
431
432fn inline(text: &str) -> ArgRef {
433    ArgRef::Inline(text.as_bytes().to_vec())
434}
435
436fn name_arg() -> ArgSpec {
437    ArgSpec::new("name")
438        .summary(
439            "The script's name: one segment of lowercase letters, digits, `-` and `_` \
440             (`eval`, `catalog`, `public`, `outcome` and `principal` are reserved).",
441        )
442        .class(XSD_STRING)
443        .binding()
444}
445
446fn as_arg() -> ArgSpec {
447    ArgSpec::new("as")
448        .summary(
449            "The face: `text/plain` (for people) or `application/json` (the versioned \
450             record, `\"schema\": 1`). Any other is refused, never substituted.",
451        )
452        .class(XSD_STRING)
453        .one_of(READ_FACES)
454        .default_value(PLAIN)
455        .optional()
456}
457
458/// `as` for a record with a graph face.
459fn record_as_arg() -> ArgSpec {
460    ArgSpec::new("as")
461        .summary(
462            "The face: `text/plain` (for people), `application/json` (the versioned record, \
463             `\"schema\": 1`) or `text/turtle` (the graph: PROV-O and the shared \
464             vocabulary, no blank nodes). Any other is refused, never substituted.",
465        )
466        .class(XSD_STRING)
467        .one_of(RECORD_FACES)
468        .default_value(PLAIN)
469        .optional()
470}
471
472fn turtle(graph: crate::graph::Graph) -> Result<Representation> {
473    let bytes = graph
474        .turtle()
475        .map_err(|e| Error::Endpoint(format!("could not write the graph face: {e}")))?;
476    Ok(Representation::new(
477        ReprType::new(TURTLE).with_param("charset", "utf-8"),
478        bytes,
479    ))
480}
481
482fn data_arg(arg: &str, summary: &str) -> ArgSpec {
483    ArgSpec::new(arg)
484        .summary(summary)
485        .class(XSD_STRING)
486        .optional()
487}
488
489// ---------------------------------------------------------------------------------------
490// urn:script:{name} — the script (the head), and publishing
491// ---------------------------------------------------------------------------------------
492
493/// The script's JSON face: its head, flattened, with the version it points at.
494#[derive(Serialize)]
495struct ScriptDocument<'a> {
496    iri: String,
497    #[serde(flatten)]
498    head: &'a Head,
499    #[serde(rename = "versionIri")]
500    version_iri: String,
501    language: Language,
502    requires: &'a BTreeSet<String>,
503    source: &'a str,
504}
505
506struct ScriptEndpoint {
507    shared: Arc<SpaceConfig>,
508}
509
510#[async_trait]
511impl Endpoint for ScriptEndpoint {
512    async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
513        let name = name::from_bindings(inv)?;
514        let shared = &self.shared;
515        match inv.request.verb {
516            Verb::Exists => {
517                let (exists, seen) = match readable_head(inv, shared, &name)? {
518                    None => (false, Sight::Everyone),
519                    Some(head) => match sight(inv, shared, &head, &head.version) {
520                        Sight::Hidden => (false, Sight::Hidden),
521                        seen => (true, seen),
522                    },
523                };
524                Ok(cached_if(
525                    plain(if exists { "true\n" } else { "false\n" }),
526                    seen,
527                ))
528            }
529            Verb::Source => {
530                let want = wanted_face(inv)?;
531                let (head, seen) =
532                    seen_head(inv, shared, &name, readable_head(inv, shared, &name)?)?;
533                let version = head_version(shared, &head)?;
534                let repr = if want == JSON {
535                    json(&ScriptDocument {
536                        iri: name::script_iri(&name),
537                        head: &head,
538                        version_iri: name::version_iri(&name, &head.version),
539                        language: version.language,
540                        requires: &version.requires,
541                        source: &version.source,
542                    })?
543                } else {
544                    plain(version.source)
545                };
546                Ok(cached_if(repr, seen))
547            }
548            Verb::Sink => publish(inv, shared, &name).await,
549            Verb::Delete => retire(inv, shared, &name),
550            other => Err(unsupported("script", other)),
551        }
552    }
553
554    fn name(&self) -> &str {
555        "script"
556    }
557
558    fn describe(&self) -> Description {
559        Description::new("script")
560            .title("A script")
561            .summary(
562                "A script as a resource: fetch its source without running it, publish or \
563                 replace it (every write is a new content-addressed version), or retire it.",
564            )
565            .verb(Verb::Meta)
566            .action(
567                ActionSpec::new(Verb::Source)
568                    .summary(
569                        "The script, WITHOUT running it: its source (`text/plain`), or its head \
570                         record (`application/json`): state, public flag, version, what it \
571                         declares and what its publisher held. Needs \
572                         `urn:cap:script:read:{name}`, or `urn:cap:script:read:public` for a \
573                         public published script.",
574                    )
575                    .input(name_arg())
576                    .input(as_arg())
577                    .output(PLAIN)
578                    .output(JSON)
579                    .requires(CAP_READ),
580            )
581            .action(
582                ActionSpec::new(Verb::Exists)
583                    .summary("Whether the script exists (to a caller who may read it).")
584                    .input(name_arg())
585                    .output(PLAIN)
586                    .requires(CAP_READ),
587            )
588            .action(
589                ActionSpec::new(Verb::Sink)
590                    .summary(
591                        "Publish or replace the script; answers the new version's IRI. The \
592                         declared `requires` (plus the language's own capability) must all \
593                         be held by the publisher, or the publish is refused: a script never \
594                         runs with more than its publisher held. Needs \
595                         `urn:cap:script:write:{name}`.",
596                    )
597                    .input(name_arg())
598                    .input(
599                        ArgSpec::new("content")
600                            .summary("The source text.")
601                            .class(XSD_STRING),
602                    )
603                    .input(
604                        ArgSpec::new("language")
605                            .summary(
606                                "The script's language: `lisp`, `sparql` (a query or update, \
607                                 when the host mounts a SPARQL door), or `plan` (an \
608                                 ik:Process graph, when the host binds the plan doors).",
609                            )
610                            .class(XSD_STRING)
611                            .one_of(Language::ALL.map(Language::as_str))
612                            .default_value("lisp")
613                            .optional(),
614                    )
615                    .input(
616                        ArgSpec::new("requires")
617                            .summary(
618                                "Whitespace-separated `urn:cap:` grants the script needs to \
619                                 run. Lisp: the language's capability is added; exact grants \
620                                 only, no wildcards, no exclusions. SPARQL: DERIVED from the \
621                                 graphs the text names; a plan: DERIVED from its steps' \
622                                 contracts. For both, omit it: one that says anything else \
623                                 is refused, naming the derived set.",
624                            )
625                            .class(XSD_STRING)
626                            .optional(),
627                    )
628                    .input(
629                        ArgSpec::new("public")
630                            .summary(
631                                "Whether holders of `urn:cap:script:run:public` (a host's \
632                                 anonymous principal) may run it and holders of \
633                                 `urn:cap:script:read:public` read it.",
634                            )
635                            .class(XSD_BOOLEAN)
636                            .one_of(["true", "false"])
637                            .default_value("false")
638                            .optional(),
639                    )
640                    .input(
641                        ArgSpec::new("state")
642                            .summary(
643                                "`published` (runnable) or `draft` (saved, not runnable, \
644                                 and visible only to its author until published). To \
645                                 retire, Delete.",
646                            )
647                            .class(XSD_STRING)
648                            .one_of(["published", "draft"])
649                            .default_value("published")
650                            .optional(),
651                    )
652                    .input(
653                        ArgSpec::new("if-version")
654                            .summary(
655                                "Write only if the head is still this version (`none`: only \
656                                 if the script does not exist yet); otherwise `Conflict`.",
657                            )
658                            .class(XSD_STRING)
659                            .optional(),
660                    )
661                    .output(PLAIN)
662                    .requires(CAP_WRITE),
663            )
664            .action(
665                ActionSpec::new(Verb::Delete)
666                    .summary(
667                        "Retire the script: it stays readable and its versions fetchable, but \
668                         it never runs again until republished. Needs \
669                         `urn:cap:script:delete:{name}`.",
670                    )
671                    .input(name_arg())
672                    .output(PLAIN)
673                    .requires(CAP_DELETE),
674            )
675    }
676}
677
678async fn publish(inv: &Invocation<'_>, shared: &SpaceConfig, name: &str) -> Result<Representation> {
679    require(inv, &cap_write(name), "publishing a script")?;
680    let source = inv.inline_str("content")?;
681    let language = optional(inv, "language")?
682        .map(Language::parse)
683        .transpose()?
684        .unwrap_or(Language::Lisp);
685    let requires = match language {
686        Language::Lisp => {
687            let mut requires = optional(inv, "requires")?
688                .map(authority::parse_requires)
689                .transpose()?
690                .unwrap_or_default();
691            requires.insert(CAP_LISP.to_string());
692            requires
693        }
694        Language::Sparql => derived_requires(inv, shared, source)?,
695        Language::Plan => {
696            // Validated by the host's validator first, so a malformed plan is refused
697            // naming the shape it broke; then read for what this crate decides itself;
698            // then its authority derived from its steps' contracts.
699            plan::check_bound(source)?;
700            plan::validate(inv, source).await?;
701            plan::analyze(source)?;
702            plan::derive(inv, source, optional(inv, "requires")?).await?
703        }
704    };
705    let public = optional(inv, "public")?
706        .map(|v| parse_bool("public", v))
707        .transpose()?
708        .unwrap_or(false);
709    let state = match optional(inv, "state")?.map(str::trim) {
710        None | Some("published") => State::Published,
711        Some("draft") => State::Draft,
712        Some(other) => {
713            return Err(Error::InvalidArgument {
714                name: "state".to_string(),
715                detail: format!(
716                    "`{other}` is not `published` or `draft`; to retire a script, Delete it"
717                ),
718            })
719        }
720    };
721    // ★ No elevation: refused here, before anything is written, if the publisher does not
722    // hold every scope the script declares.
723    let grant = authority::grant_at_publish(inv.capability, &requires)?;
724
725    let version = Version {
726        language,
727        requires,
728        source: source.to_string(),
729    };
730    let digest = version.digest();
731    let current = shared.backend.head(name)?;
732    if let Some(expected) = optional(inv, "if-version")? {
733        let at = current.as_ref().map_or("none", |h| h.version.as_str());
734        if at != expected.trim() {
735            return Err(Error::Conflict(format!(
736                "urn:script:{name} is at {at}, not {}; read it again and retry",
737                expected.trim()
738            )));
739        }
740    }
741    shared.backend.put_version(name, &version)?;
742
743    let principal = (shared.principal)(inv);
744    let at = inv.now().map(|t| t.as_millis());
745    let mut history = current
746        .as_ref()
747        .map(|h| h.history.clone())
748        .unwrap_or_default();
749    history.push(Event {
750        action: if state == State::Draft {
751            "draft"
752        } else {
753            "publish"
754        }
755        .to_string(),
756        version: digest.clone(),
757        state,
758        public,
759        principal: principal.clone(),
760        at,
761    });
762    let head = Head {
763        schema: SCHEMA,
764        name: name.to_string(),
765        version: digest.clone(),
766        state,
767        public,
768        granted: grant.granted,
769        exclusions: grant.exclusions,
770        publisher: principal,
771        updated: at,
772        history,
773    };
774    shared.backend.swap_head(name, current.as_ref(), &head)?;
775    shared.changed(name);
776    Ok(plain(format!("{}\n", name::version_iri(name, &digest))))
777}
778
779/// A SPARQL script's authority: DERIVED from the graphs its text names, never declared.
780/// A `requires=` is accepted only when it says exactly the same thing, and refused naming
781/// the derived set when it does not, so an author can neither under- nor over-declare.
782fn derived_requires(
783    inv: &Invocation<'_>,
784    shared: &SpaceConfig,
785    source: &str,
786) -> Result<BTreeSet<String>> {
787    let door = door(shared).map_err(|_| Error::InvalidArgument {
788        name: "language".to_string(),
789        detail: "this host takes no SPARQL scripts: it mounts no SPARQL door".to_string(),
790    })?;
791    let derived = sparql::analyze(source, door)?.requires();
792    if let Some(said) = optional(inv, "requires")? {
793        let said: BTreeSet<String> = said.split_whitespace().map(str::to_string).collect();
794        if said != derived {
795            return Err(Error::InvalidArgument {
796                name: "requires".to_string(),
797                detail: format!(
798                    "a SPARQL script's authority is derived from the graphs its text names, \
799                     not declared, and this one derives {}; the `requires` given says {}. \
800                     Omit `requires`",
801                    listed(&derived),
802                    listed(&said)
803                ),
804            });
805        }
806    }
807    Ok(derived)
808}
809
810fn listed(scopes: &BTreeSet<String>) -> String {
811    if scopes.is_empty() {
812        "nothing".to_string()
813    } else {
814        scopes.iter().cloned().collect::<Vec<_>>().join(" ")
815    }
816}
817
818fn door(shared: &SpaceConfig) -> Result<&SparqlDoor> {
819    shared.sparql.as_ref().ok_or_else(|| {
820        Error::Endpoint(
821            "this host mounts no SPARQL door (`SpaceConfig::sparql`), so it cannot run a \
822             SPARQL script"
823                .to_string(),
824        )
825    })
826}
827
828fn retire(inv: &Invocation<'_>, shared: &SpaceConfig, name: &str) -> Result<Representation> {
829    require(inv, &cap_delete(name), "retiring a script")?;
830    let current = found(shared.backend.head(name)?, name)?;
831    let iri = name::script_iri(name);
832    if current.state == State::Retired {
833        return Ok(plain(format!("{iri} was already retired\n")));
834    }
835    let at = inv.now().map(|t| t.as_millis());
836    let mut head = current.clone();
837    head.state = State::Retired;
838    head.updated = at;
839    head.history.push(Event {
840        action: "retire".to_string(),
841        version: current.version.clone(),
842        state: State::Retired,
843        public: current.public,
844        principal: (shared.principal)(inv),
845        at,
846    });
847    shared.backend.swap_head(name, Some(&current), &head)?;
848    shared.changed(name);
849    Ok(plain(format!("retired {iri}\n")))
850}
851
852// ---------------------------------------------------------------------------------------
853// urn:script:{name}:version:{digest}
854// ---------------------------------------------------------------------------------------
855
856struct VersionEndpoint {
857    shared: Arc<SpaceConfig>,
858}
859
860#[async_trait]
861impl Endpoint for VersionEndpoint {
862    async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
863        let name = name::from_bindings(inv)?;
864        let digest = inv
865            .bindings
866            .get("digest")
867            .ok_or_else(|| Error::Endpoint("no `digest` captured".to_string()))?
868            .to_string();
869        let head = readable_head(inv, &self.shared, &name)?;
870        let version = self.shared.backend.version(&name, &digest);
871        // A version that exists is seen as its head's history says: one never published is
872        // its author's alone. One with no head at all (a write that lost its race) was never
873        // published either.
874        let seen = match (&version, &head) {
875            (Ok(Some(_)), Some(head)) => sight(inv, &self.shared, head, &digest),
876            (Ok(Some(_)), None) if !inv.capability.is_root() => Sight::Hidden,
877            _ => Sight::Everyone,
878        };
879        let no_such = || Error::NotFound(format!("urn:script:{name} has no version {digest}"));
880        // ★ Hung from the SCRIPT's thread, which every publish and retire cuts (ledger #1076).
881        // Everything this answer says is the script's state, not the version's: whether that
882        // content has been written under the name (a version named by its content can be
883        // asked for before anyone publishes it), and whether the caller may read the name
884        // at all (a public script's version stops being readable through the public grant
885        // when it is retired). The kernel hangs a cacheable answer from its OWN name, which
886        // no write here ever targets, so without this a cached `false` outlived the publish
887        // that made it true. Caching the negative is kept, not dropped: it is a backend read
888        // per ask, and an uncacheable `Exists` would make every composite over it uncacheable.
889        let script_thread = name::script_iri(&name);
890        match inv.request.verb {
891            Verb::Exists => {
892                let exists = match version {
893                    Ok(v) => v.is_some() && seen != Sight::Hidden,
894                    Err(Error::InvalidArgument { .. }) => false,
895                    Err(other) => return Err(other),
896                };
897                Ok(
898                    cached_if(plain(if exists { "true\n" } else { "false\n" }), seen)
899                        .depends_on(script_thread),
900                )
901            }
902            Verb::Source => {
903                let want = wanted_face(inv)?;
904                let version = version?.ok_or_else(no_such)?;
905                if seen == Sight::Hidden {
906                    return Err(no_such());
907                }
908                let repr = if want == JSON {
909                    json(&version.document(&name))?
910                } else {
911                    plain(version.source)
912                };
913                Ok(cached_if(repr, seen).depends_on(script_thread))
914            }
915            other => Err(unsupported("script-version", other)),
916        }
917    }
918
919    fn name(&self) -> &str {
920        "script-version"
921    }
922
923    fn describe(&self) -> Description {
924        let digest = || {
925            ArgSpec::new("digest")
926                .summary("The version's digest: `sha256:` and 64 hex digits over its content.")
927                .class(XSD_STRING)
928                .binding()
929        };
930        Description::new("script-version")
931            .title("One version of a script")
932            .summary(
933                "An immutable version, named by the sha256 of its content (language, declared \
934                 capability, source). Republishing moves the head; every version stays \
935                 fetchable here.",
936            )
937            .verb(Verb::Meta)
938            .action(
939                ActionSpec::new(Verb::Source)
940                    .summary(
941                        "The version's source (`text/plain`) or its record (`application/json`).",
942                    )
943                    .input(name_arg())
944                    .input(digest())
945                    .input(as_arg())
946                    .output(PLAIN)
947                    .output(JSON)
948                    .requires(CAP_READ),
949            )
950            .action(
951                ActionSpec::new(Verb::Exists)
952                    .summary("Whether the script has this version.")
953                    .input(name_arg())
954                    .input(digest())
955                    .output(PLAIN)
956                    .requires(CAP_READ),
957            )
958    }
959}
960
961// ---------------------------------------------------------------------------------------
962// urn:script:{name}:compiled — the prepared form
963// ---------------------------------------------------------------------------------------
964
965/// The compiled form: everything a run needs, in one cacheable resource hung from the
966/// script's thread. For Lisp the "compilation" is preparation — the evaluator offers no
967/// separate compile step to cache — so this is the program and the authority it runs
968/// under, bound to the version they came from. For SPARQL it adds what the parsed text
969/// says: its form, its parameters and the graphs it names ([`Analysis`]).
970#[derive(Clone, Debug, Serialize, Deserialize)]
971pub struct Prepared {
972    /// [`SCHEMA`].
973    pub schema: u32,
974    /// The script.
975    pub name: String,
976    /// `urn:script:{name}:compiled`.
977    pub iri: String,
978    /// The version prepared.
979    pub version: String,
980    /// Its language.
981    pub language: Language,
982    /// The door a run issues to: `urn:lisp:eval`, or the host's SPARQL door for this form.
983    pub evaluator: String,
984    /// The program.
985    pub program: String,
986    /// What it declares (for SPARQL: what its text derives).
987    pub requires: BTreeSet<String>,
988    /// What its publisher held of that.
989    pub granted: BTreeSet<String>,
990    /// The publisher's exclusions.
991    pub exclusions: BTreeSet<String>,
992    /// Whether it is public.
993    pub public: bool,
994    /// Its state.
995    pub state: State,
996    /// For a SPARQL script: its form, parameters and graphs, read from the parsed text.
997    #[serde(default, skip_serializing_if = "Option::is_none")]
998    pub sparql: Option<Analysis>,
999    /// For a plan: its process, its steps' verbs (read or write) and its parameters.
1000    #[serde(default, skip_serializing_if = "Option::is_none")]
1001    pub plan: Option<plan::Analysis>,
1002}
1003
1004struct CompiledEndpoint {
1005    shared: Arc<SpaceConfig>,
1006}
1007
1008#[async_trait]
1009impl Endpoint for CompiledEndpoint {
1010    async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
1011        if inv.request.verb != Verb::Source {
1012            return Err(unsupported("script-compiled", inv.request.verb));
1013        }
1014        let name = name::from_bindings(inv)?;
1015        // Readable by a reader or a runner: a run reads its program here. A draft's program
1016        // is its author's alone, so a run of someone else's draft finds nothing to run.
1017        let gated = gated_head(
1018            inv,
1019            &self.shared,
1020            &name,
1021            &[cap_read(&name), cap_run(&name)],
1022            &[CAP_READ_PUBLIC, CAP_RUN_PUBLIC],
1023        )?;
1024        let (head, seen) = seen_head(inv, &self.shared, &name, gated)?;
1025        let version = head_version(&self.shared, &head)?;
1026        let (evaluator, analysis, plan_analysis) = match version.language {
1027            Language::Lisp => (LISP_EVAL.to_string(), None, None),
1028            Language::Sparql => {
1029                let door = door(&self.shared)?;
1030                let analysis = sparql::analyze(&version.source, door)?;
1031                (door.query_iri(analysis.form), Some(analysis), None)
1032            }
1033            Language::Plan => (
1034                plan::EVAL.to_string(),
1035                None,
1036                Some(plan::analyze(&version.source)?),
1037            ),
1038        };
1039        let prepared = Prepared {
1040            schema: SCHEMA,
1041            iri: name::part_iri(&name, "compiled"),
1042            version: head.version.clone(),
1043            language: version.language,
1044            evaluator,
1045            program: version.source,
1046            requires: version.requires,
1047            granted: head.granted,
1048            exclusions: head.exclusions,
1049            public: head.public,
1050            state: head.state,
1051            sparql: analysis,
1052            plan: plan_analysis,
1053            name: name.clone(),
1054        };
1055        // ★ Hung from the SCRIPT's thread, which every publish and retire cuts (the kernel
1056        // cuts the thread named after a Sink's or Delete's target). Without it a cached
1057        // compiled form would outlive the version it was prepared from.
1058        Ok(cached_if(json(&prepared)?, seen).depends_on(name::script_iri(&name)))
1059    }
1060
1061    fn name(&self) -> &str {
1062        "script-compiled"
1063    }
1064
1065    fn describe(&self) -> Description {
1066        Description::new("script-compiled")
1067            .title("A script's compiled form")
1068            .summary(
1069                "The head version prepared for its evaluator, with the authority it runs \
1070                 under (and, for SPARQL, its form, parameters and graphs; for a plan, its \
1071                 steps' verbs and its parameters): cached, and cut \
1072                 whenever the script is republished or retired. Readable by a holder of the \
1073                 script's read OR run grant (a run reads its program here).",
1074            )
1075            .verb(Verb::Meta)
1076            .action(
1077                ActionSpec::new(Verb::Source)
1078                    .summary("The prepared form, as JSON.")
1079                    .input(name_arg())
1080                    .output(JSON)
1081                    .requires(CAP_ANY),
1082            )
1083    }
1084}
1085
1086// ---------------------------------------------------------------------------------------
1087// Running: …:result (a read) and …:runs (a write)
1088// ---------------------------------------------------------------------------------------
1089
1090const LISP_EVAL: &str = "urn:lisp:eval";
1091
1092/// Read the compiled form through the kernel, check the run gate and the state, and
1093/// compute what the run may keep of the runner's capability (and under which ceiling).
1094async fn prepare_run(
1095    inv: &Invocation<'_>,
1096    shared: &SpaceConfig,
1097    name: &str,
1098) -> Result<(Prepared, BTreeSet<String>, Ceiling)> {
1099    // Composed, not reached into: the run is a reader of its own compiled form, so the
1100    // compiled form's thread (and the script's, through it) lands on the result.
1101    let compiled = inv
1102        .issue(Request::new(
1103            Verb::Source,
1104            iri(&name::part_iri(name, "compiled"))?,
1105        ))
1106        .await?;
1107    let prepared: Prepared = serde_json::from_slice(&compiled.bytes).map_err(|e| {
1108        Error::Endpoint(format!(
1109            "urn:script:{name}:compiled did not answer a prepared form: {e}"
1110        ))
1111    })?;
1112    if prepared.name != name {
1113        return Err(Error::Endpoint(format!(
1114            "urn:script:{name}:compiled answered for `{}`",
1115            prepared.name
1116        )));
1117    }
1118    let public = prepared.public && prepared.state == State::Published;
1119    let may_run = holds(inv, &cap_run(name)) || (public && holds(inv, CAP_RUN_PUBLIC));
1120    if !may_run {
1121        return Err(Error::Denied(format!(
1122            "running urn:script:{name} needs `{}`{}",
1123            cap_run(name),
1124            if prepared.public {
1125                format!(" (or `{CAP_RUN_PUBLIC}`, since it is public)")
1126            } else {
1127                " — it is not public".to_string()
1128            }
1129        )));
1130    }
1131    match prepared.state {
1132        State::Published => {}
1133        State::Draft => {
1134            return Err(Error::Conflict(format!(
1135                "urn:script:{name} is a draft; publish it (`state=published`) to run it, or \
1136                 try the code at urn:script:eval"
1137            )))
1138        }
1139        State::Retired => {
1140            return Err(Error::Conflict(format!(
1141                "urn:script:{name} is retired; publish it again to run it"
1142            )))
1143        }
1144    }
1145    let ceiling = (shared.ceiling)(name);
1146    let keep = authority::effective(
1147        &prepared.requires,
1148        &prepared.granted,
1149        &prepared.exclusions,
1150        &ceiling,
1151    );
1152    Ok((prepared, keep, ceiling))
1153}
1154
1155/// The Lisp evaluator sub-request: `in` is the program, `data` what it reads with `(input)`.
1156fn lisp_request(program: &str, data: Option<&str>) -> Result<Request> {
1157    let mut request = Request::new(Verb::Source, iri(LISP_EVAL)?).with_arg("in", inline(program));
1158    if let Some(data) = data {
1159        request = request.with_arg("data", inline(data));
1160    }
1161    Ok(request)
1162}
1163
1164/// `run_args`: the description of the run's input, shared by both run doors.
1165const DATA_SUMMARY: &str = "Optional data the script reads with `(input)` — data, never code.";
1166
1167/// Which door a run came through.
1168#[derive(Clone, Copy, PartialEq, Eq)]
1169enum Door {
1170    /// `…:result`: a read, answered with the script's value.
1171    Result,
1172    /// `…:runs`: a write, recorded.
1173    Runs,
1174}
1175
1176/// What a run issues, and exactly the scopes it issues it under (before the runner's own
1177/// capability is attenuated to them).
1178struct Planned {
1179    request: Request,
1180    keep: BTreeSet<String>,
1181}
1182
1183/// The run's sub-request, for whichever language the script is in.
1184async fn prepare_request(
1185    inv: &Invocation<'_>,
1186    shared: &SpaceConfig,
1187    prepared: &Prepared,
1188    keep: BTreeSet<String>,
1189    ceiling: &Ceiling,
1190    through: Door,
1191) -> Result<Planned> {
1192    match prepared.language {
1193        Language::Lisp => {
1194            let data = optional(
1195                inv,
1196                match through {
1197                    Door::Result => "data",
1198                    Door::Runs => "content",
1199                },
1200            )?;
1201            Ok(Planned {
1202                request: lisp_request(&prepared.program, data)?,
1203                keep,
1204            })
1205        }
1206        Language::Sparql => plan_sparql(inv, shared, prepared, keep, ceiling, through).await,
1207        Language::Plan => plan_eval_request(inv, prepared, keep, ceiling, through),
1208    }
1209}
1210
1211/// A plan run: refuse a write through the read door, take the plan's parameters, turn a
1212/// root publisher's derived families into exact grants, and address `urn:plan:eval`.
1213///
1214/// The plan's steps then run as `urn:plan:eval`'s sub-requests under exactly the narrowed
1215/// capability, and each meets its own target's floor there: a step the run may not take is
1216/// refused by the kernel at that step, typed, and arrives here unchanged.
1217fn plan_eval_request(
1218    inv: &Invocation<'_>,
1219    prepared: &Prepared,
1220    keep: BTreeSet<String>,
1221    ceiling: &Ceiling,
1222    through: Door,
1223) -> Result<Planned> {
1224    let name = &prepared.name;
1225    let analysis = prepared.plan.as_ref().ok_or_else(|| {
1226        Error::Endpoint(format!(
1227            "urn:script:{name}:compiled carries no plan analysis"
1228        ))
1229    })?;
1230    if through == Door::Result && analysis.mutates() {
1231        let writers: Vec<String> = analysis
1232            .steps
1233            .iter()
1234            .filter(|s| s.mutates())
1235            .map(|s| format!("<{}> ({} <{}>)", s.iri, s.verb, s.resolves))
1236            .collect();
1237        return Err(Error::InvalidArgument {
1238            name: "name".to_string(),
1239            detail: format!(
1240                "urn:script:{name} is a plan that writes ({}), so it is not a read: run it with \
1241                 a Sink to urn:script:{name}:runs",
1242                writers.join(", ")
1243            ),
1244        });
1245    }
1246    let declared: Vec<(&str, bool)> = analysis
1247        .parameters
1248        .iter()
1249        .map(|p| (p.name.as_str(), p.must_be_given()))
1250        .collect();
1251    let values = given_parameters(inv, name, &declared, through)?;
1252    let face = optional(inv, "as")?;
1253    Ok(Planned {
1254        request: plan::eval_request(&prepared.program, &values, face),
1255        keep: plan::expand_families(keep, inv.capability, ceiling),
1256    })
1257}
1258
1259/// A SPARQL run: bind the parameters into the parsed query, work out its dataset and the
1260/// exact store grants it needs, and address the host's store door for its form.
1261///
1262/// ★ **The dataset is never wider than the authority.** The graphs the text fixes need
1263/// their grants in the run's capability (the store refuses the whole read, naming the
1264/// grant, when one is missing). A graph only the run decides (`GRAPH ?g`, or a parameter
1265/// naming a graph) is admitted only under the derived family, and only when the
1266/// publisher held it (or was root), the host's ceiling allows it AND the runner holds it:
1267/// that set is "the caller's readable union", computed here per run and never more.
1268async fn plan_sparql(
1269    inv: &Invocation<'_>,
1270    shared: &SpaceConfig,
1271    prepared: &Prepared,
1272    mut keep: BTreeSet<String>,
1273    ceiling: &Ceiling,
1274    through: Door,
1275) -> Result<Planned> {
1276    let name = &prepared.name;
1277    let door = door(shared)?;
1278    let analysis = prepared.sparql.as_ref().ok_or_else(|| {
1279        Error::Endpoint(format!(
1280            "urn:script:{name}:compiled carries no SPARQL analysis"
1281        ))
1282    })?;
1283    if through == Door::Result && !analysis.form.is_read() {
1284        return Err(Error::InvalidArgument {
1285            name: "name".to_string(),
1286            detail: format!(
1287                "urn:script:{name} is a SPARQL UPDATE, which is a write: run it with a Sink to \
1288                 urn:script:{name}:runs"
1289            ),
1290        });
1291    }
1292    let values = parameter_values(inv, name, &analysis.parameters, through)?;
1293    let face = sparql_face(inv, analysis.form)?;
1294    let bound = sparql::bind(&prepared.program, door, &values)?;
1295
1296    let runner = inv.capability;
1297    // The root publisher's marker: expanded below into exact grants, never attenuated as
1298    // it stands (attenuation keeps grants by exact name).
1299    let root_marker = keep.remove(CAP_READ_GRAPH);
1300    let family = prepared.requires.contains(CAP_READ_GRAPH);
1301    let by_family = |scope: &str, keep: &BTreeSet<String>| {
1302        family
1303            && runner.allows(scope)
1304            && if root_marker {
1305                ceiling.allows(scope)
1306            } else {
1307                keep.contains(scope)
1308            }
1309    };
1310
1311    if bound.form == Form::Update {
1312        // An update's one graph is fixed by its text (a parameter cannot name it), so its
1313        // grants are exactly the derived ones, already in `keep`.
1314        let graph = bound.graphs.iter().next().cloned().ok_or_else(|| {
1315            Error::Endpoint(format!("urn:script:{name} names no graph to update"))
1316        })?;
1317        for scope in &prepared.requires {
1318            may_use(name, &graph, scope, &keep, runner)?;
1319        }
1320        let request = Request::new(Verb::Sink, iri(&door.query_iri(Form::Update))?)
1321            .with_arg("content", inline(&bound.text))
1322            .with_arg("graph", inline(&graph));
1323        return Ok(Planned { request, keep });
1324    }
1325
1326    let mut extra = BTreeSet::new();
1327    for graph in &bound.graphs {
1328        let scope = sparql::cap_read_graph(graph);
1329        if !keep.contains(&scope) && by_family(&scope, &keep) {
1330            extra.insert(scope);
1331        }
1332    }
1333    let mut dataset = bound.graphs.clone();
1334    if bound.any_graph {
1335        if !family {
1336            return Err(Error::Endpoint(format!(
1337                "urn:script:{name} reads a variable graph but its record derives no \
1338                 `{CAP_READ_GRAPH}`; republish it"
1339            )));
1340        }
1341        let under = |scopes: &mut dyn Iterator<Item = &String>| -> BTreeSet<String> {
1342            scopes
1343                .filter_map(|s| s.strip_prefix(sparql::READ_GRAPH_PREFIX))
1344                .filter(|g| !g.starts_with('-') && !g.ends_with('*'))
1345                .map(str::to_string)
1346                .collect()
1347        };
1348        let candidates = if !root_marker {
1349            under(&mut keep.iter())
1350        } else if let Some(held) = runner.scopes() {
1351            under(&mut held.iter())
1352        } else {
1353            // Root ran a script a root published: the readable union is every graph the
1354            // store holds, listed by the store itself under the broad read the runner holds.
1355            let listing = inv
1356                .issue_attenuated(
1357                    Request::new(Verb::Source, iri(&door.graphs_iri())?),
1358                    [sparql::CAP_STORE_READ.to_string()],
1359                )
1360                .await?;
1361            String::from_utf8_lossy(&listing.bytes)
1362                .lines()
1363                .map(str::trim)
1364                .filter(|l| !l.is_empty())
1365                .map(str::to_string)
1366                .collect()
1367        };
1368        for graph in candidates {
1369            let scope = sparql::cap_read_graph(&graph);
1370            if keep.contains(&scope) && runner.allows(&scope) || by_family(&scope, &keep) {
1371                extra.insert(scope);
1372                dataset.insert(graph);
1373            }
1374        }
1375    }
1376    if dataset.is_empty() {
1377        return Err(Error::Denied(format!(
1378            "urn:script:{name} reads the caller's readable graphs, and this run may read none: \
1379             it needs `{}` grants the publisher held, the host allows and the runner holds",
1380            sparql::READ_GRAPH_PREFIX
1381        )));
1382    }
1383    keep.extend(extra);
1384    for graph in &dataset {
1385        may_use(name, graph, &sparql::cap_read_graph(graph), &keep, runner)?;
1386    }
1387    let graphs = dataset.into_iter().collect::<Vec<_>>().join(" ");
1388    let request = Request::new(Verb::Source, iri(&door.query_iri(bound.form))?)
1389        .with_arg("query", inline(&bound.text))
1390        .with_arg("graph", inline(&graphs))
1391        .with_arg("as", inline(face));
1392    Ok(Planned { request, keep })
1393}
1394
1395/// Refused HERE, naming the exact grant and who withheld it, rather than by the store's
1396/// door: the kernel's floor there sees only the family a run holds, so its refusal would
1397/// name `urn:cap:store:read:graph:*` and not the graph.
1398fn may_use(
1399    name: &str,
1400    graph: &str,
1401    scope: &str,
1402    keep: &BTreeSet<String>,
1403    runner: &ikigai_core::Capability,
1404) -> Result<()> {
1405    if !runner.allows(scope) {
1406        return Err(Error::Denied(format!(
1407            "running urn:script:{name} reads or writes <{graph}>, which needs `{scope}`, and the \
1408             runner does not hold it"
1409        )));
1410    }
1411    if !keep.contains(scope) {
1412        return Err(Error::Denied(format!(
1413            "running urn:script:{name} reads or writes <{graph}>, which needs `{scope}`, and \
1414             this script may not have it: its publisher did not hold it, or the host's ceiling \
1415             for it does not allow it"
1416        )));
1417    }
1418    Ok(())
1419}
1420
1421/// The face a SPARQL run answers in: `as=`, one its form can serve, or the form's default.
1422/// Refused, never substituted.
1423fn sparql_face(inv: &Invocation<'_>, form: Form) -> Result<&'static str> {
1424    let faces = form.faces();
1425    match optional(inv, "as")? {
1426        None => Ok(faces[0]),
1427        Some(asked) => {
1428            let bare = asked.split(';').next().unwrap_or(asked).trim();
1429            faces
1430                .iter()
1431                .find(|f| **f == bare)
1432                .copied()
1433                .ok_or_else(|| Error::InvalidArgument {
1434                    name: "as".to_string(),
1435                    detail: format!(
1436                        "`{asked}` is not a face a SPARQL {} answers in; one of {}",
1437                        form.as_str().to_uppercase(),
1438                        faces.join(", ")
1439                    ),
1440                })
1441        }
1442    }
1443}
1444
1445/// The run's parameter values, each checked against its declared type.
1446///
1447/// What was given is [`given_parameters`]'s; a parameter not given takes its default, and
1448/// one with neither stays unbound.
1449fn parameter_values(
1450    inv: &Invocation<'_>,
1451    name: &str,
1452    parameters: &[Parameter],
1453    through: Door,
1454) -> Result<BTreeMap<String, Value>> {
1455    let declared: Vec<(&str, bool)> = parameters
1456        .iter()
1457        .map(|p| (p.name.as_str(), p.required && p.default.is_none()))
1458        .collect();
1459    let mut given = given_parameters(inv, name, &declared, through)?;
1460    let mut values = BTreeMap::new();
1461    for parameter in parameters {
1462        if let Some(lexical) = given
1463            .remove(&parameter.name)
1464            .or_else(|| parameter.default.clone())
1465        {
1466            values.insert(parameter.name.clone(), parameter.value(&lexical)?);
1467        }
1468    }
1469    Ok(values)
1470}
1471
1472/// The parameters a run was GIVEN, as text, for a script that declares `declared` (each
1473/// parameter's name, and whether a run must give it).
1474///
1475/// Named arguments, or (through `…:runs`) a JSON object piped as `content`. A missing
1476/// parameter a run must give is `MissingArgument`; a parameter given both ways and an
1477/// argument the script does not declare are `InvalidArgument`: a binding the script does not
1478/// mention is refused, never ignored, so a filter you thought was applied can never silently
1479/// not be. Refused here, before a run is recorded: nothing ran.
1480fn given_parameters(
1481    inv: &Invocation<'_>,
1482    name: &str,
1483    declared: &[(&str, bool)],
1484    through: Door,
1485) -> Result<BTreeMap<String, String>> {
1486    let listed = || {
1487        if declared.is_empty() {
1488            "it declares none".to_string()
1489        } else {
1490            format!(
1491                "its parameters are {}",
1492                declared
1493                    .iter()
1494                    .map(|(p, _)| *p)
1495                    .collect::<Vec<_>>()
1496                    .join(", ")
1497            )
1498        }
1499    };
1500    for arg in inv.request.args.keys() {
1501        let known = arg == "as"
1502            || (through == Door::Runs && arg == "content")
1503            || declared.iter().any(|(p, _)| p == arg);
1504        if !known {
1505            return Err(Error::InvalidArgument {
1506                name: arg.clone(),
1507                detail: format!("is not a parameter of urn:script:{name}; {}", listed()),
1508            });
1509        }
1510    }
1511    let mut piped: BTreeMap<String, String> = BTreeMap::new();
1512    if through == Door::Runs {
1513        // An empty body is no parameters: a bare `sink …:runs` sends one.
1514        if let Some(content) = optional(inv, "content")?.filter(|c| !c.trim().is_empty()) {
1515            let object: serde_json::Map<String, serde_json::Value> = serde_json::from_str(content)
1516                .map_err(|e| Error::InvalidArgument {
1517                    name: "content".to_string(),
1518                    detail: format!(
1519                        "a script's piped content is its parameters as one JSON object ({e})"
1520                    ),
1521                })?;
1522            for (key, value) in object {
1523                let lexical = match value {
1524                    serde_json::Value::String(s) => s,
1525                    serde_json::Value::Number(n) => n.to_string(),
1526                    serde_json::Value::Bool(b) => b.to_string(),
1527                    other => {
1528                        return Err(Error::InvalidArgument {
1529                            name: "content".to_string(),
1530                            detail: format!(
1531                                "`{key}` is {other}; a parameter's value is a string, a number \
1532                                 or a boolean"
1533                            ),
1534                        })
1535                    }
1536                };
1537                piped.insert(key, lexical);
1538            }
1539        }
1540    }
1541    let mut given = BTreeMap::new();
1542    for (parameter, must) in declared {
1543        let named = optional(inv, parameter)?;
1544        let lexical = match (named, piped.remove(*parameter)) {
1545            (Some(_), Some(_)) => {
1546                return Err(Error::InvalidArgument {
1547                    name: parameter.to_string(),
1548                    detail: "is given both by name and in the piped content".to_string(),
1549                })
1550            }
1551            (Some(value), None) => Some(value.to_string()),
1552            (None, Some(value)) => Some(value),
1553            (None, None) => None,
1554        };
1555        match lexical {
1556            Some(lexical) => {
1557                given.insert(parameter.to_string(), lexical);
1558            }
1559            None if *must => return Err(Error::MissingArgument(parameter.to_string())),
1560            None => {}
1561        }
1562    }
1563    if let Some(stray) = piped.keys().next() {
1564        return Err(Error::InvalidArgument {
1565            name: "content".to_string(),
1566            detail: format!(
1567                "`{stray}` is not a parameter of urn:script:{name}; {}",
1568                listed()
1569            ),
1570        });
1571    }
1572    Ok(given)
1573}
1574
1575struct ResultEndpoint {
1576    shared: Arc<SpaceConfig>,
1577}
1578
1579#[async_trait]
1580impl Endpoint for ResultEndpoint {
1581    async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
1582        if inv.request.verb != Verb::Source {
1583            return Err(unsupported("script-result", inv.request.verb));
1584        }
1585        let name = name::from_bindings(inv)?;
1586        let (prepared, keep, ceiling) = prepare_run(inv, &self.shared, &name).await?;
1587        let planned =
1588            prepare_request(inv, &self.shared, &prepared, keep, &ceiling, Door::Result).await?;
1589        // ★ The ONLY authority a run gets: the runner's own capability, narrowed. There is
1590        // no form that widens, so a script cannot reach past its runner whatever it says.
1591        let answer = inv.issue_attenuated(planned.request, planned.keep).await?;
1592        // Cacheable as far as this endpoint is concerned; the kernel folds in the
1593        // evaluator's expiry (a Lisp program is uncacheable unless it opts in with
1594        // `(cacheable …)`; a query is as cacheable as the store's answer, which hangs from
1595        // the store's write threads) and the compiled form's threads. The authority thread
1596        // is the host's to cut when it changes this script's ceiling.
1597        Ok(Representation::new(answer.repr_type, answer.bytes)
1598            .cacheable()
1599            .depends_on(name::authority_thread(&name)))
1600    }
1601
1602    fn name(&self) -> &str {
1603        "script-result"
1604    }
1605
1606    fn describe(&self) -> Description {
1607        Description::new("script-result")
1608            .title("A script's result")
1609            .summary(
1610                "Run the script as a READ and answer its value. It runs under the runner's \
1611                 capability narrowed to what the script declares, its publisher held and \
1612                 the host allows it — never more. Cacheable exactly as far as the script's \
1613                 own sub-requests are (a Lisp program opts in with `(cacheable …)`), and \
1614                 recomputed when the script is republished. No run record: a cached answer \
1615                 runs nothing; record a run with `…:runs`. A published SPARQL script is \
1616                 its own entry, with its parameters as arguments.",
1617            )
1618            .verb(Verb::Meta)
1619            .action(
1620                ActionSpec::new(Verb::Source)
1621                    .summary("The script's answer.")
1622                    .input(name_arg())
1623                    .input(data_arg("data", DATA_SUMMARY))
1624                    .output(PLAIN)
1625                    .requires(CAP_RUN)
1626                    .requires(CAP_LISP),
1627            )
1628    }
1629}
1630
1631struct RunsEndpoint {
1632    shared: Arc<SpaceConfig>,
1633}
1634
1635#[async_trait]
1636impl Endpoint for RunsEndpoint {
1637    async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
1638        if inv.request.verb != Verb::Sink {
1639            return Err(unsupported("script-runs", inv.request.verb));
1640        }
1641        let name = name::from_bindings(inv)?;
1642        let shared = &self.shared;
1643        let (prepared, keep, ceiling) = prepare_run(inv, shared, &name).await?;
1644        // A caller's mistake (a missing or ill-typed parameter) is refused here, before a
1645        // run is recorded: nothing ran.
1646        let planned = prepare_request(inv, shared, &prepared, keep, &ceiling, Door::Runs).await?;
1647        let runs_as = inv.capability.attenuate(planned.keep.iter().cloned());
1648        let mut run = Run {
1649            schema: SCHEMA,
1650            name: name.clone(),
1651            id: 0,
1652            version: prepared.version.clone(),
1653            principal: (shared.principal)(inv),
1654            capability: runs_as.scopes().cloned().unwrap_or_default(),
1655            started: inv.now().map(|t| t.as_millis()),
1656            ended: None,
1657            outcome: Outcome::Running,
1658            result: None,
1659            truncated: false,
1660            trace_span: inv.trace_span(),
1661        };
1662        run.id = shared.backend.start_run(&name, &run)?;
1663        let answer = inv.issue_attenuated(planned.request, planned.keep).await;
1664        run.ended = inv.now().map(|t| t.as_millis());
1665        match answer {
1666            Ok(repr) => {
1667                let (text, truncated) = model::truncate(
1668                    String::from_utf8_lossy(&repr.bytes).into_owned(),
1669                    MAX_RECORDED_RESULT,
1670                );
1671                run.outcome = Outcome::Ok;
1672                run.result = Some(text);
1673                run.truncated = truncated;
1674                shared.backend.finish_run(&name, &run)?;
1675                Ok(plain(format!("{}\n", run.iri())))
1676            }
1677            Err(error) => {
1678                run.outcome = Outcome::Failed {
1679                    kind: kind(&error).to_string(),
1680                    message: error.to_string(),
1681                };
1682                shared.backend.finish_run(&name, &run)?;
1683                Err(annotate(error, &run.iri()))
1684            }
1685        }
1686    }
1687
1688    fn name(&self) -> &str {
1689        "script-runs"
1690    }
1691
1692    fn describe(&self) -> Description {
1693        Description::new("script-runs")
1694            .title("A script's runs")
1695            .summary(
1696                "Run the script for its EFFECTS and record the run: who (as the host \
1697                 stamped it), which version, under exactly what capability, when, and the \
1698                 outcome. Answers the run's IRI; a run that fails is recorded too, and its \
1699                 error names the record. A published SPARQL script is its own entry, with \
1700                 its parameters as arguments.",
1701            )
1702            .verb(Verb::Meta)
1703            .action(
1704                ActionSpec::new(Verb::Sink)
1705                    .summary("Run the script and record the run.")
1706                    .input(name_arg())
1707                    .input(data_arg("content", DATA_SUMMARY))
1708                    .output(PLAIN)
1709                    .requires(CAP_RUN)
1710                    .requires(CAP_LISP),
1711            )
1712    }
1713}
1714
1715// ---------------------------------------------------------------------------------------
1716// A published SPARQL script's own contract, and the space that offers it
1717// ---------------------------------------------------------------------------------------
1718
1719/// A SPARQL script's or a plan's own endpoints, `…:result` and `…:runs`, each the
1720/// template's endpoint behind the script's OWN description.
1721#[derive(Clone)]
1722struct PerScript {
1723    result: Arc<dyn Endpoint>,
1724    runs: Arc<dyn Endpoint>,
1725    /// Whether its `…:result` is a read it offers (a query, not an update).
1726    reads: bool,
1727    /// Whether it is published, and so listed in the catalog. A draft of content that was
1728    /// published before, or a retired script, still answers under its own contract (its run
1729    /// is refused for its state, not for a language capability it never needed), but is not
1730    /// offered. A draft never published has no contract of its own (see `build`).
1731    listed: bool,
1732}
1733
1734/// The template's endpoint, behind one script's contract.
1735///
1736/// ★ **Why a script is its own entry.** A description is the contract the engine routes
1737/// arguments by, selection matches on, MCP projects and the Emacs aliases are generated
1738/// from, and the kernel answers it per ENDPOINT, never per IRI. A query's or a plan's
1739/// parameters are real arguments only if they are in a description, so each gets one. And
1740/// the template's `requires` is Lisp's (`urn:cap:lisp`): a query or a plan behind it would be
1741/// refused at the floor for a language grant it never needs, so a plan with no parameters
1742/// still gets its own.
1743/// It is built once per head (the kernel memoizes floors by this allocation) and dropped
1744/// by the next publish or retire.
1745struct Described {
1746    inner: Arc<dyn Endpoint>,
1747    id: String,
1748    description: Description,
1749}
1750
1751#[async_trait]
1752impl Endpoint for Described {
1753    async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
1754        self.inner.invoke(inv).await
1755    }
1756
1757    fn name(&self) -> &str {
1758        &self.id
1759    }
1760
1761    fn describe(&self) -> Description {
1762        self.description.clone()
1763    }
1764}
1765
1766/// The script resources as a space: [`space`]'s bindings, plus each published SPARQL
1767/// script and plan as its own `…:result` and `…:runs` entry, carrying its parameters.
1768pub struct ScriptSpace {
1769    inner: EndpointSpace,
1770    shared: Arc<SpaceConfig>,
1771}
1772
1773impl ScriptSpace {
1774    /// The script's own endpoints, when it is a SPARQL script this host can run or a plan.
1775    ///
1776    /// A Lisp script has none (the template's contract IS its contract), but finding that
1777    /// out costs one head and version read per name, remembered until the next publish.
1778    fn per_script(&self, name: &str) -> Option<PerScript> {
1779        let shared = &self.shared;
1780        if let Some(known) = shared
1781            .described
1782            .lock()
1783            .expect("the described-script memo")
1784            .get(name)
1785        {
1786            return known.clone();
1787        }
1788        let built = self.build(name);
1789        // ⚠ Remember only scripts that exist: a name nobody published is a miss every
1790        // time, never an entry, so resolving arbitrary names cannot grow this map.
1791        if built.is_some() || matches!(shared.backend.head(name), Ok(Some(_))) {
1792            shared
1793                .described
1794                .lock()
1795                .expect("the described-script memo")
1796                .insert(name.to_string(), built.clone());
1797        }
1798        built
1799    }
1800
1801    fn build(&self, name: &str) -> Option<PerScript> {
1802        let shared = &self.shared;
1803        let head = shared.backend.head(name).ok()??;
1804        // ★ A draft never published wears the TEMPLATE's contract. Meta is answered from the
1805        // description to anyone who can reach the door (the kernel's, before any endpoint
1806        // runs, so it cannot ask who is calling), and a script's own contract is made of its
1807        // text: its parameters, its leading comment, the graphs it names. Its own entry would
1808        // publish all three, and that the draft exists. Its run is still refused (for its
1809        // state, to its author; as absent, to everyone else), now at the template's floor.
1810        if !ever_published(&head, &head.version) {
1811            return None;
1812        }
1813        let version = shared.backend.version(name, &head.version).ok()??;
1814        match version.language {
1815            Language::Lisp => None,
1816            Language::Sparql => self.build_sparql(name, &head, &version),
1817            Language::Plan => self.build_plan(name, &head, &version),
1818        }
1819    }
1820
1821    /// A per-script endpoint: the template's, behind `description`.
1822    fn described(
1823        &self,
1824        inner: Arc<dyn Endpoint>,
1825        id: String,
1826        description: Description,
1827    ) -> Arc<dyn Endpoint> {
1828        Arc::new(Described {
1829            inner,
1830            id,
1831            description,
1832        }) as Arc<dyn Endpoint>
1833    }
1834
1835    fn result_endpoint(&self) -> Arc<dyn Endpoint> {
1836        Arc::new(ResultEndpoint {
1837            shared: Arc::clone(&self.shared),
1838        })
1839    }
1840
1841    fn runs_endpoint(&self) -> Arc<dyn Endpoint> {
1842        Arc::new(RunsEndpoint {
1843            shared: Arc::clone(&self.shared),
1844        })
1845    }
1846
1847    fn build_sparql(&self, name: &str, head: &Head, version: &Version) -> Option<PerScript> {
1848        let door = self.shared.sparql.as_ref()?;
1849        let analysis = sparql::analyze(&version.source, door).ok()?;
1850        let about = about(&version.source);
1851        let inputs: Vec<ArgSpec> = analysis
1852            .parameters
1853            .iter()
1854            .map(|p| {
1855                let mut arg = ArgSpec::new(p.name.clone())
1856                    .summary(
1857                        p.summary
1858                            .clone()
1859                            .unwrap_or_else(|| format!("The value of ?{} in the query.", p.name)),
1860                    )
1861                    .class(p.kind.iri().to_string());
1862                if let Some(default) = &p.default {
1863                    arg = arg.default_value(default.clone()).optional();
1864                } else if !p.required {
1865                    arg = arg.optional();
1866                }
1867                arg
1868            })
1869            .collect();
1870        let params = |spec: ActionSpec| contracted(spec, &inputs, name, head, version);
1871        let form = analysis.form;
1872        let faces = form.faces();
1873        let as_arg = || {
1874            ArgSpec::new("as")
1875                .summary(format!(
1876                    "The face: one of {}. Any other is refused, never substituted.",
1877                    faces.join(", ")
1878                ))
1879                .class(XSD_STRING)
1880                .one_of(faces.iter().copied())
1881                .default_value(faces[0])
1882                .optional()
1883        };
1884        let id = format!("script-{name}-result");
1885        let description = if form.is_read() {
1886            let mut spec = params(ActionSpec::new(Verb::Source))
1887                .summary(format!("Run the query {name} and answer its result."))
1888                .input(as_arg());
1889            for face in faces {
1890                spec = spec.output(*face);
1891            }
1892            Description::new(id.clone())
1893                .title(format!("{name}: a SPARQL {}", form.as_str().to_uppercase()))
1894                .summary(format!(
1895                    "{about}Runs as a READ under the runner's capability narrowed to the \
1896                     graphs the query names; cached as the store's answer is, and \
1897                     recomputed after a write to the store or a republish."
1898                ))
1899                .verb(Verb::Meta)
1900                .action(spec)
1901        } else {
1902            // An update is never a read: no action is offered here, and a Source that
1903            // arrives anyway is refused by the run, naming `…:runs`.
1904            Description::new(id.clone())
1905                .title(format!("{name}: a SPARQL UPDATE (run it at …:runs)"))
1906                .summary(format!(
1907                    "{about}A write: run it with a Sink to urn:script:{name}:runs."
1908                ))
1909                .verb(Verb::Meta)
1910        };
1911        let result = self.described(self.result_endpoint(), id, description);
1912        let id = format!("script-{name}-runs");
1913        let mut spec = params(ActionSpec::new(Verb::Sink))
1914            .summary(format!(
1915                "Run {name} for its effects and record the run; answers the run's IRI."
1916            ))
1917            .input(piped_parameters_arg())
1918            .output(PLAIN);
1919        if form.is_read() {
1920            spec = spec.input(as_arg());
1921        }
1922        let runs = self.described(
1923            self.runs_endpoint(),
1924            id.clone(),
1925            Description::new(id)
1926                .title(format!("{name}: runs"))
1927                .summary(format!(
1928                    "{about}Each run is recorded: who, which version, under exactly what \
1929                     capability, when, and the outcome."
1930                ))
1931                .verb(Verb::Meta)
1932                .action(spec),
1933        );
1934        Some(PerScript {
1935            result,
1936            runs,
1937            reads: form.is_read(),
1938            listed: head.state == State::Published,
1939        })
1940    }
1941
1942    /// A plan's own contract: its declared parameters as arguments, and the capability
1943    /// derived from its steps as what the floor checks.
1944    fn build_plan(&self, name: &str, head: &Head, version: &Version) -> Option<PerScript> {
1945        let analysis = plan::analyze(&version.source).ok()?;
1946        let about = about(&version.source);
1947        let inputs: Vec<ArgSpec> = analysis
1948            .parameters
1949            .iter()
1950            .map(|p| {
1951                let mut arg = ArgSpec::new(p.name.clone())
1952                    .summary(
1953                        p.summary
1954                            .clone()
1955                            .unwrap_or_else(|| format!("The plan's parameter `{}`.", p.name)),
1956                    )
1957                    .class(p.class.clone().unwrap_or_else(|| XSD_STRING.to_string()));
1958                if let Some(default) = &p.default {
1959                    arg = arg.default_value(default.clone()).optional();
1960                } else if !p.required {
1961                    arg = arg.optional();
1962                }
1963                arg
1964            })
1965            .collect();
1966        let params = |spec: ActionSpec| contracted(spec, &inputs, name, head, version);
1967        // A plan's answer is its result step's own representation, so no list of faces can
1968        // be declared in advance: `as` is passed to the evaluator, which transrepts or
1969        // refuses.
1970        let as_arg = || {
1971            ArgSpec::new("as")
1972                .summary(
1973                    "The face of the plan's result: when its result step served another \
1974                     media type it is transrepted, and refused when nothing converts it.",
1975                )
1976                .class(XSD_STRING)
1977                .optional()
1978        };
1979        let reads = !analysis.mutates();
1980        let id = format!("script-{name}-result");
1981        let description = if reads {
1982            Description::new(id.clone())
1983                .title(format!("{name}: a plan"))
1984                .summary(format!(
1985                    "{about}Runs as a READ (every step reads) under the runner's capability \
1986                     narrowed to what the plan's steps require; as cacheable as its least \
1987                     cacheable step, and recomputed after a republish."
1988                ))
1989                .verb(Verb::Meta)
1990                .action(
1991                    params(ActionSpec::new(Verb::Source))
1992                        .summary(format!("Run the plan {name} and answer its result."))
1993                        .input(as_arg())
1994                        .output(PLAIN),
1995                )
1996        } else {
1997            // A plan with a Sink or Delete step is never a read: no action is offered here,
1998            // and a Source that arrives anyway is refused by the run, naming `…:runs`.
1999            Description::new(id.clone())
2000                .title(format!("{name}: a plan that writes (run it at …:runs)"))
2001                .summary(format!(
2002                    "{about}A write: run it with a Sink to urn:script:{name}:runs."
2003                ))
2004                .verb(Verb::Meta)
2005        };
2006        let result = self.described(self.result_endpoint(), id, description);
2007        let id = format!("script-{name}-runs");
2008        let runs = self.described(
2009            self.runs_endpoint(),
2010            id.clone(),
2011            Description::new(id)
2012                .title(format!("{name}: runs"))
2013                .summary(format!(
2014                    "{about}Each run is recorded: who, which version, under exactly what \
2015                     capability, when, and the outcome."
2016                ))
2017                .verb(Verb::Meta)
2018                .action(
2019                    params(ActionSpec::new(Verb::Sink))
2020                        .summary(format!(
2021                            "Run the plan {name} and record the run; answers the run's IRI."
2022                        ))
2023                        .input(piped_parameters_arg())
2024                        .input(as_arg())
2025                        .output(PLAIN),
2026                ),
2027        );
2028        Some(PerScript {
2029            result,
2030            runs,
2031            reads,
2032            listed: head.state == State::Published,
2033        })
2034    }
2035}
2036
2037/// `spec` with a script's own `inputs`, the run gate and every scope its version requires.
2038///
2039/// The run gate, as the kernel can check it before the endpoint is entered: the script's own
2040/// grant, or for a public one the family (its own grant OR the public one, which `requires`
2041/// cannot say any other way: it is all-of).
2042fn contracted(
2043    mut spec: ActionSpec,
2044    inputs: &[ArgSpec],
2045    name: &str,
2046    head: &Head,
2047    version: &Version,
2048) -> ActionSpec {
2049    for input in inputs {
2050        spec = spec.input(input.clone());
2051    }
2052    spec = spec.requires(if head.public {
2053        CAP_RUN.to_string()
2054    } else {
2055        cap_run(name)
2056    });
2057    for scope in &version.requires {
2058        spec = spec.requires(scope.clone());
2059    }
2060    spec
2061}
2062
2063/// `…:runs`' piped body, for a script with parameters.
2064fn piped_parameters_arg() -> ArgSpec {
2065    ArgSpec::new("content")
2066        .summary(
2067            "The parameters as one JSON object, for a pipe (a parameter given both here and \
2068             by name is refused).",
2069        )
2070        .class(XSD_STRING)
2071        .optional()
2072}
2073
2074/// A script's own words for the catalog: its leading comment lines that declare nothing.
2075fn about(source: &str) -> String {
2076    let mut lines = Vec::new();
2077    for line in source.lines() {
2078        let trimmed = line.trim();
2079        let Some(body) = trimmed.strip_prefix('#') else {
2080            if trimmed.is_empty() {
2081                continue;
2082            }
2083            break;
2084        };
2085        let body = body.trim();
2086        if !body.is_empty() && !body.starts_with("@param") {
2087            lines.push(body);
2088        }
2089    }
2090    if lines.is_empty() {
2091        String::new()
2092    } else {
2093        format!("{} ", lines.join(" "))
2094    }
2095}
2096
2097impl Space for ScriptSpace {
2098    fn resolve(&self, request: &Request, scope: &Scope) -> Resolution {
2099        let resolution = self.inner.resolve(request, scope);
2100        let (name, part) = match &resolution {
2101            Resolution::Hit(hit) => {
2102                let part = match hit.endpoint.name() {
2103                    "script-result" => Door::Result,
2104                    "script-runs" => Door::Runs,
2105                    _ => return resolution,
2106                };
2107                match hit.bindings.get("name") {
2108                    Some(name) if name::validate(name).is_ok() => (name.to_string(), part),
2109                    _ => return resolution,
2110                }
2111            }
2112            Resolution::Miss => return resolution,
2113        };
2114        match self.per_script(&name) {
2115            Some(per) => resolution.map_endpoint(|_| match part {
2116                Door::Result => per.result,
2117                Door::Runs => per.runs,
2118            }),
2119            None => resolution,
2120        }
2121    }
2122
2123    fn entries(&self) -> Option<Vec<SpaceEntry>> {
2124        let mut entries = self.inner.entries().unwrap_or_default();
2125        for name in self.shared.backend.names().unwrap_or_default() {
2126            let Some(per) = self.per_script(&name).filter(|per| per.listed) else {
2127                continue;
2128            };
2129            if per.reads {
2130                entries.push(SpaceEntry::new(
2131                    name::part_iri(&name, "result"),
2132                    per.result.name(),
2133                ));
2134            }
2135            entries.push(SpaceEntry::new(
2136                name::part_iri(&name, "runs"),
2137                per.runs.name(),
2138            ));
2139        }
2140        Some(entries)
2141    }
2142
2143    fn id(&self) -> Option<Iri> {
2144        self.inner.id()
2145    }
2146
2147    fn topology(&self) -> Topology {
2148        self.inner.topology()
2149    }
2150}
2151
2152/// An error's kind, as a run record spells it.
2153fn kind(error: &Error) -> &'static str {
2154    match error {
2155        Error::Unresolved(_) => "unresolved",
2156        Error::MissingArgument(_) => "missing-argument",
2157        Error::InvalidArgument { .. } => "invalid-argument",
2158        Error::Endpoint(_) => "endpoint",
2159        Error::Denied(_) => "denied",
2160        Error::NotFound(_) => "not-found",
2161        Error::Conflict(_) => "conflict",
2162        Error::Timeout(_) => "timeout",
2163        Error::Unavailable(_) => "unavailable",
2164        _ => "other",
2165    }
2166}
2167
2168/// The same error, of the same type, naming the run record it was written to.
2169fn annotate(error: Error, run: &str) -> Error {
2170    let note = |message: String| format!("{message} (run recorded at {run})");
2171    match error {
2172        Error::Endpoint(m) => Error::Endpoint(note(m)),
2173        Error::Denied(m) => Error::Denied(note(m)),
2174        Error::NotFound(m) => Error::NotFound(note(m)),
2175        Error::Conflict(m) => Error::Conflict(note(m)),
2176        Error::Timeout(m) => Error::Timeout(note(m)),
2177        Error::Unavailable(m) => Error::Unavailable(note(m)),
2178        Error::InvalidArgument { name, detail } => Error::InvalidArgument {
2179            name,
2180            detail: note(detail),
2181        },
2182        other => other,
2183    }
2184}
2185
2186// ---------------------------------------------------------------------------------------
2187// urn:script:{name}:run:{id}
2188// ---------------------------------------------------------------------------------------
2189
2190struct RunEndpoint {
2191    shared: Arc<SpaceConfig>,
2192}
2193
2194#[async_trait]
2195impl Endpoint for RunEndpoint {
2196    async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
2197        let name = name::from_bindings(inv)?;
2198        // A run record says who ran what: readable by the script's readers and its named
2199        // runners, never through the public grants.
2200        if !holds(inv, &cap_read(&name)) && !holds(inv, &cap_run(&name)) {
2201            return Err(Error::Denied(format!(
2202                "a run record of urn:script:{name} needs `{}` or `{}`",
2203                cap_read(&name),
2204                cap_run(&name)
2205            )));
2206        }
2207        let id_text = inv
2208            .bindings
2209            .get("id")
2210            .ok_or_else(|| Error::Endpoint("no `id` captured".to_string()))?;
2211        let id = id_text.parse::<u64>().map_err(|_| Error::InvalidArgument {
2212            name: "id".to_string(),
2213            detail: format!("`{id_text}` is not a run number"),
2214        });
2215        let run = match id {
2216            Ok(id) => self.shared.backend.run(&name, id)?,
2217            Err(_) if inv.request.verb == Verb::Exists => None,
2218            Err(e) => return Err(e),
2219        };
2220        match inv.request.verb {
2221            Verb::Exists => Ok(plain(if run.is_some() { "true\n" } else { "false\n" })),
2222            Verb::Source => {
2223                let want = face_of(inv, &RECORD_FACES)?;
2224                let run = run.ok_or_else(|| {
2225                    Error::NotFound(format!("urn:script:{name} has no run {id_text}"))
2226                })?;
2227                let finished = run.outcome != Outcome::Running;
2228                let repr = match want {
2229                    JSON => json(&run)?,
2230                    TURTLE => {
2231                        let mut graph = crate::graph::Graph::default();
2232                        graph.record(&run);
2233                        turtle(graph)?
2234                    }
2235                    _ => plain(run.render()),
2236                };
2237                // A finished run never changes again; one still running will.
2238                Ok(if finished { repr.cacheable() } else { repr })
2239            }
2240            other => Err(unsupported("script-run", other)),
2241        }
2242    }
2243
2244    fn name(&self) -> &str {
2245        "script-run"
2246    }
2247
2248    fn describe(&self) -> Description {
2249        let id = || {
2250            ArgSpec::new("id")
2251                .summary("The run's number, unique per script.")
2252                .class(XSD_INTEGER)
2253                .binding()
2254        };
2255        Description::new("script-run")
2256            .title("One run of a script")
2257            .summary(
2258                "A recorded run: principal, version, capability, start and end, outcome, the \
2259                 result (up to 64 KiB) and the host's trace span when it traced. Needs the \
2260                 script's read or run grant.",
2261            )
2262            .verb(Verb::Meta)
2263            .action(
2264                ActionSpec::new(Verb::Source)
2265                    .summary("The run record, as text, JSON, or a PROV-O graph (Turtle).")
2266                    .input(name_arg())
2267                    .input(id())
2268                    .input(record_as_arg())
2269                    .output(PLAIN)
2270                    .output(JSON)
2271                    .output(TURTLE)
2272                    .requires(CAP_ANY),
2273            )
2274            .action(
2275                ActionSpec::new(Verb::Exists)
2276                    .summary("Whether the run exists.")
2277                    .input(name_arg())
2278                    .input(id())
2279                    .output(PLAIN)
2280                    .requires(CAP_ANY),
2281            )
2282    }
2283}
2284
2285// ---------------------------------------------------------------------------------------
2286// urn:script:eval — the paste box
2287// ---------------------------------------------------------------------------------------
2288
2289struct EvalEndpoint;
2290
2291#[async_trait]
2292impl Endpoint for EvalEndpoint {
2293    async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
2294        if inv.request.verb != Verb::Sink {
2295            return Err(unsupported("script-eval", inv.request.verb));
2296        }
2297        let code = inv.inline_str("content")?;
2298        let language_text = optional(inv, "language")?;
2299        let language = language_text
2300            .map(Language::parse)
2301            .transpose()?
2302            .unwrap_or(Language::Lisp);
2303        if language == Language::Sparql {
2304            return Err(Error::InvalidArgument {
2305                name: "language".to_string(),
2306                detail: "SPARQL through urn:script:eval is the SPARQL Protocol face (ledger \
2307                         #955), not in this version: publish the query as a script and read \
2308                         its `…:result`"
2309                    .to_string(),
2310            });
2311        }
2312        if language == Language::Plan {
2313            // Not a second door onto the same thing: the host's evaluator already runs a
2314            // supplied plan under the caller's own capability, which is all this door adds.
2315            return Err(Error::InvalidArgument {
2316                name: "language".to_string(),
2317                detail: format!(
2318                    "a plan supplied ad hoc runs at {} (`in` = the plan), under the caller's \
2319                     own capability; to keep it, publish it as a script",
2320                    plan::EVAL
2321                ),
2322            });
2323        }
2324        if let Some(save) = optional(inv, "save")? {
2325            let save = save.trim();
2326            name::validate(save)?;
2327            // Saved the way any writer saves: a Sink to the script, under the caller's own
2328            // capability (so it needs `urn:cap:script:write:{save}`), which cuts the script's
2329            // thread like every other write.
2330            let mut request = Request::new(Verb::Sink, iri(&name::script_iri(save))?)
2331                .with_arg("content", inline(code))
2332                .with_arg("language", inline(language.as_str()))
2333                .with_arg("state", inline("draft"));
2334            if let Some(requires) = optional(inv, "requires")? {
2335                request = request.with_arg("requires", inline(requires));
2336            }
2337            inv.issue(request).await?;
2338        }
2339        // ★ The caller's OWN capability, unchanged: `issue`, not `issue_attenuated`, and
2340        // nothing that could add to it exists.
2341        let answer = inv
2342            .issue(lisp_request(code, optional(inv, "data")?)?)
2343            .await?;
2344        Ok(Representation::new(answer.repr_type, answer.bytes))
2345    }
2346
2347    fn name(&self) -> &str {
2348        "script-eval"
2349    }
2350
2351    fn describe(&self) -> Description {
2352        Description::new("script-eval")
2353            .title("Run code you supply")
2354            .summary(
2355                "Run typed or pasted code as an anonymous script under the caller's OWN \
2356                 capability and nothing more; answers its value. `save=` keeps it as a draft \
2357                 of that script (which needs that script's write grant).",
2358            )
2359            .verb(Verb::Meta)
2360            .action(
2361                ActionSpec::new(Verb::Sink)
2362                    .summary("Run the code.")
2363                    .input(
2364                        ArgSpec::new("content")
2365                            .summary("The code (piped).")
2366                            .class(XSD_STRING),
2367                    )
2368                    .input(
2369                        ArgSpec::new("language")
2370                            .summary(
2371                                "The code's language: `lisp` (SPARQL here is the Protocol \
2372                                 face, not in this version; a plan runs at urn:plan:eval).",
2373                            )
2374                            .class(XSD_STRING)
2375                            .one_of(Language::ALL.map(Language::as_str))
2376                            .default_value("lisp")
2377                            .optional(),
2378                    )
2379                    .input(data_arg("data", DATA_SUMMARY))
2380                    .input(
2381                        ArgSpec::new("save")
2382                            .summary(
2383                                "A script name: also keep the code as a draft at \
2384                                 `urn:script:{save}`. Needs `urn:cap:script:write:{save}`.",
2385                            )
2386                            .class(XSD_STRING)
2387                            .optional(),
2388                    )
2389                    .input(
2390                        ArgSpec::new("requires")
2391                            .summary("With `save=`: the draft's declared capability.")
2392                            .class(XSD_STRING)
2393                            .optional(),
2394                    )
2395                    .output(PLAIN)
2396                    .requires(CAP_LISP),
2397            )
2398    }
2399}
2400
2401// ---------------------------------------------------------------------------------------
2402// urn:script:catalog
2403// ---------------------------------------------------------------------------------------
2404
2405/// One row of the catalog.
2406#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
2407pub struct CatalogEntry {
2408    /// The script.
2409    pub name: String,
2410    /// `urn:script:{name}`.
2411    pub iri: String,
2412    /// Its state, when its head could be read.
2413    pub state: Option<State>,
2414    /// Whether it is public.
2415    pub public: Option<bool>,
2416    /// Its head version.
2417    pub version: Option<String>,
2418    /// Its latest run.
2419    #[serde(rename = "lastRun")]
2420    pub last_run: Option<LastRun>,
2421    /// Why its head could not be read, when it could not.
2422    pub error: Option<String>,
2423}
2424
2425/// A catalog row's latest run.
2426#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
2427pub struct LastRun {
2428    /// `urn:script:{name}:run:{id}`.
2429    pub iri: String,
2430    /// `running`, `ok` or `failed`.
2431    pub status: String,
2432    /// When it ended.
2433    pub ended: Option<u64>,
2434}
2435
2436/// The catalog's JSON face.
2437#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
2438pub struct Catalog {
2439    /// [`SCHEMA`].
2440    pub schema: u32,
2441    /// Every script the caller may read, by name.
2442    pub scripts: Vec<CatalogEntry>,
2443}
2444
2445struct CatalogEndpoint {
2446    shared: Arc<SpaceConfig>,
2447}
2448
2449#[async_trait]
2450impl Endpoint for CatalogEndpoint {
2451    async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
2452        if inv.request.verb != Verb::Source {
2453            return Err(unsupported("script-catalog", inv.request.verb));
2454        }
2455        let want = face_of(inv, &RECORD_FACES)?;
2456        let backend = &self.shared.backend;
2457        let mut scripts = Vec::new();
2458        // Each row's last run in full, for the graph face (a catalog row keeps less).
2459        let mut last_runs = Vec::new();
2460        for name in backend.names()? {
2461            let own = holds(inv, &cap_read(&name));
2462            let mut entry = CatalogEntry {
2463                iri: name::script_iri(&name),
2464                name: name.clone(),
2465                state: None,
2466                public: None,
2467                version: None,
2468                last_run: None,
2469                error: None,
2470            };
2471            let mut last = None;
2472            match backend.head(&name) {
2473                // A broken head is shown to whoever may read the script, so the dashboard
2474                // says what is wrong instead of losing the row (or the whole list).
2475                Err(e) if own => entry.error = Some(e.to_string()),
2476                // Someone else's draft is not listed: to this caller it does not exist.
2477                Ok(Some(head))
2478                    if sight(inv, &self.shared, &head, &head.version) == Sight::Hidden =>
2479                {
2480                    continue
2481                }
2482                Ok(Some(head)) if own || (is_public(&head) && holds(inv, CAP_READ_PUBLIC)) => {
2483                    entry.state = Some(head.state);
2484                    entry.public = Some(head.public);
2485                    entry.version = Some(head.version);
2486                    last = backend.last_run(&name).ok().flatten();
2487                    entry.last_run = last.as_ref().map(|run| LastRun {
2488                        iri: run.iri(),
2489                        status: match run.outcome {
2490                            Outcome::Running => "running",
2491                            Outcome::Ok => "ok",
2492                            Outcome::Failed { .. } => "failed",
2493                        }
2494                        .to_string(),
2495                        ended: run.ended,
2496                    });
2497                }
2498                _ => continue,
2499            }
2500            last_runs.push(last);
2501            scripts.push(entry);
2502        }
2503        if want == TURTLE {
2504            let mut graph = crate::graph::Graph::default();
2505            for (entry, last) in scripts.iter().zip(&last_runs) {
2506                graph.script(&entry.name, entry.version.as_deref(), last.as_ref());
2507            }
2508            return turtle(graph);
2509        }
2510        if want == JSON {
2511            // Live: a run that fails is recorded without a write through any name the
2512            // catalog could hang from, so this list is never cached.
2513            return json(&Catalog {
2514                schema: SCHEMA,
2515                scripts,
2516            });
2517        }
2518        let mut text = String::new();
2519        for entry in &scripts {
2520            match (&entry.error, entry.state) {
2521                (Some(error), _) => text.push_str(&format!("{}  BROKEN: {error}\n", entry.iri)),
2522                (None, Some(state)) => {
2523                    let version = entry.version.as_deref().unwrap_or("");
2524                    text.push_str(&format!(
2525                        "{}  {}{}  {}",
2526                        entry.iri,
2527                        state.as_str(),
2528                        if entry.public == Some(true) {
2529                            " public"
2530                        } else {
2531                            ""
2532                        },
2533                        &version[..version.len().min(19)],
2534                    ));
2535                    if let Some(run) = &entry.last_run {
2536                        text.push_str(&format!(
2537                            "  last run {} {} {}",
2538                            run.iri,
2539                            run.status,
2540                            model::when(run.ended)
2541                        ));
2542                    }
2543                    text.push('\n');
2544                }
2545                _ => {}
2546            }
2547        }
2548        if scripts.is_empty() {
2549            text.push_str("no scripts this capability may read\n");
2550        }
2551        Ok(plain(text))
2552    }
2553
2554    fn name(&self) -> &str {
2555        "script-catalog"
2556    }
2557
2558    fn describe(&self) -> Description {
2559        Description::new("script-catalog")
2560            .title("Scripts")
2561            .summary(
2562                "Every script the caller may read: state, public flag, head version and last \
2563                 run. Live (never cached). A host that wants `urn:host:scripts` aliases it \
2564                 here.",
2565            )
2566            .verb(Verb::Meta)
2567            .action(
2568                ActionSpec::new(Verb::Source)
2569                    .summary("The catalog, as text, JSON, or a graph (Turtle).")
2570                    .input(record_as_arg())
2571                    .output(PLAIN)
2572                    .output(JSON)
2573                    .output(TURTLE)
2574                    .requires(CAP_READ),
2575            )
2576    }
2577}
2578
2579fn unsupported(id: &str, verb: Verb) -> Error {
2580    Error::Endpoint(format!("{id} does not answer {verb:?}"))
2581}