//! The W272 **mesofact bundle** format — the content-addressed unit a mesofact
//! app distributes as, plus (behind the `store` feature) the R2 blob store that
//! publishes and materializes it.
//!
//! A bundle is one immutable, content-addressed directory shape:
//!
//! ```text
//! <bundle>/
//! manifest.toml # identity + runtime selection
//! app/ # routes/config + built TS/assets
//! bins/<triple>/serve # ONLY when runtime = "self"
//! ```
//!
//! ```toml
//! # manifest.toml
//! schema_version = 1
//! name = "yah-marketing"
//! runtime = "mesofact/0.8.18" # vanilla: resolve stock runtime from node cache
//! # runtime = "self" # custom: bins/<triple>/serve ships in the bundle
//!
//! [content]
//! "app/mesofact.routes.ts" = "b3f1…" # path → blake3, one row per file
//! ```
//!
//! There is exactly one format; "vanilla" is the degenerate case where
//! `runtime = "mesofact/<ver>"` and no `bins/` are carried. Immutability is the
//! whole coherence story: any change produces a new [`BundleManifest::digest`],
//! "update" is publish-new-digest + drain-old, and rollback is a pointer flip.
//!
//! This crate is deliberately light: the default feature set is serde + blake3
//! + toml only, so the `mesofact-build` subcamp (R599-F2, a separate Cargo
//! workspace) can link it to *emit* manifests without dragging the object-store
//! / reqwest stack across the boundary. The `store` feature adds that surface
//! for the cloud publish path (R599) and kamaji node materialize (R599-F4).
//!
//! Home of R599-F1 (the W272 bundle format + store) — the canonical `@yah:`
//! ticket annotation lives in `oss/yubaba/crates/cloud/src/release_manifest.rs`
//! to keep one block per ID; see W272 for the design.
//!
//! @yah:ticket(R746-F5, "Bundle-only release for CUSTOM mesofact: reuse the serve binary by digest, gated on a recorded runtime contract")
//! @yah:at(2026-08-11T07:59:46Z)
//! @yah:status(open)
//! @yah:assignee(agent:bundle-anthropic-ashguard)
//! @yah:parent(R746)
//! @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.")
//! @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.")
//! @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.")
//! @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.")
//! @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.")
//! @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.")
//! @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.")
//! @yah:verify("The re-publish uploads only the changed app blobs. Read it off the publish output rather than inferring it from dedupe existing.")
//! @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.")
//! @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.")
//! @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.")
//! @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.")
//! @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.")
//! @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.")
//!
//! @yah:ticket(R746-F6, "Version the bundle-runtime contract so a compatibility range means something a runtime is held to")
//! @yah:at(2026-08-11T08:07:47Z)
//! @yah:status(open)
//! @yah:assignee(agent:bundle-anthropic-ashguard)
//! @yah:parent(R746)
//! @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.")
//! @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.")
//! @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.")
//! @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.")
//! @yah:verify("The matrix runs in CI and a new contract version cannot land without a row.")
//! @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.")
use std::collections::BTreeMap;
use std::fmt;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// Emitting side: walk a built app into a bundle tree + manifest. Pure
/// `std::fs` + blake3 + toml, so it stays in the default (types-only) feature
/// set — `mesofact-build` re-exports it, and the V8-less yah CLI can assemble a
/// bundle without linking the build toolchain (R599-T5).
mod assemble;
pub use assemble::{
assemble_bundle, assemble_self_bundle, assemble_self_bundle_with, assemble_vanilla_bundle,
collect_dir, BundleFile,
};
#[cfg(feature = "store")]
mod store;
#[cfg(feature = "store")]
pub use store::{materialize_bundle, publish_bundle, BundleCache, PublishReport};
/// Node runtime-asset tier (R746-F1): binaries a vanilla bundle resolves by
/// name instead of carrying — the shared `serve` runtime, and (R746-T3) the
/// `almanac-feed` sidecar. [`RuntimeRef`] (the cache-key shape) is types-only;
/// the fetch/publish half needs `store`.
mod runtime;
pub use runtime::{check_bin_name, FEED_BIN, RuntimeRef, SERVE_BIN};
#[cfg(feature = "store")]
pub use runtime::{
ensure_runtime_asset, publish_runtime_asset, RuntimeAsset, RuntimeAssetManifest,
RUNTIME_ASSET_SCHEMA_VERSION,
};
/// Bundle-manifest schema version. Bumping this is a wire-format change: the
/// materialize side rejects a manifest whose `schema_version` it does not know.
pub const SCHEMA_VERSION: u32 = 1;
/// Error surface for parsing / hashing a bundle manifest. The `store` feature
/// adds I/O + object-store variants (see `store.rs`).
#[derive(Debug, thiserror::Error)]
pub enum BundleError {
/// `manifest.toml` failed to parse.
#[error("malformed bundle manifest: {0}")]
Manifest(String),
/// A manifest carried a `schema_version` this build doesn't understand.
#[error("unsupported bundle schema_version {found} (this build speaks {SCHEMA_VERSION})")]
SchemaVersion { found: u32 },
/// A `runtime = "..."` string was neither `self` nor `mesofact/<version>`.
#[error("unrecognized bundle runtime {0:?} (expected \"self\" or \"mesofact/<version>\")")]
Runtime(String),
/// Bytes hashed to a different digest than the manifest recorded. Carries
/// the offending path (or blob key) plus both hashes.
#[error("blake3 mismatch for {what}: manifest says {expected}, bytes hash to {actual}")]
HashMismatch {
what: String,
expected: String,
actual: String,
},
/// Filesystem or object-store failure. Constructed by the assemble path
/// (always available) and by the `store` feature's publish/materialize.
#[error("bundle io: {0}")]
Io(String),
/// A blob referenced by the manifest was absent from the store during
/// materialize (only constructed under `store`).
#[error("missing blob {key} for path {path:?} while materializing bundle")]
MissingBlob { key: String, path: String },
/// No runtime asset is published for the runtime × triple a vanilla bundle
/// named (R746-F1). Deliberately verbose: this is the one failure an
/// operator hits by declaring a `runtime_version` nothing has built yet, and
/// the fix is entirely in the version and the location, so both are in the
/// message rather than in a log line somewhere upstream.
#[error(
"no serve runtime asset published for {runtime} on {triple} \
(looked for {location}); a vanilla bundle resolves its runtime by name \
and will not fall back to any other binary on this node"
)]
RuntimeAssetMissing {
runtime: String,
triple: String,
location: String,
},
}
/// A BLAKE3 content hash: exactly 64 lowercase ASCII hex digits.
///
/// The content-address key for every file in a bundle *and* for the bundle's
/// own manifest digest. Deserialization rejects anything that isn't 64 hex
/// chars, and normalizes to lowercase so `blob_key` is stable regardless of how
/// a hand-authored manifest cased its hashes.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct BundleHash(String);
impl BundleHash {
/// Wrap a hex string, validating shape (64 hex chars) and lowercasing.
pub fn parse(s: impl Into<String>) -> Result<Self, BundleError> {
let s = s.into();
if s.len() != 64 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(BundleError::Manifest(format!(
"blake3 hash must be exactly 64 hex digits, got {s:?}"
)));
}
Ok(BundleHash(s.to_ascii_lowercase()))
}
/// The BLAKE3 hash of `bytes`, as a [`BundleHash`].
pub fn of(bytes: &[u8]) -> Self {
BundleHash(blake3::hash(bytes).to_hex().to_string())
}
/// Borrow the hex string.
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for BundleHash {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl Serialize for BundleHash {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.0)
}
}
impl<'de> Deserialize<'de> for BundleHash {
fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
let s = String::deserialize(de)?;
BundleHash::parse(s).map_err(serde::de::Error::custom)
}
}
/// Which runtime serves a bundle — the `runtime = "..."` manifest field.
///
/// Serialized as a single string: `"self"` for a bundle that ships its own
/// `bins/<triple>/serve`, or `"mesofact/<version>"` for a vanilla bundle that
/// resolves the stock `mesofact serve` runtime from the node's runtime cache.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BundleRuntime {
/// Vanilla: resolve the stock `mesofact serve` runtime asset of this
/// version from the node cache. No binaries in the bundle.
Mesofact { version: String },
/// Custom: the bundle carries `bins/<triple>/serve` and is served by its
/// own binary. No node runtime asset is resolved.
SelfContained,
}
impl BundleRuntime {
/// The wire string form (`"self"` or `"mesofact/<version>"`).
pub fn as_wire(&self) -> String {
match self {
BundleRuntime::SelfContained => "self".to_string(),
BundleRuntime::Mesofact { version } => format!("mesofact/{version}"),
}
}
/// Parse the wire string form.
pub fn parse(s: &str) -> Result<Self, BundleError> {
if s == "self" {
return Ok(BundleRuntime::SelfContained);
}
if let Some(version) = s.strip_prefix("mesofact/") {
if !version.is_empty() {
return Ok(BundleRuntime::Mesofact {
version: version.to_string(),
});
}
}
Err(BundleError::Runtime(s.to_string()))
}
/// Whether the bundle ships its own serve binaries (`runtime = "self"`).
pub fn is_self_contained(&self) -> bool {
matches!(self, BundleRuntime::SelfContained)
}
}
impl Serialize for BundleRuntime {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.as_wire())
}
}
impl<'de> Deserialize<'de> for BundleRuntime {
fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
let s = String::deserialize(de)?;
BundleRuntime::parse(&s).map_err(serde::de::Error::custom)
}
}
/// The parsed `manifest.toml` at a bundle's root — the bundle's identity plus
/// the per-file content map that makes it content-addressed.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BundleManifest {
/// Wire-format version. Always [`SCHEMA_VERSION`] today.
pub schema_version: u32,
/// Human-readable bundle name, e.g. `"yah-marketing"`. Not part of the
/// content-address surface beyond the digest — two bundles with the same
/// bytes but different names have different digests.
pub name: String,
/// Runtime selection (`"self"` / `"mesofact/<ver>"`).
pub runtime: BundleRuntime,
/// Every file the bundle carries, keyed by its path *within the bundle*
/// (e.g. `"app/dist/index.html"`, `"bins/x86_64-unknown-linux-musl/serve"`)
/// mapped to its BLAKE3 content hash. Sorted (BTreeMap) so the manifest
/// serialization — and therefore the digest — is deterministic.
#[serde(default)]
pub content: BTreeMap<String, BundleHash>,
}
impl BundleManifest {
/// Parse a `manifest.toml` string, rejecting an unknown schema version.
pub fn from_toml_str(s: &str) -> Result<Self, BundleError> {
let manifest: BundleManifest =
toml::from_str(s).map_err(|e| BundleError::Manifest(e.to_string()))?;
if manifest.schema_version != SCHEMA_VERSION {
return Err(BundleError::SchemaVersion {
found: manifest.schema_version,
});
}
Ok(manifest)
}
/// Serialize back to `manifest.toml` form.
pub fn to_toml_string(&self) -> Result<String, BundleError> {
toml::to_string_pretty(self).map_err(|e| BundleError::Manifest(e.to_string()))
}
/// The bundle's content-address: a BLAKE3 digest over the manifest's
/// identity + content map.
///
/// Computed over a canonical, order-stable byte encoding (not the TOML text,
/// which whitespace/comments could perturb): `schema_version`, `name`, the
/// runtime wire string, then every `(path, hash)` pair in sorted path order.
/// Any change to any file's bytes (its hash), the name, the runtime, or the
/// file set flips the digest — that immutability is what makes rollback a
/// pointer flip and eviction a no-invalidation affair (W272 §1).
pub fn digest(&self) -> BundleHash {
let mut hasher = blake3::Hasher::new();
// Domain-separate + length-prefix every field so no two distinct
// manifests can collide by field-boundary ambiguity.
let mut feed = |bytes: &[u8]| {
hasher.update(&(bytes.len() as u64).to_le_bytes());
hasher.update(bytes);
};
feed(b"yah-mesofact-bundle/v1");
feed(&self.schema_version.to_le_bytes());
feed(self.name.as_bytes());
feed(self.runtime.as_wire().as_bytes());
feed(&(self.content.len() as u64).to_le_bytes());
// BTreeMap iterates in sorted key order — deterministic.
for (path, hash) in &self.content {
feed(path.as_bytes());
feed(hash.as_str().as_bytes());
}
BundleHash(hasher.finalize().to_hex().to_string())
}
}
/// Object-store key for a content blob: `blobs/<blake3>`. Every file across
/// every bundle dedupes on this key — two bundles sharing a byte-identical file
/// share the one blob.
pub fn blob_key(hash: &BundleHash) -> String {
format!("blobs/{}", hash.as_str())
}
/// Object-store key for a bundle manifest: `manifests/<digest>`. Immutable —
/// the digest is derived from the manifest, so re-publishing an identical
/// bundle writes the same key with the same bytes.
pub fn manifest_key(digest: &BundleHash) -> String {
format!("manifests/{}", digest.as_str())
}
#[cfg(test)]
mod tests {
use super::*;
fn hash_of(s: &str) -> BundleHash {
BundleHash::of(s.as_bytes())
}
fn sample() -> BundleManifest {
let mut content = BTreeMap::new();
content.insert("app/index.html".to_string(), hash_of("<html>"));
content.insert("app/mesofact.routes.ts".to_string(), hash_of("routes"));
BundleManifest {
schema_version: SCHEMA_VERSION,
name: "yah-marketing".to_string(),
runtime: BundleRuntime::Mesofact {
version: "0.8.18".to_string(),
},
content,
}
}
#[test]
fn hash_rejects_non_hex_and_wrong_length() {
assert!(BundleHash::parse("zz").is_err());
assert!(BundleHash::parse("g".repeat(64)).is_err());
assert!(BundleHash::parse("a".repeat(63)).is_err());
assert!(BundleHash::parse("A".repeat(64)).is_ok()); // hex, gets lowercased
assert_eq!(BundleHash::parse("A".repeat(64)).unwrap().as_str(), "a".repeat(64));
}
#[test]
fn runtime_round_trips_both_forms() {
assert_eq!(BundleRuntime::parse("self").unwrap(), BundleRuntime::SelfContained);
assert_eq!(
BundleRuntime::parse("mesofact/0.8.18").unwrap(),
BundleRuntime::Mesofact { version: "0.8.18".into() }
);
assert_eq!(BundleRuntime::SelfContained.as_wire(), "self");
assert_eq!(
BundleRuntime::Mesofact { version: "1.2.3".into() }.as_wire(),
"mesofact/1.2.3"
);
// Malformed runtimes reject.
assert!(BundleRuntime::parse("mesofact/").is_err());
assert!(BundleRuntime::parse("caddy").is_err());
}
#[test]
fn manifest_round_trips_through_toml() {
let m = sample();
let text = m.to_toml_string().unwrap();
let back = BundleManifest::from_toml_str(&text).unwrap();
assert_eq!(m, back);
}
#[test]
fn manifest_parses_the_doc_shape() {
let h = "a".repeat(64);
let text = format!(
r#"
schema_version = 1
name = "yah-marketing"
runtime = "mesofact/0.8.18"
[content]
"app/mesofact.routes.ts" = "{h}"
"#
);
let m = BundleManifest::from_toml_str(&text).unwrap();
assert_eq!(m.name, "yah-marketing");
assert!(matches!(m.runtime, BundleRuntime::Mesofact { .. }));
assert_eq!(m.content.len(), 1);
}
#[test]
fn unknown_schema_version_is_rejected() {
let text = r#"schema_version = 999
name = "x"
runtime = "self"
"#;
assert!(matches!(
BundleManifest::from_toml_str(text),
Err(BundleError::SchemaVersion { found: 999 })
));
}
#[test]
fn digest_is_deterministic_and_order_independent() {
// Insertion order differs but BTreeMap normalizes — same digest.
let mut a = BTreeMap::new();
a.insert("z".to_string(), hash_of("1"));
a.insert("a".to_string(), hash_of("2"));
let mut b = BTreeMap::new();
b.insert("a".to_string(), hash_of("2"));
b.insert("z".to_string(), hash_of("1"));
let ma = BundleManifest { content: a, ..sample() };
let mb = BundleManifest { content: b, ..sample() };
assert_eq!(ma.digest(), mb.digest());
}
#[test]
fn digest_changes_when_any_field_changes() {
let base = sample();
let d0 = base.digest();
// Different file bytes (hash) → different digest.
let mut c = base.content.clone();
c.insert("app/index.html".to_string(), hash_of("<html>changed"));
assert_ne!(BundleManifest { content: c, ..sample() }.digest(), d0);
// Different name → different digest.
assert_ne!(BundleManifest { name: "other".into(), ..sample() }.digest(), d0);
// Different runtime → different digest.
assert_ne!(
BundleManifest { runtime: BundleRuntime::SelfContained, ..sample() }.digest(),
d0
);
// Extra file → different digest.
let mut c2 = base.content.clone();
c2.insert("app/extra.js".to_string(), hash_of("x"));
assert_ne!(BundleManifest { content: c2, ..sample() }.digest(), d0);
}
#[test]
fn key_layout_is_stable() {
let h = hash_of("blob");
assert_eq!(blob_key(&h), format!("blobs/{h}"));
assert_eq!(manifest_key(&h), format!("manifests/{h}"));
}
}