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};
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 let already_loaded = cx.registry().libs().iter().any(|lib| {
63 lib.manifest.id.as_qualified_str() == *req
64 || lib.manifest.id.name.as_ref() == req.as_str()
65 });
66 if already_loaded {
67 continue;
68 }
69 match catalog.resolve(req) {
70 // Idempotent: only load when the id is not already registered.
71 Some(lib) if cx.registry().lib(&lib.manifest().id).is_none() => {
72 if let Err(err) = cx.load_lib(lib) {
73 unresolved.push(format!("{req} (load failed: {err})"));
74 }
75 }
76 Some(_) => {}
77 None => unresolved.push(req.clone()),
78 }
79 }
80 unresolved
81}
82
83/// The deterministic capability profile the cookbook seats its eval `Cx` with.
84///
85/// GRANTS the pure/offline/deterministic effects a recipe may legitimately need
86/// and DENIES (by omission) every live/effectful capability. A recipe requesting
87/// a denied capability fails closed: it is a Category D descriptor whose purpose
88/// is the denial. The profile is data, not a closed enum -- the granted and
89/// denied vocabularies are named capability strings the kernel interns.
90#[derive(Clone, Debug, Default)]
91pub struct CookbookCapabilityProfile;
92
93impl CookbookCapabilityProfile {
94 /// The capabilities this profile GRANTS: read-construct, read-eval, and the
95 /// pure/offline/deterministic effect vocabulary (compute, codec round-trip,
96 /// offline render with no device, deterministic cassette replay, and a
97 /// deterministic model fixture).
98 pub fn granted() -> Vec<CapabilityName> {
99 [
100 "read-construct",
101 "read-eval",
102 "compute",
103 "codec-encode",
104 "codec-decode",
105 "offline-render",
106 "cassette-replay",
107 "model-fixture",
108 ]
109 .into_iter()
110 .map(CapabilityName::new)
111 .collect()
112 }
113
114 /// The capabilities this profile explicitly DENIES: live network, live
115 /// hardware, process spawn, wall-clock time, filesystem writes, and unseeded
116 /// entropy. These are the Category D boundary; a recipe requesting one fails
117 /// closed and its purpose becomes the denial.
118 pub fn denied() -> Vec<CapabilityName> {
119 [
120 "net-connect",
121 "device-open",
122 "process-spawn",
123 "clock-now",
124 "fs-write",
125 "rng-unseeded",
126 ]
127 .into_iter()
128 .map(CapabilityName::new)
129 .collect()
130 }
131
132 /// Whether this profile grants `capability`.
133 pub fn grants(capability: &CapabilityName) -> bool {
134 Self::granted().contains(capability)
135 }
136
137 /// Whether this profile explicitly denies `capability`.
138 pub fn denies(capability: &CapabilityName) -> bool {
139 Self::denied().contains(capability)
140 }
141
142 /// Seat `cx` with the profile: grant every granted capability through the
143 /// host `seat` (minted with the `Cx` by [`sim_kernel::Cx::new_seated`]).
144 ///
145 /// Denied capabilities are never granted, so an op that demands one fails
146 /// closed. Granting is idempotent, so re-seating a `Cx` is a no-op beyond the
147 /// first call.
148 pub fn seat(seat: &GrantSeat, cx: &mut Cx) -> Result<()> {
149 for capability in Self::granted() {
150 seat.grant(cx, capability);
151 }
152 Ok(())
153 }
154}