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//!
262//! @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)")
263//! @yah:status(review)
264//! @yah:assignee(agent:bundle-anthropic-glimmerstone)
265//! @yah:at(2026-07-23T17:47:24Z)
266//! @yah:kind(spike)
267//! @yah:phase(P3)
268//! @yah:parent(R626)
269//! @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.")
270//! @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.")
271//! @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.")
272//! @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.")
273//! @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.")
274//! @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.")
275//! @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'.")
276//! @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.")
277//! @yah:next("Wire DesiredStateStore::forget into the undeclare path so the document doesn't accumulate intent for mirrors that no longer exist.")
278//! @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")
279//! @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)")
280//! @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.")
281//! @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.")
282//! @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).")
283//!
284//!
285//! @yah:relay(R658, "workload.toml envelope: two type-vs-reality mismatches R546-B7 uncovered but did not fix")
286//! @yah:at(2026-08-03T00:43:00Z)
287//! @yah:status(open)
288//! @yah:assignee(agent:bundle-anthropic-ashguard)
289//! @yah:parent(R546)
290//!
291//! @yah:ticket(R658-B1, "MesofactStaticWorkload.routes is a required top-level field, but every real file and the CLI scaffold write it inside [build]")
292//! @yah:at(2026-08-03T00:43:04Z)
293//! @yah:status(open)
294//! @yah:assignee(agent:bundle-anthropic-ashguard)
295//! @yah:parent(R658)
296//! @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.")
297//! @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.")
298//! @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.")
299//! @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.")
300//! @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.")
301//! @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.")
302//!
303//! @yah:ticket(R658-B2, "kind = \"container\" means two incompatible shapes — Workload::Container(WorkloadSpec) vs ContainerReconciler's local docker build/run")
304//! @yah:at(2026-08-03T00:43:24Z)
305//! @yah:status(open)
306//! @yah:assignee(agent:bundle-anthropic-ashguard)
307//! @yah:parent(R658)
308//! @yah:next("REPRO: `cargo test -p xtask --test workload_envelope` with the KNOWN_GAPS entry for crates/yah/cloud-admin/workload.toml deleted -> `missing field `image``.")
309//! @yah:next("THE COLLISION: workload_spec::Workload maps `kind = \"container\"` to WorkloadSpec -- an OCI spec with a required digest-pinned `image`, handed to yubaba over the kamaji wire. ContainerReconciler (oss/yubaba/crates/cloud/src/reconciler/container.rs, R602-T1) reads the SAME discriminator as a local-docker shape: `[build] dockerfile/context/image` + `[run] port/host_port/[run.env]`. crates/yah/cloud-admin/workload.toml is the second shape and cannot parse as the first.")
310//! @yah:next("SO ONE `kind` STRING SELECTS TWO INCOMPATIBLE SCHEMAS, and which one you get depends on which loader happens to read the file. Nothing detects the mismatch -- ContainerReconciler parses its own struct, so the envelope is never consulted for these files.")
311//! @yah:next("DECISION NEEDED (operator call, do not pick unilaterally): (a) rename the local-docker kind (e.g. `local-container`) and migrate crates/yah/cloud-admin/workload.toml + the ContainerReconciler dispatch, or (b) model the local-docker shape as a second Workload variant so the envelope covers both, or (c) declare the envelope non-authoritative for `container` and document it. (a) is the smallest and the only one that makes the discriminator honest.")
312//! @yah:next("AFTER FIXING: delete the `missing field `image`` entry from KNOWN_GAPS in xtask/tests/workload_envelope.rs.")
313//!
314//! @yah:ticket(R743-T4, "workload-spec: 7 test binaries to 1")
315//! @yah:at(2026-08-11T01:18:24Z)
316//! @yah:status(open)
317//! @yah:phase(P2)
318//! @yah:parent(R743)
319//! @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.")
320//! @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).")
321//! @yah:verify("cargo test -p yah-workload-spec -- --list count unchanged; three green runs. One commit — oss subtree.")
322//! @yah:tier(Cleric)
323
324use std::collections::BTreeMap;
325use std::collections::HashMap;
326use std::path::PathBuf;
327
328use serde::{Deserialize, Serialize};
329use ts_rs::TS;
330
331pub mod compose_import;
332pub mod control_plane_install;
333pub mod rollout;
334pub mod secrets;
335pub mod sovereign;
336pub mod validate;
337mod version;
338
339pub use version::SchemaVersion;
340
341// ── Duration ──────────────────────────────────────────────────────────────────
342
343/// Duration expressed as an integer millisecond count.
344///
345/// Used for healthcheck intervals, timeouts, delays, and stop grace periods.
346/// Chosen over `std::time::Duration` to keep serde support dependency-free.
347#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, TS)]
348#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
349#[ts(type = "number")]
350pub struct Millis(pub u64);
351
352impl Millis {
353 pub fn from_secs(s: u64) -> Self {
354 Self(s * 1000)
355 }
356
357 pub fn from_ms(ms: u64) -> Self {
358 Self(ms)
359 }
360
361 pub fn as_ms(self) -> u64 {
362 self.0
363 }
364
365 pub fn as_secs_f64(self) -> f64 {
366 self.0 as f64 / 1000.0
367 }
368}
369
370// ── Primitive newtypes ────────────────────────────────────────────────────────
371
372/// Opaque identifier for a yubaba-managed machine within the cluster.
373///
374/// Used by the semantic validation layer for admission-control capacity checks.
375/// Yubaba passes its own machine ID when validating a spec before deployment.
376#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, TS)]
377#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
378pub struct MachineId(pub String);
379
380/// DNS-segment identity for a workload on the cluster mesh, e.g.
381/// `"noisetable-api.pdx"`. Regex constraint: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`,
382/// length ≤ 63. Enforced in shape validation (R090-F2).
383#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
384#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
385pub struct MeshIdent(pub String);
386
387/// Tier classification that governs admission control and mesh `allow_from`
388/// filtering. Known values: `"public"`, `"tenant"`, `"private"`, `"infra"`.
389/// Custom tiers are allowed per cluster; shape validation warns on unknowns
390/// rather than rejecting them (R090-F2).
391#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
392#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
393pub struct TierTag(pub String);
394
395/// Default single-tenant identity written to specs that predate the tenant
396/// axis (W206). Its concrete string is arbitrary — what matters is that a
397/// single-tenant cluster only ever sees this one value, so every per-tenant
398/// isolation primitive collapses to a no-op. See [`TenantId::singleton`].
399pub const DEFAULT_TENANT: &str = "default";
400
401/// Default single-namespace identity for specs that predate the namespace
402/// axis (W206). See [`NamespaceId::singleton`].
403pub const DEFAULT_NAMESPACE: &str = "default";
404
405/// Tenant **isolation** axis (W206). Separates one operator's workloads from
406/// another's at the network / DB / mesh-identity level. Orthogonal to
407/// [`NamespaceId`] (routing/naming) and [`TierTag`] (workload class within a
408/// `(tenant, namespace)` pair).
409///
410/// **Degenerate case:** when a yubaba reconciler sees only one `TenantId`
411/// across every workload on a machine, per-tenant Podman networks collapse
412/// into the shared tier networks, the tenant prefix on mesh identity is
413/// dropped, and PostgreSQL role separation is skipped — isolation primitives
414/// become no-ops. You pay only when more than one tenant is present. Specs
415/// written before this axis existed deserialize to [`TenantId::singleton`].
416#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, TS)]
417#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
418pub struct TenantId(pub String);
419
420impl TenantId {
421 /// The singleton tenant used for back-compat with single-tenant (current)
422 /// deployments. Specs written before the tenant axis existed deserialize
423 /// to this value via the `#[serde(default)]` on [`WorkloadSpec::tenant`],
424 /// keeping the whole cluster single-tenant so every isolation primitive
425 /// stays a no-op.
426 pub fn singleton() -> Self {
427 Self(DEFAULT_TENANT.to_string())
428 }
429
430 /// Whether this is the singleton (degenerate single-tenant) identity.
431 pub fn is_singleton(&self) -> bool {
432 self.0 == DEFAULT_TENANT
433 }
434}
435
436/// Namespace **routing/naming** axis (W206). A pure naming key that never
437/// affects isolation: it selects the config root, disambiguates service DNS
438/// names within a tenant, prefixes object-store bucket names within a tenant's
439/// bucket scope, and selects the provider zone (e.g. `noisetable.com` vs
440/// `yah.dev`). Two namespaces in the same tenant share networks, mesh-identity
441/// space, and PG cluster — they simply cannot collide on workload names or
442/// external domains. Specs written before this axis existed deserialize to
443/// [`NamespaceId::singleton`].
444#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, TS)]
445#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
446pub struct NamespaceId(pub String);
447
448impl NamespaceId {
449 /// The singleton namespace used for back-compat with single-namespace
450 /// (current) deployments. Specs written before the namespace axis existed
451 /// deserialize to this value via the `#[serde(default)]` on
452 /// [`WorkloadSpec::namespace`].
453 pub fn singleton() -> Self {
454 Self(DEFAULT_NAMESPACE.to_string())
455 }
456
457 /// Whether this is the singleton (degenerate single-namespace) identity.
458 pub fn is_singleton(&self) -> bool {
459 self.0 == DEFAULT_NAMESPACE
460 }
461}
462
463// ── Workload (on-disk envelope) ──────────────────────────────────────────────
464
465/// On-disk `workload.toml` manifest. Each variant matches one
466/// `ServiceComponent.kind` value; the `kind` field on the wire is the serde
467/// discriminator.
468///
469/// This is the **on-disk** envelope — distinct from [`WorkloadSpec`], the
470/// containerd wire format yubaba receives over RPC. A `kind = "container"`
471/// workload deserializes its remaining fields as a `WorkloadSpec`; other
472/// kinds carry their own per-reconciler payload shape.
473///
474/// **Never put `#[serde(skip_serializing_if = "Option::is_none")]` on a field
475/// of this enum or any type it reaches.** These types ride the kamaji-proto
476/// **postcard** wire, which is non-self-describing and positional:
477/// `skip_serializing_if` omits the field's byte on serialize while decode still
478/// expects to read it at that offset, so the byte stream misaligns and the
479/// round-trip fails. Use `#[serde(default)]` + `#[ts(optional = nullable)]`
480/// instead — that still gives TOML/JSON back-compat (missing field → `None`)
481/// while the field is always encoded. `MesofactStaticWorkload::ssr_runtime` and
482/// `::serve_bundle` are the reference shape.
483/// **Two wire shapes, one type (R546-B7).** `Serialize`/`Deserialize` are
484/// hand-written and branch on [`is_human_readable`](serde::Deserializer::is_human_readable):
485///
486/// - **TOML/JSON (human-readable)** → *internally* tagged on `kind`, i.e. the
487/// flat shape every on-disk `workload.toml` actually uses
488/// (`kind = "static-asset"` beside `schema_version`, `[[asset]]`, `[aliases]`).
489/// - **postcard (binary)** → *externally* tagged, byte-identical to the derived
490/// representation R590-B3 established for the kamaji UDS.
491///
492/// Why not just `#[serde(tag = "kind")]`: internal tagging buffers through
493/// `deserialize_any`, which postcard (non-self-describing) refuses with
494/// `WontImplement` — that is exactly the failure R590-B3 fixed by flipping this
495/// enum to external tagging. But external tagging wants a single-key map, so
496/// every flat on-disk file then failed with `wanted exactly 1 element, more
497/// than 1 element` and `yah cloud apply` broke for every static-asset
498/// component. Branching on the format satisfies both, and mirrors what
499/// [`ImageRef`] already does for its string-vs-struct form.
500#[derive(Debug, Clone, PartialEq, TS)]
501#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
502#[cfg_attr(
503 feature = "json-schema",
504 schemars(tag = "kind", rename_all = "kebab-case")
505)]
506#[ts(tag = "kind", rename_all = "kebab-case")]
507pub enum Workload {
508 /// Static-site build that publishes an artifact directory to the
509 /// service's `static` provider slot. Reconciled by the
510 /// `mesofact-static` reconciler — does not deploy to yubaba.
511 MesofactStatic(MesofactStaticWorkload),
512
513 /// Containerd workload handed to yubaba over RPC. The inline fields
514 /// are the full [`WorkloadSpec`] minus the `kind` discriminator.
515 ///
516 /// This is also the shape of the W267 sovereign-public-ingress appliance
517 /// (R594-F2): a container-kind workload with `archetype =
518 /// Some(LifecycleArchetype::Appliance)` and
519 /// `requires_taint() == Some(PUBLIC_IP_TAINT)`, **not** a dedicated
520 /// `Workload::ingress(..)` variant. It runs an ordinary OCI image (the
521 /// `passway` proxy, R594-F4) supervised by kamaji exactly like any other
522 /// `Container`, so no admission-list or wire-codec change was needed to
523 /// let kamaji accept it. A new enum variant would have forced an
524 /// exhaustive-match update in every `Workload` consumer, including
525 /// peer-owned `kamaji-proto/src/codec.rs` — the archetype + annotation
526 /// combination expresses "this is the public ingress appliance" without
527 /// that blast radius. See [`WorkloadSpec::requires_taint`] and
528 /// [`LifecycleArchetype::Appliance`].
529 Container(WorkloadSpec),
530
531 /// Data-pipeline job with declared I/O and a readiness policy. The
532 /// orchestrator checks all `inputs` are reachable before each run and
533 /// verifies `outputs` afterward. Generalises the OpenRouter JSON-cache
534 /// refresher (`spawn_almanac_refresher`) to the full manifest form.
535 Almanac(AlmanacManifest),
536
537 /// Content-addressed static files uploaded to the mirror's `object_store`
538 /// provider slot. Wave-0 by default — gating mesofact and container waves.
539 /// Rollback is a pointer-flip via `mirror.toml [asset_aliases]`; bytes are
540 /// append-only and never re-pushed on rollback. See W160.
541 StaticAsset(StaticAssetWorkload),
542}
543
544impl Workload {
545 /// The `kind` discriminator this variant serializes as — the same string a
546 /// `workload.toml` writes and a `ServiceComponent.kind` names.
547 ///
548 /// Lives here rather than at a call site because this enum now has FIVE
549 /// places that enumerate its variants (itself plus the four tagging
550 /// mirrors below); a caller-local match would be a sixth, in another crate,
551 /// with nothing to force it to keep up.
552 pub fn kind_str(&self) -> &'static str {
553 match self {
554 Workload::MesofactStatic(_) => "mesofact-static",
555 Workload::Container(_) => "container",
556 Workload::Almanac(_) => "almanac",
557 Workload::StaticAsset(_) => "static-asset",
558 }
559 }
560}
561
562/// Internally-tagged mirror of [`Workload`] — the on-disk shape. Only ever
563/// reached on the human-readable branch, so its `deserialize_any` buffering is
564/// never asked of postcard.
565#[derive(Serialize, Deserialize)]
566#[serde(tag = "kind", rename_all = "kebab-case")]
567enum WorkloadTagged {
568 MesofactStatic(MesofactStaticWorkload),
569 Container(WorkloadSpec),
570 Almanac(AlmanacManifest),
571 StaticAsset(StaticAssetWorkload),
572}
573
574/// Borrowing twin of [`WorkloadTagged`] so `Serialize` need not clone the
575/// payload. Variant order must match [`Workload`].
576#[derive(Serialize)]
577#[serde(tag = "kind", rename_all = "kebab-case")]
578enum WorkloadTaggedRef<'a> {
579 MesofactStatic(&'a MesofactStaticWorkload),
580 Container(&'a WorkloadSpec),
581 Almanac(&'a AlmanacManifest),
582 StaticAsset(&'a StaticAssetWorkload),
583}
584
585/// Externally-tagged mirror — the postcard wire shape R590-B3 established.
586/// postcard encodes an external tag as the *variant index*, so the variant
587/// ORDER here is load-bearing: it must match [`Workload`] exactly or the
588/// kamaji UDS silently decodes into the wrong variant.
589#[derive(Serialize, Deserialize)]
590#[serde(rename_all = "kebab-case")]
591enum WorkloadExternal {
592 MesofactStatic(MesofactStaticWorkload),
593 Container(WorkloadSpec),
594 Almanac(AlmanacManifest),
595 StaticAsset(StaticAssetWorkload),
596}
597
598/// Borrowing twin of [`WorkloadExternal`]. Same order requirement.
599#[derive(Serialize)]
600#[serde(rename_all = "kebab-case")]
601enum WorkloadExternalRef<'a> {
602 MesofactStatic(&'a MesofactStaticWorkload),
603 Container(&'a WorkloadSpec),
604 Almanac(&'a AlmanacManifest),
605 StaticAsset(&'a StaticAssetWorkload),
606}
607
608impl Serialize for Workload {
609 fn serialize<S>(&self, s: S) -> Result<S::Ok, S::Error>
610 where
611 S: serde::Serializer,
612 {
613 if s.is_human_readable() {
614 match self {
615 Workload::MesofactStatic(w) => WorkloadTaggedRef::MesofactStatic(w),
616 Workload::Container(w) => WorkloadTaggedRef::Container(w),
617 Workload::Almanac(w) => WorkloadTaggedRef::Almanac(w),
618 Workload::StaticAsset(w) => WorkloadTaggedRef::StaticAsset(w),
619 }
620 .serialize(s)
621 } else {
622 match self {
623 Workload::MesofactStatic(w) => WorkloadExternalRef::MesofactStatic(w),
624 Workload::Container(w) => WorkloadExternalRef::Container(w),
625 Workload::Almanac(w) => WorkloadExternalRef::Almanac(w),
626 Workload::StaticAsset(w) => WorkloadExternalRef::StaticAsset(w),
627 }
628 .serialize(s)
629 }
630 }
631}
632
633impl<'de> Deserialize<'de> for Workload {
634 fn deserialize<D>(de: D) -> Result<Self, D::Error>
635 where
636 D: serde::Deserializer<'de>,
637 {
638 if de.is_human_readable() {
639 Ok(match WorkloadTagged::deserialize(de)? {
640 WorkloadTagged::MesofactStatic(w) => Workload::MesofactStatic(w),
641 WorkloadTagged::Container(w) => Workload::Container(w),
642 WorkloadTagged::Almanac(w) => Workload::Almanac(w),
643 WorkloadTagged::StaticAsset(w) => Workload::StaticAsset(w),
644 })
645 } else {
646 Ok(match WorkloadExternal::deserialize(de)? {
647 WorkloadExternal::MesofactStatic(w) => Workload::MesofactStatic(w),
648 WorkloadExternal::Container(w) => Workload::Container(w),
649 WorkloadExternal::Almanac(w) => Workload::Almanac(w),
650 WorkloadExternal::StaticAsset(w) => Workload::StaticAsset(w),
651 })
652 }
653 }
654}
655
656/// `kind = "mesofact-static"` payload — static-site build colocated with the
657/// frontend it deploys.
658///
659/// The two-role model (R256-F7): a build/publish step plus an optional
660/// SSR/SPA runtime companion. The build step is always transient (runs once,
661/// publishes, exits). The companion is long-lived and only present when the
662/// app has dynamic/server-rendered pages; pure static sites leave it `None`.
663#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
664#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
665pub struct MesofactStaticWorkload {
666 /// Wire-format version. Always `V1` today.
667 pub schema_version: SchemaVersion,
668
669 /// Build command + output directory.
670 pub build: BuildConfig,
671
672 /// Path (relative to the manifest) of the routes module the
673 /// `mesofact-static` reconciler reads to enumerate routes.
674 pub routes: PathBuf,
675
676 /// Where the build command runs. Default: `HostSide` (mesofact-dev on the
677 /// host). Set to `InContainer` for cloud/HA where no host watcher is
678 /// present and CI-fidelity build environments are required.
679 #[serde(default)]
680 pub build_mode: BuildMode,
681
682 /// Optional SSR/SPA runtime companion container.
683 ///
684 /// `None` → pure static site; Caddy (or equivalent CDN) serves all
685 /// requests directly from the object store. This is the common case for
686 /// dev-yah today.
687 ///
688 /// `Some` → the workload spec describes a long-lived container that
689 /// handles dynamic/SSR requests. Caddy routes static asset paths to
690 /// the object store and all other paths to this container. The companion
691 /// uses `RestartPolicy::Always`; the orchestrator (camp or yubaba)
692 /// ensures it stays up alongside the Caddy edge.
693 #[ts(optional = nullable)]
694 pub ssr_runtime: Option<WorkloadSpec>,
695
696 /// Serve-time reference to a published W272 bundle (R599-F4).
697 ///
698 /// `Some` → the built app is deployed as a content-addressed bundle that
699 /// kamaji materializes from the bundle store (R599-F1) and serves via its
700 /// native backend, instead of (or in addition to) the build reconciler
701 /// pushing `dist/` to the object-store/CDN. `None` → legacy
702 /// build-and-publish-only workload — kamaji rejects that form as yubaba's
703 /// `mesofact-static` reconciler's responsibility.
704 ///
705 /// No `skip_serializing_if`: like `ssr_runtime`, this field is always
706 /// encoded so the postcard wire codec (non-self-describing, positional)
707 /// round-trips — `skip_serializing_if` would omit the byte on serialize
708 /// while decode still expects it. `#[serde(default)]` keeps every existing
709 /// `mesofact-static` TOML/JSON that predates this field parsing to `None`.
710 #[serde(default)]
711 #[ts(optional = nullable)]
712 pub serve_bundle: Option<MesofactServeBundle>,
713
714 /// Revalidate receiver for the almanac push model (R330-F12).
715 ///
716 /// `Some` → kamaji also forks `mesofact serve --revalidate <workload>`
717 /// alongside the bundle's static serve (or in place of it when
718 /// `serve_bundle` is `None`). The receiver is ephemeral-V8: each
719 /// `POST /dawn` boots a V8 isolate, re-renders the route, republishes to
720 /// the CDN, then drops the isolate. (`/revalidate` is still served as a
721 /// transitional alias — yah R752-T10 renamed it so the render stage stops
722 /// sharing a path with almanac's feed-refetch stage, `POST /freshen`.)
723 ///
724 /// Env vars are resolved at deploy time (R2 creds + mirror bearer) so
725 /// the node never sees keystore slot names.
726 #[serde(default)]
727 #[ts(optional = nullable)]
728 pub revalidate_receiver: Option<MesofactRevalidateReceiver>,
729}
730
731/// Revalidate receiver config (R330-F12) — tells kamaji to fork a second
732/// `mesofact serve --revalidate` process alongside the static bundle server.
733///
734/// The receiver is the almanac push endpoint: a lightweight resident axum
735/// server mounting `POST /dawn` (plus the legacy `/revalidate` alias) that
736/// boots V8 on each poke, re-renders the invalidated route, publishes to
737/// R2/CDN, then drops the isolate.
738#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
739#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
740pub struct MesofactRevalidateReceiver {
741 /// Routes the receiver accepts pokes for (allowlist).
742 /// Empty vec → all routes in the workload's manifest are revalidatable.
743 #[serde(default)]
744 pub routes: Vec<String>,
745
746 /// Path to `mesofact.config.toml` carrying the `[publish]` block
747 /// (bucket / zone / env-named credentials). Relative to the workload
748 /// directory. Default: `"mesofact.config.toml"`.
749 #[serde(default = "default_publish_config_path")]
750 pub publish_config: String,
751
752 /// Env var name holding the bearer secret for this tenant, resolved
753 /// at deploy time and set as `MESOFACT_MIRROR_KEY` on the receiver
754 /// process. `None` → open receiver (no bearer check).
755 #[ts(optional = nullable)]
756 pub mirror_key_env: Option<String>,
757
758 /// Environment variables set on the revalidate process by kamaji.
759 /// Keys are the canonical env var names (`MESOFACT_S3_ACCESS_KEY_ID`,
760 /// `MESOFACT_S3_SECRET_ACCESS_KEY`, `CLOUDFLARE_API_TOKEN`,
761 /// `MESOFACT_MIRROR_KEY`). Values are resolved from the keystore at
762 /// deploy time — the node never sees slot names.
763 #[serde(default)]
764 pub env: std::collections::BTreeMap<String, String>,
765
766 /// Feed-fetch tier (R330-F31) — the almanac feeds whose artifacts must be
767 /// refreshed **on the node** for a poke to have anything new to render.
768 ///
769 /// Empty → no fetcher; the receiver re-renders whatever data the bundle was
770 /// built with (correct for a site whose data only changes at build time,
771 /// silently stale for one whose data is a live feed). Non-empty → kamaji
772 /// forks a third resident process, the `almanac-feed` fetcher, next to the
773 /// receiver — resolved from the bundle's `bins/<triple>/almanac-feed` when
774 /// it carries one, else from [`feed_runtime`](Self::feed_runtime).
775 #[serde(default)]
776 pub feeds: Vec<AlmanacFeed>,
777
778 /// Runtime ref the `almanac-feed` fetcher resolves from the node's shared
779 /// runtime-asset cache when the bundle carries no `bins/` (R746-T3), e.g.
780 /// `"almanac-feed/0.8.22"`.
781 ///
782 /// This is what lets a **vanilla** bundle have a feed tier at all. A
783 /// self-contained bundle stages the fetcher into `bins/` and stays closed
784 /// over it; a vanilla bundle carries no binaries by construction, so the
785 /// fetcher has to be a node-level asset for the same reason `serve` is —
786 /// otherwise a templates-only sync would still need a cross-built musl
787 /// binary sitting on the syncing machine's disk.
788 ///
789 /// `None` with `feeds` non-empty and no sidecar in the bundle is a deploy
790 /// failure, named at the node. It is not a silent skip: "the site serves
791 /// but its data is frozen" is the exact state R330-F31 exists to make
792 /// observable.
793 #[serde(default)]
794 #[ts(optional = nullable)]
795 pub feed_runtime: Option<String>,
796
797 /// Seconds between feed-fetch ticks. Ignored when `feeds` is empty.
798 ///
799 /// This is the site's freshness bound: a release lands, and the next tick
800 /// refreshes + pokes. `FeedRunner`'s change-suppression means an idle tick
801 /// costs one conditional fetch, so a short interval is affordable.
802 #[serde(default = "default_feed_interval_secs")]
803 pub feed_interval_secs: u64,
804
805 /// Workspace-relative path of the component whose build produced this
806 /// bundle, e.g. `app/yah/web/marketing` (R330-F31).
807 ///
808 /// Reconciles two roots for one file: a feed declares `emit.artifact`
809 /// workspace-relative (that is where it is authored), while the route
810 /// declares the same file project-relative (that is what the bundle
811 /// carries). The fetcher strips this prefix to get from one to the other.
812 /// `None` → the two already coincide.
813 #[serde(default)]
814 #[ts(optional = nullable)]
815 pub feed_project_prefix: Option<String>,
816}
817
818/// One almanac feed handed to the on-node fetcher (R330-F31).
819///
820/// The definition travels **by value**, not by path: the node has no copy of
821/// the camp's `.yah/almanac/` tree, and staging one into the content-addressed
822/// bundle would put a mutable-by-nature config inside an immutable artifact.
823/// The fetcher parses `config_toml` with the same `FeedConfig` type that reads
824/// the file at the source, so there is one schema and no drift.
825#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
826#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
827pub struct AlmanacFeed {
828 /// Feed name — the `.yah/almanac/<name>.toml` stem. Logs/diagnostics only;
829 /// `config_toml` is authoritative.
830 pub name: String,
831
832 /// Verbatim contents of the feed definition TOML.
833 pub config_toml: String,
834}
835
836fn default_publish_config_path() -> String {
837 "mesofact.config.toml".to_string()
838}
839
840/// Five minutes: fast enough that a release is live on yah.dev before anyone
841/// goes looking, slow enough to be invisible against a source API's rate limit.
842fn default_feed_interval_secs() -> u64 {
843 300
844}
845
846/// Serve-time reference to a published W272 bundle (R599-F4) — the
847/// `{bundle_digest, runtime, lifecycle}` triple a `mesofact-static` workload
848/// carries when kamaji, not the build reconciler, serves it.
849#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
850#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
851pub struct MesofactServeBundle {
852 /// BLAKE3 digest of the published bundle manifest — the content-address
853 /// kamaji materializes from the bundle store (`yah_mesofact_bundle`,
854 /// R599-F1). Same 64-hex shape the bundle crate's `BundleHash` validates.
855 pub digest: BlakeHash,
856
857 /// Runtime that serves the bundle: `"self"` (bundle ships its own
858 /// `bins/<triple>/serve`) or `"mesofact/<version>"` (resolve the stock
859 /// serve runtime asset from the node cache). Wire-mirrors
860 /// `yah_mesofact_bundle::BundleRuntime`; kept as a plain `String` here so
861 /// workload-spec stays free of the bundle crate and its non-TS/schema
862 /// newtypes.
863 pub runtime: String,
864
865 /// How kamaji supervises the served bundle. Default: keep-alive.
866 #[serde(default)]
867 pub lifecycle: BundleLifecycle,
868
869 /// Port the served bundle listens on (R599-F12). This is the bundle-tier
870 /// analogue of a container's `expose.mesh.ports`: the *declared* serving
871 /// port, which a proxy pairs with the workload's mesh IP to get a dialable
872 /// address.
873 ///
874 /// `None` → kamaji falls back to its node-wide default
875 /// (`KAMAJI_BUNDLE_PORT` / `DEFAULT_BUNDLE_PORT` = 8080), which is the
876 /// pre-R599-F12 behaviour and is only correct while a node hosts one
877 /// bundle. Declaring a port per workload is what lets one node host
878 /// several — that singleton default is the thing it removes.
879 ///
880 /// No `skip_serializing_if` — see `serve_bundle`'s note: the postcard wire
881 /// codec is positional, so an omitted byte shifts every later field.
882 #[serde(default)]
883 #[ts(optional = nullable)]
884 pub port: Option<u16>,
885}
886
887/// Lifecycle mode for a served bundle (W272 §3).
888#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
889#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
890#[serde(rename_all = "snake_case")]
891pub enum BundleLifecycle {
892 /// Fork at deploy, keep resident, restart per policy — today's server
893 /// archetype. Memory is resident for the workload's lifetime.
894 KeepAlive,
895
896 /// Kamaji owns the listen socket, forks the runtime on the first connection
897 /// (fd-passing), and reaps it after `idle_ttl` with zero connections — the
898 /// "serverless" tier (zero memory when idle). The JIT fork/reap mechanics
899 /// land in R599-F6; this variant only declares the intent + budget.
900 OnDemand {
901 /// Idle time with no live connections before kamaji reaps the process.
902 idle_ttl: Millis,
903 },
904}
905
906impl Default for BundleLifecycle {
907 /// Keep-alive — the resident server archetype — matches the current
908 /// deploy-and-supervise default.
909 fn default() -> Self {
910 BundleLifecycle::KeepAlive
911 }
912}
913
914/// Build step that produces the static artifact published by a
915/// `mesofact-static` workload.
916#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
917#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
918pub struct BuildConfig {
919 /// Shell command run from the manifest's directory, e.g. `"bun run build"`.
920 pub command: String,
921
922 /// Output directory (relative to the manifest) the reconciler uploads.
923 pub out_dir: PathBuf,
924
925 /// Data-only re-render command (W225 §3 "revalidate"), run from the
926 /// manifest's directory against the **already-built** `out_dir` — no
927 /// bundler. `{route}` is substituted with the invalidated route pattern,
928 /// e.g. `"cargo run -p mesofact-build -- render . --route {route} --all"`.
929 /// Absent → a revalidate dispatch republishes `out_dir` as-is.
930 #[serde(default)]
931 pub render_command: Option<String>,
932}
933
934// ── BuildMode ─────────────────────────────────────────────────────────────────
935
936/// Where the build command runs for a `mesofact-static` workload.
937///
938/// The two-role split encodes the F7 design decision: build/publish is a
939/// **transient job** (runs once, exits, GC'd); SSR/SPA serving is a separate
940/// **long-lived companion container** (optional, only for dynamic pages). A
941/// single merged "mesofact container" is the trap — in cloud, CI builds the
942/// artifact, R2+CDN serve it, and a distinct worker handles any SSR.
943///
944/// Default: `HostSide` — mesofact-dev runs the build on the host and publishes
945/// to the tier's object store. No container overhead; compatible with dev and
946/// sim tiers.
947#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
948#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
949#[serde(rename_all = "snake_case")]
950pub enum BuildMode {
951 /// Build command runs on the host (mesofact-dev watcher). The watcher
952 /// publishes the output to the tier's object store (DistPointer for dev,
953 /// MinIO for sim). Compatible with all tiers; zero container overhead.
954 #[default]
955 HostSide,
956
957 /// Build runs inside a transient container matching the CI image. Higher
958 /// fidelity (environment matches CI exactly); costs image pull +
959 /// container cold-start. Required for cloud/HA where no mesofact-dev
960 /// watcher is running on the host.
961 InContainer {
962 /// Container image that runs the build (e.g. `"ghcr.io/org/app-build:v1.2"`).
963 /// Must have the build toolchain installed. The container is started with
964 /// the workspace root bind-mounted, runs `build.command`, uploads
965 /// `build.out_dir` to the object store, then exits.
966 image: ImageRef,
967 },
968}
969
970// ── AlmanacManifest ───────────────────────────────────────────────────────────
971
972/// An observable endpoint the almanac scheduler probes to check readiness.
973///
974/// Used for both inputs (checked before the run) and outputs (verified after
975/// a successful run to confirm the job produced something reachable).
976/// The probe is intentionally lightweight — no S3 SigV4, no xlb-net discovery
977/// required; a simple TCP connect or HTTP GET is enough for the dev/sim tier.
978#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
979#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
980#[serde(rename_all = "snake_case")]
981pub enum AlmanacTarget {
982 /// Issue an HTTP GET to `url`; ready when the server responds with
983 /// `expect_status` (default: any 2xx).
984 Http {
985 url: String,
986 #[ts(optional = nullable)]
987 expect_status: Option<u16>,
988 },
989
990 /// Establish a TCP connection to `host:port`; ready when the connect
991 /// succeeds. Used for non-HTTP services (e.g. MinIO API on port 9000)
992 /// and as a lighter probe when an HTTP endpoint isn't stable yet.
993 Tcp { host: String, port: u16 },
994}
995
996/// What the almanac scheduler does when a precondition check fails.
997///
998/// The F9 design decision: `WaitWithTimeout` is the default. Fail-fast is
999/// too brittle for the sim tier (containers may still be cold-starting);
1000/// requeue-with-no-ceiling can block the scheduler indefinitely. The
1001/// recommended timeout for sim is the container spinup budget (~5 s cold,
1002/// ~1 s warm): set `timeout` to a few seconds, then let the retry cadence
1003/// handle transient glitches.
1004#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1005#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1006#[serde(rename_all = "snake_case")]
1007pub enum NotReadyPolicy {
1008 /// Wait up to `timeout` for all preconditions to pass before aborting
1009 /// the run. The run is skipped (not rescheduled); the next cadence tick
1010 /// will retry. Suitable when targets occasionally lag at startup.
1011 WaitWithTimeout {
1012 /// How long to wait for each precondition to become reachable. The
1013 /// scheduler polls with a short sleep between attempts.
1014 timeout: Millis,
1015 },
1016
1017 /// Abort immediately if any precondition check fails. Suitable for
1018 /// integration-test harnesses where a missing dependency is always a
1019 /// hard error.
1020 FailFast,
1021
1022 /// Requeue with exponential backoff up to `max_attempts` times. After
1023 /// exhaustion the run is marked failed. Suitable for cloud/HA where
1024 /// transient dependency outages are expected.
1025 Requeue {
1026 /// Maximum number of requeue attempts before the run is marked failed.
1027 max_attempts: u32,
1028 /// Initial backoff between attempts, in milliseconds.
1029 backoff: Millis,
1030 },
1031}
1032
1033impl Default for NotReadyPolicy {
1034 /// Default is `WaitWithTimeout { timeout: 5 seconds }` — matches the
1035 /// container spinup budget for the sim tier (few-second cold, sub-second warm).
1036 fn default() -> Self {
1037 Self::WaitWithTimeout { timeout: Millis::from_secs(5) }
1038 }
1039}
1040
1041/// When the almanac scheduler triggers a run.
1042#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1043#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1044#[serde(rename_all = "snake_case")]
1045pub enum Cadence {
1046 /// Run once at first opportunity, then never again.
1047 Once,
1048
1049 /// Run repeatedly with a fixed interval between the end of one run and
1050 /// the start of the next. Equivalent to `sleep N && run` in a loop.
1051 Every {
1052 /// Minimum time between consecutive run completions.
1053 interval: Millis,
1054 },
1055
1056 /// Run on a UTC cron schedule (standard 5-field expression, e.g.
1057 /// `"0 */6 * * *"` for every 6 hours). The scheduler evaluates the
1058 /// expression relative to UTC midnight.
1059 Cron { expression: String },
1060}
1061
1062/// `kind = "almanac"` manifest — a declared data-pipeline job.
1063///
1064/// An almanac job is the generalisation of the OpenRouter refresher
1065/// (`spawn_almanac_refresher`): it declares its I/O contract explicitly so
1066/// the orchestrator can enforce preconditions before each run and verify
1067/// outputs afterward. The degenerate case (no inputs, no app target, cron
1068/// schedule) is exactly the OpenRouter JSON-cache refresher.
1069///
1070/// Lifecycle:
1071/// 1. Cadence tick fires.
1072/// 2. Scheduler probes every `inputs` target. If any fail → apply
1073/// `not_ready_policy`.
1074/// 3. Command runs (`sh -c command` from the workload directory).
1075/// 4. Scheduler probes every `outputs` target. Failure → mark run as
1076/// failed but do not retry.
1077/// 5. Any workloads listed in `invalidates` receive a cache-bust signal
1078/// (implementation detail of the orchestrator; in camp this is a
1079/// rebuild trigger on the mesofact-dev watcher).
1080#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
1081#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1082pub struct AlmanacManifest {
1083 /// Wire-format version. Always `V1` today.
1084 pub schema_version: SchemaVersion,
1085
1086 /// Shell command executed via `sh -c` from the workload directory.
1087 pub command: String,
1088
1089 /// When to run.
1090 pub cadence: Cadence,
1091
1092 /// Input targets that must be reachable before the command runs.
1093 /// Empty list → no precondition checks (degenerate case).
1094 #[serde(default)]
1095 pub inputs: Vec<AlmanacTarget>,
1096
1097 /// Output targets verified after a successful run.
1098 /// Empty list → no post-run verification.
1099 #[serde(default)]
1100 pub outputs: Vec<AlmanacTarget>,
1101
1102 /// What to do when a precondition check fails.
1103 /// Default: `WaitWithTimeout { timeout: 5000ms }`.
1104 #[serde(default)]
1105 pub not_ready_policy: NotReadyPolicy,
1106
1107 /// Mesh identities of workloads to notify after a successful run.
1108 /// The orchestrator sends a cache-bust signal to each entry so
1109 /// downstream consumers can reload their data (e.g. mesofact-dev
1110 /// triggers a rebuild when the OpenRouter cache refreshes).
1111 /// Empty list → no downstream invalidation.
1112 #[serde(default)]
1113 pub invalidates: Vec<MeshIdent>,
1114}
1115
1116// ── StaticAssetWorkload ───────────────────────────────────────────────────────
1117
1118/// BLAKE3 content hash expressed as exactly 64 ASCII hex digits.
1119///
1120/// This is the content-address key for every file in the static-asset catalog.
1121/// Deserialization rejects values that do not conform — 64 hex chars, case
1122/// insensitive. Mismatch between the recorded hash and the source file halts
1123/// the upload step in the reconciler.
1124#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)]
1125#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1126#[ts(type = "string")]
1127pub struct BlakeHash(pub String);
1128
1129impl<'de> Deserialize<'de> for BlakeHash {
1130 fn deserialize<D>(de: D) -> Result<Self, D::Error>
1131 where
1132 D: serde::Deserializer<'de>,
1133 {
1134 let s = String::deserialize(de)?;
1135 if s.len() != 64 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
1136 return Err(serde::de::Error::custom(format!(
1137 "blake3 hash must be exactly 64 hex digits, got {:?}",
1138 s
1139 )));
1140 }
1141 Ok(BlakeHash(s))
1142 }
1143}
1144
1145// ── License & FetchSource (W164) ──────────────────────────────────────────────
1146
1147/// Closed-set, parse-time-enforced license tag. Mirrors the workspace
1148/// permissive-license rule (MIT / Apache-2.0 / BSD-2/3-Clause / ISC). Adding a
1149/// variant is an explicit schema change — non-permissive strings
1150/// (`"GPL-3.0"`, `"AGPL"`, etc.) fail at serde-deserialize before any shape
1151/// validator runs.
1152///
1153/// Shared between `asset.derive.fetch.license` (W164, required) and a future
1154/// `almanac::ReleaseSource.license` migration (R438-F10, optional).
1155#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
1156#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1157#[serde(rename_all = "kebab-case")]
1158pub enum License {
1159 Mit,
1160 Apache2,
1161 Bsd2Clause,
1162 Bsd3Clause,
1163 Isc,
1164}
1165
1166/// Shared fetch primitive — usable by `asset.derive` today, and by Almanac's
1167/// `ReleaseSource` after a follow-up migration (R438-F10). Defined once in
1168/// workload-spec so both consumers reject the same set of non-permissive
1169/// licenses.
1170///
1171/// The `blake3` hash pins the upstream bytes; mismatch at fetch time is a hard
1172/// error in the reconciler. The `license` field is **required** here — every
1173/// derived asset must declare its upstream license. If/when Almanac adopts
1174/// `FetchSource`, the Almanac side may wrap this in a struct with
1175/// `Option<License>` since release manifests have no distribution license per
1176/// se.
1177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1178#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1179pub struct FetchSource {
1180 /// Upstream URL fetched verbatim. Reconciler retry policy is configured
1181 /// elsewhere (R438-F11); the URL itself is opaque to workload-spec.
1182 pub url: String,
1183
1184 /// Expected BLAKE3 hash of the fetched bytes (64 hex characters). The
1185 /// reconciler verifies this after download and aborts on mismatch.
1186 pub blake3: BlakeHash,
1187
1188 /// Upstream license. Closed-set, parse-time enforced.
1189 pub license: License,
1190}
1191
1192/// Optional transform applied after a [`FetchSource`] download, lowering to a
1193/// `ForgeCommand::Subprocess` via the recipe loader (R438-T4). The transform's
1194/// output is content-addressed by the entry's `blake3` (the recipe runs only
1195/// when the cache misses).
1196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1197#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1198pub struct TransformSpec {
1199 /// Named recipe under `.yah/qed/transforms/<recipe>.toml`. Loader rejects
1200 /// missing recipes at materialize time.
1201 pub recipe: String,
1202
1203 /// `{{key}}` substitutions passed to the recipe argv at element
1204 /// granularity (no shell, no string concat). Empty when the recipe is
1205 /// fully parameterless.
1206 #[serde(default)]
1207 pub params: BTreeMap<String, String>,
1208}
1209
1210/// W212/R518: the committed derivation lock — the in-tree action-cache
1211/// receipt. `input_hash` is the input-addressed derivation key computed over
1212/// the complete declared input set (fetched-input pin ⊕ recipe-file bytes ⊕
1213/// invocation params ⊕ schema version); `output_blake3` is what those inputs
1214/// produced (== the entry's `blake3`). The reconciler skips the entire build
1215/// (no fetch, no transform, no PUT) when the lock matches the inputs recomputed
1216/// from the current pins and the bucket already holds the output — the
1217/// Nix-substituter / Bazel-remote-cache behaviour. Written by the R510 bind
1218/// path from the reconciler's `discovered_input_hash:<filename>` output; the
1219/// `git diff` on this block is the receipt that the derivation rolled.
1220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1221#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1222pub struct DeriveLock {
1223 /// Input-addressed derivation key (BLAKE3 hex). A change to any declared
1224 /// input flips this, so a stale lock never produces a false skip.
1225 pub input_hash: String,
1226 /// Output the locked inputs produced (BLAKE3 hex; equals the entry's
1227 /// `blake3`). Carried so the lock is a self-contained action-cache entry.
1228 pub output_blake3: String,
1229}
1230
1231/// Provenance chain for a derived asset: required `fetch` step, optional
1232/// `transform` step. Materialized bytes replace `AssetEntry.source` for the
1233/// rest of the static-asset reconcile loop.
1234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1235#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1236pub struct AssetDerive {
1237 /// Upstream fetch — URL + content-pin + license.
1238 pub fetch: FetchSource,
1239
1240 /// Post-fetch transform. `None` → the fetched bytes ARE the asset
1241 /// (entry `blake3` must match fetch `blake3`).
1242 #[serde(default)]
1243 #[ts(optional = nullable)]
1244 pub transform: Option<TransformSpec>,
1245
1246 /// W212/R518: committed derivation lock (input-addressed action-cache
1247 /// receipt). Absent until the first successful build writes it via the
1248 /// bind path. When present and current, enables the substituter-style
1249 /// build skip.
1250 #[serde(default)]
1251 #[ts(optional = nullable)]
1252 pub lock: Option<DeriveLock>,
1253}
1254
1255/// A single file entry in the static-asset catalog.
1256///
1257/// One `[[asset]]` row per bucket object. Multiple rows for different variants
1258/// (e.g. q5 and q4 whisper models) are fine — each declares its own filename
1259/// and hash. The reconciler treats the catalog as exhaustive and append-only:
1260/// new rows trigger a PUT; removed rows surface as drift (never a DELETE).
1261///
1262/// **Source-vs-derive XOR.** Exactly one of `source` or `derive` must be set.
1263/// Legacy local-bytes assets keep `source = "..."`; W164 derived assets set
1264/// `[asset.derive]` instead. [`validate::shape_static_asset`] enforces the
1265/// XOR; both-set and neither-set are hard `ShapeError::Field`.
1266#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1267#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1268pub struct AssetEntry {
1269 /// Destination path within the bucket, e.g.
1270 /// `"whisper/distil-large-v3-q5_1.bin"`. Must be unique in the catalog.
1271 /// Used as the S3 object key by the reconciler.
1272 pub filename: String,
1273
1274 /// Path to a local source file, relative to the `workload.toml` directory.
1275 /// Mutually exclusive with `derive`.
1276 #[serde(default)]
1277 #[ts(optional = nullable)]
1278 pub source: Option<PathBuf>,
1279
1280 /// Declared fetch (+ optional transform) provenance chain. The reconciler
1281 /// materializes the bytes into a content-addressed cache; the cache path
1282 /// then replaces `source` for the rest of the upload pipeline. Mutually
1283 /// exclusive with `source`.
1284 #[serde(default)]
1285 #[ts(optional = nullable)]
1286 pub derive: Option<AssetDerive>,
1287
1288 /// Expected BLAKE3 hash of the *final* asset bytes (64 hex characters).
1289 /// For `source` mode, this is hashed before upload. For `derive` mode,
1290 /// it's the post-transform (or post-fetch when no transform) output.
1291 /// Mismatch aborts the upload.
1292 pub blake3: BlakeHash,
1293}
1294
1295/// `kind = "static-asset"` payload — content-addressed bucket catalog.
1296///
1297/// The reconciler makes the bucket match the `[[asset]]` list exactly
1298/// (append-only: new rows → PUT; removed rows → drift report, not DELETE).
1299/// Rollback is pointer-flip via `mirror.toml [asset_aliases]` — bytes never
1300/// move during rollback.
1301///
1302/// **Closed-catalog invariant**: every value in `[aliases]` must be a
1303/// `filename` that exists in `[[asset]]`. Enforced by
1304/// [`validate::shape_static_asset`]. Mirror overrides (`[asset_aliases]` in
1305/// `mirror.toml`) are bound by the same rule — the alias graph can only
1306/// resolve to filenames already in the catalog.
1307#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
1308#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1309pub struct StaticAssetWorkload {
1310 /// Wire-format version. Always `V1` today.
1311 pub schema_version: SchemaVersion,
1312
1313 /// Exhaustive catalog of files this component manages in the bucket.
1314 ///
1315 /// Named `asset` on disk (TOML `[[asset]]` array-of-tables) to follow TOML
1316 /// convention; accessed as `.assets` in Rust code.
1317 #[serde(rename = "asset", default)]
1318 pub assets: Vec<AssetEntry>,
1319
1320 /// Canonical logical-name → filename mappings for this component.
1321 ///
1322 /// Values must be filenames present in `assets` — validated by
1323 /// [`validate::shape_static_asset`]. Mirror files may override individual
1324 /// entries via `[asset_aliases]` but may never reference filenames absent
1325 /// from this catalog.
1326 #[serde(default)]
1327 pub aliases: BTreeMap<String, String>,
1328}
1329
1330// ── Lifecycle archetype (R572-F1 / W244) ───────────────────────────────────────
1331
1332/// Explicit lifecycle archetype for a `kind = "container"` workload (W244).
1333///
1334/// The question that actually matters to a scheduler: *"can I kill this and
1335/// recreate it somewhere else?"* Before this field existed, the answer was
1336/// inferred per-spec from `volumes.is_empty()` + `restart_policy` — fragile
1337/// absence-as-policy, the same trap W243 calls out on the node-taint side.
1338/// This type makes the answer structural instead of guessed.
1339///
1340/// This ticket (R572-F1) adds the discriminator only. The reconciler does not
1341/// yet branch on it (R572-F4) and neither does the scheduler (R572-F5).
1342#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
1343#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1344#[serde(rename_all = "kebab-case")]
1345pub enum LifecycleArchetype {
1346 /// k8s analogue: Deployment. Stateless and fungible — the scheduler may
1347 /// move it, scale it to N replicas, or restart it on a different node
1348 /// with zero consequence. Drainable.
1349 Server,
1350
1351 /// k8s analogue: StatefulSet. Stable identity + a volume that must
1352 /// follow it; at most one live instance. Not drainable — the reconciler
1353 /// must not schedule it onto a different node. Example: a postgres peer,
1354 /// headscale (W267/R591).
1355 Appliance,
1356
1357 /// k8s analogue: Job. Runs to completion with declared inputs/outputs,
1358 /// then is gone — no steady-state identity. `almanac` is the first
1359 /// job-family member; forge runs (`WorkloadSpec::for_forge`, used by QED)
1360 /// are the `container`-kind instance of this archetype.
1361 Job,
1362}
1363
1364impl LifecycleArchetype {
1365 /// Every variant, in declaration order. Exists so a consumer can enumerate
1366 /// the archetypes without hand-maintaining a parallel list — the taint
1367 /// vocabulary in `cloud::config::taint_effect` is built from this, so
1368 /// adding a fourth archetype extends the set of live repel keys for free.
1369 pub const ALL: [LifecycleArchetype; 3] = [Self::Server, Self::Appliance, Self::Job];
1370
1371 /// The repel-taint key for this archetype (R572-F5). A node carrying the
1372 /// taint `"no-<key>"` **absolutely** rejects workloads of this class.
1373 ///
1374 /// Examples: `Server` → `"server"` (repelled by `"no-server"`);
1375 /// `Appliance` → `"appliance"` (repelled by `"no-appliance"`).
1376 ///
1377 /// W305/R742-T4: there is no toleration. Earlier prose here and in
1378 /// `cloud::config` called this "repel-unless-tolerate"; the `unless` was
1379 /// never built, and reading it as a preference is what made `no-appliance`
1380 /// on the dev Pis look advisory when it was an unconditional block.
1381 pub fn taint_key(&self) -> &'static str {
1382 match self {
1383 Self::Server => "server",
1384 Self::Appliance => "appliance",
1385 Self::Job => "job",
1386 }
1387 }
1388
1389 /// The pre-R572 inference this field replaces, kept only to give
1390 /// `WorkloadSpec::effective_archetype` a behavior-preserving fallback for
1391 /// specs written before this field existed (`archetype: None`).
1392 ///
1393 /// A volume that must follow the workload is the strongest signal of
1394 /// durable state → [`Self::Appliance`]. Absent that, `RestartPolicy::Never`
1395 /// is the existing forge/run-once convention (see
1396 /// [`RestartPolicy::Never`]'s doc comment) → [`Self::Job`]. Everything
1397 /// else defaults to the common case, [`Self::Server`].
1398 fn infer(volumes: &[VolumeMount], restart_policy: &RestartPolicy) -> Self {
1399 if !volumes.is_empty() {
1400 LifecycleArchetype::Appliance
1401 } else if matches!(restart_policy, RestartPolicy::Never) {
1402 LifecycleArchetype::Job
1403 } else {
1404 LifecycleArchetype::Server
1405 }
1406 }
1407}
1408
1409// ── WorkloadSpec ──────────────────────────────────────────────────────────────
1410
1411/// Complete typed description of a containerd workload handed to yubaba over
1412/// RPC. This is also the payload of the `kind = "container"` variant of
1413/// [`Workload`] on disk.
1414///
1415/// Yubaba never accepts compose YAML on its RPC surface — agents, the desktop,
1416/// and operator CLIs all hand yubaba `WorkloadSpec` values. See the arch doc
1417/// for the validation layers and evolution rules.
1418#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
1419#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1420pub struct WorkloadSpec {
1421 /// Wire-format version; always `V1` today. Present at the top level so
1422 /// rolling clusters can detect and migrate across schema generations.
1423 pub schema_version: SchemaVersion,
1424
1425 /// DNS-friendly workload name, e.g. `"noisetable-api"`. Regex:
1426 /// `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`, length ≤ 63.
1427 pub name: String,
1428
1429 /// Container image to pull.
1430 pub image: ImageRef,
1431
1432 /// Tier tag controlling admission control and mesh filtering.
1433 pub tier: TierTag,
1434
1435 /// Tenant **isolation** axis (W206). Separates operators' workloads at the
1436 /// network / DB / mesh-identity level. Defaults to [`TenantId::singleton`]
1437 /// for specs that predate the axis, so single-tenant clusters keep every
1438 /// isolation primitive a no-op. Orthogonal to [`Self::tier`] (class) and
1439 /// [`Self::namespace`] (routing).
1440 #[serde(default = "TenantId::singleton")]
1441 pub tenant: TenantId,
1442
1443 /// Namespace **routing/naming** axis (W206). A pure naming key — never
1444 /// affects isolation; disambiguates DNS names and selects config root /
1445 /// provider zone within a tenant. Defaults to [`NamespaceId::singleton`].
1446 #[serde(default = "NamespaceId::singleton")]
1447 pub namespace: NamespaceId,
1448
1449 /// Target replica count. `0` registers the workload without deploying it.
1450 /// Range: 0–100 (cluster-wide cap; operator can raise it).
1451 pub replicas: u32,
1452
1453 /// Override the image's `CMD`. `None` leaves the image default.
1454 #[ts(optional = nullable)]
1455 pub command: Option<Vec<String>>,
1456
1457 /// Override the image's `ENTRYPOINT`. `None` leaves the image default.
1458 #[ts(optional = nullable)]
1459 pub entrypoint: Option<Vec<String>>,
1460
1461 /// Working directory inside the container.
1462 #[ts(optional = nullable)]
1463 pub workdir: Option<PathBuf>,
1464
1465 /// User to run as, e.g. `"1000:1000"` or `"appuser"`.
1466 #[ts(optional = nullable)]
1467 pub user: Option<String>,
1468
1469 /// Environment variables. Values may be literals, secret refs, or
1470 /// mesh-address references resolved by yubaba at deploy time.
1471 #[serde(default)]
1472 pub env: Vec<EnvVar>,
1473
1474 /// Secret mounts. Values never appear in the spec JSON — only references.
1475 #[serde(default)]
1476 pub secrets: Vec<SecretMount>,
1477
1478 /// Volume mounts.
1479 #[serde(default)]
1480 pub volumes: Vec<VolumeMount>,
1481
1482 /// Hard resource caps enforced by containerd/cgroups.
1483 pub resources: ResourceLimits,
1484
1485 /// Mesh idents that must reach `Ready` before this workload starts.
1486 #[serde(default)]
1487 pub depends_on: Vec<MeshIdent>,
1488
1489 /// Container liveness/readiness probe.
1490 #[ts(optional = nullable)]
1491 pub healthcheck: Option<Healthcheck>,
1492
1493 /// What yubaba does when the container exits.
1494 pub restart_policy: RestartPolicy,
1495
1496 /// Explicit lifecycle archetype (R572-F1 / W244): `server`, `appliance`,
1497 /// or `job`. `None` means the spec predates this field (or the author
1498 /// didn't set it) — callers MUST NOT read this directly to decide
1499 /// drainability; use [`WorkloadSpec::effective_archetype`], which falls
1500 /// back to the pre-R572 `volumes`/`restart_policy` inference so no
1501 /// existing spec's effective meaning changes.
1502 ///
1503 /// Additive: this field did not exist before R572-F1. Reconciler (F4)
1504 /// and scheduler (F5) branching on the resolved archetype are separate,
1505 /// later tickets — this field alone changes no runtime behavior.
1506 #[serde(default)]
1507 #[ts(optional = nullable)]
1508 pub archetype: Option<LifecycleArchetype>,
1509
1510 /// Graceful shutdown configuration.
1511 pub stop_policy: StopPolicy,
1512
1513 /// Network exposure configuration — mesh, public, and operator channels
1514 /// are independent and can be set in any combination.
1515 pub expose: ExposeSpec,
1516
1517 /// OCI-style labels, passed through to the container. Opaque to yubaba.
1518 #[serde(default)]
1519 pub labels: HashMap<String, String>,
1520
1521 /// Yah-specific metadata, conventionally prefixed `yah.*`. Opaque to
1522 /// yubaba beyond `yah.forge=true` which suppresses the Never-restart guard.
1523 #[serde(default)]
1524 pub annotations: HashMap<String, String>,
1525}
1526
1527impl WorkloadSpec {
1528 /// Build a `WorkloadSpec` for a forge run.
1529 ///
1530 /// Sets the conventional forge fields in one place so callers cannot
1531 /// forget any of them:
1532 ///
1533 /// - `restart_policy = Never`
1534 /// - `archetype = Some(LifecycleArchetype::Job)` — a forge run is
1535 /// exactly the `container`-kind instance of the job archetype (W244);
1536 /// set explicitly rather than left to infer since this constructor
1537 /// knows its own shape
1538 /// - `expose.public = None`, `expose.operator = None`
1539 /// - `expose.mesh.identity = "forge.<forge_id>"`
1540 /// - `annotations["yah.forge"] = "true"` (suppresses the shape warning)
1541 /// - `tier` and `image` come from the caller; `ports` becomes the mesh
1542 /// port list (empty is valid — forge jobs often don't expose ports)
1543 ///
1544 /// All other fields are set to safe defaults. Callers can mutate the
1545 /// returned value to fill in `command`, `env`, `resources`, etc.
1546 pub fn for_forge(
1547 forge_id: &str,
1548 image: ImageRef,
1549 tier: TierTag,
1550 ports: Vec<u16>,
1551 ) -> Self {
1552 let mut annotations = HashMap::new();
1553 annotations.insert("yah.forge".into(), "true".into());
1554 // The placement floor, kept distinct from the cgroup ceiling below.
1555 // Without this, admission reads the 32 GiB ceiling as the amount of
1556 // RAM a node must have — see `memory_request_mb` for what that cost.
1557 annotations.insert(
1558 MEMORY_REQUEST_ANNOTATION.into(),
1559 FORGE_MEMORY_REQUEST_MB.to_string(),
1560 );
1561
1562 WorkloadSpec {
1563 schema_version: SchemaVersion::V1,
1564 // NB: DNS-label safe (no dots) — `check_name` validation rejects
1565 // dots here. The container_id derives from this; the state-poll
1566 // keys off `expose.mesh.identity` (`forge.<id>`) instead, so those
1567 // two must be reconciled at the read path, NOT by dotting the name
1568 // (see R590-B9).
1569 name: format!("forge-{forge_id}"),
1570 image,
1571 tier,
1572 tenant: TenantId::singleton(),
1573 namespace: NamespaceId::singleton(),
1574 replicas: 1,
1575 command: None,
1576 entrypoint: None,
1577 workdir: None,
1578 user: None,
1579 env: vec![],
1580 secrets: vec![],
1581 volumes: vec![],
1582 resources: ResourceLimits {
1583 // R590-B10: forge workloads are BUILDS (cargo, buildkit, a
1584 // from-source V8 checkout+compile), not tiny services. The old
1585 // 256 MB placeholder became a hard cgroup memory.limit in
1586 // build_oci_spec and SIGKILL'd the rusty-v8 build mid-checkout
1587 // (git checkout of third_party/icu died of signal 9) — the
1588 // more so because /tmp is a RAM-backed tmpfs, so the source
1589 // tree counts against this limit too. 32 GiB is a bounded
1590 // ceiling that fits the V8 build's >12 GB peak with headroom,
1591 // protects the host from a runaway (vs truly unlimited), and is
1592 // above physical RAM on smaller build-workers (⇒ effectively
1593 // unlimited there).
1594 //
1595 // That last clause is only true while this stays a CEILING. It
1596 // was also the placement floor until the annotation set above
1597 // split the two, which made every build-worker under 32 GiB
1598 // unschedulable — the story is on `memory_request_mb`.
1599 memory_mb: FORGE_MEMORY_LIMIT_MB,
1600 cpu_millis: 512,
1601 ephemeral_storage_mb: 512,
1602 },
1603 depends_on: vec![],
1604 healthcheck: None,
1605 restart_policy: RestartPolicy::Never,
1606 archetype: Some(LifecycleArchetype::Job),
1607 stop_policy: StopPolicy {
1608 signal: 15,
1609 grace_period: Millis::from_secs(30),
1610 },
1611 expose: ExposeSpec {
1612 mesh: MeshExpose {
1613 identity: MeshIdent(format!("forge.{forge_id}")),
1614 ports,
1615 allow_from: vec![],
1616 },
1617 public: None,
1618 operator: None,
1619 },
1620 labels: HashMap::new(),
1621 annotations,
1622 }
1623 }
1624
1625 /// Whether this workload requests the **host network namespace** rather
1626 /// than an isolated one.
1627 ///
1628 /// Opt-in via `annotations["yah.network"] == "host"` (see
1629 /// [`HOST_NETWORK_ANNOTATION`] / [`HOST_NETWORK_VALUE`]). Default is the
1630 /// isolated netns every other workload gets — host networking is a
1631 /// privileged escape hatch for the few infra workloads that must bind a
1632 /// host port so an on-host ingress (e.g. a Cloudflare tunnel reaching
1633 /// `127.0.0.1:<port>`) can route to them without CNI/bridge plumbing.
1634 ///
1635 /// The backend (kamaji) is responsible for **guarding** this: host
1636 /// networking is only honoured for `tier == "infra"` workloads; a
1637 /// non-infra workload that sets the annotation is rejected at deploy. See
1638 /// `validate_spec_for_constable`.
1639 pub fn wants_host_network(&self) -> bool {
1640 self.annotations
1641 .get(HOST_NETWORK_ANNOTATION)
1642 .map(|v| v == HOST_NETWORK_VALUE)
1643 .unwrap_or(false)
1644 }
1645
1646 /// Resolve the lifecycle archetype (R572-F1 / W244): the explicit
1647 /// [`Self::archetype`] if set, otherwise the pre-R572 inference from
1648 /// `volumes`/`restart_policy` this field replaces.
1649 ///
1650 /// This is the one seam callers should use to ask "can I kill and
1651 /// reschedule this?" — it is intentionally the *only* place that
1652 /// implements the fallback, so behavior for pre-existing specs (no
1653 /// `archetype` on disk) is identical to what it was before this field
1654 /// existed. Consumers (reconciler R572-F4, scheduler R572-F5) branch on
1655 /// the return value; this crate does not itself change any reconciler or
1656 /// scheduler behavior.
1657 pub fn effective_archetype(&self) -> LifecycleArchetype {
1658 self.archetype
1659 .unwrap_or_else(|| LifecycleArchetype::infer(&self.volumes, &self.restart_policy))
1660 }
1661
1662 /// Fully-qualified mesh identity `<tenant>/<namespace>/<name>` (W206 /
1663 /// R558-F3), where `<name>` is this workload's [`MeshExpose::identity`].
1664 ///
1665 /// Within a tenant, workloads still address each other by the short
1666 /// identity (namespace disambiguates only on collision); the FQN is what
1667 /// makes the identity unambiguous across tenants and is exactly what a
1668 /// [`MeshPeer::CrossTenant`] grant names.
1669 pub fn fq_mesh_identity(&self) -> String {
1670 format!(
1671 "{}/{}/{}",
1672 self.tenant.0, self.namespace.0, self.expose.mesh.identity.0
1673 )
1674 }
1675
1676 /// The taint this workload requires its node to carry, if any (R594-F2 /
1677 /// W267 sovereign public ingress).
1678 ///
1679 /// Opt-in via `annotations["yah.placement.requires-taint"] = "<taint
1680 /// name>"` (see [`REQUIRES_TAINT_ANNOTATION`]) — same annotation-based,
1681 /// zero-blast-radius shape as [`Self::wants_host_network`], chosen so
1682 /// declaring this requirement does not force a struct-literal edit at
1683 /// every existing `WorkloadSpec { .. }` construction site the way a new
1684 /// plain field would (see R572-F1's handoff: ~26 sites for one field).
1685 ///
1686 /// Both halves have since landed: `MachineConfig.taints` (R572-F3) and the
1687 /// scheduler's affinity check in `cloud::config::RequiredSpec::matches`
1688 /// (R572-F5), which requires the key in the node's `taints` **or**
1689 /// `mesh_tags`.
1690 ///
1691 /// A key named here is one of only two ways a node taint can influence
1692 /// placement — the other is the `no-<archetype>` repulsion form. W305/
1693 /// R742-T4 makes `yah cloud validate` reject any node taint that is
1694 /// neither, so a new affinity key must be added to
1695 /// `cloud::config::AFFINITY_TAINT_KEYS` alongside the workload that
1696 /// requires it.
1697 ///
1698 /// The public-ingress appliance (W267) is the first user: a
1699 /// `kind = "container"` workload with `archetype =
1700 /// Some(LifecycleArchetype::Appliance)` and
1701 /// `requires_taint() == Some(PUBLIC_IP_TAINT)`, so yubaba may one day
1702 /// place it only on machines carrying the `"public-ip"` taint and kamaji
1703 /// supervises it like any other container (no new `Workload` variant —
1704 /// see [`Workload::Container`]'s doc comment).
1705 pub fn requires_taint(&self) -> Option<&str> {
1706 self.annotations
1707 .get(REQUIRES_TAINT_ANNOTATION)
1708 .map(String::as_str)
1709 }
1710
1711 /// The memory (MiB) a scheduler must find on a node before placing this
1712 /// workload — its **request**, as distinct from [`ResourceLimits::memory_mb`],
1713 /// which is a **ceiling** the backend turns into a cgroup `memory.max`.
1714 ///
1715 /// Opt-in via `annotations["yah.placement.memory-request-mb"]` (see
1716 /// [`MEMORY_REQUEST_ANNOTATION`]); absent or unparseable falls back to
1717 /// `resources.memory_mb`, so every spec that does not set it is admitted
1718 /// exactly as it was before this accessor existed.
1719 ///
1720 /// # Why the two numbers must not be the same one
1721 ///
1722 /// A limit answers "kill it past here"; a request answers "don't start it
1723 /// somewhere smaller than here". Generous is the safe direction for the
1724 /// first and the unschedulable direction for the second, so one field
1725 /// serving both makes a deliberately-roomy ceiling into an admission floor.
1726 ///
1727 /// That is not hypothetical: [`WorkloadSpec::for_forge`] sets a 32 GiB
1728 /// ceiling explicitly reasoned as "above physical RAM on smaller
1729 /// build-workers ⇒ effectively unlimited there" (R590-B10), and
1730 /// `CloudConfig::admit_workload` fed that same 32768 in as the R572-F5
1731 /// capacity floor. Every build-worker under 32 GiB — the three 8 GiB Pi-5s
1732 /// and the 16 GiB us-west-003 — became structurally unadmittable for *any*
1733 /// offloaded qed step, leaving one 47 GiB node as the fleet's only legal
1734 /// target for remote CI. This is R590-B10's own recorded follow-up
1735 /// ("thread a per-step memory request … instead of a blanket forge
1736 /// default"), reduced to the seam that closes the bug.
1737 ///
1738 /// An annotation rather than a new `ResourceLimits` field on purpose:
1739 /// `WorkloadSpec` crosses a postcard wire that is positional and
1740 /// carries no field names (R590-B3), so adding a field would break decode
1741 /// on every fleet node still running an older kamaji. `annotations` is an
1742 /// existing map — an extra key rides it safely, and admission already
1743 /// reads placement inputs from exactly there
1744 /// ([`Self::requires_taint`], the R594 node-selector).
1745 pub fn memory_request_mb(&self) -> u32 {
1746 self.annotations
1747 .get(MEMORY_REQUEST_ANNOTATION)
1748 .and_then(|v| v.trim().parse::<u32>().ok())
1749 .unwrap_or(self.resources.memory_mb)
1750 }
1751
1752 /// Whether this workload must be run by kamaji's **native** (fork+exec)
1753 /// backend on the node's own userland, rather than by a container backend
1754 /// (R577-T1 / W254).
1755 ///
1756 /// Opt-in via `annotations["yah.exec"] == "native"` (see
1757 /// [`NATIVE_EXEC_ANNOTATION`] / [`NATIVE_EXEC_VALUE`]) — the same
1758 /// annotation-shaped, zero-blast-radius marker as
1759 /// [`Self::wants_host_network`] and [`Self::requires_taint`], chosen over
1760 /// a new plain field for the reason R572-F1 recorded: a field forces a
1761 /// struct-literal edit at every existing construction site and an
1762 /// exhaustive-match update in `kamaji-proto`'s codec, and this marker
1763 /// needs neither.
1764 ///
1765 /// # Why an annotation and not a runtime enum on the wire
1766 ///
1767 /// The remote-execution wire already carries exactly one workload shape —
1768 /// `Workload::Container(WorkloadSpec)` — and every layer between the
1769 /// dispatcher and the node (yubaba admission, mesh assignment, log
1770 /// ingest, produced-file retrieval, teardown) is written against it. A
1771 /// Darwin build differs from a Linux build in *one* respect: there is no
1772 /// container that can host it, because you cannot containerize the Darwin
1773 /// kernel. Marking that one difference keeps the rest of the path shared
1774 /// instead of growing a parallel `exec_native` RPC that would have to
1775 /// re-implement all of it.
1776 ///
1777 /// `image` stays populated for a native workload and is **identity
1778 /// metadata only** — nothing is pulled; the native backend resolves argv
1779 /// from `entrypoint` + `command` (container semantics) and execs it on
1780 /// the host.
1781 pub fn wants_native_exec(&self) -> bool {
1782 self.annotations
1783 .get(NATIVE_EXEC_ANNOTATION)
1784 .map(|v| v == NATIVE_EXEC_VALUE)
1785 .unwrap_or(false)
1786 }
1787
1788 /// Whether this workload builds its **own unprivileged container sandbox**
1789 /// inside the one the backend gives it, and therefore needs the two
1790 /// capabilities plus the `no_new_privs` relaxation that setting up a
1791 /// user namespace requires (R636-B2).
1792 ///
1793 /// Opt-in via `annotations["yah.sandbox"] == "nested"` (see
1794 /// [`NESTED_SANDBOX_ANNOTATION`] / [`NESTED_SANDBOX_VALUE`]) — the same
1795 /// annotation-shaped, zero-blast-radius marker as
1796 /// [`Self::wants_host_network`] and [`Self::wants_native_exec`].
1797 ///
1798 /// # What it actually grants, and why exactly that
1799 ///
1800 /// Rootless BuildKit (the only user today: remote `build-image` steps
1801 /// dispatch `moby/buildkit:*-rootless`) boots through `rootlesskit`, which
1802 /// must map a range of sub-uids into a fresh user namespace. It does that
1803 /// by exec'ing the **setuid-root** helpers `newuidmap` / `newgidmap`, so
1804 /// it needs `CAP_SETUID` + `CAP_SETGID` in the bounding set *and*
1805 /// `noNewPrivileges = false` (with `no_new_privs` on, the kernel silently
1806 /// strips the setuid bit and the helper fails with "Could not set caps").
1807 ///
1808 /// Each of those three was measured on us-west-002 to be **individually
1809 /// necessary** — dropping any one of them puts `rootlesskit` back to
1810 /// failing before the first layer:
1811 ///
1812 /// | grant | `rootlesskit` result |
1813 /// |---|---|
1814 /// | baseline (`CAP_NET_BIND_SERVICE` only, `nnp` on) | `fork/exec /usr/bin/newuidmap: operation not permitted` |
1815 /// | `+CAP_SETUID` only, `nnp` off | `fork/exec /usr/bin/newgidmap: operation not permitted` |
1816 /// | `+CAP_SETUID +CAP_SETGID`, `nnp` **on** | `newuidmap: Could not set caps` |
1817 /// | `+CAP_SETUID +CAP_SETGID`, `nnp` off | starts; build runs to completion |
1818 ///
1819 /// It is deliberately *not* `CAP_SYS_ADMIN`: a non-rootless buildkitd
1820 /// would need that instead, which is a far wider grant. Emptying
1821 /// `/etc/subuid` to force `rootlesskit`'s single-mapping path does not
1822 /// avoid the helpers either — it just fails earlier with "No subuid
1823 /// ranges found".
1824 ///
1825 /// **The backend guards this.** Like host networking, it is honoured only
1826 /// for `tier == "infra"` workloads; a non-infra workload that sets the
1827 /// annotation is rejected at deploy. Every other workload keeps the
1828 /// `CAP_NET_BIND_SERVICE`-only, `no_new_privs` baseline.
1829 ///
1830 /// # Mutually exclusive with [`Self::wants_native_exec`]
1831 ///
1832 /// This grant is defined in terms of an **OCI process spec** — a
1833 /// capability set and a `noNewPrivileges` bit. A native (fork+exec)
1834 /// workload has no OCI spec, so there is nothing to apply it to; kamaji
1835 /// refuses a spec carrying both markers rather than accepting a request
1836 /// for widened privileges and silently dropping it (R577-T1 owns that
1837 /// refusal). The two are independent *annotations* — neither implies the
1838 /// other, which is what
1839 /// `nested_sandbox_marker_is_independent_of_the_other_markers` pins — but
1840 /// they are not a legal *pair*.
1841 ///
1842 /// If a future runtime does have a sandbox worth widening (a MacVM under
1843 /// W254, say), give it its own annotation rather than relaxing that
1844 /// refusal. The grant this marker names is `CAP_SETUID` + `CAP_SETGID` +
1845 /// `no_new_privs` off and nothing else; letting it mean a different
1846 /// privilege set per backend would make "what does `yah.sandbox=nested`
1847 /// grant?" unanswerable without knowing which backend received it, which
1848 /// is precisely what a security-relevant marker must not be.
1849 pub fn wants_nested_sandbox(&self) -> bool {
1850 self.annotations
1851 .get(NESTED_SANDBOX_ANNOTATION)
1852 .map(|v| v == NESTED_SANDBOX_VALUE)
1853 .unwrap_or(false)
1854 }
1855}
1856
1857/// Annotation key requesting a workload share the host network namespace.
1858/// See [`WorkloadSpec::wants_host_network`].
1859pub const HOST_NETWORK_ANNOTATION: &str = "yah.network";
1860
1861/// Annotation value (for [`HOST_NETWORK_ANNOTATION`]) selecting host
1862/// networking. Any other value leaves the workload in an isolated netns.
1863pub const HOST_NETWORK_VALUE: &str = "host";
1864
1865/// Annotation key declaring that a workload must land only on a node
1866/// carrying a specific taint. See [`WorkloadSpec::requires_taint`].
1867pub const REQUIRES_TAINT_ANNOTATION: &str = "yah.placement.requires-taint";
1868
1869/// Annotation key carrying a workload's memory **request** in MiB — what a
1870/// scheduler must find free on a node — separate from the `memory_mb`
1871/// **ceiling** the backend enforces as a cgroup limit. See
1872/// [`WorkloadSpec::memory_request_mb`].
1873pub const MEMORY_REQUEST_ANNOTATION: &str = "yah.placement.memory-request-mb";
1874
1875/// The memory request [`WorkloadSpec::for_forge`] declares (MiB).
1876///
1877/// A forge run is a build, and a build's *ceiling* is deliberately roomy
1878/// (`FORGE_MEMORY_LIMIT_MB`); this is the much smaller floor a node must have
1879/// free to be a legal target for one. 2 GiB is what the heaviest forge shape
1880/// in the tree already asks for by hand — `velveteen_exec::remote`'s buildkit
1881/// image-build step overrides `resources.memory_mb` to exactly this — so it is
1882/// a measured number rather than a guess, and it keeps the fleet's 8 GiB
1883/// build-workers schedulable.
1884pub const FORGE_MEMORY_REQUEST_MB: u32 = 2048;
1885
1886/// The cgroup memory ceiling [`WorkloadSpec::for_forge`] sets (MiB).
1887///
1888/// Bounded rather than unlimited so a runaway build cannot take the host
1889/// down, and large enough for the V8 build's >12 GB peak (R590-B10). It is
1890/// **not** a placement input — see [`FORGE_MEMORY_REQUEST_MB`].
1891pub const FORGE_MEMORY_LIMIT_MB: u32 = 32768;
1892
1893/// Taint name (for [`REQUIRES_TAINT_ANNOTATION`]) identifying machines with
1894/// a publicly-routable IP — the W267 sovereign-ingress placement
1895/// requirement. `MachineConfig.taints` (R572-F3) is the matching node-side
1896/// field and `RequiredSpec::matches` (R572-F5) is the consumer, so this is a
1897/// live key on both sides: a node may carry it, and the cloudflared/passway
1898/// ingress specs require it.
1899pub const PUBLIC_IP_TAINT: &str = "public-ip";
1900
1901/// Annotation key selecting kamaji's native (fork+exec) backend for a
1902/// workload. See [`WorkloadSpec::wants_native_exec`].
1903pub const NATIVE_EXEC_ANNOTATION: &str = "yah.exec";
1904
1905/// Annotation value (for [`NATIVE_EXEC_ANNOTATION`]) selecting native
1906/// host execution. Any other value leaves the workload on a container
1907/// backend.
1908pub const NATIVE_EXEC_VALUE: &str = "native";
1909
1910/// Annotation key requesting the capabilities a workload needs to stand up an
1911/// unprivileged container sandbox of its own.
1912/// See [`WorkloadSpec::wants_nested_sandbox`].
1913pub const NESTED_SANDBOX_ANNOTATION: &str = "yah.sandbox";
1914
1915/// Annotation value (for [`NESTED_SANDBOX_ANNOTATION`]) requesting the
1916/// nested-sandbox grant (`CAP_SETUID` + `CAP_SETGID`, `no_new_privs` off).
1917/// Any other value leaves the workload on the baseline sandbox.
1918pub const NESTED_SANDBOX_VALUE: &str = "nested";
1919
1920// ── ImageRef ─────────────────────────────────────────────────────────────────
1921
1922/// Container image reference identifying a specific image to pull.
1923///
1924/// **Digest is required.** Every executable image reference in the workspace
1925/// is content-addressed by `sha256:<hex>`. The `tag` is preserved as a
1926/// human-readable identifier but is not the source of truth — registries
1927/// return mutable `tag → digest` mappings and we don't trust them for
1928/// reproducibility. R438-T3 tightened `digest: Option<String> → String` to
1929/// make unpinned-image bugs impossible by construction.
1930///
1931/// **Two deserialize shapes.** The struct form
1932/// (`registry`/`repository`/`tag`/`digest` fields) is the on-disk envelope.
1933/// A **string form** (`image = "ghcr.io/foo/bar:v1@sha256:<hex>"`) is also
1934/// accepted and is the shape W164 transform recipes (R438-T4) and W165
1935/// `BuildMode::InContainer` (R438-T6) use. Both shapes go through a single
1936/// parser ([`compose_import::parse_pinned_image_ref`]) that rejects
1937/// bare-tag references at serde-deserialize.
1938#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)]
1939#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1940pub struct ImageRef {
1941 /// Registry hostname, e.g. `"ghcr.io"` or `"localhost:5000"`.
1942 pub registry: String,
1943
1944 /// Repository path, e.g. `"noisetable/api"`.
1945 pub repository: String,
1946
1947 /// Tag, e.g. `"v1.4.2"` or `"latest"`. Informational — the digest is
1948 /// the source of truth for image identity.
1949 pub tag: String,
1950
1951 /// Content-addressed pinned identity, e.g. `"sha256:abc..."`. Required.
1952 pub digest: String,
1953}
1954
1955impl<'de> Deserialize<'de> for ImageRef {
1956 fn deserialize<D>(de: D) -> Result<Self, D::Error>
1957 where
1958 D: serde::Deserializer<'de>,
1959 {
1960 #[derive(Deserialize)]
1961 struct Fields {
1962 registry: String,
1963 repository: String,
1964 tag: String,
1965 digest: String,
1966 }
1967
1968 // The string-or-struct `untagged` probe requires `deserialize_any`,
1969 // which only self-describing formats support. Postcard — the binary
1970 // wire behind the kamaji UDS — returns `WontImplement` for it, so a
1971 // `Workload::Container(WorkloadSpec)` carrying a nested `ImageRef`
1972 // failed to decode and every container deploy 500'd (R590-B3).
1973 //
1974 // The string form is purely an authoring convenience in human-readable
1975 // configs (`image = "ghcr.io/…@sha256:…"` in recipe/workload TOML and
1976 // JSON); the binary wire only ever carries the derived struct form
1977 // (Serialize is a plain struct derive). So branch on the format: text
1978 // keeps the string-or-struct convenience via `untagged`; binary decodes
1979 // the plain positional struct with no `deserialize_any`.
1980 if de.is_human_readable() {
1981 #[derive(Deserialize)]
1982 #[serde(untagged)]
1983 enum Repr {
1984 // Order matters for `untagged`: try the string form first so
1985 // explicit strings don't get coerced into a struct error.
1986 Pinned(String),
1987 Struct(Fields),
1988 }
1989
1990 match Repr::deserialize(de)? {
1991 Repr::Pinned(s) => {
1992 compose_import::parse_pinned_image_ref(&s).map_err(serde::de::Error::custom)
1993 }
1994 Repr::Struct(f) => Ok(ImageRef {
1995 registry: f.registry,
1996 repository: f.repository,
1997 tag: f.tag,
1998 digest: f.digest,
1999 }),
2000 }
2001 } else {
2002 let f = Fields::deserialize(de)?;
2003 Ok(ImageRef {
2004 registry: f.registry,
2005 repository: f.repository,
2006 tag: f.tag,
2007 digest: f.digest,
2008 })
2009 }
2010 }
2011}
2012
2013// ── testing helpers ───────────────────────────────────────────────────────────
2014
2015/// Fixture helpers for test code that needs to construct types whose schemas
2016/// would otherwise demand operator-pinned values (digests, hashes). Doc-hidden
2017/// to discourage misuse from non-test code — production paths must source
2018/// digests from registry resolution or compile-time injection.
2019#[doc(hidden)]
2020pub mod testing {
2021 /// Fixed valid-format sha256 digest for test fixtures. All-zeros marker
2022 /// is impossible for any real image, so a leaked test fixture in a
2023 /// production code-path surfaces obviously.
2024 ///
2025 /// Aliases [`super::ImageRef::UNPINNED_DIGEST`] — the two are deliberately
2026 /// the same value: the fixture sentinel and the production "unpinned"
2027 /// marker must agree so [`super::ImageRef::pull_ref`]'s tag-fallback fires
2028 /// on exactly the digest `catalog_image` writes.
2029 pub const TEST_DIGEST: &str = super::ImageRef::UNPINNED_DIGEST;
2030
2031 /// Owned `String` form of [`TEST_DIGEST`] for fixture constructors.
2032 pub fn test_digest() -> String {
2033 TEST_DIGEST.to_string()
2034 }
2035}
2036
2037// ── EnvVar ────────────────────────────────────────────────────────────────────
2038
2039/// A single environment variable injected into the container.
2040#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
2041#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2042pub struct EnvVar {
2043 /// Variable name, conventionally `SCREAMING_SNAKE_CASE`.
2044 pub name: String,
2045
2046 /// Value source.
2047 pub value: EnvValue,
2048}
2049
2050/// Value source for an environment variable.
2051#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
2052#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2053#[serde(rename_all = "snake_case")]
2054pub enum EnvValue {
2055 /// Static string baked into the spec.
2056 Literal { value: String },
2057
2058 /// Resolved from a yubaba secret at deploy time; the secret value never
2059 /// appears in the spec JSON.
2060 FromSecret { secret: String, key: String },
2061
2062 /// Resolved from another workload's mesh address at deploy time by yubaba.
2063 /// Lets workloads reference each other symbolically without IP pinning.
2064 FromMesh { ident: MeshIdent, kind: MeshLookup },
2065}
2066
2067/// Which aspect of a mesh peer's address to inject.
2068#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
2069#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2070#[serde(rename_all = "snake_case")]
2071pub enum MeshLookup {
2072 /// Full URL, e.g. `"http://noisetable-db.pdx:5432"`.
2073 Url,
2074 /// Hostname only, e.g. `"noisetable-db.pdx"`.
2075 Host,
2076 /// Port only, e.g. `"5432"`.
2077 Port,
2078}
2079
2080// ── Secrets ───────────────────────────────────────────────────────────────────
2081
2082/// A secret value mounted into the container as an env var or file.
2083///
2084/// The secret value never appears in the spec JSON — only the reference.
2085/// Yubaba audits secret access per workload from these references.
2086#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
2087#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2088pub struct SecretMount {
2089 /// Where yubaba reads the secret value from.
2090 pub source: SecretRef,
2091
2092 /// How the secret is surfaced inside the container.
2093 pub target: SecretTarget,
2094}
2095
2096/// Where yubaba resolves the secret value from.
2097#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
2098#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2099#[serde(rename_all = "snake_case")]
2100pub enum SecretRef {
2101 /// Per-machine yubaba secret store at `/var/lib/yah/yubaba/secrets/`.
2102 LocalFile { path: PathBuf },
2103
2104 /// Raft-replicated cluster secret spanning all machines (planned; not in
2105 /// V1 deployment). Sketch preserved for wire compatibility.
2106 Cluster { name: String },
2107}
2108
2109/// How the secret is surfaced inside the container.
2110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
2111#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2112#[serde(rename_all = "snake_case")]
2113pub enum SecretTarget {
2114 /// Injected as an environment variable. Value never appears in spec JSON.
2115 /// Prefer `File` — env vars leak through subprocess env and log dumps.
2116 EnvVar { name: String },
2117
2118 /// Mounted as a file inside the container at `path` with `mode` (octal).
2119 File { path: PathBuf, mode: u32 },
2120}
2121
2122// ── Volumes ───────────────────────────────────────────────────────────────────
2123
2124/// A volume mount inside the container.
2125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
2126#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2127pub struct VolumeMount {
2128 /// Backing volume source.
2129 pub source: VolumeSource,
2130
2131 /// Absolute path inside the container.
2132 pub target: PathBuf,
2133
2134 /// Whether the container sees the volume as read-only.
2135 pub read_only: bool,
2136}
2137
2138/// Backing source for a volume mount.
2139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
2140#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2141#[serde(rename_all = "snake_case")]
2142pub enum VolumeSource {
2143 /// Yubaba-managed named volume; created on first use.
2144 Named { name: String },
2145
2146 /// Operator-managed host path. Yubaba rejects bind mounts unless
2147 /// `WorkloadSpec.tier == "infra"`; shape validation enforces this.
2148 Bind { host_path: PathBuf },
2149
2150 /// In-memory tmpfs; discarded on container stop. `size_mb` caps space
2151 /// consumed by the writable layer.
2152 Tmpfs { size_mb: u32 },
2153}
2154
2155// ── Durable forge produced-artifact convention (R603-T5) ──────────────────────
2156
2157/// Convention for a remote forge step's durable produced artifacts.
2158///
2159/// A remote build (e.g. the rusty_v8 musl build on a build-worker) writes its
2160/// output tarball to a path *inside* the container. The container's rootfs is
2161/// destroyed when kamaji reaps the EXITED container — so if the camp daemon is
2162/// down when the build finishes, the artifact is gone before boot-reconcile can
2163/// retrieve it (R603-T4 surfaced this as `Success`-but-`UNPUBLISHED`).
2164///
2165/// The fix (R603-T5) is a **host-persistent bind mount**: forge Subprocess
2166/// workloads mount [`HOST_ROOT`]`/<forge_id>` onto [`CONTAINER_DIR`], so a
2167/// build that writes its `produces` under `/yah/produced` lands the bytes on
2168/// the worker's host filesystem. yubaba then reads them back from the host path
2169/// ([`host_path`]) — which outlives container reaping — instead of the
2170/// unreachable container rootfs.
2171///
2172/// The container-side path and the host root are a shared convention between
2173/// three crates: the qed `build_workload_spec` that adds the mount, kamaji that
2174/// binds it, and the yubaba handler that reads + reaps it. Keeping it here (the
2175/// crate all three already depend on) is the single source of truth.
2176pub mod forge_produced {
2177 use std::path::{Path, PathBuf};
2178
2179 /// Conventional container-side directory a remote forge step writes its
2180 /// durable produced artifacts to. Bind-mounted onto a host-persistent dir.
2181 pub const CONTAINER_DIR: &str = "/yah/produced";
2182
2183 /// Host root under which each forge's durable produced dir lives, one
2184 /// subdir per run: `<HOST_ROOT>/<forge_id>/`. yubaba owns this directory —
2185 /// it creates the per-forge subdir at deploy, serves reads from it, and
2186 /// reaps it on teardown / TTL sweep.
2187 pub const HOST_ROOT: &str = "/var/lib/yah/qed/produced";
2188
2189 /// Forge mesh idents are `forge.<id>` (see [`WorkloadSpec::for_forge`]).
2190 /// Extract the bare `<id>`, or `None` for a non-forge ident.
2191 ///
2192 /// [`WorkloadSpec::for_forge`]: super::WorkloadSpec::for_forge
2193 pub fn forge_id_from_ident(ident: &str) -> Option<&str> {
2194 ident.strip_prefix("forge.")
2195 }
2196
2197 /// The host-persistent produced directory for one forge run.
2198 pub fn host_dir(forge_id: &str) -> PathBuf {
2199 PathBuf::from(HOST_ROOT).join(forge_id)
2200 }
2201
2202 /// Translate a container-side produced path to its durable host path for a
2203 /// given forge run. Returns `None` when `container_path` is not under
2204 /// [`CONTAINER_DIR`] (the caller then knows the artifact was not written to
2205 /// the durable location and won't survive reaping), or when the relative
2206 /// path contains a `..` component (a traversal attempt that could escape the
2207 /// per-forge dir — the reader must never serve a file outside it).
2208 pub fn host_path(forge_id: &str, container_path: &Path) -> Option<PathBuf> {
2209 let rel = container_path.strip_prefix(CONTAINER_DIR).ok()?;
2210 if rel
2211 .components()
2212 .any(|c| matches!(c, std::path::Component::ParentDir))
2213 {
2214 return None;
2215 }
2216 Some(host_dir(forge_id).join(rel))
2217 }
2218
2219 /// The durable produced-dir bind mount for a forge run: host
2220 /// `<HOST_ROOT>/<forge_id>` → container [`CONTAINER_DIR`], writable.
2221 pub fn durable_mount(forge_id: &str) -> super::VolumeMount {
2222 super::VolumeMount {
2223 source: super::VolumeSource::Bind {
2224 host_path: host_dir(forge_id),
2225 },
2226 target: PathBuf::from(CONTAINER_DIR),
2227 read_only: false,
2228 }
2229 }
2230
2231 /// True when `path` is (or is under) the conventional durable produced dir
2232 /// — the guard qed uses to enforce that declared `produces` land somewhere
2233 /// reap-durable.
2234 pub fn is_durable_path(path: &Path) -> bool {
2235 path.starts_with(CONTAINER_DIR)
2236 }
2237}
2238
2239// ── Forge host-state root (R636-B1) ───────────────────────────────────────────
2240
2241/// The one host directory tree a QED forge step's bind mounts may live under.
2242///
2243/// # Why this is a named root rather than a list of paths
2244///
2245/// runc refuses a bind whose source is missing, and the OCI mapper never
2246/// mkdirs one — so *something* has to create each host dir before deploy.
2247/// yubaba does, but only for paths it recognizes, and "recognizes" was
2248/// originally a hardcoded match on the produced dir. Every new forge mount then
2249/// re-learned the lesson the expensive way, on a real box, minutes into a
2250/// build: R603-B6 for `produced/`, then R636-B1 for `build-out/`, each
2251/// surfacing as the same opaque `failed to fulfil mount request: … no such file
2252/// or directory` from deep inside containerd.
2253///
2254/// Naming the *root* makes the rule checkable instead of enumerable: yubaba
2255/// creates any forge bind under [`HOST_ROOT`], and `yubaba.service` grants the
2256/// root once via `StateDirectory=yah/qed`. A third mount needs no new code and
2257/// no unit-file edit — it only has to live here.
2258///
2259/// The prefix bound is load-bearing in the other direction too: it is what
2260/// keeps a workload spec from asking yubaba to mkdir an arbitrary host path.
2261pub mod forge_state {
2262 use std::path::Path;
2263
2264 /// Root of the forge's host-persistent state. Both
2265 /// [`super::forge_produced::HOST_ROOT`] and [`BUILD_OUT_DIR`] are under it.
2266 pub const HOST_ROOT: &str = "/var/lib/yah/qed";
2267
2268 /// Host directory a `build-image` step's OCI archive is written to, bound
2269 /// at `/yah/build/out` in the BuildKit container. Shared (rather than
2270 /// per-forge like `produced/`) because the archive is named after the image
2271 /// tag, which is already unique per build.
2272 pub const BUILD_OUT_DIR: &str = "/var/lib/yah/qed/build-out";
2273
2274 /// Whether yubaba may create `host_path` on behalf of a forge workload.
2275 ///
2276 /// Rejects anything outside [`HOST_ROOT`], and anything with a `..`
2277 /// component — `/var/lib/yah/qed/../../../etc` starts with the root as a
2278 /// string and is nowhere near it as a path.
2279 pub fn is_forge_state_path(host_path: &Path) -> bool {
2280 !host_path
2281 .components()
2282 .any(|c| matches!(c, std::path::Component::ParentDir))
2283 && host_path.starts_with(HOST_ROOT)
2284 }
2285}
2286
2287#[cfg(test)]
2288mod forge_state_tests {
2289 use super::forge_state::*;
2290 use std::path::Path;
2291
2292 #[test]
2293 fn both_known_forge_roots_are_under_the_state_root() {
2294 assert!(is_forge_state_path(Path::new(
2295 super::forge_produced::HOST_ROOT
2296 )));
2297 assert!(is_forge_state_path(Path::new(BUILD_OUT_DIR)));
2298 assert!(is_forge_state_path(&super::forge_produced::host_dir(
2299 "abc-123"
2300 )));
2301 }
2302
2303 /// A spec must not be able to steer yubaba's mkdir anywhere it likes —
2304 /// neither by naming an unrelated absolute path nor by climbing out with
2305 /// `..`, which a plain string prefix check would wave through.
2306 #[test]
2307 fn paths_outside_the_root_are_refused() {
2308 for bad in [
2309 "/var/lib/yah/yubaba",
2310 "/etc/systemd/system",
2311 "/var/lib/yah/qed/../../../etc",
2312 "relative/path",
2313 ] {
2314 assert!(
2315 !is_forge_state_path(Path::new(bad)),
2316 "{bad} must not be creatable by a forge spec"
2317 );
2318 }
2319 }
2320}
2321
2322// ── Resources ─────────────────────────────────────────────────────────────────
2323
2324/// Hard resource caps enforced by containerd/cgroups at runtime.
2325#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
2326#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2327pub struct ResourceLimits {
2328 /// Maximum RAM the container may allocate, in MiB. The container is OOM-
2329 /// killed if it exceeds this.
2330 ///
2331 /// A **ceiling**, not a request: setting it generously is the safe
2332 /// direction here and the unschedulable direction for placement, so
2333 /// schedulers must read [`WorkloadSpec::memory_request_mb`] instead of
2334 /// this field. (`cpu_millis` below is the opposite — a request by
2335 /// definition — which is why the two are not symmetric.)
2336 pub memory_mb: u32,
2337
2338 /// CPU **request** in millicores (k8s convention): `1000` = one full core,
2339 /// `250` = `.25 CPU`. Unlike a Docker relative weight this is an
2340 /// allocatable quantity a bin-packer can subtract from a node's budget.
2341 /// `0` means "no CPU limit". Backends that speak a relative weight derive
2342 /// it via [`ResourceLimits::cpu_shares`].
2343 pub cpu_millis: u32,
2344
2345 /// Cap on the writable layer + tmpfs footprint, in MiB.
2346 pub ephemeral_storage_mb: u32,
2347}
2348
2349impl ResourceLimits {
2350 /// The Docker/OCI relative CPU weight (`cpu.shares`, where `1024` ≈ one
2351 /// core) equivalent to this millicore request. The containerd and docker
2352 /// backends express CPU as a weight rather than a millicore request, so
2353 /// they derive it here instead of storing shares: `1000m` ⇒ `1024`.
2354 pub fn cpu_shares(&self) -> u64 {
2355 (u64::from(self.cpu_millis) * 1024) / 1000
2356 }
2357}
2358
2359// ── Healthcheck ───────────────────────────────────────────────────────────────
2360
2361/// Container health probe configuration.
2362#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
2363#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2364pub struct Healthcheck {
2365 /// The probe executed to determine container health.
2366 pub probe: HealthProbe,
2367
2368 /// How often the probe runs.
2369 pub interval: Millis,
2370
2371 /// Per-probe timeout; a slow response counts as failure.
2372 pub timeout: Millis,
2373
2374 /// Time to wait after container start before the first probe. Shape
2375 /// validation warns (not errors) if this is less than
2376 /// `stop_policy.grace_period * 2`.
2377 pub initial_delay: Millis,
2378
2379 /// Number of consecutive failures before the container is marked
2380 /// `Unhealthy`.
2381 pub failure_threshold: u32,
2382}
2383
2384/// Mechanism used to check container health.
2385#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
2386#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2387#[serde(rename_all = "snake_case")]
2388pub enum HealthProbe {
2389 /// HTTP GET to `path` on `port`. A 2xx (or `expect_status` if set)
2390 /// response counts as healthy.
2391 HttpGet {
2392 path: String,
2393 port: u16,
2394 #[ts(optional = nullable)]
2395 expect_status: Option<u16>,
2396 },
2397
2398 /// Run `argv` inside the container; exit-0 counts as healthy.
2399 Exec { argv: Vec<String> },
2400
2401 /// TCP connection to `port`; a successful connect counts as healthy.
2402 TcpConnect { port: u16 },
2403}
2404
2405// ── Restart / Stop ────────────────────────────────────────────────────────────
2406
2407/// What yubaba does when the container exits.
2408#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
2409#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2410#[serde(rename_all = "snake_case")]
2411pub enum RestartPolicy {
2412 /// Restart unconditionally on any exit.
2413 Always,
2414
2415 /// Restart on non-zero exit, up to `max_attempts` times with exponential
2416 /// backoff. After exhaustion, the workload is marked `Failed`.
2417 OnFailure {
2418 max_attempts: u32,
2419 backoff: BackoffPolicy,
2420 },
2421
2422 /// Do not restart. The container runs once and exits.
2423 ///
2424 /// **Forge convention.** Forge runs (R094) synthesize a `WorkloadSpec`
2425 /// using [`WorkloadSpec::for_forge`] which sets all the conventional fields
2426 /// together:
2427 ///
2428 /// - `restart_policy = Never`
2429 /// - `expose.public = None`, `expose.operator = None`
2430 /// - `expose.mesh.identity = "forge.<forge_id>"` — distinguishable from
2431 /// persistent mirror identities at the mesh layer
2432 /// - `tier = "infra"` (or the forge-spec's effective tier)
2433 /// - `annotations["yah.forge"] = "true"` — suppresses the shape warning
2434 ///
2435 /// Using `Never` on a persistent mirror (not a forge run) means the mirror
2436 /// stays dead after any exit — a likely misconfiguration. Shape validation
2437 /// emits a soft warning unless `annotations["yah.forge"] == "true"` is
2438 /// present. See R094 forge.
2439 Never,
2440}
2441
2442/// Exponential backoff parameters for `RestartPolicy::OnFailure`.
2443#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
2444#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2445pub struct BackoffPolicy {
2446 /// Initial delay before the first restart, in milliseconds.
2447 pub initial_ms: u32,
2448
2449 /// Maximum delay between retries, in milliseconds.
2450 pub max_ms: u32,
2451
2452 /// Backoff multiplier applied to each successive delay.
2453 pub multiplier: f32,
2454}
2455
2456/// Graceful shutdown configuration for yubaba's stop sequence.
2457#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
2458#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2459pub struct StopPolicy {
2460 /// Signal number sent first, e.g. `15` (SIGTERM) or `2` (SIGINT).
2461 pub signal: i32,
2462
2463 /// Time yubaba waits after sending `signal` before issuing SIGKILL.
2464 pub grace_period: Millis,
2465}
2466
2467// ── Expose ────────────────────────────────────────────────────────────────────
2468
2469/// Network exposure configuration. The three channels are independent; any
2470/// combination is valid.
2471#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
2472#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2473pub struct ExposeSpec {
2474 /// Mesh-internal exposure. Required; every workload must have a mesh
2475 /// identity even if no other workload currently reaches it.
2476 pub mesh: MeshExpose,
2477
2478 /// Public internet exposure via a Cloudflare tunnel route. `None` means
2479 /// the workload is not internet-reachable.
2480 #[ts(optional = nullable)]
2481 pub public: Option<PublicExpose>,
2482
2483 /// Operator-facing exposure via a Tailscale ACL tag. `None` means the
2484 /// workload is not operator-reachable via Tailscale.
2485 #[ts(optional = nullable)]
2486 pub operator: Option<OperatorExpose>,
2487}
2488
2489/// A peer permitted to initiate mesh connections to a workload (W206 / R558-F3).
2490///
2491/// Cross-tenant access is **deny-by-default**: a workload accepts inter-tenant
2492/// traffic only from peers it lists explicitly as [`MeshPeer::CrossTenant`].
2493/// Same-tenant access stays tier-based ([`MeshPeer::Tier`]) — the pre-R558
2494/// model — and an `allow_from` with no `Tier` entries still admits every
2495/// same-tenant peer (the historical "empty = allow all" default).
2496///
2497/// External serde tagging keeps this postcard-safe (R590-B3): no internal tag,
2498/// no untagged, no `skip_serializing_if`.
2499#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
2500#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2501#[serde(rename_all = "snake_case")]
2502pub enum MeshPeer {
2503 /// Any **same-tenant** workload whose `tier` matches this tag. This is the
2504 /// pre-R558 `allow_from` semantics.
2505 Tier(TierTag),
2506
2507 /// A specific workload in **another tenant**, addressed by its fully
2508 /// qualified mesh identity `<tenant>/<namespace>/<name>`. There is no
2509 /// cross-tenant tier wildcard — each cross-tenant peer is granted
2510 /// individually, so a shared fleet stays isolated unless an operator opts
2511 /// in here.
2512 CrossTenant {
2513 tenant: TenantId,
2514 namespace: NamespaceId,
2515 /// Peer's mesh identity (its [`MeshExpose::identity`]).
2516 name: MeshIdent,
2517 },
2518}
2519
2520/// Mesh-internal port exposure and peer access control.
2521#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
2522#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2523pub struct MeshExpose {
2524 /// DNS-segment mesh identity for this workload. Must be unique in the
2525 /// cluster. Regex: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`, length ≤ 63.
2526 pub identity: MeshIdent,
2527
2528 /// Container-side ports this workload listens on. Other workloads reach
2529 /// it at `<identity>:<port>` on the mesh.
2530 pub ports: Vec<u16>,
2531
2532 /// Peers permitted to initiate connections to this workload on the mesh
2533 /// (W206 / R558-F3). Same-tenant tier rules and explicit cross-tenant
2534 /// grants share this one list. With **no** [`MeshPeer::Tier`] entries every
2535 /// same-tenant peer is admitted (the historical "empty = allow all"
2536 /// default); cross-tenant peers are always denied unless named by a
2537 /// [`MeshPeer::CrossTenant`] entry. See [`MeshExpose::admits_peer`].
2538 #[serde(default)]
2539 pub allow_from: Vec<MeshPeer>,
2540}
2541
2542impl MeshExpose {
2543 /// Whether a peer may initiate a mesh connection to a workload whose mesh
2544 /// exposure is `self`. `own_tenant` is the tenant of the workload being
2545 /// protected; the remaining arguments identify the connecting peer.
2546 ///
2547 /// Deny-by-default across tenants (W206 / R558-F3):
2548 /// - **Same tenant** (`own_tenant == peer_tenant`): admitted when the
2549 /// peer's tier matches a [`MeshPeer::Tier`] rule, or when there are no
2550 /// `Tier` rules at all (historical "empty `allow_from` = allow all
2551 /// same-tenant").
2552 /// - **Cross tenant**: admitted only when an explicit
2553 /// [`MeshPeer::CrossTenant`] entry matches the peer's
2554 /// `(tenant, namespace, name)`.
2555 pub fn admits_peer(
2556 &self,
2557 own_tenant: &TenantId,
2558 peer_tenant: &TenantId,
2559 peer_namespace: &NamespaceId,
2560 peer_name: &MeshIdent,
2561 peer_tier: &TierTag,
2562 ) -> bool {
2563 if own_tenant == peer_tenant {
2564 let mut has_tier_rule = false;
2565 for peer in &self.allow_from {
2566 if let MeshPeer::Tier(t) = peer {
2567 has_tier_rule = true;
2568 if t == peer_tier {
2569 return true;
2570 }
2571 }
2572 }
2573 // No same-tenant tier restriction declared → admit all same-tenant.
2574 !has_tier_rule
2575 } else {
2576 self.allow_from.iter().any(|peer| {
2577 matches!(
2578 peer,
2579 MeshPeer::CrossTenant { tenant, namespace, name }
2580 if tenant == peer_tenant
2581 && namespace == peer_namespace
2582 && name == peer_name
2583 )
2584 })
2585 }
2586 }
2587}
2588
2589/// The name by which a workload is addressed **within its own tenant** (W206 /
2590/// R558-F3), given every `(namespace, identity)` pair present in that tenant.
2591///
2592/// Within a tenant, a workload is reached by its short mesh `identity` when that
2593/// identity is unique across the tenant's namespaces. When two namespaces
2594/// expose the same identity, the name is ambiguous, so both are disambiguated
2595/// by a namespace prefix — `<namespace>.<identity>` (e.g. `yah.runner` vs
2596/// `noisetable.runner`). Cross-tenant addressing always uses the full FQN
2597/// ([`WorkloadSpec::fq_mesh_identity`]) and is out of scope here.
2598pub fn intra_tenant_address(
2599 namespace: &NamespaceId,
2600 identity: &MeshIdent,
2601 tenant_workloads: &[(NamespaceId, MeshIdent)],
2602) -> String {
2603 let collides = tenant_workloads
2604 .iter()
2605 .any(|(ns, id)| id == identity && ns != namespace);
2606 if collides {
2607 format!("{}.{}", namespace.0, identity.0)
2608 } else {
2609 identity.0.clone()
2610 }
2611}
2612
2613/// Public internet exposure via a Cloudflare tunnel route.
2614#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
2615#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2616pub struct PublicExpose {
2617 /// Public hostname to route, e.g. `"api.noisetable.io"`. Semantic
2618 /// validation checks that this hostname is owned by a configured CF zone.
2619 pub hostname: String,
2620
2621 /// Container-side port to route traffic to. Shape validation requires this
2622 /// port to appear in `expose.mesh.ports`.
2623 pub port: u16,
2624
2625 /// TLS configuration for the public endpoint.
2626 pub tls: PublicTls,
2627}
2628
2629/// TLS mode for a public endpoint.
2630#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
2631#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2632#[serde(rename_all = "snake_case")]
2633pub enum PublicTls {
2634 /// Cloudflare manages the TLS certificate (default; requires a proxied DNS
2635 /// record in the configured zone).
2636 CfManaged,
2637
2638 /// User-supplied certificate referenced by name in the yubaba secret store.
2639 UserCertRef { name: String },
2640}
2641
2642/// Operator-facing exposure via a Tailscale ACL tag.
2643#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
2644#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2645pub struct OperatorExpose {
2646 /// Tailscale ACL tag granting access, e.g. `"tag:noisetable-ops"`. Semantic
2647 /// validation checks that this tag exists in the cluster's Tailscale ACL.
2648 pub tailscale_tag: String,
2649
2650 /// Container-side port to expose to Tailscale-authorized operators.
2651 pub port: u16,
2652}
2653
2654// ── ImageRef helpers ──────────────────────────────────────────────────────────
2655
2656impl ImageRef {
2657 /// The all-zeros sha256 digest that marks an image reference as **not
2658 /// content-pinned**. No real image can carry it, so a build that never
2659 /// injected a compile-time digest (dev builds) or a catalog image that
2660 /// isn't published-and-pinned yet lands on this sentinel. This is the
2661 /// single source of truth both the catalog emitter
2662 /// (`task::default_image::catalog_image`, which writes it) and the
2663 /// container-runtime resolvers ([`Self::pull_ref`], via kamaji) agree on —
2664 /// keeping them here means they cannot drift. [`testing::TEST_DIGEST`] is
2665 /// the same value re-exported for fixtures.
2666 pub const UNPINNED_DIGEST: &'static str =
2667 "sha256:0000000000000000000000000000000000000000000000000000000000000000";
2668
2669 /// Parse a full digest-pinned image reference —
2670 /// `[registry/]repo[:tag]@sha256:<hex>` — into its parts.
2671 ///
2672 /// This is the public door onto the same parser the `ImageRef` string-form
2673 /// `Deserialize` arm uses, so a config that spells an image as one string
2674 /// (a qed `step.image`, a transform recipe) and a config that spells it as
2675 /// a struct land on identical semantics. A bare tag is rejected: the whole
2676 /// point of the string form is that it carries the digest.
2677 pub fn parse_pinned(s: &str) -> Result<Self, String> {
2678 compose_import::parse_pinned_image_ref(s)
2679 }
2680
2681 /// Format this reference as a Docker-compatible image string,
2682 /// `{registry}/{repository}:{tag}@{digest}`. Tag is included for human
2683 /// readability; the digest is what the pull resolves against. Always emits
2684 /// the digest — this is the display/logging form; use [`Self::pull_ref`]
2685 /// for the string handed to a container runtime.
2686 pub fn docker_ref(&self) -> String {
2687 format!("{}/{}:{}@{}", self.registry, self.repository, self.tag, self.digest)
2688 }
2689
2690 /// True when this reference carries a real content-addressed digest, i.e.
2691 /// its digest is not the all-zeros [`Self::UNPINNED_DIGEST`] sentinel.
2692 pub fn is_pinned(&self) -> bool {
2693 self.digest != Self::UNPINNED_DIGEST
2694 }
2695
2696 /// The reference string to hand a container runtime for pull/resolve.
2697 ///
2698 /// - **Pinned** (real digest): `{registry}/{repository}:{tag}@{digest}` —
2699 /// content-addressed, the reproducible path.
2700 /// - **Unpinned** (all-zeros [`Self::UNPINNED_DIGEST`]): `{registry}/{repository}:{tag}`
2701 /// — tag-only. No registry or local store holds an image under the
2702 /// sentinel digest, so `…@sha256:0000…` can never resolve; a
2703 /// tag-pulled or locally-built image is keyed by `registry/repo:tag`.
2704 /// This is the tag-fallback path that lets a not-yet-published catalog
2705 /// image (e.g. a from-source build-worker image) still pull by tag.
2706 pub fn pull_ref(&self) -> String {
2707 if self.is_pinned() {
2708 format!("{}/{}:{}@{}", self.registry, self.repository, self.tag, self.digest)
2709 } else {
2710 format!("{}/{}:{}", self.registry, self.repository, self.tag)
2711 }
2712 }
2713}
2714
2715// ── WorkloadRuntime trait ─────────────────────────────────────────────────────
2716
2717/// Shared interface for deploying and managing `WorkloadSpec` containers.
2718///
2719/// This is the keystone abstraction (R256-F10) that makes sim and cloud
2720/// literally interchangeable at the container level:
2721///
2722/// - **Camp/sim tier**: `LocalDockerRuntime` in `cloud` implements this trait
2723/// via the docker CLI pointed at OrbStack (or any Docker-compatible socket).
2724/// No mesh — containers communicate over OrbStack's bridge network.
2725///
2726/// - **Yubaba/cloud-HA tier**: `yubaba::runtime::ContainerRuntime` (gRPC to
2727/// containerd) will implement this trait. Mesh assignment is a separate
2728/// orchestration step on top (handled by yubaba's raft layer), not part
2729/// of the shared deploy/supervise interface.
2730///
2731/// Callers that type against `WorkloadRuntime` automatically work with both
2732/// backends. Reconcilers in `cloud` use it today; yubaba wires its own impl
2733/// when R276 Tier-3 lands.
2734#[async_trait::async_trait]
2735pub trait WorkloadRuntime: Send + Sync {
2736 /// Deploy a workload described by `spec`. Pulls the image if needed,
2737 /// creates and starts the container, and returns an opaque workload ID
2738 /// (typically the container name derived from `spec.name`).
2739 ///
2740 /// Idempotent: re-deploying a running workload replaces it cleanly.
2741 async fn deploy_workload(&self, spec: &WorkloadSpec) -> anyhow::Result<String>;
2742
2743 /// Tear down a deployed workload — stop the process and remove all
2744 /// associated state. No-op when the workload is already gone.
2745 async fn teardown_workload(&self, name: &str) -> anyhow::Result<()>;
2746
2747 /// Returns `true` when the named workload is currently running (i.e.
2748 /// the container process is alive and has not exited).
2749 async fn is_running(&self, name: &str) -> anyhow::Result<bool>;
2750
2751 /// Probe the runtime backend. Returns `true` when the backend socket is
2752 /// reachable and healthy (e.g. docker daemon up, containerd gRPC up).
2753 /// Used by health endpoints and startup checks.
2754 async fn runtime_health(&self) -> anyhow::Result<bool>;
2755}
2756
2757// ── Tests ─────────────────────────────────────────────────────────────────────
2758
2759#[cfg(test)]
2760mod tests {
2761 use super::*;
2762
2763 // ── R603-T5 durable forge produced convention ──────────────────────────────
2764
2765 #[test]
2766 fn forge_produced_ident_parse() {
2767 assert_eq!(forge_produced::forge_id_from_ident("forge.abc123"), Some("abc123"));
2768 assert_eq!(forge_produced::forge_id_from_ident("svc.web"), None);
2769 assert_eq!(forge_produced::forge_id_from_ident("abc123"), None);
2770 }
2771
2772 #[test]
2773 fn forge_produced_host_path_translates_under_convention_dir() {
2774 let hp = forge_produced::host_path(
2775 "fid",
2776 std::path::Path::new("/yah/produced/librusty_v8.tar.gz"),
2777 )
2778 .expect("path under the convention dir translates");
2779 assert_eq!(
2780 hp,
2781 PathBuf::from("/var/lib/yah/qed/produced/fid/librusty_v8.tar.gz")
2782 );
2783 }
2784
2785 #[test]
2786 fn forge_produced_host_path_rejects_paths_outside_convention_dir() {
2787 assert_eq!(
2788 forge_produced::host_path("fid", std::path::Path::new("/tmp/x.tar.gz")),
2789 None,
2790 "a path outside /yah/produced has no durable host mapping"
2791 );
2792 }
2793
2794 #[test]
2795 fn forge_produced_host_path_rejects_traversal() {
2796 // A `..` component must never let a read escape the per-forge dir.
2797 assert_eq!(
2798 forge_produced::host_path(
2799 "fid",
2800 std::path::Path::new("/yah/produced/../../etc/passwd")
2801 ),
2802 None,
2803 "traversal out of the per-forge dir must be refused"
2804 );
2805 }
2806
2807 #[test]
2808 fn forge_produced_durable_mount_shape() {
2809 let m = forge_produced::durable_mount("fid");
2810 assert_eq!(m.target, PathBuf::from("/yah/produced"));
2811 assert!(!m.read_only, "the build must be able to write to it");
2812 assert_eq!(
2813 m.source,
2814 VolumeSource::Bind {
2815 host_path: PathBuf::from("/var/lib/yah/qed/produced/fid"),
2816 }
2817 );
2818 }
2819
2820 const HASH_64: &str = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
2821
2822 #[test]
2823 fn blake_hash_accepts_64_hex() {
2824 let h: BlakeHash = toml::from_str(&format!("x = \"{HASH_64}\""))
2825 .map(|t: toml::Table| t["x"].as_str().unwrap().to_owned())
2826 .map(|s| serde_json::from_value(serde_json::Value::String(s)).unwrap())
2827 .unwrap();
2828 assert_eq!(h.0, HASH_64);
2829 }
2830
2831 #[test]
2832 fn blake_hash_rejects_wrong_length() {
2833 let short = "abcdef";
2834 let res: Result<BlakeHash, _> =
2835 serde_json::from_value(serde_json::Value::String(short.into()));
2836 assert!(res.is_err());
2837 }
2838
2839 #[test]
2840 fn blake_hash_rejects_non_hex() {
2841 let bad = "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz";
2842 let res: Result<BlakeHash, _> =
2843 serde_json::from_value(serde_json::Value::String(bad.into()));
2844 assert!(res.is_err());
2845 }
2846
2847 fn image_ref(digest: &str) -> ImageRef {
2848 ImageRef {
2849 registry: "ghcr.io".into(),
2850 repository: "yah-ai/rusty-v8-musl-builder".into(),
2851 tag: "latest".into(),
2852 digest: digest.into(),
2853 }
2854 }
2855
2856 #[test]
2857 fn is_pinned_distinguishes_real_digest_from_sentinel() {
2858 assert!(!image_ref(ImageRef::UNPINNED_DIGEST).is_pinned());
2859 assert!(!image_ref(&testing::test_digest()).is_pinned());
2860 assert!(image_ref("sha256:deadbeef").is_pinned());
2861 }
2862
2863 #[test]
2864 fn pull_ref_pinned_carries_tag_and_digest() {
2865 assert_eq!(
2866 image_ref("sha256:deadbeef").pull_ref(),
2867 "ghcr.io/yah-ai/rusty-v8-musl-builder:latest@sha256:deadbeef",
2868 );
2869 }
2870
2871 #[test]
2872 fn pull_ref_unpinned_falls_back_to_tag_only() {
2873 // An unpinned catalog image (all-zeros sentinel) resolves by tag —
2874 // no store holds `…@sha256:0000…`, so the tag is the only usable key.
2875 assert_eq!(
2876 image_ref(ImageRef::UNPINNED_DIGEST).pull_ref(),
2877 "ghcr.io/yah-ai/rusty-v8-musl-builder:latest",
2878 );
2879 }
2880
2881 #[test]
2882 fn test_digest_alias_is_the_unpinned_sentinel() {
2883 assert_eq!(testing::TEST_DIGEST, ImageRef::UNPINNED_DIGEST);
2884 }
2885
2886 #[test]
2887 fn static_asset_workload_round_trips() {
2888 let src = format!(
2889 r#"
2890schema_version = "V1"
2891
2892[[asset]]
2893filename = "whisper/distil-large-v3-q5_1.bin"
2894source = "sources/distil-large-v3-q5_1.bin"
2895blake3 = "{HASH_64}"
2896
2897[[asset]]
2898filename = "whisper/distil-large-v3-q4_0.bin"
2899source = "sources/distil-large-v3-q4_0.bin"
2900blake3 = "{HASH_64}"
2901
2902[aliases]
2903"whisper-default" = "whisper/distil-large-v3-q5_1.bin"
2904"#
2905 );
2906 let w: StaticAssetWorkload = toml::from_str(&src).expect("parse");
2907 assert_eq!(w.assets.len(), 2);
2908 assert_eq!(w.assets[0].filename, "whisper/distil-large-v3-q5_1.bin");
2909 assert_eq!(w.assets[0].blake3.0, HASH_64);
2910 assert_eq!(w.aliases["whisper-default"], "whisper/distil-large-v3-q5_1.bin");
2911
2912 let back = toml::to_string(&w).expect("serialize");
2913 let w2: StaticAssetWorkload = toml::from_str(&back).expect("re-parse");
2914 assert_eq!(w, w2);
2915 }
2916
2917 #[test]
2918 fn license_round_trip_each_variant() {
2919 // Wire format is whatever serde's `rename_all = "kebab-case"` emits.
2920 // heck's kebab-case keeps letter→digit attached but splits digit→uppercase,
2921 // so `Apache2 → "apache2"` and `Bsd2Clause → "bsd2-clause"`.
2922 for (variant, on_wire) in [
2923 (License::Mit, "mit"),
2924 (License::Apache2, "apache2"),
2925 (License::Bsd2Clause, "bsd2-clause"),
2926 (License::Bsd3Clause, "bsd3-clause"),
2927 (License::Isc, "isc"),
2928 ] {
2929 let ser = serde_json::to_value(variant).expect("serialize");
2930 assert_eq!(ser, serde_json::Value::String(on_wire.into()));
2931 let back: License = serde_json::from_value(ser).expect("deserialize");
2932 assert_eq!(back, variant);
2933 }
2934 }
2935
2936 #[test]
2937 fn license_rejects_non_permissive_variants() {
2938 for unknown in ["GPL-3.0", "AGPL", "lgpl-2.1", "unknown", "MIT"] {
2939 let res: Result<License, _> =
2940 serde_json::from_value(serde_json::Value::String(unknown.into()));
2941 assert!(res.is_err(), "expected rejection for {unknown:?}");
2942 }
2943 }
2944
2945 #[test]
2946 fn fetch_source_round_trips() {
2947 let src = format!(
2948 r#"
2949url = "https://example.invalid/upstream.bin"
2950blake3 = "{HASH_64}"
2951license = "mit"
2952"#
2953 );
2954 let fs: FetchSource = toml::from_str(&src).expect("parse");
2955 assert_eq!(fs.url, "https://example.invalid/upstream.bin");
2956 assert_eq!(fs.blake3.0, HASH_64);
2957 assert_eq!(fs.license, License::Mit);
2958
2959 let back = toml::to_string(&fs).expect("serialize");
2960 let fs2: FetchSource = toml::from_str(&back).expect("re-parse");
2961 assert_eq!(fs, fs2);
2962 }
2963
2964 #[test]
2965 fn fetch_source_rejects_unknown_license() {
2966 let src = format!(
2967 r#"
2968url = "https://example.invalid/upstream.bin"
2969blake3 = "{HASH_64}"
2970license = "GPL-3.0"
2971"#
2972 );
2973 let res: Result<FetchSource, _> = toml::from_str(&src);
2974 assert!(res.is_err(), "expected non-permissive license to reject");
2975 }
2976
2977 #[test]
2978 fn asset_entry_derive_mode_round_trips() {
2979 let src = format!(
2980 r#"
2981schema_version = "V1"
2982
2983[[asset]]
2984filename = "whisper/distil-large-v3-q5_1.bin"
2985blake3 = "{HASH_64}"
2986
2987[asset.derive.fetch]
2988url = "https://example.invalid/ggml-distil-large-v3.bin"
2989blake3 = "{HASH_64}"
2990license = "mit"
2991
2992[asset.derive.transform]
2993recipe = "whisper-quantize"
2994params = {{ quant = "q5_1" }}
2995"#
2996 );
2997 let w: StaticAssetWorkload = toml::from_str(&src).expect("parse");
2998 assert_eq!(w.assets.len(), 1);
2999 let entry = &w.assets[0];
3000 assert!(entry.source.is_none());
3001 let derive = entry.derive.as_ref().expect("derive present");
3002 assert_eq!(derive.fetch.url, "https://example.invalid/ggml-distil-large-v3.bin");
3003 assert_eq!(derive.fetch.license, License::Mit);
3004 let transform = derive.transform.as_ref().expect("transform present");
3005 assert_eq!(transform.recipe, "whisper-quantize");
3006 assert_eq!(transform.params.get("quant").map(String::as_str), Some("q5_1"));
3007
3008 let back = toml::to_string(&w).expect("serialize");
3009 let w2: StaticAssetWorkload = toml::from_str(&back).expect("re-parse");
3010 assert_eq!(w, w2);
3011 }
3012
3013 #[test]
3014 fn legacy_source_only_asset_serializes_without_derive_field() {
3015 // Verify the skip_serializing_if guards keep legacy TOMLs round-tripping
3016 // without ever emitting an empty `derive = ...` line.
3017 let src = format!(
3018 r#"
3019schema_version = "V1"
3020
3021[[asset]]
3022filename = "operator-curated.bin"
3023source = "sources/operator-curated.bin"
3024blake3 = "{HASH_64}"
3025"#
3026 );
3027 let w: StaticAssetWorkload = toml::from_str(&src).expect("parse");
3028 let back = toml::to_string(&w).expect("serialize");
3029 assert!(!back.contains("derive"), "serialized output leaked a derive field: {back}");
3030 let w2: StaticAssetWorkload = toml::from_str(&back).expect("re-parse");
3031 assert_eq!(w, w2);
3032 }
3033
3034 /// W212/R518: the `[asset.derive.lock]` block round-trips through TOML, and
3035 /// is omitted from output when absent (so non-derive / unlocked assets stay
3036 /// clean).
3037 #[test]
3038 fn derive_lock_round_trips_through_toml() {
3039 let toml = r#"
3040url = "https://example.invalid/config.json"
3041blake3 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
3042license = "mit"
3043"#;
3044 let fetch: FetchSource = ::toml::from_str(toml).unwrap();
3045 let derive = AssetDerive {
3046 fetch,
3047 transform: Some(TransformSpec {
3048 recipe: "whisper-bundle-tar".into(),
3049 params: BTreeMap::new(),
3050 }),
3051 lock: Some(DeriveLock {
3052 input_hash: "1111111111111111111111111111111111111111111111111111111111111111".into(),
3053 output_blake3: "2222222222222222222222222222222222222222222222222222222222222222".into(),
3054 }),
3055 };
3056 let s = ::toml::to_string(&derive).unwrap();
3057 assert!(s.contains("[lock]"), "lock serialized: {s}");
3058 let back: AssetDerive = ::toml::from_str(&s).unwrap();
3059 assert_eq!(derive, back);
3060
3061 // Absent lock → no `[lock]` table in the output.
3062 let unlocked = AssetDerive { lock: None, ..derive };
3063 let s2 = ::toml::to_string(&unlocked).unwrap();
3064 assert!(!s2.contains("[lock]"), "unlocked must omit lock: {s2}");
3065 }
3066
3067 #[test]
3068 fn shape_static_asset_rejects_both_source_and_derive() {
3069 use crate::validate::{shape_static_asset, FieldPath, ShapeError};
3070
3071 let entry = AssetEntry {
3072 filename: "ambiguous.bin".into(),
3073 source: Some("sources/ambiguous.bin".into()),
3074 derive: Some(AssetDerive {
3075 fetch: FetchSource {
3076 url: "https://example.invalid/x".into(),
3077 blake3: BlakeHash(HASH_64.into()),
3078 license: License::Mit,
3079 },
3080 transform: None,
3081 lock: None,
3082 }),
3083 blake3: BlakeHash(HASH_64.into()),
3084 };
3085 let w = StaticAssetWorkload {
3086 schema_version: SchemaVersion::V1,
3087 assets: vec![entry],
3088 aliases: BTreeMap::new(),
3089 };
3090 let err = shape_static_asset(&w).expect_err("XOR violated");
3091 match err {
3092 ShapeError::Field { path: FieldPath::Asset(0, "source"), .. } => {}
3093 other => panic!("expected Asset(0, \"source\") shape error, got {other:?}"),
3094 }
3095 }
3096
3097 #[test]
3098 fn shape_static_asset_rejects_neither_source_nor_derive() {
3099 use crate::validate::{shape_static_asset, FieldPath, ShapeError};
3100
3101 let entry = AssetEntry {
3102 filename: "empty.bin".into(),
3103 source: None,
3104 derive: None,
3105 blake3: BlakeHash(HASH_64.into()),
3106 };
3107 let w = StaticAssetWorkload {
3108 schema_version: SchemaVersion::V1,
3109 assets: vec![entry],
3110 aliases: BTreeMap::new(),
3111 };
3112 let err = shape_static_asset(&w).expect_err("XOR violated");
3113 match err {
3114 ShapeError::Field { path: FieldPath::Asset(0, "source"), .. } => {}
3115 other => panic!("expected Asset(0, \"source\") shape error, got {other:?}"),
3116 }
3117 }
3118
3119 #[test]
3120 fn shape_static_asset_accepts_either_mode() {
3121 use crate::validate::shape_static_asset;
3122
3123 let legacy = AssetEntry {
3124 filename: "a.bin".into(),
3125 source: Some("sources/a.bin".into()),
3126 derive: None,
3127 blake3: BlakeHash(HASH_64.into()),
3128 };
3129 let derived = AssetEntry {
3130 filename: "b.bin".into(),
3131 source: None,
3132 derive: Some(AssetDerive {
3133 fetch: FetchSource {
3134 url: "https://example.invalid/b".into(),
3135 blake3: BlakeHash(HASH_64.into()),
3136 license: License::Apache2,
3137 },
3138 transform: None,
3139 lock: None,
3140 }),
3141 blake3: BlakeHash(HASH_64.into()),
3142 };
3143 let w = StaticAssetWorkload {
3144 schema_version: SchemaVersion::V1,
3145 assets: vec![legacy, derived],
3146 aliases: BTreeMap::new(),
3147 };
3148 shape_static_asset(&w).expect("both modes accepted");
3149 }
3150
3151 #[test]
3152 fn image_ref_string_form_rejects_bare_tag() {
3153 let res: Result<ImageRef, _> =
3154 serde_json::from_value(serde_json::Value::String("node:20".into()));
3155 let err = res.expect_err("bare-tag must reject");
3156 let msg = format!("{err}");
3157 assert!(msg.contains("digest"), "error should mention digest: {msg}");
3158 }
3159
3160 #[test]
3161 fn image_ref_string_form_accepts_digest_pinned() {
3162 let pinned = format!("node:20@sha256:{HASH_64}");
3163 let img: ImageRef =
3164 serde_json::from_value(serde_json::Value::String(pinned.clone())).expect("parse");
3165 assert_eq!(img.registry, "docker.io");
3166 assert_eq!(img.repository, "library/node");
3167 assert_eq!(img.tag, "20");
3168 assert_eq!(img.digest, format!("sha256:{HASH_64}"));
3169 }
3170
3171 #[test]
3172 fn image_ref_string_form_accepts_ghcr_with_pin() {
3173 let pinned = format!("ghcr.io/foo/bar:v1.7.4@sha256:{HASH_64}");
3174 let img: ImageRef =
3175 serde_json::from_value(serde_json::Value::String(pinned)).expect("parse");
3176 assert_eq!(img.registry, "ghcr.io");
3177 assert_eq!(img.repository, "foo/bar");
3178 assert_eq!(img.tag, "v1.7.4");
3179 assert!(img.digest.starts_with("sha256:"));
3180 }
3181
3182 #[test]
3183 fn image_ref_string_form_rejects_non_sha256_digest() {
3184 for bad in [
3185 "node:20@md5:abcdef",
3186 "node:20@sha1:abcdef",
3187 "node:20@sha256:",
3188 "node:20@sha256:zzznothex",
3189 ] {
3190 let res: Result<ImageRef, _> =
3191 serde_json::from_value(serde_json::Value::String(bad.into()));
3192 assert!(res.is_err(), "expected reject for {bad:?}");
3193 }
3194 }
3195
3196 #[test]
3197 fn image_ref_struct_form_rejects_missing_digest() {
3198 // Digest is now structurally required (R438-T3). Struct-form payloads
3199 // without `digest` must fail at serde-deserialize.
3200 let v = serde_json::json!({
3201 "registry": "ghcr.io",
3202 "repository": "noisetable/api",
3203 "tag": "v1.4.2",
3204 });
3205 let res: Result<ImageRef, _> = serde_json::from_value(v);
3206 assert!(res.is_err(), "missing digest must reject");
3207 }
3208
3209 #[test]
3210 fn image_ref_struct_form_round_trips_through_toml() {
3211 let img = ImageRef {
3212 registry: "ghcr.io".into(),
3213 repository: "ggerganov/whisper.cpp".into(),
3214 tag: "v1.7.4".into(),
3215 digest: format!("sha256:{HASH_64}"),
3216 };
3217 let toml_doc = toml::to_string(&img).expect("serialize");
3218 let back: ImageRef = toml::from_str(&toml_doc).expect("re-parse");
3219 assert_eq!(img, back);
3220 }
3221
3222 /// R546-B7: assert the shape real files use. This test previously fed the
3223 /// EXTERNALLY-tagged wrapping-table form (`[static-asset]` +
3224 /// `[[static-asset.asset]]`), which no on-disk `workload.toml` has ever
3225 /// used — so it stayed green while `yah cloud apply` was broken for every
3226 /// static-asset component. The flat `kind = "..."` form below is what every
3227 /// workload.toml in the workspace is written in.
3228 #[test]
3229 fn workload_envelope_dispatches_static_asset() {
3230 let src = format!(
3231 r#"
3232kind = "static-asset"
3233schema_version = "V1"
3234
3235[[asset]]
3236filename = "foo/bar.bin"
3237source = "sources/bar.bin"
3238blake3 = "{HASH_64}"
3239"#
3240 );
3241 let w: Workload = toml::from_str(&src).expect("parse");
3242 assert!(matches!(w, Workload::StaticAsset(_)));
3243 }
3244
3245 /// R546-B7: the format branch, both directions. Human-readable formats get
3246 /// the flat `kind`-tagged shape; postcard keeps the externally-tagged
3247 /// variant-index encoding the kamaji UDS depends on (R590-B3). Regressing
3248 /// either side breaks a different half of the system, so pin both.
3249 #[test]
3250 fn workload_envelope_is_tagged_in_toml_and_external_in_postcard() {
3251 let src = format!(
3252 r#"
3253kind = "static-asset"
3254schema_version = "V1"
3255
3256[[asset]]
3257filename = "foo/bar.bin"
3258source = "sources/bar.bin"
3259blake3 = "{HASH_64}"
3260"#
3261 );
3262 let w: Workload = toml::from_str(&src).expect("parse flat TOML");
3263
3264 // Human-readable round-trips stay flat — no wrapping table.
3265 let json = serde_json::to_string(&w).expect("serialize json");
3266 assert!(json.contains("\"kind\":\"static-asset\""), "got {json}");
3267 assert!(
3268 !json.contains("{\"static-asset\":"),
3269 "human-readable output must not be externally tagged: {json}"
3270 );
3271 assert_eq!(
3272 serde_json::from_str::<Workload>(&json).expect("re-parse json"),
3273 w
3274 );
3275
3276 // postcard is non-self-describing: it can only round-trip because the
3277 // binary branch never asks for deserialize_any.
3278 let bytes = postcard::to_allocvec(&w).expect("postcard encode");
3279 assert_eq!(
3280 postcard::from_bytes::<Workload>(&bytes).expect("postcard decode"),
3281 w
3282 );
3283 }
3284
3285 // ── R572-F1: lifecycle archetype discriminator ─────────────────────────
3286
3287 fn archetype_test_spec(name: &str) -> WorkloadSpec {
3288 WorkloadSpec::for_forge(
3289 name,
3290 ImageRef {
3291 registry: "ghcr.io".into(),
3292 repository: "yah/test".into(),
3293 tag: "latest".into(),
3294 digest: testing::test_digest(),
3295 },
3296 TierTag("infra".into()),
3297 vec![],
3298 )
3299 }
3300
3301 #[test]
3302 fn explicit_archetype_round_trips_through_json_and_wins_over_inference() {
3303 for archetype in [
3304 LifecycleArchetype::Server,
3305 LifecycleArchetype::Appliance,
3306 LifecycleArchetype::Job,
3307 ] {
3308 let mut spec = archetype_test_spec("explicit");
3309 // Volumes present + restart_policy Always would infer Appliance
3310 // (see effective_archetype_infers_* below) — deliberately
3311 // mismatched against every archetype under test so the
3312 // assertion actually proves the explicit field wins, not that
3313 // it happens to agree with inference.
3314 spec.volumes = vec![VolumeMount {
3315 source: VolumeSource::Named { name: "data".into() },
3316 target: PathBuf::from("/data"),
3317 read_only: false,
3318 }];
3319 spec.restart_policy = RestartPolicy::Always;
3320 spec.archetype = Some(archetype);
3321
3322 let json = serde_json::to_string(&spec).expect("serialize");
3323 assert!(
3324 json.contains("\"archetype\""),
3325 "explicit archetype must be present on the wire"
3326 );
3327 let back: WorkloadSpec = serde_json::from_str(&json).expect("deserialize");
3328 assert_eq!(spec, back, "spec did not survive JSON round-trip");
3329 assert_eq!(back.archetype, Some(archetype));
3330 assert_eq!(
3331 back.effective_archetype(),
3332 archetype,
3333 "explicit archetype must win over the volumes/restart_policy inference"
3334 );
3335 }
3336 }
3337
3338 #[test]
3339 fn archetype_serializes_as_null_when_none() {
3340 let mut spec = archetype_test_spec("omitted");
3341 spec.archetype = None;
3342 let json = serde_json::to_value(&spec).expect("to_value");
3343 // Postcard-native (R590-B3): no `skip_serializing_if` anywhere on the
3344 // graph, so every field is always on the wire — a None Option is an
3345 // explicit `null`, not an absent key. The binary UDS wire is positional
3346 // and requires the slot to be present.
3347 assert_eq!(json.get("archetype"), Some(&serde_json::Value::Null));
3348 }
3349
3350 #[test]
3351 fn spec_without_archetype_field_deserializes_to_none() {
3352 // Simulates an on-disk spec written before R572-F1: no `archetype`
3353 // key at all. Omitting the key must still parse to None (the additive-
3354 // default contract) even though we now always *emit* the field.
3355 let mut spec = archetype_test_spec("pre-existing");
3356 spec.archetype = None;
3357 let mut json = serde_json::to_value(&spec).expect("to_value");
3358 json.as_object_mut().unwrap().remove("archetype");
3359 let back: WorkloadSpec = serde_json::from_value(json).expect("deserialize");
3360 assert_eq!(back.archetype, None);
3361 }
3362
3363 #[test]
3364 fn effective_archetype_infers_appliance_from_volumes_when_field_absent() {
3365 // Pre-R572 behavior: a workload with a volume was understood (by
3366 // convention, never a type) to be stateful/pinned. Confirm that
3367 // meaning is preserved bit-for-bit through effective_archetype().
3368 let mut spec = archetype_test_spec("appliance-inferred");
3369 spec.volumes = vec![VolumeMount {
3370 source: VolumeSource::Named { name: "pgdata".into() },
3371 target: PathBuf::from("/var/lib/postgresql/data"),
3372 read_only: false,
3373 }];
3374 spec.restart_policy = RestartPolicy::Always;
3375 spec.archetype = None;
3376 assert_eq!(spec.effective_archetype(), LifecycleArchetype::Appliance);
3377 }
3378
3379 #[test]
3380 fn effective_archetype_infers_job_from_restart_never_when_field_absent() {
3381 // Pre-R572 behavior: RestartPolicy::Never + no volumes is the forge
3382 // run-once convention (see RestartPolicy::Never's own doc comment) —
3383 // structurally a job. WorkloadSpec::for_forge already produces
3384 // exactly this shape; isolate the pure-inference path by clearing
3385 // the explicit archetype for_forge now sets.
3386 let mut spec = archetype_test_spec("job-inferred");
3387 assert!(spec.volumes.is_empty());
3388 assert!(matches!(spec.restart_policy, RestartPolicy::Never));
3389 spec.archetype = None;
3390 assert_eq!(spec.effective_archetype(), LifecycleArchetype::Job);
3391 }
3392
3393 #[test]
3394 fn effective_archetype_defaults_to_server_as_the_common_case_when_field_absent() {
3395 // Pre-R572 behavior: no volumes + a restartable policy (the common
3396 // stateless-web-server shape) inferred as movable/fungible.
3397 let mut spec = archetype_test_spec("server-inferred");
3398 spec.restart_policy = RestartPolicy::Always;
3399 spec.archetype = None;
3400 assert_eq!(spec.effective_archetype(), LifecycleArchetype::Server);
3401 }
3402
3403 // ── R594-F2: public-ingress appliance (container-shaped, not a new
3404 // Workload variant — see Workload::Container's doc comment) ───────────
3405
3406 #[test]
3407 fn ingress_marked_spec_is_appliance_and_carries_public_ip_placement_requirement() {
3408 let mut spec = archetype_test_spec("public-ingress");
3409 spec.archetype = Some(LifecycleArchetype::Appliance);
3410 spec.annotations.insert(
3411 REQUIRES_TAINT_ANNOTATION.to_string(),
3412 PUBLIC_IP_TAINT.to_string(),
3413 );
3414
3415 assert_eq!(
3416 spec.effective_archetype(),
3417 LifecycleArchetype::Appliance,
3418 "ingress must be pinned-per-node/non-drainable, the R572 appliance sense"
3419 );
3420 assert_eq!(
3421 spec.requires_taint(),
3422 Some(PUBLIC_IP_TAINT),
3423 "ingress must declare it can only land on a public-ip-tainted node"
3424 );
3425
3426 // No taint exists to match against yet (R572-F3) and nothing
3427 // enforces placement yet (R572-F5) — confirm this ticket stays
3428 // declarative-only by checking a spec with no requirement stays
3429 // unaffected.
3430 let unrelated = archetype_test_spec("unrelated");
3431 assert_eq!(unrelated.requires_taint(), None);
3432 }
3433
3434 #[test]
3435 fn ingress_marked_spec_round_trips_through_json_as_a_container_workload() {
3436 // Mirrors the on-disk envelope: the externally-tagged `container`
3437 // variant wrapping the WorkloadSpec, exactly like every other
3438 // container-shaped workload. No new Workload variant, no new
3439 // discriminator.
3440 let mut inner = archetype_test_spec("public-ingress");
3441 inner.archetype = Some(LifecycleArchetype::Appliance);
3442 inner.annotations.insert(
3443 REQUIRES_TAINT_ANNOTATION.to_string(),
3444 PUBLIC_IP_TAINT.to_string(),
3445 );
3446 let workload = Workload::Container(inner.clone());
3447
3448 let json = serde_json::to_string(&workload).expect("serialize");
3449 assert!(json.contains("\"container\""));
3450 assert!(json.contains(REQUIRES_TAINT_ANNOTATION));
3451 assert!(json.contains(PUBLIC_IP_TAINT));
3452
3453 let back: Workload = serde_json::from_str(&json).expect("deserialize");
3454 match back {
3455 Workload::Container(spec) => {
3456 assert_eq!(spec, inner);
3457 assert_eq!(spec.effective_archetype(), LifecycleArchetype::Appliance);
3458 assert_eq!(spec.requires_taint(), Some(PUBLIC_IP_TAINT));
3459 }
3460 other => panic!("expected Workload::Container, got {other:?}"),
3461 }
3462 }
3463
3464 // ── Nested-sandbox grant (R636-B2) ──────────────────────────────────────
3465
3466 #[test]
3467 fn nested_sandbox_marker_is_opt_in_and_reads_back() {
3468 // The half that matters: no workload gets the grant by default, so
3469 // adding the marker cannot widen anything already deployed.
3470 let plain = archetype_test_spec("ordinary-build");
3471 assert!(!plain.wants_nested_sandbox());
3472
3473 let mut buildkit = archetype_test_spec("build-image");
3474 buildkit.annotations.insert(
3475 NESTED_SANDBOX_ANNOTATION.to_string(),
3476 NESTED_SANDBOX_VALUE.to_string(),
3477 );
3478 assert!(buildkit.wants_nested_sandbox());
3479
3480 // Fails closed on any other value, same strictness as
3481 // `wants_host_network` — a typo must not hand out CAP_SETUID.
3482 let mut typo = archetype_test_spec("typo");
3483 typo.annotations
3484 .insert(NESTED_SANDBOX_ANNOTATION.to_string(), "Nested".to_string());
3485 assert!(!typo.wants_nested_sandbox());
3486 }
3487
3488 /// The three markers are independent axes: asking for host networking or
3489 /// native exec must not imply the capability grant, and vice versa.
3490 #[test]
3491 fn nested_sandbox_marker_is_independent_of_the_other_markers() {
3492 let mut host_net = archetype_test_spec("host-net");
3493 host_net.annotations.insert(
3494 HOST_NETWORK_ANNOTATION.to_string(),
3495 HOST_NETWORK_VALUE.to_string(),
3496 );
3497 assert!(host_net.wants_host_network());
3498 assert!(!host_net.wants_nested_sandbox());
3499
3500 let mut nested = archetype_test_spec("nested");
3501 nested.annotations.insert(
3502 NESTED_SANDBOX_ANNOTATION.to_string(),
3503 NESTED_SANDBOX_VALUE.to_string(),
3504 );
3505 assert!(nested.wants_nested_sandbox());
3506 assert!(!nested.wants_host_network());
3507 assert!(!nested.wants_native_exec());
3508 }
3509
3510 // ── Native exec marker (R577-T1 / W254) ─────────────────────────────────
3511
3512 #[test]
3513 fn native_exec_marker_is_opt_in_and_reads_back() {
3514 // Default: every forge workload is a container workload. This is the
3515 // half that matters most — the marker must not silently reroute the
3516 // Linux offload leg proven live on us-west-002.
3517 let plain = archetype_test_spec("linux-build");
3518 assert!(!plain.wants_native_exec());
3519
3520 let mut native = archetype_test_spec("darwin-build");
3521 native.annotations.insert(
3522 NATIVE_EXEC_ANNOTATION.to_string(),
3523 NATIVE_EXEC_VALUE.to_string(),
3524 );
3525 assert!(native.wants_native_exec());
3526
3527 // Any other value is not the opt-in — same strictness as
3528 // `wants_host_network`, so a typo fails closed onto the container
3529 // backend rather than escaping the sandbox.
3530 let mut typo = archetype_test_spec("typo");
3531 typo.annotations
3532 .insert(NATIVE_EXEC_ANNOTATION.to_string(), "Native".to_string());
3533 assert!(!typo.wants_native_exec());
3534 }
3535
3536 #[test]
3537 fn native_marked_spec_round_trips_through_json_as_a_container_workload() {
3538 // The point of the annotation shape: a native workload is still a
3539 // `Workload::Container` on the wire, so kamaji-proto's codec, yubaba
3540 // admission and the mesh-assignment path need no new variant.
3541 let mut inner = archetype_test_spec("darwin-build");
3542 inner.annotations.insert(
3543 NATIVE_EXEC_ANNOTATION.to_string(),
3544 NATIVE_EXEC_VALUE.to_string(),
3545 );
3546 let workload = Workload::Container(inner.clone());
3547
3548 let json = serde_json::to_string(&workload).expect("serialize");
3549 assert!(json.contains(NATIVE_EXEC_ANNOTATION));
3550
3551 let back: Workload = serde_json::from_str(&json).expect("deserialize");
3552 match back {
3553 Workload::Container(spec) => {
3554 assert_eq!(spec, inner);
3555 assert!(spec.wants_native_exec());
3556 }
3557 other => panic!("expected Workload::Container, got {other:?}"),
3558 }
3559 }
3560}