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