Skip to main content

ikigai_script/
model.rs

1//! What the atoms hold: a script's head, its immutable versions, and its runs.
2//!
3//! These are also the machine faces (`as=application/json`): one serde shape for the disk
4//! and the wire, carrying `"schema": 1` so a consumer can tell a future shape apart.
5
6use std::collections::BTreeSet;
7
8use ikigai_core::{Error, Result};
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11
12use crate::authority::CAP_LISP;
13
14/// The version of every JSON shape this crate writes.
15pub const SCHEMA: u32 = 1;
16
17/// The languages a script can be written in.
18#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "lowercase")]
20pub enum Language {
21    /// Steel Scheme, evaluated by `ikigai-lisp` at `urn:lisp:eval`.
22    Lisp,
23    /// A SPARQL query or update, evaluated by the host's store through its graph-scoped
24    /// doors (see [`crate::sparql`]). Its form, parameters and authority are read from the
25    /// parsed text.
26    Sparql,
27    /// A plan: an `ik:Process` graph (Turtle), validated by the host's `urn:plan:validate`
28    /// and run by its `urn:plan:eval` (see [`crate::plan`]). Its authority is derived from
29    /// its steps' contracts, and whether it is a read or a write from its steps' verbs.
30    Plan,
31}
32
33impl Language {
34    /// Every language, in the order `language=` offers them.
35    pub const ALL: [Language; 3] = [Language::Lisp, Language::Sparql, Language::Plan];
36
37    /// The word `language=` takes and every face shows.
38    pub fn as_str(self) -> &'static str {
39        match self {
40            Language::Lisp => "lisp",
41            Language::Sparql => "sparql",
42            Language::Plan => "plan",
43        }
44    }
45
46    /// Parse `language=`.
47    pub fn parse(text: &str) -> Result<Language> {
48        match text.trim() {
49            "lisp" => Ok(Language::Lisp),
50            "sparql" => Ok(Language::Sparql),
51            "plan" => Ok(Language::Plan),
52            other => Err(Error::InvalidArgument {
53                name: "language".to_string(),
54                detail: format!(
55                    "`{other}` is not a script language here; `lisp`, `sparql` or `plan`"
56                ),
57            }),
58        }
59    }
60
61    /// The evaluator a Lisp run issues its sub-request to: the language is a resource, so
62    /// this crate links no interpreter. `None` for SPARQL, whose door is the HOST's choice
63    /// ([`crate::sparql::SparqlDoor`]) and depends on the query's form. A plan's is
64    /// [`crate::plan::EVAL`].
65    pub fn evaluator(self) -> Option<&'static str> {
66        match self {
67            Language::Lisp => Some("urn:lisp:eval"),
68            Language::Sparql => None,
69            Language::Plan => Some(crate::plan::EVAL),
70        }
71    }
72
73    /// The capability the evaluator itself requires, which every script in the language
74    /// declares implicitly: evaluating code is itself authority, so a publisher who may not
75    /// run Lisp may not publish Lisp for others to run. `None` for SPARQL: a query's
76    /// authority is the graphs it names, derived from its text (see
77    /// [`crate::sparql::Analysis::requires`]). `None` for a plan: its authority is what its
78    /// steps' contracts require, derived at publish (see [`crate::plan::derive`]), and
79    /// `urn:plan:eval` declares none of its own.
80    pub fn capability(self) -> Option<&'static str> {
81        match self {
82            Language::Lisp => Some(CAP_LISP),
83            Language::Sparql | Language::Plan => None,
84        }
85    }
86}
87
88/// A script's lifecycle state.
89#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "lowercase")]
91pub enum State {
92    /// Saved, not runnable, and PRIVATE until published: a version that was never published
93    /// is visible only to its author (the principal their capability named when they wrote
94    /// it)
95    /// and to root. To every other holder of the script's read or run grant it is absent.
96    Draft,
97    /// Runnable.
98    Published,
99    /// Retired by a Delete: still readable, versions still fetchable, never runnable.
100    Retired,
101}
102
103impl State {
104    /// The word every face shows.
105    pub fn as_str(self) -> &'static str {
106        match self {
107            State::Draft => "draft",
108            State::Published => "published",
109            State::Retired => "retired",
110        }
111    }
112}
113
114/// One immutable version: the content a digest names.
115#[derive(Clone, Debug, PartialEq, Eq)]
116pub struct Version {
117    /// Its language.
118    pub language: Language,
119    /// The capability it declares it needs, its language's included.
120    pub requires: BTreeSet<String>,
121    /// The store time budget it declares, in milliseconds (`budget=` at publish), or `None`
122    /// for the store's base. Part of the content, so a republish that changes it is a new
123    /// version (see [`crate::authority::effective_budget`] for what a run gets).
124    pub budget: Option<u64>,
125    /// The source text.
126    pub source: String,
127}
128
129impl Version {
130    /// The bytes the digest is taken over:
131    ///
132    /// ```text
133    /// ikigai-script/1\n
134    /// language <language>\n
135    /// requires <scope>\n        (one line per scope, sorted)
136    /// budget <milliseconds>\n   (only when it declares one)
137    /// \n
138    /// <source>
139    /// ```
140    ///
141    /// A scope contains no whitespace and the source comes last, so the framing is
142    /// unambiguous without escaping anything. A version declaring no budget frames exactly as
143    /// one did before `budget` existed, so every digest already stored is unchanged.
144    pub fn canonical(&self) -> Vec<u8> {
145        let mut out = format!("ikigai-script/1\nlanguage {}\n", self.language.as_str());
146        for scope in &self.requires {
147            out.push_str("requires ");
148            out.push_str(scope);
149            out.push('\n');
150        }
151        if let Some(millis) = self.budget {
152            out.push_str(&format!("budget {millis}\n"));
153        }
154        out.push('\n');
155        out.push_str(&self.source);
156        out.into_bytes()
157    }
158
159    /// The version's name: `sha256:` and 64 lowercase hex digits over [`canonical`].
160    ///
161    /// ```
162    /// use ikigai_script::model::{Language, Version};
163    /// let v = Version {
164    ///     language: Language::Lisp,
165    ///     requires: ["urn:cap:lisp".to_string()].into_iter().collect(),
166    ///     budget: None,
167    ///     source: "(+ 1 2)".to_string(),
168    /// };
169    /// let digest = v.digest();
170    /// assert!(digest.starts_with("sha256:"));
171    /// assert_eq!(digest.len(), "sha256:".len() + 64);
172    /// assert!(digest["sha256:".len()..].bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()));
173    /// // Content-addressed: the same content is the same version.
174    /// assert_eq!(digest, v.clone().digest());
175    /// // A declared budget is content too.
176    /// let budgeted = Version { budget: Some(900), ..v.clone() };
177    /// assert_ne!(budgeted.digest(), digest);
178    /// ```
179    ///
180    /// [`canonical`]: Version::canonical
181    pub fn digest(&self) -> String {
182        let hash = Sha256::digest(self.canonical());
183        let mut out = String::with_capacity(7 + 64);
184        out.push_str("sha256:");
185        for byte in hash {
186            out.push_str(&format!("{byte:02x}"));
187        }
188        out
189    }
190
191    /// The JSON face of this version under `name`.
192    pub fn document(&self, name: &str) -> VersionDocument {
193        let digest = self.digest();
194        VersionDocument {
195            schema: SCHEMA,
196            iri: crate::name::version_iri(name, &digest),
197            script: crate::name::script_iri(name),
198            version: digest,
199            language: self.language,
200            requires: self.requires.clone(),
201            budget: self.budget,
202            source: self.source.clone(),
203        }
204    }
205}
206
207/// A version's JSON face.
208#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
209pub struct VersionDocument {
210    /// [`SCHEMA`].
211    pub schema: u32,
212    /// `urn:script:{name}:version:{digest}`.
213    pub iri: String,
214    /// `urn:script:{name}`.
215    pub script: String,
216    /// `sha256:…`.
217    pub version: String,
218    /// Its language.
219    pub language: Language,
220    /// What it declares it needs.
221    pub requires: BTreeSet<String>,
222    /// The store time budget it declares, in milliseconds; absent when it declares none.
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub budget: Option<u64>,
225    /// The source text.
226    pub source: String,
227}
228
229/// One change to a script's head: what its history is made of.
230#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
231pub struct Event {
232    /// `publish`, `draft` or `retire`.
233    pub action: String,
234    /// The version the head pointed at afterwards.
235    pub version: String,
236    /// The state afterwards.
237    pub state: State,
238    /// Whether it was public afterwards.
239    pub public: bool,
240    /// Who: the principal the writer's capability named ([`crate::authority::principal_of`]).
241    pub principal: String,
242    /// When, in milliseconds since the epoch, from the kernel's clock (`None` on a
243    /// clockless kernel).
244    pub at: Option<u64>,
245}
246
247/// A script's head: the mutable atom `urn:script:{name}` names.
248#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
249pub struct Head {
250    /// [`SCHEMA`].
251    pub schema: u32,
252    /// The script's name.
253    pub name: String,
254    /// The version it points at.
255    pub version: String,
256    /// Its state.
257    pub state: State,
258    /// Whether it is public: runnable by holders of
259    /// [`CAP_RUN_PUBLIC`](crate::authority::CAP_RUN_PUBLIC) and readable by holders of
260    /// [`CAP_READ_PUBLIC`](crate::authority::CAP_READ_PUBLIC), once published.
261    pub public: bool,
262    /// The part of the version's `requires` its publisher held when they published it.
263    pub granted: BTreeSet<String>,
264    /// Every exclusion the publisher's capability carried then.
265    pub exclusions: BTreeSet<String>,
266    /// The store time budget its publisher held when they published a version declaring one
267    /// ([`crate::authority::budget_at_publish`]), in milliseconds; absent otherwise.
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub granted_budget: Option<u64>,
270    /// Who published the version the head points at.
271    pub publisher: String,
272    /// When the head last changed.
273    pub updated: Option<u64>,
274    /// Every change, oldest first.
275    pub history: Vec<Event>,
276}
277
278/// How a run ended.
279#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
280#[serde(rename_all = "lowercase", tag = "status")]
281pub enum Outcome {
282    /// Not finished. A run that stays here after its host stopped was abandoned.
283    Running,
284    /// Finished with an answer.
285    Ok,
286    /// Finished with an error.
287    Failed {
288        /// The error's kind: `denied`, `not-found`, `conflict`, `invalid-argument`,
289        /// `timeout`, `unavailable`, `endpoint`, …
290        kind: String,
291        /// The error, as text.
292        message: String,
293    },
294}
295
296/// The longest result a run record keeps; a longer one is cut there and marked.
297pub const MAX_RECORDED_RESULT: usize = 64 * 1024;
298
299/// One run: the atom `urn:script:{name}:run:{id}` names.
300#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
301pub struct Run {
302    /// [`SCHEMA`].
303    pub schema: u32,
304    /// The script.
305    pub name: String,
306    /// Its number, unique per script, in order of starting.
307    pub id: u64,
308    /// The version that ran.
309    pub version: String,
310    /// Who ran it: the principal the runner's capability named
311    /// ([`crate::authority::principal_of`]), never caller-supplied.
312    pub principal: String,
313    /// The capability the run executed under, exactly: grants and exclusions.
314    pub capability: BTreeSet<String>,
315    /// When it started, from the kernel's clock.
316    pub started: Option<u64>,
317    /// When it ended.
318    pub ended: Option<u64>,
319    /// How it ended.
320    pub outcome: Outcome,
321    /// What it answered, as text, up to [`MAX_RECORDED_RESULT`] bytes.
322    pub result: Option<String>,
323    /// Whether `result` was cut.
324    pub truncated: bool,
325    /// The trace span the host's tracer gave the run's request, when the host traced it:
326    /// the key into the host's trace, which holds every verb the script issued.
327    pub trace_span: Option<u64>,
328}
329
330impl Run {
331    /// Its IRI.
332    pub fn iri(&self) -> String {
333        crate::name::run_iri(&self.name, self.id)
334    }
335
336    /// The plain face: one fact per line.
337    pub fn render(&self) -> String {
338        let outcome = match &self.outcome {
339            Outcome::Running => "running".to_string(),
340            Outcome::Ok => "ok".to_string(),
341            Outcome::Failed { kind, message } => format!("failed ({kind}): {message}"),
342        };
343        let mut text = format!(
344            "{}\n  script:     {}\n  version:    {}\n  principal:  {}\n  started:    {}\n  \
345             ended:      {}\n  outcome:    {outcome}\n  capability: {}\n",
346            self.iri(),
347            crate::name::script_iri(&self.name),
348            self.version,
349            self.principal,
350            when(self.started),
351            when(self.ended),
352            if self.capability.is_empty() {
353                "(none)".to_string()
354            } else {
355                self.capability
356                    .iter()
357                    .cloned()
358                    .collect::<Vec<_>>()
359                    .join(" ")
360            },
361        );
362        if let Some(span) = self.trace_span {
363            text.push_str(&format!("  trace span: {span}\n"));
364        }
365        if let Some(result) = &self.result {
366            text.push_str(&format!(
367                "  result{}:\n{result}\n",
368                if self.truncated { " (truncated)" } else { "" }
369            ));
370        }
371        text
372    }
373}
374
375/// A timestamp for the plain faces: `xsd:dateTime` in UTC, or a note that the kernel had
376/// no clock.
377///
378/// ```
379/// use ikigai_script::model::when;
380/// assert_eq!(when(Some(1_789_430_400_000)), "2026-09-15T00:00:00.000Z");
381/// assert_eq!(when(Some(0)), "1970-01-01T00:00:00.000Z");
382/// assert_eq!(when(None), "unstamped (the kernel has no clock)");
383/// ```
384pub fn when(millis: Option<u64>) -> String {
385    let Some(millis) = millis else {
386        return "unstamped (the kernel has no clock)".to_string();
387    };
388    let secs = millis / 1000;
389    let (days, rem) = (secs / 86_400, secs % 86_400);
390    // Howard Hinnant's civil-from-days, over days since 1970-01-01.
391    let z = days as i64 + 719_468;
392    let era = z.div_euclid(146_097);
393    let doe = z - era * 146_097;
394    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
395    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
396    let mp = (5 * doy + 2) / 153;
397    let day = doy - (153 * mp + 2) / 5 + 1;
398    let month = if mp < 10 { mp + 3 } else { mp - 9 };
399    let year = yoe + era * 400 + i64::from(month <= 2);
400    format!(
401        "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}.{:03}Z",
402        rem / 3600,
403        rem % 3600 / 60,
404        rem % 60,
405        millis % 1000
406    )
407}
408
409/// Cut `text` to at most `max` bytes on a character boundary; whether it was cut.
410pub(crate) fn truncate(mut text: String, max: usize) -> (String, bool) {
411    if text.len() <= max {
412        return (text, false);
413    }
414    let mut end = max;
415    while !text.is_char_boundary(end) {
416        end -= 1;
417    }
418    text.truncate(end);
419    (text, true)
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425
426    #[test]
427    fn requires_and_language_are_part_of_the_content() {
428        let a = Version {
429            language: Language::Lisp,
430            requires: BTreeSet::new(),
431            budget: None,
432            source: "(+ 1 2)".to_string(),
433        };
434        let mut b = a.clone();
435        b.requires.insert("urn:cap:lisp".to_string());
436        assert_ne!(a.digest(), b.digest());
437    }
438
439    /// A version declaring no budget keeps the digest it had before `budget` existed
440    /// (ledger #1384): the value is pinned from the framing as 0.3.3 wrote it, so every
441    /// version a host already stores is still found by its name.
442    #[test]
443    fn a_version_without_a_budget_keeps_its_digest() {
444        let v = Version {
445            language: Language::Lisp,
446            requires: ["urn:cap:lisp".to_string()].into_iter().collect(),
447            budget: None,
448            source: "(+ 1 2)".to_string(),
449        };
450        assert_eq!(
451            v.digest(),
452            "sha256:ccea630c6cbeb36e3c3a57a9199934e32666e552550dd3c2d321171a493c9bf6"
453        );
454        let budgeted = Version {
455            budget: Some(900),
456            ..v.clone()
457        };
458        assert!(String::from_utf8(budgeted.canonical())
459            .unwrap()
460            .contains("requires urn:cap:lisp\nbudget 900\n\n(+ 1 2)"));
461    }
462
463    #[test]
464    fn truncation_respects_characters() {
465        let (text, cut) = truncate("héllo".to_string(), 2);
466        assert_eq!((text.as_str(), cut), ("h", true));
467        let (text, cut) = truncate("abc".to_string(), 3);
468        assert_eq!((text.as_str(), cut), ("abc", false));
469    }
470}