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//!
214//! @yah:ticket(R594-F2, "Ingress workload kind in workload-spec: pinned-per-node appliance on public-ip-tainted machines")
215//! @yah:status(review)
216//! @yah:assignee(agent:claude)
217//! @yah:at(2026-07-03T06:03:30Z)
218//! @yah:phase(P2)
219//! @yah:parent(R594)
220//! @yah:next("Add the ingress workload kind to the Workload enum (lib.rs:296) as an appliance in the R572 archetype sense: pinned-per-node, non-drainable, placed by yubaba only on machines carrying a public-ip taint, supervised by kamaji. Depends on R572-F1 (lifecycle archetype discriminator) so the archetype field exists to mark it. Breaking change is fine (pre-release house style); update kamaji-bin server.rs InvalidSpec rejection list deliberately — kamaji MUST accept this kind (it supervises the proxy), unlike MesofactStatic/Almanac/StaticAsset.")
221//! @yah:verify("cargo test -p yah-workload-spec; cargo check -p yubaba -p kamaji-bin; kamaji admission accepts kind=ingress in a unit fixture")
222//! @yah:gotcha("RUNS SOLO: workload-spec is the shared-type DAG sink (yah-base) — every lane (yubaba, kamaji, qed, host app) rebuilds on its change. Pause all other wave-2/3 implementer lanes while this is active, and check R572-T2 (cpu_millis, Handoff) + R572-F1 owner state before claiming — same file.")
223//! @yah:depends_on(R572-F1)
224//! @yah:tier(Cleric)
225//! @yah:handoff("Modeled the W267 public-ingress appliance as a container-shaped workload (Workload::Container(WorkloadSpec)), not a new Workload variant: mark archetype = Some(LifecycleArchetype::Appliance) (R572-F1, pinned/non-drainable) and declare the public-ip placement requirement via a new annotation-based marker on WorkloadSpec (same zero-blast-radius pattern as existing wants_host_network/HOST_NETWORK_ANNOTATION, chosen specifically to avoid the ~26-call-site churn a new plain field forced for R572-F1's archetype field, and to avoid an exhaustive-match update in peer-owned kamaji-proto/codec.rs that a new Workload variant would force). Added: WorkloadSpec::requires_taint() -> Option<&str>, const REQUIRES_TAINT_ANNOTATION = \"yah.placement.requires-taint\", const PUBLIC_IP_TAINT = \"public-ip\", plus doc comments on Workload::Container recording the modeling decision and its rationale, all in oss/yah-base/crates/workload-spec/src/lib.rs (single file changed). This only declares the requirement as inert metadata — matching taint field on machine TOML is R572-F3 (not yet present) and scheduler enforcement is R572-F5; both out of scope here, noted in the doc comments. Verified kamaji needs NO change: deploy_workload's match in kamaji-bin/src/server.rs already dispatches any Workload::Container(_) to the containerd backend regardless of tier/annotations (only MesofactStatic/Almanac/StaticAsset hit the InvalidSpec rejection arm), confirmed by reading the code and by the existing deploy_container_without_feature_says_so / deploy_mesofast_static_is_rejected_as_invalid_spec unit tests both still passing unmodified. 2 new unit tests added (ingress_marked_spec_is_appliance_and_carries_public_ip_placement_requirement, ingress_marked_spec_round_trips_through_json_as_a_container_workload). cargo test -p yah-workload-spec --lib: 38/38 pass. cargo test -p yah-workload-spec --test round_trip: 7 pass, exactly the same pre-existing 2 postcard failures (round_trip_full_spec_through_postcard, workload_container_round_trips_through_postcard — R590-B3, unrelated) as before this change, confirmed not increased. cargo check -p yah-workload-spec / -p yubaba / -p kamaji-bin all clean, plus full cargo check --workspace in both oss/kamaji and oss/yubaba clean (only pre-existing unrelated warnings). No peer-owned file touched or needed.")
226//!
227//! @yah:ticket(R590-B10, "forge workload 256MB cgroup memory limit SIGKILLs real builds — rusty-v8 checkout OOMs (bumped to 32GB stopgap)")
228//! @yah:at(2026-07-12T00:14:52Z)
229//! @yah:status(review)
230//! @yah:assignee(agent:claude)
231//! @yah:parent(R590)
232//! @yah:severity(blocks-on-box-green)
233//! @yah:next("Proper fix: thread a per-step memory request from the pipeline (QedStep) through ForgeSpec -> WorkloadSpec so a build declares its footprint, instead of a blanket forge default. Also consider: build_oci_spec should treat memory_mb==0 as 'omit the cgroup limit' (unlimited) so dedicated build-workers aren't capped by an arbitrary constant; pair with a node-sized default. Revisit the 32GB stopgap once per-step resources land.")
234//! @yah:verify("yah qed run rusty-v8-musl on us-west-002 completes the checkout + gn/ninja compile without an OOM SIGKILL; a small forge task still runs (32GB is a ceiling, not a reservation).")
235//! @yah:gotcha("PROVEN live (2026-07-11): with B7 networking fixed, the rusty-v8 build cloned the full V8 tree then `git checkout third_party/icu` DIED OF SIGNAL 9 (OOM). WorkloadSpec::for_forge set resources.memory_mb=256, which build_oci_spec turns into a hard cgroup memory.limit. /tmp is a RAM-backed tmpfs so the multi-GB source checkout counts against that 256MB too. Bumped for_forge to 32768 (32GB) as a CLI-side stopgap; verified the build proceeds past icu.")
236//! @yah:handoff("FIXED + PROVEN LIVE (2026-07-11). WorkloadSpec::for_forge memory_mb 256 -> 32768 (oss/yah-base/crates/workload-spec/src/lib.rs). CLI-only change (spec is client-built), no kamaji redeploy. RESULT: with B7 networking, the rusty-v8 build previously OOM'd (SIGKILL/signal 9) at the icu git-checkout under the 256MB cgroup cap; now it clones the full V8 tree AND proceeds past icu into cargo/gn compilation (Compiling icu_locale_data/icu_calendar_data...) with task RUNNING. Stopgap 32GB ceiling; proper per-step memory request from the pipeline is the follow-up in the ticket body.")
237//!
238//! @yah:ticket(R546-B7, "workload_spec::Workload envelope is externally tagged (missing serde tag=kind) — no flat on-disk workload.toml can parse through it, broke yah cloud apply for EVERY static-asset component")
239//! @yah:at(2026-07-20T23:52:55Z)
240//! @yah:status(open)
241//! @yah:parent(R546)
242//! @yah:next("DO NOT simply add `#[serde(tag = \"kind\")]` without checking postcard: `Workload` is also a postcard wire type on the kamaji RPC path (kamaji-proto/src/codec.rs matches on it; round_trip tests exist). postcard is non-self-describing and cannot decode internally-tagged enums, so naive tagging risks breaking the kamaji wire. Decide deliberately: (a) tag it and prove the postcard round-trips still pass, or (b) split the types — an on-disk `WorkloadManifest` with tag=kind, leaving `Workload` as the untagged wire type.")
243//! @yah:next("INTERIM FIX ALREADY LANDED (unblocks publishing): static_asset.rs::load_workload no longer routes through the envelope — it deserializes a small `KindProbe { kind }`, validates kind == \"static-asset\", then parses `StaticAssetWorkload` directly. Same approach seed_derivation_for_target already used successfully. This restored `yah cloud apply` and got the x86_64 rusty-v8 artifact published to the CDN (HTTP 200). The ENVELOPE itself is still broken for every other caller/kind.")
244//! @yah:next("Fix the test/example disagreement: lib.rs ~L2544 should assert the FLAT `kind = \"...\"` shape that real files use, and examples/parse_whisper_toml.rs should run in CI so this cannot regress silently again.")
245//! @yah:gotcha("SEVERITY: this silently broke `yah cloud apply` for EVERY static-asset component, not just rusty-v8. Verified against the long-published whisper catalog via the repo's own examples/parse_whisper_toml.rs, which panics with the identical error — so the breakage is general and pre-existing, not caused by the R546 hash edits.")
246//! @yah:gotcha("ROOT CAUSE: `pub enum Workload` (oss/yah-base/crates/workload-spec/src/lib.rs ~L386) derives Deserialize with ONLY `#[serde(rename_all = \"kebab-case\")]` — there is NO `#[serde(tag = \"kind\")]`, despite its own doc comment stating 'the `kind` field on the wire is the serde discriminator'. Without the tag it is EXTERNALLY tagged, so serde wants a map with exactly ONE key (the variant name). Every real workload.toml is FLAT (`kind = \"static-asset\"` + `schema_version` + `[[asset]]` + `[aliases]`), i.e. a multi-key map -> `TomlError: wanted exactly 1 element, more than 1 element`, reported confusingly at line 1 col 1.")
247//! @yah:gotcha("WHY THE UNIT TEST DIDN'T CATCH IT: the passing test at lib.rs ~L2544 feeds the EXTERNALLY-tagged shape `[[static-asset.asset]]`, which no on-disk file actually uses. So the test asserts the broken encoding and the example (parse_whisper_toml.rs) asserting the REAL encoding was never run in CI. The test and the example disagree; the example is right.")
248
249use std::collections::BTreeMap;
250use std::collections::HashMap;
251use std::path::PathBuf;
252
253use serde::{Deserialize, Serialize};
254use ts_rs::TS;
255
256pub mod compose_import;
257pub mod control_plane_install;
258pub mod rollout;
259pub mod secrets;
260pub mod validate;
261mod version;
262
263pub use version::SchemaVersion;
264
265// ── Duration ──────────────────────────────────────────────────────────────────
266
267/// Duration expressed as an integer millisecond count.
268///
269/// Used for healthcheck intervals, timeouts, delays, and stop grace periods.
270/// Chosen over `std::time::Duration` to keep serde support dependency-free.
271#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, TS)]
272#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
273#[ts(type = "number")]
274pub struct Millis(pub u64);
275
276impl Millis {
277    pub fn from_secs(s: u64) -> Self {
278        Self(s * 1000)
279    }
280
281    pub fn from_ms(ms: u64) -> Self {
282        Self(ms)
283    }
284
285    pub fn as_ms(self) -> u64 {
286        self.0
287    }
288
289    pub fn as_secs_f64(self) -> f64 {
290        self.0 as f64 / 1000.0
291    }
292}
293
294// ── Primitive newtypes ────────────────────────────────────────────────────────
295
296/// Opaque identifier for a yubaba-managed machine within the cluster.
297///
298/// Used by the semantic validation layer for admission-control capacity checks.
299/// Yubaba passes its own machine ID when validating a spec before deployment.
300#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, TS)]
301#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
302pub struct MachineId(pub String);
303
304/// DNS-segment identity for a workload on the cluster mesh, e.g.
305/// `"noisetable-api.pdx"`. Regex constraint: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`,
306/// length ≤ 63. Enforced in shape validation (R090-F2).
307#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
308#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
309pub struct MeshIdent(pub String);
310
311/// Tier classification that governs admission control and mesh `allow_from`
312/// filtering. Known values: `"public"`, `"tenant"`, `"private"`, `"infra"`.
313/// Custom tiers are allowed per cluster; shape validation warns on unknowns
314/// rather than rejecting them (R090-F2).
315#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
316#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
317pub struct TierTag(pub String);
318
319/// Default single-tenant identity written to specs that predate the tenant
320/// axis (W206). Its concrete string is arbitrary — what matters is that a
321/// single-tenant cluster only ever sees this one value, so every per-tenant
322/// isolation primitive collapses to a no-op. See [`TenantId::singleton`].
323pub const DEFAULT_TENANT: &str = "default";
324
325/// Default single-namespace identity for specs that predate the namespace
326/// axis (W206). See [`NamespaceId::singleton`].
327pub const DEFAULT_NAMESPACE: &str = "default";
328
329/// Tenant **isolation** axis (W206). Separates one operator's workloads from
330/// another's at the network / DB / mesh-identity level. Orthogonal to
331/// [`NamespaceId`] (routing/naming) and [`TierTag`] (workload class within a
332/// `(tenant, namespace)` pair).
333///
334/// **Degenerate case:** when a yubaba reconciler sees only one `TenantId`
335/// across every workload on a machine, per-tenant Podman networks collapse
336/// into the shared tier networks, the tenant prefix on mesh identity is
337/// dropped, and PostgreSQL role separation is skipped — isolation primitives
338/// become no-ops. You pay only when more than one tenant is present. Specs
339/// written before this axis existed deserialize to [`TenantId::singleton`].
340#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, TS)]
341#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
342pub struct TenantId(pub String);
343
344impl TenantId {
345    /// The singleton tenant used for back-compat with single-tenant (current)
346    /// deployments. Specs written before the tenant axis existed deserialize
347    /// to this value via the `#[serde(default)]` on [`WorkloadSpec::tenant`],
348    /// keeping the whole cluster single-tenant so every isolation primitive
349    /// stays a no-op.
350    pub fn singleton() -> Self {
351        Self(DEFAULT_TENANT.to_string())
352    }
353
354    /// Whether this is the singleton (degenerate single-tenant) identity.
355    pub fn is_singleton(&self) -> bool {
356        self.0 == DEFAULT_TENANT
357    }
358}
359
360/// Namespace **routing/naming** axis (W206). A pure naming key that never
361/// affects isolation: it selects the config root, disambiguates service DNS
362/// names within a tenant, prefixes object-store bucket names within a tenant's
363/// bucket scope, and selects the provider zone (e.g. `noisetable.com` vs
364/// `yah.dev`). Two namespaces in the same tenant share networks, mesh-identity
365/// space, and PG cluster — they simply cannot collide on workload names or
366/// external domains. Specs written before this axis existed deserialize to
367/// [`NamespaceId::singleton`].
368#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, TS)]
369#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
370pub struct NamespaceId(pub String);
371
372impl NamespaceId {
373    /// The singleton namespace used for back-compat with single-namespace
374    /// (current) deployments. Specs written before the namespace axis existed
375    /// deserialize to this value via the `#[serde(default)]` on
376    /// [`WorkloadSpec::namespace`].
377    pub fn singleton() -> Self {
378        Self(DEFAULT_NAMESPACE.to_string())
379    }
380
381    /// Whether this is the singleton (degenerate single-namespace) identity.
382    pub fn is_singleton(&self) -> bool {
383        self.0 == DEFAULT_NAMESPACE
384    }
385}
386
387// ── Workload (on-disk envelope) ──────────────────────────────────────────────
388
389/// On-disk `workload.toml` manifest. Each variant matches one
390/// `ServiceComponent.kind` value; the `kind` field on the wire is the serde
391/// discriminator.
392///
393/// This is the **on-disk** envelope — distinct from [`WorkloadSpec`], the
394/// containerd wire format yubaba receives over RPC. A `kind = "container"`
395/// workload deserializes its remaining fields as a `WorkloadSpec`; other
396/// kinds carry their own per-reconciler payload shape.
397#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
398#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
399#[serde(rename_all = "kebab-case")]
400pub enum Workload {
401    /// Static-site build that publishes an artifact directory to the
402    /// service's `static` provider slot. Reconciled by the
403    /// `mesofact-static` reconciler — does not deploy to yubaba.
404    MesofactStatic(MesofactStaticWorkload),
405
406    /// Containerd workload handed to yubaba over RPC. The inline fields
407    /// are the full [`WorkloadSpec`] minus the `kind` discriminator.
408    ///
409    /// This is also the shape of the W267 sovereign-public-ingress appliance
410    /// (R594-F2): a container-kind workload with `archetype =
411    /// Some(LifecycleArchetype::Appliance)` and
412    /// `requires_taint() == Some(PUBLIC_IP_TAINT)`, **not** a dedicated
413    /// `Workload::ingress(..)` variant. It runs an ordinary OCI image (the
414    /// `passway` proxy, R594-F4) supervised by kamaji exactly like any other
415    /// `Container`, so no admission-list or wire-codec change was needed to
416    /// let kamaji accept it. A new enum variant would have forced an
417    /// exhaustive-match update in every `Workload` consumer, including
418    /// peer-owned `kamaji-proto/src/codec.rs` — the archetype + annotation
419    /// combination expresses "this is the public ingress appliance" without
420    /// that blast radius. See [`WorkloadSpec::requires_taint`] and
421    /// [`LifecycleArchetype::Appliance`].
422    Container(WorkloadSpec),
423
424    /// Data-pipeline job with declared I/O and a readiness policy. The
425    /// orchestrator checks all `inputs` are reachable before each run and
426    /// verifies `outputs` afterward. Generalises the OpenRouter JSON-cache
427    /// refresher (`spawn_almanac_refresher`) to the full manifest form.
428    Almanac(AlmanacManifest),
429
430    /// Content-addressed static files uploaded to the mirror's `object_store`
431    /// provider slot. Wave-0 by default — gating mesofact and container waves.
432    /// Rollback is a pointer-flip via `mirror.toml [asset_aliases]`; bytes are
433    /// append-only and never re-pushed on rollback. See W160.
434    StaticAsset(StaticAssetWorkload),
435}
436
437/// `kind = "mesofact-static"` payload — static-site build colocated with the
438/// frontend it deploys.
439///
440/// The two-role model (R256-F7): a build/publish step plus an optional
441/// SSR/SPA runtime companion. The build step is always transient (runs once,
442/// publishes, exits). The companion is long-lived and only present when the
443/// app has dynamic/server-rendered pages; pure static sites leave it `None`.
444#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
445#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
446pub struct MesofactStaticWorkload {
447    /// Wire-format version. Always `V1` today.
448    pub schema_version: SchemaVersion,
449
450    /// Build command + output directory.
451    pub build: BuildConfig,
452
453    /// Path (relative to the manifest) of the routes module the
454    /// `mesofact-static` reconciler reads to enumerate routes.
455    pub routes: PathBuf,
456
457    /// Where the build command runs. Default: `HostSide` (mesofact-dev on the
458    /// host). Set to `InContainer` for cloud/HA where no host watcher is
459    /// present and CI-fidelity build environments are required.
460    #[serde(default)]
461    pub build_mode: BuildMode,
462
463    /// Optional SSR/SPA runtime companion container.
464    ///
465    /// `None` → pure static site; Caddy (or equivalent CDN) serves all
466    /// requests directly from the object store. This is the common case for
467    /// dev-yah today.
468    ///
469    /// `Some` → the workload spec describes a long-lived container that
470    /// handles dynamic/SSR requests. Caddy routes static asset paths to
471    /// the object store and all other paths to this container. The companion
472    /// uses `RestartPolicy::Always`; the orchestrator (camp or yubaba)
473    /// ensures it stays up alongside the Caddy edge.
474    #[ts(optional = nullable)]
475    pub ssr_runtime: Option<WorkloadSpec>,
476
477    /// Serve-time reference to a published W272 bundle (R599-F4).
478    ///
479    /// `Some` → the built app is deployed as a content-addressed bundle that
480    /// kamaji materializes from the bundle store (R599-F1) and serves via its
481    /// native backend, instead of (or in addition to) the build reconciler
482    /// pushing `dist/` to the object-store/CDN. `None` → legacy
483    /// build-and-publish-only workload — kamaji rejects that form as yubaba's
484    /// `mesofact-static` reconciler's responsibility.
485    ///
486    /// No `skip_serializing_if`: like `ssr_runtime`, this field is always
487    /// encoded so the postcard wire codec (non-self-describing, positional)
488    /// round-trips — `skip_serializing_if` would omit the byte on serialize
489    /// while decode still expects it. `#[serde(default)]` keeps every existing
490    /// `mesofact-static` TOML/JSON that predates this field parsing to `None`.
491    #[serde(default)]
492    #[ts(optional = nullable)]
493    pub serve_bundle: Option<MesofactServeBundle>,
494}
495
496/// Serve-time reference to a published W272 bundle (R599-F4) — the
497/// `{bundle_digest, runtime, lifecycle}` triple a `mesofact-static` workload
498/// carries when kamaji, not the build reconciler, serves it.
499#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
500#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
501pub struct MesofactServeBundle {
502    /// BLAKE3 digest of the published bundle manifest — the content-address
503    /// kamaji materializes from the bundle store (`yah_mesofact_bundle`,
504    /// R599-F1). Same 64-hex shape the bundle crate's `BundleHash` validates.
505    pub digest: BlakeHash,
506
507    /// Runtime that serves the bundle: `"self"` (bundle ships its own
508    /// `bins/<triple>/serve`) or `"mesofact/<version>"` (resolve the stock
509    /// serve runtime asset from the node cache). Wire-mirrors
510    /// `yah_mesofact_bundle::BundleRuntime`; kept as a plain `String` here so
511    /// workload-spec stays free of the bundle crate and its non-TS/schema
512    /// newtypes.
513    pub runtime: String,
514
515    /// How kamaji supervises the served bundle. Default: keep-alive.
516    #[serde(default)]
517    pub lifecycle: BundleLifecycle,
518}
519
520/// Lifecycle mode for a served bundle (W272 §3).
521#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
522#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
523#[serde(rename_all = "snake_case")]
524pub enum BundleLifecycle {
525    /// Fork at deploy, keep resident, restart per policy — today's server
526    /// archetype. Memory is resident for the workload's lifetime.
527    KeepAlive,
528
529    /// Kamaji owns the listen socket, forks the runtime on the first connection
530    /// (fd-passing), and reaps it after `idle_ttl` with zero connections — the
531    /// "serverless" tier (zero memory when idle). The JIT fork/reap mechanics
532    /// land in R599-F6; this variant only declares the intent + budget.
533    OnDemand {
534        /// Idle time with no live connections before kamaji reaps the process.
535        idle_ttl: Millis,
536    },
537}
538
539impl Default for BundleLifecycle {
540    /// Keep-alive — the resident server archetype — matches the current
541    /// deploy-and-supervise default.
542    fn default() -> Self {
543        BundleLifecycle::KeepAlive
544    }
545}
546
547/// Build step that produces the static artifact published by a
548/// `mesofact-static` workload.
549#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
550#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
551pub struct BuildConfig {
552    /// Shell command run from the manifest's directory, e.g. `"bun run build"`.
553    pub command: String,
554
555    /// Output directory (relative to the manifest) the reconciler uploads.
556    pub out_dir: PathBuf,
557
558    /// Data-only re-render command (W225 §3 "revalidate"), run from the
559    /// manifest's directory against the **already-built** `out_dir` — no
560    /// bundler. `{route}` is substituted with the invalidated route pattern,
561    /// e.g. `"cargo run -p mesofact-build -- render . --route {route} --all"`.
562    /// Absent → a revalidate dispatch republishes `out_dir` as-is.
563    #[serde(default)]
564    pub render_command: Option<String>,
565}
566
567// ── BuildMode ─────────────────────────────────────────────────────────────────
568
569/// Where the build command runs for a `mesofact-static` workload.
570///
571/// The two-role split encodes the F7 design decision: build/publish is a
572/// **transient job** (runs once, exits, GC'd); SSR/SPA serving is a separate
573/// **long-lived companion container** (optional, only for dynamic pages). A
574/// single merged "mesofact container" is the trap — in cloud, CI builds the
575/// artifact, R2+CDN serve it, and a distinct worker handles any SSR.
576///
577/// Default: `HostSide` — mesofact-dev runs the build on the host and publishes
578/// to the tier's object store. No container overhead; compatible with dev and
579/// sim tiers.
580#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
581#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
582#[serde(rename_all = "snake_case")]
583pub enum BuildMode {
584    /// Build command runs on the host (mesofact-dev watcher). The watcher
585    /// publishes the output to the tier's object store (DistPointer for dev,
586    /// MinIO for sim). Compatible with all tiers; zero container overhead.
587    #[default]
588    HostSide,
589
590    /// Build runs inside a transient container matching the CI image. Higher
591    /// fidelity (environment matches CI exactly); costs image pull +
592    /// container cold-start. Required for cloud/HA where no mesofact-dev
593    /// watcher is running on the host.
594    InContainer {
595        /// Container image that runs the build (e.g. `"ghcr.io/org/app-build:v1.2"`).
596        /// Must have the build toolchain installed. The container is started with
597        /// the workspace root bind-mounted, runs `build.command`, uploads
598        /// `build.out_dir` to the object store, then exits.
599        image: ImageRef,
600    },
601}
602
603// ── AlmanacManifest ───────────────────────────────────────────────────────────
604
605/// An observable endpoint the almanac scheduler probes to check readiness.
606///
607/// Used for both inputs (checked before the run) and outputs (verified after
608/// a successful run to confirm the job produced something reachable).
609/// The probe is intentionally lightweight — no S3 SigV4, no xlb-net discovery
610/// required; a simple TCP connect or HTTP GET is enough for the dev/sim tier.
611#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
612#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
613#[serde(rename_all = "snake_case")]
614pub enum AlmanacTarget {
615    /// Issue an HTTP GET to `url`; ready when the server responds with
616    /// `expect_status` (default: any 2xx).
617    Http {
618        url: String,
619        #[ts(optional = nullable)]
620        expect_status: Option<u16>,
621    },
622
623    /// Establish a TCP connection to `host:port`; ready when the connect
624    /// succeeds. Used for non-HTTP services (e.g. MinIO API on port 9000)
625    /// and as a lighter probe when an HTTP endpoint isn't stable yet.
626    Tcp { host: String, port: u16 },
627}
628
629/// What the almanac scheduler does when a precondition check fails.
630///
631/// The F9 design decision: `WaitWithTimeout` is the default. Fail-fast is
632/// too brittle for the sim tier (containers may still be cold-starting);
633/// requeue-with-no-ceiling can block the scheduler indefinitely. The
634/// recommended timeout for sim is the container spinup budget (~5 s cold,
635/// ~1 s warm): set `timeout` to a few seconds, then let the retry cadence
636/// handle transient glitches.
637#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
638#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
639#[serde(rename_all = "snake_case")]
640pub enum NotReadyPolicy {
641    /// Wait up to `timeout` for all preconditions to pass before aborting
642    /// the run. The run is skipped (not rescheduled); the next cadence tick
643    /// will retry. Suitable when targets occasionally lag at startup.
644    WaitWithTimeout {
645        /// How long to wait for each precondition to become reachable. The
646        /// scheduler polls with a short sleep between attempts.
647        timeout: Millis,
648    },
649
650    /// Abort immediately if any precondition check fails. Suitable for
651    /// integration-test harnesses where a missing dependency is always a
652    /// hard error.
653    FailFast,
654
655    /// Requeue with exponential backoff up to `max_attempts` times. After
656    /// exhaustion the run is marked failed. Suitable for cloud/HA where
657    /// transient dependency outages are expected.
658    Requeue {
659        /// Maximum number of requeue attempts before the run is marked failed.
660        max_attempts: u32,
661        /// Initial backoff between attempts, in milliseconds.
662        backoff: Millis,
663    },
664}
665
666impl Default for NotReadyPolicy {
667    /// Default is `WaitWithTimeout { timeout: 5 seconds }` — matches the
668    /// container spinup budget for the sim tier (few-second cold, sub-second warm).
669    fn default() -> Self {
670        Self::WaitWithTimeout { timeout: Millis::from_secs(5) }
671    }
672}
673
674/// When the almanac scheduler triggers a run.
675#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
676#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
677#[serde(rename_all = "snake_case")]
678pub enum Cadence {
679    /// Run once at first opportunity, then never again.
680    Once,
681
682    /// Run repeatedly with a fixed interval between the end of one run and
683    /// the start of the next. Equivalent to `sleep N && run` in a loop.
684    Every {
685        /// Minimum time between consecutive run completions.
686        interval: Millis,
687    },
688
689    /// Run on a UTC cron schedule (standard 5-field expression, e.g.
690    /// `"0 */6 * * *"` for every 6 hours). The scheduler evaluates the
691    /// expression relative to UTC midnight.
692    Cron { expression: String },
693}
694
695/// `kind = "almanac"` manifest — a declared data-pipeline job.
696///
697/// An almanac job is the generalisation of the OpenRouter refresher
698/// (`spawn_almanac_refresher`): it declares its I/O contract explicitly so
699/// the orchestrator can enforce preconditions before each run and verify
700/// outputs afterward. The degenerate case (no inputs, no app target, cron
701/// schedule) is exactly the OpenRouter JSON-cache refresher.
702///
703/// Lifecycle:
704/// 1. Cadence tick fires.
705/// 2. Scheduler probes every `inputs` target. If any fail → apply
706///    `not_ready_policy`.
707/// 3. Command runs (`sh -c command` from the workload directory).
708/// 4. Scheduler probes every `outputs` target. Failure → mark run as
709///    failed but do not retry.
710/// 5. Any workloads listed in `invalidates` receive a cache-bust signal
711///    (implementation detail of the orchestrator; in camp this is a
712///    rebuild trigger on the mesofact-dev watcher).
713#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
714#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
715pub struct AlmanacManifest {
716    /// Wire-format version. Always `V1` today.
717    pub schema_version: SchemaVersion,
718
719    /// Shell command executed via `sh -c` from the workload directory.
720    pub command: String,
721
722    /// When to run.
723    pub cadence: Cadence,
724
725    /// Input targets that must be reachable before the command runs.
726    /// Empty list → no precondition checks (degenerate case).
727    #[serde(default)]
728    pub inputs: Vec<AlmanacTarget>,
729
730    /// Output targets verified after a successful run.
731    /// Empty list → no post-run verification.
732    #[serde(default)]
733    pub outputs: Vec<AlmanacTarget>,
734
735    /// What to do when a precondition check fails.
736    /// Default: `WaitWithTimeout { timeout: 5000ms }`.
737    #[serde(default)]
738    pub not_ready_policy: NotReadyPolicy,
739
740    /// Mesh identities of workloads to notify after a successful run.
741    /// The orchestrator sends a cache-bust signal to each entry so
742    /// downstream consumers can reload their data (e.g. mesofact-dev
743    /// triggers a rebuild when the OpenRouter cache refreshes).
744    /// Empty list → no downstream invalidation.
745    #[serde(default)]
746    pub invalidates: Vec<MeshIdent>,
747}
748
749// ── StaticAssetWorkload ───────────────────────────────────────────────────────
750
751/// BLAKE3 content hash expressed as exactly 64 ASCII hex digits.
752///
753/// This is the content-address key for every file in the static-asset catalog.
754/// Deserialization rejects values that do not conform — 64 hex chars, case
755/// insensitive. Mismatch between the recorded hash and the source file halts
756/// the upload step in the reconciler.
757#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)]
758#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
759#[ts(type = "string")]
760pub struct BlakeHash(pub String);
761
762impl<'de> Deserialize<'de> for BlakeHash {
763    fn deserialize<D>(de: D) -> Result<Self, D::Error>
764    where
765        D: serde::Deserializer<'de>,
766    {
767        let s = String::deserialize(de)?;
768        if s.len() != 64 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
769            return Err(serde::de::Error::custom(format!(
770                "blake3 hash must be exactly 64 hex digits, got {:?}",
771                s
772            )));
773        }
774        Ok(BlakeHash(s))
775    }
776}
777
778// ── License & FetchSource (W164) ──────────────────────────────────────────────
779
780/// Closed-set, parse-time-enforced license tag. Mirrors the workspace
781/// permissive-license rule (MIT / Apache-2.0 / BSD-2/3-Clause / ISC). Adding a
782/// variant is an explicit schema change — non-permissive strings
783/// (`"GPL-3.0"`, `"AGPL"`, etc.) fail at serde-deserialize before any shape
784/// validator runs.
785///
786/// Shared between `asset.derive.fetch.license` (W164, required) and a future
787/// `almanac::ReleaseSource.license` migration (R438-F10, optional).
788#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
789#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
790#[serde(rename_all = "kebab-case")]
791pub enum License {
792    Mit,
793    Apache2,
794    Bsd2Clause,
795    Bsd3Clause,
796    Isc,
797}
798
799/// Shared fetch primitive — usable by `asset.derive` today, and by Almanac's
800/// `ReleaseSource` after a follow-up migration (R438-F10). Defined once in
801/// workload-spec so both consumers reject the same set of non-permissive
802/// licenses.
803///
804/// The `blake3` hash pins the upstream bytes; mismatch at fetch time is a hard
805/// error in the reconciler. The `license` field is **required** here — every
806/// derived asset must declare its upstream license. If/when Almanac adopts
807/// `FetchSource`, the Almanac side may wrap this in a struct with
808/// `Option<License>` since release manifests have no distribution license per
809/// se.
810#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
811#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
812pub struct FetchSource {
813    /// Upstream URL fetched verbatim. Reconciler retry policy is configured
814    /// elsewhere (R438-F11); the URL itself is opaque to workload-spec.
815    pub url: String,
816
817    /// Expected BLAKE3 hash of the fetched bytes (64 hex characters). The
818    /// reconciler verifies this after download and aborts on mismatch.
819    pub blake3: BlakeHash,
820
821    /// Upstream license. Closed-set, parse-time enforced.
822    pub license: License,
823}
824
825/// Optional transform applied after a [`FetchSource`] download, lowering to a
826/// `ForgeCommand::Subprocess` via the recipe loader (R438-T4). The transform's
827/// output is content-addressed by the entry's `blake3` (the recipe runs only
828/// when the cache misses).
829#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
830#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
831pub struct TransformSpec {
832    /// Named recipe under `.yah/qed/transforms/<recipe>.toml`. Loader rejects
833    /// missing recipes at materialize time.
834    pub recipe: String,
835
836    /// `{{key}}` substitutions passed to the recipe argv at element
837    /// granularity (no shell, no string concat). Empty when the recipe is
838    /// fully parameterless.
839    #[serde(default)]
840    pub params: BTreeMap<String, String>,
841}
842
843/// W212/R518: the committed derivation lock — the in-tree action-cache
844/// receipt. `input_hash` is the input-addressed derivation key computed over
845/// the complete declared input set (fetched-input pin ⊕ recipe-file bytes ⊕
846/// invocation params ⊕ schema version); `output_blake3` is what those inputs
847/// produced (== the entry's `blake3`). The reconciler skips the entire build
848/// (no fetch, no transform, no PUT) when the lock matches the inputs recomputed
849/// from the current pins and the bucket already holds the output — the
850/// Nix-substituter / Bazel-remote-cache behaviour. Written by the R510 bind
851/// path from the reconciler's `discovered_input_hash:<filename>` output; the
852/// `git diff` on this block is the receipt that the derivation rolled.
853#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
854#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
855pub struct DeriveLock {
856    /// Input-addressed derivation key (BLAKE3 hex). A change to any declared
857    /// input flips this, so a stale lock never produces a false skip.
858    pub input_hash: String,
859    /// Output the locked inputs produced (BLAKE3 hex; equals the entry's
860    /// `blake3`). Carried so the lock is a self-contained action-cache entry.
861    pub output_blake3: String,
862}
863
864/// Provenance chain for a derived asset: required `fetch` step, optional
865/// `transform` step. Materialized bytes replace `AssetEntry.source` for the
866/// rest of the static-asset reconcile loop.
867#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
868#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
869pub struct AssetDerive {
870    /// Upstream fetch — URL + content-pin + license.
871    pub fetch: FetchSource,
872
873    /// Post-fetch transform. `None` → the fetched bytes ARE the asset
874    /// (entry `blake3` must match fetch `blake3`).
875    #[serde(default)]
876    #[ts(optional = nullable)]
877    pub transform: Option<TransformSpec>,
878
879    /// W212/R518: committed derivation lock (input-addressed action-cache
880    /// receipt). Absent until the first successful build writes it via the
881    /// bind path. When present and current, enables the substituter-style
882    /// build skip.
883    #[serde(default)]
884    #[ts(optional = nullable)]
885    pub lock: Option<DeriveLock>,
886}
887
888/// A single file entry in the static-asset catalog.
889///
890/// One `[[asset]]` row per bucket object. Multiple rows for different variants
891/// (e.g. q5 and q4 whisper models) are fine — each declares its own filename
892/// and hash. The reconciler treats the catalog as exhaustive and append-only:
893/// new rows trigger a PUT; removed rows surface as drift (never a DELETE).
894///
895/// **Source-vs-derive XOR.** Exactly one of `source` or `derive` must be set.
896/// Legacy local-bytes assets keep `source = "..."`; W164 derived assets set
897/// `[asset.derive]` instead. [`validate::shape_static_asset`] enforces the
898/// XOR; both-set and neither-set are hard `ShapeError::Field`.
899#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
900#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
901pub struct AssetEntry {
902    /// Destination path within the bucket, e.g.
903    /// `"whisper/distil-large-v3-q5_1.bin"`. Must be unique in the catalog.
904    /// Used as the S3 object key by the reconciler.
905    pub filename: String,
906
907    /// Path to a local source file, relative to the `workload.toml` directory.
908    /// Mutually exclusive with `derive`.
909    #[serde(default)]
910    #[ts(optional = nullable)]
911    pub source: Option<PathBuf>,
912
913    /// Declared fetch (+ optional transform) provenance chain. The reconciler
914    /// materializes the bytes into a content-addressed cache; the cache path
915    /// then replaces `source` for the rest of the upload pipeline. Mutually
916    /// exclusive with `source`.
917    #[serde(default)]
918    #[ts(optional = nullable)]
919    pub derive: Option<AssetDerive>,
920
921    /// Expected BLAKE3 hash of the *final* asset bytes (64 hex characters).
922    /// For `source` mode, this is hashed before upload. For `derive` mode,
923    /// it's the post-transform (or post-fetch when no transform) output.
924    /// Mismatch aborts the upload.
925    pub blake3: BlakeHash,
926}
927
928/// `kind = "static-asset"` payload — content-addressed bucket catalog.
929///
930/// The reconciler makes the bucket match the `[[asset]]` list exactly
931/// (append-only: new rows → PUT; removed rows → drift report, not DELETE).
932/// Rollback is pointer-flip via `mirror.toml [asset_aliases]` — bytes never
933/// move during rollback.
934///
935/// **Closed-catalog invariant**: every value in `[aliases]` must be a
936/// `filename` that exists in `[[asset]]`. Enforced by
937/// [`validate::shape_static_asset`]. Mirror overrides (`[asset_aliases]` in
938/// `mirror.toml`) are bound by the same rule — the alias graph can only
939/// resolve to filenames already in the catalog.
940#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
941#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
942pub struct StaticAssetWorkload {
943    /// Wire-format version. Always `V1` today.
944    pub schema_version: SchemaVersion,
945
946    /// Exhaustive catalog of files this component manages in the bucket.
947    ///
948    /// Named `asset` on disk (TOML `[[asset]]` array-of-tables) to follow TOML
949    /// convention; accessed as `.assets` in Rust code.
950    #[serde(rename = "asset", default)]
951    pub assets: Vec<AssetEntry>,
952
953    /// Canonical logical-name → filename mappings for this component.
954    ///
955    /// Values must be filenames present in `assets` — validated by
956    /// [`validate::shape_static_asset`]. Mirror files may override individual
957    /// entries via `[asset_aliases]` but may never reference filenames absent
958    /// from this catalog.
959    #[serde(default)]
960    pub aliases: BTreeMap<String, String>,
961}
962
963// ── Lifecycle archetype (R572-F1 / W244) ───────────────────────────────────────
964
965/// Explicit lifecycle archetype for a `kind = "container"` workload (W244).
966///
967/// The question that actually matters to a scheduler: *"can I kill this and
968/// recreate it somewhere else?"* Before this field existed, the answer was
969/// inferred per-spec from `volumes.is_empty()` + `restart_policy` — fragile
970/// absence-as-policy, the same trap W243 calls out on the node-taint side.
971/// This type makes the answer structural instead of guessed.
972///
973/// This ticket (R572-F1) adds the discriminator only. The reconciler does not
974/// yet branch on it (R572-F4) and neither does the scheduler (R572-F5).
975#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
976#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
977#[serde(rename_all = "kebab-case")]
978pub enum LifecycleArchetype {
979    /// k8s analogue: Deployment. Stateless and fungible — the scheduler may
980    /// move it, scale it to N replicas, or restart it on a different node
981    /// with zero consequence. Drainable.
982    Server,
983
984    /// k8s analogue: StatefulSet. Stable identity + a volume that must
985    /// follow it; at most one live instance. Not drainable — the reconciler
986    /// must not schedule it onto a different node. Example: a postgres peer,
987    /// headscale (W267/R591).
988    Appliance,
989
990    /// k8s analogue: Job. Runs to completion with declared inputs/outputs,
991    /// then is gone — no steady-state identity. `almanac` is the first
992    /// job-family member; forge runs (`WorkloadSpec::for_forge`, used by QED)
993    /// are the `container`-kind instance of this archetype.
994    Job,
995}
996
997impl LifecycleArchetype {
998    /// The repel-taint key for this archetype (R572-F5). A node carrying the
999    /// taint `"no-<key>"` repels workloads of this class unless they
1000    /// explicitly tolerate it.
1001    ///
1002    /// Examples: `Server` → `"server"` (repelled by `"no-server"`);
1003    /// `Appliance` → `"appliance"` (repelled by `"no-appliance"`).
1004    pub fn taint_key(&self) -> &'static str {
1005        match self {
1006            Self::Server => "server",
1007            Self::Appliance => "appliance",
1008            Self::Job => "job",
1009        }
1010    }
1011
1012    /// The pre-R572 inference this field replaces, kept only to give
1013    /// `WorkloadSpec::effective_archetype` a behavior-preserving fallback for
1014    /// specs written before this field existed (`archetype: None`).
1015    ///
1016    /// A volume that must follow the workload is the strongest signal of
1017    /// durable state → [`Self::Appliance`]. Absent that, `RestartPolicy::Never`
1018    /// is the existing forge/run-once convention (see
1019    /// [`RestartPolicy::Never`]'s doc comment) → [`Self::Job`]. Everything
1020    /// else defaults to the common case, [`Self::Server`].
1021    fn infer(volumes: &[VolumeMount], restart_policy: &RestartPolicy) -> Self {
1022        if !volumes.is_empty() {
1023            LifecycleArchetype::Appliance
1024        } else if matches!(restart_policy, RestartPolicy::Never) {
1025            LifecycleArchetype::Job
1026        } else {
1027            LifecycleArchetype::Server
1028        }
1029    }
1030}
1031
1032// ── WorkloadSpec ──────────────────────────────────────────────────────────────
1033
1034/// Complete typed description of a containerd workload handed to yubaba over
1035/// RPC. This is also the payload of the `kind = "container"` variant of
1036/// [`Workload`] on disk.
1037///
1038/// Yubaba never accepts compose YAML on its RPC surface — agents, the desktop,
1039/// and operator CLIs all hand yubaba `WorkloadSpec` values. See the arch doc
1040/// for the validation layers and evolution rules.
1041#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
1042#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1043pub struct WorkloadSpec {
1044    /// Wire-format version; always `V1` today. Present at the top level so
1045    /// rolling clusters can detect and migrate across schema generations.
1046    pub schema_version: SchemaVersion,
1047
1048    /// DNS-friendly workload name, e.g. `"noisetable-api"`. Regex:
1049    /// `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`, length ≤ 63.
1050    pub name: String,
1051
1052    /// Container image to pull.
1053    pub image: ImageRef,
1054
1055    /// Tier tag controlling admission control and mesh filtering.
1056    pub tier: TierTag,
1057
1058    /// Tenant **isolation** axis (W206). Separates operators' workloads at the
1059    /// network / DB / mesh-identity level. Defaults to [`TenantId::singleton`]
1060    /// for specs that predate the axis, so single-tenant clusters keep every
1061    /// isolation primitive a no-op. Orthogonal to [`Self::tier`] (class) and
1062    /// [`Self::namespace`] (routing).
1063    #[serde(default = "TenantId::singleton")]
1064    pub tenant: TenantId,
1065
1066    /// Namespace **routing/naming** axis (W206). A pure naming key — never
1067    /// affects isolation; disambiguates DNS names and selects config root /
1068    /// provider zone within a tenant. Defaults to [`NamespaceId::singleton`].
1069    #[serde(default = "NamespaceId::singleton")]
1070    pub namespace: NamespaceId,
1071
1072    /// Target replica count. `0` registers the workload without deploying it.
1073    /// Range: 0–100 (cluster-wide cap; operator can raise it).
1074    pub replicas: u32,
1075
1076    /// Override the image's `CMD`. `None` leaves the image default.
1077    #[ts(optional = nullable)]
1078    pub command: Option<Vec<String>>,
1079
1080    /// Override the image's `ENTRYPOINT`. `None` leaves the image default.
1081    #[ts(optional = nullable)]
1082    pub entrypoint: Option<Vec<String>>,
1083
1084    /// Working directory inside the container.
1085    #[ts(optional = nullable)]
1086    pub workdir: Option<PathBuf>,
1087
1088    /// User to run as, e.g. `"1000:1000"` or `"appuser"`.
1089    #[ts(optional = nullable)]
1090    pub user: Option<String>,
1091
1092    /// Environment variables. Values may be literals, secret refs, or
1093    /// mesh-address references resolved by yubaba at deploy time.
1094    #[serde(default)]
1095    pub env: Vec<EnvVar>,
1096
1097    /// Secret mounts. Values never appear in the spec JSON — only references.
1098    #[serde(default)]
1099    pub secrets: Vec<SecretMount>,
1100
1101    /// Volume mounts.
1102    #[serde(default)]
1103    pub volumes: Vec<VolumeMount>,
1104
1105    /// Hard resource caps enforced by containerd/cgroups.
1106    pub resources: ResourceLimits,
1107
1108    /// Mesh idents that must reach `Ready` before this workload starts.
1109    #[serde(default)]
1110    pub depends_on: Vec<MeshIdent>,
1111
1112    /// Container liveness/readiness probe.
1113    #[ts(optional = nullable)]
1114    pub healthcheck: Option<Healthcheck>,
1115
1116    /// What yubaba does when the container exits.
1117    pub restart_policy: RestartPolicy,
1118
1119    /// Explicit lifecycle archetype (R572-F1 / W244): `server`, `appliance`,
1120    /// or `job`. `None` means the spec predates this field (or the author
1121    /// didn't set it) — callers MUST NOT read this directly to decide
1122    /// drainability; use [`WorkloadSpec::effective_archetype`], which falls
1123    /// back to the pre-R572 `volumes`/`restart_policy` inference so no
1124    /// existing spec's effective meaning changes.
1125    ///
1126    /// Additive: this field did not exist before R572-F1. Reconciler (F4)
1127    /// and scheduler (F5) branching on the resolved archetype are separate,
1128    /// later tickets — this field alone changes no runtime behavior.
1129    #[serde(default)]
1130    #[ts(optional = nullable)]
1131    pub archetype: Option<LifecycleArchetype>,
1132
1133    /// Graceful shutdown configuration.
1134    pub stop_policy: StopPolicy,
1135
1136    /// Network exposure configuration — mesh, public, and operator channels
1137    /// are independent and can be set in any combination.
1138    pub expose: ExposeSpec,
1139
1140    /// OCI-style labels, passed through to the container. Opaque to yubaba.
1141    #[serde(default)]
1142    pub labels: HashMap<String, String>,
1143
1144    /// Yah-specific metadata, conventionally prefixed `yah.*`. Opaque to
1145    /// yubaba beyond `yah.forge=true` which suppresses the Never-restart guard.
1146    #[serde(default)]
1147    pub annotations: HashMap<String, String>,
1148}
1149
1150impl WorkloadSpec {
1151    /// Build a `WorkloadSpec` for a forge run.
1152    ///
1153    /// Sets the conventional forge fields in one place so callers cannot
1154    /// forget any of them:
1155    ///
1156    /// - `restart_policy = Never`
1157    /// - `archetype = Some(LifecycleArchetype::Job)` — a forge run is
1158    ///   exactly the `container`-kind instance of the job archetype (W244);
1159    ///   set explicitly rather than left to infer since this constructor
1160    ///   knows its own shape
1161    /// - `expose.public = None`, `expose.operator = None`
1162    /// - `expose.mesh.identity = "forge.<forge_id>"`
1163    /// - `annotations["yah.forge"] = "true"` (suppresses the shape warning)
1164    /// - `tier` and `image` come from the caller; `ports` becomes the mesh
1165    ///   port list (empty is valid — forge jobs often don't expose ports)
1166    ///
1167    /// All other fields are set to safe defaults. Callers can mutate the
1168    /// returned value to fill in `command`, `env`, `resources`, etc.
1169    pub fn for_forge(
1170        forge_id: &str,
1171        image: ImageRef,
1172        tier: TierTag,
1173        ports: Vec<u16>,
1174    ) -> Self {
1175        let mut annotations = HashMap::new();
1176        annotations.insert("yah.forge".into(), "true".into());
1177
1178        WorkloadSpec {
1179            schema_version: SchemaVersion::V1,
1180            // NB: DNS-label safe (no dots) — `check_name` validation rejects
1181            // dots here. The container_id derives from this; the state-poll
1182            // keys off `expose.mesh.identity` (`forge.<id>`) instead, so those
1183            // two must be reconciled at the read path, NOT by dotting the name
1184            // (see R590-B9).
1185            name: format!("forge-{forge_id}"),
1186            image,
1187            tier,
1188            tenant: TenantId::singleton(),
1189            namespace: NamespaceId::singleton(),
1190            replicas: 1,
1191            command: None,
1192            entrypoint: None,
1193            workdir: None,
1194            user: None,
1195            env: vec![],
1196            secrets: vec![],
1197            volumes: vec![],
1198            resources: ResourceLimits {
1199                // R590-B10: forge workloads are BUILDS (cargo, buildkit, a
1200                // from-source V8 checkout+compile), not tiny services. The old
1201                // 256 MB placeholder became a hard cgroup memory.limit in
1202                // build_oci_spec and SIGKILL'd the rusty-v8 build mid-checkout
1203                // (git checkout of third_party/icu died of signal 9) — the
1204                // more so because /tmp is a RAM-backed tmpfs, so the source
1205                // tree counts against this limit too. 32 GiB is a bounded
1206                // ceiling that fits the V8 build's >12 GB peak with headroom,
1207                // protects the host from a runaway (vs truly unlimited), and is
1208                // above physical RAM on smaller build-workers (⇒ effectively
1209                // unlimited there). A per-step memory request threaded from the
1210                // pipeline is the eventual right model (see R590-B10).
1211                memory_mb: 32768,
1212                cpu_millis: 512,
1213                ephemeral_storage_mb: 512,
1214            },
1215            depends_on: vec![],
1216            healthcheck: None,
1217            restart_policy: RestartPolicy::Never,
1218            archetype: Some(LifecycleArchetype::Job),
1219            stop_policy: StopPolicy {
1220                signal: 15,
1221                grace_period: Millis::from_secs(30),
1222            },
1223            expose: ExposeSpec {
1224                mesh: MeshExpose {
1225                    identity: MeshIdent(format!("forge.{forge_id}")),
1226                    ports,
1227                    allow_from: vec![],
1228                },
1229                public: None,
1230                operator: None,
1231            },
1232            labels: HashMap::new(),
1233            annotations,
1234        }
1235    }
1236
1237    /// Whether this workload requests the **host network namespace** rather
1238    /// than an isolated one.
1239    ///
1240    /// Opt-in via `annotations["yah.network"] == "host"` (see
1241    /// [`HOST_NETWORK_ANNOTATION`] / [`HOST_NETWORK_VALUE`]). Default is the
1242    /// isolated netns every other workload gets — host networking is a
1243    /// privileged escape hatch for the few infra workloads that must bind a
1244    /// host port so an on-host ingress (e.g. a Cloudflare tunnel reaching
1245    /// `127.0.0.1:<port>`) can route to them without CNI/bridge plumbing.
1246    ///
1247    /// The backend (kamaji) is responsible for **guarding** this: host
1248    /// networking is only honoured for `tier == "infra"` workloads; a
1249    /// non-infra workload that sets the annotation is rejected at deploy. See
1250    /// `validate_spec_for_constable`.
1251    pub fn wants_host_network(&self) -> bool {
1252        self.annotations
1253            .get(HOST_NETWORK_ANNOTATION)
1254            .map(|v| v == HOST_NETWORK_VALUE)
1255            .unwrap_or(false)
1256    }
1257
1258    /// Resolve the lifecycle archetype (R572-F1 / W244): the explicit
1259    /// [`Self::archetype`] if set, otherwise the pre-R572 inference from
1260    /// `volumes`/`restart_policy` this field replaces.
1261    ///
1262    /// This is the one seam callers should use to ask "can I kill and
1263    /// reschedule this?" — it is intentionally the *only* place that
1264    /// implements the fallback, so behavior for pre-existing specs (no
1265    /// `archetype` on disk) is identical to what it was before this field
1266    /// existed. Consumers (reconciler R572-F4, scheduler R572-F5) branch on
1267    /// the return value; this crate does not itself change any reconciler or
1268    /// scheduler behavior.
1269    pub fn effective_archetype(&self) -> LifecycleArchetype {
1270        self.archetype
1271            .unwrap_or_else(|| LifecycleArchetype::infer(&self.volumes, &self.restart_policy))
1272    }
1273
1274    /// Fully-qualified mesh identity `<tenant>/<namespace>/<name>` (W206 /
1275    /// R558-F3), where `<name>` is this workload's [`MeshExpose::identity`].
1276    ///
1277    /// Within a tenant, workloads still address each other by the short
1278    /// identity (namespace disambiguates only on collision); the FQN is what
1279    /// makes the identity unambiguous across tenants and is exactly what a
1280    /// [`MeshPeer::CrossTenant`] grant names.
1281    pub fn fq_mesh_identity(&self) -> String {
1282        format!(
1283            "{}/{}/{}",
1284            self.tenant.0, self.namespace.0, self.expose.mesh.identity.0
1285        )
1286    }
1287
1288    /// The taint this workload requires its node to carry, if any (R594-F2 /
1289    /// W267 sovereign public ingress).
1290    ///
1291    /// Opt-in via `annotations["yah.placement.requires-taint"] = "<taint
1292    /// name>"` (see [`REQUIRES_TAINT_ANNOTATION`]) — same annotation-based,
1293    /// zero-blast-radius shape as [`Self::wants_host_network`], chosen so
1294    /// declaring this requirement does not force a struct-literal edit at
1295    /// every existing `WorkloadSpec { .. }` construction site the way a new
1296    /// plain field would (see R572-F1's handoff: ~26 sites for one field).
1297    ///
1298    /// **This only declares the requirement — nothing matches it yet.** The
1299    /// taint itself doesn't exist on the machine-TOML side until
1300    /// [R572-F3](yah://arch/symbol/R572) adds a `taints` list there, and
1301    /// nothing enforces repel-unless-tolerate placement until
1302    /// [R572-F5](yah://arch/symbol/R572)'s scheduler lands. Until then this
1303    /// is inert metadata a future scheduler can read.
1304    ///
1305    /// The public-ingress appliance (W267) is the first user: a
1306    /// `kind = "container"` workload with `archetype =
1307    /// Some(LifecycleArchetype::Appliance)` and
1308    /// `requires_taint() == Some(PUBLIC_IP_TAINT)`, so yubaba may one day
1309    /// place it only on machines carrying the `"public-ip"` taint and kamaji
1310    /// supervises it like any other container (no new `Workload` variant —
1311    /// see [`Workload::Container`]'s doc comment).
1312    pub fn requires_taint(&self) -> Option<&str> {
1313        self.annotations
1314            .get(REQUIRES_TAINT_ANNOTATION)
1315            .map(String::as_str)
1316    }
1317}
1318
1319/// Annotation key requesting a workload share the host network namespace.
1320/// See [`WorkloadSpec::wants_host_network`].
1321pub const HOST_NETWORK_ANNOTATION: &str = "yah.network";
1322
1323/// Annotation value (for [`HOST_NETWORK_ANNOTATION`]) selecting host
1324/// networking. Any other value leaves the workload in an isolated netns.
1325pub const HOST_NETWORK_VALUE: &str = "host";
1326
1327/// Annotation key declaring that a workload must land only on a node
1328/// carrying a specific taint. See [`WorkloadSpec::requires_taint`].
1329pub const REQUIRES_TAINT_ANNOTATION: &str = "yah.placement.requires-taint";
1330
1331/// Taint name (for [`REQUIRES_TAINT_ANNOTATION`]) identifying machines with
1332/// a publicly-routable IP — the W267 sovereign-ingress placement
1333/// requirement. The corresponding taint field on the machine TOML doesn't
1334/// exist yet (R572-F3); this constant is the agreed-upon name both sides
1335/// will use once it does.
1336pub const PUBLIC_IP_TAINT: &str = "public-ip";
1337
1338// ── ImageRef ─────────────────────────────────────────────────────────────────
1339
1340/// Container image reference identifying a specific image to pull.
1341///
1342/// **Digest is required.** Every executable image reference in the workspace
1343/// is content-addressed by `sha256:<hex>`. The `tag` is preserved as a
1344/// human-readable identifier but is not the source of truth — registries
1345/// return mutable `tag → digest` mappings and we don't trust them for
1346/// reproducibility. R438-T3 tightened `digest: Option<String> → String` to
1347/// make unpinned-image bugs impossible by construction.
1348///
1349/// **Two deserialize shapes.** The struct form
1350/// (`registry`/`repository`/`tag`/`digest` fields) is the on-disk envelope.
1351/// A **string form** (`image = "ghcr.io/foo/bar:v1@sha256:<hex>"`) is also
1352/// accepted and is the shape W164 transform recipes (R438-T4) and W165
1353/// `BuildMode::InContainer` (R438-T6) use. Both shapes go through a single
1354/// parser ([`compose_import::parse_pinned_image_ref`]) that rejects
1355/// bare-tag references at serde-deserialize.
1356#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)]
1357#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1358pub struct ImageRef {
1359    /// Registry hostname, e.g. `"ghcr.io"` or `"localhost:5000"`.
1360    pub registry: String,
1361
1362    /// Repository path, e.g. `"noisetable/api"`.
1363    pub repository: String,
1364
1365    /// Tag, e.g. `"v1.4.2"` or `"latest"`. Informational — the digest is
1366    /// the source of truth for image identity.
1367    pub tag: String,
1368
1369    /// Content-addressed pinned identity, e.g. `"sha256:abc..."`. Required.
1370    pub digest: String,
1371}
1372
1373impl<'de> Deserialize<'de> for ImageRef {
1374    fn deserialize<D>(de: D) -> Result<Self, D::Error>
1375    where
1376        D: serde::Deserializer<'de>,
1377    {
1378        #[derive(Deserialize)]
1379        struct Fields {
1380            registry: String,
1381            repository: String,
1382            tag: String,
1383            digest: String,
1384        }
1385
1386        // The string-or-struct `untagged` probe requires `deserialize_any`,
1387        // which only self-describing formats support. Postcard — the binary
1388        // wire behind the kamaji UDS — returns `WontImplement` for it, so a
1389        // `Workload::Container(WorkloadSpec)` carrying a nested `ImageRef`
1390        // failed to decode and every container deploy 500'd (R590-B3).
1391        //
1392        // The string form is purely an authoring convenience in human-readable
1393        // configs (`image = "ghcr.io/…@sha256:…"` in recipe/workload TOML and
1394        // JSON); the binary wire only ever carries the derived struct form
1395        // (Serialize is a plain struct derive). So branch on the format: text
1396        // keeps the string-or-struct convenience via `untagged`; binary decodes
1397        // the plain positional struct with no `deserialize_any`.
1398        if de.is_human_readable() {
1399            #[derive(Deserialize)]
1400            #[serde(untagged)]
1401            enum Repr {
1402                // Order matters for `untagged`: try the string form first so
1403                // explicit strings don't get coerced into a struct error.
1404                Pinned(String),
1405                Struct(Fields),
1406            }
1407
1408            match Repr::deserialize(de)? {
1409                Repr::Pinned(s) => {
1410                    compose_import::parse_pinned_image_ref(&s).map_err(serde::de::Error::custom)
1411                }
1412                Repr::Struct(f) => Ok(ImageRef {
1413                    registry: f.registry,
1414                    repository: f.repository,
1415                    tag: f.tag,
1416                    digest: f.digest,
1417                }),
1418            }
1419        } else {
1420            let f = Fields::deserialize(de)?;
1421            Ok(ImageRef {
1422                registry: f.registry,
1423                repository: f.repository,
1424                tag: f.tag,
1425                digest: f.digest,
1426            })
1427        }
1428    }
1429}
1430
1431// ── testing helpers ───────────────────────────────────────────────────────────
1432
1433/// Fixture helpers for test code that needs to construct types whose schemas
1434/// would otherwise demand operator-pinned values (digests, hashes). Doc-hidden
1435/// to discourage misuse from non-test code — production paths must source
1436/// digests from registry resolution or compile-time injection.
1437#[doc(hidden)]
1438pub mod testing {
1439    /// Fixed valid-format sha256 digest for test fixtures. All-zeros marker
1440    /// is impossible for any real image, so a leaked test fixture in a
1441    /// production code-path surfaces obviously.
1442    ///
1443    /// Aliases [`super::ImageRef::UNPINNED_DIGEST`] — the two are deliberately
1444    /// the same value: the fixture sentinel and the production "unpinned"
1445    /// marker must agree so [`super::ImageRef::pull_ref`]'s tag-fallback fires
1446    /// on exactly the digest `catalog_image` writes.
1447    pub const TEST_DIGEST: &str = super::ImageRef::UNPINNED_DIGEST;
1448
1449    /// Owned `String` form of [`TEST_DIGEST`] for fixture constructors.
1450    pub fn test_digest() -> String {
1451        TEST_DIGEST.to_string()
1452    }
1453}
1454
1455// ── EnvVar ────────────────────────────────────────────────────────────────────
1456
1457/// A single environment variable injected into the container.
1458#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1459#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1460pub struct EnvVar {
1461    /// Variable name, conventionally `SCREAMING_SNAKE_CASE`.
1462    pub name: String,
1463
1464    /// Value source.
1465    pub value: EnvValue,
1466}
1467
1468/// Value source for an environment variable.
1469#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1470#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1471#[serde(rename_all = "snake_case")]
1472pub enum EnvValue {
1473    /// Static string baked into the spec.
1474    Literal { value: String },
1475
1476    /// Resolved from a yubaba secret at deploy time; the secret value never
1477    /// appears in the spec JSON.
1478    FromSecret { secret: String, key: String },
1479
1480    /// Resolved from another workload's mesh address at deploy time by yubaba.
1481    /// Lets workloads reference each other symbolically without IP pinning.
1482    FromMesh { ident: MeshIdent, kind: MeshLookup },
1483}
1484
1485/// Which aspect of a mesh peer's address to inject.
1486#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
1487#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1488#[serde(rename_all = "snake_case")]
1489pub enum MeshLookup {
1490    /// Full URL, e.g. `"http://noisetable-db.pdx:5432"`.
1491    Url,
1492    /// Hostname only, e.g. `"noisetable-db.pdx"`.
1493    Host,
1494    /// Port only, e.g. `"5432"`.
1495    Port,
1496}
1497
1498// ── Secrets ───────────────────────────────────────────────────────────────────
1499
1500/// A secret value mounted into the container as an env var or file.
1501///
1502/// The secret value never appears in the spec JSON — only the reference.
1503/// Yubaba audits secret access per workload from these references.
1504#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1505#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1506pub struct SecretMount {
1507    /// Where yubaba reads the secret value from.
1508    pub source: SecretRef,
1509
1510    /// How the secret is surfaced inside the container.
1511    pub target: SecretTarget,
1512}
1513
1514/// Where yubaba resolves the secret value from.
1515#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1516#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1517#[serde(rename_all = "snake_case")]
1518pub enum SecretRef {
1519    /// Per-machine yubaba secret store at `/var/lib/yah/yubaba/secrets/`.
1520    LocalFile { path: PathBuf },
1521
1522    /// Raft-replicated cluster secret spanning all machines (planned; not in
1523    /// V1 deployment). Sketch preserved for wire compatibility.
1524    Cluster { name: String },
1525}
1526
1527/// How the secret is surfaced inside the container.
1528#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1529#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1530#[serde(rename_all = "snake_case")]
1531pub enum SecretTarget {
1532    /// Injected as an environment variable. Value never appears in spec JSON.
1533    /// Prefer `File` — env vars leak through subprocess env and log dumps.
1534    EnvVar { name: String },
1535
1536    /// Mounted as a file inside the container at `path` with `mode` (octal).
1537    File { path: PathBuf, mode: u32 },
1538}
1539
1540// ── Volumes ───────────────────────────────────────────────────────────────────
1541
1542/// A volume mount inside the container.
1543#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1544#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1545pub struct VolumeMount {
1546    /// Backing volume source.
1547    pub source: VolumeSource,
1548
1549    /// Absolute path inside the container.
1550    pub target: PathBuf,
1551
1552    /// Whether the container sees the volume as read-only.
1553    pub read_only: bool,
1554}
1555
1556/// Backing source for a volume mount.
1557#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1558#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1559#[serde(rename_all = "snake_case")]
1560pub enum VolumeSource {
1561    /// Yubaba-managed named volume; created on first use.
1562    Named { name: String },
1563
1564    /// Operator-managed host path. Yubaba rejects bind mounts unless
1565    /// `WorkloadSpec.tier == "infra"`; shape validation enforces this.
1566    Bind { host_path: PathBuf },
1567
1568    /// In-memory tmpfs; discarded on container stop. `size_mb` caps space
1569    /// consumed by the writable layer.
1570    Tmpfs { size_mb: u32 },
1571}
1572
1573// ── Durable forge produced-artifact convention (R603-T5) ──────────────────────
1574
1575/// Convention for a remote forge step's durable produced artifacts.
1576///
1577/// A remote build (e.g. the rusty_v8 musl build on a build-worker) writes its
1578/// output tarball to a path *inside* the container. The container's rootfs is
1579/// destroyed when kamaji reaps the EXITED container — so if the camp daemon is
1580/// down when the build finishes, the artifact is gone before boot-reconcile can
1581/// retrieve it (R603-T4 surfaced this as `Success`-but-`UNPUBLISHED`).
1582///
1583/// The fix (R603-T5) is a **host-persistent bind mount**: forge Subprocess
1584/// workloads mount [`HOST_ROOT`]`/<forge_id>` onto [`CONTAINER_DIR`], so a
1585/// build that writes its `produces` under `/yah/produced` lands the bytes on
1586/// the worker's host filesystem. yubaba then reads them back from the host path
1587/// ([`host_path`]) — which outlives container reaping — instead of the
1588/// unreachable container rootfs.
1589///
1590/// The container-side path and the host root are a shared convention between
1591/// three crates: the qed `build_workload_spec` that adds the mount, kamaji that
1592/// binds it, and the yubaba handler that reads + reaps it. Keeping it here (the
1593/// crate all three already depend on) is the single source of truth.
1594pub mod forge_produced {
1595    use std::path::{Path, PathBuf};
1596
1597    /// Conventional container-side directory a remote forge step writes its
1598    /// durable produced artifacts to. Bind-mounted onto a host-persistent dir.
1599    pub const CONTAINER_DIR: &str = "/yah/produced";
1600
1601    /// Host root under which each forge's durable produced dir lives, one
1602    /// subdir per run: `<HOST_ROOT>/<forge_id>/`. yubaba owns this directory —
1603    /// it creates the per-forge subdir at deploy, serves reads from it, and
1604    /// reaps it on teardown / TTL sweep.
1605    pub const HOST_ROOT: &str = "/var/lib/yah/qed/produced";
1606
1607    /// Forge mesh idents are `forge.<id>` (see [`WorkloadSpec::for_forge`]).
1608    /// Extract the bare `<id>`, or `None` for a non-forge ident.
1609    ///
1610    /// [`WorkloadSpec::for_forge`]: super::WorkloadSpec::for_forge
1611    pub fn forge_id_from_ident(ident: &str) -> Option<&str> {
1612        ident.strip_prefix("forge.")
1613    }
1614
1615    /// The host-persistent produced directory for one forge run.
1616    pub fn host_dir(forge_id: &str) -> PathBuf {
1617        PathBuf::from(HOST_ROOT).join(forge_id)
1618    }
1619
1620    /// Translate a container-side produced path to its durable host path for a
1621    /// given forge run. Returns `None` when `container_path` is not under
1622    /// [`CONTAINER_DIR`] (the caller then knows the artifact was not written to
1623    /// the durable location and won't survive reaping), or when the relative
1624    /// path contains a `..` component (a traversal attempt that could escape the
1625    /// per-forge dir — the reader must never serve a file outside it).
1626    pub fn host_path(forge_id: &str, container_path: &Path) -> Option<PathBuf> {
1627        let rel = container_path.strip_prefix(CONTAINER_DIR).ok()?;
1628        if rel
1629            .components()
1630            .any(|c| matches!(c, std::path::Component::ParentDir))
1631        {
1632            return None;
1633        }
1634        Some(host_dir(forge_id).join(rel))
1635    }
1636
1637    /// The durable produced-dir bind mount for a forge run: host
1638    /// `<HOST_ROOT>/<forge_id>` → container [`CONTAINER_DIR`], writable.
1639    pub fn durable_mount(forge_id: &str) -> super::VolumeMount {
1640        super::VolumeMount {
1641            source: super::VolumeSource::Bind {
1642                host_path: host_dir(forge_id),
1643            },
1644            target: PathBuf::from(CONTAINER_DIR),
1645            read_only: false,
1646        }
1647    }
1648
1649    /// True when `path` is (or is under) the conventional durable produced dir
1650    /// — the guard qed uses to enforce that declared `produces` land somewhere
1651    /// reap-durable.
1652    pub fn is_durable_path(path: &Path) -> bool {
1653        path.starts_with(CONTAINER_DIR)
1654    }
1655}
1656
1657// ── Resources ─────────────────────────────────────────────────────────────────
1658
1659/// Hard resource caps enforced by containerd/cgroups at runtime.
1660#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1661#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1662pub struct ResourceLimits {
1663    /// Maximum RAM the container may allocate, in MiB. The container is OOM-
1664    /// killed if it exceeds this.
1665    pub memory_mb: u32,
1666
1667    /// CPU **request** in millicores (k8s convention): `1000` = one full core,
1668    /// `250` = `.25 CPU`. Unlike a Docker relative weight this is an
1669    /// allocatable quantity a bin-packer can subtract from a node's budget.
1670    /// `0` means "no CPU limit". Backends that speak a relative weight derive
1671    /// it via [`ResourceLimits::cpu_shares`].
1672    pub cpu_millis: u32,
1673
1674    /// Cap on the writable layer + tmpfs footprint, in MiB.
1675    pub ephemeral_storage_mb: u32,
1676}
1677
1678impl ResourceLimits {
1679    /// The Docker/OCI relative CPU weight (`cpu.shares`, where `1024` ≈ one
1680    /// core) equivalent to this millicore request. The containerd and docker
1681    /// backends express CPU as a weight rather than a millicore request, so
1682    /// they derive it here instead of storing shares: `1000m` ⇒ `1024`.
1683    pub fn cpu_shares(&self) -> u64 {
1684        (u64::from(self.cpu_millis) * 1024) / 1000
1685    }
1686}
1687
1688// ── Healthcheck ───────────────────────────────────────────────────────────────
1689
1690/// Container health probe configuration.
1691#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1692#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1693pub struct Healthcheck {
1694    /// The probe executed to determine container health.
1695    pub probe: HealthProbe,
1696
1697    /// How often the probe runs.
1698    pub interval: Millis,
1699
1700    /// Per-probe timeout; a slow response counts as failure.
1701    pub timeout: Millis,
1702
1703    /// Time to wait after container start before the first probe. Shape
1704    /// validation warns (not errors) if this is less than
1705    /// `stop_policy.grace_period * 2`.
1706    pub initial_delay: Millis,
1707
1708    /// Number of consecutive failures before the container is marked
1709    /// `Unhealthy`.
1710    pub failure_threshold: u32,
1711}
1712
1713/// Mechanism used to check container health.
1714#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1715#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1716#[serde(rename_all = "snake_case")]
1717pub enum HealthProbe {
1718    /// HTTP GET to `path` on `port`. A 2xx (or `expect_status` if set)
1719    /// response counts as healthy.
1720    HttpGet {
1721        path: String,
1722        port: u16,
1723        #[ts(optional = nullable)]
1724        expect_status: Option<u16>,
1725    },
1726
1727    /// Run `argv` inside the container; exit-0 counts as healthy.
1728    Exec { argv: Vec<String> },
1729
1730    /// TCP connection to `port`; a successful connect counts as healthy.
1731    TcpConnect { port: u16 },
1732}
1733
1734// ── Restart / Stop ────────────────────────────────────────────────────────────
1735
1736/// What yubaba does when the container exits.
1737#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
1738#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1739#[serde(rename_all = "snake_case")]
1740pub enum RestartPolicy {
1741    /// Restart unconditionally on any exit.
1742    Always,
1743
1744    /// Restart on non-zero exit, up to `max_attempts` times with exponential
1745    /// backoff. After exhaustion, the workload is marked `Failed`.
1746    OnFailure {
1747        max_attempts: u32,
1748        backoff: BackoffPolicy,
1749    },
1750
1751    /// Do not restart. The container runs once and exits.
1752    ///
1753    /// **Forge convention.** Forge runs (R094) synthesize a `WorkloadSpec`
1754    /// using [`WorkloadSpec::for_forge`] which sets all the conventional fields
1755    /// together:
1756    ///
1757    /// - `restart_policy = Never`
1758    /// - `expose.public = None`, `expose.operator = None`
1759    /// - `expose.mesh.identity = "forge.<forge_id>"` — distinguishable from
1760    ///   persistent mirror identities at the mesh layer
1761    /// - `tier = "infra"` (or the forge-spec's effective tier)
1762    /// - `annotations["yah.forge"] = "true"` — suppresses the shape warning
1763    ///
1764    /// Using `Never` on a persistent mirror (not a forge run) means the mirror
1765    /// stays dead after any exit — a likely misconfiguration. Shape validation
1766    /// emits a soft warning unless `annotations["yah.forge"] == "true"` is
1767    /// present. See R094 forge.
1768    Never,
1769}
1770
1771/// Exponential backoff parameters for `RestartPolicy::OnFailure`.
1772#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
1773#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1774pub struct BackoffPolicy {
1775    /// Initial delay before the first restart, in milliseconds.
1776    pub initial_ms: u32,
1777
1778    /// Maximum delay between retries, in milliseconds.
1779    pub max_ms: u32,
1780
1781    /// Backoff multiplier applied to each successive delay.
1782    pub multiplier: f32,
1783}
1784
1785/// Graceful shutdown configuration for yubaba's stop sequence.
1786#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1787#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1788pub struct StopPolicy {
1789    /// Signal number sent first, e.g. `15` (SIGTERM) or `2` (SIGINT).
1790    pub signal: i32,
1791
1792    /// Time yubaba waits after sending `signal` before issuing SIGKILL.
1793    pub grace_period: Millis,
1794}
1795
1796// ── Expose ────────────────────────────────────────────────────────────────────
1797
1798/// Network exposure configuration. The three channels are independent; any
1799/// combination is valid.
1800#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1801#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1802pub struct ExposeSpec {
1803    /// Mesh-internal exposure. Required; every workload must have a mesh
1804    /// identity even if no other workload currently reaches it.
1805    pub mesh: MeshExpose,
1806
1807    /// Public internet exposure via a Cloudflare tunnel route. `None` means
1808    /// the workload is not internet-reachable.
1809    #[ts(optional = nullable)]
1810    pub public: Option<PublicExpose>,
1811
1812    /// Operator-facing exposure via a Tailscale ACL tag. `None` means the
1813    /// workload is not operator-reachable via Tailscale.
1814    #[ts(optional = nullable)]
1815    pub operator: Option<OperatorExpose>,
1816}
1817
1818/// A peer permitted to initiate mesh connections to a workload (W206 / R558-F3).
1819///
1820/// Cross-tenant access is **deny-by-default**: a workload accepts inter-tenant
1821/// traffic only from peers it lists explicitly as [`MeshPeer::CrossTenant`].
1822/// Same-tenant access stays tier-based ([`MeshPeer::Tier`]) — the pre-R558
1823/// model — and an `allow_from` with no `Tier` entries still admits every
1824/// same-tenant peer (the historical "empty = allow all" default).
1825///
1826/// External serde tagging keeps this postcard-safe (R590-B3): no internal tag,
1827/// no untagged, no `skip_serializing_if`.
1828#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1829#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1830#[serde(rename_all = "snake_case")]
1831pub enum MeshPeer {
1832    /// Any **same-tenant** workload whose `tier` matches this tag. This is the
1833    /// pre-R558 `allow_from` semantics.
1834    Tier(TierTag),
1835
1836    /// A specific workload in **another tenant**, addressed by its fully
1837    /// qualified mesh identity `<tenant>/<namespace>/<name>`. There is no
1838    /// cross-tenant tier wildcard — each cross-tenant peer is granted
1839    /// individually, so a shared fleet stays isolated unless an operator opts
1840    /// in here.
1841    CrossTenant {
1842        tenant: TenantId,
1843        namespace: NamespaceId,
1844        /// Peer's mesh identity (its [`MeshExpose::identity`]).
1845        name: MeshIdent,
1846    },
1847}
1848
1849/// Mesh-internal port exposure and peer access control.
1850#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1851#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1852pub struct MeshExpose {
1853    /// DNS-segment mesh identity for this workload. Must be unique in the
1854    /// cluster. Regex: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`, length ≤ 63.
1855    pub identity: MeshIdent,
1856
1857    /// Container-side ports this workload listens on. Other workloads reach
1858    /// it at `<identity>:<port>` on the mesh.
1859    pub ports: Vec<u16>,
1860
1861    /// Peers permitted to initiate connections to this workload on the mesh
1862    /// (W206 / R558-F3). Same-tenant tier rules and explicit cross-tenant
1863    /// grants share this one list. With **no** [`MeshPeer::Tier`] entries every
1864    /// same-tenant peer is admitted (the historical "empty = allow all"
1865    /// default); cross-tenant peers are always denied unless named by a
1866    /// [`MeshPeer::CrossTenant`] entry. See [`MeshExpose::admits_peer`].
1867    #[serde(default)]
1868    pub allow_from: Vec<MeshPeer>,
1869}
1870
1871impl MeshExpose {
1872    /// Whether a peer may initiate a mesh connection to a workload whose mesh
1873    /// exposure is `self`. `own_tenant` is the tenant of the workload being
1874    /// protected; the remaining arguments identify the connecting peer.
1875    ///
1876    /// Deny-by-default across tenants (W206 / R558-F3):
1877    /// - **Same tenant** (`own_tenant == peer_tenant`): admitted when the
1878    ///   peer's tier matches a [`MeshPeer::Tier`] rule, or when there are no
1879    ///   `Tier` rules at all (historical "empty `allow_from` = allow all
1880    ///   same-tenant").
1881    /// - **Cross tenant**: admitted only when an explicit
1882    ///   [`MeshPeer::CrossTenant`] entry matches the peer's
1883    ///   `(tenant, namespace, name)`.
1884    pub fn admits_peer(
1885        &self,
1886        own_tenant: &TenantId,
1887        peer_tenant: &TenantId,
1888        peer_namespace: &NamespaceId,
1889        peer_name: &MeshIdent,
1890        peer_tier: &TierTag,
1891    ) -> bool {
1892        if own_tenant == peer_tenant {
1893            let mut has_tier_rule = false;
1894            for peer in &self.allow_from {
1895                if let MeshPeer::Tier(t) = peer {
1896                    has_tier_rule = true;
1897                    if t == peer_tier {
1898                        return true;
1899                    }
1900                }
1901            }
1902            // No same-tenant tier restriction declared → admit all same-tenant.
1903            !has_tier_rule
1904        } else {
1905            self.allow_from.iter().any(|peer| {
1906                matches!(
1907                    peer,
1908                    MeshPeer::CrossTenant { tenant, namespace, name }
1909                        if tenant == peer_tenant
1910                            && namespace == peer_namespace
1911                            && name == peer_name
1912                )
1913            })
1914        }
1915    }
1916}
1917
1918/// The name by which a workload is addressed **within its own tenant** (W206 /
1919/// R558-F3), given every `(namespace, identity)` pair present in that tenant.
1920///
1921/// Within a tenant, a workload is reached by its short mesh `identity` when that
1922/// identity is unique across the tenant's namespaces. When two namespaces
1923/// expose the same identity, the name is ambiguous, so both are disambiguated
1924/// by a namespace prefix — `<namespace>.<identity>` (e.g. `yah.runner` vs
1925/// `noisetable.runner`). Cross-tenant addressing always uses the full FQN
1926/// ([`WorkloadSpec::fq_mesh_identity`]) and is out of scope here.
1927pub fn intra_tenant_address(
1928    namespace: &NamespaceId,
1929    identity: &MeshIdent,
1930    tenant_workloads: &[(NamespaceId, MeshIdent)],
1931) -> String {
1932    let collides = tenant_workloads
1933        .iter()
1934        .any(|(ns, id)| id == identity && ns != namespace);
1935    if collides {
1936        format!("{}.{}", namespace.0, identity.0)
1937    } else {
1938        identity.0.clone()
1939    }
1940}
1941
1942/// Public internet exposure via a Cloudflare tunnel route.
1943#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1944#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1945pub struct PublicExpose {
1946    /// Public hostname to route, e.g. `"api.noisetable.io"`. Semantic
1947    /// validation checks that this hostname is owned by a configured CF zone.
1948    pub hostname: String,
1949
1950    /// Container-side port to route traffic to. Shape validation requires this
1951    /// port to appear in `expose.mesh.ports`.
1952    pub port: u16,
1953
1954    /// TLS configuration for the public endpoint.
1955    pub tls: PublicTls,
1956}
1957
1958/// TLS mode for a public endpoint.
1959#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1960#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1961#[serde(rename_all = "snake_case")]
1962pub enum PublicTls {
1963    /// Cloudflare manages the TLS certificate (default; requires a proxied DNS
1964    /// record in the configured zone).
1965    CfManaged,
1966
1967    /// User-supplied certificate referenced by name in the yubaba secret store.
1968    UserCertRef { name: String },
1969}
1970
1971/// Operator-facing exposure via a Tailscale ACL tag.
1972#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1973#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1974pub struct OperatorExpose {
1975    /// Tailscale ACL tag granting access, e.g. `"tag:noisetable-ops"`. Semantic
1976    /// validation checks that this tag exists in the cluster's Tailscale ACL.
1977    pub tailscale_tag: String,
1978
1979    /// Container-side port to expose to Tailscale-authorized operators.
1980    pub port: u16,
1981}
1982
1983// ── ImageRef helpers ──────────────────────────────────────────────────────────
1984
1985impl ImageRef {
1986    /// The all-zeros sha256 digest that marks an image reference as **not
1987    /// content-pinned**. No real image can carry it, so a build that never
1988    /// injected a compile-time digest (dev builds) or a catalog image that
1989    /// isn't published-and-pinned yet lands on this sentinel. This is the
1990    /// single source of truth both the catalog emitter
1991    /// (`task::default_image::catalog_image`, which writes it) and the
1992    /// container-runtime resolvers ([`Self::pull_ref`], via kamaji) agree on —
1993    /// keeping them here means they cannot drift. [`testing::TEST_DIGEST`] is
1994    /// the same value re-exported for fixtures.
1995    pub const UNPINNED_DIGEST: &'static str =
1996        "sha256:0000000000000000000000000000000000000000000000000000000000000000";
1997
1998    /// Format this reference as a Docker-compatible image string,
1999    /// `{registry}/{repository}:{tag}@{digest}`. Tag is included for human
2000    /// readability; the digest is what the pull resolves against. Always emits
2001    /// the digest — this is the display/logging form; use [`Self::pull_ref`]
2002    /// for the string handed to a container runtime.
2003    pub fn docker_ref(&self) -> String {
2004        format!("{}/{}:{}@{}", self.registry, self.repository, self.tag, self.digest)
2005    }
2006
2007    /// True when this reference carries a real content-addressed digest, i.e.
2008    /// its digest is not the all-zeros [`Self::UNPINNED_DIGEST`] sentinel.
2009    pub fn is_pinned(&self) -> bool {
2010        self.digest != Self::UNPINNED_DIGEST
2011    }
2012
2013    /// The reference string to hand a container runtime for pull/resolve.
2014    ///
2015    /// - **Pinned** (real digest): `{registry}/{repository}:{tag}@{digest}` —
2016    ///   content-addressed, the reproducible path.
2017    /// - **Unpinned** (all-zeros [`Self::UNPINNED_DIGEST`]): `{registry}/{repository}:{tag}`
2018    ///   — tag-only. No registry or local store holds an image under the
2019    ///   sentinel digest, so `…@sha256:0000…` can never resolve; a
2020    ///   tag-pulled or locally-built image is keyed by `registry/repo:tag`.
2021    ///   This is the tag-fallback path that lets a not-yet-published catalog
2022    ///   image (e.g. a from-source build-worker image) still pull by tag.
2023    pub fn pull_ref(&self) -> String {
2024        if self.is_pinned() {
2025            format!("{}/{}:{}@{}", self.registry, self.repository, self.tag, self.digest)
2026        } else {
2027            format!("{}/{}:{}", self.registry, self.repository, self.tag)
2028        }
2029    }
2030}
2031
2032// ── WorkloadRuntime trait ─────────────────────────────────────────────────────
2033
2034/// Shared interface for deploying and managing `WorkloadSpec` containers.
2035///
2036/// This is the keystone abstraction (R256-F10) that makes sim and cloud
2037/// literally interchangeable at the container level:
2038///
2039/// - **Camp/sim tier**: `LocalDockerRuntime` in `cloud` implements this trait
2040///   via the docker CLI pointed at OrbStack (or any Docker-compatible socket).
2041///   No mesh — containers communicate over OrbStack's bridge network.
2042///
2043/// - **Yubaba/cloud-HA tier**: `yubaba::runtime::ContainerRuntime` (gRPC to
2044///   containerd) will implement this trait. Mesh assignment is a separate
2045///   orchestration step on top (handled by yubaba's raft layer), not part
2046///   of the shared deploy/supervise interface.
2047///
2048/// Callers that type against `WorkloadRuntime` automatically work with both
2049/// backends. Reconcilers in `cloud` use it today; yubaba wires its own impl
2050/// when R276 Tier-3 lands.
2051#[async_trait::async_trait]
2052pub trait WorkloadRuntime: Send + Sync {
2053    /// Deploy a workload described by `spec`. Pulls the image if needed,
2054    /// creates and starts the container, and returns an opaque workload ID
2055    /// (typically the container name derived from `spec.name`).
2056    ///
2057    /// Idempotent: re-deploying a running workload replaces it cleanly.
2058    async fn deploy_workload(&self, spec: &WorkloadSpec) -> anyhow::Result<String>;
2059
2060    /// Tear down a deployed workload — stop the process and remove all
2061    /// associated state. No-op when the workload is already gone.
2062    async fn teardown_workload(&self, name: &str) -> anyhow::Result<()>;
2063
2064    /// Returns `true` when the named workload is currently running (i.e.
2065    /// the container process is alive and has not exited).
2066    async fn is_running(&self, name: &str) -> anyhow::Result<bool>;
2067
2068    /// Probe the runtime backend. Returns `true` when the backend socket is
2069    /// reachable and healthy (e.g. docker daemon up, containerd gRPC up).
2070    /// Used by health endpoints and startup checks.
2071    async fn runtime_health(&self) -> anyhow::Result<bool>;
2072}
2073
2074// ── Tests ─────────────────────────────────────────────────────────────────────
2075
2076#[cfg(test)]
2077mod tests {
2078    use super::*;
2079
2080    // ── R603-T5 durable forge produced convention ──────────────────────────────
2081
2082    #[test]
2083    fn forge_produced_ident_parse() {
2084        assert_eq!(forge_produced::forge_id_from_ident("forge.abc123"), Some("abc123"));
2085        assert_eq!(forge_produced::forge_id_from_ident("svc.web"), None);
2086        assert_eq!(forge_produced::forge_id_from_ident("abc123"), None);
2087    }
2088
2089    #[test]
2090    fn forge_produced_host_path_translates_under_convention_dir() {
2091        let hp = forge_produced::host_path(
2092            "fid",
2093            std::path::Path::new("/yah/produced/librusty_v8.tar.gz"),
2094        )
2095        .expect("path under the convention dir translates");
2096        assert_eq!(
2097            hp,
2098            PathBuf::from("/var/lib/yah/qed/produced/fid/librusty_v8.tar.gz")
2099        );
2100    }
2101
2102    #[test]
2103    fn forge_produced_host_path_rejects_paths_outside_convention_dir() {
2104        assert_eq!(
2105            forge_produced::host_path("fid", std::path::Path::new("/tmp/x.tar.gz")),
2106            None,
2107            "a path outside /yah/produced has no durable host mapping"
2108        );
2109    }
2110
2111    #[test]
2112    fn forge_produced_host_path_rejects_traversal() {
2113        // A `..` component must never let a read escape the per-forge dir.
2114        assert_eq!(
2115            forge_produced::host_path(
2116                "fid",
2117                std::path::Path::new("/yah/produced/../../etc/passwd")
2118            ),
2119            None,
2120            "traversal out of the per-forge dir must be refused"
2121        );
2122    }
2123
2124    #[test]
2125    fn forge_produced_durable_mount_shape() {
2126        let m = forge_produced::durable_mount("fid");
2127        assert_eq!(m.target, PathBuf::from("/yah/produced"));
2128        assert!(!m.read_only, "the build must be able to write to it");
2129        assert_eq!(
2130            m.source,
2131            VolumeSource::Bind {
2132                host_path: PathBuf::from("/var/lib/yah/qed/produced/fid"),
2133            }
2134        );
2135    }
2136
2137    const HASH_64: &str = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
2138
2139    #[test]
2140    fn blake_hash_accepts_64_hex() {
2141        let h: BlakeHash = toml::from_str(&format!("x = \"{HASH_64}\""))
2142            .map(|t: toml::Table| t["x"].as_str().unwrap().to_owned())
2143            .map(|s| serde_json::from_value(serde_json::Value::String(s)).unwrap())
2144            .unwrap();
2145        assert_eq!(h.0, HASH_64);
2146    }
2147
2148    #[test]
2149    fn blake_hash_rejects_wrong_length() {
2150        let short = "abcdef";
2151        let res: Result<BlakeHash, _> =
2152            serde_json::from_value(serde_json::Value::String(short.into()));
2153        assert!(res.is_err());
2154    }
2155
2156    #[test]
2157    fn blake_hash_rejects_non_hex() {
2158        let bad = "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz";
2159        let res: Result<BlakeHash, _> =
2160            serde_json::from_value(serde_json::Value::String(bad.into()));
2161        assert!(res.is_err());
2162    }
2163
2164    fn image_ref(digest: &str) -> ImageRef {
2165        ImageRef {
2166            registry: "ghcr.io".into(),
2167            repository: "yah-ai/rusty-v8-musl-builder".into(),
2168            tag: "latest".into(),
2169            digest: digest.into(),
2170        }
2171    }
2172
2173    #[test]
2174    fn is_pinned_distinguishes_real_digest_from_sentinel() {
2175        assert!(!image_ref(ImageRef::UNPINNED_DIGEST).is_pinned());
2176        assert!(!image_ref(&testing::test_digest()).is_pinned());
2177        assert!(image_ref("sha256:deadbeef").is_pinned());
2178    }
2179
2180    #[test]
2181    fn pull_ref_pinned_carries_tag_and_digest() {
2182        assert_eq!(
2183            image_ref("sha256:deadbeef").pull_ref(),
2184            "ghcr.io/yah-ai/rusty-v8-musl-builder:latest@sha256:deadbeef",
2185        );
2186    }
2187
2188    #[test]
2189    fn pull_ref_unpinned_falls_back_to_tag_only() {
2190        // An unpinned catalog image (all-zeros sentinel) resolves by tag —
2191        // no store holds `…@sha256:0000…`, so the tag is the only usable key.
2192        assert_eq!(
2193            image_ref(ImageRef::UNPINNED_DIGEST).pull_ref(),
2194            "ghcr.io/yah-ai/rusty-v8-musl-builder:latest",
2195        );
2196    }
2197
2198    #[test]
2199    fn test_digest_alias_is_the_unpinned_sentinel() {
2200        assert_eq!(testing::TEST_DIGEST, ImageRef::UNPINNED_DIGEST);
2201    }
2202
2203    #[test]
2204    fn static_asset_workload_round_trips() {
2205        let src = format!(
2206            r#"
2207schema_version = "V1"
2208
2209[[asset]]
2210filename = "whisper/distil-large-v3-q5_1.bin"
2211source   = "sources/distil-large-v3-q5_1.bin"
2212blake3   = "{HASH_64}"
2213
2214[[asset]]
2215filename = "whisper/distil-large-v3-q4_0.bin"
2216source   = "sources/distil-large-v3-q4_0.bin"
2217blake3   = "{HASH_64}"
2218
2219[aliases]
2220"whisper-default" = "whisper/distil-large-v3-q5_1.bin"
2221"#
2222        );
2223        let w: StaticAssetWorkload = toml::from_str(&src).expect("parse");
2224        assert_eq!(w.assets.len(), 2);
2225        assert_eq!(w.assets[0].filename, "whisper/distil-large-v3-q5_1.bin");
2226        assert_eq!(w.assets[0].blake3.0, HASH_64);
2227        assert_eq!(w.aliases["whisper-default"], "whisper/distil-large-v3-q5_1.bin");
2228
2229        let back = toml::to_string(&w).expect("serialize");
2230        let w2: StaticAssetWorkload = toml::from_str(&back).expect("re-parse");
2231        assert_eq!(w, w2);
2232    }
2233
2234    #[test]
2235    fn license_round_trip_each_variant() {
2236        // Wire format is whatever serde's `rename_all = "kebab-case"` emits.
2237        // heck's kebab-case keeps letter→digit attached but splits digit→uppercase,
2238        // so `Apache2 → "apache2"` and `Bsd2Clause → "bsd2-clause"`.
2239        for (variant, on_wire) in [
2240            (License::Mit, "mit"),
2241            (License::Apache2, "apache2"),
2242            (License::Bsd2Clause, "bsd2-clause"),
2243            (License::Bsd3Clause, "bsd3-clause"),
2244            (License::Isc, "isc"),
2245        ] {
2246            let ser = serde_json::to_value(variant).expect("serialize");
2247            assert_eq!(ser, serde_json::Value::String(on_wire.into()));
2248            let back: License = serde_json::from_value(ser).expect("deserialize");
2249            assert_eq!(back, variant);
2250        }
2251    }
2252
2253    #[test]
2254    fn license_rejects_non_permissive_variants() {
2255        for unknown in ["GPL-3.0", "AGPL", "lgpl-2.1", "unknown", "MIT"] {
2256            let res: Result<License, _> =
2257                serde_json::from_value(serde_json::Value::String(unknown.into()));
2258            assert!(res.is_err(), "expected rejection for {unknown:?}");
2259        }
2260    }
2261
2262    #[test]
2263    fn fetch_source_round_trips() {
2264        let src = format!(
2265            r#"
2266url     = "https://example.invalid/upstream.bin"
2267blake3  = "{HASH_64}"
2268license = "mit"
2269"#
2270        );
2271        let fs: FetchSource = toml::from_str(&src).expect("parse");
2272        assert_eq!(fs.url, "https://example.invalid/upstream.bin");
2273        assert_eq!(fs.blake3.0, HASH_64);
2274        assert_eq!(fs.license, License::Mit);
2275
2276        let back = toml::to_string(&fs).expect("serialize");
2277        let fs2: FetchSource = toml::from_str(&back).expect("re-parse");
2278        assert_eq!(fs, fs2);
2279    }
2280
2281    #[test]
2282    fn fetch_source_rejects_unknown_license() {
2283        let src = format!(
2284            r#"
2285url     = "https://example.invalid/upstream.bin"
2286blake3  = "{HASH_64}"
2287license = "GPL-3.0"
2288"#
2289        );
2290        let res: Result<FetchSource, _> = toml::from_str(&src);
2291        assert!(res.is_err(), "expected non-permissive license to reject");
2292    }
2293
2294    #[test]
2295    fn asset_entry_derive_mode_round_trips() {
2296        let src = format!(
2297            r#"
2298schema_version = "V1"
2299
2300[[asset]]
2301filename = "whisper/distil-large-v3-q5_1.bin"
2302blake3   = "{HASH_64}"
2303
2304[asset.derive.fetch]
2305url     = "https://example.invalid/ggml-distil-large-v3.bin"
2306blake3  = "{HASH_64}"
2307license = "mit"
2308
2309[asset.derive.transform]
2310recipe = "whisper-quantize"
2311params = {{ quant = "q5_1" }}
2312"#
2313        );
2314        let w: StaticAssetWorkload = toml::from_str(&src).expect("parse");
2315        assert_eq!(w.assets.len(), 1);
2316        let entry = &w.assets[0];
2317        assert!(entry.source.is_none());
2318        let derive = entry.derive.as_ref().expect("derive present");
2319        assert_eq!(derive.fetch.url, "https://example.invalid/ggml-distil-large-v3.bin");
2320        assert_eq!(derive.fetch.license, License::Mit);
2321        let transform = derive.transform.as_ref().expect("transform present");
2322        assert_eq!(transform.recipe, "whisper-quantize");
2323        assert_eq!(transform.params.get("quant").map(String::as_str), Some("q5_1"));
2324
2325        let back = toml::to_string(&w).expect("serialize");
2326        let w2: StaticAssetWorkload = toml::from_str(&back).expect("re-parse");
2327        assert_eq!(w, w2);
2328    }
2329
2330    #[test]
2331    fn legacy_source_only_asset_serializes_without_derive_field() {
2332        // Verify the skip_serializing_if guards keep legacy TOMLs round-tripping
2333        // without ever emitting an empty `derive = ...` line.
2334        let src = format!(
2335            r#"
2336schema_version = "V1"
2337
2338[[asset]]
2339filename = "operator-curated.bin"
2340source   = "sources/operator-curated.bin"
2341blake3   = "{HASH_64}"
2342"#
2343        );
2344        let w: StaticAssetWorkload = toml::from_str(&src).expect("parse");
2345        let back = toml::to_string(&w).expect("serialize");
2346        assert!(!back.contains("derive"), "serialized output leaked a derive field: {back}");
2347        let w2: StaticAssetWorkload = toml::from_str(&back).expect("re-parse");
2348        assert_eq!(w, w2);
2349    }
2350
2351    /// W212/R518: the `[asset.derive.lock]` block round-trips through TOML, and
2352    /// is omitted from output when absent (so non-derive / unlocked assets stay
2353    /// clean).
2354    #[test]
2355    fn derive_lock_round_trips_through_toml() {
2356        let toml = r#"
2357url     = "https://example.invalid/config.json"
2358blake3  = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
2359license = "mit"
2360"#;
2361        let fetch: FetchSource = ::toml::from_str(toml).unwrap();
2362        let derive = AssetDerive {
2363            fetch,
2364            transform: Some(TransformSpec {
2365                recipe: "whisper-bundle-tar".into(),
2366                params: BTreeMap::new(),
2367            }),
2368            lock: Some(DeriveLock {
2369                input_hash: "1111111111111111111111111111111111111111111111111111111111111111".into(),
2370                output_blake3: "2222222222222222222222222222222222222222222222222222222222222222".into(),
2371            }),
2372        };
2373        let s = ::toml::to_string(&derive).unwrap();
2374        assert!(s.contains("[lock]"), "lock serialized: {s}");
2375        let back: AssetDerive = ::toml::from_str(&s).unwrap();
2376        assert_eq!(derive, back);
2377
2378        // Absent lock → no `[lock]` table in the output.
2379        let unlocked = AssetDerive { lock: None, ..derive };
2380        let s2 = ::toml::to_string(&unlocked).unwrap();
2381        assert!(!s2.contains("[lock]"), "unlocked must omit lock: {s2}");
2382    }
2383
2384    #[test]
2385    fn shape_static_asset_rejects_both_source_and_derive() {
2386        use crate::validate::{shape_static_asset, FieldPath, ShapeError};
2387
2388        let entry = AssetEntry {
2389            filename: "ambiguous.bin".into(),
2390            source: Some("sources/ambiguous.bin".into()),
2391            derive: Some(AssetDerive {
2392                fetch: FetchSource {
2393                    url: "https://example.invalid/x".into(),
2394                    blake3: BlakeHash(HASH_64.into()),
2395                    license: License::Mit,
2396                },
2397                transform: None,
2398                lock: None,
2399            }),
2400            blake3: BlakeHash(HASH_64.into()),
2401        };
2402        let w = StaticAssetWorkload {
2403            schema_version: SchemaVersion::V1,
2404            assets: vec![entry],
2405            aliases: BTreeMap::new(),
2406        };
2407        let err = shape_static_asset(&w).expect_err("XOR violated");
2408        match err {
2409            ShapeError::Field { path: FieldPath::Asset(0, "source"), .. } => {}
2410            other => panic!("expected Asset(0, \"source\") shape error, got {other:?}"),
2411        }
2412    }
2413
2414    #[test]
2415    fn shape_static_asset_rejects_neither_source_nor_derive() {
2416        use crate::validate::{shape_static_asset, FieldPath, ShapeError};
2417
2418        let entry = AssetEntry {
2419            filename: "empty.bin".into(),
2420            source: None,
2421            derive: None,
2422            blake3: BlakeHash(HASH_64.into()),
2423        };
2424        let w = StaticAssetWorkload {
2425            schema_version: SchemaVersion::V1,
2426            assets: vec![entry],
2427            aliases: BTreeMap::new(),
2428        };
2429        let err = shape_static_asset(&w).expect_err("XOR violated");
2430        match err {
2431            ShapeError::Field { path: FieldPath::Asset(0, "source"), .. } => {}
2432            other => panic!("expected Asset(0, \"source\") shape error, got {other:?}"),
2433        }
2434    }
2435
2436    #[test]
2437    fn shape_static_asset_accepts_either_mode() {
2438        use crate::validate::shape_static_asset;
2439
2440        let legacy = AssetEntry {
2441            filename: "a.bin".into(),
2442            source: Some("sources/a.bin".into()),
2443            derive: None,
2444            blake3: BlakeHash(HASH_64.into()),
2445        };
2446        let derived = AssetEntry {
2447            filename: "b.bin".into(),
2448            source: None,
2449            derive: Some(AssetDerive {
2450                fetch: FetchSource {
2451                    url: "https://example.invalid/b".into(),
2452                    blake3: BlakeHash(HASH_64.into()),
2453                    license: License::Apache2,
2454                },
2455                transform: None,
2456                lock: None,
2457            }),
2458            blake3: BlakeHash(HASH_64.into()),
2459        };
2460        let w = StaticAssetWorkload {
2461            schema_version: SchemaVersion::V1,
2462            assets: vec![legacy, derived],
2463            aliases: BTreeMap::new(),
2464        };
2465        shape_static_asset(&w).expect("both modes accepted");
2466    }
2467
2468    #[test]
2469    fn image_ref_string_form_rejects_bare_tag() {
2470        let res: Result<ImageRef, _> =
2471            serde_json::from_value(serde_json::Value::String("node:20".into()));
2472        let err = res.expect_err("bare-tag must reject");
2473        let msg = format!("{err}");
2474        assert!(msg.contains("digest"), "error should mention digest: {msg}");
2475    }
2476
2477    #[test]
2478    fn image_ref_string_form_accepts_digest_pinned() {
2479        let pinned = format!("node:20@sha256:{HASH_64}");
2480        let img: ImageRef =
2481            serde_json::from_value(serde_json::Value::String(pinned.clone())).expect("parse");
2482        assert_eq!(img.registry, "docker.io");
2483        assert_eq!(img.repository, "library/node");
2484        assert_eq!(img.tag, "20");
2485        assert_eq!(img.digest, format!("sha256:{HASH_64}"));
2486    }
2487
2488    #[test]
2489    fn image_ref_string_form_accepts_ghcr_with_pin() {
2490        let pinned = format!("ghcr.io/foo/bar:v1.7.4@sha256:{HASH_64}");
2491        let img: ImageRef =
2492            serde_json::from_value(serde_json::Value::String(pinned)).expect("parse");
2493        assert_eq!(img.registry, "ghcr.io");
2494        assert_eq!(img.repository, "foo/bar");
2495        assert_eq!(img.tag, "v1.7.4");
2496        assert!(img.digest.starts_with("sha256:"));
2497    }
2498
2499    #[test]
2500    fn image_ref_string_form_rejects_non_sha256_digest() {
2501        for bad in [
2502            "node:20@md5:abcdef",
2503            "node:20@sha1:abcdef",
2504            "node:20@sha256:",
2505            "node:20@sha256:zzznothex",
2506        ] {
2507            let res: Result<ImageRef, _> =
2508                serde_json::from_value(serde_json::Value::String(bad.into()));
2509            assert!(res.is_err(), "expected reject for {bad:?}");
2510        }
2511    }
2512
2513    #[test]
2514    fn image_ref_struct_form_rejects_missing_digest() {
2515        // Digest is now structurally required (R438-T3). Struct-form payloads
2516        // without `digest` must fail at serde-deserialize.
2517        let v = serde_json::json!({
2518            "registry": "ghcr.io",
2519            "repository": "noisetable/api",
2520            "tag": "v1.4.2",
2521        });
2522        let res: Result<ImageRef, _> = serde_json::from_value(v);
2523        assert!(res.is_err(), "missing digest must reject");
2524    }
2525
2526    #[test]
2527    fn image_ref_struct_form_round_trips_through_toml() {
2528        let img = ImageRef {
2529            registry: "ghcr.io".into(),
2530            repository: "ggerganov/whisper.cpp".into(),
2531            tag: "v1.7.4".into(),
2532            digest: format!("sha256:{HASH_64}"),
2533        };
2534        let toml_doc = toml::to_string(&img).expect("serialize");
2535        let back: ImageRef = toml::from_str(&toml_doc).expect("re-parse");
2536        assert_eq!(img, back);
2537    }
2538
2539    #[test]
2540    fn workload_envelope_dispatches_static_asset() {
2541        // Externally-tagged: the `static-asset` variant is a wrapping table
2542        // (R590-B3 flipped the Workload graph off internal `kind =` tags so the
2543        // whole spec is postcard-decodable over the kamaji UDS).
2544        let src = format!(
2545            r#"
2546[static-asset]
2547schema_version = "V1"
2548
2549[[static-asset.asset]]
2550filename = "foo/bar.bin"
2551source   = "sources/bar.bin"
2552blake3   = "{HASH_64}"
2553"#
2554        );
2555        let w: Workload = toml::from_str(&src).expect("parse");
2556        assert!(matches!(w, Workload::StaticAsset(_)));
2557    }
2558
2559    // ── R572-F1: lifecycle archetype discriminator ─────────────────────────
2560
2561    fn archetype_test_spec(name: &str) -> WorkloadSpec {
2562        WorkloadSpec::for_forge(
2563            name,
2564            ImageRef {
2565                registry: "ghcr.io".into(),
2566                repository: "yah/test".into(),
2567                tag: "latest".into(),
2568                digest: testing::test_digest(),
2569            },
2570            TierTag("infra".into()),
2571            vec![],
2572        )
2573    }
2574
2575    #[test]
2576    fn explicit_archetype_round_trips_through_json_and_wins_over_inference() {
2577        for archetype in [
2578            LifecycleArchetype::Server,
2579            LifecycleArchetype::Appliance,
2580            LifecycleArchetype::Job,
2581        ] {
2582            let mut spec = archetype_test_spec("explicit");
2583            // Volumes present + restart_policy Always would infer Appliance
2584            // (see effective_archetype_infers_* below) — deliberately
2585            // mismatched against every archetype under test so the
2586            // assertion actually proves the explicit field wins, not that
2587            // it happens to agree with inference.
2588            spec.volumes = vec![VolumeMount {
2589                source: VolumeSource::Named { name: "data".into() },
2590                target: PathBuf::from("/data"),
2591                read_only: false,
2592            }];
2593            spec.restart_policy = RestartPolicy::Always;
2594            spec.archetype = Some(archetype);
2595
2596            let json = serde_json::to_string(&spec).expect("serialize");
2597            assert!(
2598                json.contains("\"archetype\""),
2599                "explicit archetype must be present on the wire"
2600            );
2601            let back: WorkloadSpec = serde_json::from_str(&json).expect("deserialize");
2602            assert_eq!(spec, back, "spec did not survive JSON round-trip");
2603            assert_eq!(back.archetype, Some(archetype));
2604            assert_eq!(
2605                back.effective_archetype(),
2606                archetype,
2607                "explicit archetype must win over the volumes/restart_policy inference"
2608            );
2609        }
2610    }
2611
2612    #[test]
2613    fn archetype_serializes_as_null_when_none() {
2614        let mut spec = archetype_test_spec("omitted");
2615        spec.archetype = None;
2616        let json = serde_json::to_value(&spec).expect("to_value");
2617        // Postcard-native (R590-B3): no `skip_serializing_if` anywhere on the
2618        // graph, so every field is always on the wire — a None Option is an
2619        // explicit `null`, not an absent key. The binary UDS wire is positional
2620        // and requires the slot to be present.
2621        assert_eq!(json.get("archetype"), Some(&serde_json::Value::Null));
2622    }
2623
2624    #[test]
2625    fn spec_without_archetype_field_deserializes_to_none() {
2626        // Simulates an on-disk spec written before R572-F1: no `archetype`
2627        // key at all. Omitting the key must still parse to None (the additive-
2628        // default contract) even though we now always *emit* the field.
2629        let mut spec = archetype_test_spec("pre-existing");
2630        spec.archetype = None;
2631        let mut json = serde_json::to_value(&spec).expect("to_value");
2632        json.as_object_mut().unwrap().remove("archetype");
2633        let back: WorkloadSpec = serde_json::from_value(json).expect("deserialize");
2634        assert_eq!(back.archetype, None);
2635    }
2636
2637    #[test]
2638    fn effective_archetype_infers_appliance_from_volumes_when_field_absent() {
2639        // Pre-R572 behavior: a workload with a volume was understood (by
2640        // convention, never a type) to be stateful/pinned. Confirm that
2641        // meaning is preserved bit-for-bit through effective_archetype().
2642        let mut spec = archetype_test_spec("appliance-inferred");
2643        spec.volumes = vec![VolumeMount {
2644            source: VolumeSource::Named { name: "pgdata".into() },
2645            target: PathBuf::from("/var/lib/postgresql/data"),
2646            read_only: false,
2647        }];
2648        spec.restart_policy = RestartPolicy::Always;
2649        spec.archetype = None;
2650        assert_eq!(spec.effective_archetype(), LifecycleArchetype::Appliance);
2651    }
2652
2653    #[test]
2654    fn effective_archetype_infers_job_from_restart_never_when_field_absent() {
2655        // Pre-R572 behavior: RestartPolicy::Never + no volumes is the forge
2656        // run-once convention (see RestartPolicy::Never's own doc comment) —
2657        // structurally a job. WorkloadSpec::for_forge already produces
2658        // exactly this shape; isolate the pure-inference path by clearing
2659        // the explicit archetype for_forge now sets.
2660        let mut spec = archetype_test_spec("job-inferred");
2661        assert!(spec.volumes.is_empty());
2662        assert!(matches!(spec.restart_policy, RestartPolicy::Never));
2663        spec.archetype = None;
2664        assert_eq!(spec.effective_archetype(), LifecycleArchetype::Job);
2665    }
2666
2667    #[test]
2668    fn effective_archetype_defaults_to_server_as_the_common_case_when_field_absent() {
2669        // Pre-R572 behavior: no volumes + a restartable policy (the common
2670        // stateless-web-server shape) inferred as movable/fungible.
2671        let mut spec = archetype_test_spec("server-inferred");
2672        spec.restart_policy = RestartPolicy::Always;
2673        spec.archetype = None;
2674        assert_eq!(spec.effective_archetype(), LifecycleArchetype::Server);
2675    }
2676
2677    // ── R594-F2: public-ingress appliance (container-shaped, not a new
2678    // Workload variant — see Workload::Container's doc comment) ───────────
2679
2680    #[test]
2681    fn ingress_marked_spec_is_appliance_and_carries_public_ip_placement_requirement() {
2682        let mut spec = archetype_test_spec("public-ingress");
2683        spec.archetype = Some(LifecycleArchetype::Appliance);
2684        spec.annotations.insert(
2685            REQUIRES_TAINT_ANNOTATION.to_string(),
2686            PUBLIC_IP_TAINT.to_string(),
2687        );
2688
2689        assert_eq!(
2690            spec.effective_archetype(),
2691            LifecycleArchetype::Appliance,
2692            "ingress must be pinned-per-node/non-drainable, the R572 appliance sense"
2693        );
2694        assert_eq!(
2695            spec.requires_taint(),
2696            Some(PUBLIC_IP_TAINT),
2697            "ingress must declare it can only land on a public-ip-tainted node"
2698        );
2699
2700        // No taint exists to match against yet (R572-F3) and nothing
2701        // enforces placement yet (R572-F5) — confirm this ticket stays
2702        // declarative-only by checking a spec with no requirement stays
2703        // unaffected.
2704        let unrelated = archetype_test_spec("unrelated");
2705        assert_eq!(unrelated.requires_taint(), None);
2706    }
2707
2708    #[test]
2709    fn ingress_marked_spec_round_trips_through_json_as_a_container_workload() {
2710        // Mirrors the on-disk envelope: the externally-tagged `container`
2711        // variant wrapping the WorkloadSpec, exactly like every other
2712        // container-shaped workload. No new Workload variant, no new
2713        // discriminator.
2714        let mut inner = archetype_test_spec("public-ingress");
2715        inner.archetype = Some(LifecycleArchetype::Appliance);
2716        inner.annotations.insert(
2717            REQUIRES_TAINT_ANNOTATION.to_string(),
2718            PUBLIC_IP_TAINT.to_string(),
2719        );
2720        let workload = Workload::Container(inner.clone());
2721
2722        let json = serde_json::to_string(&workload).expect("serialize");
2723        assert!(json.contains("\"container\""));
2724        assert!(json.contains(REQUIRES_TAINT_ANNOTATION));
2725        assert!(json.contains(PUBLIC_IP_TAINT));
2726
2727        let back: Workload = serde_json::from_str(&json).expect("deserialize");
2728        match back {
2729            Workload::Container(spec) => {
2730                assert_eq!(spec, inner);
2731                assert_eq!(spec.effective_archetype(), LifecycleArchetype::Appliance);
2732                assert_eq!(spec.requires_taint(), Some(PUBLIC_IP_TAINT));
2733            }
2734            other => panic!("expected Workload::Container, got {other:?}"),
2735        }
2736    }
2737}