yah-cloud 0.8.40

Declarative cloud substrate for yah-managed camps: .yah/cloud/ config schema, MachineProvider drivers (Hetzner + local containerd), cloud-init rendering, and the pond/mesofact reconcilers.
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
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
//! Yubaba release-manifest fetch + per-triple resolution (R330-F21).
//!
//! `.github/workflows/release.yml`'s `yubaba-release-manifest` job publishes
//! `https://cdn.yah.dev/yubaba/release-manifest.json` after every release tag
//! (R330-F19). Operators do not need to memorise per-release URLs and sha256s —
//! `yah cloud machine provision <name>` fetches the manifest once, looks up
//! the entry matching the machine's architecture, and threads the resolved
//! URL + sha256 + sig/cert URLs into the cloud-init render.
//!
//! Per W203 §1.5 + R330-F19's per-triple entries:
//!
//! ```json
//! {
//!   "version": "0.9.0",
//!   "triples": {
//!     "x86_64-unknown-linux-musl": {
//!       "url":         "https://cdn.yah.dev/yubaba/0.9.0/x86_64-unknown-linux-musl/yah-yubaba-x86_64-unknown-linux-musl.tar.gz",
//!       "size":        2345678,
//!       "sha256":      "abc…",
//!       // cosign 3.x shape. Manifests cut by the cosign-2.x pipeline carry
//!       // "sig_url"/"cert_url" here instead — both shapes parse, see
//!       // [`ManifestTripleEntry`].
//!       "bundle_url":  "https://cdn.yah.dev/yubaba/.../...tar.gz.sigstore.json"
//!     },
//!     "aarch64-unknown-linux-musl": { … }
//!   }
//! }
//! ```
//!
//! Identity-regexp is *not* per-release — it identifies the workspace's
//! GitHub OIDC identity (matches the cosign sign-blob step in release.yml).
//! See [`DEFAULT_YUBABA_COSIGN_IDENTITY`].
//!
//! @yah:ticket(R599-F1, "R2 bundle store: append-only blob/manifest publish + node-side materialize into kamaji LRU cache")
//! @yah:status(review)
//! @yah:assignee(agent:bundle-anthropic-ashguard)
//! @yah:at(2026-07-20T04:20:25Z)
//! @yah:phase(P1)
//! @yah:parent(R599)
//! @yah:depends_on(R599-F2)
//! @yah:next("R599-F2: wire mesofact-build to EMIT a BundleManifest during a build (add yah-mesofact-bundle default-features=false; assemble app/ + optional bins/ tree, per-file blake3, write manifest.toml). Type already exists -> F2 is 'emit', not 'define'.")
//! @yah:next("R599-F4: kamaji deploy dispatch calls materialize_bundle/BundleCache into kamaji state dir on Deploy of the mesofact bundle variant (add yah-mesofact-bundle features=store + yah-object-store to kamaji).")
//! @yah:next("R599-F8: services-tab sync arm calls cloud reconciler::bundle_store::publish_bundle_to_r2 after building a bundle.")
//! @yah:handoff("LANDED (types + store, 16 tests). New crate oss/yah-base/crates/mesofact-bundle (yah-mesofact-bundle). Default features = serde+blake3+toml TYPES ONLY: BundleManifest, BundleRuntime (self | mesofact/<ver>), BundleHash 64-hex, digest() over canonical length-prefixed fields, blob_key/manifest_key layout. This is what R599-F2's mesofact-build consumes via default-features=false.")
//! @yah:handoff("Feature `store` adds yah-object-store + publish_bundle (append-only blob dedupe via HEAD + blake3 verify + immutable manifest-by-digest PUT), materialize_bundle (fetch+verify+atomic-rename into cache tree, idempotent, path-traversal guarded), BundleCache (LRU-by-digest, bytes budget, 0=unbounded). Store tests via InMemoryObjectStore+tempdir.")
//! @yah:handoff("Canonical annotation kept here in release_manifest.rs (one-block-per-ID); the real work is the new crate (its lib.rs has a prose pointer, not a 2nd @yah block). Wired: yah-base workspace member + root + oss/yubaba [patch.crates-io]. cloud deps it (features=store) -> reconciler::bundle_store::publish_bundle_to_r2 (spawn_blocking R2ObjectStore wrapper) + BundlePublishReport re-export. Type home = new minimal crate (user-approved via ask_user): NOT workload-spec (avoids ts-rs/schemars weight at mesofact boundary), NOT mesofact-build (avoids drift); store logic feature-gated so the boundary stays serde-only.")
//! @yah:verify("cd oss/yah-base && cargo test -p yah-mesofact-bundle (8 pass, types-only)")
//! @yah:verify("cd oss/yah-base && cargo test -p yah-mesofact-bundle --features store (16 pass)")
//! @yah:verify("cd oss/yubaba && cargo test -p cloud --lib reconciler::bundle_store (1 pass)")

use anyhow::{anyhow, bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::time::Duration;

/// Canonical manifest URL published by `.github/workflows/release.yml`'s
/// `yubaba-release-manifest` job. Hardcoded so operators don't need to memorise
/// it; operators *can* still override by passing `--yubaba-manifest-url`.
pub const DEFAULT_RELEASE_MANIFEST_URL: &str = "https://cdn.yah.dev/yubaba/release-manifest.json";

/// Default cosign keyless OIDC identity regexp the yubaba release pipeline
/// signs against. Matches the `cosign sign-blob` certificate identity emitted
/// by GitHub Actions when running under `yah-ai/yah` (R330-F19). Operators
/// override with `--yubaba-cosign-identity` to point at a fork's OIDC subject.
pub const DEFAULT_YUBABA_COSIGN_IDENTITY: &str = r"^https://github\.com/yah-ai/yah/";

/// Sigstore Fulcio OIDC issuer for GitHub-Actions-rooted keyless signing —
/// what `.github/workflows/release.yml`'s `cosign sign-blob` step produces
/// (R330-F19). The canonical copy; `cloud_init` and the yah CLI's
/// `yubaba_fetch` both read it from here so the two verify paths can't drift.
pub const COSIGN_OIDC_ISSUER: &str = "https://token.actions.githubusercontent.com";

/// Prefix that selects key-based verification in a cosign-identity spec.
/// See [`ReleaseTrust::parse`].
pub const KEY_TRUST_PREFIX: &str = "key:";

/// How a consumer proves a release artifact is ours (R605-F1).
///
/// The two arms are the verify-side mirror of `yah_qed::SigningIdentity`, and
/// they exist for the same reason: a release cut on QED instead of GitHub
/// cannot use Fulcio keyless, because the public-good Fulcio only issues certs
/// for OIDC issuers on its own configured allowlist. W235 §"Off-GHA forfeits
/// GitHub OIDC keyless cosign" makes the call — key-based cosign, key vaulted
/// in kamaji — so every consumer that verifies a yubaba/CLI artifact has to be
/// able to express both.
///
/// This is carried through the config plumbing as a plain string (the existing
/// `--yubaba-cosign-identity` value, the `RenderInput` field, the manifest's
/// identity regexp) and parsed at the two points that actually shell `cosign`.
/// Keeping the wire type a string is deliberate: it means flipping a fleet to
/// key-based trust is a value change, not a schema migration.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReleaseTrust {
    /// Fulcio keyless: pin the certificate identity + the OIDC issuer.
    Keyless {
        identity_regexp: String,
        oidc_issuer: String,
    },
    /// Key-based: pin the cosign *public* key. `key_ref` is whatever
    /// `cosign verify-blob --key` accepts — a `.pub` path, an `https://` URL,
    /// or a KMS URI (`awskms://…`, `hashivault://…`).
    Key { key_ref: String },
}

impl ReleaseTrust {
    /// Parse an operator-supplied cosign-identity spec.
    ///
    /// `key:<ref>` selects key-based verification; anything else is a keyless
    /// certificate-identity regexp against [`COSIGN_OIDC_ISSUER`]. The prefix
    /// is explicit rather than sniffed, because a KMS URI and a regexp are
    /// both arbitrary strings and guessing between them would silently pick
    /// the wrong trust model.
    pub fn parse(spec: &str) -> Self {
        match spec.strip_prefix(KEY_TRUST_PREFIX) {
            Some(key_ref) => Self::Key {
                key_ref: key_ref.trim().to_string(),
            },
            None => Self::Keyless {
                identity_regexp: spec.to_string(),
                oidc_issuer: COSIGN_OIDC_ISSUER.to_string(),
            },
        }
    }

    /// The `cosign verify-blob` flags this trust model contributes, in order.
    /// The caller appends `--bundle <path>`/the blob itself. cosign 3.x's
    /// bundle format carries the signature (and, for keyless, the Fulcio
    /// certificate + transparency proof) as ONE file, so there is no
    /// separate `.cert` sidecar to conditionally fetch any more — the old
    /// `needs_certificate()` distinction that gated a `.cert` download is
    /// gone with it.
    pub fn verify_flags(&self) -> Vec<String> {
        match self {
            Self::Keyless {
                identity_regexp,
                oidc_issuer,
            } => vec![
                "--certificate-identity-regexp".into(),
                identity_regexp.clone(),
                "--certificate-oidc-issuer".into(),
                oidc_issuer.clone(),
            ],
            // `--insecure-ignore-tlog`: key-based signing deliberately signs
            // with no transparency-log upload (W235 — the entire point of
            // key trust is working with no public-Sigstore-infra dependency,
            // and an org's release artifacts should not be named in a public
            // log by default). cosign's verify side has to be told the same
            // thing, or it hard-fails expecting an inclusion proof that was
            // never produced. cosign's own naming call this flag "insecure";
            // for this trust arm it is the correct, intended mode, not a
            // downgrade — see the R605-F1 signing-key incident this is
            // paired with (a bare sign-blob call defaults to tlog upload
            // even in key mode, which is the actual insecure surprise).
            Self::Key { key_ref } => vec![
                "--key".into(),
                key_ref.clone(),
                "--insecure-ignore-tlog".into(),
            ],
        }
    }
}

/// One per-triple entry in the manifest.
///
/// TWO SIGNATURE SHAPES EXIST IN THE WILD and this type has to read both, so
/// every signature field is optional. Until R755-T4 measured it (2026-08-26)
/// this struct required `bundle_url` and the doc comment here claimed the field
/// order matched what release.yml's `Emit per-triple manifest fragment` step
/// writes. It did not, and never had: that step emits `sig_url`/`cert_url` and
/// no `bundle_url` at all. The consequence was total — `fetch_release_manifest`
/// hard-failed with `missing field bundle_url` against the ONLY manifest ever
/// published, so `yah cloud rollout plan` could not resolve any release, ever.
/// Every test in this module used a hand-written fixture carrying `bundle_url`,
/// which is precisely why fixtures alone never caught it; see
/// `parses_the_real_published_manifest_bytes` for the regression that would
/// have, and `scripts/release-manifest-rehearsal.sh`'s header for the same
/// lesson stated in advance.
///
///   * `bundle_url` — cosign 3.x sigstore bundle: signature + certificate +
///     transparency proof in one file. What the verify path wants.
///   * `sig_url` / `cert_url` — cosign 2.x detached signature and Fulcio cert.
///     What release.yml (pinned to cosign v2.6.5) actually emits today, and
///     what every published manifest through 0.8.20 carries.
///
/// Unknown fields (`hash`, `bootstrap_hash`) are ignored by serde, so the
/// richer fragment shape release.yml writes round-trips without a schema bump.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestTripleEntry {
    pub url: String,
    pub size: u64,
    pub sha256: String,
    /// cosign 3.x sigstore-bundle URL. Absent on manifests cut by the
    /// cosign-2.x pipeline — use [`ManifestTripleEntry::bundle`] rather than
    /// reading this directly, so a missing bundle is a named refusal instead of
    /// an empty URL handed to `curl`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub bundle_url: Option<String>,
    /// cosign 2.x detached signature URL.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub sig_url: Option<String>,
    /// cosign 2.x Fulcio certificate URL. Empty string in key-trust mode —
    /// release.yml writes `""` rather than omitting the key (R605-F1).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cert_url: Option<String>,
}

impl ManifestTripleEntry {
    /// The sigstore-bundle URL, or a refusal naming why verification cannot
    /// proceed. Never returns an empty string: an entry that carries only the
    /// cosign-2.x `sig_url`/`cert_url` pair has no bundle to verify against,
    /// and silently fetching `""` would surface as an unrelated curl error at
    /// the wrong layer.
    pub fn bundle(&self) -> Result<&str> {
        match self.bundle_url.as_deref().filter(|u| !u.is_empty()) {
            Some(url) => Ok(url),
            None if self.sig_url.is_some() => bail!(
                "release-manifest entry for {} carries a cosign 2.x detached \
                 signature (sig_url) but no sigstore bundle_url, and the verify \
                 path needs a bundle. This manifest was cut by the cosign-v2.6.5 \
                 pipeline in .github/workflows/release.yml; a release cut with \
                 cosign 3.x publishes bundle_url instead. Pass --yubaba-url + \
                 --yubaba-sha256 to install without manifest-driven verification.",
                self.url
            ),
            None => bail!(
                "release-manifest entry for {} declares no signature at all \
                 (neither bundle_url nor sig_url) — refusing to install \
                 unverified bytes",
                self.url
            ),
        }
    }
}

/// Top-level shape of `release-manifest.json`.
///
/// The two epoch fields (R625-F2) are **release-wide, not per-triple** — the
/// raft protocol and on-disk layout are architecture-independent, so they sit
/// alongside `version` rather than inside each [`ManifestTripleEntry`]. They are
/// `Option` because manifests published before this field existed carry neither;
/// see [`YubabaReleaseManifest::cluster_epochs`] for why absent must never be
/// read as "compatible".
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct YubabaReleaseManifest {
    pub version: String,
    /// Wire-compatibility epoch: may a node running this release sit in one raft
    /// cluster with a node running some other release. Mixed operation is
    /// permitted iff every node shares this integer (W275 "Cluster compatibility
    /// epochs"). Sourced from `oss/yubaba/crates/yubaba/cluster-epochs.json`,
    /// the same file the binary parses at compile time.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cluster_protocol: Option<u32>,
    /// On-disk state epoch: can this release read the previous release's raft
    /// log/snapshot, and can you roll **back** to it. Tracked separately from
    /// `cluster_protocol` on purpose — openraft 0.9→0.10 broke both at once,
    /// which is exactly how one combined flag would have hidden the rollback
    /// hazard (W275 §5 "Roll-back is symmetric").
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub state_epoch: Option<u32>,
    pub triples: BTreeMap<String, ManifestTripleEntry>,
}

impl YubabaReleaseManifest {
    /// Look up the manifest entry for the given Rust target triple. Returns a
    /// descriptive error listing the available triples so operators can spot a
    /// typo or a missing matrix leg at a glance.
    pub fn entry(&self, triple: &str) -> Result<&ManifestTripleEntry> {
        self.triples.get(triple).ok_or_else(|| {
            let available: Vec<_> = self.triples.keys().cloned().collect();
            anyhow!(
                "release-manifest has no entry for triple '{triple}' (available: {available:?})"
            )
        })
    }
}

/// Map a Hetzner server-type code to a Rust target triple. The current matrix
/// (W203 §1.2) is x86_64 + aarch64 Linux musl. Hetzner's ARM line uses the
/// `cax` prefix (cax11 / cax21 / …) — every other prefix (cpx*, ccx*, cx*) is
/// Intel/AMD x86_64. This is operational truth, not heuristic — Hetzner
/// publishes that mapping in their public pricing page and the catalog crate.
pub fn server_type_to_triple(server_type: &str) -> Result<&'static str> {
    if server_type.starts_with("cax") {
        Ok("aarch64-unknown-linux-musl")
    } else if server_type.starts_with("cpx")
        || server_type.starts_with("ccx")
        || server_type.starts_with("cx")
    {
        Ok("x86_64-unknown-linux-musl")
    } else {
        bail!(
            "unknown Hetzner server_type '{server_type}' — cannot infer architecture. \
             Pass --yubaba-url + --yubaba-sha256 explicitly to skip manifest resolution."
        )
    }
}

/// Fetch the release-manifest from the canonical URL. Caller picks the runtime;
/// inside `yah cloud machine provision` the existing `tokio::runtime::Runtime`
/// drives this. Surfaces every failure mode loud (no silent placeholder
/// fallback) — operators see an actionable message and pass `--yubaba-url`
/// explicitly to bypass.
pub async fn fetch_release_manifest(url: &str) -> Result<YubabaReleaseManifest> {
    let client = reqwest::Client::builder()
        .timeout(Duration::from_secs(15))
        .user_agent("yah/cloud release-manifest fetcher")
        .build()
        .context("building http client for release-manifest fetch")?;
    let resp = client
        .get(url)
        .send()
        .await
        .with_context(|| format!("fetching release-manifest from {url}"))?;
    if !resp.status().is_success() {
        bail!(
            "release-manifest fetch from {url} returned HTTP {} — \
             pass --yubaba-url explicitly if the manifest is unreachable",
            resp.status()
        );
    }
    let body = resp
        .bytes()
        .await
        .with_context(|| format!("reading release-manifest body from {url}"))?;
    let manifest: YubabaReleaseManifest = serde_json::from_slice(&body).with_context(|| {
        format!(
            "parsing release-manifest from {url} — pass --yubaba-url explicitly if the manifest is malformed"
        )
    })?;
    if manifest.triples.is_empty() {
        bail!(
            "release-manifest from {url} has no triples — \
             pass --yubaba-url explicitly to bypass manifest resolution"
        );
    }
    Ok(manifest)
}

#[cfg(test)]
mod tests {
    use super::*;

    // ── R605-F1 release trust spec ────────────────────────────────────────

    #[test]
    fn bare_spec_is_keyless_against_the_default_issuer() {
        // Every existing config value must keep meaning exactly what it meant
        // before the key arm existed.
        assert_eq!(
            ReleaseTrust::parse(DEFAULT_YUBABA_COSIGN_IDENTITY),
            ReleaseTrust::Keyless {
                identity_regexp: DEFAULT_YUBABA_COSIGN_IDENTITY.into(),
                oidc_issuer: COSIGN_OIDC_ISSUER.into(),
            }
        );
    }

    #[test]
    fn key_prefix_selects_key_trust_and_trims() {
        assert_eq!(
            ReleaseTrust::parse("key: awskms:///alias/yah-release "),
            ReleaseTrust::Key {
                key_ref: "awskms:///alias/yah-release".into()
            }
        );
    }

    #[test]
    fn a_kms_uri_without_the_prefix_stays_keyless() {
        // Deliberate: sniffing a URI scheme out of a free-form regexp would
        // silently swap the trust model. The prefix is the only signal.
        assert!(matches!(
            ReleaseTrust::parse("awskms:///alias/yah-release"),
            ReleaseTrust::Keyless { .. }
        ));
    }

    #[test]
    fn verify_flags_track_the_arm() {
        let keyless = ReleaseTrust::parse(DEFAULT_YUBABA_COSIGN_IDENTITY);
        assert_eq!(
            keyless.verify_flags(),
            vec![
                "--certificate-identity-regexp".to_string(),
                DEFAULT_YUBABA_COSIGN_IDENTITY.to_string(),
                "--certificate-oidc-issuer".to_string(),
                COSIGN_OIDC_ISSUER.to_string(),
            ]
        );

        let keyed = ReleaseTrust::parse("key:cosign.pub");
        assert_eq!(
            keyed.verify_flags(),
            vec![
                "--key".to_string(),
                "cosign.pub".to_string(),
                "--insecure-ignore-tlog".to_string(),
            ]
        );
    }

    #[test]
    fn server_type_to_triple_maps_arm_prefix() {
        assert_eq!(
            server_type_to_triple("cax11").unwrap(),
            "aarch64-unknown-linux-musl"
        );
        assert_eq!(
            server_type_to_triple("cax21").unwrap(),
            "aarch64-unknown-linux-musl"
        );
        assert_eq!(
            server_type_to_triple("cax41").unwrap(),
            "aarch64-unknown-linux-musl"
        );
    }

    #[test]
    fn server_type_to_triple_maps_x86_prefixes() {
        assert_eq!(
            server_type_to_triple("cpx22").unwrap(),
            "x86_64-unknown-linux-musl"
        );
        assert_eq!(
            server_type_to_triple("ccx13").unwrap(),
            "x86_64-unknown-linux-musl"
        );
        assert_eq!(
            server_type_to_triple("cx32").unwrap(),
            "x86_64-unknown-linux-musl"
        );
    }

    #[test]
    fn server_type_to_triple_rejects_unknown_prefix() {
        let err = server_type_to_triple("xxx99").unwrap_err().to_string();
        assert!(err.contains("unknown Hetzner server_type"), "msg: {err}");
        assert!(
            err.contains("--yubaba-url"),
            "msg points to override: {err}"
        );
    }

    #[test]
    fn manifest_round_trips_through_json() {
        let raw = r#"{
            "version": "0.9.0",
            "triples": {
                "x86_64-unknown-linux-musl": {
                    "url": "https://cdn.yah.dev/yubaba/0.9.0/x86_64-unknown-linux-musl/yah-yubaba-x86_64-unknown-linux-musl.tar.gz",
                    "size": 2345678,
                    "sha256": "abc123",
                    "bundle_url": "https://cdn.yah.dev/yubaba/0.9.0/x86_64-unknown-linux-musl/yah-yubaba-x86_64-unknown-linux-musl.tar.gz.sigstore.json"
                }
            }
        }"#;
        let parsed: YubabaReleaseManifest = serde_json::from_str(raw).unwrap();
        assert_eq!(parsed.version, "0.9.0");
        // This manifest predates R625-F2 — the epochs are absent, not zero.
        assert_eq!(parsed.cluster_protocol, None);
        assert_eq!(parsed.state_epoch, None);
        let entry = parsed.entry("x86_64-unknown-linux-musl").unwrap();
        assert_eq!(entry.size, 2345678);
        assert_eq!(entry.sha256, "abc123");
        assert!(entry.bundle().unwrap().ends_with(".tar.gz.sigstore.json"));
    }

    /// The regression the hand-written fixtures above could never catch.
    ///
    /// These are the VERBATIM bytes served at
    /// `https://cdn.yah.dev/yubaba/release-manifest.json` (fetched 2026-08-26,
    /// version 0.8.20 — the only yubaba manifest ever published). Every other
    /// fixture in this module was written against the type instead of against
    /// the pipeline, so all of them carried `bundle_url` and all of them passed
    /// while `fetch_release_manifest` was failing in production with
    /// `missing field bundle_url` — reproduced live via
    /// `yah cloud rollout plan --to 0.8.20 --node us-west-003`.
    ///
    /// If a future schema change breaks real published manifests again, this is
    /// the test that goes red. Do not "fix" it by editing the JSON to match the
    /// type; re-fetch the live manifest and make the type read it.
    #[test]
    fn parses_the_real_published_manifest_bytes() {
        let raw = r#"{
  "version": "0.8.20",
  "triples": {
    "aarch64-unknown-linux-musl": {
      "url": "https://cdn.yah.dev/yubaba/0.8.20/aarch64-unknown-linux-musl/yubaba-aarch64-unknown-linux-musl.tar.gz",
      "size": 9780352,
      "sha256": "be8bb1602cd8827cb8aa5c56639a84c03d47508405b3baee5aa004a7d0787797",
      "sig_url": "https://cdn.yah.dev/yubaba/0.8.20/aarch64-unknown-linux-musl/yubaba-aarch64-unknown-linux-musl.tar.gz.sig",
      "cert_url": "https://cdn.yah.dev/yubaba/0.8.20/aarch64-unknown-linux-musl/yubaba-aarch64-unknown-linux-musl.tar.gz.cert"
    },
    "x86_64-unknown-linux-musl": {
      "url": "https://cdn.yah.dev/yubaba/0.8.20/x86_64-unknown-linux-musl/yubaba-x86_64-unknown-linux-musl.tar.gz",
      "size": 10067454,
      "sha256": "ce1978cf9d7362621290265e87e0bdb55de4b2410b6c8c6931c3cf806a462767",
      "sig_url": "https://cdn.yah.dev/yubaba/0.8.20/x86_64-unknown-linux-musl/yubaba-x86_64-unknown-linux-musl.tar.gz.sig",
      "cert_url": "https://cdn.yah.dev/yubaba/0.8.20/x86_64-unknown-linux-musl/yubaba-x86_64-unknown-linux-musl.tar.gz.cert"
    }
  }
}"#;
        let parsed: YubabaReleaseManifest =
            serde_json::from_str(raw).expect("the live published manifest must parse");
        assert_eq!(parsed.version, "0.8.20");
        assert_eq!(parsed.triples.len(), 2);
        // Predates R625-F2, so it declares neither epoch. Absent is "unproven",
        // never "compatible" — see ClusterEpochs.
        assert_eq!(parsed.cluster_protocol, None);
        assert_eq!(parsed.state_epoch, None);

        let entry = parsed.entry("x86_64-unknown-linux-musl").unwrap();
        assert_eq!(entry.size, 10_067_454);
        assert!(entry.sig_url.as_deref().unwrap().ends_with(".tar.gz.sig"));
        assert!(entry.bundle_url.is_none());

        // No bundle to verify against — and the refusal must SAY that, rather
        // than let an empty URL reach the fetcher.
        let err = entry.bundle().unwrap_err().to_string();
        assert!(err.contains("sig_url"), "names the shape it found: {err}");
        assert!(err.contains("bundle_url"), "names what it wanted: {err}");
    }

    /// The richer per-triple shape release.yml's fragment step actually writes:
    /// extra `hash` / `bootstrap_hash` keys, and `cert_url: ""` in key-trust
    /// mode (R605-F1). Unknown keys are ignored; the empty cert is preserved
    /// rather than mistaken for a missing field.
    #[test]
    fn parses_the_release_yml_fragment_shape() {
        let raw = r#"{
            "version": "0.8.29",
            "cluster_protocol": 5,
            "state_epoch": 2,
            "triples": {
                "x86_64-unknown-linux-musl": {
                    "url": "https://cdn.yah.dev/yubaba/0.8.29/x86_64-unknown-linux-musl/yubaba-x86_64-unknown-linux-musl.tar.gz",
                    "size": 10000000,
                    "hash": "blake3:aa",
                    "bootstrap_hash": "sha256:bb",
                    "sha256": "bb",
                    "sig_url": "https://cdn.yah.dev/yubaba/0.8.29/x86_64-unknown-linux-musl/yubaba-x86_64-unknown-linux-musl.tar.gz.sig",
                    "cert_url": ""
                }
            }
        }"#;
        let parsed: YubabaReleaseManifest = serde_json::from_str(raw).unwrap();
        assert_eq!(parsed.cluster_protocol, Some(5));
        let entry = parsed.entry("x86_64-unknown-linux-musl").unwrap();
        assert_eq!(entry.sha256, "bb");
        assert_eq!(entry.cert_url.as_deref(), Some(""));
        assert!(entry.bundle().is_err());
    }

    #[test]
    fn manifest_entry_missing_triple_lists_available() {
        let raw = r#"{"version":"0.9.0","triples":{"x86_64-unknown-linux-musl":{"url":"u","size":1,"sha256":"s","bundle_url":"u.sigstore.json"}}}"#;
        let parsed: YubabaReleaseManifest = serde_json::from_str(raw).unwrap();
        let err = parsed
            .entry("aarch64-unknown-linux-musl")
            .unwrap_err()
            .to_string();
        assert!(err.contains("aarch64-unknown-linux-musl"));
        assert!(
            err.contains("x86_64-unknown-linux-musl"),
            "lists available: {err}"
        );
    }

    #[test]
    fn manifest_carries_the_two_cluster_epochs() {
        // R625-F2. The shape release.yml now publishes: two release-wide
        // integers alongside `version`, NOT per-triple (raft protocol and
        // on-disk layout are architecture-independent).
        let raw = r#"{
            "version": "0.8.22",
            "cluster_protocol": 2,
            "state_epoch": 2,
            "triples": {
                "x86_64-unknown-linux-musl": {
                    "url": "u", "size": 1, "sha256": "s", "bundle_url": "b"
                }
            }
        }"#;
        let parsed: YubabaReleaseManifest = serde_json::from_str(raw).unwrap();
        assert_eq!(parsed.cluster_protocol, Some(2));
        assert_eq!(parsed.state_epoch, Some(2));

        // Round-trip keeps them, and a manifest that declares nothing emits
        // nothing (rather than an implicit 0, which would read as "declared").
        let back: YubabaReleaseManifest =
            serde_json::from_str(&serde_json::to_string(&parsed).unwrap()).unwrap();
        assert_eq!(back.cluster_protocol, Some(2));
        assert_eq!(back.state_epoch, Some(2));

        let undeclared = YubabaReleaseManifest {
            version: "0.8.20".into(),
            cluster_protocol: None,
            state_epoch: None,
            triples: BTreeMap::new(),
        };
        let json = serde_json::to_string(&undeclared).unwrap();
        assert!(!json.contains("cluster_protocol"), "{json}");
        assert!(!json.contains("state_epoch"), "{json}");
    }

    #[test]
    fn default_identity_regexp_matches_yah_ai_yah() {
        // Sanity: the constant matches what release.yml's cosign sign-blob
        // step emits as the keyless certificate identity. Bumping the org
        // requires updating both this constant AND release.yml in lockstep.
        assert!(DEFAULT_YUBABA_COSIGN_IDENTITY.contains("yah-ai/yah"));
    }
}