Skip to main content

cloud/reconciler/
static_asset.rs

1//! Reconciler for `kind = "static-asset"` components (R429-F2).
2//!
3//! Syncs a content-addressed file catalog declared in `workload.toml` to the
4//! mirror's `providers.object_store` slot (Cloudflare R2 or pond MinIO).
5//!
6//! ## Per-asset pipeline
7//!
8//! For each `[[asset]]` row:
9//! 1. Read the source file from disk.
10//! 2. Compute its BLAKE3 hash; compare to `entry.blake3`. Mismatch → surface
11//!    as a hard error and skip upload (don't push bad data).
12//! 3. HEAD the object key in the bucket — if already present, skip PUT.
13//! 4. PUT the file (single-part; R2 accepts up to 5 GB per PUT).
14//!
15//! ## Drift detection (v1)
16//!
17//! A `_yah-asset-catalog.json` sidecar stored in the bucket records what was
18//! uploaded. On the next run, the manifest∖current-catalog delta surfaces as
19//! prune candidates (logged as warnings; nothing is deleted). Exhaustive bucket
20//! listing via S3 ListObjects (which would find bucket∖catalog stragglers added
21//! outside of yah) is deferred to v2 when an XML list response parser exists.
22//!
23//! ## Auto-delete is OFF
24//!
25//! The reconciler NEVER deletes from the bucket. Prune candidates are reported
26//! for operator review; deletion requires `yah service prune`.
27//!
28//! @arch:see(.yah/docs/working/W164-derived-static-assets.md)
29//!
30//! @yah:ticket(R438-T8, "Worked examples: whisper-derive e2e + mesofact in-container build")
31//! @yah:assignee(agent:claude)
32//! @yah:at(2026-06-04T21:07:41Z)
33//! @yah:status(review)
34//! @yah:phase(P3)
35//! @yah:parent(R438)
36//! @yah:next("Whisper-derive e2e against fake R2: cargo run --example whisper_derive_e2e — two consecutive runs upload-then-skip")
37//! @yah:next("Post-prune --derive run re-materializes from scratch and produces bit-identical bytes (reproducibility check)")
38//! @yah:next("In-tree mesofact-static workload with build_mode=in_container builds green in CI")
39//! @yah:verify("Examples run green in CI matrix")
40//! @yah:verify("Reproducibility: independent operator/machine yields identical output blake3 (pinned container guarantees this)")
41//! @arch:see(.yah/docs/working/W164-derived-static-assets.md)
42//! @arch:see(.yah/docs/working/W165-mesofact-build-mode-lowering.md)
43//! @yah:depends_on(R438-T7)
44//! @yah:handoff("T8 landed. Three artifacts: (1) crates/yah/cloud/testdata/mesofact-in-container/workload.toml — in-tree fixture with build_mode = { mode = \"in_container\", image = \"oven/bun:1.2.13@sha256:aaa...\" }; (2) new test in_container_fixture_roundtrips_as_container_build_mode in mesofact_static.rs tests: loads fixture via read_mesofact_build, asserts BuildMode::InContainer returned, runs in CI without docker — passes; (3) crates/yah/cloud/tests/whisper_derive_e2e.rs — integration test derive_pipeline_upload_skip_prune_reproducibility. Uses in-process axum fake-S3 (HEAD/GET/PUT, state map) + fake upstream HTTP server + MockCopyExecutor (copies argv[1]→argv[2], counts invocations). Three-run scenario: run1=cold→executor invoked 1x, model.bin PUT; run2=warm→executor NOT invoked again, HEAD 200 skip; run3=prune cache+evict S3 object→executor invoked again, re-uploaded bytes have identical BLAKE3. cargo test -p cloud --lib: 304 pass, 5 pre-existing failures (port collision + cloud_init drift, same as T6 gotcha). cargo test -p cloud --test whisper_derive_e2e: 1 pass. cargo check --workspace --locked: clean.")
45//! @yah:verify("cargo test -p cloud --lib reconciler::mesofact_static::tests::in_container_fixture_roundtrips_as_container_build_mode  # 1 pass")
46//! @yah:verify("cargo test -p cloud --test whisper_derive_e2e  # 1 pass")
47//! @yah:verify("cargo check --workspace --locked  # clean")
48//!
49//! @yah:ticket(R438-F11, "HTTP fetch retry/resume policy for materialize step (W164 OQ#4)")
50//! @yah:assignee(agent:claude)
51//! @yah:at(2026-06-04T21:08:05Z)
52//! @yah:status(review)
53//! @yah:parent(R438)
54//! @yah:next("Exponential backoff with bounded attempts on transient network failures")
55//! @yah:next("Range: header for resumable downloads on multi-GB blobs (whisper-large is 1.5GB)")
56//! @yah:next("Surface progress to yah QED/task-pane per long-running→yah-surface rule (not Bash bg)")
57//! @yah:verify("Simulated 50%-mid-download disconnect resumes via Range and completes")
58//! @arch:see(.yah/docs/working/W164-derived-static-assets.md)
59//! @yah:depends_on(R438-T5)
60//! @yah:handoff("F11 landed. HTTP fetch retry + Range resume for materialize_fetch (W164 OQ#4). Changes: (1) Added stream feature to reqwest in cloud/Cargo.toml. (2) Replaced the single-shot reqwest::get+bytes() call in materialize_fetch with: FETCH_MAX_ATTEMPTS=5 (1 in test) retry loop with exponential backoff (FETCH_BASE_DELAY_MS=1000ms, capped at 30s; 1ms/5ms in tests so suite stays fast); fetch_once helper streams via resp.chunk() to a <hash>.partial file; Range: bytes=<offset>- header sent on retry when partial file exists; 206 response appended, 200 response truncates-and-restarts, 416 clears partial and retries fresh, 4xx is fatal (no retry), 5xx/429 is transient (retry). (3) Progress logged via info!() every 100MiB (FETCH_LOG_INTERVAL_BYTES) to surface through task-pane. (4) FetchOnceFail enum (Fatal/Transient) separates non-retriable from transient outcomes. After successful fetch_once, blake3 is verified on the partial file then atomic renamed to <hash>.bin. (5) Two new tests: materialize_fetch_retries_on_server_error (500 first, 200 second, asserts 2 requests made) and materialize_fetch_resumes_via_range_header (pre-writes half to .partial, server verifies Range header and returns 206+second-half, asserts full body in cache). cargo test -p cloud --lib reconciler::static_asset: 34 pass. cargo check -p cloud: clean.")
61//! @yah:verify("cargo test -p cloud --lib reconciler::static_asset::tests::materialize_fetch_retries_on_server_error  # passes")
62//! @yah:verify("cargo test -p cloud --lib reconciler::static_asset::tests::materialize_fetch_resumes_via_range_header  # passes, Range header asserted")
63//! @yah:verify("cargo check -p cloud  # clean")
64//!
65//! @yah:ticket(R438-T15, "cloud reconciler materialize step: HTTP fetch + recipe lowering + content-addressed cache")
66//! @yah:at(2026-06-05T00:03:49Z)
67//! @yah:status(review)
68//! @yah:phase(P2)
69//! @yah:parent(R438)
70//! @yah:next("Inject executor: Arc<dyn ForgeExecutor> via StaticAssetReconciler::with_executor(...) setter (defaults to Arc::new(LocalForgeDriver::default())).")
71//! @yah:verify("Two consecutive runs against a fake R2 / MinIO mock: upload-then-skip; derive-mode assets resolve through cache and round-trip.")
72//! @arch:see(.yah/docs/working/W164-derived-static-assets.md)
73//! @yah:depends_on(R438-T13)
74//! @yah:handoff("T15 landed. Cloud reconciler materialize step (W164) wired end-to-end. Changes: (1) StaticAssetReconciler gains `executor: Arc<dyn ForgeExecutor>` field + `with_executor(...)` setter; default Arc::new(LocalForgeDriver::default()). Threaded through up→sync_to_r2/sync_to_minio→sync_assets. (2) New materialize_asset() in crates/yah/cloud/src/reconciler/static_asset.rs lifts legacy `source=...` to disk path verbatim; derive-mode lowers to materialize_fetch() + optional materialize_transform(). (3) materialize_fetch: HTTP GET cached by upstream-blake3 to .yah/cache/derive/fetch/<hex>.bin; cache HIT verifies hash to catch bit-rot; cache MISS does atomic tmp+rename. (4) materialize_transform: TransformRecipeLoader.load → substitute_argv binds YAH_TRANSFORM_IN_0/_OUT + recipe.params at argv-element granularity → lowers each step to ForgeSpec{Subprocess{argv, image}, TaskPlacement{Local, recipe.runtime}, timeout, label, initiator=Gnome{static-asset-reconciler}}, hands to executor.execute(); cached by output-blake3. Unresolved {{placeholder}} after substitute_argv is a hard error. (5) Materialized path replaces entry.source for the existing BLAKE3-verify + S3 PUT loop — derive-mode assets ride the same upload path as legacy ones.")
75//! @yah:next("Sign off → archive R438-T15.")
76//! @yah:next("R422-F11 unblocked. Picker can resume: workload.toml gets [[asset]] + [asset.derive.fetch] + [asset.derive.transform], and the recipe TOML at .yah/qed/transforms/whisper-quantize.toml needs to be authored (R438-T8 worked-example covers the e2e verification).")
77//! @yah:next("Follow-up R438-F11 (HTTP fetch retry/resume policy) is the natural next-step for production whisper-large fetches; today's path single-shots reqwest::get.")
78//! @yah:verify("cargo test -p cloud --lib reconciler::static_asset  # 32 pass including 7 new W164 tests: materialize_legacy_source_returns_workload_dir_path + materialize_fetch_cache_miss_then_hit + materialize_fetch_blake3_mismatch_is_hard_error + materialize_transform_cache_miss_then_hit + materialize_transform_blake3_mismatch_on_output_is_hard_error + materialize_transform_recipe_failure_surfaces_stderr + materialize_derive_fetch_only_uses_fetch_path_for_upload")
79//! @yah:verify("cargo check --workspace  # clean (only pre-existing warnings in desktop)")
80//! @yah:verify("no new cloud→qed dep edge — cloud's new dep is task only (verified via cloud/Cargo.toml diff)")
81//! @yah:gotcha("Architectural decision during T15: moved qed::transforms → task::transforms because cloud cannot dep on qed (per the original T5 verify clause). transforms.rs only deps on task::TaskRuntime + workload_spec::ImageRef; task is the right home. qed re-exports dropped (no external callers existed). Added `toml = '0.8'` + `pub mod transforms;` to task/Cargo.toml + task/src/lib.rs. Cloud's Cargo.toml now has `task = { path = '../task' }`. The W164 doc's piece-placement table that says 'Recipe TOML loader | qed (existing)' is now stale; transforms lives in task.")
82//! @yah:gotcha("Stale test fixed: task::transforms::tests::rejects_recipe_with_struct_image_missing_digest now expects RecipeError::Parse (was ImageNotPinned). After R438-T3 tightened ImageRef.digest to non-Optional String, struct-form bare-tag fails at serde-deserialize, not at the post-parse ImageNotPinned check (which is now belt-and-braces against an empty-string digest).")
83//! @yah:gotcha("Reconciler is per-asset sequential — W164 calls for bounded semaphore (default 4) cross-asset concurrency. Filed as R438-F11-style follow-up rather than added in this ticket to keep the diff focused on correctness. Not blocking for R422-F11.")
84//!
85//! @yah:ticket(R546-B6, "yah cloud cache seed computes the fetched-input blake3 then discards it — leaves [asset.derive.fetch].blake3 sentinel unfilled, silently disarming the shared lock fast-path")
86//! @yah:phase(P1)
87//! @yah:status(review)
88//! @yah:assignee(agent:bundle-anthropic-ashguard)
89//! @yah:at(2026-08-02T23:38:04Z)
90//! @yah:parent(R546)
91//! @yah:next("Make `yah cloud cache seed` print the fetched-input blake3 alongside the other three, e.g. `[asset.derive.fetch].blake3 = \"<fetched_hash>\"`, so the operator can paste all four and arm BOTH skip layers. The value is already in scope as `fetched_hash` in seed_derivation_for_target — this is a print-line change plus threading it out through SeededDerivation.")
92//! @yah:next("Verified fix shape by hand for the x86_64 row: fetch.blake3 = 3568931fb074a4e1b4d43098db5810e683cdadd06887a8bdd964a5719bca6481 (b3sum of the rusty_v8-149.4.0 archive; the fetch cache is content-addressed so the blob FILENAME is the blake3). After pasting, a re-seed reproduces the identical derive_key 6238d80e..., i.e. lock_skip_hash's recomputation now matches the lock.")
93//! @yah:next("Consider also asserting it: if seed writes a lock whose fast-path cannot engage (fetch pin still sentinel), emit a warning rather than printing a success message that implies the job is done.")
94//! @yah:gotcha("Surfaced 2026-07-20 by leif while closing R546-T3: 'why wasn't fetch.blake3 filled in already?'. `seed_derivation_for_target` ALREADY has the value — it calls materialize_fetch(&derive.fetch, ...) and binds `fetched_hash`, uses it to compute the derive_key, then prints ONLY [[asset]].blake3 + lock.input_hash + lock.output_blake3. The fetched-input hash is dropped on the floor.")
95//! @yah:gotcha("CONSEQUENCE (non-obvious, cost real time to find): with [asset.derive.fetch].blake3 left as the zero sentinel, lock_skip_hash() bails at its FIRST guard (`is_bootstrap_sentinel(&derive.fetch.blake3.0) -> return None`, static_asset.rs ~line 770). So the W212 substituter fast-path never engages. The local action cache still skips the build, which MASKS the problem on the seeding machine — but a clean checkout / CI / another operator gets no skip at all. The seed command's whole purpose is 'prepare a no-rebuild apply', so leaving this unfilled defeats the shared half of it.")
96//! @yah:handoff("DONE. `yah cloud cache seed` now prints all FOUR paste-back values and a pin verdict. (1) oss/yubaba/crates/cloud/src/reconciler/static_asset.rs: SeededDerivation gains fetch_blake3 (the fetched-input hash the derive_key was keyed on, previously computed then dropped) and fetch_pin: Option<FetchPinState>; new pub enum FetchPinState {Pinned, Sentinel, Mismatch{committed}} plus pure fn classify_fetch_pin(committed, fetched). seed_transform_derivation fills fetch_blake3 and logs it; seed_derivation_for_target classifies the COMMITTED [asset.derive.fetch].blake3 against what it actually fetched. (2) app/yah/cli/src/cloud.rs handle_cache_seed: prints [asset.derive.fetch].blake3 first (columns realigned), then a per-verdict block -- Pinned = fast-path arms as soon as the lock lands; Sentinel = WARNING that lock_skip_hash declines and the skip is local-action-cache-only so CI / a clean checkout / another operator still rebuilds; Mismatch = WARNING printing both hashes (moved version anchor or wrong pin).")
97//! @yah:verify("LIVE-VERIFIED offline against the real workload (fetch anchor + artifact both already cached): `./target/debug/yah cloud cache seed --workload .yah/services/yah-cloud/components/rusty-v8-musl/workload.toml --target x86_64-unknown-linux-musl --artifact .yah/cache/artifacts/567e8f9c...` printed [asset.derive.fetch].blake3 = 3568931fb074a4e1b4d43098db5810e683cdadd06887a8bdd964a5719bca6481 -- byte-identical to the value leif verified by hand in this ticket -- and input_hash f188580181d5ae26004ecd3267b48ca7703b68969e8ad1e1db68e6c4b94d5207, reproducing the committed lock exactly. Verdict line printed: 'fetch pin already committed - the W212 fast-path arms as soon as the lock lands' (both rows of that workload were pinned by hand in an earlier session, so Pinned is correct there).")
98//! @yah:verify("Tests: 2 new in static_asset.rs. seed_surfaces_fetched_input_hash_and_pin_state drives seed_derivation_for_target end-to-end with a pre-warmed fetch cache (materialize_fetch HIT path = hermetic, no network) and asserts (a) fetch_blake3 equals the fetched hash, (b) fetch_pin == Pinned, (c) derivation_key(fetch_blake3, recipe_bk, params) reproduces seeded.derive_key -- i.e. the surfaced value really IS the key input. classify_fetch_pin_covers_all_three_verdicts covers Sentinel / Pinned (case-insensitive) / Mismatch without a network fetch. `cargo test -p yah-cloud --lib` from oss/yubaba: 595 passed, 1 pre-existing flake (reconciler::pond::tests::ensure_sim_port_free_ok_when_unbound -- binds a real port, passes in isolation, unrelated). `cargo check -p yah --bin yah` clean.")
99//! @yah:gotcha("RESOLVED-IN-PLACE, not a leftover: the two [asset.derive.fetch].blake3 rows in .yah/services/yah-cloud/components/rusty-v8-musl/workload.toml were ALREADY pinned to 3568931f... by hand in an earlier session, so this workload's fast-path is armed today. This ticket fixed the TOOLING that made them get missed; nothing in the workload needed editing. Consequence for review: the Sentinel and Mismatch warning branches are covered by unit test only, not by a live run -- exercising Sentinel live would require an unpinned workload and a real network fetch.")
100//!
101//! @yah:ticket(R546-B8, "static-asset transform passes a RELATIVE YAH_TRANSFORM_OUT into the container — any recipe that chdirs writes its artifact inside the container and silently loses it (cost a ~2h rusty-v8 arm64 build)")
102//! @yah:phase(P1)
103//! @yah:status(review)
104//! @yah:assignee(agent:bundle-anthropic-ashguard)
105//! @yah:at(2026-08-03T00:53:38Z)
106//! @yah:parent(R546)
107//! @yah:next("FIX LANDED: canonicalize `cache_dir` and join the filename (the tmp file itself does not exist yet so it cannot be canonicalized directly); also canonicalize `input_path` for the YAH_TRANSFORM_IN_0 binding for the same reason. static_asset tests 46/46 green.")
108//! @yah:next("ADD A REGRESSION TEST that asserts both YAH_TRANSFORM_IN_0 and YAH_TRANSFORM_OUT bindings are absolute after substitution. Not added yet — the existing tests exercise recipes that never chdir, so they cannot catch this class. A cheap version: assert Path::new(&params[ENV_TRANSFORM_OUT]).is_absolute().")
109//! @yah:next("HARDENING: the reconciler should fail LOUDLY when a transform step exits 0 but produces no output at YAH_TRANSFORM_OUT — today the missing-file error surfaces as a confusing BLAKE3 read failure that reads like cache corruption rather than 'your recipe never wrote its output'.")
110//! @yah:gotcha("FAILURE MODE IS SILENT AND EXPENSIVE: the step exits 0, no 'step failed' is reported, and the reconciler dies afterwards on `reading transform output ./.yah/cache/derive/transform/<derive_key>.tmp for BLAKE3: No such file or directory`. Hit 2026-07-20 after a ~2h native arm64 V8 build that had actually SUCCEEDED — the finished tar was written inside the container and thrown away with it.")
111//! @yah:gotcha("ROOT CAUSE: materialize_transform built `tmp_output = cache_dir.join(\"<derive_key>.tmp\")` from `workspace_root`, which is \".\" by CLI default, then bound that RELATIVE string into the recipe argv as {{YAH_TRANSFORM_OUT}}. Note the code immediately below it already canonicalized `workspace_abs` for the container cwd (docker rejects `-w .`) — the OUT binding just never got the same treatment. A relative OUT only survives if the recipe never leaves its cwd: true for the whisper recipes (which is why this went unnoticed for months), FALSE for rusty-v8 because build-v8.sh chdirs into /tmp/tmp.XXXX/v8src to build V8.")
112//! @yah:gotcha("CONTRAST: the QED/P018 offload path was unaffected because it passes an ABSOLUTE container path (/yah/produced/...). Only the LOCAL static-asset transform path had the relative binding.")
113//! @yah:handoff("DONE — both remaining next-steps landed (the canonicalize fix itself was already in the tree when I picked this up). (1) REGRESSION TEST: reconciler::static_asset::tests::transform_bindings_are_absolute_from_a_relative_workspace_root asserts BOTH YAH_TRANSFORM_IN_0 and YAH_TRANSFORM_OUT are absolute in the substituted argv. New ArgvCapture test executor records the argv (and can decline to write the output while still reporting exit 0 — the exact shape of the bug). (2) HARDENING: materialize_transform now checks tmp_output exists before reading it and bails with 'recipe X completed successfully but produced no output at <path> (YAH_TRANSFORM_OUT)' plus the three things to check. Covered by transform_that_writes_no_output_names_the_recipe_contract, which also asserts the raw 'No such file or directory' does NOT leak — that string is what sent the last reader into the cache subsystem instead of the recipe.")
114//! @yah:handoff("THE FIXTURE IS THE LOAD-BEARING PART OF THE TEST, and it is not obvious: it uses tempfile::TempDir::new_in(\".\") and rebuilds the relative form by hand (new_in returns an ABSOLUTE path), because `yah cloud apply` defaults --path to \".\". The repo's standard Fixture uses an absolute tempdir, and against an absolute root cache_dir is absolute either way — so an absolute-tempdir version of this test passes against the BUGGY code and proves nothing. Commented at the test.")
115//! @yah:handoff("ALSO recorded the invariant at the definition site so a future edit cannot re-break it silently: oss/qed/crates/velveteen-exec/src/transforms.rs — ENV_TRANSFORM_OUT / ENV_TRANSFORM_IN_0 doc comments now state 'Bind an ABSOLUTE path', why (a recipe is free to cd), and what it cost. Doc-only, no behaviour change; cargo check -p velveteen-exec clean.")
116//! @yah:handoff("SWEEP: grepped every ENV_TRANSFORM_IN_0/ENV_TRANSFORM_OUT binding site across oss/yubaba, oss/qed and app/yah/cli. static_asset.rs is the only production binder; velveteen-exec only defines the constants and its own test already uses absolute paths. The QED/P018 offload path was never affected (it passes /yah/produced/... absolute), confirming the ticket's contrast note. No other instance of this bug class exists.")
117//! @yah:verify("THE TEST WAS PROVEN TO FAIL AGAINST THE BUG, not just to pass against the fix. I reverted both canonicalizations in place (tmp_output back to cache_dir.join(...), input_abs back to input_path.to_path_buf()), re-ran, and got: 'YAH_TRANSFORM_IN_0 binding must be absolute, got \"./.tmpX7vsrP/.yah/cache/derive/fetch/in.bin\"'. Then restored both and re-ran green. Without this step the test would have been indistinguishable from one that can never fire.")
118//! @yah:verify("GREEN: cargo test -p yah-cloud --lib — 617 passed, 0 failed, 4 ignored (the pond real-port flakes did not fire this run; they are unrelated and pass in isolation). cargo test -p yah-cloud --test whisper_derive_e2e — 1/1. cargo check -p velveteen-exec clean.")
119//!
120//! @yah:ticket(R546-B10, "static-asset publish skips PUT on key EXISTENCE (HEAD), not content — bucket keeps stale bytes while the manifest records the new hash")
121//! @yah:status(review)
122//! @yah:assignee(agent:claude)
123//! @yah:at(2026-07-21T21:00:26Z)
124//! @yah:parent(R546)
125//! @yah:severity(high)
126//! @yah:next("Repro (real, cost this relay a wrong pin): static_asset.rs:670 does `if object_exists(...) { report.already_synced.push(key); new_manifest.insert(key, actual_hash); continue; }`. object_exists issues an existence-only probe (HTTP-verb HEAD) with no content comparison. So when the locally-materialized artifact differs from the object already at that key, the reconciler skips the PUT (bucket keeps the OLD bytes) yet still records the NEW locally-computed hash in the manifest. The published _yah-asset-catalog.json then advertises a hash that describes bytes which were never uploaded.")
127//! @yah:next("Live evidence: cdn.yah.dev/yah-cloud/rusty-v8/v149.4.0/rusty-v8-aarch64-unknown-linux-musl.tar.gz serves blake3 d322b4a1… (last-modified 2026-06-20, hand-uploaded), while _yah-asset-catalog.json claims ebdd842d… — the artifact the 2026-07-20 apply built and then silently declined to upload. Both hashes are real; they describe different tarballs.")
128//! @yah:next("Fix shape: when the object already exists, compare its hash against actual_hash before skipping. Cheapest probe is the ETag when it is a plain MD5 (single-part upload), but ETag is NOT a content hash for multipart uploads — do not key correctness on it alone. Preferred: store the blake3 as object metadata (x-amz-meta-blake3) at PUT time and compare that on the existence probe; fall back to re-PUT when the metadata is absent (older objects). Re-PUT on mismatch instead of skipping.")
129//! @yah:next("Also decide the drift POSTURE, not just the mechanism: a content mismatch at a stable key is exactly the DriftBucket condition the journal already models (AssetState::DriftBucket, used at static_asset.rs:645 for source-vs-declared mismatch). Emitting the same event here, rather than silently overwriting, is probably the right default for a published CDN object — make it explicit rather than incidental.")
130//! @yah:verify("Regression test: seed a bucket object at key K with bytes A, run the reconciler with a workload whose materialized artifact for K is bytes B (B != A), assert the object at K afterwards is B (or that a DriftBucket event was emitted) AND that the manifest never records B while the bucket still holds A.")
131//! @yah:gotcha("Latent for most assets, which is why it went unnoticed for so long: W164 catalogs put the VERSION IN THE PATH (yah-cloud/rusty-v8/v149.4.0/…), so bytes at a given key normally never change and the skip is correct. It only bites when an object is placed at a key out-of-band (hand-uploaded) or when a rebuild is non-deterministic — both true for rusty-v8 arm64.")
132//! @yah:gotcha("The manifest is NOT load-bearing for consumer correctness, so do not overstate the blast radius: prior_manifest is read only for PRUNE-candidate detection (static_asset.rs:534-549), and consumers verify pulled bytes against [[asset]].blake3 in workload.toml, not against the catalog JSON. The damage is a lying audit record + a silently-skipped publish, not corrupted downloads.")
133//! @yah:assumes("Tier: Cleric — mechanism is a few lines, but the drift posture (overwrite vs refuse-and-report) is a real design call, and the ETag/multipart trap makes the naive fix wrong.")
134
135use std::collections::{BTreeMap, HashMap};
136use std::path::{Path, PathBuf};
137use std::sync::Arc;
138
139use anyhow::{Context, Result};
140use async_trait::async_trait;
141use chrono::Utc;
142use sha2::{Digest, Sha256};
143use tracing::{debug, info, warn};
144
145use super::{ReconcileCtx, Reconciler, RunningWorkload};
146use crate::asset_journal::{AssetState, AssetStatusEvent, AssetStatusJournal};
147use crate::provider::cloudflare::CloudflareClient;
148use crate::reconciler::pond::DEFAULT_MINIO_PASSWORD;
149use crate::reconciler::pond::DEFAULT_MINIO_USER;
150use crate::{MirrorProviderSlot, Provider};
151
152use local_driver::s3_sign::{sign_s3_empty_body, sign_s3_put_object};
153use velveteen::{ForgeCommand, ForgeSpec, Initiator, MeshAccess, TaskLocation, TaskPlacement};
154use velveteen_exec::transforms::{
155    substitute_argv, RecipeStep, TransformRecipe, TransformRecipeLoader, ENV_TRANSFORM_IN_0,
156    ENV_TRANSFORM_OUT,
157};
158use velveteen_exec::{ExecContext, ForgeExecutor, LocalForgeDriver, PlacementRouter};
159use workload_spec::validate::shape_static_asset;
160use workload_spec::{AssetEntry, FetchSource, Millis, StaticAssetWorkload, TransformSpec};
161
162/// Workload kind handled by this reconciler.
163pub const WORKLOAD_KIND: &str = "static-asset";
164
165/// S3 region string for Cloudflare R2.
166const R2_REGION: &str = "auto";
167/// S3 region string for MinIO (SigV4 requires a non-empty value).
168const MINIO_REGION: &str = "us-east-1";
169
170/// Bucket key for the per-run catalog manifest sidecar.
171const CATALOG_MANIFEST_KEY: &str = "_yah-asset-catalog.json";
172
173/// 64 zero hex digits — the "not yet pinned" sentinel for `BlakeHash`.
174///
175/// Bootstrap mode: a derive-mode asset can ship with this value in any
176/// `blake3` field (`[[asset]].blake3` or `[asset.derive.fetch].blake3`)
177/// before its first apply. The reconciler computes the actual hash, accepts
178/// the bytes, uploads, and surfaces the discovered hash in the report for
179/// paste-back. Once pinned, subsequent runs verify normally. Treats the
180/// `blake3` field as the lockfile's *output*, not its precondition — first
181/// publish or disaster-recovery hydration just works.
182#[allow(dead_code)] // referenced by tests + serves as documentation of the sentinel literal
183const ZERO_SENTINEL_HEX: &str = "0000000000000000000000000000000000000000000000000000000000000000";
184
185/// True when `hex` is the 64-zero "not pinned yet" sentinel for `BlakeHash`.
186fn is_bootstrap_sentinel(hex: &str) -> bool {
187    hex.len() == 64 && hex.bytes().all(|b| b == b'0')
188}
189
190/// W209/F4: per-bootstrap output key on `$YAH_OUTPUTS`. The filename is
191/// included so multi-asset workloads round-trip — each asset's bind in the
192/// publish-assets pipeline TOML references its own key. Key shape:
193///   - `discovered_asset_blake3:<filename>` for post-transform output
194///   - `discovered_fetch_blake3:<filename>` for upstream content pin
195fn bootstrap_output_key(b: &BootstrappedHash) -> String {
196    let prefix = match b.kind {
197        BootstrapHashKind::Output => "discovered_asset_blake3",
198        BootstrapHashKind::Fetch => "discovered_fetch_blake3",
199        // W212/R518: the derivation key for the in-tree `[asset.derive.lock]`.
200        BootstrapHashKind::Input => "discovered_input_hash",
201    };
202    format!("{prefix}:{}", b.filename)
203}
204
205/// Append discovered BLAKE3 values to `$YAH_OUTPUTS` so the QED runner's
206/// per-step output collector picks them up after `yah cloud apply` returns
207/// (W209/F4). No-op outside a QED pipeline (env var unset).
208fn write_bootstrap_outputs(bootstrapped: &[BootstrappedHash]) -> std::io::Result<()> {
209    let Some(path) = std::env::var_os("YAH_OUTPUTS") else {
210        return Ok(());
211    };
212    append_bootstrap_outputs(Path::new(&path), bootstrapped)
213}
214
215/// Inner write — extracted so unit tests can exercise the format without
216/// racing on the `YAH_OUTPUTS` env var (cargo's parallel test runner makes
217/// process-wide env mutation unsafe).
218fn append_bootstrap_outputs(path: &Path, bootstrapped: &[BootstrappedHash]) -> std::io::Result<()> {
219    use std::io::Write;
220    let mut file = std::fs::OpenOptions::new()
221        .create(true)
222        .append(true)
223        .open(path)?;
224    for b in bootstrapped {
225        writeln!(file, "{}={}", bootstrap_output_key(b), b.hash)?;
226    }
227    Ok(())
228}
229
230/// Where a discovered BLAKE3 belongs in `workload.toml` for paste-back.
231#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232pub enum BootstrapHashKind {
233    /// `[[asset]].blake3` — post-transform (or post-fetch when no transform) output.
234    Output,
235    /// `[asset.derive.fetch].blake3` — upstream content pin.
236    Fetch,
237    /// W212/R518: `[asset.derive.lock].input_hash` — the input-addressed
238    /// derivation key. Emitted on every successful transform build so the bind
239    /// path refreshes the lock whenever the inputs change.
240    Input,
241}
242
243/// One BLAKE3 value discovered during a bootstrap-mode apply.
244///
245/// Operator pastes `hash` into the field named by `kind` for the row matching
246/// `filename`. Subsequent runs verify against the pinned value.
247#[derive(Debug, Clone)]
248pub struct BootstrappedHash {
249    pub filename: String,
250    pub kind: BootstrapHashKind,
251    pub hash: String,
252}
253
254/// In-bucket sidecar: maps object key → blake3_hex for what was last uploaded.
255/// Used to detect prune candidates across runs without listing the bucket.
256type CatalogManifest = HashMap<String, String>;
257
258/// Summary of a completed static-asset sync.
259#[derive(Debug, Default)]
260pub struct StaticAssetSyncReport {
261    /// Asset filenames that were already present in the bucket with a matching
262    /// BLAKE3 stamp (genuinely skipped — the bytes are known to be current).
263    pub already_synced: Vec<String>,
264    /// Asset filenames uploaded this run.
265    pub uploaded: Vec<String>,
266    /// R546-B10: filenames whose bucket object existed but did NOT match the
267    /// declared artifact — either drifted content or a legacy object with no
268    /// `x-amz-meta-blake3` stamp — and was therefore re-uploaded. A key showing
269    /// up here repeatedly across runs means something outside this reconciler
270    /// keeps rewriting it; a key appearing exactly once is the expected
271    /// one-time heal of a pre-metadata object.
272    pub republished: Vec<String>,
273    /// Asset filenames whose source BLAKE3 hash didn't match the manifest.
274    /// These are NOT uploaded — operator must rebuild or fix the declaration.
275    pub hash_mismatch: Vec<String>,
276    /// Asset filenames that were in the stored catalog manifest but are no
277    /// longer in the current `workload.toml`. Prune candidates — not deleted.
278    pub prune_candidates: Vec<String>,
279    /// BLAKE3 values discovered during a bootstrap-mode apply (a `blake3` field
280    /// shipped as the zero sentinel). Operator pastes these into the catalog
281    /// to pin it; the assets were nonetheless uploaded this run.
282    pub bootstrapped: Vec<BootstrappedHash>,
283}
284
285/// Reconciles `kind = "static-asset"` components.
286///
287/// The `executor` field handles W164 transform recipes for derive-mode assets
288/// (R438-T15). Default is a [`PlacementRouter`] over `LocalForgeDriver` with no
289/// remote side: local recipes run on the host that owns the cache, and a
290/// remotely-placed one is refused with a message naming the missing wiring.
291///
292/// It is a router rather than a bare `LocalForgeDriver` because this crate
293/// *cannot* build the remote half — a `RemoteForgeDriver` needs a
294/// `WardenClient` over the camp's machine inventory, which lives one layer up
295/// in the CLI (R555-F3). So the default has to be the honest "no cloud from
296/// here" case, and the caller that does have one injects it through
297/// [`Self::with_executor`] (see `yah cloud apply`).
298pub struct StaticAssetReconciler {
299    executor: Arc<dyn ForgeExecutor>,
300}
301
302impl StaticAssetReconciler {
303    pub fn new() -> Self {
304        Self {
305            executor: Arc::new(PlacementRouter::local_only(Arc::new(
306                LocalForgeDriver::default(),
307            ))),
308        }
309    }
310
311    /// Swap the [`ForgeExecutor`] used to materialize derive-mode transforms.
312    /// Production callers inject a router that can also reach the camp's fleet;
313    /// tests inject a mock.
314    pub fn with_executor(mut self, executor: Arc<dyn ForgeExecutor>) -> Self {
315        self.executor = executor;
316        self
317    }
318}
319
320impl Default for StaticAssetReconciler {
321    fn default() -> Self {
322        Self::new()
323    }
324}
325
326#[async_trait]
327impl Reconciler for StaticAssetReconciler {
328    fn kind(&self) -> &'static str {
329        WORKLOAD_KIND
330    }
331
332    async fn up(&self, ctx: ReconcileCtx<'_>) -> Result<RunningWorkload> {
333        let workload_dir = ctx.workload_dir();
334
335        // Load and shape-validate the workload.
336        let workload = load_workload(&workload_dir)?;
337        shape_static_asset(&workload).with_context(|| {
338            format!(
339                "{}/workload.toml: closed-catalog invariant violated",
340                workload_dir.display()
341            )
342        })?;
343
344        // Resolve the object_store provider slot.
345        let slot = ctx.slot("object_store").with_context(|| {
346            format!(
347                "mirror has no `providers.object_store` slot — required for \
348                 kind=\"static-asset\" (service={}, env={})",
349                ctx.service.name, ctx.env,
350            )
351        })?;
352
353        let journal = AssetStatusJournal::at_workspace(ctx.workspace_root);
354        let service_name = ctx.service.name.as_str();
355
356        let report = match slot {
357            MirrorProviderSlot::Reference {
358                provider_id,
359                fields,
360            } => {
361                // Any cloudflare-*kind* provider routes here (dispatch is on
362                // the resolved kind, not the literal name) so a workspace can
363                // declare several — e.g. `cloudflare` + `cloudflare-scrabcake`.
364                let cf = super::cf_creds::CfProvider::resolve_scoped(
365                    ctx.workspace_root,
366                    provider_id,
367                    &ctx.scope.tenant,
368                    &ctx.scope.namespace,
369                )?;
370                anyhow::ensure!(
371                    matches!(cf.cfg.kind, Provider::Cloudflare),
372                    "providers.object_store.use = {provider_id:?} (kind={:?}) not supported \
373                     for static-asset — only cloudflare-kind reference providers are",
374                    cf.cfg.kind,
375                );
376                sync_to_r2(
377                    &ctx,
378                    cf,
379                    &workload,
380                    fields,
381                    self.executor.clone(),
382                    service_name,
383                    &journal,
384                )
385                .await?
386            }
387            MirrorProviderSlot::Inline {
388                kind: Provider::MinioContainer,
389                fields,
390            } => {
391                sync_to_minio(
392                    &ctx,
393                    &workload,
394                    fields,
395                    self.executor.clone(),
396                    service_name,
397                    &journal,
398                )
399                .await?
400            }
401            MirrorProviderSlot::Inline { kind, .. } => {
402                anyhow::bail!(
403                    "providers.object_store.kind = {kind:?} not supported for static-asset \
404                     (expected minio-container or a cloudflare reference)"
405                );
406            }
407        };
408
409        if !report.hash_mismatch.is_empty() {
410            warn!(
411                files = ?report.hash_mismatch,
412                "BLAKE3 mismatch for {} asset(s) — rebuild source files before syncing",
413                report.hash_mismatch.len(),
414            );
415            anyhow::bail!(
416                "static-asset sync failed: {} source file(s) have BLAKE3 mismatches: {:?}",
417                report.hash_mismatch.len(),
418                report.hash_mismatch,
419            );
420        }
421
422        if !report.prune_candidates.is_empty() {
423            warn!(
424                files = ?report.prune_candidates,
425                "{} file(s) no longer in catalog — run `yah service prune` to remove",
426                report.prune_candidates.len(),
427            );
428        }
429
430        // Surface bootstrap-mode discoveries as pipeline outputs (W209 §
431        // Migration #1). When this reconciler runs inside a QED step, the
432        // runner sets `$YAH_OUTPUTS` to a per-step KEY=VALUE sidechannel;
433        // discovered BLAKE3s flow into the run's OutputMap and feed any
434        // `[[bind]]` declarations in the publish-assets pipeline TOML — the
435        // applier writes them back into workload.toml mid-pipeline. Key
436        // shape encodes the asset filename so multi-asset workloads round-
437        // trip: `discovered_asset_blake3:<filename>=<hex>`.
438        if !report.bootstrapped.is_empty() {
439            if let Err(err) = write_bootstrap_outputs(&report.bootstrapped) {
440                // A write failure shouldn't poison the apply — the operator
441                // still has the summary info! line below for triage.
442                warn!(error = %err, "failed to write discovered BLAKE3s to $YAH_OUTPUTS");
443            }
444            info!(
445                count = report.bootstrapped.len(),
446                discoveries = ?report
447                    .bootstrapped
448                    .iter()
449                    .map(|b| format!("{}={}", bootstrap_output_key(b), b.hash))
450                    .collect::<Vec<_>>(),
451                "discovered {} BLAKE3 value(s) — surfaced via $YAH_OUTPUTS for pipeline bind",
452                report.bootstrapped.len(),
453            );
454        }
455
456        info!(
457            uploaded = report.uploaded.len(),
458            already_synced = report.already_synced.len(),
459            prune_candidates = report.prune_candidates.len(),
460            bootstrapped = report.bootstrapped.len(),
461            "static-asset sync complete",
462        );
463
464        Ok(
465            RunningWorkload::adopted(WORKLOAD_KIND, "object_store", None)
466                .with_notes(render_sync_notes(&report)),
467        )
468    }
469}
470
471/// R546-B12: what this run actually DID, one line per outcome class, for the
472/// apply console.
473///
474/// Everything above goes to `info!`, which the CLI does not render — so a clean
475/// static-asset reconcile printed the component header and then nothing at all.
476/// A successful publish and a successful no-op looked identical, and the view
477/// that would have told them apart (`yah cloud status`) was blind for the same
478/// underlying reason. The no-op case is stated explicitly rather than omitted:
479/// "nothing to do" is a result, and silence is not.
480fn render_sync_notes(report: &StaticAssetSyncReport) -> Vec<String> {
481    let mut notes = Vec::new();
482    if !report.uploaded.is_empty() {
483        notes.push(format!("published {} asset(s):", report.uploaded.len()));
484        notes.extend(report.uploaded.iter().map(|k| format!("  + {k}")));
485    }
486    if !report.republished.is_empty() {
487        notes.push(format!(
488            "re-published {} asset(s) whose bucket bytes did not match the declared hash:",
489            report.republished.len()
490        ));
491        notes.extend(report.republished.iter().map(|k| format!("  ~ {k}")));
492    }
493    if !report.already_synced.is_empty() {
494        notes.push(format!(
495            "{} asset(s) already current (bucket hash matches) — no upload",
496            report.already_synced.len()
497        ));
498    }
499    if !report.bootstrapped.is_empty() {
500        notes.push(format!(
501            "discovered {} BLAKE3 value(s) for paste-back into workload.toml:",
502            report.bootstrapped.len()
503        ));
504        notes.extend(
505            report
506                .bootstrapped
507                .iter()
508                .map(|b| format!("  {} = {}", bootstrap_output_key(b), b.hash)),
509        );
510    }
511    if !report.prune_candidates.is_empty() {
512        notes.push(format!(
513            "{} bucket object(s) no longer declared — `yah cloud cache prune` to review",
514            report.prune_candidates.len()
515        ));
516    }
517    if notes.is_empty() {
518        notes.push("no assets declared — nothing to reconcile".to_string());
519    }
520    notes
521}
522
523// ── Backend dispatch ──────────────────────────────────────────────────────────
524
525async fn sync_to_r2(
526    ctx: &ReconcileCtx<'_>,
527    cf_provider: super::cf_creds::CfProvider,
528    workload: &StaticAssetWorkload,
529    slot_fields: &std::collections::BTreeMap<String, toml::Value>,
530    executor: Arc<dyn ForgeExecutor>,
531    service_name: &str,
532    journal: &AssetStatusJournal,
533) -> Result<StaticAssetSyncReport> {
534    // `cf_provider` resolved from the mirror slot's `use = "<id>"` — supplies
535    // account_id + management token + R2 S3 keys, all per-provider.
536    let account_id = cf_provider.account_id.clone();
537
538    let bucket = slot_fields
539        .get("bucket")
540        .and_then(|v| v.as_str())
541        .context("providers.object_store missing `bucket` field for cloudflare static-asset sync")?
542        .to_string();
543
544    let (access_key, secret_key) = cf_provider.r2_keys()?;
545    let api_token = cf_provider.api_token()?;
546
547    ensure_r2_bucket(&api_token, &account_id, &bucket).await?;
548
549    let endpoint = format!("https://{account_id}.r2.cloudflarestorage.com");
550    let client = reqwest::Client::new();
551
552    sync_assets(
553        workload,
554        ctx.workspace_root,
555        &ctx.workload_dir(),
556        &client,
557        &endpoint,
558        &bucket,
559        R2_REGION,
560        &access_key,
561        &secret_key,
562        executor,
563        service_name,
564        journal,
565    )
566    .await
567}
568
569async fn sync_to_minio(
570    ctx: &ReconcileCtx<'_>,
571    workload: &StaticAssetWorkload,
572    slot_fields: &std::collections::BTreeMap<String, toml::Value>,
573    executor: Arc<dyn ForgeExecutor>,
574    service_name: &str,
575    journal: &AssetStatusJournal,
576) -> Result<StaticAssetSyncReport> {
577    use super::slot_field_u16;
578    use crate::reconciler::pond::DEFAULT_MINIO_API_PORT;
579
580    let api_port = slot_field_u16(slot_fields, "api_port").unwrap_or(DEFAULT_MINIO_API_PORT);
581    let bucket = slot_fields
582        .get("bucket")
583        .and_then(|v| v.as_str())
584        .context(
585            "providers.object_store missing `bucket` field for minio-container static-asset sync",
586        )?
587        .to_string();
588
589    let endpoint = format!("http://127.0.0.1:{api_port}");
590    let client = reqwest::Client::new();
591
592    sync_assets(
593        workload,
594        ctx.workspace_root,
595        &ctx.workload_dir(),
596        &client,
597        &endpoint,
598        &bucket,
599        MINIO_REGION,
600        DEFAULT_MINIO_USER,
601        DEFAULT_MINIO_PASSWORD,
602        executor,
603        service_name,
604        journal,
605    )
606    .await
607}
608
609// ── Core sync logic ───────────────────────────────────────────────────────────
610
611async fn sync_assets(
612    workload: &StaticAssetWorkload,
613    workspace_root: &Path,
614    workload_dir: &Path,
615    client: &reqwest::Client,
616    endpoint: &str,
617    bucket: &str,
618    region: &str,
619    access_key: &str,
620    secret_key: &str,
621    executor: Arc<dyn ForgeExecutor>,
622    service_name: &str,
623    journal: &AssetStatusJournal,
624) -> Result<StaticAssetSyncReport> {
625    let mut report = StaticAssetSyncReport::default();
626    let endpoint = endpoint.trim_end_matches('/');
627
628    // Load the stored catalog manifest to detect prune candidates.
629    let prior_manifest =
630        load_catalog_manifest(client, endpoint, bucket, region, access_key, secret_key).await;
631
632    // Catalog set for prune detection: current catalog filenames.
633    let current_filenames: std::collections::HashSet<&str> = workload
634        .assets
635        .iter()
636        .map(|a| a.filename.as_str())
637        .collect();
638
639    // Prune candidates: filenames in the stored manifest but not in current catalog.
640    for prior_key in prior_manifest.keys() {
641        if !current_filenames.contains(prior_key.as_str()) {
642            report.prune_candidates.push(prior_key.clone());
643        }
644    }
645
646    // Updated manifest built from this run.
647    let mut new_manifest = CatalogManifest::new();
648
649    for entry in &workload.assets {
650        // W212/R518: substituter fast-path. When the committed `[asset.derive.lock]`
651        // still matches the inputs recomputed from the current pins (purely
652        // local — no fetch) AND the bucket already holds the output, skip the
653        // entire build: no model download, no Docker transform, no PUT. This is
654        // the Nix-substituter / Bazel-remote-cache behaviour, backed by the
655        // checked-in lock as the action cache and R2 as the CAS.
656        if let Some(out_hash) = lock_skip_hash(entry, workspace_root).await {
657            let object_url = format!("{endpoint}/{bucket}/{}", entry.filename);
658            // R546-B10: the lock says WHAT the output should be; the bucket must
659            // actually hold it. An unstamped legacy object is not evidence of a
660            // match, so it falls through and rebuilds rather than blessing bytes
661            // we cannot identify — the expensive-but-correct direction.
662            let remote = head_object(client, &object_url, region, access_key, secret_key).await?;
663            if remote.matches(&out_hash) {
664                debug!(
665                    filename = %entry.filename,
666                    "derivation lock in sync + object content matches — skipping build",
667                );
668                report.already_synced.push(entry.filename.clone());
669                new_manifest.insert(entry.filename.clone(), out_hash);
670                continue;
671            }
672            if remote.exists() {
673                debug!(
674                    filename = %entry.filename,
675                    "derivation lock in sync but bucket object is unstamped or drifted \
676                     — not trusting it, falling through to build/publish",
677                );
678            }
679        }
680
681        // R438-T15: derive-mode assets materialize to a content-addressed cache
682        // path (W164); legacy `source = "..."` assets read straight from disk.
683        // Both arms return a real on-disk path that the existing BLAKE3 verify +
684        // S3 PUT loop below treats uniformly.
685        let materialized =
686            materialize_asset(entry, workspace_root, workload_dir, executor.as_ref())
687                .await
688                .with_context(|| format!("materializing asset {:?}", entry.filename))?;
689        let source_path = materialized.path;
690        // Capture before the discovered_fetch_hash is moved out below.
691        let fetch_was_bootstrap = materialized.discovered_fetch_hash.is_some();
692        // W212/R518: derivation key for this build — emitted below so the bind
693        // path refreshes `[asset.derive.lock].input_hash`.
694        let build_derive_key = materialized.derive_key.clone();
695
696        // Surface any discovered upstream BLAKE3 (bootstrap mode for
697        // `[asset.derive.fetch].blake3`). Collected into `report.bootstrapped`
698        // and emitted to `$YAH_OUTPUTS` by the summary block in `up` (W209/F4).
699        if let Some(hash) = materialized.discovered_fetch_hash {
700            debug!(
701                filename = %entry.filename,
702                discovered = %hash,
703                "bootstrap discovered upstream blake3",
704            );
705            report.bootstrapped.push(BootstrappedHash {
706                filename: entry.filename.clone(),
707                kind: BootstrapHashKind::Fetch,
708                hash,
709            });
710        }
711
712        // Read source file.
713        let body = tokio::fs::read(&source_path).await.with_context(|| {
714            format!(
715                "reading source file {} for asset {:?}",
716                source_path.display(),
717                entry.filename
718            )
719        })?;
720
721        // BLAKE3 verification — strict mode rejects mismatch and skips upload;
722        // bootstrap mode (entry.blake3 == zero sentinel) accepts the computed
723        // hash and surfaces it in the report for paste-back. The bytes are
724        // uploaded either way — the difference is whether the pin is being
725        // *verified against* (strict) or *discovered* (bootstrap).
726        let actual_hash = blake3_hex(&body);
727        if is_bootstrap_sentinel(&entry.blake3.0) {
728            debug!(
729                filename = %entry.filename,
730                discovered = %actual_hash,
731                "bootstrap discovered asset blake3",
732            );
733            report.bootstrapped.push(BootstrappedHash {
734                filename: entry.filename.clone(),
735                kind: BootstrapHashKind::Output,
736                hash: actual_hash.clone(),
737            });
738        } else if !hashes_equal(&actual_hash, &entry.blake3.0) {
739            warn!(
740                filename = %entry.filename,
741                declared = %entry.blake3.0,
742                actual = %actual_hash,
743                "BLAKE3 mismatch — source file doesn't match declared hash",
744            );
745            report.hash_mismatch.push(entry.filename.clone());
746            journal
747                .append(&AssetStatusEvent {
748                    at: Utc::now(),
749                    asset: format!("{service_name}:{}", entry.filename),
750                    from: None,
751                    to: AssetState::DriftBucket,
752                    bytes: None,
753                    blake3: None,
754                })
755                .await;
756            continue;
757        }
758
759        // W212/R518: a verified build emits its derivation key so the bind path
760        // refreshes `[asset.derive.lock].input_hash`. Paired with the output
761        // hash above, this records the action-cache entry the next run's
762        // substituter fast-path consults. Only emitted when a build actually
763        // ran (the lock-skip path `continue`d before reaching here).
764        if let Some(dk) = &build_derive_key {
765            report.bootstrapped.push(BootstrappedHash {
766                filename: entry.filename.clone(),
767                kind: BootstrapHashKind::Input,
768                hash: dk.clone(),
769            });
770        }
771
772        let key = &entry.filename;
773        let object_url = format!("{endpoint}/{bucket}/{key}");
774
775        // R546-B10: skip the PUT only when the bucket demonstrably holds THESE
776        // bytes. Existence alone is not enough — an object placed at this key
777        // out-of-band, or a rebuild that isn't byte-reproducible, leaves content
778        // that differs from the artifact we just verified. Skipping on mere
779        // existence let the bucket keep stale bytes while `new_manifest` below
780        // recorded the new hash, so the published catalog described bytes that
781        // were never uploaded.
782        //
783        // Note `actual_hash` is already verified equal to the declared
784        // `entry.blake3` at this point (the mismatch arm above `continue`s), so
785        // re-PUTting on drift converges the bucket toward the committed catalog
786        // rather than clobbering it with something unvetted.
787        let remote = head_object(client, &object_url, region, access_key, secret_key).await?;
788        if remote.matches(&actual_hash) {
789            debug!(key, "already present with matching blake3 — skipping PUT");
790            report.already_synced.push(key.clone());
791            new_manifest.insert(key.clone(), actual_hash);
792            continue;
793        }
794        if remote.exists() {
795            // Drifted, or a legacy object predating the metadata stamp. Either
796            // way re-PUT: identical bytes make it a cheap idempotent overwrite
797            // that heals the missing stamp, and differing bytes are exactly the
798            // case that must not be skipped. Legacy objects therefore cost ONE
799            // re-upload, after which the stamp makes the skip work again.
800            warn!(
801                key,
802                expected = %actual_hash,
803                remote = ?match &remote {
804                    RemoteObject::Present { blake3 } => blake3.as_deref(),
805                    RemoteObject::Absent => None,
806                },
807                "bucket object does not match the declared artifact — re-publishing",
808            );
809            report.republished.push(key.clone());
810        }
811
812        // PUT the file.
813        let body_sha256 = sha256_hex(&body);
814        let content_length = body.len();
815        let content_type = content_type_for(&source_path);
816        let headers = sign_s3_put_object(
817            &object_url,
818            &body_sha256,
819            content_type,
820            content_length,
821            region,
822            access_key,
823            secret_key,
824            // R546-B10: stamp the content hash so a later run can tell whether
825            // the bucket already holds these exact bytes.
826            Some(actual_hash.as_str()),
827        )
828        .with_context(|| format!("signing PUT {object_url}"))?;
829
830        let resp = client
831            .put(&object_url)
832            .headers(headers)
833            .body(body)
834            .send()
835            .await
836            .with_context(|| format!("PUT {object_url}"))?;
837
838        if !resp.status().is_success() {
839            let status = resp.status();
840            let body_text = resp.text().await.unwrap_or_default();
841            anyhow::bail!("PUT {object_url} → {status}: {}", body_text.trim());
842        }
843
844        info!(key, "uploaded");
845        report.uploaded.push(key.clone());
846        new_manifest.insert(key.clone(), actual_hash.clone());
847        let from_state = if fetch_was_bootstrap {
848            AssetState::PlaceholderFetch
849        } else if is_bootstrap_sentinel(&entry.blake3.0) {
850            AssetState::PlaceholderOutput
851        } else {
852            AssetState::PinnedNotPublished
853        };
854        journal
855            .append(&AssetStatusEvent {
856                at: Utc::now(),
857                asset: format!("{service_name}:{key}"),
858                from: Some(from_state),
859                to: AssetState::Published,
860                bytes: Some(content_length as u64),
861                blake3: Some(actual_hash),
862            })
863            .await;
864    }
865
866    // Save the updated manifest (non-fatal: data is already in the bucket).
867    if let Err(e) = save_catalog_manifest(
868        client,
869        endpoint,
870        bucket,
871        &new_manifest,
872        region,
873        access_key,
874        secret_key,
875    )
876    .await
877    {
878        warn!(
879            error = %e,
880            "failed to save asset catalog manifest (non-fatal) — prune detection may miss \
881             candidates on the next run"
882        );
883    }
884
885    Ok(report)
886}
887
888// ── W164 materialize step (R438-T15) ──────────────────────────────────────────
889
890/// Output of [`materialize_asset`]: the on-disk path the upload loop reads,
891/// plus any BLAKE3 discovered during bootstrap-mode fetch.
892struct MaterializedAsset {
893    path: PathBuf,
894    /// Set when `derive.fetch.blake3` was the zero sentinel and the reconciler
895    /// computed the actual upstream hash. Operator pastes this back into
896    /// `[asset.derive.fetch].blake3`. `None` in strict mode and for legacy
897    /// `source = "..."` assets.
898    discovered_fetch_hash: Option<String>,
899    /// W212/R518: the input-addressed derivation key for this build, emitted to
900    /// `$YAH_OUTPUTS` so the bind path refreshes `[asset.derive.lock].input_hash`.
901    /// `None` for `source` / no-transform assets.
902    derive_key: Option<String>,
903}
904
905/// BLAKE3 of a transform recipe's TOML bytes — an input to the derivation key
906/// (W212). The file carries the pinned container digest, steps, and placement,
907/// so hashing it captures every recipe-side input in one value. `None` when the
908/// recipe file is unreadable (the caller treats that as "can't memoize").
909async fn recipe_blake3(workspace_root: &Path, recipe: &str) -> Option<String> {
910    let path = workspace_root
911        .join(".yah/qed/transforms")
912        .join(format!("{recipe}.toml"));
913    let bytes = tokio::fs::read(&path).await.ok()?;
914    Some(blake3_hex(&bytes))
915}
916
917/// W212/R518: the substituter fast-path. Returns `Some(output_hash)` when the
918/// asset's committed `[asset.derive.lock]` is *current* — i.e. the derivation
919/// key recomputed from the COMMITTED pins (no network) equals `lock.input_hash`
920/// and the lock describes the currently-pinned output. The caller still
921/// confirms the bytes exist in the bucket before skipping the build. Returns
922/// `None` (→ build normally) for source assets, no-transform assets, bootstrap
923/// rows, a missing lock, or any input drift.
924async fn lock_skip_hash(entry: &AssetEntry, workspace_root: &Path) -> Option<String> {
925    let derive = entry.derive.as_ref()?;
926    let transform = derive.transform.as_ref()?;
927    let lock = derive.lock.as_ref()?;
928    // A sentinel pin means nothing has been pinned yet → must build.
929    if is_bootstrap_sentinel(&entry.blake3.0) || is_bootstrap_sentinel(&derive.fetch.blake3.0) {
930        return None;
931    }
932    // The lock must describe the currently-pinned output, else it's stale.
933    if !hashes_equal(&lock.output_blake3, &entry.blake3.0) {
934        return None;
935    }
936    // Recompute the derivation key from the committed pins — purely local, no
937    // fetch. The fetch pin is the declared input identity (a fixed-output
938    // derivation), so in strict mode it equals the bytes that fed the build.
939    let recipe_bk = recipe_blake3(workspace_root, &transform.recipe).await?;
940    let key = derivation_key(&derive.fetch.blake3.0, &recipe_bk, &transform.params);
941    (key == lock.input_hash).then(|| entry.blake3.0.clone())
942}
943
944/// Resolve an `[[asset]]` row to an on-disk path the upload loop can read.
945///
946/// Two modes:
947///
948/// - **Legacy `source`**: returns `workload_dir.join(source_rel)` — bytes are
949///   already on disk, the rest of the pipeline is unchanged.
950/// - **Derive (`derive.fetch[+transform]`)**: fetches the upstream blob into
951///   `.yah/cache/derive/fetch/<upstream-blake3>.bin`, optionally runs the
952///   transform recipe into `.yah/cache/derive/transform/<output-blake3>.bin`,
953///   and returns whichever cache path holds the final bytes. The shape
954///   validator (`shape_static_asset`) guarantees exactly one of the two is
955///   set, so the `else` branch is total.
956///
957/// **Bootstrap mode.** When `derive.fetch.blake3` or `entry.blake3` is the
958/// zero sentinel, the reconciler discovers the actual hash instead of
959/// verifying against the pinned value. The discovered upstream hash is
960/// returned in `discovered_fetch_hash`; the discovered output hash is
961/// computed at the upload site (the bytes are read there anyway) and
962/// surfaced separately.
963async fn materialize_asset(
964    entry: &AssetEntry,
965    workspace_root: &Path,
966    workload_dir: &Path,
967    executor: &dyn ForgeExecutor,
968) -> Result<MaterializedAsset> {
969    if let Some(source_rel) = entry.source.as_ref() {
970        return Ok(MaterializedAsset {
971            path: workload_dir.join(source_rel),
972            discovered_fetch_hash: None,
973            derive_key: None,
974        });
975    }
976
977    let derive = entry
978        .derive
979        .as_ref()
980        .expect("AssetEntry shape: exactly one of source/derive must be set (shape_static_asset)");
981
982    let cache_root = workspace_root.join(".yah/cache/derive");
983    let fetch_bootstrap = is_bootstrap_sentinel(&derive.fetch.blake3.0);
984    let (fetched_path, fetched_hash) =
985        materialize_fetch(&derive.fetch, &cache_root.join("fetch")).await?;
986    let discovered_fetch_hash = fetch_bootstrap.then(|| fetched_hash.clone());
987
988    let path = if let Some(transform) = &derive.transform {
989        materialize_transform(
990            transform,
991            &fetched_path,
992            &fetched_hash,
993            &entry.blake3.0,
994            &cache_root.join("transform"),
995            workspace_root,
996            executor,
997        )
998        .await?
999    } else {
1000        // No transform — the fetched bytes ARE the output. In strict mode,
1001        // fetch.blake3 must equal entry.blake3; let the upload loop's BLAKE3
1002        // verify surface any mismatch. In bootstrap mode the upload loop
1003        // discovers entry.blake3 directly from the file bytes.
1004        fetched_path
1005    };
1006
1007    // W212/R518: derivation key for this build — the same value the local action
1008    // cache used (fetched-input hash ⊕ recipe-file hash ⊕ params). Surfaced so
1009    // the bind path can refresh `[asset.derive.lock].input_hash`.
1010    let derive_key = match &derive.transform {
1011        Some(transform) => recipe_blake3(workspace_root, &transform.recipe)
1012            .await
1013            .map(|rb| derivation_key(&fetched_hash, &rb, &transform.params)),
1014        None => None,
1015    };
1016
1017    Ok(MaterializedAsset {
1018        path,
1019        discovered_fetch_hash,
1020        derive_key,
1021    })
1022}
1023
1024/// Maximum number of fetch attempts (initial try + retries).
1025#[cfg(not(test))]
1026const FETCH_MAX_ATTEMPTS: u32 = 5;
1027#[cfg(test)]
1028const FETCH_MAX_ATTEMPTS: u32 = 3; // fewer retries in tests
1029
1030/// Initial backoff between retries in milliseconds (doubles each attempt).
1031#[cfg(not(test))]
1032const FETCH_BASE_DELAY_MS: u64 = 1_000;
1033#[cfg(test)]
1034const FETCH_BASE_DELAY_MS: u64 = 1; // near-instant in tests
1035
1036/// Ceiling on retry backoff in milliseconds.
1037#[cfg(not(test))]
1038const FETCH_MAX_DELAY_MS: u64 = 30_000;
1039#[cfg(test)]
1040const FETCH_MAX_DELAY_MS: u64 = 5; // near-instant in tests
1041
1042/// Log download progress every N bytes.
1043const FETCH_LOG_INTERVAL_BYTES: u64 = 100 * 1024 * 1024; // 100 MiB
1044
1045/// Outcome of a single download attempt that did not fully succeed. Used by
1046/// [`fetch_once`] to distinguish a transient failure (retry + Range resume)
1047/// from a non-retriable 4xx (bail immediately).
1048enum FetchOnceFail {
1049    /// Server returned a non-retriable 4xx. Caller should surface and stop.
1050    Fatal(reqwest::StatusCode),
1051    /// Network error or retriable server response (5xx / 429). Partial file
1052    /// is preserved on disk for Range-header resumption on the next attempt.
1053    Transient(anyhow::Error),
1054}
1055
1056/// Fetch `fetch.url` into `<cache_dir>/<fetch.blake3>.bin`, verifying BLAKE3.
1057///
1058/// On cache HIT the network is skipped entirely; a hash mismatch surfaces as a
1059/// hard error (bit-rot / hand-edited cache, not silently re-fetched).
1060///
1061/// Cache MISS downloads the blob with:
1062/// - **Streaming to disk** — `Response::chunk()` rather than buffering the full
1063///   body in RAM (required for multi-GB blobs like whisper-large at 1.5 GB).
1064/// - **Exponential-backoff retry** — up to [`FETCH_MAX_ATTEMPTS`] attempts on
1065///   transient network errors and 5xx / 429 HTTP responses.
1066/// - **Range-header resume** — if a `.partial` file exists from a prior attempt,
1067///   subsequent tries send `Range: bytes=<offset>-` to avoid re-downloading
1068///   already-received bytes.
1069/// - **Progress logging** — `info!()` every 100 MiB surfaces download progress
1070///   through the task-pane / QED log surface (W164 OQ#4).
1071async fn materialize_fetch(fetch: &FetchSource, cache_dir: &Path) -> Result<(PathBuf, String)> {
1072    let bootstrap = is_bootstrap_sentinel(&fetch.blake3.0);
1073
1074    tokio::fs::create_dir_all(cache_dir)
1075        .await
1076        .with_context(|| format!("creating fetch cache dir {}", cache_dir.display()))?;
1077
1078    // Cache HIT — only meaningful when we know the expected hash. Bootstrap
1079    // mode has no stable cache key to look up; it always re-downloads on the
1080    // first run, then strict mode (after the operator pins the discovered
1081    // hash) takes the HIT path on subsequent runs.
1082    if !bootstrap {
1083        let cache_path = cache_dir.join(format!("{}.bin", fetch.blake3.0));
1084        if tokio::fs::try_exists(&cache_path).await.unwrap_or(false) {
1085            verify_blake3_path(&cache_path, &fetch.blake3.0)
1086                .await
1087                .with_context(|| {
1088                    format!(
1089                        "fetch cache HIT for {} but bytes don't match pinned BLAKE3 — \
1090                     rm the cache entry or fix the pin",
1091                        cache_path.display()
1092                    )
1093                })?;
1094            debug!(url = %fetch.url, "fetch cache HIT");
1095            return Ok((cache_path, fetch.blake3.0.clone()));
1096        }
1097    }
1098
1099    // Partial filename: stable per (URL, mode) so Range resume works across
1100    // attempts. In bootstrap mode every fetch shares the zero "expected" hash,
1101    // so partials are namespaced by URL hash to avoid cross-asset collisions.
1102    let partial_path = if bootstrap {
1103        cache_dir.join(format!(
1104            "bootstrap-{}.partial",
1105            blake3_hex(fetch.url.as_bytes())
1106        ))
1107    } else {
1108        cache_dir.join(format!("{}.partial", fetch.blake3.0))
1109    };
1110
1111    let client = reqwest::Client::new();
1112    let mut last_err = anyhow::anyhow!("no attempt made");
1113
1114    for attempt in 0..FETCH_MAX_ATTEMPTS {
1115        if attempt > 0 {
1116            let delay_ms = (FETCH_BASE_DELAY_MS << (attempt - 1)).min(FETCH_MAX_DELAY_MS);
1117            warn!(
1118                url = %fetch.url,
1119                attempt,
1120                delay_ms,
1121                err = %last_err,
1122                "fetch transient failure; retrying with backoff",
1123            );
1124            tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await;
1125        }
1126
1127        match fetch_once(&client, &fetch.url, &partial_path).await {
1128            Ok(()) => {
1129                // Compute hash once — used for verify (strict) and cache
1130                // naming (both modes; bootstrap mode names by the discovered
1131                // value rather than the zero placeholder).
1132                let bytes = tokio::fs::read(&partial_path)
1133                    .await
1134                    .with_context(|| format!("reading {} for BLAKE3", partial_path.display()))?;
1135                let actual = blake3_hex(&bytes);
1136                drop(bytes);
1137
1138                if !bootstrap && !hashes_equal(&actual, &fetch.blake3.0) {
1139                    anyhow::bail!(
1140                        "downloaded {} but BLAKE3 doesn't match pin — \
1141                         check the pin in workload.toml (actual {actual} != expected {})",
1142                        fetch.url,
1143                        fetch.blake3.0,
1144                    );
1145                }
1146
1147                let cache_path = cache_dir.join(format!("{actual}.bin"));
1148                tokio::fs::rename(&partial_path, &cache_path)
1149                    .await
1150                    .with_context(|| {
1151                        format!(
1152                            "promoting {} → {}",
1153                            partial_path.display(),
1154                            cache_path.display()
1155                        )
1156                    })?;
1157                if bootstrap {
1158                    info!(url = %fetch.url, discovered = %actual, "fetch complete (bootstrap)");
1159                } else {
1160                    info!(url = %fetch.url, "fetch complete");
1161                }
1162                return Ok((cache_path, actual));
1163            }
1164            Err(FetchOnceFail::Fatal(status)) => {
1165                anyhow::bail!("GET {} → HTTP {status} (not retriable)", fetch.url);
1166            }
1167            Err(FetchOnceFail::Transient(e)) => {
1168                last_err = e;
1169            }
1170        }
1171    }
1172
1173    Err(last_err).with_context(|| {
1174        format!(
1175            "GET {} failed after {FETCH_MAX_ATTEMPTS} attempts",
1176            fetch.url
1177        )
1178    })
1179}
1180
1181/// Single download attempt: GET `url` (with `Range` if `partial_path` is non-empty),
1182/// stream the response body to `partial_path`, and return once the body is exhausted.
1183///
1184/// Returns `Ok(())` when all bytes were received and flushed to disk.
1185/// Returns `Err(FetchOnceFail::Fatal)` for non-retriable 4xx responses.
1186/// Returns `Err(FetchOnceFail::Transient)` for connection errors, 5xx, or 429;
1187/// the partial file is left intact so the next attempt can resume via `Range`.
1188async fn fetch_once(
1189    client: &reqwest::Client,
1190    url: &str,
1191    partial_path: &Path,
1192) -> Result<(), FetchOnceFail> {
1193    use tokio::io::AsyncWriteExt;
1194
1195    let resume_offset = tokio::fs::metadata(partial_path)
1196        .await
1197        .ok()
1198        .map(|m| m.len())
1199        .filter(|&n| n > 0);
1200
1201    let mut req = client.get(url);
1202    if let Some(offset) = resume_offset {
1203        req = req.header(reqwest::header::RANGE, format!("bytes={offset}-"));
1204        info!(url, offset, "resuming partial download");
1205    } else {
1206        info!(url, "downloading");
1207    }
1208
1209    let resp = req
1210        .send()
1211        .await
1212        .map_err(|e| FetchOnceFail::Transient(anyhow::anyhow!("connecting to {url}: {e}")))?;
1213
1214    let status = resp.status();
1215    let is_partial_response = status == reqwest::StatusCode::PARTIAL_CONTENT;
1216
1217    if status == reqwest::StatusCode::RANGE_NOT_SATISFIABLE {
1218        // Partial file may already hold all bytes (prior complete-but-not-verified
1219        // attempt). Delete it and let the next retry start fresh.
1220        let _ = tokio::fs::remove_file(partial_path).await;
1221        return Err(FetchOnceFail::Transient(anyhow::anyhow!(
1222            "GET {url} → 416 Range Not Satisfiable; partial cleared"
1223        )));
1224    }
1225    if status.is_client_error() {
1226        return Err(FetchOnceFail::Fatal(status));
1227    }
1228    if status.is_server_error() || status == reqwest::StatusCode::TOO_MANY_REQUESTS {
1229        return Err(FetchOnceFail::Transient(anyhow::anyhow!(
1230            "GET {url} → HTTP {status}"
1231        )));
1232    }
1233    if !status.is_success() && !is_partial_response {
1234        return Err(FetchOnceFail::Fatal(status));
1235    }
1236
1237    // Server returned 200 instead of 206 — it ignored our Range request.
1238    // Discard any partial bytes and stream the full body fresh.
1239    if !is_partial_response && resume_offset.is_some() {
1240        let _ = tokio::fs::remove_file(partial_path).await;
1241    }
1242
1243    let mut file = tokio::fs::OpenOptions::new()
1244        .write(true)
1245        .create(true)
1246        .append(is_partial_response)
1247        .truncate(!is_partial_response)
1248        .open(partial_path)
1249        .await
1250        .map_err(|e| {
1251            FetchOnceFail::Transient(anyhow::anyhow!("opening {}: {e}", partial_path.display()))
1252        })?;
1253
1254    let mut downloaded = if is_partial_response {
1255        resume_offset.unwrap_or(0)
1256    } else {
1257        0
1258    };
1259    let mut resp = resp;
1260    loop {
1261        match resp.chunk().await {
1262            Ok(Some(chunk)) => {
1263                file.write_all(&chunk).await.map_err(|e| {
1264                    FetchOnceFail::Transient(anyhow::anyhow!(
1265                        "writing chunk to {}: {e}",
1266                        partial_path.display()
1267                    ))
1268                })?;
1269                let prev = downloaded;
1270                downloaded += chunk.len() as u64;
1271                if downloaded / FETCH_LOG_INTERVAL_BYTES > prev / FETCH_LOG_INTERVAL_BYTES {
1272                    info!(
1273                        url,
1274                        downloaded_mib = downloaded / (1024 * 1024),
1275                        "fetch progress",
1276                    );
1277                }
1278            }
1279            Ok(None) => break,
1280            Err(e) => {
1281                return Err(FetchOnceFail::Transient(anyhow::anyhow!(
1282                    "reading body from {url}: {e}"
1283                )));
1284            }
1285        }
1286    }
1287
1288    file.flush().await.map_err(|e| {
1289        FetchOnceFail::Transient(anyhow::anyhow!("flushing {}: {e}", partial_path.display()))
1290    })?;
1291
1292    Ok(())
1293}
1294
1295/// Run the transform recipe against `input_path`, writing the output to
1296/// `<cache_dir>/<actual_blake3>.bin`. In strict mode the actual hash is
1297/// verified against `output_blake3`; in bootstrap mode (`output_blake3` ==
1298/// zero sentinel) the actual hash is accepted as-is and named accordingly.
1299/// Cache HIT (file present + hash matches) skips recipe execution; bootstrap
1300/// mode has no stable cache key and always re-runs the recipe.
1301/// W212 schema version for the derivation key. Bump when the *way* we hash or
1302/// lower inputs changes (not when an individual recipe changes — that's
1303/// captured by the recipe-file hash). A bump invalidates every action-cache
1304/// entry, forcing a clean rebuild under the new keying.
1305const DERIVE_KEY_SCHEMA: u32 = 1;
1306
1307/// Compute the input-addressed action-cache key for a derive-mode transform
1308/// (W212). A pure function of the complete declared input set:
1309///
1310/// - `input_hash` — BLAKE3 of the fetched transform input (the fixed-output /
1311///   version anchor; for whisper this is `config.json`, which the model's repo
1312///   version tracks).
1313/// - `recipe_blake3` — BLAKE3 of the recipe TOML bytes. The file carries the
1314///   pinned container digest, the steps, and the placement, so hashing it
1315///   covers all three: a digest bump or a step edit flips the key.
1316/// - `params` — the workload-side invocation params (`BTreeMap` is already
1317///   sorted, so the encoding is deterministic).
1318///
1319/// **Hermeticity invariant:** every input that can change the output bytes MUST
1320/// feed this key. A missing dimension reintroduces the W164/R438 silent-stale
1321/// cache bug. The per-dimension unit test guards this.
1322fn derivation_key(
1323    input_hash: &str,
1324    recipe_blake3: &str,
1325    params: &BTreeMap<String, String>,
1326) -> String {
1327    let mut buf = String::new();
1328    buf.push_str(&format!("derive-key-v{DERIVE_KEY_SCHEMA}\n"));
1329    buf.push_str(&format!("input={input_hash}\n"));
1330    buf.push_str(&format!("recipe={recipe_blake3}\n"));
1331    for (k, v) in params {
1332        buf.push_str(&format!("param.{k}={v}\n"));
1333    }
1334    blake3_hex(buf.as_bytes())
1335}
1336
1337/// Read a recorded action-cache output hash, if present. A missing or
1338/// unreadable / empty entry is a cache miss, never an error.
1339async fn read_action_cache(ac_path: &Path) -> Option<String> {
1340    let raw = tokio::fs::read_to_string(ac_path).await.ok()?;
1341    let hash = raw.trim();
1342    (!hash.is_empty()).then(|| hash.to_string())
1343}
1344
1345/// Record an action-cache entry (`derive_key → output hash`). Written
1346/// atomically via tmp + rename so a crashed run never leaves a half-written
1347/// entry a later run would trust.
1348async fn write_action_cache(ac_path: &Path, output_hash: &str) -> Result<()> {
1349    if let Some(parent) = ac_path.parent() {
1350        tokio::fs::create_dir_all(parent)
1351            .await
1352            .with_context(|| format!("creating action-cache dir {}", parent.display()))?;
1353    }
1354    let tmp = ac_path.with_extension("out.tmp");
1355    tokio::fs::write(&tmp, format!("{output_hash}\n"))
1356        .await
1357        .with_context(|| format!("writing action-cache tmp {}", tmp.display()))?;
1358    tokio::fs::rename(&tmp, ac_path)
1359        .await
1360        .with_context(|| format!("promoting action-cache entry {}", ac_path.display()))?;
1361    Ok(())
1362}
1363
1364async fn materialize_transform(
1365    transform: &TransformSpec,
1366    input_path: &Path,
1367    input_hash: &str,
1368    output_blake3: &str,
1369    cache_dir: &Path,
1370    workspace_root: &Path,
1371    executor: &dyn ForgeExecutor,
1372) -> Result<PathBuf> {
1373    let bootstrap = is_bootstrap_sentinel(output_blake3);
1374
1375    tokio::fs::create_dir_all(cache_dir)
1376        .await
1377        .with_context(|| format!("creating transform cache dir {}", cache_dir.display()))?;
1378
1379    // Load + hash the recipe up front: the recipe TOML's bytes are an INPUT to
1380    // the derivation key (W212), so the key — and therefore the cache decision —
1381    // depends on them. The file carries the pinned container digest, steps, and
1382    // placement, so a change to any of those flips the key and forces a rebuild.
1383    let transforms_dir = workspace_root.join(".yah/qed/transforms");
1384    let loader = TransformRecipeLoader::new(&transforms_dir);
1385    let recipe_path = loader.recipe_path(&transform.recipe);
1386    let recipe = loader.load(&transform.recipe).with_context(|| {
1387        format!(
1388            "loading recipe {:?} from {}",
1389            transform.recipe,
1390            transforms_dir.display()
1391        )
1392    })?;
1393    let recipe_bytes = tokio::fs::read(&recipe_path).await.with_context(|| {
1394        format!(
1395            "reading recipe {} for derivation key",
1396            recipe_path.display()
1397        )
1398    })?;
1399    let recipe_blake3 = blake3_hex(&recipe_bytes);
1400
1401    // W212: input-addressed action cache. The skip decision is a pure function
1402    // of the declared INPUTS (fetched input content ⊕ recipe definition ⊕
1403    // invocation params ⊕ lowering-schema version), not of the expected output
1404    // hash. A recipe / digest / param change flips `derive_key` → guaranteed
1405    // miss → rebuild, eliminating the warm-cache silent-stale skip (the
1406    // W164/R438 defect this fixes). Applies in bootstrap mode too: identical
1407    // inputs surface the recorded output without re-running.
1408    let derive_key = derivation_key(input_hash, &recipe_blake3, &transform.params);
1409    let ac_path = cache_dir.join("ac").join(format!("{derive_key}.out"));
1410
1411    if let Some(recorded) = read_action_cache(&ac_path).await {
1412        let cas_path = cache_dir.join(format!("{recorded}.bin"));
1413        if tokio::fs::try_exists(&cas_path).await.unwrap_or(false)
1414            && verify_blake3_path(&cas_path, &recorded).await.is_ok()
1415        {
1416            // The output pin (`entry.blake3`) is still enforced downstream by
1417            // the upload loop's strict BLAKE3 verify, so a pin that has drifted
1418            // from the inputs surfaces there even though we skip the transform.
1419            debug!(recipe = %recipe.name, derive_key, "derivation cache HIT — skipping transform");
1420            return Ok(cas_path);
1421        }
1422        // AC entry present but the CAS bytes are gone / corrupt → fall through
1423        // and re-materialise (the AC records what the output should be; the CAS
1424        // lost the bytes).
1425    }
1426
1427    // Absolute workspace_root for the container bind-mount — docker rejects
1428    // `-w .` ("the working directory '.' is invalid"). Callers may pass a
1429    // relative path (the CLI `--path` default is "."), so canonicalize once.
1430    let workspace_abs = workspace_root
1431        .canonicalize()
1432        .with_context(|| format!("canonicalizing workspace root {}", workspace_root.display()))?;
1433
1434    // R546-B8: the IN/OUT bindings handed to the recipe MUST be absolute.
1435    // `cache_dir` is derived from workspace_root, which is "." by CLI default,
1436    // so these used to be relative (`./.yah/cache/derive/transform/<key>.tmp`).
1437    // A relative path only survives if the recipe never leaves its cwd — true
1438    // for whisper's recipes, FALSE for rusty-v8: build-v8.sh chdirs into a
1439    // scratch dir (/tmp/tmp.XXXX/v8src) to build V8, so it wrote its finished
1440    // tar to "<scratch>/./.yah/cache/..." INSIDE the container, invisible to the
1441    // host. The step then exited 0 and the reconciler failed reading a file that
1442    // was never going to be there — after a ~2h build. Canonicalize the parent
1443    // (it exists; created above) and join the filename, since the tmp output
1444    // itself does not exist yet and cannot be canonicalized directly.
1445    let cache_dir_abs = cache_dir
1446        .canonicalize()
1447        .with_context(|| format!("canonicalizing transform cache dir {}", cache_dir.display()))?;
1448
1449    // Tmp output is named by the derivation key — unique per input set, so
1450    // concurrent rows (and zero-sentinel bootstrap rows) never collide on it.
1451    let tmp_output = cache_dir_abs.join(format!("{derive_key}.tmp"));
1452
1453    let input_abs = input_path
1454        .canonicalize()
1455        .unwrap_or_else(|_| input_path.to_path_buf());
1456
1457    // R555-F3: a remotely-placed recipe runs on a worker with its own
1458    // filesystem, so the IN/OUT contract has to be re-read there. `remote_out`
1459    // is the worker-side path bound as {{YAH_TRANSFORM_OUT}}; the bytes are
1460    // pulled back to `tmp_output` after the step, and everything downstream
1461    // (exists-check, BLAKE3, rename, action cache) is untouched.
1462    let remote_out = match &recipe.placement.location {
1463        TaskLocation::Local => None,
1464        _ => Some(remote_transform_out(&recipe, &derive_key)?),
1465    };
1466
1467    let mut params: BTreeMap<String, String> = BTreeMap::new();
1468    params.insert(
1469        ENV_TRANSFORM_IN_0.to_string(),
1470        input_abs.to_string_lossy().into_owned(),
1471    );
1472    params.insert(
1473        ENV_TRANSFORM_OUT.to_string(),
1474        remote_out
1475            .as_ref()
1476            .unwrap_or(&tmp_output)
1477            .to_string_lossy()
1478            .into_owned(),
1479    );
1480    for (k, v) in &transform.params {
1481        params.insert(k.clone(), v.clone());
1482    }
1483
1484    info!(recipe = %recipe.name, "transform cache MISS, running");
1485    for step in &recipe.steps {
1486        let argv = substitute_argv(&step.argv, &params);
1487        // substitute_argv preserves `{{key}}` for unknown keys (no shell, no
1488        // string concat). Surface a missing-binding bug as a hard error rather
1489        // than letting the subprocess receive a literal placeholder.
1490        if let Some(unresolved) = argv.iter().find(|a| a.contains("{{")) {
1491            anyhow::bail!(
1492                "recipe {:?} step {:?}: unresolved placeholder in argv element {:?}",
1493                recipe.name,
1494                step.name,
1495                unresolved,
1496            );
1497        }
1498        // R555-T2 opened the recipe surface to `remote` / `remote_any`, so the
1499        // lowered spec carries whatever the recipe declared and reaches the
1500        // matching driver through the injected router (R555-F3). The two
1501        // placements need different execution context, and the difference is
1502        // invisible at the type level — hence the split below rather than one
1503        // shared `ctx`.
1504        let spec = lower_recipe_step_to_forge_spec(&recipe, step, argv);
1505        let mut ctx = ExecContext::default();
1506        if let Some(remote_out) = &remote_out {
1507            // NO cwd: `workspace_abs` is a path on THIS box. The local driver
1508            // bind-mounts it; a remote workdir is just a string interpreted on
1509            // the worker, where that path doesn't exist. A recipe needing a
1510            // source tree on the worker must bring it (the rusty-v8 builder
1511            // image clones its own).
1512            ctx = ctx.with_produced(remote_out.clone(), tmp_output.clone());
1513        } else {
1514            ctx = ctx.with_cwd(workspace_abs.clone());
1515            // `platform` asks a HOST container runtime for foreign-arch
1516            // emulation. It has no remote referent — a remote run picks
1517            // architecture by scheduling (mesh_tags) — and RemoteForgeDriver
1518            // refuses it rather than handing back a wrong-arch artifact, so it
1519            // is only ever applied on the local leg.
1520            if let Some(platform) = &recipe.placement.platform {
1521                ctx = ctx.with_platform(platform.clone());
1522            }
1523        }
1524        let outcome = executor
1525            .execute(spec, ctx, None)
1526            .await
1527            .with_context(|| format!("executing recipe {:?} step {:?}", recipe.name, step.name))?;
1528        if !outcome.succeeded() {
1529            anyhow::bail!(
1530                "recipe {:?} step {:?} failed ({}): {}",
1531                recipe.name,
1532                step.name,
1533                outcome.status.discriminant(),
1534                outcome.stderr_tail
1535            );
1536        }
1537    }
1538
1539    // R546-B8: every step exited 0, so if the output is missing the recipe
1540    // simply never wrote it. Say THAT, rather than letting the `read` below
1541    // surface `No such file or directory` on a cache path — which reads like
1542    // cache corruption and sent the last person debugging the wrong subsystem
1543    // after a ~2h build. The relative-OUT bug that caused it is fixed above,
1544    // but a recipe can still write to the wrong place on its own, and this is
1545    // the only moment we can tell the operator exactly which contract broke.
1546    if !tokio::fs::try_exists(&tmp_output).await.unwrap_or(false) {
1547        anyhow::bail!(
1548            "recipe {:?} completed successfully but produced no output at {} \
1549             ({ENV_TRANSFORM_OUT}). The recipe's last step must write its artifact \
1550             to that exact path — check that it doesn't chdir and then write to a \
1551             relative location, and that it isn't writing to a directory instead \
1552             of a file.",
1553            recipe.name,
1554            // A remote run was told a worker-side path; naming `tmp_output` (a
1555            // local path it never saw) would send the reader hunting for a
1556            // binding bug that isn't there.
1557            remote_out.as_ref().unwrap_or(&tmp_output).display(),
1558        );
1559    }
1560
1561    // Compute the actual hash once — used for verify (strict) and cache
1562    // naming (both modes). Bootstrap mode names by the discovered value;
1563    // strict mode names by the expected value (which equals actual after
1564    // the verify below).
1565    let output_bytes = tokio::fs::read(&tmp_output).await.with_context(|| {
1566        format!(
1567            "reading transform output {} for BLAKE3",
1568            tmp_output.display()
1569        )
1570    })?;
1571    let actual = blake3_hex(&output_bytes);
1572    drop(output_bytes);
1573
1574    if !bootstrap && !hashes_equal(&actual, output_blake3) {
1575        anyhow::bail!(
1576            "transform output from recipe {:?} doesn't match pinned BLAKE3 \
1577             (actual {actual} != expected {output_blake3})",
1578            recipe.name
1579        );
1580    }
1581
1582    // tmp_output already lives in cache_dir — atomic publish is one rename.
1583    let cache_path = cache_dir.join(format!("{actual}.bin"));
1584    tokio::fs::rename(&tmp_output, &cache_path)
1585        .await
1586        .with_context(|| {
1587            format!(
1588                "renaming {} → {}",
1589                tmp_output.display(),
1590                cache_path.display()
1591            )
1592        })?;
1593
1594    // W212: record the action-cache entry (derive_key → output hash) so the
1595    // next run with identical inputs skips this transform entirely.
1596    write_action_cache(&ac_path, &actual)
1597        .await
1598        .with_context(|| format!("recording action-cache entry {}", ac_path.display()))?;
1599
1600    if bootstrap {
1601        info!(
1602            recipe = %recipe.name,
1603            discovered = %actual,
1604            "transform complete (bootstrap)",
1605        );
1606    }
1607
1608    Ok(cache_path)
1609}
1610
1611/// State of the committed `[asset.derive.fetch].blake3` pin, relative to the
1612/// fetched-input hash a seed run actually keyed its derivation on (R546-B6).
1613///
1614/// The W212 substituter fast-path ([`lock_skip_hash`]) recomputes the derivation
1615/// key from the COMMITTED pins with no network access, so it bails immediately
1616/// when the fetch pin is still the zero sentinel. Seeding a lock without also
1617/// pinning the fetch input therefore leaves that fast-path disarmed for every
1618/// machine except the one that seeded (whose local action cache masks it).
1619#[derive(Debug, Clone, PartialEq, Eq)]
1620pub enum FetchPinState {
1621    /// Committed pin equals the fetched-input hash — the fast-path can engage.
1622    Pinned,
1623    /// Committed pin is the 64-zero sentinel — the fast-path is disarmed until
1624    /// the operator pastes the fetched-input hash back into the workload.
1625    Sentinel,
1626    /// Committed pin names a DIFFERENT blob than the one this seed fetched.
1627    /// Either the version anchor moved or the pin is wrong; the fast-path will
1628    /// silently decline (input drift) until they agree.
1629    Mismatch { committed: String },
1630}
1631
1632/// Classify the committed `[asset.derive.fetch].blake3` against the hash the
1633/// fetch actually resolved to (R546-B6). Pure so the three arms are testable
1634/// without a network fetch.
1635fn classify_fetch_pin(committed: &str, fetched: &str) -> FetchPinState {
1636    if is_bootstrap_sentinel(committed) {
1637        FetchPinState::Sentinel
1638    } else if hashes_equal(committed, fetched) {
1639        FetchPinState::Pinned
1640    } else {
1641        FetchPinState::Mismatch {
1642            committed: committed.to_string(),
1643        }
1644    }
1645}
1646
1647/// Outcome of seeding the transform derivation cache from a pre-built artifact
1648/// (the qed→W164 bridge, R546-T3).
1649#[derive(Debug, Clone)]
1650pub struct SeededDerivation {
1651    /// The W212 derivation key the seeded action-cache entry is filed under —
1652    /// byte-identical to the one [`materialize_transform`] will look up, so the
1653    /// next `yah cloud apply` HITs it. Equals the value to paste into
1654    /// `[asset.derive.lock].input_hash`.
1655    pub derive_key: String,
1656    /// BLAKE3 of the pre-built artifact — the transform output hash. Equals the
1657    /// value to paste into `[[asset]].blake3` and `[asset.derive.lock].output_blake3`.
1658    pub output_blake3: String,
1659    /// BLAKE3 of the fetched *input* the derivation key was keyed on — the
1660    /// fourth paste-back value, `[asset.derive.fetch].blake3` (R546-B6). Without
1661    /// it the seeded lock is unusable off the seeding machine.
1662    pub fetch_blake3: String,
1663    /// What the workload currently commits for that pin, when the caller went
1664    /// through [`seed_derivation_for_target`] (which has the `[[asset]]` row in
1665    /// hand). `None` for direct [`seed_transform_derivation`] callers.
1666    pub fetch_pin: Option<FetchPinState>,
1667    /// The content-addressed store path the artifact was landed at.
1668    pub cas_path: PathBuf,
1669}
1670
1671/// Seed the transform derivation cache (AC + CAS) from an artifact built OUT OF
1672/// BAND — the qed→W164 bridge (R546-T3).
1673///
1674/// # Why this exists
1675///
1676/// The static-asset reconciler lowers every transform to
1677/// [`TaskLocation::Local`](task::TaskLocation::Local) (see
1678/// [`lower_recipe_step_to_forge_spec`]) — it has no fleet-offload path, so on a
1679/// foreign-arch host it can only build the recipe under emulation (which OOMs for
1680/// the rusty_v8 musl build, the reason R546 exists). `yah qed run <pipeline>`
1681/// (e.g. rusty-v8-musl) DOES offload to an arch-matched build-worker and, via R590-F6,
1682/// retrieves the produced tar content-addressed onto the caller. This function
1683/// bridges that pre-built tar into the W164 substituter: it writes the exact
1684/// action-cache + CAS entries [`materialize_transform`] would have written, so
1685/// the next `yah cloud apply` finds a HIT and PUBLISHES the pre-built bytes to
1686/// the consumer key + records the lock, instead of re-running the transform.
1687///
1688/// # Hermeticity
1689///
1690/// The derivation key is computed with the reconciler's OWN [`derivation_key`] +
1691/// [`recipe_blake3`], keyed on the SAME `fetched_hash` the reconciler derives
1692/// from the version anchor. Reusing those (not reimplementing) is what makes the
1693/// seeded key byte-identical to the lookup key — a drift would reintroduce the
1694/// W164/R438 silent-stale-cache bug. `fetched_hash` MUST be the value
1695/// [`materialize_fetch`] returns for `derive.fetch` (see [`seed_derivation_for_target`]).
1696pub async fn seed_transform_derivation(
1697    workspace_root: &Path,
1698    transform: &TransformSpec,
1699    fetched_hash: &str,
1700    artifact_path: &Path,
1701) -> Result<SeededDerivation> {
1702    let recipe_bk = recipe_blake3(workspace_root, &transform.recipe)
1703        .await
1704        .ok_or_else(|| {
1705            anyhow::anyhow!(
1706                "recipe {:?} unreadable under .yah/qed/transforms — cannot compute derivation key",
1707                transform.recipe,
1708            )
1709        })?;
1710    let derive_key = derivation_key(fetched_hash, &recipe_bk, &transform.params);
1711
1712    let bytes = tokio::fs::read(artifact_path)
1713        .await
1714        .with_context(|| format!("reading pre-built artifact {}", artifact_path.display()))?;
1715    let output_blake3 = blake3_hex(&bytes);
1716
1717    let cache_dir = workspace_root.join(".yah/cache/derive/transform");
1718    tokio::fs::create_dir_all(&cache_dir)
1719        .await
1720        .with_context(|| format!("creating transform cache dir {}", cache_dir.display()))?;
1721
1722    // CAS: <cache_dir>/<output_blake3>.bin — the exact name materialize_transform
1723    // renames its output to (so its HIT-path verify_blake3_path passes). Atomic
1724    // tmp+rename; idempotent when the entry already exists.
1725    let cas_path = cache_dir.join(format!("{output_blake3}.bin"));
1726    if !tokio::fs::try_exists(&cas_path).await.unwrap_or(false) {
1727        let tmp = cache_dir.join(format!("{output_blake3}.seed.tmp"));
1728        tokio::fs::write(&tmp, &bytes)
1729            .await
1730            .with_context(|| format!("writing CAS tmp {}", tmp.display()))?;
1731        tokio::fs::rename(&tmp, &cas_path)
1732            .await
1733            .with_context(|| format!("promoting CAS entry {}", cas_path.display()))?;
1734    }
1735
1736    // Action cache: <cache_dir>/ac/<derive_key>.out = <output_blake3>. Reuses the
1737    // reconciler's writer so the on-disk format can't drift from the reader.
1738    let ac_path = cache_dir.join("ac").join(format!("{derive_key}.out"));
1739    write_action_cache(&ac_path, &output_blake3)
1740        .await
1741        .with_context(|| format!("recording seeded action-cache entry {}", ac_path.display()))?;
1742
1743    info!(
1744        recipe = %transform.recipe,
1745        derive_key,
1746        output_blake3,
1747        fetch_blake3 = fetched_hash,
1748        "seeded transform derivation cache from pre-built artifact (qed→W164 bridge)",
1749    );
1750
1751    Ok(SeededDerivation {
1752        derive_key,
1753        output_blake3,
1754        fetch_blake3: fetched_hash.to_string(),
1755        fetch_pin: None,
1756        cas_path,
1757    })
1758}
1759
1760/// CLI-facing entry for the qed→W164 bridge (R546-T3): seed the derivation cache
1761/// for the `[[asset]]` row whose `derive.transform.params["target"]` equals
1762/// `target`, from a pre-built `artifact_path`.
1763///
1764/// Loads the static-asset workload, resolves the matching asset row, computes the
1765/// fetched-input hash via the reconciler's own [`materialize_fetch`] (downloading
1766/// the version anchor once; cached thereafter), then delegates to
1767/// [`seed_transform_derivation`]. After this returns, `yah cloud apply` on the
1768/// same service publishes the pre-built bytes without re-running the transform.
1769pub async fn seed_derivation_for_target(
1770    workspace_root: &Path,
1771    workload_path: &Path,
1772    target: &str,
1773    artifact_path: &Path,
1774) -> Result<SeededDerivation> {
1775    let raw = tokio::fs::read_to_string(workload_path)
1776        .await
1777        .with_context(|| format!("reading workload {}", workload_path.display()))?;
1778    let workload: StaticAssetWorkload = toml::from_str(&raw)
1779        .with_context(|| format!("parsing static-asset workload {}", workload_path.display()))?;
1780
1781    let entry = workload
1782        .assets
1783        .iter()
1784        .find(|a| {
1785            a.derive
1786                .as_ref()
1787                .and_then(|d| d.transform.as_ref())
1788                .map(|t| t.params.get("target").map(String::as_str) == Some(target))
1789                .unwrap_or(false)
1790        })
1791        .ok_or_else(|| {
1792            anyhow::anyhow!(
1793                "no [[asset]] with derive.transform.params.target = {target:?} in {}",
1794                workload_path.display(),
1795            )
1796        })?;
1797
1798    let derive = entry
1799        .derive
1800        .as_ref()
1801        .expect("asset matched by derive.transform above");
1802    let transform = derive
1803        .transform
1804        .as_ref()
1805        .expect("asset matched by derive.transform above");
1806
1807    // Recompute the fetched-input hash exactly as the reconciler will — same
1808    // fetch source, same cache dir.
1809    let cache_root = workspace_root.join(".yah/cache/derive");
1810    let (_fetched_path, fetched_hash) =
1811        materialize_fetch(&derive.fetch, &cache_root.join("fetch")).await?;
1812
1813    // R546-B6: classify the COMMITTED fetch pin against what we just fetched, so
1814    // the caller can tell the operator whether the W212 fast-path is armed. A
1815    // seeded lock with a sentinel fetch pin skips builds only on this machine
1816    // (via the local action cache) — everywhere else `lock_skip_hash` declines.
1817    let fetch_pin = classify_fetch_pin(&derive.fetch.blake3.0, &fetched_hash);
1818
1819    let mut seeded =
1820        seed_transform_derivation(workspace_root, transform, &fetched_hash, artifact_path).await?;
1821    seeded.fetch_pin = Some(fetch_pin);
1822    Ok(seeded)
1823}
1824
1825/// Lower a single recipe step to a [`ForgeSpec`] (W164).
1826///
1827/// - `image` is always `Some(recipe.image)` — recipes always run inside the
1828///   pinned container.
1829/// - `where_` mirrors `recipe.placement` straight through — both the
1830///   recipe-declared location and runtime. Until W235 this hard-coded
1831///   `TaskLocation::Local` because `RecipeLocation` had no other variant;
1832///   R555-T2 opened the recipe surface to `remote` / `remote_any`, so pinning
1833///   here would have quietly demoted every remote recipe back to the dev box.
1834/// - `timeout=0` in the recipe means "no timeout" (omitted from the spec).
1835/// - `label = "transform:<recipe>:<step>"`; initiator carries the reconciler
1836///   identity in the Gnome variant so audit traces attribute the run.
1837///
1838/// Pure function: callers feed it the already-substituted argv (or the raw
1839/// one in tests) and decide what to do with the resulting spec. Exposed at
1840/// `pub(crate)` for golden-test parity with the BuildMode lowering helper
1841/// (R438-T7).
1842pub(crate) fn lower_recipe_step_to_forge_spec(
1843    recipe: &TransformRecipe,
1844    step: &RecipeStep,
1845    substituted_argv: Vec<String>,
1846) -> ForgeSpec {
1847    ForgeSpec {
1848        command: ForgeCommand::Subprocess {
1849            argv: substituted_argv,
1850            image: Some(recipe.image.clone()),
1851        },
1852        where_: TaskPlacement::new(recipe.placement.location.clone(), recipe.placement.runtime),
1853        timeout: if step.timeout == 0 {
1854            None
1855        } else {
1856            Some(Millis::from_secs(step.timeout))
1857        },
1858        label: Some(format!("transform:{}:{}", recipe.name, step.name)),
1859        initiator: Initiator::Gnome {
1860            camp: "static-asset-reconciler".into(),
1861            shift: format!("derive-{}", recipe.name),
1862        },
1863        mesh_access: MeshAccess::default(),
1864    }
1865}
1866
1867/// Validate a remotely-placed transform recipe and return the worker-side path
1868/// its output must be written to (R555-F3).
1869///
1870/// Three things a local recipe may do that a remote one may not, each refused
1871/// here rather than at the point it would produce a wrong artifact:
1872///
1873/// 1. **Reference `{{YAH_TRANSFORM_IN_0}}`.** The fetched input lives on this
1874///    box. [`velveteen_exec::WardenClient`] has a retrieval leg
1875///    (`fetch_produced_file`) and no upload leg, so there is no transport that
1876///    puts those bytes on the worker. Substituting the host path would hand
1877///    the recipe a path that doesn't resolve there — and the recipes that
1878///    ignore IN_0 entirely (rusty-v8-musl, whisper-bundle-tar drive their own
1879///    source) are exactly the ones worth dispatching, so this is a real
1880///    boundary rather than a stopgap.
1881/// 2. **Declare more than one step.** Each step is a separate one-shot
1882///    workload with its own container and its own produced dir — locally the
1883///    steps share a filesystem, remotely they share nothing. A two-step remote
1884///    recipe would silently lose whatever step 1 wrote.
1885/// 3. **Declare `[placement] platform`.** That asks a *host* container runtime
1886///    for foreign-arch emulation; a yubaba node has no such knob and picks
1887///    architecture by scheduling instead. `RemoteForgeDriver` refuses it too —
1888///    this refusal exists so the message names the recipe (R555-T7 is the
1889///    ticket that collapses the per-arch recipe fork onto `mesh_tags`).
1890fn remote_transform_out(recipe: &TransformRecipe, derive_key: &str) -> Result<PathBuf> {
1891    if recipe.steps.len() > 1 {
1892        anyhow::bail!(
1893            "recipe {:?} declares {} steps and a remote placement. Each step dispatches as \
1894             its own one-shot workload with its own container and produced dir, so steps \
1895             cannot hand files to each other the way they do locally — collapse them into \
1896             one step (the builder image is the right place for the sequencing), or run \
1897             the recipe locally",
1898            recipe.name,
1899            recipe.steps.len(),
1900        );
1901    }
1902    if let Some(platform) = &recipe.placement.platform {
1903        anyhow::bail!(
1904            "recipe {:?} declares [placement] platform = {:?} together with a remote \
1905             location. `platform` asks a host container runtime for foreign-arch \
1906             emulation; a remote node has no such knob and selects architecture by \
1907             scheduling instead. Drop it and express the arch as mesh tags, e.g. \
1908             location = {{ kind = \"remote_any\", tier = \"infra\", mesh_tags = \
1909             [\"tag:build-worker\", \"tier:x86\", \"os:linux\"] }}",
1910            recipe.name,
1911            platform,
1912        );
1913    }
1914    let in_0 = format!("{{{{{ENV_TRANSFORM_IN_0}}}}}");
1915    for step in &recipe.steps {
1916        if step.argv.iter().any(|a| a.contains(&in_0)) {
1917            anyhow::bail!(
1918                "recipe {:?} step {:?} references {} but is placed remotely. The fetched \
1919                 input lives on this machine and there is no upload leg to the worker \
1920                 (WardenClient can retrieve produced files, not send inputs), so the \
1921                 binding would name a path that does not exist on the node. Either have \
1922                 the recipe fetch its own input, or run it locally",
1923                recipe.name,
1924                step.name,
1925                in_0,
1926            );
1927        }
1928    }
1929    Ok(PathBuf::from(workload_spec::forge_produced::CONTAINER_DIR).join(format!("{derive_key}.out")))
1930}
1931
1932/// Read `path` and assert its BLAKE3 hex matches `expected_hex` (case-insensitive).
1933async fn verify_blake3_path(path: &Path, expected_hex: &str) -> Result<()> {
1934    let bytes = tokio::fs::read(path)
1935        .await
1936        .with_context(|| format!("reading {} for BLAKE3 verify", path.display()))?;
1937    let actual = blake3_hex(&bytes);
1938    if !hashes_equal(&actual, expected_hex) {
1939        anyhow::bail!(
1940            "BLAKE3 mismatch for {}: actual {} != expected {}",
1941            path.display(),
1942            actual,
1943            expected_hex,
1944        );
1945    }
1946    Ok(())
1947}
1948
1949// ── Manifest helpers ──────────────────────────────────────────────────────────
1950
1951async fn load_catalog_manifest(
1952    client: &reqwest::Client,
1953    endpoint: &str,
1954    bucket: &str,
1955    region: &str,
1956    access_key: &str,
1957    secret_key: &str,
1958) -> CatalogManifest {
1959    let url = format!("{endpoint}/{bucket}/{CATALOG_MANIFEST_KEY}");
1960    let Ok(headers) = sign_s3_empty_body("GET", &url, region, access_key, secret_key) else {
1961        return HashMap::new();
1962    };
1963    let Ok(resp) = client.get(&url).headers(headers).send().await else {
1964        return HashMap::new();
1965    };
1966    if !resp.status().is_success() {
1967        return HashMap::new();
1968    }
1969    let Ok(bytes) = resp.bytes().await else {
1970        return HashMap::new();
1971    };
1972    serde_json::from_slice(&bytes).unwrap_or_default()
1973}
1974
1975async fn save_catalog_manifest(
1976    client: &reqwest::Client,
1977    endpoint: &str,
1978    bucket: &str,
1979    manifest: &CatalogManifest,
1980    region: &str,
1981    access_key: &str,
1982    secret_key: &str,
1983) -> Result<()> {
1984    let body = serde_json::to_vec(manifest).context("serializing catalog manifest")?;
1985    let body_sha256 = sha256_hex(&body);
1986    let url = format!("{endpoint}/{bucket}/{CATALOG_MANIFEST_KEY}");
1987    let headers = sign_s3_put_object(
1988        &url,
1989        &body_sha256,
1990        "application/json",
1991        body.len(),
1992        region,
1993        access_key,
1994        secret_key,
1995        // The manifest sidecar is rewritten every run by definition — stamping it
1996        // would be noise, and nothing skips a PUT on it (R546-B10).
1997        None,
1998    )
1999    .context("signing manifest PUT")?;
2000    let resp = client
2001        .put(&url)
2002        .headers(headers)
2003        .body(body)
2004        .send()
2005        .await
2006        .context("PUT catalog manifest")?;
2007    if !resp.status().is_success() {
2008        let status = resp.status();
2009        let body_text = resp.text().await.unwrap_or_default();
2010        anyhow::bail!("PUT {url} → {status}: {}", body_text.trim());
2011    }
2012    Ok(())
2013}
2014
2015// ── S3 helpers ────────────────────────────────────────────────────────────────
2016
2017/// What a HEAD probe found at an object key (R546-B10).
2018///
2019/// The distinction that matters is *not* present-vs-absent but whether the
2020/// present bytes are the ones we are about to publish. `Present { blake3: None }`
2021/// is an object written before we started stamping `x-amz-meta-blake3` — its
2022/// content is unknowable from a HEAD, so callers must treat it as "might be
2023/// stale" rather than "matches".
2024#[derive(Debug, Clone, PartialEq, Eq)]
2025enum RemoteObject {
2026    Absent,
2027    Present { blake3: Option<String> },
2028}
2029
2030impl RemoteObject {
2031    /// True only when the remote object is known to hold exactly `expected`.
2032    /// Unknown provenance (no metadata) is deliberately NOT a match.
2033    fn matches(&self, expected: &str) -> bool {
2034        matches!(self, RemoteObject::Present { blake3: Some(b3) } if hashes_equal(b3, expected))
2035    }
2036
2037    fn exists(&self) -> bool {
2038        matches!(self, RemoteObject::Present { .. })
2039    }
2040}
2041
2042/// HEAD an object key and report existence *plus* the recorded BLAKE3.
2043///
2044/// R546-B10: the previous `object_exists` returned a bare bool, so the publish
2045/// loop could not tell "same bytes already there" from "different bytes already
2046/// there" and skipped the PUT for both — leaving stale content in the bucket
2047/// while the catalog manifest advertised the new hash. We read our own
2048/// `x-amz-meta-blake3` stamp rather than the ETag, because ETag is only an MD5
2049/// of the content for single-part uploads; for multipart it is a digest-of-
2050/// digests and comparing it to a content hash is simply wrong.
2051async fn head_object(
2052    client: &reqwest::Client,
2053    url: &str,
2054    region: &str,
2055    access_key: &str,
2056    secret_key: &str,
2057) -> Result<RemoteObject> {
2058    let headers = sign_s3_empty_body("HEAD", url, region, access_key, secret_key)
2059        .with_context(|| format!("signing HEAD {url}"))?;
2060    let resp = client
2061        .head(url)
2062        .headers(headers)
2063        .send()
2064        .await
2065        .with_context(|| format!("HEAD {url}"))?;
2066    if !resp.status().is_success() {
2067        return Ok(RemoteObject::Absent);
2068    }
2069    let blake3 = resp
2070        .headers()
2071        .get("x-amz-meta-blake3")
2072        .and_then(|v| v.to_str().ok())
2073        .map(str::to_string);
2074    Ok(RemoteObject::Present { blake3 })
2075}
2076
2077// ── Crypto helpers ────────────────────────────────────────────────────────────
2078
2079fn blake3_hex(body: &[u8]) -> String {
2080    hex::encode(blake3::hash(body).as_bytes())
2081}
2082
2083fn sha256_hex(body: &[u8]) -> String {
2084    hex::encode(Sha256::digest(body))
2085}
2086
2087/// Case-insensitive hex comparison (BLAKE3 crate outputs lowercase; stored
2088/// hashes may have been authored in uppercase).
2089fn hashes_equal(a: &str, b: &str) -> bool {
2090    a.eq_ignore_ascii_case(b)
2091}
2092
2093// ── Content-type ──────────────────────────────────────────────────────────────
2094
2095fn content_type_for(path: &Path) -> &'static str {
2096    match path.extension().and_then(|e| e.to_str()).unwrap_or("") {
2097        "bin" => "application/octet-stream",
2098        "json" => "application/json",
2099        "txt" => "text/plain; charset=utf-8",
2100        "wasm" => "application/wasm",
2101        _ => "application/octet-stream",
2102    }
2103}
2104
2105// ── Bucket auto-create ────────────────────────────────────────────────────────
2106
2107/// List R2 buckets under `account_id`; create `bucket_name` only when absent.
2108///
2109/// Takes the management `api_token` already resolved from the service's
2110/// provider (see [`super::cf_creds::CfProvider`]). Mirrors the idempotent pattern in
2111/// `cloudflare_worker.rs::ensure_r2_bucket` — list-first keeps the reconcile
2112/// loop safe to re-run without depending on CF's 4xx error shape on duplicate
2113/// create. R422-T12: lets the first `yah cloud apply --env cloud --service <s>`
2114/// against a static-asset mirror provision its bucket without an out-of-band
2115/// dashboard step.
2116async fn ensure_r2_bucket(api_token: &str, account_id: &str, bucket_name: &str) -> Result<()> {
2117    let cf = CloudflareClient::new(api_token.to_string());
2118    let existing = cf
2119        .list_r2_buckets(account_id)
2120        .await
2121        .context("listing R2 buckets")?;
2122    if existing.iter().any(|b| b.name == bucket_name) {
2123        debug!(bucket_name, "R2 bucket already exists — skipping create");
2124        return Ok(());
2125    }
2126    cf.create_r2_bucket(account_id, bucket_name)
2127        .await
2128        .with_context(|| format!("creating R2 bucket {bucket_name}"))?;
2129    info!(bucket_name, "R2 bucket created");
2130    Ok(())
2131}
2132
2133// ── Workload loading ──────────────────────────────────────────────────────────
2134
2135fn load_workload(workload_dir: &Path) -> Result<StaticAssetWorkload> {
2136    let path = workload_dir.join("workload.toml");
2137    let src =
2138        std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
2139    // R546-B7 (RESOLVED — this comment used to say the envelope was unusable).
2140    // `workload_spec::Workload` is no longer externally tagged for text
2141    // formats: its hand-written Deserialize branches on `is_human_readable`, so
2142    // TOML/JSON get the flat `kind`-tagged shape real files use while postcard
2143    // keeps the variant-index encoding the kamaji UDS needs.
2144    //
2145    // This path still probes `kind` and deserializes `StaticAssetWorkload`
2146    // directly rather than matching on the envelope, because it needs to reject
2147    // a non-static-asset workload with a precise message naming the kind it
2148    // found — an envelope match would only say "expected variant". The bypass
2149    // is now a choice, not a workaround.
2150    #[derive(serde::Deserialize)]
2151    struct KindProbe {
2152        kind: String,
2153    }
2154    let probe: KindProbe =
2155        toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))?;
2156    if probe.kind != "static-asset" {
2157        anyhow::bail!(
2158            "{}: expected kind=\"static-asset\" but found kind={:?}",
2159            path.display(),
2160            probe.kind
2161        );
2162    }
2163    let workload: StaticAssetWorkload =
2164        toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))?;
2165
2166    // R546-B7: enforce the closed-catalog invariant the type's docs promise
2167    // ("every value in [aliases] must be a filename that exists in [[asset]],
2168    // enforced by validate::shape_static_asset"). It was NOT actually being run
2169    // here — `up_bails_when_catalog_alias_orphaned` only passed because the old
2170    // fixture failed to PARSE, so the expected error came from the wrong place.
2171    // With parsing fixed, an orphaned alias would have silently reconciled.
2172    workload_spec::validate::shape_static_asset(&workload)
2173        .map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))?;
2174
2175    Ok(workload)
2176}
2177
2178// ── Tests ─────────────────────────────────────────────────────────────────────
2179
2180#[cfg(test)]
2181mod tests {
2182    use super::*;
2183    use crate::asset_journal::AssetStatusJournal;
2184    use crate::{MirrorConfig, MirrorShape, ServiceComponent, ServiceConfig};
2185    use std::collections::BTreeMap;
2186    use std::path::PathBuf;
2187    use tempfile::tempdir;
2188    use workload_spec::{AssetEntry, BlakeHash};
2189
2190    const HASH_64: &str = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
2191
2192    /// Minimal test fixture that mirrors the mesofact_static test pattern.
2193    struct Fixture {
2194        _workspace: tempfile::TempDir,
2195        workspace_root: PathBuf,
2196        service: ServiceConfig,
2197        component: ServiceComponent,
2198        mirror: MirrorConfig,
2199        env: String,
2200    }
2201
2202    impl Fixture {
2203        fn new(slot: MirrorProviderSlot) -> Self {
2204            let workspace = tempdir().unwrap();
2205            let workspace_root = workspace.path().to_path_buf();
2206            let workload_dir = workspace_root.join("app/assets/whisper");
2207            std::fs::create_dir_all(&workload_dir).unwrap();
2208
2209            let mut providers = BTreeMap::new();
2210            providers.insert("object_store".to_string(), slot);
2211            let mirror = MirrorConfig {
2212                schema_version: 1,
2213                shape: MirrorShape::Local,
2214                providers,
2215                ingress: Default::default(),
2216                drivers: Default::default(),
2217                asset_aliases: BTreeMap::new(),
2218            };
2219            let service = ServiceConfig {
2220                schema_version: 1,
2221                name: "yah-desktop".to_string(),
2222                domain: "releases.yah.dev".to_string(),
2223                components: vec![],
2224                db: crate::DbCatalog::default(),
2225            };
2226            let component = ServiceComponent {
2227                id: "whisper-models".to_string(),
2228                kind: "static-asset".to_string(),
2229                path: "app/assets/whisper".to_string(),
2230                role: "assets".to_string(),
2231                publishes: None,
2232                wave: 0,
2233                git: None,
2234            };
2235            Self {
2236                _workspace: workspace,
2237                workspace_root,
2238                service,
2239                component,
2240                mirror,
2241                env: "pond".to_string(),
2242            }
2243        }
2244
2245        fn ctx(&self) -> ReconcileCtx<'_> {
2246            ReconcileCtx {
2247                workspace_root: &self.workspace_root,
2248                service: &self.service,
2249                component: &self.component,
2250                mirror: &self.mirror,
2251                env: &self.env,
2252                scope: crate::reconciler::ProviderScope::singleton(),
2253            }
2254        }
2255
2256        fn workload_dir(&self) -> PathBuf {
2257            self.workspace_root.join("app/assets/whisper")
2258        }
2259
2260        fn write_workload(&self, extra: &str) {
2261            // R546-B7: emit the FLAT shape every real on-disk workload.toml uses
2262            // (`kind = "static-asset"` beside `schema_version`), not the
2263            // externally-tagged `[static-asset]` wrapper. The old fixture encoded
2264            // the `workload_spec::Workload` enum's (untagged) representation,
2265            // which NO real file has ever used — so these tests were asserting a
2266            // shape that could not occur in production and masked the fact that
2267            // `yah cloud apply` could not parse any actual catalog.
2268            let toml = format!(
2269                r#"schema_version = "V1"
2270kind = "static-asset"
2271{extra}
2272"#
2273            );
2274            std::fs::write(self.workload_dir().join("workload.toml"), toml).unwrap();
2275        }
2276
2277        fn write_source_file(&self, name: &str, content: &[u8]) -> PathBuf {
2278            let path = self.workload_dir().join(name);
2279            if let Some(parent) = path.parent() {
2280                std::fs::create_dir_all(parent).unwrap();
2281            }
2282            std::fs::write(&path, content).unwrap();
2283            path
2284        }
2285    }
2286
2287    fn minio_slot() -> MirrorProviderSlot {
2288        let mut fields = BTreeMap::new();
2289        fields.insert("api_port".to_string(), toml::Value::Integer(9000));
2290        fields.insert(
2291            "bucket".to_string(),
2292            toml::Value::String("yah-dev".to_string()),
2293        );
2294        MirrorProviderSlot::Inline {
2295            kind: Provider::MinioContainer,
2296            fields,
2297        }
2298    }
2299
2300    /// Default executor for tests that don't exercise transforms — legacy
2301    /// `source = "..."` assets never touch the executor, so any impl works.
2302    fn test_executor() -> Arc<dyn ForgeExecutor> {
2303        Arc::new(LocalForgeDriver::default())
2304    }
2305
2306    // ── Mock executor for W164 materialize-transform tests ────────────────────
2307
2308    use tokio::sync::mpsc::UnboundedSender;
2309    use tokio::sync::Mutex;
2310    use velveteen::ForgeStatus;
2311    use velveteen_exec::executor::{ExecEvent, ExecOutcome, ForgeExecutorError};
2312
2313    /// Executor that writes a caller-supplied byte string to whatever path the
2314    /// recipe sets `YAH_TRANSFORM_OUT` to (via the substituted argv). Returns
2315    /// success unless `fail_with` is set. Tracks invocation count for HIT/MISS
2316    /// assertions.
2317    struct MockExecutor {
2318        out_bytes: Vec<u8>,
2319        invocations: Arc<Mutex<u32>>,
2320        fail_with: Option<String>,
2321    }
2322
2323    impl MockExecutor {
2324        fn new(out_bytes: Vec<u8>) -> (Arc<Self>, Arc<Mutex<u32>>) {
2325            let counter = Arc::new(Mutex::new(0));
2326            let me = Arc::new(Self {
2327                out_bytes,
2328                invocations: counter.clone(),
2329                fail_with: None,
2330            });
2331            (me, counter)
2332        }
2333
2334        fn failing(reason: String) -> Arc<Self> {
2335            Arc::new(Self {
2336                out_bytes: Vec::new(),
2337                invocations: Arc::new(Mutex::new(0)),
2338                fail_with: Some(reason),
2339            })
2340        }
2341    }
2342
2343    #[async_trait]
2344    impl ForgeExecutor for MockExecutor {
2345        async fn execute(
2346            &self,
2347            spec: ForgeSpec,
2348            _ctx: ExecContext,
2349            _sink: Option<UnboundedSender<ExecEvent>>,
2350        ) -> Result<ExecOutcome, ForgeExecutorError> {
2351            *self.invocations.lock().await += 1;
2352            if let Some(reason) = &self.fail_with {
2353                return Ok(ExecOutcome {
2354                    status: ForgeStatus::Done {
2355                        exit_code: 1,
2356                        ended_at: 0,
2357                    },
2358                    stderr_tail: reason.clone(),
2359                });
2360            }
2361            // Find the YAH_TRANSFORM_OUT path in the substituted argv. The
2362            // recipe convention is that {{YAH_TRANSFORM_OUT}} resolves to the
2363            // tmp path we want to write to; it appears as a positional arg.
2364            let ForgeCommand::Subprocess { argv, .. } = &spec.command else {
2365                return Err(ForgeExecutorError::Unsupported(
2366                    "mock only handles Subprocess",
2367                ));
2368            };
2369            // The test recipes follow the W164 convention: argv contains the
2370            // tmp output path verbatim. materialize_transform writes to
2371            // `<cache_dir>/<output_blake3>.tmp` (then renames to .bin on hash
2372            // match), so the output element is the one ending in `.tmp`.
2373            let out_path = argv.iter().find(|a| a.ends_with(".tmp")).ok_or(
2374                ForgeExecutorError::Unsupported("mock recipe must pass a .tmp output path"),
2375            )?;
2376            std::fs::write(out_path, &self.out_bytes).map_err(ForgeExecutorError::Io)?;
2377            Ok(ExecOutcome {
2378                status: ForgeStatus::Done {
2379                    exit_code: 0,
2380                    ended_at: 0,
2381                },
2382                stderr_tail: String::new(),
2383            })
2384        }
2385    }
2386
2387    /// R546-B8: records the substituted argv and, optionally, declines to write
2388    /// the output while still reporting exit 0 — the exact shape of a recipe
2389    /// that chdirs and then writes its artifact to a relative path inside the
2390    /// container.
2391    struct ArgvCapture {
2392        argv: Arc<Mutex<Vec<String>>>,
2393        write_output: bool,
2394    }
2395
2396    impl ArgvCapture {
2397        fn new(write_output: bool) -> (Arc<Self>, Arc<Mutex<Vec<String>>>) {
2398            let argv = Arc::new(Mutex::new(Vec::new()));
2399            let me = Arc::new(Self {
2400                argv: argv.clone(),
2401                write_output,
2402            });
2403            (me, argv)
2404        }
2405    }
2406
2407    #[async_trait]
2408    impl ForgeExecutor for ArgvCapture {
2409        async fn execute(
2410            &self,
2411            spec: ForgeSpec,
2412            _ctx: ExecContext,
2413            _sink: Option<UnboundedSender<ExecEvent>>,
2414        ) -> Result<ExecOutcome, ForgeExecutorError> {
2415            let ForgeCommand::Subprocess { argv, .. } = &spec.command else {
2416                return Err(ForgeExecutorError::Unsupported(
2417                    "capture only handles Subprocess",
2418                ));
2419            };
2420            *self.argv.lock().await = argv.clone();
2421            if self.write_output {
2422                let out = argv.iter().find(|a| a.ends_with(".tmp")).ok_or(
2423                    ForgeExecutorError::Unsupported("recipe must pass a .tmp output path"),
2424                )?;
2425                std::fs::write(out, b"produced").map_err(ForgeExecutorError::Io)?;
2426            }
2427            Ok(ExecOutcome {
2428                status: ForgeStatus::Done {
2429                    exit_code: 0,
2430                    ended_at: 0,
2431                },
2432                stderr_tail: String::new(),
2433            })
2434        }
2435    }
2436
2437    /// Write a `whisper-quantize.toml`-style recipe under
2438    /// `<workspace>/.yah/qed/transforms/<name>.toml`. Returns nothing — the
2439    /// recipe loader resolves the path itself.
2440    fn write_recipe(workspace_root: &Path, name: &str) {
2441        let transforms_dir = workspace_root.join(".yah/qed/transforms");
2442        std::fs::create_dir_all(&transforms_dir).unwrap();
2443        let toml = format!(
2444            r#"
2445name  = "{name}"
2446label = "test recipe"
2447image = "ghcr.io/test/tool:v1@sha256:{HASH_64}"
2448
2449[placement]
2450location = "local"
2451runtime  = "container"
2452
2453[[steps]]
2454name = "transform"
2455argv = ["./tool", "{{{{YAH_TRANSFORM_IN_0}}}}", "{{{{YAH_TRANSFORM_OUT}}}}"]
2456"#
2457        );
2458        std::fs::write(transforms_dir.join(format!("{name}.toml")), toml).unwrap();
2459    }
2460
2461    /// Like [`write_recipe`] but with a caller-chosen container digest, so a
2462    /// test can simulate a digest bump (which must invalidate the W212 action
2463    /// cache and force a re-run).
2464    fn write_recipe_with_digest(workspace_root: &Path, name: &str, digest: &str) {
2465        let transforms_dir = workspace_root.join(".yah/qed/transforms");
2466        std::fs::create_dir_all(&transforms_dir).unwrap();
2467        let toml = format!(
2468            r#"
2469name  = "{name}"
2470label = "test recipe"
2471image = "ghcr.io/test/tool:v1@sha256:{digest}"
2472
2473[placement]
2474location = "local"
2475runtime  = "container"
2476
2477[[steps]]
2478name = "transform"
2479argv = ["./tool", "{{{{YAH_TRANSFORM_IN_0}}}}", "{{{{YAH_TRANSFORM_OUT}}}}"]
2480"#
2481        );
2482        std::fs::write(transforms_dir.join(format!("{name}.toml")), toml).unwrap();
2483    }
2484
2485    /// Write a remotely-placed recipe. `placement_extra` appends lines to the
2486    /// `[placement]` table (e.g. a `platform` the remote leg must refuse) and
2487    /// `steps` is the whole `[[steps]]` block, so a test can pass two of them.
2488    fn write_remote_recipe(
2489        workspace_root: &Path,
2490        name: &str,
2491        placement_extra: &str,
2492        steps: &str,
2493    ) {
2494        let transforms_dir = workspace_root.join(".yah/qed/transforms");
2495        std::fs::create_dir_all(&transforms_dir).unwrap();
2496        let toml = format!(
2497            r#"
2498name  = "{name}"
2499label = "test remote recipe"
2500image = "ghcr.io/test/tool:v1@sha256:{HASH_64}"
2501
2502[placement]
2503location = {{ kind = "remote_any", tier = "infra", mesh_tags = ["tier:x86"] }}
2504runtime  = "container"
2505{placement_extra}
2506
2507{steps}
2508"#
2509        );
2510        std::fs::write(transforms_dir.join(format!("{name}.toml")), toml).unwrap();
2511    }
2512
2513    /// Stands in for `RemoteForgeDriver`: records the context it was handed and
2514    /// performs the retrieval half by writing the "built" bytes to
2515    /// `ctx.produced.dest`, exactly as the real driver does after
2516    /// `fetch_produced_file`.
2517    struct RemoteMock {
2518        out_bytes: Vec<u8>,
2519        seen: Arc<Mutex<Option<(Vec<String>, ExecContext)>>>,
2520    }
2521
2522    impl RemoteMock {
2523        fn new(out_bytes: Vec<u8>) -> (Arc<Self>, Arc<Mutex<Option<(Vec<String>, ExecContext)>>>) {
2524            let seen = Arc::new(Mutex::new(None));
2525            let me = Arc::new(Self {
2526                out_bytes,
2527                seen: seen.clone(),
2528            });
2529            (me, seen)
2530        }
2531    }
2532
2533    #[async_trait]
2534    impl ForgeExecutor for RemoteMock {
2535        async fn execute(
2536            &self,
2537            spec: ForgeSpec,
2538            ctx: ExecContext,
2539            _sink: Option<UnboundedSender<ExecEvent>>,
2540        ) -> Result<ExecOutcome, ForgeExecutorError> {
2541            let ForgeCommand::Subprocess { argv, .. } = &spec.command else {
2542                return Err(ForgeExecutorError::Unsupported("remote mock: Subprocess only"));
2543            };
2544            assert!(
2545                !matches!(spec.where_.location, TaskLocation::Local),
2546                "the remote mock must only ever see a remotely-placed spec",
2547            );
2548            if let Some(produced) = &ctx.produced {
2549                std::fs::write(&produced.dest, &self.out_bytes).map_err(ForgeExecutorError::Io)?;
2550            }
2551            *self.seen.lock().await = Some((argv.clone(), ctx));
2552            Ok(ExecOutcome {
2553                status: ForgeStatus::Done {
2554                    exit_code: 0,
2555                    ended_at: 0,
2556                },
2557                stderr_tail: String::new(),
2558            })
2559        }
2560    }
2561
2562    /// R555-F3, the whole point: a remotely-placed recipe binds its output to
2563    /// the worker's durable produced dir, and the bytes come back to the local
2564    /// derivation cache so the publish leg above is untouched.
2565    #[tokio::test]
2566    async fn a_remote_recipe_binds_out_on_the_worker_and_lands_the_bytes_locally() {
2567        let fx = Fixture::new(minio_slot());
2568        write_remote_recipe(
2569            &fx.workspace_root,
2570            "remote-recipe",
2571            "",
2572            "[[steps]]\nname = \"build\"\nargv = [\"build.sh\", \"{{YAH_TRANSFORM_OUT}}\"]",
2573        );
2574        let out_bytes = b"remotely built".to_vec();
2575        let out_hash = blake3_hex(&out_bytes);
2576        let (mock, seen) = RemoteMock::new(out_bytes.clone());
2577        let executor: Arc<dyn ForgeExecutor> = mock;
2578
2579        let fetch_path = fx.workspace_root.join(".yah/cache/derive/fetch/in.bin");
2580        std::fs::create_dir_all(fetch_path.parent().unwrap()).unwrap();
2581        std::fs::write(&fetch_path, b"anchor").unwrap();
2582
2583        let landed = materialize_transform(
2584            &TransformSpec {
2585                recipe: "remote-recipe".to_string(),
2586                params: BTreeMap::new(),
2587            },
2588            &fetch_path,
2589            &blake3_hex(b"anchor"),
2590            &out_hash,
2591            &fx.workspace_root.join(".yah/cache/derive/transform"),
2592            &fx.workspace_root,
2593            executor.as_ref(),
2594        )
2595        .await
2596        .unwrap();
2597
2598        assert_eq!(std::fs::read(&landed).unwrap(), out_bytes);
2599
2600        let seen = seen.lock().await;
2601        let (argv, ctx) = seen.as_ref().expect("the remote step ran");
2602        let bound_out = argv.last().expect("argv carries the OUT binding");
2603        assert!(
2604            bound_out.starts_with(workload_spec::forge_produced::CONTAINER_DIR),
2605            "OUT must be bound under the worker's durable produced dir, got {bound_out}",
2606        );
2607        let produced = ctx.produced.as_ref().expect("retrieval must be requested");
2608        assert_eq!(produced.remote_path, PathBuf::from(bound_out));
2609        // A host path handed to a remote workdir names a directory that does
2610        // not exist on the node.
2611        assert!(ctx.cwd.is_none(), "no host cwd may travel to a worker");
2612    }
2613
2614    /// The fetched input lives on this box and there is no upload leg, so a
2615    /// remote recipe that reads it would be handed an unresolvable path.
2616    #[tokio::test]
2617    async fn a_remote_recipe_reading_the_fetched_input_is_refused() {
2618        let fx = Fixture::new(minio_slot());
2619        write_remote_recipe(
2620            &fx.workspace_root,
2621            "needs-input",
2622            "",
2623            "[[steps]]\nname = \"build\"\nargv = [\"tool\", \"{{YAH_TRANSFORM_IN_0}}\", \"{{YAH_TRANSFORM_OUT}}\"]",
2624        );
2625        let msg = remote_refusal(&fx, "needs-input").await;
2626        assert!(msg.contains("YAH_TRANSFORM_IN_0"), "got: {msg}");
2627        assert!(msg.contains("no upload leg"), "got: {msg}");
2628    }
2629
2630    /// Steps share a filesystem locally and share nothing remotely.
2631    #[tokio::test]
2632    async fn a_multi_step_remote_recipe_is_refused() {
2633        let fx = Fixture::new(minio_slot());
2634        write_remote_recipe(
2635            &fx.workspace_root,
2636            "two-steps",
2637            "",
2638            "[[steps]]\nname = \"one\"\nargv = [\"a\"]\n\n[[steps]]\nname = \"two\"\nargv = [\"b\", \"{{YAH_TRANSFORM_OUT}}\"]",
2639        );
2640        let msg = remote_refusal(&fx, "two-steps").await;
2641        assert!(msg.contains("one-shot workload"), "got: {msg}");
2642    }
2643
2644    /// `platform` is host-emulation; remote picks arch by scheduling. Silently
2645    /// dropping it is how R546 got a wrong-arch artifact in the first place.
2646    #[tokio::test]
2647    async fn a_remote_recipe_declaring_a_platform_is_refused_and_named_mesh_tags() {
2648        let fx = Fixture::new(minio_slot());
2649        write_remote_recipe(
2650            &fx.workspace_root,
2651            "emulated",
2652            "platform = \"linux/amd64\"",
2653            "[[steps]]\nname = \"build\"\nargv = [\"build.sh\", \"{{YAH_TRANSFORM_OUT}}\"]",
2654        );
2655        let msg = remote_refusal(&fx, "emulated").await;
2656        assert!(msg.contains("mesh_tags"), "got: {msg}");
2657    }
2658
2659    /// Run `recipe` through `materialize_transform` with an executor that
2660    /// panics if reached, and return the refusal message. Every caller here
2661    /// asserts the recipe is rejected *before* anything is dispatched.
2662    async fn remote_refusal(fx: &Fixture, recipe: &str) -> String {
2663        struct NeverRuns;
2664        #[async_trait]
2665        impl ForgeExecutor for NeverRuns {
2666            async fn execute(
2667                &self,
2668                _spec: ForgeSpec,
2669                _ctx: ExecContext,
2670                _sink: Option<UnboundedSender<ExecEvent>>,
2671            ) -> Result<ExecOutcome, ForgeExecutorError> {
2672                panic!("the recipe must be refused before dispatch");
2673            }
2674        }
2675        let fetch_path = fx.workspace_root.join(".yah/cache/derive/fetch/in.bin");
2676        std::fs::create_dir_all(fetch_path.parent().unwrap()).unwrap();
2677        std::fs::write(&fetch_path, b"anchor").unwrap();
2678        let executor: Arc<dyn ForgeExecutor> = Arc::new(NeverRuns);
2679        let err = materialize_transform(
2680            &TransformSpec {
2681                recipe: recipe.to_string(),
2682                params: BTreeMap::new(),
2683            },
2684            &fetch_path,
2685            &blake3_hex(b"anchor"),
2686            &blake3_hex(b"whatever"),
2687            &fx.workspace_root.join(".yah/cache/derive/transform"),
2688            &fx.workspace_root,
2689            executor.as_ref(),
2690        )
2691        .await
2692        .expect_err("remote recipe must be refused");
2693        format!("{err:#}")
2694    }
2695
2696    // ── Slot validation ───────────────────────────────────────────────────────
2697
2698    #[tokio::test]
2699    async fn up_bails_when_object_store_slot_missing() {
2700        let mut fx = Fixture::new(minio_slot());
2701        fx.mirror.providers.clear();
2702        fx.write_workload("");
2703        let err = StaticAssetReconciler::new().up(fx.ctx()).await.unwrap_err();
2704        let msg = format!("{err:#}");
2705        assert!(msg.contains("providers.object_store"), "got: {msg}");
2706    }
2707
2708    #[tokio::test]
2709    async fn up_bails_on_unsupported_inline_slot_kind() {
2710        let slot = MirrorProviderSlot::Inline {
2711            kind: Provider::LocalStatic,
2712            fields: BTreeMap::new(),
2713        };
2714        let fx = Fixture::new(slot);
2715        fx.write_workload("");
2716        let err = StaticAssetReconciler::new().up(fx.ctx()).await.unwrap_err();
2717        let msg = format!("{err:#}");
2718        assert!(msg.contains("minio-container"), "got: {msg}");
2719    }
2720
2721    #[tokio::test]
2722    async fn up_bails_on_unsupported_reference_provider() {
2723        let slot = MirrorProviderSlot::Reference {
2724            provider_id: "hetzner".to_string(),
2725            fields: BTreeMap::new(),
2726        };
2727        let fx = Fixture::new(slot);
2728        fx.write_workload("");
2729        // Declare a real non-cloudflare provider so dispatch resolves it and
2730        // reaches the kind check (the new dispatch keys on kind, not name).
2731        let providers_dir = fx.workspace_root.join(".yah/infra/providers");
2732        std::fs::create_dir_all(&providers_dir).unwrap();
2733        std::fs::write(
2734            providers_dir.join("hetzner.toml"),
2735            "schema_version = 1\nid = \"hetzner\"\nkind = \"hetzner\"\naccount_id = \"x\"\n",
2736        )
2737        .unwrap();
2738        let err = StaticAssetReconciler::new().up(fx.ctx()).await.unwrap_err();
2739        let msg = format!("{err:#}");
2740        assert!(msg.contains("cloudflare-kind"), "got: {msg}");
2741    }
2742
2743    #[tokio::test]
2744    async fn up_bails_when_workload_toml_missing() {
2745        let fx = Fixture::new(minio_slot());
2746        // No workload.toml written.
2747        let err = StaticAssetReconciler::new().up(fx.ctx()).await.unwrap_err();
2748        let msg = format!("{err:#}");
2749        assert!(msg.contains("workload.toml"), "got: {msg}");
2750    }
2751
2752    #[tokio::test]
2753    async fn up_bails_when_catalog_alias_orphaned() {
2754        // Alias points at a filename not in the catalog — shape_static_asset rejects it.
2755        let fx = Fixture::new(minio_slot());
2756        fx.write_workload(
2757            r#"[aliases]
2758"default" = "nonexistent/file.bin"
2759"#,
2760        );
2761        let err = StaticAssetReconciler::new().up(fx.ctx()).await.unwrap_err();
2762        let msg = format!("{err:#}");
2763        assert!(
2764            msg.contains("closed-catalog") || msg.contains("aliases"),
2765            "got: {msg}"
2766        );
2767    }
2768
2769    // ── BLAKE3 verification ───────────────────────────────────────────────────
2770
2771    #[test]
2772    fn blake3_mismatch_detected() {
2773        let body = b"hello world";
2774        let actual = blake3_hex(body);
2775        // Fabricate a hash that's wrong.
2776        let wrong = HASH_64;
2777        assert!(!hashes_equal(&actual, wrong));
2778    }
2779
2780    #[test]
2781    fn blake3_match_is_case_insensitive() {
2782        let body = b"hello world";
2783        let lower = blake3_hex(body);
2784        let upper = lower.to_uppercase();
2785        assert!(hashes_equal(&lower, &upper));
2786    }
2787
2788    #[test]
2789    fn sha256_hex_is_64_chars() {
2790        let h = sha256_hex(b"test");
2791        assert_eq!(h.len(), 64);
2792    }
2793
2794    /// Core scenario: source file whose BLAKE3 matches the declared hash should
2795    /// be identified as upload-ready (no mismatch). Tests the in-memory path
2796    /// without hitting any S3 endpoint.
2797    #[test]
2798    fn matching_blake3_produces_no_mismatch_entry() {
2799        let body = b"real model weights here";
2800        let real_hash = blake3_hex(body);
2801        // Simulate what the reconciler checks.
2802        let stored = BlakeHash(real_hash.clone());
2803        assert!(hashes_equal(&real_hash, &stored.0));
2804    }
2805
2806    #[tokio::test]
2807    async fn blake3_mismatch_on_source_file_is_caught() {
2808        let fx = Fixture::new(minio_slot());
2809        let body = b"real model content";
2810        let real_hash = blake3_hex(body);
2811        // Write a workload that declares the correct hash but we'll give it
2812        // a different file content to verify the check fires before any network.
2813        let wrong_content = b"tampered content";
2814        fx.write_source_file("model.bin", wrong_content);
2815        let wrong_hash_for_real = blake3_hex(wrong_content);
2816        // Sanity: the wrong content should produce a different hash.
2817        assert_ne!(real_hash, wrong_hash_for_real);
2818
2819        let workload = StaticAssetWorkload {
2820            schema_version: workload_spec::SchemaVersion::V1,
2821            assets: vec![AssetEntry {
2822                filename: "model.bin".to_string(),
2823                source: Some("model.bin".into()),
2824                derive: None,
2825                blake3: BlakeHash(real_hash),
2826            }],
2827            aliases: BTreeMap::new(),
2828        };
2829
2830        // Drive the sync with a non-existent MinIO so it would fail on network
2831        // if it ever got past the BLAKE3 check.
2832        let client = reqwest::Client::new();
2833        let journal = AssetStatusJournal::new(fx.workspace_root.join(".yah/cloud/status.jsonl"));
2834        let report = sync_assets(
2835            &workload,
2836            &fx.workspace_root,
2837            &fx.workload_dir(),
2838            &client,
2839            "http://127.0.0.1:19999", // unreachable
2840            "test-bucket",
2841            MINIO_REGION,
2842            "user",
2843            "pass",
2844            test_executor(),
2845            "test-service",
2846            &journal,
2847        )
2848        .await
2849        .unwrap();
2850
2851        assert_eq!(report.hash_mismatch, vec!["model.bin"]);
2852        assert!(report.uploaded.is_empty());
2853    }
2854
2855    #[tokio::test]
2856    async fn correct_blake3_proceeds_to_s3_head() {
2857        let fx = Fixture::new(minio_slot());
2858        let body = b"correct content";
2859        let hash = blake3_hex(body);
2860        fx.write_source_file("model.bin", body);
2861
2862        let workload = StaticAssetWorkload {
2863            schema_version: workload_spec::SchemaVersion::V1,
2864            assets: vec![AssetEntry {
2865                filename: "model.bin".to_string(),
2866                source: Some("model.bin".into()),
2867                derive: None,
2868                blake3: BlakeHash(hash),
2869            }],
2870            aliases: BTreeMap::new(),
2871        };
2872
2873        // BLAKE3 passes → reconciler proceeds to HEAD the S3 endpoint.
2874        // The unreachable endpoint causes a network error (not a BLAKE3 error).
2875        let client = reqwest::Client::new();
2876        let journal = AssetStatusJournal::new(fx.workspace_root.join(".yah/cloud/status.jsonl"));
2877        let err = sync_assets(
2878            &workload,
2879            &fx.workspace_root,
2880            &fx.workload_dir(),
2881            &client,
2882            "http://127.0.0.1:19999", // unreachable
2883            "test-bucket",
2884            MINIO_REGION,
2885            "user",
2886            "pass",
2887            test_executor(),
2888            "test-service",
2889            &journal,
2890        )
2891        .await
2892        .unwrap_err();
2893
2894        // Error must be about network, not BLAKE3.
2895        let msg = format!("{err:#}");
2896        assert!(
2897            !msg.contains("BLAKE3") && !msg.contains("mismatch"),
2898            "should fail at network, not BLAKE3; got: {msg}"
2899        );
2900    }
2901
2902    // ── Catalog manifest ──────────────────────────────────────────────────────
2903
2904    #[test]
2905    fn catalog_manifest_roundtrip() {
2906        let mut m = CatalogManifest::new();
2907        m.insert("whisper/model-v1.bin".to_string(), HASH_64.to_string());
2908        let json = serde_json::to_vec(&m).unwrap();
2909        let m2: CatalogManifest = serde_json::from_slice(&json).unwrap();
2910        assert_eq!(m, m2);
2911    }
2912
2913    // ── R546-B10: publish-skip must be content-aware, not existence-aware ─────
2914
2915    /// The defect this encodes: an object that EXISTS is not evidence that the
2916    /// object holds the bytes we are about to publish. Skipping the PUT on mere
2917    /// existence left stale bytes in the bucket while the catalog manifest
2918    /// advertised the new hash — the published catalog described bytes that were
2919    /// never uploaded (real incident: rusty-v8 aarch64, bucket held d322b4a1
2920    /// while the manifest claimed ebdd842d).
2921    #[test]
2922    fn remote_object_matches_only_on_identical_content() {
2923        let expected = "a".repeat(64);
2924        let other = "b".repeat(64);
2925
2926        // Same bytes → genuine no-op, safe to skip the PUT.
2927        assert!(RemoteObject::Present {
2928            blake3: Some(expected.clone())
2929        }
2930        .matches(&expected));
2931
2932        // Different bytes → MUST NOT be treated as synced. This is the bug.
2933        assert!(!RemoteObject::Present {
2934            blake3: Some(other)
2935        }
2936        .matches(&expected));
2937
2938        // Absent → nothing to match.
2939        assert!(!RemoteObject::Absent.matches(&expected));
2940        assert!(!RemoteObject::Absent.exists());
2941    }
2942
2943    /// A legacy object written before we stamped `x-amz-meta-blake3` has
2944    /// unknowable content. It must NOT count as a match — otherwise every
2945    /// pre-existing object keeps its old bytes forever. It re-PUTs once, which
2946    /// installs the stamp and restores the cheap skip on subsequent runs.
2947    #[test]
2948    fn unstamped_remote_object_is_not_a_match_but_does_exist() {
2949        let expected = "c".repeat(64);
2950        let legacy = RemoteObject::Present { blake3: None };
2951        assert!(
2952            !legacy.matches(&expected),
2953            "unstamped object must not be trusted as current"
2954        );
2955        assert!(
2956            legacy.exists(),
2957            "it does exist — caller re-publishes rather than treating as absent"
2958        );
2959    }
2960
2961    /// Hashes may have been authored in either case; comparison goes through
2962    /// `hashes_equal`, so an uppercase stamp must still match.
2963    #[test]
2964    fn remote_object_match_is_case_insensitive() {
2965        let lower = "abcdef".repeat(10) + "abcd";
2966        let upper = lower.to_uppercase();
2967        assert_eq!(lower.len(), 64);
2968        assert!(RemoteObject::Present {
2969            blake3: Some(upper)
2970        }
2971        .matches(&lower));
2972    }
2973
2974    // ── Prune candidate detection ─────────────────────────────────────────────
2975
2976    #[tokio::test]
2977    async fn prune_candidate_from_prior_manifest() {
2978        let fx = Fixture::new(minio_slot());
2979        let body = b"current content";
2980        let hash = blake3_hex(body);
2981        fx.write_source_file("current.bin", body);
2982
2983        // Workload only has "current.bin"; prior manifest also had "old.bin".
2984        let workload = StaticAssetWorkload {
2985            schema_version: workload_spec::SchemaVersion::V1,
2986            assets: vec![AssetEntry {
2987                filename: "current.bin".to_string(),
2988                source: Some("current.bin".into()),
2989                derive: None,
2990                blake3: BlakeHash(hash),
2991            }],
2992            aliases: BTreeMap::new(),
2993        };
2994
2995        // We can't inject a prior manifest without a real/mock server, but we
2996        // can verify the prune detection logic directly.
2997        let current_filenames: std::collections::HashSet<&str> = workload
2998            .assets
2999            .iter()
3000            .map(|a| a.filename.as_str())
3001            .collect();
3002        let prior: CatalogManifest = [("old.bin".to_string(), HASH_64.to_string())]
3003            .into_iter()
3004            .collect();
3005
3006        let prune: Vec<_> = prior
3007            .keys()
3008            .filter(|k| !current_filenames.contains(k.as_str()))
3009            .cloned()
3010            .collect();
3011        assert_eq!(prune, vec!["old.bin"]);
3012    }
3013
3014    // ── W164 materialize step (R438-T15) ──────────────────────────────────────
3015
3016    use workload_spec::{AssetDerive, FetchSource, License, TransformSpec};
3017
3018    /// Helper: build an `AssetEntry` whose `source` is already on disk —
3019    /// the legacy arm of `materialize_asset` should return the disk path
3020    /// verbatim without ever consulting `derive`/cache/executor.
3021    fn legacy_entry(filename: &str, source_rel: &str, hash: &str) -> AssetEntry {
3022        AssetEntry {
3023            filename: filename.to_string(),
3024            source: Some(source_rel.into()),
3025            derive: None,
3026            blake3: BlakeHash(hash.to_string()),
3027        }
3028    }
3029
3030    #[tokio::test]
3031    async fn materialize_legacy_source_returns_workload_dir_path() {
3032        let fx = Fixture::new(minio_slot());
3033        let body = b"on-disk bytes";
3034        fx.write_source_file("model.bin", body);
3035        let entry = legacy_entry("model.bin", "model.bin", &blake3_hex(body));
3036
3037        let materialized = materialize_asset(
3038            &entry,
3039            &fx.workspace_root,
3040            &fx.workload_dir(),
3041            test_executor().as_ref(),
3042        )
3043        .await
3044        .expect("legacy materialize must succeed");
3045
3046        assert_eq!(materialized.path, fx.workload_dir().join("model.bin"));
3047        assert!(
3048            materialized.discovered_fetch_hash.is_none(),
3049            "legacy source mode has no fetch step → never bootstrap-discovers"
3050        );
3051    }
3052
3053    #[tokio::test]
3054    async fn materialize_fetch_cache_miss_then_hit() {
3055        let fx = Fixture::new(minio_slot());
3056        let body = b"upstream bytes";
3057        let hash = blake3_hex(body);
3058
3059        // Server returns the body once; second call would 500 if hit.
3060        use axum::routing::get;
3061        let counter = Arc::new(std::sync::atomic::AtomicU32::new(0));
3062        let counter_c = counter.clone();
3063        let body_c = body.to_vec();
3064        let app = axum::Router::new().route(
3065            "/blob.bin",
3066            get(move || {
3067                let counter = counter_c.clone();
3068                let body = body_c.clone();
3069                async move {
3070                    counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3071                    body
3072                }
3073            }),
3074        );
3075        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3076        let addr = listener.local_addr().unwrap();
3077        tokio::spawn(async move {
3078            axum::serve(listener, app).await.unwrap();
3079        });
3080
3081        let fetch = FetchSource {
3082            url: format!("http://{addr}/blob.bin"),
3083            blake3: BlakeHash(hash.clone()),
3084            license: License::Mit,
3085        };
3086        let cache_dir = fx.workspace_root.join(".yah/cache/derive/fetch");
3087
3088        // MISS — fetch + write to cache.
3089        let (p1, h1) = materialize_fetch(&fetch, &cache_dir).await.unwrap();
3090        assert_eq!(counter.load(std::sync::atomic::Ordering::SeqCst), 1);
3091        assert!(p1.exists(), "cache file must exist after MISS");
3092        assert_eq!(h1, hash, "strict mode returns the pinned hash");
3093
3094        // HIT — no second HTTP call.
3095        let (p2, h2) = materialize_fetch(&fetch, &cache_dir).await.unwrap();
3096        assert_eq!(p1, p2);
3097        assert_eq!(h1, h2, "HIT returns the same hash");
3098        assert_eq!(
3099            counter.load(std::sync::atomic::Ordering::SeqCst),
3100            1,
3101            "cache HIT must not re-fetch"
3102        );
3103    }
3104
3105    #[tokio::test]
3106    async fn materialize_fetch_blake3_mismatch_is_hard_error() {
3107        let fx = Fixture::new(minio_slot());
3108        let actual_body = b"real bytes";
3109        let actual_hash = blake3_hex(actual_body);
3110        let pinned_hash = HASH_64; // deliberately wrong
3111        assert_ne!(actual_hash, pinned_hash);
3112
3113        use axum::routing::get;
3114        let body_c = actual_body.to_vec();
3115        let app = axum::Router::new().route(
3116            "/blob.bin",
3117            get(move || {
3118                let body = body_c.clone();
3119                async move { body }
3120            }),
3121        );
3122        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3123        let addr = listener.local_addr().unwrap();
3124        tokio::spawn(async move {
3125            axum::serve(listener, app).await.unwrap();
3126        });
3127
3128        let fetch = FetchSource {
3129            url: format!("http://{addr}/blob.bin"),
3130            blake3: BlakeHash(pinned_hash.to_string()),
3131            license: License::Mit,
3132        };
3133        let err = materialize_fetch(&fetch, &fx.workspace_root.join(".yah/cache/derive/fetch"))
3134            .await
3135            .expect_err("BLAKE3 mismatch must surface as hard error");
3136        let msg = format!("{err:#}");
3137        assert!(
3138            msg.contains("BLAKE3") || msg.contains(&actual_hash),
3139            "got: {msg}"
3140        );
3141        assert!(msg.contains(pinned_hash), "diff must mention pin: {msg}");
3142    }
3143
3144    #[tokio::test]
3145    async fn materialize_fetch_retries_on_server_error() {
3146        // First request returns 500 (transient); second returns 200 with the body.
3147        // Verifies exponential-backoff retry (W164 OQ#4, R438-F11).
3148        use axum::http::StatusCode;
3149        use axum::routing::get;
3150
3151        let fx = Fixture::new(minio_slot());
3152        let body: Vec<u8> = b"retry-me bytes".to_vec();
3153        let hash = blake3_hex(&body);
3154
3155        let counter = Arc::new(std::sync::atomic::AtomicU32::new(0));
3156        let counter_c = counter.clone();
3157        let body_c = body.clone();
3158        let app = axum::Router::new().route(
3159            "/blob.bin",
3160            get(move || {
3161                let n = counter_c.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3162                let body = body_c.clone();
3163                async move {
3164                    if n == 0 {
3165                        (StatusCode::INTERNAL_SERVER_ERROR, vec![])
3166                    } else {
3167                        (StatusCode::OK, body)
3168                    }
3169                }
3170            }),
3171        );
3172        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3173        let addr = listener.local_addr().unwrap();
3174        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
3175
3176        let fetch = FetchSource {
3177            url: format!("http://{addr}/blob.bin"),
3178            blake3: BlakeHash(hash.clone()),
3179            license: License::Mit,
3180        };
3181        let cache_dir = fx.workspace_root.join(".yah/cache/derive/fetch");
3182        let (path, _hash) = materialize_fetch(&fetch, &cache_dir).await.unwrap();
3183        assert!(
3184            path.exists(),
3185            "cache file must exist after successful retry"
3186        );
3187        assert_eq!(
3188            counter.load(std::sync::atomic::Ordering::SeqCst),
3189            2,
3190            "exactly two attempts: one 500 + one 200"
3191        );
3192        assert_eq!(tokio::fs::read(&path).await.unwrap(), body);
3193    }
3194
3195    #[tokio::test]
3196    async fn materialize_fetch_resumes_via_range_header() {
3197        // Pre-populate the partial file with the first half of the blob. The
3198        // server should receive a Range header and return 206 + the second half.
3199        // Verifies Range-resume logic (W164 OQ#4, R438-F11).
3200        use axum::body::Body;
3201        use axum::http::{HeaderMap, StatusCode};
3202        use axum::routing::get;
3203
3204        let fx = Fixture::new(minio_slot());
3205        let body: Vec<u8> = (0u8..100u8).collect();
3206        let half = body.len() / 2;
3207        let hash = blake3_hex(&body);
3208
3209        // Pre-write first half to the .partial file.
3210        let cache_dir = fx.workspace_root.join(".yah/cache/derive/fetch");
3211        tokio::fs::create_dir_all(&cache_dir).await.unwrap();
3212        let partial_path = cache_dir.join(format!("{hash}.partial"));
3213        tokio::fs::write(&partial_path, &body[..half])
3214            .await
3215            .unwrap();
3216
3217        let body_c = body.clone();
3218        let received_range: Arc<std::sync::Mutex<Option<String>>> =
3219            Arc::new(std::sync::Mutex::new(None));
3220        let received_range_c = received_range.clone();
3221        let app = axum::Router::new().route(
3222            "/blob.bin",
3223            get(move |headers: HeaderMap| {
3224                let body = body_c.clone();
3225                let received = received_range_c.clone();
3226                async move {
3227                    let range_hdr = headers
3228                        .get("range")
3229                        .and_then(|v| v.to_str().ok())
3230                        .map(str::to_string);
3231                    *received.lock().unwrap() = range_hdr.clone();
3232
3233                    let Some(range) = range_hdr else {
3234                        return axum::response::Response::builder()
3235                            .status(StatusCode::BAD_REQUEST)
3236                            .body(Body::empty())
3237                            .unwrap();
3238                    };
3239                    let offset: usize = range
3240                        .strip_prefix("bytes=")
3241                        .and_then(|s| s.strip_suffix('-'))
3242                        .and_then(|s| s.parse().ok())
3243                        .unwrap_or(0);
3244                    let total = body.len();
3245                    axum::response::Response::builder()
3246                        .status(StatusCode::PARTIAL_CONTENT)
3247                        .header(
3248                            "Content-Range",
3249                            format!("bytes {offset}-{}/{total}", total - 1),
3250                        )
3251                        .body(Body::from(body[offset..].to_vec()))
3252                        .unwrap()
3253                }
3254            }),
3255        );
3256        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3257        let addr = listener.local_addr().unwrap();
3258        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
3259
3260        let fetch = FetchSource {
3261            url: format!("http://{addr}/blob.bin"),
3262            blake3: BlakeHash(hash.clone()),
3263            license: License::Mit,
3264        };
3265        let (path, _hash) = materialize_fetch(&fetch, &cache_dir).await.unwrap();
3266
3267        // Range header must carry the pre-existing partial size.
3268        let range = received_range.lock().unwrap().clone().unwrap();
3269        assert_eq!(
3270            range,
3271            format!("bytes={half}-"),
3272            "Range header must resume from partial offset"
3273        );
3274        // Final cache file must contain the complete body.
3275        assert_eq!(
3276            tokio::fs::read(&path).await.unwrap(),
3277            body,
3278            "cache file must contain the full blob after Range resume"
3279        );
3280    }
3281
3282    #[tokio::test]
3283    async fn materialize_transform_cache_miss_then_hit() {
3284        let fx = Fixture::new(minio_slot());
3285        write_recipe(&fx.workspace_root, "noop-recipe");
3286        let out_bytes = b"transformed output".to_vec();
3287        let out_hash = blake3_hex(&out_bytes);
3288        let (mock, invocations) = MockExecutor::new(out_bytes.clone());
3289        let executor: Arc<dyn ForgeExecutor> = mock;
3290
3291        // Pre-seed a fetch cache entry the transform reads from.
3292        let fetch_path = fx.workspace_root.join(".yah/cache/derive/fetch/in.bin");
3293        std::fs::create_dir_all(fetch_path.parent().unwrap()).unwrap();
3294        std::fs::write(&fetch_path, b"fetch bytes").unwrap();
3295
3296        let transform = TransformSpec {
3297            recipe: "noop-recipe".to_string(),
3298            params: BTreeMap::new(),
3299        };
3300        let cache_dir = fx.workspace_root.join(".yah/cache/derive/transform");
3301
3302        // MISS — runs the recipe.
3303        let p1 = materialize_transform(
3304            &transform,
3305            &fetch_path,
3306            &blake3_hex(b"fetch bytes"),
3307            &out_hash,
3308            &cache_dir,
3309            &fx.workspace_root,
3310            executor.as_ref(),
3311        )
3312        .await
3313        .unwrap();
3314        assert!(p1.exists());
3315        assert_eq!(*invocations.lock().await, 1);
3316
3317        // HIT — recipe NOT re-run.
3318        let p2 = materialize_transform(
3319            &transform,
3320            &fetch_path,
3321            &blake3_hex(b"fetch bytes"),
3322            &out_hash,
3323            &cache_dir,
3324            &fx.workspace_root,
3325            executor.as_ref(),
3326        )
3327        .await
3328        .unwrap();
3329        assert_eq!(p1, p2);
3330        assert_eq!(*invocations.lock().await, 1, "cache HIT must not re-run");
3331    }
3332
3333    /// R546-T3 qed→W164 bridge: seeding the derivation cache from a pre-built
3334    /// artifact makes the NEXT `materialize_transform` a HIT that returns the
3335    /// seeded bytes without running the recipe. This is the whole point —
3336    /// offload the arch-locked, expensive build to a fleet worker (rusty-v8-musl), retrieve
3337    /// its tar (R590-F6), seed the cache, and let `yah cloud apply` publish the
3338    /// pre-built bytes instead of re-building under emulation.
3339    #[tokio::test]
3340    async fn seed_transform_derivation_makes_next_transform_a_hit() {
3341        let fx = Fixture::new(minio_slot());
3342        write_recipe(&fx.workspace_root, "noop-recipe");
3343
3344        let transform = TransformSpec {
3345            recipe: "noop-recipe".to_string(),
3346            params: BTreeMap::new(),
3347        };
3348        let fetched_hash = blake3_hex(b"fetch bytes");
3349
3350        // A pre-built artifact standing in for the F6-retrieved rusty_v8 tar.
3351        let prebuilt = fx.workspace_root.join("prebuilt.tar.gz");
3352        let prebuilt_bytes = b"prebuilt rusty_v8 tarball".to_vec();
3353        std::fs::write(&prebuilt, &prebuilt_bytes).unwrap();
3354
3355        let seeded =
3356            seed_transform_derivation(&fx.workspace_root, &transform, &fetched_hash, &prebuilt)
3357                .await
3358                .unwrap();
3359        assert_eq!(seeded.output_blake3, blake3_hex(&prebuilt_bytes));
3360        assert!(seeded.cas_path.exists(), "CAS entry must be written");
3361
3362        // A materialize_transform with the SAME inputs must HIT the seeded cache:
3363        // the executor is never invoked and the returned bytes are the seeded
3364        // artifact (NOT what the recipe would have produced).
3365        let fetch_path = fx.workspace_root.join(".yah/cache/derive/fetch/in.bin");
3366        std::fs::create_dir_all(fetch_path.parent().unwrap()).unwrap();
3367        std::fs::write(&fetch_path, b"fetch bytes").unwrap();
3368        let cache_dir = fx.workspace_root.join(".yah/cache/derive/transform");
3369        let (mock, invocations) = MockExecutor::new(b"RECIPE OUTPUT (must not appear)".to_vec());
3370
3371        let out = materialize_transform(
3372            &transform,
3373            &fetch_path,
3374            &fetched_hash,
3375            &seeded.output_blake3,
3376            &cache_dir,
3377            &fx.workspace_root,
3378            mock.as_ref(),
3379        )
3380        .await
3381        .unwrap();
3382
3383        assert_eq!(
3384            *invocations.lock().await,
3385            0,
3386            "seeded derivation cache must make the transform a HIT (no recipe run)"
3387        );
3388        assert_eq!(
3389            std::fs::read(&out).unwrap(),
3390            prebuilt_bytes,
3391            "HIT must return the seeded pre-built bytes",
3392        );
3393    }
3394
3395    /// W212/R518: the derivation key is hermetic — every declared input
3396    /// dimension flips it, and identical inputs reproduce it. This is the
3397    /// correctness invariant that makes the silent-stale-cache bug impossible:
3398    /// a missing dimension would let two distinct inputs collide on one key.
3399    #[test]
3400    fn derivation_key_is_hermetic_over_every_input_dimension() {
3401        let input = "a".repeat(64);
3402        let recipe = "b".repeat(64);
3403        let mut params = BTreeMap::new();
3404        params.insert("quant".to_string(), "q5_1".to_string());
3405
3406        let base = derivation_key(&input, &recipe, &params);
3407
3408        // Fetched-input content (the model/version anchor) flips it.
3409        assert_ne!(base, derivation_key(&"c".repeat(64), &recipe, &params));
3410        // Recipe-file bytes (steps / pinned container digest / placement) flip it.
3411        assert_ne!(base, derivation_key(&input, &"d".repeat(64), &params));
3412        // A changed param value flips it.
3413        let mut p2 = params.clone();
3414        p2.insert("quant".to_string(), "q4_0".to_string());
3415        assert_ne!(base, derivation_key(&input, &recipe, &p2));
3416        // An added param flips it.
3417        let mut p3 = params.clone();
3418        p3.insert("extra".to_string(), "x".to_string());
3419        assert_ne!(base, derivation_key(&input, &recipe, &p3));
3420        // Identical inputs reproduce the key (deterministic, order-stable).
3421        assert_eq!(base, derivation_key(&input, &recipe, &params));
3422    }
3423
3424    /// W212/R518: the action cache is keyed on INPUTS, so a recipe change (here
3425    /// a container-digest bump) forces a re-run even on a warm cache. This is
3426    /// the regression guard against the W164/R438 output-keyed silent-stale
3427    /// skip — the exact defect this relay fixes.
3428    #[tokio::test]
3429    async fn transform_recipe_change_busts_cache_and_reruns() {
3430        let fx = Fixture::new(minio_slot());
3431        let digest_a = HASH_64;
3432        let digest_b = "1".repeat(64);
3433        write_recipe_with_digest(&fx.workspace_root, "pinned", digest_a);
3434
3435        let out_bytes = b"transformed output".to_vec();
3436        let out_hash = blake3_hex(&out_bytes);
3437        let (mock, invocations) = MockExecutor::new(out_bytes);
3438        let executor: Arc<dyn ForgeExecutor> = mock;
3439
3440        let fetch_path = fx.workspace_root.join(".yah/cache/derive/fetch/in.bin");
3441        std::fs::create_dir_all(fetch_path.parent().unwrap()).unwrap();
3442        std::fs::write(&fetch_path, b"fetch bytes").unwrap();
3443        let in_hash = blake3_hex(b"fetch bytes");
3444
3445        let transform = TransformSpec {
3446            recipe: "pinned".to_string(),
3447            params: BTreeMap::new(),
3448        };
3449        let cache_dir = fx.workspace_root.join(".yah/cache/derive/transform");
3450        let wr = &fx.workspace_root;
3451
3452        // Run 1 — cold, digest A.
3453        materialize_transform(
3454            &transform,
3455            &fetch_path,
3456            &in_hash,
3457            &out_hash,
3458            &cache_dir,
3459            wr,
3460            executor.as_ref(),
3461        )
3462        .await
3463        .unwrap();
3464        assert_eq!(*invocations.lock().await, 1);
3465
3466        // Re-run with the SAME recipe → warm HIT, no re-run.
3467        materialize_transform(
3468            &transform,
3469            &fetch_path,
3470            &in_hash,
3471            &out_hash,
3472            &cache_dir,
3473            wr,
3474            executor.as_ref(),
3475        )
3476        .await
3477        .unwrap();
3478        assert_eq!(*invocations.lock().await, 1, "identical inputs must skip");
3479
3480        // Bump the container digest A → B. Output-keyed caching silently
3481        // skipped this; input-addressed caching MUST re-run.
3482        write_recipe_with_digest(&fx.workspace_root, "pinned", &digest_b);
3483        materialize_transform(
3484            &transform,
3485            &fetch_path,
3486            &in_hash,
3487            &out_hash,
3488            &cache_dir,
3489            wr,
3490            executor.as_ref(),
3491        )
3492        .await
3493        .unwrap();
3494        assert_eq!(
3495            *invocations.lock().await,
3496            2,
3497            "a digest bump must invalidate the cache and re-run",
3498        );
3499    }
3500
3501    /// W212/R518-P2b: the substituter fast-path honours the committed lock —
3502    /// `Some(output)` when the key recomputed from the pins matches, `None` the
3503    /// moment any input drifts (here a recipe-digest bump).
3504    #[tokio::test]
3505    async fn lock_skip_hash_honours_committed_lock() {
3506        let fx = Fixture::new(minio_slot());
3507        write_recipe_with_digest(&fx.workspace_root, "pinned", HASH_64);
3508
3509        let fetch_pin = "a".repeat(64);
3510        let out_pin = "b".repeat(64);
3511        // The lock's input_hash must equal the key the reconciler recomputes
3512        // from the committed pins.
3513        let recipe_bk = recipe_blake3(&fx.workspace_root, "pinned").await.unwrap();
3514        let key = derivation_key(&fetch_pin, &recipe_bk, &BTreeMap::new());
3515
3516        let toml = format!(
3517            r#"kind = "static-asset"
3518schema_version = "V1"
3519
3520[[asset]]
3521filename = "whisper/coreml.tar.gz"
3522blake3   = "{out_pin}"
3523
3524[asset.derive.fetch]
3525url     = "https://example/config.json"
3526blake3  = "{fetch_pin}"
3527license = "mit"
3528
3529[asset.derive.transform]
3530recipe = "pinned"
3531
3532[asset.derive.lock]
3533input_hash    = "{key}"
3534output_blake3 = "{out_pin}"
3535"#
3536        );
3537        let workload: StaticAssetWorkload = toml::from_str(&toml).unwrap();
3538        let entry = &workload.assets[0];
3539
3540        // Lock current → Some(output hash) (caller then HEADs the bucket).
3541        assert_eq!(
3542            lock_skip_hash(entry, &fx.workspace_root).await.as_deref(),
3543            Some(out_pin.as_str()),
3544        );
3545
3546        // Bump the recipe digest → recipe-file hash changes → recomputed key no
3547        // longer matches the lock → None (must rebuild). The exact drift the
3548        // output-keyed cache missed.
3549        write_recipe_with_digest(&fx.workspace_root, "pinned", &"1".repeat(64));
3550        assert!(
3551            lock_skip_hash(entry, &fx.workspace_root).await.is_none(),
3552            "a stale recipe must not skip the build",
3553        );
3554    }
3555
3556    /// W212/R518-P2b: a bootstrap (zero-sentinel) row never short-circuits via
3557    /// the lock — there is no pinned output to trust yet.
3558    #[tokio::test]
3559    async fn lock_skip_hash_declines_bootstrap_rows() {
3560        let fx = Fixture::new(minio_slot());
3561        write_recipe_with_digest(&fx.workspace_root, "pinned", HASH_64);
3562        let sentinel = "0".repeat(64);
3563        let toml = format!(
3564            r#"kind = "static-asset"
3565schema_version = "V1"
3566
3567[[asset]]
3568filename = "whisper/coreml.tar.gz"
3569blake3   = "{sentinel}"
3570
3571[asset.derive.fetch]
3572url     = "https://example/config.json"
3573blake3  = "{sentinel}"
3574license = "mit"
3575
3576[asset.derive.transform]
3577recipe = "pinned"
3578
3579[asset.derive.lock]
3580input_hash    = "whatever"
3581output_blake3 = "{sentinel}"
3582"#
3583        );
3584        let workload: StaticAssetWorkload = toml::from_str(&toml).unwrap();
3585        assert!(
3586            lock_skip_hash(&workload.assets[0], &fx.workspace_root)
3587                .await
3588                .is_none(),
3589            "bootstrap rows must always build",
3590        );
3591    }
3592
3593    /// R546-B6: the fetched-input hash is the FOURTH paste-back value. Seeding
3594    /// used to compute it, key the derivation on it, and drop it — leaving
3595    /// `[asset.derive.fetch].blake3` at the sentinel, which makes
3596    /// `lock_skip_hash` decline at its first guard for everyone but the seeding
3597    /// machine (whose local action cache masks it).
3598    #[tokio::test]
3599    async fn seed_surfaces_fetched_input_hash_and_pin_state() {
3600        let fx = Fixture::new(minio_slot());
3601        write_recipe(&fx.workspace_root, "pinned");
3602
3603        // Warm the fetch cache so materialize_fetch takes its HIT path — no
3604        // network, and the committed pin is already correct (Pinned arm).
3605        let fetch_bytes = b"upstream source tarball".to_vec();
3606        let fetch_hash = blake3_hex(&fetch_bytes);
3607        let fetch_cache = fx.workspace_root.join(".yah/cache/derive/fetch");
3608        std::fs::create_dir_all(&fetch_cache).unwrap();
3609        std::fs::write(fetch_cache.join(format!("{fetch_hash}.bin")), &fetch_bytes).unwrap();
3610
3611        let sentinel = "0".repeat(64);
3612        let workload_path = fx.workspace_root.join("workload.toml");
3613        std::fs::write(
3614            &workload_path,
3615            format!(
3616                r#"kind = "static-asset"
3617schema_version = "V1"
3618
3619[[asset]]
3620filename = "rusty-v8/x86_64.tar.gz"
3621blake3   = "{sentinel}"
3622
3623[asset.derive.fetch]
3624url     = "https://example/v8.tar.gz"
3625blake3  = "{fetch_hash}"
3626license = "mit"
3627
3628[asset.derive.transform]
3629recipe = "pinned"
3630params = {{ target = "x86_64-unknown-linux-musl" }}
3631"#
3632            ),
3633        )
3634        .unwrap();
3635
3636        let prebuilt = fx.workspace_root.join("prebuilt.tar.gz");
3637        std::fs::write(&prebuilt, b"prebuilt rusty_v8 tarball").unwrap();
3638
3639        let seeded = seed_derivation_for_target(
3640            &fx.workspace_root,
3641            &workload_path,
3642            "x86_64-unknown-linux-musl",
3643            &prebuilt,
3644        )
3645        .await
3646        .unwrap();
3647
3648        assert_eq!(
3649            seeded.fetch_blake3, fetch_hash,
3650            "the hash the derivation key was keyed on must be surfaced for paste-back",
3651        );
3652        assert_eq!(seeded.fetch_pin, Some(FetchPinState::Pinned));
3653
3654        // And it really is the key input: recomputing from the surfaced value
3655        // reproduces the seeded lock's input_hash.
3656        let recipe_bk = recipe_blake3(&fx.workspace_root, "pinned").await.unwrap();
3657        let mut params = BTreeMap::new();
3658        params.insert(
3659            "target".to_string(),
3660            "x86_64-unknown-linux-musl".to_string(),
3661        );
3662        assert_eq!(
3663            derivation_key(&seeded.fetch_blake3, &recipe_bk, &params),
3664            seeded.derive_key,
3665        );
3666    }
3667
3668    /// R546-B6: the three pin verdicts the seed command reports on. `Sentinel`
3669    /// is the disarmed case the ticket was filed for; `Mismatch` catches a
3670    /// moved version anchor before the operator pastes a lock that can only
3671    /// ever decline on input drift.
3672    #[test]
3673    fn classify_fetch_pin_covers_all_three_verdicts() {
3674        let fetched = blake3_hex(b"upstream");
3675        assert_eq!(
3676            classify_fetch_pin(&"0".repeat(64), &fetched),
3677            FetchPinState::Sentinel,
3678        );
3679        assert_eq!(
3680            classify_fetch_pin(&fetched.to_uppercase(), &fetched),
3681            FetchPinState::Pinned,
3682            "hash comparison is case-insensitive like the rest of the reconciler",
3683        );
3684        let stale = blake3_hex(b"a different upstream");
3685        assert_eq!(
3686            classify_fetch_pin(&stale, &fetched),
3687            FetchPinState::Mismatch {
3688                committed: stale.clone()
3689            },
3690        );
3691    }
3692
3693    /// R546-B12: a clean reconcile must SAY something. The failure this guards
3694    /// is not a wrong line, it is an absent one — a successful publish and a
3695    /// successful no-op printing identically (i.e. nothing), which is what made
3696    /// the blind `yah cloud status` view so costly to notice.
3697    #[test]
3698    fn sync_notes_distinguish_publish_from_no_op_and_are_never_empty() {
3699        // Published.
3700        let mut report = StaticAssetSyncReport::default();
3701        report.uploaded.push("a/one.bin".to_string());
3702        let notes = render_sync_notes(&report);
3703        assert!(
3704            notes.iter().any(|n| n.contains("published 1 asset")),
3705            "{notes:?}"
3706        );
3707        assert!(notes.iter().any(|n| n.contains("a/one.bin")), "{notes:?}");
3708
3709        // No-op: everything already current. Must NOT look like a publish, and
3710        // must not be silent.
3711        let mut report = StaticAssetSyncReport::default();
3712        report.already_synced.push("a/one.bin".to_string());
3713        let notes = render_sync_notes(&report);
3714        assert!(
3715            notes.iter().any(|n| n.contains("already current")),
3716            "{notes:?}"
3717        );
3718        assert!(
3719            !notes.iter().any(|n| n.contains("published")),
3720            "a no-op must not read as a publish: {notes:?}"
3721        );
3722
3723        // Bootstrap discoveries are paste-back values — the operator needs the
3724        // hashes themselves, not a count.
3725        let mut report = StaticAssetSyncReport::default();
3726        report.bootstrapped.push(BootstrappedHash {
3727            filename: "a/one.bin".into(),
3728            kind: BootstrapHashKind::Output,
3729            hash: HASH_64.to_string(),
3730        });
3731        let notes = render_sync_notes(&report);
3732        assert!(notes.iter().any(|n| n.contains(HASH_64)), "{notes:?}");
3733
3734        // The degenerate case still speaks.
3735        let notes = render_sync_notes(&StaticAssetSyncReport::default());
3736        assert_eq!(notes.len(), 1, "{notes:?}");
3737        assert!(notes[0].contains("nothing to reconcile"), "{notes:?}");
3738    }
3739
3740    #[test]
3741    fn bootstrap_output_key_input_variant() {
3742        let b = BootstrappedHash {
3743            filename: "whisper/coreml.tar.gz".into(),
3744            kind: BootstrapHashKind::Input,
3745            hash: "deadbeef".into(),
3746        };
3747        assert_eq!(
3748            bootstrap_output_key(&b),
3749            "discovered_input_hash:whisper/coreml.tar.gz",
3750        );
3751    }
3752
3753    /// R546-B8: both recipe bindings must be ABSOLUTE, even when the caller
3754    /// passes a relative workspace root.
3755    ///
3756    /// The fixture deliberately uses `TempDir::new_in(".")` rather than the
3757    /// usual absolute tempdir — that is the whole point. `yah cloud apply`
3758    /// defaults `--path` to `"."`, so `cache_dir` came out relative
3759    /// (`./.yah/cache/derive/transform/<key>.tmp`) and got handed to the recipe
3760    /// verbatim. A relative OUT survives only while the recipe stays in its
3761    /// cwd, which is why the whisper recipes never tripped it; rusty-v8's
3762    /// build-v8.sh chdirs into a scratch dir to build V8, so it wrote the
3763    /// finished tar to `<scratch>/./.yah/cache/...` inside the container and the
3764    /// bytes died with it — after a ~2h build, with the step reporting exit 0.
3765    ///
3766    /// An absolute-tempdir fixture cannot catch this: `cache_dir` is then
3767    /// absolute either way and the assertion passes against the buggy code too.
3768    #[tokio::test]
3769    async fn transform_bindings_are_absolute_from_a_relative_workspace_root() {
3770        // `TempDir::new_in(".")` hands back an ABSOLUTE path, so rebuild the
3771        // relative form by hand — cargo runs tests with cwd at the package root,
3772        // so `./<name>` resolves to the same directory.
3773        let tmp = tempfile::TempDir::new_in(".").expect("tempdir beside cwd");
3774        let workspace_root = Path::new(".").join(tmp.path().file_name().unwrap());
3775        assert!(
3776            workspace_root.is_relative(),
3777            "fixture must reproduce the relative-root case, got {}",
3778            workspace_root.display(),
3779        );
3780
3781        write_recipe(&workspace_root, "noop-recipe");
3782        let cache_dir = workspace_root.join(".yah/cache/derive/transform");
3783        std::fs::create_dir_all(&cache_dir).unwrap();
3784        let fetch_path = workspace_root.join(".yah/cache/derive/fetch/in.bin");
3785        std::fs::create_dir_all(fetch_path.parent().unwrap()).unwrap();
3786        std::fs::write(&fetch_path, b"fetch bytes").unwrap();
3787
3788        let (capture, argv) = ArgvCapture::new(true);
3789        let executor: Arc<dyn ForgeExecutor> = capture;
3790        let transform = TransformSpec {
3791            recipe: "noop-recipe".to_string(),
3792            params: BTreeMap::new(),
3793        };
3794
3795        materialize_transform(
3796            &transform,
3797            &fetch_path,
3798            &blake3_hex(b"fetch bytes"),
3799            ZERO_SENTINEL_HEX, // bootstrap: accept whatever hash comes out
3800            &cache_dir,
3801            &workspace_root,
3802            executor.as_ref(),
3803        )
3804        .await
3805        .expect("transform must succeed");
3806
3807        // Recipe argv is ["./tool", "{{YAH_TRANSFORM_IN_0}}", "{{YAH_TRANSFORM_OUT}}"].
3808        let argv = argv.lock().await.clone();
3809        assert_eq!(argv.len(), 3, "unexpected argv shape: {argv:?}");
3810        assert!(
3811            Path::new(&argv[1]).is_absolute(),
3812            "{ENV_TRANSFORM_IN_0} binding must be absolute, got {:?}",
3813            argv[1],
3814        );
3815        assert!(
3816            Path::new(&argv[2]).is_absolute(),
3817            "{ENV_TRANSFORM_OUT} binding must be absolute, got {:?}",
3818            argv[2],
3819        );
3820    }
3821
3822    /// R546-B8 hardening: a recipe that exits 0 without writing its artifact
3823    /// must be named as such. Before this, the missing file surfaced as
3824    /// `reading transform output …: No such file or directory` on a path under
3825    /// `.yah/cache/`, which reads like cache corruption and sends the reader
3826    /// into the wrong subsystem.
3827    #[tokio::test]
3828    async fn transform_that_writes_no_output_names_the_recipe_contract() {
3829        let fx = Fixture::new(minio_slot());
3830        write_recipe(&fx.workspace_root, "writes-nothing");
3831        let cache_dir = fx.workspace_root.join(".yah/cache/derive/transform");
3832        let fetch_path = fx.workspace_root.join(".yah/cache/derive/fetch/in.bin");
3833        std::fs::create_dir_all(fetch_path.parent().unwrap()).unwrap();
3834        std::fs::write(&fetch_path, b"fetch bytes").unwrap();
3835
3836        // Exit 0, write nothing — exactly what the relative-OUT bug looked like
3837        // from the reconciler's side.
3838        let (capture, _) = ArgvCapture::new(false);
3839        let executor: Arc<dyn ForgeExecutor> = capture;
3840        let transform = TransformSpec {
3841            recipe: "writes-nothing".to_string(),
3842            params: BTreeMap::new(),
3843        };
3844
3845        let err = materialize_transform(
3846            &transform,
3847            &fetch_path,
3848            &blake3_hex(b"fetch bytes"),
3849            ZERO_SENTINEL_HEX,
3850            &cache_dir,
3851            &fx.workspace_root,
3852            executor.as_ref(),
3853        )
3854        .await
3855        .expect_err("a recipe that writes nothing must fail");
3856
3857        let msg = format!("{err:#}");
3858        assert!(msg.contains("produced no output"), "{msg}");
3859        assert!(msg.contains("writes-nothing"), "{msg}");
3860        assert!(
3861            !msg.contains("No such file or directory"),
3862            "must not leak the raw io error that reads like cache corruption: {msg}",
3863        );
3864    }
3865
3866    #[tokio::test]
3867    async fn materialize_transform_blake3_mismatch_on_output_is_hard_error() {
3868        let fx = Fixture::new(minio_slot());
3869        write_recipe(&fx.workspace_root, "wrong-output");
3870        let out_bytes = b"actual output".to_vec();
3871        let actual_hash = blake3_hex(&out_bytes);
3872        let pinned_hash = HASH_64; // deliberately wrong
3873        assert_ne!(actual_hash, pinned_hash);
3874        let (mock, _) = MockExecutor::new(out_bytes);
3875        let executor: Arc<dyn ForgeExecutor> = mock;
3876
3877        let fetch_path = fx.workspace_root.join(".yah/cache/derive/fetch/in.bin");
3878        std::fs::create_dir_all(fetch_path.parent().unwrap()).unwrap();
3879        std::fs::write(&fetch_path, b"fetch bytes").unwrap();
3880
3881        let transform = TransformSpec {
3882            recipe: "wrong-output".to_string(),
3883            params: BTreeMap::new(),
3884        };
3885        let err = materialize_transform(
3886            &transform,
3887            &fetch_path,
3888            &blake3_hex(b"fetch bytes"),
3889            pinned_hash,
3890            &fx.workspace_root.join(".yah/cache/derive/transform"),
3891            &fx.workspace_root,
3892            executor.as_ref(),
3893        )
3894        .await
3895        .expect_err("transform output mismatch must surface");
3896        let msg = format!("{err:#}");
3897        assert!(msg.contains("BLAKE3"), "{msg}");
3898        assert!(msg.contains(pinned_hash), "{msg}");
3899    }
3900
3901    #[tokio::test]
3902    async fn materialize_transform_recipe_failure_surfaces_stderr() {
3903        let fx = Fixture::new(minio_slot());
3904        write_recipe(&fx.workspace_root, "failing-recipe");
3905        let executor: Arc<dyn ForgeExecutor> = MockExecutor::failing("tool exploded".to_string());
3906
3907        let fetch_path = fx.workspace_root.join(".yah/cache/derive/fetch/in.bin");
3908        std::fs::create_dir_all(fetch_path.parent().unwrap()).unwrap();
3909        std::fs::write(&fetch_path, b"fetch bytes").unwrap();
3910
3911        let transform = TransformSpec {
3912            recipe: "failing-recipe".to_string(),
3913            params: BTreeMap::new(),
3914        };
3915        let err = materialize_transform(
3916            &transform,
3917            &fetch_path,
3918            &blake3_hex(b"fetch bytes"),
3919            HASH_64,
3920            &fx.workspace_root.join(".yah/cache/derive/transform"),
3921            &fx.workspace_root,
3922            executor.as_ref(),
3923        )
3924        .await
3925        .expect_err("failing recipe must surface");
3926        let msg = format!("{err:#}");
3927        assert!(msg.contains("failing-recipe"), "{msg}");
3928        assert!(msg.contains("tool exploded"), "{msg}");
3929    }
3930
3931    #[tokio::test]
3932    async fn materialize_derive_fetch_only_uses_fetch_path_for_upload() {
3933        // No transform — materialize_asset returns the fetch cache path
3934        // directly. The downstream BLAKE3 verify happens in sync_assets.
3935        let fx = Fixture::new(minio_slot());
3936        let body = b"weights v1";
3937        let hash = blake3_hex(body);
3938
3939        use axum::routing::get;
3940        let body_c = body.to_vec();
3941        let app = axum::Router::new().route(
3942            "/w.bin",
3943            get(move || {
3944                let body = body_c.clone();
3945                async move { body }
3946            }),
3947        );
3948        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3949        let addr = listener.local_addr().unwrap();
3950        tokio::spawn(async move {
3951            axum::serve(listener, app).await.unwrap();
3952        });
3953
3954        let entry = AssetEntry {
3955            filename: "w.bin".to_string(),
3956            source: None,
3957            derive: Some(AssetDerive {
3958                fetch: FetchSource {
3959                    url: format!("http://{addr}/w.bin"),
3960                    blake3: BlakeHash(hash.clone()),
3961                    license: License::Mit,
3962                },
3963                transform: None,
3964                lock: None,
3965            }),
3966            blake3: BlakeHash(hash.clone()),
3967        };
3968        let materialized = materialize_asset(
3969            &entry,
3970            &fx.workspace_root,
3971            &fx.workload_dir(),
3972            test_executor().as_ref(),
3973        )
3974        .await
3975        .expect("fetch-only materialize");
3976        assert!(materialized
3977            .path
3978            .to_string_lossy()
3979            .contains(".yah/cache/derive/fetch/"));
3980        assert_eq!(std::fs::read(&materialized.path).unwrap(), body);
3981        assert!(
3982            materialized.discovered_fetch_hash.is_none(),
3983            "strict mode: pinned fetch.blake3 → no discovery"
3984        );
3985    }
3986
3987    #[tokio::test]
3988    async fn materialize_fetch_bootstrap_sentinel_accepts_actual_hash() {
3989        // fetch.blake3 ships as the zero sentinel; reconciler accepts the
3990        // computed hash and names the cache by the discovered value.
3991        let fx = Fixture::new(minio_slot());
3992        let body = b"discovered-upstream";
3993        let actual_hash = blake3_hex(body);
3994
3995        use axum::routing::get;
3996        let body_c = body.to_vec();
3997        let app = axum::Router::new().route(
3998            "/blob.bin",
3999            get(move || {
4000                let body = body_c.clone();
4001                async move { body }
4002            }),
4003        );
4004        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4005        let addr = listener.local_addr().unwrap();
4006        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
4007
4008        let fetch = FetchSource {
4009            url: format!("http://{addr}/blob.bin"),
4010            blake3: BlakeHash(ZERO_SENTINEL_HEX.to_string()),
4011            license: License::Mit,
4012        };
4013        let cache_dir = fx.workspace_root.join(".yah/cache/derive/fetch");
4014        let (path, discovered) = materialize_fetch(&fetch, &cache_dir).await.unwrap();
4015        assert!(path.exists(), "cache file must exist after bootstrap fetch");
4016        assert_eq!(
4017            discovered, actual_hash,
4018            "discovered hash = actual content hash"
4019        );
4020        assert!(
4021            path.file_name()
4022                .and_then(|s| s.to_str())
4023                .map(|s| s.starts_with(&actual_hash))
4024                .unwrap_or(false),
4025            "bootstrap mode names cache by discovered hash, got {path:?}",
4026        );
4027    }
4028
4029    #[tokio::test]
4030    async fn materialize_derive_fetch_bootstrap_surfaces_discovered_hash() {
4031        // End-to-end: AssetEntry with sentinel fetch.blake3 → materialize_asset
4032        // populates discovered_fetch_hash so sync_assets can surface it.
4033        let fx = Fixture::new(minio_slot());
4034        let body = b"upstream-bytes";
4035        let actual_hash = blake3_hex(body);
4036
4037        use axum::routing::get;
4038        let body_c = body.to_vec();
4039        let app = axum::Router::new().route(
4040            "/w.bin",
4041            get(move || {
4042                let body = body_c.clone();
4043                async move { body }
4044            }),
4045        );
4046        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4047        let addr = listener.local_addr().unwrap();
4048        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
4049
4050        let entry = AssetEntry {
4051            filename: "w.bin".to_string(),
4052            source: None,
4053            derive: Some(AssetDerive {
4054                fetch: FetchSource {
4055                    url: format!("http://{addr}/w.bin"),
4056                    blake3: BlakeHash(ZERO_SENTINEL_HEX.to_string()),
4057                    license: License::Mit,
4058                },
4059                transform: None,
4060                lock: None,
4061            }),
4062            // entry.blake3 still sentinel — upload-side verify in sync_assets
4063            // handles the output-hash discovery; here we only assert the
4064            // fetch-side discovery threads up correctly.
4065            blake3: BlakeHash(ZERO_SENTINEL_HEX.to_string()),
4066        };
4067        let materialized = materialize_asset(
4068            &entry,
4069            &fx.workspace_root,
4070            &fx.workload_dir(),
4071            test_executor().as_ref(),
4072        )
4073        .await
4074        .expect("bootstrap materialize");
4075        assert_eq!(
4076            materialized.discovered_fetch_hash.as_deref(),
4077            Some(actual_hash.as_str()),
4078            "fetch.blake3 sentinel → discovered hash returned",
4079        );
4080    }
4081
4082    /// W209/F4: discovered hashes write to `$YAH_OUTPUTS` with one line per
4083    /// (kind, filename) pair so the QED runner can route them through
4084    /// OutputMap into pipeline binds. Key shape encodes the filename so
4085    /// multi-asset workloads round-trip; the value is the raw blake3 hex.
4086    #[test]
4087    fn bootstrap_outputs_format_round_trip() {
4088        let dir = tempdir().unwrap();
4089        let outputs = dir.path().join("outputs");
4090        let bootstrapped = vec![
4091            BootstrappedHash {
4092                filename: "yah-desktop/whisper/distil-large-v3-coreml.tar.gz".to_string(),
4093                kind: BootstrapHashKind::Output,
4094                hash: "fb0afc9f3d966f5347c6dfd335adab12f1dc8ee6df18cf9e9ff90fe86f0416c0"
4095                    .to_string(),
4096            },
4097            BootstrappedHash {
4098                filename: "yah-desktop/whisper/distil-large-v3-coreml.tar.gz".to_string(),
4099                kind: BootstrapHashKind::Fetch,
4100                hash: "050ffe562134208781dc316181b146a725821fff005fb4ffb6de2a6ada334a9b"
4101                    .to_string(),
4102            },
4103        ];
4104        append_bootstrap_outputs(&outputs, &bootstrapped).expect("write outputs");
4105        let content = std::fs::read_to_string(&outputs).expect("read outputs");
4106        let lines: Vec<&str> = content.lines().collect();
4107        assert_eq!(lines.len(), 2, "one line per discovery");
4108        assert!(
4109            lines.contains(
4110                &"discovered_asset_blake3:yah-desktop/whisper/distil-large-v3-coreml.tar.gz=\
4111                  fb0afc9f3d966f5347c6dfd335adab12f1dc8ee6df18cf9e9ff90fe86f0416c0"
4112            ),
4113            "asset output key encodes filename: {content:?}",
4114        );
4115        assert!(
4116            lines.contains(
4117                &"discovered_fetch_blake3:yah-desktop/whisper/distil-large-v3-coreml.tar.gz=\
4118                  050ffe562134208781dc316181b146a725821fff005fb4ffb6de2a6ada334a9b"
4119            ),
4120            "fetch output key encodes filename: {content:?}",
4121        );
4122    }
4123
4124    /// Append, not truncate: a step that materializes N assets and emits
4125    /// other outputs in the same step must not clobber what's already in
4126    /// the file.
4127    #[test]
4128    fn bootstrap_outputs_append_preserves_prior_lines() {
4129        let dir = tempdir().unwrap();
4130        let outputs = dir.path().join("outputs");
4131        std::fs::write(&outputs, "prior_key=prior_value\n").unwrap();
4132        let bootstrapped = vec![BootstrappedHash {
4133            filename: "a.bin".to_string(),
4134            kind: BootstrapHashKind::Output,
4135            hash: HASH_64.to_string(),
4136        }];
4137        append_bootstrap_outputs(&outputs, &bootstrapped).expect("append");
4138        let content = std::fs::read_to_string(&outputs).expect("read");
4139        assert!(content.starts_with("prior_key=prior_value\n"), "prior kept");
4140        assert!(
4141            content.contains(&format!("discovered_asset_blake3:a.bin={HASH_64}")),
4142            "new line appended: {content:?}",
4143        );
4144    }
4145}