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