Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Who a script runs as

A published script is code one person wrote and another person runs, on a host neither of them operates. So every run has to answer a question an ordinary endpoint never raises: whose authority is this? The runner’s, because they asked? The publisher’s, because they wrote it? The host’s, because it is running? Each answer alone is a hole. The runner’s alone lets a publisher plant code that does, with a powerful runner’s grants, what the publisher could never do. The publisher’s alone lets anyone who can run a script borrow its author’s authority. The host’s alone is root.

ikigai-script takes all three, and keeps the least. A run holds the RUNNER’s capability, narrowed to

{ declared } ∩ { what the publisher held at publish } ∩ { the host's ceiling for this script }
  + every exclusion the publisher or the ceiling carries

and never more, because the only operation the library uses to make a run’s authority is Invocation::issue_attenuated, and attenuation can only narrow. A run cannot hold anything its runner does not, so the runner’s own capability is the fourth term, implicit in all of it.

For a SPARQL script the first term is not written by anybody. It is derived from the text: each graph a query names is urn:cap:store:read:graph:<iri>, and the one graph an update writes is urn:cap:store:write:graph:<iri>. So the author cannot understate what a query needs, and cannot overstate it either. What those grants mean at the store itself, and why a query naming one graph its caller was not granted is refused rather than answered over the rest, is A query’s authority is its dataset.

This chapter runs every case it describes. The host is the previous chapter’s, with the same items, under a ceiling policy of its own:

cargo run -p script-authority

The program runs all five sections every time; each block below prints one of them.

The people

/// Publishes. Holds the items graph (read and write), and writes the `open-items` and
/// `graphs` scripts and every script in the `team` namespace except `team-payroll`.
pub fn alice() -> Capability {
    person(
        "alice",
        &[
            "urn:cap:script:write:open-items",
            "urn:cap:script:write:graphs",
            "urn:cap:script:write:team-*",
            "urn:cap:script:write:-team-payroll",
            "urn:cap:script:read:open-items",
            READ_ITEMS,
            WRITE_ITEMS,
        ],
    )
}

/// Runs. Holds MORE data than Alice (the payroll graph too), and may run every script in this
/// chapter and every one in the `team` namespace.
pub fn bob() -> Capability {
    person(
        "bob",
        &[
            "urn:cap:script:run:open-items",
            "urn:cap:script:run:graphs",
            "urn:cap:script:run:every-graph",
            "urn:cap:script:run:orphan",
            "urn:cap:script:run:team-*",
            READ_ITEMS,
            READ_PAYROLL,
        ],
    )
}

/// Holds less: she may write `salaries` and run `open-items`, and read no graph at all.
pub fn carol() -> Capability {
    person(
        "carol",
        &[
            "urn:cap:script:write:salaries",
            "urn:cap:script:run:open-items",
        ],
    )
}

Alice writes scripts and reads only the items. Bob runs them, and reads more than Alice does: the payroll graph too. Carol reads nothing. All three are capabilities a host’s door would mint, each naming its person as the principal.

The host’s ceilings

The ceiling is the host’s word on each script, and it is written where the host’s configuration lives, one file per script under <config home>/script-authority/{name}: one urn:cap: scope per line, # comments, a trailing * for a family of grants, a lone * for no ceiling at all. The crate keeps those files in memory, where the program can edit one, and hands the script host a policy that reads them:

/// The host's ceiling files, by script name, as they would sit under
/// `<config home>/script-authority/`. One `urn:cap:` scope per line, `#` comments, a
/// trailing `*` for a family, a lone `*` for no ceiling.
pub const CEILINGS: &[(&str, &str)] = &[
    (
        "open-items",
        "# the items, and nothing else\nurn:cap:store:read:graph:urn:iki:tutorial:items\n",
    ),
    (
        "graphs",
        "# any graph the publisher and the runner both hold\nurn:cap:store:read:graph:*\n",
    ),
    ("every-graph", "urn:cap:store:read:graph:*\n"),
    ("team-open", "*\n"),
    (
        "team-a-open",
        "urn:cap:store:read:graph:urn:iki:tutorial:items\n",
    ),
];

/// The host's ceiling files, held where the host can edit them (a stand-in for the directory).
#[derive(Clone)]
pub struct Ceilings(Arc<Mutex<BTreeMap<String, String>>>);

impl Ceilings {
    /// The files in [`CEILINGS`].
    pub fn new() -> Ceilings {
        let files = CEILINGS
            .iter()
            .map(|(name, text)| (name.to_string(), text.to_string()))
            .collect();
        Ceilings(Arc::new(Mutex::new(files)))
    }

    /// Replace one script's file. ⚠ The library cannot see this happen: the host must also cut
    /// `urn:script:{name}:authority`, or a result cached under the old ceiling is served on.
    pub fn set(&self, name: &str, text: &str) {
        self.0
            .lock()
            .expect("the ceiling files")
            .insert(name.to_string(), text.to_string());
    }

    /// The policy the host hands the script space: the script's file, parsed, and **nothing**
    /// for a script the host has no file for, so a script nobody configured can run nothing at
    /// all. Asked on every run, so a changed file takes effect at the next one that runs.
    pub fn policy(&self) -> CeilingPolicy {
        let files = Arc::clone(&self.0);
        Arc::new(move |name: &str| {
            files
                .lock()
                .expect("the ceiling files")
                .get(name)
                .and_then(|text| Ceiling::parse(text).ok())
                .unwrap_or_else(Ceiling::nothing)
        })
    }
}

impl Default for Ceilings {
    fn default() -> Self {
        Ceilings::new()
    }
}

A script the host has no file for gets Ceiling::nothing(): it may touch nothing, so it cannot run. That is a choice the host makes in this closure, and the library’s own example makes it too. Failing open would mean any script somebody managed to publish ran with whatever its publisher and its runner happened to hold.

A publish is checked against its publisher

cargo run -p script-authority -- 1
carol> sink urn:script:salaries language=sparql   (a query over the payroll graph)
error: denied: a script cannot be given more than its publisher holds, and this capability does not hold urn:cap:store:read:graph:urn:iki:tutorial:payroll — declare less, or publish under a capability that holds it

alice> sink urn:script:open-items language=sparql requires=urn:cap:store:read:graph:urn:iki:tutorial:payroll
error: invalid argument `requires`: a SPARQL script's authority is derived from the graphs its text names, not declared, and this one derives urn:cap:store:read:graph:urn:iki:tutorial:items; the `requires` given says urn:cap:store:read:graph:urn:iki:tutorial:payroll. Omit `requires`

alice> sink urn:script:open-items language=sparql
urn:script:open-items:version:sha256:e1977365…

Carol may write a script called salaries, and the query names a graph she cannot read. Storing it anyway and narrowing at run time would store a script whose declared authority says one thing and whose runs get another, and the action manifold would offer it on the strength of the first. So the publish is refused, naming what she does not hold. No elevation starts here: a script never holds, even on paper, more than the person who published it.

Alice’s first attempt declares a graph her query does not read. For Lisp, requires= is how a script says what it needs. For SPARQL the text already says it, so a requires that disagrees is refused rather than believed. What is stored with the version is the derived set, intersected with what Alice held when she published: her grant, taken as a snapshot, so revoking something from her later does not reach back into a version she already published, and granting her something later does not widen it.

A run is checked against its runner

cargo run -p script-authority -- 2
alice> source urn:script:open-items:result as=text/tab-separated-values
error: denied: capability does not grant `urn:cap:script:run:open-*` (declared by `urn:script:open-items:result`; any grant under `urn:cap:script:run:open-` satisfies it)

carol> source urn:script:open-items:result as=text/tab-separated-values
error: denied: capability does not grant `urn:cap:store:read:graph:urn:iki:tutorial:items` (declared by `urn:script:open-items:result`)

bob> source urn:script:open-items:result as=text/tab-separated-values
?number	?title
1	"Write the README"
2	"Fix the flaky test"
4	"Answer the issue"

Alice published open-items and may not run it. Publishing and running are separate grants, and she holds no run grant at all. The scope the refusal names is worth a second look: not urn:cap:script:run:open-items but urn:cap:script:run:open-*. A published query is its own endpoint, and the kernel checks its declared grant before the endpoint runs, from a description that cannot depend on who asks. So a name with a hyphen in it declares its top-level namespace as the gate (open-items is in the namespace open), and the exact grant is checked inside. The refusal is the kernel’s own, so it can name only that family, and since ikigai-core 0.1.97 it says what passes the floor: any grant under the prefix, which only gets the run as far as the exact check inside. The entry’s own contract, what a meta of urn:script:open-items:result answers, says which exact grant runs it and why the floor is the family. Here that reads oddly, because nobody meant open as a namespace; the last section is where namespaces are meant.

Carol holds the run grant, and the run is refused anyway: the query reads the items, and Carol cannot. The kernel’s floor refused it before the script host ran anything, from the same description, because a published query’s own entry declares the graph grants its text derives. A run is the runner’s capability, narrowed; Carol’s has nothing in it to narrow to.

Bob holds the run grant and the items, and gets the answer.

A run never holds more than its publisher held

This query names no graph. GRAPH ?g ranges over whatever graphs the run may read, so it counts the triples in each, which makes it a probe of exactly that:

/// How many triples each graph a run can see holds. `GRAPH ?g` fixes no graph in the text, so
/// the script declares the FAMILY `urn:cap:store:read:graph:*`, and each run reads the graphs
/// its own narrowed capability holds: never more.
pub const GRAPHS: &str = r#"# How many triples each graph this run may read holds.
SELECT ?g (COUNT(*) AS ?triples) WHERE { GRAPH ?g { ?s ?p ?o } }
GROUP BY ?g ORDER BY ?g"#;
cargo run -p script-authority -- 3
alice> sink urn:script:graphs language=sparql   (GRAPH ?g: every graph the run may read)
urn:script:graphs:version:sha256:646ac869…

bob> source urn:script:graphs:result as=text/tab-separated-values
?g	?triples
<urn:iki:tutorial:items>	20

bob> sink urn:script:graphs:runs   (the same query, run for the record)
urn:script:graphs:run:1

bob> source urn:script:graphs:run:1
urn:script:graphs:run:1
  script:     urn:script:graphs
  version:    sha256:646ac869…
  principal:  urn:iki:tutorial:person:bob
  started:    2026-10-11T09:00:00.000Z
  ended:      2026-10-11T09:00:00.000Z
  outcome:    ok
  capability: urn:cap:script:write:-team-payroll urn:cap:store:read:graph:urn:iki:tutorial:items
  result:
{"head":{"vars":["g","triples"]},"results":{"bindings":[{"g":{"type":"uri","value":"urn:iki:tutorial:items"},"triples":{"type":"literal","value":"20","datatype":"http://www.w3.org/2001/XMLSchema#integer"}}]}}

Bob can read the payroll graph. Alice cannot, and Alice published this. So a run of it, by Bob, sees one graph. A query reads the caller’s readable union and never more: the graph grants the runner holds, that the publisher held, and that the ceiling allows, computed again on every run. Bob cannot borrow Alice’s authority, and Alice’s script cannot borrow Bob’s.

A query can run through …:runs too, when a run should be recorded, and the record shows exactly what the run held. Two scopes, and one of them is not Bob’s: urn:cap:script:write:-team-payroll is an exclusion Alice carries (a grant with a - taking something back out), and it rides along on every run of every script she publishes, whoever runs it. Exclusions travel because a publisher who could not touch something must not be able to publish code that can. This one has nothing to do with counting triples, and it is carried anyway, because the library cannot know which of a publisher’s exclusions matter to a script, and keeping one that does not costs nothing.

Nor more than the host’s ceiling allows

The same query, published by the host itself. Root holds every graph, so the publisher’s half no longer narrows anything, and the ceiling is all that is left between Bob and the payroll:

cargo run -p script-authority -- 4
host> sink urn:script:every-graph language=sparql   (the same query, published by the host)
urn:script:every-graph:version:sha256:646ac869…

bob> source urn:script:every-graph:result as=text/tab-separated-values
?g	?triples
<urn:iki:tutorial:items>	20
<urn:iki:tutorial:payroll>	2

host> (writes script-authority/every-graph: the items graph alone)

bob> source urn:script:every-graph:result as=text/tab-separated-values
?g	?triples
<urn:iki:tutorial:items>	20
<urn:iki:tutorial:payroll>	2

host> sink urn:kernel:cut thread=urn:script:every-graph:authority
cut urn:script:every-graph:authority

bob> source urn:script:every-graph:result as=text/tab-separated-values
?g	?triples
<urn:iki:tutorial:items>	20

host> sink urn:script:orphan language=sparql   (no ceiling file names it)
urn:script:orphan:version:sha256:646ac869…

bob> source urn:script:orphan:result as=text/tab-separated-values
error: denied: urn:script:orphan reads the caller's readable graphs, and this run may read none: it needs `urn:cap:store:read:graph:` grants the publisher held, the host allows and the runner holds

The ceiling file for every-graph starts as the family urn:cap:store:read:graph:*, so Bob’s run reads both graphs. Then the host narrows the file to the items alone:

    say(
        "host",
        "(writes script-authority/every-graph: the items graph alone)",
    );
    ceilings.set(
        "every-graph",
        "urn:cap:store:read:graph:urn:iki:tutorial:items\n",
    );

And Bob still sees the payroll. The ceiling is asked on every run, but nothing ran: the answer was cached, under Bob’s capability, from before the edit. The library cannot see a file change, and the kernel cannot know that a closure’s answer moved. Every result hangs from a thread named for exactly this, urn:script:{name}:authority, and cutting it is the host’s half of the contract:

    ask(
        &kernel,
        "host",
        &host,
        Verb::Sink,
        "urn:kernel:cut",
        &[("thread", "urn:script:every-graph:authority")],
        "sink urn:kernel:cut thread=urn:script:every-graph:authority",
    );

A host that reads its ceilings from files puts a watcher on that directory and makes the same cut when a file changes; this one, which edits the file itself, makes it by hand. After the cut, the next read runs, the policy answers the new ceiling, and the payroll is gone.

orphan has no file at all, so the policy answers Ceiling::nothing(), and the run is refused before the store is asked.

A namespace grant, and an exclusion that wins over it

Granting one script at a time does not scale to a team. A grant can name a namespace instead: urn:cap:script:{act}:{namespace}-* covers every script whose name begins {namespace}-. Alice holds urn:cap:script:write:team-*, and the exclusion urn:cap:script:write:-team-payroll:

cargo run -p script-authority -- 5
alice> sink urn:script:team-open language=sparql
urn:script:team-open:version:sha256:e1977365…

alice> sink urn:script:team-a-open language=sparql
urn:script:team-a-open:version:sha256:e1977365…

alice> sink urn:script:teammate language=sparql
error: denied: publishing a script needs `urn:cap:script:write:teammate`, which this capability does not hold. A script grant names one script (`urn:cap:script:{act}:{name}`) or a namespace of them (`urn:cap:script:{act}:{namespace}-*`), and an exclusion (`…:-{name}`) takes one back out

alice> sink urn:script:team-payroll language=sparql
error: denied: publishing a script needs `urn:cap:script:write:team-payroll`, which this capability does not hold. A script grant names one script (`urn:cap:script:{act}:{name}`) or a namespace of them (`urn:cap:script:{act}:{namespace}-*`), and an exclusion (`…:-{name}`) takes one back out

bob> source urn:script:team-a-open:result as=text/tab-separated-values priority=1
?number	?title
1	"Write the README"
2	"Fix the flaky test"

team-open and team-a-open are in the namespace (namespaces nest: team-a is one inside team). teammate is not: the wildcard comes only after a -, so team-* is not a prefix match on the letters. team-payroll is in it, and the exclusion wins, over a namespace grant and over an exact one alike.

The act stays spelled out in every grant (write, run, read, delete), so a namespace grant to run never publishes, and the wildcard comes only at the end, because the kernel has no infix wildcard to put anywhere else. There is no “every script” grant below root at all: urn:cap:script:write:* is spelled exactly like the family the script door declares (“holds some write grant”), and a string that meant “everything” in one reading and “something” in the other is the confusion a grant must not carry, so it grants nothing.

Bob’s urn:cap:script:run:team-* runs team-a-open, and its ceiling file allows the items, which Alice held when she published it.

The rule, once more

the termwho decideswhenwhat this chapter showed
declaredthe text, for SPARQLat publisha requires that disagrees is refused
the publisher’s grantthe publisher’s capabilityat publish, kept as a snapshotCarol cannot publish what she cannot read; Bob’s run of Alice’s query sees Alice’s graphs
the ceilingthe host, per scripton every run (cut the authority thread when it changes)the payroll leaves only after the cut; no file, no run
the runner’s capabilitythe door that minted iton every runAlice cannot run what she wrote; Carol’s run has nothing to narrow to
exclusionsthe publisher and the ceilingtravel with every runAlice’s -team-payroll is on Bob’s run of graphs

The script host wrote none of this as a second permission system. Every refusal above is either the kernel’s floor, checking what a description declares, or Capability::attenuate, which keeps a grant only when the runner holds it. The library’s part is to compute the request it hands to attenuation, and to refuse a publish that would make that request a lie.