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(¶ms[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, uri_encode_key};
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 // R630-B1: encode at URL-construction time (see `uri_encode_key`).
658 let object_url = format!("{endpoint}/{bucket}/{}", uri_encode_key(&entry.filename));
659 // R546-B10: the lock says WHAT the output should be; the bucket must
660 // actually hold it. An unstamped legacy object is not evidence of a
661 // match, so it falls through and rebuilds rather than blessing bytes
662 // we cannot identify — the expensive-but-correct direction.
663 let remote = head_object(client, &object_url, region, access_key, secret_key).await?;
664 if remote.matches(&out_hash) {
665 debug!(
666 filename = %entry.filename,
667 "derivation lock in sync + object content matches — skipping build",
668 );
669 report.already_synced.push(entry.filename.clone());
670 new_manifest.insert(entry.filename.clone(), out_hash);
671 continue;
672 }
673 if remote.exists() {
674 debug!(
675 filename = %entry.filename,
676 "derivation lock in sync but bucket object is unstamped or drifted \
677 — not trusting it, falling through to build/publish",
678 );
679 }
680 }
681
682 // R438-T15: derive-mode assets materialize to a content-addressed cache
683 // path (W164); legacy `source = "..."` assets read straight from disk.
684 // Both arms return a real on-disk path that the existing BLAKE3 verify +
685 // S3 PUT loop below treats uniformly.
686 let materialized =
687 materialize_asset(entry, workspace_root, workload_dir, executor.as_ref())
688 .await
689 .with_context(|| format!("materializing asset {:?}", entry.filename))?;
690 let source_path = materialized.path;
691 // Capture before the discovered_fetch_hash is moved out below.
692 let fetch_was_bootstrap = materialized.discovered_fetch_hash.is_some();
693 // W212/R518: derivation key for this build — emitted below so the bind
694 // path refreshes `[asset.derive.lock].input_hash`.
695 let build_derive_key = materialized.derive_key.clone();
696
697 // Surface any discovered upstream BLAKE3 (bootstrap mode for
698 // `[asset.derive.fetch].blake3`). Collected into `report.bootstrapped`
699 // and emitted to `$YAH_OUTPUTS` by the summary block in `up` (W209/F4).
700 if let Some(hash) = materialized.discovered_fetch_hash {
701 debug!(
702 filename = %entry.filename,
703 discovered = %hash,
704 "bootstrap discovered upstream blake3",
705 );
706 report.bootstrapped.push(BootstrappedHash {
707 filename: entry.filename.clone(),
708 kind: BootstrapHashKind::Fetch,
709 hash,
710 });
711 }
712
713 // Read source file.
714 let body = tokio::fs::read(&source_path).await.with_context(|| {
715 format!(
716 "reading source file {} for asset {:?}",
717 source_path.display(),
718 entry.filename
719 )
720 })?;
721
722 // BLAKE3 verification — strict mode rejects mismatch and skips upload;
723 // bootstrap mode (entry.blake3 == zero sentinel) accepts the computed
724 // hash and surfaces it in the report for paste-back. The bytes are
725 // uploaded either way — the difference is whether the pin is being
726 // *verified against* (strict) or *discovered* (bootstrap).
727 let actual_hash = blake3_hex(&body);
728 if is_bootstrap_sentinel(&entry.blake3.0) {
729 debug!(
730 filename = %entry.filename,
731 discovered = %actual_hash,
732 "bootstrap discovered asset blake3",
733 );
734 report.bootstrapped.push(BootstrappedHash {
735 filename: entry.filename.clone(),
736 kind: BootstrapHashKind::Output,
737 hash: actual_hash.clone(),
738 });
739 } else if !hashes_equal(&actual_hash, &entry.blake3.0) {
740 warn!(
741 filename = %entry.filename,
742 declared = %entry.blake3.0,
743 actual = %actual_hash,
744 "BLAKE3 mismatch — source file doesn't match declared hash",
745 );
746 report.hash_mismatch.push(entry.filename.clone());
747 journal
748 .append(&AssetStatusEvent {
749 at: Utc::now(),
750 asset: format!("{service_name}:{}", entry.filename),
751 from: None,
752 to: AssetState::DriftBucket,
753 bytes: None,
754 blake3: None,
755 })
756 .await;
757 continue;
758 }
759
760 // W212/R518: a verified build emits its derivation key so the bind path
761 // refreshes `[asset.derive.lock].input_hash`. Paired with the output
762 // hash above, this records the action-cache entry the next run's
763 // substituter fast-path consults. Only emitted when a build actually
764 // ran (the lock-skip path `continue`d before reaching here).
765 if let Some(dk) = &build_derive_key {
766 report.bootstrapped.push(BootstrappedHash {
767 filename: entry.filename.clone(),
768 kind: BootstrapHashKind::Input,
769 hash: dk.clone(),
770 });
771 }
772
773 let key = &entry.filename;
774 // R630-B1: encode at URL-construction time (see `uri_encode_key`).
775 let object_url = format!("{endpoint}/{bucket}/{}", uri_encode_key(key));
776
777 // R546-B10: skip the PUT only when the bucket demonstrably holds THESE
778 // bytes. Existence alone is not enough — an object placed at this key
779 // out-of-band, or a rebuild that isn't byte-reproducible, leaves content
780 // that differs from the artifact we just verified. Skipping on mere
781 // existence let the bucket keep stale bytes while `new_manifest` below
782 // recorded the new hash, so the published catalog described bytes that
783 // were never uploaded.
784 //
785 // Note `actual_hash` is already verified equal to the declared
786 // `entry.blake3` at this point (the mismatch arm above `continue`s), so
787 // re-PUTting on drift converges the bucket toward the committed catalog
788 // rather than clobbering it with something unvetted.
789 let remote = head_object(client, &object_url, region, access_key, secret_key).await?;
790 if remote.matches(&actual_hash) {
791 debug!(key, "already present with matching blake3 — skipping PUT");
792 report.already_synced.push(key.clone());
793 new_manifest.insert(key.clone(), actual_hash);
794 continue;
795 }
796 if remote.exists() {
797 // Drifted, or a legacy object predating the metadata stamp. Either
798 // way re-PUT: identical bytes make it a cheap idempotent overwrite
799 // that heals the missing stamp, and differing bytes are exactly the
800 // case that must not be skipped. Legacy objects therefore cost ONE
801 // re-upload, after which the stamp makes the skip work again.
802 warn!(
803 key,
804 expected = %actual_hash,
805 remote = ?match &remote {
806 RemoteObject::Present { blake3 } => blake3.as_deref(),
807 RemoteObject::Absent => None,
808 },
809 "bucket object does not match the declared artifact — re-publishing",
810 );
811 report.republished.push(key.clone());
812 }
813
814 // PUT the file.
815 let body_sha256 = sha256_hex(&body);
816 let content_length = body.len();
817 let content_type = content_type_for(&source_path);
818 let headers = sign_s3_put_object(
819 &object_url,
820 &body_sha256,
821 content_type,
822 content_length,
823 region,
824 access_key,
825 secret_key,
826 // R546-B10: stamp the content hash so a later run can tell whether
827 // the bucket already holds these exact bytes.
828 Some(actual_hash.as_str()),
829 )
830 .with_context(|| format!("signing PUT {object_url}"))?;
831
832 let resp = client
833 .put(&object_url)
834 .headers(headers)
835 .body(body)
836 .send()
837 .await
838 .with_context(|| format!("PUT {object_url}"))?;
839
840 if !resp.status().is_success() {
841 let status = resp.status();
842 let body_text = resp.text().await.unwrap_or_default();
843 anyhow::bail!("PUT {object_url} → {status}: {}", body_text.trim());
844 }
845
846 info!(key, "uploaded");
847 report.uploaded.push(key.clone());
848 new_manifest.insert(key.clone(), actual_hash.clone());
849 let from_state = if fetch_was_bootstrap {
850 AssetState::PlaceholderFetch
851 } else if is_bootstrap_sentinel(&entry.blake3.0) {
852 AssetState::PlaceholderOutput
853 } else {
854 AssetState::PinnedNotPublished
855 };
856 journal
857 .append(&AssetStatusEvent {
858 at: Utc::now(),
859 asset: format!("{service_name}:{key}"),
860 from: Some(from_state),
861 to: AssetState::Published,
862 bytes: Some(content_length as u64),
863 blake3: Some(actual_hash),
864 })
865 .await;
866 }
867
868 // Save the updated manifest (non-fatal: data is already in the bucket).
869 if let Err(e) = save_catalog_manifest(
870 client,
871 endpoint,
872 bucket,
873 &new_manifest,
874 region,
875 access_key,
876 secret_key,
877 )
878 .await
879 {
880 warn!(
881 error = %e,
882 "failed to save asset catalog manifest (non-fatal) — prune detection may miss \
883 candidates on the next run"
884 );
885 }
886
887 Ok(report)
888}
889
890// ── W164 materialize step (R438-T15) ──────────────────────────────────────────
891
892/// Output of [`materialize_asset`]: the on-disk path the upload loop reads,
893/// plus any BLAKE3 discovered during bootstrap-mode fetch.
894struct MaterializedAsset {
895 path: PathBuf,
896 /// Set when `derive.fetch.blake3` was the zero sentinel and the reconciler
897 /// computed the actual upstream hash. Operator pastes this back into
898 /// `[asset.derive.fetch].blake3`. `None` in strict mode and for legacy
899 /// `source = "..."` assets.
900 discovered_fetch_hash: Option<String>,
901 /// W212/R518: the input-addressed derivation key for this build, emitted to
902 /// `$YAH_OUTPUTS` so the bind path refreshes `[asset.derive.lock].input_hash`.
903 /// `None` for `source` / no-transform assets.
904 derive_key: Option<String>,
905}
906
907/// BLAKE3 of a transform recipe's TOML bytes — an input to the derivation key
908/// (W212). The file carries the pinned container digest, steps, and placement,
909/// so hashing it captures every recipe-side input in one value. `None` when the
910/// recipe file is unreadable (the caller treats that as "can't memoize").
911async fn recipe_blake3(workspace_root: &Path, recipe: &str) -> Option<String> {
912 let path = workspace_root
913 .join(".yah/qed/transforms")
914 .join(format!("{recipe}.toml"));
915 let bytes = tokio::fs::read(&path).await.ok()?;
916 Some(blake3_hex(&bytes))
917}
918
919/// W212/R518: the substituter fast-path. Returns `Some(output_hash)` when the
920/// asset's committed `[asset.derive.lock]` is *current* — i.e. the derivation
921/// key recomputed from the COMMITTED pins (no network) equals `lock.input_hash`
922/// and the lock describes the currently-pinned output. The caller still
923/// confirms the bytes exist in the bucket before skipping the build. Returns
924/// `None` (→ build normally) for source assets, no-transform assets, bootstrap
925/// rows, a missing lock, or any input drift.
926async fn lock_skip_hash(entry: &AssetEntry, workspace_root: &Path) -> Option<String> {
927 let derive = entry.derive.as_ref()?;
928 let transform = derive.transform.as_ref()?;
929 let lock = derive.lock.as_ref()?;
930 // A sentinel pin means nothing has been pinned yet → must build.
931 if is_bootstrap_sentinel(&entry.blake3.0) || is_bootstrap_sentinel(&derive.fetch.blake3.0) {
932 return None;
933 }
934 // The lock must describe the currently-pinned output, else it's stale.
935 if !hashes_equal(&lock.output_blake3, &entry.blake3.0) {
936 return None;
937 }
938 // Recompute the derivation key from the committed pins — purely local, no
939 // fetch. The fetch pin is the declared input identity (a fixed-output
940 // derivation), so in strict mode it equals the bytes that fed the build.
941 let recipe_bk = recipe_blake3(workspace_root, &transform.recipe).await?;
942 let key = derivation_key(&derive.fetch.blake3.0, &recipe_bk, &transform.params);
943 (key == lock.input_hash).then(|| entry.blake3.0.clone())
944}
945
946/// Resolve an `[[asset]]` row to an on-disk path the upload loop can read.
947///
948/// Two modes:
949///
950/// - **Legacy `source`**: returns `workload_dir.join(source_rel)` — bytes are
951/// already on disk, the rest of the pipeline is unchanged.
952/// - **Derive (`derive.fetch[+transform]`)**: fetches the upstream blob into
953/// `.yah/cache/derive/fetch/<upstream-blake3>.bin`, optionally runs the
954/// transform recipe into `.yah/cache/derive/transform/<output-blake3>.bin`,
955/// and returns whichever cache path holds the final bytes. The shape
956/// validator (`shape_static_asset`) guarantees exactly one of the two is
957/// set, so the `else` branch is total.
958///
959/// **Bootstrap mode.** When `derive.fetch.blake3` or `entry.blake3` is the
960/// zero sentinel, the reconciler discovers the actual hash instead of
961/// verifying against the pinned value. The discovered upstream hash is
962/// returned in `discovered_fetch_hash`; the discovered output hash is
963/// computed at the upload site (the bytes are read there anyway) and
964/// surfaced separately.
965async fn materialize_asset(
966 entry: &AssetEntry,
967 workspace_root: &Path,
968 workload_dir: &Path,
969 executor: &dyn ForgeExecutor,
970) -> Result<MaterializedAsset> {
971 if let Some(source_rel) = entry.source.as_ref() {
972 return Ok(MaterializedAsset {
973 path: workload_dir.join(source_rel),
974 discovered_fetch_hash: None,
975 derive_key: None,
976 });
977 }
978
979 let derive = entry
980 .derive
981 .as_ref()
982 .expect("AssetEntry shape: exactly one of source/derive must be set (shape_static_asset)");
983
984 let cache_root = workspace_root.join(".yah/cache/derive");
985 let fetch_bootstrap = is_bootstrap_sentinel(&derive.fetch.blake3.0);
986 let (fetched_path, fetched_hash) =
987 materialize_fetch(&derive.fetch, &cache_root.join("fetch")).await?;
988 let discovered_fetch_hash = fetch_bootstrap.then(|| fetched_hash.clone());
989
990 let path = if let Some(transform) = &derive.transform {
991 materialize_transform(
992 transform,
993 &fetched_path,
994 &fetched_hash,
995 &entry.blake3.0,
996 &cache_root.join("transform"),
997 workspace_root,
998 executor,
999 )
1000 .await?
1001 } else {
1002 // No transform — the fetched bytes ARE the output. In strict mode,
1003 // fetch.blake3 must equal entry.blake3; let the upload loop's BLAKE3
1004 // verify surface any mismatch. In bootstrap mode the upload loop
1005 // discovers entry.blake3 directly from the file bytes.
1006 fetched_path
1007 };
1008
1009 // W212/R518: derivation key for this build — the same value the local action
1010 // cache used (fetched-input hash ⊕ recipe-file hash ⊕ params). Surfaced so
1011 // the bind path can refresh `[asset.derive.lock].input_hash`.
1012 let derive_key = match &derive.transform {
1013 Some(transform) => recipe_blake3(workspace_root, &transform.recipe)
1014 .await
1015 .map(|rb| derivation_key(&fetched_hash, &rb, &transform.params)),
1016 None => None,
1017 };
1018
1019 Ok(MaterializedAsset {
1020 path,
1021 discovered_fetch_hash,
1022 derive_key,
1023 })
1024}
1025
1026/// Maximum number of fetch attempts (initial try + retries).
1027#[cfg(not(test))]
1028const FETCH_MAX_ATTEMPTS: u32 = 5;
1029#[cfg(test)]
1030const FETCH_MAX_ATTEMPTS: u32 = 3; // fewer retries in tests
1031
1032/// Initial backoff between retries in milliseconds (doubles each attempt).
1033#[cfg(not(test))]
1034const FETCH_BASE_DELAY_MS: u64 = 1_000;
1035#[cfg(test)]
1036const FETCH_BASE_DELAY_MS: u64 = 1; // near-instant in tests
1037
1038/// Ceiling on retry backoff in milliseconds.
1039#[cfg(not(test))]
1040const FETCH_MAX_DELAY_MS: u64 = 30_000;
1041#[cfg(test)]
1042const FETCH_MAX_DELAY_MS: u64 = 5; // near-instant in tests
1043
1044/// Log download progress every N bytes.
1045const FETCH_LOG_INTERVAL_BYTES: u64 = 100 * 1024 * 1024; // 100 MiB
1046
1047/// Outcome of a single download attempt that did not fully succeed. Used by
1048/// [`fetch_once`] to distinguish a transient failure (retry + Range resume)
1049/// from a non-retriable 4xx (bail immediately).
1050enum FetchOnceFail {
1051 /// Server returned a non-retriable 4xx. Caller should surface and stop.
1052 Fatal(reqwest::StatusCode),
1053 /// Network error or retriable server response (5xx / 429). Partial file
1054 /// is preserved on disk for Range-header resumption on the next attempt.
1055 Transient(anyhow::Error),
1056}
1057
1058/// Fetch `fetch.url` into `<cache_dir>/<fetch.blake3>.bin`, verifying BLAKE3.
1059///
1060/// On cache HIT the network is skipped entirely; a hash mismatch surfaces as a
1061/// hard error (bit-rot / hand-edited cache, not silently re-fetched).
1062///
1063/// Cache MISS downloads the blob with:
1064/// - **Streaming to disk** — `Response::chunk()` rather than buffering the full
1065/// body in RAM (required for multi-GB blobs like whisper-large at 1.5 GB).
1066/// - **Exponential-backoff retry** — up to [`FETCH_MAX_ATTEMPTS`] attempts on
1067/// transient network errors and 5xx / 429 HTTP responses.
1068/// - **Range-header resume** — if a `.partial` file exists from a prior attempt,
1069/// subsequent tries send `Range: bytes=<offset>-` to avoid re-downloading
1070/// already-received bytes.
1071/// - **Progress logging** — `info!()` every 100 MiB surfaces download progress
1072/// through the task-pane / QED log surface (W164 OQ#4).
1073async fn materialize_fetch(fetch: &FetchSource, cache_dir: &Path) -> Result<(PathBuf, String)> {
1074 let bootstrap = is_bootstrap_sentinel(&fetch.blake3.0);
1075
1076 tokio::fs::create_dir_all(cache_dir)
1077 .await
1078 .with_context(|| format!("creating fetch cache dir {}", cache_dir.display()))?;
1079
1080 // Cache HIT — only meaningful when we know the expected hash. Bootstrap
1081 // mode has no stable cache key to look up; it always re-downloads on the
1082 // first run, then strict mode (after the operator pins the discovered
1083 // hash) takes the HIT path on subsequent runs.
1084 if !bootstrap {
1085 let cache_path = cache_dir.join(format!("{}.bin", fetch.blake3.0));
1086 if tokio::fs::try_exists(&cache_path).await.unwrap_or(false) {
1087 verify_blake3_path(&cache_path, &fetch.blake3.0)
1088 .await
1089 .with_context(|| {
1090 format!(
1091 "fetch cache HIT for {} but bytes don't match pinned BLAKE3 — \
1092 rm the cache entry or fix the pin",
1093 cache_path.display()
1094 )
1095 })?;
1096 debug!(url = %fetch.url, "fetch cache HIT");
1097 return Ok((cache_path, fetch.blake3.0.clone()));
1098 }
1099 }
1100
1101 // Partial filename: stable per (URL, mode) so Range resume works across
1102 // attempts. In bootstrap mode every fetch shares the zero "expected" hash,
1103 // so partials are namespaced by URL hash to avoid cross-asset collisions.
1104 let partial_path = if bootstrap {
1105 cache_dir.join(format!(
1106 "bootstrap-{}.partial",
1107 blake3_hex(fetch.url.as_bytes())
1108 ))
1109 } else {
1110 cache_dir.join(format!("{}.partial", fetch.blake3.0))
1111 };
1112
1113 let client = reqwest::Client::new();
1114 let mut last_err = anyhow::anyhow!("no attempt made");
1115
1116 for attempt in 0..FETCH_MAX_ATTEMPTS {
1117 if attempt > 0 {
1118 let delay_ms = (FETCH_BASE_DELAY_MS << (attempt - 1)).min(FETCH_MAX_DELAY_MS);
1119 warn!(
1120 url = %fetch.url,
1121 attempt,
1122 delay_ms,
1123 err = %last_err,
1124 "fetch transient failure; retrying with backoff",
1125 );
1126 tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await;
1127 }
1128
1129 match fetch_once(&client, &fetch.url, &partial_path).await {
1130 Ok(()) => {
1131 // Compute hash once — used for verify (strict) and cache
1132 // naming (both modes; bootstrap mode names by the discovered
1133 // value rather than the zero placeholder).
1134 let bytes = tokio::fs::read(&partial_path)
1135 .await
1136 .with_context(|| format!("reading {} for BLAKE3", partial_path.display()))?;
1137 let actual = blake3_hex(&bytes);
1138 drop(bytes);
1139
1140 if !bootstrap && !hashes_equal(&actual, &fetch.blake3.0) {
1141 anyhow::bail!(
1142 "downloaded {} but BLAKE3 doesn't match pin — \
1143 check the pin in workload.toml (actual {actual} != expected {})",
1144 fetch.url,
1145 fetch.blake3.0,
1146 );
1147 }
1148
1149 let cache_path = cache_dir.join(format!("{actual}.bin"));
1150 tokio::fs::rename(&partial_path, &cache_path)
1151 .await
1152 .with_context(|| {
1153 format!(
1154 "promoting {} → {}",
1155 partial_path.display(),
1156 cache_path.display()
1157 )
1158 })?;
1159 if bootstrap {
1160 info!(url = %fetch.url, discovered = %actual, "fetch complete (bootstrap)");
1161 } else {
1162 info!(url = %fetch.url, "fetch complete");
1163 }
1164 return Ok((cache_path, actual));
1165 }
1166 Err(FetchOnceFail::Fatal(status)) => {
1167 anyhow::bail!("GET {} → HTTP {status} (not retriable)", fetch.url);
1168 }
1169 Err(FetchOnceFail::Transient(e)) => {
1170 last_err = e;
1171 }
1172 }
1173 }
1174
1175 Err(last_err).with_context(|| {
1176 format!(
1177 "GET {} failed after {FETCH_MAX_ATTEMPTS} attempts",
1178 fetch.url
1179 )
1180 })
1181}
1182
1183/// Single download attempt: GET `url` (with `Range` if `partial_path` is non-empty),
1184/// stream the response body to `partial_path`, and return once the body is exhausted.
1185///
1186/// Returns `Ok(())` when all bytes were received and flushed to disk.
1187/// Returns `Err(FetchOnceFail::Fatal)` for non-retriable 4xx responses.
1188/// Returns `Err(FetchOnceFail::Transient)` for connection errors, 5xx, or 429;
1189/// the partial file is left intact so the next attempt can resume via `Range`.
1190async fn fetch_once(
1191 client: &reqwest::Client,
1192 url: &str,
1193 partial_path: &Path,
1194) -> Result<(), FetchOnceFail> {
1195 use tokio::io::AsyncWriteExt;
1196
1197 let resume_offset = tokio::fs::metadata(partial_path)
1198 .await
1199 .ok()
1200 .map(|m| m.len())
1201 .filter(|&n| n > 0);
1202
1203 let mut req = client.get(url);
1204 if let Some(offset) = resume_offset {
1205 req = req.header(reqwest::header::RANGE, format!("bytes={offset}-"));
1206 info!(url, offset, "resuming partial download");
1207 } else {
1208 info!(url, "downloading");
1209 }
1210
1211 let resp = req
1212 .send()
1213 .await
1214 .map_err(|e| FetchOnceFail::Transient(anyhow::anyhow!("connecting to {url}: {e}")))?;
1215
1216 let status = resp.status();
1217 let is_partial_response = status == reqwest::StatusCode::PARTIAL_CONTENT;
1218
1219 if status == reqwest::StatusCode::RANGE_NOT_SATISFIABLE {
1220 // Partial file may already hold all bytes (prior complete-but-not-verified
1221 // attempt). Delete it and let the next retry start fresh.
1222 let _ = tokio::fs::remove_file(partial_path).await;
1223 return Err(FetchOnceFail::Transient(anyhow::anyhow!(
1224 "GET {url} → 416 Range Not Satisfiable; partial cleared"
1225 )));
1226 }
1227 if status.is_client_error() {
1228 return Err(FetchOnceFail::Fatal(status));
1229 }
1230 if status.is_server_error() || status == reqwest::StatusCode::TOO_MANY_REQUESTS {
1231 return Err(FetchOnceFail::Transient(anyhow::anyhow!(
1232 "GET {url} → HTTP {status}"
1233 )));
1234 }
1235 if !status.is_success() && !is_partial_response {
1236 return Err(FetchOnceFail::Fatal(status));
1237 }
1238
1239 // Server returned 200 instead of 206 — it ignored our Range request.
1240 // Discard any partial bytes and stream the full body fresh.
1241 if !is_partial_response && resume_offset.is_some() {
1242 let _ = tokio::fs::remove_file(partial_path).await;
1243 }
1244
1245 let mut file = tokio::fs::OpenOptions::new()
1246 .write(true)
1247 .create(true)
1248 .append(is_partial_response)
1249 .truncate(!is_partial_response)
1250 .open(partial_path)
1251 .await
1252 .map_err(|e| {
1253 FetchOnceFail::Transient(anyhow::anyhow!("opening {}: {e}", partial_path.display()))
1254 })?;
1255
1256 let mut downloaded = if is_partial_response {
1257 resume_offset.unwrap_or(0)
1258 } else {
1259 0
1260 };
1261 let mut resp = resp;
1262 loop {
1263 match resp.chunk().await {
1264 Ok(Some(chunk)) => {
1265 file.write_all(&chunk).await.map_err(|e| {
1266 FetchOnceFail::Transient(anyhow::anyhow!(
1267 "writing chunk to {}: {e}",
1268 partial_path.display()
1269 ))
1270 })?;
1271 let prev = downloaded;
1272 downloaded += chunk.len() as u64;
1273 if downloaded / FETCH_LOG_INTERVAL_BYTES > prev / FETCH_LOG_INTERVAL_BYTES {
1274 info!(
1275 url,
1276 downloaded_mib = downloaded / (1024 * 1024),
1277 "fetch progress",
1278 );
1279 }
1280 }
1281 Ok(None) => break,
1282 Err(e) => {
1283 return Err(FetchOnceFail::Transient(anyhow::anyhow!(
1284 "reading body from {url}: {e}"
1285 )));
1286 }
1287 }
1288 }
1289
1290 file.flush().await.map_err(|e| {
1291 FetchOnceFail::Transient(anyhow::anyhow!("flushing {}: {e}", partial_path.display()))
1292 })?;
1293
1294 Ok(())
1295}
1296
1297/// Run the transform recipe against `input_path`, writing the output to
1298/// `<cache_dir>/<actual_blake3>.bin`. In strict mode the actual hash is
1299/// verified against `output_blake3`; in bootstrap mode (`output_blake3` ==
1300/// zero sentinel) the actual hash is accepted as-is and named accordingly.
1301/// Cache HIT (file present + hash matches) skips recipe execution; bootstrap
1302/// mode has no stable cache key and always re-runs the recipe.
1303/// W212 schema version for the derivation key. Bump when the *way* we hash or
1304/// lower inputs changes (not when an individual recipe changes — that's
1305/// captured by the recipe-file hash). A bump invalidates every action-cache
1306/// entry, forcing a clean rebuild under the new keying.
1307const DERIVE_KEY_SCHEMA: u32 = 1;
1308
1309/// Compute the input-addressed action-cache key for a derive-mode transform
1310/// (W212). A pure function of the complete declared input set:
1311///
1312/// - `input_hash` — BLAKE3 of the fetched transform input (the fixed-output /
1313/// version anchor; for whisper this is `config.json`, which the model's repo
1314/// version tracks).
1315/// - `recipe_blake3` — BLAKE3 of the recipe TOML bytes. The file carries the
1316/// pinned container digest, the steps, and the placement, so hashing it
1317/// covers all three: a digest bump or a step edit flips the key.
1318/// - `params` — the workload-side invocation params (`BTreeMap` is already
1319/// sorted, so the encoding is deterministic).
1320///
1321/// **Hermeticity invariant:** every input that can change the output bytes MUST
1322/// feed this key. A missing dimension reintroduces the W164/R438 silent-stale
1323/// cache bug. The per-dimension unit test guards this.
1324fn derivation_key(
1325 input_hash: &str,
1326 recipe_blake3: &str,
1327 params: &BTreeMap<String, String>,
1328) -> String {
1329 let mut buf = String::new();
1330 buf.push_str(&format!("derive-key-v{DERIVE_KEY_SCHEMA}\n"));
1331 buf.push_str(&format!("input={input_hash}\n"));
1332 buf.push_str(&format!("recipe={recipe_blake3}\n"));
1333 for (k, v) in params {
1334 buf.push_str(&format!("param.{k}={v}\n"));
1335 }
1336 blake3_hex(buf.as_bytes())
1337}
1338
1339/// Read a recorded action-cache output hash, if present. A missing or
1340/// unreadable / empty entry is a cache miss, never an error.
1341async fn read_action_cache(ac_path: &Path) -> Option<String> {
1342 let raw = tokio::fs::read_to_string(ac_path).await.ok()?;
1343 let hash = raw.trim();
1344 (!hash.is_empty()).then(|| hash.to_string())
1345}
1346
1347/// Record an action-cache entry (`derive_key → output hash`). Written
1348/// atomically via tmp + rename so a crashed run never leaves a half-written
1349/// entry a later run would trust.
1350async fn write_action_cache(ac_path: &Path, output_hash: &str) -> Result<()> {
1351 if let Some(parent) = ac_path.parent() {
1352 tokio::fs::create_dir_all(parent)
1353 .await
1354 .with_context(|| format!("creating action-cache dir {}", parent.display()))?;
1355 }
1356 let tmp = ac_path.with_extension("out.tmp");
1357 tokio::fs::write(&tmp, format!("{output_hash}\n"))
1358 .await
1359 .with_context(|| format!("writing action-cache tmp {}", tmp.display()))?;
1360 tokio::fs::rename(&tmp, ac_path)
1361 .await
1362 .with_context(|| format!("promoting action-cache entry {}", ac_path.display()))?;
1363 Ok(())
1364}
1365
1366async fn materialize_transform(
1367 transform: &TransformSpec,
1368 input_path: &Path,
1369 input_hash: &str,
1370 output_blake3: &str,
1371 cache_dir: &Path,
1372 workspace_root: &Path,
1373 executor: &dyn ForgeExecutor,
1374) -> Result<PathBuf> {
1375 let bootstrap = is_bootstrap_sentinel(output_blake3);
1376
1377 tokio::fs::create_dir_all(cache_dir)
1378 .await
1379 .with_context(|| format!("creating transform cache dir {}", cache_dir.display()))?;
1380
1381 // Load + hash the recipe up front: the recipe TOML's bytes are an INPUT to
1382 // the derivation key (W212), so the key — and therefore the cache decision —
1383 // depends on them. The file carries the pinned container digest, steps, and
1384 // placement, so a change to any of those flips the key and forces a rebuild.
1385 let transforms_dir = workspace_root.join(".yah/qed/transforms");
1386 let loader = TransformRecipeLoader::new(&transforms_dir);
1387 let recipe_path = loader.recipe_path(&transform.recipe);
1388 let recipe = loader.load(&transform.recipe).with_context(|| {
1389 format!(
1390 "loading recipe {:?} from {}",
1391 transform.recipe,
1392 transforms_dir.display()
1393 )
1394 })?;
1395 let recipe_bytes = tokio::fs::read(&recipe_path).await.with_context(|| {
1396 format!(
1397 "reading recipe {} for derivation key",
1398 recipe_path.display()
1399 )
1400 })?;
1401 let recipe_blake3 = blake3_hex(&recipe_bytes);
1402
1403 // W212: input-addressed action cache. The skip decision is a pure function
1404 // of the declared INPUTS (fetched input content ⊕ recipe definition ⊕
1405 // invocation params ⊕ lowering-schema version), not of the expected output
1406 // hash. A recipe / digest / param change flips `derive_key` → guaranteed
1407 // miss → rebuild, eliminating the warm-cache silent-stale skip (the
1408 // W164/R438 defect this fixes). Applies in bootstrap mode too: identical
1409 // inputs surface the recorded output without re-running.
1410 let derive_key = derivation_key(input_hash, &recipe_blake3, &transform.params);
1411 let ac_path = cache_dir.join("ac").join(format!("{derive_key}.out"));
1412
1413 if let Some(recorded) = read_action_cache(&ac_path).await {
1414 let cas_path = cache_dir.join(format!("{recorded}.bin"));
1415 if tokio::fs::try_exists(&cas_path).await.unwrap_or(false)
1416 && verify_blake3_path(&cas_path, &recorded).await.is_ok()
1417 {
1418 // The output pin (`entry.blake3`) is still enforced downstream by
1419 // the upload loop's strict BLAKE3 verify, so a pin that has drifted
1420 // from the inputs surfaces there even though we skip the transform.
1421 debug!(recipe = %recipe.name, derive_key, "derivation cache HIT — skipping transform");
1422 return Ok(cas_path);
1423 }
1424 // AC entry present but the CAS bytes are gone / corrupt → fall through
1425 // and re-materialise (the AC records what the output should be; the CAS
1426 // lost the bytes).
1427 }
1428
1429 // Absolute workspace_root for the container bind-mount — docker rejects
1430 // `-w .` ("the working directory '.' is invalid"). Callers may pass a
1431 // relative path (the CLI `--path` default is "."), so canonicalize once.
1432 let workspace_abs = workspace_root
1433 .canonicalize()
1434 .with_context(|| format!("canonicalizing workspace root {}", workspace_root.display()))?;
1435
1436 // R546-B8: the IN/OUT bindings handed to the recipe MUST be absolute.
1437 // `cache_dir` is derived from workspace_root, which is "." by CLI default,
1438 // so these used to be relative (`./.yah/cache/derive/transform/<key>.tmp`).
1439 // A relative path only survives if the recipe never leaves its cwd — true
1440 // for whisper's recipes, FALSE for rusty-v8: build-v8.sh chdirs into a
1441 // scratch dir (/tmp/tmp.XXXX/v8src) to build V8, so it wrote its finished
1442 // tar to "<scratch>/./.yah/cache/..." INSIDE the container, invisible to the
1443 // host. The step then exited 0 and the reconciler failed reading a file that
1444 // was never going to be there — after a ~2h build. Canonicalize the parent
1445 // (it exists; created above) and join the filename, since the tmp output
1446 // itself does not exist yet and cannot be canonicalized directly.
1447 let cache_dir_abs = cache_dir
1448 .canonicalize()
1449 .with_context(|| format!("canonicalizing transform cache dir {}", cache_dir.display()))?;
1450
1451 // Tmp output is named by the derivation key — unique per input set, so
1452 // concurrent rows (and zero-sentinel bootstrap rows) never collide on it.
1453 let tmp_output = cache_dir_abs.join(format!("{derive_key}.tmp"));
1454
1455 let input_abs = input_path
1456 .canonicalize()
1457 .unwrap_or_else(|_| input_path.to_path_buf());
1458
1459 // R555-F3: a remotely-placed recipe runs on a worker with its own
1460 // filesystem, so the IN/OUT contract has to be re-read there. `remote_out`
1461 // is the worker-side path bound as {{YAH_TRANSFORM_OUT}}; the bytes are
1462 // pulled back to `tmp_output` after the step, and everything downstream
1463 // (exists-check, BLAKE3, rename, action cache) is untouched.
1464 let remote_out = match &recipe.placement.location {
1465 TaskLocation::Local => {
1466 refuse_secrets_on_a_local_recipe(&recipe)?;
1467 None
1468 }
1469 _ => Some(remote_transform_out(&recipe, &derive_key)?),
1470 };
1471
1472 let mut params: BTreeMap<String, String> = BTreeMap::new();
1473 params.insert(
1474 ENV_TRANSFORM_IN_0.to_string(),
1475 input_abs.to_string_lossy().into_owned(),
1476 );
1477 params.insert(
1478 ENV_TRANSFORM_OUT.to_string(),
1479 remote_out
1480 .as_ref()
1481 .unwrap_or(&tmp_output)
1482 .to_string_lossy()
1483 .into_owned(),
1484 );
1485 for (k, v) in &transform.params {
1486 params.insert(k.clone(), v.clone());
1487 }
1488
1489 info!(recipe = %recipe.name, "transform cache MISS, running");
1490 for step in &recipe.steps {
1491 let argv = substitute_argv(&step.argv, ¶ms);
1492 // substitute_argv preserves `{{key}}` for unknown keys (no shell, no
1493 // string concat). Surface a missing-binding bug as a hard error rather
1494 // than letting the subprocess receive a literal placeholder.
1495 if let Some(unresolved) = argv.iter().find(|a| a.contains("{{")) {
1496 anyhow::bail!(
1497 "recipe {:?} step {:?}: unresolved placeholder in argv element {:?}",
1498 recipe.name,
1499 step.name,
1500 unresolved,
1501 );
1502 }
1503 // R555-T2 opened the recipe surface to `remote` / `remote_any`, so the
1504 // lowered spec carries whatever the recipe declared and reaches the
1505 // matching driver through the injected router (R555-F3). The two
1506 // placements need different execution context, and the difference is
1507 // invisible at the type level — hence the split below rather than one
1508 // shared `ctx`.
1509 let spec = lower_recipe_step_to_forge_spec(&recipe, step, argv);
1510 let mut ctx = ExecContext::default();
1511 if let Some(remote_out) = &remote_out {
1512 // NO cwd: `workspace_abs` is a path on THIS box. The local driver
1513 // bind-mounts it; a remote workdir is just a string interpreted on
1514 // the worker, where that path doesn't exist. A recipe needing a
1515 // source tree on the worker must bring it (the rusty-v8 builder
1516 // image clones its own).
1517 ctx = ctx.with_produced(remote_out.clone(), tmp_output.clone());
1518 // R555-F4 / W235 §(c): a signed recipe carries its admission grant
1519 // to kamaji. The grant is DERIVED from the recipe here rather than
1520 // stored in the TOML, so editing what a recipe runs un-signs it;
1521 // `None` for an unsigned recipe, which a node running the default
1522 // `permissive` policy still admits.
1523 if let Some(envelope) =
1524 velveteen_exec::envelope_for_recipe_step(&recipe, step)
1525 .with_context(|| format!("deriving admission grant for recipe {:?}", recipe.name))?
1526 {
1527 ctx = ctx.with_admission(envelope);
1528 }
1529 // R555-F5: the credentials the recipe declared, mounted per run.
1530 // Only ever on the remote leg — the local driver refuses them,
1531 // because a dev box has no cluster secret store to resolve them
1532 // from and a silently-dropped credential fails deep inside the
1533 // build instead of here.
1534 if !recipe.secrets.is_empty() {
1535 ctx = ctx
1536 .with_secrets(recipe.secrets.iter().map(|s| s.to_mount()).collect());
1537 }
1538 } else {
1539 ctx = ctx.with_cwd(workspace_abs.clone());
1540 // `platform` asks a HOST container runtime for foreign-arch
1541 // emulation. It has no remote referent — a remote run picks
1542 // architecture by scheduling (mesh_tags) — and RemoteForgeDriver
1543 // refuses it rather than handing back a wrong-arch artifact, so it
1544 // is only ever applied on the local leg.
1545 if let Some(platform) = &recipe.placement.platform {
1546 ctx = ctx.with_platform(platform.clone());
1547 }
1548 }
1549 let outcome = executor
1550 .execute(spec, ctx, None)
1551 .await
1552 .with_context(|| format!("executing recipe {:?} step {:?}", recipe.name, step.name))?;
1553 if !outcome.succeeded() {
1554 anyhow::bail!(
1555 "recipe {:?} step {:?} failed ({}): {}",
1556 recipe.name,
1557 step.name,
1558 outcome.status.discriminant(),
1559 outcome.stderr_tail
1560 );
1561 }
1562 }
1563
1564 // R546-B8: every step exited 0, so if the output is missing the recipe
1565 // simply never wrote it. Say THAT, rather than letting the `read` below
1566 // surface `No such file or directory` on a cache path — which reads like
1567 // cache corruption and sent the last person debugging the wrong subsystem
1568 // after a ~2h build. The relative-OUT bug that caused it is fixed above,
1569 // but a recipe can still write to the wrong place on its own, and this is
1570 // the only moment we can tell the operator exactly which contract broke.
1571 if !tokio::fs::try_exists(&tmp_output).await.unwrap_or(false) {
1572 anyhow::bail!(
1573 "recipe {:?} completed successfully but produced no output at {} \
1574 ({ENV_TRANSFORM_OUT}). The recipe's last step must write its artifact \
1575 to that exact path — check that it doesn't chdir and then write to a \
1576 relative location, and that it isn't writing to a directory instead \
1577 of a file.",
1578 recipe.name,
1579 // A remote run was told a worker-side path; naming `tmp_output` (a
1580 // local path it never saw) would send the reader hunting for a
1581 // binding bug that isn't there.
1582 remote_out.as_ref().unwrap_or(&tmp_output).display(),
1583 );
1584 }
1585
1586 // Compute the actual hash once — used for verify (strict) and cache
1587 // naming (both modes). Bootstrap mode names by the discovered value;
1588 // strict mode names by the expected value (which equals actual after
1589 // the verify below).
1590 let output_bytes = tokio::fs::read(&tmp_output).await.with_context(|| {
1591 format!(
1592 "reading transform output {} for BLAKE3",
1593 tmp_output.display()
1594 )
1595 })?;
1596 let actual = blake3_hex(&output_bytes);
1597 drop(output_bytes);
1598
1599 if !bootstrap && !hashes_equal(&actual, output_blake3) {
1600 anyhow::bail!(
1601 "transform output from recipe {:?} doesn't match pinned BLAKE3 \
1602 (actual {actual} != expected {output_blake3})",
1603 recipe.name
1604 );
1605 }
1606
1607 // tmp_output already lives in cache_dir — atomic publish is one rename.
1608 let cache_path = cache_dir.join(format!("{actual}.bin"));
1609 tokio::fs::rename(&tmp_output, &cache_path)
1610 .await
1611 .with_context(|| {
1612 format!(
1613 "renaming {} → {}",
1614 tmp_output.display(),
1615 cache_path.display()
1616 )
1617 })?;
1618
1619 // W212: record the action-cache entry (derive_key → output hash) so the
1620 // next run with identical inputs skips this transform entirely.
1621 write_action_cache(&ac_path, &actual)
1622 .await
1623 .with_context(|| format!("recording action-cache entry {}", ac_path.display()))?;
1624
1625 if bootstrap {
1626 info!(
1627 recipe = %recipe.name,
1628 discovered = %actual,
1629 "transform complete (bootstrap)",
1630 );
1631 }
1632
1633 Ok(cache_path)
1634}
1635
1636/// State of the committed `[asset.derive.fetch].blake3` pin, relative to the
1637/// fetched-input hash a seed run actually keyed its derivation on (R546-B6).
1638///
1639/// The W212 substituter fast-path ([`lock_skip_hash`]) recomputes the derivation
1640/// key from the COMMITTED pins with no network access, so it bails immediately
1641/// when the fetch pin is still the zero sentinel. Seeding a lock without also
1642/// pinning the fetch input therefore leaves that fast-path disarmed for every
1643/// machine except the one that seeded (whose local action cache masks it).
1644#[derive(Debug, Clone, PartialEq, Eq)]
1645pub enum FetchPinState {
1646 /// Committed pin equals the fetched-input hash — the fast-path can engage.
1647 Pinned,
1648 /// Committed pin is the 64-zero sentinel — the fast-path is disarmed until
1649 /// the operator pastes the fetched-input hash back into the workload.
1650 Sentinel,
1651 /// Committed pin names a DIFFERENT blob than the one this seed fetched.
1652 /// Either the version anchor moved or the pin is wrong; the fast-path will
1653 /// silently decline (input drift) until they agree.
1654 Mismatch { committed: String },
1655}
1656
1657/// Classify the committed `[asset.derive.fetch].blake3` against the hash the
1658/// fetch actually resolved to (R546-B6). Pure so the three arms are testable
1659/// without a network fetch.
1660fn classify_fetch_pin(committed: &str, fetched: &str) -> FetchPinState {
1661 if is_bootstrap_sentinel(committed) {
1662 FetchPinState::Sentinel
1663 } else if hashes_equal(committed, fetched) {
1664 FetchPinState::Pinned
1665 } else {
1666 FetchPinState::Mismatch {
1667 committed: committed.to_string(),
1668 }
1669 }
1670}
1671
1672/// Outcome of seeding the transform derivation cache from a pre-built artifact
1673/// (the qed→W164 bridge, R546-T3).
1674#[derive(Debug, Clone)]
1675pub struct SeededDerivation {
1676 /// The W212 derivation key the seeded action-cache entry is filed under —
1677 /// byte-identical to the one [`materialize_transform`] will look up, so the
1678 /// next `yah cloud apply` HITs it. Equals the value to paste into
1679 /// `[asset.derive.lock].input_hash`.
1680 pub derive_key: String,
1681 /// BLAKE3 of the pre-built artifact — the transform output hash. Equals the
1682 /// value to paste into `[[asset]].blake3` and `[asset.derive.lock].output_blake3`.
1683 pub output_blake3: String,
1684 /// BLAKE3 of the fetched *input* the derivation key was keyed on — the
1685 /// fourth paste-back value, `[asset.derive.fetch].blake3` (R546-B6). Without
1686 /// it the seeded lock is unusable off the seeding machine.
1687 pub fetch_blake3: String,
1688 /// What the workload currently commits for that pin, when the caller went
1689 /// through [`seed_derivation_for_target`] (which has the `[[asset]]` row in
1690 /// hand). `None` for direct [`seed_transform_derivation`] callers.
1691 pub fetch_pin: Option<FetchPinState>,
1692 /// The content-addressed store path the artifact was landed at.
1693 pub cas_path: PathBuf,
1694}
1695
1696/// Seed the transform derivation cache (AC + CAS) from an artifact built OUT OF
1697/// BAND — the qed→W164 bridge (R546-T3).
1698///
1699/// # Why this exists
1700///
1701/// The static-asset reconciler lowers every transform to
1702/// [`TaskLocation::Local`](task::TaskLocation::Local) (see
1703/// [`lower_recipe_step_to_forge_spec`]) — it has no fleet-offload path, so on a
1704/// foreign-arch host it can only build the recipe under emulation (which OOMs for
1705/// the rusty_v8 musl build, the reason R546 exists). `yah qed run <pipeline>`
1706/// (e.g. rusty-v8-musl) DOES offload to an arch-matched build-worker and, via R590-F6,
1707/// retrieves the produced tar content-addressed onto the caller. This function
1708/// bridges that pre-built tar into the W164 substituter: it writes the exact
1709/// action-cache + CAS entries [`materialize_transform`] would have written, so
1710/// the next `yah cloud apply` finds a HIT and PUBLISHES the pre-built bytes to
1711/// the consumer key + records the lock, instead of re-running the transform.
1712///
1713/// # Hermeticity
1714///
1715/// The derivation key is computed with the reconciler's OWN [`derivation_key`] +
1716/// [`recipe_blake3`], keyed on the SAME `fetched_hash` the reconciler derives
1717/// from the version anchor. Reusing those (not reimplementing) is what makes the
1718/// seeded key byte-identical to the lookup key — a drift would reintroduce the
1719/// W164/R438 silent-stale-cache bug. `fetched_hash` MUST be the value
1720/// [`materialize_fetch`] returns for `derive.fetch` (see [`seed_derivation_for_target`]).
1721pub async fn seed_transform_derivation(
1722 workspace_root: &Path,
1723 transform: &TransformSpec,
1724 fetched_hash: &str,
1725 artifact_path: &Path,
1726) -> Result<SeededDerivation> {
1727 let recipe_bk = recipe_blake3(workspace_root, &transform.recipe)
1728 .await
1729 .ok_or_else(|| {
1730 anyhow::anyhow!(
1731 "recipe {:?} unreadable under .yah/qed/transforms — cannot compute derivation key",
1732 transform.recipe,
1733 )
1734 })?;
1735 let derive_key = derivation_key(fetched_hash, &recipe_bk, &transform.params);
1736
1737 let bytes = tokio::fs::read(artifact_path)
1738 .await
1739 .with_context(|| format!("reading pre-built artifact {}", artifact_path.display()))?;
1740 let output_blake3 = blake3_hex(&bytes);
1741
1742 let cache_dir = workspace_root.join(".yah/cache/derive/transform");
1743 tokio::fs::create_dir_all(&cache_dir)
1744 .await
1745 .with_context(|| format!("creating transform cache dir {}", cache_dir.display()))?;
1746
1747 // CAS: <cache_dir>/<output_blake3>.bin — the exact name materialize_transform
1748 // renames its output to (so its HIT-path verify_blake3_path passes). Atomic
1749 // tmp+rename; idempotent when the entry already exists.
1750 let cas_path = cache_dir.join(format!("{output_blake3}.bin"));
1751 if !tokio::fs::try_exists(&cas_path).await.unwrap_or(false) {
1752 let tmp = cache_dir.join(format!("{output_blake3}.seed.tmp"));
1753 tokio::fs::write(&tmp, &bytes)
1754 .await
1755 .with_context(|| format!("writing CAS tmp {}", tmp.display()))?;
1756 tokio::fs::rename(&tmp, &cas_path)
1757 .await
1758 .with_context(|| format!("promoting CAS entry {}", cas_path.display()))?;
1759 }
1760
1761 // Action cache: <cache_dir>/ac/<derive_key>.out = <output_blake3>. Reuses the
1762 // reconciler's writer so the on-disk format can't drift from the reader.
1763 let ac_path = cache_dir.join("ac").join(format!("{derive_key}.out"));
1764 write_action_cache(&ac_path, &output_blake3)
1765 .await
1766 .with_context(|| format!("recording seeded action-cache entry {}", ac_path.display()))?;
1767
1768 info!(
1769 recipe = %transform.recipe,
1770 derive_key,
1771 output_blake3,
1772 fetch_blake3 = fetched_hash,
1773 "seeded transform derivation cache from pre-built artifact (qed→W164 bridge)",
1774 );
1775
1776 Ok(SeededDerivation {
1777 derive_key,
1778 output_blake3,
1779 fetch_blake3: fetched_hash.to_string(),
1780 fetch_pin: None,
1781 cas_path,
1782 })
1783}
1784
1785/// CLI-facing entry for the qed→W164 bridge (R546-T3): seed the derivation cache
1786/// for the `[[asset]]` row whose `derive.transform.params["target"]` equals
1787/// `target`, from a pre-built `artifact_path`.
1788///
1789/// Loads the static-asset workload, resolves the matching asset row, computes the
1790/// fetched-input hash via the reconciler's own [`materialize_fetch`] (downloading
1791/// the version anchor once; cached thereafter), then delegates to
1792/// [`seed_transform_derivation`]. After this returns, `yah cloud apply` on the
1793/// same service publishes the pre-built bytes without re-running the transform.
1794pub async fn seed_derivation_for_target(
1795 workspace_root: &Path,
1796 workload_path: &Path,
1797 target: &str,
1798 artifact_path: &Path,
1799) -> Result<SeededDerivation> {
1800 let raw = tokio::fs::read_to_string(workload_path)
1801 .await
1802 .with_context(|| format!("reading workload {}", workload_path.display()))?;
1803 let workload: StaticAssetWorkload = toml::from_str(&raw)
1804 .with_context(|| format!("parsing static-asset workload {}", workload_path.display()))?;
1805
1806 let entry = workload
1807 .assets
1808 .iter()
1809 .find(|a| {
1810 a.derive
1811 .as_ref()
1812 .and_then(|d| d.transform.as_ref())
1813 .map(|t| t.params.get("target").map(String::as_str) == Some(target))
1814 .unwrap_or(false)
1815 })
1816 .ok_or_else(|| {
1817 anyhow::anyhow!(
1818 "no [[asset]] with derive.transform.params.target = {target:?} in {}",
1819 workload_path.display(),
1820 )
1821 })?;
1822
1823 let derive = entry
1824 .derive
1825 .as_ref()
1826 .expect("asset matched by derive.transform above");
1827 let transform = derive
1828 .transform
1829 .as_ref()
1830 .expect("asset matched by derive.transform above");
1831
1832 // Recompute the fetched-input hash exactly as the reconciler will — same
1833 // fetch source, same cache dir.
1834 let cache_root = workspace_root.join(".yah/cache/derive");
1835 let (_fetched_path, fetched_hash) =
1836 materialize_fetch(&derive.fetch, &cache_root.join("fetch")).await?;
1837
1838 // R546-B6: classify the COMMITTED fetch pin against what we just fetched, so
1839 // the caller can tell the operator whether the W212 fast-path is armed. A
1840 // seeded lock with a sentinel fetch pin skips builds only on this machine
1841 // (via the local action cache) — everywhere else `lock_skip_hash` declines.
1842 let fetch_pin = classify_fetch_pin(&derive.fetch.blake3.0, &fetched_hash);
1843
1844 let mut seeded =
1845 seed_transform_derivation(workspace_root, transform, &fetched_hash, artifact_path).await?;
1846 seeded.fetch_pin = Some(fetch_pin);
1847 Ok(seeded)
1848}
1849
1850/// Lower a single recipe step to a [`ForgeSpec`] (W164).
1851///
1852/// - `image` is always `Some(recipe.image)` — recipes always run inside the
1853/// pinned container.
1854/// - `where_` mirrors `recipe.placement` straight through — both the
1855/// recipe-declared location and runtime. Until W235 this hard-coded
1856/// `TaskLocation::Local` because `RecipeLocation` had no other variant;
1857/// R555-T2 opened the recipe surface to `remote` / `remote_any`, so pinning
1858/// here would have quietly demoted every remote recipe back to the dev box.
1859/// - `timeout=0` in the recipe means "no timeout" (omitted from the spec).
1860/// - `label = "transform:<recipe>:<step>"`; initiator carries the reconciler
1861/// identity in the Gnome variant so audit traces attribute the run.
1862///
1863/// Pure function: callers feed it the already-substituted argv (or the raw
1864/// one in tests) and decide what to do with the resulting spec. Exposed at
1865/// `pub(crate)` for golden-test parity with the BuildMode lowering helper
1866/// (R438-T7).
1867pub(crate) fn lower_recipe_step_to_forge_spec(
1868 recipe: &TransformRecipe,
1869 step: &RecipeStep,
1870 substituted_argv: Vec<String>,
1871) -> ForgeSpec {
1872 ForgeSpec {
1873 command: ForgeCommand::Subprocess {
1874 argv: substituted_argv,
1875 image: Some(recipe.image.clone()),
1876 },
1877 where_: TaskPlacement::new(recipe.placement.location.clone(), recipe.placement.runtime),
1878 timeout: if step.timeout == 0 {
1879 None
1880 } else {
1881 Some(Millis::from_secs(step.timeout))
1882 },
1883 label: Some(format!("transform:{}:{}", recipe.name, step.name)),
1884 initiator: Initiator::Gnome {
1885 camp: "static-asset-reconciler".into(),
1886 shift: format!("derive-{}", recipe.name),
1887 },
1888 mesh_access: MeshAccess::default(),
1889 }
1890}
1891
1892/// Validate a remotely-placed transform recipe and return the worker-side path
1893/// its output must be written to (R555-F3).
1894///
1895/// Three things a local recipe may do that a remote one may not, each refused
1896/// here rather than at the point it would produce a wrong artifact:
1897///
1898/// 1. **Reference `{{YAH_TRANSFORM_IN_0}}`.** The fetched input lives on this
1899/// box. [`velveteen_exec::WardenClient`] has a retrieval leg
1900/// (`fetch_produced_file`) and no upload leg, so there is no transport that
1901/// puts those bytes on the worker. Substituting the host path would hand
1902/// the recipe a path that doesn't resolve there — and the recipes that
1903/// ignore IN_0 entirely (rusty-v8-musl, whisper-bundle-tar drive their own
1904/// source) are exactly the ones worth dispatching, so this is a real
1905/// boundary rather than a stopgap.
1906/// 2. **Declare more than one step.** Each step is a separate one-shot
1907/// workload with its own container and its own produced dir — locally the
1908/// steps share a filesystem, remotely they share nothing. A two-step remote
1909/// recipe would silently lose whatever step 1 wrote.
1910/// 3. **Declare `[placement] platform`.** That asks a *host* container runtime
1911/// for foreign-arch emulation; a yubaba node has no such knob and picks
1912/// architecture by scheduling instead. `RemoteForgeDriver` refuses it too —
1913/// this refusal exists so the message names the recipe (R555-T7 is the
1914/// ticket that collapses the per-arch recipe fork onto `mesh_tags`).
1915fn remote_transform_out(recipe: &TransformRecipe, derive_key: &str) -> Result<PathBuf> {
1916 if recipe.steps.len() > 1 {
1917 anyhow::bail!(
1918 "recipe {:?} declares {} steps and a remote placement. Each step dispatches as \
1919 its own one-shot workload with its own container and produced dir, so steps \
1920 cannot hand files to each other the way they do locally — collapse them into \
1921 one step (the builder image is the right place for the sequencing), or run \
1922 the recipe locally",
1923 recipe.name,
1924 recipe.steps.len(),
1925 );
1926 }
1927 if let Some(platform) = &recipe.placement.platform {
1928 anyhow::bail!(
1929 "recipe {:?} declares [placement] platform = {:?} together with a remote \
1930 location. `platform` asks a host container runtime for foreign-arch \
1931 emulation; a remote node has no such knob and selects architecture by \
1932 scheduling instead. Drop it and express the arch as mesh tags, e.g. \
1933 location = {{ kind = \"remote_any\", tier = \"infra\", mesh_tags = \
1934 [\"tag:build-worker\", \"arch:x86\", \"os:linux\"] }}",
1935 recipe.name,
1936 platform,
1937 );
1938 }
1939 let in_0 = format!("{{{{{ENV_TRANSFORM_IN_0}}}}}");
1940 for step in &recipe.steps {
1941 if step.argv.iter().any(|a| a.contains(&in_0)) {
1942 anyhow::bail!(
1943 "recipe {:?} step {:?} references {} but is placed remotely. The fetched \
1944 input lives on this machine and there is no upload leg to the worker \
1945 (WardenClient can retrieve produced files, not send inputs), so the \
1946 binding would name a path that does not exist on the node. Either have \
1947 the recipe fetch its own input, or run it locally",
1948 recipe.name,
1949 step.name,
1950 in_0,
1951 );
1952 }
1953 }
1954 Ok(PathBuf::from(workload_spec::forge_produced::CONTAINER_DIR).join(format!("{derive_key}.out")))
1955}
1956
1957/// The mirror of [`remote_transform_out`]'s refusals, for the local leg: one
1958/// thing a remote recipe may do that a local one may not (R555-F5).
1959///
1960/// Vault credentials are resolved by yubaba on the node that runs the workload,
1961/// against the cluster secret store and that node's KEK. A local run has no such
1962/// node, so `LocalForgeDriver` refuses `ExecContext::secrets` outright. Refused
1963/// here as well so the message names the *recipe* — a `location = "local"`
1964/// recipe with a `[[secrets]]` block is a mistake in the recipe, and a driver
1965/// error names only the dispatch.
1966fn refuse_secrets_on_a_local_recipe(recipe: &TransformRecipe) -> Result<()> {
1967 if !recipe.secrets.is_empty() {
1968 anyhow::bail!(
1969 "recipe {:?} declares {} [[secrets]] entr{} but is placed locally. Vault \
1970 credentials are resolved by yubaba on the node that runs the workload, \
1971 and a local run has no such node — give the recipe a remote placement \
1972 (location = {{ kind = \"remote_any\", … }}), or drop the secret and have \
1973 the step read its credential from the environment it already runs in \
1974 (W235 §(c) / R555-F5)",
1975 recipe.name,
1976 recipe.secrets.len(),
1977 if recipe.secrets.len() == 1 { "y" } else { "ies" },
1978 );
1979 }
1980 Ok(())
1981}
1982
1983/// Read `path` and assert its BLAKE3 hex matches `expected_hex` (case-insensitive).
1984async fn verify_blake3_path(path: &Path, expected_hex: &str) -> Result<()> {
1985 let bytes = tokio::fs::read(path)
1986 .await
1987 .with_context(|| format!("reading {} for BLAKE3 verify", path.display()))?;
1988 let actual = blake3_hex(&bytes);
1989 if !hashes_equal(&actual, expected_hex) {
1990 anyhow::bail!(
1991 "BLAKE3 mismatch for {}: actual {} != expected {}",
1992 path.display(),
1993 actual,
1994 expected_hex,
1995 );
1996 }
1997 Ok(())
1998}
1999
2000// ── Manifest helpers ──────────────────────────────────────────────────────────
2001
2002async fn load_catalog_manifest(
2003 client: &reqwest::Client,
2004 endpoint: &str,
2005 bucket: &str,
2006 region: &str,
2007 access_key: &str,
2008 secret_key: &str,
2009) -> CatalogManifest {
2010 let url = format!("{endpoint}/{bucket}/{CATALOG_MANIFEST_KEY}");
2011 let Ok(headers) = sign_s3_empty_body("GET", &url, region, access_key, secret_key) else {
2012 return HashMap::new();
2013 };
2014 let Ok(resp) = client.get(&url).headers(headers).send().await else {
2015 return HashMap::new();
2016 };
2017 if !resp.status().is_success() {
2018 return HashMap::new();
2019 }
2020 let Ok(bytes) = resp.bytes().await else {
2021 return HashMap::new();
2022 };
2023 serde_json::from_slice(&bytes).unwrap_or_default()
2024}
2025
2026async fn save_catalog_manifest(
2027 client: &reqwest::Client,
2028 endpoint: &str,
2029 bucket: &str,
2030 manifest: &CatalogManifest,
2031 region: &str,
2032 access_key: &str,
2033 secret_key: &str,
2034) -> Result<()> {
2035 let body = serde_json::to_vec(manifest).context("serializing catalog manifest")?;
2036 let body_sha256 = sha256_hex(&body);
2037 let url = format!("{endpoint}/{bucket}/{CATALOG_MANIFEST_KEY}");
2038 let headers = sign_s3_put_object(
2039 &url,
2040 &body_sha256,
2041 "application/json",
2042 body.len(),
2043 region,
2044 access_key,
2045 secret_key,
2046 // The manifest sidecar is rewritten every run by definition — stamping it
2047 // would be noise, and nothing skips a PUT on it (R546-B10).
2048 None,
2049 )
2050 .context("signing manifest PUT")?;
2051 let resp = client
2052 .put(&url)
2053 .headers(headers)
2054 .body(body)
2055 .send()
2056 .await
2057 .context("PUT catalog manifest")?;
2058 if !resp.status().is_success() {
2059 let status = resp.status();
2060 let body_text = resp.text().await.unwrap_or_default();
2061 anyhow::bail!("PUT {url} → {status}: {}", body_text.trim());
2062 }
2063 Ok(())
2064}
2065
2066// ── S3 helpers ────────────────────────────────────────────────────────────────
2067
2068/// What a HEAD probe found at an object key (R546-B10).
2069///
2070/// The distinction that matters is *not* present-vs-absent but whether the
2071/// present bytes are the ones we are about to publish. `Present { blake3: None }`
2072/// is an object written before we started stamping `x-amz-meta-blake3` — its
2073/// content is unknowable from a HEAD, so callers must treat it as "might be
2074/// stale" rather than "matches".
2075#[derive(Debug, Clone, PartialEq, Eq)]
2076enum RemoteObject {
2077 Absent,
2078 Present { blake3: Option<String> },
2079}
2080
2081impl RemoteObject {
2082 /// True only when the remote object is known to hold exactly `expected`.
2083 /// Unknown provenance (no metadata) is deliberately NOT a match.
2084 fn matches(&self, expected: &str) -> bool {
2085 matches!(self, RemoteObject::Present { blake3: Some(b3) } if hashes_equal(b3, expected))
2086 }
2087
2088 fn exists(&self) -> bool {
2089 matches!(self, RemoteObject::Present { .. })
2090 }
2091}
2092
2093/// HEAD an object key and report existence *plus* the recorded BLAKE3.
2094///
2095/// R546-B10: the previous `object_exists` returned a bare bool, so the publish
2096/// loop could not tell "same bytes already there" from "different bytes already
2097/// there" and skipped the PUT for both — leaving stale content in the bucket
2098/// while the catalog manifest advertised the new hash. We read our own
2099/// `x-amz-meta-blake3` stamp rather than the ETag, because ETag is only an MD5
2100/// of the content for single-part uploads; for multipart it is a digest-of-
2101/// digests and comparing it to a content hash is simply wrong.
2102async fn head_object(
2103 client: &reqwest::Client,
2104 url: &str,
2105 region: &str,
2106 access_key: &str,
2107 secret_key: &str,
2108) -> Result<RemoteObject> {
2109 let headers = sign_s3_empty_body("HEAD", url, region, access_key, secret_key)
2110 .with_context(|| format!("signing HEAD {url}"))?;
2111 let resp = client
2112 .head(url)
2113 .headers(headers)
2114 .send()
2115 .await
2116 .with_context(|| format!("HEAD {url}"))?;
2117 if !resp.status().is_success() {
2118 return Ok(RemoteObject::Absent);
2119 }
2120 let blake3 = resp
2121 .headers()
2122 .get("x-amz-meta-blake3")
2123 .and_then(|v| v.to_str().ok())
2124 .map(str::to_string);
2125 Ok(RemoteObject::Present { blake3 })
2126}
2127
2128// ── Crypto helpers ────────────────────────────────────────────────────────────
2129
2130fn blake3_hex(body: &[u8]) -> String {
2131 hex::encode(blake3::hash(body).as_bytes())
2132}
2133
2134fn sha256_hex(body: &[u8]) -> String {
2135 hex::encode(Sha256::digest(body))
2136}
2137
2138/// Case-insensitive hex comparison (BLAKE3 crate outputs lowercase; stored
2139/// hashes may have been authored in uppercase).
2140fn hashes_equal(a: &str, b: &str) -> bool {
2141 a.eq_ignore_ascii_case(b)
2142}
2143
2144// ── Content-type ──────────────────────────────────────────────────────────────
2145
2146fn content_type_for(path: &Path) -> &'static str {
2147 match path.extension().and_then(|e| e.to_str()).unwrap_or("") {
2148 "bin" => "application/octet-stream",
2149 "json" => "application/json",
2150 "txt" => "text/plain; charset=utf-8",
2151 "wasm" => "application/wasm",
2152 _ => "application/octet-stream",
2153 }
2154}
2155
2156// ── Bucket auto-create ────────────────────────────────────────────────────────
2157
2158/// List R2 buckets under `account_id`; create `bucket_name` only when absent.
2159///
2160/// Takes the management `api_token` already resolved from the service's
2161/// provider (see [`super::cf_creds::CfProvider`]). Mirrors the idempotent pattern in
2162/// `cloudflare_worker.rs::ensure_r2_bucket` — list-first keeps the reconcile
2163/// loop safe to re-run without depending on CF's 4xx error shape on duplicate
2164/// create. R422-T12: lets the first `yah cloud apply --env cloud --service <s>`
2165/// against a static-asset mirror provision its bucket without an out-of-band
2166/// dashboard step.
2167async fn ensure_r2_bucket(api_token: &str, account_id: &str, bucket_name: &str) -> Result<()> {
2168 let cf = CloudflareClient::new(api_token.to_string());
2169 let existing = cf
2170 .list_r2_buckets(account_id)
2171 .await
2172 .context("listing R2 buckets")?;
2173 if existing.iter().any(|b| b.name == bucket_name) {
2174 debug!(bucket_name, "R2 bucket already exists — skipping create");
2175 return Ok(());
2176 }
2177 cf.create_r2_bucket(account_id, bucket_name)
2178 .await
2179 .with_context(|| format!("creating R2 bucket {bucket_name}"))?;
2180 info!(bucket_name, "R2 bucket created");
2181 Ok(())
2182}
2183
2184// ── Workload loading ──────────────────────────────────────────────────────────
2185
2186fn load_workload(workload_dir: &Path) -> Result<StaticAssetWorkload> {
2187 let path = workload_dir.join("workload.toml");
2188 let src =
2189 std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
2190 // R546-B7 (RESOLVED — this comment used to say the envelope was unusable).
2191 // `workload_spec::Workload` is no longer externally tagged for text
2192 // formats: its hand-written Deserialize branches on `is_human_readable`, so
2193 // TOML/JSON get the flat `kind`-tagged shape real files use while postcard
2194 // keeps the variant-index encoding the kamaji UDS needs.
2195 //
2196 // This path still probes `kind` and deserializes `StaticAssetWorkload`
2197 // directly rather than matching on the envelope, because it needs to reject
2198 // a non-static-asset workload with a precise message naming the kind it
2199 // found — an envelope match would only say "expected variant". The bypass
2200 // is now a choice, not a workaround.
2201 #[derive(serde::Deserialize)]
2202 struct KindProbe {
2203 kind: String,
2204 }
2205 let probe: KindProbe =
2206 toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))?;
2207 if probe.kind != "static-asset" {
2208 anyhow::bail!(
2209 "{}: expected kind=\"static-asset\" but found kind={:?}",
2210 path.display(),
2211 probe.kind
2212 );
2213 }
2214 let workload: StaticAssetWorkload =
2215 toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))?;
2216
2217 // R546-B7: enforce the closed-catalog invariant the type's docs promise
2218 // ("every value in [aliases] must be a filename that exists in [[asset]],
2219 // enforced by validate::shape_static_asset"). It was NOT actually being run
2220 // here — `up_bails_when_catalog_alias_orphaned` only passed because the old
2221 // fixture failed to PARSE, so the expected error came from the wrong place.
2222 // With parsing fixed, an orphaned alias would have silently reconciled.
2223 workload_spec::validate::shape_static_asset(&workload)
2224 .map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))?;
2225
2226 Ok(workload)
2227}
2228
2229// ── Tests ─────────────────────────────────────────────────────────────────────
2230
2231#[cfg(test)]
2232mod tests {
2233 use super::*;
2234 use crate::asset_journal::AssetStatusJournal;
2235 use crate::{MirrorConfig, MirrorShape, ServiceComponent, ServiceConfig};
2236 use std::collections::BTreeMap;
2237 use std::path::PathBuf;
2238 use tempfile::tempdir;
2239 use workload_spec::{AssetEntry, BlakeHash};
2240
2241 const HASH_64: &str = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
2242
2243 /// Minimal test fixture that mirrors the mesofact_static test pattern.
2244 struct Fixture {
2245 _workspace: tempfile::TempDir,
2246 workspace_root: PathBuf,
2247 service: ServiceConfig,
2248 component: ServiceComponent,
2249 mirror: MirrorConfig,
2250 env: String,
2251 }
2252
2253 impl Fixture {
2254 fn new(slot: MirrorProviderSlot) -> Self {
2255 let workspace = tempdir().unwrap();
2256 let workspace_root = workspace.path().to_path_buf();
2257 let workload_dir = workspace_root.join("app/assets/whisper");
2258 std::fs::create_dir_all(&workload_dir).unwrap();
2259
2260 let mut providers = BTreeMap::new();
2261 providers.insert("object_store".to_string(), slot);
2262 let mirror = MirrorConfig {
2263 schema_version: 1,
2264 shape: MirrorShape::Local,
2265 providers,
2266 ingress: Default::default(),
2267 ingress_machines: Vec::new(),
2268 drivers: Default::default(),
2269 asset_aliases: BTreeMap::new(),
2270 };
2271 let service = ServiceConfig {
2272 schema_version: 1,
2273 name: "yah-desktop".to_string(),
2274 domain: "releases.yah.dev".to_string(),
2275 components: vec![],
2276 db: crate::DbCatalog::default(),
2277 };
2278 let component = ServiceComponent {
2279 mount: None,
2280 id: "whisper-models".to_string(),
2281 kind: "static-asset".to_string(),
2282 path: "app/assets/whisper".to_string(),
2283 role: "assets".to_string(),
2284 publishes: None,
2285 wave: 0,
2286 git: None,
2287 };
2288 Self {
2289 _workspace: workspace,
2290 workspace_root,
2291 service,
2292 component,
2293 mirror,
2294 env: "pond".to_string(),
2295 }
2296 }
2297
2298 fn ctx(&self) -> ReconcileCtx<'_> {
2299 ReconcileCtx {
2300 workspace_root: &self.workspace_root,
2301 service: &self.service,
2302 component: &self.component,
2303 mirror: &self.mirror,
2304 env: &self.env,
2305 scope: crate::reconciler::ProviderScope::singleton(),
2306 }
2307 }
2308
2309 fn workload_dir(&self) -> PathBuf {
2310 self.workspace_root.join("app/assets/whisper")
2311 }
2312
2313 fn write_workload(&self, extra: &str) {
2314 // R546-B7: emit the FLAT shape every real on-disk workload.toml uses
2315 // (`kind = "static-asset"` beside `schema_version`), not the
2316 // externally-tagged `[static-asset]` wrapper. The old fixture encoded
2317 // the `workload_spec::Workload` enum's (untagged) representation,
2318 // which NO real file has ever used — so these tests were asserting a
2319 // shape that could not occur in production and masked the fact that
2320 // `yah cloud apply` could not parse any actual catalog.
2321 let toml = format!(
2322 r#"schema_version = "V1"
2323kind = "static-asset"
2324{extra}
2325"#
2326 );
2327 std::fs::write(self.workload_dir().join("workload.toml"), toml).unwrap();
2328 }
2329
2330 fn write_source_file(&self, name: &str, content: &[u8]) -> PathBuf {
2331 let path = self.workload_dir().join(name);
2332 if let Some(parent) = path.parent() {
2333 std::fs::create_dir_all(parent).unwrap();
2334 }
2335 std::fs::write(&path, content).unwrap();
2336 path
2337 }
2338 }
2339
2340 fn minio_slot() -> MirrorProviderSlot {
2341 let mut fields = BTreeMap::new();
2342 fields.insert("api_port".to_string(), toml::Value::Integer(9000));
2343 fields.insert(
2344 "bucket".to_string(),
2345 toml::Value::String("yah-dev".to_string()),
2346 );
2347 MirrorProviderSlot::Inline {
2348 kind: Provider::MinioContainer,
2349 fields,
2350 }
2351 }
2352
2353 /// Default executor for tests that don't exercise transforms — legacy
2354 /// `source = "..."` assets never touch the executor, so any impl works.
2355 fn test_executor() -> Arc<dyn ForgeExecutor> {
2356 Arc::new(LocalForgeDriver::default())
2357 }
2358
2359 // ── Mock executor for W164 materialize-transform tests ────────────────────
2360
2361 use tokio::sync::mpsc::UnboundedSender;
2362 use tokio::sync::Mutex;
2363 use velveteen::ForgeStatus;
2364 use velveteen_exec::executor::{ExecEvent, ExecOutcome, ForgeExecutorError};
2365
2366 /// Executor that writes a caller-supplied byte string to whatever path the
2367 /// recipe sets `YAH_TRANSFORM_OUT` to (via the substituted argv). Returns
2368 /// success unless `fail_with` is set. Tracks invocation count for HIT/MISS
2369 /// assertions.
2370 struct MockExecutor {
2371 out_bytes: Vec<u8>,
2372 invocations: Arc<Mutex<u32>>,
2373 fail_with: Option<String>,
2374 }
2375
2376 impl MockExecutor {
2377 fn new(out_bytes: Vec<u8>) -> (Arc<Self>, Arc<Mutex<u32>>) {
2378 let counter = Arc::new(Mutex::new(0));
2379 let me = Arc::new(Self {
2380 out_bytes,
2381 invocations: counter.clone(),
2382 fail_with: None,
2383 });
2384 (me, counter)
2385 }
2386
2387 fn failing(reason: String) -> Arc<Self> {
2388 Arc::new(Self {
2389 out_bytes: Vec::new(),
2390 invocations: Arc::new(Mutex::new(0)),
2391 fail_with: Some(reason),
2392 })
2393 }
2394 }
2395
2396 #[async_trait]
2397 impl ForgeExecutor for MockExecutor {
2398 async fn execute(
2399 &self,
2400 spec: ForgeSpec,
2401 _ctx: ExecContext,
2402 _sink: Option<UnboundedSender<ExecEvent>>,
2403 ) -> Result<ExecOutcome, ForgeExecutorError> {
2404 *self.invocations.lock().await += 1;
2405 if let Some(reason) = &self.fail_with {
2406 return Ok(ExecOutcome {
2407 status: ForgeStatus::Done {
2408 exit_code: 1,
2409 ended_at: 0,
2410 },
2411 stderr_tail: reason.clone(),
2412 });
2413 }
2414 // Find the YAH_TRANSFORM_OUT path in the substituted argv. The
2415 // recipe convention is that {{YAH_TRANSFORM_OUT}} resolves to the
2416 // tmp path we want to write to; it appears as a positional arg.
2417 let ForgeCommand::Subprocess { argv, .. } = &spec.command else {
2418 return Err(ForgeExecutorError::Unsupported(
2419 "mock only handles Subprocess",
2420 ));
2421 };
2422 // The test recipes follow the W164 convention: argv contains the
2423 // tmp output path verbatim. materialize_transform writes to
2424 // `<cache_dir>/<output_blake3>.tmp` (then renames to .bin on hash
2425 // match), so the output element is the one ending in `.tmp`.
2426 let out_path = argv.iter().find(|a| a.ends_with(".tmp")).ok_or(
2427 ForgeExecutorError::Unsupported("mock recipe must pass a .tmp output path"),
2428 )?;
2429 std::fs::write(out_path, &self.out_bytes).map_err(ForgeExecutorError::Io)?;
2430 Ok(ExecOutcome {
2431 status: ForgeStatus::Done {
2432 exit_code: 0,
2433 ended_at: 0,
2434 },
2435 stderr_tail: String::new(),
2436 })
2437 }
2438 }
2439
2440 /// R546-B8: records the substituted argv and, optionally, declines to write
2441 /// the output while still reporting exit 0 — the exact shape of a recipe
2442 /// that chdirs and then writes its artifact to a relative path inside the
2443 /// container.
2444 struct ArgvCapture {
2445 argv: Arc<Mutex<Vec<String>>>,
2446 write_output: bool,
2447 }
2448
2449 impl ArgvCapture {
2450 fn new(write_output: bool) -> (Arc<Self>, Arc<Mutex<Vec<String>>>) {
2451 let argv = Arc::new(Mutex::new(Vec::new()));
2452 let me = Arc::new(Self {
2453 argv: argv.clone(),
2454 write_output,
2455 });
2456 (me, argv)
2457 }
2458 }
2459
2460 #[async_trait]
2461 impl ForgeExecutor for ArgvCapture {
2462 async fn execute(
2463 &self,
2464 spec: ForgeSpec,
2465 _ctx: ExecContext,
2466 _sink: Option<UnboundedSender<ExecEvent>>,
2467 ) -> Result<ExecOutcome, ForgeExecutorError> {
2468 let ForgeCommand::Subprocess { argv, .. } = &spec.command else {
2469 return Err(ForgeExecutorError::Unsupported(
2470 "capture only handles Subprocess",
2471 ));
2472 };
2473 *self.argv.lock().await = argv.clone();
2474 if self.write_output {
2475 let out = argv.iter().find(|a| a.ends_with(".tmp")).ok_or(
2476 ForgeExecutorError::Unsupported("recipe must pass a .tmp output path"),
2477 )?;
2478 std::fs::write(out, b"produced").map_err(ForgeExecutorError::Io)?;
2479 }
2480 Ok(ExecOutcome {
2481 status: ForgeStatus::Done {
2482 exit_code: 0,
2483 ended_at: 0,
2484 },
2485 stderr_tail: String::new(),
2486 })
2487 }
2488 }
2489
2490 /// Write a `whisper-quantize.toml`-style recipe under
2491 /// `<workspace>/.yah/qed/transforms/<name>.toml`. Returns nothing — the
2492 /// recipe loader resolves the path itself.
2493 fn write_recipe(workspace_root: &Path, name: &str) {
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 recipe"
2500image = "ghcr.io/test/tool:v1@sha256:{HASH_64}"
2501
2502[placement]
2503location = "local"
2504runtime = "container"
2505
2506[[steps]]
2507name = "transform"
2508argv = ["./tool", "{{{{YAH_TRANSFORM_IN_0}}}}", "{{{{YAH_TRANSFORM_OUT}}}}"]
2509"#
2510 );
2511 std::fs::write(transforms_dir.join(format!("{name}.toml")), toml).unwrap();
2512 }
2513
2514 /// Like [`write_recipe`] but with a caller-chosen container digest, so a
2515 /// test can simulate a digest bump (which must invalidate the W212 action
2516 /// cache and force a re-run).
2517 fn write_recipe_with_digest(workspace_root: &Path, name: &str, digest: &str) {
2518 let transforms_dir = workspace_root.join(".yah/qed/transforms");
2519 std::fs::create_dir_all(&transforms_dir).unwrap();
2520 let toml = format!(
2521 r#"
2522name = "{name}"
2523label = "test recipe"
2524image = "ghcr.io/test/tool:v1@sha256:{digest}"
2525
2526[placement]
2527location = "local"
2528runtime = "container"
2529
2530[[steps]]
2531name = "transform"
2532argv = ["./tool", "{{{{YAH_TRANSFORM_IN_0}}}}", "{{{{YAH_TRANSFORM_OUT}}}}"]
2533"#
2534 );
2535 std::fs::write(transforms_dir.join(format!("{name}.toml")), toml).unwrap();
2536 }
2537
2538 /// Write a remotely-placed recipe. `placement_extra` appends lines to the
2539 /// `[placement]` table (e.g. a `platform` the remote leg must refuse) and
2540 /// `steps` is the whole `[[steps]]` block, so a test can pass two of them.
2541 fn write_remote_recipe(
2542 workspace_root: &Path,
2543 name: &str,
2544 placement_extra: &str,
2545 steps: &str,
2546 ) {
2547 let transforms_dir = workspace_root.join(".yah/qed/transforms");
2548 std::fs::create_dir_all(&transforms_dir).unwrap();
2549 let toml = format!(
2550 r#"
2551name = "{name}"
2552label = "test remote recipe"
2553image = "ghcr.io/test/tool:v1@sha256:{HASH_64}"
2554
2555[placement]
2556location = {{ kind = "remote_any", tier = "infra", mesh_tags = ["arch:x86"] }}
2557runtime = "container"
2558{placement_extra}
2559
2560{steps}
2561"#
2562 );
2563 std::fs::write(transforms_dir.join(format!("{name}.toml")), toml).unwrap();
2564 }
2565
2566 /// Stands in for `RemoteForgeDriver`: records the context it was handed and
2567 /// performs the retrieval half by writing the "built" bytes to
2568 /// `ctx.produced.dest`, exactly as the real driver does after
2569 /// `fetch_produced_file`.
2570 struct RemoteMock {
2571 out_bytes: Vec<u8>,
2572 seen: Arc<Mutex<Option<(Vec<String>, ExecContext)>>>,
2573 }
2574
2575 impl RemoteMock {
2576 fn new(out_bytes: Vec<u8>) -> (Arc<Self>, Arc<Mutex<Option<(Vec<String>, ExecContext)>>>) {
2577 let seen = Arc::new(Mutex::new(None));
2578 let me = Arc::new(Self {
2579 out_bytes,
2580 seen: seen.clone(),
2581 });
2582 (me, seen)
2583 }
2584 }
2585
2586 #[async_trait]
2587 impl ForgeExecutor for RemoteMock {
2588 async fn execute(
2589 &self,
2590 spec: ForgeSpec,
2591 ctx: ExecContext,
2592 _sink: Option<UnboundedSender<ExecEvent>>,
2593 ) -> Result<ExecOutcome, ForgeExecutorError> {
2594 let ForgeCommand::Subprocess { argv, .. } = &spec.command else {
2595 return Err(ForgeExecutorError::Unsupported("remote mock: Subprocess only"));
2596 };
2597 assert!(
2598 !matches!(spec.where_.location, TaskLocation::Local),
2599 "the remote mock must only ever see a remotely-placed spec",
2600 );
2601 if let Some(produced) = &ctx.produced {
2602 std::fs::write(&produced.dest, &self.out_bytes).map_err(ForgeExecutorError::Io)?;
2603 }
2604 *self.seen.lock().await = Some((argv.clone(), ctx));
2605 Ok(ExecOutcome {
2606 status: ForgeStatus::Done {
2607 exit_code: 0,
2608 ended_at: 0,
2609 },
2610 stderr_tail: String::new(),
2611 })
2612 }
2613 }
2614
2615 /// R555-F3, the whole point: a remotely-placed recipe binds its output to
2616 /// the worker's durable produced dir, and the bytes come back to the local
2617 /// derivation cache so the publish leg above is untouched.
2618 #[tokio::test]
2619 async fn a_remote_recipe_binds_out_on_the_worker_and_lands_the_bytes_locally() {
2620 let fx = Fixture::new(minio_slot());
2621 write_remote_recipe(
2622 &fx.workspace_root,
2623 "remote-recipe",
2624 "",
2625 "[[steps]]\nname = \"build\"\nargv = [\"build.sh\", \"{{YAH_TRANSFORM_OUT}}\"]",
2626 );
2627 let out_bytes = b"remotely built".to_vec();
2628 let out_hash = blake3_hex(&out_bytes);
2629 let (mock, seen) = RemoteMock::new(out_bytes.clone());
2630 let executor: Arc<dyn ForgeExecutor> = mock;
2631
2632 let fetch_path = fx.workspace_root.join(".yah/cache/derive/fetch/in.bin");
2633 std::fs::create_dir_all(fetch_path.parent().unwrap()).unwrap();
2634 std::fs::write(&fetch_path, b"anchor").unwrap();
2635
2636 let landed = materialize_transform(
2637 &TransformSpec {
2638 recipe: "remote-recipe".to_string(),
2639 params: BTreeMap::new(),
2640 },
2641 &fetch_path,
2642 &blake3_hex(b"anchor"),
2643 &out_hash,
2644 &fx.workspace_root.join(".yah/cache/derive/transform"),
2645 &fx.workspace_root,
2646 executor.as_ref(),
2647 )
2648 .await
2649 .unwrap();
2650
2651 assert_eq!(std::fs::read(&landed).unwrap(), out_bytes);
2652
2653 let seen = seen.lock().await;
2654 let (argv, ctx) = seen.as_ref().expect("the remote step ran");
2655 let bound_out = argv.last().expect("argv carries the OUT binding");
2656 assert!(
2657 bound_out.starts_with(workload_spec::forge_produced::CONTAINER_DIR),
2658 "OUT must be bound under the worker's durable produced dir, got {bound_out}",
2659 );
2660 let produced = ctx.produced.as_ref().expect("retrieval must be requested");
2661 assert_eq!(produced.remote_path, PathBuf::from(bound_out));
2662 // A host path handed to a remote workdir names a directory that does
2663 // not exist on the node.
2664 assert!(ctx.cwd.is_none(), "no host cwd may travel to a worker");
2665 }
2666
2667 /// The fetched input lives on this box and there is no upload leg, so a
2668 /// remote recipe that reads it would be handed an unresolvable path.
2669 #[tokio::test]
2670 async fn a_remote_recipe_reading_the_fetched_input_is_refused() {
2671 let fx = Fixture::new(minio_slot());
2672 write_remote_recipe(
2673 &fx.workspace_root,
2674 "needs-input",
2675 "",
2676 "[[steps]]\nname = \"build\"\nargv = [\"tool\", \"{{YAH_TRANSFORM_IN_0}}\", \"{{YAH_TRANSFORM_OUT}}\"]",
2677 );
2678 let msg = remote_refusal(&fx, "needs-input").await;
2679 assert!(msg.contains("YAH_TRANSFORM_IN_0"), "got: {msg}");
2680 assert!(msg.contains("no upload leg"), "got: {msg}");
2681 }
2682
2683 /// Steps share a filesystem locally and share nothing remotely.
2684 #[tokio::test]
2685 async fn a_multi_step_remote_recipe_is_refused() {
2686 let fx = Fixture::new(minio_slot());
2687 write_remote_recipe(
2688 &fx.workspace_root,
2689 "two-steps",
2690 "",
2691 "[[steps]]\nname = \"one\"\nargv = [\"a\"]\n\n[[steps]]\nname = \"two\"\nargv = [\"b\", \"{{YAH_TRANSFORM_OUT}}\"]",
2692 );
2693 let msg = remote_refusal(&fx, "two-steps").await;
2694 assert!(msg.contains("one-shot workload"), "got: {msg}");
2695 }
2696
2697 /// R555-F5: the recipe's declared vault credentials have to reach the
2698 /// driver as mounts, or the build runs without the credential it asked for
2699 /// and fails as a missing-auth error deep inside itself.
2700 #[tokio::test]
2701 async fn a_remote_recipe_hands_its_declared_secrets_to_the_driver() {
2702 let fx = Fixture::new(minio_slot());
2703 write_remote_recipe(
2704 &fx.workspace_root,
2705 "needs-r2",
2706 "",
2707 "[[steps]]\nname = \"build\"\nargv = [\"build.sh\", \"{{YAH_TRANSFORM_OUT}}\"]\n\n\
2708 [[secrets]]\ncluster = \"r2/write\"\npath = \"/run/yah/r2.json\"",
2709 );
2710 let out_bytes = b"built with a credential".to_vec();
2711 let (mock, seen) = RemoteMock::new(out_bytes.clone());
2712 let executor: Arc<dyn ForgeExecutor> = mock;
2713
2714 let fetch_path = fx.workspace_root.join(".yah/cache/derive/fetch/in.bin");
2715 std::fs::create_dir_all(fetch_path.parent().unwrap()).unwrap();
2716 std::fs::write(&fetch_path, b"anchor").unwrap();
2717
2718 materialize_transform(
2719 &TransformSpec {
2720 recipe: "needs-r2".to_string(),
2721 params: BTreeMap::new(),
2722 },
2723 &fetch_path,
2724 &blake3_hex(b"anchor"),
2725 &blake3_hex(&out_bytes),
2726 &fx.workspace_root.join(".yah/cache/derive/transform"),
2727 &fx.workspace_root,
2728 executor.as_ref(),
2729 )
2730 .await
2731 .unwrap();
2732
2733 let seen = seen.lock().await;
2734 let (_, ctx) = seen.as_ref().expect("the remote step ran");
2735 assert_eq!(
2736 ctx.secrets,
2737 vec![workload_spec::SecretMount {
2738 source: workload_spec::SecretRef::Cluster {
2739 name: "r2/write".into()
2740 },
2741 target: workload_spec::SecretTarget::File {
2742 path: PathBuf::from("/run/yah/r2.json"),
2743 // Owner-only is the default a recipe gets without asking.
2744 mode: 0o400,
2745 },
2746 }]
2747 );
2748 }
2749
2750 /// The local mirror: yubaba resolves credentials on the node that runs the
2751 /// workload, and a local run has no node. Refused by name so the operator
2752 /// is pointed at the recipe rather than at a driver error.
2753 #[tokio::test]
2754 async fn a_local_recipe_declaring_secrets_is_refused_before_dispatch() {
2755 let fx = Fixture::new(minio_slot());
2756 let transforms_dir = fx.workspace_root.join(".yah/qed/transforms");
2757 std::fs::create_dir_all(&transforms_dir).unwrap();
2758 std::fs::write(
2759 transforms_dir.join("local-with-secret.toml"),
2760 format!(
2761 r#"
2762name = "local-with-secret"
2763label = "test local recipe"
2764image = "ghcr.io/test/tool:v1@sha256:{HASH_64}"
2765
2766[placement]
2767location = "local"
2768runtime = "container"
2769
2770[[steps]]
2771name = "build"
2772argv = ["build.sh", "{{{{YAH_TRANSFORM_OUT}}}}"]
2773
2774[[secrets]]
2775cluster = "r2/write"
2776path = "/run/yah/r2.json"
2777"#
2778 ),
2779 )
2780 .unwrap();
2781 let msg = remote_refusal(&fx, "local-with-secret").await;
2782 assert!(msg.contains("placed locally"), "got: {msg}");
2783 assert!(msg.contains("local-with-secret"), "got: {msg}");
2784 }
2785
2786 /// `platform` is host-emulation; remote picks arch by scheduling. Silently
2787 /// dropping it is how R546 got a wrong-arch artifact in the first place.
2788 #[tokio::test]
2789 async fn a_remote_recipe_declaring_a_platform_is_refused_and_named_mesh_tags() {
2790 let fx = Fixture::new(minio_slot());
2791 write_remote_recipe(
2792 &fx.workspace_root,
2793 "emulated",
2794 "platform = \"linux/amd64\"",
2795 "[[steps]]\nname = \"build\"\nargv = [\"build.sh\", \"{{YAH_TRANSFORM_OUT}}\"]",
2796 );
2797 let msg = remote_refusal(&fx, "emulated").await;
2798 assert!(msg.contains("mesh_tags"), "got: {msg}");
2799 }
2800
2801 /// Run `recipe` through `materialize_transform` with an executor that
2802 /// panics if reached, and return the refusal message. Every caller here
2803 /// asserts the recipe is rejected *before* anything is dispatched.
2804 async fn remote_refusal(fx: &Fixture, recipe: &str) -> String {
2805 struct NeverRuns;
2806 #[async_trait]
2807 impl ForgeExecutor for NeverRuns {
2808 async fn execute(
2809 &self,
2810 _spec: ForgeSpec,
2811 _ctx: ExecContext,
2812 _sink: Option<UnboundedSender<ExecEvent>>,
2813 ) -> Result<ExecOutcome, ForgeExecutorError> {
2814 panic!("the recipe must be refused before dispatch");
2815 }
2816 }
2817 let fetch_path = fx.workspace_root.join(".yah/cache/derive/fetch/in.bin");
2818 std::fs::create_dir_all(fetch_path.parent().unwrap()).unwrap();
2819 std::fs::write(&fetch_path, b"anchor").unwrap();
2820 let executor: Arc<dyn ForgeExecutor> = Arc::new(NeverRuns);
2821 let err = materialize_transform(
2822 &TransformSpec {
2823 recipe: recipe.to_string(),
2824 params: BTreeMap::new(),
2825 },
2826 &fetch_path,
2827 &blake3_hex(b"anchor"),
2828 &blake3_hex(b"whatever"),
2829 &fx.workspace_root.join(".yah/cache/derive/transform"),
2830 &fx.workspace_root,
2831 executor.as_ref(),
2832 )
2833 .await
2834 .expect_err("remote recipe must be refused");
2835 format!("{err:#}")
2836 }
2837
2838 // ── Slot validation ───────────────────────────────────────────────────────
2839
2840 #[tokio::test]
2841 async fn up_bails_when_object_store_slot_missing() {
2842 let mut fx = Fixture::new(minio_slot());
2843 fx.mirror.providers.clear();
2844 fx.write_workload("");
2845 let err = StaticAssetReconciler::new().up(fx.ctx()).await.unwrap_err();
2846 let msg = format!("{err:#}");
2847 assert!(msg.contains("providers.object_store"), "got: {msg}");
2848 }
2849
2850 #[tokio::test]
2851 async fn up_bails_on_unsupported_inline_slot_kind() {
2852 let slot = MirrorProviderSlot::Inline {
2853 kind: Provider::LocalStatic,
2854 fields: BTreeMap::new(),
2855 };
2856 let fx = Fixture::new(slot);
2857 fx.write_workload("");
2858 let err = StaticAssetReconciler::new().up(fx.ctx()).await.unwrap_err();
2859 let msg = format!("{err:#}");
2860 assert!(msg.contains("minio-container"), "got: {msg}");
2861 }
2862
2863 #[tokio::test]
2864 async fn up_bails_on_unsupported_reference_provider() {
2865 let slot = MirrorProviderSlot::Reference {
2866 provider_id: "hetzner".to_string(),
2867 fields: BTreeMap::new(),
2868 };
2869 let fx = Fixture::new(slot);
2870 fx.write_workload("");
2871 // Declare a real non-cloudflare provider so dispatch resolves it and
2872 // reaches the kind check (the new dispatch keys on kind, not name).
2873 let providers_dir = fx.workspace_root.join(".yah/infra/providers");
2874 std::fs::create_dir_all(&providers_dir).unwrap();
2875 std::fs::write(
2876 providers_dir.join("hetzner.toml"),
2877 "schema_version = 1\nid = \"hetzner\"\nkind = \"hetzner\"\naccount_id = \"x\"\n",
2878 )
2879 .unwrap();
2880 let err = StaticAssetReconciler::new().up(fx.ctx()).await.unwrap_err();
2881 let msg = format!("{err:#}");
2882 assert!(msg.contains("cloudflare-kind"), "got: {msg}");
2883 }
2884
2885 #[tokio::test]
2886 async fn up_bails_when_workload_toml_missing() {
2887 let fx = Fixture::new(minio_slot());
2888 // No workload.toml written.
2889 let err = StaticAssetReconciler::new().up(fx.ctx()).await.unwrap_err();
2890 let msg = format!("{err:#}");
2891 assert!(msg.contains("workload.toml"), "got: {msg}");
2892 }
2893
2894 #[tokio::test]
2895 async fn up_bails_when_catalog_alias_orphaned() {
2896 // Alias points at a filename not in the catalog — shape_static_asset rejects it.
2897 let fx = Fixture::new(minio_slot());
2898 fx.write_workload(
2899 r#"[aliases]
2900"default" = "nonexistent/file.bin"
2901"#,
2902 );
2903 let err = StaticAssetReconciler::new().up(fx.ctx()).await.unwrap_err();
2904 let msg = format!("{err:#}");
2905 assert!(
2906 msg.contains("closed-catalog") || msg.contains("aliases"),
2907 "got: {msg}"
2908 );
2909 }
2910
2911 // ── BLAKE3 verification ───────────────────────────────────────────────────
2912
2913 #[test]
2914 fn blake3_mismatch_detected() {
2915 let body = b"hello world";
2916 let actual = blake3_hex(body);
2917 // Fabricate a hash that's wrong.
2918 let wrong = HASH_64;
2919 assert!(!hashes_equal(&actual, wrong));
2920 }
2921
2922 #[test]
2923 fn blake3_match_is_case_insensitive() {
2924 let body = b"hello world";
2925 let lower = blake3_hex(body);
2926 let upper = lower.to_uppercase();
2927 assert!(hashes_equal(&lower, &upper));
2928 }
2929
2930 #[test]
2931 fn sha256_hex_is_64_chars() {
2932 let h = sha256_hex(b"test");
2933 assert_eq!(h.len(), 64);
2934 }
2935
2936 /// Core scenario: source file whose BLAKE3 matches the declared hash should
2937 /// be identified as upload-ready (no mismatch). Tests the in-memory path
2938 /// without hitting any S3 endpoint.
2939 #[test]
2940 fn matching_blake3_produces_no_mismatch_entry() {
2941 let body = b"real model weights here";
2942 let real_hash = blake3_hex(body);
2943 // Simulate what the reconciler checks.
2944 let stored = BlakeHash(real_hash.clone());
2945 assert!(hashes_equal(&real_hash, &stored.0));
2946 }
2947
2948 #[tokio::test]
2949 async fn blake3_mismatch_on_source_file_is_caught() {
2950 let fx = Fixture::new(minio_slot());
2951 let body = b"real model content";
2952 let real_hash = blake3_hex(body);
2953 // Write a workload that declares the correct hash but we'll give it
2954 // a different file content to verify the check fires before any network.
2955 let wrong_content = b"tampered content";
2956 fx.write_source_file("model.bin", wrong_content);
2957 let wrong_hash_for_real = blake3_hex(wrong_content);
2958 // Sanity: the wrong content should produce a different hash.
2959 assert_ne!(real_hash, wrong_hash_for_real);
2960
2961 let workload = StaticAssetWorkload {
2962 schema_version: workload_spec::SchemaVersion::V1,
2963 assets: vec![AssetEntry {
2964 filename: "model.bin".to_string(),
2965 source: Some("model.bin".into()),
2966 derive: None,
2967 blake3: BlakeHash(real_hash),
2968 }],
2969 aliases: BTreeMap::new(),
2970 };
2971
2972 // Drive the sync with a non-existent MinIO so it would fail on network
2973 // if it ever got past the BLAKE3 check.
2974 let client = reqwest::Client::new();
2975 let journal = AssetStatusJournal::new(fx.workspace_root.join(".yah/cloud/status.jsonl"));
2976 let report = sync_assets(
2977 &workload,
2978 &fx.workspace_root,
2979 &fx.workload_dir(),
2980 &client,
2981 "http://127.0.0.1:19999", // unreachable
2982 "test-bucket",
2983 MINIO_REGION,
2984 "user",
2985 "pass",
2986 test_executor(),
2987 "test-service",
2988 &journal,
2989 )
2990 .await
2991 .unwrap();
2992
2993 assert_eq!(report.hash_mismatch, vec!["model.bin"]);
2994 assert!(report.uploaded.is_empty());
2995 }
2996
2997 #[tokio::test]
2998 async fn correct_blake3_proceeds_to_s3_head() {
2999 let fx = Fixture::new(minio_slot());
3000 let body = b"correct content";
3001 let hash = blake3_hex(body);
3002 fx.write_source_file("model.bin", body);
3003
3004 let workload = StaticAssetWorkload {
3005 schema_version: workload_spec::SchemaVersion::V1,
3006 assets: vec![AssetEntry {
3007 filename: "model.bin".to_string(),
3008 source: Some("model.bin".into()),
3009 derive: None,
3010 blake3: BlakeHash(hash),
3011 }],
3012 aliases: BTreeMap::new(),
3013 };
3014
3015 // BLAKE3 passes → reconciler proceeds to HEAD the S3 endpoint.
3016 // The unreachable endpoint causes a network error (not a BLAKE3 error).
3017 let client = reqwest::Client::new();
3018 let journal = AssetStatusJournal::new(fx.workspace_root.join(".yah/cloud/status.jsonl"));
3019 let err = sync_assets(
3020 &workload,
3021 &fx.workspace_root,
3022 &fx.workload_dir(),
3023 &client,
3024 "http://127.0.0.1:19999", // unreachable
3025 "test-bucket",
3026 MINIO_REGION,
3027 "user",
3028 "pass",
3029 test_executor(),
3030 "test-service",
3031 &journal,
3032 )
3033 .await
3034 .unwrap_err();
3035
3036 // Error must be about network, not BLAKE3.
3037 let msg = format!("{err:#}");
3038 assert!(
3039 !msg.contains("BLAKE3") && !msg.contains("mismatch"),
3040 "should fail at network, not BLAKE3; got: {msg}"
3041 );
3042 }
3043
3044 // ── Catalog manifest ──────────────────────────────────────────────────────
3045
3046 #[test]
3047 fn catalog_manifest_roundtrip() {
3048 let mut m = CatalogManifest::new();
3049 m.insert("whisper/model-v1.bin".to_string(), HASH_64.to_string());
3050 let json = serde_json::to_vec(&m).unwrap();
3051 let m2: CatalogManifest = serde_json::from_slice(&json).unwrap();
3052 assert_eq!(m, m2);
3053 }
3054
3055 // ── R546-B10: publish-skip must be content-aware, not existence-aware ─────
3056
3057 /// The defect this encodes: an object that EXISTS is not evidence that the
3058 /// object holds the bytes we are about to publish. Skipping the PUT on mere
3059 /// existence left stale bytes in the bucket while the catalog manifest
3060 /// advertised the new hash — the published catalog described bytes that were
3061 /// never uploaded (real incident: rusty-v8 aarch64, bucket held d322b4a1
3062 /// while the manifest claimed ebdd842d).
3063 #[test]
3064 fn remote_object_matches_only_on_identical_content() {
3065 let expected = "a".repeat(64);
3066 let other = "b".repeat(64);
3067
3068 // Same bytes → genuine no-op, safe to skip the PUT.
3069 assert!(RemoteObject::Present {
3070 blake3: Some(expected.clone())
3071 }
3072 .matches(&expected));
3073
3074 // Different bytes → MUST NOT be treated as synced. This is the bug.
3075 assert!(!RemoteObject::Present {
3076 blake3: Some(other)
3077 }
3078 .matches(&expected));
3079
3080 // Absent → nothing to match.
3081 assert!(!RemoteObject::Absent.matches(&expected));
3082 assert!(!RemoteObject::Absent.exists());
3083 }
3084
3085 /// A legacy object written before we stamped `x-amz-meta-blake3` has
3086 /// unknowable content. It must NOT count as a match — otherwise every
3087 /// pre-existing object keeps its old bytes forever. It re-PUTs once, which
3088 /// installs the stamp and restores the cheap skip on subsequent runs.
3089 #[test]
3090 fn unstamped_remote_object_is_not_a_match_but_does_exist() {
3091 let expected = "c".repeat(64);
3092 let legacy = RemoteObject::Present { blake3: None };
3093 assert!(
3094 !legacy.matches(&expected),
3095 "unstamped object must not be trusted as current"
3096 );
3097 assert!(
3098 legacy.exists(),
3099 "it does exist — caller re-publishes rather than treating as absent"
3100 );
3101 }
3102
3103 /// Hashes may have been authored in either case; comparison goes through
3104 /// `hashes_equal`, so an uppercase stamp must still match.
3105 #[test]
3106 fn remote_object_match_is_case_insensitive() {
3107 let lower = "abcdef".repeat(10) + "abcd";
3108 let upper = lower.to_uppercase();
3109 assert_eq!(lower.len(), 64);
3110 assert!(RemoteObject::Present {
3111 blake3: Some(upper)
3112 }
3113 .matches(&lower));
3114 }
3115
3116 // ── Prune candidate detection ─────────────────────────────────────────────
3117
3118 #[tokio::test]
3119 async fn prune_candidate_from_prior_manifest() {
3120 let fx = Fixture::new(minio_slot());
3121 let body = b"current content";
3122 let hash = blake3_hex(body);
3123 fx.write_source_file("current.bin", body);
3124
3125 // Workload only has "current.bin"; prior manifest also had "old.bin".
3126 let workload = StaticAssetWorkload {
3127 schema_version: workload_spec::SchemaVersion::V1,
3128 assets: vec![AssetEntry {
3129 filename: "current.bin".to_string(),
3130 source: Some("current.bin".into()),
3131 derive: None,
3132 blake3: BlakeHash(hash),
3133 }],
3134 aliases: BTreeMap::new(),
3135 };
3136
3137 // We can't inject a prior manifest without a real/mock server, but we
3138 // can verify the prune detection logic directly.
3139 let current_filenames: std::collections::HashSet<&str> = workload
3140 .assets
3141 .iter()
3142 .map(|a| a.filename.as_str())
3143 .collect();
3144 let prior: CatalogManifest = [("old.bin".to_string(), HASH_64.to_string())]
3145 .into_iter()
3146 .collect();
3147
3148 let prune: Vec<_> = prior
3149 .keys()
3150 .filter(|k| !current_filenames.contains(k.as_str()))
3151 .cloned()
3152 .collect();
3153 assert_eq!(prune, vec!["old.bin"]);
3154 }
3155
3156 // ── W164 materialize step (R438-T15) ──────────────────────────────────────
3157
3158 use workload_spec::{AssetDerive, FetchSource, License, TransformSpec};
3159
3160 /// Helper: build an `AssetEntry` whose `source` is already on disk —
3161 /// the legacy arm of `materialize_asset` should return the disk path
3162 /// verbatim without ever consulting `derive`/cache/executor.
3163 fn legacy_entry(filename: &str, source_rel: &str, hash: &str) -> AssetEntry {
3164 AssetEntry {
3165 filename: filename.to_string(),
3166 source: Some(source_rel.into()),
3167 derive: None,
3168 blake3: BlakeHash(hash.to_string()),
3169 }
3170 }
3171
3172 #[tokio::test]
3173 async fn materialize_legacy_source_returns_workload_dir_path() {
3174 let fx = Fixture::new(minio_slot());
3175 let body = b"on-disk bytes";
3176 fx.write_source_file("model.bin", body);
3177 let entry = legacy_entry("model.bin", "model.bin", &blake3_hex(body));
3178
3179 let materialized = materialize_asset(
3180 &entry,
3181 &fx.workspace_root,
3182 &fx.workload_dir(),
3183 test_executor().as_ref(),
3184 )
3185 .await
3186 .expect("legacy materialize must succeed");
3187
3188 assert_eq!(materialized.path, fx.workload_dir().join("model.bin"));
3189 assert!(
3190 materialized.discovered_fetch_hash.is_none(),
3191 "legacy source mode has no fetch step → never bootstrap-discovers"
3192 );
3193 }
3194
3195 #[tokio::test]
3196 async fn materialize_fetch_cache_miss_then_hit() {
3197 let fx = Fixture::new(minio_slot());
3198 let body = b"upstream bytes";
3199 let hash = blake3_hex(body);
3200
3201 // Server returns the body once; second call would 500 if hit.
3202 use axum::routing::get;
3203 let counter = Arc::new(std::sync::atomic::AtomicU32::new(0));
3204 let counter_c = counter.clone();
3205 let body_c = body.to_vec();
3206 let app = axum::Router::new().route(
3207 "/blob.bin",
3208 get(move || {
3209 let counter = counter_c.clone();
3210 let body = body_c.clone();
3211 async move {
3212 counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3213 body
3214 }
3215 }),
3216 );
3217 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3218 let addr = listener.local_addr().unwrap();
3219 tokio::spawn(async move {
3220 axum::serve(listener, app).await.unwrap();
3221 });
3222
3223 let fetch = FetchSource {
3224 url: format!("http://{addr}/blob.bin"),
3225 blake3: BlakeHash(hash.clone()),
3226 license: License::Mit,
3227 };
3228 let cache_dir = fx.workspace_root.join(".yah/cache/derive/fetch");
3229
3230 // MISS — fetch + write to cache.
3231 let (p1, h1) = materialize_fetch(&fetch, &cache_dir).await.unwrap();
3232 assert_eq!(counter.load(std::sync::atomic::Ordering::SeqCst), 1);
3233 assert!(p1.exists(), "cache file must exist after MISS");
3234 assert_eq!(h1, hash, "strict mode returns the pinned hash");
3235
3236 // HIT — no second HTTP call.
3237 let (p2, h2) = materialize_fetch(&fetch, &cache_dir).await.unwrap();
3238 assert_eq!(p1, p2);
3239 assert_eq!(h1, h2, "HIT returns the same hash");
3240 assert_eq!(
3241 counter.load(std::sync::atomic::Ordering::SeqCst),
3242 1,
3243 "cache HIT must not re-fetch"
3244 );
3245 }
3246
3247 #[tokio::test]
3248 async fn materialize_fetch_blake3_mismatch_is_hard_error() {
3249 let fx = Fixture::new(minio_slot());
3250 let actual_body = b"real bytes";
3251 let actual_hash = blake3_hex(actual_body);
3252 let pinned_hash = HASH_64; // deliberately wrong
3253 assert_ne!(actual_hash, pinned_hash);
3254
3255 use axum::routing::get;
3256 let body_c = actual_body.to_vec();
3257 let app = axum::Router::new().route(
3258 "/blob.bin",
3259 get(move || {
3260 let body = body_c.clone();
3261 async move { body }
3262 }),
3263 );
3264 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3265 let addr = listener.local_addr().unwrap();
3266 tokio::spawn(async move {
3267 axum::serve(listener, app).await.unwrap();
3268 });
3269
3270 let fetch = FetchSource {
3271 url: format!("http://{addr}/blob.bin"),
3272 blake3: BlakeHash(pinned_hash.to_string()),
3273 license: License::Mit,
3274 };
3275 let err = materialize_fetch(&fetch, &fx.workspace_root.join(".yah/cache/derive/fetch"))
3276 .await
3277 .expect_err("BLAKE3 mismatch must surface as hard error");
3278 let msg = format!("{err:#}");
3279 assert!(
3280 msg.contains("BLAKE3") || msg.contains(&actual_hash),
3281 "got: {msg}"
3282 );
3283 assert!(msg.contains(pinned_hash), "diff must mention pin: {msg}");
3284 }
3285
3286 #[tokio::test]
3287 async fn materialize_fetch_retries_on_server_error() {
3288 // First request returns 500 (transient); second returns 200 with the body.
3289 // Verifies exponential-backoff retry (W164 OQ#4, R438-F11).
3290 use axum::http::StatusCode;
3291 use axum::routing::get;
3292
3293 let fx = Fixture::new(minio_slot());
3294 let body: Vec<u8> = b"retry-me bytes".to_vec();
3295 let hash = blake3_hex(&body);
3296
3297 let counter = Arc::new(std::sync::atomic::AtomicU32::new(0));
3298 let counter_c = counter.clone();
3299 let body_c = body.clone();
3300 let app = axum::Router::new().route(
3301 "/blob.bin",
3302 get(move || {
3303 let n = counter_c.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3304 let body = body_c.clone();
3305 async move {
3306 if n == 0 {
3307 (StatusCode::INTERNAL_SERVER_ERROR, vec![])
3308 } else {
3309 (StatusCode::OK, body)
3310 }
3311 }
3312 }),
3313 );
3314 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3315 let addr = listener.local_addr().unwrap();
3316 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
3317
3318 let fetch = FetchSource {
3319 url: format!("http://{addr}/blob.bin"),
3320 blake3: BlakeHash(hash.clone()),
3321 license: License::Mit,
3322 };
3323 let cache_dir = fx.workspace_root.join(".yah/cache/derive/fetch");
3324 let (path, _hash) = materialize_fetch(&fetch, &cache_dir).await.unwrap();
3325 assert!(
3326 path.exists(),
3327 "cache file must exist after successful retry"
3328 );
3329 assert_eq!(
3330 counter.load(std::sync::atomic::Ordering::SeqCst),
3331 2,
3332 "exactly two attempts: one 500 + one 200"
3333 );
3334 assert_eq!(tokio::fs::read(&path).await.unwrap(), body);
3335 }
3336
3337 #[tokio::test]
3338 async fn materialize_fetch_resumes_via_range_header() {
3339 // Pre-populate the partial file with the first half of the blob. The
3340 // server should receive a Range header and return 206 + the second half.
3341 // Verifies Range-resume logic (W164 OQ#4, R438-F11).
3342 use axum::body::Body;
3343 use axum::http::{HeaderMap, StatusCode};
3344 use axum::routing::get;
3345
3346 let fx = Fixture::new(minio_slot());
3347 let body: Vec<u8> = (0u8..100u8).collect();
3348 let half = body.len() / 2;
3349 let hash = blake3_hex(&body);
3350
3351 // Pre-write first half to the .partial file.
3352 let cache_dir = fx.workspace_root.join(".yah/cache/derive/fetch");
3353 tokio::fs::create_dir_all(&cache_dir).await.unwrap();
3354 let partial_path = cache_dir.join(format!("{hash}.partial"));
3355 tokio::fs::write(&partial_path, &body[..half])
3356 .await
3357 .unwrap();
3358
3359 let body_c = body.clone();
3360 let received_range: Arc<std::sync::Mutex<Option<String>>> =
3361 Arc::new(std::sync::Mutex::new(None));
3362 let received_range_c = received_range.clone();
3363 let app = axum::Router::new().route(
3364 "/blob.bin",
3365 get(move |headers: HeaderMap| {
3366 let body = body_c.clone();
3367 let received = received_range_c.clone();
3368 async move {
3369 let range_hdr = headers
3370 .get("range")
3371 .and_then(|v| v.to_str().ok())
3372 .map(str::to_string);
3373 *received.lock().unwrap() = range_hdr.clone();
3374
3375 let Some(range) = range_hdr else {
3376 return axum::response::Response::builder()
3377 .status(StatusCode::BAD_REQUEST)
3378 .body(Body::empty())
3379 .unwrap();
3380 };
3381 let offset: usize = range
3382 .strip_prefix("bytes=")
3383 .and_then(|s| s.strip_suffix('-'))
3384 .and_then(|s| s.parse().ok())
3385 .unwrap_or(0);
3386 let total = body.len();
3387 axum::response::Response::builder()
3388 .status(StatusCode::PARTIAL_CONTENT)
3389 .header(
3390 "Content-Range",
3391 format!("bytes {offset}-{}/{total}", total - 1),
3392 )
3393 .body(Body::from(body[offset..].to_vec()))
3394 .unwrap()
3395 }
3396 }),
3397 );
3398 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3399 let addr = listener.local_addr().unwrap();
3400 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
3401
3402 let fetch = FetchSource {
3403 url: format!("http://{addr}/blob.bin"),
3404 blake3: BlakeHash(hash.clone()),
3405 license: License::Mit,
3406 };
3407 let (path, _hash) = materialize_fetch(&fetch, &cache_dir).await.unwrap();
3408
3409 // Range header must carry the pre-existing partial size.
3410 let range = received_range.lock().unwrap().clone().unwrap();
3411 assert_eq!(
3412 range,
3413 format!("bytes={half}-"),
3414 "Range header must resume from partial offset"
3415 );
3416 // Final cache file must contain the complete body.
3417 assert_eq!(
3418 tokio::fs::read(&path).await.unwrap(),
3419 body,
3420 "cache file must contain the full blob after Range resume"
3421 );
3422 }
3423
3424 #[tokio::test]
3425 async fn materialize_transform_cache_miss_then_hit() {
3426 let fx = Fixture::new(minio_slot());
3427 write_recipe(&fx.workspace_root, "noop-recipe");
3428 let out_bytes = b"transformed output".to_vec();
3429 let out_hash = blake3_hex(&out_bytes);
3430 let (mock, invocations) = MockExecutor::new(out_bytes.clone());
3431 let executor: Arc<dyn ForgeExecutor> = mock;
3432
3433 // Pre-seed a fetch cache entry the transform reads from.
3434 let fetch_path = fx.workspace_root.join(".yah/cache/derive/fetch/in.bin");
3435 std::fs::create_dir_all(fetch_path.parent().unwrap()).unwrap();
3436 std::fs::write(&fetch_path, b"fetch bytes").unwrap();
3437
3438 let transform = TransformSpec {
3439 recipe: "noop-recipe".to_string(),
3440 params: BTreeMap::new(),
3441 };
3442 let cache_dir = fx.workspace_root.join(".yah/cache/derive/transform");
3443
3444 // MISS — runs the recipe.
3445 let p1 = materialize_transform(
3446 &transform,
3447 &fetch_path,
3448 &blake3_hex(b"fetch bytes"),
3449 &out_hash,
3450 &cache_dir,
3451 &fx.workspace_root,
3452 executor.as_ref(),
3453 )
3454 .await
3455 .unwrap();
3456 assert!(p1.exists());
3457 assert_eq!(*invocations.lock().await, 1);
3458
3459 // HIT — recipe NOT re-run.
3460 let p2 = materialize_transform(
3461 &transform,
3462 &fetch_path,
3463 &blake3_hex(b"fetch bytes"),
3464 &out_hash,
3465 &cache_dir,
3466 &fx.workspace_root,
3467 executor.as_ref(),
3468 )
3469 .await
3470 .unwrap();
3471 assert_eq!(p1, p2);
3472 assert_eq!(*invocations.lock().await, 1, "cache HIT must not re-run");
3473 }
3474
3475 /// R546-T3 qed→W164 bridge: seeding the derivation cache from a pre-built
3476 /// artifact makes the NEXT `materialize_transform` a HIT that returns the
3477 /// seeded bytes without running the recipe. This is the whole point —
3478 /// offload the arch-locked, expensive build to a fleet worker (rusty-v8-musl), retrieve
3479 /// its tar (R590-F6), seed the cache, and let `yah cloud apply` publish the
3480 /// pre-built bytes instead of re-building under emulation.
3481 #[tokio::test]
3482 async fn seed_transform_derivation_makes_next_transform_a_hit() {
3483 let fx = Fixture::new(minio_slot());
3484 write_recipe(&fx.workspace_root, "noop-recipe");
3485
3486 let transform = TransformSpec {
3487 recipe: "noop-recipe".to_string(),
3488 params: BTreeMap::new(),
3489 };
3490 let fetched_hash = blake3_hex(b"fetch bytes");
3491
3492 // A pre-built artifact standing in for the F6-retrieved rusty_v8 tar.
3493 let prebuilt = fx.workspace_root.join("prebuilt.tar.gz");
3494 let prebuilt_bytes = b"prebuilt rusty_v8 tarball".to_vec();
3495 std::fs::write(&prebuilt, &prebuilt_bytes).unwrap();
3496
3497 let seeded =
3498 seed_transform_derivation(&fx.workspace_root, &transform, &fetched_hash, &prebuilt)
3499 .await
3500 .unwrap();
3501 assert_eq!(seeded.output_blake3, blake3_hex(&prebuilt_bytes));
3502 assert!(seeded.cas_path.exists(), "CAS entry must be written");
3503
3504 // A materialize_transform with the SAME inputs must HIT the seeded cache:
3505 // the executor is never invoked and the returned bytes are the seeded
3506 // artifact (NOT what the recipe would have produced).
3507 let fetch_path = fx.workspace_root.join(".yah/cache/derive/fetch/in.bin");
3508 std::fs::create_dir_all(fetch_path.parent().unwrap()).unwrap();
3509 std::fs::write(&fetch_path, b"fetch bytes").unwrap();
3510 let cache_dir = fx.workspace_root.join(".yah/cache/derive/transform");
3511 let (mock, invocations) = MockExecutor::new(b"RECIPE OUTPUT (must not appear)".to_vec());
3512
3513 let out = materialize_transform(
3514 &transform,
3515 &fetch_path,
3516 &fetched_hash,
3517 &seeded.output_blake3,
3518 &cache_dir,
3519 &fx.workspace_root,
3520 mock.as_ref(),
3521 )
3522 .await
3523 .unwrap();
3524
3525 assert_eq!(
3526 *invocations.lock().await,
3527 0,
3528 "seeded derivation cache must make the transform a HIT (no recipe run)"
3529 );
3530 assert_eq!(
3531 std::fs::read(&out).unwrap(),
3532 prebuilt_bytes,
3533 "HIT must return the seeded pre-built bytes",
3534 );
3535 }
3536
3537 /// W212/R518: the derivation key is hermetic — every declared input
3538 /// dimension flips it, and identical inputs reproduce it. This is the
3539 /// correctness invariant that makes the silent-stale-cache bug impossible:
3540 /// a missing dimension would let two distinct inputs collide on one key.
3541 #[test]
3542 fn derivation_key_is_hermetic_over_every_input_dimension() {
3543 let input = "a".repeat(64);
3544 let recipe = "b".repeat(64);
3545 let mut params = BTreeMap::new();
3546 params.insert("quant".to_string(), "q5_1".to_string());
3547
3548 let base = derivation_key(&input, &recipe, ¶ms);
3549
3550 // Fetched-input content (the model/version anchor) flips it.
3551 assert_ne!(base, derivation_key(&"c".repeat(64), &recipe, ¶ms));
3552 // Recipe-file bytes (steps / pinned container digest / placement) flip it.
3553 assert_ne!(base, derivation_key(&input, &"d".repeat(64), ¶ms));
3554 // A changed param value flips it.
3555 let mut p2 = params.clone();
3556 p2.insert("quant".to_string(), "q4_0".to_string());
3557 assert_ne!(base, derivation_key(&input, &recipe, &p2));
3558 // An added param flips it.
3559 let mut p3 = params.clone();
3560 p3.insert("extra".to_string(), "x".to_string());
3561 assert_ne!(base, derivation_key(&input, &recipe, &p3));
3562 // Identical inputs reproduce the key (deterministic, order-stable).
3563 assert_eq!(base, derivation_key(&input, &recipe, ¶ms));
3564 }
3565
3566 /// W212/R518: the action cache is keyed on INPUTS, so a recipe change (here
3567 /// a container-digest bump) forces a re-run even on a warm cache. This is
3568 /// the regression guard against the W164/R438 output-keyed silent-stale
3569 /// skip — the exact defect this relay fixes.
3570 #[tokio::test]
3571 async fn transform_recipe_change_busts_cache_and_reruns() {
3572 let fx = Fixture::new(minio_slot());
3573 let digest_a = HASH_64;
3574 let digest_b = "1".repeat(64);
3575 write_recipe_with_digest(&fx.workspace_root, "pinned", digest_a);
3576
3577 let out_bytes = b"transformed output".to_vec();
3578 let out_hash = blake3_hex(&out_bytes);
3579 let (mock, invocations) = MockExecutor::new(out_bytes);
3580 let executor: Arc<dyn ForgeExecutor> = mock;
3581
3582 let fetch_path = fx.workspace_root.join(".yah/cache/derive/fetch/in.bin");
3583 std::fs::create_dir_all(fetch_path.parent().unwrap()).unwrap();
3584 std::fs::write(&fetch_path, b"fetch bytes").unwrap();
3585 let in_hash = blake3_hex(b"fetch bytes");
3586
3587 let transform = TransformSpec {
3588 recipe: "pinned".to_string(),
3589 params: BTreeMap::new(),
3590 };
3591 let cache_dir = fx.workspace_root.join(".yah/cache/derive/transform");
3592 let wr = &fx.workspace_root;
3593
3594 // Run 1 — cold, digest A.
3595 materialize_transform(
3596 &transform,
3597 &fetch_path,
3598 &in_hash,
3599 &out_hash,
3600 &cache_dir,
3601 wr,
3602 executor.as_ref(),
3603 )
3604 .await
3605 .unwrap();
3606 assert_eq!(*invocations.lock().await, 1);
3607
3608 // Re-run with the SAME recipe → warm HIT, no re-run.
3609 materialize_transform(
3610 &transform,
3611 &fetch_path,
3612 &in_hash,
3613 &out_hash,
3614 &cache_dir,
3615 wr,
3616 executor.as_ref(),
3617 )
3618 .await
3619 .unwrap();
3620 assert_eq!(*invocations.lock().await, 1, "identical inputs must skip");
3621
3622 // Bump the container digest A → B. Output-keyed caching silently
3623 // skipped this; input-addressed caching MUST re-run.
3624 write_recipe_with_digest(&fx.workspace_root, "pinned", &digest_b);
3625 materialize_transform(
3626 &transform,
3627 &fetch_path,
3628 &in_hash,
3629 &out_hash,
3630 &cache_dir,
3631 wr,
3632 executor.as_ref(),
3633 )
3634 .await
3635 .unwrap();
3636 assert_eq!(
3637 *invocations.lock().await,
3638 2,
3639 "a digest bump must invalidate the cache and re-run",
3640 );
3641 }
3642
3643 /// W212/R518-P2b: the substituter fast-path honours the committed lock —
3644 /// `Some(output)` when the key recomputed from the pins matches, `None` the
3645 /// moment any input drifts (here a recipe-digest bump).
3646 #[tokio::test]
3647 async fn lock_skip_hash_honours_committed_lock() {
3648 let fx = Fixture::new(minio_slot());
3649 write_recipe_with_digest(&fx.workspace_root, "pinned", HASH_64);
3650
3651 let fetch_pin = "a".repeat(64);
3652 let out_pin = "b".repeat(64);
3653 // The lock's input_hash must equal the key the reconciler recomputes
3654 // from the committed pins.
3655 let recipe_bk = recipe_blake3(&fx.workspace_root, "pinned").await.unwrap();
3656 let key = derivation_key(&fetch_pin, &recipe_bk, &BTreeMap::new());
3657
3658 let toml = format!(
3659 r#"kind = "static-asset"
3660schema_version = "V1"
3661
3662[[asset]]
3663filename = "whisper/coreml.tar.gz"
3664blake3 = "{out_pin}"
3665
3666[asset.derive.fetch]
3667url = "https://example/config.json"
3668blake3 = "{fetch_pin}"
3669license = "mit"
3670
3671[asset.derive.transform]
3672recipe = "pinned"
3673
3674[asset.derive.lock]
3675input_hash = "{key}"
3676output_blake3 = "{out_pin}"
3677"#
3678 );
3679 let workload: StaticAssetWorkload = toml::from_str(&toml).unwrap();
3680 let entry = &workload.assets[0];
3681
3682 // Lock current → Some(output hash) (caller then HEADs the bucket).
3683 assert_eq!(
3684 lock_skip_hash(entry, &fx.workspace_root).await.as_deref(),
3685 Some(out_pin.as_str()),
3686 );
3687
3688 // Bump the recipe digest → recipe-file hash changes → recomputed key no
3689 // longer matches the lock → None (must rebuild). The exact drift the
3690 // output-keyed cache missed.
3691 write_recipe_with_digest(&fx.workspace_root, "pinned", &"1".repeat(64));
3692 assert!(
3693 lock_skip_hash(entry, &fx.workspace_root).await.is_none(),
3694 "a stale recipe must not skip the build",
3695 );
3696 }
3697
3698 /// W212/R518-P2b: a bootstrap (zero-sentinel) row never short-circuits via
3699 /// the lock — there is no pinned output to trust yet.
3700 #[tokio::test]
3701 async fn lock_skip_hash_declines_bootstrap_rows() {
3702 let fx = Fixture::new(minio_slot());
3703 write_recipe_with_digest(&fx.workspace_root, "pinned", HASH_64);
3704 let sentinel = "0".repeat(64);
3705 let toml = format!(
3706 r#"kind = "static-asset"
3707schema_version = "V1"
3708
3709[[asset]]
3710filename = "whisper/coreml.tar.gz"
3711blake3 = "{sentinel}"
3712
3713[asset.derive.fetch]
3714url = "https://example/config.json"
3715blake3 = "{sentinel}"
3716license = "mit"
3717
3718[asset.derive.transform]
3719recipe = "pinned"
3720
3721[asset.derive.lock]
3722input_hash = "whatever"
3723output_blake3 = "{sentinel}"
3724"#
3725 );
3726 let workload: StaticAssetWorkload = toml::from_str(&toml).unwrap();
3727 assert!(
3728 lock_skip_hash(&workload.assets[0], &fx.workspace_root)
3729 .await
3730 .is_none(),
3731 "bootstrap rows must always build",
3732 );
3733 }
3734
3735 /// R546-B6: the fetched-input hash is the FOURTH paste-back value. Seeding
3736 /// used to compute it, key the derivation on it, and drop it — leaving
3737 /// `[asset.derive.fetch].blake3` at the sentinel, which makes
3738 /// `lock_skip_hash` decline at its first guard for everyone but the seeding
3739 /// machine (whose local action cache masks it).
3740 #[tokio::test]
3741 async fn seed_surfaces_fetched_input_hash_and_pin_state() {
3742 let fx = Fixture::new(minio_slot());
3743 write_recipe(&fx.workspace_root, "pinned");
3744
3745 // Warm the fetch cache so materialize_fetch takes its HIT path — no
3746 // network, and the committed pin is already correct (Pinned arm).
3747 let fetch_bytes = b"upstream source tarball".to_vec();
3748 let fetch_hash = blake3_hex(&fetch_bytes);
3749 let fetch_cache = fx.workspace_root.join(".yah/cache/derive/fetch");
3750 std::fs::create_dir_all(&fetch_cache).unwrap();
3751 std::fs::write(fetch_cache.join(format!("{fetch_hash}.bin")), &fetch_bytes).unwrap();
3752
3753 let sentinel = "0".repeat(64);
3754 let workload_path = fx.workspace_root.join("workload.toml");
3755 std::fs::write(
3756 &workload_path,
3757 format!(
3758 r#"kind = "static-asset"
3759schema_version = "V1"
3760
3761[[asset]]
3762filename = "rusty-v8/x86_64.tar.gz"
3763blake3 = "{sentinel}"
3764
3765[asset.derive.fetch]
3766url = "https://example/v8.tar.gz"
3767blake3 = "{fetch_hash}"
3768license = "mit"
3769
3770[asset.derive.transform]
3771recipe = "pinned"
3772params = {{ target = "x86_64-unknown-linux-musl" }}
3773"#
3774 ),
3775 )
3776 .unwrap();
3777
3778 let prebuilt = fx.workspace_root.join("prebuilt.tar.gz");
3779 std::fs::write(&prebuilt, b"prebuilt rusty_v8 tarball").unwrap();
3780
3781 let seeded = seed_derivation_for_target(
3782 &fx.workspace_root,
3783 &workload_path,
3784 "x86_64-unknown-linux-musl",
3785 &prebuilt,
3786 )
3787 .await
3788 .unwrap();
3789
3790 assert_eq!(
3791 seeded.fetch_blake3, fetch_hash,
3792 "the hash the derivation key was keyed on must be surfaced for paste-back",
3793 );
3794 assert_eq!(seeded.fetch_pin, Some(FetchPinState::Pinned));
3795
3796 // And it really is the key input: recomputing from the surfaced value
3797 // reproduces the seeded lock's input_hash.
3798 let recipe_bk = recipe_blake3(&fx.workspace_root, "pinned").await.unwrap();
3799 let mut params = BTreeMap::new();
3800 params.insert(
3801 "target".to_string(),
3802 "x86_64-unknown-linux-musl".to_string(),
3803 );
3804 assert_eq!(
3805 derivation_key(&seeded.fetch_blake3, &recipe_bk, ¶ms),
3806 seeded.derive_key,
3807 );
3808 }
3809
3810 /// R546-B6: the three pin verdicts the seed command reports on. `Sentinel`
3811 /// is the disarmed case the ticket was filed for; `Mismatch` catches a
3812 /// moved version anchor before the operator pastes a lock that can only
3813 /// ever decline on input drift.
3814 #[test]
3815 fn classify_fetch_pin_covers_all_three_verdicts() {
3816 let fetched = blake3_hex(b"upstream");
3817 assert_eq!(
3818 classify_fetch_pin(&"0".repeat(64), &fetched),
3819 FetchPinState::Sentinel,
3820 );
3821 assert_eq!(
3822 classify_fetch_pin(&fetched.to_uppercase(), &fetched),
3823 FetchPinState::Pinned,
3824 "hash comparison is case-insensitive like the rest of the reconciler",
3825 );
3826 let stale = blake3_hex(b"a different upstream");
3827 assert_eq!(
3828 classify_fetch_pin(&stale, &fetched),
3829 FetchPinState::Mismatch {
3830 committed: stale.clone()
3831 },
3832 );
3833 }
3834
3835 /// R546-B12: a clean reconcile must SAY something. The failure this guards
3836 /// is not a wrong line, it is an absent one — a successful publish and a
3837 /// successful no-op printing identically (i.e. nothing), which is what made
3838 /// the blind `yah cloud status` view so costly to notice.
3839 #[test]
3840 fn sync_notes_distinguish_publish_from_no_op_and_are_never_empty() {
3841 // Published.
3842 let mut report = StaticAssetSyncReport::default();
3843 report.uploaded.push("a/one.bin".to_string());
3844 let notes = render_sync_notes(&report);
3845 assert!(
3846 notes.iter().any(|n| n.contains("published 1 asset")),
3847 "{notes:?}"
3848 );
3849 assert!(notes.iter().any(|n| n.contains("a/one.bin")), "{notes:?}");
3850
3851 // No-op: everything already current. Must NOT look like a publish, and
3852 // must not be silent.
3853 let mut report = StaticAssetSyncReport::default();
3854 report.already_synced.push("a/one.bin".to_string());
3855 let notes = render_sync_notes(&report);
3856 assert!(
3857 notes.iter().any(|n| n.contains("already current")),
3858 "{notes:?}"
3859 );
3860 assert!(
3861 !notes.iter().any(|n| n.contains("published")),
3862 "a no-op must not read as a publish: {notes:?}"
3863 );
3864
3865 // Bootstrap discoveries are paste-back values — the operator needs the
3866 // hashes themselves, not a count.
3867 let mut report = StaticAssetSyncReport::default();
3868 report.bootstrapped.push(BootstrappedHash {
3869 filename: "a/one.bin".into(),
3870 kind: BootstrapHashKind::Output,
3871 hash: HASH_64.to_string(),
3872 });
3873 let notes = render_sync_notes(&report);
3874 assert!(notes.iter().any(|n| n.contains(HASH_64)), "{notes:?}");
3875
3876 // The degenerate case still speaks.
3877 let notes = render_sync_notes(&StaticAssetSyncReport::default());
3878 assert_eq!(notes.len(), 1, "{notes:?}");
3879 assert!(notes[0].contains("nothing to reconcile"), "{notes:?}");
3880 }
3881
3882 #[test]
3883 fn bootstrap_output_key_input_variant() {
3884 let b = BootstrappedHash {
3885 filename: "whisper/coreml.tar.gz".into(),
3886 kind: BootstrapHashKind::Input,
3887 hash: "deadbeef".into(),
3888 };
3889 assert_eq!(
3890 bootstrap_output_key(&b),
3891 "discovered_input_hash:whisper/coreml.tar.gz",
3892 );
3893 }
3894
3895 /// R546-B8: both recipe bindings must be ABSOLUTE, even when the caller
3896 /// passes a relative workspace root.
3897 ///
3898 /// The fixture deliberately uses `TempDir::new_in(".")` rather than the
3899 /// usual absolute tempdir — that is the whole point. `yah cloud apply`
3900 /// defaults `--path` to `"."`, so `cache_dir` came out relative
3901 /// (`./.yah/cache/derive/transform/<key>.tmp`) and got handed to the recipe
3902 /// verbatim. A relative OUT survives only while the recipe stays in its
3903 /// cwd, which is why the whisper recipes never tripped it; rusty-v8's
3904 /// build-v8.sh chdirs into a scratch dir to build V8, so it wrote the
3905 /// finished tar to `<scratch>/./.yah/cache/...` inside the container and the
3906 /// bytes died with it — after a ~2h build, with the step reporting exit 0.
3907 ///
3908 /// An absolute-tempdir fixture cannot catch this: `cache_dir` is then
3909 /// absolute either way and the assertion passes against the buggy code too.
3910 #[tokio::test]
3911 async fn transform_bindings_are_absolute_from_a_relative_workspace_root() {
3912 // `TempDir::new_in(".")` hands back an ABSOLUTE path, so rebuild the
3913 // relative form by hand — cargo runs tests with cwd at the package root,
3914 // so `./<name>` resolves to the same directory.
3915 let tmp = tempfile::TempDir::new_in(".").expect("tempdir beside cwd");
3916 let workspace_root = Path::new(".").join(tmp.path().file_name().unwrap());
3917 assert!(
3918 workspace_root.is_relative(),
3919 "fixture must reproduce the relative-root case, got {}",
3920 workspace_root.display(),
3921 );
3922
3923 write_recipe(&workspace_root, "noop-recipe");
3924 let cache_dir = workspace_root.join(".yah/cache/derive/transform");
3925 std::fs::create_dir_all(&cache_dir).unwrap();
3926 let fetch_path = workspace_root.join(".yah/cache/derive/fetch/in.bin");
3927 std::fs::create_dir_all(fetch_path.parent().unwrap()).unwrap();
3928 std::fs::write(&fetch_path, b"fetch bytes").unwrap();
3929
3930 let (capture, argv) = ArgvCapture::new(true);
3931 let executor: Arc<dyn ForgeExecutor> = capture;
3932 let transform = TransformSpec {
3933 recipe: "noop-recipe".to_string(),
3934 params: BTreeMap::new(),
3935 };
3936
3937 materialize_transform(
3938 &transform,
3939 &fetch_path,
3940 &blake3_hex(b"fetch bytes"),
3941 ZERO_SENTINEL_HEX, // bootstrap: accept whatever hash comes out
3942 &cache_dir,
3943 &workspace_root,
3944 executor.as_ref(),
3945 )
3946 .await
3947 .expect("transform must succeed");
3948
3949 // Recipe argv is ["./tool", "{{YAH_TRANSFORM_IN_0}}", "{{YAH_TRANSFORM_OUT}}"].
3950 let argv = argv.lock().await.clone();
3951 assert_eq!(argv.len(), 3, "unexpected argv shape: {argv:?}");
3952 assert!(
3953 Path::new(&argv[1]).is_absolute(),
3954 "{ENV_TRANSFORM_IN_0} binding must be absolute, got {:?}",
3955 argv[1],
3956 );
3957 assert!(
3958 Path::new(&argv[2]).is_absolute(),
3959 "{ENV_TRANSFORM_OUT} binding must be absolute, got {:?}",
3960 argv[2],
3961 );
3962 }
3963
3964 /// R546-B8 hardening: a recipe that exits 0 without writing its artifact
3965 /// must be named as such. Before this, the missing file surfaced as
3966 /// `reading transform output …: No such file or directory` on a path under
3967 /// `.yah/cache/`, which reads like cache corruption and sends the reader
3968 /// into the wrong subsystem.
3969 #[tokio::test]
3970 async fn transform_that_writes_no_output_names_the_recipe_contract() {
3971 let fx = Fixture::new(minio_slot());
3972 write_recipe(&fx.workspace_root, "writes-nothing");
3973 let cache_dir = fx.workspace_root.join(".yah/cache/derive/transform");
3974 let fetch_path = fx.workspace_root.join(".yah/cache/derive/fetch/in.bin");
3975 std::fs::create_dir_all(fetch_path.parent().unwrap()).unwrap();
3976 std::fs::write(&fetch_path, b"fetch bytes").unwrap();
3977
3978 // Exit 0, write nothing — exactly what the relative-OUT bug looked like
3979 // from the reconciler's side.
3980 let (capture, _) = ArgvCapture::new(false);
3981 let executor: Arc<dyn ForgeExecutor> = capture;
3982 let transform = TransformSpec {
3983 recipe: "writes-nothing".to_string(),
3984 params: BTreeMap::new(),
3985 };
3986
3987 let err = materialize_transform(
3988 &transform,
3989 &fetch_path,
3990 &blake3_hex(b"fetch bytes"),
3991 ZERO_SENTINEL_HEX,
3992 &cache_dir,
3993 &fx.workspace_root,
3994 executor.as_ref(),
3995 )
3996 .await
3997 .expect_err("a recipe that writes nothing must fail");
3998
3999 let msg = format!("{err:#}");
4000 assert!(msg.contains("produced no output"), "{msg}");
4001 assert!(msg.contains("writes-nothing"), "{msg}");
4002 assert!(
4003 !msg.contains("No such file or directory"),
4004 "must not leak the raw io error that reads like cache corruption: {msg}",
4005 );
4006 }
4007
4008 #[tokio::test]
4009 async fn materialize_transform_blake3_mismatch_on_output_is_hard_error() {
4010 let fx = Fixture::new(minio_slot());
4011 write_recipe(&fx.workspace_root, "wrong-output");
4012 let out_bytes = b"actual output".to_vec();
4013 let actual_hash = blake3_hex(&out_bytes);
4014 let pinned_hash = HASH_64; // deliberately wrong
4015 assert_ne!(actual_hash, pinned_hash);
4016 let (mock, _) = MockExecutor::new(out_bytes);
4017 let executor: Arc<dyn ForgeExecutor> = mock;
4018
4019 let fetch_path = fx.workspace_root.join(".yah/cache/derive/fetch/in.bin");
4020 std::fs::create_dir_all(fetch_path.parent().unwrap()).unwrap();
4021 std::fs::write(&fetch_path, b"fetch bytes").unwrap();
4022
4023 let transform = TransformSpec {
4024 recipe: "wrong-output".to_string(),
4025 params: BTreeMap::new(),
4026 };
4027 let err = materialize_transform(
4028 &transform,
4029 &fetch_path,
4030 &blake3_hex(b"fetch bytes"),
4031 pinned_hash,
4032 &fx.workspace_root.join(".yah/cache/derive/transform"),
4033 &fx.workspace_root,
4034 executor.as_ref(),
4035 )
4036 .await
4037 .expect_err("transform output mismatch must surface");
4038 let msg = format!("{err:#}");
4039 assert!(msg.contains("BLAKE3"), "{msg}");
4040 assert!(msg.contains(pinned_hash), "{msg}");
4041 }
4042
4043 #[tokio::test]
4044 async fn materialize_transform_recipe_failure_surfaces_stderr() {
4045 let fx = Fixture::new(minio_slot());
4046 write_recipe(&fx.workspace_root, "failing-recipe");
4047 let executor: Arc<dyn ForgeExecutor> = MockExecutor::failing("tool exploded".to_string());
4048
4049 let fetch_path = fx.workspace_root.join(".yah/cache/derive/fetch/in.bin");
4050 std::fs::create_dir_all(fetch_path.parent().unwrap()).unwrap();
4051 std::fs::write(&fetch_path, b"fetch bytes").unwrap();
4052
4053 let transform = TransformSpec {
4054 recipe: "failing-recipe".to_string(),
4055 params: BTreeMap::new(),
4056 };
4057 let err = materialize_transform(
4058 &transform,
4059 &fetch_path,
4060 &blake3_hex(b"fetch bytes"),
4061 HASH_64,
4062 &fx.workspace_root.join(".yah/cache/derive/transform"),
4063 &fx.workspace_root,
4064 executor.as_ref(),
4065 )
4066 .await
4067 .expect_err("failing recipe must surface");
4068 let msg = format!("{err:#}");
4069 assert!(msg.contains("failing-recipe"), "{msg}");
4070 assert!(msg.contains("tool exploded"), "{msg}");
4071 }
4072
4073 #[tokio::test]
4074 async fn materialize_derive_fetch_only_uses_fetch_path_for_upload() {
4075 // No transform — materialize_asset returns the fetch cache path
4076 // directly. The downstream BLAKE3 verify happens in sync_assets.
4077 let fx = Fixture::new(minio_slot());
4078 let body = b"weights v1";
4079 let hash = blake3_hex(body);
4080
4081 use axum::routing::get;
4082 let body_c = body.to_vec();
4083 let app = axum::Router::new().route(
4084 "/w.bin",
4085 get(move || {
4086 let body = body_c.clone();
4087 async move { body }
4088 }),
4089 );
4090 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4091 let addr = listener.local_addr().unwrap();
4092 tokio::spawn(async move {
4093 axum::serve(listener, app).await.unwrap();
4094 });
4095
4096 let entry = AssetEntry {
4097 filename: "w.bin".to_string(),
4098 source: None,
4099 derive: Some(AssetDerive {
4100 fetch: FetchSource {
4101 url: format!("http://{addr}/w.bin"),
4102 blake3: BlakeHash(hash.clone()),
4103 license: License::Mit,
4104 },
4105 transform: None,
4106 lock: None,
4107 }),
4108 blake3: BlakeHash(hash.clone()),
4109 };
4110 let materialized = materialize_asset(
4111 &entry,
4112 &fx.workspace_root,
4113 &fx.workload_dir(),
4114 test_executor().as_ref(),
4115 )
4116 .await
4117 .expect("fetch-only materialize");
4118 assert!(materialized
4119 .path
4120 .to_string_lossy()
4121 .contains(".yah/cache/derive/fetch/"));
4122 assert_eq!(std::fs::read(&materialized.path).unwrap(), body);
4123 assert!(
4124 materialized.discovered_fetch_hash.is_none(),
4125 "strict mode: pinned fetch.blake3 → no discovery"
4126 );
4127 }
4128
4129 #[tokio::test]
4130 async fn materialize_fetch_bootstrap_sentinel_accepts_actual_hash() {
4131 // fetch.blake3 ships as the zero sentinel; reconciler accepts the
4132 // computed hash and names the cache by the discovered value.
4133 let fx = Fixture::new(minio_slot());
4134 let body = b"discovered-upstream";
4135 let actual_hash = blake3_hex(body);
4136
4137 use axum::routing::get;
4138 let body_c = body.to_vec();
4139 let app = axum::Router::new().route(
4140 "/blob.bin",
4141 get(move || {
4142 let body = body_c.clone();
4143 async move { body }
4144 }),
4145 );
4146 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4147 let addr = listener.local_addr().unwrap();
4148 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
4149
4150 let fetch = FetchSource {
4151 url: format!("http://{addr}/blob.bin"),
4152 blake3: BlakeHash(ZERO_SENTINEL_HEX.to_string()),
4153 license: License::Mit,
4154 };
4155 let cache_dir = fx.workspace_root.join(".yah/cache/derive/fetch");
4156 let (path, discovered) = materialize_fetch(&fetch, &cache_dir).await.unwrap();
4157 assert!(path.exists(), "cache file must exist after bootstrap fetch");
4158 assert_eq!(
4159 discovered, actual_hash,
4160 "discovered hash = actual content hash"
4161 );
4162 assert!(
4163 path.file_name()
4164 .and_then(|s| s.to_str())
4165 .map(|s| s.starts_with(&actual_hash))
4166 .unwrap_or(false),
4167 "bootstrap mode names cache by discovered hash, got {path:?}",
4168 );
4169 }
4170
4171 #[tokio::test]
4172 async fn materialize_derive_fetch_bootstrap_surfaces_discovered_hash() {
4173 // End-to-end: AssetEntry with sentinel fetch.blake3 → materialize_asset
4174 // populates discovered_fetch_hash so sync_assets can surface it.
4175 let fx = Fixture::new(minio_slot());
4176 let body = b"upstream-bytes";
4177 let actual_hash = blake3_hex(body);
4178
4179 use axum::routing::get;
4180 let body_c = body.to_vec();
4181 let app = axum::Router::new().route(
4182 "/w.bin",
4183 get(move || {
4184 let body = body_c.clone();
4185 async move { body }
4186 }),
4187 );
4188 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4189 let addr = listener.local_addr().unwrap();
4190 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
4191
4192 let entry = AssetEntry {
4193 filename: "w.bin".to_string(),
4194 source: None,
4195 derive: Some(AssetDerive {
4196 fetch: FetchSource {
4197 url: format!("http://{addr}/w.bin"),
4198 blake3: BlakeHash(ZERO_SENTINEL_HEX.to_string()),
4199 license: License::Mit,
4200 },
4201 transform: None,
4202 lock: None,
4203 }),
4204 // entry.blake3 still sentinel — upload-side verify in sync_assets
4205 // handles the output-hash discovery; here we only assert the
4206 // fetch-side discovery threads up correctly.
4207 blake3: BlakeHash(ZERO_SENTINEL_HEX.to_string()),
4208 };
4209 let materialized = materialize_asset(
4210 &entry,
4211 &fx.workspace_root,
4212 &fx.workload_dir(),
4213 test_executor().as_ref(),
4214 )
4215 .await
4216 .expect("bootstrap materialize");
4217 assert_eq!(
4218 materialized.discovered_fetch_hash.as_deref(),
4219 Some(actual_hash.as_str()),
4220 "fetch.blake3 sentinel → discovered hash returned",
4221 );
4222 }
4223
4224 /// W209/F4: discovered hashes write to `$YAH_OUTPUTS` with one line per
4225 /// (kind, filename) pair so the QED runner can route them through
4226 /// OutputMap into pipeline binds. Key shape encodes the filename so
4227 /// multi-asset workloads round-trip; the value is the raw blake3 hex.
4228 #[test]
4229 fn bootstrap_outputs_format_round_trip() {
4230 let dir = tempdir().unwrap();
4231 let outputs = dir.path().join("outputs");
4232 let bootstrapped = vec![
4233 BootstrappedHash {
4234 filename: "yah-desktop/whisper/distil-large-v3-coreml.tar.gz".to_string(),
4235 kind: BootstrapHashKind::Output,
4236 hash: "fb0afc9f3d966f5347c6dfd335adab12f1dc8ee6df18cf9e9ff90fe86f0416c0"
4237 .to_string(),
4238 },
4239 BootstrappedHash {
4240 filename: "yah-desktop/whisper/distil-large-v3-coreml.tar.gz".to_string(),
4241 kind: BootstrapHashKind::Fetch,
4242 hash: "050ffe562134208781dc316181b146a725821fff005fb4ffb6de2a6ada334a9b"
4243 .to_string(),
4244 },
4245 ];
4246 append_bootstrap_outputs(&outputs, &bootstrapped).expect("write outputs");
4247 let content = std::fs::read_to_string(&outputs).expect("read outputs");
4248 let lines: Vec<&str> = content.lines().collect();
4249 assert_eq!(lines.len(), 2, "one line per discovery");
4250 assert!(
4251 lines.contains(
4252 &"discovered_asset_blake3:yah-desktop/whisper/distil-large-v3-coreml.tar.gz=\
4253 fb0afc9f3d966f5347c6dfd335adab12f1dc8ee6df18cf9e9ff90fe86f0416c0"
4254 ),
4255 "asset output key encodes filename: {content:?}",
4256 );
4257 assert!(
4258 lines.contains(
4259 &"discovered_fetch_blake3:yah-desktop/whisper/distil-large-v3-coreml.tar.gz=\
4260 050ffe562134208781dc316181b146a725821fff005fb4ffb6de2a6ada334a9b"
4261 ),
4262 "fetch output key encodes filename: {content:?}",
4263 );
4264 }
4265
4266 /// Append, not truncate: a step that materializes N assets and emits
4267 /// other outputs in the same step must not clobber what's already in
4268 /// the file.
4269 #[test]
4270 fn bootstrap_outputs_append_preserves_prior_lines() {
4271 let dir = tempdir().unwrap();
4272 let outputs = dir.path().join("outputs");
4273 std::fs::write(&outputs, "prior_key=prior_value\n").unwrap();
4274 let bootstrapped = vec![BootstrappedHash {
4275 filename: "a.bin".to_string(),
4276 kind: BootstrapHashKind::Output,
4277 hash: HASH_64.to_string(),
4278 }];
4279 append_bootstrap_outputs(&outputs, &bootstrapped).expect("append");
4280 let content = std::fs::read_to_string(&outputs).expect("read");
4281 assert!(content.starts_with("prior_key=prior_value\n"), "prior kept");
4282 assert!(
4283 content.contains(&format!("discovered_asset_blake3:a.bin={HASH_64}")),
4284 "new line appended: {content:?}",
4285 );
4286 }
4287}