Skip to main content

ikigai_script/
authority.rs

1//! The three authorities, never merged — and the one computation that decides what a run
2//! may touch.
3//!
4//! 1. **Publish or change**: `urn:cap:script:write:{name}`, and `urn:cap:script:delete:{name}`
5//!    to retire.
6//! 2. **Run**: `urn:cap:script:run:{name}`. A **public** script is also runnable by any
7//!    holder of [`CAP_RUN_PUBLIC`], which is the grant a host gives its anonymous
8//!    principal.
9//! 3. **Runs as**: what [`effective`] computes, applied to the runner's own capability by
10//!    attenuation. Never minted.
11//!
12//! Reading a script without running it is a fourth scope, `urn:cap:script:read:{name}`
13//! (or [`CAP_READ_PUBLIC`] for a public, published script): source code is not the same
14//! sensitivity as running it, the same split `ikigai-ledger` draws between read and write.
15//!
16//! Each of the four is held for ONE script (`urn:cap:script:{act}:{name}`) or for a
17//! NAMESPACE of them (`urn:cap:script:{act}:{namespace}-*`, [`cap_namespace`]: every name
18//! that begins `{namespace}-`), and an exclusion (`…:{act}:-{name}`, `…:{act}:-{prefix}*`)
19//! takes a script back out. [`holds`] is the one place that rule lives.
20//!
21//! # Runs as: the least of four
22//!
23//! A run executes under
24//!
25//! ```text
26//! runner.attenuate( { s in declared : s in granted-at-publish and ceiling.allows(s) }
27//!                   + the publisher's exclusions + the ceiling's exclusions )
28//! ```
29//!
30//! - **declared**: what the script says it needs (`requires=` at publish), plus the
31//!   capability its LANGUAGE needs (`urn:cap:lisp` for Lisp), because evaluating code is
32//!   itself authority.
33//! - **granted at publish**: the part of `declared` the publisher held when they published.
34//!   **A publish that declares a scope the publisher does not hold is REFUSED** with a typed
35//!   `Denied` naming the scopes. Narrowing silently instead would store a script whose
36//!   `requires` says one thing and whose runs get another, and the manifold would lie.
37//!   The snapshot is still stored and still intersected, so a hand edit of ONE record (a
38//!   version's `requires`, say, in a [`crate::DirBackend`] file) cannot widen a run past
39//!   the other. Editing both is the host operator's act — the same trust as the ceiling
40//!   files — and the ceiling bounds every run whatever the records say.
41//! - **the host's ceiling** for this script ([`Ceiling`], from the host's
42//!   `<config home>/script-authority/{name}`): the host decides, never the script.
43//! - **the runner's own capability**: [`Capability::attenuate`] keeps a grant only when the
44//!   runner holds it, so a run never exceeds the intersection of runner and script.
45//!
46//! **Exclusions travel.** A publisher who holds `urn:cap:fs:read:/root` together with the
47//! exclusion `urn:cap:fs:read:-/root/secret` could not read the secret, so neither can the
48//! script, whoever runs it: every exclusion the publisher held is stored with the version's
49//! grant and handed to `attenuate`, which keeps a requested exclusion (it only narrows).
50//! The ceiling's exclusions travel the same way.
51//!
52//! **Store time is declared and bounded the same way** (ledger #1384). A runner's
53//! `urn:cap:store:budget:<ms>` grant never follows a script into its run on its own: a
54//! script declares a budget at publish (`budget=`), refused unless its publisher then held at
55//! least that much ([`budget_at_publish`]), and a run gets the least of the declaration, the
56//! publisher's value, the host's ceiling and the runner's own grant ([`effective_budget`]).
57//! A budget is a number, not a token to intersect, so that is the one comparison here that
58//! attenuation does not make, and attenuation still bounds what a run can carry
59//! ([`budget_grant`], [`budget_grant_lowered`]).
60//!
61//! ```
62//! use ikigai_core::Capability;
63//! use ikigai_script::authority::{effective, Ceiling};
64//! use std::collections::BTreeSet;
65//!
66//! let set = |xs: &[&str]| xs.iter().map(|s| s.to_string()).collect::<BTreeSet<_>>();
67//! let declared = set(&["urn:cap:lisp", "urn:cap:net:example.com", "urn:cap:mail:send"]);
68//! let granted = declared.clone(); // the publisher held all three
69//! let exclusions = set(&["urn:cap:net:-example.com/admin"]);
70//! // The host lets this script run Lisp and reach the network, but not send mail.
71//! let ceiling = Ceiling::scoped(["urn:cap:lisp", "urn:cap:net:*"]);
72//!
73//! let request = effective(&declared, &granted, &exclusions, &ceiling);
74//! assert_eq!(
75//!     request,
76//!     set(&["urn:cap:lisp", "urn:cap:net:-example.com/admin", "urn:cap:net:example.com"])
77//! );
78//!
79//! // A runner who holds Lisp and mail, but not the network: the run gets Lisp, and the
80//! // exclusion still rides along (it only narrows).
81//! let runner = Capability::scoped(["urn:cap:lisp", "urn:cap:mail:send", "urn:cap:script:run:x"]);
82//! let runs_as = runner.attenuate(request);
83//! assert!(runs_as.allows("urn:cap:lisp"));
84//! assert!(!runs_as.allows("urn:cap:mail:send")); // the ceiling withheld it
85//! assert!(!runs_as.allows("urn:cap:net:example.com")); // the runner never held it
86//! assert!(!runs_as.allows("urn:cap:script:run:x")); // nothing undeclared survives
87//! ```
88
89use std::collections::BTreeSet;
90use std::sync::Arc;
91
92use ikigai_core::{is_deny_scope, Capability, Error, Result};
93
94/// Reading a script, as declared: "holds some script read grant". A held grant names one
95/// script ([`cap_read`]) or a namespace ([`cap_namespace`]), checked inside by [`holds`].
96pub const CAP_READ: &str = "urn:cap:script:read:*";
97/// Publishing or replacing a script, as declared. Exact form: [`cap_write`].
98pub const CAP_WRITE: &str = "urn:cap:script:write:*";
99/// Retiring a script, as declared. Exact form: [`cap_delete`].
100pub const CAP_DELETE: &str = "urn:cap:script:delete:*";
101/// Running a script, as declared. Exact form: [`cap_run`], or [`CAP_RUN_PUBLIC`] for a
102/// public script.
103pub const CAP_RUN: &str = "urn:cap:script:run:*";
104/// Any script grant at all: the compiled form is readable by a reader OR a runner, and a
105/// `requires` is a conjunction, so it declares the family and checks the exact pair inside.
106pub const CAP_ANY: &str = "urn:cap:script:*";
107
108/// Run any script marked **public** (and published). The grant a host gives its anonymous
109/// principal: it is how "a public script grants the run capability to the anonymous
110/// principal" is spelled, since a host's anonymous grant cannot enumerate scripts that do
111/// not exist yet. Withhold it and nothing runs anonymously.
112pub const CAP_RUN_PUBLIC: &str = "urn:cap:script:run:public";
113/// Read the source of any script marked public (and published).
114pub const CAP_READ_PUBLIC: &str = "urn:cap:script:read:public";
115
116/// The capability `urn:lisp:eval` requires, and so every Lisp script declares implicitly
117/// (see [`crate::model::Language::capability`]). Spelled here rather than imported:
118/// the library does not link `ikigai-lisp`.
119pub const CAP_LISP: &str = "urn:cap:lisp";
120
121/// The exact grant to read `{name}`.
122pub fn cap_read(name: &str) -> String {
123    format!("urn:cap:script:read:{name}")
124}
125
126/// The exact grant to publish or replace `{name}`.
127pub fn cap_write(name: &str) -> String {
128    format!("urn:cap:script:write:{name}")
129}
130
131/// The exact grant to retire `{name}`.
132pub fn cap_delete(name: &str) -> String {
133    format!("urn:cap:script:delete:{name}")
134}
135
136/// The exact grant to run `{name}`.
137pub fn cap_run(name: &str) -> String {
138    format!("urn:cap:script:run:{name}")
139}
140
141/// What a script grant authorizes: the `{act}` in `urn:cap:script:{act}:{name}`.
142#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
143pub enum Act {
144    /// Read the source and head record without running it.
145    Read,
146    /// Publish, replace or draft it.
147    Write,
148    /// Retire it.
149    Delete,
150    /// Run it, as a read or for effects.
151    Run,
152}
153
154impl Act {
155    /// Every act.
156    pub const ALL: [Act; 4] = [Act::Read, Act::Write, Act::Delete, Act::Run];
157
158    /// The word in the scope.
159    pub fn as_str(self) -> &'static str {
160        match self {
161            Act::Read => "read",
162            Act::Write => "write",
163            Act::Delete => "delete",
164            Act::Run => "run",
165        }
166    }
167
168    /// `urn:cap:script:{act}:`, the prefix every grant of this act starts with.
169    fn prefix(self) -> String {
170        format!("urn:cap:script:{}:", self.as_str())
171    }
172}
173
174/// The exact grant to `act` on `{name}`: [`cap_read`], [`cap_write`], [`cap_delete`] or
175/// [`cap_run`].
176pub fn cap(act: Act, name: &str) -> String {
177    format!("{}{name}", act.prefix())
178}
179
180/// The grant to `act` on every script in a NAMESPACE: `urn:cap:script:{act}:{namespace}-*`
181/// (ledger #1174).
182///
183/// A namespace is a name prefix that ends at a `-`: the grant for `team` covers `team-report`
184/// and `team-a-report`, never `teammate` or `team` itself. The `{namespace}` is spelled like
185/// a script name (lowercase letters, digits, `-` and `_`, starting with a letter or digit),
186/// so it can be nested: `team-a` is a namespace inside `team`.
187///
188/// ★ **The parameter goes last, and the wildcard only at its end.** Core has no infix
189/// wildcard (`urn:cap:script:*:team` is not a form anything matches), and the act stays
190/// spelled out, so a namespace grant to RUN never publishes and a grant to READ never runs.
191/// There is no "every script" grant below root: `urn:cap:script:{act}:*` grants nothing,
192/// because it is spelled exactly like the FAMILY each act's door declares
193/// ([`CAP_WRITE`], …), the "holds some script grant" presence test, and a held string that
194/// means "everything" in one reading and "something" in the other is the confusion a grant
195/// must not carry.
196///
197/// An exclusion names a script (`urn:cap:script:{act}:-{name}`) or a namespace
198/// (`urn:cap:script:{act}:-{namespace}-*`) and always wins, over an exact grant too.
199///
200/// ```
201/// use ikigai_core::Capability;
202/// use ikigai_script::authority::{cap_namespace, holds, Act};
203///
204/// let grant = cap_namespace(Act::Write, "team").unwrap();
205/// assert_eq!(grant, "urn:cap:script:write:team-*");
206/// let alice = Capability::scoped([grant.as_str(), "urn:cap:script:write:-team-payroll"]);
207/// assert!(holds(&alice, Act::Write, "team-report"));
208/// assert!(holds(&alice, Act::Write, "team-a-report")); // nested
209/// assert!(!holds(&alice, Act::Write, "teammate")); // not in the namespace
210/// assert!(!holds(&alice, Act::Write, "team")); // the namespace is not a script
211/// assert!(!holds(&alice, Act::Write, "team-payroll")); // excluded
212/// assert!(!holds(&alice, Act::Run, "team-report")); // another act
213/// // Not a namespace: the wildcard must follow a `-`, and there is no "every script".
214/// assert!(cap_namespace(Act::Write, "").is_err());
215/// assert!(cap_namespace(Act::Write, "Team").is_err());
216/// assert!(!holds(&Capability::scoped(["urn:cap:script:write:team*"]), Act::Write, "teammate"));
217/// assert!(!holds(&Capability::scoped(["urn:cap:script:write:*"]), Act::Write, "anything"));
218/// ```
219pub fn cap_namespace(act: Act, namespace: &str) -> Result<String> {
220    if !is_namespace(namespace) {
221        return Err(Error::InvalidArgument {
222            name: "namespace".to_string(),
223            detail: format!(
224                "`{namespace}` is not a namespace: one or more `-`-separated words of lowercase \
225                 letters, digits, `-` and `_`, starting with a letter or digit, at most {} bytes",
226                crate::name::MAX_NAME - 1
227            ),
228        });
229    }
230    Ok(format!("{}{namespace}-*", act.prefix()))
231}
232
233/// Whether `namespace` is spelled like a script name (and leaves room for one more byte).
234fn is_namespace(namespace: &str) -> bool {
235    let mut chars = namespace.chars();
236    namespace.len() < crate::name::MAX_NAME
237        && chars
238            .next()
239            .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
240        && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
241}
242
243/// Whether `capability` may `act` on the script `name`: it holds the exact grant
244/// ([`cap`]) or a namespace grant covering the name ([`cap_namespace`]), and no exclusion
245/// it holds names the script or a namespace it is in ([`excluded`]). Root holds every grant
246/// and no exclusion.
247///
248/// This is the whole rule, and every door of this crate checks it; a host or module asking
249/// "may this caller read script X?" (to filter what it shows, say) asks it here rather than
250/// with `Capability::allows`, which matches a held scope exactly and so would miss a
251/// namespace grant.
252pub fn holds(capability: &Capability, act: Act, name: &str) -> bool {
253    let Some(held) = capability.scopes() else {
254        return true;
255    };
256    if excluded(capability, act, name) {
257        return false;
258    }
259    if capability.allows(&cap(act, name)) {
260        return true;
261    }
262    let prefix = act.prefix();
263    held.iter()
264        .filter_map(|scope| scope.strip_prefix(prefix.as_str()))
265        .filter_map(|rule| rule.strip_suffix('*'))
266        .any(|covered| {
267            covered
268                .strip_suffix('-')
269                .is_some_and(|namespace| is_namespace(namespace) && name.starts_with(covered))
270        })
271}
272
273/// Whether an exclusion `capability` holds names the script `name` for `act`: the script
274/// itself (`urn:cap:script:{act}:-{name}`) or any prefix of its name
275/// (`urn:cap:script:{act}:-{prefix}*`).
276///
277/// Read liberally where a grant is read strictly: an exclusion only ever narrows, so any
278/// `-…*` it spells is honored as a prefix (`-*` excludes every script), while a grant must
279/// be a well-formed namespace to grant anything.
280pub fn excluded(capability: &Capability, act: Act, name: &str) -> bool {
281    let Some(held) = capability.scopes() else {
282        return false;
283    };
284    let prefix = act.prefix();
285    held.iter()
286        .filter_map(|scope| scope.strip_prefix(prefix.as_str()))
287        .filter_map(|rule| rule.strip_prefix('-'))
288        .any(|rule| match rule.strip_suffix('*') {
289            Some(covered) => name.starts_with(covered),
290            None => rule == name,
291        })
292}
293
294/// The run gate a script's OWN contract declares (a published query's or plan's
295/// `…:result` and `…:runs`), as the kernel's floor can check it before the endpoint runs.
296///
297/// The floor matches a declared scope exactly, or a declared `prefix*` as "holds some grant
298/// starting with `prefix`", and a description cannot depend on who asks. So a name with no
299/// `-` (which no namespace grant can cover) declares its exact run grant, and a name in a
300/// namespace declares its TOP-LEVEL namespace as a family: `team-a-report` declares
301/// `urn:cap:script:run:team-*`, which a holder of `run:team-a-report`, `run:team-a-*` or
302/// `run:team-*` satisfies. The exact rule ([`holds`]) is checked inside, so the cost is
303/// only that the script's entry is offered to a holder of another grant in the same
304/// top-level namespace, who is refused when they call it.
305///
306/// ```
307/// use ikigai_script::authority::run_floor;
308/// assert_eq!(run_floor("nightly"), "urn:cap:script:run:nightly");
309/// assert_eq!(run_floor("team-a-report"), "urn:cap:script:run:team-*");
310/// ```
311pub fn run_floor(name: &str) -> String {
312    match name.split_once('-') {
313        Some((top, _)) => format!("{}{top}-*", Act::Run.prefix()),
314        None => cap_run(name),
315    }
316}
317
318/// What is recorded as the principal when the request's capability names none: root (the
319/// host's own authority, which holds every principal and names none), a door that minted no
320/// `urn:cap:principal:` scope, or a capability carrying several (which names nobody). Honest
321/// rather than useful, so a host that never identifies its callers cannot have its records
322/// claim an identity nobody checked. The value records written before 0.2.0 carry for a host
323/// that stamped nothing, kept so old and new records agree.
324pub const UNSTAMPED: &str = "urn:script:principal:unstamped";
325
326/// What a door conventionally mints (`Capability::with_principal`) for a caller it cannot
327/// identify. Many callers share it, so it is nobody's identity: see [`is_identity`].
328pub const ANONYMOUS: &str = "urn:script:principal:anonymous";
329
330/// Who a request comes from: the principal its CAPABILITY names, minted by the host's door
331/// (`urn:cap:principal:<iri>`, ikigai-core 0.1.93), or [`UNSTAMPED`] when it names none.
332///
333/// The library records it on every publish and every run. It is never an argument: a caller
334/// cannot name itself, and narrowing a capability can never add or change a principal, so a
335/// sub-request issued on someone's behalf carries their name or none.
336///
337/// ```
338/// use ikigai_core::Capability;
339/// use ikigai_script::authority::{principal_of, UNSTAMPED};
340///
341/// let door = Capability::scoped(["urn:cap:script:run:job"]);
342/// let alice = door.with_principal("urn:example:person:alice").unwrap();
343/// assert_eq!(principal_of(&alice), "urn:example:person:alice");
344/// assert_eq!(principal_of(&door), UNSTAMPED);
345/// // Root is the host's own authority, not a party's.
346/// assert_eq!(principal_of(&Capability::root()), UNSTAMPED);
347/// ```
348pub fn principal_of(capability: &Capability) -> String {
349    capability.principal().unwrap_or(UNSTAMPED).to_string()
350}
351
352/// Whether a recorded principal names SOMEONE: anything but [`UNSTAMPED`] and
353/// [`ANONYMOUS`], which stand for many different callers at once.
354///
355/// It decides who may see a draft. A draft is visible only to its author until it is
356/// published, and the author is the principal recorded when the draft was written; two
357/// callers minted [`ANONYMOUS`] are not one person, and a capability naming no principal
358/// cannot be told apart from any other. So a draft written under either is nobody's: only
359/// root sees it until it is published. A door that cannot identify a caller mints
360/// [`ANONYMOUS`] (or nothing) rather than inventing a shared name.
361///
362/// ```
363/// use ikigai_script::authority::{is_identity, ANONYMOUS, UNSTAMPED};
364/// assert!(is_identity("urn:example:person:brian"));
365/// assert!(!is_identity(ANONYMOUS));
366/// assert!(!is_identity(UNSTAMPED));
367/// assert!(!is_identity(""));
368/// ```
369pub fn is_identity(principal: &str) -> bool {
370    !principal.trim().is_empty() && principal != UNSTAMPED && principal != ANONYMOUS
371}
372
373/// The host's ceiling for one script: the most it will let that script's runs touch.
374#[derive(Clone, Debug, PartialEq, Eq)]
375pub enum Ceiling {
376    /// The host imposes nothing beyond what the script declared and its publisher held.
377    Unbounded,
378    /// Exactly these scopes. A grant ending in `*` admits every scope under that prefix
379    /// (`urn:cap:net:*`); a deny-shaped scope ([`is_deny_scope`]) is an exclusion every run
380    /// of the script carries.
381    Scopes(BTreeSet<String>),
382}
383
384impl Ceiling {
385    /// No ceiling: [`Ceiling::Unbounded`].
386    pub fn unbounded() -> Ceiling {
387        Ceiling::Unbounded
388    }
389
390    /// The empty ceiling: the script may touch nothing, not even its language, so it cannot
391    /// run. The answer for a script the host has no file for, when the host fails closed.
392    pub fn nothing() -> Ceiling {
393        Ceiling::Scopes(BTreeSet::new())
394    }
395
396    /// Exactly these scopes.
397    pub fn scoped<I, S>(scopes: I) -> Ceiling
398    where
399        I: IntoIterator<Item = S>,
400        S: Into<String>,
401    {
402        Ceiling::Scopes(scopes.into_iter().map(Into::into).collect())
403    }
404
405    /// Parse the text of a ceiling file (`<config home>/script-authority/{name}`): one
406    /// `urn:cap:` scope per line, `#` to the end of a line is a comment, blank lines are
407    /// ignored. A lone `*` line means [`Ceiling::Unbounded`].
408    ///
409    /// ```
410    /// use ikigai_script::authority::Ceiling;
411    /// let ceiling = Ceiling::parse(
412    ///     "# the nightly report\nurn:cap:lisp\nurn:cap:net:*   # any host\n\
413    ///      urn:cap:net:-internal.example\n",
414    /// )
415    /// .unwrap();
416    /// assert!(ceiling.allows("urn:cap:lisp"));
417    /// assert!(ceiling.allows("urn:cap:net:example.com"));
418    /// assert!(!ceiling.allows("urn:cap:mail:send"));
419    /// assert_eq!(ceiling.exclusions(), vec!["urn:cap:net:-internal.example".to_string()]);
420    /// assert_eq!(Ceiling::parse("*\n").unwrap(), Ceiling::Unbounded);
421    /// assert!(Ceiling::parse("lisp\n").is_err()); // not a capability scope
422    /// ```
423    pub fn parse(text: &str) -> Result<Ceiling> {
424        let mut scopes = BTreeSet::new();
425        for (number, line) in text.lines().enumerate() {
426            let line = line.split('#').next().unwrap_or("").trim();
427            if line.is_empty() {
428                continue;
429            }
430            if line == "*" {
431                return Ok(Ceiling::Unbounded);
432            }
433            if !line.starts_with("urn:cap:") || line.split_whitespace().count() != 1 {
434                return Err(Error::InvalidArgument {
435                    name: "ceiling".to_string(),
436                    detail: format!(
437                        "line {}: `{line}` is not one `urn:cap:` scope (or `*` for no ceiling)",
438                        number + 1
439                    ),
440                });
441            }
442            scopes.insert(line.to_string());
443        }
444        Ok(Ceiling::Scopes(scopes))
445    }
446
447    /// Whether the ceiling admits the grant `scope`: held exactly, or under a held
448    /// `prefix*` family. An exclusion is never admitted as a grant.
449    pub fn allows(&self, scope: &str) -> bool {
450        match self {
451            Ceiling::Unbounded => !is_deny_scope(scope),
452            Ceiling::Scopes(held) => {
453                !is_deny_scope(scope)
454                    && held.iter().any(|h| match h.strip_suffix('*') {
455                        Some(prefix) => scope.starts_with(prefix),
456                        None => h == scope,
457                    })
458            }
459        }
460    }
461
462    /// The exclusions the ceiling carries, which every run of the script carries too.
463    pub fn exclusions(&self) -> Vec<String> {
464        match self {
465            Ceiling::Unbounded => Vec::new(),
466            Ceiling::Scopes(held) => held.iter().filter(|s| is_deny_scope(s)).cloned().collect(),
467        }
468    }
469}
470
471/// How the host answers "what is the ceiling for this script?" — called on every run, so a
472/// changed ceiling takes effect at the next one (and a cached result is cut by
473/// [`crate::name::authority_thread`]).
474///
475/// ```
476/// use ikigai_script::authority::{Ceiling, CeilingPolicy};
477/// use std::sync::Arc;
478/// // A host reading `<config home>/script-authority/{name}`, failing closed when the file
479/// // is missing or unreadable:
480/// let dir = std::path::PathBuf::from("/etc/my-host/script-authority");
481/// let policy: CeilingPolicy = Arc::new(move |name| {
482///     std::fs::read_to_string(dir.join(name))
483///         .ok()
484///         .and_then(|text| Ceiling::parse(&text).ok())
485///         .unwrap_or_else(Ceiling::nothing)
486/// });
487/// # let _ = policy;
488/// ```
489pub type CeilingPolicy = Arc<dyn Fn(&str) -> Ceiling + Send + Sync>;
490
491/// A ceiling policy that answers the same ceiling for every script.
492pub fn same_for_all(ceiling: Ceiling) -> CeilingPolicy {
493    Arc::new(move |_| ceiling.clone())
494}
495
496/// Parse `requires=`: whitespace-separated `urn:cap:` grants.
497///
498/// Refused: anything not under `urn:cap:`, a family wildcard (`*`: a script states the
499/// grants it needs, and attenuation keeps grants by exact name, so a wildcard would keep
500/// nothing), and an exclusion (it is not something a script can need; exclusions come
501/// from the publisher and the host).
502pub fn parse_requires(text: &str) -> Result<BTreeSet<String>> {
503    let mut scopes = BTreeSet::new();
504    for scope in text.split_whitespace() {
505        let detail = if !scope.starts_with("urn:cap:") {
506            Some("is not a `urn:cap:` scope")
507        } else if scope.ends_with('*') {
508            Some(
509                "is a family wildcard; a script declares the exact grants it needs, \
510                 because a run keeps grants by exact name",
511            )
512        } else if is_deny_scope(scope) {
513            Some("is an exclusion; a script declares what it needs, not what it may not touch")
514        } else {
515            None
516        };
517        if let Some(detail) = detail {
518            return Err(Error::InvalidArgument {
519                name: "requires".to_string(),
520                detail: format!("`{scope}` {detail}"),
521            });
522        }
523        scopes.insert(scope.to_string());
524    }
525    Ok(scopes)
526}
527
528/// What the publisher held of a script's declared scopes, taken at publish time.
529#[derive(Clone, Debug, PartialEq, Eq)]
530pub struct Grant {
531    /// The declared scopes the publisher held: all of them, since a publish declaring more
532    /// is refused. For a declared FAMILY (`prefix*`, which only a SPARQL script's derived
533    /// authority carries: [`crate::sparql::CAP_READ_GRAPH`]), the publisher's own grants
534    /// under it, each one, or the family itself when the publisher was root.
535    pub granted: BTreeSet<String>,
536    /// Every exclusion the publisher's capability carried, which every run carries too.
537    pub exclusions: BTreeSet<String>,
538}
539
540/// A declared family (`prefix*`): its prefix.
541fn family(scope: &str) -> Option<&str> {
542    scope.strip_suffix('*')
543}
544
545/// Take the publisher's grant for `declared`, or refuse with a typed `Denied` naming what
546/// they do not hold. **No elevation**: a script never runs with more than its publisher
547/// held.
548///
549/// A declared family (`prefix*`) is held when the publisher holds some grant under it,
550/// the kernel's own reading of a family in a `requires`; what is taken is every such
551/// grant, so a run's union is never wider than its publisher's.
552pub fn grant_at_publish(publisher: &Capability, declared: &BTreeSet<String>) -> Result<Grant> {
553    let held_under = |prefix: &str| -> Vec<String> {
554        publisher
555            .scopes()
556            .map(|held| {
557                held.iter()
558                    .filter(|s| s.starts_with(prefix) && !is_deny_scope(s))
559                    .cloned()
560                    .collect()
561            })
562            .unwrap_or_default()
563    };
564    let mut granted = BTreeSet::new();
565    let mut missing: Vec<&str> = Vec::new();
566    for scope in declared {
567        match family(scope) {
568            Some(_) if publisher.is_root() => {
569                granted.insert(scope.clone());
570            }
571            Some(prefix) => {
572                let held = held_under(prefix);
573                if held.is_empty() {
574                    missing.push(scope);
575                }
576                granted.extend(held);
577            }
578            None if publisher.allows(scope) => {
579                granted.insert(scope.clone());
580            }
581            None => missing.push(scope),
582        }
583    }
584    if !missing.is_empty() {
585        return Err(Error::Denied(format!(
586            "a script cannot be given more than its publisher holds, and this capability \
587             does not hold {} — declare less, or publish under a capability that holds it",
588            missing.join(", ")
589        )));
590    }
591    let exclusions = publisher
592        .scopes()
593        .map(|held| held.iter().filter(|s| is_deny_scope(s)).cloned().collect())
594        .unwrap_or_default();
595    Ok(Grant {
596        granted,
597        exclusions,
598    })
599}
600
601/// The scopes a run asks to keep: `{ s in declared : s in granted and ceiling.allows(s) }`
602/// plus the publisher's and the ceiling's exclusions. The run's capability is the runner's
603/// own [`Capability::attenuate`]d to this set — see the module documentation.
604///
605/// A declared family keeps the publisher's grants under it that the ceiling allows; when
606/// the publisher was root it keeps the family itself, a marker the SPARQL run expands
607/// into exact grants (each checked against the ceiling and the runner) before anything
608/// is attenuated, because attenuation keeps grants by exact name.
609pub fn effective(
610    declared: &BTreeSet<String>,
611    granted: &BTreeSet<String>,
612    exclusions: &BTreeSet<String>,
613    ceiling: &Ceiling,
614) -> BTreeSet<String> {
615    let mut keep: BTreeSet<String> = BTreeSet::new();
616    for scope in declared {
617        match family(scope) {
618            Some(_) if granted.contains(scope) => {
619                keep.insert(scope.clone());
620            }
621            Some(prefix) => keep.extend(
622                granted
623                    .iter()
624                    .filter(|g| g.starts_with(prefix) && ceiling.allows(g))
625                    .cloned(),
626            ),
627            None if granted.contains(scope) && ceiling.allows(scope) => {
628                keep.insert(scope.clone());
629            }
630            None => {}
631        }
632    }
633    keep.extend(exclusions.iter().filter(|s| is_deny_scope(s)).cloned());
634    keep.extend(ceiling.exclusions());
635    keep
636}
637
638// ---------------------------------------------------------------------------------------
639// A declared store time budget (ledger #1384)
640// ---------------------------------------------------------------------------------------
641
642/// Where a store time-budget grant starts: `urn:cap:store:budget:<milliseconds>`, the grant
643/// `ikigai-store` reads to lift a caller above its base budget (spelled here rather than
644/// imported: the library does not link the store).
645pub const BUDGET_PREFIX: &str = "urn:cap:store:budget:";
646
647/// The most a script may declare, and the budget root is taken to hold: **120 s**, the
648/// store's default ceiling (`ikigai_store::budget::DEFAULT_CEILING`). The store clamps every
649/// grant to its own ceiling as well, so a host that lowers its store's ceiling still bounds
650/// every run; one that RAISES it does not raise this.
651pub const MAX_BUDGET_MS: u64 = 120_000;
652
653/// The store budget grant for `millis` milliseconds.
654///
655/// ```
656/// assert_eq!(ikigai_script::authority::cap_budget(900), "urn:cap:store:budget:900");
657/// ```
658pub fn cap_budget(millis: u64) -> String {
659    format!("{BUDGET_PREFIX}{millis}")
660}
661
662/// The budget grants `capability` holds, as numbers (each held exactly, never an exclusion).
663fn budget_grants(capability: &Capability) -> BTreeSet<u64> {
664    capability
665        .scopes()
666        .map(|held| {
667            held.iter()
668                .filter(|scope| capability.allows(scope))
669                .filter_map(|scope| scope.strip_prefix(BUDGET_PREFIX)?.parse::<u64>().ok())
670                .filter(|millis| *millis > 0)
671                .collect()
672        })
673        .unwrap_or_default()
674}
675
676/// The most store time `capability` holds: [`MAX_BUDGET_MS`] for root, otherwise its largest
677/// `urn:cap:store:budget:<ms>` grant, or `None` when it holds none (the store's base).
678///
679/// ```
680/// use ikigai_core::Capability;
681/// use ikigai_script::authority::{held_budget, MAX_BUDGET_MS};
682/// let two = Capability::scoped(["urn:cap:store:budget:900", "urn:cap:store:budget:5000"]);
683/// assert_eq!(held_budget(&two), Some(5000));
684/// assert_eq!(held_budget(&Capability::scoped(["urn:cap:lisp"])), None);
685/// assert_eq!(held_budget(&Capability::root()), Some(MAX_BUDGET_MS));
686/// ```
687pub fn held_budget(capability: &Capability) -> Option<u64> {
688    if capability.is_root() {
689        return Some(MAX_BUDGET_MS);
690    }
691    budget_grants(capability).last().copied()
692}
693
694/// Parse `budget=` at publish: a positive whole number of milliseconds, at most
695/// [`MAX_BUDGET_MS`].
696pub fn parse_budget(text: &str) -> Result<u64> {
697    match text.trim().parse::<u64>() {
698        Ok(millis) if (1..=MAX_BUDGET_MS).contains(&millis) => Ok(millis),
699        _ => Err(Error::InvalidArgument {
700            name: "budget".to_string(),
701            detail: format!(
702                "`{text}` is not a store time budget: give a positive whole number of \
703                 milliseconds, at most {MAX_BUDGET_MS} (the store's ceiling)"
704            ),
705        }),
706    }
707}
708
709/// Take the publisher's store budget for a script declaring `declared` milliseconds, or
710/// refuse with a typed `Denied` naming both values. **No elevation**, the same rule as
711/// [`grant_at_publish`]: a script never asks for more store time than its publisher held
712/// when they published it. The value answered is what the publisher held, stored with the
713/// head and intersected at every run, as the granted scopes are.
714///
715/// ```
716/// use ikigai_core::Capability;
717/// use ikigai_script::authority::{budget_at_publish, MAX_BUDGET_MS};
718/// let publisher = Capability::scoped(["urn:cap:store:budget:2000"]);
719/// assert_eq!(budget_at_publish(&publisher, 900).unwrap(), 2000);
720/// let refused = budget_at_publish(&publisher, 5000).unwrap_err().to_string();
721/// assert!(refused.contains("5000") && refused.contains("2000"), "{refused}");
722/// assert!(budget_at_publish(&Capability::scoped(["urn:cap:lisp"]), 900).is_err());
723/// assert_eq!(budget_at_publish(&Capability::root(), 900).unwrap(), MAX_BUDGET_MS);
724/// ```
725pub fn budget_at_publish(publisher: &Capability, declared: u64) -> Result<u64> {
726    match held_budget(publisher) {
727        Some(held) if held >= declared => Ok(held),
728        held => Err(Error::Denied(format!(
729            "a script cannot be given more store time than its publisher holds: it declares \
730             budget={declared} ms, and this capability holds {} — declare less, or publish \
731             under a capability holding `{}`",
732            match held {
733                Some(held) => format!("`{}` ({held} ms) at most", cap_budget(held)),
734                None => "no store budget grant (only the store's base)".to_string(),
735            },
736            cap_budget(declared)
737        ))),
738    }
739}
740
741impl Ceiling {
742    /// The most store time this ceiling lets a script's runs carry: [`MAX_BUDGET_MS`] when
743    /// it is [`Ceiling::Unbounded`] or holds a family covering the budget grants
744    /// (`urn:cap:store:budget:*`, `urn:cap:store:*`, …); otherwise its largest exact
745    /// `urn:cap:store:budget:<ms>`; `None` when it names none, and then a run carries no
746    /// budget grant and gets the store's base. A budget is a number, so a ceiling of 5000
747    /// admits a declared 900.
748    ///
749    /// ```
750    /// use ikigai_script::authority::{Ceiling, MAX_BUDGET_MS};
751    /// assert_eq!(Ceiling::Unbounded.budget(), Some(MAX_BUDGET_MS));
752    /// assert_eq!(Ceiling::scoped(["urn:cap:store:budget:5000"]).budget(), Some(5000));
753    /// assert_eq!(Ceiling::scoped(["urn:cap:store:budget:*"]).budget(), Some(MAX_BUDGET_MS));
754    /// assert_eq!(Ceiling::scoped(["urn:cap:lisp"]).budget(), None);
755    /// ```
756    pub fn budget(&self) -> Option<u64> {
757        match self {
758            Ceiling::Unbounded => Some(MAX_BUDGET_MS),
759            Ceiling::Scopes(held) => {
760                let family = held.iter().any(|h| {
761                    h.strip_suffix('*')
762                        .is_some_and(|prefix| BUDGET_PREFIX.starts_with(prefix))
763                });
764                if family {
765                    return Some(MAX_BUDGET_MS);
766                }
767                held.iter()
768                    .filter_map(|h| h.strip_prefix(BUDGET_PREFIX)?.parse::<u64>().ok())
769                    .filter(|millis| *millis > 0)
770                    .max()
771            }
772        }
773    }
774}
775
776/// The script's side of a run's budget: the least of what the version declares, what its
777/// publisher held at publish and what the host's ceiling admits. `None` when the script
778/// declares nothing, or any of the three withholds it: the run then gets the store's base.
779pub fn budget_bound(declared: Option<u64>, granted: Option<u64>, ceiling: &Ceiling) -> Option<u64> {
780    Some(declared?.min(granted?).min(ceiling.budget()?))
781}
782
783/// A run's store time budget: the least of the declared value, the publisher's value at
784/// publish, the host's ceiling ([`budget_bound`]) and the RUNNER's own budget
785/// ([`held_budget`]). `None` means the store's base: no declaration, or a runner holding no
786/// budget grant (a script never lifts its runner above the base).
787///
788/// A budget grant is a number, not a token to intersect, so this is the one comparison the
789/// run makes that attenuation does not. What a run can then CARRY is bounded by attenuation
790/// as well: see [`budget_grant`] and [`budget_grant_lowered`].
791///
792/// ```
793/// use ikigai_core::Capability;
794/// use ikigai_script::authority::{effective_budget, Ceiling};
795/// let runner = Capability::scoped(["urn:cap:store:budget:900"]);
796/// let host = Ceiling::Unbounded;
797/// assert_eq!(effective_budget(Some(900), Some(2000), &host, &runner), Some(900));
798/// assert_eq!(effective_budget(Some(5000), Some(5000), &host, &runner), Some(900));
799/// assert_eq!(effective_budget(None, None, &host, &runner), None);
800/// ```
801pub fn effective_budget(
802    declared: Option<u64>,
803    granted: Option<u64>,
804    ceiling: &Ceiling,
805    runner: &Capability,
806) -> Option<u64> {
807    Some(budget_bound(declared, granted, ceiling)?.min(held_budget(runner)?))
808}
809
810/// The budget grant a run carries to reach `millis`, when nothing on its way to the store
811/// can lower it (a Lisp script's or a plan's own sub-requests).
812///
813/// A run's capability is the runner's [`Capability::attenuate`]d, which keeps grants by EXACT
814/// name and has no widening form, so it can carry only a grant its runner holds: root
815/// carries `urn:cap:store:budget:<millis>` exactly, and so does a runner holding exactly
816/// that grant. Any other runner carries the largest budget grant it holds BELOW `millis`
817/// (never one above it), or none, and its run gets less than `millis`, never more.
818///
819/// ```
820/// use ikigai_core::Capability;
821/// use ikigai_script::authority::budget_grant;
822/// let runner = Capability::scoped(["urn:cap:store:budget:500", "urn:cap:store:budget:60000"]);
823/// assert_eq!(budget_grant(&runner, 60000).as_deref(), Some("urn:cap:store:budget:60000"));
824/// assert_eq!(budget_grant(&runner, 900).as_deref(), Some("urn:cap:store:budget:500"));
825/// assert_eq!(budget_grant(&runner, 400), None);
826/// assert_eq!(budget_grant(&Capability::root(), 900).as_deref(), Some("urn:cap:store:budget:900"));
827/// ```
828pub fn budget_grant(runner: &Capability, millis: u64) -> Option<String> {
829    if runner.is_root() {
830        return Some(cap_budget(millis));
831    }
832    budget_grants(runner)
833        .range(..=millis)
834        .next_back()
835        .map(|held| cap_budget(*held))
836}
837
838/// The budget grant a run carries when its one store request also carries `budget=<millis>`
839/// (a SPARQL script's run), which the store's doors read as a LOWERING: the request is
840/// answered at the least of `budget=` and what its capability gets.
841///
842/// So the run may carry a grant above `millis` without its request getting more: root
843/// carries `millis` exactly, any other runner the smallest budget grant it holds at or
844/// above `millis`, or failing that its largest. The store then answers at
845/// `min(millis, max(base, that grant))`, which is exactly [`effective_budget`] with the
846/// store's base standing in for a runner holding none.
847///
848/// ```
849/// use ikigai_core::Capability;
850/// use ikigai_script::authority::budget_grant_lowered;
851/// let runner = Capability::scoped(["urn:cap:store:budget:500", "urn:cap:store:budget:60000"]);
852/// assert_eq!(budget_grant_lowered(&runner, 900).as_deref(), Some("urn:cap:store:budget:60000"));
853/// assert_eq!(budget_grant_lowered(&runner, 400).as_deref(), Some("urn:cap:store:budget:500"));
854/// assert_eq!(budget_grant_lowered(&runner, 90000).as_deref(), Some("urn:cap:store:budget:60000"));
855/// assert_eq!(budget_grant_lowered(&Capability::scoped(["urn:cap:lisp"]), 900), None);
856/// ```
857pub fn budget_grant_lowered(runner: &Capability, millis: u64) -> Option<String> {
858    if runner.is_root() {
859        return Some(cap_budget(millis));
860    }
861    let held = budget_grants(runner);
862    held.range(millis..)
863        .next()
864        .or_else(|| held.last())
865        .map(|held| cap_budget(*held))
866}
867
868#[cfg(test)]
869mod tests {
870    use super::*;
871
872    fn set(xs: &[&str]) -> BTreeSet<String> {
873        xs.iter().map(|s| s.to_string()).collect()
874    }
875
876    #[test]
877    fn a_publisher_cannot_declare_what_they_do_not_hold() {
878        let publisher = Capability::scoped(["urn:cap:lisp", "urn:cap:script:write:x"]);
879        let err =
880            grant_at_publish(&publisher, &set(&["urn:cap:lisp", "urn:cap:mail:send"])).unwrap_err();
881        assert!(
882            matches!(&err, Error::Denied(m) if m.contains("urn:cap:mail:send") && !m.contains("urn:cap:lisp,")),
883            "{err:?}"
884        );
885    }
886
887    #[test]
888    fn the_publishers_exclusions_are_taken_with_the_grant() {
889        let publisher = Capability::scoped([
890            "urn:cap:lisp",
891            "urn:cap:fs:read:/root",
892            "urn:cap:fs:read:-/root/secret",
893        ]);
894        let grant =
895            grant_at_publish(&publisher, &set(&["urn:cap:lisp", "urn:cap:fs:read:/root"])).unwrap();
896        assert_eq!(grant.exclusions, set(&["urn:cap:fs:read:-/root/secret"]));
897        // Root holds no exclusions and every grant.
898        let grant = grant_at_publish(&Capability::root(), &set(&["urn:cap:anything"])).unwrap();
899        assert!(grant.exclusions.is_empty());
900    }
901
902    #[test]
903    fn a_hand_widened_grant_still_cannot_exceed_the_stored_snapshot() {
904        // declared names mail, but the snapshot (what the publisher held) does not.
905        let request = effective(
906            &set(&["urn:cap:lisp", "urn:cap:mail:send"]),
907            &set(&["urn:cap:lisp"]),
908            &BTreeSet::new(),
909            &Ceiling::Unbounded,
910        );
911        assert_eq!(request, set(&["urn:cap:lisp"]));
912    }
913
914    #[test]
915    fn a_family_takes_the_publishers_grants_under_it() {
916        let family = "urn:cap:store:read:graph:*";
917        let publisher = Capability::scoped([
918            "urn:cap:store:read:graph:urn:a",
919            "urn:cap:store:read:graph:urn:b",
920            "urn:cap:other",
921        ]);
922        let grant = grant_at_publish(&publisher, &set(&[family])).unwrap();
923        assert_eq!(
924            grant.granted,
925            set(&[
926                "urn:cap:store:read:graph:urn:a",
927                "urn:cap:store:read:graph:urn:b"
928            ])
929        );
930        // Holding nothing under it is holding nothing it declares.
931        assert!(grant_at_publish(&Capability::scoped(["urn:cap:other"]), &set(&[family])).is_err());
932        // Root keeps the family, as a marker for the run to expand.
933        let root = grant_at_publish(&Capability::root(), &set(&[family])).unwrap();
934        assert_eq!(root.granted, set(&[family]));
935        // The ceiling narrows the members.
936        let keep = effective(
937            &set(&[family]),
938            &grant.granted,
939            &BTreeSet::new(),
940            &Ceiling::scoped(["urn:cap:store:read:graph:urn:a"]),
941        );
942        assert_eq!(keep, set(&["urn:cap:store:read:graph:urn:a"]));
943    }
944
945    #[test]
946    fn requires_refuses_wildcards_exclusions_and_strangers() {
947        assert!(parse_requires("urn:cap:net:*").is_err());
948        assert!(parse_requires("urn:cap:fs:read:-/x").is_err());
949        assert!(parse_requires("lisp").is_err());
950        assert_eq!(
951            parse_requires(" urn:cap:a\n urn:cap:b urn:cap:a ").unwrap(),
952            set(&["urn:cap:a", "urn:cap:b"])
953        );
954    }
955
956    /// The min rule (ledger #1384), across every case the decision names. `None` is the
957    /// store's base.
958    #[test]
959    fn a_runs_budget_is_the_least_of_four() {
960        let runner = |ms: u64| Capability::scoped([cap_budget(ms)]);
961        let none = Capability::scoped(["urn:cap:script:run:x"]);
962        let root = Capability::root();
963        let open = Ceiling::Unbounded;
964        let host = |ms: u64| Ceiling::scoped([cap_budget(ms), "urn:cap:lisp".to_string()]);
965        let silent = Ceiling::scoped(["urn:cap:lisp"]);
966        let check = |what: &str,
967                     declared: Option<u64>,
968                     granted: Option<u64>,
969                     ceiling: &Ceiling,
970                     runner: &Capability,
971                     want: Option<u64>| {
972            assert_eq!(
973                effective_budget(declared, granted, ceiling, runner),
974                want,
975                "{what}"
976            );
977        };
978        check("no declaration", None, None, &open, &runner(900), None);
979        check(
980            "no declaration, root runner",
981            None,
982            None,
983            &open,
984            &root,
985            None,
986        );
987        check(
988            "declared, held",
989            Some(900),
990            Some(900),
991            &open,
992            &runner(900),
993            Some(900),
994        );
995        check(
996            "above the runner",
997            Some(900),
998            Some(900),
999            &open,
1000            &runner(400),
1001            Some(400),
1002        );
1003        check(
1004            "runner holds no grant",
1005            Some(900),
1006            Some(900),
1007            &open,
1008            &none,
1009            None,
1010        );
1011        check(
1012            "above the publisher",
1013            Some(900),
1014            Some(600),
1015            &open,
1016            &runner(900),
1017            Some(600),
1018        );
1019        check(
1020            "above the ceiling",
1021            Some(900),
1022            Some(900),
1023            &host(700),
1024            &runner(900),
1025            Some(700),
1026        );
1027        check(
1028            "a ceiling naming no budget",
1029            Some(900),
1030            Some(900),
1031            &silent,
1032            &runner(900),
1033            None,
1034        );
1035        check("root runner", Some(900), Some(900), &open, &root, Some(900));
1036        check(
1037            "root publisher",
1038            Some(900),
1039            Some(MAX_BUDGET_MS),
1040            &open,
1041            &runner(5000),
1042            Some(900),
1043        );
1044        check(
1045            "all root, ceiling lower",
1046            Some(90_000),
1047            Some(MAX_BUDGET_MS),
1048            &host(30_000),
1049            &root,
1050            Some(30_000),
1051        );
1052        // A root publisher holds the store's ceiling, so a declaration up to it is admitted.
1053        assert_eq!(
1054            budget_at_publish(&root, MAX_BUDGET_MS).unwrap(),
1055            MAX_BUDGET_MS
1056        );
1057        assert!(parse_budget(&(MAX_BUDGET_MS + 1).to_string()).is_err());
1058        assert!(parse_budget("0").is_err());
1059        assert!(parse_budget("1s").is_err());
1060    }
1061
1062    #[test]
1063    fn an_exclusion_is_never_read_as_a_budget() {
1064        let odd = Capability::scoped(["urn:cap:store:budget:-900", "urn:cap:store:budget:0"]);
1065        assert_eq!(held_budget(&odd), None);
1066    }
1067
1068    #[test]
1069    fn a_ceiling_never_admits_an_exclusion_as_a_grant() {
1070        assert!(!Ceiling::Unbounded.allows("urn:cap:net:-x"));
1071        assert!(!Ceiling::scoped(["urn:cap:net:*"]).allows("urn:cap:net:-x"));
1072        assert!(!Ceiling::nothing().allows("urn:cap:lisp"));
1073    }
1074}