yah-mesofact-bundle 0.8.26

W272 mesofact bundle format: content-addressed manifest + digest types, and a feature-gated R2 blob store (publish / node-side materialize / LRU cache).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
//! 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}"));
    }
}