Skip to main content

sim_lib_cookbook/
catalog.rs

1//! Requires-driven library loading and the cookbook capability profile.
2//!
3//! COOKBOOK_7 turns the cookbook into a capability-scoped `realize` site with
4//! requires-driven loading. Two contracts live here:
5//!
6//! - [`LibCatalog`]: a resolver from a recipe `requires` name to a loadable
7//!   library. The catalog is assembled at the HOST/facade level (batteries `sim`,
8//!   `sim-web-shell`) from the standard distribution, so NO dependency cycle
9//!   touches the thin cookbook engine (R6/R7): the runner (`run.rs`) depends only
10//!   on this trait, never on a concrete domain lib.
11//! - [`CookbookCapabilityProfile`]: the deterministic capability set the cookbook
12//!   seats its eval `Cx` with. It GRANTS pure/offline/deterministic effects and
13//!   DENIES live-net, live-hardware, process-spawn, wall-clock, fs-write, and
14//!   unseeded entropy. An op that requests a denied capability fails closed (the
15//!   capability is simply never granted), which is what makes a Category D recipe
16//!   a descriptor BY CONSTRUCTION.
17
18use sim_cookbook::RecipeCard;
19use sim_kernel::{CapabilityName, Cx, GrantSeat, Lib, Result, Symbol};
20
21/// A resolver from a recipe `requires` name to a loadable library.
22///
23/// A recipe's `requires = [...]` list names the libs its setup needs
24/// (`numbers/complex`, `codec/lisp`, `organ/binding`, ...). The runner asks the
25/// catalog to resolve each name and loads the returned lib into the eval `Cx`
26/// before decode+eval. A name the catalog does not carry stays unresolved, and
27/// the recipe becomes a descriptor whose reason is the unresolved require.
28pub trait LibCatalog {
29    /// Resolve a cookbook require name to a loadable library, or `None` when this
30    /// catalog does not carry it.
31    ///
32    /// A require matches either the lib's fully qualified cookbook name
33    /// (`numbers/cas`) or its unqualified tail (`cas`), mirroring
34    /// [`crate::missing_requires`].
35    fn resolve(&self, name: &str) -> Option<&dyn Lib>;
36}
37
38/// The empty catalog: resolves nothing.
39///
40/// Callers on the legacy path pre-load every required lib into the `Cx`
41/// themselves; the runner then finds each require already present and loads
42/// nothing. [`crate::run_recipe`] uses this so its behavior is unchanged.
43pub struct EmptyCatalog;
44
45impl LibCatalog for EmptyCatalog {
46    fn resolve(&self, _name: &str) -> Option<&dyn Lib> {
47        None
48    }
49}
50
51/// Load every `requires` entry of `card` into `cx` via `catalog`, idempotently,
52/// returning the names that stayed unresolved (neither already loaded nor
53/// carried by the catalog).
54///
55/// A require already satisfied by a loaded lib is skipped. A require the catalog
56/// resolves is loaded only if its id is not already registered (idempotent, the
57/// `install_once` contract). An unresolved require is returned so the caller can
58/// report the recipe as a descriptor.
59pub fn load_requires(cx: &mut Cx, catalog: &dyn LibCatalog, card: &RecipeCard) -> Vec<String> {
60    let mut unresolved = Vec::new();
61    for req in &card.requires {
62        if lib_present(cx, req) {
63            continue;
64        }
65        match catalog.resolve(req) {
66            Some(lib) => {
67                let mut visiting = Vec::new();
68                if let Err(err) = load_lib_with_deps(cx, catalog, lib, &mut visiting) {
69                    unresolved.push(format!("{req} (load failed: {err})"));
70                }
71            }
72            None => unresolved.push(req.clone()),
73        }
74    }
75    unresolved
76}
77
78/// Whether a lib matching `name` (by qualified id or unqualified tail) is loaded.
79fn lib_present(cx: &Cx, name: &str) -> bool {
80    cx.registry().libs().iter().any(|lib| {
81        lib.manifest.id.as_qualified_str() == name || lib.manifest.id.name.as_ref() == name
82    })
83}
84
85/// Load `lib` into `cx` after its catalog-resolvable manifest dependencies, so a
86/// lib whose `load` observes already-registered libs (e.g. a promotion rule over
87/// existing number domains) sees them first regardless of the recipe's
88/// `requires` order (COOK8.07 dependency-order loading). Idempotent (a
89/// registered lib is skipped) and cycle-safe (an in-progress id is not
90/// re-entered; R7 keeps the lib graph acyclic anyway).
91fn load_lib_with_deps(
92    cx: &mut Cx,
93    catalog: &dyn LibCatalog,
94    lib: &dyn Lib,
95    visiting: &mut Vec<Symbol>,
96) -> Result<()> {
97    let manifest = lib.manifest();
98    if cx.registry().lib(&manifest.id).is_some() || visiting.contains(&manifest.id) {
99        return Ok(());
100    }
101    visiting.push(manifest.id.clone());
102    for dep in &manifest.requires {
103        let resolved = catalog
104            .resolve(&dep.id.as_qualified_str())
105            .or_else(|| catalog.resolve(dep.id.name.as_ref()));
106        // A dependency the catalog does not carry is assumed already loaded (the
107        // boot prelude) or genuinely absent; either way the lib's own load
108        // surfaces the error. Only catalog-resolvable deps are ordered here.
109        if let Some(dep_lib) = resolved {
110            load_lib_with_deps(cx, catalog, dep_lib, visiting)?;
111        }
112    }
113    cx.load_lib(lib)?;
114    visiting.pop();
115    Ok(())
116}
117
118/// The deterministic capability profile the cookbook seats its eval `Cx` with.
119///
120/// GRANTS the pure/offline/deterministic effects a recipe may legitimately need
121/// and DENIES (by omission) every live/effectful capability. A recipe requesting
122/// a denied capability fails closed: it is a Category D descriptor whose purpose
123/// is the denial. The profile is data, not a closed enum -- the granted and
124/// denied vocabularies are named capability strings the kernel interns.
125#[derive(Clone, Debug, Default)]
126pub struct CookbookCapabilityProfile;
127
128impl CookbookCapabilityProfile {
129    /// The capabilities this profile GRANTS: read-construct, read-eval, and the
130    /// pure/offline/deterministic effect vocabulary (compute, codec round-trip,
131    /// offline render with no device, deterministic cassette replay, and a
132    /// deterministic model fixture).
133    pub fn granted() -> Vec<CapabilityName> {
134        [
135            "read-construct",
136            "read-eval",
137            "compute",
138            "codec-encode",
139            "codec-decode",
140            "offline-render",
141            "cassette-replay",
142            "model-fixture",
143        ]
144        .into_iter()
145        .map(CapabilityName::new)
146        .collect()
147    }
148
149    /// The capabilities this profile explicitly DENIES: live network, live
150    /// hardware, process spawn, wall-clock time, filesystem writes, and unseeded
151    /// entropy. These are the Category D boundary; a recipe requesting one fails
152    /// closed and its purpose becomes the denial.
153    pub fn denied() -> Vec<CapabilityName> {
154        [
155            "net-connect",
156            "device-open",
157            "process-spawn",
158            "clock-now",
159            "fs-write",
160            "rng-unseeded",
161        ]
162        .into_iter()
163        .map(CapabilityName::new)
164        .collect()
165    }
166
167    /// Whether this profile grants `capability`.
168    pub fn grants(capability: &CapabilityName) -> bool {
169        Self::granted().contains(capability)
170    }
171
172    /// Whether this profile explicitly denies `capability`.
173    pub fn denies(capability: &CapabilityName) -> bool {
174        Self::denied().contains(capability)
175    }
176
177    /// Seat `cx` with the profile: grant every granted capability through the
178    /// host `seat` (minted with the `Cx` by [`sim_kernel::Cx::new_seated`]).
179    ///
180    /// Denied capabilities are never granted, so an op that demands one fails
181    /// closed. Granting is idempotent, so re-seating a `Cx` is a no-op beyond the
182    /// first call.
183    pub fn seat(seat: &GrantSeat, cx: &mut Cx) -> Result<()> {
184        for capability in Self::granted() {
185            seat.grant(cx, capability);
186        }
187        Ok(())
188    }
189}