yah_mesofact_bundle/lib.rs
1//! The W272 **mesofact bundle** format — the content-addressed unit a mesofact
2//! app distributes as, plus (behind the `store` feature) the R2 blob store that
3//! publishes and materializes it.
4//!
5//! A bundle is one immutable, content-addressed directory shape:
6//!
7//! ```text
8//! <bundle>/
9//! manifest.toml # identity + runtime selection
10//! app/ # routes/config + built TS/assets
11//! bins/<triple>/serve # ONLY when runtime = "self"
12//! ```
13//!
14//! ```toml
15//! # manifest.toml
16//! schema_version = 1
17//! name = "yah-marketing"
18//! runtime = "mesofact/0.8.18" # vanilla: resolve stock runtime from node cache
19//! # runtime = "self" # custom: bins/<triple>/serve ships in the bundle
20//! requires_contract = 1 # bundle↔runtime interface version (R746-F6)
21//!
22//! [content]
23//! "app/mesofact.routes.ts" = "b3f1…" # path → blake3, one row per file
24//! ```
25//!
26//! `schema_version` and `requires_contract` version different things and move
27//! independently: the first is the wire shape of *this file*, the second is the
28//! whole interface a serving binary must implement (see [`contract`]).
29//!
30//! There is exactly one format; "vanilla" is the degenerate case where
31//! `runtime = "mesofact/<ver>"` and no `bins/` are carried. Immutability is the
32//! whole coherence story: any change produces a new [`BundleManifest::digest`],
33//! "update" is publish-new-digest + drain-old, and rollback is a pointer flip.
34//!
35//! This crate is deliberately light: the default feature set is serde + blake3
36//! + toml only, so the `mesofact-build` subcamp (R599-F2, a separate Cargo
37//! workspace) can link it to *emit* manifests without dragging the object-store
38//! / reqwest stack across the boundary. The `store` feature adds that surface
39//! for the cloud publish path (R599) and kamaji node materialize (R599-F4).
40//!
41//! Home of R599-F1 (the W272 bundle format + store) — the canonical `@yah:`
42//! ticket annotation lives in `oss/yubaba/crates/cloud/src/release_manifest.rs`
43//! to keep one block per ID; see W272 for the design.
44//!
45//! @yah:ticket(R746-F5, "Bundle-only release for CUSTOM mesofact: reuse the serve binary by digest, gated on a recorded runtime contract")
46//! @yah:status(review)
47//! @yah:at(2026-08-16T04:36:16Z)
48//! @yah:assignee(agent:bundle-anthropic-miravel)
49//! @yah:parent(R746)
50//! @yah:next("OPERATOR INTENT, and the relay's title under-describes it: bundle-only release must work for CUSTOM mesofact too, not just vanilla. A consumer who changed only templates should re-release without recompiling. A Rust rebuild happens when the Rust side changed — and only then.")
51//! @yah:next("GAP 1, the mechanism. assemble_self_bundle / assemble_self_bundle_with take serve_bins as (triple, PathBuf) — always a local file — so a template-only re-release needs the binary present on the assembling machine even when it is byte-identical to the one already in the store. Add a digest form: pin the bin by blake3 and have the assembler reference the blob the previous publish already uploaded, with no local file and no build. The store is append-only and blob-deduped, so the bytes are provably still there and the re-publish uploads only the changed app blobs.")
52//! @yah:next("GAP 2, and this is the one that makes GAP 1 safe. BundleRuntime::SelfContained (oss/yah-base/crates/mesofact-bundle/src/lib.rs:164) is a UNIT variant — it records nothing about what the carried binary was built against. That is sound today only because bins and app are assembled in one moment from one tree; reuse across assemblies destroys the guarantee. SelfContained must carry the contract: at minimum the mesofact library version the binary was built against, plus the bundle manifest schema version it can read.")
53//! @yah:next("THE RULE that falls out: reuse is permitted when the app's DECLARED mesofact dependency equals the version recorded on the reused binary. Different -> refuse the reuse and dispatch the build (R746-F2's arm). Declared, never diffed — same discipline as the vanilla/self split itself. This is precisely the operator's framing: bundle-only works as long as the consumer has not messed up the mesofact library dependency, and the recorded contract is what makes messing it up detectable instead of silent.")
54//! @yah:next("SHIP BOTH HALVES TOGETHER. Landing the digest-reuse without the recorded contract is strictly worse than today: it makes a silent binary/bundle mismatch reachable for the first time, and the symptom would be a served site failing at runtime on a node, not a failed apply.")
55//! @yah:next("REFINEMENT WORTH DECIDING IN-TICKET, not before: crate version is a conservative PROXY for the real contract, which is the bundle manifest schema plus the app-tree layout (mesofact.routes.ts + dist/) plus the SSR entrypoint calling convention. Exact-match refuses reuse across a patch bump that changed nothing relevant. Start exact — wrong-and-rebuilds is cheap, wrong-and-reuses is a broken site — and consider a declared compat range once the surface is actually versioned.")
56//! @yah:verify("Change only a template in a custom service, sync, and the release completes with NO cargo invocation and NO local serve binary present — the new bundle references the previous binary blob by digest. This is the ticket's whole point; prove it on a machine that has never built that binary.")
57//! @yah:verify("The re-publish uploads only the changed app blobs. Read it off the publish output rather than inferring it from dedupe existing.")
58//! @yah:verify("Bump the service's mesofact dependency, change nothing else, and sync: the reuse is REFUSED and a build is dispatched. A reuse that silently succeeds here is the defect this ticket exists to prevent.")
59//! @yah:verify("A bundle published before this change (SelfContained with no recorded contract) still materializes and serves — it just cannot be a reuse SOURCE, since nothing recorded what it was built against.")
60//! @yah:assumes("Tier: Wizard — it puts a compatibility contract into a content-addressed format that is already published and immutable, and the failure mode of getting it wrong is a live site serving from a mismatched runtime rather than a red build.")
61//! @yah:next("SUPERSEDES the exact-version-match framing above (operator direction 2026-08-11, written up as W272 section 7). A bundle names a compatible SET of runtimes rather than one, tight to loose: an exact blake3 pin, a semver range over a stock build, a semver range over an org/project-namespaced CUSTOM build, or a capability constraint. Vanilla and self stop being two shapes and become two narrowings of one expression, with self as the degenerate tightest case.")
62//! @yah:next("CONSEQUENCE for this ticket: the reuse gate is no longer equality on a recorded crate version, it is SATISFACTION of the bundle's declared constraint by the candidate runtime. Reuse when the previously built binary still satisfies what the app now declares; rebuild when it does not. Same discipline — declared, never diffed — but it stops refusing reuse across a patch bump that changed nothing relevant.")
63//! @yah:next("ORDERING, and it is not optional: a range is only as good as the contract it ranges over. Land R746-F6 (versioned bundle-runtime contract) FIRST, or ship this ticket pinned to exact digests only. A semver range with nothing enforcing compatibility is unenforced optimism whose failure surfaces on a node at serve time — after the apply that caused it reported success.")
64//! @yah:handoff("SHIPPED PINNED-TO-EXACT-DIGESTS ONLY, per the ticket's own ordering escape hatch (F6's semver-range contract is NOT landed or required by this). GAP1+GAP2 both closed.")
65//! @yah:handoff("GAP2 (the contract): BundleRuntime::SelfContained is now a struct variant carrying built_against: Option<String> — the yah_qed::build_context::source_context_fingerprint of the source tree every bins/<triple>/serve in the bundle was compiled from. Wire form self / self/<fp>, old bundles with no fp still parse+serve, they just can't be a reuse SOURCE. oss/yah-base/crates/mesofact-bundle/src/lib.rs.")
66//! @yah:handoff("GAP1 (the mechanism): new ServeBinSource enum {Local(PathBuf), Pinned(BundleHash)} — assemble_self_bundle_with takes serve_bins as (triple, ServeBinSource) + a built_against param. A Pinned entry costs no local read/copy; publish_bundle already skipped-on-head for any hash the store holds (verified with a new store.rs test proving publish never touches a local path that was never written). oss/yah-base/crates/mesofact-bundle/src/assemble.rs.")
67//! @yah:handoff("New by-name pointer bundles/current/<workload-name>.toml (pointer.rs, write_current_digest/read_current_digest) + fetch_manifest (manifest-object-only read, no blob download) — what a reuse decision on a machine with zero local state reads to find a candidate.")
68//! @yah:handoff("CLI wiring: serve_build::resolve_serve_build_with_reuse computes each declared triple's fingerprint (reusing F2's plan_triple), checks it against the previously-published bundle's recorded built_against + per-triple bins hash; on a full match every triple is Pinned with ZERO dispatch and no local-cache lookup — proves the 'never built here' case unit-testably. Any mismatch/miss falls through to F2's existing Cached/Built tiers untouched. deploy_mesofact_bundle opens the bucket once, reads the pointer before assembly, writes it after a successful publish (never before — a reader must only ever see a digest that's provably in the store).")
69//! @yah:handoff("The serve_bins hand-path escape hatch and yah cloud bundle build (CLI one-shot) stay Local-only / built_against=None by design — no fingerprint to reuse against.")
70//! @yah:handoff("Extra: refactored materialize_bundle to share fetch_manifest's fetch+verify — no behavior change, one fewer copy of the verification logic.")
71//! @yah:verify("cargo test -p yah-mesofact-bundle --all-features (run from oss/yah-base): 54 pass, 0 fail — includes the pinned-entry publish/assemble/round-trip tests and the pointer tests.")
72//! @yah:verify("cargo test -p yah --lib serve_build:: : 15 pass, 0 fail — 5 new: matching-contract reuse dispatches nothing AND needs no local cache dir at all (the literal 'machine that never built it' case), stale contract forces a build, missing-triple forces a build, first-ever-sync builds and records a fingerprint, PreviousBundle::from_manifest round-trips a real manifest.")
73//! @yah:verify("cargo test -p yah --lib cloud:: -- bundle: 126 pass, 0 fail (bundle_assembly_tests + serve_build:: together) — confirms assemble_component_bundle*'s new ServeBinSource-typed signature didn't break any existing call site.")
74//! @yah:verify("cargo check -p yah --lib: clean, 0 errors (18 pre-existing warnings elsewhere, none touched by this change).")
75//! @yah:gotcha("NOT LIVE-VERIFIED end to end — same gap R746-F2 was accepted at review with (its own gotcha: 'VERIFY #2 HAS NOT RUN LIVE, and cannot yet... blocked on relocating containerd to /data on the arm workers'). yah-marketing is going vanilla (R746-T3), so there is still no live custom (runtime=self) bundle-tier service to sync from a real machine — R746-S4 tracks finding one. This ticket's reuse arm rides the exact same serve_build declaration F2's live gap already blocks on; nothing here narrows or widens that gap.")
76//! @yah:gotcha("R746-F6 (versioned bundle-runtime contract / compat ranges) is NOT required by this and was deliberately NOT started — the ordering note in the ticket explicitly allows shipping exact-digest-only first. If F6 lands later, the natural extension point is BundleRuntime::SelfContained::built_against and the exact-equality check in resolve_serve_build_with_reuse — both isolated to one file/one function.")
77//!
78//! @yah:ticket(R746-F6, "Version the bundle-runtime contract so a compatibility range means something a runtime is held to")
79//! @yah:status(review)
80//! @yah:at(2026-08-16T23:35:10Z)
81//! @yah:assignee(agent:bundle-anthropic-ashguard)
82//! @yah:parent(R746)
83//! @yah:next("PREREQUISITE for R746-F5's constraint expression. The interface between a bundle and the binary that serves it is currently undeclared: the manifest carries schema_version = 1 and nothing states what a runtime must implement. So mesofact ^0.8 is a claim nobody is held to.")
84//! @yah:next("The interface, enumerated (W272 section 7): (1) the bundle manifest schema; (2) the app/ tree layout the runtime reads — mesofact.routes.ts plus dist/; (3) the SSR entrypoint calling convention; (4) the sidecar and bins/ contract from assemble_self_bundle_with. Give that set a version. A runtime ADVERTISES which contract versions it implements; a bundle REQUIRES one; mesofact semver stays a human-facing hint layered on top rather than the thing resolution trusts.")
85//! @yah:next("Test shape that makes it real rather than declarative: a compatibility matrix that materializes and SERVES a fixture bundle at contract version N against every runtime claiming N. A contract version nothing exercises is a comment. This is also what makes it safe to widen a range later — the matrix is the evidence.")
86//! @yah:verify("A runtime that does not implement the contract version a bundle requires is REFUSED at apply time, naming both versions. It must not reach a node and fail at serve time — that is the failure mode the whole ticket exists to move earlier.")
87//! @yah:verify("The matrix runs in CI and a new contract version cannot land without a row.")
88//! @yah:assumes("Tier: Wizard — defining a compatibility interface for an already-published immutable format; the cost of drawing the boundary wrong is paid by every future runtime and bundle.")
89//! @yah:handoff("LANDED at contract version 1. New pub mod oss/yah-base/crates/mesofact-bundle/src/contract.rs is the NORMATIVE text: ContractVersion, BUNDLE_CONTRACT_VERSION (what this build emits), IMPLEMENTED_CONTRACTS (what a runtime built here implements), ContractRequirement (Version | Unchecked), describe_contracts. Its doc enumerates the four clauses of v1 and the bump procedure. W272 section 7 now points at it rather than restating it.")
90//! @yah:handoff("THE THREE WIRE CHANGES. (1) BundleManifest.requires_contract, stamped by assemble_bundle - the single site every shape funnels through, so no caller can under-declare it. (2) RuntimeAssetManifest.contract (a Vec) - the advertisement, defaulted to [1] for assets published before the field, which is a fact about every binary predating v2 rather than an optimistic guess. (3) publish_runtime_asset takes the advertised set as a REQUIRED param and refuses an empty one; the yah cloud bundle publish-runtime --contract flag defaults it to IMPLEMENTED_CONTRACTS, which is right when the binary came from this tree and silently wrong for a cross-built binary from an older checkout - so the assumption sits one layer up where an operator can override it.")
91//! @yah:handoff("DIGEST STABILITY, the one subtle thing a reviewer must check. requires_contract is fed into BundleManifest::digest ONLY when it is not 1. Feeding it unconditionally would re-hash every manifest already in R2, and fetch_manifest verifies a fetched manifest against the digest it was stored under - so every live bundle would stop resolving at once. The skip is permanent, not a placeholder. Pinned by the test adding_requires_contract_did_not_move_any_published_digest, which spells the pre-F6 field sequence out long-hand rather than pinning a hex literal nobody can audit.")
92//! @yah:handoff("ENFORCEMENT, two points. PRIMARY is apply time: require_runtime_contract surveys every published triple for the runtime a vanilla bundle names and fails the apply naming both versions. Wired into deploy_mesofact_bundle (app/yah/cli/src/cloud.rs) between assembly and publish, so a refusal uploads nothing. One unsatisfied triple fails the whole apply even for machines that would not have used it - one runtime ref is one release, and a mixed-contract publish otherwise relocates the failure onto whichever node is the odd one out, at serve time. Nothing published at all is NOT a contract failure (that is RuntimeAssetMissing, R746-F1) - it prints a note. BACKSTOP is kamaji: materialize_and_resolve_serve reads requires_contract off the materialized manifest.toml (covered by the digest, so it cannot disagree with the bundle, and no new wire field to keep in sync) and passes it to ensure_runtime_asset.")
93//! @yah:handoff("THE MATRIX (the half that makes this real rather than declarative). NEW oss/yah-base/crates/mesofact-bundle/tests/contract_matrix.rs: one row per known contract version, each taken through assemble -> publish -> materialize -> resolve -> FORK against a stand-in runtime advertising it, asserting the page comes out. The stand-in is a shell script, not a real mesofact serve, because this crate is the V8-less assembly half and linking the runtime would invert the dependency it exists to avoid - it exercises exactly the clauses that are this crate's to keep (manifest parse, app/ tree layout, argv shape) and does not pretend to cover the runtime's rendering.")
94//! @yah:handoff("DISCOVERED WORK, done in this pass and wider than the title. (1) NOTHING in oss/yah-base has ever run in CI - it is its own cargo workspace, consumed by the root only as a [patch.crates-io] path source, and cargo builds a patched path dep but never runs its tests. So R746-F1/F5's 54+ tests were invisible to the bar. New bundle-contract-matrix step in .yah/qed/check.toml closes that for this one package (scoped deliberately: making the matrix a gate, not quietly adopting the whole workspace). Verified live via yah qed pipelines. (2) Fixed an unresolved [`SERVE_BIN`] intra-doc link in runtime.rs (mod asset has no such name in scope). (3) Made mod contract public - W272 points at its doc as normative, so a runtime author outside this repo has to be able to read it.")
95//! @yah:handoff("THE SIDECAR EXEMPTION is narrow and has to be chosen. almanac-feed is versioned with yubaba and its interface is its own CLI, not the bundle-runtime contract, so kamaji resolves it with ContractRequirement::Unchecked. Spelled as an enum variant rather than Option::None precisely so it cannot be copy-pasted into a serve-runtime call site without someone noticing. Only a serve runtime is contract-checked.")
96//! @yah:gotcha("A WARM CACHE HIT SKIPS THE CONTRACT CHECK, deliberately. ensure_runtime_asset returns early when the binary is already at the cache path, before any manifest GET. Checking there would cost a network round-trip per deploy - exactly the round-trip the shared runtime tier exists to avoid, pinned by a_second_resolve_at_the_same_version_is_a_cache_hit - and would buy only the case of a LIVE version repointed to bytes advertising a different set, which publish_runtime_asset already documents as the thing not to do (publish a new version instead). Documented at the early-return site.")
97//! @yah:gotcha("STILL OPEN, and it is F5's to consume: the manifest field is ONE required version, not the compatible SET the W272 section 7 grammar describes (blake3 pin / semver over stock / semver over a namespaced custom build / capability constraint). That is fine today because F5 shipped pinned to exact digests under its own ordering escape hatch, so nothing depends on a range yet. What this ticket delivers is the thing such a range would range over - which was the stated prerequisite. Widening the field is a separate ticket and now has a matrix to be safe against.")
98//! @yah:verify("cargo test -p yah-mesofact-bundle --all-features --locked (run from the repo root, the exact argv the new CI step uses): 73 pass, 0 fail. 70 lib + 3 matrix. New lib tests cover the refusal naming both versions, a runtime advertising a set serving every version in it, the Unchecked sidecar path, a pre-F6 asset manifest defaulting to [1] and still serving, an empty advertised set refused at publish, the apply-gate survey, one-stale-triple-fails-all, unpublished-surveys-empty, and the digest-stability proof.")
99//! @yah:verify("cargo test -p kamaji-bin --features bundle-serving (from oss/kamaji): 240 pass, 0 fail, including the new end-to-end a_runtime_that_does_not_implement_the_bundles_contract_fails_the_deploy - the deploy reaches WorkloadState::Failed, the detail names both contract versions, and no runtime binary is left in the cache. cargo check -p kamaji-bin --features bundle-serving --all-targets: clean.")
100//! @yah:verify("cargo check -p yah --lib: exit 0, clean - covers the apply-time gate in deploy_mesofact_bundle and the new --contract flag on yah cloud bundle publish-runtime. The 14 warnings in that run are all pre-existing and in peers in-flight files (cli.rs, mesh.rs, mcp/tools.rs, rollout/apply.rs), none from this change. cargo doc -p yah-mesofact-bundle --all-features --no-deps: no warnings from any new doc; the four remaining unclosed-HTML-tag warnings are inside R746-F5s own annotation block and are not mine to edit.")
101//! @yah:verify("Verify #2 of the ticket, both halves: the matrix runs in CI as the bundle-contract-matrix step of .yah/qed/check.toml, which .github/workflows/ci.yml runs via `yah qed run check` on every dispatch (confirmed the step parses and is live with `yah qed pipelines --verbose`); and a new contract version cannot land without a row, enforced by every_known_contract_version_has_a_row, which fails in both directions - a version in IMPLEMENTED_CONTRACTS with no row, and a row naming a version nothing implements.")
102//! @yah:gotcha("oss/yubaba CANNOT be test-compiled right now, for a reason unrelated to this ticket: a peers uncommitted TransformRecipe.admission field (untracked oss/yah-base/crates/workload-spec/src/admission.rs + oss/qed/crates/velveteen-exec/src/admission.rs, the R555-F4 signed-transform-admission work) leaves crates/cloud/src/reconciler/lowering_golden.rs:48 at E0063. The two one-line requires_contract additions this ticket made to that workspace (reconciler/publish_beacon.rs, reconciler/bundle_store.rs test fixtures) DO compile - they would have surfaced in the same rustc pass and did not. Left alone per shared-tree: not my ticket, not my files.")
103//! @yah:verify("cargo test -p yah --lib -- serve_build:: cloud::bundle : 28 pass, 0 fail. Confirms the apply-gate insertion and the new ServeBinSource/manifest field did not disturb assemble_component_bundle*'s call sites (bundle_assembly_tests) or R746-F5's reuse resolution (serve_build::).")
104//! @yah:gotcha("NOT INSTALLED, deliberately. The CLI leg (the --contract flag and the apply gate in yah cloud apply) does not reach the operator shell until cargo xtask install runs, per app/yah/cli/CLAUDE.md. I did not run it: on this camp the working tree currently carries four other sessions uncommitted edits (app/yah/cli/src/mcp/tools.rs, cli.rs, crates/yah/policy-dsl, oss/qed velveteen-exec, the R555-F4 admission work), so an install right now would snapshot all of that into ~/.local/bin/yah and into the app bundle. R746-F1s own install already hit exactly this (its verify records a W298 SUSPECT RESULT from two peer edits landing mid-build). One line for the operator: install when the tree is quieter, or accept the peer WIP knowingly.")
105//! @yah:handoff("COMPLETE at contract version 1. Both ticket verify criteria met: a runtime that does not implement the contract a bundle requires is refused at apply time naming both versions, and the matrix runs in CI with a gate that blocks a new contract version landing without a row. Full detail in the handoff/verify/gotcha entries above.")
106
107use std::collections::BTreeMap;
108use std::fmt;
109
110use serde::{Deserialize, Deserializer, Serialize, Serializer};
111
112/// Emitting side: walk a built app into a bundle tree + manifest. Pure
113/// `std::fs` + blake3 + toml, so it stays in the default (types-only) feature
114/// set — `mesofact-build` re-exports it, and the V8-less yah CLI can assemble a
115/// bundle without linking the build toolchain (R599-T5).
116mod assemble;
117pub use assemble::{
118 assemble_bundle, assemble_bundle_from_files, assemble_self_bundle, assemble_self_bundle_with,
119 assemble_vanilla_bundle, collect_component_files, collect_dir, BundleFile, ServeBinSource,
120};
121
122/// Per-file serving metadata for a served static tree (MFT-R825-F1): the
123/// asset index (`ETag`, declared immutability, precompressed encodings) every
124/// assembly entry point writes, and that `mesofact serve` and yubaba's R2
125/// publish both read. Types-only plus brotli/gzip; no store.
126pub mod assets;
127
128#[cfg(feature = "store")]
129mod store;
130#[cfg(feature = "store")]
131pub use store::{fetch_manifest, materialize_bundle, publish_bundle, BundleCache, PublishReport};
132
133/// Node runtime-asset tier (R746-F1): binaries a vanilla bundle resolves by
134/// name instead of carrying — the shared `serve` runtime, and (R746-T3) the
135/// `almanac-feed` sidecar. [`RuntimeRef`] (the cache-key shape) is types-only;
136/// the fetch/publish half needs `store`.
137mod runtime;
138pub use runtime::{check_bin_name, FEED_BIN, RuntimeRef, SERVE_BIN};
139#[cfg(feature = "store")]
140pub use runtime::{
141 ensure_runtime_asset, publish_runtime_asset, published_runtime_assets, require_runtime_contract,
142 PublishedRuntime, RuntimeAsset, RuntimeAssetManifest, RUNTIME_ASSET_SCHEMA_VERSION,
143};
144
145/// By-name pointer to the most recently published bundle per workload
146/// (R746-F5) — what a reuse-aware assembly reads to find a candidate to pin
147/// by digest instead of building.
148#[cfg(feature = "store")]
149mod pointer;
150#[cfg(feature = "store")]
151pub use pointer::{current_pointer_key, read_current_digest, write_current_digest};
152
153/// The versioned bundle↔runtime contract (R746-F6): what a runtime must
154/// implement to serve a bundle, and the enforcement points that hold it to it.
155///
156/// Public because its doc comment is the *normative* statement of the contract
157/// — W272 §7 points here rather than restating it, and a runtime author outside
158/// this repo needs to be able to read it.
159pub mod contract;
160pub use contract::{
161 describe_contracts, ContractRequirement, ContractVersion, BUNDLE_CONTRACT_VERSION,
162 IMPLEMENTED_CONTRACTS,
163};
164
165/// Bundle-manifest schema version. Bumping this is a wire-format change: the
166/// materialize side rejects a manifest whose `schema_version` it does not know.
167///
168/// Narrower than [`BUNDLE_CONTRACT_VERSION`]: this versions the shape of
169/// `manifest.toml` only, which is clause 1 of four in the contract.
170pub const SCHEMA_VERSION: u32 = 1;
171
172/// Error surface for parsing / hashing a bundle manifest. The `store` feature
173/// adds I/O + object-store variants (see `store.rs`).
174#[derive(Debug, thiserror::Error)]
175pub enum BundleError {
176 /// `manifest.toml` failed to parse.
177 #[error("malformed bundle manifest: {0}")]
178 Manifest(String),
179
180 /// A manifest carried a `schema_version` this build doesn't understand.
181 #[error("unsupported bundle schema_version {found} (this build speaks {SCHEMA_VERSION})")]
182 SchemaVersion { found: u32 },
183
184 /// A `runtime = "..."` string was neither `self` nor `mesofact/<version>`.
185 #[error("unrecognized bundle runtime {0:?} (expected \"self\" or \"mesofact/<version>\")")]
186 Runtime(String),
187
188 /// Bytes hashed to a different digest than the manifest recorded. Carries
189 /// the offending path (or blob key) plus both hashes.
190 #[error("blake3 mismatch for {what}: manifest says {expected}, bytes hash to {actual}")]
191 HashMismatch {
192 what: String,
193 expected: String,
194 actual: String,
195 },
196
197 /// Filesystem or object-store failure. Constructed by the assemble path
198 /// (always available) and by the `store` feature's publish/materialize.
199 #[error("bundle io: {0}")]
200 Io(String),
201
202 /// A component's declared serving policy is unusable — a malformed
203 /// `workload.toml` or an `[build] immutable` pattern
204 /// [`assets::ImmutableGlobs::new`] refuses (MFT-R825-F1).
205 #[error("{0}")]
206 AssetPolicy(String),
207
208 /// A blob referenced by the manifest was absent from the store during
209 /// materialize (only constructed under `store`).
210 #[error("missing blob {key} for path {path:?} while materializing bundle")]
211 MissingBlob { key: String, path: String },
212
213 /// No runtime asset is published for the runtime × triple a vanilla bundle
214 /// named (R746-F1). Deliberately verbose: this is the one failure an
215 /// operator hits by declaring a `runtime_version` nothing has built yet, and
216 /// the fix is entirely in the version and the location, so both are in the
217 /// message rather than in a log line somewhere upstream.
218 #[error(
219 "no serve runtime asset published for {runtime} on {triple} \
220 (looked for {location}); a vanilla bundle resolves its runtime by name \
221 and will not fall back to any other binary on this node"
222 )]
223 RuntimeAssetMissing {
224 runtime: String,
225 triple: String,
226 location: String,
227 },
228
229 /// A runtime does not implement the contract version a bundle requires
230 /// (R746-F6). Both versions are in the message because the fix is a choice
231 /// between them — publish a newer runtime, or rebuild the bundle against an
232 /// older contract — and an operator cannot make it holding only one.
233 #[error(
234 "runtime {runtime} on {triple} implements bundle contract version(s) {advertised} \
235 but this bundle requires contract version {required} — refusing to serve it \
236 with a runtime that does not implement the interface it was built against"
237 )]
238 ContractUnsatisfied {
239 runtime: String,
240 triple: String,
241 required: ContractVersion,
242 advertised: String,
243 },
244}
245
246/// A BLAKE3 content hash: exactly 64 lowercase ASCII hex digits.
247///
248/// The content-address key for every file in a bundle *and* for the bundle's
249/// own manifest digest. Deserialization rejects anything that isn't 64 hex
250/// chars, and normalizes to lowercase so `blob_key` is stable regardless of how
251/// a hand-authored manifest cased its hashes.
252#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
253pub struct BundleHash(String);
254
255impl BundleHash {
256 /// Wrap a hex string, validating shape (64 hex chars) and lowercasing.
257 pub fn parse(s: impl Into<String>) -> Result<Self, BundleError> {
258 let s = s.into();
259 if s.len() != 64 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
260 return Err(BundleError::Manifest(format!(
261 "blake3 hash must be exactly 64 hex digits, got {s:?}"
262 )));
263 }
264 Ok(BundleHash(s.to_ascii_lowercase()))
265 }
266
267 /// The BLAKE3 hash of `bytes`, as a [`BundleHash`].
268 pub fn of(bytes: &[u8]) -> Self {
269 BundleHash(blake3::hash(bytes).to_hex().to_string())
270 }
271
272 /// Borrow the hex string.
273 pub fn as_str(&self) -> &str {
274 &self.0
275 }
276}
277
278impl fmt::Display for BundleHash {
279 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
280 f.write_str(&self.0)
281 }
282}
283
284impl Serialize for BundleHash {
285 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
286 s.serialize_str(&self.0)
287 }
288}
289
290impl<'de> Deserialize<'de> for BundleHash {
291 fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
292 let s = String::deserialize(de)?;
293 BundleHash::parse(s).map_err(serde::de::Error::custom)
294 }
295}
296
297/// Which runtime serves a bundle — the `runtime = "..."` manifest field.
298///
299/// Serialized as a single string: `"self"` / `"self/<built_against>"` for a
300/// bundle that ships its own `bins/<triple>/serve`, or `"mesofact/<version>"`
301/// for a vanilla bundle that resolves the stock `mesofact serve` runtime from
302/// the node's runtime cache.
303#[derive(Debug, Clone, PartialEq, Eq)]
304pub enum BundleRuntime {
305 /// Vanilla: resolve the stock `mesofact serve` runtime asset of this
306 /// version from the node cache. No binaries in the bundle.
307 Mesofact { version: String },
308
309 /// Custom: the bundle carries `bins/<triple>/serve` and is served by its
310 /// own binary. No node runtime asset is resolved.
311 SelfContained {
312 /// R746-F5: the reuse contract. A content fingerprint
313 /// (`yah_qed::build_context::source_context_fingerprint`) of the
314 /// source tree every `bins/<triple>/serve` in this bundle was
315 /// compiled from — `None` when the assembly didn't (or couldn't)
316 /// record one, or when the triples in this bundle came from more than
317 /// one fingerprint.
318 ///
319 /// A later assembly may reuse a *previous* bundle's serve binary by
320 /// digest (skipping the build) only when its own fingerprint equals
321 /// the one recorded here — exact match, not a semver range (R746-F6
322 /// supersedes this with a versioned compatibility expression; until
323 /// that lands, a bundle with no recorded fingerprint can still be
324 /// materialized and served, it just cannot be a reuse SOURCE).
325 built_against: Option<String>,
326 },
327}
328
329impl BundleRuntime {
330 /// The wire string form (`"self"`, `"self/<built_against>"`, or
331 /// `"mesofact/<version>"`).
332 pub fn as_wire(&self) -> String {
333 match self {
334 BundleRuntime::SelfContained {
335 built_against: None,
336 } => "self".to_string(),
337 BundleRuntime::SelfContained {
338 built_against: Some(fp),
339 } => format!("self/{fp}"),
340 BundleRuntime::Mesofact { version } => format!("mesofact/{version}"),
341 }
342 }
343
344 /// Parse the wire string form.
345 pub fn parse(s: &str) -> Result<Self, BundleError> {
346 if s == "self" {
347 return Ok(BundleRuntime::SelfContained {
348 built_against: None,
349 });
350 }
351 if let Some(fp) = s.strip_prefix("self/") {
352 if !fp.is_empty() {
353 return Ok(BundleRuntime::SelfContained {
354 built_against: Some(fp.to_string()),
355 });
356 }
357 }
358 if let Some(version) = s.strip_prefix("mesofact/") {
359 if !version.is_empty() {
360 return Ok(BundleRuntime::Mesofact {
361 version: version.to_string(),
362 });
363 }
364 }
365 Err(BundleError::Runtime(s.to_string()))
366 }
367
368 /// Whether the bundle ships its own serve binaries (`runtime = "self"` /
369 /// `"self/<built_against>"`).
370 pub fn is_self_contained(&self) -> bool {
371 matches!(self, BundleRuntime::SelfContained { .. })
372 }
373
374 /// A self-contained runtime with no recorded contract — the shape every
375 /// call site used before R746-F5, and still the right one for a one-shot
376 /// local assembly (`yah cloud bundle build`) that isn't going through the
377 /// fingerprint-tracked sync path.
378 pub fn self_contained() -> Self {
379 BundleRuntime::SelfContained {
380 built_against: None,
381 }
382 }
383}
384
385impl Serialize for BundleRuntime {
386 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
387 s.serialize_str(&self.as_wire())
388 }
389}
390
391impl<'de> Deserialize<'de> for BundleRuntime {
392 fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
393 let s = String::deserialize(de)?;
394 BundleRuntime::parse(&s).map_err(serde::de::Error::custom)
395 }
396}
397
398/// The parsed `manifest.toml` at a bundle's root — the bundle's identity plus
399/// the per-file content map that makes it content-addressed.
400#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
401pub struct BundleManifest {
402 /// Wire-format version. Always [`SCHEMA_VERSION`] today.
403 pub schema_version: u32,
404
405 /// Human-readable bundle name, e.g. `"yah-marketing"`. Not part of the
406 /// content-address surface beyond the digest — two bundles with the same
407 /// bytes but different names have different digests.
408 pub name: String,
409
410 /// Runtime selection (`"self"` / `"mesofact/<ver>"`).
411 pub runtime: BundleRuntime,
412
413 /// The bundle↔runtime contract version this bundle was built against
414 /// (R746-F6). Whatever serves it must advertise this version; see
415 /// [`contract`] for what the four clauses of version 1 are.
416 ///
417 /// Defaulted rather than required so a bundle published before the field
418 /// existed still parses — and parses as exactly what it is, since every
419 /// such bundle was assembled against contract 1. That default is also what
420 /// keeps their digests intact; see [`BundleManifest::digest`].
421 ///
422 /// Declared before `content` because TOML puts a table's keys after it —
423 /// a scalar field serialized after `[content]` would land inside the table.
424 #[serde(default = "default_requires_contract")]
425 pub requires_contract: ContractVersion,
426
427 /// Every file the bundle carries, keyed by its path *within the bundle*
428 /// (e.g. `"app/dist/index.html"`, `"bins/x86_64-unknown-linux-musl/serve"`)
429 /// mapped to its BLAKE3 content hash. Sorted (BTreeMap) so the manifest
430 /// serialization — and therefore the digest — is deterministic.
431 #[serde(default)]
432 pub content: BTreeMap<String, BundleHash>,
433}
434
435/// Contract version assumed for a manifest that predates the field. Every
436/// bundle published before R746-F6 was assembled against contract 1.
437fn default_requires_contract() -> ContractVersion {
438 1
439}
440
441impl BundleManifest {
442 /// Parse a `manifest.toml` string, rejecting an unknown schema version.
443 pub fn from_toml_str(s: &str) -> Result<Self, BundleError> {
444 let manifest: BundleManifest =
445 toml::from_str(s).map_err(|e| BundleError::Manifest(e.to_string()))?;
446 if manifest.schema_version != SCHEMA_VERSION {
447 return Err(BundleError::SchemaVersion {
448 found: manifest.schema_version,
449 });
450 }
451 Ok(manifest)
452 }
453
454 /// Serialize back to `manifest.toml` form.
455 pub fn to_toml_string(&self) -> Result<String, BundleError> {
456 toml::to_string_pretty(self).map_err(|e| BundleError::Manifest(e.to_string()))
457 }
458
459 /// The bundle's content-address: a BLAKE3 digest over the manifest's
460 /// identity + content map.
461 ///
462 /// Computed over a canonical, order-stable byte encoding (not the TOML text,
463 /// which whitespace/comments could perturb): `schema_version`, `name`, the
464 /// runtime wire string, then every `(path, hash)` pair in sorted path order.
465 /// Any change to any file's bytes (its hash), the name, the runtime, or the
466 /// file set flips the digest — that immutability is what makes rollback a
467 /// pointer flip and eviction a no-invalidation affair (W272 §1).
468 ///
469 /// **`requires_contract` is fed only when it is not 1** (R746-F6). It is
470 /// identity-affecting — two bundles differing only in the interface they
471 /// demand are different bundles — but feeding it unconditionally would
472 /// re-hash every *already published* manifest to a new value, and
473 /// [`fetch_manifest`] verifies a fetched manifest against the digest it was
474 /// stored under. Every such manifest defaults to 1, so skipping the default
475 /// leaves their encoding byte-identical while any bundle requiring contract
476 /// ≥ 2 still gets a distinct digest. The skip is not a place-holder to tidy
477 /// up later: the manifests it protects are immutable and permanent.
478 pub fn digest(&self) -> BundleHash {
479 let mut hasher = blake3::Hasher::new();
480 // Domain-separate + length-prefix every field so no two distinct
481 // manifests can collide by field-boundary ambiguity.
482 let mut feed = |bytes: &[u8]| {
483 hasher.update(&(bytes.len() as u64).to_le_bytes());
484 hasher.update(bytes);
485 };
486 feed(b"yah-mesofact-bundle/v1");
487 feed(&self.schema_version.to_le_bytes());
488 feed(self.name.as_bytes());
489 feed(self.runtime.as_wire().as_bytes());
490 if self.requires_contract != 1 {
491 feed(b"requires_contract");
492 feed(&self.requires_contract.to_le_bytes());
493 }
494 feed(&(self.content.len() as u64).to_le_bytes());
495 // BTreeMap iterates in sorted key order — deterministic.
496 for (path, hash) in &self.content {
497 feed(path.as_bytes());
498 feed(hash.as_str().as_bytes());
499 }
500 BundleHash(hasher.finalize().to_hex().to_string())
501 }
502}
503
504/// Object-store key for a content blob: `blobs/<blake3>`. Every file across
505/// every bundle dedupes on this key — two bundles sharing a byte-identical file
506/// share the one blob.
507pub fn blob_key(hash: &BundleHash) -> String {
508 format!("blobs/{}", hash.as_str())
509}
510
511/// Object-store key for a bundle manifest: `manifests/<digest>`. Immutable —
512/// the digest is derived from the manifest, so re-publishing an identical
513/// bundle writes the same key with the same bytes.
514pub fn manifest_key(digest: &BundleHash) -> String {
515 format!("manifests/{}", digest.as_str())
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521
522 fn hash_of(s: &str) -> BundleHash {
523 BundleHash::of(s.as_bytes())
524 }
525
526 fn sample() -> BundleManifest {
527 let mut content = BTreeMap::new();
528 content.insert("app/index.html".to_string(), hash_of("<html>"));
529 content.insert("app/mesofact.routes.ts".to_string(), hash_of("routes"));
530 BundleManifest {
531 schema_version: SCHEMA_VERSION,
532 name: "yah-marketing".to_string(),
533 runtime: BundleRuntime::Mesofact {
534 version: "0.8.18".to_string(),
535 },
536 requires_contract: BUNDLE_CONTRACT_VERSION,
537 content,
538 }
539 }
540
541 #[test]
542 fn hash_rejects_non_hex_and_wrong_length() {
543 assert!(BundleHash::parse("zz").is_err());
544 assert!(BundleHash::parse("g".repeat(64)).is_err());
545 assert!(BundleHash::parse("a".repeat(63)).is_err());
546 assert!(BundleHash::parse("A".repeat(64)).is_ok()); // hex, gets lowercased
547 assert_eq!(BundleHash::parse("A".repeat(64)).unwrap().as_str(), "a".repeat(64));
548 }
549
550 #[test]
551 fn runtime_round_trips_both_forms() {
552 assert_eq!(BundleRuntime::parse("self").unwrap(), BundleRuntime::self_contained());
553 assert_eq!(
554 BundleRuntime::parse("mesofact/0.8.18").unwrap(),
555 BundleRuntime::Mesofact { version: "0.8.18".into() }
556 );
557 assert_eq!(BundleRuntime::self_contained().as_wire(), "self");
558 assert_eq!(
559 BundleRuntime::Mesofact { version: "1.2.3".into() }.as_wire(),
560 "mesofact/1.2.3"
561 );
562 // Malformed runtimes reject.
563 assert!(BundleRuntime::parse("mesofact/").is_err());
564 assert!(BundleRuntime::parse("caddy").is_err());
565 }
566
567 /// R746-F5: a recorded fingerprint round-trips through `"self/<fp>"`, and
568 /// a bundle carrying one is still self-contained.
569 #[test]
570 fn runtime_round_trips_the_recorded_contract() {
571 let fp = "a".repeat(64);
572 let rt = BundleRuntime::SelfContained {
573 built_against: Some(fp.clone()),
574 };
575 assert_eq!(rt.as_wire(), format!("self/{fp}"));
576 assert_eq!(BundleRuntime::parse(&rt.as_wire()).unwrap(), rt);
577 assert!(rt.is_self_contained());
578 assert!(BundleRuntime::parse("self/").is_err());
579 }
580
581 #[test]
582 fn manifest_round_trips_through_toml() {
583 let m = sample();
584 let text = m.to_toml_string().unwrap();
585 let back = BundleManifest::from_toml_str(&text).unwrap();
586 assert_eq!(m, back);
587 }
588
589 #[test]
590 fn manifest_parses_the_doc_shape() {
591 let h = "a".repeat(64);
592 let text = format!(
593 r#"
594schema_version = 1
595name = "yah-marketing"
596runtime = "mesofact/0.8.18"
597
598[content]
599"app/mesofact.routes.ts" = "{h}"
600"#
601 );
602 let m = BundleManifest::from_toml_str(&text).unwrap();
603 assert_eq!(m.name, "yah-marketing");
604 assert!(matches!(m.runtime, BundleRuntime::Mesofact { .. }));
605 assert_eq!(m.content.len(), 1);
606 // No `requires_contract` in the doc shape above — a manifest published
607 // before R746-F6 parses as the contract-1 bundle it is.
608 assert_eq!(m.requires_contract, 1);
609 }
610
611 /// R746-F6, and the reason `digest` skips the default: a manifest already
612 /// in the store must keep hashing to the key it was stored under, or
613 /// `fetch_manifest`'s digest check rejects it and the site stops serving —
614 /// for every bundle published before this field existed, all at once.
615 ///
616 /// The expected encoding is spelled out here rather than pinned as a hex
617 /// literal on purpose. A literal would catch the regression but tell a
618 /// reader nothing about *what* the bytes are supposed to be, and nobody can
619 /// audit a blake3 by eye; this is the pre-F6 field sequence written out
620 /// long-hand, so a diff that starts feeding `requires_contract`
621 /// unconditionally fails against something legible.
622 #[test]
623 fn adding_requires_contract_did_not_move_any_published_digest() {
624 let h = "a".repeat(64);
625 let pre_f6 = format!(
626 r#"
627schema_version = 1
628name = "yah-marketing"
629runtime = "mesofact/0.8.18"
630
631[content]
632"app/mesofact.routes.ts" = "{h}"
633"#
634 );
635 let parsed = BundleManifest::from_toml_str(&pre_f6).unwrap();
636 assert_eq!(parsed.requires_contract, 1);
637
638 // The exact encoding pre-F6 `digest()` produced: domain tag,
639 // schema_version, name, runtime wire string, content count, then each
640 // (path, hash) pair — every field length-prefixed, and NO contract
641 // field anywhere in it.
642 let mut hasher = blake3::Hasher::new();
643 let mut feed = |bytes: &[u8]| {
644 hasher.update(&(bytes.len() as u64).to_le_bytes());
645 hasher.update(bytes);
646 };
647 feed(b"yah-mesofact-bundle/v1");
648 feed(&1u32.to_le_bytes());
649 feed(b"yah-marketing");
650 feed(b"mesofact/0.8.18");
651 feed(&1u64.to_le_bytes());
652 feed(b"app/mesofact.routes.ts");
653 feed(h.as_bytes());
654 let pre_f6_digest = hasher.finalize().to_hex().to_string();
655
656 assert_eq!(parsed.digest().as_str(), pre_f6_digest);
657 }
658
659 /// Requiring a *different* contract is a different bundle: two otherwise
660 /// identical trees that demand different runtime interfaces must not
661 /// collide on one digest.
662 #[test]
663 fn a_higher_contract_requirement_flips_the_digest() {
664 let base = sample();
665 assert_ne!(
666 BundleManifest {
667 requires_contract: 2,
668 ..sample()
669 }
670 .digest(),
671 base.digest()
672 );
673 }
674
675 /// The field survives the TOML round trip, and lands *before* `[content]` —
676 /// serialized after the table, TOML would read it as a content entry.
677 #[test]
678 fn requires_contract_round_trips_ahead_of_the_content_table() {
679 let m = BundleManifest {
680 requires_contract: 7,
681 ..sample()
682 };
683 let text = m.to_toml_string().unwrap();
684 assert_eq!(BundleManifest::from_toml_str(&text).unwrap(), m);
685 let contract_at = text.find("requires_contract").expect("field is emitted");
686 let content_at = text.find("[content]").expect("content table is emitted");
687 assert!(
688 contract_at < content_at,
689 "requires_contract must precede [content], got:\n{text}"
690 );
691 }
692
693 #[test]
694 fn unknown_schema_version_is_rejected() {
695 let text = r#"schema_version = 999
696name = "x"
697runtime = "self"
698"#;
699 assert!(matches!(
700 BundleManifest::from_toml_str(text),
701 Err(BundleError::SchemaVersion { found: 999 })
702 ));
703 }
704
705 #[test]
706 fn digest_is_deterministic_and_order_independent() {
707 // Insertion order differs but BTreeMap normalizes — same digest.
708 let mut a = BTreeMap::new();
709 a.insert("z".to_string(), hash_of("1"));
710 a.insert("a".to_string(), hash_of("2"));
711 let mut b = BTreeMap::new();
712 b.insert("a".to_string(), hash_of("2"));
713 b.insert("z".to_string(), hash_of("1"));
714 let ma = BundleManifest { content: a, ..sample() };
715 let mb = BundleManifest { content: b, ..sample() };
716 assert_eq!(ma.digest(), mb.digest());
717 }
718
719 #[test]
720 fn digest_changes_when_any_field_changes() {
721 let base = sample();
722 let d0 = base.digest();
723
724 // Different file bytes (hash) → different digest.
725 let mut c = base.content.clone();
726 c.insert("app/index.html".to_string(), hash_of("<html>changed"));
727 assert_ne!(BundleManifest { content: c, ..sample() }.digest(), d0);
728
729 // Different name → different digest.
730 assert_ne!(BundleManifest { name: "other".into(), ..sample() }.digest(), d0);
731
732 // Different runtime → different digest.
733 assert_ne!(
734 BundleManifest { runtime: BundleRuntime::self_contained(), ..sample() }.digest(),
735 d0
736 );
737
738 // Extra file → different digest.
739 let mut c2 = base.content.clone();
740 c2.insert("app/extra.js".to_string(), hash_of("x"));
741 assert_ne!(BundleManifest { content: c2, ..sample() }.digest(), d0);
742 }
743
744 #[test]
745 fn key_layout_is_stable() {
746 let h = hash_of("blob");
747 assert_eq!(blob_key(&h), format!("blobs/{h}"));
748 assert_eq!(manifest_key(&h), format!("manifests/{h}"));
749 }
750}