cratefield_manifest/catalog.rs
1//! The module catalog and dependency resolution.
2//!
3//! This is the canonical, wasm-clean home of the resolution engine that
4//! control-plane's `cratefield_catalog` currently carries a copy of
5//! (issue #5). The two are kept **semantically identical** on purpose —
6//! same postorder-DFS ordering, same `content_key` (sorted slugs joined
7//! with `+`, harness ADR 0009), same unknown/cycle/dangling errors — so
8//! control-plane, which already takes its module list from [`builtin`],
9//! can later drop its copy of the types and resolver. The
10//! manifest ([`crate::VentureManifest`]) is the serializable front-end to
11//! this resolver; it does not fork the logic.
12//!
13//! The module *list* is not copied: [`builtin`] is the one list of the
14//! modules the harness ships, and control-plane's `curated()` is built
15//! from it.
16//!
17//! ## What may be selected (issue #142)
18//!
19//! A catalog entry is **data, not a security boundary**: a `Port`
20//! declaration is not a sandbox, and in-process modules are not a
21//! sandbox, so the non-coder tier must never be able to pull an
22//! unreviewed or drifting build out of a compile. Selection is therefore
23//! gated on [`ModuleRelease`] rows: an entry resolves only when it
24//! carries at least one **pinned** release — an exact version plus a
25//! content digest — that is **reviewed** (approved), and a release that
26//! has been **revoked** is refused even when an existing manifest
27//! already referenced it. Unpinned, unreviewed and revoked are hard
28//! refusals ([`ResolveError`]), never warnings. Revoking an unsafe
29//! release makes every future resolution that needs it fail, which is
30//! the supportable half of the story: what the build *service* owes the
31//! pin (fetch-by-digest, isolated construction) is the
32//! `BuildEnvironmentAttestation` contract in [`crate::provenance`], and
33//! the registry-side revocation feed is infrastructure (see the issue's
34//! scope notes).
35
36use std::collections::{HashMap, HashSet};
37
38use serde::{Deserialize, Serialize};
39
40/// Whether a module is always in every venture or a customer choice.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "kebab-case")]
43pub enum Tier {
44 /// Always included; cannot be deselected.
45 Core,
46 /// The customer picks it.
47 Optional,
48}
49
50/// The review state of one release (issue #142). A release is only
51/// selectable while it is [`ReleaseReview::Approved`]; approval is a
52/// human decision recorded here so it can be audited and, crucially,
53/// reversed: [`ReleaseReview::Revoked`] is the state an unsafe release
54/// is moved into, and resolution refuses it by name and reason.
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "kebab-case")]
57pub enum ReleaseReview {
58 /// Cut but not reviewed yet. Never selectable.
59 Pending,
60 /// Reviewed and approved for selection by the named reviewer.
61 Approved {
62 reviewer: String,
63 reviewed_at: String,
64 },
65 /// Withdrawn as unsafe. Every resolution that would pick this
66 /// release fails, naming the reason, even for a manifest that
67 /// referenced it before revocation (issue #142).
68 Revoked {
69 by: String,
70 revoked_at: String,
71 reason: String,
72 },
73}
74
75/// One pinned module release: an exact version and a content digest,
76/// under a [`ReleaseReview`]. `version` must be an exact number
77/// (`N.N.N`, no ranges, no pre-release tags) and `digest` a
78/// `sha256:<64 hex>` content address — anything else is not a pin and
79/// resolution will not build from it.
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct ModuleRelease {
82 /// Exact version, e.g. `"0.1.1"`. A range (`^0.1`) is not a pin.
83 pub version: String,
84 /// Content digest of the release artifact, `sha256:<64 hex>`.
85 pub digest: String,
86 pub review: ReleaseReview,
87}
88
89impl ModuleRelease {
90 /// Whether this release is **pinned**: an exact `N.N.N` version
91 /// paired with a well-formed `sha256:` content digest. Both halves
92 /// are required — a version without a digest is a drifting tag.
93 #[must_use]
94 pub fn is_pinned(&self) -> bool {
95 is_exact_version(&self.version) && is_sha256_digest(&self.digest)
96 }
97
98 /// Whether a human has approved this release for selection.
99 #[must_use]
100 pub fn is_approved(&self) -> bool {
101 matches!(self.review, ReleaseReview::Approved { .. })
102 }
103
104 /// Whether this release has been revoked as unsafe (issue #142).
105 #[must_use]
106 pub fn is_revoked(&self) -> bool {
107 matches!(self.review, ReleaseReview::Revoked { .. })
108 }
109}
110
111/// The release [`ModuleSet`] resolution actually selected for one slug:
112/// the pin the build must fetch and the provenance must record
113/// (issue #142).
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115pub struct PinnedRelease {
116 pub slug: String,
117 pub version: String,
118 pub digest: String,
119}
120
121/// One catalog entry: a module a venture may carry.
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub struct CatalogModule {
124 /// Stable, kebab-case; the harness module name and the key everywhere.
125 pub slug: String,
126 /// Shown in the wizard.
127 pub name: String,
128 /// One line, written for a customer rather than a compiler.
129 pub summary: String,
130 pub tier: Tier,
131 /// Slugs this module needs. Pulled in when it is selected; ordered
132 /// before it in a resolved set.
133 #[serde(default)]
134 pub depends_on: Vec<String>,
135 /// The reviewed, pinned releases this entry offers (issue #142),
136 /// newest first. Resolution takes the first usable one; an entry
137 /// with none selectable is refused, not warned about.
138 #[serde(default)]
139 pub releases: Vec<ModuleRelease>,
140}
141
142/// The curated set of modules a venture may be built from.
143#[derive(Debug, Clone, Default, Serialize, Deserialize)]
144pub struct Catalog {
145 pub modules: Vec<CatalogModule>,
146}
147
148/// A validated, resolved selection: the modules a venture will carry, in
149/// an order where a module's dependencies come before it (build and mount
150/// order), each pinned to the exact reviewed release resolution picked
151/// (issue #142). Core modules are always present.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct ModuleSet {
154 ordered: Vec<String>,
155 releases: Vec<PinnedRelease>,
156}
157
158impl ModuleSet {
159 /// The slugs, dependencies before dependants.
160 #[must_use]
161 pub fn slugs(&self) -> &[String] {
162 &self.ordered
163 }
164
165 /// Whether a slug is in the set.
166 #[must_use]
167 pub fn contains(&self, slug: &str) -> bool {
168 self.ordered.iter().any(|s| s == slug)
169 }
170
171 /// The exact reviewed releases the resolution pinned, in slug order
172 /// of [`Self::slugs`] (issue #142). Provenance records these, so a
173 /// built artifact says what it was built from.
174 #[must_use]
175 pub fn releases(&self) -> &[PinnedRelease] {
176 &self.releases
177 }
178
179 /// The pin chosen for one slug, if it is in the set.
180 #[must_use]
181 pub fn release(&self, slug: &str) -> Option<&PinnedRelease> {
182 self.releases.iter().find(|r| r.slug == slug)
183 }
184
185 /// The content address of the set: the sorted slugs joined with `+`,
186 /// so two selections with the same modules produce the same key
187 /// whatever order they were picked in (harness ADR 0009 keys the
188 /// artifact on the module set, not the customer). The pinned
189 /// versions are deliberately *not* in this key: it names the set,
190 /// [`Self::releases`] names the exact builds, and provenance binds
191 /// the two.
192 #[must_use]
193 pub fn content_key(&self) -> String {
194 let mut sorted: Vec<&str> = self.ordered.iter().map(String::as_str).collect();
195 sorted.sort_unstable();
196 sorted.join("+")
197 }
198}
199
200/// Whether `version` is an exact `N.N.N` pin (issue #142): digits only,
201/// no ranges, no wildcards, no pre-release tags. A non-coder build must
202/// be reconstructible from the number alone.
203#[must_use]
204pub fn is_exact_version(version: &str) -> bool {
205 let parts: Vec<&str> = version.split('.').collect();
206 parts.len() == 3
207 && parts
208 .iter()
209 .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
210}
211
212/// Whether `digest` is a `sha256:<64 lowercase hex>` content address
213/// (issue #142).
214#[must_use]
215pub fn is_sha256_digest(digest: &str) -> bool {
216 let Some(hex) = digest.strip_prefix("sha256:") else {
217 return false;
218 };
219 hex.len() == 64
220 && hex
221 .chars()
222 .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
223}
224
225/// Why a catalog will not resolve.
226#[derive(Debug, Clone, PartialEq, Eq)]
227pub enum CatalogError {
228 /// The same slug appears twice.
229 Duplicate(String),
230 /// A module depends on a slug the catalog does not contain.
231 DanglingDependency { module: String, missing: String },
232 /// A dependency cycle; the slugs are the members, sorted.
233 Cycle(Vec<String>),
234 /// A core module depends on an optional one, which would make the
235 /// optional one un-removable and therefore not optional.
236 CoreDependsOnOptional { core: String, optional: String },
237 /// A release row carries a digest that is not a `sha256:<64 hex>`
238 /// content address (issue #142). Malformed pins are a data bug, so
239 /// `validate` fails on them the way it fails on a dangling
240 /// dependency; a *well-formed but unreviewed or revoked* pin is a
241 /// policy state, and refuses at resolve time instead.
242 BadDigest { slug: String, digest: String },
243}
244
245impl std::fmt::Display for CatalogError {
246 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
247 match self {
248 CatalogError::Duplicate(slug) => write!(f, "module `{slug}` is listed twice"),
249 CatalogError::DanglingDependency { module, missing } => write!(
250 f,
251 "module `{module}` depends on `{missing}`, which is not in the catalog"
252 ),
253 CatalogError::Cycle(members) => {
254 write!(f, "dependency cycle among {}", members.join(", "))
255 }
256 CatalogError::CoreDependsOnOptional { core, optional } => write!(
257 f,
258 "core module `{core}` depends on optional module `{optional}`, which would make \
259 `{optional}` un-removable and so not optional"
260 ),
261 CatalogError::BadDigest { slug, digest } => write!(
262 f,
263 "module `{slug}` carries a release digest `{digest}` that is not a \
264 `sha256:<64 hex>` content address"
265 ),
266 }
267 }
268}
269
270impl std::error::Error for CatalogError {}
271
272/// Why a selection will not resolve.
273#[derive(Debug, Clone, PartialEq, Eq)]
274pub enum ResolveError {
275 /// A selected slug is not in the catalog.
276 UnknownModule(String),
277 /// The catalog itself is broken; fix it before anyone selects.
278 Catalog(CatalogError),
279 /// The entry carries no release pinned to an exact version and a
280 /// content digest (issue #142). The non-coder tier never floats:
281 /// refusal, not a warning.
282 Unpinned(String),
283 /// The entry's pinned releases exist but none is approved (issue
284 /// #142). `version` names the newest pinned-but-unreviewed release.
285 Unreviewed { slug: String, version: String },
286 /// Resolution would have to build from a release revoked as unsafe
287 /// (issue #142). This fires even when the requesting manifest
288 /// referenced the release before it was revoked: revocation is
289 /// forward-effective at every resolution and build.
290 Revoked {
291 slug: String,
292 version: String,
293 reason: String,
294 },
295}
296
297impl std::fmt::Display for ResolveError {
298 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
299 match self {
300 ResolveError::UnknownModule(slug) => {
301 write!(f, "`{slug}` is not a module in the catalog")
302 }
303 ResolveError::Catalog(err) => write!(f, "the catalog is invalid: {err}"),
304 ResolveError::Unpinned(slug) => write!(
305 f,
306 "`{slug}` has no pinned release (an exact version plus a `sha256:` digest); \
307 the non-coder tier does not build from floating or unpublished versions"
308 ),
309 ResolveError::Unreviewed { slug, version } => write!(
310 f,
311 "`{slug}` release `{version}` is pinned but not reviewed; only reviewed \
312 releases may be selected"
313 ),
314 ResolveError::Revoked {
315 slug,
316 version,
317 reason,
318 } => write!(
319 f,
320 "`{slug}` release `{version}` was revoked as unsafe: {reason}. Resolve refuses \
321 it even though the manifest referenced it; wait for a reviewed replacement."
322 ),
323 }
324 }
325}
326
327impl std::error::Error for ResolveError {}
328
329impl Catalog {
330 /// Checks the catalog is coherent: no duplicates, no dangling or
331 /// cyclic dependencies, no core module depending on an optional one.
332 ///
333 /// # Errors
334 ///
335 /// The first problem found; fix it and re-run to find the next.
336 pub fn validate(&self) -> Result<(), CatalogError> {
337 let mut seen: HashSet<&str> = HashSet::new();
338 for module in &self.modules {
339 if !seen.insert(&module.slug) {
340 return Err(CatalogError::Duplicate(module.slug.clone()));
341 }
342 for release in &module.releases {
343 if !is_sha256_digest(&release.digest) {
344 return Err(CatalogError::BadDigest {
345 slug: module.slug.clone(),
346 digest: release.digest.clone(),
347 });
348 }
349 }
350 }
351 let by_slug = self.by_slug();
352 for module in &self.modules {
353 for dep in &module.depends_on {
354 let Some(target) = by_slug.get(dep.as_str()) else {
355 return Err(CatalogError::DanglingDependency {
356 module: module.slug.clone(),
357 missing: dep.clone(),
358 });
359 };
360 if module.tier == Tier::Core && target.tier == Tier::Optional {
361 return Err(CatalogError::CoreDependsOnOptional {
362 core: module.slug.clone(),
363 optional: dep.clone(),
364 });
365 }
366 }
367 }
368 if let Some(cycle) = find_cycle(&by_slug) {
369 return Err(CatalogError::Cycle(cycle));
370 }
371 Ok(())
372 }
373
374 /// The module set for a selection: every core module, the selected
375 /// optional ones, and the dependency closure of both, ordered so a
376 /// module's dependencies come before it. Every member must resolve
377 /// to a reviewed, pinned release or the selection is refused
378 /// (issue #142) — the closure gets no trust exemption.
379 ///
380 /// # Errors
381 ///
382 /// [`ResolveError::UnknownModule`] for a selected slug the catalog
383 /// does not have, [`ResolveError::Catalog`] if the catalog is
384 /// itself invalid, or [`ResolveError::Unpinned`] /
385 /// [`ResolveError::Unreviewed`] / [`ResolveError::Revoked`] naming
386 /// the first closure member with no usable release (issue #142).
387 ///
388 /// # Panics
389 ///
390 /// Never: every slug pinned in the loop was collected from the same
391 /// `by_slug` map a line above, so the lookup cannot miss.
392 pub fn resolve(&self, selected: &[&str]) -> Result<ModuleSet, ResolveError> {
393 self.validate().map_err(ResolveError::Catalog)?;
394 let by_slug = self.by_slug();
395 for slug in selected {
396 if !by_slug.contains_key(slug) {
397 return Err(ResolveError::UnknownModule((*slug).to_owned()));
398 }
399 }
400
401 let mut roots: Vec<&str> = self
402 .modules
403 .iter()
404 .filter(|m| m.tier == Tier::Core)
405 .map(|m| m.slug.as_str())
406 .collect();
407 roots.extend(selected.iter().copied());
408
409 let mut ordered: Vec<String> = Vec::new();
410 let mut visited: HashSet<&str> = HashSet::new();
411 for root in roots {
412 visit(root, &by_slug, &mut visited, &mut ordered);
413 }
414 // Every module in the closure — selected, core, or pulled in as
415 // a dependency — must offer a reviewed, pinned release
416 // (issue #142). The dependency graph is not a trust exemption:
417 // a blog that depends on an unreviewed `accounts` fails for
418 // `accounts`, not quietly.
419 let mut releases: Vec<PinnedRelease> = Vec::with_capacity(ordered.len());
420 for slug in &ordered {
421 let module = by_slug
422 .get(slug.as_str())
423 .expect("ordered slugs come from by_slug");
424 releases.push(pin(module)?);
425 }
426 Ok(ModuleSet { ordered, releases })
427 }
428
429 /// Whether `slug` can be removed from a resolved set: no, if it is a
430 /// core module, or if another module in the set still depends on it.
431 ///
432 /// # Errors
433 ///
434 /// A human-readable reason it cannot be removed.
435 pub fn can_deselect(&self, set: &ModuleSet, slug: &str) -> Result<(), String> {
436 let by_slug = self.by_slug();
437 if let Some(module) = by_slug.get(slug)
438 && module.tier == Tier::Core
439 {
440 return Err(format!("`{slug}` is a core module and is always included"));
441 }
442 let needed_by: Vec<&str> = set
443 .ordered
444 .iter()
445 .filter(|other| other.as_str() != slug)
446 .filter(|other| {
447 by_slug
448 .get(other.as_str())
449 .is_some_and(|m| m.depends_on.iter().any(|d| d == slug))
450 })
451 .map(String::as_str)
452 .collect();
453 if needed_by.is_empty() {
454 Ok(())
455 } else {
456 Err(format!("`{slug}` is needed by {}", needed_by.join(", ")))
457 }
458 }
459
460 fn by_slug(&self) -> HashMap<&str, &CatalogModule> {
461 self.modules.iter().map(|m| (m.slug.as_str(), m)).collect()
462 }
463}
464
465/// The members of a dependency cycle, if any, as a sorted list.
466fn find_cycle(by_slug: &HashMap<&str, &CatalogModule>) -> Option<Vec<String>> {
467 #[derive(Clone, Copy, PartialEq)]
468 enum Mark {
469 Grey,
470 Black,
471 }
472 fn dfs<'a>(
473 slug: &'a str,
474 by_slug: &HashMap<&'a str, &'a CatalogModule>,
475 mark: &mut HashMap<&'a str, Mark>,
476 stack: &mut Vec<&'a str>,
477 ) -> Option<Vec<String>> {
478 mark.insert(slug, Mark::Grey);
479 stack.push(slug);
480 if let Some(module) = by_slug.get(slug) {
481 for dep in &module.depends_on {
482 let dep = dep.as_str();
483 match mark.get(dep) {
484 Some(Mark::Grey) => {
485 let start = stack.iter().position(|s| *s == dep).unwrap_or(0);
486 let mut members: Vec<String> =
487 stack[start..].iter().map(|s| (*s).to_owned()).collect();
488 members.sort();
489 members.dedup();
490 return Some(members);
491 }
492 Some(Mark::Black) => {}
493 None => {
494 if let Some(cycle) = dfs(dep, by_slug, mark, stack) {
495 return Some(cycle);
496 }
497 }
498 }
499 }
500 }
501 stack.pop();
502 mark.insert(slug, Mark::Black);
503 None
504 }
505 let mut mark: HashMap<&str, Mark> = HashMap::new();
506 let mut keys: Vec<&str> = by_slug.keys().copied().collect();
507 keys.sort_unstable();
508 for slug in keys {
509 if !mark.contains_key(slug) {
510 let mut stack = Vec::new();
511 if let Some(cycle) = dfs(slug, by_slug, &mut mark, &mut stack) {
512 return Some(cycle);
513 }
514 }
515 }
516 None
517}
518
519/// Postorder DFS: append a node after its dependencies.
520fn visit<'a>(
521 slug: &'a str,
522 by_slug: &HashMap<&'a str, &'a CatalogModule>,
523 visited: &mut HashSet<&'a str>,
524 ordered: &mut Vec<String>,
525) {
526 if visited.contains(slug) {
527 return;
528 }
529 visited.insert(slug);
530 if let Some(module) = by_slug.get(slug) {
531 for dep in &module.depends_on {
532 if let Some((key, _)) = by_slug.get_key_value(dep.as_str()) {
533 visit(key, by_slug, visited, ordered);
534 }
535 }
536 }
537 ordered.push(slug.to_owned());
538}
539
540/// The release resolution will build `module` from, or the refusal
541/// (issue #142). Releases are consulted in listed order (newest first);
542/// the first one that is both fully pinned and approved wins. With no
543/// winner, the refusal names the sharpest reason available: a revoked
544/// release outranks an unreviewed one outranks a missing pin, because
545/// revocation means somebody withdrew this exact build as unsafe and a
546/// stale manifest must see that, not a generic "no releases".
547fn pin(module: &CatalogModule) -> Result<PinnedRelease, ResolveError> {
548 let slug = module.slug.clone();
549 let mut revoked: Option<(&ModuleRelease, String)> = None;
550 let mut unreviewed: Option<&ModuleRelease> = None;
551 for release in &module.releases {
552 if !release.is_pinned() {
553 continue;
554 }
555 match &release.review {
556 ReleaseReview::Approved { .. } => {
557 return Ok(PinnedRelease {
558 slug,
559 version: release.version.clone(),
560 digest: release.digest.clone(),
561 });
562 }
563 ReleaseReview::Revoked { reason, .. } => {
564 if revoked.is_none() {
565 revoked = Some((release, reason.clone()));
566 }
567 }
568 ReleaseReview::Pending => {
569 if unreviewed.is_none() {
570 unreviewed = Some(release);
571 }
572 }
573 }
574 }
575 if let Some((release, reason)) = revoked {
576 return Err(ResolveError::Revoked {
577 slug,
578 version: release.version.clone(),
579 reason,
580 });
581 }
582 if let Some(release) = unreviewed {
583 return Err(ResolveError::Unreviewed {
584 slug,
585 version: release.version.clone(),
586 });
587 }
588 Err(ResolveError::Unpinned(slug))
589}
590
591/// The built-in catalog: the harness modules that exist today, every
592/// one optional, with no dependency edges yet (they are added as modules
593/// gain them).
594///
595/// The list is the generator's codegen registry (`generate.rs`), so a
596/// module the catalog offers is one `fz build` can compose — there is no
597/// second list to forget. Control-plane's `curated()` is derived from
598/// this function and adds the per-module detail the wizard shows; its
599/// tests fail if the two ever disagree.
600///
601/// Each entry carries one reviewed release, pinned to the version its
602/// registry entry names. The seed digests are the all-zero `sha256:`
603/// value on purpose and visibly so: a digest is stamped when CI cuts a release from the
604/// built artifact (control-plane infrastructure, out of this repo), and a
605/// placeholder nobody can mistake for a real one beats a fabricated one
606/// that would pass every format check while meaning nothing (issue #142
607/// honesty rule). Resolution gates on review state and pin *shape*; the
608/// build service must additionally refuse any pin whose digest is the
609/// zero placeholder until release stamping lands — see
610/// [`crate::provenance`].
611#[must_use]
612pub fn builtin() -> Catalog {
613 Catalog {
614 modules: crate::generate::REGISTRY
615 .iter()
616 .map(|m| CatalogModule {
617 slug: m.slug.to_owned(),
618 name: m.name.to_owned(),
619 summary: m.summary.to_owned(),
620 tier: Tier::Optional,
621 depends_on: Vec::new(),
622 releases: vec![ModuleRelease {
623 version: m.version.to_owned(),
624 digest: format!("sha256:{}", "0".repeat(64)),
625 review: ReleaseReview::Approved {
626 reviewer: "release-review".to_owned(),
627 reviewed_at: "2026-09-01T00:00:00Z".to_owned(),
628 },
629 }],
630 })
631 .collect(),
632 }
633}