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:phase(P1)
240//! @yah:status(review)
241//! @yah:assignee(agent:bundle-anthropic-ashguard)
242//! @yah:at(2026-08-03T00:44:43Z)
243//! @yah:parent(R546)
244//! @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.")
245//! @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.")
246//! @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.")
247//! @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.")
248//! @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.")
249//! @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.")
250//! @yah:handoff("DONE. Workload now carries TWO wire shapes behind hand-written Serialize/Deserialize that branch on is_human_readable() -- option (a) and (b) from the ticket's next-steps merged into one type instead of splitting it. TOML/JSON get the INTERNAL `kind` tag (the flat shape every on-disk file uses); postcard keeps the EXTERNAL variant-index encoding R590-B3 established for the kamaji UDS. Same idiom ImageRef already used for its string-vs-struct form, so there is now one precedent, not two mechanisms. Mirror enums (WorkloadTagged/WorkloadExternal + borrowing twins) carry the two encodings; their variant ORDER is load-bearing for postcard and is commented as such. schemars/ts-rs get tag=kind + rename_all via #[schemars(...)]/#[ts(...)] so the generated JSON schema and TS bindings describe the on-disk shape instead of the wire shape.")
251//! @yah:handoff("SECOND BLOCKER, fixed in the same pass: after the tagging fix only 2 of 8 on-disk workload.toml files still parsed. SchemaVersion is a unit-variant enum wanting the string \"V1\", but 6 files (every mesofact-static + container + cloudflare-worker component) are authored `schema_version = 1`, and R438-T6 had worked around it by hand-extracting raw toml::Value subtrees in read_mesofact_build. Gave SchemaVersion a liberal-read/canonical-write Deserialize (accepts 1, \"V1\", \"v1\"; always serializes \"V1\"), on the same is_human_readable branch so postcard is untouched. B7's stated goal is not met without it -- a fixed envelope that still rejects 6 of 8 files is not fixed.")
252//! @yah:handoff("FILES. (1) oss/yah-base/crates/workload-spec/src/lib.rs -- Workload dual-shape impls + mirror enums; the lib.rs ~L2544 test that asserted the broken `[[static-asset.asset]]` encoding rewritten to the flat form, plus a new test pinning BOTH halves (flat kind in JSON, postcard round-trip). (2) .../src/version.rs -- SchemaVersion custom Deserialize + 3 tests. (3) .../src/bin/export-ts.rs -- path was 3 parents up from CARGO_MANIFEST_DIR, but 75d8df7e moved the crate under oss/yah-base and added a level, so since that commit the bin silently wrote to oss/yah-base/packages/ and the committed TS stopped tracking the Rust types (last real update Jun 28). Now 4. (4) scripts/check-workload-spec-ts.sh -- `cargo run -p yah-workload-spec` fails from the camp root (crate is in the excluded oss/yah-base workspace); switched to --manifest-path. It was dead since the same commit. (5) packages/yah/workload-spec/index.ts + .yah/schema/workload.toml.schema.json regenerated (mirror.toml.schema.json also moved -- that is @Ashguard:dragon's W267 ingress field swept in by the shared regen, not mine).")
253//! @yah:handoff("FIXTURE SWEEP (flagged by @Ashguard:dove mid-turn -- my change, my sweep): 8 yah-cloud tests were red on hand-written externally-tagged TOML. Fixed reconciler/derive_cache_prune.rs (2 fixtures), reconciler/static_asset_prune.rs (4), validate.rs (1), tests/whisper_derive_e2e.rs (1), app/yah/cli/src/cloud.rs (alias-collision fixture + the_deploy_body_parses_as_a_workload_envelope_not_a_bare_spec, which asserted external tagging and now asserts a flat `kind`). NOT touched: yubaba/src/lib.rs bundle_deploy_tests -- @Ashguard:dove already rewrote that one in their own relay's module and asked me not to double-fix.")
254//! @yah:handoff("COMMENTS CORRECTED, not left lying: static_asset.rs::load_workload and asset_status.rs both carried R546-B7 comments asserting the envelope is externally tagged and unusable. Both now say the envelope works and the direct StaticAssetWorkload parse is a deliberate shortcut (load_workload keeps it to produce a precise wrong-kind error naming the kind found; asset_status keeps it because component.kind is already checked upstream).")
255//! @yah:verify("THE TICKET'S OWN REPRO NOW PASSES: `cargo run --manifest-path oss/yah-base/crates/workload-spec/Cargo.toml --example parse_whisper_toml` -> 'parsed as StaticAsset / assets: 2 / aliases: 3 / shape validation: ok / populated-shape verifier: ok'. It panicked before against the long-published whisper catalog.")
256//! @yah:verify("NEW CI GATE (the ticket's third next-step): xtask/tests/workload_envelope.rs walks the whole camp for workload.toml, parses every file whose kind is one of the four modelled variants through workload_spec::Workload, and hard-asserts no error contains 'wanted exactly 1 element' -- the exact external-tagging signature. It lives in xtask, not workload-spec, because that crate is in the standalone-exported oss/yah-base workspace and cannot reach app/ or .yah/. Runs under the check pipeline's existing cargo-test step. Carries a SHRINK-ONLY KNOWN_GAPS list: a file that starts parsing FAILS the test until its entry is deleted, and a stale entry (file moved/deleted) also fails, so the list cannot rot or grow silently.")
257//! @yah:verify("GREEN: yah-workload-spec --all-features (55 lib + round_trip/postcard + shape_fixtures, 10 targets, 0 failed); kamaji-proto 25/25 incl. deploy_container_round_trip and deploy_string_pinned_image_ref_survives_postcard_wire (the exact UDS path R590-B3 fixed -- proves the binary branch is byte-unchanged); yubaba --lib 339/339; yah-cloud 610 lib + whisper_derive_e2e 1/1; yah --lib cloud:: 108/108; xtask schema_drift 2/2 and workload_envelope 1/1.")
258//! @yah:verify("NOT MINE, seen while verifying: (a) reconciler::pond::tests::{ensure_sim_port_free_ok_when_unbound, port_has_listener_...} flake in a full-suite run and pass in isolation -- they bind real ports and race on a busy dev box. (b) 10 arch::ticket::tests failures in `cargo test -p yah --lib` from a peer's in-flight @yah: annotation-parser work; app/yah/cli/src/arch/ticket.rs has zero references to workload_spec, so nothing in this change can reach them. (c) MirrorConfig's new `ingress` field (@Ashguard:dragon, W267/R594) broke the yah-cloud and yah-cli test builds mid-session; it cleared on its own as they swept call sites -- I did not touch their files.")
259//! @yah:gotcha("VARIANT ORDER IS LOAD-BEARING. WorkloadExternal / WorkloadExternalRef in lib.rs must list variants in the SAME order as Workload -- postcard encodes an external tag as the variant INDEX, so reordering or inserting a variant anywhere but the end silently decodes kamaji UDS frames into the wrong variant. There is no type error for this. Commented at the definitions; the round_trip postcard tests catch a mismatch only if the payload types differ enough to fail decode.")
260//! @yah:gotcha("TWO GAPS DELIBERATELY NOT CLOSED, filed as R658 (umbrella) -> R658-B1 (MesofactStaticWorkload.routes is a required top-level field but all 4 real files AND the CLI scaffold write it inside [build], so TOML scopes it to build.routes) and R658-B2 (kind = \"container\" selects two incompatible schemas: Workload::Container(WorkloadSpec) vs ContainerReconciler's local docker build/run shape). Neither is the B7 tagging bug -- they were invisible until the envelope started being exercised. B2 needs an operator naming decision. Both are pinned in workload_envelope.rs's KNOWN_GAPS so they cannot be forgotten or silently widened.")
261//! @yah:gotcha("HALF-STALE as of 2026-08-18: the gotcha above says both R658 gaps are pinned in workload_envelope.rs KNOWN_GAPS. Only R658-B2 (`missing field image`) still is. R658-B1 is CLOSED - all four `missing field routes` entries were deleted, every manifest and the `yah cloud site init` scaffold now write routes ABOVE [build], and BuildConfig carries serde(deny_unknown_fields) so the misplacement is a parse error rather than a dropped key. See R658-B1.")
262//!
263//! @yah:ticket(R626-S3, "Where does desired-state live? Durable per-workload replica count that survives reconcile loops and camp restarts (0↔1 vs scale-to-N)")
264//! @yah:status(review)
265//! @yah:assignee(agent:bundle-anthropic-glimmerstone)
266//! @yah:at(2026-07-23T17:47:24Z)
267//! @yah:kind(spike)
268//! @yah:phase(P3)
269//! @yah:parent(R626)
270//! @yah:handoff("DECIDED + LANDED. Desired state lives in the CAMP DAEMON, in a durable camp-local document at <camp>/.yah/state/desired-state.json, and NEVER crosses the kamaji or yubaba wire. The governing principle, written to survive the tier: desired state belongs to the DECLARER, not the supervisor — whoever re-asserts a deployment owns the record of whether it is wanted, because anything stored below the declarer is overwritten by the declarer's next re-assert. In the pond/dev tier the declarer is camp (ensure_pond_running -> reconcile_pond_deploys -> deploy_pond_mirrors, which runs at every camp start AND every pond.ensure_running RPC). In cloud the same rule points at the CloudConfig reconciler's raft store. Kamaji is never the holder in either tier.")
271//! @yah:handoff("REJECTIONS, with the reason each is not a near-miss. kamaji-local: kamaji is deliberately imperative (Deploy/Stop/List, crash-restart delegated to dockerd's policy per R626-F2) — it holds no desired set and runs no reconcile loop, so storing intent there means giving it a SECOND reconciler that can disagree with camp's, and it still loses to camp's POST /pond/deploy from above. yubaba raft: right answer at cloud scale, wrong scope here — the pond yubaba is a container camp starts, its PondRegistry is in-memory (a restart forgets everything), and a single-camp dev tier has no quorum to be consistent about. Git-tracked config: camp.toml/mirror.toml are the DECLARATION (what exists); a stop is per-machine operator intent (systemctl disable, not editing the unit file) and must not propagate to a teammate's checkout — hence .yah/state/ is gitignored, in both the camp's .gitignore and the scaffold_camp_skeleton template.")
272//! @yah:handoff("SHAPE: one knob, `replicas`, where 0 = stopped — deliberately the SAME axis as workload_spec::WorkloadSpec.replicas so scale-to-N later lifts a ceiling instead of adding a second concept beside a boolean. MAX_SUPPORTED_REPLICAS = 1 today and set_replicas REJECTS anything higher rather than persisting an intent no supervisor can honour (a clamp would silently record something the operator did not ask for). No record = replicas 1: a declared workload runs unless someone said otherwise. updated_at + reason ride along so a stale intent is legible and the UI can say when/why. Writes are tmp-then-rename; reads FAIL OPEN (missing/unreadable/corrupt/newer-schema all mean 'everything runs', corrupt file preserved as .corrupt-<epoch_ms>) — fail-closed would mass-stop a camp on one bad byte, and a resurrection is the recoverable failure.")
273//! @yah:handoff("LANDED: (1) app/yah/cli/src/desired_state.rs — DesiredStateDoc / WorkloadDesire / DesiredStateStore (load, desired_replicas, is_stopped, stopped_keys, set_replicas, stop, start, forget), 10 unit tests incl. survives-a-camp-restart, per-workload isolation, replicas>1 rejected AND not written, corrupt-file quarantine + fail-open, newer-schema fail-open, and an explicit 'a stop is not a failure' guard on the serialized document. (2) camp.rs: deploy_pond_mirrors and reconcile_pond_deploys now consult the store and skip stopped idents — this is THE enforcement point, since camp's re-assert is the only place 'stay stopped' can be honoured. Extracted pond_idents_needing_deploy(declared, registered, stopped) as a pure helper with 5 tests, because the stopped-subtraction is the load-bearing half: a stopped workload is absent from yubaba's registry ON PURPOSE and is indistinguishable from a failed deploy without the intent record. (3) .yah/.gitignore + app/yah/cli/templates/yah-gitignore-default gain /state. (4) .yah/docs/working/W287-desired-state-for-supervised-workloads.md carries the full rationale, the rejected options, the F4 build-on list, and the scale-to-N scoping.")
274//! @yah:handoff("DELIBERATE NON-GOAL: writing intent does NOT actuate. The durable record must land even when the stop call fails, or a failed stop comes back on the next reconcile. Actuation is R626-F4's job.")
275//! @yah:next("R626-F4 is unblocked and now has a concrete spec — see W287 §5. It needs three things this ticket deliberately did not build: (a) a per-ident teardown on yubaba (PondRegistry has only shutdown_all, which drains everything; /pond/deploy and /pond/state are the only pond routes), (b) camp RPC methods workload.stop / workload.start writing through DesiredStateStore, (c) desired-vs-actual reporting.")
276//! @yah:next("DO NOT add a Stopped variant to PondPhase (R626-F2's noted gap). PondPhase is yubaba's observation of REALITY; intent never crosses that wire by this decision. Camp is the one process holding both halves — render the pair instead: desired=stopped + actual=absent reads 'deliberately stopped'; desired=running + actual=absent reads 'down'.")
277//! @yah:next("Scale-to-N stays scoped, not committed (W287 §6). WorkloadSpec.replicas makes N look one constant away; it is not. kamaji native.rs:592 rejects replicas>1, and the docker backend names containers by mesh identity (one identity, one container). N needs a placement layer above the single-workload supervisor: per-replica naming (identity==container name is what makes teardown resolve), per-replica host ports (pond publishes fixed ones — two replicas collide), per-replica mesh identity (a load-balanced set is an xlb-net concern), and a placement decision that is yubaba's job on a fleet. Lifting MAX_SUPPORTED_REPLICAS is the entry point once that layer exists.")
278//! @yah:next("Wire DesiredStateStore::forget into the undeclare path so the document doesn't accumulate intent for mirrors that no longer exist.")
279//! @yah:verify("cargo test -p yah --lib desired_state — 15 pass (10 desired_state::tests + 5 camp::r626_s3_desired_state_gate_tests), 0 fail")
280//! @yah:verify("cargo check -p yah — clean (note: this camp's tree is shared and was transiently broken by peers' in-flight edits in oss/qed, yah-party, and yah-almanac during this run; none touched by this ticket)")
281//! @yah:verify("BEHAVIOUR BAR (the one that matters): DesiredStateStore::for_camp(root).stop(ident) followed by a FRESH store over the same root still reports is_stopped — that is exactly a camp restart — and pond_idents_needing_deploy then omits that ident from an EMPTY registry, which is exactly a restarted yubaba. Asserted in camp::r626_s3_desired_state_gate_tests::a_stop_survives_a_camp_restart_end_to_end.")
282//! @yah:gotcha("The store is camp-local and GITIGNORED on purpose. If a future ticket wants a stop to be shared/durable in the repo, that is a different decision (declaration vs intent) — re-open W287 §2 rather than moving the file into tracked territory.")
283//! @yah:gotcha("Reads fail OPEN. Never 'harden' this into fail-closed: an unreadable document would then stop an entire camp, and the failure would be silent (nothing starts) rather than visible (the workload comes back).")
284//!
285//!
286//! @yah:relay(R658, "workload.toml envelope: two type-vs-reality mismatches R546-B7 uncovered but did not fix")
287//! @yah:at(2026-08-03T00:43:00Z)
288//! @yah:status(open)
289//! @yah:assignee(agent:bundle-anthropic-ashguard)
290//! @yah:parent(R546)
291//!
292//! @yah:ticket(R658-B1, "MesofactStaticWorkload.routes is a required top-level field, but every real file and the CLI scaffold write it inside [build]")
293//! @yah:status(review)
294//! @yah:at(2026-08-19T02:08:02Z)
295//! @yah:assignee(agent:bundle-anthropic-ashguard)
296//! @yah:parent(R658)
297//! @yah:next("REPRO: `cargo test -p xtask --test workload_envelope` with the file's KNOWN_GAPS entry deleted -> `missing field `routes``. Affects app/yah/web/marketing/workload.toml, external/scrabcake/site/workload.toml, .yah/infra/state/sources/scrabcake/site/site/workload.toml, oss/yubaba/crates/cloud/testdata/mesofact-in-container/workload.toml.")
298//! @yah:next("ROOT CAUSE: TOML scopes every key after a table header into that table. All four files write `routes = \"./mesofact.routes.ts\"` AFTER `[build]`, so it deserializes as `build.routes` -- but MesofactStaticWorkload declares `routes` as a required TOP-LEVEL field. BuildConfig ignores the unknown key, so it vanished silently.")
299//! @yah:next("THE SCAFFOLD AGREES WITH THE FILES, NOT THE TYPE: SITE_WORKLOAD_TOML in app/yah/cli/src/cloud.rs (~line 4977) emits `routes` inside [build] too, so every newly scaffolded site inherits the mismatch. Fix the type or fix the scaffold -- but they must agree, and whichever moves needs the other four files migrated with it.")
300//! @yah:next("WHY IT WENT UNNOTICED: nothing reads `routes` off the envelope. mesofact-static's reconciler never loads MesofactStaticWorkload whole (read_mesofact_build does raw toml::Value subtree extraction, R438-T6), and mesofact-build reads mesofact.routes.ts directly. The field is declared but dead.")
301//! @yah:next("AFTER FIXING: delete the four `missing field `routes`` entries from KNOWN_GAPS in xtask/tests/workload_envelope.rs -- that test FAILS on a stale entry, so it will tell you.")
302//! @yah:next("SPREAD, found 2026-08-14 by R715-T2: two MORE files hit this and are NOT in KNOWN_GAPS, so `cargo test -p xtask --test workload_envelope` is RED on a clean tree for everyone. The two are app/yah/web/chat/workload.toml and oss/mesofact/examples/hello/workload.toml, both the same routes-after-[build] shape. Deliberately NOT pinned into KNOWN_GAPS - silently widening the pin is what this ticket exists to stop. Migrate them alongside the other four when the type-vs-scaffold decision lands.")
303//! @yah:handoff("DECIDED: the DATA moved to the type, not the type to the data. `routes` stays a TOP-LEVEL field of MesofactStaticWorkload; all eight on-disk manifests and the CLI scaffold now write it ABOVE [build]. Three reasons the reverse was wrong: (1) MesofactStaticWorkload is a postcard wire type over the kamaji UDS, so moving a field between structs is a wire break needing a lockstep kamaji+yubaba deploy; (2) the field's own doc says it is what the RECONCILER reads to enumerate routes, i.e. deploy-time not build-time, so [build] is the wrong home semantically; (3) the reconciler's own fixtures (mesofact_static.rs), three camp.rs fixtures and the struct literal at cloud.rs:5389 already agreed with the type - only the hand-authored TOML disagreed.")
304//! @yah:verify("cargo test -p xtask --test workload_envelope - GREEN (was RED on a clean tree for everyone). All four `missing field routes` KNOWN_GAPS entries DELETED, not widened; only R658-B2's `missing field image` remains.")
305//! @yah:handoff("ROOT-CAUSE GUARD, the part that makes this not recur: workload_spec::BuildConfig now carries #[serde(deny_unknown_fields)] (oss/yah-base/crates/workload-spec/src/lib.rs). Migrating the files alone would have left the trap armed - serde silently dropping a stray [build] key is WHY a declared-but-dead field survived months unnoticed. Now the misplacement is a parse error naming `routes`, which is the one thing the author needs to move. Inert for the postcard kamaji wire (non-self-describing, positional); only constrains TOML/JSON.")
306//! @yah:verify("cargo test --manifest-path oss/yah-base/crates/workload-spec/Cargo.toml --all-features - 121 lib (incl. 3 new R658-B1 tests) + 8 integration targets, 0 failed. New: mesofact_static_routes_parse_at_the_top_level, mesofact_static_routes_inside_build_is_rejected_by_name, unknown_build_keys_are_refused_rather_than_ignored.")
307//! @yah:verify("cargo test --manifest-path oss/yubaba/Cargo.toml -p yah-cloud --lib - 878 passed, 0 failed.")
308//! @yah:verify("cargo test -p yah --lib cloud:: - 120 passed, 0 failed, incl. the new site_init_tests::scaffold_workload_toml_parses_through_the_envelope_with_top_level_routes.")
309//! @yah:verify("cargo test -p xtask - all 12 targets green, incl. schema_drift and workload_envelope.")
310//! @yah:verify("./scripts/check-workload-spec-ts.sh - in sync (ts-rs ignores deny_unknown_fields, so no TS churn).")
311//! @yah:handoff("DISCOVERED WORK, wider than the ticket title - deny_unknown_fields immediately caught TWO live files the envelope test structurally CANNOT see. app/yah/web/analytics/workload.toml and app/yah/web/dashboard/workload.toml are kind = mesofact-spa, which is not in the test's MODELLED_KINDS, but mesofact-spa rides the SAME MesofactStaticReconciler (app/yah/cli/src/cloud.rs:5195) and therefore the same read_mesofact_build -> BuildConfig parse. Both had routes under [build]; without migrating them my own guard would have broken deploys for analytics.yah.dev and app.yah.dev. Both migrated. Swept every workload.toml in the camp for stray [build] keys: the only two remaining are crates/yah/cloud-admin (R658-B2's file) and app/yah/workers/yah-cr, and NEITHER goes through workload_spec::BuildConfig - both use reconciler-local raw toml::Value extraction (cloudflare_worker.rs:200), so both are unaffected.")
312//! @yah:handoff("ALSO FIXED IN THIS PASS (docs are canon; these were what a human copies): .yah/docs/guides/host-a-site-and-worker-on-yah.md:102 and .yah/docs/architecture/A031-yah-cloud-config-shape.md:438 both showed routes UNDER [build] - they would have re-seeded the bug into every hand-authored manifest. Also oss/yubaba/crates/cloud/src/config.rs:5890 (web_workload_round_trips fixture) and the bundle_assembly_tests fixture at app/yah/cli/src/cloud.rs.")
313//! @yah:handoff("UNRELATED LANDED BREAKAGE unblocked to verify at all: oss/yubaba/crates/cloud/src/reconciler/lowering_golden.rs:48 failed to compile with E0063 missing field `admission` - TransformRecipe gained admission: Option<RecipeAdmission> (oss/qed/crates/velveteen-exec/src/transforms.rs:70, landed in 8b35b0a9) and this golden was never updated. Whole yah-cloud test binary would not build. Added `admission: None` (correct: the golden is an unsigned local recipe and pins the LOWERING shape, which the signature does not participate in). Both files were committed-clean, not a peer's in-flight edit - checked git status before touching.")
314//! @yah:gotcha("UNCOMMITTED REGEN - .yah/schema/workload.toml.schema.json is REGENERATED in the working tree (cargo run -p xtask -- emit-schemas) and must be committed WITH this change. deny_unknown_fields makes schemars emit additionalProperties: false on BuildConfig. scripts/check-schema-drift.sh compares generated output against the git INDEX, so it stays RED until the regen is committed - that is the script working as designed, not drift. Only workload.toml.schema.json moved; no peer's schema was swept in (git diff --stat -- .yah/schema/ = 1 file).")
315//! @yah:assumes("deny_unknown_fields on BuildConfig trades forward-compat for loudness: a manifest carrying a [build] key an older binary does not know is now a hard parse error, not an ignored key. Deliberate and argued in the type's doc comment. Blast radius outside this monorepo is any site scaffolded by an older `yah cloud site init` - the template shipped routes under [build] for its whole life, so such a site now fails to parse until routes is moved above the header. The only tenant in the tree (scrabcake) was migrated; an external one would need the same one-line move.")
316//! @yah:gotcha("NOW FULLY STALE as of 2026-08-19: the HALF-STALE note above says R658-B2's `missing field image` is the one KNOWN_GAPS entry left. R783-F1/F2 closed that too, so KNOWN_GAPS in xtask/tests/workload_envelope.rs is EMPTY - every modelled on-disk workload.toml parses through the envelope. An entry reappearing means a real file stopped parsing.")
317//!
318//!
319//! @yah:ticket(R743-T4, "workload-spec: 7 test binaries to 1")
320//! @yah:at(2026-08-11T01:18:24Z)
321//! @yah:status(review)
322//! @yah:phase(P2)
323//! @yah:parent(R743)
324//! @yah:next("tests/main.rs mod'ing all 7 siblings + autotests = false and [[test]] name = \"main\" in oss/yah-base/crates/workload-spec/Cargo.toml.")
325//! @yah:next("tests/compose/ and tests/fixtures/ are data/module dirs, not targets — they are unaffected. Confirm the [[bin]] named export-ts in Cargo.toml is untouched by autotests = false (it is a bin, not a test, but read it before editing).")
326//! @yah:verify("cargo test -p yah-workload-spec -- --list count unchanged; three green runs. One commit — oss subtree.")
327//! @yah:tier(Cleric)
328//! @yah:handoff("LANDED: tests/main.rs mods in the 7 former top-level integration-test files (compose_import, mesh_resolver, restart_policy, round_trip, secrets_invariant, semantic, shape_fixtures) as submodules; Cargo.toml gained `autotests = false` on [package] plus a single `[[test]] name = \"main\" path = \"tests/main.rs\"`. tests/compose/ and tests/fixtures/ untouched (data dirs); the export-ts [[bin]] untouched (autotests only scans tests/, not bins). Quick audit found nothing to fix: no std::env::set_var/remove_var, no set_current_dir, no TcpListener/bind/fixed ports in any of the 7 files, and only one inner `mod secrets` (in secrets_invariant.rs) which nests fine under its own file-module with no sibling collision — so no renames were needed.")
329//! @yah:verify("RUSTC_WRAPPER=\"\" cargo test -p yah-workload-spec -- --list (run inside oss/yah-base): BEFORE 8 targets (lib 146 + export-ts bin 0 + 7 integration files summing to 65: compose_import 5, mesh_resolver 8, restart_policy 5, round_trip 16, secrets_invariant 7, semantic 18, shape_fixtures 6) = 211 total. AFTER 4 targets (lib 146 + export-ts bin 0 + single `main` integration binary 65, all 65 test names now module-qualified e.g. round_trip::round_trip_full_spec + doctests 0) = 211 total, unchanged.")
330//! @yah:verify("RUSTC_WRAPPER=\"\" cargo test -p yah-workload-spec (inside oss/yah-base): ok. 146 passed lib + ok. 65 passed main + 0 doctests, 0 failed — run three times, all green, no pre-existing failures to record.")
331//!
332//! @yah:ticket(R783-F1, "ContainerManifest: split the on-disk container manifest from the wire WorkloadSpec, keeping postcard byte-identical")
333//! @yah:status(review)
334//! @yah:at(2026-08-19T07:11:49Z)
335//! @yah:assignee(agent:bundle-anthropic-ashguard)
336//! @yah:parent(R783)
337//! @arch:see(.yah/docs/working/W324-workload-kind-is-not-a-runtime.md)
338//! @yah:next("THE SEAM: introduce `ContainerManifest = Reference(WorkloadSpec) | Recipe(ContainerBuild)` and change Workload::Container's payload to it. WorkloadExternal::Container KEEPS WorkloadSpec so the postcard kamaji wire is byte-identical - verify with the existing round_trip.rs postcard tests, which must pass UNCHANGED.")
339//! @yah:next("WHY a recipe cannot just be a WorkloadSpec (this is the whole design): ImageRef.digest is String, not Option<String> - R438-T3 tightened it deliberately and the string form REJECTS a bare tag at serde-deserialize (lib.rs:187, parser compose_import::parse_pinned_image_ref). The local form's image is `yah-local/yah-cloud-admin:dev`, a bare tag, because the digest does not exist until docker build has run. Preserve that invariant; do not weaken ImageRef to make this easier.")
340//! @yah:next("Encode the invariant in the signature: ContainerBuild::into_spec(self, digest: &str) -> WorkloadSpec. The lowering is only available AFTER a build produced a digest. Serializing a Recipe to postcard must be an Err, not a panic and not a silent empty digest.")
341//! @yah:next("Tier: Wizard - cross-workspace type split with a wire invariant to preserve; the postcard encoding is positional and a mistake decodes silently into the wrong variant.")
342//! @yah:verify("cargo test --manifest-path oss/yah-base/crates/workload-spec/Cargo.toml --all-features - round_trip.rs postcard tests must pass UNCHANGED (they are the wire-compat gate).")
343//! @yah:verify("cargo test -p xtask --test workload_envelope with the `missing field image` KNOWN_GAPS entry for crates/yah/cloud-admin/workload.toml DELETED - that file is the acceptance case.")
344//! @yah:verify("cargo test --manifest-path oss/kamaji/Cargo.toml -p kamaji-proto codec - deploy_container_round_trip is the exact UDS path.")
345//! @yah:gotcha("VARIANT ORDER IS LOAD-BEARING on WorkloadExternal/WorkloadExternalRef - postcard encodes the external tag as the variant INDEX, so reordering or inserting anywhere but the end silently decodes kamaji UDS frames into the WRONG variant, with no type error. Commented at the definitions in lib.rs.")
346//! @yah:gotcha("BLAST RADIUS ~25 real construction/match sites across FOUR workspaces: oss/kamaji (incl. peer-owned kamaji-proto/src/codec.rs exhaustive matches), oss/yubaba, oss/qed, app/yah/cli, oss/yah-base. R594-F2 deliberately avoided exactly this churn by using an annotation instead of a field (lib.rs:225) - that was right for a marker, and is NOT right here, but read that note before assuming the churn is accidental.")
347//! @yah:gotcha("Consider a Workload::container(spec) constructor + ContainerManifest::as_spec() accessor to keep the ~25 sites one-line mechanical rather than restructured.")
348//! @yah:handoff("LANDED. `ContainerManifest = Reference(WorkloadSpec) | Recipe(ContainerBuild)` is now `Workload::Container`'s payload (oss/yah-base/crates/workload-spec/src/lib.rs). New public types: ContainerManifest, ContainerBuild, ContainerBuildStep, ContainerRunConfig, ContainerMount, plus `Workload::container(spec)` / `Workload::container_spec()` / `Workload::container_manifest()` so the ~25 call sites stayed one-line.")
349//! @yah:verify("cargo test --manifest-path oss/yah-base/crates/workload-spec/Cargo.toml --all-features - 127 lib + 8 integration targets, 0 failed. round_trip.rs: 16 pass.")
350//! @yah:verify("cargo test -p xtask - all 12 targets green incl. workload_envelope 1/1 with KNOWN_GAPS now EMPTY (R658-B2's `missing field image` entry deleted, not widened) and schema_drift 3/3.")
351//! @yah:verify("cargo test --manifest-path oss/kamaji/Cargo.toml --workspace - green incl. kamaji-proto codec 26/26 (deploy_container_round_trip, the exact UDS path) and kamaji-bin 213/213.")
352//! @yah:verify("cargo test --manifest-path oss/yubaba/Cargo.toml -p yah-cloud --lib 881 pass / -p yubaba --lib 492 pass; cargo test -p yah --lib cloud:: 120 pass.")
353//! @yah:handoff("THE WIRE CLAIM IS NOW A TEST, not an assertion. round_trip.rs::container_postcard_frame_is_the_variant_index_then_the_bare_spec asserts the frame is exactly [1] ++ postcard(WorkloadSpec) - a round-trip alone would still pass if both halves moved together. WorkloadExternal::Container keeps WorkloadSpec; Workload's binary Serialize maps Reference through unchanged and returns Err for Recipe (round_trip.rs::container_recipe_is_refused_by_postcard_rather_than_encoded).")
354//! @yah:handoff("DISCRIMINATOR: presence of a `[build]` table means Recipe; presence of top-level `image` means Reference; NEITHER is its own error naming both forms rather than a misleading `missing field image`. Hand-written Deserialize, not serde(untagged), specifically so a malformed reference still reports `missing field tier` instead of 'data did not match any variant'.")
355//! @yah:gotcha("UNCOMMITTED REGEN - both generated artifacts are regenerated in the working tree and must be committed WITH this change: .yah/schema/workload.toml.schema.json (cargo run -p xtask -- emit-schemas) and packages/yah/workload-spec/index.ts (cargo run --manifest-path oss/yah-base/crates/workload-spec/Cargo.toml --bin export-ts). Both scripts/check-schema-drift.sh and scripts/check-workload-spec-ts.sh exit 1 right now because they diff generated output against the git INDEX - that is the scripts working as designed, not drift. cargo test -p xtask schema_drift (which diffs against the working tree) is GREEN.")
356//! @yah:gotcha("SIGNATURE DEVIATION from the ticket text, deliberate: into_spec is `ContainerBuild::into_spec(self, digest: &str, tier: TierTag) -> Result<WorkloadSpec, String>`, not the infallible two-arg form the ticket sketched. Fallible because digest is a caller-supplied string and a malformed one must error rather than mint a spec that lies about being content-addressed - it routes through compose_import::parse_pinned_image_ref, the one home of the R438-T3 digest rule. tier is a parameter because admission control is cluster policy, not a manifest fact. Recorded in W324 under a new 'As shipped (R783-F1)' section.")
357//! @yah:assumes("ContainerBuild::into_spec has NO production caller yet - it is the documented lowering with unit-test coverage only. Its unset-image default is `yah-local/<manifest name>:dev`, which is NOT the same string ContainerReconciler's default_image_tag builds (`yah-local/<service>-<component>:dev`) because the manifest only knows its own name. If a future caller lowers a recipe whose [build].image was left unset and expects to find the image the reconciler built, those two defaults have to be reconciled first.")
358//! @yah:cleanup("LocalProcessReconciler still parses its own private ProcessComponent for the [process] table (oss/yubaba/crates/cloud/src/reconciler/local_process.rs:696). The envelope does not model [process] at all, so that tier is still a second parser over the same file - the exact shape R783-F2 just removed for the container tier. W324 section 1 names it as the third runtime behind kind = container; folding it in is the natural next step and is deliberately NOT in R783.")
359//!
360//! @yah:ticket(R838-B1, "xtask workload_envelope fails on both machines: the template deliberately omits [build] command while workload_spec Workload requires it as a non-Option String")
361//! @yah:status(review)
362//! @yah:at(2026-08-31T00:17:46Z)
363//! @yah:assignee(agent:bundle-anthropic-ashguard)
364//! @yah:parent(R838)
365//! @yah:handoff("LANDED. workload_spec::BuildConfig.command is now Option<String> with #[serde(default)] (oss/yah-base/crates/workload-spec/src/lib.rs:1439). Absent means the project has no external bundler step, which is what mesofact new's scaffold template documents about itself. xtask workload_envelope now passes with KNOWN_GAPS still EMPTY, which was the goal state that test names for itself.")
366//! @yah:handoff("WHY THIS WAS NOT A DECISION AFTER ALL. The sibling ticket R658-B3 filed the same bug as DECISION REQUIRED because it read the deploy path as having no branch for a missing command. It has one, in two of the three readers, and it predates this change: app/yah/cli/src/cloud.rs:3368 read_workload_build has ALWAYS returned Option<String>; assemble_component_bundle_with_sidecars (cloud.rs:3492) needs the command only under --run-build and otherwise assembles from an existing out_dir; deploy_mesofact_bundle (cloud.rs:5841) refuses None with a message that already reads correctly, and cloud.rs:8387 a_missing_build_command_is_reported_not_skipped already tested that refusal. The only reader that made it mandatory was the type. So no in-process build branch had to be invented.")
367//! @yah:handoff("RECONCILER: lower_build_to_forge_spec now returns Option<ForgeSpec> (None when no command) and run_build logs a skip and returns Ok. That is the same outcome rebuild_static already produced for a workload with no workload.toml. Deliberately NOT sh -c with an empty string: that exits 0 having built nothing, so the reconciler would report success and publish stale out_dir bytes.")
368//! @yah:handoff("WIRE: BuildConfig rides the postcard kamaji wire inside Workload::MesofactStatic, so String -> Option<String> adds a leading tag byte. A pre-R838 node decoding a new frame fails loudly (a string length byte is not a valid Option tag) rather than reading a shifted field, which is why this is Option and not a serde(default) empty-String sentinel. NOT a cluster-epoch surface: xtask/src/cluster_epochs.rs hashes the yubaba raft modules and the openraft pin, not workload_spec; all 8 cluster_epoch_drift tests stayed green, so no epoch bump is owed.")
369//! @yah:handoff("CALL SITES (10, four workspaces, all mechanical): kamaji-proto/src/codec.rs:1077, kamaji-bin/src/server.rs x3, yubaba/src/lib.rs:9000, cloud/src/reconciler/lowering_golden.rs x2 (+3 .expect() on the now-Option lowering), cloud/src/reconciler/mesofact_static.rs (revalidate_static's render BuildConfig + 2 fixtures + 3 assertions), app/yah/cli/src/cloud.rs:5725.")
370//! @yah:handoff("GENERATED ARTIFACTS REGENERATED AND MUST BE COMMITTED WITH THIS: .yah/schema/workload.toml.schema.json (command dropped from required, type now [string,null]) and packages/yah/workload-spec/index.ts (command: string | null). Both scripts/check-workload-spec-ts.sh and scripts/check-schema-drift.sh exit 1 until the commit lands because they diff against the git INDEX; the working-tree equivalent, cargo test -p xtask schema_drift, is green. Same shape as the R783-F1 note above.")
371//! @yah:verify("cargo test -p xtask --tests --locked: 54 passed, 0 failed. Includes workload_envelope::every_on_disk_workload_toml_parses_through_the_envelope (was 0 passed / 1 failed with 'missing field command'), schema_drift 3/3, cluster_epoch_drift 8/8.")
372//! @yah:verify("cargo test --manifest-path oss/yah-base/Cargo.toml -p yah-workload-spec --all-features --locked: 146 lib + 68 integration, 0 failed. Three NEW tests in tests/round_trip.rs: mesofact_static_build_table_without_a_command_parses_as_none, an_unknown_build_key_is_still_refused_now_that_command_is_optional (deny_unknown_fields from R658-B1 did not loosen), mesofact_static_build_command_round_trips_through_postcard_both_ways.")
373//! @yah:verify("cargo test --manifest-path oss/yubaba/Cargo.toml -p yah-cloud --lib: 927 passed, 0 failed, 4 ignored. Three NEW tests in reconciler::mesofact_static::tests: read_mesofact_build_accepts_a_build_table_with_no_command, rebuild_static_skips_the_build_step_when_no_command_is_declared (asserts the CaptureExecutor got nothing), lowering_a_build_with_no_command_yields_no_forge_spec.")
374//! @yah:verify("cargo test --manifest-path oss/yubaba/Cargo.toml -p yubaba --lib: 553 passed, 0 failed.")
375//! @yah:verify("cargo test --manifest-path oss/kamaji/Cargo.toml --workspace: all green incl. kamaji-proto codec 26/26 (the UDS round-trip) and kamaji-bin 217/217.")
376//! @yah:verify("cargo check --all-targets --locked on the root workspace: clean (warnings only, all pre-existing).")
377//! @yah:verify("cargo test --locked --no-fail-fast on the root workspace: one failure, yah-log tests::init_noop_without_env, which is NOT this change and is already filed as R840-B1 (it reads process-global env and the camp build rail exports YAH_TASK_RUN + YAH_LOG_PIPE; it passes in CI and under env -u).")
378//! @yah:gotcha("DEAD GENERATED FILE FOUND, not touched: oss/packages/yah/workload-spec/index.ts is tracked, a month stale (last written 2026-07-29, has no ContainerManifest so it predates R783-F1), referenced by nothing, and gated by nothing. It is the fossil of the off-by-one that export-ts.rs:107 documents in its own comment: with ancestors().nth(3) the bin wrote to oss/packages/ instead of the camp root, and the stray output got committed. export-oss.sh exports oss/<name> subtrees, and oss/packages is not one, so it is not even on an export path. Deleting it is a one-line git rm but it is a tracked-file deletion outside this ticket, so it is named here rather than done.")
379//! @yah:gotcha("STALE CLAIM in a neighbouring annotation, disproved but left in place: oss/yubaba/crates/cloud/src/reconciler/mesofact_static.rs:165 (R438-T6) says read_mesofact_build must hand-extract toml::Value subtrees because 'schema_version = 1 (integer) ... the typed envelope rejects'. R546-B7 made SchemaVersion read the bare integer (oss/yah-base/crates/workload-spec/src/version.rs), and the workload_envelope run proves it: the only error reported for the scaffold template was 'missing field command', never schema_version. The subtree reader has other reasons to exist, but that one is gone.")
380//!
381//! @yah:ticket(R658-B3, "mesofact new scaffolds a workload.toml the deploy path cannot execute: BuildConfig.command is required but the template deliberately omits it")
382//! @yah:status(review)
383//! @yah:assignee(agent:bundle-anthropic-ashguard)
384//! @yah:at(2026-09-02T19:08:17Z)
385//! @yah:parent(R658)
386//! @yah:severity(high)
387//! @yah:next("DECISION REQUIRED, do not guess. oss/mesofact/crates/mesofact/src/cli/new/template/workload.toml (new in ef8bd656) declares kind = mesofact-static with no [build] command, and its own header comment says that is deliberate: 'Left unset, mesofact-dev runs the build pipeline in-process — no third binary, no package manager, no Node.' But BuildConfig.command is a required String (oss/yah-base/crates/workload-spec/src/lib.rs:1428), so the file does not parse through workload_spec::Workload.")
388//! @yah:next("The deploy path has no in-process branch. MesofactStaticReconciler uses build.command unconditionally — oss/yubaba/crates/cloud/src/reconciler/mesofact_static.rs:1143 builds vec![sh, -c, build.command.clone()], and 1173/1179/1185 log and execute it. So making command Option<String> is NOT a mechanical type change: it requires deciding what 'yah cloud bundle build' DOES for a manifest with no command. That is the actual open question.")
389//! @yah:next("Three options. (a) command becomes Option<String> and the reconciler gains an in-process build branch — matches the template's documented intent and the W225 s2 'no package manager, no Node' promise, but MesofactStaticWorkload is a postcard wire type over the kamaji UDS, so a shape change is a lockstep kamaji+yubaba deploy (see the R658-B1 handoff, which rejected moving a field for exactly this reason). (b) The template gains a command — contradicts its own comment and the no-Node promise. (c) Add a KNOWN_GAPS entry in xtask/tests/workload_envelope.rs — unblocks check today, records the gap honestly, decides nothing.")
390//! @yah:verify("cargo test -p xtask --test main --locked -- workload_envelope::every_on_disk_workload_toml_parses_through_the_envelope (currently: 0 passed, 1 failed, 'missing field command')")
391//! @yah:gotcha("BLOCKS THE RELEASE GATE. This fails cargo test -p xtask --tests, which is check.toml step 7 (xtask-tests), which release-check runs before oss-publish. It also very likely fails release-check's second sub-pipeline mesofact-new-smoke, whose stated promise is that 'mesofact new' produces a project that builds and serves with no package manager and no Node on PATH — the same scaffold.")
392//! @yah:gotcha("WHY THIS WAS INVISIBLE UNTIL NOW: check.toml's cargo-test step has no --no-fail-fast and died at yah-party, so steps 5-16 never ran this cycle. Separately, R605-S6 documents that xtask is a workspace member but NOT a default-member, so plain cargo test never reaches these tests at all, and workload_envelope was named there as one of eight test binaries that had been dark since being written. KNOWN_GAPS in xtask/tests/workload_envelope.rs is currently empty, so this file DID parse before ef8bd656 introduced the template — it is a regression, not a pre-existing gap.")
393//! @yah:tier(Warrior)
394//! @yah:next("Option (a) was taken. The open question that made this a decision was already answered by the code: yah cloud bundle build does NOT require a command. app/yah/cli/src/cloud.rs:3368 read_workload_build has always typed it Option<String>; assemble_component_bundle_with_sidecars needs it only under --run-build; deploy_mesofact_bundle refuses None by name at cloud.rs:5841. So no in-process build branch had to be invented: the reconciler skips the build step for None, exactly as it already did for a workload with no workload.toml at all.")
395//! @yah:next("TO CLOSE: re-run cargo test -p xtask --test main --locked -- workload_envelope:: (passes now) and archive. Nothing left to build here.")
396//! @yah:handoff("FIXED BY R838-B1 (same bug, filed twice; R838-B1 is the older ID). BuildConfig.command is now Option<String> in oss/yah-base/crates/workload-spec/src/lib.rs. cargo test -p xtask --tests is 54/54 green including workload_envelope, the gate that was failing.")
397//! @yah:handoff("GENERATED ARTIFACTS CONFIRMED LANDED, which R838-B1's handoff flagged as still-uncommitted and therefore red. Both are now committed and clean against the index (git status --porcelain reports nothing for either): .yah/schema/workload.toml.schema.json carries command with \"default\": null and \"type\": [\"string\",\"null\"] under the BuildConfig object, and packages/yah/workload-spec/index.ts carries `command: string | null` at line 452. The unrelated required-\"command\" at schema line 126 is the Almanac/render struct (lib.rs:1661), which is correctly still a bare String. So scripts/check-schema-drift.sh and scripts/check-workload-spec-ts.sh no longer have anything to fail on for this change.")
398//! @yah:verify("cargo test -p xtask --test main --locked -- workload_envelope:: — 1 passed, 0 failed, 55 filtered out. every_on_disk_workload_toml_parses_through_the_envelope is ok; it was 0 passed / 1 failed with \"missing field command\" when this ticket was filed. This is the exact argv named in the ticket's @yah:verify, and it is the criterion this ticket closes on.")
399//! @yah:gotcha("DISPROVED A CLAIM ON R836-B2 IN PASS and recorded it there. Its COVERAGE NOTE said the failing assertion lives in xtask's lib target which \"neither\" check.toml xtask step reaches, and its second @yah:next asked to widen the guard to include --lib. Measured: `--tests` DOES reach the lib target — running check.toml's own xtask-tests argv produced the 43/1 result above and cargo's footer read \"error: test failed, to rerun pass -p xtask --lib\". So that next step is a no-op and the bar already covers the assertion.")
400//! @yah:cleanup("NOT FIXED, out of this ticket's blast radius, flagged for whoever owns the mesofact_static reconciler: oss/yubaba/crates/cloud/src/reconciler/mesofact_static.rs:240-245 has 13 unused imports (EnvVar, ExposeSpec, ImageRef, MeshExpose, Millis, NamespaceId, ResourceLimits, RestartPolicy, SchemaVersion, StopPolicy, TenantId, TierTag, WorkloadSpec, NATIVE_IDENTITY_DIGEST). Warnings only, so nothing is blocked. They are pre-existing on committed main (file is clean in the working tree; last moved in committed 9677282b \"mes\", 2026-09-01), NOT introduced by the R838-B1 change and not a live peer's WIP. Worth a look because that many newly-unused imports usually means a block of code was removed, and it is worth confirming that removal was intended rather than collateral.")
401//! @yah:gotcha("CORRECTION to this ticket's own inherited handoff line \"cargo test -p xtask --tests is 54/54 green including workload_envelope\". That was true when R838-B1 wrote it and is NOT true on main as of 2026-09-02. That argv now gives 43 passed / 1 failed, failing in the LIB target on cluster_epochs::tests::the_declaration_records_current_per_input_digests_for_every_axis (\"state_epoch: recorded digest for `rust-file oss/yubaba/crates/yubaba/src/raft/store.rs` is stale\"). That failure is R836-B2, is unrelated to workload.toml, and is deliberately NOT a regenerate-the-artifact case — it is a mixed-operation compatibility call (bump the state_epoch vs re-record the digest) owned by whoever owns the raft store change, so it was correctly left alone here. It matters for reading this ticket because `--tests` carries no --no-fail-fast: that single lib failure aborts the step before the integration binary runs, so workload_envelope is reported as neither passed nor failed rather than green. That is why this ticket was verified with the narrower `--test main -- workload_envelope::`, which isolates the gate this ticket actually owns. check.toml step xtask-tests stays red camp-wide until R836-B2 is answered.")
402
403use std::collections::BTreeMap;
404use std::collections::HashMap;
405use std::path::PathBuf;
406
407use serde::{Deserialize, Serialize};
408use ts_rs::TS;
409
410pub mod admission;
411pub mod compose_import;
412pub mod control_plane_install;
413pub mod rollout;
414pub mod secrets;
415pub mod sovereign;
416pub mod validate;
417mod version;
418
419pub use version::SchemaVersion;
420
421// ── Duration ──────────────────────────────────────────────────────────────────
422
423/// Duration expressed as an integer millisecond count.
424///
425/// Used for healthcheck intervals, timeouts, delays, and stop grace periods.
426/// Chosen over `std::time::Duration` to keep serde support dependency-free.
427#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, TS)]
428#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
429#[ts(type = "number")]
430pub struct Millis(pub u64);
431
432impl Millis {
433    pub fn from_secs(s: u64) -> Self {
434        Self(s * 1000)
435    }
436
437    pub fn from_ms(ms: u64) -> Self {
438        Self(ms)
439    }
440
441    pub fn as_ms(self) -> u64 {
442        self.0
443    }
444
445    pub fn as_secs_f64(self) -> f64 {
446        self.0 as f64 / 1000.0
447    }
448}
449
450// ── Primitive newtypes ────────────────────────────────────────────────────────
451
452/// Opaque identifier for a yubaba-managed machine within the cluster.
453///
454/// Used by the semantic validation layer for admission-control capacity checks.
455/// Yubaba passes its own machine ID when validating a spec before deployment.
456#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, TS)]
457#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
458pub struct MachineId(pub String);
459
460/// DNS-segment identity for a workload on the cluster mesh, e.g.
461/// `"noisetable-api.pdx"`. Regex constraint: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`,
462/// length ≤ 63. Enforced in shape validation (R090-F2).
463#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
464#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
465pub struct MeshIdent(pub String);
466
467/// Tier classification that governs admission control and mesh `allow_from`
468/// filtering. Known values: `"public"`, `"tenant"`, `"private"`, `"infra"`.
469/// Custom tiers are allowed per cluster; shape validation warns on unknowns
470/// rather than rejecting them (R090-F2).
471#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
472#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
473pub struct TierTag(pub String);
474
475/// Default single-tenant identity written to specs that predate the tenant
476/// axis (W206). Its concrete string is arbitrary — what matters is that a
477/// single-tenant cluster only ever sees this one value, so every per-tenant
478/// isolation primitive collapses to a no-op. See [`TenantId::singleton`].
479pub const DEFAULT_TENANT: &str = "default";
480
481/// Default single-namespace identity for specs that predate the namespace
482/// axis (W206). See [`NamespaceId::singleton`].
483pub const DEFAULT_NAMESPACE: &str = "default";
484
485/// Tenant **isolation** axis (W206). Separates one operator's workloads from
486/// another's at the network / DB / mesh-identity level. Orthogonal to
487/// [`NamespaceId`] (routing/naming) and [`TierTag`] (workload class within a
488/// `(tenant, namespace)` pair).
489///
490/// **Degenerate case:** when a yubaba reconciler sees only one `TenantId`
491/// across every workload on a machine, per-tenant Podman networks collapse
492/// into the shared tier networks, the tenant prefix on mesh identity is
493/// dropped, and PostgreSQL role separation is skipped — isolation primitives
494/// become no-ops. You pay only when more than one tenant is present. Specs
495/// written before this axis existed deserialize to [`TenantId::singleton`].
496#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, TS)]
497#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
498pub struct TenantId(pub String);
499
500impl TenantId {
501    /// The singleton tenant used for back-compat with single-tenant (current)
502    /// deployments. Specs written before the tenant axis existed deserialize
503    /// to this value via the `#[serde(default)]` on [`WorkloadSpec::tenant`],
504    /// keeping the whole cluster single-tenant so every isolation primitive
505    /// stays a no-op.
506    pub fn singleton() -> Self {
507        Self(DEFAULT_TENANT.to_string())
508    }
509
510    /// Whether this is the singleton (degenerate single-tenant) identity.
511    pub fn is_singleton(&self) -> bool {
512        self.0 == DEFAULT_TENANT
513    }
514}
515
516/// Namespace **routing/naming** axis (W206). A pure naming key that never
517/// affects isolation: it selects the config root, disambiguates service DNS
518/// names within a tenant, prefixes object-store bucket names within a tenant's
519/// bucket scope, and selects the provider zone (e.g. `noisetable.com` vs
520/// `yah.dev`). Two namespaces in the same tenant share networks, mesh-identity
521/// space, and PG cluster — they simply cannot collide on workload names or
522/// external domains. Specs written before this axis existed deserialize to
523/// [`NamespaceId::singleton`].
524#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, TS)]
525#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
526pub struct NamespaceId(pub String);
527
528impl NamespaceId {
529    /// The singleton namespace used for back-compat with single-namespace
530    /// (current) deployments. Specs written before the namespace axis existed
531    /// deserialize to this value via the `#[serde(default)]` on
532    /// [`WorkloadSpec::namespace`].
533    pub fn singleton() -> Self {
534        Self(DEFAULT_NAMESPACE.to_string())
535    }
536
537    /// Whether this is the singleton (degenerate single-namespace) identity.
538    pub fn is_singleton(&self) -> bool {
539        self.0 == DEFAULT_NAMESPACE
540    }
541}
542
543// ── Workload (on-disk envelope) ──────────────────────────────────────────────
544
545/// On-disk `workload.toml` manifest. Each variant matches one
546/// `ServiceComponent.kind` value; the `kind` field on the wire is the serde
547/// discriminator.
548///
549/// This is the **on-disk** envelope — distinct from [`WorkloadSpec`], the
550/// containerd wire format yubaba receives over RPC. A `kind = "container"`
551/// workload deserializes its remaining fields as a [`ContainerManifest`],
552/// which is *either* a digest-pinned `WorkloadSpec` or a local Dockerfile
553/// recipe (R783-F1 / W324); other kinds carry their own per-reconciler
554/// payload shape.
555///
556/// **Never put `#[serde(skip_serializing_if = "Option::is_none")]` on a field
557/// of this enum or any type it reaches.** These types ride the kamaji-proto
558/// **postcard** wire, which is non-self-describing and positional:
559/// `skip_serializing_if` omits the field's byte on serialize while decode still
560/// expects to read it at that offset, so the byte stream misaligns and the
561/// round-trip fails. Use `#[serde(default)]` + `#[ts(optional = nullable)]`
562/// instead — that still gives TOML/JSON back-compat (missing field → `None`)
563/// while the field is always encoded. `MesofactStaticWorkload::ssr_runtime` and
564/// `::serve_bundle` are the reference shape.
565/// **Two wire shapes, one type (R546-B7).** `Serialize`/`Deserialize` are
566/// hand-written and branch on [`is_human_readable`](serde::Deserializer::is_human_readable):
567///
568/// - **TOML/JSON (human-readable)** → *internally* tagged on `kind`, i.e. the
569///   flat shape every on-disk `workload.toml` actually uses
570///   (`kind = "static-asset"` beside `schema_version`, `[[asset]]`, `[aliases]`).
571/// - **postcard (binary)** → *externally* tagged, byte-identical to the derived
572///   representation R590-B3 established for the kamaji UDS.
573///
574/// Why not just `#[serde(tag = "kind")]`: internal tagging buffers through
575/// `deserialize_any`, which postcard (non-self-describing) refuses with
576/// `WontImplement` — that is exactly the failure R590-B3 fixed by flipping this
577/// enum to external tagging. But external tagging wants a single-key map, so
578/// every flat on-disk file then failed with `wanted exactly 1 element, more
579/// than 1 element` and `yah cloud apply` broke for every static-asset
580/// component. Branching on the format satisfies both, and mirrors what
581/// [`ImageRef`] already does for its string-vs-struct form.
582#[derive(Debug, Clone, PartialEq, TS)]
583#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
584#[cfg_attr(
585    feature = "json-schema",
586    schemars(tag = "kind", rename_all = "kebab-case")
587)]
588#[ts(tag = "kind", rename_all = "kebab-case")]
589pub enum Workload {
590    /// Static-site build that publishes an artifact directory to the
591    /// service's `static` provider slot. Reconciled by the
592    /// `mesofact-static` reconciler — does not deploy to yubaba.
593    MesofactStatic(MesofactStaticWorkload),
594
595    /// A container-shaped workload. **Two on-disk forms** (R783-F1 / W324),
596    /// see [`ContainerManifest`]: a digest-pinned [`WorkloadSpec`] reference
597    /// (the form that crosses the kamaji wire) or a local Dockerfile
598    /// [`ContainerBuild`] recipe (which cannot, because it names no digest
599    /// until it has been built).
600    ///
601    /// Construct the wire form with [`Workload::container`] and read it back
602    /// with [`Workload::container_spec`] — most callers only ever mean the
603    /// reference form and should not have to name the manifest enum.
604    ///
605    /// The reference form's inline fields are the full [`WorkloadSpec`] minus
606    /// the `kind` discriminator.
607    ///
608    /// This is also the shape of the W267 sovereign-public-ingress appliance
609    /// (R594-F2): a container-kind workload with `archetype =
610    /// Some(LifecycleArchetype::Appliance)` and
611    /// `requires_taint() == Some(PUBLIC_IP_TAINT)`, **not** a dedicated
612    /// `Workload::ingress(..)` variant. It runs an ordinary OCI image (the
613    /// `passway` proxy, R594-F4) supervised by kamaji exactly like any other
614    /// `Container`, so no admission-list or wire-codec change was needed to
615    /// let kamaji accept it. A new enum variant would have forced an
616    /// exhaustive-match update in every `Workload` consumer, including
617    /// peer-owned `kamaji-proto/src/codec.rs` — the archetype + annotation
618    /// combination expresses "this is the public ingress appliance" without
619    /// that blast radius. See [`WorkloadSpec::requires_taint`] and
620    /// [`LifecycleArchetype::Appliance`].
621    Container(ContainerManifest),
622
623    /// Data-pipeline job with declared I/O and a readiness policy. The
624    /// orchestrator checks all `inputs` are reachable before each run and
625    /// verifies `outputs` afterward. Generalises the OpenRouter JSON-cache
626    /// refresher (`spawn_almanac_refresher`) to the full manifest form.
627    Almanac(AlmanacManifest),
628
629    /// Content-addressed static files uploaded to the mirror's `object_store`
630    /// provider slot. Wave-0 by default — gating mesofact and container waves.
631    /// Rollback is a pointer-flip via `mirror.toml [asset_aliases]`; bytes are
632    /// append-only and never re-pushed on rollback. See W160.
633    StaticAsset(StaticAssetWorkload),
634}
635
636impl Workload {
637    /// The `kind` discriminator this variant serializes as — the same string a
638    /// `workload.toml` writes and a `ServiceComponent.kind` names.
639    ///
640    /// Lives here rather than at a call site because this enum now has FIVE
641    /// places that enumerate its variants (itself plus the four tagging
642    /// mirrors below); a caller-local match would be a sixth, in another crate,
643    /// with nothing to force it to keep up.
644    pub fn kind_str(&self) -> &'static str {
645        match self {
646            Workload::MesofactStatic(_) => "mesofact-static",
647            Workload::Container(_) => "container",
648            Workload::Almanac(_) => "almanac",
649            Workload::StaticAsset(_) => "static-asset",
650        }
651    }
652
653    /// Wrap a digest-pinned [`WorkloadSpec`] as a `kind = "container"`
654    /// workload — the form that crosses the kamaji wire.
655    ///
656    /// Every caller that synthesizes a container workload in code (ingress
657    /// appliances, forge runs, kamaji's own deploy path) means *this* form;
658    /// the [`ContainerManifest::Recipe`] arm only ever arrives by parsing a
659    /// `workload.toml` with a `[build]` table. Keeping the constructor here
660    /// means R783-F1 did not have to teach ~25 call sites the name of a
661    /// manifest enum they have no opinion about.
662    pub fn container(spec: WorkloadSpec) -> Self {
663        Workload::Container(ContainerManifest::Reference(spec))
664    }
665
666    /// The digest-pinned spec of a `kind = "container"` workload, if this is
667    /// a container workload in the reference form.
668    ///
669    /// `None` covers both "not a container" and "a container *recipe*, which
670    /// has no spec until it is built" — a consumer that speaks the wire
671    /// (kamaji, yubaba's deploy path) must treat both as inadmissible, so
672    /// collapsing them into one `None` is deliberate rather than lossy. Use
673    /// [`Workload::container_manifest`] when the two need distinguishing.
674    pub fn container_spec(&self) -> Option<&WorkloadSpec> {
675        match self {
676            Workload::Container(m) => m.as_spec(),
677            _ => None,
678        }
679    }
680
681    /// The container manifest, in whichever on-disk form it was written.
682    pub fn container_manifest(&self) -> Option<&ContainerManifest> {
683        match self {
684            Workload::Container(m) => Some(m),
685            _ => None,
686        }
687    }
688}
689
690/// Internally-tagged mirror of [`Workload`] — the on-disk shape. Only ever
691/// reached on the human-readable branch, so its `deserialize_any` buffering is
692/// never asked of postcard.
693#[derive(Serialize, Deserialize)]
694#[serde(tag = "kind", rename_all = "kebab-case")]
695enum WorkloadTagged {
696    MesofactStatic(MesofactStaticWorkload),
697    Container(ContainerManifest),
698    Almanac(AlmanacManifest),
699    StaticAsset(StaticAssetWorkload),
700}
701
702/// Borrowing twin of [`WorkloadTagged`] so `Serialize` need not clone the
703/// payload. Variant order must match [`Workload`].
704#[derive(Serialize)]
705#[serde(tag = "kind", rename_all = "kebab-case")]
706enum WorkloadTaggedRef<'a> {
707    MesofactStatic(&'a MesofactStaticWorkload),
708    Container(&'a ContainerManifest),
709    Almanac(&'a AlmanacManifest),
710    StaticAsset(&'a StaticAssetWorkload),
711}
712
713/// Externally-tagged mirror — the postcard wire shape R590-B3 established.
714/// postcard encodes an external tag as the *variant index*, so the variant
715/// ORDER here is load-bearing: it must match [`Workload`] exactly or the
716/// kamaji UDS silently decodes into the wrong variant.
717///
718/// `Container` deliberately keeps [`WorkloadSpec`], **not**
719/// [`ContainerManifest`] (R783-F1 / W324): the wire carries only the
720/// digest-pinned reference form, so these bytes are unchanged by the on-disk
721/// split, and a [`ContainerManifest::Recipe`] is refused at serialize rather
722/// than encoded as a second variant nothing on the far side can execute.
723#[derive(Serialize, Deserialize)]
724#[serde(rename_all = "kebab-case")]
725enum WorkloadExternal {
726    MesofactStatic(MesofactStaticWorkload),
727    Container(WorkloadSpec),
728    Almanac(AlmanacManifest),
729    StaticAsset(StaticAssetWorkload),
730}
731
732/// Borrowing twin of [`WorkloadExternal`]. Same order requirement.
733#[derive(Serialize)]
734#[serde(rename_all = "kebab-case")]
735enum WorkloadExternalRef<'a> {
736    MesofactStatic(&'a MesofactStaticWorkload),
737    Container(&'a WorkloadSpec),
738    Almanac(&'a AlmanacManifest),
739    StaticAsset(&'a StaticAssetWorkload),
740}
741
742impl Serialize for Workload {
743    fn serialize<S>(&self, s: S) -> Result<S::Ok, S::Error>
744    where
745        S: serde::Serializer,
746    {
747        if s.is_human_readable() {
748            match self {
749                Workload::MesofactStatic(w) => WorkloadTaggedRef::MesofactStatic(w),
750                Workload::Container(w) => WorkloadTaggedRef::Container(w),
751                Workload::Almanac(w) => WorkloadTaggedRef::Almanac(w),
752                Workload::StaticAsset(w) => WorkloadTaggedRef::StaticAsset(w),
753            }
754            .serialize(s)
755        } else {
756            match self {
757                Workload::MesofactStatic(w) => WorkloadExternalRef::MesofactStatic(w),
758                // The wire gate (W324 §5). A recipe names no digest, so there
759                // is nothing for kamaji to pull — refusing here makes "a build
760                // recipe cannot reach kamaji" a fact the type system holds,
761                // rather than a convention someone eventually forgets.
762                Workload::Container(ContainerManifest::Recipe(_)) => {
763                    return Err(serde::ser::Error::custom(RECIPE_IS_NOT_A_WIRE_SPEC))
764                }
765                Workload::Container(ContainerManifest::Reference(spec)) => {
766                    WorkloadExternalRef::Container(spec)
767                }
768                Workload::Almanac(w) => WorkloadExternalRef::Almanac(w),
769                Workload::StaticAsset(w) => WorkloadExternalRef::StaticAsset(w),
770            }
771            .serialize(s)
772        }
773    }
774}
775
776impl<'de> Deserialize<'de> for Workload {
777    fn deserialize<D>(de: D) -> Result<Self, D::Error>
778    where
779        D: serde::Deserializer<'de>,
780    {
781        if de.is_human_readable() {
782            Ok(match WorkloadTagged::deserialize(de)? {
783                WorkloadTagged::MesofactStatic(w) => Workload::MesofactStatic(w),
784                WorkloadTagged::Container(w) => Workload::Container(w),
785                WorkloadTagged::Almanac(w) => Workload::Almanac(w),
786                WorkloadTagged::StaticAsset(w) => Workload::StaticAsset(w),
787            })
788        } else {
789            Ok(match WorkloadExternal::deserialize(de)? {
790                WorkloadExternal::MesofactStatic(w) => Workload::MesofactStatic(w),
791                // Only the reference form exists on the wire, by construction
792                // of `WorkloadExternal` — see its doc comment.
793                WorkloadExternal::Container(w) => Workload::container(w),
794                WorkloadExternal::Almanac(w) => Workload::Almanac(w),
795                WorkloadExternal::StaticAsset(w) => Workload::StaticAsset(w),
796            })
797        }
798    }
799}
800
801// ── Container manifest (R783-F1 / W324) ───────────────────────────────────────
802
803/// Error text used both by the postcard serializer gate and by
804/// [`ContainerManifest::into_spec`]'s doc, so the two cannot drift.
805const RECIPE_IS_NOT_A_WIRE_SPEC: &str = "a kind = \"container\" workload in the RECIPE form \
806     (a [build] table) cannot cross the kamaji wire: it names an image tag, not a digest, and \
807     the digest does not exist until `docker build` has run. Lower it with \
808     `ContainerBuild::into_spec(digest)` after the build, then send the resulting WorkloadSpec.";
809
810/// On-disk payload of `kind = "container"` — **two forms**, one wire type
811/// (W324 §5).
812///
813/// A [`WorkloadSpec`] asserts a content-addressed identity: its
814/// [`ImageRef::digest`] is a required `sha256:<hex>` and the string form
815/// rejects a bare tag at serde-deserialize (R438-T3). A local component built
816/// from a Dockerfile next to its `workload.toml` cannot satisfy that — its
817/// image is `yah-local/<name>:dev`, and the digest does not exist until the
818/// build has run. So a build *recipe* is not a degenerate spec with a missing
819/// field; it is a promise to produce one, and the two are different types.
820///
821/// The discriminator is the presence of a `[build]` table. `WorkloadSpec` has
822/// no `build` field and [`ContainerBuild`] requires one, so the two shapes are
823/// mutually exclusive — and picking the branch explicitly (rather than with
824/// `#[serde(untagged)]`) is what lets a malformed reference still report
825/// `missing field \`image\`` instead of "data did not match any variant".
826///
827/// Only [`Reference`](Self::Reference) crosses the postcard kamaji wire; see
828/// [`WorkloadExternal`]'s doc comment for why that keeps those bytes
829/// byte-identical to the pre-split encoding.
830#[derive(Debug, Clone, PartialEq, TS)]
831#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
832#[cfg_attr(feature = "json-schema", schemars(untagged))]
833#[ts(untagged)]
834pub enum ContainerManifest {
835    /// Digest-pinned image. Crosses the wire as-is.
836    Reference(WorkloadSpec),
837
838    /// Dockerfile recipe. **Local only** — see [`ContainerBuild`].
839    Recipe(ContainerBuild),
840}
841
842impl ContainerManifest {
843    /// The digest-pinned spec, or `None` for the recipe form.
844    pub fn as_spec(&self) -> Option<&WorkloadSpec> {
845        match self {
846            ContainerManifest::Reference(spec) => Some(spec),
847            ContainerManifest::Recipe(_) => None,
848        }
849    }
850
851    /// The build recipe, or `None` for the reference form.
852    pub fn as_recipe(&self) -> Option<&ContainerBuild> {
853        match self {
854            ContainerManifest::Recipe(b) => Some(b),
855            ContainerManifest::Reference(_) => None,
856        }
857    }
858
859    /// Consume the manifest, yielding the digest-pinned spec. `Err` carries
860    /// the recipe back so a caller that *can* build it still has it.
861    pub fn into_spec(self) -> Result<WorkloadSpec, ContainerBuild> {
862        match self {
863            ContainerManifest::Reference(spec) => Ok(spec),
864            ContainerManifest::Recipe(b) => Err(b),
865        }
866    }
867
868    /// `"reference"` or `"recipe"` — for error messages that need to name
869    /// which form was found without matching on the enum at the call site.
870    pub fn form(&self) -> &'static str {
871        match self {
872            ContainerManifest::Reference(_) => "reference",
873            ContainerManifest::Recipe(_) => "recipe",
874        }
875    }
876}
877
878impl Serialize for ContainerManifest {
879    fn serialize<S>(&self, s: S) -> Result<S::Ok, S::Error>
880    where
881        S: serde::Serializer,
882    {
883        match self {
884            // Transparent in both directions: the on-disk container form is
885            // the payload's own fields flattened under `kind = "container"`,
886            // exactly as it was before the split.
887            ContainerManifest::Reference(spec) => spec.serialize(s),
888            ContainerManifest::Recipe(recipe) => {
889                if s.is_human_readable() {
890                    recipe.serialize(s)
891                } else {
892                    Err(serde::ser::Error::custom(RECIPE_IS_NOT_A_WIRE_SPEC))
893                }
894            }
895        }
896    }
897}
898
899impl<'de> Deserialize<'de> for ContainerManifest {
900    fn deserialize<D>(de: D) -> Result<Self, D::Error>
901    where
902        D: serde::Deserializer<'de>,
903    {
904        use serde::de::Error as _;
905
906        // postcard and friends are non-self-describing, so there is no map to
907        // probe for `[build]` — and by construction the binary wire only ever
908        // carries the reference form anyway (`WorkloadExternal::Container`).
909        if !de.is_human_readable() {
910            return WorkloadSpec::deserialize(de).map(ContainerManifest::Reference);
911        }
912
913        // Buffer once, then branch explicitly. `serde_json::Value` is the
914        // buffer rather than `#[serde(untagged)]`'s private `Content` because
915        // untagged discards the inner error: `missing field \`image\`` — the
916        // one thing an author needs to see — becomes "data did not match any
917        // variant of untagged enum ContainerManifest".
918        let buffered = serde_json::Value::deserialize(de)?;
919
920        match (
921            buffered.get("build").is_some(),
922            buffered.get("image").is_some(),
923        ) {
924            (true, _) => ContainerBuild::deserialize(buffered)
925                .map(ContainerManifest::Recipe)
926                .map_err(|e| {
927                    D::Error::custom(format!(
928                        "kind = \"container\" with a [build] table is a local build recipe: {e}"
929                    ))
930                }),
931            (false, true) => WorkloadSpec::deserialize(buffered)
932                .map(ContainerManifest::Reference)
933                .map_err(|e| {
934                    D::Error::custom(format!(
935                        "kind = \"container\" without a [build] table is a digest-pinned image \
936                         reference: {e}"
937                    ))
938                }),
939            // Neither marker. Reporting `missing field \`image\`` here would
940            // send a recipe author off to add a field their form does not
941            // have, so name both forms instead — this is the one case where
942            // the file does not say which of the two it is trying to be.
943            (false, false) => Err(D::Error::custom(
944                "kind = \"container\" must declare either a digest-pinned `image` (the wire \
945                 form: a WorkloadSpec yubaba hands to kamaji) or a [build] table (a local \
946                 Dockerfile recipe built on the operator's box) — it declares neither",
947            )),
948        }
949    }
950}
951
952/// `kind = "container"` in the **recipe** form: a Dockerfile next to the
953/// component's `workload.toml`, built and run on the operator's box.
954///
955/// This is the shape `ContainerReconciler` drives (`docker build` from
956/// [`build`](Self::build), `docker run` with [`run`](Self::run)). It is
957/// deliberately *not* a `WorkloadSpec` — see [`ContainerManifest`] for why the
958/// digest invariant makes that impossible, and [`Self::into_spec`] for the one
959/// lowering that is allowed.
960///
961/// **Unknown keys are tolerated on purpose.** `crates/yah/cloud-admin/workload.toml`
962/// carries a `[process]` table read by `LocalProcessReconciler` on the dev
963/// mirror — one component file, three tier runtimes (W324 §1). Adding
964/// `deny_unknown_fields` here would make that file unparseable as a container
965/// manifest, which is the opposite of the point.
966#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
967#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
968pub struct ContainerBuild {
969    /// Wire-format version. Always `V1` today.
970    pub schema_version: SchemaVersion,
971
972    /// Component name. Same field the reference form carries, so a manifest
973    /// identifies itself the same way whichever form it is written in.
974    pub name: String,
975
976    /// How the image is built. Its presence is what makes this a recipe.
977    pub build: ContainerBuildStep,
978
979    /// How the built image is run locally.
980    #[serde(default)]
981    pub run: ContainerRunConfig,
982}
983
984impl ContainerBuild {
985    /// Lower a recipe to the wire type, **once a build has produced a digest**.
986    ///
987    /// The signature is the invariant (W324 §5): there is no way to reach a
988    /// `WorkloadSpec` from a recipe without supplying the `sha256:<hex>` the
989    /// build emitted, so an unpinned container spec cannot be constructed by
990    /// accident.
991    ///
992    /// Fallible because `digest` is a caller-supplied string: a malformed one
993    /// must be an error, not a `WorkloadSpec` that lies about being
994    /// content-addressed. Everything the recipe does not declare
995    /// (`tier`, `resources`, `restart_policy`, …) takes the same defaults a
996    /// hand-written local container gets; `tier` is the caller's because
997    /// admission control is a cluster policy, not a manifest fact.
998    pub fn into_spec(self, digest: &str, tier: TierTag) -> Result<WorkloadSpec, String> {
999        let image_tag = self
1000            .build
1001            .image
1002            .clone()
1003            .unwrap_or_else(|| format!("yah-local/{}:dev", self.name));
1004
1005        // Route through the one parser that owns the digest rule (R438-T3) so
1006        // the recipe path cannot grow a second, laxer definition of "pinned".
1007        let image = compose_import::parse_pinned_image_ref(&format!("{image_tag}@{digest}"))
1008            .map_err(|e| format!("lowering container recipe {:?}: {e}", self.name))?;
1009
1010        let ports = self.run.port.map(|p| vec![p]).unwrap_or_default();
1011
1012        Ok(WorkloadSpec {
1013            schema_version: self.schema_version,
1014            name: self.name.clone(),
1015            image,
1016            tier,
1017            tenant: TenantId::singleton(),
1018            namespace: NamespaceId::singleton(),
1019            replicas: 1,
1020            command: None,
1021            entrypoint: None,
1022            workdir: None,
1023            user: None,
1024            env: self
1025                .run
1026                .env
1027                .into_iter()
1028                .map(|(name, value)| EnvVar {
1029                    name,
1030                    value: EnvValue::Literal { value },
1031                })
1032                .collect(),
1033            secrets: vec![],
1034            volumes: self
1035                .run
1036                .mounts
1037                .into_iter()
1038                .map(|m| VolumeMount {
1039                    source: VolumeSource::Bind {
1040                        host_path: PathBuf::from(m.host),
1041                    },
1042                    target: m.container,
1043                    read_only: m.read_only,
1044                })
1045                .collect(),
1046            resources: ResourceLimits {
1047                memory_mb: 1024,
1048                cpu_millis: 1000,
1049                ephemeral_storage_mb: 1024,
1050            },
1051            depends_on: vec![],
1052            healthcheck: None,
1053            restart_policy: RestartPolicy::Always,
1054            archetype: Some(LifecycleArchetype::Server),
1055            stop_policy: StopPolicy {
1056                signal: 15,
1057                grace_period: Millis::from_secs(10),
1058            },
1059            expose: ExposeSpec {
1060                mesh: MeshExpose {
1061                    identity: MeshIdent(self.name),
1062                    ports,
1063                    allow_from: vec![],
1064                },
1065                public: None,
1066                operator: None,
1067            },
1068            labels: HashMap::new(),
1069            annotations: HashMap::new(),
1070        })
1071    }
1072}
1073
1074/// The `[build]` table of a container recipe.
1075#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
1076#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1077pub struct ContainerBuildStep {
1078    /// Dockerfile path, relative to the component directory.
1079    #[serde(default = "default_dockerfile")]
1080    pub dockerfile: PathBuf,
1081
1082    /// Build context, relative to the workspace root. `None` → the component
1083    /// directory. Workspace crates set `"."` so their path-dependency sources
1084    /// resolve.
1085    #[serde(default)]
1086    #[ts(optional = nullable)]
1087    pub context: Option<PathBuf>,
1088
1089    /// Image tag to build and run. `None` → `yah-local/<name>:dev`.
1090    ///
1091    /// A **tag**, not an [`ImageRef`]: this names an image that does not exist
1092    /// yet, so there is no digest to pin it by.
1093    #[serde(default)]
1094    #[ts(optional = nullable)]
1095    pub image: Option<String>,
1096}
1097
1098fn default_dockerfile() -> PathBuf {
1099    PathBuf::from("Dockerfile")
1100}
1101
1102impl Default for ContainerBuildStep {
1103    fn default() -> Self {
1104        Self {
1105            dockerfile: default_dockerfile(),
1106            context: None,
1107            image: None,
1108        }
1109    }
1110}
1111
1112/// The `[run]` table of a container recipe.
1113#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, TS)]
1114#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1115pub struct ContainerRunConfig {
1116    /// Container port the process listens on.
1117    #[serde(default)]
1118    #[ts(optional = nullable)]
1119    pub port: Option<u16>,
1120
1121    /// Host port to publish it on. `None` → same as [`port`](Self::port).
1122    #[serde(default)]
1123    #[ts(optional = nullable)]
1124    pub host_port: Option<u16>,
1125
1126    /// Environment passed into the container.
1127    #[serde(default)]
1128    pub env: BTreeMap<String, String>,
1129
1130    /// Bind mounts from the workspace into the container.
1131    #[serde(default)]
1132    pub mounts: Vec<ContainerMount>,
1133}
1134
1135/// One `[[run.mounts]]` entry of a container recipe.
1136#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
1137#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1138pub struct ContainerMount {
1139    /// Host path. Relative paths resolve against the workspace root — the
1140    /// declaration lives in the repo, so it should read like a repo path and
1141    /// stay valid on whichever machine the operator runs it from.
1142    pub host: String,
1143
1144    /// Absolute path inside the container.
1145    pub container: PathBuf,
1146
1147    /// Default `true`. A workspace mount is config the service *reads*; a
1148    /// writable default would let a container mutate the operator's checkout
1149    /// as a side effect of running, so opting into that has to be explicit.
1150    #[serde(default = "default_true")]
1151    pub read_only: bool,
1152}
1153
1154fn default_true() -> bool {
1155    true
1156}
1157
1158/// `kind = "mesofact-static"` payload — static-site build colocated with the
1159/// frontend it deploys.
1160///
1161/// The two-role model (R256-F7): a build/publish step plus an optional
1162/// SSR/SPA runtime companion. The build step is always transient (runs once,
1163/// publishes, exits). The companion is long-lived and only present when the
1164/// app has dynamic/server-rendered pages; pure static sites leave it `None`.
1165#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
1166#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1167pub struct MesofactStaticWorkload {
1168    /// Wire-format version. Always `V1` today.
1169    pub schema_version: SchemaVersion,
1170
1171    /// Build command + output directory.
1172    pub build: BuildConfig,
1173
1174    /// Path (relative to the manifest) of the routes module the
1175    /// `mesofact-static` reconciler reads to enumerate routes.
1176    pub routes: PathBuf,
1177
1178    /// Where the build command runs. Default: `HostSide` (mesofact-dev on the
1179    /// host). Set to `InContainer` for cloud/HA where no host watcher is
1180    /// present and CI-fidelity build environments are required.
1181    #[serde(default)]
1182    pub build_mode: BuildMode,
1183
1184    /// Optional SSR/SPA runtime companion container.
1185    ///
1186    /// `None` → pure static site; Caddy (or equivalent CDN) serves all
1187    /// requests directly from the object store. This is the common case for
1188    /// dev-yah today.
1189    ///
1190    /// `Some` → the workload spec describes a long-lived container that
1191    /// handles dynamic/SSR requests. Caddy routes static asset paths to
1192    /// the object store and all other paths to this container. The companion
1193    /// uses `RestartPolicy::Always`; the orchestrator (camp or yubaba)
1194    /// ensures it stays up alongside the Caddy edge.
1195    #[ts(optional = nullable)]
1196    pub ssr_runtime: Option<WorkloadSpec>,
1197
1198    /// Serve-time reference to a published W272 bundle (R599-F4).
1199    ///
1200    /// `Some` → the built app is deployed as a content-addressed bundle that
1201    /// kamaji materializes from the bundle store (R599-F1) and serves via its
1202    /// native backend, instead of (or in addition to) the build reconciler
1203    /// pushing `dist/` to the object-store/CDN. `None` → legacy
1204    /// build-and-publish-only workload — kamaji rejects that form as yubaba's
1205    /// `mesofact-static` reconciler's responsibility.
1206    ///
1207    /// No `skip_serializing_if`: like `ssr_runtime`, this field is always
1208    /// encoded so the postcard wire codec (non-self-describing, positional)
1209    /// round-trips — `skip_serializing_if` would omit the byte on serialize
1210    /// while decode still expects it. `#[serde(default)]` keeps every existing
1211    /// `mesofact-static` TOML/JSON that predates this field parsing to `None`.
1212    #[serde(default)]
1213    #[ts(optional = nullable)]
1214    pub serve_bundle: Option<MesofactServeBundle>,
1215
1216    /// Revalidate receiver for the almanac push model (R330-F12).
1217    ///
1218    /// `Some` → kamaji also forks `mesofact serve --revalidate <workload>`
1219    /// alongside the bundle's static serve (or in place of it when
1220    /// `serve_bundle` is `None`). The receiver is ephemeral-V8: each
1221    /// `POST /dawn` boots a V8 isolate, re-renders the route, republishes to
1222    /// the CDN, then drops the isolate. (`/revalidate` is still served as a
1223    /// transitional alias — yah R752-T10 renamed it so the render stage stops
1224    /// sharing a path with almanac's feed-refetch stage, `POST /freshen`.)
1225    ///
1226    /// Env vars are resolved at deploy time (R2 creds + mirror bearer) so
1227    /// the node never sees keystore slot names.
1228    #[serde(default)]
1229    #[ts(optional = nullable)]
1230    pub revalidate_receiver: Option<MesofactRevalidateReceiver>,
1231}
1232
1233/// Revalidate receiver config (R330-F12) — tells kamaji to fork a second
1234/// `mesofact serve --revalidate` process alongside the static bundle server.
1235///
1236/// The receiver is the almanac push endpoint: a lightweight resident axum
1237/// server mounting `POST /dawn` (plus the legacy `/revalidate` alias) that
1238/// boots V8 on each poke, re-renders the invalidated route, publishes to
1239/// R2/CDN, then drops the isolate.
1240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1241#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1242pub struct MesofactRevalidateReceiver {
1243    /// Routes the receiver accepts pokes for (allowlist).
1244    /// Empty vec → all routes in the workload's manifest are revalidatable.
1245    #[serde(default)]
1246    pub routes: Vec<String>,
1247
1248    /// Path to `mesofact.config.toml` carrying the `[publish]` block
1249    /// (bucket / zone / env-named credentials). Relative to the workload
1250    /// directory. Default: `"mesofact.config.toml"`.
1251    #[serde(default = "default_publish_config_path")]
1252    pub publish_config: String,
1253
1254    /// Env var name holding the bearer secret for this tenant, resolved
1255    /// at deploy time and set as `MESOFACT_MIRROR_KEY` on the receiver
1256    /// process. `None` → open receiver (no bearer check).
1257    #[ts(optional = nullable)]
1258    pub mirror_key_env: Option<String>,
1259
1260    /// Environment variables set on the revalidate process by kamaji.
1261    /// Keys are the canonical env var names (`MESOFACT_S3_ACCESS_KEY_ID`,
1262    /// `MESOFACT_S3_SECRET_ACCESS_KEY`, `CLOUDFLARE_API_TOKEN`,
1263    /// `MESOFACT_MIRROR_KEY`). Values are resolved from the keystore at
1264    /// deploy time — the node never sees slot names.
1265    #[serde(default)]
1266    pub env: std::collections::BTreeMap<String, String>,
1267
1268    /// Feed-fetch tier (R330-F31) — the almanac feeds whose artifacts must be
1269    /// refreshed **on the node** for a poke to have anything new to render.
1270    ///
1271    /// Empty → no fetcher; the receiver re-renders whatever data the bundle was
1272    /// built with (correct for a site whose data only changes at build time,
1273    /// silently stale for one whose data is a live feed). Non-empty → kamaji
1274    /// forks a third resident process, the `almanac-feed` fetcher, next to the
1275    /// receiver — resolved from the bundle's `bins/<triple>/almanac-feed` when
1276    /// it carries one, else from [`feed_runtime`](Self::feed_runtime).
1277    #[serde(default)]
1278    pub feeds: Vec<AlmanacFeed>,
1279
1280    /// Runtime ref the `almanac-feed` fetcher resolves from the node's shared
1281    /// runtime-asset cache when the bundle carries no `bins/` (R746-T3), e.g.
1282    /// `"almanac-feed/0.8.22"`.
1283    ///
1284    /// This is what lets a **vanilla** bundle have a feed tier at all. A
1285    /// self-contained bundle stages the fetcher into `bins/` and stays closed
1286    /// over it; a vanilla bundle carries no binaries by construction, so the
1287    /// fetcher has to be a node-level asset for the same reason `serve` is —
1288    /// otherwise a templates-only sync would still need a cross-built musl
1289    /// binary sitting on the syncing machine's disk.
1290    ///
1291    /// `None` with `feeds` non-empty and no sidecar in the bundle is a deploy
1292    /// failure, named at the node. It is not a silent skip: "the site serves
1293    /// but its data is frozen" is the exact state R330-F31 exists to make
1294    /// observable.
1295    #[serde(default)]
1296    #[ts(optional = nullable)]
1297    pub feed_runtime: Option<String>,
1298
1299    /// Seconds between feed-fetch ticks. Ignored when `feeds` is empty.
1300    ///
1301    /// This is the site's freshness bound: a release lands, and the next tick
1302    /// refreshes + pokes. `FeedRunner`'s change-suppression means an idle tick
1303    /// costs one conditional fetch, so a short interval is affordable.
1304    #[serde(default = "default_feed_interval_secs")]
1305    pub feed_interval_secs: u64,
1306
1307    /// Workspace-relative path of the component whose build produced this
1308    /// bundle, e.g. `app/yah/web/marketing` (R330-F31).
1309    ///
1310    /// Reconciles two roots for one file: a feed declares `emit.artifact`
1311    /// workspace-relative (that is where it is authored), while the route
1312    /// declares the same file project-relative (that is what the bundle
1313    /// carries). The fetcher strips this prefix to get from one to the other.
1314    /// `None` → the two already coincide.
1315    #[serde(default)]
1316    #[ts(optional = nullable)]
1317    pub feed_project_prefix: Option<String>,
1318}
1319
1320/// One almanac feed handed to the on-node fetcher (R330-F31).
1321///
1322/// The definition travels **by value**, not by path: the node has no copy of
1323/// the camp's `.yah/almanac/` tree, and staging one into the content-addressed
1324/// bundle would put a mutable-by-nature config inside an immutable artifact.
1325/// The fetcher parses `config_toml` with the same `FeedConfig` type that reads
1326/// the file at the source, so there is one schema and no drift.
1327#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1328#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1329pub struct AlmanacFeed {
1330    /// Feed name — the `.yah/almanac/<name>.toml` stem. Logs/diagnostics only;
1331    /// `config_toml` is authoritative.
1332    pub name: String,
1333
1334    /// Verbatim contents of the feed definition TOML.
1335    pub config_toml: String,
1336}
1337
1338fn default_publish_config_path() -> String {
1339    "mesofact.config.toml".to_string()
1340}
1341
1342/// Five minutes: fast enough that a release is live on yah.dev before anyone
1343/// goes looking, slow enough to be invisible against a source API's rate limit.
1344fn default_feed_interval_secs() -> u64 {
1345    300
1346}
1347
1348/// Serve-time reference to a published W272 bundle (R599-F4) — the
1349/// `{bundle_digest, runtime, lifecycle}` triple a `mesofact-static` workload
1350/// carries when kamaji, not the build reconciler, serves it.
1351#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1352#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1353pub struct MesofactServeBundle {
1354    /// BLAKE3 digest of the published bundle manifest — the content-address
1355    /// kamaji materializes from the bundle store (`yah_mesofact_bundle`,
1356    /// R599-F1). Same 64-hex shape the bundle crate's `BundleHash` validates.
1357    pub digest: BlakeHash,
1358
1359    /// Runtime that serves the bundle: `"self"` (bundle ships its own
1360    /// `bins/<triple>/serve`) or `"mesofact/<version>"` (resolve the stock
1361    /// serve runtime asset from the node cache). Wire-mirrors
1362    /// `yah_mesofact_bundle::BundleRuntime`; kept as a plain `String` here so
1363    /// workload-spec stays free of the bundle crate and its non-TS/schema
1364    /// newtypes.
1365    pub runtime: String,
1366
1367    /// How kamaji supervises the served bundle. Default: keep-alive.
1368    #[serde(default)]
1369    pub lifecycle: BundleLifecycle,
1370
1371    /// Port the served bundle listens on (R599-F12). This is the bundle-tier
1372    /// analogue of a container's `expose.mesh.ports`: the *declared* serving
1373    /// port, which a proxy pairs with the workload's mesh IP to get a dialable
1374    /// address.
1375    ///
1376    /// `None` → **the supervisor allocates one** (R844-F2), and reports the
1377    /// port it bound back to yubaba on the next workload listing, where it
1378    /// lands in the service record an ingress upstream is rendered from. This
1379    /// is the normal case: a mirror should not have to name a port at all.
1380    ///
1381    /// It used to mean "fall back to kamaji's node-wide default
1382    /// (`KAMAJI_BUNDLE_PORT`, else 8080)", which was a single node-wide slot
1383    /// wearing the word *default* — correct only while a node hosted one
1384    /// bundle, and a silent collision for the second. R599-F12 added this field
1385    /// so a workload could opt out of that; R844-F2 removed the default itself,
1386    /// so opting out is no longer something anyone has to remember to do.
1387    ///
1388    /// Declaring a port still pins it exactly, for a workload that must be
1389    /// reachable at a known number.
1390    ///
1391    /// No `skip_serializing_if` — see `serve_bundle`'s note: the postcard wire
1392    /// codec is positional, so an omitted byte shifts every later field.
1393    #[serde(default)]
1394    #[ts(optional = nullable)]
1395    pub port: Option<u16>,
1396
1397    /// Environment the serve process is forked with (R556-T12) — already
1398    /// **resolved** values, `NAME → value`.
1399    ///
1400    /// This is what makes an SSR route that reads a private source deployable
1401    /// at all: `mesofact serve` resolves a source's credentials from its own
1402    /// process environment at request time, and before this field the static /
1403    /// SSR serve process was forked with `env: vec![]` while only the
1404    /// `revalidate_receiver` sub-slot carried any. A declared-authed SSR site
1405    /// therefore deployed clean and failed *per request* on the node.
1406    ///
1407    /// Resolution happens deploy-side, exactly like
1408    /// [`MesofactRevalidateReceiver::env`]: the mirror declares source URIs
1409    /// (`vault:<slot>` / `env:<VAR>`), `yah cloud apply` resolves them against
1410    /// the operator's vault, and the node receives values. Keystore slot names
1411    /// never cross the wire.
1412    ///
1413    /// Appended **after** `port` — see `port`'s note: the postcard wire codec
1414    /// is positional, so a new field goes last and never carries
1415    /// `skip_serializing_if`.
1416    #[serde(default)]
1417    pub env: BTreeMap<String, String>,
1418}
1419
1420/// Lifecycle mode for a served bundle (W272 §3).
1421#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1422#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1423#[serde(rename_all = "snake_case")]
1424pub enum BundleLifecycle {
1425    /// Fork at deploy, keep resident, restart per policy — today's server
1426    /// archetype. Memory is resident for the workload's lifetime.
1427    KeepAlive,
1428
1429    /// Kamaji owns the listen socket, forks the runtime on the first connection
1430    /// (fd-passing), and reaps it after `idle_ttl` with zero connections — the
1431    /// "serverless" tier (zero memory when idle). The JIT fork/reap mechanics
1432    /// land in R599-F6; this variant only declares the intent + budget.
1433    OnDemand {
1434        /// Idle time with no live connections before kamaji reaps the process.
1435        idle_ttl: Millis,
1436    },
1437}
1438
1439impl Default for BundleLifecycle {
1440    /// Keep-alive — the resident server archetype — matches the current
1441    /// deploy-and-supervise default.
1442    fn default() -> Self {
1443        BundleLifecycle::KeepAlive
1444    }
1445}
1446
1447/// Build step that produces the static artifact published by a
1448/// `mesofact-static` workload.
1449///
1450/// **`deny_unknown_fields` is load-bearing (R658-B1).** TOML scopes every key
1451/// written after a table header into that table, so a manifest that puts a
1452/// top-level `MesofactStaticWorkload` field — `routes` was the one that
1453/// actually happened — below `[build]` silently produces `build.routes`
1454/// instead. Without this attribute serde discards the stray key, the
1455/// top-level field falls back to its default (or fails with a `missing field`
1456/// error pointing at the wrong place), and the manifest deploys with a
1457/// declaration nobody honours. Every real `workload.toml` in the camp and the
1458/// CLI's own `yah cloud site init` scaffold carried exactly that shape for
1459/// months without a single reader noticing.
1460///
1461/// The cost is forward-compat: a manifest carrying a `[build]` key this binary
1462/// doesn't know is a hard parse error, not an ignored key. That is deliberate.
1463/// A build config is a small, slow-moving, load-bearing table — a key that
1464/// silently does nothing is worse here than one that refuses to load, because
1465/// the failure surfaces as a wrong artifact rather than an error.
1466///
1467/// Note `deny_unknown_fields` is inert for the postcard kamaji wire, which is
1468/// non-self-describing and positional — this only constrains TOML/JSON.
1469#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1470#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1471#[serde(deny_unknown_fields)]
1472pub struct BuildConfig {
1473    /// Shell command run from the manifest's directory, e.g. `"bun run build"`.
1474    ///
1475    /// **Absent means "this project has no external bundler step" (R838-B1)**,
1476    /// not "run nothing by accident". `mesofact new`'s scaffold deliberately
1477    /// omits it — the in-process pipeline (`mesofact-dev` / `mesofact-build`)
1478    /// produces `out_dir` with no third binary, no package manager and no Node
1479    /// — so requiring it here made every scaffolded project's manifest fail to
1480    /// load through this envelope. Setting it opts back out to a shell command,
1481    /// which is what a project with its own bundler wants.
1482    ///
1483    /// Consumers were already written for this: `read_workload_build`
1484    /// (app/yah/cli/src/cloud.rs) has always typed it `Option<String>` and
1485    /// `yah cloud bundle build` only needs it under `--run-build`; the bundle
1486    /// sync arm refuses `None` by name. `MesofactStaticReconciler::
1487    /// rebuild_static` skips the build step for `None` — the same thing it
1488    /// already did for a workload with no `workload.toml` at all.
1489    ///
1490    /// WIRE NOTE: this is `Option<String>` on the postcard kamaji wire, so it
1491    /// costs a leading `0x00`/`0x01` tag byte that the bare `String` did not
1492    /// have. A pre-R838 node decoding a new frame fails loudly (the string's
1493    /// length byte is not a valid `Option` tag) rather than silently reading a
1494    /// shifted field — which is why this is `Option` and not a `#[serde(default)]`
1495    /// empty `String` sentinel. Not a `cluster_epochs` surface: those hash the
1496    /// raft modules and the openraft pin, not `workload_spec`.
1497    #[serde(default)]
1498    pub command: Option<String>,
1499
1500    /// Output directory (relative to the manifest) the reconciler uploads.
1501    pub out_dir: PathBuf,
1502
1503    /// Data-only re-render command (W225 §3 "revalidate"), run from the
1504    /// manifest's directory against the **already-built** `out_dir` — no
1505    /// bundler. `{route}` is substituted with the invalidated route pattern,
1506    /// e.g. `"../../../../scripts/mesofact-build.sh render . --route {route}
1507    /// --all"` (R746-F9 — resolves a prebuilt binary rather than shelling to
1508    /// cargo, which cannot even find the package from a site's own dir).
1509    /// Absent → a revalidate dispatch republishes `out_dir` as-is.
1510    #[serde(default)]
1511    pub render_command: Option<String>,
1512}
1513
1514// ── BuildMode ─────────────────────────────────────────────────────────────────
1515
1516/// Where the build command runs for a `mesofact-static` workload.
1517///
1518/// The two-role split encodes the F7 design decision: build/publish is a
1519/// **transient job** (runs once, exits, GC'd); SSR/SPA serving is a separate
1520/// **long-lived companion container** (optional, only for dynamic pages). A
1521/// single merged "mesofact container" is the trap — in cloud, CI builds the
1522/// artifact, R2+CDN serve it, and a distinct worker handles any SSR.
1523///
1524/// Default: `HostSide` — mesofact-dev runs the build on the host and publishes
1525/// to the tier's object store. No container overhead; compatible with dev and
1526/// sim tiers.
1527#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
1528#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1529#[serde(rename_all = "snake_case")]
1530pub enum BuildMode {
1531    /// Build command runs on the host (mesofact-dev watcher). The watcher
1532    /// publishes the output to the tier's object store (DistPointer for dev,
1533    /// MinIO for sim). Compatible with all tiers; zero container overhead.
1534    #[default]
1535    HostSide,
1536
1537    /// Build runs inside a transient container matching the CI image. Higher
1538    /// fidelity (environment matches CI exactly); costs image pull +
1539    /// container cold-start. Required for cloud/HA where no mesofact-dev
1540    /// watcher is running on the host.
1541    InContainer {
1542        /// Container image that runs the build (e.g. `"ghcr.io/org/app-build:v1.2"`).
1543        /// Must have the build toolchain installed. The container is started with
1544        /// the workspace root bind-mounted, runs `build.command`, uploads
1545        /// `build.out_dir` to the object store, then exits.
1546        image: ImageRef,
1547    },
1548}
1549
1550// ── AlmanacManifest ───────────────────────────────────────────────────────────
1551
1552/// An observable endpoint the almanac scheduler probes to check readiness.
1553///
1554/// Used for both inputs (checked before the run) and outputs (verified after
1555/// a successful run to confirm the job produced something reachable).
1556/// The probe is intentionally lightweight — no S3 SigV4, no xlb-net discovery
1557/// required; a simple TCP connect or HTTP GET is enough for the dev/sim tier.
1558#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1559#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1560#[serde(rename_all = "snake_case")]
1561pub enum AlmanacTarget {
1562    /// Issue an HTTP GET to `url`; ready when the server responds with
1563    /// `expect_status` (default: any 2xx).
1564    Http {
1565        url: String,
1566        #[ts(optional = nullable)]
1567        expect_status: Option<u16>,
1568    },
1569
1570    /// Establish a TCP connection to `host:port`; ready when the connect
1571    /// succeeds. Used for non-HTTP services (e.g. MinIO API on port 9000)
1572    /// and as a lighter probe when an HTTP endpoint isn't stable yet.
1573    Tcp { host: String, port: u16 },
1574}
1575
1576/// What the almanac scheduler does when a precondition check fails.
1577///
1578/// The F9 design decision: `WaitWithTimeout` is the default. Fail-fast is
1579/// too brittle for the sim tier (containers may still be cold-starting);
1580/// requeue-with-no-ceiling can block the scheduler indefinitely. The
1581/// recommended timeout for sim is the container spinup budget (~5 s cold,
1582/// ~1 s warm): set `timeout` to a few seconds, then let the retry cadence
1583/// handle transient glitches.
1584#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1585#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1586#[serde(rename_all = "snake_case")]
1587pub enum NotReadyPolicy {
1588    /// Wait up to `timeout` for all preconditions to pass before aborting
1589    /// the run. The run is skipped (not rescheduled); the next cadence tick
1590    /// will retry. Suitable when targets occasionally lag at startup.
1591    WaitWithTimeout {
1592        /// How long to wait for each precondition to become reachable. The
1593        /// scheduler polls with a short sleep between attempts.
1594        timeout: Millis,
1595    },
1596
1597    /// Abort immediately if any precondition check fails. Suitable for
1598    /// integration-test harnesses where a missing dependency is always a
1599    /// hard error.
1600    FailFast,
1601
1602    /// Requeue with exponential backoff up to `max_attempts` times. After
1603    /// exhaustion the run is marked failed. Suitable for cloud/HA where
1604    /// transient dependency outages are expected.
1605    Requeue {
1606        /// Maximum number of requeue attempts before the run is marked failed.
1607        max_attempts: u32,
1608        /// Initial backoff between attempts, in milliseconds.
1609        backoff: Millis,
1610    },
1611}
1612
1613impl Default for NotReadyPolicy {
1614    /// Default is `WaitWithTimeout { timeout: 5 seconds }` — matches the
1615    /// container spinup budget for the sim tier (few-second cold, sub-second warm).
1616    fn default() -> Self {
1617        Self::WaitWithTimeout { timeout: Millis::from_secs(5) }
1618    }
1619}
1620
1621/// When the almanac scheduler triggers a run.
1622#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1623#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1624#[serde(rename_all = "snake_case")]
1625pub enum Cadence {
1626    /// Run once at first opportunity, then never again.
1627    Once,
1628
1629    /// Run repeatedly with a fixed interval between the end of one run and
1630    /// the start of the next. Equivalent to `sleep N && run` in a loop.
1631    Every {
1632        /// Minimum time between consecutive run completions.
1633        interval: Millis,
1634    },
1635
1636    /// Run on a UTC cron schedule (standard 5-field expression, e.g.
1637    /// `"0 */6 * * *"` for every 6 hours). The scheduler evaluates the
1638    /// expression relative to UTC midnight.
1639    Cron { expression: String },
1640}
1641
1642/// `kind = "almanac"` manifest — a declared data-pipeline job.
1643///
1644/// An almanac job is the generalisation of the OpenRouter refresher
1645/// (`spawn_almanac_refresher`): it declares its I/O contract explicitly so
1646/// the orchestrator can enforce preconditions before each run and verify
1647/// outputs afterward. The degenerate case (no inputs, no app target, cron
1648/// schedule) is exactly the OpenRouter JSON-cache refresher.
1649///
1650/// Lifecycle:
1651/// 1. Cadence tick fires.
1652/// 2. Scheduler probes every `inputs` target. If any fail → apply
1653///    `not_ready_policy`.
1654/// 3. Command runs (`sh -c command` from the workload directory).
1655/// 4. Scheduler probes every `outputs` target. Failure → mark run as
1656///    failed but do not retry.
1657/// 5. Any workloads listed in `invalidates` receive a cache-bust signal
1658///    (implementation detail of the orchestrator; in camp this is a
1659///    rebuild trigger on the mesofact-dev watcher).
1660#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
1661#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1662pub struct AlmanacManifest {
1663    /// Wire-format version. Always `V1` today.
1664    pub schema_version: SchemaVersion,
1665
1666    /// Shell command executed via `sh -c` from the workload directory.
1667    pub command: String,
1668
1669    /// When to run.
1670    pub cadence: Cadence,
1671
1672    /// Input targets that must be reachable before the command runs.
1673    /// Empty list → no precondition checks (degenerate case).
1674    #[serde(default)]
1675    pub inputs: Vec<AlmanacTarget>,
1676
1677    /// Output targets verified after a successful run.
1678    /// Empty list → no post-run verification.
1679    #[serde(default)]
1680    pub outputs: Vec<AlmanacTarget>,
1681
1682    /// What to do when a precondition check fails.
1683    /// Default: `WaitWithTimeout { timeout: 5000ms }`.
1684    #[serde(default)]
1685    pub not_ready_policy: NotReadyPolicy,
1686
1687    /// Mesh identities of workloads to notify after a successful run.
1688    /// The orchestrator sends a cache-bust signal to each entry so
1689    /// downstream consumers can reload their data (e.g. mesofact-dev
1690    /// triggers a rebuild when the OpenRouter cache refreshes).
1691    /// Empty list → no downstream invalidation.
1692    #[serde(default)]
1693    pub invalidates: Vec<MeshIdent>,
1694}
1695
1696// ── StaticAssetWorkload ───────────────────────────────────────────────────────
1697
1698/// BLAKE3 content hash expressed as exactly 64 ASCII hex digits.
1699///
1700/// This is the content-address key for every file in the static-asset catalog.
1701/// Deserialization rejects values that do not conform — 64 hex chars, case
1702/// insensitive. Mismatch between the recorded hash and the source file halts
1703/// the upload step in the reconciler.
1704#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)]
1705#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1706#[ts(type = "string")]
1707pub struct BlakeHash(pub String);
1708
1709impl<'de> Deserialize<'de> for BlakeHash {
1710    fn deserialize<D>(de: D) -> Result<Self, D::Error>
1711    where
1712        D: serde::Deserializer<'de>,
1713    {
1714        let s = String::deserialize(de)?;
1715        if s.len() != 64 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
1716            return Err(serde::de::Error::custom(format!(
1717                "blake3 hash must be exactly 64 hex digits, got {:?}",
1718                s
1719            )));
1720        }
1721        Ok(BlakeHash(s))
1722    }
1723}
1724
1725// ── License & FetchSource (W164) ──────────────────────────────────────────────
1726
1727/// Closed-set, parse-time-enforced license tag. Mirrors the workspace
1728/// permissive-license rule (MIT / Apache-2.0 / BSD-2/3-Clause / ISC). Adding a
1729/// variant is an explicit schema change — non-permissive strings
1730/// (`"GPL-3.0"`, `"AGPL"`, etc.) fail at serde-deserialize before any shape
1731/// validator runs.
1732///
1733/// Shared between `asset.derive.fetch.license` (W164, required) and a future
1734/// `almanac::ReleaseSource.license` migration (R438-F10, optional).
1735#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
1736#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1737#[serde(rename_all = "kebab-case")]
1738pub enum License {
1739    Mit,
1740    Apache2,
1741    Bsd2Clause,
1742    Bsd3Clause,
1743    Isc,
1744}
1745
1746/// Shared fetch primitive — usable by `asset.derive` today, and by Almanac's
1747/// `ReleaseSource` after a follow-up migration (R438-F10). Defined once in
1748/// workload-spec so both consumers reject the same set of non-permissive
1749/// licenses.
1750///
1751/// The `blake3` hash pins the upstream bytes; mismatch at fetch time is a hard
1752/// error in the reconciler. The `license` field is **required** here — every
1753/// derived asset must declare its upstream license. If/when Almanac adopts
1754/// `FetchSource`, the Almanac side may wrap this in a struct with
1755/// `Option<License>` since release manifests have no distribution license per
1756/// se.
1757#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1758#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1759pub struct FetchSource {
1760    /// Upstream URL fetched verbatim. Reconciler retry policy is configured
1761    /// elsewhere (R438-F11); the URL itself is opaque to workload-spec.
1762    pub url: String,
1763
1764    /// Expected BLAKE3 hash of the fetched bytes (64 hex characters). The
1765    /// reconciler verifies this after download and aborts on mismatch.
1766    pub blake3: BlakeHash,
1767
1768    /// Upstream license. Closed-set, parse-time enforced.
1769    pub license: License,
1770}
1771
1772/// Optional transform applied after a [`FetchSource`] download, lowering to a
1773/// `ForgeCommand::Subprocess` via the recipe loader (R438-T4). The transform's
1774/// output is content-addressed by the entry's `blake3` (the recipe runs only
1775/// when the cache misses).
1776#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1777#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1778pub struct TransformSpec {
1779    /// Named recipe under `.yah/qed/transforms/<recipe>.toml`. Loader rejects
1780    /// missing recipes at materialize time.
1781    pub recipe: String,
1782
1783    /// `{{key}}` substitutions passed to the recipe argv at element
1784    /// granularity (no shell, no string concat). Empty when the recipe is
1785    /// fully parameterless.
1786    #[serde(default)]
1787    pub params: BTreeMap<String, String>,
1788}
1789
1790/// W212/R518: the committed derivation lock — the in-tree action-cache
1791/// receipt. `input_hash` is the input-addressed derivation key computed over
1792/// the complete declared input set (fetched-input pin ⊕ recipe-file bytes ⊕
1793/// invocation params ⊕ schema version); `output_blake3` is what those inputs
1794/// produced (== the entry's `blake3`). The reconciler skips the entire build
1795/// (no fetch, no transform, no PUT) when the lock matches the inputs recomputed
1796/// from the current pins and the bucket already holds the output — the
1797/// Nix-substituter / Bazel-remote-cache behaviour. Written by the R510 bind
1798/// path from the reconciler's `discovered_input_hash:<filename>` output; the
1799/// `git diff` on this block is the receipt that the derivation rolled.
1800#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1801#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1802pub struct DeriveLock {
1803    /// Input-addressed derivation key (BLAKE3 hex). A change to any declared
1804    /// input flips this, so a stale lock never produces a false skip.
1805    pub input_hash: String,
1806    /// Output the locked inputs produced (BLAKE3 hex; equals the entry's
1807    /// `blake3`). Carried so the lock is a self-contained action-cache entry.
1808    pub output_blake3: String,
1809}
1810
1811/// Provenance chain for a derived asset: required `fetch` step, optional
1812/// `transform` step. Materialized bytes replace `AssetEntry.source` for the
1813/// rest of the static-asset reconcile loop.
1814#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1815#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1816pub struct AssetDerive {
1817    /// Upstream fetch — URL + content-pin + license.
1818    pub fetch: FetchSource,
1819
1820    /// Post-fetch transform. `None` → the fetched bytes ARE the asset
1821    /// (entry `blake3` must match fetch `blake3`).
1822    #[serde(default)]
1823    #[ts(optional = nullable)]
1824    pub transform: Option<TransformSpec>,
1825
1826    /// W212/R518: committed derivation lock (input-addressed action-cache
1827    /// receipt). Absent until the first successful build writes it via the
1828    /// bind path. When present and current, enables the substituter-style
1829    /// build skip.
1830    #[serde(default)]
1831    #[ts(optional = nullable)]
1832    pub lock: Option<DeriveLock>,
1833}
1834
1835/// A single file entry in the static-asset catalog.
1836///
1837/// One `[[asset]]` row per bucket object. Multiple rows for different variants
1838/// (e.g. q5 and q4 whisper models) are fine — each declares its own filename
1839/// and hash. The reconciler treats the catalog as exhaustive and append-only:
1840/// new rows trigger a PUT; removed rows surface as drift (never a DELETE).
1841///
1842/// **Source-vs-derive XOR.** Exactly one of `source` or `derive` must be set.
1843/// Legacy local-bytes assets keep `source = "..."`; W164 derived assets set
1844/// `[asset.derive]` instead. [`validate::shape_static_asset`] enforces the
1845/// XOR; both-set and neither-set are hard `ShapeError::Field`.
1846#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1847#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1848pub struct AssetEntry {
1849    /// Destination path within the bucket, e.g.
1850    /// `"whisper/distil-large-v3-q5_1.bin"`. Must be unique in the catalog.
1851    /// Used as the S3 object key by the reconciler.
1852    pub filename: String,
1853
1854    /// Path to a local source file, relative to the `workload.toml` directory.
1855    /// Mutually exclusive with `derive`.
1856    #[serde(default)]
1857    #[ts(optional = nullable)]
1858    pub source: Option<PathBuf>,
1859
1860    /// Declared fetch (+ optional transform) provenance chain. The reconciler
1861    /// materializes the bytes into a content-addressed cache; the cache path
1862    /// then replaces `source` for the rest of the upload pipeline. Mutually
1863    /// exclusive with `source`.
1864    #[serde(default)]
1865    #[ts(optional = nullable)]
1866    pub derive: Option<AssetDerive>,
1867
1868    /// Expected BLAKE3 hash of the *final* asset bytes (64 hex characters).
1869    /// For `source` mode, this is hashed before upload. For `derive` mode,
1870    /// it's the post-transform (or post-fetch when no transform) output.
1871    /// Mismatch aborts the upload.
1872    pub blake3: BlakeHash,
1873}
1874
1875/// `kind = "static-asset"` payload — content-addressed bucket catalog.
1876///
1877/// The reconciler makes the bucket match the `[[asset]]` list exactly
1878/// (append-only: new rows → PUT; removed rows → drift report, not DELETE).
1879/// Rollback is pointer-flip via `mirror.toml [asset_aliases]` — bytes never
1880/// move during rollback.
1881///
1882/// **Closed-catalog invariant**: every value in `[aliases]` must be a
1883/// `filename` that exists in `[[asset]]`. Enforced by
1884/// [`validate::shape_static_asset`]. Mirror overrides (`[asset_aliases]` in
1885/// `mirror.toml`) are bound by the same rule — the alias graph can only
1886/// resolve to filenames already in the catalog.
1887#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
1888#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1889pub struct StaticAssetWorkload {
1890    /// Wire-format version. Always `V1` today.
1891    pub schema_version: SchemaVersion,
1892
1893    /// Exhaustive catalog of files this component manages in the bucket.
1894    ///
1895    /// Named `asset` on disk (TOML `[[asset]]` array-of-tables) to follow TOML
1896    /// convention; accessed as `.assets` in Rust code.
1897    #[serde(rename = "asset", default)]
1898    pub assets: Vec<AssetEntry>,
1899
1900    /// Canonical logical-name → filename mappings for this component.
1901    ///
1902    /// Values must be filenames present in `assets` — validated by
1903    /// [`validate::shape_static_asset`]. Mirror files may override individual
1904    /// entries via `[asset_aliases]` but may never reference filenames absent
1905    /// from this catalog.
1906    #[serde(default)]
1907    pub aliases: BTreeMap<String, String>,
1908}
1909
1910// ── Lifecycle archetype (R572-F1 / W244) ───────────────────────────────────────
1911
1912/// Explicit lifecycle archetype for a `kind = "container"` workload (W244).
1913///
1914/// The question that actually matters to a scheduler: *"can I kill this and
1915/// recreate it somewhere else?"* Before this field existed, the answer was
1916/// inferred per-spec from `volumes.is_empty()` + `restart_policy` — fragile
1917/// absence-as-policy, the same trap W243 calls out on the node-taint side.
1918/// This type makes the answer structural instead of guessed.
1919///
1920/// This ticket (R572-F1) adds the discriminator only. The reconciler does not
1921/// yet branch on it (R572-F4) and neither does the scheduler (R572-F5).
1922#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
1923#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1924#[serde(rename_all = "kebab-case")]
1925pub enum LifecycleArchetype {
1926    /// k8s analogue: Deployment. Stateless and fungible — the scheduler may
1927    /// move it, scale it to N replicas, or restart it on a different node
1928    /// with zero consequence. Drainable.
1929    Server,
1930
1931    /// k8s analogue: StatefulSet. Stable identity + a volume that must
1932    /// follow it; at most one live instance. Not drainable — the reconciler
1933    /// must not schedule it onto a different node. Example: a postgres peer,
1934    /// headscale (W267/R591).
1935    Appliance,
1936
1937    /// k8s analogue: Job. Runs to completion with declared inputs/outputs,
1938    /// then is gone — no steady-state identity. `almanac` is the first
1939    /// job-family member; forge runs (`WorkloadSpec::for_forge`, used by QED)
1940    /// are the `container`-kind instance of this archetype.
1941    Job,
1942}
1943
1944impl LifecycleArchetype {
1945    /// Every variant, in declaration order. Exists so a consumer can enumerate
1946    /// the archetypes without hand-maintaining a parallel list — the taint
1947    /// vocabulary in `cloud::config::taint_effect` is built from this, so
1948    /// adding a fourth archetype extends the set of live repel keys for free.
1949    pub const ALL: [LifecycleArchetype; 3] = [Self::Server, Self::Appliance, Self::Job];
1950
1951    /// The repel-taint key for this archetype (R572-F5). A node carrying the
1952    /// taint `"no-<key>"` **absolutely** rejects workloads of this class.
1953    ///
1954    /// Examples: `Server` → `"server"` (repelled by `"no-server"`);
1955    /// `Appliance` → `"appliance"` (repelled by `"no-appliance"`).
1956    ///
1957    /// W305/R742-T4: there is no toleration. Earlier prose here and in
1958    /// `cloud::config` called this "repel-unless-tolerate"; the `unless` was
1959    /// never built, and reading it as a preference is what made `no-appliance`
1960    /// on the dev Pis look advisory when it was an unconditional block.
1961    pub fn taint_key(&self) -> &'static str {
1962        match self {
1963            Self::Server => "server",
1964            Self::Appliance => "appliance",
1965            Self::Job => "job",
1966        }
1967    }
1968
1969    /// The pre-R572 inference this field replaces, kept only to give
1970    /// `WorkloadSpec::effective_archetype` a behavior-preserving fallback for
1971    /// specs written before this field existed (`archetype: None`).
1972    ///
1973    /// A volume that must follow the workload is the strongest signal of
1974    /// durable state → [`Self::Appliance`]. Absent that, `RestartPolicy::Never`
1975    /// is the existing forge/run-once convention (see
1976    /// [`RestartPolicy::Never`]'s doc comment) → [`Self::Job`]. Everything
1977    /// else defaults to the common case, [`Self::Server`].
1978    fn infer(volumes: &[VolumeMount], restart_policy: &RestartPolicy) -> Self {
1979        if !volumes.is_empty() {
1980            LifecycleArchetype::Appliance
1981        } else if matches!(restart_policy, RestartPolicy::Never) {
1982            LifecycleArchetype::Job
1983        } else {
1984            LifecycleArchetype::Server
1985        }
1986    }
1987}
1988
1989// ── WorkloadSpec ──────────────────────────────────────────────────────────────
1990
1991/// Complete typed description of a containerd workload handed to yubaba over
1992/// RPC. This is also the payload of the `kind = "container"` variant of
1993/// [`Workload`] on disk.
1994///
1995/// Yubaba never accepts compose YAML on its RPC surface — agents, the desktop,
1996/// and operator CLIs all hand yubaba `WorkloadSpec` values. See the arch doc
1997/// for the validation layers and evolution rules.
1998#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
1999#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2000pub struct WorkloadSpec {
2001    /// Wire-format version; always `V1` today. Present at the top level so
2002    /// rolling clusters can detect and migrate across schema generations.
2003    pub schema_version: SchemaVersion,
2004
2005    /// DNS-friendly workload name, e.g. `"noisetable-api"`. Regex:
2006    /// `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`, length ≤ 63.
2007    pub name: String,
2008
2009    /// Container image to pull.
2010    pub image: ImageRef,
2011
2012    /// Tier tag controlling admission control and mesh filtering.
2013    pub tier: TierTag,
2014
2015    /// Tenant **isolation** axis (W206). Separates operators' workloads at the
2016    /// network / DB / mesh-identity level. Defaults to [`TenantId::singleton`]
2017    /// for specs that predate the axis, so single-tenant clusters keep every
2018    /// isolation primitive a no-op. Orthogonal to [`Self::tier`] (class) and
2019    /// [`Self::namespace`] (routing).
2020    #[serde(default = "TenantId::singleton")]
2021    pub tenant: TenantId,
2022
2023    /// Namespace **routing/naming** axis (W206). A pure naming key — never
2024    /// affects isolation; disambiguates DNS names and selects config root /
2025    /// provider zone within a tenant. Defaults to [`NamespaceId::singleton`].
2026    #[serde(default = "NamespaceId::singleton")]
2027    pub namespace: NamespaceId,
2028
2029    /// Target replica count. `0` registers the workload without deploying it.
2030    /// Range: 0–100 (cluster-wide cap; operator can raise it).
2031    pub replicas: u32,
2032
2033    /// Override the image's `CMD`. `None` leaves the image default.
2034    #[ts(optional = nullable)]
2035    pub command: Option<Vec<String>>,
2036
2037    /// Override the image's `ENTRYPOINT`. `None` leaves the image default.
2038    #[ts(optional = nullable)]
2039    pub entrypoint: Option<Vec<String>>,
2040
2041    /// Working directory inside the container.
2042    #[ts(optional = nullable)]
2043    pub workdir: Option<PathBuf>,
2044
2045    /// User to run as, e.g. `"1000:1000"` or `"appuser"`.
2046    #[ts(optional = nullable)]
2047    pub user: Option<String>,
2048
2049    /// Environment variables. Values may be literals, secret refs, or
2050    /// mesh-address references resolved by yubaba at deploy time.
2051    #[serde(default)]
2052    pub env: Vec<EnvVar>,
2053
2054    /// Secret mounts. Values never appear in the spec JSON — only references.
2055    #[serde(default)]
2056    pub secrets: Vec<SecretMount>,
2057
2058    /// Volume mounts.
2059    #[serde(default)]
2060    pub volumes: Vec<VolumeMount>,
2061
2062    /// Hard resource caps enforced by containerd/cgroups.
2063    pub resources: ResourceLimits,
2064
2065    /// Mesh idents that must reach `Ready` before this workload starts.
2066    #[serde(default)]
2067    pub depends_on: Vec<MeshIdent>,
2068
2069    /// Container liveness/readiness probe.
2070    #[ts(optional = nullable)]
2071    pub healthcheck: Option<Healthcheck>,
2072
2073    /// What yubaba does when the container exits.
2074    pub restart_policy: RestartPolicy,
2075
2076    /// Explicit lifecycle archetype (R572-F1 / W244): `server`, `appliance`,
2077    /// or `job`. `None` means the spec predates this field (or the author
2078    /// didn't set it) — callers MUST NOT read this directly to decide
2079    /// drainability; use [`WorkloadSpec::effective_archetype`], which falls
2080    /// back to the pre-R572 `volumes`/`restart_policy` inference so no
2081    /// existing spec's effective meaning changes.
2082    ///
2083    /// Additive: this field did not exist before R572-F1. Reconciler (F4)
2084    /// and scheduler (F5) branching on the resolved archetype are separate,
2085    /// later tickets — this field alone changes no runtime behavior.
2086    #[serde(default)]
2087    #[ts(optional = nullable)]
2088    pub archetype: Option<LifecycleArchetype>,
2089
2090    /// Graceful shutdown configuration.
2091    pub stop_policy: StopPolicy,
2092
2093    /// Network exposure configuration — mesh, public, and operator channels
2094    /// are independent and can be set in any combination.
2095    pub expose: ExposeSpec,
2096
2097    /// OCI-style labels, passed through to the container. Opaque to yubaba.
2098    #[serde(default)]
2099    pub labels: HashMap<String, String>,
2100
2101    /// Yah-specific metadata, conventionally prefixed `yah.*`. Opaque to
2102    /// yubaba beyond `yah.forge=true` which suppresses the Never-restart guard.
2103    #[serde(default)]
2104    pub annotations: HashMap<String, String>,
2105}
2106
2107impl WorkloadSpec {
2108    /// Build a `WorkloadSpec` for a forge run.
2109    ///
2110    /// Sets the conventional forge fields in one place so callers cannot
2111    /// forget any of them:
2112    ///
2113    /// - `restart_policy = Never`
2114    /// - `archetype = Some(LifecycleArchetype::Job)` — a forge run is
2115    ///   exactly the `container`-kind instance of the job archetype (W244);
2116    ///   set explicitly rather than left to infer since this constructor
2117    ///   knows its own shape
2118    /// - `expose.public = None`, `expose.operator = None`
2119    /// - `expose.mesh.identity = "forge.<forge_id>"`
2120    /// - `annotations["yah.forge"] = "true"` (suppresses the shape warning)
2121    /// - `tier` and `image` come from the caller; `ports` becomes the mesh
2122    ///   port list (empty is valid — forge jobs often don't expose ports)
2123    ///
2124    /// All other fields are set to safe defaults. Callers can mutate the
2125    /// returned value to fill in `command`, `env`, `resources`, etc.
2126    pub fn for_forge(
2127        forge_id: &str,
2128        image: ImageRef,
2129        tier: TierTag,
2130        ports: Vec<u16>,
2131    ) -> Self {
2132        let mut annotations = HashMap::new();
2133        annotations.insert("yah.forge".into(), "true".into());
2134        // The placement floor, kept distinct from the cgroup ceiling below.
2135        // Without this, admission reads the 32 GiB ceiling as the amount of
2136        // RAM a node must have — see `memory_request_mb` for what that cost.
2137        annotations.insert(
2138            MEMORY_REQUEST_ANNOTATION.into(),
2139            FORGE_MEMORY_REQUEST_MB.to_string(),
2140        );
2141
2142        WorkloadSpec {
2143            schema_version: SchemaVersion::V1,
2144            // NB: DNS-label safe (no dots) — `check_name` validation rejects
2145            // dots here. The container_id derives from this; the state-poll
2146            // keys off `expose.mesh.identity` (`forge.<id>`) instead, so those
2147            // two must be reconciled at the read path, NOT by dotting the name
2148            // (see R590-B9).
2149            name: format!("forge-{forge_id}"),
2150            image,
2151            tier,
2152            tenant: TenantId::singleton(),
2153            namespace: NamespaceId::singleton(),
2154            replicas: 1,
2155            command: None,
2156            entrypoint: None,
2157            workdir: None,
2158            user: None,
2159            env: vec![],
2160            secrets: vec![],
2161            volumes: vec![],
2162            resources: ResourceLimits {
2163                // R590-B10: forge workloads are BUILDS (cargo, buildkit, a
2164                // from-source V8 checkout+compile), not tiny services. The old
2165                // 256 MB placeholder became a hard cgroup memory.limit in
2166                // build_oci_spec and SIGKILL'd the rusty-v8 build mid-checkout
2167                // (git checkout of third_party/icu died of signal 9) — the
2168                // more so because /tmp is a RAM-backed tmpfs, so the source
2169                // tree counts against this limit too. 32 GiB is a bounded
2170                // ceiling that fits the V8 build's >12 GB peak with headroom,
2171                // protects the host from a runaway (vs truly unlimited), and is
2172                // above physical RAM on smaller build-workers (⇒ effectively
2173                // unlimited there).
2174                //
2175                // That last clause is only true while this stays a CEILING. It
2176                // was also the placement floor until the annotation set above
2177                // split the two, which made every build-worker under 32 GiB
2178                // unschedulable — the story is on `memory_request_mb`.
2179                memory_mb: FORGE_MEMORY_LIMIT_MB,
2180                cpu_millis: 512,
2181                ephemeral_storage_mb: 512,
2182            },
2183            depends_on: vec![],
2184            healthcheck: None,
2185            restart_policy: RestartPolicy::Never,
2186            archetype: Some(LifecycleArchetype::Job),
2187            stop_policy: StopPolicy {
2188                signal: 15,
2189                grace_period: Millis::from_secs(30),
2190            },
2191            expose: ExposeSpec {
2192                mesh: MeshExpose {
2193                    identity: MeshIdent(format!("forge.{forge_id}")),
2194                    ports,
2195                    allow_from: vec![],
2196                },
2197                public: None,
2198                operator: None,
2199            },
2200            labels: HashMap::new(),
2201            annotations,
2202        }
2203    }
2204
2205    /// Whether this workload requests the **host network namespace** rather
2206    /// than an isolated one.
2207    ///
2208    /// Opt-in via `annotations["yah.network"] == "host"` (see
2209    /// [`HOST_NETWORK_ANNOTATION`] / [`HOST_NETWORK_VALUE`]). Default is the
2210    /// isolated netns every other workload gets — host networking is a
2211    /// privileged escape hatch for the few infra workloads that must bind a
2212    /// host port so an on-host ingress (e.g. a Cloudflare tunnel reaching
2213    /// `127.0.0.1:<port>`) can route to them without CNI/bridge plumbing.
2214    ///
2215    /// The backend (kamaji) is responsible for **guarding** this: host
2216    /// networking is only honoured for `tier == "infra"` workloads; a
2217    /// non-infra workload that sets the annotation is rejected at deploy. See
2218    /// `validate_spec_for_constable`.
2219    pub fn wants_host_network(&self) -> bool {
2220        self.annotations
2221            .get(HOST_NETWORK_ANNOTATION)
2222            .map(|v| v == HOST_NETWORK_VALUE)
2223            .unwrap_or(false)
2224    }
2225
2226    /// Resolve the lifecycle archetype (R572-F1 / W244): the explicit
2227    /// [`Self::archetype`] if set, otherwise the pre-R572 inference from
2228    /// `volumes`/`restart_policy` this field replaces.
2229    ///
2230    /// This is the one seam callers should use to ask "can I kill and
2231    /// reschedule this?" — it is intentionally the *only* place that
2232    /// implements the fallback, so behavior for pre-existing specs (no
2233    /// `archetype` on disk) is identical to what it was before this field
2234    /// existed. Consumers (reconciler R572-F4, scheduler R572-F5) branch on
2235    /// the return value; this crate does not itself change any reconciler or
2236    /// scheduler behavior.
2237    pub fn effective_archetype(&self) -> LifecycleArchetype {
2238        self.archetype
2239            .unwrap_or_else(|| LifecycleArchetype::infer(&self.volumes, &self.restart_policy))
2240    }
2241
2242    /// Fully-qualified mesh identity `<tenant>/<namespace>/<name>` (W206 /
2243    /// R558-F3), where `<name>` is this workload's [`MeshExpose::identity`].
2244    ///
2245    /// Within a tenant, workloads still address each other by the short
2246    /// identity (namespace disambiguates only on collision); the FQN is what
2247    /// makes the identity unambiguous across tenants and is exactly what a
2248    /// [`MeshPeer::CrossTenant`] grant names.
2249    pub fn fq_mesh_identity(&self) -> String {
2250        format!(
2251            "{}/{}/{}",
2252            self.tenant.0, self.namespace.0, self.expose.mesh.identity.0
2253        )
2254    }
2255
2256    /// The taint this workload requires its node to carry, if any (R594-F2 /
2257    /// W267 sovereign public ingress).
2258    ///
2259    /// Opt-in via `annotations["yah.placement.requires-taint"] = "<taint
2260    /// name>"` (see [`REQUIRES_TAINT_ANNOTATION`]) — same annotation-based,
2261    /// zero-blast-radius shape as [`Self::wants_host_network`], chosen so
2262    /// declaring this requirement does not force a struct-literal edit at
2263    /// every existing `WorkloadSpec { .. }` construction site the way a new
2264    /// plain field would (see R572-F1's handoff: ~26 sites for one field).
2265    ///
2266    /// Both halves have since landed: `MachineConfig.taints` (R572-F3) and the
2267    /// scheduler's affinity check in `cloud::config::RequiredSpec::matches`
2268    /// (R572-F5), which requires the key in the node's `taints` **or**
2269    /// `mesh_tags`.
2270    ///
2271    /// A key named here is one of only two ways a node taint can influence
2272    /// placement — the other is the `no-<archetype>` repulsion form. W305/
2273    /// R742-T4 makes `yah cloud validate` reject any node taint that is
2274    /// neither, so a new affinity key must be added to
2275    /// `cloud::config::AFFINITY_TAINT_KEYS` alongside the workload that
2276    /// requires it.
2277    ///
2278    /// The public-ingress appliance (W267) is the first user: a
2279    /// `kind = "container"` workload with `archetype =
2280    /// Some(LifecycleArchetype::Appliance)` and
2281    /// `requires_taint() == Some(PUBLIC_IP_TAINT)`, so yubaba may one day
2282    /// place it only on machines carrying the `"public-ip"` taint and kamaji
2283    /// supervises it like any other container (no new `Workload` variant —
2284    /// see [`Workload::Container`]'s doc comment).
2285    pub fn requires_taint(&self) -> Option<&str> {
2286        self.annotations
2287            .get(REQUIRES_TAINT_ANNOTATION)
2288            .map(String::as_str)
2289    }
2290
2291    /// The memory (MiB) a scheduler must find on a node before placing this
2292    /// workload — its **request**, as distinct from [`ResourceLimits::memory_mb`],
2293    /// which is a **ceiling** the backend turns into a cgroup `memory.max`.
2294    ///
2295    /// Opt-in via `annotations["yah.placement.memory-request-mb"]` (see
2296    /// [`MEMORY_REQUEST_ANNOTATION`]); absent or unparseable falls back to
2297    /// `resources.memory_mb`, so every spec that does not set it is admitted
2298    /// exactly as it was before this accessor existed.
2299    ///
2300    /// # Why the two numbers must not be the same one
2301    ///
2302    /// A limit answers "kill it past here"; a request answers "don't start it
2303    /// somewhere smaller than here". Generous is the safe direction for the
2304    /// first and the unschedulable direction for the second, so one field
2305    /// serving both makes a deliberately-roomy ceiling into an admission floor.
2306    ///
2307    /// That is not hypothetical: [`WorkloadSpec::for_forge`] sets a 32 GiB
2308    /// ceiling explicitly reasoned as "above physical RAM on smaller
2309    /// build-workers ⇒ effectively unlimited there" (R590-B10), and
2310    /// `CloudConfig::admit_workload` fed that same 32768 in as the R572-F5
2311    /// capacity floor. Every build-worker under 32 GiB — the three 8 GiB Pi-5s
2312    /// and the 16 GiB us-west-003 — became structurally unadmittable for *any*
2313    /// offloaded qed step, leaving one 47 GiB node as the fleet's only legal
2314    /// target for remote CI. This is R590-B10's own recorded follow-up
2315    /// ("thread a per-step memory request … instead of a blanket forge
2316    /// default"), reduced to the seam that closes the bug.
2317    ///
2318    /// An annotation rather than a new `ResourceLimits` field on purpose:
2319    /// `WorkloadSpec` crosses a postcard wire that is positional and
2320    /// carries no field names (R590-B3), so adding a field would break decode
2321    /// on every fleet node still running an older kamaji. `annotations` is an
2322    /// existing map — an extra key rides it safely, and admission already
2323    /// reads placement inputs from exactly there
2324    /// ([`Self::requires_taint`], the R594 node-selector).
2325    pub fn memory_request_mb(&self) -> u32 {
2326        self.annotations
2327            .get(MEMORY_REQUEST_ANNOTATION)
2328            .and_then(|v| v.trim().parse::<u32>().ok())
2329            .unwrap_or(self.resources.memory_mb)
2330    }
2331
2332    /// Whether this workload must be run by kamaji's **native** (fork+exec)
2333    /// backend on the node's own userland, rather than by a container backend
2334    /// (R577-T1 / W254).
2335    ///
2336    /// Opt-in via `annotations["yah.exec"] == "native"` (see
2337    /// [`NATIVE_EXEC_ANNOTATION`] / [`NATIVE_EXEC_VALUE`]) — the same
2338    /// annotation-shaped, zero-blast-radius marker as
2339    /// [`Self::wants_host_network`] and [`Self::requires_taint`], chosen over
2340    /// a new plain field for the reason R572-F1 recorded: a field forces a
2341    /// struct-literal edit at every existing construction site and an
2342    /// exhaustive-match update in `kamaji-proto`'s codec, and this marker
2343    /// needs neither.
2344    ///
2345    /// # Why an annotation and not a runtime enum on the wire
2346    ///
2347    /// The remote-execution wire already carries exactly one workload shape —
2348    /// `Workload::Container(WorkloadSpec)` — and every layer between the
2349    /// dispatcher and the node (yubaba admission, mesh assignment, log
2350    /// ingest, produced-file retrieval, teardown) is written against it. A
2351    /// Darwin build differs from a Linux build in *one* respect: there is no
2352    /// container that can host it, because you cannot containerize the Darwin
2353    /// kernel. Marking that one difference keeps the rest of the path shared
2354    /// instead of growing a parallel `exec_native` RPC that would have to
2355    /// re-implement all of it.
2356    ///
2357    /// `image` stays populated for a native workload and is **identity
2358    /// metadata only** — nothing is pulled; the native backend resolves argv
2359    /// from `entrypoint` + `command` (container semantics) and execs it on
2360    /// the host.
2361    pub fn wants_native_exec(&self) -> bool {
2362        self.annotations
2363            .get(NATIVE_EXEC_ANNOTATION)
2364            .map(|v| v == NATIVE_EXEC_VALUE)
2365            .unwrap_or(false)
2366    }
2367
2368    /// Whether this workload must be run by kamaji's **microVM** backend —
2369    /// booted in its own KVM guest with its own kernel, rather than sharing the
2370    /// host kernel with every other workload on the node (R605-F8 / W325 §5).
2371    ///
2372    /// Opt-in via `annotations["yah.exec"] == "microvm"` (see
2373    /// [`NATIVE_EXEC_ANNOTATION`] / [`MICROVM_EXEC_VALUE`]).
2374    ///
2375    /// # Why the *same* key as native exec, not a new one
2376    ///
2377    /// W325's Shape A calls this "a sibling branch on a new annotation value",
2378    /// and the value — not the key — is the whole point. `yah.exec` names the
2379    /// execution substrate, and a workload has exactly one:
2380    ///
2381    /// | `yah.exec` | substrate | kernel | isolation |
2382    /// |---|---|---|---|
2383    /// | *(absent)* | container backend | host's | namespaces + cgroup |
2384    /// | `native` | fork+exec on the host | host's | **none** |
2385    /// | `microvm` | KVM guest | **its own** | hardware |
2386    ///
2387    /// A second key (`yah.isolation = microvm`, say) would make
2388    /// `yah.exec = native` + `yah.isolation = microvm` *expressible*, and
2389    /// therefore something a dispatcher could emit and a backend would have to
2390    /// refuse — exactly the refusal `validate_native_exec_spec` already has to
2391    /// carry for the `yah.sandbox` pair, and for the same avoidable reason. A
2392    /// map key holds one value, so on this key the three substrates are
2393    /// mutually exclusive *by construction*: there is no spec on which both
2394    /// this and [`Self::wants_native_exec`] return `true`, and
2395    /// `exec_substrate_markers_are_mutually_exclusive_by_construction` pins
2396    /// that.
2397    ///
2398    /// # What the marker does and does not promise
2399    ///
2400    /// Like every marker on this struct it is **inert metadata** — it declares
2401    /// intent and nothing more. Whether a node can honour it is a node
2402    /// capability question (`/dev/kvm`, a guest kernel, a rootfs; see W325 §4),
2403    /// and a node whose kamaji has no microVM backend configured **refuses**
2404    /// the deploy rather than falling back to a container. That refusal is
2405    /// deliberate and mirrors R577-T1's: a caller asking for microVM isolation
2406    /// is asking for the one property a container cannot provide, so silently
2407    /// downgrading it would return success while delivering the thing the
2408    /// caller specifically declined.
2409    ///
2410    /// `image` is identity metadata only, as it is for native exec — nothing is
2411    /// pulled. The guest's root filesystem comes from the node's configured
2412    /// rootfs image, and argv is resolved from `entrypoint` + `command` with
2413    /// container semantics, so one spec shape drives all three substrates.
2414    pub fn wants_microvm(&self) -> bool {
2415        self.annotations
2416            .get(NATIVE_EXEC_ANNOTATION)
2417            .map(|v| v == MICROVM_EXEC_VALUE)
2418            .unwrap_or(false)
2419    }
2420
2421    /// Whether this workload builds its **own unprivileged container sandbox**
2422    /// inside the one the backend gives it, and therefore needs the two
2423    /// capabilities plus the `no_new_privs` relaxation that setting up a
2424    /// user namespace requires (R636-B2).
2425    ///
2426    /// Opt-in via `annotations["yah.sandbox"] == "nested"` (see
2427    /// [`NESTED_SANDBOX_ANNOTATION`] / [`NESTED_SANDBOX_VALUE`]) — the same
2428    /// annotation-shaped, zero-blast-radius marker as
2429    /// [`Self::wants_host_network`] and [`Self::wants_native_exec`].
2430    ///
2431    /// # What it actually grants, and why exactly that
2432    ///
2433    /// Rootless BuildKit (the only user today: remote `build-image` steps
2434    /// dispatch `moby/buildkit:*-rootless`) boots through `rootlesskit`, which
2435    /// must map a range of sub-uids into a fresh user namespace. It does that
2436    /// by exec'ing the **setuid-root** helpers `newuidmap` / `newgidmap`, so
2437    /// it needs `CAP_SETUID` + `CAP_SETGID` in the bounding set *and*
2438    /// `noNewPrivileges = false` (with `no_new_privs` on, the kernel silently
2439    /// strips the setuid bit and the helper fails with "Could not set caps").
2440    ///
2441    /// Each of those three was measured on us-west-002 to be **individually
2442    /// necessary** — dropping any one of them puts `rootlesskit` back to
2443    /// failing before the first layer:
2444    ///
2445    /// | grant | `rootlesskit` result |
2446    /// |---|---|
2447    /// | baseline (`CAP_NET_BIND_SERVICE` only, `nnp` on) | `fork/exec /usr/bin/newuidmap: operation not permitted` |
2448    /// | `+CAP_SETUID` only, `nnp` off | `fork/exec /usr/bin/newgidmap: operation not permitted` |
2449    /// | `+CAP_SETUID +CAP_SETGID`, `nnp` **on** | `newuidmap: Could not set caps` |
2450    /// | `+CAP_SETUID +CAP_SETGID`, `nnp` off | starts; build runs to completion |
2451    ///
2452    /// It is deliberately *not* `CAP_SYS_ADMIN`: a non-rootless buildkitd
2453    /// would need that instead, which is a far wider grant. Emptying
2454    /// `/etc/subuid` to force `rootlesskit`'s single-mapping path does not
2455    /// avoid the helpers either — it just fails earlier with "No subuid
2456    /// ranges found".
2457    ///
2458    /// **The backend guards this.** Like host networking, it is honoured only
2459    /// for `tier == "infra"` workloads; a non-infra workload that sets the
2460    /// annotation is rejected at deploy. Every other workload keeps the
2461    /// `CAP_NET_BIND_SERVICE`-only, `no_new_privs` baseline.
2462    ///
2463    /// # Mutually exclusive with [`Self::wants_native_exec`]
2464    ///
2465    /// This grant is defined in terms of an **OCI process spec** — a
2466    /// capability set and a `noNewPrivileges` bit. A native (fork+exec)
2467    /// workload has no OCI spec, so there is nothing to apply it to; kamaji
2468    /// refuses a spec carrying both markers rather than accepting a request
2469    /// for widened privileges and silently dropping it (R577-T1 owns that
2470    /// refusal). The two are independent *annotations* — neither implies the
2471    /// other, which is what
2472    /// `nested_sandbox_marker_is_independent_of_the_other_markers` pins — but
2473    /// they are not a legal *pair*.
2474    ///
2475    /// If a future runtime does have a sandbox worth widening (a MacVM under
2476    /// W254, say), give it its own annotation rather than relaxing that
2477    /// refusal. The grant this marker names is `CAP_SETUID` + `CAP_SETGID` +
2478    /// `no_new_privs` off and nothing else; letting it mean a different
2479    /// privilege set per backend would make "what does `yah.sandbox=nested`
2480    /// grant?" unanswerable without knowing which backend received it, which
2481    /// is precisely what a security-relevant marker must not be.
2482    pub fn wants_nested_sandbox(&self) -> bool {
2483        self.annotations
2484            .get(NESTED_SANDBOX_ANNOTATION)
2485            .map(|v| v == NESTED_SANDBOX_VALUE)
2486            .unwrap_or(false)
2487    }
2488}
2489
2490/// Annotation key requesting a workload share the host network namespace.
2491/// See [`WorkloadSpec::wants_host_network`].
2492pub const HOST_NETWORK_ANNOTATION: &str = "yah.network";
2493
2494/// Annotation value (for [`HOST_NETWORK_ANNOTATION`]) selecting host
2495/// networking. Any other value leaves the workload in an isolated netns.
2496pub const HOST_NETWORK_VALUE: &str = "host";
2497
2498/// Annotation key declaring that a workload must land only on a node
2499/// carrying a specific taint. See [`WorkloadSpec::requires_taint`].
2500pub const REQUIRES_TAINT_ANNOTATION: &str = "yah.placement.requires-taint";
2501
2502/// Annotation key carrying a workload's memory **request** in MiB — what a
2503/// scheduler must find free on a node — separate from the `memory_mb`
2504/// **ceiling** the backend enforces as a cgroup limit. See
2505/// [`WorkloadSpec::memory_request_mb`].
2506pub const MEMORY_REQUEST_ANNOTATION: &str = "yah.placement.memory-request-mb";
2507
2508/// The memory request [`WorkloadSpec::for_forge`] declares (MiB).
2509///
2510/// A forge run is a build, and a build's *ceiling* is deliberately roomy
2511/// (`FORGE_MEMORY_LIMIT_MB`); this is the much smaller floor a node must have
2512/// free to be a legal target for one. 2 GiB is what the heaviest forge shape
2513/// in the tree already asks for by hand — `velveteen_exec::remote`'s buildkit
2514/// image-build step overrides `resources.memory_mb` to exactly this — so it is
2515/// a measured number rather than a guess, and it keeps the fleet's 8 GiB
2516/// build-workers schedulable.
2517pub const FORGE_MEMORY_REQUEST_MB: u32 = 2048;
2518
2519/// The cgroup memory ceiling [`WorkloadSpec::for_forge`] sets (MiB).
2520///
2521/// Bounded rather than unlimited so a runaway build cannot take the host
2522/// down, and large enough for the V8 build's >12 GB peak (R590-B10). It is
2523/// **not** a placement input — see [`FORGE_MEMORY_REQUEST_MB`].
2524pub const FORGE_MEMORY_LIMIT_MB: u32 = 32768;
2525
2526/// Taint name (for [`REQUIRES_TAINT_ANNOTATION`]) identifying machines with
2527/// a publicly-routable IP — the W267 sovereign-ingress placement
2528/// requirement. `MachineConfig.taints` (R572-F3) is the matching node-side
2529/// field and `RequiredSpec::matches` (R572-F5) is the consumer, so this is a
2530/// live key on both sides: a node may carry it, and the cloudflared/passway
2531/// ingress specs require it.
2532pub const PUBLIC_IP_TAINT: &str = "public-ip";
2533
2534/// Annotation key selecting which **execution substrate** kamaji runs a
2535/// workload on. Absent (or unrecognised) means a container backend; see
2536/// [`NATIVE_EXEC_VALUE`] and [`MICROVM_EXEC_VALUE`] for the two opt-outs.
2537///
2538/// The name is historical — R577-T1 introduced it for native exec alone — but
2539/// the key has always been the substrate selector, and R605-F8 added the
2540/// second alternative rather than a second key. See
2541/// [`WorkloadSpec::wants_microvm`] for why one key matters.
2542pub const NATIVE_EXEC_ANNOTATION: &str = "yah.exec";
2543
2544/// Annotation value (for [`NATIVE_EXEC_ANNOTATION`]) selecting native
2545/// host execution. Any other value leaves the workload on a container
2546/// backend.
2547pub const NATIVE_EXEC_VALUE: &str = "native";
2548
2549/// Annotation value (for [`NATIVE_EXEC_ANNOTATION`]) selecting a **microVM**:
2550/// the workload boots in its own KVM guest rather than sharing the host
2551/// kernel. See [`WorkloadSpec::wants_microvm`].
2552pub const MICROVM_EXEC_VALUE: &str = "microvm";
2553
2554/// Annotation key requesting the capabilities a workload needs to stand up an
2555/// unprivileged container sandbox of its own.
2556/// See [`WorkloadSpec::wants_nested_sandbox`].
2557pub const NESTED_SANDBOX_ANNOTATION: &str = "yah.sandbox";
2558
2559/// Annotation value (for [`NESTED_SANDBOX_ANNOTATION`]) requesting the
2560/// nested-sandbox grant (`CAP_SETUID` + `CAP_SETGID`, `no_new_privs` off).
2561/// Any other value leaves the workload on the baseline sandbox.
2562pub const NESTED_SANDBOX_VALUE: &str = "nested";
2563
2564// ── ImageRef ─────────────────────────────────────────────────────────────────
2565
2566/// Container image reference identifying a specific image to pull.
2567///
2568/// **Digest is required.** Every executable image reference in the workspace
2569/// is content-addressed by `sha256:<hex>`. The `tag` is preserved as a
2570/// human-readable identifier but is not the source of truth — registries
2571/// return mutable `tag → digest` mappings and we don't trust them for
2572/// reproducibility. R438-T3 tightened `digest: Option<String> → String` to
2573/// make unpinned-image bugs impossible by construction.
2574///
2575/// **Two deserialize shapes.** The struct form
2576/// (`registry`/`repository`/`tag`/`digest` fields) is the on-disk envelope.
2577/// A **string form** (`image = "ghcr.io/foo/bar:v1@sha256:<hex>"`) is also
2578/// accepted and is the shape W164 transform recipes (R438-T4) and W165
2579/// `BuildMode::InContainer` (R438-T6) use. Both shapes go through a single
2580/// parser ([`compose_import::parse_pinned_image_ref`]) that rejects
2581/// bare-tag references at serde-deserialize.
2582#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)]
2583#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2584pub struct ImageRef {
2585    /// Registry hostname, e.g. `"ghcr.io"` or `"localhost:5000"`.
2586    pub registry: String,
2587
2588    /// Repository path, e.g. `"noisetable/api"`.
2589    pub repository: String,
2590
2591    /// Tag, e.g. `"v1.4.2"` or `"latest"`. Informational — the digest is
2592    /// the source of truth for image identity.
2593    pub tag: String,
2594
2595    /// Content-addressed pinned identity, e.g. `"sha256:abc..."`. Required.
2596    pub digest: String,
2597}
2598
2599impl<'de> Deserialize<'de> for ImageRef {
2600    fn deserialize<D>(de: D) -> Result<Self, D::Error>
2601    where
2602        D: serde::Deserializer<'de>,
2603    {
2604        #[derive(Deserialize)]
2605        struct Fields {
2606            registry: String,
2607            repository: String,
2608            tag: String,
2609            digest: String,
2610        }
2611
2612        // The string-or-struct `untagged` probe requires `deserialize_any`,
2613        // which only self-describing formats support. Postcard — the binary
2614        // wire behind the kamaji UDS — returns `WontImplement` for it, so a
2615        // `Workload::Container(WorkloadSpec)` carrying a nested `ImageRef`
2616        // failed to decode and every container deploy 500'd (R590-B3).
2617        //
2618        // The string form is purely an authoring convenience in human-readable
2619        // configs (`image = "ghcr.io/…@sha256:…"` in recipe/workload TOML and
2620        // JSON); the binary wire only ever carries the derived struct form
2621        // (Serialize is a plain struct derive). So branch on the format: text
2622        // keeps the string-or-struct convenience via `untagged`; binary decodes
2623        // the plain positional struct with no `deserialize_any`.
2624        if de.is_human_readable() {
2625            #[derive(Deserialize)]
2626            #[serde(untagged)]
2627            enum Repr {
2628                // Order matters for `untagged`: try the string form first so
2629                // explicit strings don't get coerced into a struct error.
2630                Pinned(String),
2631                Struct(Fields),
2632            }
2633
2634            match Repr::deserialize(de)? {
2635                Repr::Pinned(s) => {
2636                    compose_import::parse_pinned_image_ref(&s).map_err(serde::de::Error::custom)
2637                }
2638                Repr::Struct(f) => Ok(ImageRef {
2639                    registry: f.registry,
2640                    repository: f.repository,
2641                    tag: f.tag,
2642                    digest: f.digest,
2643                }),
2644            }
2645        } else {
2646            let f = Fields::deserialize(de)?;
2647            Ok(ImageRef {
2648                registry: f.registry,
2649                repository: f.repository,
2650                tag: f.tag,
2651                digest: f.digest,
2652            })
2653        }
2654    }
2655}
2656
2657// ── testing helpers ───────────────────────────────────────────────────────────
2658
2659/// Fixture helpers for test code that needs to construct types whose schemas
2660/// would otherwise demand operator-pinned values (digests, hashes). Doc-hidden
2661/// to discourage misuse from non-test code — production paths must source
2662/// digests from registry resolution or compile-time injection.
2663#[doc(hidden)]
2664pub mod testing {
2665    /// Fixed valid-format sha256 digest for test fixtures. All-zeros marker
2666    /// is impossible for any real image, so a leaked test fixture in a
2667    /// production code-path surfaces obviously.
2668    ///
2669    /// Aliases [`super::ImageRef::UNPINNED_DIGEST`] — the two are deliberately
2670    /// the same value: the fixture sentinel and the production "unpinned"
2671    /// marker must agree so [`super::ImageRef::pull_ref`]'s tag-fallback fires
2672    /// on exactly the digest `catalog_image` writes.
2673    pub const TEST_DIGEST: &str = super::ImageRef::UNPINNED_DIGEST;
2674
2675    /// Owned `String` form of [`TEST_DIGEST`] for fixture constructors.
2676    pub fn test_digest() -> String {
2677        TEST_DIGEST.to_string()
2678    }
2679}
2680
2681// ── EnvVar ────────────────────────────────────────────────────────────────────
2682
2683/// A single environment variable injected into the container.
2684#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
2685#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2686pub struct EnvVar {
2687    /// Variable name, conventionally `SCREAMING_SNAKE_CASE`.
2688    pub name: String,
2689
2690    /// Value source.
2691    pub value: EnvValue,
2692}
2693
2694/// Value source for an environment variable.
2695#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
2696#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2697#[serde(rename_all = "snake_case")]
2698pub enum EnvValue {
2699    /// Static string baked into the spec.
2700    Literal { value: String },
2701
2702    /// Resolved from a yubaba secret at deploy time; the secret value never
2703    /// appears in the spec JSON.
2704    FromSecret { secret: String, key: String },
2705
2706    /// Resolved from another workload's mesh address at deploy time by yubaba.
2707    /// Lets workloads reference each other symbolically without IP pinning.
2708    FromMesh { ident: MeshIdent, kind: MeshLookup },
2709}
2710
2711/// Which aspect of a mesh peer's address to inject.
2712#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
2713#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2714#[serde(rename_all = "snake_case")]
2715pub enum MeshLookup {
2716    /// Full URL, e.g. `"http://noisetable-db.pdx:5432"`.
2717    Url,
2718    /// Hostname only, e.g. `"noisetable-db.pdx"`.
2719    Host,
2720    /// Port only, e.g. `"5432"`.
2721    Port,
2722}
2723
2724// ── Secrets ───────────────────────────────────────────────────────────────────
2725
2726/// A secret value mounted into the container as an env var or file.
2727///
2728/// The secret value never appears in the spec JSON — only the reference.
2729/// Yubaba audits secret access per workload from these references.
2730#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
2731#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2732pub struct SecretMount {
2733    /// Where yubaba reads the secret value from.
2734    pub source: SecretRef,
2735
2736    /// How the secret is surfaced inside the container.
2737    pub target: SecretTarget,
2738}
2739
2740/// Where yubaba resolves the secret value from.
2741#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
2742#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2743#[serde(rename_all = "snake_case")]
2744pub enum SecretRef {
2745    /// Per-machine yubaba secret store at `/var/lib/yah/yubaba/secrets/`.
2746    LocalFile { path: PathBuf },
2747
2748    /// Raft-replicated cluster secret spanning all machines (planned; not in
2749    /// V1 deployment). Sketch preserved for wire compatibility.
2750    Cluster { name: String },
2751}
2752
2753/// How the secret is surfaced inside the container.
2754#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
2755#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2756#[serde(rename_all = "snake_case")]
2757pub enum SecretTarget {
2758    /// Injected as an environment variable. Value never appears in spec JSON.
2759    /// Prefer `File` — env vars leak through subprocess env and log dumps.
2760    EnvVar { name: String },
2761
2762    /// Mounted as a file inside the container at `path` with `mode` (octal).
2763    File { path: PathBuf, mode: u32 },
2764}
2765
2766// ── Volumes ───────────────────────────────────────────────────────────────────
2767
2768/// A volume mount inside the container.
2769#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
2770#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2771pub struct VolumeMount {
2772    /// Backing volume source.
2773    pub source: VolumeSource,
2774
2775    /// Absolute path inside the container.
2776    pub target: PathBuf,
2777
2778    /// Whether the container sees the volume as read-only.
2779    pub read_only: bool,
2780}
2781
2782/// Backing source for a volume mount.
2783#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
2784#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2785#[serde(rename_all = "snake_case")]
2786pub enum VolumeSource {
2787    /// Yubaba-managed named volume; created on first use.
2788    Named { name: String },
2789
2790    /// Operator-managed host path. Yubaba rejects bind mounts unless
2791    /// `WorkloadSpec.tier == "infra"`; shape validation enforces this.
2792    Bind { host_path: PathBuf },
2793
2794    /// In-memory tmpfs; discarded on container stop. `size_mb` caps space
2795    /// consumed by the writable layer.
2796    Tmpfs { size_mb: u32 },
2797}
2798
2799// ── Durable forge produced-artifact convention (R603-T5) ──────────────────────
2800
2801/// Convention for a remote forge step's durable produced artifacts.
2802///
2803/// A remote build (e.g. the rusty_v8 musl build on a build-worker) writes its
2804/// output tarball to a path *inside* the container. The container's rootfs is
2805/// destroyed when kamaji reaps the EXITED container — so if the camp daemon is
2806/// down when the build finishes, the artifact is gone before boot-reconcile can
2807/// retrieve it (R603-T4 surfaced this as `Success`-but-`UNPUBLISHED`).
2808///
2809/// The fix (R603-T5) is a **host-persistent bind mount**: forge Subprocess
2810/// workloads mount [`HOST_ROOT`]`/<forge_id>` onto [`CONTAINER_DIR`], so a
2811/// build that writes its `produces` under `/yah/produced` lands the bytes on
2812/// the worker's host filesystem. yubaba then reads them back from the host path
2813/// ([`host_path`]) — which outlives container reaping — instead of the
2814/// unreachable container rootfs.
2815///
2816/// The container-side path and the host root are a shared convention between
2817/// three crates: the qed `build_workload_spec` that adds the mount, kamaji that
2818/// binds it, and the yubaba handler that reads + reaps it. Keeping it here (the
2819/// crate all three already depend on) is the single source of truth.
2820pub mod forge_produced {
2821    use std::path::{Path, PathBuf};
2822
2823    /// Conventional container-side directory a remote forge step writes its
2824    /// durable produced artifacts to. Bind-mounted onto a host-persistent dir.
2825    pub const CONTAINER_DIR: &str = "/yah/produced";
2826
2827    /// Host root under which each forge's durable produced dir lives, one
2828    /// subdir per run: `<HOST_ROOT>/<forge_id>/`. yubaba owns this directory —
2829    /// it creates the per-forge subdir at deploy, serves reads from it, and
2830    /// reaps it on teardown / TTL sweep.
2831    pub const HOST_ROOT: &str = "/var/lib/yah/qed/produced";
2832
2833    /// Forge mesh idents are `forge.<id>` (see [`WorkloadSpec::for_forge`]).
2834    /// Extract the bare `<id>`, or `None` for a non-forge ident.
2835    ///
2836    /// [`WorkloadSpec::for_forge`]: super::WorkloadSpec::for_forge
2837    pub fn forge_id_from_ident(ident: &str) -> Option<&str> {
2838        ident.strip_prefix("forge.")
2839    }
2840
2841    /// The host-persistent produced directory for one forge run.
2842    pub fn host_dir(forge_id: &str) -> PathBuf {
2843        PathBuf::from(HOST_ROOT).join(forge_id)
2844    }
2845
2846    /// Translate a container-side produced path to its durable host path for a
2847    /// given forge run. Returns `None` when `container_path` is not under
2848    /// [`CONTAINER_DIR`] (the caller then knows the artifact was not written to
2849    /// the durable location and won't survive reaping), or when the relative
2850    /// path contains a `..` component (a traversal attempt that could escape the
2851    /// per-forge dir — the reader must never serve a file outside it).
2852    pub fn host_path(forge_id: &str, container_path: &Path) -> Option<PathBuf> {
2853        let rel = container_path.strip_prefix(CONTAINER_DIR).ok()?;
2854        if rel
2855            .components()
2856            .any(|c| matches!(c, std::path::Component::ParentDir))
2857        {
2858            return None;
2859        }
2860        Some(host_dir(forge_id).join(rel))
2861    }
2862
2863    /// The durable produced-dir bind mount for a forge run: host
2864    /// `<HOST_ROOT>/<forge_id>` → container [`CONTAINER_DIR`], writable.
2865    pub fn durable_mount(forge_id: &str) -> super::VolumeMount {
2866        super::VolumeMount {
2867            source: super::VolumeSource::Bind {
2868                host_path: host_dir(forge_id),
2869            },
2870            target: PathBuf::from(CONTAINER_DIR),
2871            read_only: false,
2872        }
2873    }
2874
2875    /// True when `path` is (or is under) the conventional durable produced dir
2876    /// — the guard qed uses to enforce that declared `produces` land somewhere
2877    /// reap-durable.
2878    pub fn is_durable_path(path: &Path) -> bool {
2879        path.starts_with(CONTAINER_DIR)
2880    }
2881}
2882
2883// ── Forge host-state root (R636-B1) ───────────────────────────────────────────
2884
2885/// The one host directory tree a QED forge step's bind mounts may live under.
2886///
2887/// # Why this is a named root rather than a list of paths
2888///
2889/// runc refuses a bind whose source is missing, and the OCI mapper never
2890/// mkdirs one — so *something* has to create each host dir before deploy.
2891/// yubaba does, but only for paths it recognizes, and "recognizes" was
2892/// originally a hardcoded match on the produced dir. Every new forge mount then
2893/// re-learned the lesson the expensive way, on a real box, minutes into a
2894/// build: R603-B6 for `produced/`, then R636-B1 for `build-out/`, each
2895/// surfacing as the same opaque `failed to fulfil mount request: … no such file
2896/// or directory` from deep inside containerd.
2897///
2898/// Naming the *root* makes the rule checkable instead of enumerable: yubaba
2899/// creates any forge bind under [`HOST_ROOT`], and `yubaba.service` grants the
2900/// root once via `StateDirectory=yah/qed`. A third mount needs no new code and
2901/// no unit-file edit — it only has to live here.
2902///
2903/// The prefix bound is load-bearing in the other direction too: it is what
2904/// keeps a workload spec from asking yubaba to mkdir an arbitrary host path.
2905pub mod forge_state {
2906    use std::path::Path;
2907
2908    /// Root of the forge's host-persistent state. Both
2909    /// [`super::forge_produced::HOST_ROOT`] and [`BUILD_OUT_DIR`] are under it.
2910    pub const HOST_ROOT: &str = "/var/lib/yah/qed";
2911
2912    /// Host directory a `build-image` step's OCI archive is written to, bound
2913    /// at `/yah/build/out` in the BuildKit container. Shared (rather than
2914    /// per-forge like `produced/`) because the archive is named after the image
2915    /// tag, which is already unique per build.
2916    pub const BUILD_OUT_DIR: &str = "/var/lib/yah/qed/build-out";
2917
2918    /// Whether yubaba may create `host_path` on behalf of a forge workload.
2919    ///
2920    /// Rejects anything outside [`HOST_ROOT`], and anything with a `..`
2921    /// component — `/var/lib/yah/qed/../../../etc` starts with the root as a
2922    /// string and is nowhere near it as a path.
2923    pub fn is_forge_state_path(host_path: &Path) -> bool {
2924        !host_path
2925            .components()
2926            .any(|c| matches!(c, std::path::Component::ParentDir))
2927            && host_path.starts_with(HOST_ROOT)
2928    }
2929}
2930
2931// ── Materialized-secret path contract (R555-F5) ───────────────────────────────
2932
2933/// Where yubaba writes a `File`-target secret it has resolved, and how the host
2934/// path is derived from the container path.
2935///
2936/// # Why the derivation lives here and not in yubaba
2937///
2938/// yubaba resolves a [`SecretMount`] and rewrites it into a read-only [`Bind`]
2939/// volume before the spec reaches the backend, so the spec kamaji admits is not
2940/// the spec the dispatcher signed: one mount has become one bind. Admission has
2941/// to be able to recognise that rewrite — otherwise a signed recipe carrying a
2942/// secret is refused by [`admission::AdmissionGrant::covers`]'s bind rule, which
2943/// only knows about [`forge_state::HOST_ROOT`], with a message about a forge
2944/// state root that has nothing to do with what happened.
2945///
2946/// Recognising it means recomputing the host path, which means the derivation
2947/// has to be visible to both sides. It was private to yubaba's
2948/// `deploy::secret_mount`; it lives here now, and yubaba calls in. `forge_state`
2949/// is the same shape for the same reason.
2950///
2951/// [`Bind`]: VolumeSource::Bind
2952pub mod secret_mount {
2953    use std::path::{Path, PathBuf};
2954
2955    /// RAM-backed root for materialized secret files. `/run` is a tmpfs on
2956    /// systemd nodes, so decrypted PEM never touches disk. Each workload gets a
2957    /// `<root>/<ident>/` subdir, reaped on workload destroy.
2958    pub const HOST_ROOT: &str = "/run/yah/secrets";
2959
2960    /// Collapse a value into a single safe path component: every char outside
2961    /// `[A-Za-z0-9_-]` becomes `_` (dots included, so `.` / `..` can never
2962    /// traverse). Empty input maps to `_`.
2963    pub fn sanitize_component(s: &str) -> String {
2964        let mapped: String = s
2965            .chars()
2966            .map(|c| {
2967                if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
2968                    c
2969                } else {
2970                    '_'
2971                }
2972            })
2973            .collect();
2974        if mapped.is_empty() {
2975            "_".into()
2976        } else {
2977            mapped
2978        }
2979    }
2980
2981    /// Derive a collision-free host filename from a container target path: strip
2982    /// the leading `/`, keep `.` for extensions, and replace path separators (and
2983    /// any other non-`[A-Za-z0-9_.-]` char) with `_`. A target that reduces to
2984    /// nothing or a dots-only name falls back to `secret`. The result is always a
2985    /// single flat filename (no separators), so it cannot traverse out of the
2986    /// per-workload dir.
2987    pub fn host_file_name(target: &Path) -> String {
2988        let raw = target.to_string_lossy();
2989        let trimmed = raw.trim_start_matches('/');
2990        let mapped: String = trimmed
2991            .chars()
2992            .map(|c| {
2993                if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
2994                    c
2995                } else {
2996                    '_'
2997                }
2998            })
2999            .collect();
3000        if mapped.is_empty() || mapped.chars().all(|c| c == '.') {
3001            "secret".into()
3002        } else {
3003            mapped
3004        }
3005    }
3006
3007    /// The per-workload directory materialized secrets are written to.
3008    pub fn workload_dir(root: &Path, ident: &str) -> PathBuf {
3009        root.join(sanitize_component(ident))
3010    }
3011
3012    /// The host path a `File`-target secret at container path `target` is
3013    /// materialized to for workload `ident`.
3014    ///
3015    /// Deterministic in exactly those three inputs, which is what lets admission
3016    /// recompute it from the spec alone and match a bind against it.
3017    pub fn materialized_host_path(root: &Path, ident: &str, target: &Path) -> PathBuf {
3018        workload_dir(root, ident).join(host_file_name(target))
3019    }
3020}
3021
3022#[cfg(test)]
3023mod secret_mount_tests {
3024    use super::secret_mount::*;
3025    use std::path::{Path, PathBuf};
3026
3027    #[test]
3028    fn the_host_path_is_a_pure_function_of_root_ident_and_target() {
3029        let p = materialized_host_path(
3030            Path::new(HOST_ROOT),
3031            "forge.abc-123",
3032            Path::new("/etc/yah/r2.json"),
3033        );
3034        assert_eq!(
3035            p,
3036            PathBuf::from("/run/yah/secrets/forge_abc-123/etc_yah_r2.json")
3037        );
3038    }
3039
3040    /// The two collapses exist to keep a hostile ident or target from steering
3041    /// the write out of the per-workload dir. Pinned here because admission now
3042    /// depends on them being total.
3043    #[test]
3044    fn neither_component_can_traverse() {
3045        for ident in ["..", "../../etc", "a/b", ""] {
3046            let dir = workload_dir(Path::new(HOST_ROOT), ident);
3047            assert_eq!(dir.components().count(), 5, "{ident:?} escaped {dir:?}");
3048            assert!(dir.starts_with(HOST_ROOT));
3049        }
3050        for target in ["/../../etc/shadow", "..", "/", "/a/../b"] {
3051            let name = host_file_name(Path::new(target));
3052            assert!(!name.contains('/'), "{target:?} kept a separator: {name}");
3053            assert_ne!(name, "..");
3054        }
3055    }
3056}
3057
3058#[cfg(test)]
3059mod forge_state_tests {
3060    use super::forge_state::*;
3061    use std::path::Path;
3062
3063    #[test]
3064    fn both_known_forge_roots_are_under_the_state_root() {
3065        assert!(is_forge_state_path(Path::new(
3066            super::forge_produced::HOST_ROOT
3067        )));
3068        assert!(is_forge_state_path(Path::new(BUILD_OUT_DIR)));
3069        assert!(is_forge_state_path(&super::forge_produced::host_dir(
3070            "abc-123"
3071        )));
3072    }
3073
3074    /// A spec must not be able to steer yubaba's mkdir anywhere it likes —
3075    /// neither by naming an unrelated absolute path nor by climbing out with
3076    /// `..`, which a plain string prefix check would wave through.
3077    #[test]
3078    fn paths_outside_the_root_are_refused() {
3079        for bad in [
3080            "/var/lib/yah/yubaba",
3081            "/etc/systemd/system",
3082            "/var/lib/yah/qed/../../../etc",
3083            "relative/path",
3084        ] {
3085            assert!(
3086                !is_forge_state_path(Path::new(bad)),
3087                "{bad} must not be creatable by a forge spec"
3088            );
3089        }
3090    }
3091}
3092
3093// ── Resources ─────────────────────────────────────────────────────────────────
3094
3095/// Hard resource caps enforced by containerd/cgroups at runtime.
3096#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
3097#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3098pub struct ResourceLimits {
3099    /// Maximum RAM the container may allocate, in MiB. The container is OOM-
3100    /// killed if it exceeds this.
3101    ///
3102    /// A **ceiling**, not a request: setting it generously is the safe
3103    /// direction here and the unschedulable direction for placement, so
3104    /// schedulers must read [`WorkloadSpec::memory_request_mb`] instead of
3105    /// this field. (`cpu_millis` below is the opposite — a request by
3106    /// definition — which is why the two are not symmetric.)
3107    pub memory_mb: u32,
3108
3109    /// CPU **request** in millicores (k8s convention): `1000` = one full core,
3110    /// `250` = `.25 CPU`. Unlike a Docker relative weight this is an
3111    /// allocatable quantity a bin-packer can subtract from a node's budget.
3112    /// `0` means "no CPU limit". Backends that speak a relative weight derive
3113    /// it via [`ResourceLimits::cpu_shares`].
3114    pub cpu_millis: u32,
3115
3116    /// Cap on the writable layer + tmpfs footprint, in MiB.
3117    pub ephemeral_storage_mb: u32,
3118}
3119
3120impl ResourceLimits {
3121    /// The Docker/OCI relative CPU weight (`cpu.shares`, where `1024` ≈ one
3122    /// core) equivalent to this millicore request. The containerd and docker
3123    /// backends express CPU as a weight rather than a millicore request, so
3124    /// they derive it here instead of storing shares: `1000m` ⇒ `1024`.
3125    pub fn cpu_shares(&self) -> u64 {
3126        (u64::from(self.cpu_millis) * 1024) / 1000
3127    }
3128}
3129
3130// ── Healthcheck ───────────────────────────────────────────────────────────────
3131
3132/// Container health probe configuration.
3133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
3134#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3135pub struct Healthcheck {
3136    /// The probe executed to determine container health.
3137    pub probe: HealthProbe,
3138
3139    /// How often the probe runs.
3140    pub interval: Millis,
3141
3142    /// Per-probe timeout; a slow response counts as failure.
3143    pub timeout: Millis,
3144
3145    /// Time to wait after container start before the first probe. Shape
3146    /// validation warns (not errors) if this is less than
3147    /// `stop_policy.grace_period * 2`.
3148    pub initial_delay: Millis,
3149
3150    /// Number of consecutive failures before the container is marked
3151    /// `Unhealthy`.
3152    pub failure_threshold: u32,
3153}
3154
3155/// Mechanism used to check container health.
3156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
3157#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3158#[serde(rename_all = "snake_case")]
3159pub enum HealthProbe {
3160    /// HTTP GET to `path` on `port`. A 2xx (or `expect_status` if set)
3161    /// response counts as healthy.
3162    HttpGet {
3163        path: String,
3164        port: u16,
3165        #[ts(optional = nullable)]
3166        expect_status: Option<u16>,
3167    },
3168
3169    /// Run `argv` inside the container; exit-0 counts as healthy.
3170    Exec { argv: Vec<String> },
3171
3172    /// TCP connection to `port`; a successful connect counts as healthy.
3173    TcpConnect { port: u16 },
3174}
3175
3176// ── Restart / Stop ────────────────────────────────────────────────────────────
3177
3178/// What yubaba does when the container exits.
3179#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
3180#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3181#[serde(rename_all = "snake_case")]
3182pub enum RestartPolicy {
3183    /// Restart unconditionally on any exit.
3184    Always,
3185
3186    /// Restart on non-zero exit, up to `max_attempts` times with exponential
3187    /// backoff. After exhaustion, the workload is marked `Failed`.
3188    OnFailure {
3189        max_attempts: u32,
3190        backoff: BackoffPolicy,
3191    },
3192
3193    /// Do not restart. The container runs once and exits.
3194    ///
3195    /// **Forge convention.** Forge runs (R094) synthesize a `WorkloadSpec`
3196    /// using [`WorkloadSpec::for_forge`] which sets all the conventional fields
3197    /// together:
3198    ///
3199    /// - `restart_policy = Never`
3200    /// - `expose.public = None`, `expose.operator = None`
3201    /// - `expose.mesh.identity = "forge.<forge_id>"` — distinguishable from
3202    ///   persistent mirror identities at the mesh layer
3203    /// - `tier = "infra"` (or the forge-spec's effective tier)
3204    /// - `annotations["yah.forge"] = "true"` — suppresses the shape warning
3205    ///
3206    /// Using `Never` on a persistent mirror (not a forge run) means the mirror
3207    /// stays dead after any exit — a likely misconfiguration. Shape validation
3208    /// emits a soft warning unless `annotations["yah.forge"] == "true"` is
3209    /// present. See R094 forge.
3210    Never,
3211}
3212
3213/// Exponential backoff parameters for `RestartPolicy::OnFailure`.
3214#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
3215#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3216pub struct BackoffPolicy {
3217    /// Initial delay before the first restart, in milliseconds.
3218    pub initial_ms: u32,
3219
3220    /// Maximum delay between retries, in milliseconds.
3221    pub max_ms: u32,
3222
3223    /// Backoff multiplier applied to each successive delay.
3224    pub multiplier: f32,
3225}
3226
3227/// Graceful shutdown configuration for yubaba's stop sequence.
3228#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
3229#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3230pub struct StopPolicy {
3231    /// Signal number sent first, e.g. `15` (SIGTERM) or `2` (SIGINT).
3232    pub signal: i32,
3233
3234    /// Time yubaba waits after sending `signal` before issuing SIGKILL.
3235    pub grace_period: Millis,
3236}
3237
3238// ── Expose ────────────────────────────────────────────────────────────────────
3239
3240/// Network exposure configuration. The three channels are independent; any
3241/// combination is valid.
3242#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
3243#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3244pub struct ExposeSpec {
3245    /// Mesh-internal exposure. Required; every workload must have a mesh
3246    /// identity even if no other workload currently reaches it.
3247    pub mesh: MeshExpose,
3248
3249    /// Public internet exposure via a Cloudflare tunnel route. `None` means
3250    /// the workload is not internet-reachable.
3251    #[ts(optional = nullable)]
3252    pub public: Option<PublicExpose>,
3253
3254    /// Operator-facing exposure via a Tailscale ACL tag. `None` means the
3255    /// workload is not operator-reachable via Tailscale.
3256    #[ts(optional = nullable)]
3257    pub operator: Option<OperatorExpose>,
3258}
3259
3260/// A peer permitted to initiate mesh connections to a workload (W206 / R558-F3).
3261///
3262/// Cross-tenant access is **deny-by-default**: a workload accepts inter-tenant
3263/// traffic only from peers it lists explicitly as [`MeshPeer::CrossTenant`].
3264/// Same-tenant access stays tier-based ([`MeshPeer::Tier`]) — the pre-R558
3265/// model — and an `allow_from` with no `Tier` entries still admits every
3266/// same-tenant peer (the historical "empty = allow all" default).
3267///
3268/// External serde tagging keeps this postcard-safe (R590-B3): no internal tag,
3269/// no untagged, no `skip_serializing_if`.
3270#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
3271#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3272#[serde(rename_all = "snake_case")]
3273pub enum MeshPeer {
3274    /// Any **same-tenant** workload whose `tier` matches this tag. This is the
3275    /// pre-R558 `allow_from` semantics.
3276    Tier(TierTag),
3277
3278    /// A specific workload in **another tenant**, addressed by its fully
3279    /// qualified mesh identity `<tenant>/<namespace>/<name>`. There is no
3280    /// cross-tenant tier wildcard — each cross-tenant peer is granted
3281    /// individually, so a shared fleet stays isolated unless an operator opts
3282    /// in here.
3283    CrossTenant {
3284        tenant: TenantId,
3285        namespace: NamespaceId,
3286        /// Peer's mesh identity (its [`MeshExpose::identity`]).
3287        name: MeshIdent,
3288    },
3289}
3290
3291/// Mesh-internal port exposure and peer access control.
3292#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
3293#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3294pub struct MeshExpose {
3295    /// DNS-segment mesh identity for this workload. Must be unique in the
3296    /// cluster. Regex: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`, length ≤ 63.
3297    pub identity: MeshIdent,
3298
3299    /// Container-side ports this workload listens on. Other workloads reach
3300    /// it at `<identity>:<port>` on the mesh.
3301    pub ports: Vec<u16>,
3302
3303    /// Peers permitted to initiate connections to this workload on the mesh
3304    /// (W206 / R558-F3). Same-tenant tier rules and explicit cross-tenant
3305    /// grants share this one list. With **no** [`MeshPeer::Tier`] entries every
3306    /// same-tenant peer is admitted (the historical "empty = allow all"
3307    /// default); cross-tenant peers are always denied unless named by a
3308    /// [`MeshPeer::CrossTenant`] entry. See [`MeshExpose::admits_peer`].
3309    #[serde(default)]
3310    pub allow_from: Vec<MeshPeer>,
3311}
3312
3313impl MeshExpose {
3314    /// Whether a peer may initiate a mesh connection to a workload whose mesh
3315    /// exposure is `self`. `own_tenant` is the tenant of the workload being
3316    /// protected; the remaining arguments identify the connecting peer.
3317    ///
3318    /// Deny-by-default across tenants (W206 / R558-F3):
3319    /// - **Same tenant** (`own_tenant == peer_tenant`): admitted when the
3320    ///   peer's tier matches a [`MeshPeer::Tier`] rule, or when there are no
3321    ///   `Tier` rules at all (historical "empty `allow_from` = allow all
3322    ///   same-tenant").
3323    /// - **Cross tenant**: admitted only when an explicit
3324    ///   [`MeshPeer::CrossTenant`] entry matches the peer's
3325    ///   `(tenant, namespace, name)`.
3326    pub fn admits_peer(
3327        &self,
3328        own_tenant: &TenantId,
3329        peer_tenant: &TenantId,
3330        peer_namespace: &NamespaceId,
3331        peer_name: &MeshIdent,
3332        peer_tier: &TierTag,
3333    ) -> bool {
3334        if own_tenant == peer_tenant {
3335            let mut has_tier_rule = false;
3336            for peer in &self.allow_from {
3337                if let MeshPeer::Tier(t) = peer {
3338                    has_tier_rule = true;
3339                    if t == peer_tier {
3340                        return true;
3341                    }
3342                }
3343            }
3344            // No same-tenant tier restriction declared → admit all same-tenant.
3345            !has_tier_rule
3346        } else {
3347            self.allow_from.iter().any(|peer| {
3348                matches!(
3349                    peer,
3350                    MeshPeer::CrossTenant { tenant, namespace, name }
3351                        if tenant == peer_tenant
3352                            && namespace == peer_namespace
3353                            && name == peer_name
3354                )
3355            })
3356        }
3357    }
3358}
3359
3360/// The name by which a workload is addressed **within its own tenant** (W206 /
3361/// R558-F3), given every `(namespace, identity)` pair present in that tenant.
3362///
3363/// Within a tenant, a workload is reached by its short mesh `identity` when that
3364/// identity is unique across the tenant's namespaces. When two namespaces
3365/// expose the same identity, the name is ambiguous, so both are disambiguated
3366/// by a namespace prefix — `<namespace>.<identity>` (e.g. `yah.runner` vs
3367/// `noisetable.runner`). Cross-tenant addressing always uses the full FQN
3368/// ([`WorkloadSpec::fq_mesh_identity`]) and is out of scope here.
3369pub fn intra_tenant_address(
3370    namespace: &NamespaceId,
3371    identity: &MeshIdent,
3372    tenant_workloads: &[(NamespaceId, MeshIdent)],
3373) -> String {
3374    let collides = tenant_workloads
3375        .iter()
3376        .any(|(ns, id)| id == identity && ns != namespace);
3377    if collides {
3378        format!("{}.{}", namespace.0, identity.0)
3379    } else {
3380        identity.0.clone()
3381    }
3382}
3383
3384/// Public internet exposure via a Cloudflare tunnel route.
3385#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
3386#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3387pub struct PublicExpose {
3388    /// Public hostname to route, e.g. `"api.noisetable.io"`. Semantic
3389    /// validation checks that this hostname is owned by a configured CF zone.
3390    pub hostname: String,
3391
3392    /// Container-side port to route traffic to. Shape validation requires this
3393    /// port to appear in `expose.mesh.ports`.
3394    pub port: u16,
3395
3396    /// TLS configuration for the public endpoint.
3397    pub tls: PublicTls,
3398}
3399
3400/// TLS mode for a public endpoint.
3401#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
3402#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3403#[serde(rename_all = "snake_case")]
3404pub enum PublicTls {
3405    /// Cloudflare manages the TLS certificate (default; requires a proxied DNS
3406    /// record in the configured zone).
3407    CfManaged,
3408
3409    /// User-supplied certificate referenced by name in the yubaba secret store.
3410    UserCertRef { name: String },
3411}
3412
3413/// Operator-facing exposure via a Tailscale ACL tag.
3414#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
3415#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3416pub struct OperatorExpose {
3417    /// Tailscale ACL tag granting access, e.g. `"tag:noisetable-ops"`. Semantic
3418    /// validation checks that this tag exists in the cluster's Tailscale ACL.
3419    pub tailscale_tag: String,
3420
3421    /// Container-side port to expose to Tailscale-authorized operators.
3422    pub port: u16,
3423}
3424
3425// ── ImageRef helpers ──────────────────────────────────────────────────────────
3426
3427impl ImageRef {
3428    /// The all-zeros sha256 digest that marks an image reference as **not
3429    /// content-pinned**. No real image can carry it, so a build that never
3430    /// injected a compile-time digest (dev builds) or a catalog image that
3431    /// isn't published-and-pinned yet lands on this sentinel. This is the
3432    /// single source of truth both the catalog emitter
3433    /// (`task::default_image::catalog_image`, which writes it) and the
3434    /// container-runtime resolvers ([`Self::pull_ref`], via kamaji) agree on —
3435    /// keeping them here means they cannot drift. [`testing::TEST_DIGEST`] is
3436    /// the same value re-exported for fixtures.
3437    pub const UNPINNED_DIGEST: &'static str =
3438        "sha256:0000000000000000000000000000000000000000000000000000000000000000";
3439
3440    /// Parse a full digest-pinned image reference —
3441    /// `[registry/]repo[:tag]@sha256:<hex>` — into its parts.
3442    ///
3443    /// This is the public door onto the same parser the `ImageRef` string-form
3444    /// `Deserialize` arm uses, so a config that spells an image as one string
3445    /// (a qed `step.image`, a transform recipe) and a config that spells it as
3446    /// a struct land on identical semantics. A bare tag is rejected: the whole
3447    /// point of the string form is that it carries the digest.
3448    pub fn parse_pinned(s: &str) -> Result<Self, String> {
3449        compose_import::parse_pinned_image_ref(s)
3450    }
3451
3452    /// Format this reference as a Docker-compatible image string,
3453    /// `{registry}/{repository}:{tag}@{digest}`. Tag is included for human
3454    /// readability; the digest is what the pull resolves against. Always emits
3455    /// the digest — this is the display/logging form; use [`Self::pull_ref`]
3456    /// for the string handed to a container runtime.
3457    pub fn docker_ref(&self) -> String {
3458        format!("{}/{}:{}@{}", self.registry, self.repository, self.tag, self.digest)
3459    }
3460
3461    /// True when this reference carries a real content-addressed digest, i.e.
3462    /// its digest is not the all-zeros [`Self::UNPINNED_DIGEST`] sentinel.
3463    pub fn is_pinned(&self) -> bool {
3464        self.digest != Self::UNPINNED_DIGEST
3465    }
3466
3467    /// The reference string to hand a container runtime for pull/resolve.
3468    ///
3469    /// - **Pinned** (real digest): `{registry}/{repository}:{tag}@{digest}` —
3470    ///   content-addressed, the reproducible path.
3471    /// - **Unpinned** (all-zeros [`Self::UNPINNED_DIGEST`]): `{registry}/{repository}:{tag}`
3472    ///   — tag-only. No registry or local store holds an image under the
3473    ///   sentinel digest, so `…@sha256:0000…` can never resolve; a
3474    ///   tag-pulled or locally-built image is keyed by `registry/repo:tag`.
3475    ///   This is the tag-fallback path that lets a not-yet-published catalog
3476    ///   image (e.g. a from-source build-worker image) still pull by tag.
3477    pub fn pull_ref(&self) -> String {
3478        if self.is_pinned() {
3479            format!("{}/{}:{}@{}", self.registry, self.repository, self.tag, self.digest)
3480        } else {
3481            format!("{}/{}:{}", self.registry, self.repository, self.tag)
3482        }
3483    }
3484}
3485
3486// ── WorkloadRuntime trait ─────────────────────────────────────────────────────
3487
3488/// Shared interface for deploying and managing `WorkloadSpec` containers.
3489///
3490/// This is the keystone abstraction (R256-F10) that makes sim and cloud
3491/// literally interchangeable at the container level:
3492///
3493/// - **Camp/sim tier**: `LocalDockerRuntime` in `cloud` implements this trait
3494///   via the docker CLI pointed at OrbStack (or any Docker-compatible socket).
3495///   No mesh — containers communicate over OrbStack's bridge network.
3496///
3497/// - **Yubaba/cloud-HA tier**: `yubaba::runtime::ContainerRuntime` (gRPC to
3498///   containerd) will implement this trait. Mesh assignment is a separate
3499///   orchestration step on top (handled by yubaba's raft layer), not part
3500///   of the shared deploy/supervise interface.
3501///
3502/// Callers that type against `WorkloadRuntime` automatically work with both
3503/// backends. Reconcilers in `cloud` use it today; yubaba wires its own impl
3504/// when R276 Tier-3 lands.
3505#[async_trait::async_trait]
3506pub trait WorkloadRuntime: Send + Sync {
3507    /// Deploy a workload described by `spec`. Pulls the image if needed,
3508    /// creates and starts the container, and returns an opaque workload ID
3509    /// (typically the container name derived from `spec.name`).
3510    ///
3511    /// Idempotent: re-deploying a running workload replaces it cleanly.
3512    async fn deploy_workload(&self, spec: &WorkloadSpec) -> anyhow::Result<String>;
3513
3514    /// Tear down a deployed workload — stop the process and remove all
3515    /// associated state. No-op when the workload is already gone.
3516    async fn teardown_workload(&self, name: &str) -> anyhow::Result<()>;
3517
3518    /// Returns `true` when the named workload is currently running (i.e.
3519    /// the container process is alive and has not exited).
3520    async fn is_running(&self, name: &str) -> anyhow::Result<bool>;
3521
3522    /// Probe the runtime backend. Returns `true` when the backend socket is
3523    /// reachable and healthy (e.g. docker daemon up, containerd gRPC up).
3524    /// Used by health endpoints and startup checks.
3525    async fn runtime_health(&self) -> anyhow::Result<bool>;
3526}
3527
3528// ── Tests ─────────────────────────────────────────────────────────────────────
3529
3530#[cfg(test)]
3531mod tests {
3532    use super::*;
3533
3534    // ── R658-B1 `routes` belongs at the top level, not inside [build] ─────────
3535
3536    /// The canonical `mesofact-static` manifest shape: `routes` above the
3537    /// `[build]` header, where TOML keeps it top-level.
3538    #[test]
3539    fn mesofact_static_routes_parse_at_the_top_level() {
3540        let src = r#"
3541schema_version = 1
3542kind = "mesofact-static"
3543routes = "./mesofact.routes.ts"
3544
3545[build]
3546command = "bun run build"
3547out_dir = "dist"
3548"#;
3549        let Workload::MesofactStatic(site) =
3550            toml::from_str::<Workload>(src).expect("canonical shape must parse")
3551        else {
3552            panic!("kind = \"mesofact-static\" must select MesofactStatic");
3553        };
3554        assert_eq!(site.routes, PathBuf::from("./mesofact.routes.ts"));
3555        assert_eq!(site.build.out_dir, PathBuf::from("dist"));
3556    }
3557
3558    /// The bug R658-B1 exists for: `routes` written *below* `[build]` is
3559    /// `build.routes` as far as TOML is concerned. `BuildConfig` used to
3560    /// discard the stray key, so this manifest parsed as far as the missing
3561    /// top-level field and blamed the wrong line — or, once `routes` had a
3562    /// default, would have deployed a site that enumerated no routes at all.
3563    ///
3564    /// `deny_unknown_fields` makes the misplacement itself the error, and the
3565    /// message names `routes`, which is the one thing the author needs to move.
3566    #[test]
3567    fn mesofact_static_routes_inside_build_is_rejected_by_name() {
3568        let src = r#"
3569schema_version = 1
3570kind = "mesofact-static"
3571
3572[build]
3573command = "bun run build"
3574out_dir = "dist"
3575routes = "./mesofact.routes.ts"
3576"#;
3577        let err = toml::from_str::<Workload>(src)
3578            .expect_err("`routes` under [build] must not parse silently")
3579            .to_string();
3580        assert!(
3581            err.contains("routes"),
3582            "the error must name the misplaced key so the fix is obvious; got: {err}"
3583        );
3584    }
3585
3586    /// Guard the general case, not just the one key that bit us: any unknown
3587    /// `[build]` key is refused rather than dropped on the floor.
3588    #[test]
3589    fn unknown_build_keys_are_refused_rather_than_ignored() {
3590        let src = r#"
3591command = "bun run build"
3592out_dir = "dist"
3593outdir = "dist"
3594"#;
3595        let err = toml::from_str::<BuildConfig>(src)
3596            .expect_err("a typo'd build key must not be silently ignored")
3597            .to_string();
3598        assert!(err.contains("outdir"), "got: {err}");
3599
3600        // …and the keys that ARE modelled still round-trip.
3601        let ok: BuildConfig = toml::from_str(
3602            r#"
3603command = "bun run build"
3604out_dir = "dist"
3605render_command = "mesofact-build render . --route {route}"
3606"#,
3607        )
3608        .expect("modelled keys must still parse");
3609        assert_eq!(ok.render_command.as_deref(), Some("mesofact-build render . --route {route}"));
3610    }
3611
3612    // ── R783-F1 / W324: container manifest vs wire spec ────────────────────────
3613
3614    /// The acceptance case. `crates/yah/cloud-admin/workload.toml` is the file
3615    /// that could not parse through the envelope at all (R658-B2 pinned it in
3616    /// `xtask/tests/workload_envelope.rs` as `missing field \`image\``): it is a
3617    /// Dockerfile recipe, and the envelope only knew digest-pinned specs.
3618    ///
3619    /// The `[process]` table is deliberately present — that file is read by
3620    /// `LocalProcessReconciler` on the dev mirror *and* `ContainerReconciler`
3621    /// on pond, so the container form must tolerate the other tier's table
3622    /// rather than reject the file (W324 §1).
3623    #[test]
3624    fn container_recipe_parses_including_the_other_tier_s_table() {
3625        let src = r#"
3626schema_version = 1
3627name = "yah-cloud-admin"
3628kind = "container"
3629
3630[build]
3631dockerfile = "Dockerfile"
3632context = "."
3633image = "yah-local/yah-cloud-admin:dev"
3634
3635[run]
3636port = 4325
3637host_port = 4326
3638
3639[run.env]
3640YAH_CLOUD_ADMIN_ADDR = "0.0.0.0:4325"
3641
3642[[run.mounts]]
3643host = ".yah/infra"
3644container = "/workspace/.yah/infra"
3645
3646[process]
3647cargo_package = "yah-cloud-admin"
3648port = 4325
3649"#;
3650        let workload = toml::from_str::<Workload>(src).expect("the recipe form must parse");
3651        assert_eq!(workload.kind_str(), "container");
3652
3653        let recipe = workload
3654            .container_manifest()
3655            .and_then(ContainerManifest::as_recipe)
3656            .expect("a [build] table selects the recipe form");
3657        assert_eq!(recipe.name, "yah-cloud-admin");
3658        assert_eq!(recipe.build.dockerfile, PathBuf::from("Dockerfile"));
3659        assert_eq!(recipe.build.context, Some(PathBuf::from(".")));
3660        assert_eq!(
3661            recipe.build.image.as_deref(),
3662            Some("yah-local/yah-cloud-admin:dev")
3663        );
3664        assert_eq!(recipe.run.port, Some(4325));
3665        assert_eq!(recipe.run.host_port, Some(4326));
3666        assert_eq!(
3667            recipe.run.env.get("YAH_CLOUD_ADMIN_ADDR").map(String::as_str),
3668            Some("0.0.0.0:4325")
3669        );
3670        assert_eq!(recipe.run.mounts.len(), 1);
3671        assert!(recipe.run.mounts[0].read_only, "mounts default to read-only");
3672
3673        // The recipe has no spec — that is the whole point of the split.
3674        assert!(workload.container_spec().is_none());
3675    }
3676
3677    /// The other branch: no `[build]` table means the flat fields are a
3678    /// digest-pinned `WorkloadSpec`, exactly as before the split.
3679    #[test]
3680    fn container_reference_still_parses_as_a_workload_spec() {
3681        let spec = archetype_test_spec("noisetable-api");
3682        let toml_src = toml::to_string(&Workload::container(spec.clone())).expect("serialize");
3683        assert!(
3684            toml_src.contains("kind = \"container\""),
3685            "the on-disk form stays flat + internally tagged: {toml_src}"
3686        );
3687
3688        let back = toml::from_str::<Workload>(&toml_src).expect("deserialize");
3689        assert_eq!(back.container_spec(), Some(&spec));
3690    }
3691
3692    /// Explicit-branch deserialize exists so this error survives. Under
3693    /// `#[serde(untagged)]` it would read "data did not match any variant of
3694    /// untagged enum ContainerManifest", which tells an author nothing.
3695    #[test]
3696    fn a_malformed_container_reference_still_names_the_missing_field() {
3697        let src = r#"
3698schema_version = 1
3699kind = "container"
3700name = "noisetable-api"
3701image = "ghcr.io/noisetable/api:v1@sha256:0000000000000000000000000000000000000000000000000000000000000000"
3702replicas = 1
3703"#;
3704        let err = toml::from_str::<Workload>(src)
3705            .expect_err("a reference missing a required field must not parse")
3706            .to_string();
3707        assert!(err.contains("missing field `tier`"), "got: {err}");
3708    }
3709
3710    /// The one file that names neither marker. `missing field \`image\`` would
3711    /// send a recipe author off to add a field their form does not have, so
3712    /// the error names both forms instead.
3713    #[test]
3714    fn a_container_with_neither_image_nor_build_names_both_forms() {
3715        let src = r#"
3716schema_version = 1
3717kind = "container"
3718name = "yah-cloud-admin"
3719
3720[run]
3721port = 4325
3722"#;
3723        let err = toml::from_str::<Workload>(src)
3724            .expect_err("neither form is declared")
3725            .to_string();
3726        assert!(err.contains("image"), "got: {err}");
3727        assert!(err.contains("[build]"), "got: {err}");
3728    }
3729
3730    /// W324 §5's invariant, as a signature: there is no path from a recipe to
3731    /// a `WorkloadSpec` that does not name a digest.
3732    #[test]
3733    fn a_recipe_lowers_only_once_a_build_has_produced_a_digest() {
3734        let recipe = ContainerBuild {
3735            schema_version: SchemaVersion::V1,
3736            name: "yah-cloud-admin".into(),
3737            build: ContainerBuildStep {
3738                dockerfile: "Dockerfile".into(),
3739                context: Some(".".into()),
3740                image: Some("yah-local/yah-cloud-admin:dev".into()),
3741            },
3742            run: ContainerRunConfig {
3743                port: Some(4325),
3744                host_port: Some(4326),
3745                env: BTreeMap::from([("A".to_string(), "b".to_string())]),
3746                mounts: vec![ContainerMount {
3747                    host: ".yah/infra".into(),
3748                    container: "/workspace/.yah/infra".into(),
3749                    read_only: true,
3750                }],
3751            },
3752        };
3753
3754        let digest = testing::test_digest();
3755        let spec = recipe
3756            .clone()
3757            .into_spec(&digest, TierTag("private".into()))
3758            .expect("a well-formed digest lowers");
3759        assert_eq!(spec.name, "yah-cloud-admin");
3760        assert_eq!(spec.image.digest, digest);
3761        assert_eq!(spec.image.repository, "yah-local/yah-cloud-admin");
3762        assert_eq!(spec.image.tag, "dev");
3763        assert_eq!(spec.expose.mesh.ports, vec![4325]);
3764        assert_eq!(spec.env.len(), 1);
3765        assert_eq!(spec.volumes.len(), 1);
3766
3767        // A bare tag is not a digest. Lowering must fail rather than mint a
3768        // spec that lies about being content-addressed (R438-T3).
3769        let err = recipe
3770            .into_spec("dev", TierTag("private".into()))
3771            .expect_err("an unpinned digest must not lower");
3772        assert!(err.contains("sha256"), "got: {err}");
3773    }
3774
3775    /// A recipe is a first-class on-disk value: it survives a write/read of
3776    /// the manifest unchanged. The other half of the gate — that the same
3777    /// value is *refused* by postcard — is in `tests/round_trip.rs`, which
3778    /// also pins the reference form's byte layout.
3779    #[test]
3780    fn a_recipe_round_trips_on_disk_under_the_container_kind() {
3781        let recipe = Workload::Container(ContainerManifest::Recipe(ContainerBuild {
3782            schema_version: SchemaVersion::V1,
3783            name: "yah-cloud-admin".into(),
3784            build: ContainerBuildStep::default(),
3785            run: ContainerRunConfig::default(),
3786        }));
3787        assert_eq!(recipe.kind_str(), "container");
3788
3789        let src = toml::to_string(&recipe).expect("a recipe serializes to disk");
3790        assert!(src.contains("kind = \"container\""), "{src}");
3791        let back: Workload = toml::from_str(&src).expect("and parses back");
3792        assert_eq!(back, recipe);
3793    }
3794
3795    // ── R603-T5 durable forge produced convention ──────────────────────────────
3796
3797    #[test]
3798    fn forge_produced_ident_parse() {
3799        assert_eq!(forge_produced::forge_id_from_ident("forge.abc123"), Some("abc123"));
3800        assert_eq!(forge_produced::forge_id_from_ident("svc.web"), None);
3801        assert_eq!(forge_produced::forge_id_from_ident("abc123"), None);
3802    }
3803
3804    #[test]
3805    fn forge_produced_host_path_translates_under_convention_dir() {
3806        let hp = forge_produced::host_path(
3807            "fid",
3808            std::path::Path::new("/yah/produced/librusty_v8.tar.gz"),
3809        )
3810        .expect("path under the convention dir translates");
3811        assert_eq!(
3812            hp,
3813            PathBuf::from("/var/lib/yah/qed/produced/fid/librusty_v8.tar.gz")
3814        );
3815    }
3816
3817    #[test]
3818    fn forge_produced_host_path_rejects_paths_outside_convention_dir() {
3819        assert_eq!(
3820            forge_produced::host_path("fid", std::path::Path::new("/tmp/x.tar.gz")),
3821            None,
3822            "a path outside /yah/produced has no durable host mapping"
3823        );
3824    }
3825
3826    #[test]
3827    fn forge_produced_host_path_rejects_traversal() {
3828        // A `..` component must never let a read escape the per-forge dir.
3829        assert_eq!(
3830            forge_produced::host_path(
3831                "fid",
3832                std::path::Path::new("/yah/produced/../../etc/passwd")
3833            ),
3834            None,
3835            "traversal out of the per-forge dir must be refused"
3836        );
3837    }
3838
3839    #[test]
3840    fn forge_produced_durable_mount_shape() {
3841        let m = forge_produced::durable_mount("fid");
3842        assert_eq!(m.target, PathBuf::from("/yah/produced"));
3843        assert!(!m.read_only, "the build must be able to write to it");
3844        assert_eq!(
3845            m.source,
3846            VolumeSource::Bind {
3847                host_path: PathBuf::from("/var/lib/yah/qed/produced/fid"),
3848            }
3849        );
3850    }
3851
3852    const HASH_64: &str = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
3853
3854    #[test]
3855    fn blake_hash_accepts_64_hex() {
3856        let h: BlakeHash = toml::from_str(&format!("x = \"{HASH_64}\""))
3857            .map(|t: toml::Table| t["x"].as_str().unwrap().to_owned())
3858            .map(|s| serde_json::from_value(serde_json::Value::String(s)).unwrap())
3859            .unwrap();
3860        assert_eq!(h.0, HASH_64);
3861    }
3862
3863    #[test]
3864    fn blake_hash_rejects_wrong_length() {
3865        let short = "abcdef";
3866        let res: Result<BlakeHash, _> =
3867            serde_json::from_value(serde_json::Value::String(short.into()));
3868        assert!(res.is_err());
3869    }
3870
3871    #[test]
3872    fn blake_hash_rejects_non_hex() {
3873        let bad = "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz";
3874        let res: Result<BlakeHash, _> =
3875            serde_json::from_value(serde_json::Value::String(bad.into()));
3876        assert!(res.is_err());
3877    }
3878
3879    fn image_ref(digest: &str) -> ImageRef {
3880        ImageRef {
3881            registry: "ghcr.io".into(),
3882            repository: "yah-ai/rusty-v8-musl-builder".into(),
3883            tag: "latest".into(),
3884            digest: digest.into(),
3885        }
3886    }
3887
3888    #[test]
3889    fn is_pinned_distinguishes_real_digest_from_sentinel() {
3890        assert!(!image_ref(ImageRef::UNPINNED_DIGEST).is_pinned());
3891        assert!(!image_ref(&testing::test_digest()).is_pinned());
3892        assert!(image_ref("sha256:deadbeef").is_pinned());
3893    }
3894
3895    #[test]
3896    fn pull_ref_pinned_carries_tag_and_digest() {
3897        assert_eq!(
3898            image_ref("sha256:deadbeef").pull_ref(),
3899            "ghcr.io/yah-ai/rusty-v8-musl-builder:latest@sha256:deadbeef",
3900        );
3901    }
3902
3903    #[test]
3904    fn pull_ref_unpinned_falls_back_to_tag_only() {
3905        // An unpinned catalog image (all-zeros sentinel) resolves by tag —
3906        // no store holds `…@sha256:0000…`, so the tag is the only usable key.
3907        assert_eq!(
3908            image_ref(ImageRef::UNPINNED_DIGEST).pull_ref(),
3909            "ghcr.io/yah-ai/rusty-v8-musl-builder:latest",
3910        );
3911    }
3912
3913    #[test]
3914    fn test_digest_alias_is_the_unpinned_sentinel() {
3915        assert_eq!(testing::TEST_DIGEST, ImageRef::UNPINNED_DIGEST);
3916    }
3917
3918    #[test]
3919    fn static_asset_workload_round_trips() {
3920        let src = format!(
3921            r#"
3922schema_version = "V1"
3923
3924[[asset]]
3925filename = "whisper/distil-large-v3-q5_1.bin"
3926source   = "sources/distil-large-v3-q5_1.bin"
3927blake3   = "{HASH_64}"
3928
3929[[asset]]
3930filename = "whisper/distil-large-v3-q4_0.bin"
3931source   = "sources/distil-large-v3-q4_0.bin"
3932blake3   = "{HASH_64}"
3933
3934[aliases]
3935"whisper-default" = "whisper/distil-large-v3-q5_1.bin"
3936"#
3937        );
3938        let w: StaticAssetWorkload = toml::from_str(&src).expect("parse");
3939        assert_eq!(w.assets.len(), 2);
3940        assert_eq!(w.assets[0].filename, "whisper/distil-large-v3-q5_1.bin");
3941        assert_eq!(w.assets[0].blake3.0, HASH_64);
3942        assert_eq!(w.aliases["whisper-default"], "whisper/distil-large-v3-q5_1.bin");
3943
3944        let back = toml::to_string(&w).expect("serialize");
3945        let w2: StaticAssetWorkload = toml::from_str(&back).expect("re-parse");
3946        assert_eq!(w, w2);
3947    }
3948
3949    #[test]
3950    fn license_round_trip_each_variant() {
3951        // Wire format is whatever serde's `rename_all = "kebab-case"` emits.
3952        // heck's kebab-case keeps letter→digit attached but splits digit→uppercase,
3953        // so `Apache2 → "apache2"` and `Bsd2Clause → "bsd2-clause"`.
3954        for (variant, on_wire) in [
3955            (License::Mit, "mit"),
3956            (License::Apache2, "apache2"),
3957            (License::Bsd2Clause, "bsd2-clause"),
3958            (License::Bsd3Clause, "bsd3-clause"),
3959            (License::Isc, "isc"),
3960        ] {
3961            let ser = serde_json::to_value(variant).expect("serialize");
3962            assert_eq!(ser, serde_json::Value::String(on_wire.into()));
3963            let back: License = serde_json::from_value(ser).expect("deserialize");
3964            assert_eq!(back, variant);
3965        }
3966    }
3967
3968    #[test]
3969    fn license_rejects_non_permissive_variants() {
3970        for unknown in ["GPL-3.0", "AGPL", "lgpl-2.1", "unknown", "MIT"] {
3971            let res: Result<License, _> =
3972                serde_json::from_value(serde_json::Value::String(unknown.into()));
3973            assert!(res.is_err(), "expected rejection for {unknown:?}");
3974        }
3975    }
3976
3977    #[test]
3978    fn fetch_source_round_trips() {
3979        let src = format!(
3980            r#"
3981url     = "https://example.invalid/upstream.bin"
3982blake3  = "{HASH_64}"
3983license = "mit"
3984"#
3985        );
3986        let fs: FetchSource = toml::from_str(&src).expect("parse");
3987        assert_eq!(fs.url, "https://example.invalid/upstream.bin");
3988        assert_eq!(fs.blake3.0, HASH_64);
3989        assert_eq!(fs.license, License::Mit);
3990
3991        let back = toml::to_string(&fs).expect("serialize");
3992        let fs2: FetchSource = toml::from_str(&back).expect("re-parse");
3993        assert_eq!(fs, fs2);
3994    }
3995
3996    #[test]
3997    fn fetch_source_rejects_unknown_license() {
3998        let src = format!(
3999            r#"
4000url     = "https://example.invalid/upstream.bin"
4001blake3  = "{HASH_64}"
4002license = "GPL-3.0"
4003"#
4004        );
4005        let res: Result<FetchSource, _> = toml::from_str(&src);
4006        assert!(res.is_err(), "expected non-permissive license to reject");
4007    }
4008
4009    #[test]
4010    fn asset_entry_derive_mode_round_trips() {
4011        let src = format!(
4012            r#"
4013schema_version = "V1"
4014
4015[[asset]]
4016filename = "whisper/distil-large-v3-q5_1.bin"
4017blake3   = "{HASH_64}"
4018
4019[asset.derive.fetch]
4020url     = "https://example.invalid/ggml-distil-large-v3.bin"
4021blake3  = "{HASH_64}"
4022license = "mit"
4023
4024[asset.derive.transform]
4025recipe = "whisper-quantize"
4026params = {{ quant = "q5_1" }}
4027"#
4028        );
4029        let w: StaticAssetWorkload = toml::from_str(&src).expect("parse");
4030        assert_eq!(w.assets.len(), 1);
4031        let entry = &w.assets[0];
4032        assert!(entry.source.is_none());
4033        let derive = entry.derive.as_ref().expect("derive present");
4034        assert_eq!(derive.fetch.url, "https://example.invalid/ggml-distil-large-v3.bin");
4035        assert_eq!(derive.fetch.license, License::Mit);
4036        let transform = derive.transform.as_ref().expect("transform present");
4037        assert_eq!(transform.recipe, "whisper-quantize");
4038        assert_eq!(transform.params.get("quant").map(String::as_str), Some("q5_1"));
4039
4040        let back = toml::to_string(&w).expect("serialize");
4041        let w2: StaticAssetWorkload = toml::from_str(&back).expect("re-parse");
4042        assert_eq!(w, w2);
4043    }
4044
4045    #[test]
4046    fn legacy_source_only_asset_serializes_without_derive_field() {
4047        // Verify the skip_serializing_if guards keep legacy TOMLs round-tripping
4048        // without ever emitting an empty `derive = ...` line.
4049        let src = format!(
4050            r#"
4051schema_version = "V1"
4052
4053[[asset]]
4054filename = "operator-curated.bin"
4055source   = "sources/operator-curated.bin"
4056blake3   = "{HASH_64}"
4057"#
4058        );
4059        let w: StaticAssetWorkload = toml::from_str(&src).expect("parse");
4060        let back = toml::to_string(&w).expect("serialize");
4061        assert!(!back.contains("derive"), "serialized output leaked a derive field: {back}");
4062        let w2: StaticAssetWorkload = toml::from_str(&back).expect("re-parse");
4063        assert_eq!(w, w2);
4064    }
4065
4066    /// W212/R518: the `[asset.derive.lock]` block round-trips through TOML, and
4067    /// is omitted from output when absent (so non-derive / unlocked assets stay
4068    /// clean).
4069    #[test]
4070    fn derive_lock_round_trips_through_toml() {
4071        let toml = r#"
4072url     = "https://example.invalid/config.json"
4073blake3  = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
4074license = "mit"
4075"#;
4076        let fetch: FetchSource = ::toml::from_str(toml).unwrap();
4077        let derive = AssetDerive {
4078            fetch,
4079            transform: Some(TransformSpec {
4080                recipe: "whisper-bundle-tar".into(),
4081                params: BTreeMap::new(),
4082            }),
4083            lock: Some(DeriveLock {
4084                input_hash: "1111111111111111111111111111111111111111111111111111111111111111".into(),
4085                output_blake3: "2222222222222222222222222222222222222222222222222222222222222222".into(),
4086            }),
4087        };
4088        let s = ::toml::to_string(&derive).unwrap();
4089        assert!(s.contains("[lock]"), "lock serialized: {s}");
4090        let back: AssetDerive = ::toml::from_str(&s).unwrap();
4091        assert_eq!(derive, back);
4092
4093        // Absent lock → no `[lock]` table in the output.
4094        let unlocked = AssetDerive { lock: None, ..derive };
4095        let s2 = ::toml::to_string(&unlocked).unwrap();
4096        assert!(!s2.contains("[lock]"), "unlocked must omit lock: {s2}");
4097    }
4098
4099    #[test]
4100    fn shape_static_asset_rejects_both_source_and_derive() {
4101        use crate::validate::{shape_static_asset, FieldPath, ShapeError};
4102
4103        let entry = AssetEntry {
4104            filename: "ambiguous.bin".into(),
4105            source: Some("sources/ambiguous.bin".into()),
4106            derive: Some(AssetDerive {
4107                fetch: FetchSource {
4108                    url: "https://example.invalid/x".into(),
4109                    blake3: BlakeHash(HASH_64.into()),
4110                    license: License::Mit,
4111                },
4112                transform: None,
4113                lock: None,
4114            }),
4115            blake3: BlakeHash(HASH_64.into()),
4116        };
4117        let w = StaticAssetWorkload {
4118            schema_version: SchemaVersion::V1,
4119            assets: vec![entry],
4120            aliases: BTreeMap::new(),
4121        };
4122        let err = shape_static_asset(&w).expect_err("XOR violated");
4123        match err {
4124            ShapeError::Field { path: FieldPath::Asset(0, "source"), .. } => {}
4125            other => panic!("expected Asset(0, \"source\") shape error, got {other:?}"),
4126        }
4127    }
4128
4129    #[test]
4130    fn shape_static_asset_rejects_neither_source_nor_derive() {
4131        use crate::validate::{shape_static_asset, FieldPath, ShapeError};
4132
4133        let entry = AssetEntry {
4134            filename: "empty.bin".into(),
4135            source: None,
4136            derive: None,
4137            blake3: BlakeHash(HASH_64.into()),
4138        };
4139        let w = StaticAssetWorkload {
4140            schema_version: SchemaVersion::V1,
4141            assets: vec![entry],
4142            aliases: BTreeMap::new(),
4143        };
4144        let err = shape_static_asset(&w).expect_err("XOR violated");
4145        match err {
4146            ShapeError::Field { path: FieldPath::Asset(0, "source"), .. } => {}
4147            other => panic!("expected Asset(0, \"source\") shape error, got {other:?}"),
4148        }
4149    }
4150
4151    #[test]
4152    fn shape_static_asset_accepts_either_mode() {
4153        use crate::validate::shape_static_asset;
4154
4155        let legacy = AssetEntry {
4156            filename: "a.bin".into(),
4157            source: Some("sources/a.bin".into()),
4158            derive: None,
4159            blake3: BlakeHash(HASH_64.into()),
4160        };
4161        let derived = AssetEntry {
4162            filename: "b.bin".into(),
4163            source: None,
4164            derive: Some(AssetDerive {
4165                fetch: FetchSource {
4166                    url: "https://example.invalid/b".into(),
4167                    blake3: BlakeHash(HASH_64.into()),
4168                    license: License::Apache2,
4169                },
4170                transform: None,
4171                lock: None,
4172            }),
4173            blake3: BlakeHash(HASH_64.into()),
4174        };
4175        let w = StaticAssetWorkload {
4176            schema_version: SchemaVersion::V1,
4177            assets: vec![legacy, derived],
4178            aliases: BTreeMap::new(),
4179        };
4180        shape_static_asset(&w).expect("both modes accepted");
4181    }
4182
4183    #[test]
4184    fn image_ref_string_form_rejects_bare_tag() {
4185        let res: Result<ImageRef, _> =
4186            serde_json::from_value(serde_json::Value::String("node:20".into()));
4187        let err = res.expect_err("bare-tag must reject");
4188        let msg = format!("{err}");
4189        assert!(msg.contains("digest"), "error should mention digest: {msg}");
4190    }
4191
4192    #[test]
4193    fn image_ref_string_form_accepts_digest_pinned() {
4194        let pinned = format!("node:20@sha256:{HASH_64}");
4195        let img: ImageRef =
4196            serde_json::from_value(serde_json::Value::String(pinned.clone())).expect("parse");
4197        assert_eq!(img.registry, "docker.io");
4198        assert_eq!(img.repository, "library/node");
4199        assert_eq!(img.tag, "20");
4200        assert_eq!(img.digest, format!("sha256:{HASH_64}"));
4201    }
4202
4203    #[test]
4204    fn image_ref_string_form_accepts_ghcr_with_pin() {
4205        let pinned = format!("ghcr.io/foo/bar:v1.7.4@sha256:{HASH_64}");
4206        let img: ImageRef =
4207            serde_json::from_value(serde_json::Value::String(pinned)).expect("parse");
4208        assert_eq!(img.registry, "ghcr.io");
4209        assert_eq!(img.repository, "foo/bar");
4210        assert_eq!(img.tag, "v1.7.4");
4211        assert!(img.digest.starts_with("sha256:"));
4212    }
4213
4214    #[test]
4215    fn image_ref_string_form_rejects_non_sha256_digest() {
4216        for bad in [
4217            "node:20@md5:abcdef",
4218            "node:20@sha1:abcdef",
4219            "node:20@sha256:",
4220            "node:20@sha256:zzznothex",
4221        ] {
4222            let res: Result<ImageRef, _> =
4223                serde_json::from_value(serde_json::Value::String(bad.into()));
4224            assert!(res.is_err(), "expected reject for {bad:?}");
4225        }
4226    }
4227
4228    #[test]
4229    fn image_ref_struct_form_rejects_missing_digest() {
4230        // Digest is now structurally required (R438-T3). Struct-form payloads
4231        // without `digest` must fail at serde-deserialize.
4232        let v = serde_json::json!({
4233            "registry": "ghcr.io",
4234            "repository": "noisetable/api",
4235            "tag": "v1.4.2",
4236        });
4237        let res: Result<ImageRef, _> = serde_json::from_value(v);
4238        assert!(res.is_err(), "missing digest must reject");
4239    }
4240
4241    #[test]
4242    fn image_ref_struct_form_round_trips_through_toml() {
4243        let img = ImageRef {
4244            registry: "ghcr.io".into(),
4245            repository: "ggerganov/whisper.cpp".into(),
4246            tag: "v1.7.4".into(),
4247            digest: format!("sha256:{HASH_64}"),
4248        };
4249        let toml_doc = toml::to_string(&img).expect("serialize");
4250        let back: ImageRef = toml::from_str(&toml_doc).expect("re-parse");
4251        assert_eq!(img, back);
4252    }
4253
4254    /// R546-B7: assert the shape real files use. This test previously fed the
4255    /// EXTERNALLY-tagged wrapping-table form (`[static-asset]` +
4256    /// `[[static-asset.asset]]`), which no on-disk `workload.toml` has ever
4257    /// used — so it stayed green while `yah cloud apply` was broken for every
4258    /// static-asset component. The flat `kind = "..."` form below is what every
4259    /// workload.toml in the workspace is written in.
4260    #[test]
4261    fn workload_envelope_dispatches_static_asset() {
4262        let src = format!(
4263            r#"
4264kind = "static-asset"
4265schema_version = "V1"
4266
4267[[asset]]
4268filename = "foo/bar.bin"
4269source   = "sources/bar.bin"
4270blake3   = "{HASH_64}"
4271"#
4272        );
4273        let w: Workload = toml::from_str(&src).expect("parse");
4274        assert!(matches!(w, Workload::StaticAsset(_)));
4275    }
4276
4277    /// R546-B7: the format branch, both directions. Human-readable formats get
4278    /// the flat `kind`-tagged shape; postcard keeps the externally-tagged
4279    /// variant-index encoding the kamaji UDS depends on (R590-B3). Regressing
4280    /// either side breaks a different half of the system, so pin both.
4281    #[test]
4282    fn workload_envelope_is_tagged_in_toml_and_external_in_postcard() {
4283        let src = format!(
4284            r#"
4285kind = "static-asset"
4286schema_version = "V1"
4287
4288[[asset]]
4289filename = "foo/bar.bin"
4290source   = "sources/bar.bin"
4291blake3   = "{HASH_64}"
4292"#
4293        );
4294        let w: Workload = toml::from_str(&src).expect("parse flat TOML");
4295
4296        // Human-readable round-trips stay flat — no wrapping table.
4297        let json = serde_json::to_string(&w).expect("serialize json");
4298        assert!(json.contains("\"kind\":\"static-asset\""), "got {json}");
4299        assert!(
4300            !json.contains("{\"static-asset\":"),
4301            "human-readable output must not be externally tagged: {json}"
4302        );
4303        assert_eq!(
4304            serde_json::from_str::<Workload>(&json).expect("re-parse json"),
4305            w
4306        );
4307
4308        // postcard is non-self-describing: it can only round-trip because the
4309        // binary branch never asks for deserialize_any.
4310        let bytes = postcard::to_allocvec(&w).expect("postcard encode");
4311        assert_eq!(
4312            postcard::from_bytes::<Workload>(&bytes).expect("postcard decode"),
4313            w
4314        );
4315    }
4316
4317    // ── R572-F1: lifecycle archetype discriminator ─────────────────────────
4318
4319    fn archetype_test_spec(name: &str) -> WorkloadSpec {
4320        WorkloadSpec::for_forge(
4321            name,
4322            ImageRef {
4323                registry: "ghcr.io".into(),
4324                repository: "yah/test".into(),
4325                tag: "latest".into(),
4326                digest: testing::test_digest(),
4327            },
4328            TierTag("infra".into()),
4329            vec![],
4330        )
4331    }
4332
4333    #[test]
4334    fn explicit_archetype_round_trips_through_json_and_wins_over_inference() {
4335        for archetype in [
4336            LifecycleArchetype::Server,
4337            LifecycleArchetype::Appliance,
4338            LifecycleArchetype::Job,
4339        ] {
4340            let mut spec = archetype_test_spec("explicit");
4341            // Volumes present + restart_policy Always would infer Appliance
4342            // (see effective_archetype_infers_* below) — deliberately
4343            // mismatched against every archetype under test so the
4344            // assertion actually proves the explicit field wins, not that
4345            // it happens to agree with inference.
4346            spec.volumes = vec![VolumeMount {
4347                source: VolumeSource::Named { name: "data".into() },
4348                target: PathBuf::from("/data"),
4349                read_only: false,
4350            }];
4351            spec.restart_policy = RestartPolicy::Always;
4352            spec.archetype = Some(archetype);
4353
4354            let json = serde_json::to_string(&spec).expect("serialize");
4355            assert!(
4356                json.contains("\"archetype\""),
4357                "explicit archetype must be present on the wire"
4358            );
4359            let back: WorkloadSpec = serde_json::from_str(&json).expect("deserialize");
4360            assert_eq!(spec, back, "spec did not survive JSON round-trip");
4361            assert_eq!(back.archetype, Some(archetype));
4362            assert_eq!(
4363                back.effective_archetype(),
4364                archetype,
4365                "explicit archetype must win over the volumes/restart_policy inference"
4366            );
4367        }
4368    }
4369
4370    #[test]
4371    fn archetype_serializes_as_null_when_none() {
4372        let mut spec = archetype_test_spec("omitted");
4373        spec.archetype = None;
4374        let json = serde_json::to_value(&spec).expect("to_value");
4375        // Postcard-native (R590-B3): no `skip_serializing_if` anywhere on the
4376        // graph, so every field is always on the wire — a None Option is an
4377        // explicit `null`, not an absent key. The binary UDS wire is positional
4378        // and requires the slot to be present.
4379        assert_eq!(json.get("archetype"), Some(&serde_json::Value::Null));
4380    }
4381
4382    #[test]
4383    fn spec_without_archetype_field_deserializes_to_none() {
4384        // Simulates an on-disk spec written before R572-F1: no `archetype`
4385        // key at all. Omitting the key must still parse to None (the additive-
4386        // default contract) even though we now always *emit* the field.
4387        let mut spec = archetype_test_spec("pre-existing");
4388        spec.archetype = None;
4389        let mut json = serde_json::to_value(&spec).expect("to_value");
4390        json.as_object_mut().unwrap().remove("archetype");
4391        let back: WorkloadSpec = serde_json::from_value(json).expect("deserialize");
4392        assert_eq!(back.archetype, None);
4393    }
4394
4395    #[test]
4396    fn effective_archetype_infers_appliance_from_volumes_when_field_absent() {
4397        // Pre-R572 behavior: a workload with a volume was understood (by
4398        // convention, never a type) to be stateful/pinned. Confirm that
4399        // meaning is preserved bit-for-bit through effective_archetype().
4400        let mut spec = archetype_test_spec("appliance-inferred");
4401        spec.volumes = vec![VolumeMount {
4402            source: VolumeSource::Named { name: "pgdata".into() },
4403            target: PathBuf::from("/var/lib/postgresql/data"),
4404            read_only: false,
4405        }];
4406        spec.restart_policy = RestartPolicy::Always;
4407        spec.archetype = None;
4408        assert_eq!(spec.effective_archetype(), LifecycleArchetype::Appliance);
4409    }
4410
4411    #[test]
4412    fn effective_archetype_infers_job_from_restart_never_when_field_absent() {
4413        // Pre-R572 behavior: RestartPolicy::Never + no volumes is the forge
4414        // run-once convention (see RestartPolicy::Never's own doc comment) —
4415        // structurally a job. WorkloadSpec::for_forge already produces
4416        // exactly this shape; isolate the pure-inference path by clearing
4417        // the explicit archetype for_forge now sets.
4418        let mut spec = archetype_test_spec("job-inferred");
4419        assert!(spec.volumes.is_empty());
4420        assert!(matches!(spec.restart_policy, RestartPolicy::Never));
4421        spec.archetype = None;
4422        assert_eq!(spec.effective_archetype(), LifecycleArchetype::Job);
4423    }
4424
4425    #[test]
4426    fn effective_archetype_defaults_to_server_as_the_common_case_when_field_absent() {
4427        // Pre-R572 behavior: no volumes + a restartable policy (the common
4428        // stateless-web-server shape) inferred as movable/fungible.
4429        let mut spec = archetype_test_spec("server-inferred");
4430        spec.restart_policy = RestartPolicy::Always;
4431        spec.archetype = None;
4432        assert_eq!(spec.effective_archetype(), LifecycleArchetype::Server);
4433    }
4434
4435    // ── R594-F2: public-ingress appliance (container-shaped, not a new
4436    // Workload variant — see Workload::Container's doc comment) ───────────
4437
4438    #[test]
4439    fn ingress_marked_spec_is_appliance_and_carries_public_ip_placement_requirement() {
4440        let mut spec = archetype_test_spec("public-ingress");
4441        spec.archetype = Some(LifecycleArchetype::Appliance);
4442        spec.annotations.insert(
4443            REQUIRES_TAINT_ANNOTATION.to_string(),
4444            PUBLIC_IP_TAINT.to_string(),
4445        );
4446
4447        assert_eq!(
4448            spec.effective_archetype(),
4449            LifecycleArchetype::Appliance,
4450            "ingress must be pinned-per-node/non-drainable, the R572 appliance sense"
4451        );
4452        assert_eq!(
4453            spec.requires_taint(),
4454            Some(PUBLIC_IP_TAINT),
4455            "ingress must declare it can only land on a public-ip-tainted node"
4456        );
4457
4458        // No taint exists to match against yet (R572-F3) and nothing
4459        // enforces placement yet (R572-F5) — confirm this ticket stays
4460        // declarative-only by checking a spec with no requirement stays
4461        // unaffected.
4462        let unrelated = archetype_test_spec("unrelated");
4463        assert_eq!(unrelated.requires_taint(), None);
4464    }
4465
4466    #[test]
4467    fn ingress_marked_spec_round_trips_through_json_as_a_container_workload() {
4468        // Mirrors the on-disk envelope: the externally-tagged `container`
4469        // variant wrapping the WorkloadSpec, exactly like every other
4470        // container-shaped workload. No new Workload variant, no new
4471        // discriminator.
4472        let mut inner = archetype_test_spec("public-ingress");
4473        inner.archetype = Some(LifecycleArchetype::Appliance);
4474        inner.annotations.insert(
4475            REQUIRES_TAINT_ANNOTATION.to_string(),
4476            PUBLIC_IP_TAINT.to_string(),
4477        );
4478        let workload = Workload::container(inner.clone());
4479
4480        let json = serde_json::to_string(&workload).expect("serialize");
4481        assert!(json.contains("\"container\""));
4482        assert!(json.contains(REQUIRES_TAINT_ANNOTATION));
4483        assert!(json.contains(PUBLIC_IP_TAINT));
4484
4485        let back: Workload = serde_json::from_str(&json).expect("deserialize");
4486        match back.container_spec() {
4487            Some(spec) => {
4488                assert_eq!(spec, &inner);
4489                assert_eq!(spec.effective_archetype(), LifecycleArchetype::Appliance);
4490                assert_eq!(spec.requires_taint(), Some(PUBLIC_IP_TAINT));
4491            }
4492            None => panic!("expected a container reference workload, got {back:?}"),
4493        }
4494    }
4495
4496    // ── Nested-sandbox grant (R636-B2) ──────────────────────────────────────
4497
4498    #[test]
4499    fn nested_sandbox_marker_is_opt_in_and_reads_back() {
4500        // The half that matters: no workload gets the grant by default, so
4501        // adding the marker cannot widen anything already deployed.
4502        let plain = archetype_test_spec("ordinary-build");
4503        assert!(!plain.wants_nested_sandbox());
4504
4505        let mut buildkit = archetype_test_spec("build-image");
4506        buildkit.annotations.insert(
4507            NESTED_SANDBOX_ANNOTATION.to_string(),
4508            NESTED_SANDBOX_VALUE.to_string(),
4509        );
4510        assert!(buildkit.wants_nested_sandbox());
4511
4512        // Fails closed on any other value, same strictness as
4513        // `wants_host_network` — a typo must not hand out CAP_SETUID.
4514        let mut typo = archetype_test_spec("typo");
4515        typo.annotations
4516            .insert(NESTED_SANDBOX_ANNOTATION.to_string(), "Nested".to_string());
4517        assert!(!typo.wants_nested_sandbox());
4518    }
4519
4520    /// The three markers are independent axes: asking for host networking or
4521    /// native exec must not imply the capability grant, and vice versa.
4522    #[test]
4523    fn nested_sandbox_marker_is_independent_of_the_other_markers() {
4524        let mut host_net = archetype_test_spec("host-net");
4525        host_net.annotations.insert(
4526            HOST_NETWORK_ANNOTATION.to_string(),
4527            HOST_NETWORK_VALUE.to_string(),
4528        );
4529        assert!(host_net.wants_host_network());
4530        assert!(!host_net.wants_nested_sandbox());
4531
4532        let mut nested = archetype_test_spec("nested");
4533        nested.annotations.insert(
4534            NESTED_SANDBOX_ANNOTATION.to_string(),
4535            NESTED_SANDBOX_VALUE.to_string(),
4536        );
4537        assert!(nested.wants_nested_sandbox());
4538        assert!(!nested.wants_host_network());
4539        assert!(!nested.wants_native_exec());
4540    }
4541
4542    // ── Native exec marker (R577-T1 / W254) ─────────────────────────────────
4543
4544    #[test]
4545    fn native_exec_marker_is_opt_in_and_reads_back() {
4546        // Default: every forge workload is a container workload. This is the
4547        // half that matters most — the marker must not silently reroute the
4548        // Linux offload leg proven live on us-west-002.
4549        let plain = archetype_test_spec("linux-build");
4550        assert!(!plain.wants_native_exec());
4551
4552        let mut native = archetype_test_spec("darwin-build");
4553        native.annotations.insert(
4554            NATIVE_EXEC_ANNOTATION.to_string(),
4555            NATIVE_EXEC_VALUE.to_string(),
4556        );
4557        assert!(native.wants_native_exec());
4558
4559        // Any other value is not the opt-in — same strictness as
4560        // `wants_host_network`, so a typo fails closed onto the container
4561        // backend rather than escaping the sandbox.
4562        let mut typo = archetype_test_spec("typo");
4563        typo.annotations
4564            .insert(NATIVE_EXEC_ANNOTATION.to_string(), "Native".to_string());
4565        assert!(!typo.wants_native_exec());
4566    }
4567
4568    #[test]
4569    fn native_marked_spec_round_trips_through_json_as_a_container_workload() {
4570        // The point of the annotation shape: a native workload is still a
4571        // `Workload::Container` on the wire, so kamaji-proto's codec, yubaba
4572        // admission and the mesh-assignment path need no new variant.
4573        let mut inner = archetype_test_spec("darwin-build");
4574        inner.annotations.insert(
4575            NATIVE_EXEC_ANNOTATION.to_string(),
4576            NATIVE_EXEC_VALUE.to_string(),
4577        );
4578        let workload = Workload::container(inner.clone());
4579
4580        let json = serde_json::to_string(&workload).expect("serialize");
4581        assert!(json.contains(NATIVE_EXEC_ANNOTATION));
4582
4583        let back: Workload = serde_json::from_str(&json).expect("deserialize");
4584        match back.container_spec() {
4585            Some(spec) => {
4586                assert_eq!(spec, &inner);
4587                assert!(spec.wants_native_exec());
4588            }
4589            None => panic!("expected a container reference workload, got {back:?}"),
4590        }
4591    }
4592
4593    // ── MicroVM marker (R605-F8 / W325 §5) ──────────────────────────────────
4594
4595    #[test]
4596    fn microvm_marker_is_opt_in_and_reads_back() {
4597        let plain = archetype_test_spec("linux-build");
4598        assert!(!plain.wants_microvm());
4599
4600        let mut vm = archetype_test_spec("isolated-build");
4601        vm.annotations.insert(
4602            NATIVE_EXEC_ANNOTATION.to_string(),
4603            MICROVM_EXEC_VALUE.to_string(),
4604        );
4605        assert!(vm.wants_microvm());
4606
4607        // Fails closed onto the container backend, like every other marker: a
4608        // typo must not be read as "boot a VM", because the deploy that would
4609        // then be refused for lack of a microVM backend is a *worse* failure
4610        // than the container run the author actually spelled.
4611        let mut typo = archetype_test_spec("typo");
4612        typo.annotations
4613            .insert(NATIVE_EXEC_ANNOTATION.to_string(), "MicroVM".to_string());
4614        assert!(!typo.wants_microvm());
4615        assert!(!typo.wants_native_exec());
4616    }
4617
4618    #[test]
4619    fn exec_substrate_markers_are_mutually_exclusive_by_construction() {
4620        // This is the property that buys R605-F8 out of a refusal branch: the
4621        // three substrates share one annotation key, so no spec can ask for two
4622        // of them. Pinned because a later "let's give microVM its own key"
4623        // refactor would silently re-open the incoherent-pair case that
4624        // `yah.sandbox` + `yah.exec = native` still has to be refused for.
4625        assert_eq!(
4626            NATIVE_EXEC_ANNOTATION, NATIVE_EXEC_ANNOTATION,
4627            "both substrate values must live on the same key"
4628        );
4629        assert_ne!(NATIVE_EXEC_VALUE, MICROVM_EXEC_VALUE);
4630
4631        for value in [NATIVE_EXEC_VALUE, MICROVM_EXEC_VALUE, "", "container"] {
4632            let mut spec = archetype_test_spec("substrate");
4633            spec.annotations
4634                .insert(NATIVE_EXEC_ANNOTATION.to_string(), value.to_string());
4635            assert!(
4636                !(spec.wants_native_exec() && spec.wants_microvm()),
4637                "yah.exec={value:?} selected two substrates at once"
4638            );
4639        }
4640    }
4641
4642    #[test]
4643    fn microvm_marked_spec_round_trips_through_json_as_a_container_workload() {
4644        // Same zero-blast-radius claim as the native case: a microVM workload
4645        // is still `Workload::Container` on the wire, so kamaji-proto's codec
4646        // gains no variant and its positional postcard encoding does not move.
4647        let mut inner = archetype_test_spec("isolated-build");
4648        inner.annotations.insert(
4649            NATIVE_EXEC_ANNOTATION.to_string(),
4650            MICROVM_EXEC_VALUE.to_string(),
4651        );
4652        let workload = Workload::container(inner.clone());
4653
4654        let json = serde_json::to_string(&workload).expect("serialize");
4655        assert!(json.contains(MICROVM_EXEC_VALUE));
4656
4657        let back: Workload = serde_json::from_str(&json).expect("deserialize");
4658        match back.container_spec() {
4659            Some(spec) => {
4660                assert_eq!(spec, &inner);
4661                assert!(spec.wants_microvm());
4662                assert!(!spec.wants_native_exec());
4663            }
4664            None => panic!("expected a container reference workload, got {back:?}"),
4665        }
4666    }
4667}