Skip to main content

khive_types/
app.rs

1//! App manifest: the declared public identity of a khive app (ADR-203 D1, D2).
2//!
3//! Nothing about an app is learned by importing its crate. The manifest is
4//! plain data a host reads before any app code runs: the verb inventory with
5//! an explicit rights class per handler, the ontology slice the app
6//! contributes, a lifecycle class for every row type it writes, the
7//! capability classes it requests and a residency request. Two manifests
8//! that do not conflict compose without knowing of each other; a conflict on
9//! a verb name or a kind claim refuses the later one naming both owners.
10
11use alloc::string::String;
12use alloc::vec::Vec;
13use core::fmt;
14
15/// Rights class of one handler.
16///
17/// Declared in the manifest and never derived from the handler's speech-act
18/// category: `Commissive` covers create-shaped and send-shaped verbs alike,
19/// so the category cannot stand in for the rights a handler needs.
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub enum RightsClass {
22    Read,
23    Create,
24    Write,
25    Delete,
26    Send,
27}
28
29impl RightsClass {
30    /// Every rights class, in declaration order.
31    pub const ALL: [RightsClass; 5] = [
32        RightsClass::Read,
33        RightsClass::Create,
34        RightsClass::Write,
35        RightsClass::Delete,
36        RightsClass::Send,
37    ];
38
39    /// The wire token for this class.
40    pub fn as_str(self) -> &'static str {
41        match self {
42            RightsClass::Read => "read",
43            RightsClass::Create => "create",
44            RightsClass::Write => "write",
45            RightsClass::Delete => "delete",
46            RightsClass::Send => "send",
47        }
48    }
49
50    /// Parse an exact wire token; anything else is `None`.
51    pub fn from_token(token: &str) -> Option<Self> {
52        Self::ALL.into_iter().find(|class| class.as_str() == token)
53    }
54}
55
56impl fmt::Display for RightsClass {
57    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58        f.write_str(self.as_str())
59    }
60}
61
62/// Lifecycle class of one row type the app writes (ADR-203 D6).
63///
64/// `Primary` rows live in the shared substrate and outlive stop, uninstall
65/// and purge. `Corpus` rows live in the store the manifest names and are
66/// dropped as a unit by purge; a representation (an index, an embedding, a
67/// derived view) is a corpus row whose store is the index it lives in, so
68/// the `representation` token resolves to `Corpus`. `Blob` rows are content
69/// addressed and referenced from primary rows; they name no store.
70#[derive(Clone, Debug, PartialEq, Eq)]
71pub enum LifecycleClass {
72    Primary,
73    Corpus { store: String },
74    Blob,
75}
76
77impl LifecycleClass {
78    /// Resolve a wire token and the store it names, if any. `representation`
79    /// is an alias of `corpus`; a corpus token with no store resolves to an
80    /// empty store name, which [`AppManifest::validate`] refuses. Anything
81    /// else is `None`.
82    pub fn from_token(token: &str, store: Option<&str>) -> Option<Self> {
83        match token {
84            "primary" => Some(LifecycleClass::Primary),
85            "corpus" | "representation" => Some(LifecycleClass::Corpus {
86                store: store.unwrap_or_default().into(),
87            }),
88            "blob" => Some(LifecycleClass::Blob),
89            _ => None,
90        }
91    }
92
93    /// The canonical wire token for this class.
94    pub fn as_str(&self) -> &'static str {
95        match self {
96            LifecycleClass::Primary => "primary",
97            LifecycleClass::Corpus { .. } => "corpus",
98            LifecycleClass::Blob => "blob",
99        }
100    }
101}
102
103/// The residency the app asks for; the host validates it against the
104/// payload (ADR-203 D3) and the request never decides by itself.
105#[derive(Clone, Copy, Debug, PartialEq, Eq)]
106pub enum Residency {
107    None,
108    Linked,
109    Process,
110}
111
112impl Residency {
113    /// The wire token for this residency.
114    pub fn as_str(self) -> &'static str {
115        match self {
116            Residency::None => "none",
117            Residency::Linked => "linked",
118            Residency::Process => "process",
119        }
120    }
121}
122
123/// Which substrate a declared row type lives in.
124#[derive(Clone, Copy, Debug, PartialEq, Eq)]
125pub enum RowSubstrate {
126    Note,
127    Entity,
128}
129
130impl RowSubstrate {
131    /// The substrate name as the kind registry spells it.
132    pub fn as_str(self) -> &'static str {
133        match self {
134            RowSubstrate::Note => "note",
135            RowSubstrate::Entity => "entity",
136        }
137    }
138}
139
140/// One verb the app registers, with the rights class its handler needs.
141#[derive(Clone, Debug, PartialEq, Eq)]
142pub struct AppVerb {
143    pub name: String,
144    pub rights: RightsClass,
145}
146
147/// The lifecycle declaration for one row type the app writes.
148#[derive(Clone, Debug, PartialEq, Eq)]
149pub struct RowTypeDecl {
150    pub substrate: RowSubstrate,
151    pub kind: String,
152    pub lifecycle: LifecycleClass,
153}
154
155/// The manifest of one khive app.
156///
157/// `entity_kinds` and `note_kinds` are the ontology slice: every kind the
158/// app writes. Each of them needs a row in `row_types`, and every row there
159/// names a kind of the slice, so the class of each row type is declared and
160/// never defaulted.
161#[derive(Clone, Debug, PartialEq, Eq)]
162pub struct AppManifest {
163    pub name: String,
164    pub version: String,
165    pub signer: String,
166    pub verbs: Vec<AppVerb>,
167    pub entity_kinds: Vec<String>,
168    pub note_kinds: Vec<String>,
169    pub row_types: Vec<RowTypeDecl>,
170    pub capabilities: Vec<String>,
171    pub residency: Residency,
172}
173
174/// The names one registered owner already holds: a pack linked into the
175/// binary or an installed app.
176#[derive(Clone, Debug, PartialEq, Eq)]
177pub struct OwnedNames<'a> {
178    pub owner: &'a str,
179    pub verbs: Vec<&'a str>,
180    pub entity_kinds: Vec<&'a str>,
181    pub note_kinds: Vec<&'a str>,
182}
183
184/// Why a manifest is refused at installation.
185#[derive(Clone, Debug, PartialEq, Eq)]
186pub enum InstallRefusal {
187    /// `name`, `version` or `signer` is empty.
188    EmptyIdentity { field: &'static str },
189    /// The manifest lists the same verb name twice.
190    DuplicateVerb { verb: String },
191    /// A kind in the ontology slice has no lifecycle declaration.
192    UndeclaredRowType {
193        substrate: RowSubstrate,
194        kind: String,
195    },
196    /// A lifecycle declaration names a kind the ontology slice does not contribute.
197    RowTypeOutsideSlice {
198        substrate: RowSubstrate,
199        kind: String,
200    },
201    /// A corpus row type names no store.
202    CorpusWithoutStore {
203        substrate: RowSubstrate,
204        kind: String,
205    },
206    /// A verb name is already registered by another owner.
207    VerbOwned {
208        verb: String,
209        owner: String,
210        candidate: String,
211    },
212    /// A kind is already contributed by another owner.
213    KindOwned {
214        substrate: RowSubstrate,
215        kind: String,
216        owner: String,
217        candidate: String,
218    },
219}
220
221impl fmt::Display for InstallRefusal {
222    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
223        match self {
224            InstallRefusal::EmptyIdentity { field } => {
225                write!(f, "manifest field {field:?} is empty")
226            }
227            InstallRefusal::DuplicateVerb { verb } => {
228                write!(f, "manifest declares verb {verb:?} twice")
229            }
230            InstallRefusal::UndeclaredRowType { substrate, kind } => write!(
231                f,
232                "{} kind {kind:?} is in the ontology slice but has no lifecycle class; every row type the app writes declares primary or corpus",
233                substrate.as_str()
234            ),
235            InstallRefusal::RowTypeOutsideSlice { substrate, kind } => write!(
236                f,
237                "{} kind {kind:?} has a lifecycle declaration but is not in the ontology slice",
238                substrate.as_str()
239            ),
240            InstallRefusal::CorpusWithoutStore { substrate, kind } => write!(
241                f,
242                "{} kind {kind:?} is a corpus row type and names no store",
243                substrate.as_str()
244            ),
245            InstallRefusal::VerbOwned {
246                verb,
247                owner,
248                candidate,
249            } => write!(
250                f,
251                "verb {verb:?} is already registered by {owner:?}; app {candidate:?} cannot register it"
252            ),
253            InstallRefusal::KindOwned {
254                substrate,
255                kind,
256                owner,
257                candidate,
258            } => write!(
259                f,
260                "{} kind {kind:?} is already contributed by {owner:?}; app {candidate:?} cannot contribute it",
261                substrate.as_str()
262            ),
263        }
264    }
265}
266
267impl AppManifest {
268    /// Check the manifest against itself: identity present, verb names
269    /// unique, every kind in the slice declared exactly once with a class,
270    /// every corpus row type naming its store.
271    pub fn validate(&self) -> Result<(), InstallRefusal> {
272        for (field, value) in [
273            ("name", &self.name),
274            ("version", &self.version),
275            ("signer", &self.signer),
276        ] {
277            if value.is_empty() {
278                return Err(InstallRefusal::EmptyIdentity { field });
279            }
280        }
281        for (index, verb) in self.verbs.iter().enumerate() {
282            if self.verbs[..index]
283                .iter()
284                .any(|seen| seen.name == verb.name)
285            {
286                return Err(InstallRefusal::DuplicateVerb {
287                    verb: verb.name.clone(),
288                });
289            }
290        }
291        for (substrate, kinds) in [
292            (RowSubstrate::Entity, &self.entity_kinds),
293            (RowSubstrate::Note, &self.note_kinds),
294        ] {
295            for kind in kinds {
296                if !self
297                    .row_types
298                    .iter()
299                    .any(|row| row.substrate == substrate && row.kind == *kind)
300                {
301                    return Err(InstallRefusal::UndeclaredRowType {
302                        substrate,
303                        kind: kind.clone(),
304                    });
305                }
306            }
307        }
308        for row in &self.row_types {
309            let slice = match row.substrate {
310                RowSubstrate::Entity => &self.entity_kinds,
311                RowSubstrate::Note => &self.note_kinds,
312            };
313            if !slice.contains(&row.kind) {
314                return Err(InstallRefusal::RowTypeOutsideSlice {
315                    substrate: row.substrate,
316                    kind: row.kind.clone(),
317                });
318            }
319            if matches!(&row.lifecycle, LifecycleClass::Corpus { store } if store.is_empty()) {
320                return Err(InstallRefusal::CorpusWithoutStore {
321                    substrate: row.substrate,
322                    kind: row.kind.clone(),
323                });
324            }
325        }
326        Ok(())
327    }
328
329    /// The names this manifest would hold once installed.
330    pub fn owned_names(&self) -> OwnedNames<'_> {
331        OwnedNames {
332            owner: &self.name,
333            verbs: self.verbs.iter().map(|verb| verb.name.as_str()).collect(),
334            entity_kinds: self.entity_kinds.iter().map(String::as_str).collect(),
335            note_kinds: self.note_kinds.iter().map(String::as_str).collect(),
336        }
337    }
338
339    /// Admit the manifest beside the owners already registered: it must
340    /// validate, and no verb name or kind it brings may be held by any of
341    /// them. The first conflict, in owner order, is the refusal; it names
342    /// both owners.
343    pub fn admit(&self, owners: &[OwnedNames<'_>]) -> Result<(), InstallRefusal> {
344        self.validate()?;
345        for owner in owners {
346            if let Some(verb) = self
347                .verbs
348                .iter()
349                .find(|verb| owner.verbs.iter().any(|held| *held == verb.name))
350            {
351                return Err(InstallRefusal::VerbOwned {
352                    verb: verb.name.clone(),
353                    owner: owner.owner.into(),
354                    candidate: self.name.clone(),
355                });
356            }
357            for (substrate, kinds, held) in [
358                (
359                    RowSubstrate::Entity,
360                    &self.entity_kinds,
361                    &owner.entity_kinds,
362                ),
363                (RowSubstrate::Note, &self.note_kinds, &owner.note_kinds),
364            ] {
365                if let Some(kind) = kinds.iter().find(|kind| held.contains(&kind.as_str())) {
366                    return Err(InstallRefusal::KindOwned {
367                        substrate,
368                        kind: kind.clone(),
369                        owner: owner.owner.into(),
370                        candidate: self.name.clone(),
371                    });
372                }
373            }
374        }
375        Ok(())
376    }
377}
378
379#[cfg(test)]
380#[path = "app_tests.rs"]
381mod tests;