Skip to main content

workload_spec/
lib.rs

1//! `WorkloadSpec` — typed wire format for yubaba workloads.
2//!
3//! This crate is the schema source of truth. It has zero dependencies on
4//! yubaba; yubaba depends on it, not the other way around. Agents and desktop
5//! code that construct specs can link this crate without pulling in yubaba's
6//! containerd client.
7//!
8//! Three validation layers live in [`validate`]: shape (sync, no I/O),
9//! semantic (reads yubaba state), and environment (deploy-time). The schema
10//! types live at the top level.
11//!
12//! @yah:ticket(R222-T3, "Workload schema doesn't match per-kind on-disk shapes (mesofact-static)")
13//! @yah:assignee(agent:claude)
14//! @yah:at(2026-05-18T16:47:03Z)
15//! @yah:status(review)
16//! @yah:parent(R222)
17//! @yah:handoff("Picked option (a): tagged-enum Workload envelope with per-kind variants. Added Workload { MesofactStatic(MesofactStaticWorkload), Container(WorkloadSpec) } + BuildConfig in workload-spec. WorkloadSpec stays the containerd RPC wire type (now also the kind=\"container\" variant payload). xtask emit-schemas now renders workload.toml.schema.json as a oneOf over kind; schema drift test green. TS export updated. Arch doc 'workloads — colocated, not registered' rewritten to describe the envelope + both example kinds; B4 outlook updated to point at Workload.")
18//! @yah:verify("cargo check -p cloud && cargo test -p cloud && cargo check -p yah && cargo check -p agent-tools && cargo check -p yah --tests && cargo check -p agent-tools --tests")
19//! @yah:verify("cargo test -p xtask  # schema drift test must stay green")
20//! @yah:verify("cargo run -p workload-spec --bin export-ts  # idempotent regen")
21//!
22//! @yah:ticket(R256-F7, "Model mesofact container as two roles: transient build/publish job vs long-lived SSR/SPA runtime")
23//! @yah:assignee(agent:claude)
24//! @yah:at(2026-05-25T20:08:29Z)
25//! @yah:status(review)
26//! @yah:parent(R256)
27//! @yah:next("role A — build/publish job: transient task that runs the build and PUTs to the object store, then exits/GC'd; needed whenever there is a build step (SSR or not)")
28//! @yah:next("role B — SSR/SPA runtime: long-lived container, only present when the app has realtime/dynamic pages (this is what the 'only if SSR/SPA' gate applies to)")
29//! @yah:next("decide the fidelity knob: does the build run in-container (matches CI, max fidelity, costs image+cold-start) or on host with yubaba orchestrating only the serving edge?")
30//! @yah:assumes("in cloud these are separate: CI/build job produces artifacts, R2+CDN serve them, and a distinct worker serves any SSR — so one merged 'mesofact container' is the trap")
31//! @yah:handoff("BuildMode enum added to workload-spec with HostSide (default) and InContainer { image } variants. MesofactStaticWorkload gains build_mode: BuildMode (skip_serializing_if default) and ssr_runtime: Option<WorkloadSpec>. Encodes the two-role model: build step is always transient; SSR companion is optional long-lived. Fidelity knob decision: HostSide = host watcher (dev+sim), InContainer = CI-fidelity (cloud/ha). All three codegen targets updated: export-ts.rs, packages/yah/workload-spec/index.ts, .yah/schema/workload.toml.schema.json. Schema drift tests pass.")
32//! @yah:verify("cargo check -p workload-spec --locked")
33//! @yah:verify("cargo test -p xtask --locked  # schema_drift tests pass")
34//! @yah:verify("cargo test -p cloud --locked --lib  # 165 passed")
35//!
36//! @yah:ticket(R256-F9, "Almanac as a dependency manifest: orchestrator verifies I/O targets live before run; output invalidates mesofact sources cleanly")
37//! @yah:assignee(agent:claude)
38//! @yah:at(2026-05-25T21:28:15Z)
39//! @yah:status(review)
40//! @yah:parent(R256)
41//! @yah:next("almanac is a manifest declaring inputs + outputs + cadence + command — NOT a bash cron; the declared I/O is the contract")
42//! @yah:next("before a run the orchestrator verifies declared inputs exist AND output targets (e.g. the mesofact app + its source/object store) are reachable; if not → the run fails or waits/times out rather than producing orphaned output")
43//! @yah:next("almanac output invalidates downstream mesofact sources cleanly + deliberately (a declared dependency edge, not a blunt rebuild-everything) — this is the reason it's a named manifest, not a shell cron")
44//! @yah:next("decide the not-ready policy knob: fail-fast vs wait-with-timeout vs requeue")
45//! @yah:next("generalizes the OpenRouter refresher (spawn_almanac_refresher), which is the degenerate no-dependency case (output = JSON cache, no app target)")
46//! @yah:assumes("precondition enforcement lives in the shared scheduler layer (embedded by camp for dev/sim, yubaba for cloud/ha) — it needs the workload registry + xlb-net discovery to answer 'is the target up?', which a bash cron lacks")
47//! @arch:see(.yah/docs/architecture/A024-vocabulary.md)
48//! @yah:depends_on(R256-F6)
49//! @yah:handoff("AlmanacTarget (Http/Tcp probe), NotReadyPolicy (WaitWithTimeout default=5s/FailFast/Requeue), Cadence (Once/Every/Cron), and AlmanacManifest types added to workload-spec. Workload enum gains Almanac(AlmanacManifest) variant (kind='almanac'). NotReadyPolicy::WaitWithTimeout(5s) is the default — matches sim-tier spinup budget. AlmanacManifest.invalidates: Vec<MeshIdent> declares downstream cache-bust targets. export-ts.rs updated; index.ts and workload.toml.schema.json regenerated; drift tests pass. The degenerate case (no inputs, no outputs, Cron, no invalidates) is exactly the OpenRouter refresher pattern. The orchestrator precondition enforcement (xlb-net probing) is left for R276/yubaba integration.")
50//! @yah:verify("cargo check -p workload-spec --locked")
51//! @yah:verify("cargo test -p xtask --locked  # schema drift tests pass")
52//! @yah:verify("cargo test -p cloud --locked --lib  # 165 passed")
53//!
54//! @yah:relay(R335, "Almanac mirror-binding — scope a feed to the mirror it affects")
55//! @yah:at(2026-05-27T02:19:09Z)
56//! @yah:status(open)
57//! @arch:see(.yah/docs/working/W058-almanac-mirror-binding.md)
58//! @yah:depends_on(R256-F9)
59//!
60//! @yah:ticket(R335-S1, "Decide cross-env pollution mechanism: extend R256-F9 manifest vs add per-mirror capability")
61//! @yah:assignee(agent:claude)
62//! @yah:at(2026-05-27T02:19:33Z)
63//! @yah:kind(spike)
64//! @yah:status(review)
65//! @yah:phase(P1)
66//! @yah:parent(R335)
67//! @yah:gotcha("Build ON R256-F9's AlmanacManifest (workload-spec/src/lib.rs) — do NOT invent a parallel manifest. R256-F9 is in review.")
68//! @yah:depends_on(R256-F9)
69//! @yah:handoff("Decided. Recorded in almanac-mirror-binding.md §11. KEY FINDING: two almanac paths exist; the live R330 feed uses almanac::FeedConfig (on_change=MesofactRebuild{service,route} — a service id, NOT a MeshIdent), so it never touches AlmanacManifest.invalidates. Verdict on the S1 title: NEITHER extend the manifest nor (yet) add capability is the accident fix — dev->cloud is ALREADY blocked by construction (feed path = process locality + per-mirror reconciler + MinIO/R2 backend split; manifest path = no camp-embedded MeshState, mesh resolution is yubaba-raft-only). Residual holes: /revalidate receiver is UNAUTHENTICATED, and same-tier (two clouds on one R2) has no per-mirror key prefix.")
70//! @yah:next("FILED: R335-F3 (P1, no yubaba dep) mirror-aware /revalidate receiver — reject feeds not bound to this mirror; satisfies R335-T2; lands with R330-F4.")
71//! @yah:next("FILED: R335-F4 (P2) per-mirror artifact key prefix in derive_minio_key/publish_to_r2 — closes same-tier collision.")
72//! @yah:next("FILED: R335-F5 (P3, BLOCKED on yubaba control plane) per-mirror capability gate on /revalidate via yubaba/xlb-net node identity.")
73//!
74//! @yah:ticket(R278-F4, "RolloutPolicy schema in workload-spec (TOML types)")
75//! @yah:assignee(agent:claude)
76//! @yah:at(2026-06-01T02:31:25Z)
77//! @yah:status(review)
78//! @yah:parent(R278)
79//! @yah:next("Add src/rollout.rs with RolloutPolicy, RolloutStrategy, RolloutGate, RolloutStep, RolloutOnFailure")
80//! @yah:next("Export pub mod rollout from lib.rs")
81//! @yah:next("Add TS export via ts-rs in export-ts.rs")
82//! @arch:see(.yah/docs/working/W140-yah-yubaba-ci-cd.md)
83//! @yah:handoff("RolloutPolicy, RolloutStrategy, RolloutGate, RolloutStep, RolloutOnFailure added to workload-spec/src/rollout.rs. Exported from lib.rs. toml dev-dep added for round-trip test. Tests: rollout::tests::round_trip_toml + on_failure_default both green.")
84//!
85//! @yah:ticket(R429-T1, "Workload::StaticAsset variant + schema in workload-spec (catalog + aliases)")
86//! @yah:assignee(agent:claude)
87//! @yah:at(2026-06-03T23:24:20Z)
88//! @yah:status(review)
89//! @yah:phase(P1)
90//! @yah:parent(R429)
91//! @yah:next("Add Workload::StaticAsset(StaticAssetWorkload) variant alongside the existing MesofactStatic + Container envelopes. Mirror the tagged-enum shape R222-T3 established.")
92//! @yah:next("StaticAssetWorkload fields: kind='static-asset' tag, assets: Vec<AssetEntry>, aliases: BTreeMap<String, String>. AssetEntry { filename: String, source: PathBuf, blake3: BlakeHash }.")
93//! @yah:next("BlakeHash newtype validates 64-hex-char shape (reuse from existing places if available, else introduce here).")
94//! @yah:next("Closed-catalog invariant: aliases values MUST be filenames present in the assets list. Reject at load with a clear error pointing at the offending alias key + bad filename.")
95//! @yah:next("Mirror schema extension: optional [asset_aliases] BTreeMap<String, String> on MirrorConfig. Semantic validator (when both workload + mirror are loaded together) rejects mirror aliases whose target filename isn't in the catalog.")
96//! @yah:next("Regenerate the workload.toml.schema.json via xtask emit-schemas (R222-B4). Confirm the drift test stays green.")
97//! @yah:next("TS mirror: extend packages/yah/workload-spec/index.ts with the StaticAsset variant + AssetEntry. Confirm bun typecheck stays green.")
98//! @yah:verify("cargo check -p workload-spec --locked")
99//! @yah:verify("cargo test -p workload-spec")
100//! @yah:verify("cargo run -p workload-spec --bin export-ts")
101//! @yah:verify("cargo test -p xtask")
102//! @arch:see(.yah/docs/working/W160-atomic-release-waves.md)
103//!
104//! @yah:ticket(R429-F2, "static-asset reconciler: BLAKE3 verify + S3 PUT against mirror's object_store + drift")
105//! @yah:assignee(agent:claude)
106//! @yah:at(2026-06-03T23:24:38Z)
107//! @yah:status(review)
108//! @yah:phase(P2)
109//! @yah:parent(R429)
110//! @yah:next("New reconciler that handles kind='static-asset' in the same service-sync loop that already runs mesofact-static + container. Same wave-gate semantics, same drift shape.")
111//! @yah:next("For each [[asset]] row: hash source file (BLAKE3) and compare to manifest entry. Mismatch → surface as drift, halt push for that asset until rebuild.")
112//! @yah:next("Resolve mirror's object_store provider → R2 bucket + credentials. HEAD cas/filename; if absent or different content-length → PUT. Idempotent on re-run.")
113//! @yah:next("Drift detection: list bucket contents under the component's prefix, compare against catalog filenames. Files in bucket ∖ catalog → report as drift (do NOT delete; that's the prune verb's job).")
114//! @yah:next("ServicesView's existing matrix consumes the new drift shape automatically. Confirm SyncGlyph/DriftList render correctly for a static-asset row without UI changes.")
115//! @yah:next("MockR2 in tests: HashMap<key, bytes> implementing the S3 surface the reconciler hits. Cover: push first-time, push idempotent, drift catches catalog-vs-bucket mismatch, BLAKE3 mismatch halts push.")
116//! @yah:next("Real-R2 integration test gated behind YAH_TEST_R2_BUCKET env var — one round-trip against a scratch bucket; skipped otherwise.")
117//! @yah:verify("cargo check --workspace --locked")
118//! @yah:verify("cargo test -p <reconciler-crate>  # crate TBD by impl agent")
119//! @yah:verify("cargo test -p workload-spec")
120//! @yah:gotcha("Auto-delete is OFF — reconciler reports drift on bucket∖catalog files but never DELETEs. That's the prune verb (R429-T2). Easy bug to introduce when 'cleaning up drift'; don't.")
121//! @yah:gotcha("S3 multipart upload threshold matters — distil-large-v3 is ~270MB which is over the 5MB single-PUT limit on R2's strictest mode. Use aws-sdk-s3's multipart helper for assets >100MB.")
122//! @yah:gotcha("Long-running progress MUST surface in QED/task-pane per the long-running-yah-surface rule. Don't silently spin in a tokio task; model as a Task with progress events.")
123//! @arch:see(.yah/docs/working/W160-atomic-release-waves.md)
124//! @yah:depends_on(R429-T1)
125//!
126//! @yah:ticket(R429-T3, "yah service prune verb: candidate enumeration + operator-confirm delete")
127//! @yah:assignee(agent:claude)
128//! @yah:at(2026-06-03T23:24:52Z)
129//! @yah:status(review)
130//! @yah:phase(P3)
131//! @yah:parent(R429)
132//! @yah:next("yah service prune <service-name> enumerates files present in the bucket but not referenced by any current mirror's resolved alias graph. Lists candidates + sizes + last-modified, requires explicit operator confirm before DELETE.")
133//! @yah:next("Resolution graph: for each mirror, walk [asset_aliases] → catalog [aliases] → catalog [[asset]] rows. Union across all mirrors = live set. Bucket ∖ live set = prune candidates.")
134//! @yah:next("MCP tool mcp__yah__service_prune routes through approval gate (write verb). Read counterpart mcp__yah__service_prune_status auto-passes — returns the candidate list without acting.")
135//! @yah:next("Camp: Tauri command + a 'Prune candidates' panel in the existing DeployPanel for each service, showing the candidate table with per-row checkboxes + confirm.")
136//! @yah:next("Analytics-driven candidate filter (old AND unaccessed-for-N-days) is OUT OF SCOPE for this ticket — needs access logs we don't aggregate yet. The candidate set today is purely catalog-derived.")
137//! @yah:next("User-asset TTL is OUT OF SCOPE — different surface, access-pattern-based, separate relay when it lands.")
138//! @yah:verify("cargo test -p <prune-crate>")
139//! @yah:verify("yah service prune yah-desktop --dry-run lists candidates")
140//! @arch:see(.yah/docs/working/W160-atomic-release-waves.md)
141//! @yah:depends_on(R429-F2)
142//! @yah:handoff("CLI + library + MCP all landed; UI deferred to R429-F4 (filed). Library lives in crates/yah/cloud/src/reconciler/static_asset_prune.rs and exposes compute_live_set (pure resolution graph), compute_prune_candidates (live + LIST + diff), execute_prune (DELETE), and load_service_and_mirror (path helper). CLI verb is `yah cloud service prune <name> --env <env> [--dry-run] [--yes] [--format=table|json]` at app/yah/cli/src/cloud.rs (ServiceCommands::Prune + handle_service_prune). MCP tools cloud.service_prune_status (read, auto-pass, --dry-run --format=json) and cloud.service_prune (write, --yes --format=json) dispatch through build_command(). New S3 helper sign_s3_get_with_query in local-driver covers ListObjectsV2 (the existing s3_sign helpers don't handle canonical query strings); ListObjectsV2 response is parsed with a tiny hand-rolled split_tags helper to avoid a quick-xml workspace dep. Tests: 12 prune-module unit tests (live-set union, kind filtering, list response parse for single/empty/truncated/no-token, candidate filtering including catalog manifest sidecar exclusion) + 1 s3_sign helper test + 2 MCP build_command tests. cargo check --workspace clean. cargo test -p cloud --lib: 279 pass (1 pre-existing failure cloud_init::tests::embedded_template_matches_workspace_canonical unrelated, per R419-F4 docstring). cargo test -p yah --lib: 299 pass.")
143//! @yah:next("R429-F4 carries the Tauri + DeployPanel UI work — depends_on R429-T3, status=open.")
144//! @yah:verify("cargo check --workspace --locked")
145//! @yah:verify("cargo test -p cloud --lib reconciler::static_asset_prune  # 12 pass")
146//! @yah:verify("cargo test -p yah --lib mcp::tools::tests::cloud_service_prune  # 2 pass")
147//! @yah:verify("yah cloud service prune --help  # renders usage with --env/--dry-run/--yes/--format")
148//!
149//! @arch:see(.yah/docs/working/W164-derived-static-assets.md)
150//!
151//! @yah:ticket(R438-T2, "AssetEntry XOR: source vs derive + shape_static_asset rules")
152//! @yah:assignee(agent:claude)
153//! @yah:at(2026-06-04T21:06:51Z)
154//! @yah:status(review)
155//! @yah:phase(P1)
156//! @yah:parent(R438)
157//! @yah:next("AssetEntry.source: PathBuf → Option<PathBuf>")
158//! @yah:next("Add AssetEntry.derive: Option<AssetDerive> with fetch + optional transform")
159//! @yah:next("Extend shape_static_asset to enforce exactly-one(source, derive) + license closed-set")
160//! @yah:verify("Both-set and neither-set fail shape validation with ShapeError::Field")
161//! @yah:verify("Legacy TOMLs with only source still parse + serialize identically")
162//! @arch:see(.yah/docs/working/W164-derived-static-assets.md)
163//! @yah:handoff("AssetEntry now carries Option<PathBuf> source + Option<AssetDerive> derive (both skip_serializing_if). New types AssetDerive {fetch: FetchSource, transform: Option<TransformSpec>} and TransformSpec {recipe, params} added to workload-spec/src/lib.rs. validate.rs grew FieldPath::Asset(usize, &'static str) and shape_static_asset enforces XOR: both-set or neither-set fail with ShapeError::Field { path: Asset(i, \"source\") }. 4 new tests cover derive-mode round-trip, legacy source-only TOML round-trip without leaking a derive field, both-set rejection, neither-set rejection, and both-modes-accepted positive case. Cloud reconciler (static_asset.rs:360) now bails on derive-mode with a pointer to R438-T5 until the materialize step lands. 3 test fixtures updated with source: Some(...) + derive: None. export-ts regenerated (TransformSpec + AssetDerive emitted); xtask emit-schemas regenerated workload.toml.schema.json; schema_drift test green. workload-spec: 24/24, cloud static_asset: 25/25, xtask: 2/2.")
164//!
165//! @yah:ticket(R438-T3, "ImageRef digest-pin enforcement at deserialize")
166//! @yah:assignee(agent:claude)
167//! @yah:at(2026-06-04T21:06:55Z)
168//! @yah:status(review)
169//! @yah:phase(P1)
170//! @yah:parent(R438)
171//! @arch:see(.yah/docs/working/W164-derived-static-assets.md)
172//! @arch:see(.yah/docs/working/W165-mesofact-build-mode-lowering.md)
173//! @yah:handoff("ImageRef now accepts either a string form (digest-pinned, W164/W165 path) or the legacy struct form (backwards-compat for WorkloadSpec configs). String form requires @sha256:<hex> suffix: bare-tag, non-sha256, and non-hex digests all reject at serde-deserialize. Single parser compose_import::parse_pinned_image_ref is the rule's one home; T4 (recipes) and T6 (BuildMode::InContainer) will both deserialize images through this string path. Custom Deserialize uses untagged enum (Pinned(String) | Struct(Fields)); Serialize/TS/JsonSchema derives stay on the struct so wire output and TS exports are unchanged. 6 new tests cover: bare-tag reject, pinned accept (docker.io + ghcr.io), non-sha256 algorithm reject, empty/non-hex digest reject, struct-form still works with digest=None, struct-form TOML round-trip. workload-spec: 30/30 lib + 18/18 semantic + 6/6 shape_fixtures. xtask schema_drift green after emit-schemas regen. cargo check --workspace clean.")
174//! @yah:next("Tighten ImageRef workspace-wide: digest: Option<String> → digest: String (required). tag stays as the human-readable identifier; digest is the source of truth. Rationale: every image we execute should be reproducible-by-construction; the on-disk shape should make unpinned-image bugs impossible.")
175//! @yah:next("Every existing ImageRef construction site updates to pass a digest. Call sites known today (~10): yubaba integration tests (fake digests via a test helper), yubaba/runtime/{containerd,fake}, yubaba/deploy/{mesh_resolve,env_validate}, local-runtime, cloud/config, workload-spec round_trip tests, restart_policy tests, compose_import::parse_image_ref. The break is bounded — single PR, no surprise call sites outside the workspace.")
176//! @yah:next("compose_import::parse_image_ref returns Result<ImageRef, ParseImageRefError> with an UnpinnedImage variant. Docker-compose strings without @sha256: become an explicit parse error — callers must pre-resolve tags to digests (most compose imports already happen at yubaba submission time where a pinning pass can run).")
177//! @yah:next("Add task::local::test_support::test_digest() (or similar) for test fixtures — a fixed valid-format sha256 string so tests don't have to mint their own.")
178//! @yah:next("Recipe TOML loader (T4) and W165 BuildMode::InContainer (T6) inherit the new requirement for free — they consume ImageRef and digest is now structurally required.")
179//! @yah:verify("cargo check --workspace --locked passes after the tightening + call-site migration (yubaba, runtime, local-runtime, mesh_resolve, env_validate, cloud/config, compose_import)")
180//! @yah:verify("ImageRef without digest no longer constructs — parse-time + type-level enforcement. cargo test -p workload-spec round_trip + restart_policy pass with updated fixtures.")
181//! @yah:verify("compose_import::parse_image_ref(\"node:20\") → Err(UnpinnedImage); parse_image_ref(\"node:20@sha256:...\") → Ok.")
182//! @yah:verify("cargo test -p yubaba --tests + -p local-driver passes with new test_digest() helper in place of bare tags.")
183//! @yah:gotcha("Earlier framing assumed T3 needed a PinnedImageRef newtype to avoid breaking yubaba. Reversed after design discussion 2026-06-04: breaking yubaba in service of reproducibility-by-construction is the right architectural move. Digest is required workspace-wide; tag stays as a human-readable identifier. ~10 call sites migrate in one PR.")
184//! @yah:gotcha("MesofactStaticWorkload.build_mode → InContainer { image } today is declared but never executed (build always runs on host — see W165). Once T3 tightens ImageRef, the wired-up build_mode lowering in T6 inherits digest-pinning automatically, closing W165 OQ#1's escape hatch.")
185//! @yah:assumes("No production yubaba deployment ships ImageRefs we don't already digest-pin. Spot-check Hetzner/cloud yah-castle workload specs before merging the tightening; if any prod path uses tag-only, it gets pinned in the same PR.")
186//! @yah:handoff("Pushed back from review 2026-06-04 — user reaffirmed the workspace-wide tightening direction. What landed (untagged Deserialize accepting string-form OR struct-form-with-digest:Option) ships digest enforcement at the W164/W165 wire surfaces but leaves the struct-form escape hatch (digest: None still constructs). User: 'breaking yubaba in order to improve it architecturally is fine'. Final shape needs both: (a) keep the string-form parser as a recipe-author convenience (image = \"ghcr.io/x@sha256:...\"), AND (b) tighten the struct form's digest: Option<String> → String. Then both paths land at the same digest-required field and unpinned-image bugs become impossible by construction. ~10 call sites still need migration (yubaba/runtime/{containerd,fake}, yubaba/deploy/{mesh_resolve,env_validate}, local-driver/local_runtime, cloud/config, workload-spec tests/round_trip + tests/restart_policy + yubaba integration_* + yubaba/tests/integration_public_ingress + integration_operator_bridge + integration_mesh + integration_single_node). Add task::local::test_support::test_digest() returning a fixed valid-format sha256 string. Pick this up by claiming R438-T3.")
187//! @yah:handoff("Workspace-wide ImageRef tightening landed. (a) ImageRef.digest: Option<String> → String at workload-spec/src/lib.rs:923; Deserialize struct arm now requires the field; untagged string-form parser at compose_import::parse_pinned_image_ref untouched. (b) ImageRef::docker_ref() now always emits tag@digest pair (informational tag alongside content-addressed digest). (c) validate.rs ImageTag check tightened: tag must be non-empty (digest presence is type-enforced now). (d) compose_import::parse_image_ref returns Result<ImageRef, String> — alias for parse_pinned_image_ref. import_compose gained ImportError::UnpinnedImage { service, image, reason } variant; only external caller (yah workload import in app/yah/cli/src/workload.rs) propagates the error type. (e) New workload_spec::testing module (doc-hidden) exposes TEST_DIGEST const + test_digest() fn — all-zeros 64-hex sentinel. (f) task::default_image::catalog_image falls back to testing::test_digest() when the per-image env var is unset, preserving the infallible API but making unset-digest visible at runtime via docker pull failure. default_buildkit_image follows the same pattern. (g) Migrated ~22 struct-form construction sites: workload-spec tests (round_trip/semantic/restart_policy + all 15 fixture JSONs + 3 compose YAML fixtures + matching expected.json), task crate (default_image/integration/lib/local/remote), yubaba (runtime/{containerd,fake}, deploy/{mesh_resolve,env_validate}, all 4 integration_*.rs files), cloud/config (3 sites), local-driver (local_runtime + pond_ssr_runtime), scryer/beholders, kamaji/{server,native,containerd}. (h) ImageSource::pull trait signature tightened: digest: Option<&'a str> → digest: &'a str (only one impl in yubaba/env_validate). (i) Read-site cleanup: yubaba::runtime::containerd::image_ref, kamaji::containerd::image_ref, local-driver::pond_ssr_runtime::compose_image_ref, task::local::image_ref_arg — all dropped Option ceremony, always emit tag@digest. cargo check --workspace clean. cargo test -p workload-spec: 82 pass. cargo test -p task --lib: 59 pass. Pre-existing test failures in cloud (5: 1 cloud_init drift, 4 mesofact_static adopt) and yubaba tests (pond_reconciler_smoke missing ssr_runtime/worker_mode/ssr_origin fields) are unrelated to ImageRef — separate ticket. R438-T4 (recipe loader) and R438-T6 (BuildMode::InContainer) inherit digest-required structurally with zero per-consumer work.")
188//!
189//! @yah:ticket(R438-T7, "Golden tests: recipe→ForgeSpec lowering + BuildMode→ForgeSpec lowering parity")
190//! @yah:assignee(agent:claude)
191//! @yah:at(2026-06-04T21:07:30Z)
192//! @yah:status(review)
193//! @yah:phase(P3)
194//! @yah:parent(R438)
195//! @yah:next("Golden test: sample transform recipe + asset.derive.transform.params lowers to expected ForgeSpec (argv, image digest, TaskPlacement)")
196//! @yah:next("Golden test: MesofactStaticWorkload with build_mode=in_container lowers to expected ForgeSpec")
197//! @yah:next("Round-trip parity: same Subprocess + Local + Container quadrant for both consumers; regression-guards argv-substitution and image-pin drop-through")
198//! @yah:verify("cargo test -p workload-spec lowering_golden_*")
199//! @yah:verify("Golden files versioned; updates require explicit --update flag")
200//! @arch:see(.yah/docs/working/W164-derived-static-assets.md)
201//! @arch:see(.yah/docs/working/W165-mesofact-build-mode-lowering.md)
202//! @yah:depends_on(R438-T5)
203//! @yah:depends_on(R438-T6)
204//! @yah:handoff("T7 landed. (1) Extracted pure lowering helpers exposed at pub(crate):\\n  - mesofact_static::lower_build_to_forge_spec(workload_dir, &BuildConfig, &BuildMode) -> ForgeSpec (run_build now wraps this)\\n  - static_asset::lower_recipe_step_to_forge_spec(&TransformRecipe, &RecipeStep, substituted_argv) -> ForgeSpec (materialize_transform now calls this for each step)\\n(2) New cfg(test) module crates/yah/cloud/src/reconciler/lowering_golden.rs registered from reconciler/mod.rs. Five golden tests:\\n  - golden_recipe_step_lowers_to_pinned_local_container_subprocess (recipe → ForgeSpec shape: argv, image digest, timeout, label, initiator)\\n  - golden_recipe_step_with_zero_timeout_lowers_to_none (regression-guards the timeout=0 → None mapping)\\n  - golden_build_in_container_lowers_to_pinned_local_container_subprocess (BuildMode::InContainer → sh -c shell wrap + pinned image + cwd label)\\n  - golden_build_host_side_lowers_to_native_quadrant_without_image (BuildMode::HostSide → image=None + TaskRuntime::Native)\\n  - parity_recipe_and_build_in_container_share_quadrant (THE architectural invariant: both consumers land in the same Subprocess + Local + Container quadrant with sha256-pinned images and Gnome initiators — lets one ForgeExecutor dispatch handle both)\\n(3) Test artifacts are hand-coded assertions, not insta/snapshot files — workspace has no insta infra and explicit-Pin tests give clearer diff on drift than auto-update snapshots. The W164/W165 lowering shape is now regression-guarded against silent drift in either consumer. cargo test -p cloud --lib reconciler::lowering_golden: 5 pass. Workspace check clean.")
205//! @yah:next("Sign off → archive R438-T7")
206//! @yah:next("T8 (worked examples) now has tested lowering primitives to reference")
207//! @yah:verify("cargo test -p cloud --lib reconciler::lowering_golden — 5 pass")
208//! @yah:verify("cargo test -p cloud --lib reconciler:: — 124 pass; 4 pre-existing R441-B4 adopt_only failures (port 4321 dev-box collision) unrelated")
209//! @yah:verify("cargo check --workspace --locked — clean (warnings only)")
210//! @yah:verify("Parity test asserts both lowerings produce TaskPlacement{Local, Container} + ForgeCommand::Subprocess + sha256-pinned image — the shared executor dispatch invariant")
211//! @yah:gotcha("Test location pivot: original ticket said `cargo test -p workload-spec lowering_golden_*` but the lowering primitives don't live in workload-spec — ForgeSpec/TaskPlacement are in task, and the actual lowering helpers are in cloud (both consumers live there). Tests landed in cloud as `reconciler::lowering_golden`. If a future consumer outside cloud needs the BuildMode lowering, lift `lower_build_to_forge_spec` up to task::transforms alongside the existing recipe lowering primitives.")
212//! @yah:gotcha("No snapshot/insta infra in workspace — 'Golden files versioned; updates require explicit --update flag' verify line interpreted as hand-coded explicit assertions instead. Drift surfaces as a single-file test diff on the lowering helper, which is more readable than a .snap diff for the small ForgeSpec shape these tests cover.")
213
214use std::collections::BTreeMap;
215use std::collections::HashMap;
216use std::path::PathBuf;
217
218use serde::{Deserialize, Serialize};
219use ts_rs::TS;
220
221pub mod compose_import;
222pub mod rollout;
223pub mod secrets;
224pub mod validate;
225mod version;
226
227pub use version::SchemaVersion;
228
229// ── Duration ──────────────────────────────────────────────────────────────────
230
231/// Duration expressed as an integer millisecond count.
232///
233/// Used for healthcheck intervals, timeouts, delays, and stop grace periods.
234/// Chosen over `std::time::Duration` to keep serde support dependency-free.
235#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, TS)]
236#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
237#[ts(type = "number")]
238pub struct Millis(pub u64);
239
240impl Millis {
241    pub fn from_secs(s: u64) -> Self {
242        Self(s * 1000)
243    }
244
245    pub fn from_ms(ms: u64) -> Self {
246        Self(ms)
247    }
248
249    pub fn as_ms(self) -> u64 {
250        self.0
251    }
252
253    pub fn as_secs_f64(self) -> f64 {
254        self.0 as f64 / 1000.0
255    }
256}
257
258// ── Primitive newtypes ────────────────────────────────────────────────────────
259
260/// Opaque identifier for a yubaba-managed machine within the cluster.
261///
262/// Used by the semantic validation layer for admission-control capacity checks.
263/// Yubaba passes its own machine ID when validating a spec before deployment.
264#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, TS)]
265#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
266pub struct MachineId(pub String);
267
268/// DNS-segment identity for a workload on the cluster mesh, e.g.
269/// `"noisetable-api.pdx"`. Regex constraint: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`,
270/// length ≤ 63. Enforced in shape validation (R090-F2).
271#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
272#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
273pub struct MeshIdent(pub String);
274
275/// Tier classification that governs admission control and mesh `allow_from`
276/// filtering. Known values: `"public"`, `"tenant"`, `"private"`, `"infra"`.
277/// Custom tiers are allowed per cluster; shape validation warns on unknowns
278/// rather than rejecting them (R090-F2).
279#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
280#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
281pub struct TierTag(pub String);
282
283// ── Workload (on-disk envelope) ──────────────────────────────────────────────
284
285/// On-disk `workload.toml` manifest. Each variant matches one
286/// `ServiceComponent.kind` value; the `kind` field on the wire is the serde
287/// discriminator.
288///
289/// This is the **on-disk** envelope — distinct from [`WorkloadSpec`], the
290/// containerd wire format yubaba receives over RPC. A `kind = "container"`
291/// workload deserializes its remaining fields as a `WorkloadSpec`; other
292/// kinds carry their own per-reconciler payload shape.
293#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
294#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
295#[serde(tag = "kind", rename_all = "kebab-case")]
296pub enum Workload {
297    /// Static-site build that publishes an artifact directory to the
298    /// service's `static` provider slot. Reconciled by the
299    /// `mesofact-static` reconciler — does not deploy to yubaba.
300    MesofactStatic(MesofactStaticWorkload),
301
302    /// Containerd workload handed to yubaba over RPC. The inline fields
303    /// are the full [`WorkloadSpec`] minus the `kind` discriminator.
304    Container(WorkloadSpec),
305
306    /// Data-pipeline job with declared I/O and a readiness policy. The
307    /// orchestrator checks all `inputs` are reachable before each run and
308    /// verifies `outputs` afterward. Generalises the OpenRouter JSON-cache
309    /// refresher (`spawn_almanac_refresher`) to the full manifest form.
310    Almanac(AlmanacManifest),
311
312    /// Content-addressed static files uploaded to the mirror's `object_store`
313    /// provider slot. Wave-0 by default — gating mesofact and container waves.
314    /// Rollback is a pointer-flip via `mirror.toml [asset_aliases]`; bytes are
315    /// append-only and never re-pushed on rollback. See W160.
316    StaticAsset(StaticAssetWorkload),
317}
318
319/// `kind = "mesofact-static"` payload — static-site build colocated with the
320/// frontend it deploys.
321///
322/// The two-role model (R256-F7): a build/publish step plus an optional
323/// SSR/SPA runtime companion. The build step is always transient (runs once,
324/// publishes, exits). The companion is long-lived and only present when the
325/// app has dynamic/server-rendered pages; pure static sites leave it `None`.
326#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
327#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
328pub struct MesofactStaticWorkload {
329    /// Wire-format version. Always `V1` today.
330    pub schema_version: SchemaVersion,
331
332    /// Build command + output directory.
333    pub build: BuildConfig,
334
335    /// Path (relative to the manifest) of the routes module the
336    /// `mesofact-static` reconciler reads to enumerate routes.
337    pub routes: PathBuf,
338
339    /// Where the build command runs. Default: `HostSide` (mesofact-dev on the
340    /// host). Set to `InContainer` for cloud/HA where no host watcher is
341    /// present and CI-fidelity build environments are required.
342    #[serde(default, skip_serializing_if = "build_mode_is_default")]
343    pub build_mode: BuildMode,
344
345    /// Optional SSR/SPA runtime companion container.
346    ///
347    /// `None` → pure static site; Caddy (or equivalent CDN) serves all
348    /// requests directly from the object store. This is the common case for
349    /// dev-yah today.
350    ///
351    /// `Some` → the workload spec describes a long-lived container that
352    /// handles dynamic/SSR requests. Caddy routes static asset paths to
353    /// the object store and all other paths to this container. The companion
354    /// uses `RestartPolicy::Always`; the orchestrator (camp or yubaba)
355    /// ensures it stays up alongside the Caddy edge.
356    #[serde(skip_serializing_if = "Option::is_none")]
357    #[ts(optional = nullable)]
358    pub ssr_runtime: Option<WorkloadSpec>,
359}
360
361/// Build step that produces the static artifact published by a
362/// `mesofact-static` workload.
363#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
364#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
365pub struct BuildConfig {
366    /// Shell command run from the manifest's directory, e.g. `"bun run build"`.
367    pub command: String,
368
369    /// Output directory (relative to the manifest) the reconciler uploads.
370    pub out_dir: PathBuf,
371}
372
373// ── BuildMode ─────────────────────────────────────────────────────────────────
374
375/// Where the build command runs for a `mesofact-static` workload.
376///
377/// The two-role split encodes the F7 design decision: build/publish is a
378/// **transient job** (runs once, exits, GC'd); SSR/SPA serving is a separate
379/// **long-lived companion container** (optional, only for dynamic pages). A
380/// single merged "mesofact container" is the trap — in cloud, CI builds the
381/// artifact, R2+CDN serve it, and a distinct worker handles any SSR.
382///
383/// Default: `HostSide` — mesofact-dev runs the build on the host and publishes
384/// to the tier's object store. No container overhead; compatible with dev and
385/// sim tiers.
386#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
387#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
388#[serde(tag = "mode", rename_all = "snake_case")]
389pub enum BuildMode {
390    /// Build command runs on the host (mesofact-dev watcher). The watcher
391    /// publishes the output to the tier's object store (DistPointer for dev,
392    /// MinIO for sim). Compatible with all tiers; zero container overhead.
393    #[default]
394    HostSide,
395
396    /// Build runs inside a transient container matching the CI image. Higher
397    /// fidelity (environment matches CI exactly); costs image pull +
398    /// container cold-start. Required for cloud/HA where no mesofact-dev
399    /// watcher is running on the host.
400    InContainer {
401        /// Container image that runs the build (e.g. `"ghcr.io/org/app-build:v1.2"`).
402        /// Must have the build toolchain installed. The container is started with
403        /// the workspace root bind-mounted, runs `build.command`, uploads
404        /// `build.out_dir` to the object store, then exits.
405        image: ImageRef,
406    },
407}
408
409/// Returns `true` when `m` is the default `BuildMode::HostSide`, used by
410/// `skip_serializing_if` to omit the field from TOML output when it's at the
411/// default value.
412fn build_mode_is_default(m: &BuildMode) -> bool {
413    matches!(m, BuildMode::HostSide)
414}
415
416// ── AlmanacManifest ───────────────────────────────────────────────────────────
417
418/// An observable endpoint the almanac scheduler probes to check readiness.
419///
420/// Used for both inputs (checked before the run) and outputs (verified after
421/// a successful run to confirm the job produced something reachable).
422/// The probe is intentionally lightweight — no S3 SigV4, no xlb-net discovery
423/// required; a simple TCP connect or HTTP GET is enough for the dev/sim tier.
424#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
425#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
426#[serde(tag = "kind", rename_all = "snake_case")]
427pub enum AlmanacTarget {
428    /// Issue an HTTP GET to `url`; ready when the server responds with
429    /// `expect_status` (default: any 2xx).
430    Http {
431        url: String,
432        #[serde(skip_serializing_if = "Option::is_none")]
433        #[ts(optional = nullable)]
434        expect_status: Option<u16>,
435    },
436
437    /// Establish a TCP connection to `host:port`; ready when the connect
438    /// succeeds. Used for non-HTTP services (e.g. MinIO API on port 9000)
439    /// and as a lighter probe when an HTTP endpoint isn't stable yet.
440    Tcp { host: String, port: u16 },
441}
442
443/// What the almanac scheduler does when a precondition check fails.
444///
445/// The F9 design decision: `WaitWithTimeout` is the default. Fail-fast is
446/// too brittle for the sim tier (containers may still be cold-starting);
447/// requeue-with-no-ceiling can block the scheduler indefinitely. The
448/// recommended timeout for sim is the container spinup budget (~5 s cold,
449/// ~1 s warm): set `timeout` to a few seconds, then let the retry cadence
450/// handle transient glitches.
451#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
452#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
453#[serde(tag = "policy", rename_all = "snake_case")]
454pub enum NotReadyPolicy {
455    /// Wait up to `timeout` for all preconditions to pass before aborting
456    /// the run. The run is skipped (not rescheduled); the next cadence tick
457    /// will retry. Suitable when targets occasionally lag at startup.
458    WaitWithTimeout {
459        /// How long to wait for each precondition to become reachable. The
460        /// scheduler polls with a short sleep between attempts.
461        timeout: Millis,
462    },
463
464    /// Abort immediately if any precondition check fails. Suitable for
465    /// integration-test harnesses where a missing dependency is always a
466    /// hard error.
467    FailFast,
468
469    /// Requeue with exponential backoff up to `max_attempts` times. After
470    /// exhaustion the run is marked failed. Suitable for cloud/HA where
471    /// transient dependency outages are expected.
472    Requeue {
473        /// Maximum number of requeue attempts before the run is marked failed.
474        max_attempts: u32,
475        /// Initial backoff between attempts, in milliseconds.
476        backoff: Millis,
477    },
478}
479
480impl Default for NotReadyPolicy {
481    /// Default is `WaitWithTimeout { timeout: 5 seconds }` — matches the
482    /// container spinup budget for the sim tier (few-second cold, sub-second warm).
483    fn default() -> Self {
484        Self::WaitWithTimeout { timeout: Millis::from_secs(5) }
485    }
486}
487
488/// When the almanac scheduler triggers a run.
489#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
490#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
491#[serde(tag = "kind", rename_all = "snake_case")]
492pub enum Cadence {
493    /// Run once at first opportunity, then never again.
494    Once,
495
496    /// Run repeatedly with a fixed interval between the end of one run and
497    /// the start of the next. Equivalent to `sleep N && run` in a loop.
498    Every {
499        /// Minimum time between consecutive run completions.
500        interval: Millis,
501    },
502
503    /// Run on a UTC cron schedule (standard 5-field expression, e.g.
504    /// `"0 */6 * * *"` for every 6 hours). The scheduler evaluates the
505    /// expression relative to UTC midnight.
506    Cron { expression: String },
507}
508
509/// `kind = "almanac"` manifest — a declared data-pipeline job.
510///
511/// An almanac job is the generalisation of the OpenRouter refresher
512/// (`spawn_almanac_refresher`): it declares its I/O contract explicitly so
513/// the orchestrator can enforce preconditions before each run and verify
514/// outputs afterward. The degenerate case (no inputs, no app target, cron
515/// schedule) is exactly the OpenRouter JSON-cache refresher.
516///
517/// Lifecycle:
518/// 1. Cadence tick fires.
519/// 2. Scheduler probes every `inputs` target. If any fail → apply
520///    `not_ready_policy`.
521/// 3. Command runs (`sh -c command` from the workload directory).
522/// 4. Scheduler probes every `outputs` target. Failure → mark run as
523///    failed but do not retry.
524/// 5. Any workloads listed in `invalidates` receive a cache-bust signal
525///    (implementation detail of the orchestrator; in camp this is a
526///    rebuild trigger on the mesofact-dev watcher).
527#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
528#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
529pub struct AlmanacManifest {
530    /// Wire-format version. Always `V1` today.
531    pub schema_version: SchemaVersion,
532
533    /// Shell command executed via `sh -c` from the workload directory.
534    pub command: String,
535
536    /// When to run.
537    pub cadence: Cadence,
538
539    /// Input targets that must be reachable before the command runs.
540    /// Empty list → no precondition checks (degenerate case).
541    #[serde(default, skip_serializing_if = "Vec::is_empty")]
542    pub inputs: Vec<AlmanacTarget>,
543
544    /// Output targets verified after a successful run.
545    /// Empty list → no post-run verification.
546    #[serde(default, skip_serializing_if = "Vec::is_empty")]
547    pub outputs: Vec<AlmanacTarget>,
548
549    /// What to do when a precondition check fails.
550    /// Default: `WaitWithTimeout { timeout: 5000ms }`.
551    #[serde(default)]
552    pub not_ready_policy: NotReadyPolicy,
553
554    /// Mesh identities of workloads to notify after a successful run.
555    /// The orchestrator sends a cache-bust signal to each entry so
556    /// downstream consumers can reload their data (e.g. mesofact-dev
557    /// triggers a rebuild when the OpenRouter cache refreshes).
558    /// Empty list → no downstream invalidation.
559    #[serde(default, skip_serializing_if = "Vec::is_empty")]
560    pub invalidates: Vec<MeshIdent>,
561}
562
563// ── StaticAssetWorkload ───────────────────────────────────────────────────────
564
565/// BLAKE3 content hash expressed as exactly 64 ASCII hex digits.
566///
567/// This is the content-address key for every file in the static-asset catalog.
568/// Deserialization rejects values that do not conform — 64 hex chars, case
569/// insensitive. Mismatch between the recorded hash and the source file halts
570/// the upload step in the reconciler.
571#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)]
572#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
573#[ts(type = "string")]
574pub struct BlakeHash(pub String);
575
576impl<'de> Deserialize<'de> for BlakeHash {
577    fn deserialize<D>(de: D) -> Result<Self, D::Error>
578    where
579        D: serde::Deserializer<'de>,
580    {
581        let s = String::deserialize(de)?;
582        if s.len() != 64 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
583            return Err(serde::de::Error::custom(format!(
584                "blake3 hash must be exactly 64 hex digits, got {:?}",
585                s
586            )));
587        }
588        Ok(BlakeHash(s))
589    }
590}
591
592// ── License & FetchSource (W164) ──────────────────────────────────────────────
593
594/// Closed-set, parse-time-enforced license tag. Mirrors the workspace
595/// permissive-license rule (MIT / Apache-2.0 / BSD-2/3-Clause / ISC). Adding a
596/// variant is an explicit schema change — non-permissive strings
597/// (`"GPL-3.0"`, `"AGPL"`, etc.) fail at serde-deserialize before any shape
598/// validator runs.
599///
600/// Shared between `asset.derive.fetch.license` (W164, required) and a future
601/// `almanac::ReleaseSource.license` migration (R438-F10, optional).
602#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
603#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
604#[serde(rename_all = "kebab-case")]
605pub enum License {
606    Mit,
607    Apache2,
608    Bsd2Clause,
609    Bsd3Clause,
610    Isc,
611}
612
613/// Shared fetch primitive — usable by `asset.derive` today, and by Almanac's
614/// `ReleaseSource` after a follow-up migration (R438-F10). Defined once in
615/// workload-spec so both consumers reject the same set of non-permissive
616/// licenses.
617///
618/// The `blake3` hash pins the upstream bytes; mismatch at fetch time is a hard
619/// error in the reconciler. The `license` field is **required** here — every
620/// derived asset must declare its upstream license. If/when Almanac adopts
621/// `FetchSource`, the Almanac side may wrap this in a struct with
622/// `Option<License>` since release manifests have no distribution license per
623/// se.
624#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
625#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
626pub struct FetchSource {
627    /// Upstream URL fetched verbatim. Reconciler retry policy is configured
628    /// elsewhere (R438-F11); the URL itself is opaque to workload-spec.
629    pub url: String,
630
631    /// Expected BLAKE3 hash of the fetched bytes (64 hex characters). The
632    /// reconciler verifies this after download and aborts on mismatch.
633    pub blake3: BlakeHash,
634
635    /// Upstream license. Closed-set, parse-time enforced.
636    pub license: License,
637}
638
639/// Optional transform applied after a [`FetchSource`] download, lowering to a
640/// `ForgeCommand::Subprocess` via the recipe loader (R438-T4). The transform's
641/// output is content-addressed by the entry's `blake3` (the recipe runs only
642/// when the cache misses).
643#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
644#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
645pub struct TransformSpec {
646    /// Named recipe under `.yah/qed/transforms/<recipe>.toml`. Loader rejects
647    /// missing recipes at materialize time.
648    pub recipe: String,
649
650    /// `{{key}}` substitutions passed to the recipe argv at element
651    /// granularity (no shell, no string concat). Empty when the recipe is
652    /// fully parameterless.
653    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
654    pub params: BTreeMap<String, String>,
655}
656
657/// W212/R518: the committed derivation lock — the in-tree action-cache
658/// receipt. `input_hash` is the input-addressed derivation key computed over
659/// the complete declared input set (fetched-input pin ⊕ recipe-file bytes ⊕
660/// invocation params ⊕ schema version); `output_blake3` is what those inputs
661/// produced (== the entry's `blake3`). The reconciler skips the entire build
662/// (no fetch, no transform, no PUT) when the lock matches the inputs recomputed
663/// from the current pins and the bucket already holds the output — the
664/// Nix-substituter / Bazel-remote-cache behaviour. Written by the R510 bind
665/// path from the reconciler's `discovered_input_hash:<filename>` output; the
666/// `git diff` on this block is the receipt that the derivation rolled.
667#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
668#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
669pub struct DeriveLock {
670    /// Input-addressed derivation key (BLAKE3 hex). A change to any declared
671    /// input flips this, so a stale lock never produces a false skip.
672    pub input_hash: String,
673    /// Output the locked inputs produced (BLAKE3 hex; equals the entry's
674    /// `blake3`). Carried so the lock is a self-contained action-cache entry.
675    pub output_blake3: String,
676}
677
678/// Provenance chain for a derived asset: required `fetch` step, optional
679/// `transform` step. Materialized bytes replace `AssetEntry.source` for the
680/// rest of the static-asset reconcile loop.
681#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
682#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
683pub struct AssetDerive {
684    /// Upstream fetch — URL + content-pin + license.
685    pub fetch: FetchSource,
686
687    /// Post-fetch transform. `None` → the fetched bytes ARE the asset
688    /// (entry `blake3` must match fetch `blake3`).
689    #[serde(default, skip_serializing_if = "Option::is_none")]
690    #[ts(optional = nullable)]
691    pub transform: Option<TransformSpec>,
692
693    /// W212/R518: committed derivation lock (input-addressed action-cache
694    /// receipt). Absent until the first successful build writes it via the
695    /// bind path. When present and current, enables the substituter-style
696    /// build skip.
697    #[serde(default, skip_serializing_if = "Option::is_none")]
698    #[ts(optional = nullable)]
699    pub lock: Option<DeriveLock>,
700}
701
702/// A single file entry in the static-asset catalog.
703///
704/// One `[[asset]]` row per bucket object. Multiple rows for different variants
705/// (e.g. q5 and q4 whisper models) are fine — each declares its own filename
706/// and hash. The reconciler treats the catalog as exhaustive and append-only:
707/// new rows trigger a PUT; removed rows surface as drift (never a DELETE).
708///
709/// **Source-vs-derive XOR.** Exactly one of `source` or `derive` must be set.
710/// Legacy local-bytes assets keep `source = "..."`; W164 derived assets set
711/// `[asset.derive]` instead. [`validate::shape_static_asset`] enforces the
712/// XOR; both-set and neither-set are hard `ShapeError::Field`.
713#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
714#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
715pub struct AssetEntry {
716    /// Destination path within the bucket, e.g.
717    /// `"whisper/distil-large-v3-q5_1.bin"`. Must be unique in the catalog.
718    /// Used as the S3 object key by the reconciler.
719    pub filename: String,
720
721    /// Path to a local source file, relative to the `workload.toml` directory.
722    /// Mutually exclusive with `derive`.
723    #[serde(default, skip_serializing_if = "Option::is_none")]
724    #[ts(optional = nullable)]
725    pub source: Option<PathBuf>,
726
727    /// Declared fetch (+ optional transform) provenance chain. The reconciler
728    /// materializes the bytes into a content-addressed cache; the cache path
729    /// then replaces `source` for the rest of the upload pipeline. Mutually
730    /// exclusive with `source`.
731    #[serde(default, skip_serializing_if = "Option::is_none")]
732    #[ts(optional = nullable)]
733    pub derive: Option<AssetDerive>,
734
735    /// Expected BLAKE3 hash of the *final* asset bytes (64 hex characters).
736    /// For `source` mode, this is hashed before upload. For `derive` mode,
737    /// it's the post-transform (or post-fetch when no transform) output.
738    /// Mismatch aborts the upload.
739    pub blake3: BlakeHash,
740}
741
742/// `kind = "static-asset"` payload — content-addressed bucket catalog.
743///
744/// The reconciler makes the bucket match the `[[asset]]` list exactly
745/// (append-only: new rows → PUT; removed rows → drift report, not DELETE).
746/// Rollback is pointer-flip via `mirror.toml [asset_aliases]` — bytes never
747/// move during rollback.
748///
749/// **Closed-catalog invariant**: every value in `[aliases]` must be a
750/// `filename` that exists in `[[asset]]`. Enforced by
751/// [`validate::shape_static_asset`]. Mirror overrides (`[asset_aliases]` in
752/// `mirror.toml`) are bound by the same rule — the alias graph can only
753/// resolve to filenames already in the catalog.
754#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
755#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
756pub struct StaticAssetWorkload {
757    /// Wire-format version. Always `V1` today.
758    pub schema_version: SchemaVersion,
759
760    /// Exhaustive catalog of files this component manages in the bucket.
761    ///
762    /// Named `asset` on disk (TOML `[[asset]]` array-of-tables) to follow TOML
763    /// convention; accessed as `.assets` in Rust code.
764    #[serde(rename = "asset", default, skip_serializing_if = "Vec::is_empty")]
765    pub assets: Vec<AssetEntry>,
766
767    /// Canonical logical-name → filename mappings for this component.
768    ///
769    /// Values must be filenames present in `assets` — validated by
770    /// [`validate::shape_static_asset`]. Mirror files may override individual
771    /// entries via `[asset_aliases]` but may never reference filenames absent
772    /// from this catalog.
773    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
774    pub aliases: BTreeMap<String, String>,
775}
776
777// ── WorkloadSpec ──────────────────────────────────────────────────────────────
778
779/// Complete typed description of a containerd workload handed to yubaba over
780/// RPC. This is also the payload of the `kind = "container"` variant of
781/// [`Workload`] on disk.
782///
783/// Yubaba never accepts compose YAML on its RPC surface — agents, the desktop,
784/// and operator CLIs all hand yubaba `WorkloadSpec` values. See the arch doc
785/// for the validation layers and evolution rules.
786#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
787#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
788pub struct WorkloadSpec {
789    /// Wire-format version; always `V1` today. Present at the top level so
790    /// rolling clusters can detect and migrate across schema generations.
791    pub schema_version: SchemaVersion,
792
793    /// DNS-friendly workload name, e.g. `"noisetable-api"`. Regex:
794    /// `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`, length ≤ 63.
795    pub name: String,
796
797    /// Container image to pull.
798    pub image: ImageRef,
799
800    /// Tier tag controlling admission control and mesh filtering.
801    pub tier: TierTag,
802
803    /// Target replica count. `0` registers the workload without deploying it.
804    /// Range: 0–100 (cluster-wide cap; operator can raise it).
805    pub replicas: u32,
806
807    /// Override the image's `CMD`. `None` leaves the image default.
808    #[serde(skip_serializing_if = "Option::is_none")]
809    #[ts(optional = nullable)]
810    pub command: Option<Vec<String>>,
811
812    /// Override the image's `ENTRYPOINT`. `None` leaves the image default.
813    #[serde(skip_serializing_if = "Option::is_none")]
814    #[ts(optional = nullable)]
815    pub entrypoint: Option<Vec<String>>,
816
817    /// Working directory inside the container.
818    #[serde(skip_serializing_if = "Option::is_none")]
819    #[ts(optional = nullable)]
820    pub workdir: Option<PathBuf>,
821
822    /// User to run as, e.g. `"1000:1000"` or `"appuser"`.
823    #[serde(skip_serializing_if = "Option::is_none")]
824    #[ts(optional = nullable)]
825    pub user: Option<String>,
826
827    /// Environment variables. Values may be literals, secret refs, or
828    /// mesh-address references resolved by yubaba at deploy time.
829    #[serde(default, skip_serializing_if = "Vec::is_empty")]
830    pub env: Vec<EnvVar>,
831
832    /// Secret mounts. Values never appear in the spec JSON — only references.
833    #[serde(default, skip_serializing_if = "Vec::is_empty")]
834    pub secrets: Vec<SecretMount>,
835
836    /// Volume mounts.
837    #[serde(default, skip_serializing_if = "Vec::is_empty")]
838    pub volumes: Vec<VolumeMount>,
839
840    /// Hard resource caps enforced by containerd/cgroups.
841    pub resources: ResourceLimits,
842
843    /// Mesh idents that must reach `Ready` before this workload starts.
844    #[serde(default, skip_serializing_if = "Vec::is_empty")]
845    pub depends_on: Vec<MeshIdent>,
846
847    /// Container liveness/readiness probe.
848    #[serde(skip_serializing_if = "Option::is_none")]
849    #[ts(optional = nullable)]
850    pub healthcheck: Option<Healthcheck>,
851
852    /// What yubaba does when the container exits.
853    pub restart_policy: RestartPolicy,
854
855    /// Graceful shutdown configuration.
856    pub stop_policy: StopPolicy,
857
858    /// Network exposure configuration — mesh, public, and operator channels
859    /// are independent and can be set in any combination.
860    pub expose: ExposeSpec,
861
862    /// OCI-style labels, passed through to the container. Opaque to yubaba.
863    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
864    pub labels: HashMap<String, String>,
865
866    /// Yah-specific metadata, conventionally prefixed `yah.*`. Opaque to
867    /// yubaba beyond `yah.forge=true` which suppresses the Never-restart guard.
868    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
869    pub annotations: HashMap<String, String>,
870}
871
872impl WorkloadSpec {
873    /// Build a `WorkloadSpec` for a forge run.
874    ///
875    /// Sets the conventional forge fields in one place so callers cannot
876    /// forget any of them:
877    ///
878    /// - `restart_policy = Never`
879    /// - `expose.public = None`, `expose.operator = None`
880    /// - `expose.mesh.identity = "forge.<forge_id>"`
881    /// - `annotations["yah.forge"] = "true"` (suppresses the shape warning)
882    /// - `tier` and `image` come from the caller; `ports` becomes the mesh
883    ///   port list (empty is valid — forge jobs often don't expose ports)
884    ///
885    /// All other fields are set to safe defaults. Callers can mutate the
886    /// returned value to fill in `command`, `env`, `resources`, etc.
887    pub fn for_forge(
888        forge_id: &str,
889        image: ImageRef,
890        tier: TierTag,
891        ports: Vec<u16>,
892    ) -> Self {
893        let mut annotations = HashMap::new();
894        annotations.insert("yah.forge".into(), "true".into());
895
896        WorkloadSpec {
897            schema_version: SchemaVersion::V1,
898            name: format!("forge-{forge_id}"),
899            image,
900            tier,
901            replicas: 1,
902            command: None,
903            entrypoint: None,
904            workdir: None,
905            user: None,
906            env: vec![],
907            secrets: vec![],
908            volumes: vec![],
909            resources: ResourceLimits {
910                memory_mb: 256,
911                cpu_shares: 512,
912                ephemeral_storage_mb: 512,
913            },
914            depends_on: vec![],
915            healthcheck: None,
916            restart_policy: RestartPolicy::Never,
917            stop_policy: StopPolicy {
918                signal: 15,
919                grace_period: Millis::from_secs(30),
920            },
921            expose: ExposeSpec {
922                mesh: MeshExpose {
923                    identity: MeshIdent(format!("forge.{forge_id}")),
924                    ports,
925                    allow_from: vec![],
926                },
927                public: None,
928                operator: None,
929            },
930            labels: HashMap::new(),
931            annotations,
932        }
933    }
934
935    /// Whether this workload requests the **host network namespace** rather
936    /// than an isolated one.
937    ///
938    /// Opt-in via `annotations["yah.network"] == "host"` (see
939    /// [`HOST_NETWORK_ANNOTATION`] / [`HOST_NETWORK_VALUE`]). Default is the
940    /// isolated netns every other workload gets — host networking is a
941    /// privileged escape hatch for the few infra workloads that must bind a
942    /// host port so an on-host ingress (e.g. a Cloudflare tunnel reaching
943    /// `127.0.0.1:<port>`) can route to them without CNI/bridge plumbing.
944    ///
945    /// The backend (kamaji) is responsible for **guarding** this: host
946    /// networking is only honoured for `tier == "infra"` workloads; a
947    /// non-infra workload that sets the annotation is rejected at deploy. See
948    /// `validate_spec_for_constable`.
949    pub fn wants_host_network(&self) -> bool {
950        self.annotations
951            .get(HOST_NETWORK_ANNOTATION)
952            .map(|v| v == HOST_NETWORK_VALUE)
953            .unwrap_or(false)
954    }
955}
956
957/// Annotation key requesting a workload share the host network namespace.
958/// See [`WorkloadSpec::wants_host_network`].
959pub const HOST_NETWORK_ANNOTATION: &str = "yah.network";
960
961/// Annotation value (for [`HOST_NETWORK_ANNOTATION`]) selecting host
962/// networking. Any other value leaves the workload in an isolated netns.
963pub const HOST_NETWORK_VALUE: &str = "host";
964
965// ── ImageRef ─────────────────────────────────────────────────────────────────
966
967/// Container image reference identifying a specific image to pull.
968///
969/// **Digest is required.** Every executable image reference in the workspace
970/// is content-addressed by `sha256:<hex>`. The `tag` is preserved as a
971/// human-readable identifier but is not the source of truth — registries
972/// return mutable `tag → digest` mappings and we don't trust them for
973/// reproducibility. R438-T3 tightened `digest: Option<String> → String` to
974/// make unpinned-image bugs impossible by construction.
975///
976/// **Two deserialize shapes.** The struct form
977/// (`registry`/`repository`/`tag`/`digest` fields) is the on-disk envelope.
978/// A **string form** (`image = "ghcr.io/foo/bar:v1@sha256:<hex>"`) is also
979/// accepted and is the shape W164 transform recipes (R438-T4) and W165
980/// `BuildMode::InContainer` (R438-T6) use. Both shapes go through a single
981/// parser ([`compose_import::parse_pinned_image_ref`]) that rejects
982/// bare-tag references at serde-deserialize.
983#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)]
984#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
985pub struct ImageRef {
986    /// Registry hostname, e.g. `"ghcr.io"` or `"localhost:5000"`.
987    pub registry: String,
988
989    /// Repository path, e.g. `"noisetable/api"`.
990    pub repository: String,
991
992    /// Tag, e.g. `"v1.4.2"` or `"latest"`. Informational — the digest is
993    /// the source of truth for image identity.
994    pub tag: String,
995
996    /// Content-addressed pinned identity, e.g. `"sha256:abc..."`. Required.
997    pub digest: String,
998}
999
1000impl<'de> Deserialize<'de> for ImageRef {
1001    fn deserialize<D>(de: D) -> Result<Self, D::Error>
1002    where
1003        D: serde::Deserializer<'de>,
1004    {
1005        #[derive(Deserialize)]
1006        struct Fields {
1007            registry: String,
1008            repository: String,
1009            tag: String,
1010            digest: String,
1011        }
1012
1013        #[derive(Deserialize)]
1014        #[serde(untagged)]
1015        enum Repr {
1016            // Order matters for `untagged`: try the string form first so
1017            // explicit strings don't get coerced into a struct error.
1018            Pinned(String),
1019            Struct(Fields),
1020        }
1021
1022        match Repr::deserialize(de)? {
1023            Repr::Pinned(s) => {
1024                compose_import::parse_pinned_image_ref(&s).map_err(serde::de::Error::custom)
1025            }
1026            Repr::Struct(f) => Ok(ImageRef {
1027                registry: f.registry,
1028                repository: f.repository,
1029                tag: f.tag,
1030                digest: f.digest,
1031            }),
1032        }
1033    }
1034}
1035
1036// ── testing helpers ───────────────────────────────────────────────────────────
1037
1038/// Fixture helpers for test code that needs to construct types whose schemas
1039/// would otherwise demand operator-pinned values (digests, hashes). Doc-hidden
1040/// to discourage misuse from non-test code — production paths must source
1041/// digests from registry resolution or compile-time injection.
1042#[doc(hidden)]
1043pub mod testing {
1044    /// Fixed valid-format sha256 digest for test fixtures. All-zeros marker
1045    /// is impossible for any real image, so a leaked test fixture in a
1046    /// production code-path surfaces obviously.
1047    pub const TEST_DIGEST: &str =
1048        "sha256:0000000000000000000000000000000000000000000000000000000000000000";
1049
1050    /// Owned `String` form of [`TEST_DIGEST`] for fixture constructors.
1051    pub fn test_digest() -> String {
1052        TEST_DIGEST.to_string()
1053    }
1054}
1055
1056// ── EnvVar ────────────────────────────────────────────────────────────────────
1057
1058/// A single environment variable injected into the container.
1059#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1060#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1061pub struct EnvVar {
1062    /// Variable name, conventionally `SCREAMING_SNAKE_CASE`.
1063    pub name: String,
1064
1065    /// Value source.
1066    pub value: EnvValue,
1067}
1068
1069/// Value source for an environment variable.
1070#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1071#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1072#[serde(tag = "type", rename_all = "snake_case")]
1073pub enum EnvValue {
1074    /// Static string baked into the spec.
1075    Literal { value: String },
1076
1077    /// Resolved from a yubaba secret at deploy time; the secret value never
1078    /// appears in the spec JSON.
1079    FromSecret { secret: String, key: String },
1080
1081    /// Resolved from another workload's mesh address at deploy time by yubaba.
1082    /// Lets workloads reference each other symbolically without IP pinning.
1083    FromMesh { ident: MeshIdent, kind: MeshLookup },
1084}
1085
1086/// Which aspect of a mesh peer's address to inject.
1087#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
1088#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1089#[serde(rename_all = "snake_case")]
1090pub enum MeshLookup {
1091    /// Full URL, e.g. `"http://noisetable-db.pdx:5432"`.
1092    Url,
1093    /// Hostname only, e.g. `"noisetable-db.pdx"`.
1094    Host,
1095    /// Port only, e.g. `"5432"`.
1096    Port,
1097}
1098
1099// ── Secrets ───────────────────────────────────────────────────────────────────
1100
1101/// A secret value mounted into the container as an env var or file.
1102///
1103/// The secret value never appears in the spec JSON — only the reference.
1104/// Yubaba audits secret access per workload from these references.
1105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1106#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1107pub struct SecretMount {
1108    /// Where yubaba reads the secret value from.
1109    pub source: SecretRef,
1110
1111    /// How the secret is surfaced inside the container.
1112    pub target: SecretTarget,
1113}
1114
1115/// Where yubaba resolves the secret value from.
1116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1117#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1118#[serde(tag = "type", rename_all = "snake_case")]
1119pub enum SecretRef {
1120    /// Per-machine yubaba secret store at `/var/lib/yah/yubaba/secrets/`.
1121    LocalFile { path: PathBuf },
1122
1123    /// Raft-replicated cluster secret spanning all machines (planned; not in
1124    /// V1 deployment). Sketch preserved for wire compatibility.
1125    Cluster { name: String },
1126}
1127
1128/// How the secret is surfaced inside the container.
1129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1130#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1131#[serde(tag = "type", rename_all = "snake_case")]
1132pub enum SecretTarget {
1133    /// Injected as an environment variable. Value never appears in spec JSON.
1134    /// Prefer `File` — env vars leak through subprocess env and log dumps.
1135    EnvVar { name: String },
1136
1137    /// Mounted as a file inside the container at `path` with `mode` (octal).
1138    File { path: PathBuf, mode: u32 },
1139}
1140
1141// ── Volumes ───────────────────────────────────────────────────────────────────
1142
1143/// A volume mount inside the container.
1144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1145#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1146pub struct VolumeMount {
1147    /// Backing volume source.
1148    pub source: VolumeSource,
1149
1150    /// Absolute path inside the container.
1151    pub target: PathBuf,
1152
1153    /// Whether the container sees the volume as read-only.
1154    pub read_only: bool,
1155}
1156
1157/// Backing source for a volume mount.
1158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1159#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1160#[serde(tag = "type", rename_all = "snake_case")]
1161pub enum VolumeSource {
1162    /// Yubaba-managed named volume; created on first use.
1163    Named { name: String },
1164
1165    /// Operator-managed host path. Yubaba rejects bind mounts unless
1166    /// `WorkloadSpec.tier == "infra"`; shape validation enforces this.
1167    Bind { host_path: PathBuf },
1168
1169    /// In-memory tmpfs; discarded on container stop. `size_mb` caps space
1170    /// consumed by the writable layer.
1171    Tmpfs { size_mb: u32 },
1172}
1173
1174// ── Resources ─────────────────────────────────────────────────────────────────
1175
1176/// Hard resource caps enforced by containerd/cgroups at runtime.
1177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1178#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1179pub struct ResourceLimits {
1180    /// Maximum RAM the container may allocate, in MiB. The container is OOM-
1181    /// killed if it exceeds this.
1182    pub memory_mb: u32,
1183
1184    /// CPU weight (Linux `cpu.shares`). 1024 ≈ one full core.
1185    pub cpu_shares: u32,
1186
1187    /// Cap on the writable layer + tmpfs footprint, in MiB.
1188    pub ephemeral_storage_mb: u32,
1189}
1190
1191// ── Healthcheck ───────────────────────────────────────────────────────────────
1192
1193/// Container health probe configuration.
1194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1195#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1196pub struct Healthcheck {
1197    /// The probe executed to determine container health.
1198    pub probe: HealthProbe,
1199
1200    /// How often the probe runs.
1201    pub interval: Millis,
1202
1203    /// Per-probe timeout; a slow response counts as failure.
1204    pub timeout: Millis,
1205
1206    /// Time to wait after container start before the first probe. Shape
1207    /// validation warns (not errors) if this is less than
1208    /// `stop_policy.grace_period * 2`.
1209    pub initial_delay: Millis,
1210
1211    /// Number of consecutive failures before the container is marked
1212    /// `Unhealthy`.
1213    pub failure_threshold: u32,
1214}
1215
1216/// Mechanism used to check container health.
1217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1218#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1219#[serde(tag = "type", rename_all = "snake_case")]
1220pub enum HealthProbe {
1221    /// HTTP GET to `path` on `port`. A 2xx (or `expect_status` if set)
1222    /// response counts as healthy.
1223    HttpGet {
1224        path: String,
1225        port: u16,
1226        #[serde(skip_serializing_if = "Option::is_none")]
1227        #[ts(optional = nullable)]
1228        expect_status: Option<u16>,
1229    },
1230
1231    /// Run `argv` inside the container; exit-0 counts as healthy.
1232    Exec { argv: Vec<String> },
1233
1234    /// TCP connection to `port`; a successful connect counts as healthy.
1235    TcpConnect { port: u16 },
1236}
1237
1238// ── Restart / Stop ────────────────────────────────────────────────────────────
1239
1240/// What yubaba does when the container exits.
1241#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
1242#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1243#[serde(tag = "type", rename_all = "snake_case")]
1244pub enum RestartPolicy {
1245    /// Restart unconditionally on any exit.
1246    Always,
1247
1248    /// Restart on non-zero exit, up to `max_attempts` times with exponential
1249    /// backoff. After exhaustion, the workload is marked `Failed`.
1250    OnFailure {
1251        max_attempts: u32,
1252        backoff: BackoffPolicy,
1253    },
1254
1255    /// Do not restart. The container runs once and exits.
1256    ///
1257    /// **Forge convention.** Forge runs (R094) synthesize a `WorkloadSpec`
1258    /// using [`WorkloadSpec::for_forge`] which sets all the conventional fields
1259    /// together:
1260    ///
1261    /// - `restart_policy = Never`
1262    /// - `expose.public = None`, `expose.operator = None`
1263    /// - `expose.mesh.identity = "forge.<forge_id>"` — distinguishable from
1264    ///   persistent mirror identities at the mesh layer
1265    /// - `tier = "infra"` (or the forge-spec's effective tier)
1266    /// - `annotations["yah.forge"] = "true"` — suppresses the shape warning
1267    ///
1268    /// Using `Never` on a persistent mirror (not a forge run) means the mirror
1269    /// stays dead after any exit — a likely misconfiguration. Shape validation
1270    /// emits a soft warning unless `annotations["yah.forge"] == "true"` is
1271    /// present. See R094 forge.
1272    Never,
1273}
1274
1275/// Exponential backoff parameters for `RestartPolicy::OnFailure`.
1276#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
1277#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1278pub struct BackoffPolicy {
1279    /// Initial delay before the first restart, in milliseconds.
1280    pub initial_ms: u32,
1281
1282    /// Maximum delay between retries, in milliseconds.
1283    pub max_ms: u32,
1284
1285    /// Backoff multiplier applied to each successive delay.
1286    pub multiplier: f32,
1287}
1288
1289/// Graceful shutdown configuration for yubaba's stop sequence.
1290#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1291#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1292pub struct StopPolicy {
1293    /// Signal number sent first, e.g. `15` (SIGTERM) or `2` (SIGINT).
1294    pub signal: i32,
1295
1296    /// Time yubaba waits after sending `signal` before issuing SIGKILL.
1297    pub grace_period: Millis,
1298}
1299
1300// ── Expose ────────────────────────────────────────────────────────────────────
1301
1302/// Network exposure configuration. The three channels are independent; any
1303/// combination is valid.
1304#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1305#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1306pub struct ExposeSpec {
1307    /// Mesh-internal exposure. Required; every workload must have a mesh
1308    /// identity even if no other workload currently reaches it.
1309    pub mesh: MeshExpose,
1310
1311    /// Public internet exposure via a Cloudflare tunnel route. `None` means
1312    /// the workload is not internet-reachable.
1313    #[serde(skip_serializing_if = "Option::is_none")]
1314    #[ts(optional = nullable)]
1315    pub public: Option<PublicExpose>,
1316
1317    /// Operator-facing exposure via a Tailscale ACL tag. `None` means the
1318    /// workload is not operator-reachable via Tailscale.
1319    #[serde(skip_serializing_if = "Option::is_none")]
1320    #[ts(optional = nullable)]
1321    pub operator: Option<OperatorExpose>,
1322}
1323
1324/// Mesh-internal port exposure and peer access control.
1325#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1326#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1327pub struct MeshExpose {
1328    /// DNS-segment mesh identity for this workload. Must be unique in the
1329    /// cluster. Regex: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`, length ≤ 63.
1330    pub identity: MeshIdent,
1331
1332    /// Container-side ports this workload listens on. Other workloads reach
1333    /// it at `<identity>:<port>` on the mesh.
1334    pub ports: Vec<u16>,
1335
1336    /// Which tiers may initiate connections to this workload on the mesh. An
1337    /// empty list means no peer restriction (yubaba default: allow all).
1338    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1339    pub allow_from: Vec<TierTag>,
1340}
1341
1342/// Public internet exposure via a Cloudflare tunnel route.
1343#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1344#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1345pub struct PublicExpose {
1346    /// Public hostname to route, e.g. `"api.noisetable.io"`. Semantic
1347    /// validation checks that this hostname is owned by a configured CF zone.
1348    pub hostname: String,
1349
1350    /// Container-side port to route traffic to. Shape validation requires this
1351    /// port to appear in `expose.mesh.ports`.
1352    pub port: u16,
1353
1354    /// TLS configuration for the public endpoint.
1355    pub tls: PublicTls,
1356}
1357
1358/// TLS mode for a public endpoint.
1359#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1360#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1361#[serde(tag = "type", rename_all = "snake_case")]
1362pub enum PublicTls {
1363    /// Cloudflare manages the TLS certificate (default; requires a proxied DNS
1364    /// record in the configured zone).
1365    CfManaged,
1366
1367    /// User-supplied certificate referenced by name in the yubaba secret store.
1368    UserCertRef { name: String },
1369}
1370
1371/// Operator-facing exposure via a Tailscale ACL tag.
1372#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1373#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1374pub struct OperatorExpose {
1375    /// Tailscale ACL tag granting access, e.g. `"tag:noisetable-ops"`. Semantic
1376    /// validation checks that this tag exists in the cluster's Tailscale ACL.
1377    pub tailscale_tag: String,
1378
1379    /// Container-side port to expose to Tailscale-authorized operators.
1380    pub port: u16,
1381}
1382
1383// ── ImageRef helpers ──────────────────────────────────────────────────────────
1384
1385impl ImageRef {
1386    /// Format this reference as a Docker-compatible image string,
1387    /// `{registry}/{repository}:{tag}@{digest}`. Tag is included for human
1388    /// readability; the digest is what the pull resolves against.
1389    pub fn docker_ref(&self) -> String {
1390        format!("{}/{}:{}@{}", self.registry, self.repository, self.tag, self.digest)
1391    }
1392}
1393
1394// ── WorkloadRuntime trait ─────────────────────────────────────────────────────
1395
1396/// Shared interface for deploying and managing `WorkloadSpec` containers.
1397///
1398/// This is the keystone abstraction (R256-F10) that makes sim and cloud
1399/// literally interchangeable at the container level:
1400///
1401/// - **Camp/sim tier**: `LocalDockerRuntime` in `cloud` implements this trait
1402///   via the docker CLI pointed at OrbStack (or any Docker-compatible socket).
1403///   No mesh — containers communicate over OrbStack's bridge network.
1404///
1405/// - **Yubaba/cloud-HA tier**: `yubaba::runtime::ContainerRuntime` (gRPC to
1406///   containerd) will implement this trait. Mesh assignment is a separate
1407///   orchestration step on top (handled by yubaba's raft layer), not part
1408///   of the shared deploy/supervise interface.
1409///
1410/// Callers that type against `WorkloadRuntime` automatically work with both
1411/// backends. Reconcilers in `cloud` use it today; yubaba wires its own impl
1412/// when R276 Tier-3 lands.
1413#[async_trait::async_trait]
1414pub trait WorkloadRuntime: Send + Sync {
1415    /// Deploy a workload described by `spec`. Pulls the image if needed,
1416    /// creates and starts the container, and returns an opaque workload ID
1417    /// (typically the container name derived from `spec.name`).
1418    ///
1419    /// Idempotent: re-deploying a running workload replaces it cleanly.
1420    async fn deploy_workload(&self, spec: &WorkloadSpec) -> anyhow::Result<String>;
1421
1422    /// Tear down a deployed workload — stop the process and remove all
1423    /// associated state. No-op when the workload is already gone.
1424    async fn teardown_workload(&self, name: &str) -> anyhow::Result<()>;
1425
1426    /// Returns `true` when the named workload is currently running (i.e.
1427    /// the container process is alive and has not exited).
1428    async fn is_running(&self, name: &str) -> anyhow::Result<bool>;
1429
1430    /// Probe the runtime backend. Returns `true` when the backend socket is
1431    /// reachable and healthy (e.g. docker daemon up, containerd gRPC up).
1432    /// Used by health endpoints and startup checks.
1433    async fn runtime_health(&self) -> anyhow::Result<bool>;
1434}
1435
1436// ── Tests ─────────────────────────────────────────────────────────────────────
1437
1438#[cfg(test)]
1439mod tests {
1440    use super::*;
1441
1442    const HASH_64: &str = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
1443
1444    #[test]
1445    fn blake_hash_accepts_64_hex() {
1446        let h: BlakeHash = toml::from_str(&format!("x = \"{HASH_64}\""))
1447            .map(|t: toml::Table| t["x"].as_str().unwrap().to_owned())
1448            .map(|s| serde_json::from_value(serde_json::Value::String(s)).unwrap())
1449            .unwrap();
1450        assert_eq!(h.0, HASH_64);
1451    }
1452
1453    #[test]
1454    fn blake_hash_rejects_wrong_length() {
1455        let short = "abcdef";
1456        let res: Result<BlakeHash, _> =
1457            serde_json::from_value(serde_json::Value::String(short.into()));
1458        assert!(res.is_err());
1459    }
1460
1461    #[test]
1462    fn blake_hash_rejects_non_hex() {
1463        let bad = "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz";
1464        let res: Result<BlakeHash, _> =
1465            serde_json::from_value(serde_json::Value::String(bad.into()));
1466        assert!(res.is_err());
1467    }
1468
1469    #[test]
1470    fn static_asset_workload_round_trips() {
1471        let src = format!(
1472            r#"
1473schema_version = "V1"
1474
1475[[asset]]
1476filename = "whisper/distil-large-v3-q5_1.bin"
1477source   = "sources/distil-large-v3-q5_1.bin"
1478blake3   = "{HASH_64}"
1479
1480[[asset]]
1481filename = "whisper/distil-large-v3-q4_0.bin"
1482source   = "sources/distil-large-v3-q4_0.bin"
1483blake3   = "{HASH_64}"
1484
1485[aliases]
1486"whisper-default" = "whisper/distil-large-v3-q5_1.bin"
1487"#
1488        );
1489        let w: StaticAssetWorkload = toml::from_str(&src).expect("parse");
1490        assert_eq!(w.assets.len(), 2);
1491        assert_eq!(w.assets[0].filename, "whisper/distil-large-v3-q5_1.bin");
1492        assert_eq!(w.assets[0].blake3.0, HASH_64);
1493        assert_eq!(w.aliases["whisper-default"], "whisper/distil-large-v3-q5_1.bin");
1494
1495        let back = toml::to_string(&w).expect("serialize");
1496        let w2: StaticAssetWorkload = toml::from_str(&back).expect("re-parse");
1497        assert_eq!(w, w2);
1498    }
1499
1500    #[test]
1501    fn license_round_trip_each_variant() {
1502        // Wire format is whatever serde's `rename_all = "kebab-case"` emits.
1503        // heck's kebab-case keeps letter→digit attached but splits digit→uppercase,
1504        // so `Apache2 → "apache2"` and `Bsd2Clause → "bsd2-clause"`.
1505        for (variant, on_wire) in [
1506            (License::Mit, "mit"),
1507            (License::Apache2, "apache2"),
1508            (License::Bsd2Clause, "bsd2-clause"),
1509            (License::Bsd3Clause, "bsd3-clause"),
1510            (License::Isc, "isc"),
1511        ] {
1512            let ser = serde_json::to_value(variant).expect("serialize");
1513            assert_eq!(ser, serde_json::Value::String(on_wire.into()));
1514            let back: License = serde_json::from_value(ser).expect("deserialize");
1515            assert_eq!(back, variant);
1516        }
1517    }
1518
1519    #[test]
1520    fn license_rejects_non_permissive_variants() {
1521        for unknown in ["GPL-3.0", "AGPL", "lgpl-2.1", "unknown", "MIT"] {
1522            let res: Result<License, _> =
1523                serde_json::from_value(serde_json::Value::String(unknown.into()));
1524            assert!(res.is_err(), "expected rejection for {unknown:?}");
1525        }
1526    }
1527
1528    #[test]
1529    fn fetch_source_round_trips() {
1530        let src = format!(
1531            r#"
1532url     = "https://example.invalid/upstream.bin"
1533blake3  = "{HASH_64}"
1534license = "mit"
1535"#
1536        );
1537        let fs: FetchSource = toml::from_str(&src).expect("parse");
1538        assert_eq!(fs.url, "https://example.invalid/upstream.bin");
1539        assert_eq!(fs.blake3.0, HASH_64);
1540        assert_eq!(fs.license, License::Mit);
1541
1542        let back = toml::to_string(&fs).expect("serialize");
1543        let fs2: FetchSource = toml::from_str(&back).expect("re-parse");
1544        assert_eq!(fs, fs2);
1545    }
1546
1547    #[test]
1548    fn fetch_source_rejects_unknown_license() {
1549        let src = format!(
1550            r#"
1551url     = "https://example.invalid/upstream.bin"
1552blake3  = "{HASH_64}"
1553license = "GPL-3.0"
1554"#
1555        );
1556        let res: Result<FetchSource, _> = toml::from_str(&src);
1557        assert!(res.is_err(), "expected non-permissive license to reject");
1558    }
1559
1560    #[test]
1561    fn asset_entry_derive_mode_round_trips() {
1562        let src = format!(
1563            r#"
1564schema_version = "V1"
1565
1566[[asset]]
1567filename = "whisper/distil-large-v3-q5_1.bin"
1568blake3   = "{HASH_64}"
1569
1570[asset.derive.fetch]
1571url     = "https://example.invalid/ggml-distil-large-v3.bin"
1572blake3  = "{HASH_64}"
1573license = "mit"
1574
1575[asset.derive.transform]
1576recipe = "whisper-quantize"
1577params = {{ quant = "q5_1" }}
1578"#
1579        );
1580        let w: StaticAssetWorkload = toml::from_str(&src).expect("parse");
1581        assert_eq!(w.assets.len(), 1);
1582        let entry = &w.assets[0];
1583        assert!(entry.source.is_none());
1584        let derive = entry.derive.as_ref().expect("derive present");
1585        assert_eq!(derive.fetch.url, "https://example.invalid/ggml-distil-large-v3.bin");
1586        assert_eq!(derive.fetch.license, License::Mit);
1587        let transform = derive.transform.as_ref().expect("transform present");
1588        assert_eq!(transform.recipe, "whisper-quantize");
1589        assert_eq!(transform.params.get("quant").map(String::as_str), Some("q5_1"));
1590
1591        let back = toml::to_string(&w).expect("serialize");
1592        let w2: StaticAssetWorkload = toml::from_str(&back).expect("re-parse");
1593        assert_eq!(w, w2);
1594    }
1595
1596    #[test]
1597    fn legacy_source_only_asset_serializes_without_derive_field() {
1598        // Verify the skip_serializing_if guards keep legacy TOMLs round-tripping
1599        // without ever emitting an empty `derive = ...` line.
1600        let src = format!(
1601            r#"
1602schema_version = "V1"
1603
1604[[asset]]
1605filename = "operator-curated.bin"
1606source   = "sources/operator-curated.bin"
1607blake3   = "{HASH_64}"
1608"#
1609        );
1610        let w: StaticAssetWorkload = toml::from_str(&src).expect("parse");
1611        let back = toml::to_string(&w).expect("serialize");
1612        assert!(!back.contains("derive"), "serialized output leaked a derive field: {back}");
1613        let w2: StaticAssetWorkload = toml::from_str(&back).expect("re-parse");
1614        assert_eq!(w, w2);
1615    }
1616
1617    /// W212/R518: the `[asset.derive.lock]` block round-trips through TOML, and
1618    /// is omitted from output when absent (so non-derive / unlocked assets stay
1619    /// clean).
1620    #[test]
1621    fn derive_lock_round_trips_through_toml() {
1622        let toml = r#"
1623url     = "https://example.invalid/config.json"
1624blake3  = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1625license = "mit"
1626"#;
1627        let fetch: FetchSource = ::toml::from_str(toml).unwrap();
1628        let derive = AssetDerive {
1629            fetch,
1630            transform: Some(TransformSpec {
1631                recipe: "whisper-bundle-tar".into(),
1632                params: BTreeMap::new(),
1633            }),
1634            lock: Some(DeriveLock {
1635                input_hash: "1111111111111111111111111111111111111111111111111111111111111111".into(),
1636                output_blake3: "2222222222222222222222222222222222222222222222222222222222222222".into(),
1637            }),
1638        };
1639        let s = ::toml::to_string(&derive).unwrap();
1640        assert!(s.contains("[lock]"), "lock serialized: {s}");
1641        let back: AssetDerive = ::toml::from_str(&s).unwrap();
1642        assert_eq!(derive, back);
1643
1644        // Absent lock → no `[lock]` table in the output.
1645        let unlocked = AssetDerive { lock: None, ..derive };
1646        let s2 = ::toml::to_string(&unlocked).unwrap();
1647        assert!(!s2.contains("[lock]"), "unlocked must omit lock: {s2}");
1648    }
1649
1650    #[test]
1651    fn shape_static_asset_rejects_both_source_and_derive() {
1652        use crate::validate::{shape_static_asset, FieldPath, ShapeError};
1653
1654        let entry = AssetEntry {
1655            filename: "ambiguous.bin".into(),
1656            source: Some("sources/ambiguous.bin".into()),
1657            derive: Some(AssetDerive {
1658                fetch: FetchSource {
1659                    url: "https://example.invalid/x".into(),
1660                    blake3: BlakeHash(HASH_64.into()),
1661                    license: License::Mit,
1662                },
1663                transform: None,
1664                lock: None,
1665            }),
1666            blake3: BlakeHash(HASH_64.into()),
1667        };
1668        let w = StaticAssetWorkload {
1669            schema_version: SchemaVersion::V1,
1670            assets: vec![entry],
1671            aliases: BTreeMap::new(),
1672        };
1673        let err = shape_static_asset(&w).expect_err("XOR violated");
1674        match err {
1675            ShapeError::Field { path: FieldPath::Asset(0, "source"), .. } => {}
1676            other => panic!("expected Asset(0, \"source\") shape error, got {other:?}"),
1677        }
1678    }
1679
1680    #[test]
1681    fn shape_static_asset_rejects_neither_source_nor_derive() {
1682        use crate::validate::{shape_static_asset, FieldPath, ShapeError};
1683
1684        let entry = AssetEntry {
1685            filename: "empty.bin".into(),
1686            source: None,
1687            derive: None,
1688            blake3: BlakeHash(HASH_64.into()),
1689        };
1690        let w = StaticAssetWorkload {
1691            schema_version: SchemaVersion::V1,
1692            assets: vec![entry],
1693            aliases: BTreeMap::new(),
1694        };
1695        let err = shape_static_asset(&w).expect_err("XOR violated");
1696        match err {
1697            ShapeError::Field { path: FieldPath::Asset(0, "source"), .. } => {}
1698            other => panic!("expected Asset(0, \"source\") shape error, got {other:?}"),
1699        }
1700    }
1701
1702    #[test]
1703    fn shape_static_asset_accepts_either_mode() {
1704        use crate::validate::shape_static_asset;
1705
1706        let legacy = AssetEntry {
1707            filename: "a.bin".into(),
1708            source: Some("sources/a.bin".into()),
1709            derive: None,
1710            blake3: BlakeHash(HASH_64.into()),
1711        };
1712        let derived = AssetEntry {
1713            filename: "b.bin".into(),
1714            source: None,
1715            derive: Some(AssetDerive {
1716                fetch: FetchSource {
1717                    url: "https://example.invalid/b".into(),
1718                    blake3: BlakeHash(HASH_64.into()),
1719                    license: License::Apache2,
1720                },
1721                transform: None,
1722                lock: None,
1723            }),
1724            blake3: BlakeHash(HASH_64.into()),
1725        };
1726        let w = StaticAssetWorkload {
1727            schema_version: SchemaVersion::V1,
1728            assets: vec![legacy, derived],
1729            aliases: BTreeMap::new(),
1730        };
1731        shape_static_asset(&w).expect("both modes accepted");
1732    }
1733
1734    #[test]
1735    fn image_ref_string_form_rejects_bare_tag() {
1736        let res: Result<ImageRef, _> =
1737            serde_json::from_value(serde_json::Value::String("node:20".into()));
1738        let err = res.expect_err("bare-tag must reject");
1739        let msg = format!("{err}");
1740        assert!(msg.contains("digest"), "error should mention digest: {msg}");
1741    }
1742
1743    #[test]
1744    fn image_ref_string_form_accepts_digest_pinned() {
1745        let pinned = format!("node:20@sha256:{HASH_64}");
1746        let img: ImageRef =
1747            serde_json::from_value(serde_json::Value::String(pinned.clone())).expect("parse");
1748        assert_eq!(img.registry, "docker.io");
1749        assert_eq!(img.repository, "library/node");
1750        assert_eq!(img.tag, "20");
1751        assert_eq!(img.digest, format!("sha256:{HASH_64}"));
1752    }
1753
1754    #[test]
1755    fn image_ref_string_form_accepts_ghcr_with_pin() {
1756        let pinned = format!("ghcr.io/foo/bar:v1.7.4@sha256:{HASH_64}");
1757        let img: ImageRef =
1758            serde_json::from_value(serde_json::Value::String(pinned)).expect("parse");
1759        assert_eq!(img.registry, "ghcr.io");
1760        assert_eq!(img.repository, "foo/bar");
1761        assert_eq!(img.tag, "v1.7.4");
1762        assert!(img.digest.starts_with("sha256:"));
1763    }
1764
1765    #[test]
1766    fn image_ref_string_form_rejects_non_sha256_digest() {
1767        for bad in [
1768            "node:20@md5:abcdef",
1769            "node:20@sha1:abcdef",
1770            "node:20@sha256:",
1771            "node:20@sha256:zzznothex",
1772        ] {
1773            let res: Result<ImageRef, _> =
1774                serde_json::from_value(serde_json::Value::String(bad.into()));
1775            assert!(res.is_err(), "expected reject for {bad:?}");
1776        }
1777    }
1778
1779    #[test]
1780    fn image_ref_struct_form_rejects_missing_digest() {
1781        // Digest is now structurally required (R438-T3). Struct-form payloads
1782        // without `digest` must fail at serde-deserialize.
1783        let v = serde_json::json!({
1784            "registry": "ghcr.io",
1785            "repository": "noisetable/api",
1786            "tag": "v1.4.2",
1787        });
1788        let res: Result<ImageRef, _> = serde_json::from_value(v);
1789        assert!(res.is_err(), "missing digest must reject");
1790    }
1791
1792    #[test]
1793    fn image_ref_struct_form_round_trips_through_toml() {
1794        let img = ImageRef {
1795            registry: "ghcr.io".into(),
1796            repository: "ggerganov/whisper.cpp".into(),
1797            tag: "v1.7.4".into(),
1798            digest: format!("sha256:{HASH_64}"),
1799        };
1800        let toml_doc = toml::to_string(&img).expect("serialize");
1801        let back: ImageRef = toml::from_str(&toml_doc).expect("re-parse");
1802        assert_eq!(img, back);
1803    }
1804
1805    #[test]
1806    fn workload_envelope_dispatches_static_asset() {
1807        let src = format!(
1808            r#"
1809kind = "static-asset"
1810schema_version = "V1"
1811
1812[[asset]]
1813filename = "foo/bar.bin"
1814source   = "sources/bar.bin"
1815blake3   = "{HASH_64}"
1816"#
1817        );
1818        let w: Workload = toml::from_str(&src).expect("parse");
1819        assert!(matches!(w, Workload::StaticAsset(_)));
1820    }
1821}