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 source text.
122 pub source: String,
123}
124
125impl Version {
126 /// The bytes the digest is taken over:
127 ///
128 /// ```text
129 /// ikigai-script/1\n
130 /// language <language>\n
131 /// requires <scope>\n (one line per scope, sorted)
132 /// \n
133 /// <source>
134 /// ```
135 ///
136 /// A scope contains no whitespace and the source comes last, so the framing is
137 /// unambiguous without escaping anything.
138 pub fn canonical(&self) -> Vec<u8> {
139 let mut out = format!("ikigai-script/1\nlanguage {}\n", self.language.as_str());
140 for scope in &self.requires {
141 out.push_str("requires ");
142 out.push_str(scope);
143 out.push('\n');
144 }
145 out.push('\n');
146 out.push_str(&self.source);
147 out.into_bytes()
148 }
149
150 /// The version's name: `sha256:` and 64 lowercase hex digits over [`canonical`].
151 ///
152 /// ```
153 /// use ikigai_script::model::{Language, Version};
154 /// let v = Version {
155 /// language: Language::Lisp,
156 /// requires: ["urn:cap:lisp".to_string()].into_iter().collect(),
157 /// source: "(+ 1 2)".to_string(),
158 /// };
159 /// let digest = v.digest();
160 /// assert!(digest.starts_with("sha256:"));
161 /// assert_eq!(digest.len(), "sha256:".len() + 64);
162 /// assert!(digest["sha256:".len()..].bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()));
163 /// // Content-addressed: the same content is the same version.
164 /// assert_eq!(digest, v.clone().digest());
165 /// ```
166 ///
167 /// [`canonical`]: Version::canonical
168 pub fn digest(&self) -> String {
169 let hash = Sha256::digest(self.canonical());
170 let mut out = String::with_capacity(7 + 64);
171 out.push_str("sha256:");
172 for byte in hash {
173 out.push_str(&format!("{byte:02x}"));
174 }
175 out
176 }
177
178 /// The JSON face of this version under `name`.
179 pub fn document(&self, name: &str) -> VersionDocument {
180 let digest = self.digest();
181 VersionDocument {
182 schema: SCHEMA,
183 iri: crate::name::version_iri(name, &digest),
184 script: crate::name::script_iri(name),
185 version: digest,
186 language: self.language,
187 requires: self.requires.clone(),
188 source: self.source.clone(),
189 }
190 }
191}
192
193/// A version's JSON face.
194#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
195pub struct VersionDocument {
196 /// [`SCHEMA`].
197 pub schema: u32,
198 /// `urn:script:{name}:version:{digest}`.
199 pub iri: String,
200 /// `urn:script:{name}`.
201 pub script: String,
202 /// `sha256:…`.
203 pub version: String,
204 /// Its language.
205 pub language: Language,
206 /// What it declares it needs.
207 pub requires: BTreeSet<String>,
208 /// The source text.
209 pub source: String,
210}
211
212/// One change to a script's head: what its history is made of.
213#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
214pub struct Event {
215 /// `publish`, `draft` or `retire`.
216 pub action: String,
217 /// The version the head pointed at afterwards.
218 pub version: String,
219 /// The state afterwards.
220 pub state: State,
221 /// Whether it was public afterwards.
222 pub public: bool,
223 /// Who: the principal the writer's capability named ([`crate::authority::principal_of`]).
224 pub principal: String,
225 /// When, in milliseconds since the epoch, from the kernel's clock (`None` on a
226 /// clockless kernel).
227 pub at: Option<u64>,
228}
229
230/// A script's head: the mutable atom `urn:script:{name}` names.
231#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
232pub struct Head {
233 /// [`SCHEMA`].
234 pub schema: u32,
235 /// The script's name.
236 pub name: String,
237 /// The version it points at.
238 pub version: String,
239 /// Its state.
240 pub state: State,
241 /// Whether it is public: runnable by holders of
242 /// [`CAP_RUN_PUBLIC`](crate::authority::CAP_RUN_PUBLIC) and readable by holders of
243 /// [`CAP_READ_PUBLIC`](crate::authority::CAP_READ_PUBLIC), once published.
244 pub public: bool,
245 /// The part of the version's `requires` its publisher held when they published it.
246 pub granted: BTreeSet<String>,
247 /// Every exclusion the publisher's capability carried then.
248 pub exclusions: BTreeSet<String>,
249 /// Who published the version the head points at.
250 pub publisher: String,
251 /// When the head last changed.
252 pub updated: Option<u64>,
253 /// Every change, oldest first.
254 pub history: Vec<Event>,
255}
256
257/// How a run ended.
258#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
259#[serde(rename_all = "lowercase", tag = "status")]
260pub enum Outcome {
261 /// Not finished. A run that stays here after its host stopped was abandoned.
262 Running,
263 /// Finished with an answer.
264 Ok,
265 /// Finished with an error.
266 Failed {
267 /// The error's kind: `denied`, `not-found`, `conflict`, `invalid-argument`,
268 /// `timeout`, `unavailable`, `endpoint`, …
269 kind: String,
270 /// The error, as text.
271 message: String,
272 },
273}
274
275/// The longest result a run record keeps; a longer one is cut there and marked.
276pub const MAX_RECORDED_RESULT: usize = 64 * 1024;
277
278/// One run: the atom `urn:script:{name}:run:{id}` names.
279#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
280pub struct Run {
281 /// [`SCHEMA`].
282 pub schema: u32,
283 /// The script.
284 pub name: String,
285 /// Its number, unique per script, in order of starting.
286 pub id: u64,
287 /// The version that ran.
288 pub version: String,
289 /// Who ran it: the principal the runner's capability named
290 /// ([`crate::authority::principal_of`]), never caller-supplied.
291 pub principal: String,
292 /// The capability the run executed under, exactly: grants and exclusions.
293 pub capability: BTreeSet<String>,
294 /// When it started, from the kernel's clock.
295 pub started: Option<u64>,
296 /// When it ended.
297 pub ended: Option<u64>,
298 /// How it ended.
299 pub outcome: Outcome,
300 /// What it answered, as text, up to [`MAX_RECORDED_RESULT`] bytes.
301 pub result: Option<String>,
302 /// Whether `result` was cut.
303 pub truncated: bool,
304 /// The trace span the host's tracer gave the run's request, when the host traced it:
305 /// the key into the host's trace, which holds every verb the script issued.
306 pub trace_span: Option<u64>,
307}
308
309impl Run {
310 /// Its IRI.
311 pub fn iri(&self) -> String {
312 crate::name::run_iri(&self.name, self.id)
313 }
314
315 /// The plain face: one fact per line.
316 pub fn render(&self) -> String {
317 let outcome = match &self.outcome {
318 Outcome::Running => "running".to_string(),
319 Outcome::Ok => "ok".to_string(),
320 Outcome::Failed { kind, message } => format!("failed ({kind}): {message}"),
321 };
322 let mut text = format!(
323 "{}\n script: {}\n version: {}\n principal: {}\n started: {}\n \
324 ended: {}\n outcome: {outcome}\n capability: {}\n",
325 self.iri(),
326 crate::name::script_iri(&self.name),
327 self.version,
328 self.principal,
329 when(self.started),
330 when(self.ended),
331 if self.capability.is_empty() {
332 "(none)".to_string()
333 } else {
334 self.capability
335 .iter()
336 .cloned()
337 .collect::<Vec<_>>()
338 .join(" ")
339 },
340 );
341 if let Some(span) = self.trace_span {
342 text.push_str(&format!(" trace span: {span}\n"));
343 }
344 if let Some(result) = &self.result {
345 text.push_str(&format!(
346 " result{}:\n{result}\n",
347 if self.truncated { " (truncated)" } else { "" }
348 ));
349 }
350 text
351 }
352}
353
354/// A timestamp for the plain faces: `xsd:dateTime` in UTC, or a note that the kernel had
355/// no clock.
356///
357/// ```
358/// use ikigai_script::model::when;
359/// assert_eq!(when(Some(1_789_430_400_000)), "2026-09-15T00:00:00.000Z");
360/// assert_eq!(when(Some(0)), "1970-01-01T00:00:00.000Z");
361/// assert_eq!(when(None), "unstamped (the kernel has no clock)");
362/// ```
363pub fn when(millis: Option<u64>) -> String {
364 let Some(millis) = millis else {
365 return "unstamped (the kernel has no clock)".to_string();
366 };
367 let secs = millis / 1000;
368 let (days, rem) = (secs / 86_400, secs % 86_400);
369 // Howard Hinnant's civil-from-days, over days since 1970-01-01.
370 let z = days as i64 + 719_468;
371 let era = z.div_euclid(146_097);
372 let doe = z - era * 146_097;
373 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
374 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
375 let mp = (5 * doy + 2) / 153;
376 let day = doy - (153 * mp + 2) / 5 + 1;
377 let month = if mp < 10 { mp + 3 } else { mp - 9 };
378 let year = yoe + era * 400 + i64::from(month <= 2);
379 format!(
380 "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}.{:03}Z",
381 rem / 3600,
382 rem % 3600 / 60,
383 rem % 60,
384 millis % 1000
385 )
386}
387
388/// Cut `text` to at most `max` bytes on a character boundary; whether it was cut.
389pub(crate) fn truncate(mut text: String, max: usize) -> (String, bool) {
390 if text.len() <= max {
391 return (text, false);
392 }
393 let mut end = max;
394 while !text.is_char_boundary(end) {
395 end -= 1;
396 }
397 text.truncate(end);
398 (text, true)
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404
405 #[test]
406 fn requires_and_language_are_part_of_the_content() {
407 let a = Version {
408 language: Language::Lisp,
409 requires: BTreeSet::new(),
410 source: "(+ 1 2)".to_string(),
411 };
412 let mut b = a.clone();
413 b.requires.insert("urn:cap:lisp".to_string());
414 assert_ne!(a.digest(), b.digest());
415 }
416
417 #[test]
418 fn truncation_respects_characters() {
419 let (text, cut) = truncate("héllo".to_string(), 2);
420 assert_eq!((text.as_str(), cut), ("h", true));
421 let (text, cut) = truncate("abc".to_string(), 3);
422 assert_eq!((text.as_str(), cut), ("abc", false));
423 }
424}