Skip to main content

cloud/reconciler/
mesofact_bundle.rs

1//! W272 bundle tier for `mesofact-static` / `mesofact-spa` components — the
2//! config half of the services-tab sync arm.
3//!
4//! Part of R599-F8 — the canonical ticket annotation lives in
5//! `app/yah/cli/src/cloud.rs`, which owns the orchestration half. This module
6//! only owns *what the mirror declares*: parsing the `[providers.bundle]` slot
7//! and resolving which machines the built bundle gets deployed to.
8//!
9//! **The component kind does not change.** A mesofact site is a mesofact site;
10//! the mirror decides how its bytes are distributed. A mirror with a
11//! `[providers.static]` slot rides the historical build-and-publish-to-CDN path
12//! ([`super::mesofact_static`]); a mirror that declares `[providers.bundle]`
13//! rides the W272 chain instead:
14//!
15//! ```text
16//! build → bundle assembly (per-file blake3) → R2 publish → workload deploy
17//!   → node materializes → kamaji forks the serve binary
18//! ```
19//!
20//! The deploy leg lives at the apply layer (`app/yah/cli/src/cloud.rs`) rather
21//! than in a `Reconciler::up`, for the same reason
22//! [`super::mesofact_runner`] does: machine resolution needs [`CloudConfig`],
23//! which [`ReconcileCtx`] deliberately does not carry. What runs here is the
24//! validation a desktop-side bring-up can still do offline —
25//! [`MesofactBundleReconciler`] checks the slot parses and the placement
26//! resolves, then bails with a pointer at the CLI.
27//!
28//! @yah:ticket(R703-T7, "Stamp a publish beacon into the W272 bundle so a passway apex can be serving-verified too")
29//! @yah:status(review)
30//! @yah:at(2026-08-08T23:55:25Z)
31//! @yah:assignee(agent:bundle-anthropic-ashguard)
32//! @yah:parent(R703)
33//! @yah:next("R703-B4 added a publish beacon (prefix/.well-known/yah-publish.json, oss/yubaba/crates/cloud/src/reconciler/publish_beacon.rs) written by the R2 publish path, and the reconciler fetches it back through the declared front door to fail an apply whose bytes nobody serves. A passway apex serves a W272 bundle, NOT the R2 prefix, so it has nothing to answer that probe with -- the front-door check can only ever pass there once the bundle carries an equivalent stamp.")
34//! @yah:next("SCOPE: stamp a PublishBeacon into the bundle at build time using the same digest shape (PublishBeacon::new + digest_of are already public and take a BTreeMap of key -> sha256; reuse them rather than inventing a second digest). It must be reachable at /.well-known/yah-publish.json through mesofact serve, which serves bundle paths directly, so it needs to be a bundle entry at exactly that path.")
35//! @yah:next("THEN: the mesofact_bundle sync path gains the same call mesofact_static::verify_serving makes. That is where the check becomes symmetric -- today only the R2 arm can prove it is being read.")
36//! @yah:verify("A bundle built for yah-marketing contains /.well-known/yah-publish.json, and curl https://yah.dev/.well-known/yah-publish.json through a passway apex returns a beacon whose digest matches the bundle that was synced.")
37//! @yah:gotcha("GATED ON R546 REGARDLESS. The bundle tier cannot sync at all until the musl serve_bins at target/x86_64-unknown-linux-musl/release/mesofact exists; slot_ready is false and .yah/services/yah-marketing/mirrors/cloud.toml falls back to the static chain. There is nothing to verify until that lands, which is why R703-B4 filed this rather than doing it in-pass.")
38//! @yah:tier(Cleric) — the digest and probe shapes are already built and public; this is threading a known artifact through the bundle builder, not a design.
39//! @yah:handoff("SHIPPED. A W272 bundle now carries a publish beacon and the bundle sync arm verifies it through the apex, so both serving tiers can prove they are being read rather than merely written.")
40//! @yah:handoff("publish_beacon.rs: BUNDLE_BEACON_PATH = app/dist/html/.well-known/yah-publish.json (the one bundle entry mesofact serve answers /.well-known/yah-publish.json from), PublishBeacon::for_bundle, bundle_beacon(), stamp_bundle(). Reuses digest_of over a BTreeMap of path -> hash as the ticket asked; no second digest was invented.")
41//! @yah:handoff("DESIGN CALL worth reviewing: the bundle stamp is clock-free. published_at became Option<String> (serde default, so beacons already in R2 still parse) and for_bundle sets None. A wall clock inside an entry of a content-addressed unit would flip the bundle digest on every assembly, breaking W272 immutability, the blob dedupe that makes a re-publish a no-op, and the assembly_is_deterministic test. The digest already names the exact immutable unit, so nothing diagnostic is lost; messages render via published_label().")
42//! @yah:handoff("Symmetry, the third next bullet: mesofact_static::verify_serving and the new cloud.rs verify_bundle_serving both call one shared publish_beacon::check_serving -> ServingVerdict, rather than the bundle arm growing a second copy that drifts. probe_urls() is the pure half (three collapses: static probes prefix + apex separately, a bundle collapses onto the apex, bucket-direct collapses onto the origin) so it is testable with no network. Probe budgets split: EDGE_PROBE (4 x 5s, CDN propagation) vs NODE_PROBE (20 x 6s) because a bundle deploy has to fetch blobs, materialize, and restart the serve process.")
43//! @yah:handoff("Stamped in assemble_component_bundle_with_sidecars (app/yah/cli/src/cloud.rs), not in the sync arm, so yah cloud bundle build and a sync still emit byte-identical trees. BundleSlot gained verify_serving (default true, non-bool rejected rather than defaulted) and an optional zone (defaults to the service domain) + serving_zone(). Documented both in the [providers.bundle] block of .yah/services/yah-marketing/mirrors/cloud.toml.")
44//! @yah:handoff("DISCOVERED WORK, outside the ticket title, done in-pass. Two claims this ticket rests on were inference, not verification, so I pinned them. (1) oss/mesofact/crates/mesofact/src/server.rs:1450 — serves_the_publish_beacon_from_a_dot_well_known_path proves GET /.well-known/yah-publish.json really returns 200 application/json through Server::from_bundle (a leading-dot directory is exactly the shape a static server tends to reject or rewrite), plus an_unstamped_bundle_does_not_answer_the_beacon_url_with_200 so an unstamped bundle 404s instead of 200-ing HTML. (2) oss/yah-base/crates/mesofact-bundle/src/store.rs:439 — a_dot_directory_entry_publishes_and_materializes proves publish_bundle + materialize_bundle round-trip the first dot-directory entry a bundle has ever carried; if checked_rel were ever tightened to a naive no-dot-segment rule, a node would refuse to materialize a bundle it had already accepted.")
45//! @yah:verify("cargo test -p yah-cloud --lib (in oss/yubaba): 741 passed, 0 failed. 23 in reconciler::publish_beacon (13 new), 34 in reconciler::mesofact_bundle (4 new).")
46//! @yah:verify("cargo test -p yah --lib: 1035 passed, 0 failed. Includes the new cloud::bundle_assembly_tests::an_assembled_bundle_carries_its_publish_beacon, and assembly_is_deterministic still passes with the stamp in place, which is the evidence the clock-free design holds W272 immutability.")
47//! @yah:verify("cargo test -p mesofact --lib server:: (in oss/mesofact): 34 passed, 0 failed. cargo test -p yah-mesofact-bundle --features store (in oss/yah-base): 31 passed, 0 failed.")
48//! @yah:verify("cargo check --workspace --exclude desktop: clean. cargo check --workspace in oss/yubaba: clean. cargo test -p xtask --test schema_drift: 3 passed, so no generated-artifact drift. yah cloud validate --path .: ok, no alias or port collisions, re-run after the mirror comment edit.")
49//! @yah:gotcha("THE SECOND HALF OF THE VERIFY LINE IS NOT DONE AND COULD NOT BE. curl https://yah.dev/.well-known/yah-publish.json still 404s, because the bundle tier cannot sync at all until R546 produces target/x86_64-unknown-linux-musl/release/{mesofact,almanac-feed}. slot_ready is false, yah-marketing still falls back to the static chain, and no bundle has been assembled by this code against the live apex. Everything is proven by test, nothing by a live apply. R703 now carries a notify_on(R546) that spells out the live run.")
50//! @yah:gotcha("When the bundle tier first turns on, expect the apply to FAIL the serving check for a while, and read that as the check working. us-east-001 is serving a hand-placed bundle from before this code existed, which carries no stamp, so the apex will answer the probe 404 (Missing) until a bundle assembled by THIS code is deployed there. Do not reach for verify_serving = false; deploy the stamped bundle.")
51//! @yah:next("LIVE VERIFY, gated on R546 and the only thing left: build the two musl binaries, yah cloud apply --service yah-marketing --env cloud, confirm it takes the bundle arm, then curl https://yah.dev/.well-known/yah-publish.json and check the digest equals bundle_beacon() over the synced manifest.")
52//!
53//! @yah:ticket(R752-B7, "revalidate routes allowlist is parsed, shipped, then dropped - the receiver accepts pokes for every route")
54//! @yah:status(review)
55//! @yah:at(2026-08-13T00:22:14Z)
56//! @yah:assignee(agent:bundle-anthropic-ashguard)
57//! @yah:parent(R752)
58//! @yah:severity(medium)
59//! @yah:gotcha("Found 2026-08-12 while wiring R330-F13's sidecar to the live receiver. `[providers.bundle.revalidate] routes` is documented as an allowlist ('empty = all routes', mesofact_bundle.rs:218), is parsed into RevalidateSlot.routes, is copied into MesofactRevalidateReceiver.routes (mesofact_bundle.rs:264), and is shipped over the wire to kamaji. kamaji then never reads it: bundle_workload_spec_revalidate (oss/kamaji/crates/kamaji-bin/src/server.rs:2117) builds the receiver's argv from publish_config + listen and its env from receiver.env, and `routes` appears nowhere. grep confirms server.rs touches receiver.feeds / feed_interval_secs / feed_project_prefix / publish_config / env and never receiver.routes.")
60//! @yah:gotcha("MEASURED, not inferred: with .yah/services/yah-marketing/mirrors/cloud.toml declaring routes = [\"/releases\"], POST http://100.64.0.3:8081/revalidate {\"routes\":[\"/issues\"]} returned 202 on us-east-001 and went on to re-render and republish /issues. An undeclared route was accepted and acted on.")
61//! @yah:gotcha("Severity is medium not high because the receiver is not publicly reachable (mesh IP, and it is the tenant's own render path) — but it IS an unauthenticated write-shaped endpoint today: its process env carries no MESOFACT_MIRROR_KEY, so mirror_key_env is unresolved too. The declared scoping control and the declared bearer are BOTH inert, which is worth knowing before anyone treats either as a boundary.")
62//! @yah:next("Decide whether the allowlist is real. If yes, pass it to the receiver (argv or env) in bundle_workload_spec_revalidate and enforce it there; if no, delete the field rather than leaving a documented control that does nothing.")
63//! @yah:next("If it becomes enforced, .yah/services/yah-marketing/mirrors/cloud.toml already lists both \"/releases\" and \"/issues\" — R330-F13 added /issues precisely so enforcement does not silently break the now-working issue-filing path.")
64//! @yah:next("Same question for mirror_key_env: it resolves to nothing today, so the receiver runs open. Whatever change starts resolving it must set the matching ALMANAC_MIRROR_KEY on the issue-tracker unit on us-east-001 in the SAME change, or the sidecar's poke starts 401ing and /issues silently stops updating.")
65//! @yah:handoff("OPERATOR CALL 2026-08-12: the allowlist is real - enforce it IF present. Auth is a separate, pluggable axis (cheers auth, preshared key, or unauthenticated are all legitimate for an almanac route); the allowlist is scoping, not authentication, and the two are now independent controls end to end.")
66//! @yah:handoff("Node leg (oss/kamaji/crates/kamaji-bin/src/server.rs, bundle_workload_spec_revalidate): each declared route is rendered as one `--allow-route <route>` on the receiver's argv. An empty list emits no flag at all, which keeps the documented 'empty = all routes' meaning - `--allow-route \"\"` would have scoped the receiver to a route that cannot exist and silently killed every revalidation.")
67//! @yah:handoff("Receiver leg (oss/mesofact/crates/mesofact/src/revalidate.rs): RevalidateConfig gained `routes`, fed by a new repeatable `--allow-route` flag on `mesofact serve`. Enforced in BOTH shapes a poke can take - an explicit `{\"route\": ...}` outside the list gets a synchronous 403 and never enqueues, and a whole-site poke (no route named) is NARROWED to the list at render time. The narrowing is the half that matters: the escape actually measured on us-east-001 sent {\"routes\":[\"/issues\"]}, which the receiver's body type does not have a field for, so it deserialized to route=None and ran as a whole-site render. A handler-only check would still have let that through.")
68//! @yah:handoff("Route selection was split out of render_routes into a pure `render_targets(workload, route, allow)` so the scoping rule is testable without booting V8 - a security-shaped control whose only evidence was 'it compiles' is how this got shipped inert in the first place. It also errors on a disallowed explicit route rather than rendering nothing, so an in-process caller cannot get a silent success.")
69//! @yah:handoff("The allowlist is intersected with the manifest, not unioned: a listed route the manifest cannot render (ssr, deferred, or a typo) is skipped instead of turning every whole-site poke into an error.")
70//! @yah:handoff("Config docs corrected where they now lie: RevalidateSlot.routes in oss/yubaba/crates/cloud/src/reconciler/mesofact_bundle.rs and the block in .yah/services/yah-marketing/mirrors/cloud.toml both said the field was inert. The cloud.toml note now says the list is LOAD-BEARING - a route absent from it stops being republished after the next deploy of that mirror.")
71//! @yah:handoff("tenants.rs (multi-tenant receiver) passes an empty allowlist with a comment naming the shape to copy - tenants/<id>.toml has no routes key yet, so per-tenant scoping is unmodelled rather than silently unenforced.")
72//! @yah:verify("cargo test -p mesofact --all-features (oss/mesofact) - 104 passed, 0 failed, including 8 new: out-of-list route 403s and does not enqueue, in-list route accepted, a correct mirror_key does NOT widen the allowlist, empty allowlist accepts anything, whole-site poke accepted then narrowed, whole-site targets = manifest INTERSECT allowlist, an allowlisted route absent from the manifest is not rendered, explicit disallowed route errors at render time.")
73//! @yah:verify("cargo test -p kamaji-bin --all-features (oss/kamaji) - 239 passed, 0 failed. The pre-existing revalidate_spec_argv_matches_mesofact_serve_clap_shape test is the one that should have caught this: it declared routes = [\"/releases\"] and pinned an argv that never mentioned it, green the whole time. It now asserts the --allow-route pair, plus two new tests for the empty-list and two-route cases.")
74//! @yah:verify("cargo test -p yah-cloud --lib mesofact_bundle (oss/yubaba) - 44 passed, 0 failed.")
75//! @yah:verify("cargo test -p xtask --test schema_drift - 3 passed; the doc-comment edits touch no schemars-derived type, so no generated artifact moved.")
76//! @yah:verify("cargo clippy --all-features --all-targets on both changed crates - no new warnings from the changed files (mesofact-core/mesofact-build/server.rs warnings are pre-existing).")
77//! @yah:verify("Checked the roll is safe BEFORE it happens: the only mirror in the tree declaring [providers.bundle.revalidate] is yah-marketing/cloud.toml, and it lists both /releases and /issues. The only live pokers name exactly those - issue-tracker sends Poke::route(\"/issues\") (crates/yah/issue-tracker/src/main.rs:86) and the almanac on_change arms in .yah/almanac/{releases,yah-desktop}.toml both name /releases. fleet.toml uses kind=\"reload\", which pokes almanac's own receiver, not this one. So nothing that works today starts 403ing.")
78//! @yah:gotcha("NOT DEPLOYED - code only. Enforcement starts at the next `yah cloud` sync of yah-marketing, which re-forks the receiver with the new argv. Deliberately not rolled from this session: it is an outward-facing change to a live node, and deployment belongs to R330-F13/R523. Before that roll, the live receiver still accepts a poke for any route.")
79//! @yah:next("mirror_key_env is still inert and the receiver still runs OPEN - untouched here, because the operator's call put auth on its own axis. Whatever change starts resolving it must set the matching ALMANAC_MIRROR_KEY on the issue-tracker unit on us-east-001 in the SAME change, or the sidecar's poke starts 403ing and /issues silently stops updating.")
80//! @yah:next("Public front door: R752-F9 filed for the low-security platform key that ships with the browser bundle for POST /api/issues (the operator's second decision). Different endpoint, different key namespace - do not collapse it with MESOFACT_MIRROR_KEY.")
81//! @yah:gotcha("BEHAVIOUR CHANGE worth knowing: after the roll, a whole-site poke at yah-marketing re-renders ONLY /releases and /issues, not / and /404. That is the intended reading of the declared list, but it means the landing page can no longer be refreshed by poking the receiver - it is republished by a full deploy. If someone wants / kept fresh from a feed, add it to routes in cloud.toml.")
82
83use std::collections::BTreeMap;
84use std::path::PathBuf;
85
86use anyhow::{bail, Context, Result};
87use async_trait::async_trait;
88
89use workload_spec::{
90    BlakeHash, BundleLifecycle, MesofactRevalidateReceiver, MesofactServeBundle, Millis,
91};
92
93use super::{ReconcileCtx, Reconciler, RunningWorkload};
94use crate::config::CloudConfig;
95use crate::MirrorConfig;
96
97/// Mirror provider role that opts a mesofact component into the bundle tier.
98pub const SLOT_ROLE: &str = "bundle";
99
100/// Sub-key under `[providers.bundle]` that declares the revalidate receiver
101/// (R330-F12 almanac push endpoint).
102pub const REVALIDATE_KEY: &str = "revalidate";
103
104/// Default idle TTL for an `on-demand` (JIT) bundle when the slot doesn't name
105/// one: five minutes with zero connections before kamaji reaps the process.
106pub const DEFAULT_IDLE_TTL_MS: u64 = 300_000;
107
108/// Every key `[providers.bundle]` is allowed to carry (R556-B14).
109///
110/// The mirror schema's `MirrorProviderSlot` is `additionalProperties: true` by
111/// construction — it is one flattened `BTreeMap<String, toml::Value>` shared by
112/// every provider role, so it cannot know what any single role reads. That
113/// leniency is fine at the schema layer and is the wrong default here: a slot
114/// whose key nobody reads is not "extra metadata", it is an operator's
115/// instruction being ignored. Both instances that motivated this were
116/// **parses clean, deploys, wrong at request time** — a typo'd `prot = 8081`
117/// falls back to kamaji's node default, which post-R599-F12 is whatever OTHER
118/// bundle already holds 8080 on that node; and a `[providers.bundle.env]` block
119/// was, before R556-T12, read by nothing at all while looking exactly like it
120/// worked.
121///
122/// The set is the UNION of what every consumer of this slot reads, not just
123/// what [`BundleSlot::parse`] reads — `plan_ingress` reads four of its own off
124/// the same table (`reconciler::ingress`), and `MirrorProviderSlot::required`
125/// reads `required`. Scoping it to one consumer would reject live mirrors.
126///
127/// `use` / `kind` are absent deliberately: they are captured by the
128/// `MirrorProviderSlot` enum variant itself and never appear in `fields()`.
129const ALLOWED_SLOT_KEYS: &[&str] = &[
130    // BundleSlot::parse
131    "account",
132    "bucket",
133    "env",
134    "idle_ttl_ms",
135    "lifecycle",
136    "machines",
137    "name",
138    "port",
139    REVALIDATE_KEY,
140    "runtime_version",
141    "serve_bins",
142    "serve_build",
143    "verify_serving",
144    "zone",
145    // MirrorProviderSlot::required — F16 placement, read via the slot, not here
146    "required",
147    // reconciler::ingress::plan_ingress — the front-door planner reads the same
148    // table. `machines`, `port` and `zone` are shared with the list above.
149    "machine",
150    "upstream_host",
151    // R844-F5 split participation from the port value, but only taught the
152    // planner about it — so a bundle slot spelling the portless shape it
153    // introduced (`fronted = true`, no `port`) was rejected here as an unknown
154    // key, and the deletion that ticket exists to enable would have failed the
155    // apply. Found and fixed from R844-F8.
156    "fronted",
157];
158
159/// True when this mirror opts its mesofact components into the W272 bundle
160/// tier — i.e. declares a `[providers.bundle]` slot.
161///
162/// Checked at the dispatch layer before the static reconciler runs, so the
163/// two tiers are mutually exclusive per mirror rather than per component.
164pub fn slot_declared(mirror: &MirrorConfig) -> bool {
165    mirror.providers.contains_key(SLOT_ROLE)
166}
167
168/// Resolve a slot-declared binary path against the workspace root.
169///
170/// Slot paths are workspace-relative unless absolute — the operator writes them
171/// in a mirror file, not from a shell cwd.
172pub fn resolve_slot_path(workspace_root: &std::path::Path, path: &std::path::Path) -> PathBuf {
173    if path.is_absolute() {
174        path.to_path_buf()
175    } else {
176        workspace_root.join(path)
177    }
178}
179
180/// Declared-but-absent binaries, as `(label, resolved path)`.
181///
182/// The label is the config coordinate (`providers.bundle.serve_bins.<triple>`)
183/// so a caller can name the exact line an operator has to fix.
184pub fn missing_bins(slot: &BundleSlot, workspace_root: &std::path::Path) -> Vec<(String, PathBuf)> {
185    let serve = slot
186        .serve_bins
187        .iter()
188        .map(|(triple, path)| (format!("providers.{SLOT_ROLE}.serve_bins.{triple}"), path));
189    let feed = slot.revalidate.iter().flat_map(|rv| {
190        rv.feed_bins.iter().map(|(triple, path)| {
191            (
192                format!("providers.{SLOT_ROLE}.{REVALIDATE_KEY}.feed_bins.{triple}"),
193                path,
194            )
195        })
196    });
197    serve
198        .chain(feed)
199        .filter_map(|(label, path)| {
200            let resolved = resolve_slot_path(workspace_root, path);
201            (!resolved.is_file()).then_some((label, resolved))
202        })
203        .collect()
204}
205
206/// True when the bundle tier can actually **serve**, not merely when it has
207/// been declared.
208///
209/// R330-B43 — THIS DISTINCTION IS THE WHOLE POINT, and getting it wrong froze
210/// yah.dev for 19 days. Dispatch used to switch tiers on [`slot_declared`]
211/// alone, so writing a `[providers.bundle]` block instantly disabled the
212/// working `[providers.static]` publish chain — while the bundle tier itself
213/// could not come up, because its `serve_bins` binaries had never been built.
214/// The old path was off, the new path could not turn on, and the site quietly
215/// served stale bytes at HTTP 200 with no error anywhere.
216///
217/// A cut-over must never be able to disable a serving path before its
218/// successor can serve. So the switch keys on the binaries EXISTING, and a
219/// declared-but-unready slot falls back to the static chain (loudly) instead
220/// of taking over and stranding the site.
221///
222/// A slot with no `serve_bins` at all is "ready" here on purpose: that is the
223/// vanilla-runtime shape, which fails later for a different, well-reported
224/// reason rather than being a half-built self-contained bundle.
225///
226/// R746-F2: a `serve_build` slot is likewise ready, and for a stronger reason —
227/// the sync can *produce* the binary it needs by dispatching the declared QED
228/// recipe, so there is no such thing as a path an operator forgot to build.
229/// That is the whole point of the declaration: B43's failure was "declared but
230/// nobody can build it here", and a recipe is exactly the thing that removes
231/// the "here".
232pub fn slot_ready(slot: &BundleSlot, workspace_root: &std::path::Path) -> bool {
233    missing_bins(slot, workspace_root).is_empty()
234}
235
236/// Parsed `[providers.bundle]` slot — everything the sync arm needs that is
237/// *declared* rather than *derived*.
238///
239/// ```toml
240/// [providers.bundle]
241/// use = "cloudflare"                  # R2 credentials resolve via this provider
242/// bucket = "yah-dev-bundles"          # the append-only bundle store
243/// machines = ["us-east-001"]          # explicit placement (or `required = {…}`)
244/// name = "yah-marketing"              # stable workload handle; defaults to the service name
245/// lifecycle = "keep-alive"            # or "on-demand"
246/// idle_ttl_ms = 300000                # on-demand only
247/// runtime_version = "0.8.20"          # vanilla bundles only (no serve binary)
248/// serve_bins = { x86_64-unknown-linux-musl = "target/…/mesofact-serve" }
249/// # …or, instead of naming pre-built paths, name the recipe that builds them:
250/// # [providers.bundle.serve_build]
251/// # pipeline = "mesofact-musl"
252/// # binary   = "mesofact"
253/// # triples  = ["x86_64-unknown-linux-musl"]
254/// zone = "yah.dev"                    # front door to serving-verify; defaults
255///                                     # to the service's own domain
256/// verify_serving = true               # default; see the field docs
257///
258/// # Environment for the serve process, as source URIs resolved at deploy
259/// # (R556-T12). An SSR route reading a private source needs this or it gets
260/// # a credential-less server on the node.
261/// [providers.bundle.env]
262/// ANALYTICS_R2_ACCESS_KEY = "vault:cloudflare-r2-access-key-id"
263/// ANALYTICS_R2_BUCKET     = "yah-analytics"      # bare literal: not a secret
264/// ```
265#[derive(Debug, Clone, PartialEq, Eq)]
266pub struct BundleSlot {
267    /// R2 bucket holding the bundle store. Append-only, blob-deduped.
268    pub bucket: String,
269    /// Cloudflare account id override. `None` → resolve from the workspace's
270    /// cloudflare provider config / `CF_ACCOUNT_ID`.
271    pub account: Option<String>,
272    /// Stable operator-facing workload name. yubaba requires one for a bundle
273    /// deploy: the digest is the *content* and changes on every rebuild, so it
274    /// is not a usable handle for `list` / `stop`.
275    pub name: Option<String>,
276    /// Explicitly named target machines, in deploy order. Empty → fall back to
277    /// the slot's `required = {…}` placement spec.
278    pub machines: Vec<String>,
279    /// Stock runtime version recorded as `runtime = "mesofact/<version>"` for a
280    /// vanilla bundle. Ignored when `serve_bins` is non-empty. `None` → the
281    /// caller's own version.
282    pub runtime_version: Option<String>,
283    /// `<triple> → <path to serve binary>`. Any entry makes this a
284    /// `runtime = "self"` bundle that carries its own serve binaries.
285    pub serve_bins: BTreeMap<String, PathBuf>,
286    /// Build the serve binaries on demand instead of naming pre-built paths
287    /// (R746-F2). Mutually exclusive with `serve_bins`; either one makes this a
288    /// `runtime = "self"` bundle.
289    pub serve_build: Option<BinBuild>,
290    /// How kamaji supervises the served bundle.
291    pub lifecycle: BundleLifecycle,
292    /// Port the served bundle listens on (R599-F12). `None` → kamaji's
293    /// node-wide default (8080), which is only correct while the node hosts a
294    /// single bundle; declare one per workload to put several on a node.
295    pub port: Option<u16>,
296    /// `[providers.bundle.env]` — environment for the **serve** process, as
297    /// `NAME → source URI` (R556-T12).
298    ///
299    /// Values are the source *declaration*, kept verbatim and resolved
300    /// deploy-side by `yah cloud apply` — `vault:<slot>`, `env:<VAR>`, a
301    /// pipe-joined fallback chain of either, or a bare literal for a
302    /// known-non-secret value. Same grammar `~/.yah/qed/secrets.toml` uses, so
303    /// there is one source-URI vocabulary in the camp rather than two.
304    ///
305    /// Parsing stays here and resolution does not: this crate is offline by
306    /// construction (a misconfigured mirror must fail before a build runs), and
307    /// only the syncing machine has the vault. The `RevalidateSlot::mirror_key_env`
308    /// → [`RevalidateSlot::to_workload_payload`] split is the same shape one
309    /// level down.
310    pub env: BTreeMap<String, String>,
311    /// Optional revalidate receiver config (R330-F12). `Some` → the deploy
312    /// also stands up a `mesofact serve --revalidate` process.
313    pub revalidate: Option<RevalidateSlot>,
314    /// Public zone whose front door is checked after a deploy (R703-T7).
315    /// `None` → the service's own `domain`, which is the shape every mirror in
316    /// tree uses; declare one only when the bundle serves a zone that isn't it.
317    ///
318    /// Unlike `[providers.static]`, this is optional: the static slot's `zone`
319    /// is load-bearing for the Worker route and cache purge, whereas here it
320    /// only names what to probe.
321    pub zone: Option<String>,
322    /// Whether a deploy is checked against the live front door (R703-T7).
323    ///
324    /// Defaults to **true**, and the only reason to turn it off is a
325    /// deliberately in-flight front-door migration — with a comment naming the
326    /// ticket. It is declared in config rather than passed as a CLI flag for
327    /// the same reason the static slot's is: switching it off should be a
328    /// reviewable diff, not an invocation habit that quietly becomes permanent.
329    pub verify_serving: bool,
330}
331
332/// A binary the bundle needs, declared as **the recipe that builds it** rather
333/// than as a path someone is expected to have already produced (R746-F2).
334///
335/// ```toml
336/// [providers.bundle.serve_build]
337/// pipeline = "mesofact-musl"                   # .yah/qed/<name>.toml
338/// binary   = "mesofact"                        # matches a step's `produces.binary`
339/// triples  = ["x86_64-unknown-linux-musl"]     # what the placed nodes run
340/// ```
341///
342/// # Why this is a declaration and not a fallback
343///
344/// The alternative shape — "use `serve_bins` if the path exists, otherwise
345/// build" — makes the deployed artifact a function of what happens to be on the
346/// operator's disk. Two machines syncing the same mirror would then ship
347/// different binaries, and the one with a stale path would ship the stale one
348/// silently. The mirror says which shape it is; the sync obeys.
349///
350/// Declaring both this and `serve_bins` is refused for the same reason.
351#[derive(Debug, Clone, PartialEq, Eq)]
352pub struct BinBuild {
353    /// QED pipeline name, resolved under `.yah/qed/<pipeline>.toml`.
354    pub pipeline: String,
355    /// Logical binary name, matched against a step's `[[steps.produces]]
356    /// binary`.
357    pub binary: String,
358    /// Target triples to resolve, in declaration order. Non-empty: a build
359    /// declaration that names no target builds nothing.
360    pub triples: Vec<String>,
361}
362
363/// Parsed `[providers.bundle.revalidate]` sub-slot — declares the almanac
364/// revalidate receiver to fork alongside the static bundle server (R330-F12).
365///
366/// ```toml
367/// [providers.bundle.revalidate]
368/// routes = ["/releases"]             # allowlist (empty = all routes)
369/// mirror_key_env = "YAH_MARKETING_MIRROR_KEY"   # env var holding the bearer
370/// publish_config = "mesofact.config.toml"        # default
371/// feeds = ["releases"]               # .yah/almanac/<name>.toml to keep fresh
372/// feed_interval_secs = 300           # default
373/// feed_runtime = "almanac-feed/0.8.22"   # vanilla: node resolves the fetcher
374/// # …or, for a self-contained bundle, stage it in and name the built paths:
375/// # feed_bins = { x86_64-unknown-linux-musl = "target/…/almanac-feed" }
376/// ```
377#[derive(Debug, Clone, PartialEq, Eq)]
378pub struct RevalidateSlot {
379    /// Routes the receiver accepts pokes for (allowlist).
380    /// Empty → all routes in the workload manifest.
381    ///
382    /// Enforced on the node since R752-B7: kamaji renders this list as one
383    /// `--allow-route` per entry on the receiver's argv, `mesofact serve`
384    /// refuses an explicit poke outside it (403) and narrows a whole-site poke
385    /// to it. Before that it was parsed here, shipped over the wire, and read
386    /// by nobody — declaring it bought exactly nothing. Scoping only: `who may
387    /// poke` is `mirror_key_env`, and the two are independent.
388    pub routes: Vec<String>,
389    /// Env var name holding the tenant bearer secret. Deploy resolves it
390    /// and sets `MESOFACT_MIRROR_KEY` on the receiver process.
391    /// `None` → open receiver (no bearer check).
392    pub mirror_key_env: Option<String>,
393    /// Path to `mesofact.config.toml` with the `[publish]` block, relative
394    /// to the workload directory. `None` → default `"mesofact.config.toml"`.
395    pub publish_config: Option<PathBuf>,
396    /// Almanac feed names (`.yah/almanac/<name>.toml`) the on-node fetch tier
397    /// keeps fresh (R330-F31). Empty → no fetcher, and the receiver re-renders
398    /// whatever data the bundle was built with.
399    pub feeds: Vec<String>,
400    /// Seconds between feed-fetch ticks. `None` → the spec default.
401    pub feed_interval_secs: Option<u64>,
402    /// Per-triple path to the `almanac-feed` binary staged into the bundle as a
403    /// sidecar. The self-contained shape's answer to "how does the fetcher
404    /// reach the node".
405    ///
406    /// Mutually exclusive with [`feed_runtime`](Self::feed_runtime), for the
407    /// same reason `serve_bins` and `serve_build` are: the mirror declares
408    /// which shape it is, and a use-whichever-exists fallback would make the
409    /// deployed binary a function of the syncing machine's disk.
410    pub feed_bins: BTreeMap<String, PathBuf>,
411    /// Runtime ref the fetcher resolves from the node's shared runtime-asset
412    /// cache — `feed_runtime = "almanac-feed/0.8.22"` (R746-T3).
413    ///
414    /// This is the **vanilla** shape's answer, and it is what makes a vanilla
415    /// bundle with a feed tier possible at all: `feed_bins` is a path someone
416    /// must have cross-built, so a bundle that carries no serve binary but
417    /// still needs a sidecar path has only moved the toolchain requirement,
418    /// not removed it.
419    pub feed_runtime: Option<String>,
420}
421
422impl RevalidateSlot {
423    /// Build the [`MesofactRevalidateReceiver`] payload for the workload spec,
424    /// given the env vars resolved at deploy time and the feed definitions read
425    /// from the camp's `.yah/almanac/` tree.
426    ///
427    /// Feed definitions travel by value: reading them is the deploy side's job
428    /// (it is the only participant that has the camp checkout), and the node
429    /// gets a self-contained payload.
430    pub fn to_workload_payload(
431        &self,
432        env: BTreeMap<String, String>,
433        feeds: Vec<workload_spec::AlmanacFeed>,
434        feed_project_prefix: Option<String>,
435    ) -> MesofactRevalidateReceiver {
436        MesofactRevalidateReceiver {
437            routes: self.routes.clone(),
438            publish_config: self
439                .publish_config
440                .as_ref()
441                .map(|p| p.to_string_lossy().into_owned())
442                .unwrap_or_else(|| "mesofact.config.toml".to_string()),
443            mirror_key_env: self.mirror_key_env.clone(),
444            env,
445            feeds,
446            feed_interval_secs: self
447                .feed_interval_secs
448                .unwrap_or(DEFAULT_FEED_INTERVAL_SECS),
449            feed_project_prefix,
450            feed_runtime: self.feed_runtime.clone(),
451        }
452    }
453}
454
455/// Mirrors `workload_spec`'s own default. Duplicated rather than exported
456/// because the spec keeps its serde defaults private; the parse tests below
457/// pin the two together.
458pub const DEFAULT_FEED_INTERVAL_SECS: u64 = 300;
459
460/// Bundle path segment the fetch tier's sidecar binary is staged under —
461/// `bins/<triple>/almanac-feed`, next to `bins/<triple>/serve` — and the
462/// filename it lands under in the node runtime-asset cache when a *vanilla*
463/// bundle resolves it by name instead (R746-T3).
464///
465/// Re-exported from `yah_mesofact_bundle` rather than re-typed: this crate and
466/// kamaji both used to declare their own copy, pinned together only by an
467/// argv-shape test. One `const` in the crate they both already depend on
468/// removes the drift instead of detecting it.
469pub use yah_mesofact_bundle::FEED_BIN as FEED_BIN_NAME;
470
471impl BundleSlot {
472    /// Parse the mirror's `[providers.bundle]` slot.
473    ///
474    /// Every failure names the offending field plus the service and env, so the
475    /// operator gets a file to open rather than a type error. Validation is
476    /// total and offline — nothing here touches the network, so a misconfigured
477    /// mirror fails before a build runs (R330-B5 fail-fast discipline).
478    pub fn parse(mirror: &MirrorConfig, service: &str, env: &str) -> Result<Self> {
479        let slot = mirror.providers.get(SLOT_ROLE).with_context(|| {
480            format!(
481                "mirror has no `providers.{SLOT_ROLE}` slot — required for the W272 bundle tier \
482                 (service={service}, env={env})"
483            )
484        })?;
485        let fields = slot.fields();
486
487        // R556-B14. Unknown keys are rejected BEFORE anything is read, so the
488        // operator gets the typo rather than a downstream complaint about the
489        // field the typo was supposed to be. Nearest-match is offered because
490        // the realistic failure is one transposed character, and an error that
491        // only says "unknown" makes the reader diff the docs by eye.
492        for key in fields.keys() {
493            if ALLOWED_SLOT_KEYS.contains(&key.as_str()) {
494                continue;
495            }
496            let hint = nearest_slot_key(key)
497                .map(|k| format!(" — did you mean `{k}`?"))
498                .unwrap_or_default();
499            bail!(
500                "providers.{SLOT_ROLE} has an unknown key `{key}`{hint} (service={service}, \
501                 env={env}). Every key this slot reads is one of: {}. An unrecognized key is \
502                 refused rather than ignored because the failure it hides is silent: a typo'd \
503                 `port` deploys onto whatever bundle already holds the node default, and a \
504                 mistyped credential block deploys a serve process with no credentials at all.",
505                ALLOWED_SLOT_KEYS.join(", "),
506            );
507        }
508
509        let bucket = fields
510            .get("bucket")
511            .and_then(|v| v.as_str())
512            .filter(|s| !s.is_empty())
513            .with_context(|| {
514                format!(
515                    "providers.{SLOT_ROLE} has no `bucket` — name the R2 bundle store in \
516                     .yah/services/{service}/mirrors/{env}.toml"
517                )
518            })?
519            .to_string();
520
521        let account = fields
522            .get("account")
523            .and_then(|v| v.as_str())
524            .filter(|s| !s.is_empty())
525            .map(str::to_string);
526
527        let name = fields
528            .get("name")
529            .and_then(|v| v.as_str())
530            .filter(|s| !s.is_empty())
531            .map(str::to_string);
532
533        let machines = match fields.get("machines") {
534            None => Vec::new(),
535            Some(v) => {
536                let list = v.as_array().with_context(|| {
537                    format!(
538                        "providers.{SLOT_ROLE}.machines must be an array of machine names \
539                         (service={service}, env={env})"
540                    )
541                })?;
542                list.iter()
543                    .map(|entry| {
544                        entry
545                            .as_str()
546                            .filter(|s| !s.is_empty())
547                            .map(str::to_string)
548                            .with_context(|| {
549                                format!(
550                                    "providers.{SLOT_ROLE}.machines holds a non-string (or empty) \
551                                     entry (service={service}, env={env})"
552                                )
553                            })
554                    })
555                    .collect::<Result<Vec<_>>>()?
556            }
557        };
558
559        let runtime_version = fields
560            .get("runtime_version")
561            .and_then(|v| v.as_str())
562            .filter(|s| !s.is_empty())
563            .map(str::to_string);
564
565        let serve_bins = match fields.get("serve_bins") {
566            None => BTreeMap::new(),
567            Some(v) => {
568                let table = v.as_table().with_context(|| {
569                    format!(
570                        "providers.{SLOT_ROLE}.serve_bins must be a table of \
571                         <target-triple> = <path> (service={service}, env={env})"
572                    )
573                })?;
574                table
575                    .iter()
576                    .map(|(triple, path)| {
577                        let path = path.as_str().filter(|s| !s.is_empty()).with_context(|| {
578                            format!(
579                                "providers.{SLOT_ROLE}.serve_bins.{triple} must be a non-empty \
580                                 path (service={service}, env={env})"
581                            )
582                        })?;
583                        Ok((triple.clone(), PathBuf::from(path)))
584                    })
585                    .collect::<Result<BTreeMap<_, _>>>()?
586            }
587        };
588
589        let serve_build = parse_bin_build(
590            fields.get("serve_build"),
591            &format!("providers.{SLOT_ROLE}.serve_build"),
592            service,
593            env,
594        )?;
595
596        if serve_build.is_some() && !serve_bins.is_empty() {
597            bail!(
598                "providers.{SLOT_ROLE} declares BOTH `serve_bins` and `serve_build` — pick one \
599                 (service={service}, env={env}). `serve_bins` names binaries you have already \
600                 built; `serve_build` names the QED recipe that builds them. Accepting both \
601                 would make the deployed binary depend on what happens to be on the syncing \
602                 machine's disk, which is how one operator ships a stale binary while another \
603                 ships a fresh one from the same mirror."
604            );
605        }
606
607        // R599-F12. Parsed strictly: a port is either absent or a real one, and
608        // a typo that silently fell back to 8080 would collide with whatever
609        // bundle already holds that port on the node — a failure that surfaces
610        // as the wrong site being served, not as an error.
611        let port = match fields.get("port") {
612            None => None,
613            Some(v) => {
614                let n = v.as_integer().with_context(|| {
615                    format!(
616                        "providers.{SLOT_ROLE}.port must be an integer TCP port \
617                         (service={service}, env={env})"
618                    )
619                })?;
620                Some(u16::try_from(n).ok().filter(|p| *p != 0).with_context(|| {
621                    format!(
622                        "providers.{SLOT_ROLE}.port = {n} is not a usable TCP port \
623                         (1..=65535) (service={service}, env={env})"
624                    )
625                })?)
626            }
627        };
628
629        // R556-T12. Env for the serve process. Declared as source URIs and
630        // stored verbatim — resolution is the deploy side's job (see the field
631        // docs). Every value is required to be a non-empty string: an empty
632        // source is a var that would silently reach the node unset, which is
633        // the exact failure mode this slot exists to remove.
634        let serve_env = match fields.get("env") {
635            None => BTreeMap::new(),
636            Some(v) => {
637                let table = v.as_table().with_context(|| {
638                    format!(
639                        "providers.{SLOT_ROLE}.env must be a table of <ENV_NAME> = \
640                         \"<source-uri>\" (service={service}, env={env})"
641                    )
642                })?;
643                table
644                    .iter()
645                    .map(|(name, source)| {
646                        let source = source
647                            .as_str()
648                            .filter(|s| !s.trim().is_empty())
649                            .with_context(|| {
650                                format!(
651                                    "providers.{SLOT_ROLE}.env.{name} must be a non-empty source \
652                                     string — \"vault:<slot>\", \"env:<VAR>\", a pipe-joined \
653                                     chain of either, or a bare literal for a non-secret \
654                                     (service={service}, env={env})"
655                                )
656                            })?;
657                        Ok((name.clone(), source.to_string()))
658                    })
659                    .collect::<Result<BTreeMap<_, _>>>()?
660            }
661        };
662
663        let lifecycle = parse_lifecycle(
664            fields.get("lifecycle").and_then(|v| v.as_str()),
665            fields.get("idle_ttl_ms").and_then(|v| v.as_integer()),
666            service,
667            env,
668        )?;
669
670        let revalidate = parse_revalidate_slot(fields, service, env)?;
671
672        let zone = fields
673            .get("zone")
674            .and_then(|v| v.as_str())
675            .filter(|s| !s.is_empty())
676            .map(str::to_string);
677
678        // R703-T7. Parsed strictly rather than `unwrap_or(true)` on a bad type:
679        // `verify_serving = "false"` silently reading as *enabled* is the
680        // friendlier-looking failure, but an operator who typed it believes the
681        // check is off and will be surprised by an apply that fails on a
682        // migration they thought they had silenced.
683        let verify_serving = match fields.get("verify_serving") {
684            None => true,
685            Some(v) => v.as_bool().with_context(|| {
686                format!(
687                    "providers.{SLOT_ROLE}.verify_serving must be a boolean \
688                     (service={service}, env={env})"
689                )
690            })?,
691        };
692
693        // R746-T3: a vanilla bundle carries no `bins/` by construction, so a
694        // sidecar declared as a PATH has nowhere to be staged into. Caught here
695        // rather than at assembly so the operator gets the mirror file and the
696        // remedy, offline, before a build runs.
697        let slot = Self {
698            bucket,
699            account,
700            name,
701            machines,
702            runtime_version,
703            serve_bins,
704            serve_build,
705            lifecycle,
706            port,
707            env: serve_env,
708            revalidate,
709            zone,
710            verify_serving,
711        };
712        if !slot.is_self_contained() {
713            if let Some(rv) = slot.revalidate.as_ref() {
714                if !rv.feed_bins.is_empty() {
715                    anyhow::bail!(
716                        "providers.{SLOT_ROLE}.{REVALIDATE_KEY}.feed_bins is declared but this is \
717                         a VANILLA bundle (no serve_bins / serve_build), which carries no bins/ \
718                         at all — replace it with feed_runtime = \"{FEED_BIN_NAME}/<version>\" \
719                         and publish that asset once per triple with `yah cloud bundle \
720                         publish-runtime` (service={service}, env={env})"
721                    );
722                }
723            }
724        }
725        Ok(slot)
726    }
727
728    /// The zone whose front door a deploy of this bundle is checked against:
729    /// the slot's `zone`, else the service's own domain.
730    pub fn serving_zone<'a>(&'a self, service_domain: &'a str) -> &'a str {
731        self.zone.as_deref().unwrap_or(service_domain)
732    }
733
734    /// Stable workload handle: the slot's `name`, else the service name.
735    pub fn workload_name<'a>(&'a self, service: &'a str) -> &'a str {
736        self.name.as_deref().unwrap_or(service)
737    }
738
739    /// True when the assembled bundle carries its own serve binaries
740    /// (`runtime = "self"`) rather than resolving a stock node runtime asset.
741    ///
742    /// Keyed on the *declaration*, not on what is on disk: a `serve_build` slot
743    /// is self-contained before its binary has ever been built, because the
744    /// mirror said so. Deriving the shape from disk state instead is the bug
745    /// this relay exists to remove — it makes a bundle's shape depend on which
746    /// machine ran the sync.
747    pub fn is_self_contained(&self) -> bool {
748        !self.serve_bins.is_empty() || self.serve_build.is_some()
749    }
750
751    /// Build the `{digest, runtime, lifecycle}` triple a `mesofact-static`
752    /// workload carries once its bundle is published.
753    ///
754    /// `runtime` wire-mirrors `yah_mesofact_bundle::BundleRuntime`, so it is
755    /// taken from the manifest the assembler actually wrote rather than
756    /// re-derived here — the manifest is what the node will verify against.
757    ///
758    /// `env` is the **resolved** serve environment, passed in rather than read
759    /// off `self.env`: this crate holds source URIs, and only the syncing
760    /// machine can turn a `vault:<slot>` into a value. Same by-value handoff
761    /// [`RevalidateSlot::to_workload_payload`] takes, for the same reason —
762    /// the node must never see a keystore slot name (R556-T12).
763    pub fn serve_bundle(
764        &self,
765        digest: &str,
766        runtime: &str,
767        env: BTreeMap<String, String>,
768    ) -> MesofactServeBundle {
769        MesofactServeBundle {
770            digest: BlakeHash(digest.to_string()),
771            runtime: runtime.to_string(),
772            lifecycle: self.lifecycle.clone(),
773            // R599-F12: the slot's declared `port`, or `None` for kamaji's
774            // node-wide default. (@Ashguard:blade parked a `None` here to
775            // unblock the camp's build while this ticket was mid-flight; this
776            // is the real threading it named.)
777            port: self.port,
778            env,
779        }
780    }
781}
782
783/// Closest [`ALLOWED_SLOT_KEYS`] entry to `key`, or `None` when nothing is
784/// close enough to be worth suggesting (R556-B14).
785///
786/// The threshold scales with the key's length — one edit for a short key like
787/// `port`, two for a longer one — so `prot` suggests `port` while an entirely
788/// invented key suggests nothing. A confidently wrong suggestion is worse than
789/// none: it sends the operator to fix a line that was never the problem.
790fn nearest_slot_key(key: &str) -> Option<&'static str> {
791    let budget = if key.len() <= 5 { 1 } else { 2 };
792    ALLOWED_SLOT_KEYS
793        .iter()
794        .map(|candidate| (edit_distance(key, candidate), *candidate))
795        .filter(|(d, _)| *d <= budget)
796        .min()
797        .map(|(_, candidate)| candidate)
798}
799
800/// Optimal string alignment (Damerau-Levenshtein restricted to adjacent
801/// transpositions), three-row DP. Byte-wise: every key in this grammar is
802/// ASCII, and a multi-byte typo is not a case worth carrying a char-vec for.
803///
804/// Transposition counts as ONE edit, not two, and that is the whole reason to
805/// carry the extra row: `prot` for `port` is the motivating typo of R556-B14,
806/// and plain Levenshtein scores it 2 — far enough away that a threshold tight
807/// enough to avoid nonsense suggestions would refuse to suggest the one that
808/// matters.
809fn edit_distance(a: &str, b: &str) -> usize {
810    let (a, b) = (a.as_bytes(), b.as_bytes());
811    let mut prev2 = vec![0usize; b.len() + 1];
812    let mut prev: Vec<usize> = (0..=b.len()).collect();
813    let mut cur = vec![0usize; b.len() + 1];
814    for (i, &ac) in a.iter().enumerate() {
815        cur[0] = i + 1;
816        for (j, &bc) in b.iter().enumerate() {
817            let mut d = (prev[j] + usize::from(ac != bc))
818                .min(prev[j + 1] + 1)
819                .min(cur[j] + 1);
820            if i > 0 && j > 0 && ac == b[j - 1] && a[i - 1] == bc {
821                d = d.min(prev2[j - 1] + 1);
822            }
823            cur[j + 1] = d;
824        }
825        std::mem::swap(&mut prev2, &mut prev);
826        std::mem::swap(&mut prev, &mut cur);
827    }
828    prev[b.len()]
829}
830
831/// Parse a `[…serve_build]`-shaped table into a [`BinBuild`] (R746-F2).
832///
833/// Taken as a helper rather than inlined because the revalidate tier's
834/// `feed_bins` has the identical "a path someone must have built" problem and
835/// will want the identical declaration once a recipe produces `almanac-feed`.
836/// Every message names the full config coordinate so the operator gets a line
837/// to open.
838fn parse_bin_build(
839    value: Option<&toml::Value>,
840    label: &str,
841    service: &str,
842    env: &str,
843) -> Result<Option<BinBuild>> {
844    let Some(value) = value else {
845        return Ok(None);
846    };
847    let table = value.as_table().with_context(|| {
848        format!("{label} must be a table of pipeline/binary/triples (service={service}, env={env})")
849    })?;
850
851    let pipeline = table
852        .get("pipeline")
853        .and_then(|v| v.as_str())
854        .filter(|s| !s.is_empty())
855        .with_context(|| {
856            format!(
857                "{label}.pipeline must name a QED pipeline (.yah/qed/<name>.toml) \
858                 (service={service}, env={env})"
859            )
860        })?
861        .to_string();
862
863    let binary = table
864        .get("binary")
865        .and_then(|v| v.as_str())
866        .filter(|s| !s.is_empty())
867        .with_context(|| {
868            format!(
869                "{label}.binary must name the produced binary — it is matched against the \
870                 pipeline's `[[steps.produces]] binary` (service={service}, env={env})"
871            )
872        })?
873        .to_string();
874
875    let triples = table
876        .get("triples")
877        .and_then(|v| v.as_array())
878        .with_context(|| {
879            format!("{label}.triples must be an array of target triples (service={service}, env={env})")
880        })?
881        .iter()
882        .map(|entry| {
883            entry
884                .as_str()
885                .filter(|s| !s.is_empty())
886                .map(str::to_string)
887                .with_context(|| {
888                    format!("{label}.triples holds a non-string (or empty) entry (service={service}, env={env})")
889                })
890        })
891        .collect::<Result<Vec<_>>>()?;
892
893    if triples.is_empty() {
894        bail!(
895            "{label}.triples is empty — a build declaration that names no target builds \
896             nothing, and the bundle would assemble with no serve binary at all \
897             (service={service}, env={env})"
898        );
899    }
900
901    Ok(Some(BinBuild {
902        pipeline,
903        binary,
904        triples,
905    }))
906}
907
908/// `lifecycle = "keep-alive" | "on-demand"` (+ `idle_ttl_ms` for the latter).
909fn parse_lifecycle(
910    raw: Option<&str>,
911    idle_ttl_ms: Option<i64>,
912    service: &str,
913    env: &str,
914) -> Result<BundleLifecycle> {
915    match raw.unwrap_or("keep-alive") {
916        "keep-alive" | "keepalive" => {
917            if idle_ttl_ms.is_some() {
918                bail!(
919                    "providers.{SLOT_ROLE}.idle_ttl_ms only applies to `lifecycle = \"on-demand\"` \
920                     — a keep-alive bundle is never reaped (service={service}, env={env})"
921                );
922            }
923            Ok(BundleLifecycle::KeepAlive)
924        }
925        "on-demand" | "ondemand" | "jit" => {
926            let ttl = idle_ttl_ms.unwrap_or(DEFAULT_IDLE_TTL_MS as i64);
927            if ttl <= 0 {
928                bail!(
929                    "providers.{SLOT_ROLE}.idle_ttl_ms must be positive, got {ttl} \
930                     (service={service}, env={env})"
931                );
932            }
933            Ok(BundleLifecycle::OnDemand {
934                idle_ttl: Millis::from_ms(ttl as u64),
935            })
936        }
937        other => bail!(
938            "providers.{SLOT_ROLE}.lifecycle must be \"keep-alive\" or \"on-demand\", got \
939             {other:?} (service={service}, env={env})"
940        ),
941    }
942}
943
944/// Parse the optional `[providers.bundle.revalidate]` sub-table.
945///
946/// `None` → no revalidate receiver declared (the common case). `Some` → the
947/// deploy also stands up a `mesofact serve --revalidate` process.
948fn parse_revalidate_slot(
949    fields: &BTreeMap<String, toml::Value>,
950    service: &str,
951    env: &str,
952) -> Result<Option<RevalidateSlot>> {
953    let sub = match fields.get(REVALIDATE_KEY) {
954        None => return Ok(None),
955        Some(v) => v.as_table().with_context(|| {
956            format!(
957                "providers.{SLOT_ROLE}.{REVALIDATE_KEY} must be a TOML table \
958                     (service={service}, env={env})"
959            )
960        })?,
961    };
962
963    let routes = match sub.get("routes") {
964        None => Vec::new(),
965        Some(v) => {
966            let list = v.as_array().with_context(|| {
967                format!(
968                    "providers.{SLOT_ROLE}.{REVALIDATE_KEY}.routes must be an array of route \
969                     patterns (service={service}, env={env})"
970                )
971            })?;
972            list.iter()
973                .map(|entry| {
974                    entry
975                        .as_str()
976                        .filter(|s| !s.is_empty())
977                        .map(str::to_string)
978                        .with_context(|| {
979                            format!(
980                                "providers.{SLOT_ROLE}.{REVALIDATE_KEY}.routes holds a non-string \
981                                 (or empty) entry (service={service}, env={env})"
982                            )
983                        })
984                })
985                .collect::<Result<Vec<_>>>()?
986        }
987    };
988
989    let mirror_key_env = sub
990        .get("mirror_key_env")
991        .and_then(|v| v.as_str())
992        .filter(|s| !s.is_empty())
993        .map(str::to_string);
994
995    let publish_config = sub
996        .get("publish_config")
997        .and_then(|v| v.as_str())
998        .filter(|s| !s.is_empty())
999        .map(PathBuf::from);
1000
1001    // ── Feed-fetch tier (R330-F31) ──────────────────────────────────────────
1002    let feeds = match sub.get("feeds") {
1003        None => Vec::new(),
1004        Some(v) => {
1005            let list = v.as_array().with_context(|| {
1006                format!(
1007                    "providers.{SLOT_ROLE}.{REVALIDATE_KEY}.feeds must be an array of almanac \
1008                     feed names (service={service}, env={env})"
1009                )
1010            })?;
1011            list.iter()
1012                .map(|entry| {
1013                    entry
1014                        .as_str()
1015                        .filter(|s| !s.is_empty())
1016                        .map(str::to_string)
1017                        .with_context(|| {
1018                            format!(
1019                                "providers.{SLOT_ROLE}.{REVALIDATE_KEY}.feeds holds a non-string \
1020                                 (or empty) entry (service={service}, env={env})"
1021                            )
1022                        })
1023                })
1024                .collect::<Result<Vec<_>>>()?
1025        }
1026    };
1027
1028    let feed_interval_secs = match sub.get("feed_interval_secs") {
1029        None => None,
1030        Some(v) => {
1031            let secs = v.as_integer().filter(|n| *n > 0).with_context(|| {
1032                format!(
1033                    "providers.{SLOT_ROLE}.{REVALIDATE_KEY}.feed_interval_secs must be a positive \
1034                     integer number of seconds (service={service}, env={env})"
1035                )
1036            })?;
1037            Some(secs as u64)
1038        }
1039    };
1040
1041    let feed_bins = match sub.get("feed_bins") {
1042        None => BTreeMap::new(),
1043        Some(v) => {
1044            let table = v.as_table().with_context(|| {
1045                format!(
1046                    "providers.{SLOT_ROLE}.{REVALIDATE_KEY}.feed_bins must be a table of \
1047                     <target-triple> = <path> (service={service}, env={env})"
1048                )
1049            })?;
1050            table
1051                .iter()
1052                .map(|(triple, path)| {
1053                    let p = path.as_str().filter(|s| !s.is_empty()).with_context(|| {
1054                        format!(
1055                            "providers.{SLOT_ROLE}.{REVALIDATE_KEY}.feed_bins.{triple} must be \
1056                                 a non-empty path string (service={service}, env={env})"
1057                        )
1058                    })?;
1059                    Ok((triple.clone(), PathBuf::from(p)))
1060                })
1061                .collect::<Result<BTreeMap<_, _>>>()?
1062        }
1063    };
1064
1065    // R746-T3: the vanilla shape's fetcher. A ref, not a path — the node
1066    // resolves it from the shared runtime-asset cache the same way it resolves
1067    // `serve`, so no cross-built binary has to exist on the syncing machine.
1068    let feed_runtime = match sub.get("feed_runtime") {
1069        None => None,
1070        Some(v) => {
1071            let s = v.as_str().filter(|s| !s.is_empty()).with_context(|| {
1072                format!(
1073                    "providers.{SLOT_ROLE}.{REVALIDATE_KEY}.feed_runtime must be a non-empty \
1074                     runtime reference like \"{FEED_BIN_NAME}/0.8.22\" (service={service}, \
1075                     env={env})"
1076                )
1077            })?;
1078            // Parse offline so a typo fails the apply with a file to open,
1079            // rather than a node failing to resolve it twenty minutes later.
1080            yah_mesofact_bundle::RuntimeRef::parse(s).with_context(|| {
1081                format!(
1082                    "providers.{SLOT_ROLE}.{REVALIDATE_KEY}.feed_runtime (service={service}, \
1083                     env={env})"
1084                )
1085            })?;
1086            Some(s.to_string())
1087        }
1088    };
1089
1090    // Declared, never inferred — the same rule serve_bins/serve_build follow.
1091    // "Use the path if it happens to exist, else the ref" would make the
1092    // deployed fetcher a function of the syncing machine's disk.
1093    if !feed_bins.is_empty() && feed_runtime.is_some() {
1094        anyhow::bail!(
1095            "providers.{SLOT_ROLE}.{REVALIDATE_KEY} declares BOTH feed_bins and feed_runtime — \
1096             pick one: feed_bins stages the `{FEED_BIN_NAME}` fetcher into the bundle (the \
1097             self-contained shape), feed_runtime resolves it from the node's runtime-asset \
1098             cache (the vanilla shape) (service={service}, env={env})"
1099        );
1100    }
1101
1102    // Declaring feeds without shipping the fetcher is the failure that looks
1103    // like success: the deploy goes green, the receiver serves, and the data
1104    // never moves again. Catch it here, offline, with the file to edit.
1105    if !feeds.is_empty() && feed_bins.is_empty() && feed_runtime.is_none() {
1106        anyhow::bail!(
1107            "providers.{SLOT_ROLE}.{REVALIDATE_KEY}.feeds declares {} feed(s) but neither \
1108             feed_bins nor feed_runtime — the node has no way to get the `{FEED_BIN_NAME}` \
1109             fetcher, so nothing would ever refresh them (service={service}, env={env})",
1110            feeds.len()
1111        );
1112    }
1113
1114    Ok(Some(RevalidateSlot {
1115        routes,
1116        mirror_key_env,
1117        publish_config,
1118        feeds,
1119        feed_interval_secs,
1120        feed_bins,
1121        feed_runtime,
1122    }))
1123}
1124
1125/// Resolve the machines a published bundle deploys to, in deploy order.
1126///
1127/// Two declaration forms, checked in that order:
1128/// 1. `machines = ["us-east-001", …]` — explicit, ordered, and the shape to
1129///    prefer while a bundle binds loopback (F10: one bundle per node, passway
1130///    co-located), because *which* nodes serve is then an operator decision
1131///    rather than a scheduler outcome.
1132/// 2. `required = { regions = […], mesh_tags = […], replicas = N }` — F16
1133///    placement. Resolves to the first `N` machines the constraint matches
1134///    (`replicas` absent = one, the only shape on disk before R844-F8).
1135///
1136/// An undeclared / unresolvable placement is an error, not an empty deploy —
1137/// silently publishing a bundle nobody serves is the failure mode this avoids.
1138/// So is a *short* one: `replicas = 2` matching a single machine fails here
1139/// rather than deploying one copy, because the front door would then publish a
1140/// hostname whose backend set is quietly half of what the mirror declared.
1141///
1142/// **R844-F8: the constraint arm shares its selector with the ingress
1143/// planner's.** [`CloudConfig::resolve_machines`] and
1144/// [`super::ingress::resolve_ingress_placements`] both bottom out in the same
1145/// N-selecting `select_matching` over the same `cfg.machines` slice, so the
1146/// deployer and the discovery fanout cannot pick different subsets of a
1147/// scale-N placement. The `machines = [...]` arm above needs no such
1148/// guarantee — the planner reads that literal list off the slot directly.
1149pub fn resolve_bundle_machines<'a>(
1150    cfg: &'a CloudConfig,
1151    mirror: &MirrorConfig,
1152    slot: &BundleSlot,
1153    service: &str,
1154    env: &str,
1155) -> Result<Vec<&'a crate::MachineConfig>> {
1156    if !slot.machines.is_empty() {
1157        return slot
1158            .machines
1159            .iter()
1160            .map(|name| {
1161                cfg.machine(name).with_context(|| {
1162                    format!(
1163                        "providers.{SLOT_ROLE}.machines names {name:?}, which is not declared in \
1164                         .yah/infra/machines/ (service={service}, env={env})"
1165                    )
1166                })
1167            })
1168            .collect();
1169    }
1170
1171    let required = mirror
1172        .providers
1173        .get(SLOT_ROLE)
1174        .and_then(|s| s.required())
1175        .filter(|r| !r.is_unconstrained())
1176        .with_context(|| {
1177            format!(
1178                "providers.{SLOT_ROLE} declares neither `machines = [...]` nor a constrained \
1179                 `required = {{ … }}` placement — a bundle must name the nodes that serve it \
1180                 (service={service}, env={env})"
1181            )
1182        })?;
1183
1184    cfg.resolve_machines(&required).with_context(|| {
1185        format!(
1186            "F16 placement: cannot place providers.{SLOT_ROLE}.required ({}) onto {} machine(s) \
1187             — check .yah/services/{service}/mirrors/{env}.toml against .yah/infra/machines/*.toml",
1188            required.describe(),
1189            required.replica_count(),
1190        )
1191    })
1192}
1193
1194/// Desktop-side (offline) half of the bundle tier: validate the mirror's
1195/// declaration and bail with a pointer at the CLI.
1196///
1197/// The real chain — build, assemble, publish, deploy — runs at the apply layer
1198/// where [`CloudConfig`] is in hand. This exists so a desktop bring-up of a
1199/// bundle-tier mirror reports a *configuration* verdict instead of "no
1200/// reconciler wired".
1201pub struct MesofactBundleReconciler;
1202
1203impl MesofactBundleReconciler {
1204    pub fn new() -> Self {
1205        Self
1206    }
1207}
1208
1209impl Default for MesofactBundleReconciler {
1210    fn default() -> Self {
1211        Self::new()
1212    }
1213}
1214
1215#[async_trait]
1216impl Reconciler for MesofactBundleReconciler {
1217    fn kind(&self) -> &'static str {
1218        super::mesofact_static::WORKLOAD_KIND
1219    }
1220
1221    async fn up(&self, ctx: ReconcileCtx<'_>) -> Result<RunningWorkload> {
1222        let slot = BundleSlot::parse(ctx.mirror, &ctx.service.name, ctx.env)?;
1223        // Name the DECLARED shape, not a count. R746-F2 added a third shape, and
1224        // a bare `0 serve binaries` reads identically for "vanilla, resolves the
1225        // node's stock runtime" and "self-contained, builds its binary on
1226        // demand" — two different deploys.
1227        let shape = match (&slot.serve_build, slot.serve_bins.len()) {
1228            (Some(build), _) => format!(
1229                "self-contained, serve binary built by QED recipe `{}` for [{}]",
1230                build.pipeline,
1231                build.triples.join(", "),
1232            ),
1233            (None, 0) => format!(
1234                "vanilla, node resolves runtime mesofact/{}",
1235                slot.runtime_version.as_deref().unwrap_or("<caller version>"),
1236            ),
1237            (None, n) => format!("self-contained, {n} declared serve binary path(s)"),
1238        };
1239        bail!(
1240            "bundle tier validated (bucket={}, workload={}, {shape}) for service={}, \
1241             env={}, but the sync arm runs at the apply layer — deploy with \
1242             `yah cloud mirror up {} --env {}` (machine placement needs the workspace's \
1243             machine set, which a desktop bring-up does not load)",
1244            slot.bucket,
1245            slot.workload_name(&ctx.service.name),
1246            ctx.service.name,
1247            ctx.env,
1248            ctx.service.name,
1249            ctx.env,
1250        )
1251    }
1252}
1253
1254#[cfg(test)]
1255mod tests {
1256    use super::*;
1257    use crate::config::{MachineConfig, MirrorProviderSlot, MirrorShape, TopologyConfig};
1258    use std::path::PathBuf;
1259
1260    fn mirror_from(slots: BTreeMap<String, MirrorProviderSlot>) -> MirrorConfig {
1261        MirrorConfig {
1262            schema_version: 1,
1263            shape: MirrorShape::SingleMachine,
1264            providers: slots,
1265            ingress: Default::default(),
1266            ingress_machines: Vec::new(),
1267            drivers: Default::default(),
1268            asset_aliases: BTreeMap::new(),
1269        }
1270    }
1271
1272    /// Build a mirror whose `[providers.bundle]` slot is exactly `slot_toml`.
1273    fn mirror_with(slot_toml: &str) -> MirrorConfig {
1274        let slot: MirrorProviderSlot = toml::from_str(slot_toml).unwrap();
1275        let mut providers = BTreeMap::new();
1276        providers.insert(SLOT_ROLE.to_string(), slot);
1277        mirror_from(providers)
1278    }
1279
1280    fn machine(name: &str, region: &str) -> MachineConfig {
1281        MachineConfig {
1282            name: name.into(),
1283            provider: "static".into(),
1284            location: None,
1285            server_type: None,
1286            hosts_mirrors: vec![],
1287            mesh_tags: vec![],
1288            region: Some(region.into()),
1289            zone: None,
1290            arch: Some("x86_64".into()),
1291            bucket: None,
1292            vendor: None,
1293            nickname: None,
1294            legacy_hostkey_fingerprint: None,
1295            registration: Default::default(),
1296            ssh_keys: vec![],
1297            cloudflared: None,
1298            hosts_operator_bridge: false,
1299            connect: None,
1300            allocatable: None,
1301            taints: vec![],
1302            sovereign_group: None,
1303            sovereign_role: None,
1304        }
1305    }
1306
1307    fn cfg_with(machines: Vec<MachineConfig>) -> CloudConfig {
1308        CloudConfig {
1309            workspace_root: PathBuf::new(),
1310            machines,
1311            providers: vec![],
1312            machine_origins: BTreeMap::new(),
1313            provider_origins: BTreeMap::new(),
1314            services: BTreeMap::new(),
1315            domains: BTreeMap::new(),
1316            legacy_mirrors: vec![],
1317            workloads: vec![],
1318            topology: TopologyConfig::default(),
1319            legacy_services: vec![],
1320        }
1321    }
1322
1323    #[test]
1324    fn slot_declared_keys_off_the_bundle_role() {
1325        assert!(slot_declared(&mirror_with(
1326            r#"use = "cloudflare"
1327bucket = "b""#
1328        )));
1329        assert!(!slot_declared(&mirror_from(BTreeMap::new())));
1330    }
1331
1332    /// R330-B43 regression pin. This is the exact shape that froze yah.dev:
1333    /// a fully-valid `[providers.bundle]` slot whose serve binary was never
1334    /// built. `slot_declared` says yes (it only reads config), so dispatching
1335    /// on it alone handed the component to a tier that could not come up while
1336    /// taking the working static chain out of the picture. `slot_ready` is what
1337    /// the dispatch gate must ask instead.
1338    #[test]
1339    fn a_declared_slot_whose_serve_bin_is_absent_is_not_ready() {
1340        let root = tempfile::tempdir().unwrap();
1341        let mirror = mirror_with(
1342            r#"
1343use = "cloudflare"
1344bucket = "yah-dev"
1345
1346[serve_bins]
1347x86_64-unknown-linux-musl = "target/x86_64-unknown-linux-musl/release/mesofact"
1348"#,
1349        );
1350        let slot = BundleSlot::parse(&mirror, "yah-marketing", "cloud").unwrap();
1351
1352        assert!(slot_declared(&mirror), "config declares the slot");
1353        assert!(
1354            !slot_ready(&slot, root.path()),
1355            "but it cannot serve — the binary does not exist"
1356        );
1357
1358        let missing = missing_bins(&slot, root.path());
1359        assert_eq!(missing.len(), 1);
1360        assert_eq!(
1361            missing[0].0, "providers.bundle.serve_bins.x86_64-unknown-linux-musl",
1362            "the label must name the exact config line to fix"
1363        );
1364    }
1365
1366    #[test]
1367    fn a_slot_becomes_ready_once_its_bins_exist() {
1368        let root = tempfile::tempdir().unwrap();
1369        let bin = root.path().join("target/x86_64-unknown-linux-musl/release");
1370        std::fs::create_dir_all(&bin).unwrap();
1371        std::fs::write(bin.join("mesofact"), b"#!/bin/sh\n").unwrap();
1372
1373        let mirror = mirror_with(
1374            r#"
1375use = "cloudflare"
1376bucket = "yah-dev"
1377
1378[serve_bins]
1379x86_64-unknown-linux-musl = "target/x86_64-unknown-linux-musl/release/mesofact"
1380"#,
1381        );
1382        let slot = BundleSlot::parse(&mirror, "yah-marketing", "cloud").unwrap();
1383        assert!(slot_ready(&slot, root.path()));
1384        assert!(missing_bins(&slot, root.path()).is_empty());
1385    }
1386
1387    /// R746-F2. The shape B43 could not express: self-contained, declared, and
1388    /// buildable *from any machine* — so it is ready without anyone having a
1389    /// binary on disk, and there is no `missing:` line to print because nothing
1390    /// was ever promised to be there.
1391    #[test]
1392    fn a_serve_build_slot_is_self_contained_and_ready_with_no_binary_on_disk() {
1393        let root = tempfile::tempdir().unwrap();
1394        let mirror = mirror_with(
1395            r#"
1396use = "cloudflare"
1397bucket = "yah-dev"
1398
1399[serve_build]
1400pipeline = "mesofact-musl"
1401binary = "mesofact"
1402triples = ["x86_64-unknown-linux-musl"]
1403"#,
1404        );
1405        let slot = BundleSlot::parse(&mirror, "yah-marketing", "cloud").unwrap();
1406
1407        let build = slot.serve_build.as_ref().expect("serve_build parsed");
1408        assert_eq!(build.pipeline, "mesofact-musl");
1409        assert_eq!(build.binary, "mesofact");
1410        assert_eq!(build.triples, vec!["x86_64-unknown-linux-musl".to_string()]);
1411
1412        assert!(slot.is_self_contained(), "declared shape, not disk state");
1413        assert!(slot_ready(&slot, root.path()));
1414        assert!(missing_bins(&slot, root.path()).is_empty());
1415    }
1416
1417    /// R746-F2 verify #1, at the only layer that can pin it offline: a vanilla
1418    /// slot carries no build declaration at all, so the sync has nothing to
1419    /// dispatch. The cheapness of the vanilla path is structural, not a
1420    /// heuristic someone has to keep true.
1421    #[test]
1422    fn a_vanilla_slot_declares_no_build_so_a_sync_has_nothing_to_dispatch() {
1423        let mirror = mirror_with(
1424            r#"
1425use = "cloudflare"
1426bucket = "yah-dev"
1427runtime_version = "0.8.22"
1428"#,
1429        );
1430        let slot = BundleSlot::parse(&mirror, "yah-marketing", "cloud").unwrap();
1431        assert!(slot.serve_build.is_none());
1432        assert!(slot.serve_bins.is_empty());
1433        assert!(!slot.is_self_contained());
1434        assert_eq!(slot.runtime_version.as_deref(), Some("0.8.22"));
1435    }
1436
1437    /// The shape must stay DECLARED, never derived — so the two ways of naming
1438    /// a serve binary are mutually exclusive rather than one falling back to
1439    /// the other. A fallback would make the deployed binary a function of the
1440    /// syncing machine's disk.
1441    #[test]
1442    fn serve_bins_and_serve_build_together_are_refused() {
1443        let mirror = mirror_with(
1444            r#"
1445use = "cloudflare"
1446bucket = "yah-dev"
1447
1448[serve_bins]
1449x86_64-unknown-linux-musl = "some/path/mesofact"
1450
1451[serve_build]
1452pipeline = "mesofact-musl"
1453binary = "mesofact"
1454triples = ["x86_64-unknown-linux-musl"]
1455"#,
1456        );
1457        let err = BundleSlot::parse(&mirror, "yah-marketing", "cloud").unwrap_err();
1458        let msg = err.to_string();
1459        assert!(msg.contains("BOTH `serve_bins` and `serve_build`"), "{msg}");
1460    }
1461
1462    /// Each field is load-bearing, so each absence is refused by name rather
1463    /// than defaulted into a build that produces nothing.
1464    #[test]
1465    fn a_serve_build_missing_a_field_is_refused_naming_the_coordinate() {
1466        let cases = [
1467            (
1468                r#"[serve_build]
1469binary = "mesofact"
1470triples = ["x86_64-unknown-linux-musl"]"#,
1471                "serve_build.pipeline",
1472            ),
1473            (
1474                r#"[serve_build]
1475pipeline = "mesofact-musl"
1476triples = ["x86_64-unknown-linux-musl"]"#,
1477                "serve_build.binary",
1478            ),
1479            (
1480                r#"[serve_build]
1481pipeline = "mesofact-musl"
1482binary = "mesofact""#,
1483                "serve_build.triples",
1484            ),
1485            (
1486                r#"[serve_build]
1487pipeline = "mesofact-musl"
1488binary = "mesofact"
1489triples = []"#,
1490                "serve_build.triples is empty",
1491            ),
1492        ];
1493        for (fragment, expected) in cases {
1494            let mirror = mirror_with(&format!(
1495                "use = \"cloudflare\"\nbucket = \"yah-dev\"\n\n{fragment}\n"
1496            ));
1497            let err = BundleSlot::parse(&mirror, "yah-marketing", "cloud").unwrap_err();
1498            let msg = format!("{err:#}");
1499            assert!(
1500                msg.contains(expected),
1501                "expected {expected:?} in error, got: {msg}"
1502            );
1503        }
1504    }
1505
1506    /// A declared feed tier is part of "can it serve" — R330-F31 stages the
1507    /// fetcher as a sidecar, so a missing feed_bin strands the feed tier the
1508    /// same way a missing serve_bin strands the server.
1509    #[test]
1510    fn a_missing_feed_bin_also_blocks_readiness() {
1511        let root = tempfile::tempdir().unwrap();
1512        let bin = root.path().join("target/musl");
1513        std::fs::create_dir_all(&bin).unwrap();
1514        std::fs::write(bin.join("mesofact"), b"x").unwrap();
1515
1516        let mirror = mirror_with(
1517            r#"
1518use = "cloudflare"
1519bucket = "yah-dev"
1520
1521[serve_bins]
1522x86_64-unknown-linux-musl = "target/musl/mesofact"
1523
1524[revalidate]
1525routes = ["/releases"]
1526feeds = ["releases"]
1527
1528[revalidate.feed_bins]
1529x86_64-unknown-linux-musl = "target/musl/almanac-feed"
1530"#,
1531        );
1532        let slot = BundleSlot::parse(&mirror, "yah-marketing", "cloud").unwrap();
1533        let missing = missing_bins(&slot, root.path());
1534        assert_eq!(missing.len(), 1, "only the feed binary is absent");
1535        assert!(missing[0].0.contains("revalidate.feed_bins"));
1536        assert!(!slot_ready(&slot, root.path()));
1537    }
1538
1539    /// A vanilla-runtime slot declares no binaries at all. That is a different
1540    /// shape, not a half-built one, so it stays "ready" here and fails later
1541    /// with its own specific message rather than being silently downgraded.
1542    #[test]
1543    fn a_vanilla_slot_declaring_no_bins_is_ready() {
1544        let root = tempfile::tempdir().unwrap();
1545        let mirror = mirror_with(
1546            r#"use = "cloudflare"
1547bucket = "b""#,
1548        );
1549        let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
1550        assert!(!slot.is_self_contained());
1551        assert!(slot_ready(&slot, root.path()));
1552    }
1553
1554    #[test]
1555    fn parses_a_self_contained_keep_alive_slot() {
1556        let mirror = mirror_with(
1557            r#"
1558use = "cloudflare"
1559bucket = "yah-dev-bundles"
1560machines = ["us-east-001"]
1561name = "yah-marketing"
1562
1563[serve_bins]
1564x86_64-unknown-linux-musl = "target/x86_64-unknown-linux-musl/release/mesofact-serve"
1565"#,
1566        );
1567        let slot = BundleSlot::parse(&mirror, "yah-marketing", "ha").unwrap();
1568        assert_eq!(slot.bucket, "yah-dev-bundles");
1569        assert_eq!(slot.machines, vec!["us-east-001".to_string()]);
1570        assert_eq!(slot.workload_name("yah-marketing"), "yah-marketing");
1571        assert!(slot.is_self_contained());
1572        assert_eq!(slot.lifecycle, BundleLifecycle::KeepAlive);
1573    }
1574
1575    #[test]
1576    fn workload_name_falls_back_to_the_service_name() {
1577        let mirror = mirror_with(
1578            r#"use = "cloudflare"
1579bucket = "b""#,
1580        );
1581        let slot = BundleSlot::parse(&mirror, "scrabcake", "ha").unwrap();
1582        assert_eq!(slot.workload_name("scrabcake"), "scrabcake");
1583        assert!(!slot.is_self_contained());
1584    }
1585
1586    #[test]
1587    fn on_demand_takes_the_default_idle_ttl() {
1588        let mirror = mirror_with(
1589            r#"use = "cloudflare"
1590bucket = "b"
1591lifecycle = "on-demand""#,
1592        );
1593        let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
1594        assert_eq!(
1595            slot.lifecycle,
1596            BundleLifecycle::OnDemand {
1597                idle_ttl: Millis::from_ms(DEFAULT_IDLE_TTL_MS)
1598            }
1599        );
1600    }
1601
1602    #[test]
1603    fn on_demand_honors_an_explicit_idle_ttl() {
1604        let mirror = mirror_with(
1605            r#"use = "cloudflare"
1606bucket = "b"
1607lifecycle = "on-demand"
1608idle_ttl_ms = 15000"#,
1609        );
1610        let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
1611        assert_eq!(
1612            slot.lifecycle,
1613            BundleLifecycle::OnDemand {
1614                idle_ttl: Millis::from_ms(15_000)
1615            }
1616        );
1617    }
1618
1619    /// An idle TTL on a keep-alive bundle is a config mistake that would
1620    /// otherwise be silently ignored — the process is never reaped.
1621    #[test]
1622    fn idle_ttl_on_a_keep_alive_slot_is_rejected() {
1623        let mirror = mirror_with(
1624            r#"use = "cloudflare"
1625bucket = "b"
1626idle_ttl_ms = 15000"#,
1627        );
1628        let err = BundleSlot::parse(&mirror, "s", "e")
1629            .unwrap_err()
1630            .to_string();
1631        assert!(err.contains("idle_ttl_ms"), "{err}");
1632        assert!(err.contains("on-demand"), "{err}");
1633    }
1634
1635    #[test]
1636    fn unknown_lifecycle_names_the_legal_values() {
1637        let mirror = mirror_with(
1638            r#"use = "cloudflare"
1639bucket = "b"
1640lifecycle = "serverless""#,
1641        );
1642        let err = BundleSlot::parse(&mirror, "s", "e")
1643            .unwrap_err()
1644            .to_string();
1645        assert!(err.contains("keep-alive"), "{err}");
1646        assert!(err.contains("on-demand"), "{err}");
1647    }
1648
1649    /// R599-F12: the declared serving port reaches the workload spec. Without
1650    /// it every bundle rides kamaji's node-wide default, so a node can host
1651    /// exactly one.
1652    #[test]
1653    fn a_declared_port_reaches_the_serve_bundle() {
1654        let slot = BundleSlot::parse(
1655            &mirror_with(
1656                r#"use = "cloudflare"
1657bucket = "b"
1658port = 8081"#,
1659            ),
1660            "s",
1661            "e",
1662        )
1663        .unwrap();
1664        assert_eq!(slot.port, Some(8081));
1665        assert_eq!(
1666            slot.serve_bundle("a".repeat(64).as_str(), "self", BTreeMap::new())
1667                .port,
1668            Some(8081)
1669        );
1670
1671        // Absent → kamaji's node default, the pre-R599-F12 behaviour.
1672        let bare = BundleSlot::parse(
1673            &mirror_with(
1674                r#"use = "cloudflare"
1675bucket = "b""#,
1676            ),
1677            "s",
1678            "e",
1679        )
1680        .unwrap();
1681        assert_eq!(bare.port, None);
1682        assert_eq!(
1683            bare.serve_bundle("a".repeat(64).as_str(), "self", BTreeMap::new())
1684                .port,
1685            None
1686        );
1687    }
1688
1689    /// R556-B14: a misspelled key fails the parse naming itself, rather than
1690    /// deploying a wrong-but-plausible workload.
1691    ///
1692    /// `prot = 8081` is the motivating instance: it parses clean today, the
1693    /// port falls back to kamaji's node-wide default, and post-R599-F12 that
1694    /// default is whatever OTHER bundle already holds 8080 on the node. The
1695    /// operator sees the wrong site served, with nothing in any log naming the
1696    /// typo.
1697    #[test]
1698    fn an_unknown_slot_key_is_rejected_naming_the_key() {
1699        let err = BundleSlot::parse(
1700            &mirror_with(
1701                r#"use = "cloudflare"
1702bucket = "b"
1703prot = 8081"#,
1704            ),
1705            "yah-marketing",
1706            "cloud",
1707        )
1708        .unwrap_err()
1709        .to_string();
1710        assert!(err.contains("prot"), "the error must name the typo: {err}");
1711        assert!(
1712            err.contains("did you mean `port`"),
1713            "one transposed character is the realistic failure — suggest the \
1714             fix rather than making the operator diff the docs: {err}"
1715        );
1716    }
1717
1718    /// The suggester's distance metric counts a transposition as ONE edit.
1719    /// Plain Levenshtein scores `prot`→`port` at 2, which is far enough away
1720    /// that any threshold tight enough to suppress nonsense suggestions would
1721    /// also suppress the single typo this ticket was filed about.
1722    #[test]
1723    fn the_key_suggester_treats_a_transposition_as_one_edit() {
1724        assert_eq!(edit_distance("prot", "port"), 1);
1725        assert_eq!(edit_distance("bukcet", "bucket"), 1);
1726        assert_eq!(nearest_slot_key("prot"), Some("port"));
1727        assert_eq!(nearest_slot_key("bucket"), Some("bucket"));
1728        assert_eq!(nearest_slot_key("zzzzzzzzzzzzzz"), None);
1729    }
1730
1731    /// No suggestion when nothing is close. A confidently wrong hint sends the
1732    /// operator to edit a line that was never the problem.
1733    #[test]
1734    fn an_unrecognizable_slot_key_is_rejected_without_a_bogus_suggestion() {
1735        let err = BundleSlot::parse(
1736            &mirror_with(
1737                r#"use = "cloudflare"
1738bucket = "b"
1739ingress_tunnel_hostname = "analytics.yah.dev""#,
1740            ),
1741            "yah-analytics",
1742            "cloud",
1743        )
1744        .unwrap_err()
1745        .to_string();
1746        assert!(err.contains("ingress_tunnel_hostname"), "{err}");
1747        assert!(!err.contains("did you mean"), "{err}");
1748    }
1749
1750    /// R556-B14's regression criterion: the allowed set is the UNION of every
1751    /// consumer's reads, not just `BundleSlot::parse`'s. `plan_ingress` reads
1752    /// `machine` / `machines` / `port` / `upstream_host` off this same table
1753    /// and `MirrorProviderSlot::required` reads `required` — scoping the set to
1754    /// one consumer would reject the live yah-marketing mirror, which carries
1755    /// `upstream_host`.
1756    #[test]
1757    fn keys_read_by_other_consumers_of_this_slot_are_allowed() {
1758        // Every non-comment key of .yah/services/yah-marketing/mirrors/cloud.toml's
1759        // [providers.bundle] block, as of R556-B14.
1760        let slot = BundleSlot::parse(
1761            &mirror_with(
1762                r#"use = "cloudflare"
1763verify_serving = false
1764bucket = "yah-dev"
1765name = "yah-marketing"
1766machines = ["us-east-001"]
1767port = 8080
1768zone = "yah.dev"
1769upstream_host = "100.64.0.3"
1770lifecycle = "keep-alive"
1771runtime_version = "0.8.23"
1772
1773[revalidate]
1774routes = ["/releases", "/issues"]
1775mirror_key_env = "YAH_MARKETING_MIRROR_KEY"
1776feeds = ["releases", "yah-desktop"]
1777feed_interval_secs = 5
1778feed_runtime = "almanac-feed/0.8.22""#,
1779            ),
1780            "yah-marketing",
1781            "cloud",
1782        )
1783        .unwrap();
1784        assert_eq!(slot.bucket, "yah-dev");
1785        assert_eq!(slot.port, Some(8080));
1786        assert!(!slot.verify_serving);
1787
1788        // …and the F16 placement form, whose `required` is read through the
1789        // slot rather than by `parse`.
1790        BundleSlot::parse(
1791            &mirror_with(
1792                r#"use = "cloudflare"
1793bucket = "yah-dev"
1794
1795[required]
1796regions = ["us-east"]"#,
1797            ),
1798            "s",
1799            "e",
1800        )
1801        .unwrap();
1802
1803        // …and R844-F5's portless shape: `fronted = true` with no `port`. This
1804        // is the same union rule one ticket later — the key is read only by
1805        // `plan_ingress`, but it is declared on THIS table, so rejecting it here
1806        // would have made the pin deletion R844-F5 exists to enable fail the
1807        // apply rather than land as a no-op.
1808        let portless = BundleSlot::parse(
1809            &mirror_with(
1810                r#"use = "cloudflare"
1811bucket = "yah-dev"
1812zone = "yah.dev"
1813fronted = true"#,
1814            ),
1815            "s",
1816            "e",
1817        )
1818        .unwrap();
1819        assert_eq!(portless.port, None);
1820    }
1821
1822    /// R556-T12: `[providers.bundle.env]` parses into source URIs, kept
1823    /// verbatim. Resolution is deliberately NOT done here — this crate is
1824    /// offline by construction and only the syncing machine holds the vault.
1825    #[test]
1826    fn env_sources_are_parsed_verbatim_and_not_resolved() {
1827        let slot = BundleSlot::parse(
1828            &mirror_with(
1829                r#"use = "cloudflare"
1830bucket = "b"
1831
1832[env]
1833ANALYTICS_R2_ACCESS_KEY = "vault:cloudflare-r2-access-key-id"
1834ANALYTICS_R2_SECRET_KEY = "vault:cloudflare-r2-secret-key|env:R2_SECRET"
1835ANALYTICS_R2_BUCKET     = "yah-analytics""#,
1836            ),
1837            "s",
1838            "e",
1839        )
1840        .unwrap();
1841        assert_eq!(slot.env.len(), 3);
1842        assert_eq!(
1843            slot.env.get("ANALYTICS_R2_ACCESS_KEY").map(String::as_str),
1844            Some("vault:cloudflare-r2-access-key-id"),
1845            "the SOURCE is stored, never a resolved secret — this struct is \
1846             parsed on any machine and printed by diagnostics",
1847        );
1848        assert_eq!(
1849            slot.env.get("ANALYTICS_R2_SECRET_KEY").map(String::as_str),
1850            Some("vault:cloudflare-r2-secret-key|env:R2_SECRET"),
1851            "a pipe-joined fallback chain survives parsing intact",
1852        );
1853        assert_eq!(
1854            slot.env.get("ANALYTICS_R2_BUCKET").map(String::as_str),
1855            Some("yah-analytics"),
1856            "a bare literal is a legitimate non-secret source",
1857        );
1858
1859        // Absent block → empty, and the serve bundle carries whatever the
1860        // deploy resolved (nothing, here).
1861        let bare = BundleSlot::parse(
1862            &mirror_with("use = \"cloudflare\"\nbucket = \"b\""),
1863            "s",
1864            "e",
1865        )
1866        .unwrap();
1867        assert!(bare.env.is_empty());
1868    }
1869
1870    /// The resolved env reaches the workload payload — the leg that was missing
1871    /// entirely (R556-T12). Before it, `MesofactServeBundle` had nowhere to put
1872    /// credentials, so kamaji forked the serve process with an empty
1873    /// environment and an SSR route reading a private source 500'd per request.
1874    #[test]
1875    fn resolved_env_reaches_the_serve_bundle() {
1876        let slot = BundleSlot::parse(
1877            &mirror_with(
1878                r#"use = "cloudflare"
1879bucket = "b"
1880
1881[env]
1882ANALYTICS_R2_ACCESS_KEY = "vault:cloudflare-r2-access-key-id""#,
1883            ),
1884            "s",
1885            "e",
1886        )
1887        .unwrap();
1888
1889        let mut resolved = BTreeMap::new();
1890        resolved.insert("ANALYTICS_R2_ACCESS_KEY".to_string(), "AKIA".to_string());
1891        let sb = slot.serve_bundle(&"a".repeat(64), "self", resolved);
1892
1893        assert_eq!(
1894            sb.env.get("ANALYTICS_R2_ACCESS_KEY").map(String::as_str),
1895            Some("AKIA"),
1896            "the node receives the VALUE; a keystore slot name must never \
1897             cross the wire",
1898        );
1899    }
1900
1901    /// An env entry that is not a usable source string must fail the parse.
1902    /// The whole point of the slot is that a credential problem surfaces at
1903    /// sync, in milliseconds, rather than as a per-request 500 on a node.
1904    #[test]
1905    fn an_unusable_env_source_is_rejected() {
1906        for bad in [
1907            "[env]\nFOO = \"\"",
1908            "[env]\nFOO = \"   \"",
1909            "[env]\nFOO = 8081",
1910            "env = \"vault:x\"",
1911        ] {
1912            let toml = format!("use = \"cloudflare\"\nbucket = \"b\"\n{bad}");
1913            let err = BundleSlot::parse(&mirror_with(&toml), "yah-marketing", "ha")
1914                .unwrap_err()
1915                .to_string();
1916            assert!(err.contains("env"), "{bad}: {err}");
1917        }
1918    }
1919
1920    /// A port typo must fail the parse, not silently fall back to 8080 — that
1921    /// fallback would land the workload on whatever bundle already holds the
1922    /// default port, and surface as the wrong site being served.
1923    #[test]
1924    fn an_unusable_port_is_rejected_rather_than_defaulted() {
1925        for bad in ["port = 0", "port = 70000", r#"port = "8081""#] {
1926            let toml = format!("use = \"cloudflare\"\nbucket = \"b\"\n{bad}");
1927            let err = BundleSlot::parse(&mirror_with(&toml), "yah-marketing", "ha")
1928                .unwrap_err()
1929                .to_string();
1930            assert!(err.contains("port"), "{bad}: {err}");
1931        }
1932    }
1933
1934    // ── serving verification (R703-T7) ──────────────────────────────────────
1935
1936    /// The check is on by default and probes the service's own domain, so a
1937    /// mirror that says nothing about it still gets verified.
1938    #[test]
1939    fn serving_verification_is_on_by_default_and_targets_the_service_domain() {
1940        let slot = BundleSlot::parse(
1941            &mirror_with(
1942                r#"use = "cloudflare"
1943bucket = "b""#,
1944            ),
1945            "yah-marketing",
1946            "cloud",
1947        )
1948        .unwrap();
1949        assert!(slot.verify_serving);
1950        assert_eq!(slot.zone, None);
1951        assert_eq!(slot.serving_zone("yah.dev"), "yah.dev");
1952    }
1953
1954    #[test]
1955    fn an_explicit_zone_overrides_the_service_domain() {
1956        let slot = BundleSlot::parse(
1957            &mirror_with(
1958                r#"use = "cloudflare"
1959bucket = "b"
1960zone = "staging.yah.dev""#,
1961            ),
1962            "yah-marketing",
1963            "cloud",
1964        )
1965        .unwrap();
1966        assert_eq!(slot.serving_zone("yah.dev"), "staging.yah.dev");
1967    }
1968
1969    #[test]
1970    fn verify_serving_can_be_switched_off_for_an_in_flight_migration() {
1971        let slot = BundleSlot::parse(
1972            &mirror_with(
1973                r#"use = "cloudflare"
1974bucket = "b"
1975verify_serving = false"#,
1976            ),
1977            "s",
1978            "e",
1979        )
1980        .unwrap();
1981        assert!(!slot.verify_serving);
1982    }
1983
1984    /// `verify_serving = "false"` reading as *enabled* would leave an operator
1985    /// certain they had silenced a check that then fails their apply.
1986    #[test]
1987    fn a_non_boolean_verify_serving_is_rejected_rather_than_defaulted() {
1988        let err = BundleSlot::parse(
1989            &mirror_with(
1990                r#"use = "cloudflare"
1991bucket = "b"
1992verify_serving = "false""#,
1993            ),
1994            "yah-marketing",
1995            "cloud",
1996        )
1997        .unwrap_err()
1998        .to_string();
1999        assert!(err.contains("verify_serving"), "{err}");
2000        assert!(err.contains("boolean"), "{err}");
2001    }
2002
2003    #[test]
2004    fn a_slot_without_a_bucket_names_the_file_to_edit() {
2005        let mirror = mirror_with(r#"use = "cloudflare""#);
2006        let err = BundleSlot::parse(&mirror, "yah-marketing", "ha")
2007            .unwrap_err()
2008            .to_string();
2009        assert!(err.contains("bucket"), "{err}");
2010        assert!(
2011            err.contains(".yah/services/yah-marketing/mirrors/ha.toml"),
2012            "{err}"
2013        );
2014    }
2015
2016    #[test]
2017    fn explicit_machines_resolve_in_declaration_order() {
2018        let mirror = mirror_with(
2019            r#"use = "cloudflare"
2020bucket = "b"
2021machines = ["us-south-001", "us-east-001"]"#,
2022        );
2023        let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
2024        let cfg = cfg_with(vec![
2025            machine("us-east-001", "us-east"),
2026            machine("us-south-001", "us-south"),
2027        ]);
2028        let resolved = resolve_bundle_machines(&cfg, &mirror, &slot, "s", "e").unwrap();
2029        let names: Vec<_> = resolved.iter().map(|m| m.name.as_str()).collect();
2030        assert_eq!(names, vec!["us-south-001", "us-east-001"]);
2031    }
2032
2033    #[test]
2034    fn an_undeclared_machine_is_an_error_not_a_skip() {
2035        let mirror = mirror_with(
2036            r#"use = "cloudflare"
2037bucket = "b"
2038machines = ["us-west-999"]"#,
2039        );
2040        let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
2041        let cfg = cfg_with(vec![machine("us-east-001", "us-east")]);
2042        let err = resolve_bundle_machines(&cfg, &mirror, &slot, "s", "e")
2043            .unwrap_err()
2044            .to_string();
2045        assert!(err.contains("us-west-999"), "{err}");
2046        assert!(err.contains(".yah/infra/machines/"), "{err}");
2047    }
2048
2049    #[test]
2050    fn falls_back_to_f16_required_placement() {
2051        let mirror = mirror_with(
2052            r#"use = "cloudflare"
2053bucket = "b"
2054required = { regions = ["us-east"] }"#,
2055        );
2056        let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
2057        let cfg = cfg_with(vec![
2058            machine("us-east-001", "us-east"),
2059            machine("us-south-001", "us-south"),
2060        ]);
2061        let resolved = resolve_bundle_machines(&cfg, &mirror, &slot, "s", "e").unwrap();
2062        assert_eq!(resolved.len(), 1);
2063        assert_eq!(resolved[0].name, "us-east-001");
2064    }
2065
2066    /// R844-F8: a constraint with `replicas = N` places N machines, and the
2067    /// ingress planner's resolver picks the SAME N.
2068    ///
2069    /// The set-for-set half is the assertion that matters. Both sides returning
2070    /// two while disagreeing about *which* two aims the discovery fanout at a
2071    /// node the bundle was never deployed to, and the front door then renders a
2072    /// subset of the backends with every line in the mirror still reading
2073    /// correctly. They agree here because they are one selector over one
2074    /// candidate slice, not two implementations that happen to match.
2075    #[test]
2076    fn a_replica_count_places_n_machines_and_the_ingress_planner_picks_the_same_n() {
2077        let mirror = mirror_with(
2078            r#"use = "cloudflare"
2079bucket = "b"
2080zone = "scaled.yah.dev"
2081port = 8080
2082required = { regions = ["us-east"], replicas = 2 }"#,
2083        );
2084        let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
2085        let cfg = cfg_with(vec![
2086            machine("us-east-001", "us-east"),
2087            machine("us-east-002", "us-east"),
2088            machine("us-east-003", "us-east"),
2089            machine("us-south-001", "us-south"),
2090        ]);
2091
2092        let deployed: Vec<&str> = resolve_bundle_machines(&cfg, &mirror, &slot, "s", "e")
2093            .unwrap()
2094            .iter()
2095            .map(|m| m.name.as_str())
2096            .collect();
2097        assert_eq!(
2098            deployed,
2099            vec!["us-east-001", "us-east-002"],
2100            "two asked for, two placed — NOT the three the constraint matches, or \
2101             adding a box to the fleet would scale a production front door"
2102        );
2103
2104        let planned = super::super::ingress::resolve_ingress_placements(&cfg.machines, &mirror)
2105            .unwrap()
2106            .remove("bundle")
2107            .expect("the constraint slot resolves for the planner too");
2108        assert_eq!(planned, deployed, "set for set, not merely in count");
2109    }
2110
2111    /// Never a partial placement. One of two reported as success is the
2112    /// failure that looks like it worked.
2113    #[test]
2114    fn fewer_matches_than_replicas_fails_the_deploy_resolver() {
2115        let mirror = mirror_with(
2116            r#"use = "cloudflare"
2117bucket = "b"
2118required = { regions = ["us-east"], replicas = 2 }"#,
2119        );
2120        let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
2121        let cfg = cfg_with(vec![
2122            machine("us-east-001", "us-east"),
2123            machine("us-south-001", "us-south"),
2124        ]);
2125        // `{:#}` — the shortfall is the *source* of the placement failure, and
2126        // the outer context only names the constraint and the count wanted.
2127        let err = format!(
2128            "{:#}",
2129            resolve_bundle_machines(&cfg, &mirror, &slot, "s", "e").unwrap_err()
2130        );
2131        assert!(err.contains("onto 2 machine(s)"), "{err}");
2132        assert!(err.contains("only 1 of 2"), "{err}");
2133        assert!(err.contains("required.regions=[us-east]"), "{err}");
2134        assert!(
2135            err.contains("us-south-001"),
2136            "names the pool it searched: {err}"
2137        );
2138    }
2139
2140    /// Publishing a bundle no node serves is the silent failure this guards.
2141    #[test]
2142    fn no_placement_at_all_is_rejected() {
2143        let mirror = mirror_with(
2144            r#"use = "cloudflare"
2145bucket = "b""#,
2146        );
2147        let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
2148        let cfg = cfg_with(vec![machine("us-east-001", "us-east")]);
2149        let err = resolve_bundle_machines(&cfg, &mirror, &slot, "s", "e")
2150            .unwrap_err()
2151            .to_string();
2152        assert!(err.contains("machines"), "{err}");
2153        assert!(err.contains("required"), "{err}");
2154    }
2155
2156    #[test]
2157    fn serve_bundle_carries_the_manifest_runtime_verbatim() {
2158        let mirror = mirror_with(
2159            r#"use = "cloudflare"
2160bucket = "b""#,
2161        );
2162        let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
2163        let digest = "a".repeat(64);
2164        let sb = slot.serve_bundle(&digest, "mesofact/0.8.20", BTreeMap::new());
2165        assert_eq!(sb.digest.0, digest);
2166        assert_eq!(sb.runtime, "mesofact/0.8.20");
2167        assert_eq!(sb.lifecycle, BundleLifecycle::KeepAlive);
2168    }
2169
2170    // ── revalidate receiver parsing (R330-F12) ──────────────────────────────
2171
2172    #[test]
2173    fn parses_revalidate_slot_with_routes_and_mirror_key_env() {
2174        let mirror = mirror_with(
2175            r#"use = "cloudflare"
2176bucket = "b"
2177machines = ["us-east-001"]
2178
2179[revalidate]
2180routes = ["/releases"]
2181mirror_key_env = "YAH_MARKETING_MIRROR_KEY"
2182"#,
2183        );
2184        let slot = BundleSlot::parse(&mirror, "yah-marketing", "cloud").unwrap();
2185        let rv = slot.revalidate.expect("revalidate slot should parse");
2186        assert_eq!(rv.routes, vec!["/releases"]);
2187        assert_eq!(
2188            rv.mirror_key_env.as_deref(),
2189            Some("YAH_MARKETING_MIRROR_KEY")
2190        );
2191        assert!(rv.publish_config.is_none());
2192    }
2193
2194    #[test]
2195    fn parses_revalidate_with_custom_publish_config() {
2196        let mirror = mirror_with(
2197            r#"use = "cloudflare"
2198bucket = "b"
2199machines = ["us-east-001"]
2200
2201[revalidate]
2202routes = ["/releases", "/downloads"]
2203publish_config = "custom-mesofact.config.toml"
2204"#,
2205        );
2206        let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
2207        let rv = slot.revalidate.unwrap();
2208        assert_eq!(rv.routes.len(), 2);
2209        assert_eq!(
2210            rv.publish_config.unwrap(),
2211            PathBuf::from("custom-mesofact.config.toml")
2212        );
2213        assert!(rv.mirror_key_env.is_none());
2214    }
2215
2216    #[test]
2217    fn no_revalidate_when_section_absent() {
2218        let mirror = mirror_with(
2219            r#"use = "cloudflare"
2220bucket = "b""#,
2221        );
2222        let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
2223        assert!(slot.revalidate.is_none());
2224    }
2225
2226    #[test]
2227    fn revalidate_with_empty_routes_is_open_allowlist() {
2228        let mirror = mirror_with(
2229            r#"use = "cloudflare"
2230bucket = "b"
2231
2232[revalidate]
2233mirror_key_env = "BEARER"
2234"#,
2235        );
2236        let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
2237        let rv = slot.revalidate.unwrap();
2238        assert!(rv.routes.is_empty());
2239        assert_eq!(rv.mirror_key_env.as_deref(), Some("BEARER"));
2240    }
2241
2242    #[test]
2243    fn to_workload_payload_maps_fields() {
2244        let slot = RevalidateSlot {
2245            routes: vec!["/releases".into()],
2246            mirror_key_env: Some("MY_KEY".into()),
2247            publish_config: Some(PathBuf::from("cfg.toml")),
2248            ..bare_revalidate_slot()
2249        };
2250        let mut env = BTreeMap::new();
2251        env.insert("MESOFACT_S3_ACCESS_KEY_ID".into(), "ak".into());
2252        env.insert("MESOFACT_MIRROR_KEY".into(), "bearer1".into());
2253        let payload = slot.to_workload_payload(env.clone(), vec![], None);
2254        assert_eq!(payload.routes, vec!["/releases"]);
2255        assert_eq!(payload.publish_config, "cfg.toml");
2256        assert_eq!(payload.mirror_key_env.as_deref(), Some("MY_KEY"));
2257        assert_eq!(payload.env.get("MESOFACT_S3_ACCESS_KEY_ID").unwrap(), "ak");
2258        assert_eq!(payload.env.get("MESOFACT_MIRROR_KEY").unwrap(), "bearer1");
2259    }
2260
2261    #[test]
2262    fn to_workload_payload_defaults_publish_config() {
2263        let payload = bare_revalidate_slot().to_workload_payload(BTreeMap::new(), vec![], None);
2264        assert_eq!(payload.publish_config, "mesofact.config.toml");
2265        assert!(payload.mirror_key_env.is_none());
2266        assert!(payload.routes.is_empty());
2267    }
2268
2269    // ── Feed-fetch tier (R330-F31) ──────────────────────────────────────────
2270
2271    fn bare_revalidate_slot() -> RevalidateSlot {
2272        RevalidateSlot {
2273            routes: vec![],
2274            mirror_key_env: None,
2275            publish_config: None,
2276            feeds: vec![],
2277            feed_interval_secs: None,
2278            feed_bins: BTreeMap::new(),
2279            feed_runtime: None,
2280        }
2281    }
2282
2283    #[test]
2284    fn parses_feed_tier_declaration() {
2285        // A staged sidecar belongs to a self-contained bundle, so this fixture
2286        // declares one — R746-T3 refuses feed_bins on a vanilla slot.
2287        let mirror = mirror_with(
2288            r#"use = "cloudflare"
2289bucket = "b"
2290
2291[serve_bins]
2292x86_64-unknown-linux-musl = "target/x86_64-unknown-linux-musl/release/mesofact"
2293
2294[revalidate]
2295routes = ["/releases"]
2296feeds = ["releases", "yah-desktop"]
2297feed_interval_secs = 60
2298
2299[revalidate.feed_bins]
2300x86_64-unknown-linux-musl = "target/x86_64-unknown-linux-musl/release/almanac-feed"
2301"#,
2302        );
2303        let rv = BundleSlot::parse(&mirror, "s", "e")
2304            .unwrap()
2305            .revalidate
2306            .unwrap();
2307        assert_eq!(rv.feeds, vec!["releases", "yah-desktop"]);
2308        assert_eq!(rv.feed_interval_secs, Some(60));
2309        assert_eq!(rv.feed_bins.len(), 1);
2310        assert!(rv.feed_bins["x86_64-unknown-linux-musl"].ends_with("almanac-feed"));
2311        assert!(rv.feed_runtime.is_none());
2312    }
2313
2314    /// R746-T3: the vanilla shape's feed tier. This is the declaration that
2315    /// makes yah-marketing deployable from a machine with no Rust toolchain —
2316    /// no path to a cross-built fetcher anywhere in it.
2317    #[test]
2318    fn a_vanilla_slot_declares_its_fetcher_as_a_runtime_ref() {
2319        let mirror = mirror_with(
2320            r#"use = "cloudflare"
2321bucket = "b"
2322runtime_version = "0.8.22"
2323
2324[revalidate]
2325routes = ["/releases"]
2326feeds = ["releases"]
2327feed_runtime = "almanac-feed/0.8.22"
2328"#,
2329        );
2330        let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
2331        assert!(!slot.is_self_contained());
2332        let rv = slot.revalidate.unwrap();
2333        assert_eq!(rv.feed_runtime.as_deref(), Some("almanac-feed/0.8.22"));
2334        assert!(rv.feed_bins.is_empty());
2335    }
2336
2337    /// The whole point: a vanilla slot with a feed tier is READY with nothing
2338    /// on disk. `feed_bins` would have kept the cross-built-binary requirement
2339    /// alive on the syncing machine while pretending the bundle was vanilla.
2340    #[test]
2341    fn a_vanilla_feed_tier_needs_no_binary_on_the_syncing_machine() {
2342        let mirror = mirror_with(
2343            r#"use = "cloudflare"
2344bucket = "b"
2345runtime_version = "0.8.22"
2346
2347[revalidate]
2348feeds = ["releases"]
2349feed_runtime = "almanac-feed/0.8.22"
2350"#,
2351        );
2352        let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
2353        let empty = std::path::Path::new("/nonexistent-workspace-root");
2354        assert!(missing_bins(&slot, empty).is_empty());
2355        assert!(slot_ready(&slot, empty));
2356    }
2357
2358    /// Declared, never inferred — the rule serve_bins/serve_build already
2359    /// follow. "Use the path if it exists, else the ref" would make the
2360    /// deployed fetcher a function of the syncing machine's disk.
2361    #[test]
2362    fn feed_bins_and_feed_runtime_together_are_refused() {
2363        let mirror = mirror_with(
2364            r#"use = "cloudflare"
2365bucket = "b"
2366
2367[serve_bins]
2368x86_64-unknown-linux-musl = "target/mesofact"
2369
2370[revalidate]
2371feeds = ["releases"]
2372feed_runtime = "almanac-feed/0.8.22"
2373
2374[revalidate.feed_bins]
2375x86_64-unknown-linux-musl = "target/almanac-feed"
2376"#,
2377        );
2378        let err = BundleSlot::parse(&mirror, "s", "e").unwrap_err().to_string();
2379        assert!(err.contains("feed_bins") && err.contains("feed_runtime"), "got {err}");
2380    }
2381
2382    /// A vanilla bundle carries no `bins/`, so a path-declared sidecar has
2383    /// nowhere to be staged. Caught at parse, with the remedy in the message.
2384    #[test]
2385    fn feed_bins_on_a_vanilla_slot_is_refused_naming_feed_runtime() {
2386        let mirror = mirror_with(
2387            r#"use = "cloudflare"
2388bucket = "b"
2389runtime_version = "0.8.22"
2390
2391[revalidate]
2392feeds = ["releases"]
2393
2394[revalidate.feed_bins]
2395x86_64-unknown-linux-musl = "target/almanac-feed"
2396"#,
2397        );
2398        let err = BundleSlot::parse(&mirror, "s", "e").unwrap_err().to_string();
2399        assert!(err.contains("VANILLA"), "got {err}");
2400        assert!(err.contains("feed_runtime"), "got {err}");
2401    }
2402
2403    /// A typo in the ref fails the apply offline, not on a node twenty minutes
2404    /// into a deploy.
2405    #[test]
2406    fn an_unparseable_feed_runtime_is_refused_at_parse() {
2407        let mirror = mirror_with(
2408            r#"use = "cloudflare"
2409bucket = "b"
2410runtime_version = "0.8.22"
2411
2412[revalidate]
2413feeds = ["releases"]
2414feed_runtime = "almanac-feed"
2415"#,
2416        );
2417        let err = BundleSlot::parse(&mirror, "s", "e").unwrap_err().to_string();
2418        assert!(err.contains("feed_runtime"), "got {err}");
2419    }
2420
2421    /// The payload the node acts on must carry the ref, or kamaji has nothing
2422    /// to resolve and the fetcher silently never forks.
2423    #[test]
2424    fn the_feed_runtime_ref_reaches_the_workload_payload() {
2425        let mut slot = bare_revalidate_slot();
2426        slot.feed_runtime = Some("almanac-feed/0.8.22".to_string());
2427        let payload = slot.to_workload_payload(BTreeMap::new(), vec![], None);
2428        assert_eq!(payload.feed_runtime.as_deref(), Some("almanac-feed/0.8.22"));
2429    }
2430
2431    /// A receiver with no feed tier is the existing shape and must keep parsing
2432    /// — the fetcher is additive, not a new requirement on every mirror.
2433    #[test]
2434    fn revalidate_without_a_feed_tier_stays_empty() {
2435        let mirror = mirror_with(
2436            r#"use = "cloudflare"
2437bucket = "b"
2438
2439[revalidate]
2440routes = ["/releases"]
2441"#,
2442        );
2443        let rv = BundleSlot::parse(&mirror, "s", "e")
2444            .unwrap()
2445            .revalidate
2446            .unwrap();
2447        assert!(rv.feeds.is_empty());
2448        assert!(rv.feed_bins.is_empty());
2449        assert_eq!(rv.feed_interval_secs, None);
2450    }
2451
2452    /// Feeds declared with no fetcher binary is the silent-staleness trap: the
2453    /// deploy would go green and the data would never move. Fail at parse.
2454    #[test]
2455    fn feeds_without_feed_bins_is_rejected() {
2456        let mirror = mirror_with(
2457            r#"use = "cloudflare"
2458bucket = "b"
2459
2460[revalidate]
2461feeds = ["releases"]
2462"#,
2463        );
2464        let err = BundleSlot::parse(&mirror, "s", "e")
2465            .unwrap_err()
2466            .to_string();
2467        assert!(err.contains("feed_bins"), "got {err}");
2468        assert!(err.contains("feed_runtime"), "got {err}");
2469        assert!(err.contains(FEED_BIN_NAME), "got {err}");
2470    }
2471
2472    #[test]
2473    fn zero_feed_interval_is_rejected() {
2474        let mirror = mirror_with(
2475            r#"use = "cloudflare"
2476bucket = "b"
2477
2478[revalidate]
2479feed_interval_secs = 0
2480"#,
2481        );
2482        let err = BundleSlot::parse(&mirror, "s", "e")
2483            .unwrap_err()
2484            .to_string();
2485        assert!(err.contains("positive integer"), "got {err}");
2486    }
2487
2488    /// The reconciler's default and the workload-spec serde default are two
2489    /// copies of one number; this pins them together.
2490    #[test]
2491    fn feed_interval_default_matches_the_workload_spec_default() {
2492        let payload = bare_revalidate_slot().to_workload_payload(BTreeMap::new(), vec![], None);
2493        assert_eq!(payload.feed_interval_secs, DEFAULT_FEED_INTERVAL_SECS);
2494
2495        let from_spec: workload_spec::MesofactRevalidateReceiver =
2496            serde_json::from_str("{}").expect("all receiver fields have serde defaults");
2497        assert_eq!(from_spec.feed_interval_secs, DEFAULT_FEED_INTERVAL_SECS);
2498    }
2499}