Skip to main content

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}