yah_mesofact_bundle/contract.rs
1//! The **bundle↔runtime contract** (R746-F6, W272 §7): a version for the
2//! interface between a bundle and whatever binary serves it.
3//!
4//! Before this, the only version in the format was
5//! [`SCHEMA_VERSION`](crate::SCHEMA_VERSION) — the wire shape of
6//! `manifest.toml`. That is a fraction of the real interface. A runtime also
7//! has to know where in the tree to find routes and assets, how it will be
8//! forked, and what `bins/` means. None of that was stated anywhere, which is
9//! why `runtime = "mesofact/0.8.18"` could only ever be an exact pin: a range
10//! like `mesofact ^0.8` asserts that every 0.8.x can serve this bundle, and
11//! nothing existed for that assertion to be *about*.
12//!
13//! So: a runtime **advertises** the contract versions it implements (recorded
14//! as `contract` on its published `RuntimeAssetManifest`, under the `store`
15//! feature), a bundle **requires** one
16//! ([`BundleManifest::requires_contract`](crate::BundleManifest::requires_contract)), and
17//! resolution refuses the pair when the advertised set doesn't contain the
18//! required version. Product semver (`mesofact 0.8.20`) stays a human-facing
19//! hint layered on top rather than the thing resolution trusts.
20//!
21//! # Contract version 1
22//!
23//! Four clauses. A change to **any** of them that an existing runtime would
24//! get wrong is a new contract version; a change no existing runtime can
25//! observe is not.
26//!
27//! 1. **Manifest schema.** `manifest.toml` at the bundle root, parsing as
28//! [`BundleManifest`](crate::BundleManifest) at
29//! [`SCHEMA_VERSION`](crate::SCHEMA_VERSION) `= 1`: `schema_version`,
30//! `name`, `runtime`, `requires_contract`, and a `[content]` table of
31//! bundle-relative path → BLAKE3.
32//! 2. **App tree layout.** Everything the runtime reads lives under `app/`:
33//! `app/mesofact.routes.ts` (routes + config), `app/mesofact.config.toml`
34//! (optional `[publish]` block for a `--revalidate` receiver),
35//! `app/dist/**` (built TS + rendered assets), and each route's declared
36//! `data_inputs` staged at `app/<rel>` — resolved against `<bundle>/app`
37//! as the workload root, not against the machine that built it.
38//! 3. **Entrypoint calling convention.** The resolved binary is forked as
39//! `<bin> serve --bundle <bundle-dir> --listen <addr>`. `serve` is a
40//! *subcommand*: the published asset is the whole mesofact binary, and one
41//! that takes `--bundle` as `argv[1]` exits on an unknown argument before
42//! it ever binds.
43//! 4. **`bins/` and sidecars.** In a self-contained bundle the runtime is
44//! `bins/<triple>/serve`; sidecars are `bins/<triple>/<name>` and `serve`
45//! is reserved. A vanilla bundle carries no `bins/` at all and resolves
46//! both from the node runtime-asset tier instead.
47//!
48//! # Bumping it
49//!
50//! Add the new version to [`IMPLEMENTED_CONTRACTS`], move
51//! [`BUNDLE_CONTRACT_VERSION`] to it, and add a row to the compatibility
52//! matrix (`tests/contract_matrix.rs`). The matrix has a gate test that fails
53//! when a known version has no row — a contract version nothing exercises is
54//! a comment, and the matrix is also what makes it safe to *widen* a bundle's
55//! accepted set later, because it is the evidence.
56//!
57//! Dropping a version from [`IMPLEMENTED_CONTRACTS`] is the breaking move: it
58//! strands every published bundle requiring it, and those are immutable.
59
60/// A contract version. Small dense integers, not semver — the whole point is
61/// that this is the thing semver ranges would have had to range over.
62pub type ContractVersion = u32;
63
64/// The contract version this build **emits** on every bundle it assembles.
65pub const BUNDLE_CONTRACT_VERSION: ContractVersion = 1;
66
67/// Every contract version a runtime built from this tree **implements**, and
68/// therefore what [`publish_runtime_asset`](crate::publish_runtime_asset)
69/// advertises by default.
70///
71/// A superset of `[BUNDLE_CONTRACT_VERSION]` in general: a runtime keeps
72/// serving older bundles long after the assembler stops emitting them, because
73/// published bundles are immutable and a node may still be asked to materialize
74/// one from years ago.
75pub const IMPLEMENTED_CONTRACTS: &[ContractVersion] = &[BUNDLE_CONTRACT_VERSION];
76
77/// What a resolution site demands of the runtime it is about to fork.
78///
79/// An enum rather than an `Option<ContractVersion>` on purpose: `None` at a
80/// call site reads as "nothing to pass" and gets copy-pasted into places that
81/// did have something to pass. [`Unchecked`](Self::Unchecked) has to be
82/// spelled, and spelling it invites the question of why.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum ContractRequirement {
85 /// The version the bundle's manifest requires. The resolved runtime must
86 /// advertise it.
87 Version(ContractVersion),
88
89 /// Resolve without a contract check. The sidecar case: `almanac-feed` is
90 /// versioned with yubaba and its interface is its own CLI, not the
91 /// bundle↔runtime contract, so holding it to *this* contract's versions
92 /// would be a category error. Nothing that resolves a **serve** runtime
93 /// may use this.
94 Unchecked,
95}
96
97impl ContractRequirement {
98 /// Whether `advertised` satisfies this requirement.
99 pub fn satisfied_by(&self, advertised: &[ContractVersion]) -> bool {
100 match self {
101 ContractRequirement::Unchecked => true,
102 ContractRequirement::Version(v) => advertised.contains(v),
103 }
104 }
105}
106
107/// Render an advertised set for an error message: `1` / `1, 2` / `(none)`.
108///
109/// `(none)` is reachable — a publisher can record an empty set — and reads
110/// far better in a refusal than `[]`.
111pub fn describe_contracts(advertised: &[ContractVersion]) -> String {
112 if advertised.is_empty() {
113 return "(none)".to_string();
114 }
115 advertised
116 .iter()
117 .map(|v| v.to_string())
118 .collect::<Vec<_>>()
119 .join(", ")
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125
126 /// The emitted version must be one this tree's runtimes can actually
127 /// serve, or every bundle assembled here is born unservable.
128 #[test]
129 fn this_build_implements_what_it_emits() {
130 assert!(IMPLEMENTED_CONTRACTS.contains(&BUNDLE_CONTRACT_VERSION));
131 }
132
133 #[test]
134 fn satisfaction_is_set_membership() {
135 assert!(ContractRequirement::Version(1).satisfied_by(&[1]));
136 assert!(ContractRequirement::Version(1).satisfied_by(&[1, 2]));
137 assert!(!ContractRequirement::Version(2).satisfied_by(&[1]));
138 assert!(!ContractRequirement::Version(1).satisfied_by(&[]));
139 // Unchecked resolves against anything, including nothing.
140 assert!(ContractRequirement::Unchecked.satisfied_by(&[]));
141 }
142
143 #[test]
144 fn describe_reads_as_a_list() {
145 assert_eq!(describe_contracts(&[]), "(none)");
146 assert_eq!(describe_contracts(&[1]), "1");
147 assert_eq!(describe_contracts(&[1, 2, 3]), "1, 2, 3");
148 }
149}