Skip to main content

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}