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#[cfg(feature = "store")]
123mod store;
124#[cfg(feature = "store")]
125pub use store::{fetch_manifest, materialize_bundle, publish_bundle, BundleCache, PublishReport};
126
127/// Node runtime-asset tier (R746-F1): binaries a vanilla bundle resolves by
128/// name instead of carrying — the shared `serve` runtime, and (R746-T3) the
129/// `almanac-feed` sidecar. [`RuntimeRef`] (the cache-key shape) is types-only;
130/// the fetch/publish half needs `store`.
131mod runtime;
132pub use runtime::{check_bin_name, FEED_BIN, RuntimeRef, SERVE_BIN};
133#[cfg(feature = "store")]
134pub use runtime::{
135 ensure_runtime_asset, publish_runtime_asset, published_runtime_assets, require_runtime_contract,
136 PublishedRuntime, RuntimeAsset, RuntimeAssetManifest, RUNTIME_ASSET_SCHEMA_VERSION,
137};
138
139/// By-name pointer to the most recently published bundle per workload
140/// (R746-F5) — what a reuse-aware assembly reads to find a candidate to pin
141/// by digest instead of building.
142#[cfg(feature = "store")]
143mod pointer;
144#[cfg(feature = "store")]
145pub use pointer::{current_pointer_key, read_current_digest, write_current_digest};
146
147/// The versioned bundle↔runtime contract (R746-F6): what a runtime must
148/// implement to serve a bundle, and the enforcement points that hold it to it.
149///
150/// Public because its doc comment is the *normative* statement of the contract
151/// — W272 §7 points here rather than restating it, and a runtime author outside
152/// this repo needs to be able to read it.
153pub mod contract;
154pub use contract::{
155 describe_contracts, ContractRequirement, ContractVersion, BUNDLE_CONTRACT_VERSION,
156 IMPLEMENTED_CONTRACTS,
157};
158
159/// Bundle-manifest schema version. Bumping this is a wire-format change: the
160/// materialize side rejects a manifest whose `schema_version` it does not know.
161///
162/// Narrower than [`BUNDLE_CONTRACT_VERSION`]: this versions the shape of
163/// `manifest.toml` only, which is clause 1 of four in the contract.
164pub const SCHEMA_VERSION: u32 = 1;
165
166/// Error surface for parsing / hashing a bundle manifest. The `store` feature
167/// adds I/O + object-store variants (see `store.rs`).
168#[derive(Debug, thiserror::Error)]
169pub enum BundleError {
170 /// `manifest.toml` failed to parse.
171 #[error("malformed bundle manifest: {0}")]
172 Manifest(String),
173
174 /// A manifest carried a `schema_version` this build doesn't understand.
175 #[error("unsupported bundle schema_version {found} (this build speaks {SCHEMA_VERSION})")]
176 SchemaVersion { found: u32 },
177
178 /// A `runtime = "..."` string was neither `self` nor `mesofact/<version>`.
179 #[error("unrecognized bundle runtime {0:?} (expected \"self\" or \"mesofact/<version>\")")]
180 Runtime(String),
181
182 /// Bytes hashed to a different digest than the manifest recorded. Carries
183 /// the offending path (or blob key) plus both hashes.
184 #[error("blake3 mismatch for {what}: manifest says {expected}, bytes hash to {actual}")]
185 HashMismatch {
186 what: String,
187 expected: String,
188 actual: String,
189 },
190
191 /// Filesystem or object-store failure. Constructed by the assemble path
192 /// (always available) and by the `store` feature's publish/materialize.
193 #[error("bundle io: {0}")]
194 Io(String),
195
196 /// A blob referenced by the manifest was absent from the store during
197 /// materialize (only constructed under `store`).
198 #[error("missing blob {key} for path {path:?} while materializing bundle")]
199 MissingBlob { key: String, path: String },
200
201 /// No runtime asset is published for the runtime × triple a vanilla bundle
202 /// named (R746-F1). Deliberately verbose: this is the one failure an
203 /// operator hits by declaring a `runtime_version` nothing has built yet, and
204 /// the fix is entirely in the version and the location, so both are in the
205 /// message rather than in a log line somewhere upstream.
206 #[error(
207 "no serve runtime asset published for {runtime} on {triple} \
208 (looked for {location}); a vanilla bundle resolves its runtime by name \
209 and will not fall back to any other binary on this node"
210 )]
211 RuntimeAssetMissing {
212 runtime: String,
213 triple: String,
214 location: String,
215 },
216
217 /// A runtime does not implement the contract version a bundle requires
218 /// (R746-F6). Both versions are in the message because the fix is a choice
219 /// between them — publish a newer runtime, or rebuild the bundle against an
220 /// older contract — and an operator cannot make it holding only one.
221 #[error(
222 "runtime {runtime} on {triple} implements bundle contract version(s) {advertised} \
223 but this bundle requires contract version {required} — refusing to serve it \
224 with a runtime that does not implement the interface it was built against"
225 )]
226 ContractUnsatisfied {
227 runtime: String,
228 triple: String,
229 required: ContractVersion,
230 advertised: String,
231 },
232}
233
234/// A BLAKE3 content hash: exactly 64 lowercase ASCII hex digits.
235///
236/// The content-address key for every file in a bundle *and* for the bundle's
237/// own manifest digest. Deserialization rejects anything that isn't 64 hex
238/// chars, and normalizes to lowercase so `blob_key` is stable regardless of how
239/// a hand-authored manifest cased its hashes.
240#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
241pub struct BundleHash(String);
242
243impl BundleHash {
244 /// Wrap a hex string, validating shape (64 hex chars) and lowercasing.
245 pub fn parse(s: impl Into<String>) -> Result<Self, BundleError> {
246 let s = s.into();
247 if s.len() != 64 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
248 return Err(BundleError::Manifest(format!(
249 "blake3 hash must be exactly 64 hex digits, got {s:?}"
250 )));
251 }
252 Ok(BundleHash(s.to_ascii_lowercase()))
253 }
254
255 /// The BLAKE3 hash of `bytes`, as a [`BundleHash`].
256 pub fn of(bytes: &[u8]) -> Self {
257 BundleHash(blake3::hash(bytes).to_hex().to_string())
258 }
259
260 /// Borrow the hex string.
261 pub fn as_str(&self) -> &str {
262 &self.0
263 }
264}
265
266impl fmt::Display for BundleHash {
267 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
268 f.write_str(&self.0)
269 }
270}
271
272impl Serialize for BundleHash {
273 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
274 s.serialize_str(&self.0)
275 }
276}
277
278impl<'de> Deserialize<'de> for BundleHash {
279 fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
280 let s = String::deserialize(de)?;
281 BundleHash::parse(s).map_err(serde::de::Error::custom)
282 }
283}
284
285/// Which runtime serves a bundle — the `runtime = "..."` manifest field.
286///
287/// Serialized as a single string: `"self"` / `"self/<built_against>"` for a
288/// bundle that ships its own `bins/<triple>/serve`, or `"mesofact/<version>"`
289/// for a vanilla bundle that resolves the stock `mesofact serve` runtime from
290/// the node's runtime cache.
291#[derive(Debug, Clone, PartialEq, Eq)]
292pub enum BundleRuntime {
293 /// Vanilla: resolve the stock `mesofact serve` runtime asset of this
294 /// version from the node cache. No binaries in the bundle.
295 Mesofact { version: String },
296
297 /// Custom: the bundle carries `bins/<triple>/serve` and is served by its
298 /// own binary. No node runtime asset is resolved.
299 SelfContained {
300 /// R746-F5: the reuse contract. A content fingerprint
301 /// (`yah_qed::build_context::source_context_fingerprint`) of the
302 /// source tree every `bins/<triple>/serve` in this bundle was
303 /// compiled from — `None` when the assembly didn't (or couldn't)
304 /// record one, or when the triples in this bundle came from more than
305 /// one fingerprint.
306 ///
307 /// A later assembly may reuse a *previous* bundle's serve binary by
308 /// digest (skipping the build) only when its own fingerprint equals
309 /// the one recorded here — exact match, not a semver range (R746-F6
310 /// supersedes this with a versioned compatibility expression; until
311 /// that lands, a bundle with no recorded fingerprint can still be
312 /// materialized and served, it just cannot be a reuse SOURCE).
313 built_against: Option<String>,
314 },
315}
316
317impl BundleRuntime {
318 /// The wire string form (`"self"`, `"self/<built_against>"`, or
319 /// `"mesofact/<version>"`).
320 pub fn as_wire(&self) -> String {
321 match self {
322 BundleRuntime::SelfContained {
323 built_against: None,
324 } => "self".to_string(),
325 BundleRuntime::SelfContained {
326 built_against: Some(fp),
327 } => format!("self/{fp}"),
328 BundleRuntime::Mesofact { version } => format!("mesofact/{version}"),
329 }
330 }
331
332 /// Parse the wire string form.
333 pub fn parse(s: &str) -> Result<Self, BundleError> {
334 if s == "self" {
335 return Ok(BundleRuntime::SelfContained {
336 built_against: None,
337 });
338 }
339 if let Some(fp) = s.strip_prefix("self/") {
340 if !fp.is_empty() {
341 return Ok(BundleRuntime::SelfContained {
342 built_against: Some(fp.to_string()),
343 });
344 }
345 }
346 if let Some(version) = s.strip_prefix("mesofact/") {
347 if !version.is_empty() {
348 return Ok(BundleRuntime::Mesofact {
349 version: version.to_string(),
350 });
351 }
352 }
353 Err(BundleError::Runtime(s.to_string()))
354 }
355
356 /// Whether the bundle ships its own serve binaries (`runtime = "self"` /
357 /// `"self/<built_against>"`).
358 pub fn is_self_contained(&self) -> bool {
359 matches!(self, BundleRuntime::SelfContained { .. })
360 }
361
362 /// A self-contained runtime with no recorded contract — the shape every
363 /// call site used before R746-F5, and still the right one for a one-shot
364 /// local assembly (`yah cloud bundle build`) that isn't going through the
365 /// fingerprint-tracked sync path.
366 pub fn self_contained() -> Self {
367 BundleRuntime::SelfContained {
368 built_against: None,
369 }
370 }
371}
372
373impl Serialize for BundleRuntime {
374 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
375 s.serialize_str(&self.as_wire())
376 }
377}
378
379impl<'de> Deserialize<'de> for BundleRuntime {
380 fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
381 let s = String::deserialize(de)?;
382 BundleRuntime::parse(&s).map_err(serde::de::Error::custom)
383 }
384}
385
386/// The parsed `manifest.toml` at a bundle's root — the bundle's identity plus
387/// the per-file content map that makes it content-addressed.
388#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
389pub struct BundleManifest {
390 /// Wire-format version. Always [`SCHEMA_VERSION`] today.
391 pub schema_version: u32,
392
393 /// Human-readable bundle name, e.g. `"yah-marketing"`. Not part of the
394 /// content-address surface beyond the digest — two bundles with the same
395 /// bytes but different names have different digests.
396 pub name: String,
397
398 /// Runtime selection (`"self"` / `"mesofact/<ver>"`).
399 pub runtime: BundleRuntime,
400
401 /// The bundle↔runtime contract version this bundle was built against
402 /// (R746-F6). Whatever serves it must advertise this version; see
403 /// [`contract`] for what the four clauses of version 1 are.
404 ///
405 /// Defaulted rather than required so a bundle published before the field
406 /// existed still parses — and parses as exactly what it is, since every
407 /// such bundle was assembled against contract 1. That default is also what
408 /// keeps their digests intact; see [`BundleManifest::digest`].
409 ///
410 /// Declared before `content` because TOML puts a table's keys after it —
411 /// a scalar field serialized after `[content]` would land inside the table.
412 #[serde(default = "default_requires_contract")]
413 pub requires_contract: ContractVersion,
414
415 /// Every file the bundle carries, keyed by its path *within the bundle*
416 /// (e.g. `"app/dist/index.html"`, `"bins/x86_64-unknown-linux-musl/serve"`)
417 /// mapped to its BLAKE3 content hash. Sorted (BTreeMap) so the manifest
418 /// serialization — and therefore the digest — is deterministic.
419 #[serde(default)]
420 pub content: BTreeMap<String, BundleHash>,
421}
422
423/// Contract version assumed for a manifest that predates the field. Every
424/// bundle published before R746-F6 was assembled against contract 1.
425fn default_requires_contract() -> ContractVersion {
426 1
427}
428
429impl BundleManifest {
430 /// Parse a `manifest.toml` string, rejecting an unknown schema version.
431 pub fn from_toml_str(s: &str) -> Result<Self, BundleError> {
432 let manifest: BundleManifest =
433 toml::from_str(s).map_err(|e| BundleError::Manifest(e.to_string()))?;
434 if manifest.schema_version != SCHEMA_VERSION {
435 return Err(BundleError::SchemaVersion {
436 found: manifest.schema_version,
437 });
438 }
439 Ok(manifest)
440 }
441
442 /// Serialize back to `manifest.toml` form.
443 pub fn to_toml_string(&self) -> Result<String, BundleError> {
444 toml::to_string_pretty(self).map_err(|e| BundleError::Manifest(e.to_string()))
445 }
446
447 /// The bundle's content-address: a BLAKE3 digest over the manifest's
448 /// identity + content map.
449 ///
450 /// Computed over a canonical, order-stable byte encoding (not the TOML text,
451 /// which whitespace/comments could perturb): `schema_version`, `name`, the
452 /// runtime wire string, then every `(path, hash)` pair in sorted path order.
453 /// Any change to any file's bytes (its hash), the name, the runtime, or the
454 /// file set flips the digest — that immutability is what makes rollback a
455 /// pointer flip and eviction a no-invalidation affair (W272 §1).
456 ///
457 /// **`requires_contract` is fed only when it is not 1** (R746-F6). It is
458 /// identity-affecting — two bundles differing only in the interface they
459 /// demand are different bundles — but feeding it unconditionally would
460 /// re-hash every *already published* manifest to a new value, and
461 /// [`fetch_manifest`] verifies a fetched manifest against the digest it was
462 /// stored under. Every such manifest defaults to 1, so skipping the default
463 /// leaves their encoding byte-identical while any bundle requiring contract
464 /// ≥ 2 still gets a distinct digest. The skip is not a place-holder to tidy
465 /// up later: the manifests it protects are immutable and permanent.
466 pub fn digest(&self) -> BundleHash {
467 let mut hasher = blake3::Hasher::new();
468 // Domain-separate + length-prefix every field so no two distinct
469 // manifests can collide by field-boundary ambiguity.
470 let mut feed = |bytes: &[u8]| {
471 hasher.update(&(bytes.len() as u64).to_le_bytes());
472 hasher.update(bytes);
473 };
474 feed(b"yah-mesofact-bundle/v1");
475 feed(&self.schema_version.to_le_bytes());
476 feed(self.name.as_bytes());
477 feed(self.runtime.as_wire().as_bytes());
478 if self.requires_contract != 1 {
479 feed(b"requires_contract");
480 feed(&self.requires_contract.to_le_bytes());
481 }
482 feed(&(self.content.len() as u64).to_le_bytes());
483 // BTreeMap iterates in sorted key order — deterministic.
484 for (path, hash) in &self.content {
485 feed(path.as_bytes());
486 feed(hash.as_str().as_bytes());
487 }
488 BundleHash(hasher.finalize().to_hex().to_string())
489 }
490}
491
492/// Object-store key for a content blob: `blobs/<blake3>`. Every file across
493/// every bundle dedupes on this key — two bundles sharing a byte-identical file
494/// share the one blob.
495pub fn blob_key(hash: &BundleHash) -> String {
496 format!("blobs/{}", hash.as_str())
497}
498
499/// Object-store key for a bundle manifest: `manifests/<digest>`. Immutable —
500/// the digest is derived from the manifest, so re-publishing an identical
501/// bundle writes the same key with the same bytes.
502pub fn manifest_key(digest: &BundleHash) -> String {
503 format!("manifests/{}", digest.as_str())
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509
510 fn hash_of(s: &str) -> BundleHash {
511 BundleHash::of(s.as_bytes())
512 }
513
514 fn sample() -> BundleManifest {
515 let mut content = BTreeMap::new();
516 content.insert("app/index.html".to_string(), hash_of("<html>"));
517 content.insert("app/mesofact.routes.ts".to_string(), hash_of("routes"));
518 BundleManifest {
519 schema_version: SCHEMA_VERSION,
520 name: "yah-marketing".to_string(),
521 runtime: BundleRuntime::Mesofact {
522 version: "0.8.18".to_string(),
523 },
524 requires_contract: BUNDLE_CONTRACT_VERSION,
525 content,
526 }
527 }
528
529 #[test]
530 fn hash_rejects_non_hex_and_wrong_length() {
531 assert!(BundleHash::parse("zz").is_err());
532 assert!(BundleHash::parse("g".repeat(64)).is_err());
533 assert!(BundleHash::parse("a".repeat(63)).is_err());
534 assert!(BundleHash::parse("A".repeat(64)).is_ok()); // hex, gets lowercased
535 assert_eq!(BundleHash::parse("A".repeat(64)).unwrap().as_str(), "a".repeat(64));
536 }
537
538 #[test]
539 fn runtime_round_trips_both_forms() {
540 assert_eq!(BundleRuntime::parse("self").unwrap(), BundleRuntime::self_contained());
541 assert_eq!(
542 BundleRuntime::parse("mesofact/0.8.18").unwrap(),
543 BundleRuntime::Mesofact { version: "0.8.18".into() }
544 );
545 assert_eq!(BundleRuntime::self_contained().as_wire(), "self");
546 assert_eq!(
547 BundleRuntime::Mesofact { version: "1.2.3".into() }.as_wire(),
548 "mesofact/1.2.3"
549 );
550 // Malformed runtimes reject.
551 assert!(BundleRuntime::parse("mesofact/").is_err());
552 assert!(BundleRuntime::parse("caddy").is_err());
553 }
554
555 /// R746-F5: a recorded fingerprint round-trips through `"self/<fp>"`, and
556 /// a bundle carrying one is still self-contained.
557 #[test]
558 fn runtime_round_trips_the_recorded_contract() {
559 let fp = "a".repeat(64);
560 let rt = BundleRuntime::SelfContained {
561 built_against: Some(fp.clone()),
562 };
563 assert_eq!(rt.as_wire(), format!("self/{fp}"));
564 assert_eq!(BundleRuntime::parse(&rt.as_wire()).unwrap(), rt);
565 assert!(rt.is_self_contained());
566 assert!(BundleRuntime::parse("self/").is_err());
567 }
568
569 #[test]
570 fn manifest_round_trips_through_toml() {
571 let m = sample();
572 let text = m.to_toml_string().unwrap();
573 let back = BundleManifest::from_toml_str(&text).unwrap();
574 assert_eq!(m, back);
575 }
576
577 #[test]
578 fn manifest_parses_the_doc_shape() {
579 let h = "a".repeat(64);
580 let text = format!(
581 r#"
582schema_version = 1
583name = "yah-marketing"
584runtime = "mesofact/0.8.18"
585
586[content]
587"app/mesofact.routes.ts" = "{h}"
588"#
589 );
590 let m = BundleManifest::from_toml_str(&text).unwrap();
591 assert_eq!(m.name, "yah-marketing");
592 assert!(matches!(m.runtime, BundleRuntime::Mesofact { .. }));
593 assert_eq!(m.content.len(), 1);
594 // No `requires_contract` in the doc shape above — a manifest published
595 // before R746-F6 parses as the contract-1 bundle it is.
596 assert_eq!(m.requires_contract, 1);
597 }
598
599 /// R746-F6, and the reason `digest` skips the default: a manifest already
600 /// in the store must keep hashing to the key it was stored under, or
601 /// `fetch_manifest`'s digest check rejects it and the site stops serving —
602 /// for every bundle published before this field existed, all at once.
603 ///
604 /// The expected encoding is spelled out here rather than pinned as a hex
605 /// literal on purpose. A literal would catch the regression but tell a
606 /// reader nothing about *what* the bytes are supposed to be, and nobody can
607 /// audit a blake3 by eye; this is the pre-F6 field sequence written out
608 /// long-hand, so a diff that starts feeding `requires_contract`
609 /// unconditionally fails against something legible.
610 #[test]
611 fn adding_requires_contract_did_not_move_any_published_digest() {
612 let h = "a".repeat(64);
613 let pre_f6 = format!(
614 r#"
615schema_version = 1
616name = "yah-marketing"
617runtime = "mesofact/0.8.18"
618
619[content]
620"app/mesofact.routes.ts" = "{h}"
621"#
622 );
623 let parsed = BundleManifest::from_toml_str(&pre_f6).unwrap();
624 assert_eq!(parsed.requires_contract, 1);
625
626 // The exact encoding pre-F6 `digest()` produced: domain tag,
627 // schema_version, name, runtime wire string, content count, then each
628 // (path, hash) pair — every field length-prefixed, and NO contract
629 // field anywhere in it.
630 let mut hasher = blake3::Hasher::new();
631 let mut feed = |bytes: &[u8]| {
632 hasher.update(&(bytes.len() as u64).to_le_bytes());
633 hasher.update(bytes);
634 };
635 feed(b"yah-mesofact-bundle/v1");
636 feed(&1u32.to_le_bytes());
637 feed(b"yah-marketing");
638 feed(b"mesofact/0.8.18");
639 feed(&1u64.to_le_bytes());
640 feed(b"app/mesofact.routes.ts");
641 feed(h.as_bytes());
642 let pre_f6_digest = hasher.finalize().to_hex().to_string();
643
644 assert_eq!(parsed.digest().as_str(), pre_f6_digest);
645 }
646
647 /// Requiring a *different* contract is a different bundle: two otherwise
648 /// identical trees that demand different runtime interfaces must not
649 /// collide on one digest.
650 #[test]
651 fn a_higher_contract_requirement_flips_the_digest() {
652 let base = sample();
653 assert_ne!(
654 BundleManifest {
655 requires_contract: 2,
656 ..sample()
657 }
658 .digest(),
659 base.digest()
660 );
661 }
662
663 /// The field survives the TOML round trip, and lands *before* `[content]` —
664 /// serialized after the table, TOML would read it as a content entry.
665 #[test]
666 fn requires_contract_round_trips_ahead_of_the_content_table() {
667 let m = BundleManifest {
668 requires_contract: 7,
669 ..sample()
670 };
671 let text = m.to_toml_string().unwrap();
672 assert_eq!(BundleManifest::from_toml_str(&text).unwrap(), m);
673 let contract_at = text.find("requires_contract").expect("field is emitted");
674 let content_at = text.find("[content]").expect("content table is emitted");
675 assert!(
676 contract_at < content_at,
677 "requires_contract must precede [content], got:\n{text}"
678 );
679 }
680
681 #[test]
682 fn unknown_schema_version_is_rejected() {
683 let text = r#"schema_version = 999
684name = "x"
685runtime = "self"
686"#;
687 assert!(matches!(
688 BundleManifest::from_toml_str(text),
689 Err(BundleError::SchemaVersion { found: 999 })
690 ));
691 }
692
693 #[test]
694 fn digest_is_deterministic_and_order_independent() {
695 // Insertion order differs but BTreeMap normalizes — same digest.
696 let mut a = BTreeMap::new();
697 a.insert("z".to_string(), hash_of("1"));
698 a.insert("a".to_string(), hash_of("2"));
699 let mut b = BTreeMap::new();
700 b.insert("a".to_string(), hash_of("2"));
701 b.insert("z".to_string(), hash_of("1"));
702 let ma = BundleManifest { content: a, ..sample() };
703 let mb = BundleManifest { content: b, ..sample() };
704 assert_eq!(ma.digest(), mb.digest());
705 }
706
707 #[test]
708 fn digest_changes_when_any_field_changes() {
709 let base = sample();
710 let d0 = base.digest();
711
712 // Different file bytes (hash) → different digest.
713 let mut c = base.content.clone();
714 c.insert("app/index.html".to_string(), hash_of("<html>changed"));
715 assert_ne!(BundleManifest { content: c, ..sample() }.digest(), d0);
716
717 // Different name → different digest.
718 assert_ne!(BundleManifest { name: "other".into(), ..sample() }.digest(), d0);
719
720 // Different runtime → different digest.
721 assert_ne!(
722 BundleManifest { runtime: BundleRuntime::self_contained(), ..sample() }.digest(),
723 d0
724 );
725
726 // Extra file → different digest.
727 let mut c2 = base.content.clone();
728 c2.insert("app/extra.js".to_string(), hash_of("x"));
729 assert_ne!(BundleManifest { content: c2, ..sample() }.digest(), d0);
730 }
731
732 #[test]
733 fn key_layout_is_stable() {
734 let h = hash_of("blob");
735 assert_eq!(blob_key(&h), format!("blobs/{h}"));
736 assert_eq!(manifest_key(&h), format!("manifests/{h}"));
737 }
738}