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