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