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#[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}