Skip to main content

workload_spec/
lib.rs

1//! `WorkloadSpec` — typed wire format for yubaba workloads.
2//!
3//! This crate is the schema source of truth. It has zero dependencies on
4//! yubaba; yubaba depends on it, not the other way around. Agents and desktop
5//! code that construct specs can link this crate without pulling in yubaba's
6//! containerd client.
7//!
8//! Three validation layers live in [`validate`]: shape (sync, no I/O),
9//! semantic (reads yubaba state), and environment (deploy-time). The schema
10//! types live at the top level.
11//!
12//! @yah:ticket(R222-T3, "Workload schema doesn't match per-kind on-disk shapes (mesofact-static)")
13//! @yah:assignee(agent:claude)
14//! @yah:at(2026-05-18T16:47:03Z)
15//! @yah:status(review)
16//! @yah:parent(R222)
17//! @yah:handoff("Picked option (a): tagged-enum Workload envelope with per-kind variants. Added Workload { MesofactStatic(MesofactStaticWorkload), Container(WorkloadSpec) } + BuildConfig in workload-spec. WorkloadSpec stays the containerd RPC wire type (now also the kind=\"container\" variant payload). xtask emit-schemas now renders workload.toml.schema.json as a oneOf over kind; schema drift test green. TS export updated. Arch doc 'workloads — colocated, not registered' rewritten to describe the envelope + both example kinds; B4 outlook updated to point at Workload.")
18//! @yah:verify("cargo check -p cloud && cargo test -p cloud && cargo check -p yah && cargo check -p agent-tools && cargo check -p yah --tests && cargo check -p agent-tools --tests")
19//! @yah:verify("cargo test -p xtask  # schema drift test must stay green")
20//! @yah:verify("cargo run -p workload-spec --bin export-ts  # idempotent regen")
21//!
22//! @yah:ticket(R256-F7, "Model mesofact container as two roles: transient build/publish job vs long-lived SSR/SPA runtime")
23//! @yah:assignee(agent:claude)
24//! @yah:at(2026-05-25T20:08:29Z)
25//! @yah:status(review)
26//! @yah:parent(R256)
27//! @yah:next("role A — build/publish job: transient task that runs the build and PUTs to the object store, then exits/GC'd; needed whenever there is a build step (SSR or not)")
28//! @yah:next("role B — SSR/SPA runtime: long-lived container, only present when the app has realtime/dynamic pages (this is what the 'only if SSR/SPA' gate applies to)")
29//! @yah:next("decide the fidelity knob: does the build run in-container (matches CI, max fidelity, costs image+cold-start) or on host with yubaba orchestrating only the serving edge?")
30//! @yah:assumes("in cloud these are separate: CI/build job produces artifacts, R2+CDN serve them, and a distinct worker serves any SSR — so one merged 'mesofact container' is the trap")
31//! @yah:handoff("BuildMode enum added to workload-spec with HostSide (default) and InContainer { image } variants. MesofactStaticWorkload gains build_mode: BuildMode (skip_serializing_if default) and ssr_runtime: Option<WorkloadSpec>. Encodes the two-role model: build step is always transient; SSR companion is optional long-lived. Fidelity knob decision: HostSide = host watcher (dev+sim), InContainer = CI-fidelity (cloud/ha). All three codegen targets updated: export-ts.rs, packages/yah/workload-spec/index.ts, .yah/schema/workload.toml.schema.json. Schema drift tests pass.")
32//! @yah:verify("cargo check -p workload-spec --locked")
33//! @yah:verify("cargo test -p xtask --locked  # schema_drift tests pass")
34//! @yah:verify("cargo test -p cloud --locked --lib  # 165 passed")
35//!
36//! @yah:ticket(R256-F9, "Almanac as a dependency manifest: orchestrator verifies I/O targets live before run; output invalidates mesofact sources cleanly")
37//! @yah:assignee(agent:claude)
38//! @yah:at(2026-05-25T21:28:15Z)
39//! @yah:status(review)
40//! @yah:parent(R256)
41//! @yah:next("almanac is a manifest declaring inputs + outputs + cadence + command — NOT a bash cron; the declared I/O is the contract")
42//! @yah:next("before a run the orchestrator verifies declared inputs exist AND output targets (e.g. the mesofact app + its source/object store) are reachable; if not → the run fails or waits/times out rather than producing orphaned output")
43//! @yah:next("almanac output invalidates downstream mesofact sources cleanly + deliberately (a declared dependency edge, not a blunt rebuild-everything) — this is the reason it's a named manifest, not a shell cron")
44//! @yah:next("decide the not-ready policy knob: fail-fast vs wait-with-timeout vs requeue")
45//! @yah:next("generalizes the OpenRouter refresher (spawn_almanac_refresher), which is the degenerate no-dependency case (output = JSON cache, no app target)")
46//! @yah:assumes("precondition enforcement lives in the shared scheduler layer (embedded by camp for dev/sim, yubaba for cloud/ha) — it needs the workload registry + xlb-net discovery to answer 'is the target up?', which a bash cron lacks")
47//! @arch:see(.yah/docs/architecture/A024-vocabulary.md)
48//! @yah:depends_on(R256-F6)
49//! @yah:handoff("AlmanacTarget (Http/Tcp probe), NotReadyPolicy (WaitWithTimeout default=5s/FailFast/Requeue), Cadence (Once/Every/Cron), and AlmanacManifest types added to workload-spec. Workload enum gains Almanac(AlmanacManifest) variant (kind='almanac'). NotReadyPolicy::WaitWithTimeout(5s) is the default — matches sim-tier spinup budget. AlmanacManifest.invalidates: Vec<MeshIdent> declares downstream cache-bust targets. export-ts.rs updated; index.ts and workload.toml.schema.json regenerated; drift tests pass. The degenerate case (no inputs, no outputs, Cron, no invalidates) is exactly the OpenRouter refresher pattern. The orchestrator precondition enforcement (xlb-net probing) is left for R276/yubaba integration.")
50//! @yah:verify("cargo check -p workload-spec --locked")
51//! @yah:verify("cargo test -p xtask --locked  # schema drift tests pass")
52//! @yah:verify("cargo test -p cloud --locked --lib  # 165 passed")
53//!
54//! @yah:relay(R335, "Almanac mirror-binding — scope a feed to the mirror it affects")
55//! @yah:at(2026-05-27T02:19:09Z)
56//! @arch:see(.yah/docs/working/W058-almanac-mirror-binding.md)
57//! @yah:depends_on(R256-F9)
58//!
59//! @yah:ticket(R335-S1, "Decide cross-env pollution mechanism: extend R256-F9 manifest vs add per-mirror capability")
60//! @yah:assignee(agent:claude)
61//! @yah:at(2026-05-27T02:19:33Z)
62//! @yah:kind(spike)
63//! @yah:status(review)
64//! @yah:phase(P1)
65//! @yah:parent(R335)
66//! @yah:gotcha("Build ON R256-F9's AlmanacManifest (workload-spec/src/lib.rs) — do NOT invent a parallel manifest. R256-F9 is in review.")
67//! @yah:depends_on(R256-F9)
68//! @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.")
69//! @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.")
70//! @yah:next("FILED: R335-F4 (P2) per-mirror artifact key prefix in derive_minio_key/publish_to_r2 — closes same-tier collision.")
71//! @yah:next("FILED: R335-F5 (P3, BLOCKED on yubaba control plane) per-mirror capability gate on /revalidate via yubaba/xlb-net node identity.")
72//!
73//! @yah:ticket(R278-F4, "RolloutPolicy schema in workload-spec (TOML types)")
74//! @yah:assignee(agent:claude)
75//! @yah:at(2026-06-01T02:31:25Z)
76//! @yah:status(review)
77//! @yah:parent(R278)
78//! @yah:next("Add src/rollout.rs with RolloutPolicy, RolloutStrategy, RolloutGate, RolloutStep, RolloutOnFailure")
79//! @yah:next("Export pub mod rollout from lib.rs")
80//! @yah:next("Add TS export via ts-rs in export-ts.rs")
81//! @arch:see(.yah/docs/working/W140-yah-yubaba-ci-cd.md)
82//! @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.")
83//!
84//! @yah:ticket(R429-T1, "Workload::StaticAsset variant + schema in workload-spec (catalog + aliases)")
85//! @yah:assignee(agent:claude)
86//! @yah:at(2026-06-03T23:24:20Z)
87//! @yah:status(review)
88//! @yah:phase(P1)
89//! @yah:parent(R429)
90//! @yah:next("Add Workload::StaticAsset(StaticAssetWorkload) variant alongside the existing MesofactStatic + Container envelopes. Mirror the tagged-enum shape R222-T3 established.")
91//! @yah:next("StaticAssetWorkload fields: kind='static-asset' tag, assets: Vec<AssetEntry>, aliases: BTreeMap<String, String>. AssetEntry { filename: String, source: PathBuf, blake3: BlakeHash }.")
92//! @yah:next("BlakeHash newtype validates 64-hex-char shape (reuse from existing places if available, else introduce here).")
93//! @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.")
94//! @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.")
95//! @yah:next("Regenerate the workload.toml.schema.json via xtask emit-schemas (R222-B4). Confirm the drift test stays green.")
96//! @yah:next("TS mirror: extend packages/yah/workload-spec/index.ts with the StaticAsset variant + AssetEntry. Confirm bun typecheck stays green.")
97//! @yah:verify("cargo check -p workload-spec --locked")
98//! @yah:verify("cargo test -p workload-spec")
99//! @yah:verify("cargo run -p workload-spec --bin export-ts")
100//! @yah:verify("cargo test -p xtask")
101//! @arch:see(.yah/docs/working/W160-atomic-release-waves.md)
102//!
103//! @yah:ticket(R429-F2, "static-asset reconciler: BLAKE3 verify + S3 PUT against mirror's object_store + drift")
104//! @yah:assignee(agent:claude)
105//! @yah:at(2026-06-03T23:24:38Z)
106//! @yah:status(review)
107//! @yah:phase(P2)
108//! @yah:parent(R429)
109//! @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.")
110//! @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.")
111//! @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.")
112//! @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).")
113//! @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.")
114//! @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.")
115//! @yah:next("Real-R2 integration test gated behind YAH_TEST_R2_BUCKET env var — one round-trip against a scratch bucket; skipped otherwise.")
116//! @yah:verify("cargo check --workspace --locked")
117//! @yah:verify("cargo test -p <reconciler-crate>  # crate TBD by impl agent")
118//! @yah:verify("cargo test -p workload-spec")
119//! @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.")
120//! @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.")
121//! @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.")
122//! @arch:see(.yah/docs/working/W160-atomic-release-waves.md)
123//! @yah:depends_on(R429-T1)
124//!
125//! @yah:ticket(R429-T3, "yah service prune verb: candidate enumeration + operator-confirm delete")
126//! @yah:assignee(agent:claude)
127//! @yah:at(2026-06-03T23:24:52Z)
128//! @yah:status(review)
129//! @yah:phase(P3)
130//! @yah:parent(R429)
131//! @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.")
132//! @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.")
133//! @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.")
134//! @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.")
135//! @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.")
136//! @yah:next("User-asset TTL is OUT OF SCOPE — different surface, access-pattern-based, separate relay when it lands.")
137//! @yah:verify("cargo test -p <prune-crate>")
138//! @yah:verify("yah service prune yah-desktop --dry-run lists candidates")
139//! @arch:see(.yah/docs/working/W160-atomic-release-waves.md)
140//! @yah:depends_on(R429-F2)
141//! @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.")
142//! @yah:next("R429-F4 carries the Tauri + DeployPanel UI work — depends_on R429-T3, status=open.")
143//! @yah:verify("cargo check --workspace --locked")
144//! @yah:verify("cargo test -p cloud --lib reconciler::static_asset_prune  # 12 pass")
145//! @yah:verify("cargo test -p yah --lib mcp::tools::tests::cloud_service_prune  # 2 pass")
146//! @yah:verify("yah cloud service prune --help  # renders usage with --env/--dry-run/--yes/--format")
147//!
148//! @arch:see(.yah/docs/working/W164-derived-static-assets.md)
149//!
150//! @yah:ticket(R438-T2, "AssetEntry XOR: source vs derive + shape_static_asset rules")
151//! @yah:assignee(agent:claude)
152//! @yah:at(2026-06-04T21:06:51Z)
153//! @yah:status(review)
154//! @yah:phase(P1)
155//! @yah:parent(R438)
156//! @yah:next("AssetEntry.source: PathBuf → Option<PathBuf>")
157//! @yah:next("Add AssetEntry.derive: Option<AssetDerive> with fetch + optional transform")
158//! @yah:next("Extend shape_static_asset to enforce exactly-one(source, derive) + license closed-set")
159//! @yah:verify("Both-set and neither-set fail shape validation with ShapeError::Field")
160//! @yah:verify("Legacy TOMLs with only source still parse + serialize identically")
161//! @arch:see(.yah/docs/working/W164-derived-static-assets.md)
162//! @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.")
163//!
164//! @yah:ticket(R438-T3, "ImageRef digest-pin enforcement at deserialize")
165//! @yah:assignee(agent:claude)
166//! @yah:at(2026-06-04T21:06:55Z)
167//! @yah:status(review)
168//! @yah:phase(P1)
169//! @yah:parent(R438)
170//! @arch:see(.yah/docs/working/W164-derived-static-assets.md)
171//! @arch:see(.yah/docs/working/W165-mesofact-build-mode-lowering.md)
172//! @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.")
173//! @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.")
174//! @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.")
175//! @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).")
176//! @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.")
177//! @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.")
178//! @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)")
179//! @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.")
180//! @yah:verify("compose_import::parse_image_ref(\"node:20\") → Err(UnpinnedImage); parse_image_ref(\"node:20@sha256:...\") → Ok.")
181//! @yah:verify("cargo test -p yubaba --tests + -p local-driver passes with new test_digest() helper in place of bare tags.")
182//! @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.")
183//! @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.")
184//! @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.")
185//! @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.")
186//! @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.")
187//!
188//! @yah:ticket(R438-T7, "Golden tests: recipe→ForgeSpec lowering + BuildMode→ForgeSpec lowering parity")
189//! @yah:assignee(agent:claude)
190//! @yah:at(2026-06-04T21:07:30Z)
191//! @yah:status(review)
192//! @yah:phase(P3)
193//! @yah:parent(R438)
194//! @yah:next("Golden test: sample transform recipe + asset.derive.transform.params lowers to expected ForgeSpec (argv, image digest, TaskPlacement)")
195//! @yah:next("Golden test: MesofactStaticWorkload with build_mode=in_container lowers to expected ForgeSpec")
196//! @yah:next("Round-trip parity: same Subprocess + Local + Container quadrant for both consumers; regression-guards argv-substitution and image-pin drop-through")
197//! @yah:verify("cargo test -p workload-spec lowering_golden_*")
198//! @yah:verify("Golden files versioned; updates require explicit --update flag")
199//! @arch:see(.yah/docs/working/W164-derived-static-assets.md)
200//! @arch:see(.yah/docs/working/W165-mesofact-build-mode-lowering.md)
201//! @yah:depends_on(R438-T5)
202//! @yah:depends_on(R438-T6)
203//! @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.")
204//! @yah:next("Sign off → archive R438-T7")
205//! @yah:next("T8 (worked examples) now has tested lowering primitives to reference")
206//! @yah:verify("cargo test -p cloud --lib reconciler::lowering_golden — 5 pass")
207//! @yah:verify("cargo test -p cloud --lib reconciler:: — 124 pass; 4 pre-existing R441-B4 adopt_only failures (port 4321 dev-box collision) unrelated")
208//! @yah:verify("cargo check --workspace --locked — clean (warnings only)")
209//! @yah:verify("Parity test asserts both lowerings produce TaskPlacement{Local, Container} + ForgeCommand::Subprocess + sha256-pinned image — the shared executor dispatch invariant")
210//! @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.")
211//! @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.")
212//!
213//! @yah:ticket(R594-F2, "Ingress workload kind in workload-spec: pinned-per-node appliance on public-ip-tainted machines")
214//! @yah:status(review)
215//! @yah:assignee(agent:claude)
216//! @yah:at(2026-07-03T06:03:30Z)
217//! @yah:phase(P2)
218//! @yah:parent(R594)
219//! @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.")
220//! @yah:verify("cargo test -p yah-workload-spec; cargo check -p yubaba -p kamaji-bin; kamaji admission accepts kind=ingress in a unit fixture")
221//! @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.")
222//! @yah:depends_on(R572-F1)
223//! @yah:tier(Cleric)
224//! @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.")
225//!
226//! @yah:ticket(R590-B10, "forge workload 256MB cgroup memory limit SIGKILLs real builds — rusty-v8 checkout OOMs (bumped to 32GB stopgap)")
227//! @yah:at(2026-07-12T00:14:52Z)
228//! @yah:status(review)
229//! @yah:assignee(agent:claude)
230//! @yah:parent(R590)
231//! @yah:severity(blocks-on-box-green)
232//! @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.")
233//! @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).")
234//! @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.")
235//! @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.")
236//!
237//! @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")
238//! @yah:phase(P1)
239//! @yah:status(review)
240//! @yah:assignee(agent:bundle-anthropic-ashguard)
241//! @yah:at(2026-08-03T00:44:43Z)
242//! @yah:parent(R546)
243//! @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.")
244//! @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.")
245//! @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.")
246//! @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.")
247//! @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.")
248//! @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.")
249//! @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.")
250//! @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.")
251//! @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).")
252//! @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.")
253//! @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).")
254//! @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.")
255//! @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.")
256//! @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.")
257//! @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.")
258//! @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.")
259//! @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.")
260//! @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.")
261//!
262//! @yah:ticket(R626-S3, "Where does desired-state live? Durable per-workload replica count that survives reconcile loops and camp restarts (0↔1 vs scale-to-N)")
263//! @yah:status(review)
264//! @yah:assignee(agent:bundle-anthropic-glimmerstone)
265//! @yah:at(2026-07-23T17:47:24Z)
266//! @yah:kind(spike)
267//! @yah:phase(P3)
268//! @yah:parent(R626)
269//! @yah:handoff("DECIDED + LANDED. Desired state lives in the CAMP DAEMON, in a durable camp-local document at <camp>/.yah/state/desired-state.json, and NEVER crosses the kamaji or yubaba wire. The governing principle, written to survive the tier: desired state belongs to the DECLARER, not the supervisor — whoever re-asserts a deployment owns the record of whether it is wanted, because anything stored below the declarer is overwritten by the declarer's next re-assert. In the pond/dev tier the declarer is camp (ensure_pond_running -> reconcile_pond_deploys -> deploy_pond_mirrors, which runs at every camp start AND every pond.ensure_running RPC). In cloud the same rule points at the CloudConfig reconciler's raft store. Kamaji is never the holder in either tier.")
270//! @yah:handoff("REJECTIONS, with the reason each is not a near-miss. kamaji-local: kamaji is deliberately imperative (Deploy/Stop/List, crash-restart delegated to dockerd's policy per R626-F2) — it holds no desired set and runs no reconcile loop, so storing intent there means giving it a SECOND reconciler that can disagree with camp's, and it still loses to camp's POST /pond/deploy from above. yubaba raft: right answer at cloud scale, wrong scope here — the pond yubaba is a container camp starts, its PondRegistry is in-memory (a restart forgets everything), and a single-camp dev tier has no quorum to be consistent about. Git-tracked config: camp.toml/mirror.toml are the DECLARATION (what exists); a stop is per-machine operator intent (systemctl disable, not editing the unit file) and must not propagate to a teammate's checkout — hence .yah/state/ is gitignored, in both the camp's .gitignore and the scaffold_camp_skeleton template.")
271//! @yah:handoff("SHAPE: one knob, `replicas`, where 0 = stopped — deliberately the SAME axis as workload_spec::WorkloadSpec.replicas so scale-to-N later lifts a ceiling instead of adding a second concept beside a boolean. MAX_SUPPORTED_REPLICAS = 1 today and set_replicas REJECTS anything higher rather than persisting an intent no supervisor can honour (a clamp would silently record something the operator did not ask for). No record = replicas 1: a declared workload runs unless someone said otherwise. updated_at + reason ride along so a stale intent is legible and the UI can say when/why. Writes are tmp-then-rename; reads FAIL OPEN (missing/unreadable/corrupt/newer-schema all mean 'everything runs', corrupt file preserved as .corrupt-<epoch_ms>) — fail-closed would mass-stop a camp on one bad byte, and a resurrection is the recoverable failure.")
272//! @yah:handoff("LANDED: (1) app/yah/cli/src/desired_state.rs — DesiredStateDoc / WorkloadDesire / DesiredStateStore (load, desired_replicas, is_stopped, stopped_keys, set_replicas, stop, start, forget), 10 unit tests incl. survives-a-camp-restart, per-workload isolation, replicas>1 rejected AND not written, corrupt-file quarantine + fail-open, newer-schema fail-open, and an explicit 'a stop is not a failure' guard on the serialized document. (2) camp.rs: deploy_pond_mirrors and reconcile_pond_deploys now consult the store and skip stopped idents — this is THE enforcement point, since camp's re-assert is the only place 'stay stopped' can be honoured. Extracted pond_idents_needing_deploy(declared, registered, stopped) as a pure helper with 5 tests, because the stopped-subtraction is the load-bearing half: a stopped workload is absent from yubaba's registry ON PURPOSE and is indistinguishable from a failed deploy without the intent record. (3) .yah/.gitignore + app/yah/cli/templates/yah-gitignore-default gain /state. (4) .yah/docs/working/W287-desired-state-for-supervised-workloads.md carries the full rationale, the rejected options, the F4 build-on list, and the scale-to-N scoping.")
273//! @yah:handoff("DELIBERATE NON-GOAL: writing intent does NOT actuate. The durable record must land even when the stop call fails, or a failed stop comes back on the next reconcile. Actuation is R626-F4's job.")
274//! @yah:next("R626-F4 is unblocked and now has a concrete spec — see W287 §5. It needs three things this ticket deliberately did not build: (a) a per-ident teardown on yubaba (PondRegistry has only shutdown_all, which drains everything; /pond/deploy and /pond/state are the only pond routes), (b) camp RPC methods workload.stop / workload.start writing through DesiredStateStore, (c) desired-vs-actual reporting.")
275//! @yah:next("DO NOT add a Stopped variant to PondPhase (R626-F2's noted gap). PondPhase is yubaba's observation of REALITY; intent never crosses that wire by this decision. Camp is the one process holding both halves — render the pair instead: desired=stopped + actual=absent reads 'deliberately stopped'; desired=running + actual=absent reads 'down'.")
276//! @yah:next("Scale-to-N stays scoped, not committed (W287 §6). WorkloadSpec.replicas makes N look one constant away; it is not. kamaji native.rs:592 rejects replicas>1, and the docker backend names containers by mesh identity (one identity, one container). N needs a placement layer above the single-workload supervisor: per-replica naming (identity==container name is what makes teardown resolve), per-replica host ports (pond publishes fixed ones — two replicas collide), per-replica mesh identity (a load-balanced set is an xlb-net concern), and a placement decision that is yubaba's job on a fleet. Lifting MAX_SUPPORTED_REPLICAS is the entry point once that layer exists.")
277//! @yah:next("Wire DesiredStateStore::forget into the undeclare path so the document doesn't accumulate intent for mirrors that no longer exist.")
278//! @yah:verify("cargo test -p yah --lib desired_state — 15 pass (10 desired_state::tests + 5 camp::r626_s3_desired_state_gate_tests), 0 fail")
279//! @yah:verify("cargo check -p yah — clean (note: this camp's tree is shared and was transiently broken by peers' in-flight edits in oss/qed, yah-party, and yah-almanac during this run; none touched by this ticket)")
280//! @yah:verify("BEHAVIOUR BAR (the one that matters): DesiredStateStore::for_camp(root).stop(ident) followed by a FRESH store over the same root still reports is_stopped — that is exactly a camp restart — and pond_idents_needing_deploy then omits that ident from an EMPTY registry, which is exactly a restarted yubaba. Asserted in camp::r626_s3_desired_state_gate_tests::a_stop_survives_a_camp_restart_end_to_end.")
281//! @yah:gotcha("The store is camp-local and GITIGNORED on purpose. If a future ticket wants a stop to be shared/durable in the repo, that is a different decision (declaration vs intent) — re-open W287 §2 rather than moving the file into tracked territory.")
282//! @yah:gotcha("Reads fail OPEN. Never 'harden' this into fail-closed: an unreadable document would then stop an entire camp, and the failure would be silent (nothing starts) rather than visible (the workload comes back).")
283//!
284//!
285//! @yah:relay(R658, "workload.toml envelope: two type-vs-reality mismatches R546-B7 uncovered but did not fix")
286//! @yah:at(2026-08-03T00:43:00Z)
287//! @yah:assignee(agent:bundle-anthropic-ashguard)
288//! @yah:parent(R546)
289//!
290//! @yah:ticket(R658-B1, "MesofactStaticWorkload.routes is a required top-level field, but every real file and the CLI scaffold write it inside [build]")
291//! @yah:status(review)
292//! @yah:at(2026-08-19T02:08:02Z)
293//! @yah:assignee(agent:bundle-anthropic-ashguard)
294//! @yah:parent(R658)
295//! @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.")
296//! @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.")
297//! @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.")
298//! @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.")
299//! @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.")
300//! @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.")
301//! @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.")
302//! @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.")
303//! @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.")
304//! @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.")
305//! @yah:verify("cargo test --manifest-path oss/yubaba/Cargo.toml -p yah-cloud --lib - 878 passed, 0 failed.")
306//! @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.")
307//! @yah:verify("cargo test -p xtask - all 12 targets green, incl. schema_drift and workload_envelope.")
308//! @yah:verify("./scripts/check-workload-spec-ts.sh - in sync (ts-rs ignores deny_unknown_fields, so no TS churn).")
309//! @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.")
310//! @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.")
311//! @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.")
312//! @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).")
313//! @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.")
314//! @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.")
315//!
316//!
317//! @yah:ticket(R743-T4, "workload-spec: 7 test binaries to 1")
318//! @yah:at(2026-08-11T01:18:24Z)
319//! @yah:status(review)
320//! @yah:phase(P2)
321//! @yah:parent(R743)
322//! @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.")
323//! @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).")
324//! @yah:verify("cargo test -p yah-workload-spec -- --list count unchanged; three green runs. One commit — oss subtree.")
325//! @yah:tier(Cleric)
326//! @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.")
327//! @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.")
328//! @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.")
329//!
330//! @yah:ticket(R783-F1, "ContainerManifest: split the on-disk container manifest from the wire WorkloadSpec, keeping postcard byte-identical")
331//! @yah:status(review)
332//! @yah:at(2026-08-19T07:11:49Z)
333//! @yah:assignee(agent:bundle-anthropic-ashguard)
334//! @yah:parent(R783)
335//! @arch:see(.yah/docs/working/W324-workload-kind-is-not-a-runtime.md)
336//! @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.")
337//! @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.")
338//! @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.")
339//! @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.")
340//! @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).")
341//! @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.")
342//! @yah:verify("cargo test --manifest-path oss/kamaji/Cargo.toml -p kamaji-proto codec - deploy_container_round_trip is the exact UDS path.")
343//! @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.")
344//! @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.")
345//! @yah:gotcha("Consider a Workload::container(spec) constructor + ContainerManifest::as_spec() accessor to keep the ~25 sites one-line mechanical rather than restructured.")
346//! @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.")
347//! @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.")
348//! @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.")
349//! @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.")
350//! @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.")
351//! @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).")
352//! @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'.")
353//! @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.")
354//! @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.")
355//! @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.")
356//! @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.")
357//!
358//! @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")
359//! @yah:status(review)
360//! @yah:at(2026-08-31T00:17:46Z)
361//! @yah:assignee(agent:bundle-anthropic-ashguard)
362//! @yah:parent(R838)
363//! @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.")
364//! @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.")
365//! @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.")
366//! @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.")
367//! @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.")
368//! @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.")
369//! @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.")
370//! @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.")
371//! @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.")
372//! @yah:verify("cargo test --manifest-path oss/yubaba/Cargo.toml -p yubaba --lib: 553 passed, 0 failed.")
373//! @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.")
374//! @yah:verify("cargo check --all-targets --locked on the root workspace: clean (warnings only, all pre-existing).")
375//! @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).")
376//! @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.")
377//! @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.")
378//!
379//! @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")
380//! @yah:status(review)
381//! @yah:assignee(agent:bundle-anthropic-ashguard)
382//! @yah:at(2026-09-02T19:08:17Z)
383//! @yah:parent(R658)
384//! @yah:severity(high)
385//! @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.")
386//! @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.")
387//! @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.")
388//! @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')")
389//! @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.")
390//! @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.")
391//! @yah:tier(Warrior)
392//! @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.")
393//! @yah:next("TO CLOSE: re-run cargo test -p xtask --test main --locked -- workload_envelope:: (passes now) and archive. Nothing left to build here.")
394//! @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.")
395//! @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.")
396//! @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.")
397//! @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.")
398//! @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.")
399//! @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.")
400//!
401//! @yah:ticket(R844-F17, "Port names are unwritable in every manifest — the declaration surface F15 built the plumbing for")
402//! @yah:status(review)
403//! @yah:assignee(agent:bundle-anthropic-ashguard)
404//! @yah:at(2026-09-04T01:29:45Z)
405//! @yah:parent(R844)
406//! @yah:depends_on(R844-F15)
407//! @yah:handoff("LANDED. A manifest can name its ports. `MeshExpose.ports` went from `Vec&lt;u16&gt;` to `Vec&lt;MeshPort&gt;` (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&lt;u16&gt;`: a name-only entry has no number yet, and a `Vec&lt;u16&gt;` field could not say that its list is shorter than the one the author wrote.")
408//! @yah:handoff("THE NAMES REACH THE RECORD, which is the only thing that makes this worth the blast radius. `kamaji::declared_port_names(&amp;MeshExpose)` (oss/kamaji/crates/kamaji/src/lib.rs) is the new single lowering from manifest to the `name -&gt; port` map every tier below already spoke, and it replaced `name_anonymous_ports(&amp;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.")
409//! @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 -&gt; `http`; several -&gt; 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.")
410//! @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&lt;u16&gt;` is len + varints; a `Vec&lt;MeshPort&gt;` 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.")
411//! @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.")
412//! @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).")
413//! @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 -&gt; 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.")
414//! @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.")
415//! @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.")
416//! @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.")
417//! @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.")
418//! @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.")
419//! @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.")
420//!
421//! @yah:ticket(R885-B5, "cpu_millis is documented as a request and rendered as a hard quota — split request from limit")
422//! @yah:status(review)
423//! @yah:assignee(agent:bundle-anthropic-glimmerstone)
424//! @yah:at(2026-09-11T07:27:24Z)
425//! @yah:phase(P2)
426//! @yah:parent(R885)
427//! @yah:next("Tier: Cleric. The judgment is made; this applies it.")
428//! @yah:next("ONE NUMBER, THREE MEANINGS. ResourceLimits::cpu_millis (workload-spec/src/lib.rs:4732) documents itself as a REQUEST — \"an allocatable quantity a bin-packer can subtract from a node budget\". containerd (kamaji-containerd-core/src/lib.rs:1216) and docker (kamaji/src/docker.rs:439) render it as a relative WEIGHT via cpu_shares(). cgroup.rs:146-152 renders it as a hard QUOTA in cpu.max. microvm.rs:563 renders it as a vCPU COUNT via div_ceil(1000).max(1).")
429//! @yah:next("THE BUG THIS CAUSES: a workload declaring 250m as its fair share gets throttled at a quarter core even on a completely idle node. A request rendered as a ceiling is a semantic bug, not a missing feature.")
430//! @yah:next("FIX: cpu_millis stays the request and renders as cpu.weight. An OPTIONAL limit, carried as an annotation (not a field — see the postcard wire rule at kamaji-proto/src/version.rs:63), renders as cpu.max when present and omits it when absent. The containerd/docker weight derivation is already correct and must not change. R572-T2 consciously postponed a separate cpu_limit_millis for exactly this; this is that work, done as an annotation instead of a field.")
431//! @yah:next("memory.high is deliberately NOT in scope. No recorded incident asks for a throttle-before-kill tier; file it when something does.")
432//! @yah:verify("The containerd and docker paths still derive the same cpu_shares value they do today — no change in oss/kamaji/crates/kamaji-containerd-core/src/lib.rs or kamaji/src/docker.rs.")
433//! @yah:depends_on(R885-B1)
434//! @yah:gotcha("YOU ARE NO LONGER ADDITIVE — THE THROTTLE IS LIVE AS OF R885-B1 (2026-09-11). Wiring the cgroup driver onto kamaji::native::NativeRuntime shipped the very semantic bug W344 Finding 5 describes: cpu_millis, documented as a REQUEST, is now rendered as a hard `cpu.max` quota on the live fleet path. Measured on us-east-001: four native workloads at `cpu.max=25600 100000`, i.e. hard-capped at 0.256 of a core even on an idle node, where before the wiring they could burst to whatever the box had. The relay's ordering note calls 2/3/5 'independent and additive behind 1'; for this ticket that is now wrong — every node that takes the R885-B1 binaries is throttled until this lands. The split is unchanged (request -> cpu.weight, optional annotation-carried limit -> cpu.max; the containerd/docker weight derivation is already correct and stays); what changed is the urgency. The write site is CgroupV2::create_workload in oss/kamaji/crates/kamaji/src/cgroup.rs — note that file MOVED out of kamaji-bin in R885-B1.")
435//! @yah:handoff("THE THROTTLE IS OFF. cpu_millis is now rendered as cpu.weight (a relative share, no ceiling on an idle node) and cpu.max is written ONLY when the spec declares a ceiling. Three files: (1) oss/yah-base/crates/workload-spec/src/lib.rs — new CPU_LIMIT_ANNOTATION = \"yah.limits.cpu-millis\" and WorkloadSpec::cpu_limit_millis() -> Option<u32>, the exact mirror of memory_request_mb (that one adds the missing REQUEST beside a field that is a ceiling; this one adds the missing CEILING beside a field that is a request). Absent, unparseable, or 0 all mean NO ceiling — the permissive direction, because capping a workload at zero CPU over a typo is the worse failure. (2) oss/kamaji/crates/kamaji/src/cgroup.rs — new pub struct WorkloadLimits {cpu_request_millis, cpu_limit_millis: Option<u32>, memory_max_mb} with from_spec (live path, reads the annotation) and from_request (callers holding only ResourceLimits, so no ceiling); create_workload takes it instead of &ResourceLimits and writes cpu.weight unconditionally, cpu.max conditionally; new format_cpu_weight beside the existing format_cpu_max. (3) oss/kamaji/crates/kamaji/src/native.rs — the live call site now passes WorkloadLimits::from_spec(spec).")
436//! @yah:handoff("THE WEIGHT MAPPING AND ITS ONE-LINE JUSTIFICATION: weight = clamp(millis * 100 / 1000, 1, 10000), i.e. 1000m -> 100. 100 is the cgroup v2 / systemd CPUWeight= default, so \"one core\" lands on the platform default exactly as the containerd and docker backends' cpu_shares() puts 1000m on 1024, cgroup v1's default — a workload's relative standing is then the same whichever backend runs it, which is the property that made the three renderings disagree in the first place. Clamped because the kernel rejects a cpu.weight outside 1..=10000: a sub-10m request still gets a nonzero share rather than a failed write, and an absurd request saturates instead of erroring. cpu_millis == 0 (\"no declared request\") renders as the default 100, so the write is unconditional and explicit rather than relying on the file's initial value.")
437//! @yah:handoff("THE OPERATOR QUESTION, DECIDED: an already-deployed workload on an upgraded node picks the new rendering up ON REDEPLOY ONLY, and nothing needs to reconcile existing leaves. Startup never rewrites a leaf — the only writer in the whole tree is create_workload (verified: grep for cpu.max/cpu.weight across oss/ crates/ app/ scripts/ returns this one write site plus doc comments). deploy_workload does teardown (rmdir) then mkdir, and a freshly created cgroup starts at the kernel default cpu.max = \"max <period>\", so the stale 25600 100000 cannot survive a redeploy and no reconciler is warranted. THE ONE RESIDUE: a leaf whose rmdir failed EBUSY and is therefore reused keeps its stale cpu.max, because an absent ceiling now skips the write rather than clearing the file. That is exactly R885-B4's race, it is narrow, and the manual remedy is one line on the node (`echo \"max 100000\" > <leaf>/cpu.max`). I chose skip-the-write over write-\"max\" because the ticket's acceptance requires cpu.max be written only when the annotation is present, and because clearing a file nobody set is how you paper over B4 instead of fixing it. NOTE FOR WHOEVER SHIPS THIS: us-east-001 is currently running hotship 0.8.38-h5 bytes carrying the R885-B1 confinement (recorded by @Ashguard:coffee on R881-B7), so its four native workloads are throttled right now and need this tree shipped plus one redeploy each.")
438//! @yah:handoff("DISCOVERED WORK DONE IN THIS PASS, beyond the ticket title. (a) THE TWO GENERATED ARTIFACTS HAD TO BE REGENERATED and the drift test caught it: schemars and ts-rs both emit Rust doc comments into their output, so correcting ResourceLimits::cpu_millis's doc (it said `0` means \"no CPU limit\", which is now wrong twice over — 0 means no declared REQUEST, and the field is not a limit at all) broke yah-workload-spec's ts_drift::committed_ts_bindings_match_current_rust_types. Ran both generators per CLAUDE.md; `git diff --stat` on the two artifacts is 2 lines of .yah/schema/workload.toml.schema.json + 13 of packages/yah/workload-spec/index.ts and NOTHING else, so no peer's pending regen was swept in. (b) The stale pointer in this ticket's own Verify block (oss/kamaji/crates/kamaji-bin/src/cgroup.rs — that file was deleted in R885-B1) is removed and replaced with the real path, along with the kamaji-bin test command that went with it. (c) Two module headers corrected where they now lie: kamaji/src/native.rs:8 said the leaf carries \"memory.max and cpu.max\", and kamaji/src/cgroup.rs's summary said the driver translates ResourceLimits into cpu.max + memory.max. cgroup.rs also gains a \"CPU: a request is not a ceiling\" section recording the us-east-001 measurement, so the next person to reach for cpu.max finds the incident rather than rediscovering it.")
439//! @yah:handoff("WHAT I DELIBERATELY DID NOT DO. memory.high — out of scope by the ticket, and no incident asks for a throttle-before-kill tier. microvm.rs:563's third reading (cpu_millis.div_ceil(1000).max(1) as a vCPU COUNT) is untouched: W344 names it as the third disagreeing interpretation but it is not a throttle, a microVM's vCPU count genuinely is a count, and changing it is a separate judgment on a backend with no live native blast radius. No spec in the tree sets yah.limits.cpu-millis, which is correct and deliberate: the fleet's current state (every native workload burstable, weighted by its request) is the pre-R885-B1 behaviour plus proportional fairness under contention, and a ceiling is now something a workload opts into rather than something it gets by accident.")
440//! @yah:verify("THE ACCEPTANCE GREP, on the file that actually exists: `rg -n \"cpu.weight|cpu.max\" oss/kamaji/crates/kamaji/src/cgroup.rs` — cpu.weight is written unconditionally from the request at create_workload (write_file(&path.join(\"cpu.weight\"), &format_cpu_weight(limits.cpu_request_millis))), and cpu.max sits inside `if let Some(ceiling_millis) = limits.cpu_limit_millis`. There is no other cpu.max/cpu.weight write site anywhere in oss/, crates/, app/ or scripts/.")
441//! @yah:verify("BOTH SHAPES ARE COVERED, and the limit-ABSENT one is asserted twice — at the driver and at the live deploy path. cgroup::tests::a_workload_with_no_declared_ceiling_gets_a_weight_and_no_quota (cpu.weight == \"100\" for 1000m, and cpu.max does NOT EXIST); cgroup::tests::a_declared_ceiling_is_written_to_cpu_max_beside_the_weight (250m request + 2000m ceiling -> weight 25 AND cpu.max \"200000 100000\", two different numbers from two different sources); native::tests::deploying_a_workload_mints_a_cgroup_leaf_carrying_its_limits now drives the REAL deploy_workload and asserts cpu.weight == \"12\" with cpu.max absent — that test previously asserted cpu.max == \"12800 100000\", i.e. it pinned the bug; native::tests::a_declared_cpu_ceiling_renders_as_cpu_max drives the real deploy with the annotation set and gets \"50000 100000\". Plus cpu_weight_translates_a_request_to_a_relative_share (clamping both ends, 0 -> 100) and three workload-spec accessor tests including one asserting that garbage and an explicit 0 both mean no ceiling.")
442//! @yah:verify("THE containerd AND docker DERIVATIONS ARE BYTE-IDENTICAL TO TODAY, verified three ways rather than asserted: `git diff -- oss/kamaji/crates/kamaji/src/docker.rs` is EMPTY (0 lines); kamaji-containerd-core/src/lib.rs shows 10 changed lines and ZERO of them are code — `git diff -U0 | grep \"^[+-]\" | grep -v \"^[+-]//!\"` returns 0, they are a peer's @yah: board annotations for R881-B7; and ResourceLimits::cpu_shares itself is untouched in workload-spec (the only cpu_shares lines in that diff are three doc-comment references being re-worded). So spec.resources.cpu_shares() at containerd lib.rs:1373 and docker.rs:440 still derives millis*1024/1000 exactly as before.")
443//! @yah:verify("EVERY NUMBER BELOW WAS RUN BY ME ON THIS TREE. cargo test -p kamaji --features native-integration --lib: 120 pass / 0 fail (baseline 116, R885-B1's recorded figure; +4 = 3 net new cgroup tests and 1 new native call-site test, all seven named above confirmed present by name in the run). cargo test -p kamaji-bin --features native-exec --lib: 237 pass / 0 fail (baseline 237, unchanged — the off-Linux spawn test's create_workload call was updated to WorkloadLimits::from_request). workload-spec --lib: 192 pass / 0 fail (baseline 189, +3 accessor tests). yah-base workspace, the argv a workload-spec change makes non-optional (its test targets are invisible to every other workspace's --all-targets): `cargo test --manifest-path oss/yah-base/Cargo.toml --workspace` — all targets ok, 0 failed, AFTER the regen; before it, yah-workload-spec --test main was 104 pass / 1 FAIL on the ts-drift gate, now 105 / 0. cargo check --manifest-path oss/kamaji/Cargo.toml --workspace --all-features --all-targets exit 0, no errors. cargo check -p camp-identity (the root-workspace consumer of kamaji-bin) exit 0.")
444//! @yah:verify("CROSS-COMPILE, the Linux-path verification this Mac can actually achieve: `cargo zigbuild -p kamaji-bin --features containerd-integration,native-exec --target x86_64-unknown-linux-gnu` — Finished, exit 0. So the Linux path COMPILES. WHAT I DID NOT ACHIEVE, stated plainly: no live reading. Every cgroup assertion here is against a tempdir, where the control files are ordinary files rather than kernel interfaces — which is why the limit-absent test asserts cpu.max does not EXIST, a check that is only equivalent to \"unlimited\" because a real cgroupfs materialises the file at the kernel default. The live acceptance is still owed: on a node running these bytes, after one redeploy, `cat /sys/fs/cgroup/yubaba.slice/kamaji.service/<ident>/{cpu.weight,cpu.max}` must show the weight set and cpu.max reading `max 100000`.")
445//! @yah:gotcha("THE FIX IS PROVEN IN TESTS AND ON A CROSS-COMPILE, NOT ON HARDWARE — and this relay has already been burned by exactly that gap (R885-B1's own first gotcha: R406-T4/T5 sat in review for two months with passing tests and no reachable call site). What is different here is that the call site IS reachable and IS asserted by a test that drives the real deploy_workload. What is still missing is a node. us-east-001 currently runs hotship 0.8.38-h5 bytes with the R885-B1 confinement and NO B5 fix, so its four native workloads are throttled at this moment; shipping this tree plus one redeploy per workload is what closes the incident, and the reading that proves it is cpu.weight set with cpu.max at `max 100000`.")
446//! @yah:cleanup("An absent ceiling SKIPS the cpu.max write rather than clearing the file, so a leaf reused after a failed teardown (R885-B4's EBUSY race) keeps a stale quota. Narrow, and deliberate — the alternative papers over B4 — but if B4 lands a cgroup.kill + ordered teardown, re-read this decision: with a reliable rmdir the residue becomes impossible, and with an unreliable one it might be worth writing \"max <period>\" explicitly.")
447//!
448//! @yah:ticket(R885-T6, "Delete ephemeral_storage_mb from ResourceLimits and give the microVM its own named scratch floor")
449//! @yah:status(review)
450//! @yah:assignee(agent:bundle-anthropic-glimmerstone)
451//! @yah:at(2026-09-11T08:49:25Z)
452//! @yah:phase(P3)
453//! @yah:parent(R885)
454//! @yah:next("Tier: Cleric either way; the operator call is what makes it blocked, not the difficulty.")
455//! @yah:next("THE FIELD LIES TODAY. ResourceLimits::ephemeral_storage_mb (workload-spec/src/lib.rs:4735) documents itself as a cap on the writable layer plus tmpfs footprint. NO backend enforces it: the OCI resources block (kamaji-containerd-core/src/lib.rs:1213-1218) carries only memory and cpu; docker argv carries only --memory and --cpu-shares; cgroup.rs deliberately omits it (not a cgroup v2 control). Its ONE live consumer, microvm.rs:693, uses it as a FLOOR on the scratch disk — the opposite of a cap. WorkloadSpec::for_forge sets 512 MiB, which as a cap would fail every build at first checkout.")
456//! @yah:next("EITHER ANSWER IS A SCHEMA CHANGE and needs the CLAUDE.md regen dance: cargo run -p xtask -- emit-schemas, then cargo run --manifest-path oss/yah-base/crates/workload-spec/Cargo.toml --bin export-ts. Gated by schema-drift-guard and workload-spec-drift-guard in .yah/qed/check.toml.")
457//! @yah:verify("rg -rn \"ephemeral_storage_mb\" --type rust — every remaining site agrees with whichever answer was chosen; no site reads it as a cap while another reads it as a floor.")
458//! @yah:verify("scripts/check-schema-drift.sh and scripts/check-workload-spec-ts.sh both clean.")
459//! @yah:gotcha("Deleting the field is a postcard wire change and therefore a ProtocolVersion bump (kamaji-proto/src/version.rs, currently V8) plus a sweep of WorkloadSpec struct literals across four cargo workspaces and two excluded manifests — R860-T1 took 35 call sites and three misses. Blast radius is bounded (node-local UDS, yubaba and kamaji self-install as a pair, so skew is a restart not a rolling upgrade) but it is not free. If R885-F3 also turns out to need a bump, do both in one.")
460//! @yah:next("DECIDED 2026-09-10 (operator): DELETE. Remove ephemeral_storage_mb from ResourceLimits entirely and give the microVM its own explicitly-named scratch-disk floor. Rationale is CLAUDE.md pre-1.0 doctrine — change the design rather than tape it. Do NOT keep the field aliased, defaulted, or read-both-and-prefer-whichever; one shape has to win and it is the one without the field.")
461//! @yah:next("THE MICROVM REPLACEMENT IS THE REAL WORK, not the deletion. microvm.rs:693 currently reads ephemeral_storage_mb as a scratch-disk FLOOR (disk_size_bytes at :1071), and WorkloadSpec::for_forge sets 512 MiB expecting exactly that. Give it a named annotation of its own so the floor is legible as a floor. Do not silently drop the behaviour along with the field — a build that fails at first checkout is the failure mode.")
462//! @yah:gotcha("THE WIRE BUMP IS NOW CERTAIN, not conditional — deleting a ResourceLimits field is a postcard wire change and therefore a ProtocolVersion bump (kamaji-proto/src/version.rs, V8 today). Coordinate with R885-F3 BEFORE bumping: if F3 also needs a new WorkloadState shape, both changes ride one V9 rather than a V9 and a V10. Whichever ticket moves first should say so in its handoff.")
463//! @yah:next("R885-F3 LANDED NODE-LOCAL ONLY AND OWES YOU A WIRE DELTA (2026-09-11). F3 classifies OOM vs plain SIGKILL inside kamaji and names it in the journal + in WorkloadStatus::Failed{reason}, but `reason` dies at the UDS boundary: kamaji-bin/src/server.rs:3866 and :3924 both flatten `WorkloadStatus::Failed { .. } => WireState::Failed`, and kamaji-proto's WorkloadState (messages.rs:158) is a FIELDLESS enum with nowhere to put a string. So yubaba still cannot tell an OOM from a crash. CARRY THIS IN YOUR V9: add `OomKilled` as a new variant of kamaji_proto::WorkloadState (it is already #[non_exhaustive]; postcard encodes the discriminant as a varint, so appending at the END keeps Pending..Failed on 0..5 and only an unbumped peer decoding discriminant 6 breaks - which is exactly what the bump exists to refuse). Then map it at BOTH server.rs sites from a classification kamaji already computes: crate::cgroup::ExitClass::is_oom() is the predicate, and NativeProcess::settle already has the value. The only extra plumbing needed is carrying ExitClass (or a bool) out of Completion, which is crate-internal and not a wire type.")
464//! @yah:gotcha("THE BATCH IS NOW CONFIRMED, NOT CONDITIONAL: R885-F3 is at review having deliberately NOT bumped ProtocolVersion (operator/leader call - land everything below the wire, leave the upward report to T6's certain V9). F3's wire delta is spelled out in this ticket's @yah:next above. Do both in the one V9: deleting ephemeral_storage_mb from ResourceLimits AND adding WorkloadState::OomKilled. F3 touched only oss/kamaji/crates/kamaji/src/{cgroup.rs,native.rs} and did NOT touch workload-spec/src/lib.rs, so it leaves no conflict with your ResourceLimits sweep.")
465//! @yah:next("TIER RE-STAMPED Cleric -> Warrior by the relay leader, 2026-09-11, with the reason recorded because a silent tier bump is exactly what R889-B1 is about. The original `Tier: Cleric` stamp was written when this ticket was 'delete a field + give the microVM a named floor'. Since then R885-F3 landed node-local-only and pushed its entire wire delta onto this ticket: a new `kamaji_proto::WorkloadState::OomKilled` variant, mapped at BOTH server.rs:3866 and :3924, plus carrying `ExitClass` out of `Completion`. Combined with the ProtocolVersion V8->V9 bump, the ~35-call-site WorkloadSpec sweep across four cargo workspaces and two excluded manifests (R860-T1's measured cost, with three misses), and the schema + TS regen, this is now the `class_tiers` Warrior descriptor verbatim - 'tricky implementation with a clear spec, heavy integration'. GENERAL LESSON WORTH CARRYING: a Tier stamp is written at filing time and can be invalidated by a SIBLING ticket pushing scope onto it. Whoever re-reads a stamp before dispatching should check whether the ticket still describes the work it was stamped for.")
466//! @yah:handoff("THE BUMP IS V11, NOT V9 — THE TICKET'S \"V8 TODAY\" WAS TWO BUMPS STALE. kamaji-proto/src/version.rs read ProtocolVersion::CURRENT = V10 on pickup, not V8: R605-T27 landed V9 (microvm: MicroVmHealth on NodeCapabilities) and R850-T4 landed V10 (DeployAck replaces AckKind::Deploy) after this ticket was filed. So both of this ticket's changes ride ONE bump to V11, which is the batching the ticket asked for; only the number moved. version.rs's V11 stanza spells out both halves and why each alone would earn a bump — (a) DELETING a field from a struct on a positional postcard wire shifts every byte after the hole, which is V2/V4/V5/V6/V8 run backwards and no safer for being subtraction; (b) appending WorkloadState::OomKilled is the benign kind, free once a bump is being spent. Tree anchor at dispatch: 494e22fa14b21d0cc72dd8a3132e7f078d1eb5eb.")
467//! @yah:handoff("PIECE 1+2 — THE FIELD IS GONE AND THE FLOOR IS NAMED `floor`. ResourceLimits::ephemeral_storage_mb deleted; successor is annotation `yah.limits.scratch-floor-mb` (SCRATCH_FLOOR_ANNOTATION) read by WorkloadSpec::scratch_floor_mb() -> Option<u32>, following R885-B5's cpu_limit_millis / R885-T2's pids_limit precedent verbatim (annotation not field, because a field costs exactly the wire bump this ticket is paying). microvm::workspace::disk_size_bytes(input_bytes, Option<u32>) and build_disk take the floor; deploy passes spec.scratch_floor_mb(). for_forge declares FORGE_SCRATCH_FLOOR_MB = 512 via the annotation so the old `ephemeral_storage_mb: 512` declaration is RENAMED, not dropped. Naming call I made: kept it in the `yah.limits.*` family beside the two ceilings, with the word `floor` in the key carrying the opposite direction — documented at the const.")
468//! @yah:handoff("MEASURED, AND IT RETIRES THE TICKET'S OWN WORRY: THE `requested` TERM WAS ALREADY DEAD CODE TREE-WIDE. microvm's WORKSPACE_MIN_BYTES floor is 8 GiB and disk_size_bytes is a `max`, while the LARGEST ephemeral_storage_mb anywhere in the tree was 1024 MiB (histogram over every rust literal: 0, 32, 64, 128, 256, 512, 1024 — nothing above). So the spec-supplied term never won for any workload that exists, and for_forge's 512 MiB in particular has always resolved to 8 GiB. Deleting the field is therefore behaviour-preserving by measurement, not by argument. The mechanism is still kept because it is load-bearing ABOVE 8 GiB, which is the only reason to have it at all — pinned by an assertion at 32 GiB.")
469//! @yah:handoff("PIECE 3 — R885-F3'S WIRE DELTA IS CARRIED, PLUS ONE BUG IT WOULD HAVE INTRODUCED. kamaji_proto::WorkloadState::OomKilled appended LAST (messages.rs). Plumbing is a new `oom_killed: bool` on kamaji's own WorkloadStatus::Failed (crate-internal, not a wire type, as F3 specified): native.rs settle() sets it from class.is_oom() — the ExitClass F3 already computes — and every other backend passes false, documented as \"not known to be an OOM\", never \"known not to be one\". Mapped at BOTH server.rs sites (runtime_state_to_entry, docker_workload_to_entry). sibling.rs carries the RETURN leg so the bit survives the round trip instead of being flattened one hop after it crossed. DISCOVERED AND FIXED IN THIS PASS, not filed: server.rs liveness_rank ends in `_ => 2` for states a newer peer might send, so OomKilled would have fallen into it and ranked 2 — ABOVE Failed(1) and level with Pending — letting an OOM-killed row win the List dedupe against a more informative row for the same id. Explicit `WireState::OomKilled => 1` arm plus a test. The catch-all is right for a state this build has never heard of and wrong for one it ships.")
470//! @yah:handoff("THE SWEEP: 75 rg hits across FIVE workspaces (root, oss/kamaji, oss/yah-base, oss/yubaba, oss/qed), all five now compiling with --all-features --all-targets. 38 files had standalone struct-literal lines; the non-mechanical ones were velveteen-exec/src/remote.rs:1101 (the buildkit image-build step set 4096 — a REAL second consumer the ticket did not name, now the annotation), workload-spec/src/validate.rs (capacity error text), and 19 JSON fixtures + app/yah/xlb-node/workload.json (field was the LAST key, so the preceding comma had to go too — all 20 re-validated as parseable JSON). A METHOD NOTE FOR WHOEVER RE-RUNS THE ACCEPTANCE GREP: this ticket's own verify line says `rg -rn \"ephemeral_storage_mb\"`, and `-r` is ripgrep's REPLACE flag — `-rn` parses as `-r n`, so every hit renders as the literal `n` and the output is unreadable. Use `rg -n`. That cost a pass to notice.")
471//! @yah:gotcha("STALE SECOND COPY OF A GENERATED FILE, PRE-EXISTING AND NOT TOUCHED BY THIS TICKET: oss/packages/yah/workload-spec/index.ts is 22040 bytes against the live packages/yah/workload-spec/index.ts at 39216, and it still carries `ephemeral_storage_mb: number`. `export-ts` writes ONLY the root packages/ path, and neither check-schema-drift.sh nor check-workload-spec-ts.sh reads the oss/ copy — both gates pass green with it stale. It was ~17KB behind before this ticket, so hand-patching the one field would make a file that is wrong in dozens of ways LOOK current, which is worse than leaving it. Deliberately left; needs either a generator that targets it or deletion, as its own ticket.")
472//! @yah:cleanup("DOCKER BACKEND CANNOT ANSWER `oom_killed` AND THE DATA IS RIGHT THERE. docker.rs status mapping hardcodes oom_killed: false because DockerState (docker.rs:87) does not parse the `OOMKilled` bool Docker's own /containers/{id}/json State object returns. Contained follow-on: add the field to DockerState, map it at the three WorkloadStatus::Failed arms. Left out deliberately — it is a different backend's classification and this ticket's piece 3 is the native path. Comment at the site names it so the `false` reads as a gap rather than a fact.")
473//! @yah:handoff("ALL FOUR ACCEPTANCE CRITERIA MET. (1) `rg -n \"ephemeral_storage_mb\" --type rust` returns ZERO code reads across all five workspaces — only board prose and deliberate historical references inside doc comments explaining what was deleted. (2) The for_forge scratch floor is proven by test, not inspection: deleting_the_field_did_not_change_what_for_forge_is_given drives the REAL WorkloadSpec::for_forge through the real accessor and asserts disk_size_bytes is identical for an empty tree and a 3 GiB one, that None and a declared-below-8-GiB floor agree, and that a 32 GiB floor still wins. (3) `rg -n \"OomKilled\" oss/kamaji` shows the variant, both server.rs mappings, and both directions tested — an_oom_kill_reaches_the_wire_distinct_from_a_plain_failure pins OOM -> OomKilled AND plain SIGKILL -> Failed. (4) ProtocolVersion is V11 exactly once with both changes in it. Schema + TS regen ran; check-schema-drift.sh and check-workload-spec-ts.sh both report `ok: in sync`.")
474//! @yah:verify("EVERY NUMBER RUN BY ME ON THIS TREE (anchor 494e22fa14b21d0cc72dd8a3132e7f078d1eb5eb), against the baselines the dispatch named. `cargo test -p kamaji --features native-integration --lib`: 153 pass / 0 fail, baseline 153 after B4 — UNCHANGED, because my microVM test needs the microvm feature. With `--features native-integration,microvm-integration --lib`: 195 pass / 0 fail, including the new microvm::tests::deleting_the_field_did_not_change_what_for_forge_is_given and all seven of F3's settle tests untouched. `cargo test -p kamaji-bin --features native-exec --lib`: 239 pass / 0 fail, baseline 237, +2 both mine. `cargo test -p yah-workload-spec --lib`: 196 pass / 0 fail, baseline 196 unchanged; `--tests` adds 105 pass / 0 fail over the 20 edited JSON fixtures. `cargo test -p kamaji-proto`: 34 pass / 0 fail, baseline 33, +1 mine. `cargo check --workspace --all-features --all-targets` exit 0 on oss/kamaji, oss/yah-base, oss/yubaba; `-p velveteen-exec --all-features --all-targets` exit 0 on oss/qed; `cargo check --workspace --all-targets` exit 0 on the root. `cargo clippy -p kamaji --features native-integration --all-targets`: 1 warning, PRE-EXISTING (jit.rs:312 too-many-arguments — the same one B1, F3 and B4 each recorded), none from this change.")
475//! @yah:verify("NOT ACHIEVED, STATED PLAINLY: NO LIVE LINUX NODE READING, and no live UDS exercised between a real yubaba and a real kamaji at V11. This is a macOS dev machine. The Linux-path verification achieved is the cross-compile — `cargo zigbuild -p kamaji-bin --features containerd-integration,native-exec --target x86_64-unknown-linux-gnu`, Finished, exit 0 — plus a byte-level pin of the wire claim in place of a real peer: appending_oom_killed_left_every_existing_discriminant_alone asserts each WorkloadState encodes to its literal postcard byte (Pending..Failed = 0..5 unmoved, OomKilled = 6), against the actual encoder rather than an `as usize` cast, because the cast reads the Rust discriminant and the claim is about the wire. Same macOS limitation B1, B5, T2, F3 and B4 each recorded. STILL OWED ON A NODE: deploy a workload past its memory.max and confirm yubaba now receives OomKilled rather than Failed — the end-to-end that R590-B10's scar is really about; and confirm a yubaba/kamaji pair restarted together negotiate V11 (skew here is a restart, not a rolling upgrade, since the two self-install as a pair).")
476//! @yah:gotcha("T6'S ROLL IS A THREE-NODE PROTOCOL BUMP, NOT A ONE-NODE ONE — and it did NOT ride R885's 2026-09-11 hot ship. That ship carried B4/B9/B10/B11 to us-east-001 only; T6 is still unshipped everywhere. From @Ashguard:coffee (R876), who dated the fleet independently: the live fleet is running PRE-T6 kamaji, established by `grep -c -a` against the deployed binaries — `ephemeral_storage_mb` PRESENT and `scratch-floor-mb` ABSENT. That string-dating trick is worth keeping: it dates a deployed binary by its own contents when `--version` cannot be trusted (hotship.sh:880 warns that `--version` is a build-time string that has agreed with a release the binary did not contain, R746-T3 — confirmed again on 2026-09-11, when us-east-001's yubaba printed 0.8.37 while /health correctly reported the hot-shipped 0.8.38-h5; `/health` is the authority, `--version` is not). WHY THIS MATTERS FOR T6 SPECIFICALLY: deleting ephemeral_storage_mb from ResourceLimits moves the postcard wire, so every node must cross together — unlike R885's capability and cgroup work, which degraded gracefully on a single node. Whoever rolls T6 is planning a fleet-wide coordinated roll of us-west-001, us-east-001 and us-south-001, not a scoped hot ship, and should read R885-B9's hotship gotcha first: hotship ships BINARIES ONLY and writes no unit file, which for T6 is fine but for the CAP_SETPCAP half of B9 is the difference between in-force and inert.")
477
478use std::collections::BTreeMap;
479use std::collections::HashMap;
480use std::fmt;
481use std::path::PathBuf;
482
483use serde::{Deserialize, Serialize};
484use ts_rs::TS;
485
486pub mod admission;
487pub mod compose_import;
488pub mod control_plane_install;
489pub mod export_ts;
490pub mod rollout;
491pub mod secrets;
492pub mod sovereign;
493pub mod validate;
494
495
496// ── Duration ──────────────────────────────────────────────────────────────────
497
498/// Duration expressed as an integer millisecond count.
499///
500/// Used for healthcheck intervals, timeouts, delays, and stop grace periods.
501/// Chosen over `std::time::Duration` to keep serde support dependency-free.
502#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, TS)]
503#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
504#[ts(type = "number")]
505pub struct Millis(pub u64);
506
507impl Millis {
508    pub fn from_secs(s: u64) -> Self {
509        Self(s * 1000)
510    }
511
512    pub fn from_ms(ms: u64) -> Self {
513        Self(ms)
514    }
515
516    pub fn as_ms(self) -> u64 {
517        self.0
518    }
519
520    pub fn as_secs_f64(self) -> f64 {
521        self.0 as f64 / 1000.0
522    }
523}
524
525// ── Primitive newtypes ────────────────────────────────────────────────────────
526
527/// Opaque identifier for a yubaba-managed machine within the cluster.
528///
529/// Used by the semantic validation layer for admission-control capacity checks.
530/// Yubaba passes its own machine ID when validating a spec before deployment.
531#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, TS)]
532#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
533pub struct MachineId(pub String);
534
535/// DNS-segment identity for a workload on the cluster mesh, e.g.
536/// `"noisetable-api.pdx"`. Regex constraint: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`,
537/// length ≤ 63. Enforced in shape validation (R090-F2).
538#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
539#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
540pub struct MeshIdent(pub String);
541
542/// Tier classification that governs admission control and mesh `allow_from`
543/// filtering. Known values: `"public"`, `"tenant"`, `"private"`, `"infra"`.
544/// Custom tiers are allowed per cluster; shape validation warns on unknowns
545/// rather than rejecting them (R090-F2).
546#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
547#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
548pub struct TierTag(pub String);
549
550/// Default single-tenant identity written to specs that predate the tenant
551/// axis (W206). Its concrete string is arbitrary — what matters is that a
552/// single-tenant cluster only ever sees this one value, so every per-tenant
553/// isolation primitive collapses to a no-op. See [`TenantId::singleton`].
554pub const DEFAULT_TENANT: &str = "default";
555
556/// Default single-namespace identity for specs that predate the namespace
557/// axis (W206). See [`NamespaceId::singleton`].
558pub const DEFAULT_NAMESPACE: &str = "default";
559
560/// Tenant **isolation** axis (W206). Separates one operator's workloads from
561/// another's at the network / DB / mesh-identity level. Orthogonal to
562/// [`NamespaceId`] (routing/naming) and [`TierTag`] (workload class within a
563/// `(tenant, namespace)` pair).
564///
565/// **Degenerate case:** when a yubaba reconciler sees only one `TenantId`
566/// across every workload on a machine, the tenant prefix on mesh identity is
567/// dropped and PostgreSQL role separation is skipped — isolation primitives
568/// become no-ops. You pay only when more than one tenant is present. Container
569/// networking is keyed on the id, not the count: [`TenantId::singleton`]
570/// workloads share the node bridge, and every other tenant gets its own (W343
571/// §Tenant isolation). Specs written before this axis existed deserialize to
572/// [`TenantId::singleton`].
573#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, TS)]
574#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
575pub struct TenantId(pub String);
576
577impl TenantId {
578    /// The singleton tenant used for back-compat with single-tenant (current)
579    /// deployments. Specs written before the tenant axis existed deserialize
580    /// to this value via the `#[serde(default)]` on [`WorkloadSpec::tenant`],
581    /// keeping the whole cluster single-tenant so every isolation primitive
582    /// stays a no-op.
583    pub fn singleton() -> Self {
584        Self(DEFAULT_TENANT.to_string())
585    }
586
587    /// Whether this is the singleton (degenerate single-tenant) identity.
588    pub fn is_singleton(&self) -> bool {
589        self.0 == DEFAULT_TENANT
590    }
591}
592
593/// Namespace **routing/naming** axis (W206). A pure naming key that never
594/// affects isolation: it selects the config root, disambiguates service DNS
595/// names within a tenant, prefixes object-store bucket names within a tenant's
596/// bucket scope, and selects the provider zone (e.g. `noisetable.com` vs
597/// `yah.dev`). Two namespaces in the same tenant share networks, mesh-identity
598/// space, and PG cluster — they simply cannot collide on workload names or
599/// external domains. Specs written before this axis existed deserialize to
600/// [`NamespaceId::singleton`].
601#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, TS)]
602#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
603pub struct NamespaceId(pub String);
604
605impl NamespaceId {
606    /// The singleton namespace used for back-compat with single-namespace
607    /// (current) deployments. Specs written before the namespace axis existed
608    /// deserialize to this value via the `#[serde(default)]` on
609    /// [`WorkloadSpec::namespace`].
610    pub fn singleton() -> Self {
611        Self(DEFAULT_NAMESPACE.to_string())
612    }
613
614    /// Whether this is the singleton (degenerate single-namespace) identity.
615    pub fn is_singleton(&self) -> bool {
616        self.0 == DEFAULT_NAMESPACE
617    }
618}
619
620// ── Workload (on-disk envelope) ──────────────────────────────────────────────
621
622/// On-disk `workload.toml` manifest. Each variant matches one
623/// `ServiceComponent.kind` value; the `kind` field on the wire is the serde
624/// discriminator.
625///
626/// This is the **on-disk** envelope — distinct from [`WorkloadSpec`], the
627/// containerd wire format yubaba receives over RPC. A `kind = "container"`
628/// workload deserializes its remaining fields as a [`ContainerManifest`],
629/// which is *either* a digest-pinned `WorkloadSpec` or a local Dockerfile
630/// recipe (R783-F1 / W324); other kinds carry their own per-reconciler
631/// payload shape.
632///
633/// **Never put `#[serde(skip_serializing_if = "Option::is_none")]` on a field
634/// of this enum or any type it reaches.** These types ride the kamaji-proto
635/// **postcard** wire, which is non-self-describing and positional:
636/// `skip_serializing_if` omits the field's byte on serialize while decode still
637/// expects to read it at that offset, so the byte stream misaligns and the
638/// round-trip fails. Use `#[serde(default)]` + `#[ts(optional = nullable)]`
639/// instead — that still gives TOML/JSON back-compat (missing field → `None`)
640/// while the field is always encoded. `MesofactStaticWorkload::ssr_runtime` and
641/// `::serve_bundle` are the reference shape.
642/// **Two wire shapes, one type (R546-B7).** `Serialize`/`Deserialize` are
643/// hand-written and branch on [`is_human_readable`](serde::Deserializer::is_human_readable):
644///
645/// - **TOML/JSON (human-readable)** → *internally* tagged on `kind`, i.e. the
646///   flat shape every on-disk `workload.toml` actually uses
647///   (`kind = "static-asset"` beside `[[asset]]` and `[aliases]`).
648/// - **postcard (binary)** → *externally* tagged, byte-identical to the derived
649///   representation R590-B3 established for the kamaji UDS.
650///
651/// Why not just `#[serde(tag = "kind")]`: internal tagging buffers through
652/// `deserialize_any`, which postcard (non-self-describing) refuses with
653/// `WontImplement` — that is exactly the failure R590-B3 fixed by flipping this
654/// enum to external tagging. But external tagging wants a single-key map, so
655/// every flat on-disk file then failed with `wanted exactly 1 element, more
656/// than 1 element` and `yah cloud apply` broke for every static-asset
657/// component. Branching on the format satisfies both, and mirrors what
658/// [`ImageRef`] already does for its string-vs-struct form.
659#[derive(Debug, Clone, PartialEq, TS)]
660#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
661#[cfg_attr(
662    feature = "json-schema",
663    schemars(tag = "kind", rename_all = "kebab-case")
664)]
665#[ts(tag = "kind", rename_all = "kebab-case")]
666pub enum Workload {
667    /// Static-site build that publishes an artifact directory to the
668    /// service's `static` provider slot. Reconciled by the
669    /// `mesofact-static` reconciler — does not deploy to yubaba.
670    MesofactStatic(MesofactStaticWorkload),
671
672    /// A container-shaped workload. **Two on-disk forms** (R783-F1 / W324),
673    /// see [`ContainerManifest`]: a digest-pinned [`WorkloadSpec`] reference
674    /// (the form that crosses the kamaji wire) or a local Dockerfile
675    /// [`ContainerBuild`] recipe (which cannot, because it names no digest
676    /// until it has been built).
677    ///
678    /// Construct the wire form with [`Workload::container`] and read it back
679    /// with [`Workload::container_spec`] — most callers only ever mean the
680    /// reference form and should not have to name the manifest enum.
681    ///
682    /// The reference form's inline fields are the full [`WorkloadSpec`] minus
683    /// the `kind` discriminator.
684    ///
685    /// This is also the shape of the W267 sovereign-public-ingress appliance
686    /// (R594-F2): a container-kind workload with `archetype =
687    /// Some(LifecycleArchetype::Appliance)` and
688    /// `requires_taint() == Some(PUBLIC_IP_TAINT)`, **not** a dedicated
689    /// `Workload::ingress(..)` variant. It runs an ordinary OCI image (the
690    /// `passway` proxy, R594-F4) supervised by kamaji exactly like any other
691    /// `Container`, so no admission-list or wire-codec change was needed to
692    /// let kamaji accept it. A new enum variant would have forced an
693    /// exhaustive-match update in every `Workload` consumer, including
694    /// peer-owned `kamaji-proto/src/codec.rs` — the archetype + annotation
695    /// combination expresses "this is the public ingress appliance" without
696    /// that blast radius. See [`WorkloadSpec::requires_taint`] and
697    /// [`LifecycleArchetype::Appliance`].
698    Container(ContainerManifest),
699
700    /// Data-pipeline job with declared I/O and a readiness policy. The
701    /// orchestrator checks all `inputs` are reachable before each run and
702    /// verifies `outputs` afterward. Generalises the OpenRouter JSON-cache
703    /// refresher (`spawn_almanac_refresher`) to the full manifest form.
704    Almanac(AlmanacManifest),
705
706    /// Content-addressed static files uploaded to the mirror's `object_store`
707    /// provider slot. Wave-0 by default — gating mesofact and container waves.
708    /// Rollback is a pointer-flip via `mirror.toml [asset_aliases]`; bytes are
709    /// append-only and never re-pushed on rollback. See W160.
710    StaticAsset(StaticAssetWorkload),
711
712    /// One cold, per-tenant passway serving a single custom domain, forked on
713    /// demand by kamaji's JIT tier (R852-F1 / W267 §"Free-tier ingress at 10k
714    /// domains"). Unlike the `Container`-shaped **node** ingress appliance
715    /// above, this one is native-forked and zero-resident — see
716    /// [`TenantPasswayWorkload`] for why that difference is what made it a
717    /// variant rather than another annotated container.
718    ///
719    /// **Appended last, deliberately.** postcard encodes an external tag as the
720    /// variant *index*, so a variant inserted anywhere but the end renumbers
721    /// every later one and a pre-R852 node silently decodes the wrong shape off
722    /// the kamaji UDS.
723    TenantPassway(TenantPasswayWorkload),
724}
725
726impl Workload {
727    /// The `kind` discriminator this variant serializes as — the same string a
728    /// `workload.toml` writes and a `ServiceComponent.kind` names.
729    ///
730    /// Lives here rather than at a call site because this enum now has FIVE
731    /// places that enumerate its variants (itself plus the four tagging
732    /// mirrors below); a caller-local match would be a sixth, in another crate,
733    /// with nothing to force it to keep up.
734    pub fn kind_str(&self) -> &'static str {
735        match self {
736            Workload::MesofactStatic(_) => "mesofact-static",
737            Workload::Container(_) => "container",
738            Workload::Almanac(_) => "almanac",
739            Workload::StaticAsset(_) => "static-asset",
740            Workload::TenantPassway(_) => "tenant-passway",
741        }
742    }
743
744    /// The per-tenant passway declaration, if this is one.
745    pub fn tenant_passway(&self) -> Option<&TenantPasswayWorkload> {
746        match self {
747            Workload::TenantPassway(w) => Some(w),
748            _ => None,
749        }
750    }
751
752    /// Wrap a digest-pinned [`WorkloadSpec`] as a `kind = "container"`
753    /// workload — the form that crosses the kamaji wire.
754    ///
755    /// Every caller that synthesizes a container workload in code (ingress
756    /// appliances, forge runs, kamaji's own deploy path) means *this* form;
757    /// the [`ContainerManifest::Recipe`] arm only ever arrives by parsing a
758    /// `workload.toml` with a `[build]` table. Keeping the constructor here
759    /// means R783-F1 did not have to teach ~25 call sites the name of a
760    /// manifest enum they have no opinion about.
761    pub fn container(spec: WorkloadSpec) -> Self {
762        Workload::Container(ContainerManifest::Reference(spec))
763    }
764
765    /// The digest-pinned spec of a `kind = "container"` workload, if this is
766    /// a container workload in the reference form.
767    ///
768    /// `None` covers both "not a container" and "a container *recipe*, which
769    /// has no spec until it is built" — a consumer that speaks the wire
770    /// (kamaji, yubaba's deploy path) must treat both as inadmissible, so
771    /// collapsing them into one `None` is deliberate rather than lossy. Use
772    /// [`Workload::container_manifest`] when the two need distinguishing.
773    pub fn container_spec(&self) -> Option<&WorkloadSpec> {
774        match self {
775            Workload::Container(m) => m.as_spec(),
776            _ => None,
777        }
778    }
779
780    /// The container manifest, in whichever on-disk form it was written.
781    pub fn container_manifest(&self) -> Option<&ContainerManifest> {
782        match self {
783            Workload::Container(m) => Some(m),
784            _ => None,
785        }
786    }
787}
788
789/// Internally-tagged mirror of [`Workload`] — the on-disk shape. Only ever
790/// reached on the human-readable branch, so its `deserialize_any` buffering is
791/// never asked of postcard.
792#[derive(Serialize, Deserialize)]
793#[serde(tag = "kind", rename_all = "kebab-case")]
794enum WorkloadTagged {
795    MesofactStatic(MesofactStaticWorkload),
796    Container(ContainerManifest),
797    Almanac(AlmanacManifest),
798    StaticAsset(StaticAssetWorkload),
799    TenantPassway(TenantPasswayWorkload),
800}
801
802/// Borrowing twin of [`WorkloadTagged`] so `Serialize` need not clone the
803/// payload. Variant order must match [`Workload`].
804#[derive(Serialize)]
805#[serde(tag = "kind", rename_all = "kebab-case")]
806enum WorkloadTaggedRef<'a> {
807    MesofactStatic(&'a MesofactStaticWorkload),
808    Container(&'a ContainerManifest),
809    Almanac(&'a AlmanacManifest),
810    StaticAsset(&'a StaticAssetWorkload),
811    TenantPassway(&'a TenantPasswayWorkload),
812}
813
814/// Externally-tagged mirror — the postcard wire shape R590-B3 established.
815/// postcard encodes an external tag as the *variant index*, so the variant
816/// ORDER here is load-bearing: it must match [`Workload`] exactly or the
817/// kamaji UDS silently decodes into the wrong variant.
818///
819/// `Container` deliberately keeps [`WorkloadSpec`], **not**
820/// [`ContainerManifest`] (R783-F1 / W324): the wire carries only the
821/// digest-pinned reference form, so these bytes are unchanged by the on-disk
822/// split, and a [`ContainerManifest::Recipe`] is refused at serialize rather
823/// than encoded as a second variant nothing on the far side can execute.
824#[derive(Serialize, Deserialize)]
825#[serde(rename_all = "kebab-case")]
826enum WorkloadExternal {
827    MesofactStatic(MesofactStaticWorkload),
828    Container(WorkloadSpec),
829    Almanac(AlmanacManifest),
830    StaticAsset(StaticAssetWorkload),
831    TenantPassway(TenantPasswayWorkload),
832}
833
834/// Borrowing twin of [`WorkloadExternal`]. Same order requirement.
835#[derive(Serialize)]
836#[serde(rename_all = "kebab-case")]
837enum WorkloadExternalRef<'a> {
838    MesofactStatic(&'a MesofactStaticWorkload),
839    Container(&'a WorkloadSpec),
840    Almanac(&'a AlmanacManifest),
841    StaticAsset(&'a StaticAssetWorkload),
842    TenantPassway(&'a TenantPasswayWorkload),
843}
844
845impl Serialize for Workload {
846    fn serialize<S>(&self, s: S) -> Result<S::Ok, S::Error>
847    where
848        S: serde::Serializer,
849    {
850        if s.is_human_readable() {
851            match self {
852                Workload::MesofactStatic(w) => WorkloadTaggedRef::MesofactStatic(w),
853                Workload::Container(w) => WorkloadTaggedRef::Container(w),
854                Workload::Almanac(w) => WorkloadTaggedRef::Almanac(w),
855                Workload::StaticAsset(w) => WorkloadTaggedRef::StaticAsset(w),
856                Workload::TenantPassway(w) => WorkloadTaggedRef::TenantPassway(w),
857            }
858            .serialize(s)
859        } else {
860            match self {
861                Workload::MesofactStatic(w) => WorkloadExternalRef::MesofactStatic(w),
862                // The wire gate (W324 §5). A recipe names no digest, so there
863                // is nothing for kamaji to pull — refusing here makes "a build
864                // recipe cannot reach kamaji" a fact the type system holds,
865                // rather than a convention someone eventually forgets.
866                Workload::Container(ContainerManifest::Recipe(_)) => {
867                    return Err(serde::ser::Error::custom(RECIPE_IS_NOT_A_WIRE_SPEC))
868                }
869                Workload::Container(ContainerManifest::Reference(spec)) => {
870                    WorkloadExternalRef::Container(spec)
871                }
872                Workload::Almanac(w) => WorkloadExternalRef::Almanac(w),
873                Workload::StaticAsset(w) => WorkloadExternalRef::StaticAsset(w),
874                Workload::TenantPassway(w) => WorkloadExternalRef::TenantPassway(w),
875            }
876            .serialize(s)
877        }
878    }
879}
880
881impl<'de> Deserialize<'de> for Workload {
882    fn deserialize<D>(de: D) -> Result<Self, D::Error>
883    where
884        D: serde::Deserializer<'de>,
885    {
886        if de.is_human_readable() {
887            Ok(match WorkloadTagged::deserialize(de)? {
888                WorkloadTagged::MesofactStatic(w) => Workload::MesofactStatic(w),
889                WorkloadTagged::Container(w) => Workload::Container(w),
890                WorkloadTagged::Almanac(w) => Workload::Almanac(w),
891                WorkloadTagged::StaticAsset(w) => Workload::StaticAsset(w),
892                WorkloadTagged::TenantPassway(w) => Workload::TenantPassway(w),
893            })
894        } else {
895            Ok(match WorkloadExternal::deserialize(de)? {
896                WorkloadExternal::MesofactStatic(w) => Workload::MesofactStatic(w),
897                // Only the reference form exists on the wire, by construction
898                // of `WorkloadExternal` — see its doc comment.
899                WorkloadExternal::Container(w) => Workload::container(w),
900                WorkloadExternal::Almanac(w) => Workload::Almanac(w),
901                WorkloadExternal::StaticAsset(w) => Workload::StaticAsset(w),
902                WorkloadExternal::TenantPassway(w) => Workload::TenantPassway(w),
903            })
904        }
905    }
906}
907
908// ── Container manifest (R783-F1 / W324) ───────────────────────────────────────
909
910/// Error text used both by the postcard serializer gate and by
911/// [`ContainerManifest::into_spec`]'s doc, so the two cannot drift.
912const RECIPE_IS_NOT_A_WIRE_SPEC: &str = "a kind = \"container\" workload in the RECIPE form \
913     (a [build] table) cannot cross the kamaji wire: it names an image tag, not a digest, and \
914     the digest does not exist until `docker build` has run. Lower it with \
915     `ContainerBuild::into_spec(digest)` after the build, then send the resulting WorkloadSpec.";
916
917/// On-disk payload of `kind = "container"` — **two forms**, one wire type
918/// (W324 §5).
919///
920/// A [`WorkloadSpec`] asserts a content-addressed identity: its
921/// [`ImageRef::digest`] is a required `sha256:<hex>` and the string form
922/// rejects a bare tag at serde-deserialize (R438-T3). A local component built
923/// from a Dockerfile next to its `workload.toml` cannot satisfy that — its
924/// image is `yah-local/<name>:dev`, and the digest does not exist until the
925/// build has run. So a build *recipe* is not a degenerate spec with a missing
926/// field; it is a promise to produce one, and the two are different types.
927///
928/// The discriminator is the presence of a `[build]` table. `WorkloadSpec` has
929/// no `build` field and [`ContainerBuild`] requires one, so the two shapes are
930/// mutually exclusive — and picking the branch explicitly (rather than with
931/// `#[serde(untagged)]`) is what lets a malformed reference still report
932/// `missing field \`image\`` instead of "data did not match any variant".
933///
934/// Only [`Reference`](Self::Reference) crosses the postcard kamaji wire; see
935/// [`WorkloadExternal`]'s doc comment for why that keeps those bytes
936/// byte-identical to the pre-split encoding.
937#[derive(Debug, Clone, PartialEq, TS)]
938#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
939#[cfg_attr(feature = "json-schema", schemars(untagged))]
940#[ts(untagged)]
941pub enum ContainerManifest {
942    /// Digest-pinned image. Crosses the wire as-is.
943    Reference(WorkloadSpec),
944
945    /// Dockerfile recipe. **Local only** — see [`ContainerBuild`].
946    Recipe(ContainerBuild),
947}
948
949impl ContainerManifest {
950    /// The digest-pinned spec, or `None` for the recipe form.
951    pub fn as_spec(&self) -> Option<&WorkloadSpec> {
952        match self {
953            ContainerManifest::Reference(spec) => Some(spec),
954            ContainerManifest::Recipe(_) => None,
955        }
956    }
957
958    /// The build recipe, or `None` for the reference form.
959    pub fn as_recipe(&self) -> Option<&ContainerBuild> {
960        match self {
961            ContainerManifest::Recipe(b) => Some(b),
962            ContainerManifest::Reference(_) => None,
963        }
964    }
965
966    /// Consume the manifest, yielding the digest-pinned spec. `Err` carries
967    /// the recipe back so a caller that *can* build it still has it.
968    pub fn into_spec(self) -> Result<WorkloadSpec, ContainerBuild> {
969        match self {
970            ContainerManifest::Reference(spec) => Ok(spec),
971            ContainerManifest::Recipe(b) => Err(b),
972        }
973    }
974
975    /// `"reference"` or `"recipe"` — for error messages that need to name
976    /// which form was found without matching on the enum at the call site.
977    pub fn form(&self) -> &'static str {
978        match self {
979            ContainerManifest::Reference(_) => "reference",
980            ContainerManifest::Recipe(_) => "recipe",
981        }
982    }
983}
984
985impl Serialize for ContainerManifest {
986    fn serialize<S>(&self, s: S) -> Result<S::Ok, S::Error>
987    where
988        S: serde::Serializer,
989    {
990        match self {
991            // Transparent in both directions: the on-disk container form is
992            // the payload's own fields flattened under `kind = "container"`,
993            // exactly as it was before the split.
994            ContainerManifest::Reference(spec) => spec.serialize(s),
995            ContainerManifest::Recipe(recipe) => {
996                if s.is_human_readable() {
997                    recipe.serialize(s)
998                } else {
999                    Err(serde::ser::Error::custom(RECIPE_IS_NOT_A_WIRE_SPEC))
1000                }
1001            }
1002        }
1003    }
1004}
1005
1006impl<'de> Deserialize<'de> for ContainerManifest {
1007    fn deserialize<D>(de: D) -> Result<Self, D::Error>
1008    where
1009        D: serde::Deserializer<'de>,
1010    {
1011        use serde::de::Error as _;
1012
1013        // postcard and friends are non-self-describing, so there is no map to
1014        // probe for `[build]` — and by construction the binary wire only ever
1015        // carries the reference form anyway (`WorkloadExternal::Container`).
1016        if !de.is_human_readable() {
1017            return WorkloadSpec::deserialize(de).map(ContainerManifest::Reference);
1018        }
1019
1020        // Buffer once, then branch explicitly. `serde_json::Value` is the
1021        // buffer rather than `#[serde(untagged)]`'s private `Content` because
1022        // untagged discards the inner error: `missing field \`image\`` — the
1023        // one thing an author needs to see — becomes "data did not match any
1024        // variant of untagged enum ContainerManifest".
1025        let buffered = serde_json::Value::deserialize(de)?;
1026
1027        match (
1028            buffered.get("build").is_some(),
1029            buffered.get("image").is_some(),
1030        ) {
1031            (true, _) => ContainerBuild::deserialize(buffered)
1032                .map(ContainerManifest::Recipe)
1033                .map_err(|e| {
1034                    D::Error::custom(format!(
1035                        "kind = \"container\" with a [build] table is a local build recipe: {e}"
1036                    ))
1037                }),
1038            (false, true) => WorkloadSpec::deserialize(buffered)
1039                .map(ContainerManifest::Reference)
1040                .map_err(|e| {
1041                    D::Error::custom(format!(
1042                        "kind = \"container\" without a [build] table is a digest-pinned image \
1043                         reference: {e}"
1044                    ))
1045                }),
1046            // Neither marker. Reporting `missing field \`image\`` here would
1047            // send a recipe author off to add a field their form does not
1048            // have, so name both forms instead — this is the one case where
1049            // the file does not say which of the two it is trying to be.
1050            (false, false) => Err(D::Error::custom(
1051                "kind = \"container\" must declare either a digest-pinned `image` (the wire \
1052                 form: a WorkloadSpec yubaba hands to kamaji) or a [build] table (a local \
1053                 Dockerfile recipe built on the operator's box) — it declares neither",
1054            )),
1055        }
1056    }
1057}
1058
1059/// `kind = "container"` in the **recipe** form: a Dockerfile next to the
1060/// component's `workload.toml`, built and run on the operator's box.
1061///
1062/// This is the shape `ContainerReconciler` drives (`docker build` from
1063/// [`build`](Self::build), `docker run` with [`run`](Self::run)). It is
1064/// deliberately *not* a `WorkloadSpec` — see [`ContainerManifest`] for why the
1065/// digest invariant makes that impossible, and [`Self::into_spec`] for the one
1066/// lowering that is allowed.
1067///
1068/// **Unknown keys are tolerated on purpose.** `crates/yah/cloud-admin/workload.toml`
1069/// carries a `[process]` table read by `LocalProcessReconciler` on the dev
1070/// mirror — one component file, three tier runtimes (W324 §1). Adding
1071/// `deny_unknown_fields` here would make that file unparseable as a container
1072/// manifest, which is the opposite of the point.
1073#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
1074#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1075pub struct ContainerBuild {
1076    /// Component name. Same field the reference form carries, so a manifest
1077    /// identifies itself the same way whichever form it is written in.
1078    pub name: String,
1079
1080    /// How the image is built. Its presence is what makes this a recipe.
1081    pub build: ContainerBuildStep,
1082
1083    /// How the built image is run locally.
1084    #[serde(default)]
1085    pub run: ContainerRunConfig,
1086}
1087
1088impl ContainerBuild {
1089    /// Lower a recipe to the wire type, **once a build has produced a digest**.
1090    ///
1091    /// The signature is the invariant (W324 §5): there is no way to reach a
1092    /// `WorkloadSpec` from a recipe without supplying the `sha256:<hex>` the
1093    /// build emitted, so an unpinned container spec cannot be constructed by
1094    /// accident.
1095    ///
1096    /// Fallible because `digest` is a caller-supplied string: a malformed one
1097    /// must be an error, not a `WorkloadSpec` that lies about being
1098    /// content-addressed. Everything the recipe does not declare
1099    /// (`tier`, `resources`, `restart_policy`, …) takes the same defaults a
1100    /// hand-written local container gets; `tier` is the caller's because
1101    /// admission control is a cluster policy, not a manifest fact.
1102    pub fn into_spec(self, digest: &str, tier: TierTag) -> Result<WorkloadSpec, String> {
1103        let image_tag = self
1104            .build
1105            .image
1106            .clone()
1107            .unwrap_or_else(|| format!("yah-local/{}:dev", self.name));
1108
1109        // Route through the one parser that owns the digest rule (R438-T3) so
1110        // the recipe path cannot grow a second, laxer definition of "pinned".
1111        let image = compose_import::parse_pinned_image_ref(&format!("{image_tag}@{digest}"))
1112            .map_err(|e| format!("lowering container recipe {:?}: {e}", self.name))?;
1113
1114        let ports = MeshExpose::anonymous_ports(self.run.port);
1115
1116        Ok(WorkloadSpec {
1117            name: self.name.clone(),
1118            image,
1119            tier,
1120            tenant: TenantId::singleton(),
1121            namespace: NamespaceId::singleton(),
1122            replicas: 1,
1123            command: None,
1124            entrypoint: None,
1125            workdir: None,
1126            user: None,
1127            env: self
1128                .run
1129                .env
1130                .into_iter()
1131                .map(|(name, value)| EnvVar {
1132                    name,
1133                    value: EnvValue::Literal { value },
1134                })
1135                .collect(),
1136            secrets: vec![],
1137            volumes: self
1138                .run
1139                .mounts
1140                .into_iter()
1141                .map(|m| VolumeMount {
1142                    source: VolumeSource::Bind {
1143                        host_path: PathBuf::from(m.host),
1144                    },
1145                    target: m.container,
1146                    read_only: m.read_only,
1147                    from_secret_mount: false,
1148                })
1149                .collect(),
1150            resources: ResourceLimits {
1151                memory_mb: 1024,
1152                cpu_millis: 1000,
1153                memory_request_mb: None,
1154                cpu_limit_millis: None,
1155                pids_max: None,
1156                scratch_floor_mb: None,
1157            },
1158            depends_on: vec![],
1159            requires: vec![],
1160            healthcheck: None,
1161            restart_policy: RestartPolicy::Always,
1162            archetype: Some(LifecycleArchetype::Server),
1163            stop_policy: StopPolicy {
1164                signal: 15,
1165                grace_period: Millis::from_secs(10),
1166            },
1167            expose: ExposeSpec {
1168                mesh: MeshExpose {
1169                    identity: MeshIdent(self.name),
1170                    ports,
1171                    allow_from: vec![],
1172                },
1173                public: None,
1174                operator: None,
1175            },
1176            labels: HashMap::new(),
1177            durability: None,
1178            db: Vec::new(),
1179            capabilities: Vec::new(),
1180            annotations: HashMap::new(),
1181            files: Vec::new(),
1182        })
1183    }
1184}
1185
1186/// The `[build]` table of a container recipe.
1187#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
1188#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1189pub struct ContainerBuildStep {
1190    /// Dockerfile path, relative to the component directory.
1191    #[serde(default = "default_dockerfile")]
1192    pub dockerfile: PathBuf,
1193
1194    /// Build context, relative to the workspace root. `None` → the component
1195    /// directory. Workspace crates set `"."` so their path-dependency sources
1196    /// resolve.
1197    #[serde(default)]
1198    #[ts(optional = nullable)]
1199    pub context: Option<PathBuf>,
1200
1201    /// Image tag to build and run. `None` → `yah-local/<name>:dev`.
1202    ///
1203    /// A **tag**, not an [`ImageRef`]: this names an image that does not exist
1204    /// yet, so there is no digest to pin it by.
1205    #[serde(default)]
1206    #[ts(optional = nullable)]
1207    pub image: Option<String>,
1208}
1209
1210fn default_dockerfile() -> PathBuf {
1211    PathBuf::from("Dockerfile")
1212}
1213
1214impl Default for ContainerBuildStep {
1215    fn default() -> Self {
1216        Self {
1217            dockerfile: default_dockerfile(),
1218            context: None,
1219            image: None,
1220        }
1221    }
1222}
1223
1224/// The `[run]` table of a container recipe.
1225#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, TS)]
1226#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1227pub struct ContainerRunConfig {
1228    /// Container port the process listens on.
1229    #[serde(default)]
1230    #[ts(optional = nullable)]
1231    pub port: Option<u16>,
1232
1233    /// Host port to publish it on. `None` → same as [`port`](Self::port).
1234    #[serde(default)]
1235    #[ts(optional = nullable)]
1236    pub host_port: Option<u16>,
1237
1238    /// Environment passed into the container.
1239    #[serde(default)]
1240    pub env: BTreeMap<String, String>,
1241
1242    /// Bind mounts from the workspace into the container.
1243    #[serde(default)]
1244    pub mounts: Vec<ContainerMount>,
1245}
1246
1247/// One `[[run.mounts]]` entry of a container recipe.
1248#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
1249#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1250pub struct ContainerMount {
1251    /// Host path. Relative paths resolve against the workspace root — the
1252    /// declaration lives in the repo, so it should read like a repo path and
1253    /// stay valid on whichever machine the operator runs it from.
1254    pub host: String,
1255
1256    /// Absolute path inside the container.
1257    pub container: PathBuf,
1258
1259    /// Default `true`. A workspace mount is config the service *reads*; a
1260    /// writable default would let a container mutate the operator's checkout
1261    /// as a side effect of running, so opting into that has to be explicit.
1262    #[serde(default = "default_true")]
1263    pub read_only: bool,
1264}
1265
1266fn default_true() -> bool {
1267    true
1268}
1269
1270/// `kind = "mesofact-static"` payload — static-site build colocated with the
1271/// frontend it deploys.
1272///
1273/// The two-role model (R256-F7): a build/publish step plus an optional
1274/// SSR/SPA runtime companion. The build step is always transient (runs once,
1275/// publishes, exits). The companion is long-lived and only present when the
1276/// app has dynamic/server-rendered pages; pure static sites leave it `None`.
1277#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
1278#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1279pub struct MesofactStaticWorkload {
1280    /// Build command + output directory.
1281    pub build: BuildConfig,
1282
1283    /// Path (relative to the manifest) of the routes module the
1284    /// `mesofact-static` reconciler reads to enumerate routes.
1285    pub routes: PathBuf,
1286
1287    /// Where the build command runs. Default: `HostSide` (mesofact-dev on the
1288    /// host). Set to `InContainer` for cloud/HA where no host watcher is
1289    /// present and CI-fidelity build environments are required.
1290    #[serde(default)]
1291    pub build_mode: BuildMode,
1292
1293    /// Optional SSR/SPA runtime companion container.
1294    ///
1295    /// `None` → pure static site; Caddy (or equivalent CDN) serves all
1296    /// requests directly from the object store. This is the common case for
1297    /// dev-yah today.
1298    ///
1299    /// `Some` → the workload spec describes a long-lived container that
1300    /// handles dynamic/SSR requests. Caddy routes static asset paths to
1301    /// the object store and all other paths to this container. The companion
1302    /// uses `RestartPolicy::Always`; the orchestrator (camp or yubaba)
1303    /// ensures it stays up alongside the Caddy edge.
1304    #[ts(optional = nullable)]
1305    pub ssr_runtime: Option<WorkloadSpec>,
1306
1307    /// Serve-time reference to a published W272 bundle (R599-F4).
1308    ///
1309    /// `Some` → the built app is deployed as a content-addressed bundle that
1310    /// kamaji materializes from the bundle store (R599-F1) and serves via its
1311    /// native backend, instead of (or in addition to) the build reconciler
1312    /// pushing `dist/` to the object-store/CDN. `None` → legacy
1313    /// build-and-publish-only workload — kamaji rejects that form as yubaba's
1314    /// `mesofact-static` reconciler's responsibility.
1315    ///
1316    /// No `skip_serializing_if`: like `ssr_runtime`, this field is always
1317    /// encoded so the postcard wire codec (non-self-describing, positional)
1318    /// round-trips — `skip_serializing_if` would omit the byte on serialize
1319    /// while decode still expects it. `#[serde(default)]` keeps every existing
1320    /// `mesofact-static` TOML/JSON that predates this field parsing to `None`.
1321    #[serde(default)]
1322    #[ts(optional = nullable)]
1323    pub serve_bundle: Option<MesofactServeBundle>,
1324
1325    /// Revalidate receiver for the almanac push model (R330-F12).
1326    ///
1327    /// `Some` → kamaji also forks `mesofact serve --revalidate <workload>`
1328    /// alongside the bundle's static serve (or in place of it when
1329    /// `serve_bundle` is `None`). The receiver is ephemeral-V8: each
1330    /// `POST /dawn` boots a V8 isolate, re-renders the route, republishes to
1331    /// the CDN, then drops the isolate. (`/revalidate` is still served as a
1332    /// transitional alias — yah R752-T10 renamed it so the render stage stops
1333    /// sharing a path with almanac's feed-refetch stage, `POST /freshen`.)
1334    ///
1335    /// Env vars are resolved at deploy time (R2 creds + mirror bearer) so
1336    /// the node never sees keystore slot names.
1337    #[serde(default)]
1338    #[ts(optional = nullable)]
1339    pub revalidate_receiver: Option<MesofactRevalidateReceiver>,
1340}
1341
1342/// Revalidate receiver config (R330-F12) — tells kamaji to fork a second
1343/// `mesofact serve --revalidate` process alongside the static bundle server.
1344///
1345/// The receiver is the almanac push endpoint: a lightweight resident axum
1346/// server mounting `POST /dawn` (plus the legacy `/revalidate` alias) that
1347/// boots V8 on each poke, re-renders the invalidated route, publishes to
1348/// R2/CDN, then drops the isolate.
1349#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1350#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1351pub struct MesofactRevalidateReceiver {
1352    /// Routes the receiver accepts pokes for (allowlist).
1353    /// Empty vec → all routes in the workload's manifest are revalidatable.
1354    #[serde(default)]
1355    pub routes: Vec<String>,
1356
1357    /// Path to `mesofact.config.toml` carrying the `[publish]` block
1358    /// (bucket / zone / env-named credentials). Relative to the workload
1359    /// directory. Default: `"mesofact.config.toml"`.
1360    #[serde(default = "default_publish_config_path")]
1361    pub publish_config: String,
1362
1363    /// Env var name holding the bearer secret for this tenant, resolved
1364    /// at deploy time and set as `MESOFACT_MIRROR_KEY` on the receiver
1365    /// process. `None` → open receiver (no bearer check).
1366    #[ts(optional = nullable)]
1367    pub mirror_key_env: Option<String>,
1368
1369    /// Environment variables set on the revalidate process by kamaji.
1370    /// Keys are the canonical env var names (`MESOFACT_S3_ACCESS_KEY_ID`,
1371    /// `MESOFACT_S3_SECRET_ACCESS_KEY`, `CLOUDFLARE_API_TOKEN`,
1372    /// `MESOFACT_MIRROR_KEY`). Values are resolved from the keystore at
1373    /// deploy time — the node never sees slot names.
1374    #[serde(default)]
1375    pub env: std::collections::BTreeMap<String, String>,
1376
1377    /// Feed-fetch tier (R330-F31) — the almanac feeds whose artifacts must be
1378    /// refreshed **on the node** for a poke to have anything new to render.
1379    ///
1380    /// Empty → no fetcher; the receiver re-renders whatever data the bundle was
1381    /// built with (correct for a site whose data only changes at build time,
1382    /// silently stale for one whose data is a live feed). Non-empty → kamaji
1383    /// forks a third resident process, the `almanac-feed` fetcher, next to the
1384    /// receiver — resolved from the bundle's `bins/<triple>/almanac-feed` when
1385    /// it carries one, else from [`feed_runtime`](Self::feed_runtime).
1386    #[serde(default)]
1387    pub feeds: Vec<AlmanacFeed>,
1388
1389    /// Runtime ref the `almanac-feed` fetcher resolves from the node's shared
1390    /// runtime-asset cache when the bundle carries no `bins/` (R746-T3), e.g.
1391    /// `"almanac-feed/0.8.22"`.
1392    ///
1393    /// This is what lets a **vanilla** bundle have a feed tier at all. A
1394    /// self-contained bundle stages the fetcher into `bins/` and stays closed
1395    /// over it; a vanilla bundle carries no binaries by construction, so the
1396    /// fetcher has to be a node-level asset for the same reason `serve` is —
1397    /// otherwise a templates-only sync would still need a cross-built musl
1398    /// binary sitting on the syncing machine's disk.
1399    ///
1400    /// `None` with `feeds` non-empty and no sidecar in the bundle is a deploy
1401    /// failure, named at the node. It is not a silent skip: "the site serves
1402    /// but its data is frozen" is the exact state R330-F31 exists to make
1403    /// observable.
1404    #[serde(default)]
1405    #[ts(optional = nullable)]
1406    pub feed_runtime: Option<String>,
1407
1408    /// Seconds between feed-fetch ticks. Ignored when `feeds` is empty.
1409    ///
1410    /// This is the site's freshness bound: a release lands, and the next tick
1411    /// refreshes + pokes. `FeedRunner`'s change-suppression means an idle tick
1412    /// costs one conditional fetch, so a short interval is affordable.
1413    #[serde(default = "default_feed_interval_secs")]
1414    pub feed_interval_secs: u64,
1415
1416    /// Workspace-relative path of the component whose build produced this
1417    /// bundle, e.g. `app/yah/web/marketing` (R330-F31).
1418    ///
1419    /// Reconciles two roots for one file: a feed declares `emit.artifact`
1420    /// workspace-relative (that is where it is authored), while the route
1421    /// declares the same file project-relative (that is what the bundle
1422    /// carries). The fetcher strips this prefix to get from one to the other.
1423    /// `None` → the two already coincide.
1424    #[serde(default)]
1425    #[ts(optional = nullable)]
1426    pub feed_project_prefix: Option<String>,
1427
1428    /// Secrets materialized to fixed host paths before the receiver process
1429    /// forks (R876-B16). Bundle-tier deploys are native (no container
1430    /// namespace, see `deploy_non_container`), so only `SecretTarget::File`
1431    /// is meaningful here — there is no env-var form, because the whole point
1432    /// is that the plaintext credential value never becomes part of this
1433    /// spec or of kamaji's in-memory/on-disk deploy record. Replaces the
1434    /// prior design where the CLI resolved R2/Cloudflare credentials itself
1435    /// and inlined them as literal values into `env` above.
1436    #[serde(default)]
1437    pub secrets: Vec<SecretMount>,
1438}
1439
1440/// One almanac feed handed to the on-node fetcher (R330-F31).
1441///
1442/// The definition travels **by value**, not by path: the node has no copy of
1443/// the camp's `.yah/almanac/` tree, and staging one into the content-addressed
1444/// bundle would put a mutable-by-nature config inside an immutable artifact.
1445/// The fetcher parses `config_toml` with the same `FeedConfig` type that reads
1446/// the file at the source, so there is one schema and no drift.
1447#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1448#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1449pub struct AlmanacFeed {
1450    /// Feed name — the `.yah/almanac/<name>.toml` stem. Logs/diagnostics only;
1451    /// `config_toml` is authoritative.
1452    pub name: String,
1453
1454    /// Verbatim contents of the feed definition TOML.
1455    pub config_toml: String,
1456}
1457
1458fn default_publish_config_path() -> String {
1459    "mesofact.config.toml".to_string()
1460}
1461
1462/// Five minutes: fast enough that a release is live on yah.dev before anyone
1463/// goes looking, slow enough to be invisible against a source API's rate limit.
1464fn default_feed_interval_secs() -> u64 {
1465    300
1466}
1467
1468/// Serve-time reference to a published W272 bundle (R599-F4) — the
1469/// `{bundle_digest, runtime, lifecycle}` triple a `mesofact-static` workload
1470/// carries when kamaji, not the build reconciler, serves it.
1471///
1472/// @yah:ticket(R870-B6, "Bundle origin is node-wide, so a second tenant's bundle can never be materialized")
1473/// @yah:status(review)
1474/// @yah:at(2026-09-09T03:23:08Z)
1475/// @yah:assignee(agent:bundle-anthropic-ashguard)
1476/// @yah:parent(R870)
1477/// @yah:gotcha("REPORTED BY THE NOISETABLE CAMP, which is R870's second tenant made concrete. Its bundle deploy gets ALL THE WAY to admission and then fails: 'materialize bundle f75c6940...: missing blob manifests/f75c6940... for path \"manifest.toml\"'. Ident and placement were correct — the CLI printed 'noisetable admitted by us-east-001 (http://100.64.0.3:7443)' — so this is not a discovery or placement bug. The node accepted a digest it has no way to fetch.")
1478/// @yah:gotcha("ROOT CAUSE, read not guessed: MesofactServeBundle is { digest, runtime, lifecycle, port, env } and carries NEITHER a bucket NOR an origin (oss/yah-base/crates/workload-spec/src/lib.rs). BundleSlot::serve_bundle constructs it from the slot and drops the slot's `bucket` on the floor (oss/yubaba/crates/cloud/src/reconciler/mesofact_bundle.rs). kamaji then fetches from the NODE-WIDE KAMAJI_BUNDLE_ORIGIN (oss/kamaji/crates/kamaji-bin/src/main.rs:86). So the publish side is per-service and the fetch side is per-node, and they only agree while the fleet has exactly one tenant.")
1479/// @yah:gotcha("MEASURED, WITH A NEGATIVE CONTROL — all four taken 2026-09-08. (1) our manifest blob IS in the noisetable-marketing bucket: `yah cloud bucket ls --bucket noisetable-marketing` lists manifests/f75c6940.... (2) https://cdn.noisetable.com/manifests/f75c6940... = 200. (3) https://cdn.yah.dev/manifests/f75c6940... = 404 — the node's origin, where it looked. (4) POSITIVE CONTROL, so 404 is not just a broken URL shape: https://cdn.yah.dev/manifests/0279f43e... (a real yah bundle) = 200. yah-dev holds 49 manifests/ objects; noisetable-marketing holds 1, and it is ours.")
1480/// @yah:gotcha("THE RUNTIME ASSET HALF FAILS IDENTICALLY AND IS A SECOND BLOCKER, not the same one twice — fixing only the manifest fetch leaves the deploy failing one step later. runtimes/mesofact/0.8.32/x86_64-unknown-linux-musl.toml exists in yah-dev and in NO other bucket, and the apply printed 'no mesofact/0.8.32 runtime asset is published yet' against the tenant's own bucket. Whatever carries the origin must cover manifests, blobs AND the runtime-asset lookup.")
1481/// @yah:gotcha("THE WORKAROUND WAS CONSIDERED AND REFUSED BY THE OPERATOR, recorded so it is not re-proposed as a shortcut: point the tenant's bundle slot at bucket = \"yah-dev\" so it lands in the store the node already reads. It works today and is one word. It also puts a tenant's build output in yah's bucket, which makes the tenant boundary fictional for bundle content in exactly the way reusing the account-scoped ACME token would have for DNS — the same call R870-F1 already made the other way when it minted a noisetable-only token instead. Noisetable's mirror still declares bucket = \"noisetable-marketing\" and is correct as written; it is yah that cannot consume it.")
1482/// @yah:next("THE PRECEDENT IS IN THE SAME STRUCT AND SHOULD BE COPIED RATHER THAN REDESIGNED. MesofactServeBundle::port's own doc records that it 'used to mean fall back to kamaji's node-wide default (KAMAJI_BUNDLE_PORT, else 8080), which was a single node-wide slot wearing the word default — correct only while a node hosted one'. R844-F2 fixed that axis by making the value travel with the workload and letting the node-wide setting be a fallback. KAMAJI_BUNDLE_ORIGIN is the same defect on the store axis, unfixed. Do the same thing: an Option<String> origin (or bucket) on MesofactServeBundle, threaded from BundleSlot::serve_bundle, with KAMAJI_BUNDLE_ORIGIN demoted to the fallback so every existing single-tenant deploy is byte-identical.")
1483/// @yah:next("SCOPE IS THREE EDIT SITES, all named: (a) MesofactServeBundle gains the field (oss/yah-base/crates/workload-spec/src/lib.rs) — wire type, so schema + TS export + drift test move with it; (b) BundleSlot::serve_bundle stops discarding the slot's `bucket` (oss/yubaba/crates/cloud/src/reconciler/mesofact_bundle.rs); (c) kamaji resolves the per-workload origin ahead of KAMAJI_BUNDLE_ORIGIN for manifest, blob AND runtime-asset fetches (oss/kamaji/crates/kamaji-bin). A bucket name is not directly fetchable, so decide deliberately whether the field carries a public origin URL or a bucket that the node maps to one — the tenant's blobs are reachable at https://cdn.noisetable.com today, so an origin URL needs no new credential on the node and keeps kamaji credential-free, which is the property worth preserving.")
1484/// @yah:next("DO NOT SOLVE THIS BY GIVING KAMAJI R2 CREDENTIALS PER TENANT. It fetches over plain HTTP from a public origin today and holds no bucket credential at all; adding one would put a tenant-scoped R2 key on three public boxes for content that is already world-readable. The W295 warning about the account-wide R2 write pair is the adjacent precedent.")
1485/// @yah:verify("End to end, from the noisetable camp: `yah cloud apply --service noisetable-marketing --env cloud` with bucket = \"noisetable-marketing\" unchanged reaches Running rather than Failed on us-east-001, and https://noisetable.com/ serves the site instead of the R870-F5 holding page.")
1486/// @yah:verify("Regression, single-tenant: yah-marketing's own deploy is unchanged with no edit to its mirror — it declares bucket = \"yah-dev\" and the node's KAMAJI_BUNDLE_ORIGIN already points there, so the fallback path must produce a byte-identical spec. Assert it at the wire type, not just by observing yah.dev stay up.")
1487/// @yah:verify("The runtime-asset half specifically: the deploy must NOT print 'no mesofact/<ver> runtime asset is published yet' when the tenant's own origin serves one, and must still resolve the stock runtime for a tenant that publishes none.")
1488/// @yah:handoff("FIXED, AND THE FIX IS THE PRECEDENT THE TICKET NAMED. `MesofactServeBundle` gains `origin: Option<String>` — the public HTTPS origin serving the bucket the workload was published to — appended after `env` (postcard is positional; no skip_serializing_if), `#[serde(default)]` + `#[ts(optional = nullable)]`. `None` means the node's own `KAMAJI_BUNDLE_ORIGIN`, so every existing single-tenant deploy is byte-identical and no yah-owned mirror needs an edit.")
1489/// @yah:handoff("A URL, NOT A BUCKET, decided rather than defaulted. A bucket name is not fetchable: resolving one would need a node-side bucket→origin table (the same node-wide defect one level down) or R2 credentials on every box, for content that is world-readable and against a node that deliberately holds none. `providers.static.asset_origin` already makes this call one tier over. Declared rather than derived from `zone`, because `https://cdn.<zone>` is a guess about an R2 custom-domain binding that may not exist.")
1490/// @yah:handoff("PUBLISHER SIDE (oss/yubaba/crates/cloud/src/reconciler/mesofact_bundle.rs): `BundleSlot` gains `origin`, `origin` joins ALLOWED_SLOT_KEYS, and `serve_bundle` stops dropping the store on the floor. Parsed strictly — a schemeless value (`cdn.noisetable.com`, or a bucket name) is REFUSED at parse with the reason, because its only consumer joins keys onto it as path segments, so accepting one would deploy clean and fail on the node at materialize time. A trailing slash is trimmed once, here.")
1491/// @yah:handoff("NODE SIDE (oss/kamaji/crates/kamaji-bin/src/server.rs): new `BundleBackend::store_for(origin)`. `None` returns the node's store itself, unwrapped. `Some` builds an `HttpReadOnlyObjectStore` (on the blocking pool — a reqwest blocking client panics if constructed inside a tokio runtime) and puts it in FRONT of the node's, not in place of it. All three fetch sites take it: the manifest+blob materialize, the serve runtime-asset resolve, and the feed-tier fetcher (threaded through `fork_revalidate_receiver` → `fork_feed_tier`).")
1492/// @yah:handoff("THE READ-THROUGH IS WHAT MAKES THE RUNTIME-ASSET HALF WORK, and it is why this is a chain and not a swap. The fleet publishes the stock `mesofact/<ver>` serve runtime once, to its own origin; a tenant has no reason to mirror ~70MB of it. Tenant origin answers for the tenant's bundle, node origin answers for the stock runtime, and which is which is not knowable per key. New `yah_object_store::FallbackObjectStore` (oss/yah-base/crates/object-store/src/fallback.rs) does exactly that and nothing else: reads chain, writes are REFUSED (a chain has no principled answer to which member a `put` lands in — guessing would put one tenant's bytes in another's store, the boundary this ticket exists to draw). Safe because every key on this path is content-addressed and blake3-verified after the fetch, so a fallback can return the wrong store's bytes only if they are the right bytes. A primary ERROR is not laundered into a miss: an unreachable tenant origin fails loudly instead of quietly serving yah's copy.")
1493/// @yah:handoff("APPLY-TIME NOTE CORRECTED (app/yah/cli/src/cloud.rs): 'no mesofact/<ver> runtime asset is published yet … or the node will have nothing to fork' was true for a single-tenant fleet and is now false — for a tenant bucket it is the NORMAL state. It names the bucket it checked and says the node reads through to its own origin for the stock one.")
1494/// @yah:handoff("REGENERATED: `cargo run -p xtask -- emit-schemas` + export-ts. `.yah/schema/workload.toml.schema.json` and `packages/yah/workload-spec/index.ts` carry the new field. (`.yah/schema/machine.toml.schema.json` also moved — it was already dirty in this shared tree when this session started, not something this ticket authored.)")
1495/// @yah:verify("cargo test -p yah-object-store -p yah-workload-spec (oss/yah-base): 101 pass, 0 fail — includes 7 new FallbackObjectStore tests (primary wins, clean miss falls through, head chains, a primary error is NOT a miss, locate names both members, writes refused, list_prefix unions).")
1496/// @yah:verify("cargo test -p yah-cloud --lib (oss/yubaba): 1134 pass, 0 fail — 4 new: a declared origin reaches the serve_bundle; NO declared origin leaves the node-wide one in charge (the single-tenant regression, asserted at the wire type, not by watching yah.dev stay up); trailing slash trimmed; a schemeless origin is refused naming the shape.")
1497/// @yah:verify("cargo test -p kamaji-bin --features bundle-serving --lib (oss/kamaji): 270 pass, 0 fail (was 267). THE END-TO-END ONE IS `a_second_tenants_bundle_materializes_from_its_own_origin`: the tenant's bundle is published to a store served over a REAL loopback HTTP/1.1 origin (not a second injected ObjectStore — a test that handed the backend an in-memory store would pass with the URL ignored, which is the bug), the node's store holds ONLY the fleet's stock runtime, and the deploy comes up: tenant content from the tenant's origin, stock runtime read through to the node's. `without_an_origin_a_tenant_bundle_fails_after_a_clean_admission` is its negative control — the reported bug verbatim. `an_undeclared_origin_resolves_to_the_node_store_unchanged` pins Arc::ptr_eq on the None path so the single-tenant case pays nothing for the mechanism.")
1498/// @yah:verify("cargo check -p kamaji-bin with NO features (the bundle-serving-off build) and cargo test -p kamaji-proto (33 pass, the postcard round-trip): both clean.")
1499/// @yah:verify("cargo check --workspace: exit 0. Every remaining warning is pre-existing in files this ticket did not touch (board.rs, runner/sessions.rs, mesofact_static.rs, agent-tools/shared_pool.rs).")
1500/// @yah:verify("NOT VERIFIED LIVE, AND CANNOT BE FROM HERE: the ticket's first verify is a `yah cloud apply` from the noisetable camp reaching Running. That needs the fleet to be RUNNING this kamaji — us-east-001 still runs the pre-change binary, which ignores the field. Filed as R870-T9 (roll kamaji, then add the one `origin` line to noisetable's mirror), which depends_on this ticket.")
1501/// @yah:gotcha("ROLL ORDER DOES NOT BITE, checked rather than assumed: `origin` reaches yubaba as JSON and `MesofactServeBundle` carries no `deny_unknown_fields`, so a mirror declaring an origin against an un-rolled node has the field ignored and fails exactly as it does today — not a parse error. Only kamaji has to move.")
1502/// @yah:gotcha("THE NODE CACHE IS SHARED ACROSS ORIGINS AND THAT IS FINE FOR BUNDLES, LESS OBVIOUSLY SO FOR RUNTIME ASSETS. Materialized bundles are keyed by blake3 digest, so two tenants cannot collide. Runtime assets are keyed by `<runtime>/<version>/<triple>` — so two tenants publishing DIFFERENT bytes under the same `mesofact/<ver>` would share one node cache entry, first writer wins. Not reachable today (nobody but the fleet publishes a mesofact runtime, and `publish_runtime_asset`'s own docs already say publish a new version rather than repointing one), but it is the next thing this axis will need if a tenant ever ships its own build of a stock runtime name.")
1503#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1504#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1505pub struct MesofactServeBundle {
1506    /// BLAKE3 digest of the published bundle manifest — the content-address
1507    /// kamaji materializes from the bundle store (`yah_mesofact_bundle`,
1508    /// R599-F1). Same 64-hex shape the bundle crate's `BundleHash` validates.
1509    pub digest: BlakeHash,
1510
1511    /// Runtime that serves the bundle: `"self"` (bundle ships its own
1512    /// `bins/<triple>/serve`) or `"mesofact/<version>"` (resolve the stock
1513    /// serve runtime asset from the node cache). Wire-mirrors
1514    /// `yah_mesofact_bundle::BundleRuntime`; kept as a plain `String` here so
1515    /// workload-spec stays free of the bundle crate and its non-TS/schema
1516    /// newtypes.
1517    pub runtime: String,
1518
1519    /// How kamaji supervises the served bundle. Default: keep-alive.
1520    #[serde(default)]
1521    pub lifecycle: BundleLifecycle,
1522
1523    /// Port the served bundle listens on (R599-F12). This is the bundle-tier
1524    /// analogue of a container's `expose.mesh.ports`: the *declared* serving
1525    /// port, which a proxy pairs with the workload's mesh IP to get a dialable
1526    /// address.
1527    ///
1528    /// `None` → **the supervisor allocates one** (R844-F2), and reports the
1529    /// port it bound back to yubaba on the next workload listing, where it
1530    /// lands in the service record an ingress upstream is rendered from. This
1531    /// is the normal case: a mirror should not have to name a port at all.
1532    ///
1533    /// It used to mean "fall back to kamaji's node-wide default
1534    /// (`KAMAJI_BUNDLE_PORT`, else 8080)", which was a single node-wide slot
1535    /// wearing the word *default* — correct only while a node hosted one
1536    /// bundle, and a silent collision for the second. R599-F12 added this field
1537    /// so a workload could opt out of that; R844-F2 removed the default itself,
1538    /// so opting out is no longer something anyone has to remember to do.
1539    ///
1540    /// Declaring a port still pins it exactly, for a workload that must be
1541    /// reachable at a known number.
1542    ///
1543    /// No `skip_serializing_if` — see `serve_bundle`'s note: the postcard wire
1544    /// codec is positional, so an omitted byte shifts every later field.
1545    #[serde(default)]
1546    #[ts(optional = nullable)]
1547    pub port: Option<u16>,
1548
1549    /// Environment the serve process is forked with (R556-T12) — already
1550    /// **resolved** values, `NAME → value`.
1551    ///
1552    /// This is what makes an SSR route that reads a private source deployable
1553    /// at all: `mesofact serve` resolves a source's credentials from its own
1554    /// process environment at request time, and before this field the static /
1555    /// SSR serve process was forked with `env: vec![]` while only the
1556    /// `revalidate_receiver` sub-slot carried any. A declared-authed SSR site
1557    /// therefore deployed clean and failed *per request* on the node.
1558    ///
1559    /// Resolution happens deploy-side, exactly like
1560    /// [`MesofactRevalidateReceiver::env`]: the mirror declares source URIs
1561    /// (`vault:<slot>` / `env:<VAR>`), `yah cloud apply` resolves them against
1562    /// the operator's vault, and the node receives values. Keystore slot names
1563    /// never cross the wire.
1564    ///
1565    /// Appended **after** `port` — see `port`'s note: the postcard wire codec
1566    /// is positional, so a new field goes last and never carries
1567    /// `skip_serializing_if`.
1568    #[serde(default)]
1569    pub env: BTreeMap<String, String>,
1570
1571    /// Public HTTPS origin serving the bundle store this workload was published
1572    /// to (R870-B6) — e.g. `"https://cdn.noisetable.com"`, the R2 custom domain
1573    /// bound to the tenant's own bucket.
1574    ///
1575    /// `None` → the node's own `KAMAJI_BUNDLE_ORIGIN`, which is the shape every
1576    /// yah-owned mirror uses and the reason this is optional rather than
1577    /// required.
1578    ///
1579    /// # Why the store has to travel with the workload
1580    ///
1581    /// This is `port`'s defect one axis over, and it was found the same way: by
1582    /// a second tenant. Publishing is per-service — `providers.bundle.bucket`
1583    /// names the tenant's own R2 bucket — while fetching was per-*node*, from
1584    /// the single `KAMAJI_BUNDLE_ORIGIN` the systemd drop-in sets. Those two
1585    /// agree only while the fleet hosts one tenant. The noisetable deploy got
1586    /// all the way to admission and then failed with `missing blob
1587    /// manifests/<digest>`: its manifest was in `noisetable-marketing`, and the
1588    /// node looked in `cdn.yah.dev`.
1589    ///
1590    /// Pointing the tenant's slot at yah's bucket would also have worked, and
1591    /// was refused — a tenant's build output in yah's store makes the tenant
1592    /// boundary fictional for bundle content.
1593    ///
1594    /// # Why a URL and not the bucket name
1595    ///
1596    /// A bucket name is not fetchable. Resolving one would need either a
1597    /// node-side bucket→origin map (another node-wide table, the same defect
1598    /// again) or R2 credentials on every box — for content that is already
1599    /// world-readable, and against a node that deliberately holds no bucket
1600    /// credential at all (see `HttpReadOnlyObjectStore`). Integrity comes from
1601    /// the content address, not the transport, so an unauthenticated origin is
1602    /// exactly as safe here as an authenticated one.
1603    ///
1604    /// The declared origin does not *replace* the node's: kamaji reads through
1605    /// to `KAMAJI_BUNDLE_ORIGIN` on a miss, which is what lets a tenant fetch
1606    /// the stock `mesofact/<ver>` serve runtime the fleet publishes once
1607    /// without republishing ~70MB into their own bucket.
1608    ///
1609    /// Appended **after** `env` — see `port`'s note on the positional codec.
1610    #[serde(default)]
1611    #[ts(optional = nullable)]
1612    pub origin: Option<String>,
1613}
1614
1615/// Lifecycle mode for a served bundle (W272 §3).
1616#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1617#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1618#[serde(rename_all = "snake_case")]
1619pub enum BundleLifecycle {
1620    /// Fork at deploy, keep resident, restart per policy — today's server
1621    /// archetype. Memory is resident for the workload's lifetime.
1622    KeepAlive,
1623
1624    /// Kamaji owns the listen socket, forks the runtime on the first connection
1625    /// (fd-passing), and reaps it after `idle_ttl` with zero connections — the
1626    /// "serverless" tier (zero memory when idle). The JIT fork/reap mechanics
1627    /// land in R599-F6; this variant only declares the intent + budget.
1628    OnDemand {
1629        /// Idle time with no live connections before kamaji reaps the process.
1630        idle_ttl: Millis,
1631    },
1632}
1633
1634impl Default for BundleLifecycle {
1635    /// Keep-alive — the resident server archetype — matches the current
1636    /// deploy-and-supervise default.
1637    fn default() -> Self {
1638        BundleLifecycle::KeepAlive
1639    }
1640}
1641
1642// ── Per-tenant passway (R852-F1 / W267 §Free-tier ingress at 10k domains) ─────
1643
1644/// Container-side / node-side path a per-tenant passway reads its PEM chain
1645/// from, when the declaration does not name one. Deliberately per-domain: two
1646/// tenants sharing a path is two tenants sharing a certificate.
1647pub const DEFAULT_TENANT_PASSWAY_CERT_DIR: &str = "/run/yah/passway/tenants";
1648
1649/// Node path of the passway binary a per-tenant passway forks, when the
1650/// declaration does not name one. Matches the path the passway image installs
1651/// to, which is what `local-driver`'s node-appliance spec also runs.
1652pub const DEFAULT_PASSWAY_COMMAND: &str = "/usr/local/bin/passway";
1653
1654/// One **cold, per-tenant passway** — a TLS terminator that serves exactly one
1655/// custom tenant domain, forked on demand by kamaji's JIT tier
1656/// (`kamaji::jit::JitRuntime`) and self-reaped when idle.
1657///
1658/// This is the declaration W267's free-tier ingress design was missing. R779
1659/// shipped every mechanism — the SNI demux that splices `:443` by ClientHello
1660/// without terminating TLS, passway's fd-3 adoption + idle self-reap, the
1661/// R2-backed cert store off raft, the per-domain DNS-01 issuer — but nothing
1662/// could *say* "there is a passway for `shop.tenant.io` at `127.0.0.1:8443`",
1663/// because kamaji's on-demand tier was reachable only through
1664/// [`MesofactServeBundle`], a mesofact-specific carrier.
1665///
1666/// ## Why a variant and not an annotated [`Workload::Container`]
1667///
1668/// The W267 **node appliance** is a container (see `Workload::Container`'s doc
1669/// comment): one resident passway per public-IP node, image-pulled, supervised
1670/// like anything else, so an archetype + annotation expressed it with no wire
1671/// change. A per-tenant passway is the opposite on every axis that decides the
1672/// question. It is **native-forked, not containerized** — kamaji's JIT tier
1673/// hands the child an inherited fd, and that path (`kamaji::jit`) forks a
1674/// process, not a container. It is **zero-resident**, so the deploy Ack means
1675/// "socket bound and armed", not "a process is running". And there are ten
1676/// thousand of them, generated from the enrollment set rather than written by
1677/// hand. Squeezing that into `Container` would mean a spec whose image is a
1678/// lie and whose supervision arm is chosen by an annotation nobody reading the
1679/// type would look for.
1680///
1681/// ## The bind string is the fd-table key
1682///
1683/// [`listen`](Self::listen) is **declared, never allocated.** It is the address
1684/// the tenant's enrollment record already names as its demux backend
1685/// (`yubaba::cert_store::Enrollment::tls_backend`), so kamaji must bind exactly
1686/// it — an allocator picking a port here would arm a socket the demux never
1687/// routes to, and the tenant's domain would resolve, handshake, and hang.
1688///
1689/// The same string is also passway's `PASSWAY_LISTEN`, and it must match **byte
1690/// for byte**: passway's socket-activation path (on by default) *panics* rather
1691/// than binding fresh when `LISTEN_FDS` is set and the seed does not take, so a
1692/// drifted string is a workload that forks and immediately dies on every
1693/// connection. [`jit_spec`](Self::jit_spec) is the reason that cannot happen —
1694/// it renders `PASSWAY_LISTEN` from this one field rather than asking a caller
1695/// to restate it, the same "derive, never re-state" rule
1696/// `yubaba::domain_admin` applies to the DNS-01 record name.
1697#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1698#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1699// R896-F2: `deny_unknown_fields` was REMOVED here. It used to be inert on the
1700// kamaji wire — R658-B1 said so explicitly, "only constrains TOML/JSON", which
1701// was true while that wire was positional postcard with no field names to
1702// reject. It is not true any more: `Workload::TenantPassway` now crosses the UDS
1703// as name-keyed JSON (`kamaji_proto::tolerant`), so this attribute would refuse
1704// a spec from a peer one field ahead — defeating the whole envelope on exactly
1705// the workload kind `yubaba::tenant_passway` reconciles most often, and turning
1706// an additive field change back into a paired fleet ship.
1707//
1708// `BuildConfig` keeps its copy: that one is authoring-only and the loudness it
1709// buys (a misplaced `routes` key is a parse error naming the key, not a silently
1710// dropped one) is the reason R658-B1 added it.
1711pub struct TenantPasswayWorkload {
1712    /// The single custom domain this passway terminates TLS for — the SNI the
1713    /// demux matched to route here, and the hostname
1714    /// [`jit_spec`](Self::jit_spec) keys the rendered `PASSWAY_UPSTREAMS`
1715    /// entries on.
1716    pub domain: String,
1717
1718    /// `host:port` kamaji binds and holds in custody, and the address the demux
1719    /// splices this domain's bytes to. See the type doc: declared, not
1720    /// allocated, and byte-identical to `PASSWAY_LISTEN`.
1721    pub listen: String,
1722
1723    /// Plaintext backends passway forwards to after terminating TLS, as bare
1724    /// `host:port`. Rendered as `<domain>=<addr>` entries — repeated entries
1725    /// load-balance (R844-F3), which is why this is a list and not one address.
1726    ///
1727    /// Empty is legal and means "no backend yet": passway answers 503 rather
1728    /// than refusing to start, so a domain can be enrolled and issued before
1729    /// the tenant's app is placed.
1730    #[serde(default)]
1731    pub upstreams: Vec<String>,
1732
1733    /// Where the per-domain PEM pair the R2 cert store holds
1734    /// (`yubaba::cert_store`) has been materialized on the node.
1735    pub tls: TenantPasswayTls,
1736
1737    /// Idle time with no in-flight request before the process exits, leaving
1738    /// kamaji holding the socket and re-forking on the next connection.
1739    ///
1740    /// `None` means **never reap** — a long-running per-tenant passway. That is
1741    /// the shape the free tier exists to avoid (10k resident processes is the
1742    /// number W267 §"Scaling B to a free tier" set out to dissolve), and it also
1743    /// re-opens a rotation gap a cold passway does not have: a cold one re-reads
1744    /// [`tls`](Self::tls) at every cold start, while a resident one holds the
1745    /// chain it started with. Sub-second values round **up** to one second, and
1746    /// zero is not "never" — see [`idle_ttl_secs`](Self::idle_ttl_secs).
1747    ///
1748    /// No `skip_serializing_if`: this rides the positional postcard wire.
1749    #[serde(default)]
1750    #[ts(optional = nullable)]
1751    pub idle_ttl: Option<Millis>,
1752
1753    /// Node path of the passway binary to fork. `None` →
1754    /// [`DEFAULT_PASSWAY_COMMAND`].
1755    #[serde(default)]
1756    #[ts(optional = nullable)]
1757    pub command: Option<String>,
1758
1759    /// Extra environment for the forked process — the ACME/auth/health knobs
1760    /// passway reads that this type has no opinion about.
1761    ///
1762    /// **Cannot override the derived keys.** [`jit_spec`](Self::jit_spec)
1763    /// applies this map *first* and the derived
1764    /// (`PASSWAY_LISTEN`/`LISTEN_FDS`/`PASSWAY_IDLE_TTL_SECS`/
1765    /// `PASSWAY_UPSTREAMS`/`PASSWAY_TLS_*`) keys last, so an escape hatch cannot
1766    /// silently break the fd handoff — which would surface as a domain that
1767    /// hangs, not as a config error.
1768    #[serde(default)]
1769    pub env: BTreeMap<String, String>,
1770
1771    /// Discover the backends from yubaba's service records instead of the
1772    /// static [`upstreams`](Self::upstreams) list (R910-F2).
1773    ///
1774    /// `None` is the free tier's shape: the door knows where TLS terminates
1775    /// and nothing about where the tenant's app runs, so it answers 503 until
1776    /// something fills `upstreams`. `Some` is a door that fronts a workload
1777    /// this fleet places, which is what a tunnel-fronted door is: the
1778    /// placement moves, so a pinned address list would go stale.
1779    ///
1780    /// A field rather than three `env` entries because `PASSWAY_UPSTREAM_SOURCE`
1781    /// is a derived key [`jit_spec`](Self::jit_spec) writes last — the escape
1782    /// hatch cannot change it, by design.
1783    #[serde(default)]
1784    #[ts(optional = nullable)]
1785    pub discover: Option<TenantPasswayDiscovery>,
1786}
1787
1788/// Where a per-tenant passway polls for its backends (R910-F2): the yubaba
1789/// nodes holding the fronted workload's service records, and that workload's
1790/// ident.
1791#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1792#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1793pub struct TenantPasswayDiscovery {
1794    /// Base URLs of the yubabas to poll, e.g. `http://100.64.0.10:7443`. Every
1795    /// entry is polled and the records unioned (R844-F23).
1796    pub urls: Vec<String>,
1797    /// The fronted workload's ident, as its service records carry it.
1798    pub ident: String,
1799}
1800
1801/// Node-side paths of one tenant's materialized certificate pair.
1802///
1803/// Paths rather than [`SecretMount`]s: the JIT tier forks a *process*, not a
1804/// container, so there is no mount namespace to project a secret into — the
1805/// files are read from the node filesystem by the forked passway. Whoever
1806/// materializes them out of `yubaba::cert_store` owns their permissions.
1807#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
1808#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1809// R896-F2: removed for the same reason as [`TenantPasswayWorkload`] above —
1810// this type rides inside it across the now name-keyed kamaji wire.
1811pub struct TenantPasswayTls {
1812    /// PEM chain path (`PASSWAY_TLS_CERT`).
1813    pub cert: String,
1814    /// PEM private-key path (`PASSWAY_TLS_KEY`).
1815    pub key: String,
1816}
1817
1818impl TenantPasswayTls {
1819    /// The conventional per-domain pair under
1820    /// [`DEFAULT_TENANT_PASSWAY_CERT_DIR`]: `<dir>/<domain>/{tls.crt,tls.key}`.
1821    pub fn for_domain(domain: &str) -> Self {
1822        Self {
1823            cert: format!("{DEFAULT_TENANT_PASSWAY_CERT_DIR}/{domain}/tls.crt"),
1824            key: format!("{DEFAULT_TENANT_PASSWAY_CERT_DIR}/{domain}/tls.key"),
1825        }
1826    }
1827}
1828
1829impl TenantPasswayWorkload {
1830    /// A cold per-tenant passway for `domain` on `listen`, with the
1831    /// conventional cert paths and a one-minute idle TTL.
1832    pub fn cold(domain: impl Into<String>, listen: impl Into<String>) -> Self {
1833        let domain = domain.into();
1834        Self {
1835            tls: TenantPasswayTls::for_domain(&domain),
1836            domain,
1837            listen: listen.into(),
1838            upstreams: Vec::new(),
1839            idle_ttl: Some(Millis::from_secs(60)),
1840            command: None,
1841            env: BTreeMap::new(),
1842            discover: None,
1843        }
1844    }
1845
1846    /// Point this passway at `addrs` (bare `host:port`).
1847    pub fn with_upstreams<S: Into<String>>(mut self, addrs: impl IntoIterator<Item = S>) -> Self {
1848        self.upstreams = addrs.into_iter().map(Into::into).collect();
1849        self
1850    }
1851
1852    /// The passway binary this workload forks.
1853    pub fn command_path(&self) -> &str {
1854        self.command.as_deref().unwrap_or(DEFAULT_PASSWAY_COMMAND)
1855    }
1856
1857    /// `PASSWAY_IDLE_TTL_SECS`, or `None` for "never reap".
1858    ///
1859    /// Rounds **up** to one second, for the reason the bundle JIT path rounds
1860    /// up: passway reads this as an integer number of seconds, so a 500 ms TTL
1861    /// would truncate to `0` — and `0` there does not mean "reap immediately",
1862    /// it means the reap never fires. Rounding down would turn a declared cold
1863    /// workload resident without any error to read.
1864    pub fn idle_ttl_secs(&self) -> Option<u64> {
1865        self.idle_ttl.map(|t| t.as_ms().div_ceil(1000).max(1))
1866    }
1867
1868    /// `PASSWAY_UPSTREAMS` for this domain: `<domain>=<addr>` per backend,
1869    /// comma-joined. Empty when no backend is declared, which passway reads as
1870    /// "fail ready with 503".
1871    pub fn passway_upstreams(&self) -> String {
1872        self.upstreams
1873            .iter()
1874            .map(|a| format!("{}={}", self.domain, a))
1875            .collect::<Vec<_>>()
1876            .join(",")
1877    }
1878
1879    /// The [`WorkloadSpec`] kamaji's JIT runtime forks for this tenant.
1880    ///
1881    /// `id` is the kamaji workload identity (also the mesh ident and the
1882    /// custodian key). Everything else is derived from `self` — see the type
1883    /// doc for why no caller is allowed to restate `PASSWAY_LISTEN`.
1884    ///
1885    /// - `entrypoint` is the passway binary; `command` is empty, because passway
1886    ///   is configured entirely by environment (it has no config-file parser).
1887    /// - `restart_policy` is [`RestartPolicy::Never`]: the JIT supervisor owns
1888    ///   re-forking on the next connection, and an idle self-reap is an expected
1889    ///   exit, not a crash.
1890    /// - `expose.mesh.ports` is parsed back off [`listen`](Self::listen) rather
1891    ///   than carried separately, so the declared port cannot drift from the
1892    ///   bound one.
1893    /// - `LISTEN_FDS=1` is set here as well as by the JIT supervisor. That is
1894    ///   deliberate redundancy, not a duplicate: it makes the spec truthful
1895    ///   about how this process expects to get its socket to anyone reading the
1896    ///   spec alone, and setting it twice to the same value is inert.
1897    pub fn jit_spec(&self, id: &str) -> WorkloadSpec {
1898        let mut env: BTreeMap<String, String> = self.env.clone();
1899        // Derived keys go last: an `env` escape hatch must not be able to break
1900        // the fd handoff (see the field doc).
1901        env.insert("PASSWAY_LISTEN".into(), self.listen.clone());
1902        env.insert("LISTEN_FDS".into(), "1".into());
1903        env.insert("PASSWAY_TLS_MODE".into(), "manual".into());
1904        env.insert("PASSWAY_TLS_CERT".into(), self.tls.cert.clone());
1905        env.insert("PASSWAY_TLS_KEY".into(), self.tls.key.clone());
1906        env.insert("PASSWAY_UPSTREAMS".into(), self.passway_upstreams());
1907        match &self.discover {
1908            // R910-F2: poll yubaba for the fronted workload's records. The
1909            // URLs carry no `<hostname>=` prefix — this door serves exactly
1910            // one domain, so the catch-all form is the whole instruction.
1911            Some(d) => {
1912                env.insert("PASSWAY_UPSTREAM_SOURCE".into(), "yubaba".into());
1913                env.insert("PASSWAY_YUBABA_URL".into(), d.urls.join(","));
1914                env.insert(
1915                    "PASSWAY_YUBABA_IDENT".into(),
1916                    format!("{}={}", self.domain, d.ident),
1917                );
1918            }
1919            None => {
1920                env.insert("PASSWAY_UPSTREAM_SOURCE".into(), "static".into());
1921                env.remove("PASSWAY_YUBABA_URL");
1922                env.remove("PASSWAY_YUBABA_IDENT");
1923            }
1924        }
1925        match self.idle_ttl_secs() {
1926            Some(secs) => {
1927                env.insert("PASSWAY_IDLE_TTL_SECS".into(), secs.to_string());
1928            }
1929            // Unset, not `0` — passway reads an absent variable as "never
1930            // reap", and `0` as a zero-second timer that fires immediately.
1931            None => {
1932                env.remove("PASSWAY_IDLE_TTL_SECS");
1933            }
1934        }
1935
1936        WorkloadSpec {
1937            name: id.to_string(),
1938            image: ImageRef {
1939                // Identity metadata only — the JIT tier forks a node binary and
1940                // pulls nothing, exactly like the bundle-serving native path.
1941                registry: "passway".into(),
1942                repository: format!("tenant/{}", self.domain),
1943                tag: "jit".into(),
1944                digest: "sha256:0000000000000000000000000000000000000000000000000000000000000000"
1945                    .into(),
1946            },
1947            tier: TierTag("infra".into()),
1948            tenant: TenantId::singleton(),
1949            namespace: NamespaceId::singleton(),
1950            replicas: 1,
1951            entrypoint: Some(vec![self.command_path().to_string()]),
1952            command: Some(vec![]),
1953            workdir: None,
1954            user: None,
1955            env: env
1956                .into_iter()
1957                .map(|(name, value)| EnvVar {
1958                    name,
1959                    value: EnvValue::Literal { value },
1960                })
1961                .collect(),
1962            secrets: vec![],
1963            volumes: vec![],
1964            resources: ResourceLimits {
1965                memory_mb: 64,
1966                cpu_millis: 256,
1967                memory_request_mb: None,
1968                cpu_limit_millis: None,
1969                pids_max: None,
1970                scratch_floor_mb: None,
1971            },
1972            depends_on: vec![],
1973            requires: vec![],
1974            // No probe: a `TcpConnect` probe would dial the held socket and
1975            // fork the process on every interval, defeating the idle reap. The
1976            // JIT bundle path refuses one for the same reason.
1977            healthcheck: None,
1978            restart_policy: RestartPolicy::Never,
1979            archetype: None,
1980            stop_policy: StopPolicy {
1981                signal: 15,
1982                grace_period: Millis::from_secs(5),
1983            },
1984            expose: ExposeSpec {
1985                mesh: MeshExpose {
1986                    identity: MeshIdent(id.to_string()),
1987                    ports: MeshExpose::anonymous_ports(self.listen_port()),
1988                    allow_from: vec![],
1989                },
1990                public: None,
1991                operator: None,
1992            },
1993            labels: Default::default(),
1994            durability: None,
1995            db: Vec::new(),
1996            capabilities: Vec::new(),
1997            annotations: Default::default(),
1998            files: Vec::new(),
1999        }
2000    }
2001
2002    /// Port half of [`listen`](Self::listen), when it parses.
2003    pub fn listen_port(&self) -> Option<u16> {
2004        self.listen
2005            .rsplit_once(':')
2006            .and_then(|(_, p)| p.parse::<u16>().ok())
2007    }
2008}
2009
2010/// Build step that produces the static artifact published by a
2011/// `mesofact-static` workload.
2012///
2013/// **`deny_unknown_fields` is load-bearing (R658-B1).** TOML scopes every key
2014/// written after a table header into that table, so a manifest that puts a
2015/// top-level `MesofactStaticWorkload` field — `routes` was the one that
2016/// actually happened — below `[build]` silently produces `build.routes`
2017/// instead. Without this attribute serde discards the stray key, the
2018/// top-level field falls back to its default (or fails with a `missing field`
2019/// error pointing at the wrong place), and the manifest deploys with a
2020/// declaration nobody honours. Every real `workload.toml` in the camp and the
2021/// CLI's own `yah cloud site init` scaffold carried exactly that shape for
2022/// months without a single reader noticing.
2023///
2024/// The cost is forward-compat: a manifest carrying a `[build]` key this binary
2025/// doesn't know is a hard parse error, not an ignored key. That is deliberate.
2026/// A build config is a small, slow-moving, load-bearing table — a key that
2027/// silently does nothing is worse here than one that refuses to load, because
2028/// the failure surfaces as a wrong artifact rather than an error.
2029///
2030/// Note `deny_unknown_fields` is inert for the postcard kamaji wire, which is
2031/// non-self-describing and positional — this only constrains TOML/JSON.
2032///
2033/// @yah:relay(R905, "Per-environment build override — a mirror cannot change what its component builds with")
2034/// @yah:status(review)
2035/// @yah:at(2026-09-14T20:25:20Z)
2036/// @yah:assignee(agent:bundle-anthropic-ashguard)
2037/// @yah:next("THE GAP, stated structurally. A `[build]` block lives on the COMPONENT's workload.toml (BuildConfig here: `command`, `out_dir`, `render_command` — nothing else), and a component is declared once in `.yah/services/<svc>/service.toml`. The MIRROR is the only per-environment surface, and `.yah/schema/mirror.toml.schema.json`'s top-level keys are exactly `asset_aliases, drivers, ingress, ingress_machines, providers, schema_version, shape` — no `build`, no `env`. So a service that deploys the SAME component to two environments has no way to build it differently for each, and `mesofact_static`'s build step passes no environment either (`run_build` -> `ExecContext::default().with_cwd()`).")
2038/// @yah:next("FIRST CONSUMER, CONFIRMED LIVE, NOT HYPOTHETICAL — noisetable's staging origin (noisetable camp R704-T4). `web/landing/workload.toml:39` hardcodes `command = \"bun run build:cloud\"`, and `build:cloud` is `NOISETABLE_API_ORIGIN=https://api.noisetable.com bun run build` (web/landing/package.json:17). A sibling `build:staging` pointing at `https://api-staging.noisetable.com` EXISTS at package.json:18 and `rg build:staging` over the whole repo returns exactly that one definition line — nothing can reference it, because there is nowhere to put the reference. Measured consequence: `curl -sS https://staging.noisetable.com/account` serves `\"api_base\":\"https://api.noisetable.com/api/v1\"`, production CORS allows only `https://noisetable.com`, so the staging account page's every API call is blocked and the page renders \"unreachable\".")
2039/// @yah:next("TWO SHAPES, both plausible; the ticket does not pick one. (a) An `env` table on BuildConfig plus a mirror-level override of it — most direct, but puts per-env data on a per-component struct. (b) A `[build]` override block on the mirror that replaces `command` for that environment — keeps the environment axis where every other environment fact already lives (`providers`, `ingress`), at the cost of a second place a build command can come from. (b) is the one this filer leans toward, precisely because the mirror is ALREADY the per-environment surface and (a) invents a second one.")
2040/// @yah:next("DO NOT 'SOLVE' THIS CONSUMER-SIDE WITH HOSTNAME SNIFFING. The tempting workaround is deriving the API origin from `location.hostname` in the browser (staging.noisetable.com -> api-staging.noisetable.com). It was considered and rejected by the noisetable operator: it is one-off string-match control flow, and it converts a deploy-time fact into client-bundle logic that no build can validate. The absence of that workaround is why this ticket exists — do not close it by suggesting one.")
2041/// @yah:verify("`rg -n \"build:staging\" --glob '!node_modules'` inside the noisetable checkout returns MORE than the single package.json definition line — i.e. something now references it. Today it returns exactly one hit, which is the whole defect.")
2042/// @yah:verify("`curl -sS https://staging.noisetable.com/account | grep -o '\"api_base\":\"[^\"]*\"'` prints `https://api-staging.noisetable.com/api/v1`. It printed `https://api.noisetable.com/api/v1` on 2026-09-13 when this was filed.")
2043/// @yah:gotcha("THE NOISETABLE SIDE IS NOT ALL OF THIS TICKET'S BLAST RADIUS, and shipping the override does not by itself fix that page. `api-staging.noisetable.com` currently resolves and serves 200 on `/health` from the PRODUCTION passway edge, whose `upstreams_by_host` lists `api.noisetable.com` and `*` with no `api-staging` entry — so a staging bundle pointed at it would 404 until noisetable R704-T4's own door (a cloudflare-tunnel on the NAT'd dev node us-west-011) is up. The two are independent and both are required; neither is a reason to defer the other.")
2044/// @yah:handoff("SHAPE (b) SHIPPED, widened by an env table. MirrorConfig.build: BTreeMap<component id, MirrorBuildOverride{command, render_command, env}> (oss/yubaba/crates/cloud/src/config.rs). Field-wise override of the workload's [build]; out_dir deliberately not overridable. Wired into BOTH build paths: MesofactStaticReconciler::rebuild_static/revalidate_static (env via ExecContext::with_env) and the bundle tier in app/yah/cli/src/cloud.rs (effective_build_command + spawn_component_build shared by single- and multi-component assembly; deploy_mesofact_bundle's provenance/refusal resolve through it too). noisetable staging uses providers.bundle, so the CLI path is the one its first consumer hits. cross_ref_validate refuses [build.<id>] naming an undeclared component. mirror.toml.schema.json regenerated.")
2045/// @yah:handoff("LANDED (yah side). Mirrors take `[build.<component id>]` with `command`, `render_command` and `env` (MirrorBuildOverride, oss/yubaba/crates/cloud/src/config.rs). Each field overrides the matching field of the workload's [build]; `out_dir` cannot be overridden. Honoured by BOTH build paths: MesofactStaticReconciler::rebuild_static/revalidate_static (env via ExecContext::with_env) and the bundle tier (app/yah/cli/src/cloud.rs effective_build_command + spawn_component_build, shared by single- and multi-component assembly and by deploy_mesofact_bundle's provenance/refusal). cross_ref_validate refuses a key naming an undeclared component. Schema regenerated; it was swept into peer commit 30c2c02c.")
2046/// @yah:verify("cd oss/yubaba && cargo test -p yah-cloud --lib -- reconciler::mesofact_static build_override → 56 + 5 pass, including rebuild_static_runs_the_mirrors_overridden_command_with_its_env, revalidate_static_honours_the_mirrors_render_override_and_env, and cloud_config_cross_ref_fails_on_a_build_override_for_an_unknown_component")
2047/// @yah:gotcha("An OLD yah binary silently ignores `[build.<id>]`, because MirrorConfig has no deny_unknown_fields. Install a yah that includes R905 before relying on the override, or staging keeps building with build:cloud and nothing errors.")
2048/// @yah:assumes("The noisetable consumer edit is noisetable-camp work (R704-T4), not done here. Add `[build.site] command = \"bun run build:staging\"` (or `[build.site.env] NOISETABLE_API_ORIGIN = ...`) to .yah/services/noisetable-marketing/mirrors/staging.toml, then apply. The two filing-time verify lines (rg build:staging, curl api_base) are only satisfiable after that step and after R704-T4's api-staging door is up.")
2049/// @yah:handoff("NOISETABLE LOCKSTEP (operator-authorized 2026-09-14): (1) noisetable .yah/services/noisetable-marketing/mirrors/staging.toml gains `[build.site] command = \"bun run build:staging\"` with the why + old-binary warning inline. (2) web/landing/workload.toml's comment claiming the reconciler passes no build env replaced — it now says build:cloud is production's default and staging overrides it from its mirror. (3) noisetable .yah/schema/mirror.toml.schema.json refreshed from yah's regenerated copy (+35 lines). external/yah there is a symlink to this monorepo, so the mesofact-build side needs no separate bump.")
2050/// @yah:gotcha("~/.local/bin/yah (0.8.39+e0530813-dirty) does NOT carry R905 (strings check for the new cross_ref_validate message: 0 hits). Until an R905 yah is installed, noisetable's `[build.site]` is silently ignored and staging still builds with build:cloud. Also: the filing-time verify `rg build:staging` skips hidden dirs, so it never sees .yah/ — its hits come from web/landing/workload.toml's comment, not from the mirror itself.")
2051/// @yah:verify("Against the REAL noisetable tree with an R905 build (target/debug/yah, 2026-09-14 13:33): `yah cloud validate --path ~/ss/noisetable` → ok. Negative: the same .yah copied to /tmp with the key changed to `[build.sight]` → `Error: loading workspace declarations: services/noisetable-marketing/mirrors/staging.toml: [build.sight] — no component \"sight\" in services/noisetable-marketing/service.toml (declared: [\"site\", \"app\"])`. CLI: cargo test -p yah --lib (6 bundle_assembly_tests incl. a_mirror_build_override_decides_what_a_component_builds_with) pass.")
2052#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
2053#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2054#[serde(deny_unknown_fields)]
2055pub struct BuildConfig {
2056    /// Shell command run from the manifest's directory, e.g. `"bun run build"`.
2057    ///
2058    /// **Absent means "this project has no external bundler step" (R838-B1)**,
2059    /// not "run nothing by accident". `mesofact new`'s scaffold deliberately
2060    /// omits it — the in-process pipeline (`mesofact-dev` / `mesofact-build`)
2061    /// produces `out_dir` with no third binary, no package manager and no Node
2062    /// — so requiring it here made every scaffolded project's manifest fail to
2063    /// load through this envelope. Setting it opts back out to a shell command,
2064    /// which is what a project with its own bundler wants.
2065    ///
2066    /// Consumers were already written for this: `read_workload_build`
2067    /// (app/yah/cli/src/cloud.rs) has always typed it `Option<String>` and
2068    /// `yah cloud bundle build` only needs it under `--run-build`; the bundle
2069    /// sync arm refuses `None` by name. `MesofactStaticReconciler::
2070    /// rebuild_static` skips the build step for `None` — the same thing it
2071    /// already did for a workload with no `workload.toml` at all.
2072    ///
2073    /// WIRE NOTE: this is `Option<String>` on the postcard kamaji wire, so it
2074    /// costs a leading `0x00`/`0x01` tag byte that the bare `String` did not
2075    /// have. A pre-R838 node decoding a new frame fails loudly (the string's
2076    /// length byte is not a valid `Option` tag) rather than silently reading a
2077    /// shifted field — which is why this is `Option` and not a `#[serde(default)]`
2078    /// empty `String` sentinel. Not a `cluster_epochs` surface: those hash the
2079    /// raft modules and the openraft pin, not `workload_spec`.
2080    #[serde(default)]
2081    pub command: Option<String>,
2082
2083    /// Output directory (relative to the manifest) the reconciler uploads.
2084    pub out_dir: PathBuf,
2085
2086    /// Data-only re-render command (W225 §3 "revalidate"), run from the
2087    /// manifest's directory against the **already-built** `out_dir` — no
2088    /// bundler. `{route}` is substituted with the invalidated route pattern,
2089    /// e.g. `"../../../../scripts/mesofact-build.sh render . --route {route}
2090    /// --all"` (R746-F9 — resolves a prebuilt binary rather than shelling to
2091    /// cargo, which cannot even find the package from a site's own dir).
2092    /// Absent → a revalidate dispatch republishes `out_dir` as-is.
2093    #[serde(default)]
2094    pub render_command: Option<String>,
2095}
2096
2097// ── BuildMode ─────────────────────────────────────────────────────────────────
2098
2099/// Where the build command runs for a `mesofact-static` workload.
2100///
2101/// The two-role split encodes the F7 design decision: build/publish is a
2102/// **transient job** (runs once, exits, GC'd); SSR/SPA serving is a separate
2103/// **long-lived companion container** (optional, only for dynamic pages). A
2104/// single merged "mesofact container" is the trap — in cloud, CI builds the
2105/// artifact, R2+CDN serve it, and a distinct worker handles any SSR.
2106///
2107/// Default: `HostSide` — mesofact-dev runs the build on the host and publishes
2108/// to the tier's object store. No container overhead; compatible with dev and
2109/// sim tiers.
2110#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
2111#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2112#[serde(rename_all = "snake_case")]
2113pub enum BuildMode {
2114    /// Build command runs on the host (mesofact-dev watcher). The watcher
2115    /// publishes the output to the tier's object store (DistPointer for dev,
2116    /// MinIO for sim). Compatible with all tiers; zero container overhead.
2117    #[default]
2118    HostSide,
2119
2120    /// Build runs inside a transient container matching the CI image. Higher
2121    /// fidelity (environment matches CI exactly); costs image pull +
2122    /// container cold-start. Required for cloud/HA where no mesofact-dev
2123    /// watcher is running on the host.
2124    InContainer {
2125        /// Container image that runs the build (e.g. `"ghcr.io/org/app-build:v1.2"`).
2126        /// Must have the build toolchain installed. The container is started with
2127        /// the workspace root bind-mounted, runs `build.command`, uploads
2128        /// `build.out_dir` to the object store, then exits.
2129        image: ImageRef,
2130    },
2131}
2132
2133// ── AlmanacManifest ───────────────────────────────────────────────────────────
2134
2135/// An observable endpoint the almanac scheduler probes to check readiness.
2136///
2137/// Used for both inputs (checked before the run) and outputs (verified after
2138/// a successful run to confirm the job produced something reachable).
2139/// The probe is intentionally lightweight — no S3 SigV4, no xlb-net discovery
2140/// required; a simple TCP connect or HTTP GET is enough for the dev/sim tier.
2141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
2142#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2143#[serde(rename_all = "snake_case")]
2144pub enum AlmanacTarget {
2145    /// Issue an HTTP GET to `url`; ready when the server responds with
2146    /// `expect_status` (default: any 2xx).
2147    Http {
2148        url: String,
2149        #[ts(optional = nullable)]
2150        expect_status: Option<u16>,
2151    },
2152
2153    /// Establish a TCP connection to `host:port`; ready when the connect
2154    /// succeeds. Used for non-HTTP services (e.g. MinIO API on port 9000)
2155    /// and as a lighter probe when an HTTP endpoint isn't stable yet.
2156    Tcp { host: String, port: u16 },
2157}
2158
2159/// What the almanac scheduler does when a precondition check fails.
2160///
2161/// The F9 design decision: `WaitWithTimeout` is the default. Fail-fast is
2162/// too brittle for the sim tier (containers may still be cold-starting);
2163/// requeue-with-no-ceiling can block the scheduler indefinitely. The
2164/// recommended timeout for sim is the container spinup budget (~5 s cold,
2165/// ~1 s warm): set `timeout` to a few seconds, then let the retry cadence
2166/// handle transient glitches.
2167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
2168#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2169#[serde(rename_all = "snake_case")]
2170pub enum NotReadyPolicy {
2171    /// Wait up to `timeout` for all preconditions to pass before aborting
2172    /// the run. The run is skipped (not rescheduled); the next cadence tick
2173    /// will retry. Suitable when targets occasionally lag at startup.
2174    WaitWithTimeout {
2175        /// How long to wait for each precondition to become reachable. The
2176        /// scheduler polls with a short sleep between attempts.
2177        timeout: Millis,
2178    },
2179
2180    /// Abort immediately if any precondition check fails. Suitable for
2181    /// integration-test harnesses where a missing dependency is always a
2182    /// hard error.
2183    FailFast,
2184
2185    /// Requeue with exponential backoff up to `max_attempts` times. After
2186    /// exhaustion the run is marked failed. Suitable for cloud/HA where
2187    /// transient dependency outages are expected.
2188    Requeue {
2189        /// Maximum number of requeue attempts before the run is marked failed.
2190        max_attempts: u32,
2191        /// Initial backoff between attempts, in milliseconds.
2192        backoff: Millis,
2193    },
2194}
2195
2196impl Default for NotReadyPolicy {
2197    /// Default is `WaitWithTimeout { timeout: 5 seconds }` — matches the
2198    /// container spinup budget for the sim tier (few-second cold, sub-second warm).
2199    fn default() -> Self {
2200        Self::WaitWithTimeout { timeout: Millis::from_secs(5) }
2201    }
2202}
2203
2204/// When the almanac scheduler triggers a run.
2205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
2206#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2207#[serde(rename_all = "snake_case")]
2208pub enum Cadence {
2209    /// Run once at first opportunity, then never again.
2210    Once,
2211
2212    /// Run repeatedly with a fixed interval between the end of one run and
2213    /// the start of the next. Equivalent to `sleep N && run` in a loop.
2214    Every {
2215        /// Minimum time between consecutive run completions.
2216        interval: Millis,
2217    },
2218
2219    /// Run on a UTC cron schedule (standard 5-field expression, e.g.
2220    /// `"0 */6 * * *"` for every 6 hours). The scheduler evaluates the
2221    /// expression relative to UTC midnight.
2222    Cron { expression: String },
2223}
2224
2225/// `kind = "almanac"` manifest — a declared data-pipeline job.
2226///
2227/// An almanac job is the generalisation of the OpenRouter refresher
2228/// (`spawn_almanac_refresher`): it declares its I/O contract explicitly so
2229/// the orchestrator can enforce preconditions before each run and verify
2230/// outputs afterward. The degenerate case (no inputs, no app target, cron
2231/// schedule) is exactly the OpenRouter JSON-cache refresher.
2232///
2233/// Lifecycle:
2234/// 1. Cadence tick fires.
2235/// 2. Scheduler probes every `inputs` target. If any fail → apply
2236///    `not_ready_policy`.
2237/// 3. Command runs (`sh -c command` from the workload directory).
2238/// 4. Scheduler probes every `outputs` target. Failure → mark run as
2239///    failed but do not retry.
2240/// 5. Any workloads listed in `invalidates` receive a cache-bust signal
2241///    (implementation detail of the orchestrator; in camp this is a
2242///    rebuild trigger on the mesofact-dev watcher).
2243#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
2244#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2245pub struct AlmanacManifest {
2246    /// Shell command executed via `sh -c` from the workload directory.
2247    pub command: String,
2248
2249    /// When to run.
2250    pub cadence: Cadence,
2251
2252    /// Input targets that must be reachable before the command runs.
2253    /// Empty list → no precondition checks (degenerate case).
2254    #[serde(default)]
2255    pub inputs: Vec<AlmanacTarget>,
2256
2257    /// Output targets verified after a successful run.
2258    /// Empty list → no post-run verification.
2259    #[serde(default)]
2260    pub outputs: Vec<AlmanacTarget>,
2261
2262    /// What to do when a precondition check fails.
2263    /// Default: `WaitWithTimeout { timeout: 5000ms }`.
2264    #[serde(default)]
2265    pub not_ready_policy: NotReadyPolicy,
2266
2267    /// Mesh identities of workloads to notify after a successful run.
2268    /// The orchestrator sends a cache-bust signal to each entry so
2269    /// downstream consumers can reload their data (e.g. mesofact-dev
2270    /// triggers a rebuild when the OpenRouter cache refreshes).
2271    /// Empty list → no downstream invalidation.
2272    #[serde(default)]
2273    pub invalidates: Vec<MeshIdent>,
2274}
2275
2276// ── StaticAssetWorkload ───────────────────────────────────────────────────────
2277
2278/// BLAKE3 content hash expressed as exactly 64 ASCII hex digits.
2279///
2280/// This is the content-address key for every file in the static-asset catalog.
2281/// Deserialization rejects values that do not conform — 64 hex chars, case
2282/// insensitive. Mismatch between the recorded hash and the source file halts
2283/// the upload step in the reconciler.
2284#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)]
2285#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2286#[ts(type = "string")]
2287pub struct BlakeHash(pub String);
2288
2289impl<'de> Deserialize<'de> for BlakeHash {
2290    fn deserialize<D>(de: D) -> Result<Self, D::Error>
2291    where
2292        D: serde::Deserializer<'de>,
2293    {
2294        let s = String::deserialize(de)?;
2295        if s.len() != 64 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
2296            return Err(serde::de::Error::custom(format!(
2297                "blake3 hash must be exactly 64 hex digits, got {:?}",
2298                s
2299            )));
2300        }
2301        Ok(BlakeHash(s))
2302    }
2303}
2304
2305// ── License & FetchSource (W164) ──────────────────────────────────────────────
2306
2307/// Closed-set, parse-time-enforced license tag. Mirrors the workspace
2308/// permissive-license rule (MIT / Apache-2.0 / BSD-2/3-Clause / ISC). Adding a
2309/// variant is an explicit schema change — non-permissive strings
2310/// (`"GPL-3.0"`, `"AGPL"`, etc.) fail at serde-deserialize before any shape
2311/// validator runs.
2312///
2313/// Shared between `asset.derive.fetch.license` (W164, required) and a future
2314/// `almanac::ReleaseSource.license` migration (R438-F10, optional).
2315#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
2316#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2317#[serde(rename_all = "kebab-case")]
2318pub enum License {
2319    Mit,
2320    Apache2,
2321    Bsd2Clause,
2322    Bsd3Clause,
2323    Isc,
2324}
2325
2326/// Shared fetch primitive — usable by `asset.derive` today, and by Almanac's
2327/// `ReleaseSource` after a follow-up migration (R438-F10). Defined once in
2328/// workload-spec so both consumers reject the same set of non-permissive
2329/// licenses.
2330///
2331/// The `blake3` hash pins the upstream bytes; mismatch at fetch time is a hard
2332/// error in the reconciler. The `license` field is **required** here — every
2333/// derived asset must declare its upstream license. If/when Almanac adopts
2334/// `FetchSource`, the Almanac side may wrap this in a struct with
2335/// `Option<License>` since release manifests have no distribution license per
2336/// se.
2337#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
2338#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2339pub struct FetchSource {
2340    /// Upstream URL fetched verbatim. Reconciler retry policy is configured
2341    /// elsewhere (R438-F11); the URL itself is opaque to workload-spec.
2342    pub url: String,
2343
2344    /// Expected BLAKE3 hash of the fetched bytes (64 hex characters). The
2345    /// reconciler verifies this after download and aborts on mismatch.
2346    pub blake3: BlakeHash,
2347
2348    /// Upstream license. Closed-set, parse-time enforced.
2349    pub license: License,
2350}
2351
2352/// Optional transform applied after a [`FetchSource`] download, lowering to a
2353/// `ForgeCommand::Subprocess` via the recipe loader (R438-T4). The transform's
2354/// output is content-addressed by the entry's `blake3` (the recipe runs only
2355/// when the cache misses).
2356#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
2357#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2358pub struct TransformSpec {
2359    /// Named recipe under `.yah/qed/transforms/<recipe>.toml`. Loader rejects
2360    /// missing recipes at materialize time.
2361    pub recipe: String,
2362
2363    /// `{{key}}` substitutions passed to the recipe argv at element
2364    /// granularity (no shell, no string concat). Empty when the recipe is
2365    /// fully parameterless.
2366    #[serde(default)]
2367    pub params: BTreeMap<String, String>,
2368}
2369
2370/// W212/R518: the committed derivation lock — the in-tree action-cache
2371/// receipt. `input_hash` is the input-addressed derivation key computed over
2372/// the complete declared input set (fetched-input pin ⊕ recipe-file bytes ⊕
2373/// invocation params ⊕ schema version); `output_blake3` is what those inputs
2374/// produced (== the entry's `blake3`). The reconciler skips the entire build
2375/// (no fetch, no transform, no PUT) when the lock matches the inputs recomputed
2376/// from the current pins and the bucket already holds the output — the
2377/// Nix-substituter / Bazel-remote-cache behaviour. Written by the R510 bind
2378/// path from the reconciler's `discovered_input_hash:<filename>` output; the
2379/// `git diff` on this block is the receipt that the derivation rolled.
2380#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
2381#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2382pub struct DeriveLock {
2383    /// Input-addressed derivation key (BLAKE3 hex). A change to any declared
2384    /// input flips this, so a stale lock never produces a false skip.
2385    pub input_hash: String,
2386    /// Output the locked inputs produced (BLAKE3 hex; equals the entry's
2387    /// `blake3`). Carried so the lock is a self-contained action-cache entry.
2388    pub output_blake3: String,
2389}
2390
2391/// Provenance chain for a derived asset: required `fetch` step, optional
2392/// `transform` step. Materialized bytes replace `AssetEntry.source` for the
2393/// rest of the static-asset reconcile loop.
2394#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
2395#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2396pub struct AssetDerive {
2397    /// Upstream fetch — URL + content-pin + license.
2398    pub fetch: FetchSource,
2399
2400    /// Post-fetch transform. `None` → the fetched bytes ARE the asset
2401    /// (entry `blake3` must match fetch `blake3`).
2402    #[serde(default)]
2403    #[ts(optional = nullable)]
2404    pub transform: Option<TransformSpec>,
2405
2406    /// W212/R518: committed derivation lock (input-addressed action-cache
2407    /// receipt). Absent until the first successful build writes it via the
2408    /// bind path. When present and current, enables the substituter-style
2409    /// build skip.
2410    #[serde(default)]
2411    #[ts(optional = nullable)]
2412    pub lock: Option<DeriveLock>,
2413}
2414
2415/// A single file entry in the static-asset catalog.
2416///
2417/// One `[[asset]]` row per bucket object. Multiple rows for different variants
2418/// (e.g. q5 and q4 whisper models) are fine — each declares its own filename
2419/// and hash. The reconciler treats the catalog as exhaustive and append-only:
2420/// new rows trigger a PUT; removed rows surface as drift (never a DELETE).
2421///
2422/// **Source-vs-derive XOR.** Exactly one of `source` or `derive` must be set.
2423/// Legacy local-bytes assets keep `source = "..."`; W164 derived assets set
2424/// `[asset.derive]` instead. [`validate::shape_static_asset`] enforces the
2425/// XOR; both-set and neither-set are hard `ShapeError::Field`.
2426#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
2427#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2428pub struct AssetEntry {
2429    /// Destination path within the bucket, e.g.
2430    /// `"whisper/distil-large-v3-q5_1.bin"`. Must be unique in the catalog.
2431    /// Used as the S3 object key by the reconciler.
2432    pub filename: String,
2433
2434    /// Path to a local source file, relative to the `workload.toml` directory.
2435    /// Mutually exclusive with `derive`.
2436    #[serde(default)]
2437    #[ts(optional = nullable)]
2438    pub source: Option<PathBuf>,
2439
2440    /// Declared fetch (+ optional transform) provenance chain. The reconciler
2441    /// materializes the bytes into a content-addressed cache; the cache path
2442    /// then replaces `source` for the rest of the upload pipeline. Mutually
2443    /// exclusive with `source`.
2444    #[serde(default)]
2445    #[ts(optional = nullable)]
2446    pub derive: Option<AssetDerive>,
2447
2448    /// Expected BLAKE3 hash of the *final* asset bytes (64 hex characters).
2449    /// For `source` mode, this is hashed before upload. For `derive` mode,
2450    /// it's the post-transform (or post-fetch when no transform) output.
2451    /// Mismatch aborts the upload.
2452    pub blake3: BlakeHash,
2453}
2454
2455/// `kind = "static-asset"` payload — content-addressed bucket catalog.
2456///
2457/// The reconciler makes the bucket match the `[[asset]]` list exactly
2458/// (append-only: new rows → PUT; removed rows → drift report, not DELETE).
2459/// Rollback is pointer-flip via `mirror.toml [asset_aliases]` — bytes never
2460/// move during rollback.
2461///
2462/// **Closed-catalog invariant**: every value in `[aliases]` must be a
2463/// `filename` that exists in `[[asset]]`. Enforced by
2464/// [`validate::shape_static_asset`]. Mirror overrides (`[asset_aliases]` in
2465/// `mirror.toml`) are bound by the same rule — the alias graph can only
2466/// resolve to filenames already in the catalog.
2467#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
2468#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2469pub struct StaticAssetWorkload {
2470    /// Exhaustive catalog of files this component manages in the bucket.
2471    ///
2472    /// Named `asset` on disk (TOML `[[asset]]` array-of-tables) to follow TOML
2473    /// convention; accessed as `.assets` in Rust code.
2474    #[serde(rename = "asset", default)]
2475    pub assets: Vec<AssetEntry>,
2476
2477    /// Canonical logical-name → filename mappings for this component.
2478    ///
2479    /// Values must be filenames present in `assets` — validated by
2480    /// [`validate::shape_static_asset`]. Mirror files may override individual
2481    /// entries via `[asset_aliases]` but may never reference filenames absent
2482    /// from this catalog.
2483    #[serde(default)]
2484    pub aliases: BTreeMap<String, String>,
2485}
2486
2487// ── Lifecycle archetype (R572-F1 / W244) ───────────────────────────────────────
2488
2489/// Explicit lifecycle archetype for a `kind = "container"` workload (W244).
2490///
2491/// The question that actually matters to a scheduler: *"can I kill this and
2492/// recreate it somewhere else?"* Before this field existed, the answer was
2493/// inferred per-spec from `volumes.is_empty()` + `restart_policy` — fragile
2494/// absence-as-policy, the same trap W243 calls out on the node-taint side.
2495/// This type makes the answer structural instead of guessed.
2496///
2497/// This ticket (R572-F1) adds the discriminator only. The reconciler does not
2498/// yet branch on it (R572-F4) and neither does the scheduler (R572-F5).
2499#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
2500#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2501#[serde(rename_all = "kebab-case")]
2502pub enum LifecycleArchetype {
2503    /// k8s analogue: Deployment. Stateless and fungible — the scheduler may
2504    /// move it, scale it to N replicas, or restart it on a different node
2505    /// with zero consequence. Drainable.
2506    Server,
2507
2508    /// k8s analogue: StatefulSet. Stable identity + a volume that must
2509    /// follow it; at most one live instance. Not drainable — the reconciler
2510    /// must not schedule it onto a different node. Example: a postgres peer,
2511    /// headscale (W267/R591).
2512    Appliance,
2513
2514    /// k8s analogue: Job. Runs to completion with declared inputs/outputs,
2515    /// then is gone — no steady-state identity. `almanac` is the first
2516    /// job-family member; forge runs (`WorkloadSpec::for_forge`, used by QED)
2517    /// are the `container`-kind instance of this archetype.
2518    Job,
2519}
2520
2521impl LifecycleArchetype {
2522    /// Every variant, in declaration order. Exists so a consumer can enumerate
2523    /// the archetypes without hand-maintaining a parallel list — the taint
2524    /// vocabulary in `cloud::config::taint_effect` is built from this, so
2525    /// adding a fourth archetype extends the set of live repel keys for free.
2526    pub const ALL: [LifecycleArchetype; 3] = [Self::Server, Self::Appliance, Self::Job];
2527
2528    /// The repel-taint key for this archetype (R572-F5). A node carrying the
2529    /// taint `"no-<key>"` **absolutely** rejects workloads of this class.
2530    ///
2531    /// Examples: `Server` → `"server"` (repelled by `"no-server"`);
2532    /// `Appliance` → `"appliance"` (repelled by `"no-appliance"`).
2533    ///
2534    /// W305/R742-T4: there is no toleration. Earlier prose here and in
2535    /// `cloud::config` called this "repel-unless-tolerate"; the `unless` was
2536    /// never built, and reading it as a preference is what made `no-appliance`
2537    /// on the dev Pis look advisory when it was an unconditional block.
2538    pub fn taint_key(&self) -> &'static str {
2539        match self {
2540            Self::Server => "server",
2541            Self::Appliance => "appliance",
2542            Self::Job => "job",
2543        }
2544    }
2545
2546    /// The pre-R572 inference this field replaces, kept only to give
2547    /// `WorkloadSpec::effective_archetype` a behavior-preserving fallback for
2548    /// specs written before this field existed (`archetype: None`).
2549    ///
2550    /// A volume that must follow the workload is the strongest signal of
2551    /// durable state → [`Self::Appliance`]. Absent that, `RestartPolicy::Never`
2552    /// is the existing forge/run-once convention (see
2553    /// [`RestartPolicy::Never`]'s doc comment) → [`Self::Job`]. Everything
2554    /// else defaults to the common case, [`Self::Server`].
2555    ///
2556    /// A [`VolumeMount::from_secret_mount`] bind does not count: yubaba appends
2557    /// one per materialized File secret, so counting it made every deployed
2558    /// secret-mounting workload read as an `Appliance` (R854), and a deployed
2559    /// spec could not be asked its own archetype (R966).
2560    fn infer(volumes: &[VolumeMount], restart_policy: &RestartPolicy) -> Self {
2561        if volumes.iter().any(|v| !v.from_secret_mount) {
2562            LifecycleArchetype::Appliance
2563        } else if matches!(restart_policy, RestartPolicy::Never) {
2564            LifecycleArchetype::Job
2565        } else {
2566            LifecycleArchetype::Server
2567        }
2568    }
2569}
2570
2571/// Whether a placement group may be drained off its node (W338 §"Placement
2572/// consequences" 2): false as soon as **any** member is an Appliance.
2573///
2574/// The set-valued form of the per-workload question. A `Server` bound to an
2575/// Appliance by a `local` edge has to move with it or not at all, so draining it
2576/// alone breaks the group the same way placing it alone would.
2577///
2578/// # Why this lives here and not in `cloud`
2579///
2580/// R860-T4 landed it in `cloud::config`, which is the right layer for the
2581/// *scheduler* — but R860-T6 needs the identical predicate on the **node** side,
2582/// in `drain_workloads`, and yubaba deliberately has no runtime dependency on
2583/// cloud (R374-F3 moved `local-driver` out of cloud precisely to avoid that
2584/// reverse edge; `cloud` is a dev-dependency of yubaba only). Placement and
2585/// drain disagreeing about drainability is exactly the drift this predicate
2586/// exists to prevent, so it belongs in the crate they both already depend on.
2587/// `cloud::config::group_is_drainable` delegates here and keeps its signature.
2588pub fn group_is_drainable(members: &[WorkloadSpec]) -> bool {
2589    !members
2590        .iter()
2591        .any(|m| m.effective_archetype() == LifecycleArchetype::Appliance)
2592}
2593
2594// ── Requirements (R860-T1 / W338) ─────────────────────────────────────────────
2595
2596/// Which providers count as satisfying a [`Requirement`] (W338).
2597///
2598/// One of the two independent axes a requirement carries. `depends_on` could
2599/// only ever say "someone, somewhere, is Ready" — which is the wrong answer for
2600/// a provider that must open the *same file on the same filesystem* as its
2601/// requirer (the headscale sqlite replicator, W338's motivating case). Locality
2602/// makes co-location a declared property instead of something arranged outside
2603/// the spec by a systemd unit.
2604#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
2605#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2606#[serde(rename_all = "kebab-case")]
2607pub enum Locality {
2608    /// Any Ready provider service discovery can reach, anywhere in the mesh.
2609    /// Exactly what a [`WorkloadSpec::depends_on`] entry means today, which is
2610    /// why it is the default — folding `depends_on` into `requires` must not
2611    /// change any existing spec's meaning.
2612    Anywhere,
2613
2614    /// A provider on this node satisfies it; otherwise a remote one does.
2615    ///
2616    /// **Never blocks placement.** This is the "at least one wherever this app
2617    /// runs" shape — a local replica is preferred, a remote one is acceptable,
2618    /// and nothing is refused for want of either.
2619    PreferLocal,
2620
2621    /// Only a provider on **this node** satisfies it. A true sidecar edge: the
2622    /// requirer and the provider form a placement group that must be placed
2623    /// together and must move together.
2624    Local,
2625}
2626
2627impl Default for Locality {
2628    fn default() -> Self {
2629        Locality::Anywhere
2630    }
2631}
2632
2633/// What to do when nothing satisfies a [`Requirement`] (W338).
2634///
2635/// The second axis, deliberately independent of [`Locality`]: all six
2636/// combinations are meaningful, and `prefer-local` + `self` is where a
2637/// DaemonSet falls out as a consequence rather than as a fourth archetype.
2638///
2639/// Kept a plain two-value enum rather than a data-carrying variant precisely so
2640/// the two axes stay independent — the provider's spec rides on
2641/// [`Requirement::provides`] instead.
2642#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
2643#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2644#[serde(rename_all = "kebab-case")]
2645pub enum Supply {
2646    /// Someone else declares and deploys the provider; block until it appears,
2647    /// under the existing healthcheck-sum deadline. Today's `depends_on`
2648    /// behaviour, and the default.
2649    Wait,
2650
2651    /// This workload carries the provider's spec in [`Requirement::provides`]
2652    /// and stands one up where the locality demands. Torn down with its
2653    /// requirer.
2654    ///
2655    /// Wire value is `"self"` — `Self` is a Rust keyword, so the variant is
2656    /// spelled `SelfProvision` and renamed on the wire.
2657    #[serde(rename = "self")]
2658    SelfProvision,
2659}
2660
2661impl Default for Supply {
2662    fn default() -> Self {
2663        Supply::Wait
2664    }
2665}
2666
2667/// One thing a workload needs before it can run (W338).
2668///
2669/// Widens [`WorkloadSpec::depends_on`] rather than adding a second concept
2670/// beside it: a requirement names an identity and answers the two questions the
2671/// bare ident list cannot — *which providers count* ([`Locality`]) and *what to
2672/// do when none exists* ([`Supply`]).
2673///
2674/// Each member of a group keeps its own mesh identity. A provider that may be
2675/// satisfied remotely must be independently discoverable, so a requirement is
2676/// an *edge between two identities*, never a way to collapse several workloads
2677/// under one. Nothing about addressing, teardown-by-identity or the
2678/// service-record rail changes.
2679#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
2680#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2681pub struct Requirement {
2682    /// Mesh identity of the provider. The same currency a
2683    /// [`WorkloadSpec::depends_on`] entry is written in.
2684    pub ident: MeshIdent,
2685
2686    /// Which providers count as satisfying this. Defaults to
2687    /// [`Locality::Anywhere`], the `depends_on` meaning.
2688    #[serde(default)]
2689    pub locality: Locality,
2690
2691    /// What to do when nothing satisfies it. Defaults to [`Supply::Wait`], the
2692    /// `depends_on` meaning.
2693    #[serde(default)]
2694    pub supply: Supply,
2695
2696    /// The provider's own spec, carried here when `supply = "self"`.
2697    ///
2698    /// Required for [`Supply::SelfProvision`] and forbidden for
2699    /// [`Supply::Wait`] — a `wait` requirement names a provider someone else
2700    /// declares, so a spec here would have no owner. Both directions are
2701    /// enforced by [`validate::shape`].
2702    ///
2703    /// Boxed because this makes [`WorkloadSpec`] recursive. The recursion is
2704    /// bounded at **depth 1**: a `provides` spec may not itself carry a
2705    /// `self`-supplied requirement (also enforced in [`validate::shape`]), so
2706    /// composition stays a requirer plus its immediate providers rather than an
2707    /// arbitrarily deep tree.
2708    #[serde(default)]
2709    #[ts(optional = nullable)]
2710    pub provides: Option<Box<WorkloadSpec>>,
2711}
2712
2713// ── WorkloadSpec ──────────────────────────────────────────────────────────────
2714
2715/// Complete typed description of a containerd workload handed to yubaba over
2716/// RPC. This is also the payload of the `kind = "container"` variant of
2717/// [`Workload`] on disk.
2718///
2719/// Yubaba never accepts compose YAML on its RPC surface — agents, the desktop,
2720/// and operator CLIs all hand yubaba `WorkloadSpec` values. See the arch doc
2721/// for the validation layers and evolution rules.
2722///
2723/// @yah:ticket(R860-T1, "Spec: Requirement { ident, locality, supply } + `requires` on WorkloadSpec, depends_on as back-compat projection")
2724/// @yah:status(review)
2725/// @yah:phase(P1)
2726/// @yah:at(2026-09-05T18:28:59Z)
2727/// @yah:assignee(agent:bundle-anthropic-ashguard)
2728/// @yah:parent(R860)
2729/// @yah:next("Regenerate the derived artifacts and commit them — they are generated, not owned (CLAUDE.md \\\"Generated artifacts do NOT regenerate on commit anymore\\\"): `cargo run -p xtask -- emit-schemas`, then `cargo run --manifest-path oss/yah-base/crates/workload-spec/Cargo.toml --bin export-ts`.")
2730/// @yah:verify("bash scripts/check-schema-drift.sh &amp;&amp; bash scripts/check-workload-spec-ts.sh &amp;&amp; cargo test -p workload-spec")
2731/// @yah:gotcha("Vocabulary ONLY — nothing reads `requires` yet. Deliberate, and it mirrors how `archetype` landed in R572-F1 (\\\"this field alone changes no runtime behavior\\\"). Enforcement is R860-T2 (deploy gate) and R860-T3 (placement group).")
2732/// @arch:see(.yah/docs/working/W338-workload-dependencies-and-appliance-composition.md)
2733/// @yah:gotcha("Adding `requires` to WorkloadSpec is NOT a one-file change in practice: a new struct field makes every `WorkloadSpec { .. }` literal in the tree an E0063, across all four workspaces (root, oss/yah-base, oss/kamaji, oss/yubaba). 22 call sites needed a mechanical `requires: vec![],`. One of them is `headscale_spec()` in oss/yubaba/crates/yubaba/src/headscale_appliance.rs, a file @Ashguard:eclipse (session:83093d9d) is live in on R858 — left it in rather than break the camp build, notified both channels (party.chat + @yah:notify_on on R858).")
2734/// @yah:gotcha("R860-T3 does not exist (board_show: \"ticket 'R860-T3' not found\"). The first gotcha's \"R860-T3 (placement group)\" is really R860-T4 (\"Admission: place the transitive closure of `local` edges as one group\"), and supply=self enforcement is R860-T6. The doc comments landed in lib.rs cite T2/T4/T6, not T3.")
2735/// @yah:verify("Baseline recorded BEFORE any edit (`cargo test --manifest-path oss/yah-base/crates/workload-spec/Cargo.toml`): lib 156 passed / 0 failed, integration \"main\" 98 passed / 0 failed. NB `cargo test -p workload-spec` does NOT work — the package is `yah-workload-spec` and it lives in the excluded oss/yah-base workspace, so `-p` from the camp root fails with \"not a member of the workspace\". Use --manifest-path.")
2736/// @yah:handoff("Decision made without asking (brief said to decide and record): \"a `provides` spec's own name/mesh ident must match its Requirement::ident\" is enforced against `expose.mesh.identity`, NOT `name`. A requirement is written in mesh idents (same currency as depends_on) and the mesh identity is what makes the provider independently discoverable — W338's \"each member keeps its own mesh identity\". The error message still prints the provider's `name` so a mismatch is diagnosable from either side.")
2737/// @yah:handoff("Second decision: `tests/round_trip.rs::full_spec()` (\"every field family populated\") now populates `requires` with BOTH a bare prefer-local/wait entry and a local/self entry carrying a nested provider (new `sidecar_spec()` helper). That makes the three existing round-trip tests — JSON, postcard, and Workload::Container-over-postcard — carry the recursive `Option<Box<WorkloadSpec>>` rather than only the flat shape, which is the thing most likely to break silently on the kamaji UDS (cf. R590-B3).")
2738/// @yah:handoff("Third decision: `Locality`/`Supply` get hand-written `impl Default` rather than `#[derive(Default)]` + `#[default]`. Three derive macros (TS, JsonSchema, Serialize) sit on the same item and a bare `#[default]` variant attribute is only meaningful to one of them; the explicit impl removes any question about how the others parse it, at the cost of six lines.")
2739/// @yah:gotcha("THE TWO DRIFT GATES ARE STILL RED, and not because of drift. `check-schema-drift.sh` / `check-workload-spec-ts.sh` regenerate and then `git diff --quiet` the generated paths — so they fail for ANY uncommitted regeneration, in-sync or not. The artifacts ARE regenerated and correct in the working tree (.yah/schema/workload.toml.schema.json, .yah/schema/machine.toml.schema.json, packages/yah/workload-spec/index.ts); a pathspec-scoped `git commit` of exactly those three was attempted and DENIED by the approval gate. Commit those three paths and both gates go green — nothing else is needed.")
2740/// @yah:gotcha("Do NOT commit the SOURCE files alongside them in one shot. Several call sites the sweep touched — oss/yubaba/crates/yubaba/src/headscale_appliance.rs, oss/yubaba/crates/cloud/src/config.rs, oss/yubaba/crates/yubaba/src/deploy/mesh_resolve.rs — hold live peers' in-flight hunks in the same files, and git cannot split uncommitted edits by author, so a pathspec commit on those paths sweeps a peer's WIP in with mine.")
2741/// @yah:handoff("LANDED (uncommitted in the working tree). W338 requirement vocabulary in oss/yah-base/crates/workload-spec/src/lib.rs: `Locality { Anywhere, PreferLocal, Local }` (kebab-case wire: anywhere / prefer-local / local, default Anywhere); `Supply { Wait, SelfProvision }` (wire: wait / \"self\" via #[serde(rename)], default Wait); `Requirement { ident: MeshIdent, locality, supply, provides: Option<Box<WorkloadSpec>> }` with locality/supply/provides all #[serde(default)] and provides #[ts(optional = nullable)]. All three derive the LifecycleArchetype set (Debug/Clone/PartialEq/Serialize/Deserialize/TS + schemars::JsonSchema under `json-schema`); Locality/Supply also Copy/Eq. `WorkloadSpec::requires: Vec<Requirement>` is #[serde(default)]; `depends_on` untouched.")
2742/// @yah:next("Commit the three regenerated artifacts (see gotcha) — that is the only thing standing between this ticket and both drift gates going green.")
2743/// @yah:handoff("`WorkloadSpec::effective_requirements()` sits beside `effective_archetype` (same doc voice): returns `requires` verbatim, then appends each `depends_on` ident not already named there as `{ locality: Anywhere, supply: Wait, provides: None }`. Dedup by ident, requires wins, order = requires-first. Doc comment states callers MUST NOT read `requires` or `depends_on` directly. Vocabulary only — nothing branches on locality/supply yet, per the R572-F1 precedent.")
2744/// @yah:handoff("Validation: new `check_requires()` in src/validate.rs, called from `shape()` right after `check_mesh_ports`, plus a new `FieldPath::Requires(usize)` rendering as `requires[i]`. Four rules, each with an explicit message: (1) supply=\"self\" requires `provides` Some / supply=\"wait\" requires None, both directions; (2) a `provides` spec's expose.mesh.identity must equal the Requirement::ident; (3) depth 1 — a `provides` spec may not itself carry a supply=\"self\" requirement (nested \"wait\" IS allowed and is tested); (4) idents unique within `requires`, and none may equal the spec's own mesh identity.")
2745/// @yah:handoff("Tests: 15 new in lib.rs `mod tests` beside the effective_archetype ones — wire spellings (incl. the \"self\" rename), bare-ident defaults, recursive JSON round trip, the four effective_requirements cases (requires-only / depends_on-only / both-with-overlap / both-empty), and one per validation rule plus a positive case and the nested-wait-is-fine case. `cargo test --manifest-path oss/yah-base/crates/workload-spec/Cargo.toml`: lib 156 -> 171 passed, integration 98 -> 98 passed, 0 failed either side. `cargo build` for the crate clean. All four workspaces build --all-targets clean: root, oss/yah-base, oss/kamaji, oss/yubaba.")
2746/// @yah:handoff("Generated artifacts regenerated and verified by content, not just by exit code: packages/yah/workload-spec/index.ts:241-245 now declares `Locality = \"anywhere\" | \"prefer-local\" | \"local\"`, `Supply = \"wait\" | \"self\"`, `Requirement`, and WorkloadSpec.requires: Array<Requirement>. schemars accepted the recursion with no derive change. src/bin/export-ts.rs gained emit!(Locality/Supply/Requirement) before emit!(WorkloadSpec) — without that the TS file would have named three types it never declared (the same bug the AlmanacFeed comment there records).")
2747/// @yah:handoff("Scope beyond the brief's \"ONE file\", all of it compile-forced: 22 `WorkloadSpec { .. }` literals across four workspaces needed `requires: vec![],`. oss/yah-base: workload-spec/src/{lib.rs x3, compose_import.rs}, workload-spec/tests/{round_trip.rs x3, semantic.rs}, local-driver/src/{cloudflared_ingress,local_runtime,passway_ingress,pond_ssr_runtime}.rs. oss/kamaji: kamaji-proto/src/codec.rs. oss/yubaba: cloud/src/config.rs x4, cloud/src/reconciler/native_support.rs, yubaba/src/{headscale_appliance,pond/launcher,service_records,deploy/mesh_resolve}.rs, yubaba/tests/integration_*.rs x7. Every one is the inert one-liner; no behaviour changed anywhere.")
2748/// @yah:handoff("Peer coordination: @Ashguard:libra (session:0ea432a1, R844-B24) flagged mid-run that native_support.rs:71 was breaking `cargo check -p yah --lib` camp-wide; patched within the turn and replied. @Ashguard:eclipse (session:83093d9d, R858) is live in headscale_appliance.rs — the brief said not to touch it, but the file cannot compile without the new field, so the inert `requires: vec![],` went in with a comment, and both channels were used: a party.chat to session:83093d9d and a durable `@yah:notify_on(R860-T1)` on R858 naming the exact line to re-add if their rewrite re-authors that literal. None of appliance_ownership.rs, headscale_state.rs, litestream.rs, leader.rs or cluster_policy.rs was touched.")
2749/// @yah:handoff("Tree anchor at handoff: 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2 — the shared tree as I left it. Diff against it (`git diff 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2..HEAD`) to see what landed under you, and quote this SHA rather than 'HEAD' in any revert/restore instruction.")
2750/// @yah:verify("After committing the three generated paths: `bash scripts/check-schema-drift.sh && bash scripts/check-workload-spec-ts.sh` — both should print \"ok\". Re-run `cargo test --manifest-path oss/yah-base/crates/workload-spec/Cargo.toml` and expect lib 171 / integration 98, 0 failed.")
2751/// @yah:handoff("LEADER RE-VERIFIED (session:69b18855, independent of the courier's self-report). `cargo test -p yah-workload-spec` from oss/yah-base: 171 lib passed + 98 integration passed, 0 failed (baseline 156 + 98). Types confirmed by content at workload-spec/src/lib.rs — `enum Locality` :2361 with PreferLocal :2373, `enum Supply` :2399, `pub requires: Vec<Requirement>` :2582, `effective_requirements()` :2778. All four shape rules confirmed in validate.rs `check_requires` :309 — supply/provides pairing, provider-identity match, the depth-1 nesting bound :376-382, and ident uniqueness/self-naming. Generated artifacts regenerated with the recursion intact: `Locality = \"anywhere\" | \"prefer-local\" | \"local\"` at packages/yah/workload-spec/index.ts:241, `requires: Array&lt;Requirement&gt;` :373, and \"prefer-local\" / \"requires\" present in .yah/schema/workload.toml.schema.json.")
2752/// @yah:handoff("Tree anchor at handoff: 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2 — the shared tree as I left it. Diff against it (`git diff 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2..HEAD`) to see what landed under you, and quote this SHA rather than 'HEAD' in any revert/restore instruction.")
2753/// @yah:verify("cargo test -p yah-workload-spec (run inside oss/yah-base): 171 lib / 98 integration / 0 failed, vs a 156 / 98 baseline.")
2754/// @yah:gotcha("UNCOMMITTED AND THE DRIFT GATES ARE RED FOR EXACTLY THAT REASON. Three generated files are dirty in the working tree — .yah/schema/workload.toml.schema.json, .yah/schema/machine.toml.schema.json, packages/yah/workload-spec/index.ts. check-schema-drift.sh and check-workload-spec-ts.sh regenerate and then `git diff --quiet` the generated paths, so they can only go green once those three are committed. The courier attempted exactly that pathspec-scoped commit and it was DENIED by the approval gate; the leader did not route around that. Content is correct and verified (Locality/Requirement/requires present in both artifacts) — this is a commit-permission gap, not a code defect.")
2755/// @yah:handoff("23rd call site, found after handoff by @Ashguard:dragon (R863-T1/S2): app/yah/desktop/src/shell_host.rs in `shell_host_spec()` — added `requires: vec![],` after `depends_on: vec![],`. Confirmed with `cargo check --manifest-path app/yah/desktop/Cargo.toml --no-default-features`: runs to completion, only pre-existing unused-import/unused-variable warnings, zero errors. BLIND SPOT WORTH NAMING: the desktop crate is EXCLUDED from the root workspace, so `cargo build --workspace` never compiles it. Anyone adding a field to WorkloadSpec must check app/yah/desktop separately by manifest-path — the root workspace is not the full radius.")
2756/// @yah:gotcha("CORRECTION TO MY OWN EARLIER HANDOFF LINE \"all four workspaces build --all-targets clean\" — THAT CLAIM WAS WRONG. I ran those builds as `cargo build ... | grep -E \"E0063|^error\"` and read an EMPTY output file as success. It was not: those runs were being cut short, and a pipeline's exit code is grep's, not cargo's, so nothing surfaced the failure. Re-run with an explicit `${PIPESTATUS[0]}` marker, `cargo check --workspace --all-targets` returned ROOT_EXIT=101 with a real E0063 at crates/yah/hub/src/workload.rs. Lesson for anyone verifying a build behind a grep: print PIPESTATUS and a trailing DONE marker, or you cannot distinguish \"clean\" from \"never finished\".")
2757/// @yah:handoff("Sites 24-35, found by re-scanning after the desktop miss: 12 more WorkloadSpec literals needed `requires: vec![],`. crates/yah/hub/src/workload.rs (this one BROKE `cargo check --workspace` outright — it is a root-workspace member with the literal inside `#[cfg(test)] mod tests`); oss/kamaji/crates/kamaji/src/{containerd,docker,fake,native}.rs; oss/kamaji/crates/kamaji/tests/jit_lazy_fork.rs; oss/kamaji/crates/kamaji/examples/native_supervise.rs; oss/kamaji/crates/kamaji-bin/src/{containerd.rs, server.rs x2}; oss/kamaji/crates/kamaji-bin/tests/sibling_wire_e2e.rs; oss/kamaji/crates/kamaji-containerd-core/src/lib.rs. Running total: 35 call sites, all the same inert one-liner.")
2758/// @yah:handoff("FULL RADIUS for a WorkloadSpec field change, learned the hard way across three misses. It is FOUR cargo workspaces plus TWO excluded manifests, and `--all-targets` is not enough on kamaji because several backends sit behind non-default features: (1) `cargo check --workspace --all-targets` [root]; (2) `--manifest-path oss/yah-base/Cargo.toml --all-targets`; (3) `--manifest-path oss/yubaba/Cargo.toml --all-targets`; (4) `--manifest-path oss/kamaji/Cargo.toml --all-targets --all-features`; (5) `--manifest-path app/yah/desktop/Cargo.toml --no-default-features` (EXCLUDED from the root workspace — `cargo build --workspace` never sees it); (6) grep the tree directly for `WorkloadSpec {` literals rather than trusting any one build. A text scan is the only check that does not depend on feature flags or workspace membership.")
2759/// @yah:handoff("Verified after the 12-site fix, with explicit PIPESTATUS and a trailing DONE marker this time: `cargo check --manifest-path oss/kamaji/Cargo.toml --all-targets --all-features` -> KAMAJI_EXIT=0, fully clean. `cargo check --workspace --all-targets` -> ZERO E0063 remaining, so the R860-T1 sweep is complete for the root workspace; it still exits 101 on 2 errors in `yah` (lib) that are NOT E0063 and not from this ticket — being attributed separately, and @Ashguard:adacf33c is running `cargo test -p yah --lib -- cloud::` against that same crate right now.")
2760/// @yah:gotcha("The root workspace still exits 101, but NOT from R860-T1 — attributed and it is a peer's. `app/yah/cli/src/keys_doctor.rs` does not PARSE: 4331:1 \"unknown start of token: \\\" and 4336:5 a `///` doc comment not attached to an item, inside what reads as a mangled R856-T10/T11 annotation block. Left untouched (shared-tree: live peer's file, their ticket); @Ashguard:spade (session:9ca2da4f, R856) notified with the exact lines. Those two parse errors are the only thing between the root workspace and a green check.")
2761/// @yah:handoff("Sweep edits audited by content after @Ashguard:spade hit an over-escaped-heredoc bug in the same window: `git diff -U0` across crates/yah/hub, oss/kamaji and app/yah/desktop/src/shell_host.rs yields exactly 14 added lines, all byte-identical `requires: vec![],` (10 at 12-space indent, 4 at 8-space) and nothing else. Worth doing rather than reasoning about — a quoted heredoc (<<'PY') passes backslashes through to python unexpanded, an unquoted one does not, and the difference silently lands a literal two-character \\n in source. That is exactly what broke app/yah/cli/src/keys_doctor.rs:4331 (R856-T11, fixed by its owner). If you script a multi-site edit, diff the result and count the added lines.")
2762/// @yah:handoff("CORRECTION TO THIS TICKET'S OWN FIRST VERIFICATION CLAIM — the sweep was 35 call sites, not 22, and the \\\"all four workspaces build clean\\\" line recorded earlier was FALSE. Two independent verifications had reported clean without ever running: (a) `cargo build … | grep -E \\\"E0063|^error\\\"` was read as success on empty output, but a pipeline's exit status is grep's, not cargo's, and those runs were being cut short — so \\\"no output\\\" meant \\\"never finished\\\"; re-run with `${PIPESTATUS[0]}` and a trailing marker, the same command returned ROOT_EXIT=101. (b) An `rg -l --glob` cross-check was a silent no-op, because this shell's `rg` is ugrep, which rejects `--glob` and returns zero files. One of the 13 missed sites (crates/yah/hub/src/workload.rs) was breaking `cargo check --workspace` outright and 11 more were latent in kamaji. All 13 are now patched with the same inert `requires: vec![],`.")
2763/// @yah:verify("POST-CORRECTION STATE, checked with explicit exit codes rather than grep-on-a-pipeline. `cargo check --manifest-path app/yah/desktop/Cargo.toml --no-default-features` → runs to completion, 0 errors (13 pre-existing warnings) — leader re-ran this independently. oss/kamaji --all-targets --all-features → KAMAJI_EXIT=0. `cargo check --workspace --all-targets` → zero E0063 remaining, sweep complete. THE RADIUS FOR A WorkloadSpec FIELD CHANGE IS SIX COMMANDS, NOT ONE: the root workspace excludes app/yah/desktop and each oss/* is its own workspace, so `cargo build --workspace` has a blind spot exactly the size of the excluded crates — which is how the desktop miss survived, and it was @Ashguard:dragon (R863) hitting the E0063 that surfaced it.")
2764/// @yah:verify("FINAL, all with explicit ${PIPESTATUS[0]} and a trailing DONE marker: `cargo check --workspace --all-targets` -> ROOT_EXIT=0 (green, once @Ashguard:spade fixed the keys_doctor.rs parse error); `cargo check --manifest-path oss/kamaji/Cargo.toml --all-targets --all-features` -> KAMAJI_EXIT=0; `cargo check --manifest-path app/yah/desktop/Cargo.toml --no-default-features` -> zero errors; `cargo test --manifest-path oss/yah-base/crates/workload-spec/Cargo.toml` -> lib 171 passed / integration 98 passed / 0 failed (baseline was 156 / 98 / 0). All 35 WorkloadSpec call sites carry `requires`.")
2765/// @yah:handoff("Column set to handoff by the R860 leader (session:69b18855). The work and its verification were already complete and recorded above; this entry exists because the ticket's derived column had fallen back to `open` after its courier's session was closed.")
2766/// @yah:handoff("Tree anchor at handoff: 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2 — the shared tree as I left it. Diff against it (`git diff 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2..HEAD`) to see what landed under you, and quote this SHA rather than 'HEAD' in any revert/restore instruction.")
2767/// @yah:handoff("Tree anchor at handoff: 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2 — the shared tree as I left it. Diff against it (`git diff 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2..HEAD`) to see what landed under you, and quote this SHA rather than 'HEAD' in any revert/restore instruction.")
2768/// @yah:handoff("GENERATED-ARTIFACT BLOCKER CLEARED. The two schema JSON files this ticket regenerated (.yah/schema/workload.toml.schema.json, .yah/schema/machine.toml.schema.json) were committed by the operator in 89ace71c; packages/yah/workload-spec/index.ts landed earlier in 4bed91fe. Both drift gates are now GREEN — nothing on R860 is waiting on a permission any more.")
2769/// @yah:verify("RE-VERIFIED AT HEAD 00ee20d1 (session:aa5e882d, 2026-09-05), two commits past the 4bed91fe the prior leader checked. `bash scripts/check-schema-drift.sh` exit 0 (\"ok: .yah/schema is in sync with the Rust types\"); `bash scripts/check-workload-spec-ts.sh` exit 0. `cargo test --manifest-path oss/yah-base/crates/workload-spec/Cargo.toml` exit 0, 0 failed. Types confirmed by content at workload-spec/src/lib.rs: `enum Locality` :2384, `enum Supply` :2422, `struct Requirement` :2458, `pub requires: Vec&lt;Requirement&gt;` :2635, `effective_requirements()` :2831. `git status --porcelain` clean on all three generated paths.")
2770///
2771/// @yah:ticket(R896-F3, "Move yah.limits.* / yah.placement.memory-request-mb / yah.durability.* annotations into typed WorkloadSpec fields")
2772/// @yah:status(review)
2773/// @yah:at(2026-09-14T22:08:18Z)
2774/// @yah:assignee(agent:bundle-anthropic-ashguard)
2775/// @yah:parent(R896)
2776/// @yah:next("Tier: Warrior. Now possible without a ProtocolVersion bump because R896-F2's V13 envelope carries Deploy.spec name-keyed. Per W349 'What the migration child gets to do': yah.limits.cpu-millis -> cpu_limit_millis: Option<u32>, yah.limits.scratch-floor-mb -> scratch_floor_mb: Option<u32>, yah.limits.pids-max, yah.placement.memory-request-mb, yah.durability.* -> durability: Option<Durability> (parse + error type already exist in lib.rs). Each field gets #[serde(default)] = None = today's no-annotation behaviour, so it crosses a skew. Delete the annotation readers in the same change (below-1.0: one shape wins) and migrate any workload.toml / reconciler that writes the annotations. yah.placement.requires-taint is a scheduler input, decide separately; yah.exec / yah.sandbox stay annotations.")
2777/// @yah:next("Rust-side radius is the other half of the cost (W349 'The other half'): WorkloadSpec has no Default and ~35 exhaustive literals across six workspaces; run the six-command sweep R860 names, not root --workspace. oss/kamaji/crates/kamaji-proto/tests/tolerant_field_policy.rs stays green only if every new field defaults; regen .yah/schema + workload-spec TS after.")
2778/// @arch:see(.yah/docs/working/W349-evolvable-kamaji-wire-envelope.md)
2779/// @yah:gotcha("SILENT BACKUP-LOSS HAZARD, found before any edit (session:dc6af742, 2026-09-14). ~/ss/noisetable authors these annotations in its own repo: .yah/infra/workloads/noisetable-account.toml and .yah/services/noisetable-api/mirrors/prod.toml. Deleting the annotation readers in this repo makes those specs parse clean with durability silently read as none (an ignored annotation is indistinguishable from an absent one), which would turn off the production account DB's backups without an error. The reverse direction is just as silent: once noisetable's TOML moves to typed fields, any node still on pre-F3 yubaba ignores the unknown `durability` key (WorkloadSpec has no deny_unknown_fields). So F3 needs (a) a loud refusal in validate.rs for any surviving yah.limits.* / yah.durability.* / yah.placement.memory-request-mb annotation, naming the replacement field, and (b) roll order: fleet on F3 code first, noisetable TOML second. Accessor call sites to move: workload-spec lib.rs 28, kamaji microvm.rs 6, workload-spec tests/restart_policy.rs 5, qed velveteen-exec remote.rs 3, cloud topology.rs 2, cloud config.rs 2, kamaji cgroup.rs 2, yubaba lib.rs 1, validate.rs 1, kamaji-bin hydrate.rs 1; plus annotation-literal fixtures in topology.rs, hydrate.rs, tail.rs, and kamaji-bin server.rs:5681 (dirty under R895, live peer @Ashguard:blade).")
2780/// @yah:handoff("OPERATOR CALL ANSWERED 2026-09-14 (ask_user, session:dc6af742): 'typed fields + loud refusal + staged roll, AND edit noisetable'. Nothing is implemented yet: this session scoped the work, then handed off at a clean tree rather than starting a ~100-site migration at ~190k context. Only ONE noisetable file needs migrating: ~/ss/noisetable/.yah/infra/workloads/noisetable-account.toml:307-311 (tier=stream, engine=turso, store=s3://noisetable-account-backup/noisetable-account, subjects=account.db,grants.db,projects.db,sessions.db, rpo-seconds=120), plus its prose at :219 and :253. The prod.toml hit is annotation prose only. Edit noisetable UNCOMMITTED; the operator ships it after the fleet runs F3 code.")
2781/// @yah:handoff("DESIGN DEFAULTS PICKED (reversible, say so if you change them): (1) Limits move onto ResourceLimits (lib.rs:5485, the old home of ephemeral_storage_mb) as #[serde(default)] Option<u32> fields: memory_request_mb, cpu_limit_millis, pids_max, scratch_floor_mb. Keep the WorkloadSpec METHODS memory_request_mb() (falls back to resources.memory_mb) and pids_limit() (falls back to DEFAULT_PIDS_MAX); they now read the fields. Those fallbacks are real semantics, not shims. Delete cpu_limit_millis()/scratch_floor_mb() accessors, or keep them as field reads if that is cleaner. (2) durability: Option<Durability> on WorkloadSpec, #[serde(default)]. Durability (lib.rs:3874) already derives Serialize/Deserialize; add TS + json-schema cfg_attr to it and to DurabilityTier/DurabilityEngine. The cross-field checks in DurabilityDeclError (lib.rs:3913, messages at :3959-4041) move into validate::shape (validate.rs:428; it already reports durability at :578-646 via FieldPath::Annotation, which becomes a field path). (3) Loud refusal: validate::shape rejects any annotation key starting 'yah.limits.' or 'yah.durability.', or equal to 'yah.placement.memory-request-mb', with an error naming the replacement field. Then delete the *_ANNOTATION consts (lib.rs:4226-4303).")
2782/// @yah:handoff("SITES (grep with line numbers from this session): workload-spec lib.rs for_forge :2949/:2959 (sets memory-request + FORGE_SCRATCH_FLOOR_MB via annotations), accessors :3183-3290 and durability() :3627, tests :7743-8158; workload-spec tests/restart_policy.rs :100-130, tests/shape_fixtures.rs (14 hits); validate.rs :19/:578-646; kamaji cgroup.rs :643/:645, microvm.rs :1008/:1561/:3155-3178, native.rs :1973; kamaji-bin hydrate.rs :127 + fixtures :355-528, tail.rs :298-303, server.rs :5681-5684 (fixture only; server.rs is DIRTY under R895, live peer @Ashguard:blade, so touch only those lines); yubaba lib.rs :5575, headscale_appliance.rs :409-421/:643-646 (constructs a durability declaration via annotations); cloud config.rs :2363/:10715/:11639, topology.rs :805/:823/:990/:1033 + TOML fixtures :1492-2207; qed velveteen-exec remote.rs :1159/:2368-2374; kamaji-proto digest.rs:184 (a test key list only; leave yah.exec/yah.sandbox, drop the limits key).")
2783/// @yah:handoff("Tree anchor at handoff: 30c2c02c84f8acd5862f2a647ef958fd304fa218 — the shared tree as I left it. Diff against it (`git diff 30c2c02c84f8acd5862f2a647ef958fd304fa218..HEAD`) to see what landed under you, and quote this SHA rather than 'HEAD' in any revert/restore instruction.")
2784/// @yah:next("Implement per the handoff defaults, then run the six-workspace sweep R860 names: root cargo check --workspace --all-targets, app/yah/desktop, oss/kamaji --all-features, oss/yubaba --all-targets, oss/yah-base (workload-spec tests), oss/qed (velveteen-exec). Adding Option fields to ResourceLimits breaks every exhaustive ResourceLimits literal, and adding durability breaks every WorkloadSpec literal (~35 across six workspaces). Fix them all; never end a turn with the tree red.")
2785/// @yah:next("oss/kamaji/crates/kamaji-proto/tests/tolerant_field_policy.rs must stay green with an UNCHANGED frozen list: every new field defaults. Then regen with `bash scripts/check-schema-drift.sh --update` and `cargo run --manifest-path oss/yah-base/crates/workload-spec/Cargo.toml --bin export-ts`. Update W349's 'What the migration child gets to do' section to say it landed.")
2786/// @yah:gotcha("ROLL ORDER IS THE SAFETY PROPERTY, not the code. Old nodes (release 0.8.36, V9-V11) silently ignore an unknown `durability` TOML key, so noisetable-account's backups would go quiet if its TOML moved first. Sequence: F3 code ships to the fleet (a paired ship, since it rides V13), THEN noisetable's TOML. Say so in the review handoff so the operator sequences it.")
2787/// @yah:handoff("IMPLEMENTED 2026-09-14 (session:cd4dec9f), per the picked defaults. ResourceLimits gained memory_request_mb / cpu_limit_millis / pids_max / scratch_floor_mb (Option<u32>, serde default); WorkloadSpec gained durability: Option<Durability> (serde default, before annotations). Durability/DurabilityTier/DurabilityEngine derive TS + JsonSchema; subjects is a list, rpo_seconds/state_mb numbers. Cross-field rules moved to Durability::check, applied by WorkloadSpec::durability() -> Result<Option<&Durability>>. Loud refusal: WorkloadSpec::retired_annotation() + pub fn retired_annotation_field(key) map every retired key to its field; validate::shape refuses with FieldPath::Annotation(key) naming the field, and durability() also refuses any yah.durability.* key (so kamaji hydrate refuses too). All *_ANNOTATION consts for those keys deleted. Accessors kept with their fallbacks (0 => unset for all four, incl. memory_request_mb which now also treats 0 as undeclared). FieldPath::Annotation is now String; new FieldPath::Durability(sub). ~100 struct literals across six workspaces fixed by a compiler-driven script. Migrated sites: for_forge, velveteen remote.rs, cloud config.rs test helper, kamaji native.rs/tail.rs/server.rs/hydrate.rs tests, headscale_appliance.rs (typed field; @Ashguard:citadel notified, recorded the roll hazard on R858), topology.rs (Declared(d.clone()) + TOML fixtures -> [durability] tables, hints -> durability.state_mb), digest.rs test key. TS bindings regenerated. W349 gained a 'Landed (R896-F3)' section. noisetable-account.toml edited UNCOMMITTED in ~/ss/noisetable ([durability] table + roll-order warning).")
2788/// @yah:handoff("LANDED: yah.limits.* / yah.placement.memory-request-mb / yah.durability.* are typed fields (ResourceLimits.{memory_request_mb,cpu_limit_millis,pids_max,scratch_floor_mb}, WorkloadSpec.durability), every one #[serde(default)] so it crosses a V13 skew with no ProtocolVersion bump. Retired annotations are REFUSED (validate::shape names the replacement field; WorkloadSpec::durability() also refuses yah.durability.* so kamaji hydrate fails closed). Full detail in the IMPLEMENTED handoff above. Schemas + TS bindings regenerated; W349 has a 'Landed (R896-F3)' section. Discovered-and-fixed beyond the site list: desktop shell_host.rs, crates/yah/hub workload.rs, local-driver passway/cloudflared literals; yah-cloud-admin.toml and cloud-client doc prose; memory_request_mb() now also treats 0 as undeclared (a zero request would admit anywhere).")
2789/// @yah:verify("root: cargo check --keep-going --workspace --all-targets → exit 0 (includes desktop); oss/qed: cargo check -p velveteen-exec --all-targets → green; scripts/check-schema-drift.sh and scripts/check-workload-spec-ts.sh → ok")
2790/// @yah:gotcha("oss/kamaji --all-targets check is currently red ONLY in a peer's in-flight test code in crates/kamaji/src/container_net.rs (Cmd.ignore_failure), not mine. The kamaji lib itself compiles.")
2791/// @yah:cleanup("scripts/roll-node.sh and scripts/publish-yubaba-release.sh still mention `yah.durability.tier` in historical comments.")
2792///
2793/// @yah:ticket(R896-T4, "SchemaVersion: adopt as the per-spec migration carrier or delete it (W349 item 4)")
2794/// @yah:status(review)
2795/// @yah:at(2026-09-14T23:51:33Z)
2796/// @yah:assignee(agent:bundle-anthropic-ashguard)
2797/// @yah:parent(R896)
2798/// @arch:see(.yah/docs/working/W349-evolvable-kamaji-wire-envelope.md)
2799/// @yah:handoff("DELETED, not adopted (the ticket's recommendation; grounded before the edit). SchemaVersion enum + oss/yah-base/crates/workload-spec/src/version.rs gone; `schema_version` field removed from WorkloadSpec, ContainerBuild, MesofactStaticWorkload, TenantPasswayWorkload, AlmanacManifest, StaticAssetWorkload; export_ts emit removed; every constructor across oss/kamaji (15 files + kamaji-proto digest.rs), oss/yubaba (20), oss/yah-base local-driver (4), crates/yah/hub, app/yah/cli cloud.rs, app/yah/desktop shell_host.rs stripped; the frozen list in kamaji-proto/tests/tolerant_field_policy.rs dropped `schema_version`; key removed from 13 workload.toml/workload.json manifests (incl. .yah/infra/workloads/yah-cloud-admin.toml, rusty-v8-musl), 17 workload-spec JSON fixtures, cloud-client's sample JSON, the TS round-trip test, and scripts/check-cloud-admin-image-guard.sh. Ticket annotation moved from version.rs onto `pub struct WorkloadSpec`. W349 item 4 + its status line updated. WHY DELETION IS SAFE ON READ: no carrying struct denies unknown keys, so a leftover key is ignored — pinned by new tests lib.rs `a_legacy_schema_version_key_is_ignored` (TOML, both spellings) and tests/round_trip.rs `a_legacy_schema_version_key_is_ignored_and_not_written` (JSON; replaced `schema_version_serializes_as_v1`). Nothing persisted carries it: yubaba-consensus raft holds no WorkloadSpec; kamaji's on-disk BundleDeployRecord holds MesofactServeBundle only. NOT TOUCHED, deliberately: same-named but unrelated fields — tower-rules SchemaVersion, mesofact-bundle/tenant-pointer u32s, service/mirror/domain/secret/provider `schema_version` (u32), kamaji StatefulServiceContract, passway README route config; external ~/ss/noisetable workload tomls keep a now-ignored key. Discovered stale docs fixed: cloud reconciler/mod.rs workload_kind() and mesofact_static.rs read_mesofact_build() both claimed the typed envelope rejects `schema_version = 1` (false since R546-B7, moot now).")
2800/// @yah:gotcha("ROLL SKEW THIS OPENS (the one direction): an OLD reader requires the key. On-node yubaba->kamaji is already a matched V13 paired ship, so it rides that. Off-node: a post-T4 CLI/desktop `POST /workloads/deploy` (cloud-client deploy_workload) against an un-rolled yubaba fails loudly as a missing-field decode until that node rolls. Old clients against new nodes are fine (extra key ignored).")
2801/// @yah:handoff("VERIFY PASS 2026-09-14 (session:e4478e54). Deletion confirmed by content; follow-on fixes found while verifying: (1) .yah/schema/workload.toml.schema.json was still emitting SchemaVersion — regenerated via `cargo run -p xtask -- emit-schemas` (-65 lines); packages/yah/workload-spec/index.ts was already current. (2) Third stale copy of the 'typed envelope rejects schema_version = 1' justification fixed at app/yah/cli/src/cloud.rs read_workload_build doc (~:5653). (3) Dead `schema_version = \"V1\"` keys dropped from test fixtures: cloud.rs write_workload_with_aliases (~:19298), oss/yubaba/crates/cloud/src/validate.rs (:1090, :1222); mesofact_static.rs read_mesofact_build_extracts_host_side_default comment (~:2295) reworded — it deliberately keeps the legacy integer key as an ignored-key regression. (4) R896-F3 breakage: its literal sweep missed two yah-local-driver test helpers — local_runtime.rs:1308 and pond_ssr_runtime.rs:431 lacked memory_request_mb/cpu_limit_millis/pids_max/scratch_floor_mb and durability; filled with None.")
2802/// @yah:verify("cargo test --manifest-path oss/yah-base/Cargo.toml -p yah-workload-spec: 205 + 107 passed, 0 failed")
2803/// @yah:verify("cargo test --manifest-path oss/kamaji/Cargo.toml -p kamaji-proto: 39 + 4 + 5 passed, 0 failed")
2804/// @yah:verify("cargo test --manifest-path oss/yah-base/Cargo.toml -p yah-local-driver: 111 passed (was a compile failure before fix 4)")
2805/// @yah:verify("cargo test --manifest-path oss/yubaba/crates/cloud/Cargo.toml --lib -- validate mesofact_static: 111 passed")
2806/// @yah:verify("cargo check --tests clean (no errors) for oss/yah-base, oss/yubaba, oss/kamaji workspaces and root `-p yah`")
2807/// @yah:verify("./scripts/check-schema-drift.sh: ok, in sync")
2808/// @yah:gotcha("An earlier `cargo check -p yah -p yah-hub --tests` in this pass hit `recursion limit reached while expanding $crate::json_internal!` in the yah lib; a re-check minutes later compiled clean with no edit from me — a peer's in-flight edit (app/yah/cli/src/mcp/tools.rs is dirty in the shared tree), not this ticket.")
2809/// @yah:handoff("RE-CHECK 2026-09-14 (session:1e5ba11e). Deletion still holds by content: version.rs absent, no SchemaVersion in workload-spec/kamaji-proto/.yah/schema/TS, no workload manifest carries the key. Dropped two more dead `schema_version = 1` lines from scaffold-manifest fixtures in oss/yah-base/crates/workload-spec/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). `cargo test -p yah-workload-spec`: 205 + 107 passed. Remaining `schema_version = 1` hits in oss/mesofact server.rs / route_headers_parity.rs are the mesofact-bundle manifest's own u32 — unrelated, correctly untouched.")
2810///
2811/// @yah:ticket(R896-B5, "apply's R892 schema-drift lint hard-errors on the legacy schema_version key that R896-T4 declared safe to leave ignored")
2812/// @yah:status(review)
2813/// @yah:at(2026-09-15T17:51:01Z)
2814/// @yah:assignee(agent:bundle-anthropic-ashguard)
2815/// @yah:parent(R896)
2816/// @yah:next("\"Tier: Cleric. Give the R892 lint (wherever it lives — grep the exact error text 'declares a key the workload schema does not have') a concept of 'known-legacy, intentionally-ignored' keys, seeded at minimum with schema_version, OR have R896-T4-style field deletions register their retired key in whatever allowlist the lint reads. Either way the fix should mean a future retired-field cleanup does not have to manually sweep every downstream repo's committed TOML the same day the field is deleted upstream.\"")
2817/// @yah:gotcha("\"THE CONFLICT: R896-T4's handoff explicitly says 'external ~/ss/noisetable workload tomls keep a now-ignored key' as an accepted, safe end state — deletion from WorkloadSpec was deliberately NOT paired with deleting the key from noisetable's own committed TOML, because leaving it is meant to be harmless. But the R892 anti-silent-drop lint (added after the 2026-09-11 incident where a key present in a file and absent from the deployed spec destroyed a live workload) does not know schema_version is on an allowed-legacy list — it has no such list — so it hard-errors exactly as it's designed to for ANY unrecognized key, including this now-intentionally-tolerated one. Worked around downstream by deleting the dead key from noisetable's three workload.toml files (harmless per R896-T4), but that only fixes this one repo for this one key; the general shape of the conflict (a field WorkloadSpec deliberately deletes-but-tolerates vs. a lint that has no concept of 'tolerated legacy key') will recur for the next field R896 or a similar cleanup retires.\"")
2818/// @yah:assumes("\"NOT verified: whether other downstream repos beyond ~/ss/noisetable also carry schema_version in committed workload.toml files and would hit the same apply failure the next time they run current yah.\"")
2819/// @arch:see(.yah/docs/working/W349-evolvable-kamaji-wire-envelope.md)
2820/// @yah:handoff("LANDED: `workload_spec::RETIRED_KEYS` (+ `RetiredKey {path, retired_by}`) right after `pub struct WorkloadSpec` in oss/yah-base/crates/workload-spec/src/lib.rs, seeded with `schema_version` / R896-T4. Doc states the rule: only INERT keys go on it — a key whose value changed node behaviour (resources.ephemeral_storage_mb) must stay refused. A future field deletion registers its key in the same diff, so no downstream TOML sweep is forced.")
2821/// @yah:handoff("oss/yubaba/crates/cloud/src/config.rs refuse_dropped_keys: dropped paths found in RETIRED_KEYS are filtered out and printed as `warning: <file> declares `<key>`, retired by <ticket> and ignored; delete it`; every other dropped key still hard-errors unchanged.")
2822/// @yah:handoff("Discovered: the R892-B1 test fixture REAL_WORKLOAD_TOML still carried `schema_version = 1` (the real yah-cloud-admin.toml no longer does), so `a_workload_file_whose_keys_all_survive_the_parse_is_accepted` was refusing its own fixture after R896-T4 (inferred from the code; not run against the pre-change tree). Dropped the dead key from the fixture.")
2823/// @yah:handoff("New tests in config.rs: `every_retired_key_is_ignored_not_refused` (registry-driven, handles dotted paths) and `a_retired_key_does_not_excuse_an_unknown_one`.")
2824/// @yah:verify("cd oss/yubaba && cargo test -p yah-cloud --lib → 1253 passed, 0 failed, 4 ignored")
2825/// @yah:verify("cd oss/yah-base && cargo test -p yah-workload-spec → 207 + 108 passed")
2826/// @yah:verify("Both packages are NOT root-workspace members: `cargo test -p yah-cloud` from the repo root errors 'not a member of the workspace' — run from oss/yubaba / oss/yah-base.")
2827#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
2828#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2829pub struct WorkloadSpec {
2830    /// DNS-friendly workload name, e.g. `"noisetable-api"`. Regex:
2831    /// `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`, length ≤ 63.
2832    pub name: String,
2833
2834    /// Container image to pull.
2835    pub image: ImageRef,
2836
2837    /// Tier tag controlling admission control and mesh filtering.
2838    pub tier: TierTag,
2839
2840    /// Tenant **isolation** axis (W206). Separates operators' workloads at the
2841    /// network / DB / mesh-identity level. Defaults to [`TenantId::singleton`]
2842    /// for specs that predate the axis, so single-tenant clusters keep every
2843    /// isolation primitive a no-op. Orthogonal to [`Self::tier`] (class) and
2844    /// [`Self::namespace`] (routing).
2845    #[serde(default = "TenantId::singleton")]
2846    pub tenant: TenantId,
2847
2848    /// Namespace **routing/naming** axis (W206). A pure naming key — never
2849    /// affects isolation; disambiguates DNS names and selects config root /
2850    /// provider zone within a tenant. Defaults to [`NamespaceId::singleton`].
2851    #[serde(default = "NamespaceId::singleton")]
2852    pub namespace: NamespaceId,
2853
2854    /// Target replica count. `0` registers the workload without deploying it.
2855    /// Range: 0–100 (cluster-wide cap; operator can raise it).
2856    pub replicas: u32,
2857
2858    /// Override the image's `CMD`. `None` leaves the image default.
2859    #[ts(optional = nullable)]
2860    pub command: Option<Vec<String>>,
2861
2862    /// Override the image's `ENTRYPOINT`. `None` leaves the image default.
2863    #[ts(optional = nullable)]
2864    pub entrypoint: Option<Vec<String>>,
2865
2866    /// Working directory inside the container.
2867    #[ts(optional = nullable)]
2868    pub workdir: Option<PathBuf>,
2869
2870    /// User to run as, e.g. `"1000:1000"` or `"appuser"`.
2871    #[ts(optional = nullable)]
2872    pub user: Option<String>,
2873
2874    /// Environment variables. Values may be literals, secret refs, or
2875    /// mesh-address references resolved by yubaba at deploy time.
2876    #[serde(default)]
2877    pub env: Vec<EnvVar>,
2878
2879    /// Secret mounts. Values never appear in the spec JSON — only references.
2880    #[serde(default)]
2881    pub secrets: Vec<SecretMount>,
2882
2883    /// Volume mounts.
2884    #[serde(default)]
2885    pub volumes: Vec<VolumeMount>,
2886
2887    /// Hard resource caps enforced by containerd/cgroups.
2888    pub resources: ResourceLimits,
2889
2890    /// Mesh idents that must reach `Ready` before this workload starts.
2891    ///
2892    /// Superseded by [`Self::requires`] (R860-T1 / W338) and kept as-is for
2893    /// wire compatibility: every entry here means exactly
2894    /// `Locality::Anywhere` + `Supply::Wait`. Callers MUST NOT read this
2895    /// directly — use [`WorkloadSpec::effective_requirements`], which folds
2896    /// both fields into one list.
2897    #[serde(default)]
2898    pub depends_on: Vec<MeshIdent>,
2899
2900    /// What this workload needs before it can run, with locality and supply
2901    /// (R860-T1 / W338). The widened form of [`Self::depends_on`].
2902    ///
2903    /// Additive: this field did not exist before R860-T1, and a spec that omits
2904    /// it is unchanged in meaning. Callers MUST NOT read this directly either —
2905    /// [`WorkloadSpec::effective_requirements`] is the only supported read,
2906    /// because a spec written against the old vocabulary carries its
2907    /// requirements in `depends_on` and would otherwise look requirement-free.
2908    ///
2909    /// Vocabulary only: nothing branches on `locality` or `supply` yet. The
2910    /// deploy gate (R860-T2) and the placement group (R860-T4) are separate,
2911    /// later tickets — this field alone changes no runtime behaviour, exactly
2912    /// as [`Self::archetype`] landed in R572-F1.
2913    #[serde(default)]
2914    pub requires: Vec<Requirement>,
2915
2916    /// Container liveness/readiness probe.
2917    #[ts(optional = nullable)]
2918    pub healthcheck: Option<Healthcheck>,
2919
2920    /// What yubaba does when the container exits.
2921    pub restart_policy: RestartPolicy,
2922
2923    /// Explicit lifecycle archetype (R572-F1 / W244): `server`, `appliance`,
2924    /// or `job`. `None` means the spec predates this field (or the author
2925    /// didn't set it) — callers MUST NOT read this directly to decide
2926    /// drainability; use [`WorkloadSpec::effective_archetype`], which falls
2927    /// back to the pre-R572 `volumes`/`restart_policy` inference so no
2928    /// existing spec's effective meaning changes.
2929    ///
2930    /// Additive: this field did not exist before R572-F1. Reconciler (F4)
2931    /// and scheduler (F5) branching on the resolved archetype are separate,
2932    /// later tickets — this field alone changes no runtime behavior.
2933    #[serde(default)]
2934    #[ts(optional = nullable)]
2935    pub archetype: Option<LifecycleArchetype>,
2936
2937    /// Graceful shutdown configuration.
2938    pub stop_policy: StopPolicy,
2939
2940    /// Network exposure configuration — mesh, public, and operator channels
2941    /// are independent and can be set in any combination.
2942    pub expose: ExposeSpec,
2943
2944    /// Where a second copy of this workload's state lives, and how far behind
2945    /// it may be (R850-P4). `None` means **nobody said**, which is a different
2946    /// answer from a declared [`DurabilityTier::None`] — see
2947    /// [`WorkloadSpec::durability`], the only supported read, since it also
2948    /// enforces the cross-field rules serde cannot.
2949    ///
2950    /// Additive and defaulted: a peer that predates the field sends no
2951    /// declaration, which is exactly what it meant (R896-F3). It replaced the
2952    /// `yah.durability.*` annotation family; a spec still carrying one of
2953    /// those keys is refused rather than read as undeclared.
2954    #[serde(default)]
2955    #[ts(optional = nullable)]
2956    pub durability: Option<Durability>,
2957
2958    /// Databases this workload declares for the data workbench (R960-F6, W358
2959    /// decision 7). Each row with `workbench != none` surfaces in the camp's
2960    /// catalog as `fleet:<workload>:<name>`; the row itself starts no
2961    /// connection. Read through [`WorkloadSpec::db_rows`], which also enforces
2962    /// the cross-field rules serde cannot.
2963    ///
2964    /// Additive and defaulted: a peer that predates the field ignores it (no
2965    /// `deny_unknown_fields`), which is safe — `db` only feeds the camp-side
2966    /// catalog, never a node's behaviour.
2967    ///
2968    /// **Always serialized, even when empty.** `skip_serializing_if` would be
2969    /// the obvious way to keep an empty `db` out of the kamaji spec digest, but
2970    /// this struct rides positional postcard too (`tests/round_trip.rs`), where
2971    /// it misaligns the frame. The digest strips an empty `db` itself — see
2972    /// `kamaji_proto::digest::spec_digest`.
2973    #[serde(default)]
2974    pub db: Vec<WorkloadDb>,
2975
2976    /// What this workload offers the mesh, each bound to one of its named
2977    /// `expose.mesh.ports` (R960-F8, W358). The node running it publishes
2978    /// these on `GET /services`. A `vend` [`WorkloadDb`] row adds its own
2979    /// `sql.hrana` capability; it is not repeated here. Read through
2980    /// [`WorkloadSpec::served_capabilities`].
2981    ///
2982    /// Always serialized, and stripped from the kamaji digest when empty, for
2983    /// the same postcard reason as [`Self::db`].
2984    #[serde(default)]
2985    pub capabilities: Vec<WorkloadCapability>,
2986
2987    /// OCI-style labels, passed through to the container. Opaque to yubaba.
2988    #[serde(default)]
2989    pub labels: HashMap<String, String>,
2990
2991    /// Yah-specific metadata, conventionally prefixed `yah.*`. Opaque to
2992    /// yubaba beyond `yah.forge=true` which suppresses the Never-restart guard.
2993    #[serde(default)]
2994    pub annotations: HashMap<String, String>,
2995
2996    /// Config files the node writes out **before** the workload starts, and
2997    /// rewrites on every redeploy (R870-F23).
2998    ///
2999    /// The case this exists for is a workload whose configuration is *derived
3000    /// from the control plane's own config* rather than baked into an image or
3001    /// expressible as an env var: R870's inner door reads its mount table from
3002    /// a JSON file (`PASSWAY_PATH_ROUTES_FILE`), because a mount carries an
3003    /// upstream *set* and a header map and an env var would have to invent two
3004    /// nesting levels inside one string.
3005    ///
3006    /// **Why this belongs to the spec and not to a separate materialization
3007    /// step.** The file's content is a pure function of the same plan that
3008    /// produced this spec, so it has to change at exactly the moment the spec
3009    /// does. Carrying it here makes that true by construction: one deploy
3010    /// writes the file and starts the process that reads it, and a redeploy
3011    /// rewrites it and re-execs. A separate "write the config, then deploy"
3012    /// step is two owners of one fact, and the seam between them is a door
3013    /// serving a stale route table for however long the two are out of step —
3014    /// the failure mode `CLAUDE.md`'s R858 entry is the standing example of.
3015    ///
3016    /// Not a secret channel: content is stored in the spec in the clear and
3017    /// travels wherever the spec travels. Secrets go through
3018    /// [`SecretMount`], which resolves by reference at the node.
3019    ///
3020    /// Appended last, `#[serde(default)]`, no `skip_serializing_if`: the
3021    /// postcard codec is positional, so the field is always encoded and every
3022    /// spec that predates it decodes to an empty vec — i.e. unchanged.
3023    #[serde(default)]
3024    pub files: Vec<InlineFile>,
3025}
3026
3027/// A key [`WorkloadSpec`] used to have, deleted because its value never
3028/// changed what a node does (R896-B5).
3029#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3030pub struct RetiredKey {
3031    /// Dotted path from the spec root, e.g. `"schema_version"` or
3032    /// `"resources.some_key"` — the same spelling a dropped-key refusal reports.
3033    pub path: &'static str,
3034    /// The ticket that deleted the field.
3035    pub retired_by: &'static str,
3036}
3037
3038/// Keys a hand-authored workload file may still declare after the field was
3039/// deleted, and which a loader must therefore ignore rather than refuse.
3040///
3041/// `cloud`'s R892-B1 check refuses any key the parser drops, because a dropped
3042/// key is normally an operator's intent evaporating — `resources.
3043/// ephemeral_storage_mb` did exactly that and cost a live workload on
3044/// 2026-09-11. A field whose value was pure bookkeeping carries no intent, so
3045/// deleting it should not force every downstream camp to sweep its committed
3046/// TOML the same day. Register it here in the same diff that deletes the field.
3047///
3048/// **Only inert keys belong here.** If ignoring the old value would make a node
3049/// behave differently from what the file says (a limit, a mount, a policy), the
3050/// refusal is the point: leave it off this list and let the file be fixed.
3051pub const RETIRED_KEYS: &[RetiredKey] = &[RetiredKey {
3052    path: "schema_version",
3053    retired_by: "R896-T4",
3054}];
3055
3056/// One entry of [`WorkloadSpec::files`] — a file the node materializes from
3057/// the spec itself.
3058#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
3059#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3060pub struct InlineFile {
3061    /// Absolute path on the node (native backend) or inside the container.
3062    /// Parent directories are created if absent.
3063    pub path: PathBuf,
3064
3065    /// The file's exact bytes, as UTF-8. Written whole — never merged into
3066    /// or appended to whatever was there before, so the file on disk is
3067    /// always precisely what the spec says and a shrinking config cannot
3068    /// leave a tail of the old one behind.
3069    pub content: String,
3070
3071    /// Unix permission bits, e.g. `0o600`. `None` = the platform default for
3072    /// a newly created file.
3073    #[serde(default)]
3074    #[ts(optional = nullable)]
3075    pub mode: Option<u32>,
3076}
3077
3078impl WorkloadSpec {
3079    /// Build a `WorkloadSpec` for a forge run.
3080    ///
3081    /// Sets the conventional forge fields in one place so callers cannot
3082    /// forget any of them:
3083    ///
3084    /// - `restart_policy = Never`
3085    /// - `archetype = Some(LifecycleArchetype::Job)` — a forge run is
3086    ///   exactly the `container`-kind instance of the job archetype (W244);
3087    ///   set explicitly rather than left to infer since this constructor
3088    ///   knows its own shape
3089    /// - `expose.public = None`, `expose.operator = None`
3090    /// - `expose.mesh.identity = "forge.<forge_id>"`
3091    /// - `annotations["yah.forge"] = "true"` (suppresses the shape warning)
3092    /// - `tier` and `image` come from the caller; `ports` becomes the mesh
3093    ///   port list (empty is valid — forge jobs often don't expose ports)
3094    ///
3095    /// All other fields are set to safe defaults. Callers can mutate the
3096    /// returned value to fill in `command`, `env`, `resources`, etc.
3097    pub fn for_forge(
3098        forge_id: &str,
3099        image: ImageRef,
3100        tier: TierTag,
3101        ports: Vec<u16>,
3102    ) -> Self {
3103        let mut annotations = HashMap::new();
3104        annotations.insert("yah.forge".into(), "true".into());
3105
3106        WorkloadSpec {
3107            // NB: DNS-label safe (no dots) — `check_name` validation rejects
3108            // dots here. The container_id derives from this; the state-poll
3109            // keys off `expose.mesh.identity` (`forge.<id>`) instead, so those
3110            // two must be reconciled at the read path, NOT by dotting the name
3111            // (see R590-B9).
3112            name: format!("forge-{forge_id}"),
3113            image,
3114            tier,
3115            tenant: TenantId::singleton(),
3116            namespace: NamespaceId::singleton(),
3117            replicas: 1,
3118            command: None,
3119            entrypoint: None,
3120            workdir: None,
3121            user: None,
3122            env: vec![],
3123            secrets: vec![],
3124            volumes: vec![],
3125            resources: ResourceLimits {
3126                // R590-B10: forge workloads are BUILDS (cargo, buildkit, a
3127                // from-source V8 checkout+compile), not tiny services. The old
3128                // 256 MB placeholder became a hard cgroup memory.limit in
3129                // build_oci_spec and SIGKILL'd the rusty-v8 build mid-checkout
3130                // (git checkout of third_party/icu died of signal 9) — the
3131                // more so because /tmp is a RAM-backed tmpfs, so the source
3132                // tree counts against this limit too. 32 GiB is a bounded
3133                // ceiling that fits the V8 build's >12 GB peak with headroom,
3134                // protects the host from a runaway (vs truly unlimited), and is
3135                // above physical RAM on smaller build-workers (⇒ effectively
3136                // unlimited there).
3137                //
3138                // That last clause is only true while this stays a CEILING. It
3139                // was also the placement floor until `memory_request_mb` below
3140                // split the two, which made every build-worker under 32 GiB
3141                // unschedulable — the story is on `memory_request_mb`.
3142                memory_mb: FORGE_MEMORY_LIMIT_MB,
3143                cpu_millis: 512,
3144                // The placement floor, kept distinct from the cgroup ceiling
3145                // above. Without it, admission reads the 32 GiB ceiling as the
3146                // amount of RAM a node must have.
3147                memory_request_mb: Some(FORGE_MEMORY_REQUEST_MB),
3148                cpu_limit_millis: None,
3149                pids_max: None,
3150                // Carried over verbatim from the `ephemeral_storage_mb: 512`
3151                // this spec used to set (R885-T6). It changes nothing today and
3152                // is not meant to: the microVM backend's own 8 GiB floor has
3153                // always dominated this number. It is here so the workload's
3154                // declared minimum survives rather than evaporating.
3155                scratch_floor_mb: Some(FORGE_SCRATCH_FLOOR_MB),
3156            },
3157            depends_on: vec![],
3158            requires: vec![],
3159            healthcheck: None,
3160            restart_policy: RestartPolicy::Never,
3161            archetype: Some(LifecycleArchetype::Job),
3162            stop_policy: StopPolicy {
3163                signal: 15,
3164                grace_period: Millis::from_secs(30),
3165            },
3166            expose: ExposeSpec {
3167                mesh: MeshExpose {
3168                    identity: MeshIdent(format!("forge.{forge_id}")),
3169                    // A forge job's ports come from a caller holding bare
3170                    // numbers (a job exposes what its image exposes), so they
3171                    // stay unnamed — `kamaji::name_anonymous_ports` names them.
3172                    ports: MeshExpose::anonymous_ports(ports),
3173                    allow_from: vec![],
3174                },
3175                public: None,
3176                operator: None,
3177            },
3178            durability: None,
3179            db: Vec::new(),
3180            capabilities: Vec::new(),
3181            labels: HashMap::new(),
3182            annotations,
3183            files: Vec::new(),
3184        }
3185    }
3186
3187    /// Whether this workload requests the **host network namespace** rather
3188    /// than an isolated one.
3189    ///
3190    /// Opt-in via `annotations["yah.network"] == "host"` (see
3191    /// [`HOST_NETWORK_ANNOTATION`] / [`HOST_NETWORK_VALUE`]). Default is the
3192    /// isolated netns every other workload gets — host networking is a
3193    /// privileged escape hatch for the few infra workloads that must bind a
3194    /// host port so an on-host ingress (e.g. a Cloudflare tunnel reaching
3195    /// `127.0.0.1:<port>`) can route to them without CNI/bridge plumbing.
3196    ///
3197    /// The backend (kamaji) is responsible for **guarding** this: host
3198    /// networking is only honoured for `tier == "infra"` workloads; a
3199    /// non-infra workload that sets the annotation is rejected at deploy. See
3200    /// `validate_spec_for_constable`.
3201    pub fn wants_host_network(&self) -> bool {
3202        self.annotations
3203            .get(HOST_NETWORK_ANNOTATION)
3204            .map(|v| v == HOST_NETWORK_VALUE)
3205            .unwrap_or(false)
3206    }
3207
3208    /// Resolve the lifecycle archetype (R572-F1 / W244): the explicit
3209    /// [`Self::archetype`] if set, otherwise the pre-R572 inference from
3210    /// `volumes`/`restart_policy` this field replaces.
3211    ///
3212    /// This is the one seam callers should use to ask "can I kill and
3213    /// reschedule this?" — it is intentionally the *only* place that
3214    /// implements the fallback, so behavior for pre-existing specs (no
3215    /// `archetype` on disk) is identical to what it was before this field
3216    /// existed. Consumers (reconciler R572-F4, scheduler R572-F5) branch on
3217    /// the return value; this crate does not itself change any reconciler or
3218    /// scheduler behavior.
3219    pub fn effective_archetype(&self) -> LifecycleArchetype {
3220        self.archetype
3221            .unwrap_or_else(|| LifecycleArchetype::infer(&self.volumes, &self.restart_policy))
3222    }
3223
3224    /// Resolve what this workload needs (R860-T1 / W338): [`Self::requires`],
3225    /// then every [`Self::depends_on`] ident not already named there, folded
3226    /// into the `Anywhere` + `Wait` requirement that a bare `depends_on` entry
3227    /// has always meant.
3228    ///
3229    /// This is the one seam callers should use to ask "what does this workload
3230    /// need?" — it is intentionally the *only* place that implements the fold,
3231    /// so a spec written before `requires` existed keeps its exact previous
3232    /// meaning. Callers MUST NOT read [`Self::requires`] or
3233    /// [`Self::depends_on`] directly: reading either alone silently drops half
3234    /// the requirements of any spec that uses both.
3235    ///
3236    /// Deduplicated by ident, and `requires` wins — an ident named in both is
3237    /// the author restating a dependency with a locality, not two separate
3238    /// edges. Consumers (the deploy gate R860-T2, the placement group R860-T4)
3239    /// branch on the return value; this crate does not itself change any
3240    /// deploy or placement behaviour.
3241    pub fn effective_requirements(&self) -> Vec<Requirement> {
3242        let mut out = self.requires.clone();
3243        for ident in &self.depends_on {
3244            if out.iter().any(|req| &req.ident == ident) {
3245                continue;
3246            }
3247            out.push(Requirement {
3248                ident: ident.clone(),
3249                locality: Locality::Anywhere,
3250                supply: Supply::Wait,
3251                provides: None,
3252            });
3253        }
3254        out
3255    }
3256
3257    /// Fully-qualified mesh identity `<tenant>/<namespace>/<name>` (W206 /
3258    /// R558-F3), where `<name>` is this workload's [`MeshExpose::identity`].
3259    ///
3260    /// Within a tenant, workloads still address each other by the short
3261    /// identity (namespace disambiguates only on collision); the FQN is what
3262    /// makes the identity unambiguous across tenants and is exactly what a
3263    /// [`MeshPeer::CrossTenant`] grant names.
3264    pub fn fq_mesh_identity(&self) -> String {
3265        format!(
3266            "{}/{}/{}",
3267            self.tenant.0, self.namespace.0, self.expose.mesh.identity.0
3268        )
3269    }
3270
3271    /// The taint this workload requires its node to carry, if any (R594-F2 /
3272    /// W267 sovereign public ingress).
3273    ///
3274    /// Opt-in via `annotations["yah.placement.requires-taint"] = "<taint
3275    /// name>"` (see [`REQUIRES_TAINT_ANNOTATION`]) — same annotation-based,
3276    /// zero-blast-radius shape as [`Self::wants_host_network`], chosen so
3277    /// declaring this requirement does not force a struct-literal edit at
3278    /// every existing `WorkloadSpec { .. }` construction site the way a new
3279    /// plain field would (see R572-F1's handoff: ~26 sites for one field).
3280    ///
3281    /// Both halves have since landed: `MachineConfig.taints` (R572-F3) and the
3282    /// scheduler's affinity check in `cloud::config::RequiredSpec::matches`
3283    /// (R572-F5), which requires the key in the node's `taints` **or**
3284    /// `mesh_tags`.
3285    ///
3286    /// A key named here is one of only two ways a node taint can influence
3287    /// placement — the other is the `no-<archetype>` repulsion form. W305/
3288    /// R742-T4 makes `yah cloud validate` reject any node taint that is
3289    /// neither, so a new affinity key must be added to
3290    /// `cloud::config::AFFINITY_TAINT_KEYS` alongside the workload that
3291    /// requires it.
3292    ///
3293    /// The public-ingress appliance (W267) is the first user: a
3294    /// `kind = "container"` workload with `archetype =
3295    /// Some(LifecycleArchetype::Appliance)` and
3296    /// `requires_taint() == Some(PUBLIC_IP_TAINT)`, so yubaba may one day
3297    /// place it only on machines carrying the `"public-ip"` taint and kamaji
3298    /// supervises it like any other container (no new `Workload` variant —
3299    /// see [`Workload::Container`]'s doc comment).
3300    pub fn requires_taint(&self) -> Option<&str> {
3301        self.annotations
3302            .get(REQUIRES_TAINT_ANNOTATION)
3303            .map(String::as_str)
3304    }
3305
3306    /// The memory (MiB) a scheduler must find on a node before placing this
3307    /// workload — its **request**, as distinct from [`ResourceLimits::memory_mb`],
3308    /// which is a **ceiling** the backend turns into a cgroup `memory.max`.
3309    ///
3310    /// Opt-in via [`ResourceLimits::memory_request_mb`]; absent falls back to
3311    /// `resources.memory_mb`, so every spec that does not set it is admitted
3312    /// exactly as it was before the request existed.
3313    ///
3314    /// # Why the two numbers must not be the same one
3315    ///
3316    /// A limit answers "kill it past here"; a request answers "don't start it
3317    /// somewhere smaller than here". Generous is the safe direction for the
3318    /// first and the unschedulable direction for the second, so one field
3319    /// serving both makes a deliberately-roomy ceiling into an admission floor.
3320    ///
3321    /// That is not hypothetical: [`WorkloadSpec::for_forge`] sets a 32 GiB
3322    /// ceiling explicitly reasoned as "above physical RAM on smaller
3323    /// build-workers ⇒ effectively unlimited there" (R590-B10), and
3324    /// `CloudConfig::admit_workload` fed that same 32768 in as the R572-F5
3325    /// capacity floor. Every build-worker under 32 GiB — the three 8 GiB Pi-5s
3326    /// and the 16 GiB us-west-003 — became structurally unadmittable for *any*
3327    /// offloaded qed step, leaving one 47 GiB node as the fleet's only legal
3328    /// target for remote CI. This is R590-B10's own recorded follow-up
3329    /// ("thread a per-step memory request … instead of a blanket forge
3330    /// default"), reduced to the seam that closes the bug.
3331    pub fn memory_request_mb(&self) -> u32 {
3332        // `0` falls back too: a zero request would admit the workload on any
3333        // node at all, which is never what a declared request meant.
3334        self.resources
3335            .memory_request_mb
3336            .filter(|mb| *mb > 0)
3337            .unwrap_or(self.resources.memory_mb)
3338    }
3339
3340    /// The hard CPU ceiling (millicores) a backend may enforce, if the workload
3341    /// declares one — the exact mirror of [`Self::memory_request_mb`], which
3342    /// adds the missing *request* beside a field that is a ceiling. Here the
3343    /// field ([`ResourceLimits::cpu_millis`]) is the *request* and this adds the
3344    /// missing ceiling.
3345    ///
3346    /// Opt-in via [`ResourceLimits::cpu_limit_millis`]. Absent or `0` means **no
3347    /// ceiling**: the workload gets its declared share of a contended node and
3348    /// may burst to the whole box on an idle one. That is the default because
3349    /// `cpu_millis` is documented as a request, and every backend but one has
3350    /// always rendered it as a relative weight
3351    /// ([`ResourceLimits::cpu_shares`]).
3352    ///
3353    /// # Why this exists (R885-B5 / W344 Finding 5)
3354    ///
3355    /// R885-B1 wired the cgroup v2 driver onto the live native deploy path, and
3356    /// that driver rendered `cpu_millis` into `cpu.max` — a hard quota. Measured
3357    /// on us-east-001 on 2026-09-11: four native workloads at
3358    /// `cpu.max = 25600 100000`, i.e. capped at 0.256 of a core even on an idle
3359    /// node, where before the wiring they could burst to the whole machine. A
3360    /// request rendered as a ceiling is a semantic bug, not a missing feature.
3361    pub fn cpu_limit_millis(&self) -> Option<u32> {
3362        self.resources.cpu_limit_millis.filter(|millis| *millis > 0)
3363    }
3364
3365    /// The hard process-count ceiling (cgroup v2 `pids.max`) a backend
3366    /// enforces on this workload's leaf — R885-T2 (W344 Finding 3: "no bound
3367    /// on a fork bomb, accidental or otherwise").
3368    ///
3369    /// Shaped like [`Self::cpu_limit_millis`] (an opt-in override), but its
3370    /// default runs the OPPOSITE direction on purpose. An absent
3371    /// `cpu_limit_millis` correctly means "no ceiling", because `cpu_millis`
3372    /// already has a well-defined request-only meaning without it. There is no
3373    /// such fallback for pids: "unbounded" is precisely the bug R885-T2 closed,
3374    /// not a feature to preserve, so absent or `0` both fall back to
3375    /// [`DEFAULT_PIDS_MAX`] instead of to "unset".
3376    ///
3377    /// [`DEFAULT_PIDS_MAX`]'s doc comment records where the number comes
3378    /// from and why it does not need to be tight to be useful.
3379    ///
3380    /// Opt-in override via [`ResourceLimits::pids_max`] for a workload that
3381    /// legitimately needs a different bound.
3382    pub fn pids_limit(&self) -> u32 {
3383        self.resources
3384            .pids_max
3385            .filter(|pids| *pids > 0)
3386            .unwrap_or(DEFAULT_PIDS_MAX)
3387    }
3388
3389    /// A **floor** on the microVM scratch disk in MiB — the smallest workspace
3390    /// this workload is willing to be given, or `None` for "the backend's own
3391    /// floor is fine".
3392    ///
3393    /// Opt-in via [`ResourceLimits::scratch_floor_mb`]. Absent or `0` both mean
3394    /// **no declared floor**; the microVM backend still applies its own
3395    /// (`microvm::WORKSPACE_MIN_BYTES`), which is what actually sizes every
3396    /// workload in this tree today.
3397    ///
3398    /// # Why this replaced a field (R885-T6 / W344)
3399    ///
3400    /// It is the successor to `ResourceLimits::ephemeral_storage_mb`, which was
3401    /// deleted rather than renamed because the field **lied**. Its doc comment
3402    /// called it a "cap on the writable layer + tmpfs footprint" and no backend
3403    /// ever enforced it as one — not the OCI resources block, not docker's
3404    /// argv, and deliberately not the cgroup v2 driver. Its single live
3405    /// consumer, [`microvm::workspace::disk_size_bytes`], read it as a
3406    /// **floor**, i.e. the exact opposite. `for_forge` then set it to 512 MiB,
3407    /// a number that as a cap would have failed every build at its first
3408    /// checkout and as a floor was simply ignored.
3409    ///
3410    /// A field that means one thing at its definition and the reverse at its
3411    /// only use is not a field to keep compatible with, so per the pre-1.0
3412    /// doctrine in `CLAUDE.md` the design changed instead of being taped: the
3413    /// floor is now spelled `floor`.
3414    ///
3415    /// [`microvm::workspace::disk_size_bytes`]: https://docs.rs/kamaji
3416    pub fn scratch_floor_mb(&self) -> Option<u32> {
3417        self.resources.scratch_floor_mb.filter(|mb| *mb > 0)
3418    }
3419
3420    /// Whether this workload must be run by kamaji's **native** (fork+exec)
3421    /// backend on the node's own userland, rather than by a container backend
3422    /// (R577-T1 / W254).
3423    ///
3424    /// Opt-in via `annotations["yah.exec"] == "native"` (see
3425    /// [`NATIVE_EXEC_ANNOTATION`] / [`NATIVE_EXEC_VALUE`]) — the same
3426    /// annotation-shaped, zero-blast-radius marker as
3427    /// [`Self::wants_host_network`] and [`Self::requires_taint`], chosen over
3428    /// a new plain field for the reason R572-F1 recorded: a field forces a
3429    /// struct-literal edit at every existing construction site and an
3430    /// exhaustive-match update in `kamaji-proto`'s codec, and this marker
3431    /// needs neither.
3432    ///
3433    /// # Why an annotation and not a runtime enum on the wire
3434    ///
3435    /// The remote-execution wire already carries exactly one workload shape —
3436    /// `Workload::Container(WorkloadSpec)` — and every layer between the
3437    /// dispatcher and the node (yubaba admission, mesh assignment, log
3438    /// ingest, produced-file retrieval, teardown) is written against it. A
3439    /// Darwin build differs from a Linux build in *one* respect: there is no
3440    /// container that can host it, because you cannot containerize the Darwin
3441    /// kernel. Marking that one difference keeps the rest of the path shared
3442    /// instead of growing a parallel `exec_native` RPC that would have to
3443    /// re-implement all of it.
3444    ///
3445    /// `image` stays populated for a native workload and is **identity
3446    /// metadata only** — nothing is pulled; the native backend resolves argv
3447    /// from `entrypoint` + `command` (container semantics) and execs it on
3448    /// the host.
3449    pub fn wants_native_exec(&self) -> bool {
3450        self.annotations
3451            .get(NATIVE_EXEC_ANNOTATION)
3452            .map(|v| v == NATIVE_EXEC_VALUE)
3453            .unwrap_or(false)
3454    }
3455
3456    /// Whether this workload asks its node's kamaji to replay it after kamaji
3457    /// restarts. See [`RESUME_AFTER_RESTART_ANNOTATION`] for why it is opt-in.
3458    pub fn wants_resume_after_restart(&self) -> bool {
3459        self.annotations
3460            .get(RESUME_AFTER_RESTART_ANNOTATION)
3461            .is_some_and(|v| v == RESUME_AFTER_RESTART_VALUE)
3462    }
3463
3464    /// Whether this workload is a cluster floater. See [`FLOAT_ANNOTATION`].
3465    pub fn wants_float(&self) -> bool {
3466        self.annotations
3467            .get(FLOAT_ANNOTATION)
3468            .is_some_and(|v| v == FLOAT_VALUE)
3469    }
3470
3471    /// Whether this workload must be run by kamaji's **microVM** backend —
3472    /// booted in its own KVM guest with its own kernel, rather than sharing the
3473    /// host kernel with every other workload on the node (R605-F8 / W325 §5).
3474    ///
3475    /// Opt-in via `annotations["yah.exec"] == "microvm"` (see
3476    /// [`NATIVE_EXEC_ANNOTATION`] / [`MICROVM_EXEC_VALUE`]).
3477    ///
3478    /// # Why the *same* key as native exec, not a new one
3479    ///
3480    /// W325's Shape A calls this "a sibling branch on a new annotation value",
3481    /// and the value — not the key — is the whole point. `yah.exec` names the
3482    /// execution substrate, and a workload has exactly one:
3483    ///
3484    /// | `yah.exec` | substrate | kernel | isolation |
3485    /// |---|---|---|---|
3486    /// | *(absent)* | container backend | host's | namespaces + cgroup |
3487    /// | `native` | fork+exec on the host | host's | **none** |
3488    /// | `microvm` | KVM guest | **its own** | hardware |
3489    ///
3490    /// A second key (`yah.isolation = microvm`, say) would make
3491    /// `yah.exec = native` + `yah.isolation = microvm` *expressible*, and
3492    /// therefore something a dispatcher could emit and a backend would have to
3493    /// refuse — exactly the refusal `validate_native_exec_spec` already has to
3494    /// carry for the `yah.sandbox` pair, and for the same avoidable reason. A
3495    /// map key holds one value, so on this key the three substrates are
3496    /// mutually exclusive *by construction*: there is no spec on which both
3497    /// this and [`Self::wants_native_exec`] return `true`, and
3498    /// `exec_substrate_markers_are_mutually_exclusive_by_construction` pins
3499    /// that.
3500    ///
3501    /// # What the marker does and does not promise
3502    ///
3503    /// Like every marker on this struct it is **inert metadata** — it declares
3504    /// intent and nothing more. Whether a node can honour it is a node
3505    /// capability question (`/dev/kvm`, a guest kernel, a rootfs; see W325 §4),
3506    /// and a node whose kamaji has no microVM backend configured **refuses**
3507    /// the deploy rather than falling back to a container. That refusal is
3508    /// deliberate and mirrors R577-T1's: a caller asking for microVM isolation
3509    /// is asking for the one property a container cannot provide, so silently
3510    /// downgrading it would return success while delivering the thing the
3511    /// caller specifically declined.
3512    ///
3513    /// `image` is identity metadata only, as it is for native exec — nothing is
3514    /// pulled. The guest's root filesystem comes from the node's configured
3515    /// rootfs image, and argv is resolved from `entrypoint` + `command` with
3516    /// container semantics, so one spec shape drives all three substrates.
3517    pub fn wants_microvm(&self) -> bool {
3518        self.annotations
3519            .get(NATIVE_EXEC_ANNOTATION)
3520            .map(|v| v == MICROVM_EXEC_VALUE)
3521            .unwrap_or(false)
3522    }
3523
3524    /// The substrate this spec selects, as an *ordered* value (R894-F1).
3525    ///
3526    /// The same reading [`Self::wants_native_exec`] and [`Self::wants_microvm`]
3527    /// perform, collapsed into one total function so that "which substrate is
3528    /// this" has a single answer rather than two booleans a caller re-combines.
3529    /// Every existing `if wants_native_exec() … else if wants_microvm() …`
3530    /// ladder in the tree is that re-combination, and R605-F8's own doc notes
3531    /// that a duplicated ladder is exactly what drifts when a fourth substrate
3532    /// arrives.
3533    ///
3534    /// An unrecognised `yah.exec` value reads as [`ExecSubstrate::Container`],
3535    /// preserving what both predicates already do. That is the fail-*closed*
3536    /// direction for the trust check in [`Self::trust`]'s consumers: a typo'd
3537    /// `microvm` on an untrusted spec reads as a container and is refused,
3538    /// rather than reading as the microVM the author meant to ask for.
3539    pub fn exec_substrate(&self) -> ExecSubstrate {
3540        match self
3541            .annotations
3542            .get(NATIVE_EXEC_ANNOTATION)
3543            .map(String::as_str)
3544        {
3545            Some(v) if v == NATIVE_EXEC_VALUE => ExecSubstrate::Native,
3546            Some(v) if v == MICROVM_EXEC_VALUE => ExecSubstrate::MicroVm,
3547            _ => ExecSubstrate::Container,
3548        }
3549    }
3550
3551    /// How far this workload's code is trusted, from [`TRUST_ANNOTATION`]
3552    /// (R894-F1).
3553    ///
3554    /// Absent means [`TrustLevel::Trusted`] — see that type's docs for why that
3555    /// default is safe only because of *where* the untrusted marker is stamped.
3556    /// An unrecognised value is an `Err`, never a fallback to either side.
3557    pub fn trust(&self) -> std::result::Result<TrustLevel, TrustDeclError> {
3558        match self.annotations.get(TRUST_ANNOTATION).map(|v| v.trim()) {
3559            None => Ok(TrustLevel::Trusted),
3560            Some(v) if v == TRUST_TRUSTED_VALUE => Ok(TrustLevel::Trusted),
3561            Some(v) if v == TRUST_UNTRUSTED_VALUE => Ok(TrustLevel::Untrusted),
3562            Some(other) => Err(TrustDeclError::UnknownLevel(other.to_string())),
3563        }
3564    }
3565
3566    /// Stamp this spec as carrying code the operator did not write, and raise
3567    /// its substrate request to the minimum that trust level requires.
3568    ///
3569    /// **This is the choke-point verb.** Any constructor that turns third-party
3570    /// bytes (a tenant image, a vended camp, a user-supplied argv) into a
3571    /// `WorkloadSpec` calls it *in the same function that takes those bytes*, so
3572    /// the marker cannot be lost by a caller who forgets. R823 is the first such
3573    /// path.
3574    ///
3575    /// It raises the substrate rather than only marking trust because the two
3576    /// halves belong to the same decision and splitting them across two call
3577    /// sites is how one of them goes missing. Raising is one-directional: a
3578    /// caller that already asked for something at least as strong keeps its own
3579    /// request, so stamping a spec that explicitly wants a microVM is a no-op
3580    /// and stamping is idempotent.
3581    ///
3582    /// A spec that had asked for `native` becomes `microvm`. That is not a
3583    /// silent downgrade — it is a *widening* of isolation, the safe direction —
3584    /// and it happens at the moment the untrusted origin is established, not at
3585    /// admission, where the same mismatch is a refusal.
3586    pub fn stamp_untrusted(&mut self) {
3587        self.annotations.insert(
3588            TRUST_ANNOTATION.to_string(),
3589            TRUST_UNTRUSTED_VALUE.to_string(),
3590        );
3591        let floor = TrustLevel::Untrusted.minimum_substrate();
3592        if self.exec_substrate() < floor {
3593            match floor.annotation_value() {
3594                Some(v) => {
3595                    self.annotations
3596                        .insert(NATIVE_EXEC_ANNOTATION.to_string(), v.to_string());
3597                }
3598                None => {
3599                    self.annotations.remove(NATIVE_EXEC_ANNOTATION);
3600                }
3601            }
3602        }
3603    }
3604
3605    /// Whether this workload builds its **own unprivileged container sandbox**
3606    /// inside the one the backend gives it, and therefore needs the two
3607    /// capabilities plus the `no_new_privs` relaxation that setting up a
3608    /// user namespace requires (R636-B2).
3609    ///
3610    /// Opt-in via `annotations["yah.sandbox"] == "nested"` (see
3611    /// [`NESTED_SANDBOX_ANNOTATION`] / [`NESTED_SANDBOX_VALUE`]) — the same
3612    /// annotation-shaped, zero-blast-radius marker as
3613    /// [`Self::wants_host_network`] and [`Self::wants_native_exec`].
3614    ///
3615    /// # What it actually grants, and why exactly that
3616    ///
3617    /// Rootless BuildKit (the only user today: remote `build-image` steps
3618    /// dispatch `moby/buildkit:*-rootless`) boots through `rootlesskit`, which
3619    /// must map a range of sub-uids into a fresh user namespace. It does that
3620    /// by exec'ing the **setuid-root** helpers `newuidmap` / `newgidmap`, so
3621    /// it needs `CAP_SETUID` + `CAP_SETGID` in the bounding set *and*
3622    /// `noNewPrivileges = false` (with `no_new_privs` on, the kernel silently
3623    /// strips the setuid bit and the helper fails with "Could not set caps").
3624    ///
3625    /// Each of those three was measured on us-west-002 to be **individually
3626    /// necessary** — dropping any one of them puts `rootlesskit` back to
3627    /// failing before the first layer:
3628    ///
3629    /// | grant | `rootlesskit` result |
3630    /// |---|---|
3631    /// | baseline (`CAP_NET_BIND_SERVICE` only, `nnp` on) | `fork/exec /usr/bin/newuidmap: operation not permitted` |
3632    /// | `+CAP_SETUID` only, `nnp` off | `fork/exec /usr/bin/newgidmap: operation not permitted` |
3633    /// | `+CAP_SETUID +CAP_SETGID`, `nnp` **on** | `newuidmap: Could not set caps` |
3634    /// | `+CAP_SETUID +CAP_SETGID`, `nnp` off | starts; build runs to completion |
3635    ///
3636    /// It is deliberately *not* `CAP_SYS_ADMIN`: a non-rootless buildkitd
3637    /// would need that instead, which is a far wider grant. Emptying
3638    /// `/etc/subuid` to force `rootlesskit`'s single-mapping path does not
3639    /// avoid the helpers either — it just fails earlier with "No subuid
3640    /// ranges found".
3641    ///
3642    /// **The backend guards this.** Like host networking, it is honoured only
3643    /// for `tier == "infra"` workloads; a non-infra workload that sets the
3644    /// annotation is rejected at deploy. Every other workload keeps the
3645    /// `CAP_NET_BIND_SERVICE`-only, `no_new_privs` baseline.
3646    ///
3647    /// # Mutually exclusive with [`Self::wants_native_exec`]
3648    ///
3649    /// This grant is defined in terms of an **OCI process spec** — a
3650    /// capability set and a `noNewPrivileges` bit. A native (fork+exec)
3651    /// workload has no OCI spec, so there is nothing to apply it to; kamaji
3652    /// refuses a spec carrying both markers rather than accepting a request
3653    /// for widened privileges and silently dropping it (R577-T1 owns that
3654    /// refusal). The two are independent *annotations* — neither implies the
3655    /// other, which is what
3656    /// `nested_sandbox_marker_is_independent_of_the_other_markers` pins — but
3657    /// they are not a legal *pair*.
3658    ///
3659    /// If a future runtime does have a sandbox worth widening (a MacVM under
3660    /// W254, say), give it its own annotation rather than relaxing that
3661    /// refusal. The grant this marker names is `CAP_SETUID` + `CAP_SETGID` +
3662    /// `no_new_privs` off and nothing else; letting it mean a different
3663    /// privilege set per backend would make "what does `yah.sandbox=nested`
3664    /// grant?" unanswerable without knowing which backend received it, which
3665    /// is precisely what a security-relevant marker must not be.
3666    pub fn wants_nested_sandbox(&self) -> bool {
3667        self.annotations
3668            .get(NESTED_SANDBOX_ANNOTATION)
3669            .map(|v| v == NESTED_SANDBOX_VALUE)
3670            .unwrap_or(false)
3671    }
3672
3673    /// The host paths this workload declares it **writes to** (R885-B11 / W344),
3674    /// from [`WRITABLE_PATHS_ANNOTATION`]. Empty when nothing is declared.
3675    ///
3676    /// This is the input to kamaji's native filesystem confinement: a native
3677    /// workload is a plain `fork`+`exec` on the host, so the only description of
3678    /// what it may write is the one its spec carries. `kamaji::sandbox` unions
3679    /// these with the spec's `Bind` volumes and denies writes everywhere else —
3680    /// and confines **only** a workload that declares one or the other, so a
3681    /// spec that says nothing about its writes is not silently guessed at.
3682    ///
3683    /// # Why an annotation rather than a field
3684    ///
3685    /// It qualifies the substrate markers (`yah.exec`, `yah.sandbox`) that
3686    /// already ride the annotation map, and it is a policy hint for the
3687    /// sandbox rather than a fact about the workload's shape. (The original
3688    /// reason — a positional postcard wire on which every new field was a
3689    /// protocol bump — stopped holding at kamaji-proto V13, R896-F2.)
3690    ///
3691    /// ```toml
3692    /// [annotations]
3693    /// "yah.writable-paths" = "/var/lib/yah/qed,/tmp"
3694    /// ```
3695    ///
3696    /// # What is refused, and why refused rather than normalized
3697    ///
3698    /// A relative entry, a `.`/`..` component, an empty entry (a stray comma),
3699    /// or a duplicate. Each of these would otherwise turn into a *grant* — a
3700    /// landlock rule is `PathBeneath`, so a mis-resolved path does not fail
3701    /// closed, it opens a subtree nobody asked for. Normalizing silently is how
3702    /// you grant write access to the wrong directory and never hear about it.
3703    ///
3704    /// Note that a declared path is a **ceiling, not a mount**: nothing here
3705    /// creates a directory. A path that does not exist on the node is skipped
3706    /// (with a warning) when the ruleset is built.
3707    pub fn writable_paths(&self) -> Result<Vec<PathBuf>, WritablePathsDeclError> {
3708        match self.annotations.get(WRITABLE_PATHS_ANNOTATION) {
3709            None => Ok(Vec::new()),
3710            Some(raw) => parse_writable_paths(raw),
3711        }
3712    }
3713
3714    /// The durability tier this workload declares for its own state, if it
3715    /// declares one at all (R850-P4).
3716    ///
3717    /// `Ok(None)` and `Ok(Some(tier: DurabilityTier::None))` are **different
3718    /// answers and must stay different**: the first is "nobody said", the
3719    /// second is "somebody looked and decided not to". A named volume with no
3720    /// declaration is the shape that loses every byte when its node dies, and
3721    /// collapsing the two would let the analyzer report that case in the same
3722    /// words as a deliberately-ephemeral cache.
3723    ///
3724    /// ```toml
3725    /// [durability]
3726    /// tier        = "stream"          # none|snapshot|dedup|stream
3727    /// engine      = "turso"           # required by every tier but "none"
3728    /// store       = "s3://yah-backups/noisetable-account"
3729    /// subjects    = ["accounts.db", "passkeys.db", "sessions.db"]
3730    /// rpo_seconds = 120               # stream only
3731    /// ```
3732    ///
3733    /// # Read this, not the field
3734    ///
3735    /// Serde refuses an unknown tier or engine and a non-numeric RPO, but it
3736    /// cannot see the rules that span fields — a shipping tier with no store,
3737    /// an RPO on a snapshot tier, a subject that escapes its volume. Those are
3738    /// [`Durability::check`], and this accessor is the one read that applies
3739    /// it, so a malformed declaration refuses at every consumer rather than only
3740    /// at the ones that remembered to validate.
3741    ///
3742    /// # The retired annotations (R896-F3)
3743    ///
3744    /// This declaration was the `yah.durability.*` annotation family until
3745    /// kamaji-proto V13 made a field addition cross a skew. Any surviving key
3746    /// of that family is [`DurabilityDeclError::RetiredAnnotation`], never
3747    /// ignored: an ignored annotation is indistinguishable from an absent one,
3748    /// and "absent" here means "this database has no backup".
3749    ///
3750    /// # Why `engine` and `subjects` are not optional (R850-F1)
3751    ///
3752    /// The tier vocabulary is `turso-backup`-shaped, and P4 shipped it on a
3753    /// *generic* `WorkloadSpec` — so a Postgres appliance could declare `tier =
3754    /// "stream"` and mean something no code in this tree can do. `engine` makes
3755    /// that claim explicit and refusable at parse time rather than at 3am.
3756    ///
3757    /// `subjects` exists because a restore has a *file* as its unit and a
3758    /// workload has a *volume*. The driving case (R850) is one process with
3759    /// three turso databases inside one named volume; "restore the volume" is
3760    /// not a thing turso-backup can do, and guessing which files in a directory
3761    /// are databases is guessing about the only copy of somebody's data. Paths
3762    /// are volume-relative — the same string the analyzer prints and the
3763    /// hydrate helper joins onto the host volume root — and are validated
3764    /// against traversal, because they name a host path something will write to.
3765    ///
3766    /// # What is and is not wired
3767    ///
3768    /// This accessor plus [`validate::shape`]'s check on it is the whole of the
3769    /// runtime effect today: **declaring a tier does not yet cause a backup to
3770    /// happen.** `turso-backup` implements all three tiers
3771    /// ([`DurabilityTier::Snapshot`] = its tier 1a, [`DurabilityTier::Dedup`] =
3772    /// 1b, [`DurabilityTier::Stream`] = 2 with restore-by-frame-replay) and,
3773    /// since R850-F1, the fencing epoch a hydrate must hold
3774    /// (`turso_backup::claim`). Nothing in yubaba's reconciler calls into any of
3775    /// it yet.
3776    ///
3777    /// Until that lands, the declaration's value is exactly that
3778    /// `cloud::topology` can tell an operator, *before* the topology is
3779    /// committed, which of their stateful workloads has no second copy of its
3780    /// bytes anywhere.
3781    /// The `[[db]]` rows, after the rules serde cannot enforce: names are
3782    /// non-blank and unique, and a `snapshot` row needs a `[durability]` whose
3783    /// subjects include its subject. A `vend` row needs a mesh port named
3784    /// `sql` and a verify-key secret mount (R960-F8), and every
3785    /// `capabilities` row must name a declared mesh port.
3786    pub fn db_rows(&self) -> Result<&[WorkloadDb], DbDeclError> {
3787        let port_names = self.expose.mesh.names();
3788        for c in &self.capabilities {
3789            if !port_names.contains(&c.port.as_str()) {
3790                return Err(DbDeclError::CapabilityPortUndeclared {
3791                    capability: c.name.clone(),
3792                    port: c.port.clone(),
3793                });
3794            }
3795        }
3796        let mut seen = std::collections::HashSet::new();
3797        for (index, row) in self.db.iter().enumerate() {
3798            if row.name.trim().is_empty() {
3799                return Err(DbDeclError::BlankName { index });
3800            }
3801            if !seen.insert(row.name.as_str()) {
3802                return Err(DbDeclError::DuplicateName { name: row.name.clone() });
3803            }
3804            if row.workbench == WorkbenchKind::Snapshot {
3805                let Some(d) = &self.durability else {
3806                    return Err(DbDeclError::SnapshotWithoutDurability { name: row.name.clone() });
3807                };
3808                if !d.subjects.iter().any(|s| s == &row.subject) {
3809                    return Err(DbDeclError::SnapshotSubjectNotDurable {
3810                        name: row.name.clone(),
3811                        subject: row.subject.clone(),
3812                    });
3813                }
3814            }
3815            if row.workbench == WorkbenchKind::Vend {
3816                if !port_names.contains(&SQL_PORT_NAME) {
3817                    return Err(DbDeclError::VendWithoutSqlPort { name: row.name.clone() });
3818                }
3819                if !self.secrets.iter().any(SecretMount::is_verify_key) {
3820                    return Err(DbDeclError::VendWithoutVerifyKey { name: row.name.clone() });
3821                }
3822            }
3823        }
3824        Ok(&self.db)
3825    }
3826
3827    /// Everything a node running this spec publishes on `GET /services`:
3828    /// the declared [`Self::capabilities`], then one `sql.hrana` row per
3829    /// `vend` [`WorkloadDb`] on the `sql` port.
3830    pub fn served_capabilities(&self) -> Vec<ServedCapability> {
3831        let declared = self.capabilities.iter().map(|c| ServedCapability {
3832            capability: c.name.clone(),
3833            port: c.port.clone(),
3834            db: None,
3835        });
3836        let vended = self
3837            .db
3838            .iter()
3839            .filter(|d| d.workbench == WorkbenchKind::Vend)
3840            .map(|d| ServedCapability {
3841                capability: SQL_HRANA_CAPABILITY.to_string(),
3842                port: SQL_PORT_NAME.to_string(),
3843                db: Some(d.name.clone()),
3844            });
3845        declared.chain(vended).collect()
3846    }
3847
3848    pub fn durability(&self) -> Result<Option<&Durability>, DurabilityDeclError> {
3849        if let Some(key) = self
3850            .annotations
3851            .keys()
3852            .filter(|k| k.starts_with("yah.durability."))
3853            .min()
3854        {
3855            return Err(DurabilityDeclError::RetiredAnnotation {
3856                key: key.clone(),
3857                field: retired_annotation_field(key).unwrap_or("durability"),
3858            });
3859        }
3860        let Some(d) = &self.durability else {
3861            return Ok(None);
3862        };
3863        d.check()?;
3864        Ok(Some(d))
3865    }
3866
3867    /// The first annotation (in key order) this spec still carries from a
3868    /// family R896-F3 moved into typed fields, paired with the field that
3869    /// replaced it. `None` for every spec written against the fields.
3870    ///
3871    /// [`validate::shape`] refuses on it. It exists because nothing reads
3872    /// those keys any more, and a key nothing reads fails silently: a
3873    /// `yah.limits.pids-max` would quietly fall back to the default bound, and
3874    /// a `yah.durability.tier` would quietly mean "no backup".
3875    pub fn retired_annotation(&self) -> Option<(&str, &'static str)> {
3876        self.annotations
3877            .keys()
3878            .filter_map(|k| retired_annotation_field(k).map(|field| (k.as_str(), field)))
3879            .min()
3880    }
3881}
3882
3883/// The typed field that replaced a retired annotation key (R896-F3), or `None`
3884/// for a key that was never retired.
3885///
3886/// Unrecognised keys under a retired prefix map to their parent field rather
3887/// than to `None`: a misspelled `yah.durability.teir` is still an attempt at a
3888/// durability declaration, and passing it would be the silent drop this exists
3889/// to prevent.
3890pub fn retired_annotation_field(key: &str) -> Option<&'static str> {
3891    Some(match key {
3892        "yah.placement.memory-request-mb" => "resources.memory_request_mb",
3893        "yah.limits.cpu-millis" => "resources.cpu_limit_millis",
3894        "yah.limits.pids-max" => "resources.pids_max",
3895        "yah.limits.scratch-floor-mb" => "resources.scratch_floor_mb",
3896        "yah.durability.tier" => "durability.tier",
3897        "yah.durability.engine" => "durability.engine",
3898        "yah.durability.store" => "durability.store",
3899        "yah.durability.subjects" => "durability.subjects",
3900        "yah.durability.rpo-seconds" => "durability.rpo_seconds",
3901        "yah.durability.state-mb" => "durability.state_mb",
3902        k if k.starts_with("yah.limits.") => "resources",
3903        k if k.starts_with("yah.durability.") => "durability",
3904        _ => return None,
3905    })
3906}
3907
3908/// Validate [`Durability::subjects`].
3909///
3910/// Every rule here exists because each subject is joined onto a host directory
3911/// (`/var/lib/yah/kamaji/volumes/<name>`) by something that then *writes* to
3912/// it. An absolute path or a `..` component would put a restore outside the
3913/// volume it was scoped to, so those are refused by name rather than
3914/// normalized — silently rewriting a path a human typed is how you restore the
3915/// right bytes to the wrong place.
3916fn check_durability_subjects(subjects: &[String]) -> Result<(), DurabilityDeclError> {
3917    for (i, s) in subjects.iter().enumerate() {
3918        if s.trim().is_empty() {
3919            return Err(DurabilityDeclError::EmptySubject);
3920        }
3921        if s.starts_with('/') || s.starts_with('\\') || s.contains(':') {
3922            return Err(DurabilityDeclError::AbsoluteSubject { subject: s.clone() });
3923        }
3924        if s.split('/').any(|c| c == "." || c == "..") {
3925            return Err(DurabilityDeclError::TraversingSubject { subject: s.clone() });
3926        }
3927        if subjects[..i].contains(s) {
3928            return Err(DurabilityDeclError::DuplicateSubject { subject: s.clone() });
3929        }
3930    }
3931    Ok(())
3932}
3933
3934/// Which database engine a [`DurabilityTier`]'s three tier names refer to
3935/// (R850-F1).
3936///
3937/// One variant today, and that is the point: the tier vocabulary was minted
3938/// from `turso-backup`'s implementation, so an appliance running anything else
3939/// gets a refusal at parse time instead of a tier nothing can honour. Adding an
3940/// engine means adding a restore path, not adding a string.
3941#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
3942#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3943#[serde(rename_all = "snake_case")]
3944pub enum DurabilityEngine {
3945    /// Turso / libSQL, via `turso-backup`. `snapshot` is its tier 1a `VACUUM
3946    /// INTO`, `dedup` its tier 1b page-dedup, `stream` its tier 2 WAL-frame
3947    /// streaming with restore-by-frame-replay.
3948    Turso,
3949}
3950
3951impl DurabilityEngine {
3952    /// The wire/TOML spelling, so a diagnostic and the file it points at agree.
3953    pub fn as_str(&self) -> &'static str {
3954        match self {
3955            Self::Turso => "turso",
3956        }
3957    }
3958}
3959
3960impl fmt::Display for DurabilityEngine {
3961    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3962        f.write_str(self.as_str())
3963    }
3964}
3965
3966/// A workload's declared durability tier — where a second copy of its state
3967/// lives, and how far behind that copy is allowed to be (R850-P4).
3968///
3969/// The three non-`None` variants name `turso-backup`'s three implemented
3970/// tiers. They are spelled here rather than imported because `workload-spec`
3971/// is a leaf crate every fleet node links and `turso-backup` is a service-side
3972/// dependency; the coupling that matters is the vocabulary, not the types.
3973#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
3974#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3975#[serde(rename_all = "snake_case")]
3976pub enum DurabilityTier {
3977    /// Deliberately no second copy. State lives only where the container runs
3978    /// and is gone when that node is. Legitimate for caches and scratch — and
3979    /// it is a *statement*, which is why it is not the same as declaring
3980    /// nothing (see [`WorkloadSpec::durability`]).
3981    None,
3982
3983    /// `turso-backup` tier 1a — periodic full `VACUUM INTO` snapshot to the
3984    /// object store. Recovery point is the last snapshot, so the loss window is
3985    /// the snapshot interval, which this declaration does not carry: a
3986    /// snapshot-tier workload's RPO is whatever schedules it.
3987    Snapshot,
3988
3989    /// `turso-backup` tier 1b — incremental page-dedup snapshot. Same recovery
3990    /// *point* semantics as [`Self::Snapshot`]; cheaper per run, so in practice
3991    /// a shorter interval.
3992    Dedup,
3993
3994    /// `turso-backup` tier 2 — WAL-frame streaming with restore by frame
3995    /// replay. The only tier with a *bounded, declarable* loss window; see
3996    /// [`Durability::rpo_seconds`] and
3997    /// `turso_backup::stream::DEFAULT_RPO_TARGET` (120 s), which is what an
3998    /// undeclared RPO means in practice.
3999    Stream,
4000}
4001
4002impl DurabilityTier {
4003    /// The wire/TOML spelling, so a diagnostic and the file it points at agree.
4004    pub fn as_str(&self) -> &'static str {
4005        match self {
4006            Self::None => "none",
4007            Self::Snapshot => "snapshot",
4008            Self::Dedup => "dedup",
4009            Self::Stream => "stream",
4010        }
4011    }
4012
4013    /// Whether this tier puts bytes in an object store — i.e. whether there is
4014    /// a copy to hydrate from after the node is gone.
4015    pub fn ships_bytes(&self) -> bool {
4016        !matches!(self, Self::None)
4017    }
4018}
4019
4020impl fmt::Display for DurabilityTier {
4021    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4022        f.write_str(self.as_str())
4023    }
4024}
4025
4026/// One database a workload declares for the data workbench (W358 decision 7).
4027#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
4028#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
4029pub struct WorkloadDb {
4030    /// The `name` segment of the derived id `fleet:<workload>:<name>`.
4031    pub name: String,
4032    /// The database file, volume-relative. For `snapshot` it must be one of
4033    /// `durability.subjects`.
4034    pub subject: String,
4035    /// How the workbench reaches it. Absent = [`WorkbenchKind::None`].
4036    #[serde(default)]
4037    pub workbench: WorkbenchKind,
4038}
4039
4040/// One capability a workload offers on one of its named mesh ports (R960-F8).
4041#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
4042#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
4043pub struct WorkloadCapability {
4044    /// Dotted capability name, e.g. `events.query`.
4045    pub name: String,
4046    /// The `expose.mesh.ports` name it is served on.
4047    pub port: String,
4048}
4049
4050/// A capability as a node publishes it: [`WorkloadSpec::capabilities`] plus
4051/// the `sql.hrana` row each `vend` [`WorkloadDb`] implies.
4052#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
4053pub struct ServedCapability {
4054    pub capability: String,
4055    pub port: String,
4056    /// The `[[db]]` name, for a vended database.
4057    #[serde(default)]
4058    pub db: Option<String>,
4059}
4060
4061/// The capability a `vend` [`WorkloadDb`] publishes.
4062pub const SQL_HRANA_CAPABILITY: &str = "sql.hrana";
4063/// The mesh port name a `vend` [`WorkloadDb`] is served on.
4064pub const SQL_PORT_NAME: &str = "sql";
4065/// The final path segment of the secret a `vend` workload verifies camp
4066/// tokens with (`cheers/.../verify-key`, W358 §Auth).
4067pub const VERIFY_KEY_SECRET: &str = "verify-key";
4068
4069impl SecretMount {
4070    /// Whether this mount delivers a cheers verify key — its source's last
4071    /// path segment is [`VERIFY_KEY_SECRET`].
4072    pub fn is_verify_key(&self) -> bool {
4073        let last = match &self.source {
4074            SecretRef::LocalFile { path } => path
4075                .file_name()
4076                .and_then(|n| n.to_str())
4077                .unwrap_or_default()
4078                .to_string(),
4079            SecretRef::Cluster { name } => name.rsplit('/').next().unwrap_or_default().to_string(),
4080        };
4081        last == VERIFY_KEY_SECRET
4082    }
4083}
4084
4085/// How a [`WorkloadDb`] is exposed to the workbench.
4086#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
4087#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
4088#[serde(rename_all = "kebab-case")]
4089#[ts(rename_all = "kebab-case")]
4090pub enum WorkbenchKind {
4091    /// Declared, not listed.
4092    #[default]
4093    None,
4094    /// The latest object-store backup, opened read-only.
4095    Snapshot,
4096    /// A live connection vended by the workload's own `sql.hrana` service.
4097    Vend,
4098}
4099
4100/// A `[[db]]` row that cannot be acted on.
4101#[derive(Debug, Clone, PartialEq, Eq)]
4102pub enum DbDeclError {
4103    BlankName { index: usize },
4104    DuplicateName { name: String },
4105    /// `snapshot` restores from `durability.store`; with no `[durability]`
4106    /// there is nothing to restore from.
4107    SnapshotWithoutDurability { name: String },
4108    SnapshotSubjectNotDurable { name: String, subject: String },
4109    /// `vend` serves on the mesh port named `sql`; none is declared.
4110    VendWithoutSqlPort { name: String },
4111    /// `vend` verifies camp tokens; no verify-key secret is mounted.
4112    VendWithoutVerifyKey { name: String },
4113    /// A `capabilities` row names a port `expose.mesh.ports` does not.
4114    CapabilityPortUndeclared { capability: String, port: String },
4115}
4116
4117impl fmt::Display for DbDeclError {
4118    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4119        match self {
4120            Self::BlankName { index } => write!(f, "db[{index}].name is blank"),
4121            Self::DuplicateName { name } => write!(f, "db names {name:?} twice"),
4122            Self::SnapshotWithoutDurability { name } => write!(
4123                f,
4124                "db {name:?} has workbench = \"snapshot\" but the spec declares no [durability]; \
4125                 a snapshot is the latest backup, and there is no backup to open"
4126            ),
4127            Self::SnapshotSubjectNotDurable { name, subject } => write!(
4128                f,
4129                "db {name:?} has workbench = \"snapshot\" with subject {subject:?}, which is \
4130                 not in durability.subjects; only a subject the backup covers can be opened"
4131            ),
4132            Self::VendWithoutSqlPort { name } => write!(
4133                f,
4134                "db {name:?} has workbench = \"vend\" but expose.mesh.ports names no \
4135                 \"{SQL_PORT_NAME}\" port; the vended listener is served on it"
4136            ),
4137            Self::VendWithoutVerifyKey { name } => write!(
4138                f,
4139                "db {name:?} has workbench = \"vend\" but no secret mount sources a \
4140                 \"{VERIFY_KEY_SECRET}\"; the listener cannot check camp tokens without it"
4141            ),
4142            Self::CapabilityPortUndeclared { capability, port } => write!(
4143                f,
4144                "capability {capability:?} is bound to port {port:?}, which \
4145                 expose.mesh.ports does not name"
4146            ),
4147        }
4148    }
4149}
4150
4151impl std::error::Error for DbDeclError {}
4152
4153/// A workload's durability declaration — [`WorkloadSpec::durability`].
4154///
4155/// Every field but `tier` defaults, because which of them are required depends
4156/// on the tier, and that is a rule serde cannot express. [`Self::check`] is
4157/// where it is enforced; read the declaration through
4158/// [`WorkloadSpec::durability`], which applies it.
4159///
4160/// @yah:ticket(R960-F6, "[[db]] on WorkloadSpec (name, subject, workbench = none|snapshot|vend) + catalog derives fleet:<workload>:<name> from the camp's own load_workloads")
4161/// @yah:status(review)
4162/// @yah:at(2026-10-08T00:39:23Z)
4163/// @yah:assignee(agent:bundle-anthropic-miravel)
4164/// @yah:phase(P2)
4165/// @yah:parent(R960)
4166/// @yah:next("Add `db: Vec<WorkloadDb>` beside durability/expose.mesh (W358 decision 7). Spec validation refuses a `snapshot` row whose subject is not in durability.subjects, and `snapshot` on a spec with no [durability]. `vend` validation (sql mesh port + verify-key mount) belongs to R960-F8, which also edits this file.")
4167/// @yah:next("Catalog derivation: for each camp workload TOML read by load_workloads (config.rs:3151), each [[db]] with workbench != none yields a Connection constructed (not parsed) with id fleet:<workload>:<name> - the same struct R960-F1 parses. Listing makes no fleet call.")
4168/// @yah:next("Regen both generated artifacts by hand: `cargo run -p xtask -- emit-schemas` and `cargo run --manifest-path oss/yah-base/crates/workload-spec/Cargo.toml --bin export-ts` (workload-spec-drift-guard).")
4169/// @yah:gotcha("Rolling fleet: nodes on the old binary will see `db` on specs. Carrying structs don't deny unknown keys (R896-T4 note on WorkloadSpec), so it should be ignored - but check kamaji-proto's digest + tests/tolerant_field_policy.rs before assuming a new field leaves existing spec digests (and thus deploy no-ops) unchanged. Unverified.")
4170/// @yah:gotcha("R959 (service -> workload lowering) must round-trip this block; it is design-phase, so no edge - R959 already names [[db]] in its next.")
4171/// @arch:see(.yah/docs/working/W358-data-connections-remote-dbs-through-the-vault.md)
4172/// @yah:depends_on(R960-F1)
4173/// @yah:files(oss/yah-base/crates/workload-spec/src/lib.rs)
4174/// @yah:files(packages/yah/workload-spec/index.ts)
4175/// @yah:files(oss/yubaba/crates/cloud/src/config.rs)
4176/// @yah:handoff("Landed (uncommitted, git policy defer): WorkloadSpec.db: Vec<WorkloadDb{name,subject,workbench: WorkbenchKind none|snapshot|vend (default none)}> in workload-spec lib.rs; WorkloadSpec::db_rows() enforces non-blank/unique names, snapshot needs [durability], snapshot subject must be in durability.subjects (DbDeclError); validate::shape calls it (FieldPath::Db). vend validation left to R960-F8. Catalog: Connection::derive_fleet(camp_root) + fleet_from_specs in cloud config.rs read load_workloads over .yah/infra/workloads, construct fleet:<workload>:<db> (name = '<workload>:<db>', group fleet, Snapshot->public/auth none, vend->Service/mesh/camp-token, policy deny/deny); desktop db_catalog_for_camp extends its connection list with it. All WorkloadSpec literals in oss/yah-base, kamaji, yubaba, roadcase, app/desktop, crates/hub gained db: Vec::new(). Regenerated schemas + packages/yah/workload-spec/index.ts; both drift scripts ok.")
4177/// @yah:gotcha("Digest finding: kamaji spec_digest = SHA-256 over canonical JSON of the Workload, so an always-serialized empty `db: []` would change every spec digest (redeploy-everything, and a per-sweep redeploy loop against not-yet-rolled kamajis whose digest is of a struct without db). skip_serializing_if is NOT usable: WorkloadSpec rides positional postcard (tests/round_trip.rs 3 tests broke when I tried it). Fix: db is always serialized; kamaji-proto digest.rs spec_digest drops an empty `db` array before hashing, so empty db digests byte-identical to pre-field (test an_empty_db_digests_as_if_the_field_did_not_exist). Non-empty db changes the digest; an un-rolled kamaji (ignores db) will mismatch for specs that declare db until rolled - roll kamaji before adding [[db]] to a live spec. tolerant_field_policy.rs unchanged and green (db has serde default, so absence does not break decode).")
4178/// @yah:gotcha("Unrelated pre-existing red seen: oss/yubaba crates/yubaba/tests/raft_appliance_ownership.rs:195/208 E0061 (5 args expected, 4 given) - a peer's in-flight change, not touched.")
4179/// @yah:assumes("Derived snapshot connection uses auth.kind=none (validate() permits it for snapshot sources); the R2 pair is resolved by the snapshot opener (R960-F7) from durability.store, not named on the Connection. vend derives camp-token auth, channel mesh, source Service{workload, db}.")
4180/// @yah:assumes("Derivation failure (any workload TOML failing shape validation) skips all fleet entries with a tracing warning in the desktop catalog, matching how a bad connections dir is handled.")
4181/// @yah:verify("workload-spec cargo test: 211 lib + 108 integration, 0 failed (before my change the ts_drift test was the only red after adding the field; regenerated). yah-cloud --lib: 1311 passed / 0 failed / 6 ignored incl. fleet_derivation_* x2. kamaji-proto: 44+4+6 passed incl. an_empty_db_digests_as_if_the_field_did_not_exist; tolerant_field_policy green. cargo check -p yah -p desktop EXIT=0. scripts/check-schema-drift.sh and check-workload-spec-ts.sh ok. cargo check --workspace --all-targets green for oss/yah-base and oss/kamaji; oss/yubaba red only at tests/raft_appliance_ownership.rs (not mine). Skew advisory: workload-spec lib.rs was modified mid-run on the last build (peer, likely R960-F8 sharing the file) - re-verify before trusting. Root workspace --all-targets, roadcase, and the desktop catalog test for fleet entries were not run/added.")
4182/// @yah:handoff("Leader (Fable session:62240105) re-verified 2026-10-07 after courier Miravel session:ad659ea1 returned: yah-workload-spec 319 passed / 0 failed (lib + integration), yah-cloud --lib fleet 6/0, kamaji-proto 54/0 incl. the empty-db digest test, check-schema-drift.sh and check-workload-spec-ts.sh both ok. The digest decision (always serialize db; kamaji-proto drops an empty db before hashing; roll kamaji before any live spec declares [[db]]) is accepted and carried to R960-T12's roll sequencing. The oss/yubaba raft_appliance_ownership.rs E0061 is a peer's in-flight change, not this ticket's.")
4183/// @yah:verify("Leader re-run: cargo test --manifest-path oss/yah-base/crates/workload-spec/Cargo.toml -> 319 passed, 0 failed; cd oss/yubaba && cargo test -p yah-cloud --lib fleet -> 6 passed; cd oss/kamaji && cargo test -p kamaji-proto -> 54 passed, 0 failed; scripts/check-schema-drift.sh EXIT=0; scripts/check-workload-spec-ts.sh EXIT=0.")
4184/// @yah:handoff("Validation answer (courier Miravel): `yah cloud validate` (handle_validate, app/yah/cli/src/cloud.rs:14214) calls CloudConfig::load -> load_workloads (oss/yubaba/crates/cloud/src/config.rs:3195) -> workload_spec::validate::shape, which ends in spec.db_rows() (oss/yah-base/crates/workload-spec/src/validate.rs:654). So no gap: new test cloud::validate_db_rows_tests proves a [[db]] subject outside durability.subjects fails the verb (and a covered one passes). No wiring needed.")
4185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
4186#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
4187pub struct Durability {
4188    pub tier: DurabilityTier,
4189    /// Which engine's tier vocabulary this is. `Some` exactly when
4190    /// [`DurabilityTier::ships_bytes`] — enforced by [`Self::check`] (R850-F1).
4191    #[serde(default)]
4192    #[ts(optional = nullable)]
4193    pub engine: Option<DurabilityEngine>,
4194    /// Object-store URL the copy lives at. `Some` (and non-blank) exactly when
4195    /// [`DurabilityTier::ships_bytes`] — enforced by [`Self::check`].
4196    #[serde(default)]
4197    #[ts(optional = nullable)]
4198    pub store: Option<String>,
4199    /// Volume-relative paths of the database files this tier covers, in
4200    /// declaration order. Non-empty exactly when
4201    /// [`DurabilityTier::ships_bytes`] — enforced by [`Self::check`] (R850-F1).
4202    ///
4203    /// Volume-relative, never absolute: the same string is joined onto the
4204    /// container's mount target when read as documentation and onto
4205    /// `/var/lib/yah/kamaji/volumes/<name>` when a hydrate writes it. Each is
4206    /// also the object-store key suffix under [`Self::store`], so the layout an
4207    /// operator sees in the bucket mirrors the layout on the volume.
4208    #[serde(default)]
4209    pub subjects: Vec<String>,
4210    /// Declared recovery-point objective in seconds. [`DurabilityTier::Stream`]
4211    /// only; `None` there means `turso_backup::stream::DEFAULT_RPO_TARGET`.
4212    #[serde(default)]
4213    #[ts(optional = nullable)]
4214    pub rpo_seconds: Option<u32>,
4215    /// Expected steady-state size of this workload's state, in MiB — the input
4216    /// a cold-start-from-object-store estimate needs and cannot get anywhere
4217    /// else. The microVM scratch floor ([`WorkloadSpec::scratch_floor_mb`]) is
4218    /// not it: that sizes a job's ephemeral workspace, and a named volume is
4219    /// neither ephemeral nor a workspace.
4220    ///
4221    /// **Declared, never measured.** Any recovery-time figure derived from it
4222    /// inherits that, and must say so at the point it is printed.
4223    #[serde(default)]
4224    #[ts(optional = nullable)]
4225    pub state_mb: Option<u32>,
4226}
4227
4228impl Durability {
4229    /// The rules that span fields, which serde cannot enforce. Checked in the
4230    /// order a human would fix them: where the copy goes, when, what engine,
4231    /// which files.
4232    pub fn check(&self) -> Result<(), DurabilityDeclError> {
4233        let tier = self.tier;
4234        let ships = tier.ships_bytes();
4235
4236        // A tier that ships bytes needs to name the somewhere. Defaulting it
4237        // would put the only copy of a database in a bucket nobody chose.
4238        let store = self.store.as_deref().filter(|s| !s.trim().is_empty());
4239        if ships && store.is_none() {
4240            return Err(DurabilityDeclError::MissingStore { tier });
4241        }
4242        if !ships && self.store.is_some() {
4243            return Err(DurabilityDeclError::StoreWithoutTier);
4244        }
4245
4246        if self.rpo_seconds.is_some() && tier != DurabilityTier::Stream {
4247            return Err(DurabilityDeclError::RpoOnNonStreamTier { tier });
4248        }
4249
4250        // R850-F1: the engine axis. Required by every tier that ships bytes,
4251        // because the three tier names are turso-backup's and a spec that means
4252        // something else must say so rather than be discovered at restore time.
4253        match (ships, self.engine) {
4254            (true, None) => return Err(DurabilityDeclError::MissingEngine { tier }),
4255            (false, Some(_)) => return Err(DurabilityDeclError::EngineWithoutTier),
4256            _ => {}
4257        }
4258
4259        match (ships, self.subjects.is_empty()) {
4260            (true, true) => return Err(DurabilityDeclError::MissingSubjects { tier }),
4261            (false, false) => return Err(DurabilityDeclError::SubjectsWithoutTier),
4262            _ => {}
4263        }
4264        check_durability_subjects(&self.subjects)
4265    }
4266}
4267
4268/// A durability declaration that cannot be acted on.
4269///
4270/// Every variant is a *refusal to guess*. The alternative — falling back to
4271/// "undeclared" on a malformed value, the way [`WorkloadSpec::memory_request_mb`]
4272/// falls back to its ceiling — is safe there and unsafe here: a mistyped memory
4273/// request costs a placement, a mistyped durability tier costs the database.
4274/// (An unknown tier or engine, or a non-numeric RPO, no longer reaches this
4275/// type: those are serde refusals on the typed field.)
4276#[derive(Debug, Clone, PartialEq, Eq)]
4277pub enum DurabilityDeclError {
4278    /// R896-F3: the spec still declares durability through the retired
4279    /// `yah.durability.*` annotations. Nothing reads them, so passing the spec
4280    /// would silently mean "no backup".
4281    RetiredAnnotation { key: String, field: &'static str },
4282    /// A tier that ships bytes with nowhere to ship them.
4283    MissingStore { tier: DurabilityTier },
4284    /// `tier = "none"` with a store — contradictory, and the reader cannot
4285    /// tell which half is the mistake.
4286    StoreWithoutTier,
4287    /// An RPO on a tier that has no bounded loss window to state.
4288    RpoOnNonStreamTier { tier: DurabilityTier },
4289    /// R850-F1: a bytes-shipping tier with no engine. The tier names are
4290    /// turso-backup's; a spec that means a different engine has to say so.
4291    MissingEngine { tier: DurabilityTier },
4292    /// R850-F1: an engine alongside `tier = "none"` — nothing ships, so there
4293    /// is nothing for an engine to be the engine *of*.
4294    EngineWithoutTier,
4295    /// R850-F1: a bytes-shipping tier that names no database files.
4296    MissingSubjects { tier: DurabilityTier },
4297    /// R850-F1: subjects alongside `tier = "none"`.
4298    SubjectsWithoutTier,
4299    /// R850-F1: a blank entry in the subject list. Skipping it silently would
4300    /// hide a truncated list.
4301    EmptySubject,
4302    /// R850-F1: a subject that is not volume-relative.
4303    AbsoluteSubject { subject: String },
4304    /// R850-F1: a subject containing a `.` or `..` component.
4305    TraversingSubject { subject: String },
4306    /// R850-F1: the same subject listed twice — it would be backed up twice
4307    /// under one key and restored twice over itself.
4308    DuplicateSubject { subject: String },
4309}
4310
4311impl fmt::Display for DurabilityDeclError {
4312    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4313        match self {
4314            Self::RetiredAnnotation { key, field } => write!(
4315                f,
4316                "annotation {key:?} is no longer read — durability is the typed `{field}` \
4317                 field since R896-F3; move the declaration into a [durability] table, because \
4318                 an ignored annotation would silently mean this workload has no backup"
4319            ),
4320            Self::MissingStore { tier } => write!(
4321                f,
4322                "durability.tier = \"{tier}\" needs durability.store — there is no default \
4323                 bucket, because a default would put the only copy of this workload's state \
4324                 somewhere nobody chose"
4325            ),
4326            Self::StoreWithoutTier => write!(
4327                f,
4328                "durability.store is set alongside durability.tier = \"none\"; drop one — \
4329                 either the state is backed up or it is deliberately not"
4330            ),
4331            Self::RpoOnNonStreamTier { tier } => write!(
4332                f,
4333                "durability.rpo_seconds applies only to durability.tier = \"stream\", not \
4334                 \"{tier}\" — a snapshot tier's recovery point is set by whatever schedules \
4335                 the snapshot, not by the spec"
4336            ),
4337            Self::MissingEngine { tier } => write!(
4338                f,
4339                "durability.tier = \"{tier}\" needs durability.engine = \"turso\" — the tier \
4340                 vocabulary is turso-backup's, and a declaration that does not say so cannot \
4341                 be acted on"
4342            ),
4343            Self::EngineWithoutTier => write!(
4344                f,
4345                "durability.engine is set alongside durability.tier = \"none\"; nothing \
4346                 ships, so drop one"
4347            ),
4348            Self::MissingSubjects { tier } => write!(
4349                f,
4350                "durability.tier = \"{tier}\" needs durability.subjects — a restore's unit is \
4351                 a database file, not a volume, and guessing which files in the volume are \
4352                 databases is guessing about the only copy of this workload's state"
4353            ),
4354            Self::SubjectsWithoutTier => write!(
4355                f,
4356                "durability.subjects is set alongside durability.tier = \"none\"; nothing \
4357                 ships, so drop one"
4358            ),
4359            Self::EmptySubject => write!(
4360                f,
4361                "durability.subjects has a blank entry; every entry must name a database file"
4362            ),
4363            Self::AbsoluteSubject { subject } => write!(
4364                f,
4365                "durability.subjects entry {subject:?} must be relative to the workload's \
4366                 volume — an absolute path would restore outside it"
4367            ),
4368            Self::TraversingSubject { subject } => write!(
4369                f,
4370                "durability.subjects entry {subject:?} contains a \".\" or \"..\" component; \
4371                 it would restore outside the volume it is scoped to"
4372            ),
4373            Self::DuplicateSubject { subject } => {
4374                write!(f, "durability.subjects names {subject:?} twice")
4375            }
4376        }
4377    }
4378}
4379
4380impl std::error::Error for DurabilityDeclError {}
4381
4382/// Split and validate [`WRITABLE_PATHS_ANNOTATION`].
4383///
4384/// Mirrors [`check_durability_subjects`] in shape and in temperament, with the
4385/// polarity of the absolute-path rule flipped: a subject is joined onto a volume
4386/// root and must therefore be relative, while a writable path names a host
4387/// directory outright and must therefore be absolute. Both refuse rather than
4388/// normalize, for the same reason — the result is handed to something that
4389/// grants access at that path.
4390fn parse_writable_paths(raw: &str) -> Result<Vec<PathBuf>, WritablePathsDeclError> {
4391    let mut out: Vec<PathBuf> = Vec::new();
4392    for part in raw.split(',') {
4393        let s = part.trim();
4394        if s.is_empty() {
4395            return Err(WritablePathsDeclError::EmptyPath);
4396        }
4397        if !s.starts_with('/') {
4398            return Err(WritablePathsDeclError::RelativePath {
4399                path: s.to_string(),
4400            });
4401        }
4402        if s.split('/').any(|c| c == "." || c == "..") {
4403            return Err(WritablePathsDeclError::TraversingPath {
4404                path: s.to_string(),
4405            });
4406        }
4407        let path = PathBuf::from(s);
4408        if out.contains(&path) {
4409            return Err(WritablePathsDeclError::DuplicatePath {
4410                path: s.to_string(),
4411            });
4412        }
4413        out.push(path);
4414    }
4415    Ok(out)
4416}
4417
4418/// Why a [`WRITABLE_PATHS_ANNOTATION`] value could not be read (R885-B11).
4419///
4420/// Every variant is a refusal to guess, for the reason
4421/// [`WorkloadSpec::writable_paths`] records: each entry becomes a *grant*, so a
4422/// value nobody can read unambiguously must fail admission rather than resolve
4423/// to whichever subtree the ambiguity happened to point at.
4424#[derive(Debug, Clone, PartialEq, Eq)]
4425pub enum WritablePathsDeclError {
4426    /// An empty entry — a stray or trailing comma. Skipping it silently would
4427    /// hide a truncated list.
4428    EmptyPath,
4429    /// A relative entry. There is no directory for it to be relative *to*: the
4430    /// declaration is read on the node, by a daemon whose own working directory
4431    /// is whatever systemd gave it.
4432    RelativePath { path: String },
4433    /// A `.` or `..` component. The resolved path is not the written one.
4434    TraversingPath { path: String },
4435    /// The same path twice — one of the two is a mistake, and the reader cannot
4436    /// tell which.
4437    DuplicatePath { path: String },
4438}
4439
4440impl fmt::Display for WritablePathsDeclError {
4441    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4442        match self {
4443            Self::EmptyPath => write!(
4444                f,
4445                "{WRITABLE_PATHS_ANNOTATION} has an empty entry (a stray or trailing comma)"
4446            ),
4447            Self::RelativePath { path } => write!(
4448                f,
4449                "{WRITABLE_PATHS_ANNOTATION} entry {path:?} must be an absolute host path"
4450            ),
4451            Self::TraversingPath { path } => write!(
4452                f,
4453                "{WRITABLE_PATHS_ANNOTATION} entry {path:?} contains a \".\" or \"..\" component; \
4454                 it would grant write access to a directory other than the one written down"
4455            ),
4456            Self::DuplicatePath { path } => {
4457                write!(f, "{WRITABLE_PATHS_ANNOTATION} names {path:?} twice")
4458            }
4459        }
4460    }
4461}
4462
4463impl std::error::Error for WritablePathsDeclError {}
4464
4465#[cfg(test)]
4466mod writable_paths_tests {
4467    use super::*;
4468
4469    fn spec_with(annotation: Option<&str>) -> WorkloadSpec {
4470        let image = ImageRef {
4471            registry: "localhost".into(),
4472            repository: "native/svc".into(),
4473            tag: "dev".into(),
4474            digest: crate::testing::test_digest(),
4475        };
4476        let mut spec = WorkloadSpec::for_forge("w1", image, TierTag("infra".into()), vec![]);
4477        if let Some(v) = annotation {
4478            spec.annotations
4479                .insert(WRITABLE_PATHS_ANNOTATION.to_string(), v.to_string());
4480        }
4481        spec
4482    }
4483
4484    #[test]
4485    fn an_undeclared_spec_yields_no_writable_paths() {
4486        assert_eq!(
4487            spec_with(None).writable_paths().unwrap(),
4488            Vec::<PathBuf>::new()
4489        );
4490    }
4491
4492    #[test]
4493    fn a_comma_separated_list_is_split_and_trimmed() {
4494        let spec = spec_with(Some("/var/lib/yah/qed, /tmp"));
4495        assert_eq!(
4496            spec.writable_paths().unwrap(),
4497            vec![PathBuf::from("/var/lib/yah/qed"), PathBuf::from("/tmp")]
4498        );
4499    }
4500
4501    #[test]
4502    fn a_relative_entry_is_refused() {
4503        let err = spec_with(Some("var/lib/yah/qed"))
4504            .writable_paths()
4505            .unwrap_err();
4506        assert!(matches!(err, WritablePathsDeclError::RelativePath { .. }));
4507    }
4508
4509    #[test]
4510    fn a_traversing_entry_is_refused() {
4511        let err = spec_with(Some("/var/lib/yah/qed/../../.."))
4512            .writable_paths()
4513            .unwrap_err();
4514        assert!(matches!(err, WritablePathsDeclError::TraversingPath { .. }));
4515    }
4516
4517    #[test]
4518    fn a_trailing_comma_is_refused_rather_than_skipped() {
4519        let err = spec_with(Some("/tmp,")).writable_paths().unwrap_err();
4520        assert_eq!(err, WritablePathsDeclError::EmptyPath);
4521    }
4522
4523    #[test]
4524    fn a_duplicate_entry_is_refused() {
4525        let err = spec_with(Some("/tmp,/tmp")).writable_paths().unwrap_err();
4526        assert!(matches!(err, WritablePathsDeclError::DuplicatePath { .. }));
4527    }
4528
4529    #[test]
4530    fn the_declaration_is_independent_of_the_substrate_marker() {
4531        // The two are orthogonal by construction: declaring writes says nothing
4532        // about which backend runs the workload, and a native marker does not
4533        // imply a declaration (that asymmetry is the whole of the "confine what
4534        // describes itself" rule).
4535        let spec = spec_with(Some("/tmp"));
4536        assert!(!spec.wants_native_exec());
4537        assert!(!spec_with(None)
4538            .annotations
4539            .contains_key(WRITABLE_PATHS_ANNOTATION));
4540    }
4541}
4542
4543/// Annotation key requesting a workload share the host network namespace.
4544/// See [`WorkloadSpec::wants_host_network`].
4545pub const HOST_NETWORK_ANNOTATION: &str = "yah.network";
4546
4547/// Annotation value (for [`HOST_NETWORK_ANNOTATION`]) selecting host
4548/// networking. Any other value leaves the workload in an isolated netns.
4549pub const HOST_NETWORK_VALUE: &str = "host";
4550
4551/// Annotation key declaring that a workload must land only on a node
4552/// carrying a specific taint. See [`WorkloadSpec::requires_taint`].
4553pub const REQUIRES_TAINT_ANNOTATION: &str = "yah.placement.requires-taint";
4554
4555/// Default `pids.max` for a workload that declares no
4556/// [`ResourceLimits::pids_max`] — R885-T2.
4557///
4558/// `15%` of `4_194_304`, i.e. `629_145`. Both halves are grounded, not
4559/// invented:
4560///
4561/// - **The percentage** is systemd's own `DefaultTasksMax=`, the share of
4562///   `pid_max` systemd applies to any unit that does not set `TasksMax=`
4563///   explicitly (`systemd-system.conf(5)`). `kamaji.service` is one such
4564///   unit — it sets `Delegate=yes` but no `TasksMax=` — so this constant
4565///   gives native workloads the same *proportion* systemd would already give
4566///   the service itself.
4567/// - **The base** is `4_194_304`, the `pid_max` systemd has shipped since
4568///   v243 (2019) on every 64-bit host via `/usr/lib/sysctl.d/50-pid-max.conf`
4569///   — the fleet's own baseline, since every node here runs kamaji as a
4570///   systemd unit.
4571///
4572/// It does not need to be tight to be useful. A fork bomb today can exhaust
4573/// the *entire node's* pid space, denying PIDs to every other cgroup on the
4574/// box — sshd, monitoring, kamaji itself. A per-workload ceiling at 15% of
4575/// that space turns "the node is down" into "this workload's cgroup is
4576/// full", which is the actual blast-radius reduction R885-T2 asks for; it
4577/// does not need to also be a tight budget. It is also nowhere near tight
4578/// enough to strangle a legitimate workload — the forge build leg
4579/// (`cargo build -jN`) fans out to at most a few hundred rustc/linker
4580/// processes, several orders of magnitude below this ceiling.
4581pub const DEFAULT_PIDS_MAX: u32 = 629_145;
4582
4583/// The memory request [`WorkloadSpec::for_forge`] declares (MiB).
4584///
4585/// A forge run is a build, and a build's *ceiling* is deliberately roomy
4586/// (`FORGE_MEMORY_LIMIT_MB`); this is the much smaller floor a node must have
4587/// free to be a legal target for one. 2 GiB is what the heaviest forge shape
4588/// in the tree already asks for by hand — `velveteen_exec::remote`'s buildkit
4589/// image-build step overrides `resources.memory_mb` to exactly this — so it is
4590/// a measured number rather than a guess, and it keeps the fleet's 8 GiB
4591/// build-workers schedulable.
4592pub const FORGE_MEMORY_REQUEST_MB: u32 = 2048;
4593
4594/// The microVM scratch floor [`WorkloadSpec::for_forge`] declares (MiB) — see
4595/// [`ResourceLimits::scratch_floor_mb`].
4596///
4597/// 512 MiB, carried over unchanged from the `ephemeral_storage_mb` R885-T6
4598/// deleted, and **inert by construction**: the microVM backend's own
4599/// `WORKSPACE_MIN_BYTES` floor is 8 GiB, sixteen times this, and a floor is a
4600/// `max`. It is preserved rather than dropped so the deletion of the field is a
4601/// pure rename of a declaration and not a silent behaviour change, and so the
4602/// number an operator would have to raise is visible in one place if a forge
4603/// workspace ever needs to be bigger than the backend default.
4604pub const FORGE_SCRATCH_FLOOR_MB: u32 = 512;
4605
4606/// The cgroup memory ceiling [`WorkloadSpec::for_forge`] sets (MiB).
4607///
4608/// Bounded rather than unlimited so a runaway build cannot take the host
4609/// down, and large enough for the V8 build's >12 GB peak (R590-B10). It is
4610/// **not** a placement input — see [`FORGE_MEMORY_REQUEST_MB`].
4611pub const FORGE_MEMORY_LIMIT_MB: u32 = 32768;
4612
4613/// Taint name (for [`REQUIRES_TAINT_ANNOTATION`]) identifying machines with
4614/// a publicly-routable IP — the W267 sovereign-ingress placement
4615/// requirement. `MachineConfig.taints` (R572-F3) is the matching node-side
4616/// field and `RequiredSpec::matches` (R572-F5) is the consumer, so this is a
4617/// live key on both sides: a node may carry it, and the cloudflared/passway
4618/// ingress specs require it.
4619pub const PUBLIC_IP_TAINT: &str = "public-ip";
4620
4621/// Annotation key selecting which **execution substrate** kamaji runs a
4622/// workload on. Absent (or unrecognised) means a container backend; see
4623/// [`NATIVE_EXEC_VALUE`] and [`MICROVM_EXEC_VALUE`] for the two opt-outs.
4624///
4625/// The name is historical — R577-T1 introduced it for native exec alone — but
4626/// the key has always been the substrate selector, and R605-F8 added the
4627/// second alternative rather than a second key. See
4628/// [`WorkloadSpec::wants_microvm`] for why one key matters.
4629pub const NATIVE_EXEC_ANNOTATION: &str = "yah.exec";
4630
4631/// Annotation value (for [`NATIVE_EXEC_ANNOTATION`]) selecting native
4632/// host execution. Any other value leaves the workload on a container
4633/// backend.
4634pub const NATIVE_EXEC_VALUE: &str = "native";
4635
4636/// Annotation value (for [`NATIVE_EXEC_ANNOTATION`]) selecting a **microVM**:
4637/// the workload boots in its own KVM guest rather than sharing the host
4638/// kernel. See [`WorkloadSpec::wants_microvm`].
4639pub const MICROVM_EXEC_VALUE: &str = "microvm";
4640
4641/// Annotation key asking the node's kamaji to **resume** this native-exec
4642/// workload, or this service-shaped microVM, after kamaji itself restarts (a
4643/// reboot, a control-plane roll).
4644///
4645/// Native workloads and microVM Firecracker processes live in
4646/// `kamaji.service`'s cgroup, so every kamaji restart kills them, and nothing
4647/// else on the node remembers them: yubaba keeps no spec for a direct
4648/// `/workloads/deploy`. Serve bundles have always been replayed (R755-B5); this
4649/// is the same replay for a native workload (R936-B11) or a service-shaped
4650/// microVM (R605-F16) that asks for it. A job-shaped microVM is never replayed,
4651/// annotation or not: its completion is its exit.
4652///
4653/// **Opt-in, not the default, and that is load-bearing.** A workload whose
4654/// placement yubaba decides — the headscale appliance follows the raft ingress
4655/// owner — must NOT come back on a node just because it last ran there: after
4656/// a failover that is a second coordinator. Only a workload pinned to this
4657/// node by construction sets this: a front door's inner door, or a dev-cluster
4658/// member VM whose writable root exists on this node's disk and nowhere else.
4659pub const RESUME_AFTER_RESTART_ANNOTATION: &str = "yah.resume";
4660
4661/// Annotation value (for [`RESUME_AFTER_RESTART_ANNOTATION`]) meaning "this
4662/// node's kamaji replays me after it restarts".
4663pub const RESUME_AFTER_RESTART_VALUE: &str = "node";
4664
4665/// Annotation key marking a workload as a **floater** (R936-B12): its placement
4666/// belongs to the cluster, not to the node it was deployed through.
4667///
4668/// Admitting one records its spec on every raft member and makes it a raft
4669/// tenant. The tenant's owner runs it, every other member keeps the spec and
4670/// stops any copy it holds, and yubaba's scheduler moves the tenant off an
4671/// owner the lease channel confirms dead. A stateful floater re-hydrates on its
4672/// new node through its own `[durability]` declaration, the same hydrate-on-place
4673/// a plain deploy gets.
4674///
4675/// Mutually exclusive in spirit with [`RESUME_AFTER_RESTART_ANNOTATION`]: a
4676/// floater must never come back on a node just because it last ran there.
4677pub const FLOAT_ANNOTATION: &str = "yah.float";
4678
4679/// Annotation value (for [`FLOAT_ANNOTATION`]) meaning "any live raft member
4680/// may run me, one at a time".
4681pub const FLOAT_VALUE: &str = "cluster";
4682
4683/// Annotation key requesting the capabilities a workload needs to stand up an
4684/// unprivileged container sandbox of its own.
4685/// See [`WorkloadSpec::wants_nested_sandbox`].
4686pub const NESTED_SANDBOX_ANNOTATION: &str = "yah.sandbox";
4687
4688/// Annotation value (for [`NESTED_SANDBOX_ANNOTATION`]) requesting the
4689/// nested-sandbox grant (`CAP_SETUID` + `CAP_SETGID`, `no_new_privs` off).
4690/// Any other value leaves the workload on the baseline sandbox.
4691pub const NESTED_SANDBOX_VALUE: &str = "nested";
4692
4693/// Annotation key declaring the **host paths a workload writes to**, as a
4694/// comma-separated list of absolute paths. See [`WorkloadSpec::writable_paths`].
4695///
4696/// A top-level key rather than `yah.sandbox.writable-paths`, deliberately:
4697/// [`NESTED_SANDBOX_ANNOTATION`] is itself the bare key `yah.sandbox`, and two
4698/// keys sharing that prefix while naming policies for two *different* backends
4699/// (a container capability grant and a native filesystem confinement) is the
4700/// kind of near-collision a reader has to keep straight by memory.
4701pub const WRITABLE_PATHS_ANNOTATION: &str = "yah.writable-paths";
4702
4703/// Annotation key declaring **how much this workload's code is trusted**, from
4704/// which admission derives the weakest isolation substrate it may run on
4705/// (R894-F1). See [`WorkloadSpec::trust`] and [`TrustLevel`].
4706///
4707/// # Why a separate key from [`NATIVE_EXEC_ANNOTATION`]
4708///
4709/// `yah.exec` is a *request*: what the dispatcher wants. `yah.trust` is a
4710/// *fact about the code*: where it came from. Folding them together — a fourth
4711/// `yah.exec` value meaning "untrusted, so microvm" — would make the fact
4712/// unstateable whenever the request is stricter than the minimum, and would
4713/// silently discard it if a later ticket widened the substrate set. They are
4714/// two different questions and a workload answers both.
4715///
4716/// The pairing is checked, not merely recorded: `cloud::config::admission_spec`
4717/// refuses any spec whose declared trust exceeds what its requested substrate
4718/// provides, so a `yah.trust = untrusted` workload cannot reach a node on the
4719/// host kernel.
4720pub const TRUST_ANNOTATION: &str = "yah.trust";
4721
4722/// Annotation value (for [`TRUST_ANNOTATION`]) declaring that this workload's
4723/// code is **not** trusted to share a kernel with the fleet. Stamped by
4724/// whatever ingests the code, never by the code's own author.
4725pub const TRUST_UNTRUSTED_VALUE: &str = "untrusted";
4726
4727/// Annotation value (for [`TRUST_ANNOTATION`]) declaring operator-authored
4728/// code. Identical in effect to omitting the key; it exists so an ingest path
4729/// can state the fact positively instead of by silence.
4730pub const TRUST_TRUSTED_VALUE: &str = "trusted";
4731
4732/// The execution substrate a workload runs on, **ordered by the isolation it
4733/// provides** — `Native < Container < MicroVm`.
4734///
4735/// The ordering is the type's whole reason to exist, and it is the one kamaji's
4736/// `Backend` enum documents widest-first: a native workload is fork+exec'd on
4737/// the host with no boundary at all, a container gets namespaces and a cgroup
4738/// on the host's kernel, and a microVM gets its own kernel behind hardware
4739/// virtualization. `Ord` is derived, so *declaration order below is the
4740/// security ordering* — do not reorder these variants, and insert a new one at
4741/// the position its isolation actually places it.
4742///
4743/// This is the same three-way distinction [`crate::admission::GrantRuntime`]
4744/// carries; that type stays separate because a grant names a substrate it
4745/// *admits* (an unordered label in a signed document) while this one answers
4746/// "is what I got at least as strong as what I need". `GrantRuntime::of_spec`
4747/// delegates here so the two cannot disagree about how a spec reads.
4748#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
4749pub enum ExecSubstrate {
4750    /// fork+exec on the host's own userland. Host kernel, no namespaces, no
4751    /// image — [`WorkloadSpec::wants_native_exec`].
4752    Native,
4753    /// A container backend: host kernel, namespaces + cgroup, OCI rootfs. The
4754    /// default when [`NATIVE_EXEC_ANNOTATION`] is absent or unrecognised.
4755    Container,
4756    /// A KVM guest with its own kernel — [`WorkloadSpec::wants_microvm`].
4757    MicroVm,
4758}
4759
4760impl ExecSubstrate {
4761    /// The annotation value that selects this substrate, or `None` for
4762    /// [`Self::Container`] (which is selected by saying nothing).
4763    pub fn annotation_value(self) -> Option<&'static str> {
4764        match self {
4765            ExecSubstrate::Native => Some(NATIVE_EXEC_VALUE),
4766            ExecSubstrate::MicroVm => Some(MICROVM_EXEC_VALUE),
4767            ExecSubstrate::Container => None,
4768        }
4769    }
4770
4771    /// How a refusal should name this substrate to an operator. Matches the
4772    /// annotation spelling for the two opt-outs; `container` is what every
4773    /// message in the tree already calls the default.
4774    pub fn as_str(self) -> &'static str {
4775        match self {
4776            ExecSubstrate::Native => NATIVE_EXEC_VALUE,
4777            ExecSubstrate::Container => "container",
4778            ExecSubstrate::MicroVm => MICROVM_EXEC_VALUE,
4779        }
4780    }
4781}
4782
4783/// How far a workload's code is trusted — the declared input from which
4784/// admission derives a **minimum** [`ExecSubstrate`] (R894).
4785///
4786/// # Absent means trusted, and that is only safe because of where it is stamped
4787///
4788/// The default direction is the trap this axis exists to close, so it is worth
4789/// stating exactly. Every `WorkloadSpec` in the tree today is built by operator
4790/// code — a reconciler, an appliance builder, a qed dispatcher — and none of
4791/// them declares trust. Making the absent key mean *untrusted* would refuse the
4792/// entire fleet on the day this lands; making it mean *trusted* is correct for
4793/// exactly that population and wrong for any other.
4794///
4795/// So the rule is not "absent means trusted". It is: **the single choke point
4796/// that ingests code the operator did not write stamps
4797/// [`TRUST_UNTRUSTED_VALUE`] as it builds the spec**
4798/// ([`WorkloadSpec::stamp_untrusted`]), and a spec that reaches admission
4799/// without having passed through operator-authored construction cannot exist.
4800/// A tenant does not hand yah a `WorkloadSpec`; it hands yah an image and an
4801/// argv, which yah's own code puts into a spec. That is why the marker is not
4802/// self-attestable: the untrusted party never holds the pen.
4803///
4804/// R823 (untrusted camp vending on microVMs) is the first such choke point. If
4805/// a second one appears, it stamps too — and the rule to apply is that any
4806/// constructor taking third-party bytes calls
4807/// [`stamp_untrusted`](WorkloadSpec::stamp_untrusted) in the same function that
4808/// takes them, not in a caller that might be forgotten.
4809#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
4810pub enum TrustLevel {
4811    /// Operator-authored code. No substrate floor.
4812    #[default]
4813    Trusted,
4814    /// Third-party code. Must not share a kernel with the fleet.
4815    Untrusted,
4816}
4817
4818impl TrustLevel {
4819    /// The weakest substrate this trust level may run on.
4820    ///
4821    /// [`TrustLevel::Untrusted`] maps to [`ExecSubstrate::MicroVm`] because a
4822    /// container shares the node's kernel, and "untrusted code never shares a
4823    /// kernel with the fleet" is the rule this axis makes checkable. W344's
4824    /// isolation table says "containerd or microVM" for higher-risk code; the
4825    /// 2026-09-11 operator call resolved that disjunction to the strict side
4826    /// for code the operator did not write.
4827    ///
4828    /// [`TrustLevel::Trusted`] maps to [`ExecSubstrate::Native`], the bottom of
4829    /// the ordering — i.e. no constraint, which is what every workload running
4830    /// today has.
4831    pub fn minimum_substrate(self) -> ExecSubstrate {
4832        match self {
4833            TrustLevel::Trusted => ExecSubstrate::Native,
4834            TrustLevel::Untrusted => ExecSubstrate::MicroVm,
4835        }
4836    }
4837
4838    /// The annotation value spelling this level.
4839    pub fn as_str(self) -> &'static str {
4840        match self {
4841            TrustLevel::Trusted => TRUST_TRUSTED_VALUE,
4842            TrustLevel::Untrusted => TRUST_UNTRUSTED_VALUE,
4843        }
4844    }
4845}
4846
4847/// Why a [`TRUST_ANNOTATION`] value could not be read (R894-F1).
4848///
4849/// Manual `Display` + `Error` impls in the same shape as
4850/// [`WritablePathsDeclError`], so `workload-spec` keeps its no-`thiserror` lib
4851/// surface.
4852#[derive(Debug, Clone, PartialEq, Eq)]
4853pub enum TrustDeclError {
4854    /// The value is neither [`TRUST_TRUSTED_VALUE`] nor
4855    /// [`TRUST_UNTRUSTED_VALUE`].
4856    ///
4857    /// Refused rather than normalised to either side. Reading it as trusted
4858    /// would let a typo (`untrused`) turn a microVM requirement off; reading it
4859    /// as untrusted would refuse a fleet workload over a typo nobody can see.
4860    /// Neither is a guess worth making about a security floor.
4861    UnknownLevel(String),
4862}
4863
4864impl std::fmt::Display for TrustDeclError {
4865    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4866        match self {
4867            TrustDeclError::UnknownLevel(v) => write!(
4868                f,
4869                "{TRUST_ANNOTATION} = {v:?} is not a trust level \
4870                 (expected {TRUST_TRUSTED_VALUE:?} or {TRUST_UNTRUSTED_VALUE:?}); \
4871                 omit the key for operator-authored code"
4872            ),
4873        }
4874    }
4875}
4876
4877impl std::error::Error for TrustDeclError {}
4878
4879// ── ImageRef ─────────────────────────────────────────────────────────────────
4880
4881/// Container image reference identifying a specific image to pull.
4882///
4883/// **Digest is required.** Every executable image reference in the workspace
4884/// is content-addressed by `sha256:<hex>`. The `tag` is preserved as a
4885/// human-readable identifier but is not the source of truth — registries
4886/// return mutable `tag → digest` mappings and we don't trust them for
4887/// reproducibility. R438-T3 tightened `digest: Option<String> → String` to
4888/// make unpinned-image bugs impossible by construction.
4889///
4890/// **Two deserialize shapes.** The struct form
4891/// (`registry`/`repository`/`tag`/`digest` fields) is the on-disk envelope.
4892/// A **string form** (`image = "ghcr.io/foo/bar:v1@sha256:<hex>"`) is also
4893/// accepted and is the shape W164 transform recipes (R438-T4) and W165
4894/// `BuildMode::InContainer` (R438-T6) use. Both shapes go through a single
4895/// parser ([`compose_import::parse_pinned_image_ref`]) that rejects
4896/// bare-tag references at serde-deserialize.
4897#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)]
4898#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
4899pub struct ImageRef {
4900    /// Registry hostname, e.g. `"ghcr.io"` or `"localhost:5000"`.
4901    pub registry: String,
4902
4903    /// Repository path, e.g. `"noisetable/api"`.
4904    pub repository: String,
4905
4906    /// Tag, e.g. `"v1.4.2"` or `"latest"`. Informational — the digest is
4907    /// the source of truth for image identity.
4908    pub tag: String,
4909
4910    /// Content-addressed pinned identity, e.g. `"sha256:abc..."`. Required.
4911    pub digest: String,
4912}
4913
4914impl<'de> Deserialize<'de> for ImageRef {
4915    fn deserialize<D>(de: D) -> Result<Self, D::Error>
4916    where
4917        D: serde::Deserializer<'de>,
4918    {
4919        #[derive(Deserialize)]
4920        struct Fields {
4921            registry: String,
4922            repository: String,
4923            tag: String,
4924            digest: String,
4925        }
4926
4927        // The string-or-struct `untagged` probe requires `deserialize_any`,
4928        // which only self-describing formats support. Postcard — the binary
4929        // wire behind the kamaji UDS — returns `WontImplement` for it, so a
4930        // `Workload::Container(WorkloadSpec)` carrying a nested `ImageRef`
4931        // failed to decode and every container deploy 500'd (R590-B3).
4932        //
4933        // The string form is purely an authoring convenience in human-readable
4934        // configs (`image = "ghcr.io/…@sha256:…"` in recipe/workload TOML and
4935        // JSON); the binary wire only ever carries the derived struct form
4936        // (Serialize is a plain struct derive). So branch on the format: text
4937        // keeps the string-or-struct convenience via `untagged`; binary decodes
4938        // the plain positional struct with no `deserialize_any`.
4939        if de.is_human_readable() {
4940            #[derive(Deserialize)]
4941            #[serde(untagged)]
4942            enum Repr {
4943                // Order matters for `untagged`: try the string form first so
4944                // explicit strings don't get coerced into a struct error.
4945                Pinned(String),
4946                Struct(Fields),
4947            }
4948
4949            match Repr::deserialize(de)? {
4950                Repr::Pinned(s) => {
4951                    compose_import::parse_pinned_image_ref(&s).map_err(serde::de::Error::custom)
4952                }
4953                Repr::Struct(f) => Ok(ImageRef {
4954                    registry: f.registry,
4955                    repository: f.repository,
4956                    tag: f.tag,
4957                    digest: f.digest,
4958                }),
4959            }
4960        } else {
4961            let f = Fields::deserialize(de)?;
4962            Ok(ImageRef {
4963                registry: f.registry,
4964                repository: f.repository,
4965                tag: f.tag,
4966                digest: f.digest,
4967            })
4968        }
4969    }
4970}
4971
4972// ── testing helpers ───────────────────────────────────────────────────────────
4973
4974/// Fixture helpers for test code that needs to construct types whose schemas
4975/// would otherwise demand operator-pinned values (digests, hashes). Doc-hidden
4976/// to discourage misuse from non-test code — production paths must source
4977/// digests from registry resolution or compile-time injection.
4978#[doc(hidden)]
4979pub mod testing {
4980    /// Fixed valid-format sha256 digest for test fixtures. All-zeros marker
4981    /// is impossible for any real image, so a leaked test fixture in a
4982    /// production code-path surfaces obviously.
4983    ///
4984    /// Aliases [`super::ImageRef::UNPINNED_DIGEST`] — the two are deliberately
4985    /// the same value: the fixture sentinel and the production "unpinned"
4986    /// marker must agree so [`super::ImageRef::pull_ref`]'s tag-fallback fires
4987    /// on exactly the digest `catalog_image` writes.
4988    pub const TEST_DIGEST: &str = super::ImageRef::UNPINNED_DIGEST;
4989
4990    /// Owned `String` form of [`TEST_DIGEST`] for fixture constructors.
4991    pub fn test_digest() -> String {
4992        TEST_DIGEST.to_string()
4993    }
4994}
4995
4996// ── EnvVar ────────────────────────────────────────────────────────────────────
4997
4998/// A single environment variable injected into the container.
4999#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
5000#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
5001pub struct EnvVar {
5002    /// Variable name, conventionally `SCREAMING_SNAKE_CASE`.
5003    pub name: String,
5004
5005    /// Value source.
5006    pub value: EnvValue,
5007}
5008
5009/// Value source for an environment variable.
5010#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
5011#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
5012#[serde(rename_all = "snake_case")]
5013pub enum EnvValue {
5014    /// Static string baked into the spec.
5015    Literal { value: String },
5016
5017    /// Resolved from a yubaba secret at deploy time; the secret value never
5018    /// appears in the spec JSON.
5019    FromSecret { secret: String, key: String },
5020
5021    /// Resolved from another workload's mesh address at deploy time by yubaba.
5022    /// Lets workloads reference each other symbolically without IP pinning.
5023    FromMesh { ident: MeshIdent, kind: MeshLookup },
5024}
5025
5026/// Which aspect of a mesh peer's address to inject.
5027///
5028/// ## Which port, when the peer has several (R844-B22)
5029///
5030/// [`Self::Url`] and [`Self::Port`] used to mean "the *first* entry in the
5031/// peer's `expose.mesh.ports`". That was a positional guess — the same one
5032/// `kamaji::name_anonymous_ports` refuses to make and that R844-F15 removed
5033/// from the service-record fanout — and it could hand a dependent workload a
5034/// metrics listener's number in its environment while looking entirely
5035/// successful. It survived only because, before R844-F17, a manifest had no way
5036/// to *name* a port, so "first" was the only selector that existed.
5037///
5038/// They now resolve by the same rule everything else in this workspace uses:
5039/// one port resolves to that port; several resolve to the one named `http`;
5040/// several with no `http` is an **error**, not a pick. The error is the feature
5041/// — it sends the author back to the manifest to say which listener they meant,
5042/// instead of handing a dependent a plausible wrong number.
5043///
5044/// [`Self::UrlNamed`] / [`Self::PortNamed`] say it outright and are the
5045/// spelling to prefer for any peer with more than one listener.
5046///
5047/// The named variants are **appended** rather than added as fields on the
5048/// existing ones: `MeshLookup` rides `EnvValue::FromMesh` inside a
5049/// [`WorkloadSpec`] across the postcard kamaji UDS, where an enum is encoded by
5050/// variant index, so appending leaves every existing encoding byte-identical
5051/// while adding a field to `Url` would not.
5052#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
5053#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
5054#[serde(rename_all = "snake_case")]
5055pub enum MeshLookup {
5056    /// Full URL, e.g. `"http://noisetable-db.pdx:5432"`. See the type docs for
5057    /// which port this picks when the peer has several.
5058    Url,
5059    /// Hostname only, e.g. `"noisetable-db.pdx"`.
5060    Host,
5061    /// Port only, e.g. `"5432"`. See the type docs for which port this picks
5062    /// when the peer has several.
5063    Port,
5064    /// Full URL at the peer's port called `name`, e.g. `"http://api.pdx:8443"`
5065    /// for `name = "wss"`. Errors when the peer has no port by that name.
5066    UrlNamed { name: String },
5067    /// The peer's port called `name`, stringified. Errors when the peer has no
5068    /// port by that name.
5069    PortNamed { name: String },
5070}
5071
5072impl MeshLookup {
5073    /// The port name this lookup selects, or `None` when it takes the default
5074    /// (see the type docs) or needs no port at all.
5075    pub fn port_name(&self) -> Option<&str> {
5076        match self {
5077            MeshLookup::UrlNamed { name } | MeshLookup::PortNamed { name } => Some(name),
5078            MeshLookup::Url | MeshLookup::Host | MeshLookup::Port => None,
5079        }
5080    }
5081
5082    /// Whether this lookup needs a port at all — `Host` is the one that does
5083    /// not, and it must keep resolving for a portless peer.
5084    pub fn needs_port(&self) -> bool {
5085        !matches!(self, MeshLookup::Host)
5086    }
5087}
5088
5089// ── Secrets ───────────────────────────────────────────────────────────────────
5090
5091/// A secret value mounted into the container as an env var or file.
5092///
5093/// The secret value never appears in the spec JSON — only the reference.
5094/// Yubaba audits secret access per workload from these references.
5095#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
5096#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
5097pub struct SecretMount {
5098    /// Where yubaba reads the secret value from.
5099    pub source: SecretRef,
5100
5101    /// How the secret is surfaced inside the container.
5102    pub target: SecretTarget,
5103}
5104
5105/// Where yubaba resolves the secret value from.
5106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
5107#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
5108#[serde(rename_all = "snake_case")]
5109pub enum SecretRef {
5110    /// Per-machine yubaba secret store at `/var/lib/yah/yubaba/secrets/`.
5111    LocalFile { path: PathBuf },
5112
5113    /// Raft-replicated cluster secret spanning all machines (planned; not in
5114    /// V1 deployment). Sketch preserved for wire compatibility.
5115    Cluster { name: String },
5116}
5117
5118/// How the secret is surfaced inside the container.
5119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
5120#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
5121#[serde(rename_all = "snake_case")]
5122pub enum SecretTarget {
5123    /// Injected as an environment variable. Value never appears in spec JSON.
5124    /// Prefer `File` — env vars leak through subprocess env and log dumps.
5125    EnvVar { name: String },
5126
5127    /// Mounted as a file inside the container at `path` with `mode` (octal).
5128    File { path: PathBuf, mode: u32 },
5129}
5130
5131// ── Volumes ───────────────────────────────────────────────────────────────────
5132
5133/// A volume mount inside the container.
5134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
5135#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
5136pub struct VolumeMount {
5137    /// Backing volume source.
5138    pub source: VolumeSource,
5139
5140    /// Absolute path inside the container.
5141    pub target: PathBuf,
5142
5143    /// Whether the container sees the volume as read-only.
5144    pub read_only: bool,
5145
5146    /// True when this mount was synthesized by yubaba's deploy-time secret
5147    /// materializer (`secret_mount::materialize_file_secrets`) rather than
5148    /// declared by the operator (R858-B26).
5149    ///
5150    /// `yah.durability.*`'s "exactly one named-or-bind volume" count
5151    /// (`validate::shape`, `kamaji::hydrate::plan`) skips any mount with this
5152    /// set, for the same reason it already skips `Tmpfs`: a secret bind is by
5153    /// construction not where durable subjects live, and counting it made
5154    /// every workload with both durable state and a file secret un-declarable.
5155    /// Kamaji still mounts it exactly like any other `Bind` — this flag is
5156    /// read only by the two durability-counting sites, nowhere else.
5157    #[serde(default)]
5158    pub from_secret_mount: bool,
5159}
5160
5161/// Backing source for a volume mount.
5162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
5163#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
5164#[serde(rename_all = "snake_case")]
5165pub enum VolumeSource {
5166    /// Yubaba-managed named volume; created on first use.
5167    Named { name: String },
5168
5169    /// Operator-managed host path. Yubaba rejects bind mounts unless
5170    /// `WorkloadSpec.tier == "infra"`; shape validation enforces this.
5171    Bind { host_path: PathBuf },
5172
5173    /// In-memory tmpfs; discarded on container stop. `size_mb` caps space
5174    /// consumed by the writable layer.
5175    Tmpfs { size_mb: u32 },
5176}
5177
5178// ── Durable forge produced-artifact convention (R603-T5) ──────────────────────
5179
5180/// Convention for a remote forge step's durable produced artifacts.
5181///
5182/// A remote build (e.g. the rusty_v8 musl build on a build-worker) writes its
5183/// output tarball to a path *inside* the container. The container's rootfs is
5184/// destroyed when kamaji reaps the EXITED container — so if the camp daemon is
5185/// down when the build finishes, the artifact is gone before boot-reconcile can
5186/// retrieve it (R603-T4 surfaced this as `Success`-but-`UNPUBLISHED`).
5187///
5188/// The fix (R603-T5) is a **host-persistent bind mount**: forge Subprocess
5189/// workloads mount [`HOST_ROOT`]`/<forge_id>` onto [`CONTAINER_DIR`], so a
5190/// build that writes its `produces` under `/yah/produced` lands the bytes on
5191/// the worker's host filesystem. yubaba then reads them back from the host path
5192/// ([`host_path`]) — which outlives container reaping — instead of the
5193/// unreachable container rootfs.
5194///
5195/// The container-side path and the host root are a shared convention between
5196/// three crates: the qed `build_workload_spec` that adds the mount, kamaji that
5197/// binds it, and the yubaba handler that reads + reaps it. Keeping it here (the
5198/// crate all three already depend on) is the single source of truth.
5199pub mod forge_produced {
5200    use std::path::{Path, PathBuf};
5201
5202    /// Conventional container-side directory a remote forge step writes its
5203    /// durable produced artifacts to. Bind-mounted onto a host-persistent dir.
5204    pub const CONTAINER_DIR: &str = "/yah/produced";
5205
5206    /// Host root under which each forge's durable produced dir lives, one
5207    /// subdir per run: `<HOST_ROOT>/<forge_id>/`. yubaba owns this directory —
5208    /// it creates the per-forge subdir at deploy, serves reads from it, and
5209    /// reaps it on teardown / TTL sweep.
5210    pub const HOST_ROOT: &str = "/var/lib/yah/qed/produced";
5211
5212    /// Forge mesh idents are `forge.<id>` (see [`WorkloadSpec::for_forge`]).
5213    /// Extract the bare `<id>`, or `None` for a non-forge ident.
5214    ///
5215    /// [`WorkloadSpec::for_forge`]: super::WorkloadSpec::for_forge
5216    pub fn forge_id_from_ident(ident: &str) -> Option<&str> {
5217        ident.strip_prefix("forge.")
5218    }
5219
5220    /// The host-persistent produced directory for one forge run.
5221    pub fn host_dir(forge_id: &str) -> PathBuf {
5222        PathBuf::from(HOST_ROOT).join(forge_id)
5223    }
5224
5225    /// Translate a container-side produced path to its durable host path for a
5226    /// given forge run. Returns `None` when `container_path` is not under
5227    /// [`CONTAINER_DIR`] (the caller then knows the artifact was not written to
5228    /// the durable location and won't survive reaping), or when the relative
5229    /// path contains a `..` component (a traversal attempt that could escape the
5230    /// per-forge dir — the reader must never serve a file outside it).
5231    pub fn host_path(forge_id: &str, container_path: &Path) -> Option<PathBuf> {
5232        host_path_under(&host_dir(forge_id), container_path)
5233    }
5234
5235    /// The same translation against an ARBITRARY host directory, for a produced
5236    /// dir that is not one of yubaba's (R560-B12).
5237    ///
5238    /// A LOCAL container step has the identical problem a remote one has and no
5239    /// [`HOST_ROOT`] to solve it with: `docker run --rm` throws the container's
5240    /// writable layer away on exit, so a build that writes its `produces` under
5241    /// [`CONTAINER_DIR`] and exits 0 leaves the caller reading an absent file —
5242    /// exactly the remote failure this module was created for. The caller binds
5243    /// a host dir of its own choosing (qed uses a run-scoped dir under the
5244    /// camp's cache) and maps declared container paths through this.
5245    ///
5246    /// Split out rather than duplicated because the `..` guard is the whole
5247    /// safety content of both: `host_path` must never serve a file outside the
5248    /// per-forge dir, and this one must never write outside the caller's. Two
5249    /// copies of that check is one copy that can be fixed alone.
5250    pub fn host_path_under(host_dir: &Path, container_path: &Path) -> Option<PathBuf> {
5251        let rel = container_path.strip_prefix(CONTAINER_DIR).ok()?;
5252        if rel
5253            .components()
5254            .any(|c| matches!(c, std::path::Component::ParentDir))
5255        {
5256            return None;
5257        }
5258        Some(host_dir.join(rel))
5259    }
5260
5261    /// The durable produced-dir bind mount for a forge run: host
5262    /// `<HOST_ROOT>/<forge_id>` → container [`CONTAINER_DIR`], writable.
5263    pub fn durable_mount(forge_id: &str) -> super::VolumeMount {
5264        super::VolumeMount {
5265            source: super::VolumeSource::Bind {
5266                host_path: host_dir(forge_id),
5267            },
5268            target: PathBuf::from(CONTAINER_DIR),
5269            read_only: false,
5270            from_secret_mount: false,
5271        }
5272    }
5273
5274    /// True when `path` is (or is under) the conventional durable produced dir
5275    /// — the guard qed uses to enforce that declared `produces` land somewhere
5276    /// reap-durable.
5277    pub fn is_durable_path(path: &Path) -> bool {
5278        path.starts_with(CONTAINER_DIR)
5279    }
5280}
5281
5282// ── Forge host-state root (R636-B1) ───────────────────────────────────────────
5283
5284/// The one host directory tree a QED forge step's bind mounts may live under.
5285///
5286/// # Why this is a named root rather than a list of paths
5287///
5288/// runc refuses a bind whose source is missing, and the OCI mapper never
5289/// mkdirs one — so *something* has to create each host dir before deploy.
5290/// yubaba does, but only for paths it recognizes, and "recognizes" was
5291/// originally a hardcoded match on the produced dir. Every new forge mount then
5292/// re-learned the lesson the expensive way, on a real box, minutes into a
5293/// build: R603-B6 for `produced/`, then R636-B1 for `build-out/`, each
5294/// surfacing as the same opaque `failed to fulfil mount request: … no such file
5295/// or directory` from deep inside containerd.
5296///
5297/// Naming the *root* makes the rule checkable instead of enumerable: yubaba
5298/// creates any forge bind under [`HOST_ROOT`], and `yubaba.service` grants the
5299/// root once via `StateDirectory=yah/qed`. A third mount needs no new code and
5300/// no unit-file edit — it only has to live here.
5301///
5302/// The prefix bound is load-bearing in the other direction too: it is what
5303/// keeps a workload spec from asking yubaba to mkdir an arbitrary host path.
5304pub mod forge_state {
5305    use std::path::Path;
5306
5307    /// Root of the forge's host-persistent state.
5308    /// [`super::forge_produced::HOST_ROOT`] and
5309    /// [`super::forge_cache::HOST_ROOT`] are under it. (A shared `build-out/`
5310    /// for build-image OCI archives used to be too; R636 moved those into the
5311    /// per-forge produced dir, where they are retrievable and reaped.)
5312    pub const HOST_ROOT: &str = "/var/lib/yah/qed";
5313
5314    /// Whether yubaba may create `host_path` on behalf of a forge workload.
5315    ///
5316    /// Rejects anything outside [`HOST_ROOT`], and anything with a `..`
5317    /// component — `/var/lib/yah/qed/../../../etc` starts with the root as a
5318    /// string and is nowhere near it as a path.
5319    pub fn is_forge_state_path(host_path: &Path) -> bool {
5320        !host_path
5321            .components()
5322            .any(|c| matches!(c, std::path::Component::ParentDir))
5323            && host_path.starts_with(HOST_ROOT)
5324    }
5325}
5326
5327// ── Materialized-secret path contract (R555-F5) ───────────────────────────────
5328
5329/// Where yubaba writes a `File`-target secret it has resolved, and how the host
5330/// path is derived from the container path.
5331///
5332/// # Why the derivation lives here and not in yubaba
5333///
5334/// yubaba resolves a [`SecretMount`] and rewrites it into a read-only [`Bind`]
5335/// volume before the spec reaches the backend, so the spec kamaji admits is not
5336/// the spec the dispatcher signed: one mount has become one bind. Admission has
5337/// to be able to recognise that rewrite — otherwise a signed recipe carrying a
5338/// secret is refused by [`admission::AdmissionGrant::covers`]'s bind rule, which
5339/// only knows about [`forge_state::HOST_ROOT`], with a message about a forge
5340/// state root that has nothing to do with what happened.
5341///
5342/// Recognising it means recomputing the host path, which means the derivation
5343/// has to be visible to both sides. It was private to yubaba's
5344/// `deploy::secret_mount`; it lives here now, and yubaba calls in. `forge_state`
5345/// is the same shape for the same reason.
5346///
5347/// [`Bind`]: VolumeSource::Bind
5348pub mod secret_mount {
5349    use std::path::{Path, PathBuf};
5350
5351    /// RAM-backed root for materialized secret files. `/run` is a tmpfs on
5352    /// systemd nodes, so decrypted PEM never touches disk. Each workload gets a
5353    /// `<root>/<ident>/` subdir, reaped on workload destroy.
5354    pub const HOST_ROOT: &str = "/run/yah/secrets";
5355
5356    /// Collapse a value into a single safe path component: every char outside
5357    /// `[A-Za-z0-9_-]` becomes `_` (dots included, so `.` / `..` can never
5358    /// traverse). Empty input maps to `_`.
5359    pub fn sanitize_component(s: &str) -> String {
5360        let mapped: String = s
5361            .chars()
5362            .map(|c| {
5363                if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
5364                    c
5365                } else {
5366                    '_'
5367                }
5368            })
5369            .collect();
5370        if mapped.is_empty() {
5371            "_".into()
5372        } else {
5373            mapped
5374        }
5375    }
5376
5377    /// Derive a collision-free host filename from a container target path: strip
5378    /// the leading `/`, keep `.` for extensions, and replace path separators (and
5379    /// any other non-`[A-Za-z0-9_.-]` char) with `_`. A target that reduces to
5380    /// nothing or a dots-only name falls back to `secret`. The result is always a
5381    /// single flat filename (no separators), so it cannot traverse out of the
5382    /// per-workload dir.
5383    pub fn host_file_name(target: &Path) -> String {
5384        let raw = target.to_string_lossy();
5385        let trimmed = raw.trim_start_matches('/');
5386        let mapped: String = trimmed
5387            .chars()
5388            .map(|c| {
5389                if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
5390                    c
5391                } else {
5392                    '_'
5393                }
5394            })
5395            .collect();
5396        if mapped.is_empty() || mapped.chars().all(|c| c == '.') {
5397            "secret".into()
5398        } else {
5399            mapped
5400        }
5401    }
5402
5403    /// The per-workload directory materialized secrets are written to.
5404    pub fn workload_dir(root: &Path, ident: &str) -> PathBuf {
5405        root.join(sanitize_component(ident))
5406    }
5407
5408    /// The host path a `File`-target secret at container path `target` is
5409    /// materialized to for workload `ident`.
5410    ///
5411    /// Deterministic in exactly those three inputs, which is what lets admission
5412    /// recompute it from the spec alone and match a bind against it.
5413    pub fn materialized_host_path(root: &Path, ident: &str, target: &Path) -> PathBuf {
5414        workload_dir(root, ident).join(host_file_name(target))
5415    }
5416}
5417
5418#[cfg(test)]
5419mod secret_mount_tests {
5420    use super::secret_mount::*;
5421    use std::path::{Path, PathBuf};
5422
5423    #[test]
5424    fn the_host_path_is_a_pure_function_of_root_ident_and_target() {
5425        let p = materialized_host_path(
5426            Path::new(HOST_ROOT),
5427            "forge.abc-123",
5428            Path::new("/etc/yah/r2.json"),
5429        );
5430        assert_eq!(
5431            p,
5432            PathBuf::from("/run/yah/secrets/forge_abc-123/etc_yah_r2.json")
5433        );
5434    }
5435
5436    /// The two collapses exist to keep a hostile ident or target from steering
5437    /// the write out of the per-workload dir. Pinned here because admission now
5438    /// depends on them being total.
5439    #[test]
5440    fn neither_component_can_traverse() {
5441        for ident in ["..", "../../etc", "a/b", ""] {
5442            let dir = workload_dir(Path::new(HOST_ROOT), ident);
5443            assert_eq!(dir.components().count(), 5, "{ident:?} escaped {dir:?}");
5444            assert!(dir.starts_with(HOST_ROOT));
5445        }
5446        for target in ["/../../etc/shadow", "..", "/", "/a/../b"] {
5447            let name = host_file_name(Path::new(target));
5448            assert!(!name.contains('/'), "{target:?} kept a separator: {name}");
5449            assert_ne!(name, "..");
5450        }
5451    }
5452}
5453
5454// ── Durable forge build-cache convention (R876-F4) ────────────────────────────
5455
5456/// Convention for a remote forge step's host-persistent **build cache**.
5457///
5458/// # The gap this closes
5459///
5460/// A remote subprocess gets image + argv + the [`forge_produced`] mount and
5461/// nothing else, so a step that compiles a source tree compiles it from scratch
5462/// every single run: the container's writable layer (where `CARGO_TARGET_DIR`
5463/// lands by default) is thrown away when kamaji reaps the exited container, and
5464/// `/yah/produced` is per-run and reaped on destroy. `mesofact-musl`'s x86_64
5465/// leg measured 9m56s / 9m57s / 11m10s across its successful runs and every one
5466/// of those was a cold full release build.
5467///
5468/// This is the third mount under [`forge_state::HOST_ROOT`], and — exactly as
5469/// R603-B6's handoff promised — it needs neither new yubaba code nor a
5470/// `yubaba.service` edit: `ensure_forge_state_dirs` already mkdirs *any* forge
5471/// bind under that root.
5472///
5473/// # Why the key is derived, not caller-supplied
5474///
5475/// A shared target dir keyed by nothing is a correctness bug, not merely a
5476/// race. `mesofact-musl` carries `concurrency_key = "mesofact-musl"`, but that
5477/// is *camp-side scheduling*: it does not constrain a second camp, or a
5478/// hand-rolled dispatch, aiming at the same worker. The key is therefore
5479/// derived by the dispatcher from **pipeline + step + target triple**
5480/// ([`key_from_parts`]) rather than written in a TOML, so two different
5481/// pipelines — or the same pipeline's two triples — cannot land on one target
5482/// dir however the run was started.
5483///
5484/// Two runs of the *same* pipeline+step+triple DO share, and that is the whole
5485/// point: cargo is designed for exactly that reuse, and its own `.cargo-lock`
5486/// in the target dir serializes two builds that overlap in time.
5487///
5488/// # Eviction is explicit
5489///
5490/// An unbounded cache on a worker rootfs is R702's subject. Both holders of a
5491/// cache root — yubaba on the worker, qed for the local-container placement —
5492/// sweep it with [`evict_plan`]: anything idle past [`RETENTION`] goes, and if
5493/// the filesystem is below [`FREE_FLOOR_BYTES`] the least-recently-used dirs go
5494/// too, until it is not. The cache can therefore never consume the last few GB
5495/// of a build worker's `/var`.
5496pub mod forge_cache {
5497    use std::path::{Path, PathBuf};
5498    use std::time::{Duration, SystemTime};
5499
5500    /// Conventional container-side directory a cached forge step's build
5501    /// scratch lives in. Bind-mounted onto a host-persistent, key-scoped dir.
5502    ///
5503    /// The step's argv points its own toolchain at this (mesofact-musl exports
5504    /// `CARGO_TARGET_DIR="$YAH_CACHE_DIR/target"`), the same way it does the
5505    /// source-context fetch — the mount is toolchain-agnostic and the TOML
5506    /// keeps describing what actually runs.
5507    pub const CONTAINER_DIR: &str = "/yah/cache";
5508
5509    /// Environment variable carrying [`CONTAINER_DIR`] into the step, so an
5510    /// argv never has to hardcode the convention.
5511    pub const CACHE_DIR_ENV: &str = "YAH_CACHE_DIR";
5512
5513    /// Host root under which each cache key gets a directory:
5514    /// `<HOST_ROOT>/<key>/`. Under [`super::forge_state::HOST_ROOT`], so
5515    /// yubaba's `ensure_forge_state_dirs` creates it and `yubaba.service`
5516    /// already grants write access to it.
5517    pub const HOST_ROOT: &str = "/var/lib/yah/qed/cache";
5518
5519    /// A cache dir untouched for this long is evicted. Long enough that a
5520    /// weekly release still hits a warm cache; short enough that a renamed
5521    /// step's orphan does not sit on the disk forever.
5522    pub const RETENTION: Duration = Duration::from_secs(60 * 60 * 24 * 14);
5523
5524    /// Below this much free space on the filesystem holding a cache root,
5525    /// least-recently-used cache dirs are evicted until it is above it again.
5526    /// This is the bound that matters on a build worker: us-west-003's `/var`
5527    /// is a 60 GB LV, and a release target dir is multiple GB.
5528    pub const FREE_FLOOR_BYTES: u64 = 10 * 1024 * 1024 * 1024;
5529
5530    /// Longest derived key kept verbatim; longer ones are truncated and
5531    /// disambiguated with a digest by [`key_from_parts`].
5532    pub const MAX_KEY_LEN: usize = 96;
5533
5534    /// Whether `key` is safe to use as a single path component under
5535    /// [`HOST_ROOT`]. Deliberately narrow: alphanumerics plus `.`, `-`, `_`,
5536    /// non-empty, length-capped, and never a bare `.`/`..`. Everything a
5537    /// dispatcher derives passes; nothing a hostile spec could write escapes.
5538    pub fn is_valid_key(key: &str) -> bool {
5539        !key.is_empty()
5540            && key.len() <= MAX_KEY_LEN + 24
5541            && key != "."
5542            && key != ".."
5543            && key
5544                .chars()
5545                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
5546    }
5547
5548    /// Derive the sharing key from the parts that must not collide: the
5549    /// pipeline, the step within it, and the target triple.
5550    ///
5551    /// Characters outside the [`is_valid_key`] alphabet collapse to `-`. A key
5552    /// that would exceed [`MAX_KEY_LEN`] is truncated and suffixed with a
5553    /// digest of the *full* string, so shortening can never merge two distinct
5554    /// keys into one.
5555    ///
5556    /// The digest is FNV-1a rather than blake3: this crate is deliberately a
5557    /// zero-dependency schema crate (see its `seal` / `admission-verify`
5558    /// features — even the cipher is opt-in), the inputs are pipeline and step
5559    /// names from the camp's own TOMLs rather than anything adversarial, and
5560    /// the property needed is "two long keys differ", not preimage resistance.
5561    pub fn key_from_parts(pipeline: &str, step: &str, triple: &str) -> String {
5562        let raw = format!("{pipeline}.{step}.{triple}");
5563        let mut safe: String = raw
5564            .chars()
5565            .map(|c| {
5566                if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
5567                    c
5568                } else {
5569                    '-'
5570                }
5571            })
5572            .collect();
5573        if safe.len() > MAX_KEY_LEN {
5574            let digest = fnv1a64(raw.as_bytes());
5575            safe.truncate(MAX_KEY_LEN);
5576            safe.push('.');
5577            safe.push_str(&format!("{digest:016x}"));
5578        }
5579        safe
5580    }
5581
5582    /// FNV-1a, 64-bit. See [`key_from_parts`] for why this and not a real hash.
5583    fn fnv1a64(bytes: &[u8]) -> u64 {
5584        let mut h: u64 = 0xcbf2_9ce4_8422_2325;
5585        for b in bytes {
5586            h ^= *b as u64;
5587            h = h.wrapping_mul(0x0000_0100_0000_01b3);
5588        }
5589        h
5590    }
5591
5592    /// The host-persistent cache directory for one key, under [`HOST_ROOT`].
5593    pub fn host_dir(key: &str) -> Option<PathBuf> {
5594        cache_dir_under(Path::new(HOST_ROOT), key)
5595    }
5596
5597    /// The same derivation against an arbitrary root — for the local-container
5598    /// placement, whose cache lives under the camp's own `.yah/cache` and has
5599    /// no [`HOST_ROOT`] to hang off. Split out for the same reason
5600    /// [`super::forge_produced::host_path_under`] is: the key validation is the
5601    /// whole safety content of both, and two copies is one copy that can be
5602    /// fixed alone.
5603    pub fn cache_dir_under(root: &Path, key: &str) -> Option<PathBuf> {
5604        is_valid_key(key).then(|| root.join(key))
5605    }
5606
5607    /// The build-cache bind mount for one key: host `<HOST_ROOT>/<key>` →
5608    /// container [`CONTAINER_DIR`], writable. `None` for an invalid key —
5609    /// the caller must refuse rather than mount something else.
5610    pub fn durable_mount(key: &str) -> Option<super::VolumeMount> {
5611        Some(super::VolumeMount {
5612            source: super::VolumeSource::Bind {
5613                host_path: host_dir(key)?,
5614            },
5615            target: PathBuf::from(CONTAINER_DIR),
5616            read_only: false,
5617            from_secret_mount: false,
5618        })
5619    }
5620
5621    /// Which cache dirs to delete, given every dir in a cache root with its
5622    /// last-modified time, the current free space on that filesystem, and the
5623    /// floor to hold.
5624    ///
5625    /// Pure so the policy is one testable function shared by both holders of a
5626    /// cache root (yubaba on the worker, qed for local containers) instead of
5627    /// two drifting copies. The callers own the `read_dir` / `df` / `remove`.
5628    ///
5629    /// Two rules, in order: everything idle past `retention` goes
5630    /// unconditionally; then, while `free_bytes` is under `floor_bytes`, the
5631    /// least-recently-used survivor goes — LRU because the dir a build just
5632    /// touched is the one whose loss costs the next run the most.
5633    ///
5634    /// `free_bytes` is what the caller measured BEFORE any deletion, so the
5635    /// count of extra evictions is a heuristic (this function cannot know a
5636    /// dir's size without walking it). It is bounded and monotone: under
5637    /// sustained pressure each sweep drops one more dir, and a root that is
5638    /// entirely evicted simply rebuilds cold — the failure mode is a slow
5639    /// build, never a full disk.
5640    pub fn evict_plan(
5641        entries: &[(PathBuf, SystemTime)],
5642        now: SystemTime,
5643        retention: Duration,
5644        free_bytes: u64,
5645        floor_bytes: u64,
5646    ) -> Vec<PathBuf> {
5647        let mut evict = Vec::new();
5648        let mut live: Vec<&(PathBuf, SystemTime)> = Vec::new();
5649        for entry in entries {
5650            let idle = now
5651                .duration_since(entry.1)
5652                .map(|age| age > retention)
5653                .unwrap_or(false);
5654            if idle {
5655                evict.push(entry.0.clone());
5656            } else {
5657                live.push(entry);
5658            }
5659        }
5660        if free_bytes < floor_bytes && !live.is_empty() {
5661            live.sort_by_key(|(_, mtime)| *mtime);
5662            evict.push(live[0].0.clone());
5663        }
5664        evict
5665    }
5666}
5667
5668#[cfg(test)]
5669mod forge_cache_tests {
5670    use super::forge_cache::*;
5671    use std::path::{Path, PathBuf};
5672    use std::time::{Duration, SystemTime};
5673
5674    #[test]
5675    fn the_cache_root_is_under_the_forge_state_root() {
5676        assert!(super::forge_state::is_forge_state_path(Path::new(
5677            HOST_ROOT
5678        )));
5679        assert!(super::forge_state::is_forge_state_path(
5680            &host_dir("mesofact-musl.build.x86_64-unknown-linux-musl").unwrap()
5681        ));
5682    }
5683
5684    #[test]
5685    fn the_key_separates_pipelines_steps_and_triples() {
5686        let a = key_from_parts("mesofact-musl", "build", "x86_64-unknown-linux-musl");
5687        let b = key_from_parts("mesofact-musl", "build", "aarch64-unknown-linux-musl");
5688        let c = key_from_parts("other-pipeline", "build", "x86_64-unknown-linux-musl");
5689        let d = key_from_parts("mesofact-musl", "other-step", "x86_64-unknown-linux-musl");
5690        assert_ne!(a, b);
5691        assert_ne!(a, c);
5692        assert_ne!(a, d);
5693        assert!(is_valid_key(&a), "{a}");
5694    }
5695
5696    /// Shortening must never merge two distinct keys — the whole point of the
5697    /// key is that a collision is impossible.
5698    #[test]
5699    fn an_over_long_key_is_digest_disambiguated_not_merely_truncated() {
5700        let long = "p".repeat(MAX_KEY_LEN);
5701        let a = key_from_parts(&long, "step-one", "x86_64-unknown-linux-musl");
5702        let b = key_from_parts(&long, "step-two", "x86_64-unknown-linux-musl");
5703        assert_ne!(a, b);
5704        assert!(is_valid_key(&a) && is_valid_key(&b));
5705    }
5706
5707    #[test]
5708    fn a_key_that_could_escape_the_root_is_refused_rather_than_sanitized() {
5709        for bad in ["", ".", "..", "../etc", "a/b", "a\0b"] {
5710            assert!(!is_valid_key(bad), "{bad:?} must not be a cache key");
5711            assert!(host_dir(bad).is_none(), "{bad:?}");
5712            assert!(durable_mount(bad).is_none(), "{bad:?}");
5713        }
5714        // …and a derived key from hostile parts is sanitized into the alphabet.
5715        let k = key_from_parts("../../etc", "x/y", "t");
5716        assert!(is_valid_key(&k), "{k}");
5717        assert_eq!(host_dir(&k).unwrap().parent().unwrap(), Path::new(HOST_ROOT));
5718    }
5719
5720    #[test]
5721    fn durable_mount_shape() {
5722        let m = durable_mount("k").expect("valid key");
5723        assert_eq!(m.target, PathBuf::from(CONTAINER_DIR));
5724        assert!(!m.read_only, "a build cache the step cannot write is useless");
5725        match &m.source {
5726            super::VolumeSource::Bind { host_path } => {
5727                assert_eq!(host_path, &PathBuf::from(HOST_ROOT).join("k"));
5728            }
5729            other => panic!("expected a bind, got {other:?}"),
5730        }
5731    }
5732
5733    #[test]
5734    fn idle_dirs_are_evicted_and_fresh_ones_are_kept_when_there_is_room() {
5735        let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
5736        let entries = vec![
5737            (PathBuf::from("/c/old"), now - RETENTION - Duration::from_secs(1)),
5738            (PathBuf::from("/c/fresh"), now - Duration::from_secs(60)),
5739        ];
5740        let plan = evict_plan(&entries, now, RETENTION, FREE_FLOOR_BYTES * 2, FREE_FLOOR_BYTES);
5741        assert_eq!(plan, vec![PathBuf::from("/c/old")]);
5742    }
5743
5744    #[test]
5745    fn disk_pressure_evicts_the_least_recently_used_survivor() {
5746        let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
5747        let entries = vec![
5748            (PathBuf::from("/c/hot"), now - Duration::from_secs(60)),
5749            (PathBuf::from("/c/cool"), now - Duration::from_secs(6000)),
5750        ];
5751        let plan = evict_plan(&entries, now, RETENTION, 1, FREE_FLOOR_BYTES);
5752        assert_eq!(plan, vec![PathBuf::from("/c/cool")]);
5753    }
5754
5755    #[test]
5756    fn an_empty_root_under_disk_pressure_plans_nothing() {
5757        let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
5758        assert!(evict_plan(&[], now, RETENTION, 0, FREE_FLOOR_BYTES).is_empty());
5759    }
5760}
5761
5762#[cfg(test)]
5763mod forge_state_tests {
5764    use super::forge_state::*;
5765    use std::path::Path;
5766
5767    #[test]
5768    fn both_known_forge_roots_are_under_the_state_root() {
5769        assert!(is_forge_state_path(Path::new(
5770            super::forge_produced::HOST_ROOT
5771        )));
5772        assert!(is_forge_state_path(Path::new(super::forge_cache::HOST_ROOT)));
5773        assert!(is_forge_state_path(&super::forge_produced::host_dir(
5774            "abc-123"
5775        )));
5776    }
5777
5778    /// A spec must not be able to steer yubaba's mkdir anywhere it likes —
5779    /// neither by naming an unrelated absolute path nor by climbing out with
5780    /// `..`, which a plain string prefix check would wave through.
5781    #[test]
5782    fn paths_outside_the_root_are_refused() {
5783        for bad in [
5784            "/var/lib/yah/yubaba",
5785            "/etc/systemd/system",
5786            "/var/lib/yah/qed/../../../etc",
5787            "relative/path",
5788        ] {
5789            assert!(
5790                !is_forge_state_path(Path::new(bad)),
5791                "{bad} must not be creatable by a forge spec"
5792            );
5793        }
5794    }
5795}
5796
5797// ── Resources ─────────────────────────────────────────────────────────────────
5798
5799/// Hard resource caps enforced by containerd/cgroups at runtime.
5800#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
5801#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
5802pub struct ResourceLimits {
5803    /// Maximum RAM the container may allocate, in MiB. The container is OOM-
5804    /// killed if it exceeds this.
5805    ///
5806    /// A **ceiling**, not a request: setting it generously is the safe
5807    /// direction here and the unschedulable direction for placement, so
5808    /// schedulers must read [`WorkloadSpec::memory_request_mb`] instead of
5809    /// this field. (`cpu_millis` below is the opposite — a request by
5810    /// definition — which is why the two are not symmetric.)
5811    pub memory_mb: u32,
5812
5813    /// CPU **request** in millicores (k8s convention): `1000` = one full core,
5814    /// `250` = `.25 CPU`. Unlike a Docker relative weight this is an
5815    /// allocatable quantity a bin-packer can subtract from a node's budget.
5816    /// `0` means "no declared request" — the workload gets the node's default
5817    /// share. Backends that speak a relative weight derive it via
5818    /// [`ResourceLimits::cpu_shares`].
5819    ///
5820    /// **It is not a ceiling.** A request answers "what share of a contended
5821    /// node is mine"; nothing here says "stop at this much". A workload
5822    /// declaring `250m` must still be able to burst to the whole box when the
5823    /// box is idle. The hard ceiling is optional and rides
5824    /// [`Self::cpu_limit_millis`] — R885-B5 / W344 Finding 5, filed after this
5825    /// field was rendered into a cgroup `cpu.max` and throttled four live
5826    /// workloads to a quarter core apiece.
5827    pub cpu_millis: u32,
5828
5829    // The four below were `yah.placement.memory-request-mb` and the
5830    // `yah.limits.*` annotations until R896-F3. Each defaults to `None`, which
5831    // is exactly what a peer that predates the field meant, so adding them
5832    // crossed a kamaji-proto V13 skew without a protocol bump. Read them
5833    // through the `WorkloadSpec` accessors named on each: those own the
5834    // fallback each one needs.
5835    /// Memory **request** in MiB — what a scheduler must find free on a node,
5836    /// separate from the [`Self::memory_mb`] ceiling. Read via
5837    /// [`WorkloadSpec::memory_request_mb`], which falls back to the ceiling.
5838    #[serde(default)]
5839    #[ts(optional = nullable)]
5840    pub memory_request_mb: Option<u32>,
5841
5842    /// Optional **hard CPU ceiling** in millicores — the mirror image of
5843    /// `memory_request_mb`: [`Self::cpu_millis`] is the request, this is the
5844    /// limit. Read via [`WorkloadSpec::cpu_limit_millis`]; `0` means none.
5845    #[serde(default)]
5846    #[ts(optional = nullable)]
5847    pub cpu_limit_millis: Option<u32>,
5848
5849    /// Hard **process-count ceiling** — cgroup v2 `pids.max`. Read via
5850    /// [`WorkloadSpec::pids_limit`], which falls back to [`DEFAULT_PIDS_MAX`]
5851    /// rather than to "unbounded".
5852    #[serde(default)]
5853    #[ts(optional = nullable)]
5854    pub pids_max: Option<u32>,
5855
5856    /// A **floor** on the microVM scratch disk in MiB — the smallest workspace
5857    /// this workload is willing to be given. Note the direction: the other
5858    /// limits here are ceilings a backend enforces downward, this is a floor a
5859    /// backend raises *up* to, and `floor` is in the name for exactly that
5860    /// reason (R885-T6 deleted `ephemeral_storage_mb` because it was documented
5861    /// as a cap and read as a floor). Read via [`WorkloadSpec::scratch_floor_mb`].
5862    #[serde(default)]
5863    #[ts(optional = nullable)]
5864    pub scratch_floor_mb: Option<u32>,
5865}
5866
5867impl ResourceLimits {
5868    /// The Docker/OCI relative CPU weight (`cpu.shares`, where `1024` ≈ one
5869    /// core) equivalent to this millicore request. The containerd and docker
5870    /// backends express CPU as a weight rather than a millicore request, so
5871    /// they derive it here instead of storing shares: `1000m` ⇒ `1024`.
5872    pub fn cpu_shares(&self) -> u64 {
5873        (u64::from(self.cpu_millis) * 1024) / 1000
5874    }
5875}
5876
5877// ── Healthcheck ───────────────────────────────────────────────────────────────
5878
5879/// Container health probe configuration.
5880#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
5881#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
5882pub struct Healthcheck {
5883    /// The probe executed to determine container health.
5884    pub probe: HealthProbe,
5885
5886    /// How often the probe runs.
5887    pub interval: Millis,
5888
5889    /// Per-probe timeout; a slow response counts as failure.
5890    pub timeout: Millis,
5891
5892    /// Time to wait after container start before the first probe. Shape
5893    /// validation warns (not errors) if this is less than
5894    /// `stop_policy.grace_period * 2`.
5895    pub initial_delay: Millis,
5896
5897    /// Number of consecutive failures before the container is marked
5898    /// `Unhealthy`.
5899    pub failure_threshold: u32,
5900}
5901
5902/// Mechanism used to check container health.
5903#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
5904#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
5905#[serde(rename_all = "snake_case")]
5906pub enum HealthProbe {
5907    /// HTTP GET to `path` on `port`. A 2xx (or `expect_status` if set)
5908    /// response counts as healthy.
5909    HttpGet {
5910        path: String,
5911        port: u16,
5912        #[ts(optional = nullable)]
5913        expect_status: Option<u16>,
5914    },
5915
5916    /// Run `argv` inside the container; exit-0 counts as healthy.
5917    Exec { argv: Vec<String> },
5918
5919    /// TCP connection to `port`; a successful connect counts as healthy.
5920    TcpConnect { port: u16 },
5921}
5922
5923// ── Restart / Stop ────────────────────────────────────────────────────────────
5924
5925/// What yubaba does when the container exits.
5926#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
5927#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
5928#[serde(rename_all = "snake_case")]
5929pub enum RestartPolicy {
5930    /// Restart unconditionally on any exit.
5931    Always,
5932
5933    /// Restart on non-zero exit, up to `max_attempts` times with exponential
5934    /// backoff. After exhaustion, the workload is marked `Failed`.
5935    OnFailure {
5936        max_attempts: u32,
5937        backoff: BackoffPolicy,
5938    },
5939
5940    /// Do not restart. The container runs once and exits.
5941    ///
5942    /// **Forge convention.** Forge runs (R094) synthesize a `WorkloadSpec`
5943    /// using [`WorkloadSpec::for_forge`] which sets all the conventional fields
5944    /// together:
5945    ///
5946    /// - `restart_policy = Never`
5947    /// - `expose.public = None`, `expose.operator = None`
5948    /// - `expose.mesh.identity = "forge.<forge_id>"` — distinguishable from
5949    ///   persistent mirror identities at the mesh layer
5950    /// - `tier = "infra"` (or the forge-spec's effective tier)
5951    /// - `annotations["yah.forge"] = "true"` — suppresses the shape warning
5952    ///
5953    /// Using `Never` on a persistent mirror (not a forge run) means the mirror
5954    /// stays dead after any exit — a likely misconfiguration. Shape validation
5955    /// emits a soft warning unless `annotations["yah.forge"] == "true"` is
5956    /// present. See R094 forge.
5957    Never,
5958}
5959
5960/// Exponential backoff parameters for `RestartPolicy::OnFailure`.
5961#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
5962#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
5963pub struct BackoffPolicy {
5964    /// Initial delay before the first restart, in milliseconds.
5965    pub initial_ms: u32,
5966
5967    /// Maximum delay between retries, in milliseconds.
5968    pub max_ms: u32,
5969
5970    /// Backoff multiplier applied to each successive delay.
5971    pub multiplier: f32,
5972}
5973
5974/// Graceful shutdown configuration for yubaba's stop sequence.
5975#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
5976#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
5977pub struct StopPolicy {
5978    /// Signal number sent first, e.g. `15` (SIGTERM) or `2` (SIGINT).
5979    pub signal: i32,
5980
5981    /// Time yubaba waits after sending `signal` before issuing SIGKILL.
5982    pub grace_period: Millis,
5983}
5984
5985// ── Expose ────────────────────────────────────────────────────────────────────
5986
5987/// Network exposure configuration. The three channels are independent; any
5988/// combination is valid.
5989#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
5990#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
5991pub struct ExposeSpec {
5992    /// Mesh-internal exposure. Required; every workload must have a mesh
5993    /// identity even if no other workload currently reaches it.
5994    pub mesh: MeshExpose,
5995
5996    /// Public internet exposure via a Cloudflare tunnel route. `None` means
5997    /// the workload is not internet-reachable.
5998    #[ts(optional = nullable)]
5999    pub public: Option<PublicExpose>,
6000
6001    /// Operator-facing exposure via a Tailscale ACL tag. `None` means the
6002    /// workload is not operator-reachable via Tailscale.
6003    #[ts(optional = nullable)]
6004    pub operator: Option<OperatorExpose>,
6005}
6006
6007/// A peer permitted to initiate mesh connections to a workload (W206 / R558-F3).
6008///
6009/// Cross-tenant access is **deny-by-default**: a workload accepts inter-tenant
6010/// traffic only from peers it lists explicitly as [`MeshPeer::CrossTenant`].
6011/// Same-tenant access stays tier-based ([`MeshPeer::Tier`]) — the pre-R558
6012/// model — and an `allow_from` with no `Tier` entries still admits every
6013/// same-tenant peer (the historical "empty = allow all" default).
6014///
6015/// External serde tagging keeps this postcard-safe (R590-B3): no internal tag,
6016/// no untagged, no `skip_serializing_if`.
6017#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
6018#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
6019#[serde(rename_all = "snake_case")]
6020pub enum MeshPeer {
6021    /// Any **same-tenant** workload whose `tier` matches this tag. This is the
6022    /// pre-R558 `allow_from` semantics.
6023    Tier(TierTag),
6024
6025    /// A specific workload in **another tenant**, addressed by its fully
6026    /// qualified mesh identity `<tenant>/<namespace>/<name>`. There is no
6027    /// cross-tenant tier wildcard — each cross-tenant peer is granted
6028    /// individually, so a shared fleet stays isolated unless an operator opts
6029    /// in here.
6030    CrossTenant {
6031        tenant: TenantId,
6032        namespace: NamespaceId,
6033        /// Peer's mesh identity (its [`MeshExpose::identity`]).
6034        name: MeshIdent,
6035    },
6036}
6037
6038/// One port a workload listens on, as its manifest declares it (R844-F17).
6039///
6040/// Before this, `expose.mesh.ports` was an array of bare numbers and a port
6041/// name was unwritable anywhere in the workspace — names were real at every
6042/// tier *below* the manifest (kamaji's allocator resolves `name -> port`, a
6043/// service record publishes `{"http": 8080, "wss": 8443}`, the sibling wire
6044/// carries `named_ports`, `PORT_<NAME>` reaches the process) and synthesised
6045/// from nothing at the top by [`crate::MeshExpose`]'s number list. This is the
6046/// declaration surface that had to exist for any of that to be *stated* rather
6047/// than guessed.
6048///
6049/// ## Three spellings, one type
6050///
6051/// ```toml
6052/// ports = [8080]                            # a number, unnamed
6053/// ports = ["http", "wss"]                   # names; the supervisor picks the numbers
6054/// ports = [{ name = "https", port = 443 }]  # both stated
6055/// ```
6056///
6057/// They mix freely in one array (`ports = [{ name = "http", port = 8080 },
6058/// "metrics"]`), because the two facts are independent: a container's ports are
6059/// fixed by its image and still want names, while a native workload's numbers
6060/// are the allocator's to choose and only the names are the author's.
6061///
6062/// ## What each spelling means downstream
6063///
6064/// - **A number** is a request to listen there. On a container backend that is
6065///   simply the container-side port. On the published (fleet) tier a number
6066///   outside `kamaji::ports::WORLD_FIXED_PORTS` is refused at bring-up rather
6067///   than honoured (R844-F14) — a stale pin is how one workload lands on the
6068///   port a co-tenant already holds.
6069/// - **A name** is what a consumer asks for: `ServiceRecord::port("wss")`, the
6070///   ingress planner resolving which listener a hostname fronts, the
6071///   `PORT_<NAME>` variable the process reads. A workload declaring several
6072///   ports and naming none has nothing called `http`, and the front door
6073///   refuses to resolve rather than publish a hostname at whichever listener
6074///   sorted first (`kamaji::name_anonymous_ports`). Naming them is how you
6075///   answer that question instead of being asked it.
6076///
6077/// ## Wire shapes
6078///
6079/// Human-readable formats (TOML/JSON) accept all three spellings and
6080/// round-trip back to the most compact faithful one. The binary wire (postcard,
6081/// behind the kamaji UDS) carries the plain two-`Option` struct: `untagged`
6082/// needs `deserialize_any`, which postcard refuses — the same split
6083/// [`ImageRef`] makes, and for the same reason (R590-B3).
6084///
6085/// Deliberately NOT `Default`: the all-`None` value is the one shape no accepted
6086/// spelling produces and `validate::shape` rejects, so a `..Default::default()`
6087/// would hand a caller exactly the invalid port.
6088#[derive(Debug, Clone, PartialEq, Eq)]
6089pub struct MeshPort {
6090    /// The name this port is known by — `http`, `wss`, `metrics`. `None` when
6091    /// the manifest wrote a bare number; `kamaji::name_anonymous_ports` then
6092    /// decides what to call it, which is deliberately *not* `http` when there
6093    /// is more than one.
6094    pub name: Option<String>,
6095
6096    /// The port number, when the manifest states one. `None` means the
6097    /// supervisor allocates it and tells the workload via `PORT_<NAME>`.
6098    pub number: Option<u16>,
6099}
6100
6101impl MeshPort {
6102    /// A bare number, unnamed — the pre-R844-F17 spelling, still valid.
6103    pub fn anonymous(number: u16) -> Self {
6104        Self {
6105            name: None,
6106            number: Some(number),
6107        }
6108    }
6109
6110    /// A named port whose number the supervisor allocates.
6111    pub fn named(name: impl Into<String>) -> Self {
6112        Self {
6113            name: Some(name.into()),
6114            number: None,
6115        }
6116    }
6117
6118    /// A named port whose number the manifest states.
6119    pub fn pinned(name: impl Into<String>, number: u16) -> Self {
6120        Self {
6121            name: Some(name.into()),
6122            number: Some(number),
6123        }
6124    }
6125}
6126
6127impl From<u16> for MeshPort {
6128    fn from(number: u16) -> Self {
6129        Self::anonymous(number)
6130    }
6131}
6132
6133impl From<&str> for MeshPort {
6134    fn from(name: &str) -> Self {
6135        Self::named(name)
6136    }
6137}
6138
6139impl From<String> for MeshPort {
6140    fn from(name: String) -> Self {
6141        Self::named(name)
6142    }
6143}
6144
6145/// The self-describing spelling of a [`MeshPort`] — the shape a TOML/JSON
6146/// author writes, and the one the generated JSON schema and TS bindings
6147/// advertise.
6148///
6149/// Kept as its own type rather than folded into `MeshPort` because it is only
6150/// half the story: the binary wire never sees it (see [`MeshPort`]'s docs), and
6151/// a struct with two `Option`s is the shape every *consumer* wants regardless
6152/// of which of the three forms the author picked.
6153#[derive(Serialize, Deserialize)]
6154#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
6155#[serde(untagged)]
6156enum MeshPortRepr {
6157    /// `8080` — a number with no name.
6158    Number(u16),
6159    /// `"http"` — a name whose number the supervisor allocates.
6160    Name(String),
6161    /// `{ name = "https", port = 443 }` — both stated. `port` may be omitted,
6162    /// which is the table spelling of the bare-name form.
6163    Both {
6164        name: String,
6165        #[serde(default)]
6166        port: Option<u16>,
6167    },
6168}
6169
6170impl Serialize for MeshPort {
6171    fn serialize<S>(&self, ser: S) -> Result<S::Ok, S::Error>
6172    where
6173        S: serde::Serializer,
6174    {
6175        if !ser.is_human_readable() {
6176            // Postcard and friends: the plain positional struct, every field
6177            // always encoded. See the V6 stanza in `kamaji_proto::version` —
6178            // there is no `skip_serializing_if` that is safe here.
6179            #[derive(Serialize)]
6180            struct Fields<'a> {
6181                name: &'a Option<String>,
6182                number: &'a Option<u16>,
6183            }
6184            return Fields {
6185                name: &self.name,
6186                number: &self.number,
6187            }
6188            .serialize(ser);
6189        }
6190
6191        match (&self.name, self.number) {
6192            (Some(name), Some(port)) => MeshPortRepr::Both {
6193                name: name.clone(),
6194                port: Some(port),
6195            },
6196            (Some(name), None) => MeshPortRepr::Name(name.clone()),
6197            (None, Some(port)) => MeshPortRepr::Number(port),
6198            // Not constructible from any accepted spelling; `validate::shape`
6199            // rejects it too. Emitted as an empty table rather than silently
6200            // becoming something else.
6201            (None, None) => MeshPortRepr::Both {
6202                name: String::new(),
6203                port: None,
6204            },
6205        }
6206        .serialize(ser)
6207    }
6208}
6209
6210impl<'de> Deserialize<'de> for MeshPort {
6211    fn deserialize<D>(de: D) -> Result<Self, D::Error>
6212    where
6213        D: serde::Deserializer<'de>,
6214    {
6215        if !de.is_human_readable() {
6216            #[derive(Deserialize)]
6217            struct Fields {
6218                name: Option<String>,
6219                number: Option<u16>,
6220            }
6221            let f = Fields::deserialize(de)?;
6222            return Ok(MeshPort {
6223                name: f.name,
6224                number: f.number,
6225            });
6226        }
6227
6228        Ok(match MeshPortRepr::deserialize(de)? {
6229            MeshPortRepr::Number(port) => MeshPort::anonymous(port),
6230            MeshPortRepr::Name(name) => MeshPort::named(name),
6231            MeshPortRepr::Both { name, port } => MeshPort {
6232                name: Some(name),
6233                number: port,
6234            },
6235        })
6236    }
6237}
6238
6239/// Mesh-internal port exposure and peer access control.
6240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
6241#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
6242pub struct MeshExpose {
6243    /// DNS-segment mesh identity for this workload. Must be unique in the
6244    /// cluster. Regex: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`, length ≤ 63.
6245    pub identity: MeshIdent,
6246
6247    /// Ports this workload listens on, each optionally named (R844-F17). Other
6248    /// workloads reach it at `<identity>:<port>` on the mesh.
6249    ///
6250    /// See [`MeshPort`] for the three accepted spellings. Read the numbers with
6251    /// [`MeshExpose::numbers`] and the names with
6252    /// [`MeshExpose::named_numbers`] — there is deliberately no way to read
6253    /// this as a plain `Vec<u16>`, because a name-only entry has no number yet
6254    /// and a conversion that dropped it would be exactly the silent loss named
6255    /// ports exist to prevent.
6256    #[ts(type = "(number | string | { name: string, port?: number })[]")]
6257    #[cfg_attr(feature = "json-schema", schemars(with = "Vec<MeshPortRepr>"))]
6258    pub ports: Vec<MeshPort>,
6259
6260    /// Peers permitted to initiate connections to this workload on the mesh
6261    /// (W206 / R558-F3). Same-tenant tier rules and explicit cross-tenant
6262    /// grants share this one list. With **no** [`MeshPeer::Tier`] entries every
6263    /// same-tenant peer is admitted (the historical "empty = allow all"
6264    /// default); cross-tenant peers are always denied unless named by a
6265    /// [`MeshPeer::CrossTenant`] entry. See [`MeshExpose::admits_peer`].
6266    #[serde(default)]
6267    pub allow_from: Vec<MeshPeer>,
6268}
6269
6270impl MeshExpose {
6271    /// Every port *number* this workload declares, in declaration order.
6272    ///
6273    /// Name-only entries (`ports = ["http"]`) carry no number and are simply
6274    /// absent here — they do not have one until a supervisor allocates it. That
6275    /// is why this is a method rather than the field: a caller reading numbers
6276    /// has to be able to see that the list it got is shorter than the list the
6277    /// author wrote, and a `Vec<u16>` field could not say so.
6278    pub fn numbers(&self) -> Vec<u16> {
6279        self.ports.iter().filter_map(|p| p.number).collect()
6280    }
6281
6282    /// Whether `port` appears as a declared number.
6283    pub fn declares_number(&self, port: u16) -> bool {
6284        self.ports.iter().any(|p| p.number == Some(port))
6285    }
6286
6287    /// The `name -> number` map for every port the manifest declares *both*
6288    /// for. Name-only ports are absent (no number yet) and unnamed ports are
6289    /// absent (no name); `kamaji::name_anonymous_ports` is what fills the
6290    /// second gap once numbers are known.
6291    pub fn named_numbers(&self) -> BTreeMap<String, u16> {
6292        self.ports
6293            .iter()
6294            .filter_map(|p| Some((p.name.clone()?, p.number?)))
6295            .collect()
6296    }
6297
6298    /// Every port name the manifest states, in declaration order.
6299    pub fn names(&self) -> Vec<&str> {
6300        self.ports
6301            .iter()
6302            .filter_map(|p| p.name.as_deref())
6303            .collect()
6304    }
6305
6306    /// The pre-R844-F17 spelling as a value: a list of unnamed numbers. Kept
6307    /// because most call sites — and every test fixture — genuinely mean
6308    /// "these numbers, names irrelevant".
6309    pub fn anonymous_ports(numbers: impl IntoIterator<Item = u16>) -> Vec<MeshPort> {
6310        numbers.into_iter().map(MeshPort::anonymous).collect()
6311    }
6312
6313    /// Whether a peer may initiate a mesh connection to a workload whose mesh
6314    /// exposure is `self`. `own_tenant` is the tenant of the workload being
6315    /// protected; the remaining arguments identify the connecting peer.
6316    ///
6317    /// Deny-by-default across tenants (W206 / R558-F3):
6318    /// - **Same tenant** (`own_tenant == peer_tenant`): admitted when the
6319    ///   peer's tier matches a [`MeshPeer::Tier`] rule, or when there are no
6320    ///   `Tier` rules at all (historical "empty `allow_from` = allow all
6321    ///   same-tenant").
6322    /// - **Cross tenant**: admitted only when an explicit
6323    ///   [`MeshPeer::CrossTenant`] entry matches the peer's
6324    ///   `(tenant, namespace, name)`.
6325    pub fn admits_peer(
6326        &self,
6327        own_tenant: &TenantId,
6328        peer_tenant: &TenantId,
6329        peer_namespace: &NamespaceId,
6330        peer_name: &MeshIdent,
6331        peer_tier: &TierTag,
6332    ) -> bool {
6333        if own_tenant == peer_tenant {
6334            let mut has_tier_rule = false;
6335            for peer in &self.allow_from {
6336                if let MeshPeer::Tier(t) = peer {
6337                    has_tier_rule = true;
6338                    if t == peer_tier {
6339                        return true;
6340                    }
6341                }
6342            }
6343            // No same-tenant tier restriction declared → admit all same-tenant.
6344            !has_tier_rule
6345        } else {
6346            self.allow_from.iter().any(|peer| {
6347                matches!(
6348                    peer,
6349                    MeshPeer::CrossTenant { tenant, namespace, name }
6350                        if tenant == peer_tenant
6351                            && namespace == peer_namespace
6352                            && name == peer_name
6353                )
6354            })
6355        }
6356    }
6357}
6358
6359/// The name by which a workload is addressed **within its own tenant** (W206 /
6360/// R558-F3), given every `(namespace, identity)` pair present in that tenant.
6361///
6362/// Within a tenant, a workload is reached by its short mesh `identity` when that
6363/// identity is unique across the tenant's namespaces. When two namespaces
6364/// expose the same identity, the name is ambiguous, so both are disambiguated
6365/// by a namespace prefix — `<namespace>.<identity>` (e.g. `yah.runner` vs
6366/// `noisetable.runner`). Cross-tenant addressing always uses the full FQN
6367/// ([`WorkloadSpec::fq_mesh_identity`]) and is out of scope here.
6368pub fn intra_tenant_address(
6369    namespace: &NamespaceId,
6370    identity: &MeshIdent,
6371    tenant_workloads: &[(NamespaceId, MeshIdent)],
6372) -> String {
6373    let collides = tenant_workloads
6374        .iter()
6375        .any(|(ns, id)| id == identity && ns != namespace);
6376    if collides {
6377        format!("{}.{}", namespace.0, identity.0)
6378    } else {
6379        identity.0.clone()
6380    }
6381}
6382
6383/// Public internet exposure via a Cloudflare tunnel route.
6384#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
6385#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
6386pub struct PublicExpose {
6387    /// Public hostname to route, e.g. `"api.noisetable.io"`. Semantic
6388    /// validation checks that this hostname is owned by a configured CF zone.
6389    pub hostname: String,
6390
6391    /// Container-side port to route traffic to. Shape validation requires this
6392    /// port to appear in `expose.mesh.ports`.
6393    pub port: u16,
6394
6395    /// TLS configuration for the public endpoint.
6396    pub tls: PublicTls,
6397}
6398
6399/// TLS mode for a public endpoint.
6400#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
6401#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
6402#[serde(rename_all = "snake_case")]
6403pub enum PublicTls {
6404    /// Cloudflare manages the TLS certificate (default; requires a proxied DNS
6405    /// record in the configured zone).
6406    CfManaged,
6407
6408    /// User-supplied certificate referenced by name in the yubaba secret store.
6409    UserCertRef { name: String },
6410}
6411
6412/// Operator-facing exposure via a Tailscale ACL tag.
6413#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
6414#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
6415pub struct OperatorExpose {
6416    /// Tailscale ACL tag granting access, e.g. `"tag:noisetable-ops"`. Semantic
6417    /// validation checks that this tag exists in the cluster's Tailscale ACL.
6418    pub tailscale_tag: String,
6419
6420    /// Container-side port to expose to Tailscale-authorized operators.
6421    pub port: u16,
6422}
6423
6424// ── ImageRef helpers ──────────────────────────────────────────────────────────
6425
6426impl ImageRef {
6427    /// The all-zeros sha256 digest that marks an image reference as **not
6428    /// content-pinned**. No real image can carry it, so a build that never
6429    /// injected a compile-time digest (dev builds) or a catalog image that
6430    /// isn't published-and-pinned yet lands on this sentinel. This is the
6431    /// single source of truth both the catalog emitter
6432    /// (`task::default_image::catalog_image`, which writes it) and the
6433    /// container-runtime resolvers ([`Self::pull_ref`], via kamaji) agree on —
6434    /// keeping them here means they cannot drift. [`testing::TEST_DIGEST`] is
6435    /// the same value re-exported for fixtures.
6436    pub const UNPINNED_DIGEST: &'static str =
6437        "sha256:0000000000000000000000000000000000000000000000000000000000000000";
6438
6439    /// Parse a full digest-pinned image reference —
6440    /// `[registry/]repo[:tag]@sha256:<hex>` — into its parts.
6441    ///
6442    /// This is the public door onto the same parser the `ImageRef` string-form
6443    /// `Deserialize` arm uses, so a config that spells an image as one string
6444    /// (a qed `step.image`, a transform recipe) and a config that spells it as
6445    /// a struct land on identical semantics. A bare tag is rejected: the whole
6446    /// point of the string form is that it carries the digest.
6447    pub fn parse_pinned(s: &str) -> Result<Self, String> {
6448        compose_import::parse_pinned_image_ref(s)
6449    }
6450
6451    /// Format this reference as a Docker-compatible image string,
6452    /// `{registry}/{repository}:{tag}@{digest}`. Tag is included for human
6453    /// readability; the digest is what the pull resolves against. Always emits
6454    /// the digest — this is the display/logging form; use [`Self::pull_ref`]
6455    /// for the string handed to a container runtime.
6456    pub fn docker_ref(&self) -> String {
6457        format!("{}/{}:{}@{}", self.registry, self.repository, self.tag, self.digest)
6458    }
6459
6460    /// True when this reference carries a real content-addressed digest, i.e.
6461    /// its digest is not the all-zeros [`Self::UNPINNED_DIGEST`] sentinel.
6462    pub fn is_pinned(&self) -> bool {
6463        self.digest != Self::UNPINNED_DIGEST
6464    }
6465
6466    /// The reference string to hand a container runtime for pull/resolve.
6467    ///
6468    /// - **Pinned** (real digest): `{registry}/{repository}:{tag}@{digest}` —
6469    ///   content-addressed, the reproducible path.
6470    /// - **Unpinned** (all-zeros [`Self::UNPINNED_DIGEST`]): `{registry}/{repository}:{tag}`
6471    ///   — tag-only. No registry or local store holds an image under the
6472    ///   sentinel digest, so `…@sha256:0000…` can never resolve; a
6473    ///   tag-pulled or locally-built image is keyed by `registry/repo:tag`.
6474    ///   This is the tag-fallback path that lets a not-yet-published catalog
6475    ///   image (e.g. a from-source build-worker image) still pull by tag.
6476    pub fn pull_ref(&self) -> String {
6477        if self.is_pinned() {
6478            format!("{}/{}:{}@{}", self.registry, self.repository, self.tag, self.digest)
6479        } else {
6480            format!("{}/{}:{}", self.registry, self.repository, self.tag)
6481        }
6482    }
6483}
6484
6485// ── WorkloadRuntime trait ─────────────────────────────────────────────────────
6486
6487/// Shared interface for deploying and managing `WorkloadSpec` containers.
6488///
6489/// This is the keystone abstraction (R256-F10) that makes sim and cloud
6490/// literally interchangeable at the container level:
6491///
6492/// - **Camp/sim tier**: `LocalDockerRuntime` in `cloud` implements this trait
6493///   via the docker CLI pointed at OrbStack (or any Docker-compatible socket).
6494///   No mesh — containers communicate over OrbStack's bridge network.
6495///
6496/// - **Yubaba/cloud-HA tier**: `yubaba::runtime::ContainerRuntime` (gRPC to
6497///   containerd) will implement this trait. Mesh assignment is a separate
6498///   orchestration step on top (handled by yubaba's raft layer), not part
6499///   of the shared deploy/supervise interface.
6500///
6501/// Callers that type against `WorkloadRuntime` automatically work with both
6502/// backends. Reconcilers in `cloud` use it today; yubaba wires its own impl
6503/// when R276 Tier-3 lands.
6504#[async_trait::async_trait]
6505pub trait WorkloadRuntime: Send + Sync {
6506    /// Deploy a workload described by `spec`. Pulls the image if needed,
6507    /// creates and starts the container, and returns an opaque workload ID
6508    /// (typically the container name derived from `spec.name`).
6509    ///
6510    /// Idempotent: re-deploying a running workload replaces it cleanly.
6511    async fn deploy_workload(&self, spec: &WorkloadSpec) -> anyhow::Result<String>;
6512
6513    /// Tear down a deployed workload — stop the process and remove all
6514    /// associated state. No-op when the workload is already gone.
6515    async fn teardown_workload(&self, name: &str) -> anyhow::Result<()>;
6516
6517    /// Returns `true` when the named workload is currently running (i.e.
6518    /// the container process is alive and has not exited).
6519    async fn is_running(&self, name: &str) -> anyhow::Result<bool>;
6520
6521    /// Probe the runtime backend. Returns `true` when the backend socket is
6522    /// reachable and healthy (e.g. docker daemon up, containerd gRPC up).
6523    /// Used by health endpoints and startup checks.
6524    async fn runtime_health(&self) -> anyhow::Result<bool>;
6525}
6526
6527// ── Tests ─────────────────────────────────────────────────────────────────────
6528
6529#[cfg(test)]
6530mod tests {
6531    use super::*;
6532
6533    // ── R658-B1 `routes` belongs at the top level, not inside [build] ─────────
6534
6535    /// The canonical `mesofact-static` manifest shape: `routes` above the
6536    /// `[build]` header, where TOML keeps it top-level.
6537    #[test]
6538    fn mesofact_static_routes_parse_at_the_top_level() {
6539        let src = r#"
6540kind = "mesofact-static"
6541routes = "./mesofact.routes.ts"
6542
6543[build]
6544command = "bun run build"
6545out_dir = "dist"
6546"#;
6547        let Workload::MesofactStatic(site) =
6548            toml::from_str::<Workload>(src).expect("canonical shape must parse")
6549        else {
6550            panic!("kind = \"mesofact-static\" must select MesofactStatic");
6551        };
6552        assert_eq!(site.routes, PathBuf::from("./mesofact.routes.ts"));
6553        assert_eq!(site.build.out_dir, PathBuf::from("dist"));
6554    }
6555
6556    /// The bug R658-B1 exists for: `routes` written *below* `[build]` is
6557    /// `build.routes` as far as TOML is concerned. `BuildConfig` used to
6558    /// discard the stray key, so this manifest parsed as far as the missing
6559    /// top-level field and blamed the wrong line — or, once `routes` had a
6560    /// default, would have deployed a site that enumerated no routes at all.
6561    ///
6562    /// `deny_unknown_fields` makes the misplacement itself the error, and the
6563    /// message names `routes`, which is the one thing the author needs to move.
6564    #[test]
6565    fn mesofact_static_routes_inside_build_is_rejected_by_name() {
6566        let src = r#"
6567kind = "mesofact-static"
6568
6569[build]
6570command = "bun run build"
6571out_dir = "dist"
6572routes = "./mesofact.routes.ts"
6573"#;
6574        let err = toml::from_str::<Workload>(src)
6575            .expect_err("`routes` under [build] must not parse silently")
6576            .to_string();
6577        assert!(
6578            err.contains("routes"),
6579            "the error must name the misplaced key so the fix is obvious; got: {err}"
6580        );
6581    }
6582
6583    /// Guard the general case, not just the one key that bit us: any unknown
6584    /// `[build]` key is refused rather than dropped on the floor.
6585    #[test]
6586    fn unknown_build_keys_are_refused_rather_than_ignored() {
6587        let src = r#"
6588command = "bun run build"
6589out_dir = "dist"
6590outdir = "dist"
6591"#;
6592        let err = toml::from_str::<BuildConfig>(src)
6593            .expect_err("a typo'd build key must not be silently ignored")
6594            .to_string();
6595        assert!(err.contains("outdir"), "got: {err}");
6596
6597        // …and the keys that ARE modelled still round-trip.
6598        let ok: BuildConfig = toml::from_str(
6599            r#"
6600command = "bun run build"
6601out_dir = "dist"
6602render_command = "mesofact-build render . --route {route}"
6603"#,
6604        )
6605        .expect("modelled keys must still parse");
6606        assert_eq!(ok.render_command.as_deref(), Some("mesofact-build render . --route {route}"));
6607    }
6608
6609    // ── R783-F1 / W324: container manifest vs wire spec ────────────────────────
6610
6611    /// The acceptance case. `crates/yah/cloud-admin/workload.toml` is the file
6612    /// that could not parse through the envelope at all (R658-B2 pinned it in
6613    /// `xtask/tests/workload_envelope.rs` as `missing field \`image\``): it is a
6614    /// Dockerfile recipe, and the envelope only knew digest-pinned specs.
6615    ///
6616    /// The `[process]` table is deliberately present — that file is read by
6617    /// `LocalProcessReconciler` on the dev mirror *and* `ContainerReconciler`
6618    /// on pond, so the container form must tolerate the other tier's table
6619    /// rather than reject the file (W324 §1).
6620    #[test]
6621    fn container_recipe_parses_including_the_other_tier_s_table() {
6622        let src = r#"
6623name = "yah-cloud-admin"
6624kind = "container"
6625
6626[build]
6627dockerfile = "Dockerfile"
6628context = "."
6629image = "yah-local/yah-cloud-admin:dev"
6630
6631[run]
6632port = 4325
6633host_port = 4326
6634
6635[run.env]
6636YAH_CLOUD_ADMIN_ADDR = "0.0.0.0:4325"
6637
6638[[run.mounts]]
6639host = ".yah/infra"
6640container = "/workspace/.yah/infra"
6641
6642[process]
6643cargo_package = "yah-cloud-admin"
6644port = 4325
6645"#;
6646        let workload = toml::from_str::<Workload>(src).expect("the recipe form must parse");
6647        assert_eq!(workload.kind_str(), "container");
6648
6649        let recipe = workload
6650            .container_manifest()
6651            .and_then(ContainerManifest::as_recipe)
6652            .expect("a [build] table selects the recipe form");
6653        assert_eq!(recipe.name, "yah-cloud-admin");
6654        assert_eq!(recipe.build.dockerfile, PathBuf::from("Dockerfile"));
6655        assert_eq!(recipe.build.context, Some(PathBuf::from(".")));
6656        assert_eq!(
6657            recipe.build.image.as_deref(),
6658            Some("yah-local/yah-cloud-admin:dev")
6659        );
6660        assert_eq!(recipe.run.port, Some(4325));
6661        assert_eq!(recipe.run.host_port, Some(4326));
6662        assert_eq!(
6663            recipe.run.env.get("YAH_CLOUD_ADMIN_ADDR").map(String::as_str),
6664            Some("0.0.0.0:4325")
6665        );
6666        assert_eq!(recipe.run.mounts.len(), 1);
6667        assert!(recipe.run.mounts[0].read_only, "mounts default to read-only");
6668
6669        // The recipe has no spec — that is the whole point of the split.
6670        assert!(workload.container_spec().is_none());
6671    }
6672
6673    /// The other branch: no `[build]` table means the flat fields are a
6674    /// digest-pinned `WorkloadSpec`, exactly as before the split.
6675    #[test]
6676    fn container_reference_still_parses_as_a_workload_spec() {
6677        let spec = archetype_test_spec("noisetable-api");
6678        let toml_src = toml::to_string(&Workload::container(spec.clone())).expect("serialize");
6679        assert!(
6680            toml_src.contains("kind = \"container\""),
6681            "the on-disk form stays flat + internally tagged: {toml_src}"
6682        );
6683
6684        let back = toml::from_str::<Workload>(&toml_src).expect("deserialize");
6685        assert_eq!(back.container_spec(), Some(&spec));
6686    }
6687
6688    /// Explicit-branch deserialize exists so this error survives. Under
6689    /// `#[serde(untagged)]` it would read "data did not match any variant of
6690    /// untagged enum ContainerManifest", which tells an author nothing.
6691    #[test]
6692    fn a_malformed_container_reference_still_names_the_missing_field() {
6693        let src = r#"
6694kind = "container"
6695name = "noisetable-api"
6696image = "ghcr.io/noisetable/api:v1@sha256:0000000000000000000000000000000000000000000000000000000000000000"
6697replicas = 1
6698"#;
6699        let err = toml::from_str::<Workload>(src)
6700            .expect_err("a reference missing a required field must not parse")
6701            .to_string();
6702        assert!(err.contains("missing field `tier`"), "got: {err}");
6703    }
6704
6705    /// The one file that names neither marker. `missing field \`image\`` would
6706    /// send a recipe author off to add a field their form does not have, so
6707    /// the error names both forms instead.
6708    #[test]
6709    fn a_container_with_neither_image_nor_build_names_both_forms() {
6710        let src = r#"
6711kind = "container"
6712name = "yah-cloud-admin"
6713
6714[run]
6715port = 4325
6716"#;
6717        let err = toml::from_str::<Workload>(src)
6718            .expect_err("neither form is declared")
6719            .to_string();
6720        assert!(err.contains("image"), "got: {err}");
6721        assert!(err.contains("[build]"), "got: {err}");
6722    }
6723
6724    /// W324 §5's invariant, as a signature: there is no path from a recipe to
6725    /// a `WorkloadSpec` that does not name a digest.
6726    #[test]
6727    fn a_recipe_lowers_only_once_a_build_has_produced_a_digest() {
6728        let recipe = ContainerBuild {
6729            name: "yah-cloud-admin".into(),
6730            build: ContainerBuildStep {
6731                dockerfile: "Dockerfile".into(),
6732                context: Some(".".into()),
6733                image: Some("yah-local/yah-cloud-admin:dev".into()),
6734            },
6735            run: ContainerRunConfig {
6736                port: Some(4325),
6737                host_port: Some(4326),
6738                env: BTreeMap::from([("A".to_string(), "b".to_string())]),
6739                mounts: vec![ContainerMount {
6740                    host: ".yah/infra".into(),
6741                    container: "/workspace/.yah/infra".into(),
6742                    read_only: true,
6743                }],
6744            },
6745        };
6746
6747        let digest = testing::test_digest();
6748        let spec = recipe
6749            .clone()
6750            .into_spec(&digest, TierTag("private".into()))
6751            .expect("a well-formed digest lowers");
6752        assert_eq!(spec.name, "yah-cloud-admin");
6753        assert_eq!(spec.image.digest, digest);
6754        assert_eq!(spec.image.repository, "yah-local/yah-cloud-admin");
6755        assert_eq!(spec.image.tag, "dev");
6756        assert_eq!(spec.expose.mesh.numbers(), vec![4325]);
6757        assert_eq!(spec.env.len(), 1);
6758        assert_eq!(spec.volumes.len(), 1);
6759
6760        // A bare tag is not a digest. Lowering must fail rather than mint a
6761        // spec that lies about being content-addressed (R438-T3).
6762        let err = recipe
6763            .into_spec("dev", TierTag("private".into()))
6764            .expect_err("an unpinned digest must not lower");
6765        assert!(err.contains("sha256"), "got: {err}");
6766    }
6767
6768    /// A recipe is a first-class on-disk value: it survives a write/read of
6769    /// the manifest unchanged. The other half of the gate — that the same
6770    /// value is *refused* by postcard — is in `tests/round_trip.rs`, which
6771    /// also pins the reference form's byte layout.
6772    #[test]
6773    fn a_recipe_round_trips_on_disk_under_the_container_kind() {
6774        let recipe = Workload::Container(ContainerManifest::Recipe(ContainerBuild {
6775            name: "yah-cloud-admin".into(),
6776            build: ContainerBuildStep::default(),
6777            run: ContainerRunConfig::default(),
6778        }));
6779        assert_eq!(recipe.kind_str(), "container");
6780
6781        let src = toml::to_string(&recipe).expect("a recipe serializes to disk");
6782        assert!(src.contains("kind = \"container\""), "{src}");
6783        let back: Workload = toml::from_str(&src).expect("and parses back");
6784        assert_eq!(back, recipe);
6785    }
6786
6787    // ── R603-T5 durable forge produced convention ──────────────────────────────
6788
6789    #[test]
6790    fn forge_produced_ident_parse() {
6791        assert_eq!(forge_produced::forge_id_from_ident("forge.abc123"), Some("abc123"));
6792        assert_eq!(forge_produced::forge_id_from_ident("svc.web"), None);
6793        assert_eq!(forge_produced::forge_id_from_ident("abc123"), None);
6794    }
6795
6796    #[test]
6797    fn forge_produced_host_path_translates_under_convention_dir() {
6798        let hp = forge_produced::host_path(
6799            "fid",
6800            std::path::Path::new("/yah/produced/librusty_v8.tar.gz"),
6801        )
6802        .expect("path under the convention dir translates");
6803        assert_eq!(
6804            hp,
6805            PathBuf::from("/var/lib/yah/qed/produced/fid/librusty_v8.tar.gz")
6806        );
6807    }
6808
6809    #[test]
6810    fn forge_produced_host_path_rejects_paths_outside_convention_dir() {
6811        assert_eq!(
6812            forge_produced::host_path("fid", std::path::Path::new("/tmp/x.tar.gz")),
6813            None,
6814            "a path outside /yah/produced has no durable host mapping"
6815        );
6816    }
6817
6818    #[test]
6819    fn forge_produced_host_path_rejects_traversal() {
6820        // A `..` component must never let a read escape the per-forge dir.
6821        assert_eq!(
6822            forge_produced::host_path(
6823                "fid",
6824                std::path::Path::new("/yah/produced/../../etc/passwd")
6825            ),
6826            None,
6827            "traversal out of the per-forge dir must be refused"
6828        );
6829    }
6830
6831    #[test]
6832    fn forge_produced_durable_mount_shape() {
6833        let m = forge_produced::durable_mount("fid");
6834        assert_eq!(m.target, PathBuf::from("/yah/produced"));
6835        assert!(!m.read_only, "the build must be able to write to it");
6836        assert_eq!(
6837            m.source,
6838            VolumeSource::Bind {
6839                host_path: PathBuf::from("/var/lib/yah/qed/produced/fid"),
6840            }
6841        );
6842    }
6843
6844    const HASH_64: &str = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
6845
6846    #[test]
6847    fn blake_hash_accepts_64_hex() {
6848        let h: BlakeHash = toml::from_str(&format!("x = \"{HASH_64}\""))
6849            .map(|t: toml::Table| t["x"].as_str().unwrap().to_owned())
6850            .map(|s| serde_json::from_value(serde_json::Value::String(s)).unwrap())
6851            .unwrap();
6852        assert_eq!(h.0, HASH_64);
6853    }
6854
6855    #[test]
6856    fn blake_hash_rejects_wrong_length() {
6857        let short = "abcdef";
6858        let res: Result<BlakeHash, _> =
6859            serde_json::from_value(serde_json::Value::String(short.into()));
6860        assert!(res.is_err());
6861    }
6862
6863    #[test]
6864    fn blake_hash_rejects_non_hex() {
6865        let bad = "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz";
6866        let res: Result<BlakeHash, _> =
6867            serde_json::from_value(serde_json::Value::String(bad.into()));
6868        assert!(res.is_err());
6869    }
6870
6871    fn image_ref(digest: &str) -> ImageRef {
6872        ImageRef {
6873            registry: "ghcr.io".into(),
6874            repository: "yah-ai/rusty-v8-musl-builder".into(),
6875            tag: "latest".into(),
6876            digest: digest.into(),
6877        }
6878    }
6879
6880    #[test]
6881    fn is_pinned_distinguishes_real_digest_from_sentinel() {
6882        assert!(!image_ref(ImageRef::UNPINNED_DIGEST).is_pinned());
6883        assert!(!image_ref(&testing::test_digest()).is_pinned());
6884        assert!(image_ref("sha256:deadbeef").is_pinned());
6885    }
6886
6887    #[test]
6888    fn pull_ref_pinned_carries_tag_and_digest() {
6889        assert_eq!(
6890            image_ref("sha256:deadbeef").pull_ref(),
6891            "ghcr.io/yah-ai/rusty-v8-musl-builder:latest@sha256:deadbeef",
6892        );
6893    }
6894
6895    #[test]
6896    fn pull_ref_unpinned_falls_back_to_tag_only() {
6897        // An unpinned catalog image (all-zeros sentinel) resolves by tag —
6898        // no store holds `…@sha256:0000…`, so the tag is the only usable key.
6899        assert_eq!(
6900            image_ref(ImageRef::UNPINNED_DIGEST).pull_ref(),
6901            "ghcr.io/yah-ai/rusty-v8-musl-builder:latest",
6902        );
6903    }
6904
6905    #[test]
6906    fn test_digest_alias_is_the_unpinned_sentinel() {
6907        assert_eq!(testing::TEST_DIGEST, ImageRef::UNPINNED_DIGEST);
6908    }
6909
6910    #[test]
6911    fn static_asset_workload_round_trips() {
6912        let src = format!(
6913            r#"
6914
6915[[asset]]
6916filename = "whisper/distil-large-v3-q5_1.bin"
6917source   = "sources/distil-large-v3-q5_1.bin"
6918blake3   = "{HASH_64}"
6919
6920[[asset]]
6921filename = "whisper/distil-large-v3-q4_0.bin"
6922source   = "sources/distil-large-v3-q4_0.bin"
6923blake3   = "{HASH_64}"
6924
6925[aliases]
6926"whisper-default" = "whisper/distil-large-v3-q5_1.bin"
6927"#
6928        );
6929        let w: StaticAssetWorkload = toml::from_str(&src).expect("parse");
6930        assert_eq!(w.assets.len(), 2);
6931        assert_eq!(w.assets[0].filename, "whisper/distil-large-v3-q5_1.bin");
6932        assert_eq!(w.assets[0].blake3.0, HASH_64);
6933        assert_eq!(w.aliases["whisper-default"], "whisper/distil-large-v3-q5_1.bin");
6934
6935        let back = toml::to_string(&w).expect("serialize");
6936        let w2: StaticAssetWorkload = toml::from_str(&back).expect("re-parse");
6937        assert_eq!(w, w2);
6938    }
6939
6940    #[test]
6941    fn license_round_trip_each_variant() {
6942        // Wire format is whatever serde's `rename_all = "kebab-case"` emits.
6943        // heck's kebab-case keeps letter→digit attached but splits digit→uppercase,
6944        // so `Apache2 → "apache2"` and `Bsd2Clause → "bsd2-clause"`.
6945        for (variant, on_wire) in [
6946            (License::Mit, "mit"),
6947            (License::Apache2, "apache2"),
6948            (License::Bsd2Clause, "bsd2-clause"),
6949            (License::Bsd3Clause, "bsd3-clause"),
6950            (License::Isc, "isc"),
6951        ] {
6952            let ser = serde_json::to_value(variant).expect("serialize");
6953            assert_eq!(ser, serde_json::Value::String(on_wire.into()));
6954            let back: License = serde_json::from_value(ser).expect("deserialize");
6955            assert_eq!(back, variant);
6956        }
6957    }
6958
6959    #[test]
6960    fn license_rejects_non_permissive_variants() {
6961        for unknown in ["GPL-3.0", "AGPL", "lgpl-2.1", "unknown", "MIT"] {
6962            let res: Result<License, _> =
6963                serde_json::from_value(serde_json::Value::String(unknown.into()));
6964            assert!(res.is_err(), "expected rejection for {unknown:?}");
6965        }
6966    }
6967
6968    #[test]
6969    fn fetch_source_round_trips() {
6970        let src = format!(
6971            r#"
6972url     = "https://example.invalid/upstream.bin"
6973blake3  = "{HASH_64}"
6974license = "mit"
6975"#
6976        );
6977        let fs: FetchSource = toml::from_str(&src).expect("parse");
6978        assert_eq!(fs.url, "https://example.invalid/upstream.bin");
6979        assert_eq!(fs.blake3.0, HASH_64);
6980        assert_eq!(fs.license, License::Mit);
6981
6982        let back = toml::to_string(&fs).expect("serialize");
6983        let fs2: FetchSource = toml::from_str(&back).expect("re-parse");
6984        assert_eq!(fs, fs2);
6985    }
6986
6987    #[test]
6988    fn fetch_source_rejects_unknown_license() {
6989        let src = format!(
6990            r#"
6991url     = "https://example.invalid/upstream.bin"
6992blake3  = "{HASH_64}"
6993license = "GPL-3.0"
6994"#
6995        );
6996        let res: Result<FetchSource, _> = toml::from_str(&src);
6997        assert!(res.is_err(), "expected non-permissive license to reject");
6998    }
6999
7000    #[test]
7001    fn asset_entry_derive_mode_round_trips() {
7002        let src = format!(
7003            r#"
7004
7005[[asset]]
7006filename = "whisper/distil-large-v3-q5_1.bin"
7007blake3   = "{HASH_64}"
7008
7009[asset.derive.fetch]
7010url     = "https://example.invalid/ggml-distil-large-v3.bin"
7011blake3  = "{HASH_64}"
7012license = "mit"
7013
7014[asset.derive.transform]
7015recipe = "whisper-quantize"
7016params = {{ quant = "q5_1" }}
7017"#
7018        );
7019        let w: StaticAssetWorkload = toml::from_str(&src).expect("parse");
7020        assert_eq!(w.assets.len(), 1);
7021        let entry = &w.assets[0];
7022        assert!(entry.source.is_none());
7023        let derive = entry.derive.as_ref().expect("derive present");
7024        assert_eq!(derive.fetch.url, "https://example.invalid/ggml-distil-large-v3.bin");
7025        assert_eq!(derive.fetch.license, License::Mit);
7026        let transform = derive.transform.as_ref().expect("transform present");
7027        assert_eq!(transform.recipe, "whisper-quantize");
7028        assert_eq!(transform.params.get("quant").map(String::as_str), Some("q5_1"));
7029
7030        let back = toml::to_string(&w).expect("serialize");
7031        let w2: StaticAssetWorkload = toml::from_str(&back).expect("re-parse");
7032        assert_eq!(w, w2);
7033    }
7034
7035    #[test]
7036    fn legacy_source_only_asset_serializes_without_derive_field() {
7037        // Verify the skip_serializing_if guards keep legacy TOMLs round-tripping
7038        // without ever emitting an empty `derive = ...` line.
7039        let src = format!(
7040            r#"
7041
7042[[asset]]
7043filename = "operator-curated.bin"
7044source   = "sources/operator-curated.bin"
7045blake3   = "{HASH_64}"
7046"#
7047        );
7048        let w: StaticAssetWorkload = toml::from_str(&src).expect("parse");
7049        let back = toml::to_string(&w).expect("serialize");
7050        assert!(!back.contains("derive"), "serialized output leaked a derive field: {back}");
7051        let w2: StaticAssetWorkload = toml::from_str(&back).expect("re-parse");
7052        assert_eq!(w, w2);
7053    }
7054
7055    /// W212/R518: the `[asset.derive.lock]` block round-trips through TOML, and
7056    /// is omitted from output when absent (so non-derive / unlocked assets stay
7057    /// clean).
7058    #[test]
7059    fn derive_lock_round_trips_through_toml() {
7060        let toml = r#"
7061url     = "https://example.invalid/config.json"
7062blake3  = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
7063license = "mit"
7064"#;
7065        let fetch: FetchSource = ::toml::from_str(toml).unwrap();
7066        let derive = AssetDerive {
7067            fetch,
7068            transform: Some(TransformSpec {
7069                recipe: "whisper-bundle-tar".into(),
7070                params: BTreeMap::new(),
7071            }),
7072            lock: Some(DeriveLock {
7073                input_hash: "1111111111111111111111111111111111111111111111111111111111111111".into(),
7074                output_blake3: "2222222222222222222222222222222222222222222222222222222222222222".into(),
7075            }),
7076        };
7077        let s = ::toml::to_string(&derive).unwrap();
7078        assert!(s.contains("[lock]"), "lock serialized: {s}");
7079        let back: AssetDerive = ::toml::from_str(&s).unwrap();
7080        assert_eq!(derive, back);
7081
7082        // Absent lock → no `[lock]` table in the output.
7083        let unlocked = AssetDerive { lock: None, ..derive };
7084        let s2 = ::toml::to_string(&unlocked).unwrap();
7085        assert!(!s2.contains("[lock]"), "unlocked must omit lock: {s2}");
7086    }
7087
7088    #[test]
7089    fn shape_static_asset_rejects_both_source_and_derive() {
7090        use crate::validate::{shape_static_asset, FieldPath, ShapeError};
7091
7092        let entry = AssetEntry {
7093            filename: "ambiguous.bin".into(),
7094            source: Some("sources/ambiguous.bin".into()),
7095            derive: Some(AssetDerive {
7096                fetch: FetchSource {
7097                    url: "https://example.invalid/x".into(),
7098                    blake3: BlakeHash(HASH_64.into()),
7099                    license: License::Mit,
7100                },
7101                transform: None,
7102                lock: None,
7103            }),
7104            blake3: BlakeHash(HASH_64.into()),
7105        };
7106        let w = StaticAssetWorkload {
7107            assets: vec![entry],
7108            aliases: BTreeMap::new(),
7109        };
7110        let err = shape_static_asset(&w).expect_err("XOR violated");
7111        match err {
7112            ShapeError::Field { path: FieldPath::Asset(0, "source"), .. } => {}
7113            other => panic!("expected Asset(0, \"source\") shape error, got {other:?}"),
7114        }
7115    }
7116
7117    #[test]
7118    fn shape_static_asset_rejects_neither_source_nor_derive() {
7119        use crate::validate::{shape_static_asset, FieldPath, ShapeError};
7120
7121        let entry = AssetEntry {
7122            filename: "empty.bin".into(),
7123            source: None,
7124            derive: None,
7125            blake3: BlakeHash(HASH_64.into()),
7126        };
7127        let w = StaticAssetWorkload {
7128            assets: vec![entry],
7129            aliases: BTreeMap::new(),
7130        };
7131        let err = shape_static_asset(&w).expect_err("XOR violated");
7132        match err {
7133            ShapeError::Field { path: FieldPath::Asset(0, "source"), .. } => {}
7134            other => panic!("expected Asset(0, \"source\") shape error, got {other:?}"),
7135        }
7136    }
7137
7138    #[test]
7139    fn shape_static_asset_accepts_either_mode() {
7140        use crate::validate::shape_static_asset;
7141
7142        let legacy = AssetEntry {
7143            filename: "a.bin".into(),
7144            source: Some("sources/a.bin".into()),
7145            derive: None,
7146            blake3: BlakeHash(HASH_64.into()),
7147        };
7148        let derived = AssetEntry {
7149            filename: "b.bin".into(),
7150            source: None,
7151            derive: Some(AssetDerive {
7152                fetch: FetchSource {
7153                    url: "https://example.invalid/b".into(),
7154                    blake3: BlakeHash(HASH_64.into()),
7155                    license: License::Apache2,
7156                },
7157                transform: None,
7158                lock: None,
7159            }),
7160            blake3: BlakeHash(HASH_64.into()),
7161        };
7162        let w = StaticAssetWorkload {
7163            assets: vec![legacy, derived],
7164            aliases: BTreeMap::new(),
7165        };
7166        shape_static_asset(&w).expect("both modes accepted");
7167    }
7168
7169    #[test]
7170    fn image_ref_string_form_rejects_bare_tag() {
7171        let res: Result<ImageRef, _> =
7172            serde_json::from_value(serde_json::Value::String("node:20".into()));
7173        let err = res.expect_err("bare-tag must reject");
7174        let msg = format!("{err}");
7175        assert!(msg.contains("digest"), "error should mention digest: {msg}");
7176    }
7177
7178    #[test]
7179    fn image_ref_string_form_accepts_digest_pinned() {
7180        let pinned = format!("node:20@sha256:{HASH_64}");
7181        let img: ImageRef =
7182            serde_json::from_value(serde_json::Value::String(pinned.clone())).expect("parse");
7183        assert_eq!(img.registry, "docker.io");
7184        assert_eq!(img.repository, "library/node");
7185        assert_eq!(img.tag, "20");
7186        assert_eq!(img.digest, format!("sha256:{HASH_64}"));
7187    }
7188
7189    #[test]
7190    fn image_ref_string_form_accepts_ghcr_with_pin() {
7191        let pinned = format!("ghcr.io/foo/bar:v1.7.4@sha256:{HASH_64}");
7192        let img: ImageRef =
7193            serde_json::from_value(serde_json::Value::String(pinned)).expect("parse");
7194        assert_eq!(img.registry, "ghcr.io");
7195        assert_eq!(img.repository, "foo/bar");
7196        assert_eq!(img.tag, "v1.7.4");
7197        assert!(img.digest.starts_with("sha256:"));
7198    }
7199
7200    #[test]
7201    fn image_ref_string_form_rejects_non_sha256_digest() {
7202        for bad in [
7203            "node:20@md5:abcdef",
7204            "node:20@sha1:abcdef",
7205            "node:20@sha256:",
7206            "node:20@sha256:zzznothex",
7207        ] {
7208            let res: Result<ImageRef, _> =
7209                serde_json::from_value(serde_json::Value::String(bad.into()));
7210            assert!(res.is_err(), "expected reject for {bad:?}");
7211        }
7212    }
7213
7214    #[test]
7215    fn image_ref_struct_form_rejects_missing_digest() {
7216        // Digest is now structurally required (R438-T3). Struct-form payloads
7217        // without `digest` must fail at serde-deserialize.
7218        let v = serde_json::json!({
7219            "registry": "ghcr.io",
7220            "repository": "noisetable/api",
7221            "tag": "v1.4.2",
7222        });
7223        let res: Result<ImageRef, _> = serde_json::from_value(v);
7224        assert!(res.is_err(), "missing digest must reject");
7225    }
7226
7227    #[test]
7228    fn image_ref_struct_form_round_trips_through_toml() {
7229        let img = ImageRef {
7230            registry: "ghcr.io".into(),
7231            repository: "ggerganov/whisper.cpp".into(),
7232            tag: "v1.7.4".into(),
7233            digest: format!("sha256:{HASH_64}"),
7234        };
7235        let toml_doc = toml::to_string(&img).expect("serialize");
7236        let back: ImageRef = toml::from_str(&toml_doc).expect("re-parse");
7237        assert_eq!(img, back);
7238    }
7239
7240    /// R546-B7: assert the shape real files use. This test previously fed the
7241    /// EXTERNALLY-tagged wrapping-table form (`[static-asset]` +
7242    /// `[[static-asset.asset]]`), which no on-disk `workload.toml` has ever
7243    /// used — so it stayed green while `yah cloud apply` was broken for every
7244    /// static-asset component. The flat `kind = "..."` form below is what every
7245    /// workload.toml in the workspace is written in.
7246    #[test]
7247    fn workload_envelope_dispatches_static_asset() {
7248        let src = format!(
7249            r#"
7250kind = "static-asset"
7251
7252[[asset]]
7253filename = "foo/bar.bin"
7254source   = "sources/bar.bin"
7255blake3   = "{HASH_64}"
7256"#
7257        );
7258        let w: Workload = toml::from_str(&src).expect("parse");
7259        assert!(matches!(w, Workload::StaticAsset(_)));
7260    }
7261
7262    /// R896-T4 deleted `SchemaVersion`. Manifests written before that — any
7263    /// camp that has not re-synced, e.g. `~/ss/noisetable`'s workload tomls —
7264    /// still carry the key in both spellings it used to accept, and must load
7265    /// with it ignored rather than refused.
7266    #[test]
7267    fn a_legacy_schema_version_key_is_ignored() {
7268        for line in ["schema_version = 1", r#"schema_version = "V1""#] {
7269            let src = format!(
7270                r#"
7271{line}
7272kind = "static-asset"
7273
7274[[asset]]
7275filename = "foo/bar.bin"
7276source   = "sources/bar.bin"
7277blake3   = "{HASH_64}"
7278"#
7279            );
7280            let w: Workload = toml::from_str(&src).unwrap_or_else(|e| panic!("{line}: {e}"));
7281            assert!(matches!(w, Workload::StaticAsset(_)));
7282        }
7283    }
7284
7285    /// R546-B7: the format branch, both directions. Human-readable formats get
7286    /// the flat `kind`-tagged shape; postcard keeps the externally-tagged
7287    /// variant-index encoding the kamaji UDS depends on (R590-B3). Regressing
7288    /// either side breaks a different half of the system, so pin both.
7289    #[test]
7290    fn workload_envelope_is_tagged_in_toml_and_external_in_postcard() {
7291        let src = format!(
7292            r#"
7293kind = "static-asset"
7294
7295[[asset]]
7296filename = "foo/bar.bin"
7297source   = "sources/bar.bin"
7298blake3   = "{HASH_64}"
7299"#
7300        );
7301        let w: Workload = toml::from_str(&src).expect("parse flat TOML");
7302
7303        // Human-readable round-trips stay flat — no wrapping table.
7304        let json = serde_json::to_string(&w).expect("serialize json");
7305        assert!(json.contains("\"kind\":\"static-asset\""), "got {json}");
7306        assert!(
7307            !json.contains("{\"static-asset\":"),
7308            "human-readable output must not be externally tagged: {json}"
7309        );
7310        assert_eq!(
7311            serde_json::from_str::<Workload>(&json).expect("re-parse json"),
7312            w
7313        );
7314
7315        // postcard is non-self-describing: it can only round-trip because the
7316        // binary branch never asks for deserialize_any.
7317        let bytes = postcard::to_allocvec(&w).expect("postcard encode");
7318        assert_eq!(
7319            postcard::from_bytes::<Workload>(&bytes).expect("postcard decode"),
7320            w
7321        );
7322    }
7323
7324    // ── R572-F1: lifecycle archetype discriminator ─────────────────────────
7325
7326    fn archetype_test_spec(name: &str) -> WorkloadSpec {
7327        WorkloadSpec::for_forge(
7328            name,
7329            ImageRef {
7330                registry: "ghcr.io".into(),
7331                repository: "yah/test".into(),
7332                tag: "latest".into(),
7333                digest: testing::test_digest(),
7334            },
7335            TierTag("infra".into()),
7336            vec![],
7337        )
7338    }
7339
7340    #[test]
7341    fn explicit_archetype_round_trips_through_json_and_wins_over_inference() {
7342        for archetype in [
7343            LifecycleArchetype::Server,
7344            LifecycleArchetype::Appliance,
7345            LifecycleArchetype::Job,
7346        ] {
7347            let mut spec = archetype_test_spec("explicit");
7348            // Volumes present + restart_policy Always would infer Appliance
7349            // (see effective_archetype_infers_* below) — deliberately
7350            // mismatched against every archetype under test so the
7351            // assertion actually proves the explicit field wins, not that
7352            // it happens to agree with inference.
7353            spec.volumes = vec![VolumeMount {
7354                source: VolumeSource::Named { name: "data".into() },
7355                target: PathBuf::from("/data"),
7356                read_only: false,
7357                from_secret_mount: false,
7358            }];
7359            spec.restart_policy = RestartPolicy::Always;
7360            spec.archetype = Some(archetype);
7361
7362            let json = serde_json::to_string(&spec).expect("serialize");
7363            assert!(
7364                json.contains("\"archetype\""),
7365                "explicit archetype must be present on the wire"
7366            );
7367            let back: WorkloadSpec = serde_json::from_str(&json).expect("deserialize");
7368            assert_eq!(spec, back, "spec did not survive JSON round-trip");
7369            assert_eq!(back.archetype, Some(archetype));
7370            assert_eq!(
7371                back.effective_archetype(),
7372                archetype,
7373                "explicit archetype must win over the volumes/restart_policy inference"
7374            );
7375        }
7376    }
7377
7378    #[test]
7379    fn archetype_serializes_as_null_when_none() {
7380        let mut spec = archetype_test_spec("omitted");
7381        spec.archetype = None;
7382        let json = serde_json::to_value(&spec).expect("to_value");
7383        // Postcard-native (R590-B3): no `skip_serializing_if` anywhere on the
7384        // graph, so every field is always on the wire — a None Option is an
7385        // explicit `null`, not an absent key. The binary UDS wire is positional
7386        // and requires the slot to be present.
7387        assert_eq!(json.get("archetype"), Some(&serde_json::Value::Null));
7388    }
7389
7390    #[test]
7391    fn spec_without_archetype_field_deserializes_to_none() {
7392        // Simulates an on-disk spec written before R572-F1: no `archetype`
7393        // key at all. Omitting the key must still parse to None (the additive-
7394        // default contract) even though we now always *emit* the field.
7395        let mut spec = archetype_test_spec("pre-existing");
7396        spec.archetype = None;
7397        let mut json = serde_json::to_value(&spec).expect("to_value");
7398        json.as_object_mut().unwrap().remove("archetype");
7399        let back: WorkloadSpec = serde_json::from_value(json).expect("deserialize");
7400        assert_eq!(back.archetype, None);
7401    }
7402
7403    #[test]
7404    fn a_materialized_secret_bind_does_not_make_a_workload_an_appliance() {
7405        // R854/R966: yubaba appends one of these per File secret, so a deployed
7406        // spec read back from kamaji must still report the authored archetype.
7407        let mut spec = archetype_test_spec("secret-only");
7408        spec.volumes = vec![VolumeMount {
7409            source: VolumeSource::Bind {
7410                host_path: PathBuf::from("/run/yah/secrets/secret-only/tls.key"),
7411            },
7412            target: PathBuf::from("/etc/tls.key"),
7413            read_only: true,
7414            from_secret_mount: true,
7415        }];
7416        spec.restart_policy = RestartPolicy::Always;
7417        spec.archetype = None;
7418        assert_eq!(spec.effective_archetype(), LifecycleArchetype::Server);
7419    }
7420
7421    #[test]
7422    fn effective_archetype_infers_appliance_from_volumes_when_field_absent() {
7423        // Pre-R572 behavior: a workload with a volume was understood (by
7424        // convention, never a type) to be stateful/pinned. Confirm that
7425        // meaning is preserved bit-for-bit through effective_archetype().
7426        let mut spec = archetype_test_spec("appliance-inferred");
7427        spec.volumes = vec![VolumeMount {
7428            source: VolumeSource::Named { name: "pgdata".into() },
7429            target: PathBuf::from("/var/lib/postgresql/data"),
7430            read_only: false,
7431            from_secret_mount: false,
7432        }];
7433        spec.restart_policy = RestartPolicy::Always;
7434        spec.archetype = None;
7435        assert_eq!(spec.effective_archetype(), LifecycleArchetype::Appliance);
7436    }
7437
7438    #[test]
7439    fn effective_archetype_infers_job_from_restart_never_when_field_absent() {
7440        // Pre-R572 behavior: RestartPolicy::Never + no volumes is the forge
7441        // run-once convention (see RestartPolicy::Never's own doc comment) —
7442        // structurally a job. WorkloadSpec::for_forge already produces
7443        // exactly this shape; isolate the pure-inference path by clearing
7444        // the explicit archetype for_forge now sets.
7445        let mut spec = archetype_test_spec("job-inferred");
7446        assert!(spec.volumes.is_empty());
7447        assert!(matches!(spec.restart_policy, RestartPolicy::Never));
7448        spec.archetype = None;
7449        assert_eq!(spec.effective_archetype(), LifecycleArchetype::Job);
7450    }
7451
7452    #[test]
7453    fn effective_archetype_defaults_to_server_as_the_common_case_when_field_absent() {
7454        // Pre-R572 behavior: no volumes + a restartable policy (the common
7455        // stateless-web-server shape) inferred as movable/fungible.
7456        let mut spec = archetype_test_spec("server-inferred");
7457        spec.restart_policy = RestartPolicy::Always;
7458        spec.archetype = None;
7459        assert_eq!(spec.effective_archetype(), LifecycleArchetype::Server);
7460    }
7461
7462    // ── R860-T1 / W338: requirement vocabulary ──────────────────────────────
7463
7464    /// An ordinary `anywhere` + `wait` requirement — what a `depends_on` entry
7465    /// has always meant, written the long way.
7466    fn wait_requirement(ident: &str) -> Requirement {
7467        Requirement {
7468            ident: MeshIdent(ident.into()),
7469            locality: Locality::Anywhere,
7470            supply: Supply::Wait,
7471            provides: None,
7472        }
7473    }
7474
7475    /// A `local` + `self` requirement carrying its provider — the sidecar
7476    /// shape, W338's motivating case. `ident` must be the provider's own mesh
7477    /// identity, which `archetype_test_spec` spells `forge.<name>`.
7478    fn self_requirement(provider_name: &str) -> Requirement {
7479        let provider = archetype_test_spec(provider_name);
7480        Requirement {
7481            ident: provider.expose.mesh.identity.clone(),
7482            locality: Locality::Local,
7483            supply: Supply::SelfProvision,
7484            provides: Some(Box::new(provider)),
7485        }
7486    }
7487
7488    #[test]
7489    fn locality_and_supply_use_the_wire_spellings_the_design_names() {
7490        // The TOML in W338 is written against these strings; a rename here is a
7491        // silent break of every manifest on disk. `self` in particular cannot
7492        // be the variant name (Rust keyword), so it is a serde rename and needs
7493        // guarding rather than trusting rename_all.
7494        assert_eq!(
7495            serde_json::to_string(&Locality::Anywhere).unwrap(),
7496            "\"anywhere\""
7497        );
7498        assert_eq!(
7499            serde_json::to_string(&Locality::PreferLocal).unwrap(),
7500            "\"prefer-local\""
7501        );
7502        assert_eq!(serde_json::to_string(&Locality::Local).unwrap(), "\"local\"");
7503        assert_eq!(serde_json::to_string(&Supply::Wait).unwrap(), "\"wait\"");
7504        assert_eq!(
7505            serde_json::to_string(&Supply::SelfProvision).unwrap(),
7506            "\"self\""
7507        );
7508
7509        assert_eq!(
7510            serde_json::from_str::<Supply>("\"self\"").unwrap(),
7511            Supply::SelfProvision
7512        );
7513        assert_eq!(
7514            serde_json::from_str::<Locality>("\"prefer-local\"").unwrap(),
7515            Locality::PreferLocal
7516        );
7517    }
7518
7519    #[test]
7520    fn a_requirement_omitting_locality_and_supply_defaults_to_the_depends_on_meaning() {
7521        // Folding `depends_on` into `requires` must not change any existing
7522        // spec's meaning, which is only true if the defaults are exactly the
7523        // old behaviour.
7524        let req: Requirement =
7525            serde_json::from_str(r#"{"ident":"headscale-db"}"#).expect("bare ident must parse");
7526        assert_eq!(req.locality, Locality::Anywhere);
7527        assert_eq!(req.supply, Supply::Wait);
7528        assert_eq!(req.provides, None);
7529    }
7530
7531    #[test]
7532    fn a_self_provisioned_requirement_round_trips_its_nested_provider_spec() {
7533        // `provides` makes WorkloadSpec recursive. Confirm the box survives a
7534        // JSON round trip rather than trusting the derive.
7535        let mut spec = archetype_test_spec("headscale");
7536        spec.requires = vec![self_requirement("replicator")];
7537
7538        let json = serde_json::to_string(&spec).expect("serialize");
7539        let back: WorkloadSpec = serde_json::from_str(&json).expect("deserialize");
7540        assert_eq!(back, spec);
7541
7542        let provided = back.requires[0]
7543            .provides
7544            .as_ref()
7545            .expect("the nested provider spec must survive the round trip");
7546        assert_eq!(provided.expose.mesh.identity, back.requires[0].ident);
7547    }
7548
7549    #[test]
7550    fn effective_requirements_returns_requires_verbatim_when_depends_on_is_empty() {
7551        let mut spec = archetype_test_spec("requires-only");
7552        spec.depends_on = vec![];
7553        spec.requires = vec![
7554            Requirement {
7555                ident: MeshIdent("headscale-db".into()),
7556                locality: Locality::PreferLocal,
7557                supply: Supply::Wait,
7558                provides: None,
7559            },
7560            self_requirement("replicator"),
7561        ];
7562
7563        assert_eq!(spec.effective_requirements(), spec.requires);
7564    }
7565
7566    #[test]
7567    fn effective_requirements_folds_depends_on_into_anywhere_wait() {
7568        // The back-compat projection: a pre-R860 spec carries everything in
7569        // `depends_on`, and reading `requires` alone would call it
7570        // requirement-free.
7571        let mut spec = archetype_test_spec("depends-on-only");
7572        spec.depends_on = vec![MeshIdent("noisetable-db".into()), MeshIdent("redis".into())];
7573        spec.requires = vec![];
7574
7575        assert_eq!(
7576            spec.effective_requirements(),
7577            vec![
7578                wait_requirement("noisetable-db"),
7579                wait_requirement("redis"),
7580            ]
7581        );
7582    }
7583
7584    #[test]
7585    fn effective_requirements_dedups_by_ident_and_requires_wins() {
7586        // An ident in both fields is the author restating one dependency with a
7587        // locality, not two edges — so the richer entry survives and the folded
7588        // `depends_on` projection is dropped, order following `requires` first.
7589        let mut spec = archetype_test_spec("overlap");
7590        spec.depends_on = vec![
7591            MeshIdent("headscale-db".into()),
7592            MeshIdent("only-in-depends-on".into()),
7593        ];
7594        spec.requires = vec![Requirement {
7595            ident: MeshIdent("headscale-db".into()),
7596            locality: Locality::Local,
7597            supply: Supply::Wait,
7598            provides: None,
7599        }];
7600
7601        let effective = spec.effective_requirements();
7602        assert_eq!(
7603            effective,
7604            vec![
7605                Requirement {
7606                    ident: MeshIdent("headscale-db".into()),
7607                    locality: Locality::Local,
7608                    supply: Supply::Wait,
7609                    provides: None,
7610                },
7611                wait_requirement("only-in-depends-on"),
7612            ],
7613            "the `requires` entry must win and the ident must appear exactly once"
7614        );
7615    }
7616
7617    #[test]
7618    fn effective_requirements_is_empty_when_neither_field_is_set() {
7619        let mut spec = archetype_test_spec("neither");
7620        spec.depends_on = vec![];
7621        spec.requires = vec![];
7622        assert!(spec.effective_requirements().is_empty());
7623    }
7624
7625    // ── R860-T1: `requires` shape validation ────────────────────────────────
7626
7627    /// Assert `shape` rejects `spec` with an error naming `requires[0]` and
7628    /// mentioning `needle`, so a failure points at the rule that fired.
7629    fn assert_requires_rejected(spec: &WorkloadSpec, needle: &str) {
7630        let err = validate::shape(spec).expect_err("shape must reject this spec");
7631        let rendered = err.to_string();
7632        assert!(
7633            rendered.contains("requires[0]"),
7634            "error must name the offending requirement; got: {rendered}"
7635        );
7636        assert!(
7637            rendered.contains(needle),
7638            "error must explain the rule ({needle:?}); got: {rendered}"
7639        );
7640    }
7641
7642    #[test]
7643    fn a_valid_requires_list_passes_shape_validation() {
7644        let mut spec = archetype_test_spec("valid-requires");
7645        spec.requires = vec![
7646            wait_requirement("headscale-db"),
7647            self_requirement("replicator"),
7648        ];
7649        validate::shape(&spec).expect("a well-formed requires list must pass");
7650    }
7651
7652    #[test]
7653    fn self_supply_without_a_provides_spec_is_rejected() {
7654        let mut spec = archetype_test_spec("self-without-provides");
7655        spec.requires = vec![Requirement {
7656            ident: MeshIdent("replicator".into()),
7657            locality: Locality::Local,
7658            supply: Supply::SelfProvision,
7659            provides: None,
7660        }];
7661        assert_requires_rejected(&spec, "no `provides` spec");
7662    }
7663
7664    #[test]
7665    fn wait_supply_carrying_a_provides_spec_is_rejected() {
7666        // The other direction matters just as much: a spec attached to a
7667        // `wait` requirement has no owner, so nothing would ever deploy it and
7668        // the author's intent is silently lost.
7669        let mut spec = archetype_test_spec("wait-with-provides");
7670        let mut req = self_requirement("replicator");
7671        req.supply = Supply::Wait;
7672        spec.requires = vec![req];
7673        assert_requires_rejected(&spec, "would have no owner");
7674    }
7675
7676    #[test]
7677    fn a_provides_spec_naming_a_different_identity_is_rejected() {
7678        // Each member keeps its own mesh identity (W338), and it has to be the
7679        // identity the edge points at — otherwise the provider is not the thing
7680        // the requirer asked for and is not discoverable as it.
7681        let mut spec = archetype_test_spec("identity-mismatch");
7682        let mut req = self_requirement("replicator");
7683        req.ident = MeshIdent("something-else".into());
7684        spec.requires = vec![req];
7685        assert_requires_rejected(&spec, "the provider keeps its own mesh identity");
7686    }
7687
7688    #[test]
7689    fn a_provides_spec_that_itself_self_provisions_is_rejected() {
7690        // The depth bound. Without it, `provides` is an arbitrarily deep tree
7691        // that placement would have to flatten before scheduling anything.
7692        let mut spec = archetype_test_spec("too-deep");
7693        let mut req = self_requirement("replicator");
7694        req.provides
7695            .as_mut()
7696            .expect("self_requirement always carries a provider")
7697            .requires = vec![self_requirement("replicator-of-the-replicator")];
7698        spec.requires = vec![req];
7699        assert_requires_rejected(&spec, "bounded at one");
7700    }
7701
7702    #[test]
7703    fn a_provides_spec_that_only_waits_is_accepted_at_depth_one() {
7704        // The bound is on `self` supply, not on nesting a `requires` list at
7705        // all — a provider may still name things it does not deploy.
7706        let mut spec = archetype_test_spec("nested-wait-ok");
7707        let mut req = self_requirement("replicator");
7708        req.provides
7709            .as_mut()
7710            .expect("self_requirement always carries a provider")
7711            .requires = vec![wait_requirement("object-storage")];
7712        spec.requires = vec![req];
7713        validate::shape(&spec).expect("a nested `wait` requirement is within the depth bound");
7714    }
7715
7716    #[test]
7717    fn a_repeated_requirement_ident_is_rejected() {
7718        let mut spec = archetype_test_spec("repeated-ident");
7719        spec.requires = vec![
7720            wait_requirement("headscale-db"),
7721            Requirement {
7722                ident: MeshIdent("headscale-db".into()),
7723                locality: Locality::Local,
7724                supply: Supply::Wait,
7725                provides: None,
7726            },
7727        ];
7728        let err = validate::shape(&spec)
7729            .expect_err("a duplicate ident must be rejected")
7730            .to_string();
7731        assert!(err.contains("requires[1]"), "got: {err}");
7732        assert!(err.contains("declared twice"), "got: {err}");
7733    }
7734
7735    #[test]
7736    fn a_requirement_naming_the_spec_itself_is_rejected() {
7737        let mut spec = archetype_test_spec("self-naming");
7738        spec.requires = vec![wait_requirement(&spec.expose.mesh.identity.0.clone())];
7739        assert_requires_rejected(&spec, "cannot be its own provider");
7740    }
7741
7742    // ── R594-F2: public-ingress appliance (container-shaped, not a new
7743    // Workload variant — see Workload::Container's doc comment) ───────────
7744
7745    #[test]
7746    fn ingress_marked_spec_is_appliance_and_carries_public_ip_placement_requirement() {
7747        let mut spec = archetype_test_spec("public-ingress");
7748        spec.archetype = Some(LifecycleArchetype::Appliance);
7749        spec.annotations.insert(
7750            REQUIRES_TAINT_ANNOTATION.to_string(),
7751            PUBLIC_IP_TAINT.to_string(),
7752        );
7753
7754        assert_eq!(
7755            spec.effective_archetype(),
7756            LifecycleArchetype::Appliance,
7757            "ingress must be pinned-per-node/non-drainable, the R572 appliance sense"
7758        );
7759        assert_eq!(
7760            spec.requires_taint(),
7761            Some(PUBLIC_IP_TAINT),
7762            "ingress must declare it can only land on a public-ip-tainted node"
7763        );
7764
7765        // No taint exists to match against yet (R572-F3) and nothing
7766        // enforces placement yet (R572-F5) — confirm this ticket stays
7767        // declarative-only by checking a spec with no requirement stays
7768        // unaffected.
7769        let unrelated = archetype_test_spec("unrelated");
7770        assert_eq!(unrelated.requires_taint(), None);
7771    }
7772
7773    #[test]
7774    fn ingress_marked_spec_round_trips_through_json_as_a_container_workload() {
7775        // Mirrors the on-disk envelope: the externally-tagged `container`
7776        // variant wrapping the WorkloadSpec, exactly like every other
7777        // container-shaped workload. No new Workload variant, no new
7778        // discriminator.
7779        let mut inner = archetype_test_spec("public-ingress");
7780        inner.archetype = Some(LifecycleArchetype::Appliance);
7781        inner.annotations.insert(
7782            REQUIRES_TAINT_ANNOTATION.to_string(),
7783            PUBLIC_IP_TAINT.to_string(),
7784        );
7785        let workload = Workload::container(inner.clone());
7786
7787        let json = serde_json::to_string(&workload).expect("serialize");
7788        assert!(json.contains("\"container\""));
7789        assert!(json.contains(REQUIRES_TAINT_ANNOTATION));
7790        assert!(json.contains(PUBLIC_IP_TAINT));
7791
7792        let back: Workload = serde_json::from_str(&json).expect("deserialize");
7793        match back.container_spec() {
7794            Some(spec) => {
7795                assert_eq!(spec, &inner);
7796                assert_eq!(spec.effective_archetype(), LifecycleArchetype::Appliance);
7797                assert_eq!(spec.requires_taint(), Some(PUBLIC_IP_TAINT));
7798            }
7799            None => panic!("expected a container reference workload, got {back:?}"),
7800        }
7801    }
7802
7803    // ── Nested-sandbox grant (R636-B2) ──────────────────────────────────────
7804
7805    #[test]
7806    fn nested_sandbox_marker_is_opt_in_and_reads_back() {
7807        // The half that matters: no workload gets the grant by default, so
7808        // adding the marker cannot widen anything already deployed.
7809        let plain = archetype_test_spec("ordinary-build");
7810        assert!(!plain.wants_nested_sandbox());
7811
7812        let mut buildkit = archetype_test_spec("build-image");
7813        buildkit.annotations.insert(
7814            NESTED_SANDBOX_ANNOTATION.to_string(),
7815            NESTED_SANDBOX_VALUE.to_string(),
7816        );
7817        assert!(buildkit.wants_nested_sandbox());
7818
7819        // Fails closed on any other value, same strictness as
7820        // `wants_host_network` — a typo must not hand out CAP_SETUID.
7821        let mut typo = archetype_test_spec("typo");
7822        typo.annotations
7823            .insert(NESTED_SANDBOX_ANNOTATION.to_string(), "Nested".to_string());
7824        assert!(!typo.wants_nested_sandbox());
7825    }
7826
7827    /// The three markers are independent axes: asking for host networking or
7828    /// native exec must not imply the capability grant, and vice versa.
7829    #[test]
7830    fn nested_sandbox_marker_is_independent_of_the_other_markers() {
7831        let mut host_net = archetype_test_spec("host-net");
7832        host_net.annotations.insert(
7833            HOST_NETWORK_ANNOTATION.to_string(),
7834            HOST_NETWORK_VALUE.to_string(),
7835        );
7836        assert!(host_net.wants_host_network());
7837        assert!(!host_net.wants_nested_sandbox());
7838
7839        let mut nested = archetype_test_spec("nested");
7840        nested.annotations.insert(
7841            NESTED_SANDBOX_ANNOTATION.to_string(),
7842            NESTED_SANDBOX_VALUE.to_string(),
7843        );
7844        assert!(nested.wants_nested_sandbox());
7845        assert!(!nested.wants_host_network());
7846        assert!(!nested.wants_native_exec());
7847    }
7848
7849    // ── Native exec marker (R577-T1 / W254) ─────────────────────────────────
7850
7851    #[test]
7852    fn native_exec_marker_is_opt_in_and_reads_back() {
7853        // Default: every forge workload is a container workload. This is the
7854        // half that matters most — the marker must not silently reroute the
7855        // Linux offload leg proven live on us-west-002.
7856        let plain = archetype_test_spec("linux-build");
7857        assert!(!plain.wants_native_exec());
7858
7859        let mut native = archetype_test_spec("darwin-build");
7860        native.annotations.insert(
7861            NATIVE_EXEC_ANNOTATION.to_string(),
7862            NATIVE_EXEC_VALUE.to_string(),
7863        );
7864        assert!(native.wants_native_exec());
7865
7866        // Any other value is not the opt-in — same strictness as
7867        // `wants_host_network`, so a typo fails closed onto the container
7868        // backend rather than escaping the sandbox.
7869        let mut typo = archetype_test_spec("typo");
7870        typo.annotations
7871            .insert(NATIVE_EXEC_ANNOTATION.to_string(), "Native".to_string());
7872        assert!(!typo.wants_native_exec());
7873    }
7874
7875    #[test]
7876    fn native_marked_spec_round_trips_through_json_as_a_container_workload() {
7877        // The point of the annotation shape: a native workload is still a
7878        // `Workload::Container` on the wire, so kamaji-proto's codec, yubaba
7879        // admission and the mesh-assignment path need no new variant.
7880        let mut inner = archetype_test_spec("darwin-build");
7881        inner.annotations.insert(
7882            NATIVE_EXEC_ANNOTATION.to_string(),
7883            NATIVE_EXEC_VALUE.to_string(),
7884        );
7885        let workload = Workload::container(inner.clone());
7886
7887        let json = serde_json::to_string(&workload).expect("serialize");
7888        assert!(json.contains(NATIVE_EXEC_ANNOTATION));
7889
7890        let back: Workload = serde_json::from_str(&json).expect("deserialize");
7891        match back.container_spec() {
7892            Some(spec) => {
7893                assert_eq!(spec, &inner);
7894                assert!(spec.wants_native_exec());
7895            }
7896            None => panic!("expected a container reference workload, got {back:?}"),
7897        }
7898    }
7899
7900    // ── MicroVM marker (R605-F8 / W325 §5) ──────────────────────────────────
7901
7902    #[test]
7903    fn microvm_marker_is_opt_in_and_reads_back() {
7904        let plain = archetype_test_spec("linux-build");
7905        assert!(!plain.wants_microvm());
7906
7907        let mut vm = archetype_test_spec("isolated-build");
7908        vm.annotations.insert(
7909            NATIVE_EXEC_ANNOTATION.to_string(),
7910            MICROVM_EXEC_VALUE.to_string(),
7911        );
7912        assert!(vm.wants_microvm());
7913
7914        // Fails closed onto the container backend, like every other marker: a
7915        // typo must not be read as "boot a VM", because the deploy that would
7916        // then be refused for lack of a microVM backend is a *worse* failure
7917        // than the container run the author actually spelled.
7918        let mut typo = archetype_test_spec("typo");
7919        typo.annotations
7920            .insert(NATIVE_EXEC_ANNOTATION.to_string(), "MicroVM".to_string());
7921        assert!(!typo.wants_microvm());
7922        assert!(!typo.wants_native_exec());
7923    }
7924
7925    #[test]
7926    fn exec_substrate_markers_are_mutually_exclusive_by_construction() {
7927        // This is the property that buys R605-F8 out of a refusal branch: the
7928        // three substrates share one annotation key, so no spec can ask for two
7929        // of them. Pinned because a later "let's give microVM its own key"
7930        // refactor would silently re-open the incoherent-pair case that
7931        // `yah.sandbox` + `yah.exec = native` still has to be refused for.
7932        assert_eq!(
7933            NATIVE_EXEC_ANNOTATION, NATIVE_EXEC_ANNOTATION,
7934            "both substrate values must live on the same key"
7935        );
7936        assert_ne!(NATIVE_EXEC_VALUE, MICROVM_EXEC_VALUE);
7937
7938        for value in [NATIVE_EXEC_VALUE, MICROVM_EXEC_VALUE, "", "container"] {
7939            let mut spec = archetype_test_spec("substrate");
7940            spec.annotations
7941                .insert(NATIVE_EXEC_ANNOTATION.to_string(), value.to_string());
7942            assert!(
7943                !(spec.wants_native_exec() && spec.wants_microvm()),
7944                "yah.exec={value:?} selected two substrates at once"
7945            );
7946        }
7947    }
7948
7949    // ─── R894-F1: trust as a declared axis ───────────────────────────────────
7950
7951    /// The security ordering is the derived `Ord`, so it has to be pinned
7952    /// explicitly: a reorder of the variants would silently invert the whole
7953    /// trust check in `cloud::config::check_trust_substrate` while compiling
7954    /// cleanly.
7955    #[test]
7956    fn substrates_are_ordered_by_the_isolation_they_provide() {
7957        assert!(ExecSubstrate::Native < ExecSubstrate::Container);
7958        assert!(ExecSubstrate::Container < ExecSubstrate::MicroVm);
7959
7960        assert_eq!(
7961            TrustLevel::Trusted.minimum_substrate(),
7962            ExecSubstrate::Native
7963        );
7964        assert_eq!(
7965            TrustLevel::Untrusted.minimum_substrate(),
7966            ExecSubstrate::MicroVm
7967        );
7968        // Trusted's floor is the bottom of the ordering — i.e. no constraint,
7969        // which is what every workload in the fleet has today.
7970        for s in [
7971            ExecSubstrate::Native,
7972            ExecSubstrate::Container,
7973            ExecSubstrate::MicroVm,
7974        ] {
7975            assert!(s >= TrustLevel::Trusted.minimum_substrate());
7976        }
7977    }
7978
7979    /// `exec_substrate` must agree with the two booleans it replaces on every
7980    /// value, including the unrecognised one — that equivalence is what lets
7981    /// `GrantRuntime::of_spec` delegate to it.
7982    #[test]
7983    fn exec_substrate_agrees_with_the_two_predicates_it_replaces() {
7984        for (value, expected) in [
7985            (None, ExecSubstrate::Container),
7986            (Some(NATIVE_EXEC_VALUE), ExecSubstrate::Native),
7987            (Some(MICROVM_EXEC_VALUE), ExecSubstrate::MicroVm),
7988            // A typo fails CLOSED: it reads as a container, which an untrusted
7989            // spec is refused for, rather than as the microVM it meant.
7990            (Some("micro-vm"), ExecSubstrate::Container),
7991            (Some(""), ExecSubstrate::Container),
7992        ] {
7993            let mut spec = archetype_test_spec("substrate");
7994            if let Some(v) = value {
7995                spec.annotations
7996                    .insert(NATIVE_EXEC_ANNOTATION.to_string(), v.to_string());
7997            }
7998            assert_eq!(spec.exec_substrate(), expected, "yah.exec={value:?}");
7999            assert_eq!(
8000                spec.wants_native_exec(),
8001                expected == ExecSubstrate::Native,
8002                "yah.exec={value:?}"
8003            );
8004            assert_eq!(
8005                spec.wants_microvm(),
8006                expected == ExecSubstrate::MicroVm,
8007                "yah.exec={value:?}"
8008            );
8009        }
8010    }
8011
8012    /// Absent means trusted (the fleet's population today); an unrecognised
8013    /// value is an error rather than a silent fallback to either side.
8014    #[test]
8015    fn trust_defaults_to_trusted_and_refuses_anything_it_cannot_read() {
8016        let spec = archetype_test_spec("trust");
8017        assert_eq!(spec.trust().unwrap(), TrustLevel::Trusted);
8018
8019        for (value, expected) in [
8020            (TRUST_TRUSTED_VALUE, TrustLevel::Trusted),
8021            (TRUST_UNTRUSTED_VALUE, TrustLevel::Untrusted),
8022            // Whitespace around the value is trimmed, not refused.
8023            ("  untrusted ", TrustLevel::Untrusted),
8024        ] {
8025            let mut spec = archetype_test_spec("trust");
8026            spec.annotations
8027                .insert(TRUST_ANNOTATION.to_string(), value.to_string());
8028            assert_eq!(spec.trust().unwrap(), expected, "yah.trust={value:?}");
8029        }
8030
8031        for bad in ["untrused", "UNTRUSTED", "yes", ""] {
8032            let mut spec = archetype_test_spec("trust");
8033            spec.annotations
8034                .insert(TRUST_ANNOTATION.to_string(), bad.to_string());
8035            let err = spec.trust().unwrap_err();
8036            assert_eq!(err, TrustDeclError::UnknownLevel(bad.trim().to_string()));
8037            assert!(
8038                err.to_string().contains(bad.trim()),
8039                "the error must quote the unreadable value"
8040            );
8041        }
8042    }
8043
8044    /// The choke-point verb stamps *and* raises, is idempotent, and never
8045    /// lowers a substrate a caller already asked for.
8046    #[test]
8047    fn stamp_untrusted_raises_the_substrate_and_never_lowers_it() {
8048        // No marker (container) → raised to microvm.
8049        let mut spec = archetype_test_spec("tenant");
8050        spec.stamp_untrusted();
8051        assert_eq!(spec.trust().unwrap(), TrustLevel::Untrusted);
8052        assert_eq!(spec.exec_substrate(), ExecSubstrate::MicroVm);
8053
8054        // Idempotent: stamping twice changes nothing.
8055        let once = spec.annotations.clone();
8056        spec.stamp_untrusted();
8057        assert_eq!(spec.annotations, once);
8058
8059        // `native` is a WIDENING when raised — the safe direction — and it
8060        // happens here rather than being left for admission to refuse.
8061        let mut native = archetype_test_spec("tenant");
8062        native.annotations.insert(
8063            NATIVE_EXEC_ANNOTATION.to_string(),
8064            NATIVE_EXEC_VALUE.to_string(),
8065        );
8066        native.stamp_untrusted();
8067        assert_eq!(native.exec_substrate(), ExecSubstrate::MicroVm);
8068
8069        // A caller that already asked for at least the floor keeps its request.
8070        let mut vm = archetype_test_spec("tenant");
8071        vm.annotations.insert(
8072            NATIVE_EXEC_ANNOTATION.to_string(),
8073            MICROVM_EXEC_VALUE.to_string(),
8074        );
8075        vm.stamp_untrusted();
8076        assert_eq!(vm.exec_substrate(), ExecSubstrate::MicroVm);
8077
8078        // And the post-condition the whole axis rests on: a stamped spec always
8079        // satisfies its own floor, so the choke point cannot emit a spec that
8080        // admission will refuse.
8081        for spec in [&spec, &native, &vm] {
8082            let trust = spec.trust().unwrap();
8083            assert!(spec.exec_substrate() >= trust.minimum_substrate());
8084        }
8085    }
8086
8087    #[test]
8088    fn microvm_marked_spec_round_trips_through_json_as_a_container_workload() {
8089        // Same zero-blast-radius claim as the native case: a microVM workload
8090        // is still `Workload::Container` on the wire, so kamaji-proto's codec
8091        // gains no variant and its positional postcard encoding does not move.
8092        let mut inner = archetype_test_spec("isolated-build");
8093        inner.annotations.insert(
8094            NATIVE_EXEC_ANNOTATION.to_string(),
8095            MICROVM_EXEC_VALUE.to_string(),
8096        );
8097        let workload = Workload::container(inner.clone());
8098
8099        let json = serde_json::to_string(&workload).expect("serialize");
8100        assert!(json.contains(MICROVM_EXEC_VALUE));
8101
8102        let back: Workload = serde_json::from_str(&json).expect("deserialize");
8103        match back.container_spec() {
8104            Some(spec) => {
8105                assert_eq!(spec, &inner);
8106                assert!(spec.wants_microvm());
8107                assert!(!spec.wants_native_exec());
8108            }
8109            None => panic!("expected a container reference workload, got {back:?}"),
8110        }
8111    }
8112
8113    // ── R885: resource ceilings ───────────────────────────────────────────────
8114
8115    /// R885-B5 / W344 Finding 5. The default has to be "no ceiling": every spec
8116    /// in the tree predates the ceiling, and reading the request as a ceiling
8117    /// is exactly the bug that throttled four live workloads to a quarter core
8118    /// apiece on us-east-001.
8119    #[test]
8120    fn a_spec_that_declares_no_cpu_ceiling_has_none() {
8121        let spec = archetype_test_spec("uncapped");
8122        assert!(spec.resources.cpu_millis > 0, "premise: it has a request");
8123        assert_eq!(spec.cpu_limit_millis(), None);
8124    }
8125
8126    #[test]
8127    fn a_declared_cpu_ceiling_is_read_independently_of_the_request() {
8128        let mut spec = archetype_test_spec("capped");
8129        spec.resources.cpu_limit_millis = Some(2000);
8130        assert_eq!(spec.cpu_limit_millis(), Some(2000));
8131        // Two independent numbers: the ceiling is not derived from the request,
8132        // and reading one must not move the other.
8133        assert_ne!(spec.cpu_limit_millis(), Some(spec.resources.cpu_millis));
8134    }
8135
8136    /// An explicit `0` means "no ceiling" rather than "cap it at nothing".
8137    /// Capping a workload at zero CPU is the worse failure of the two.
8138    #[test]
8139    fn a_zero_cpu_ceiling_is_no_ceiling() {
8140        let mut spec = archetype_test_spec("odd");
8141        spec.resources.cpu_limit_millis = Some(0);
8142        assert_eq!(spec.cpu_limit_millis(), None);
8143    }
8144
8145    /// R885-T2 / W344 Finding 3. Opposite default direction from the CPU
8146    /// ceiling above: absent must fall back to a real bound, not to "no
8147    /// ceiling" — an unbounded `pids.max` is exactly the fork-bomb bug this
8148    /// ticket exists to close.
8149    #[test]
8150    fn a_spec_that_declares_no_pids_limit_gets_the_default() {
8151        let spec = archetype_test_spec("uncapped-pids");
8152        assert_eq!(spec.pids_limit(), DEFAULT_PIDS_MAX);
8153    }
8154
8155    #[test]
8156    fn a_declared_pids_limit_overrides_the_default() {
8157        let mut spec = archetype_test_spec("capped-pids");
8158        spec.resources.pids_max = Some(256);
8159        assert_eq!(spec.pids_limit(), 256);
8160    }
8161
8162    /// Unlike the CPU ceiling, a `0` falls back to the default bound, not to
8163    /// "no ceiling".
8164    #[test]
8165    fn a_zero_pids_limit_falls_back_to_the_default() {
8166        let mut spec = archetype_test_spec("odd-pids");
8167        spec.resources.pids_max = Some(0);
8168        assert_eq!(spec.pids_limit(), DEFAULT_PIDS_MAX);
8169    }
8170
8171    // ── R896-F3: the retired annotations ─────────────────────────────────────
8172
8173    /// Every key the fields replaced maps to the field that replaced it, and
8174    /// the annotations that stayed annotations do not.
8175    #[test]
8176    fn every_retired_annotation_names_its_replacement_and_the_markers_are_not_retired() {
8177        for (key, field) in [
8178            ("yah.placement.memory-request-mb", "resources.memory_request_mb"),
8179            ("yah.limits.cpu-millis", "resources.cpu_limit_millis"),
8180            ("yah.limits.pids-max", "resources.pids_max"),
8181            ("yah.limits.scratch-floor-mb", "resources.scratch_floor_mb"),
8182            ("yah.durability.tier", "durability.tier"),
8183            ("yah.durability.rpo-seconds", "durability.rpo_seconds"),
8184            // A typo under a retired prefix is still an attempt at the family.
8185            ("yah.durability.teir", "durability"),
8186            ("yah.limits.memory", "resources"),
8187        ] {
8188            assert_eq!(retired_annotation_field(key), Some(field), "{key}");
8189        }
8190        for key in [
8191            "yah.exec",
8192            "yah.sandbox",
8193            "yah.forge",
8194            "yah.placement.requires-taint",
8195            "yah.writable-paths",
8196        ] {
8197            assert_eq!(retired_annotation_field(key), None, "{key}");
8198        }
8199    }
8200
8201    /// The backup-loss hazard R896-F3 was filed around: a spec still written
8202    /// against the annotation family must not read as "undeclared", even when
8203    /// it also carries a valid typed declaration.
8204    #[test]
8205    fn a_durability_annotation_is_refused_rather_than_read_as_undeclared() {
8206        let mut spec = archetype_test_spec("legacy");
8207        spec.annotations
8208            .insert("yah.durability.tier".into(), "stream".into());
8209        let err = spec.durability().unwrap_err();
8210        assert_eq!(
8211            err,
8212            DurabilityDeclError::RetiredAnnotation {
8213                key: "yah.durability.tier".into(),
8214                field: "durability.tier",
8215            }
8216        );
8217        assert!(err.to_string().contains("no backup"), "{err}");
8218
8219        spec.durability = Some(stream(&["a.db"]));
8220        assert!(matches!(
8221            spec.durability(),
8222            Err(DurabilityDeclError::RetiredAnnotation { .. })
8223        ));
8224        assert_eq!(spec.retired_annotation(), Some(("yah.durability.tier", "durability.tier")));
8225    }
8226
8227    // ── R850-P4: durability declaration ──────────────────────────────────────
8228
8229    fn stream(subjects: &[&str]) -> Durability {
8230        Durability {
8231            tier: DurabilityTier::Stream,
8232            engine: Some(DurabilityEngine::Turso),
8233            store: Some("s3://backups/db".into()),
8234            subjects: subjects.iter().map(|s| s.to_string()).collect(),
8235            rpo_seconds: None,
8236            state_mb: None,
8237        }
8238    }
8239
8240    fn none_tier() -> Durability {
8241        Durability {
8242            tier: DurabilityTier::None,
8243            engine: None,
8244            store: None,
8245            subjects: vec![],
8246            rpo_seconds: None,
8247            state_mb: None,
8248        }
8249    }
8250
8251    fn check(d: Durability) -> Result<(), DurabilityDeclError> {
8252        d.check()
8253    }
8254
8255    /// The distinction the whole surface rests on. Every spec in the tree
8256    /// predates the declaration, so `None` has to keep meaning "nobody said" —
8257    /// and a workload that says `tier = "none"` has to be distinguishable from
8258    /// one that never considered the question, because only one of those is a
8259    /// finding.
8260    #[test]
8261    fn an_absent_declaration_and_a_declared_none_are_different_answers() {
8262        let mut spec = archetype_test_spec("durable");
8263        assert_eq!(spec.durability().unwrap(), None);
8264
8265        spec.durability = Some(none_tier());
8266        let declared = spec
8267            .durability()
8268            .unwrap()
8269            .expect("tier = none is a declaration");
8270        assert_eq!(declared.tier, DurabilityTier::None);
8271        assert_eq!(declared.store, None);
8272    }
8273
8274    /// The authoring shape, parsed the way a workload.toml/JSON spec is: a
8275    /// table with snake_case keys and real numbers and lists — no more
8276    /// comma-joined strings.
8277    #[test]
8278    fn a_stream_tier_carries_its_store_rpo_and_state_size() {
8279        let d: Durability = serde_json::from_value(serde_json::json!({
8280            "tier": "stream",
8281            "engine": "turso",
8282            "store": "s3://backups/db",
8283            "subjects": ["accounts.db"],
8284            "rpo_seconds": 30,
8285            "state_mb": 100,
8286        }))
8287        .expect("a complete declaration decodes");
8288        d.check().expect("and is valid");
8289        assert_eq!(d.tier, DurabilityTier::Stream);
8290        assert_eq!(d.engine, Some(DurabilityEngine::Turso));
8291        assert_eq!(d.store.as_deref(), Some("s3://backups/db"));
8292        assert_eq!(d.subjects, vec!["accounts.db".to_string()]);
8293        assert_eq!(d.rpo_seconds, Some(30));
8294        assert_eq!(d.state_mb, Some(100));
8295    }
8296
8297    /// A misspelled tier or engine, a non-numeric RPO, and a declaration with
8298    /// no tier at all are serde refusals now — the vocabulary checks the
8299    /// annotation parser used to hand-roll. Pinned so nobody "helpfully" adds a
8300    /// fallback variant and turns `streem` into "no backups".
8301    #[test]
8302    fn a_malformed_declaration_is_refused_at_decode() {
8303        for bad in [
8304            serde_json::json!({ "tier": "streem" }),
8305            serde_json::json!({ "tier": "stream", "engine": "postgres" }),
8306            serde_json::json!({ "tier": "stream", "rpo_seconds": "2m" }),
8307            serde_json::json!({ "tier": "none", "state_mb": "100MB" }),
8308            serde_json::json!({ "store": "s3://backups/db" }),
8309        ] {
8310            assert!(
8311                serde_json::from_value::<Durability>(bad.clone()).is_err(),
8312                "{bad} must not decode"
8313            );
8314        }
8315    }
8316
8317    #[test]
8318    fn a_tier_that_ships_bytes_must_name_where() {
8319        for store in [None, Some("  ".to_string())] {
8320            let err = check(Durability {
8321                store,
8322                ..stream(&["a.db"])
8323            })
8324            .unwrap_err();
8325            assert_eq!(
8326                err,
8327                DurabilityDeclError::MissingStore {
8328                    tier: DurabilityTier::Stream
8329                }
8330            );
8331            // The refusal has to say why there is no default, or the next
8332            // reader adds one.
8333            assert!(err.to_string().contains("nobody chose"));
8334        }
8335    }
8336
8337    #[test]
8338    fn a_store_alongside_tier_none_is_contradictory_and_refused() {
8339        let err = check(Durability {
8340            store: Some("s3://backups/db".into()),
8341            ..none_tier()
8342        })
8343        .unwrap_err();
8344        assert_eq!(err, DurabilityDeclError::StoreWithoutTier);
8345    }
8346
8347    /// Only tier 2 has a recovery point the spec can state. Accepting an RPO on
8348    /// a snapshot tier would let a report print a bound nothing enforces.
8349    #[test]
8350    fn an_rpo_on_a_snapshot_tier_is_refused() {
8351        let err = check(Durability {
8352            tier: DurabilityTier::Snapshot,
8353            rpo_seconds: Some(30),
8354            ..stream(&["a.db"])
8355        })
8356        .unwrap_err();
8357        assert_eq!(
8358            err,
8359            DurabilityDeclError::RpoOnNonStreamTier {
8360                tier: DurabilityTier::Snapshot
8361            }
8362        );
8363    }
8364
8365    /// The declaration is a typed field now, carried name-keyed by the V13
8366    /// envelope; the annotation map stays empty of it.
8367    #[test]
8368    fn a_durability_declaration_round_trips_as_a_typed_field() {
8369        let mut spec = archetype_test_spec("durable");
8370        spec.durability = Some(stream(&["accounts.db"]));
8371        let json = serde_json::to_string(&spec).expect("serialize");
8372        assert!(json.contains("\"durability\""), "{json}");
8373        assert!(!json.contains("yah.durability"), "{json}");
8374        let back: WorkloadSpec = serde_json::from_str(&json).expect("deserialize");
8375        assert_eq!(back.durability().unwrap(), spec.durability().unwrap());
8376    }
8377
8378    // ── R960-F6: [[db]] rows ─────────────────────────────────────────────────
8379
8380    fn db_row(name: &str, subject: &str, workbench: WorkbenchKind) -> WorkloadDb {
8381        WorkloadDb { name: name.into(), subject: subject.into(), workbench }
8382    }
8383
8384    #[test]
8385    fn a_snapshot_db_row_needs_a_durability_declaration() {
8386        let mut spec = archetype_test_spec("db-no-durability");
8387        spec.db = vec![db_row("account", "accounts.db", WorkbenchKind::Snapshot)];
8388        assert_eq!(
8389            spec.db_rows().unwrap_err(),
8390            DbDeclError::SnapshotWithoutDurability { name: "account".into() }
8391        );
8392        // `none` needs no backup: it is declared, not listed.
8393        spec.db = vec![db_row("account", "accounts.db", WorkbenchKind::None)];
8394        assert_eq!(spec.db_rows().unwrap().len(), 1);
8395    }
8396
8397    #[test]
8398    fn a_snapshot_db_row_subject_must_be_a_durability_subject() {
8399        let mut spec = archetype_test_spec("db-bad-subject");
8400        spec.durability = Some(stream(&["accounts.db"]));
8401        spec.db = vec![db_row("account", "sessions.db", WorkbenchKind::Snapshot)];
8402        assert_eq!(
8403            spec.db_rows().unwrap_err(),
8404            DbDeclError::SnapshotSubjectNotDurable {
8405                name: "account".into(),
8406                subject: "sessions.db".into()
8407            }
8408        );
8409        spec.db = vec![db_row("account", "accounts.db", WorkbenchKind::Snapshot)];
8410        spec.db_rows().expect("a covered subject is accepted");
8411    }
8412
8413    #[test]
8414    fn db_rows_must_have_unique_non_blank_names() {
8415        let mut spec = archetype_test_spec("db-names");
8416        spec.db = vec![db_row(" ", "a.db", WorkbenchKind::None)];
8417        assert_eq!(spec.db_rows().unwrap_err(), DbDeclError::BlankName { index: 0 });
8418        spec.db = vec![
8419            db_row("a", "a.db", WorkbenchKind::None),
8420            db_row("a", "b.db", WorkbenchKind::None),
8421        ];
8422        assert_eq!(spec.db_rows().unwrap_err(), DbDeclError::DuplicateName { name: "a".into() });
8423    }
8424
8425    fn verify_key_mount() -> SecretMount {
8426        SecretMount {
8427            source: SecretRef::Cluster { name: "cheers/accounts/verify-key".into() },
8428            target: SecretTarget::File { path: "/run/secrets/verify-key".into(), mode: 0o400 },
8429        }
8430    }
8431
8432    /// R960-F8: a vend row is refused without a `sql` port, then without a
8433    /// verify-key mount, and accepted with both.
8434    #[test]
8435    fn a_vend_row_needs_a_sql_port_and_a_verify_key_mount() {
8436        let mut spec = archetype_test_spec("db-vend");
8437        spec.db = vec![db_row("accounts", "accounts.db", WorkbenchKind::Vend)];
8438        assert_eq!(
8439            spec.db_rows().unwrap_err(),
8440            DbDeclError::VendWithoutSqlPort { name: "accounts".into() }
8441        );
8442        spec.expose.mesh.ports.push(MeshPort::named(SQL_PORT_NAME));
8443        assert_eq!(
8444            spec.db_rows().unwrap_err(),
8445            DbDeclError::VendWithoutVerifyKey { name: "accounts".into() }
8446        );
8447        spec.secrets.push(verify_key_mount());
8448        spec.db_rows().expect("sql port + verify key is a complete vend row");
8449        assert_eq!(
8450            spec.served_capabilities(),
8451            vec![ServedCapability {
8452                capability: SQL_HRANA_CAPABILITY.into(),
8453                port: SQL_PORT_NAME.into(),
8454                db: Some("accounts".into()),
8455            }]
8456        );
8457    }
8458
8459    #[test]
8460    fn a_capability_on_an_undeclared_port_is_refused() {
8461        let mut spec = archetype_test_spec("caps");
8462        spec.capabilities =
8463            vec![WorkloadCapability { name: "events.query".into(), port: "events".into() }];
8464        assert_eq!(
8465            spec.db_rows().unwrap_err(),
8466            DbDeclError::CapabilityPortUndeclared {
8467                capability: "events.query".into(),
8468                port: "events".into(),
8469            }
8470        );
8471        spec.expose.mesh.ports.push(MeshPort::named("events"));
8472        spec.db_rows().expect("a declared port binds");
8473    }
8474
8475    #[test]
8476    fn a_spec_without_a_db_key_decodes_as_no_db_rows() {
8477        let mut spec = archetype_test_spec("db-digest");
8478        let mut json = serde_json::to_value(&spec).unwrap();
8479        // A pre-field peer's frame carries no `db`.
8480        json.as_object_mut().unwrap().remove("db");
8481        let back: WorkloadSpec = serde_json::from_value(json).unwrap();
8482        assert!(back.db.is_empty());
8483        spec.db = vec![db_row("a", "a.db", WorkbenchKind::None)];
8484        let json = serde_json::to_value(&spec).unwrap();
8485        assert_eq!(json["db"][0]["workbench"], "none");
8486    }
8487
8488    // ── R850-F1: the engine and subject axes ─────────────────────────────────
8489
8490    /// The driving shape from R850: one appliance, one named volume, three
8491    /// turso databases inside it. The declaration has to carry all three by
8492    /// name, because a restore's unit is a file and "the volume" is not one.
8493    #[test]
8494    fn three_databases_in_one_volume_are_three_named_subjects() {
8495        let d = stream(&["accounts.db", "passkeys.db", "sessions.db"]);
8496        d.check().expect("three distinct relative subjects are valid");
8497        assert_eq!(d.subjects, vec!["accounts.db", "passkeys.db", "sessions.db"]);
8498    }
8499
8500    /// Gotcha (c) on R850-F1, closed: the three tier names are turso-backup's,
8501    /// so a Postgres appliance saying `tier = "stream"` was declaring something
8502    /// no code in this tree can do. It cannot say it without also naming an
8503    /// engine, and the only engine that decodes is the one with a restore path
8504    /// (see `a_malformed_declaration_is_refused_at_decode`).
8505    #[test]
8506    fn a_bytes_shipping_tier_must_name_an_engine() {
8507        let err = check(Durability {
8508            engine: None,
8509            ..stream(&["a.db"])
8510        })
8511        .unwrap_err();
8512        assert_eq!(
8513            err,
8514            DurabilityDeclError::MissingEngine {
8515                tier: DurabilityTier::Stream
8516            }
8517        );
8518    }
8519
8520    #[test]
8521    fn a_bytes_shipping_tier_must_name_its_databases() {
8522        let err = check(Durability {
8523            tier: DurabilityTier::Snapshot,
8524            ..stream(&[])
8525        })
8526        .unwrap_err();
8527        assert_eq!(
8528            err,
8529            DurabilityDeclError::MissingSubjects {
8530                tier: DurabilityTier::Snapshot
8531            }
8532        );
8533        assert!(err.to_string().contains("guessing"), "{err}");
8534    }
8535
8536    /// `tier = "none"` ships nothing, so an engine or a subject list beside it
8537    /// is a half-edited declaration — the same shape `StoreWithoutTier`
8538    /// already refuses, and refused for the same reason: the reader cannot tell
8539    /// which half is the mistake.
8540    #[test]
8541    fn an_engine_or_subject_list_alongside_tier_none_is_refused() {
8542        assert_eq!(
8543            check(Durability {
8544                engine: Some(DurabilityEngine::Turso),
8545                ..none_tier()
8546            })
8547            .unwrap_err(),
8548            DurabilityDeclError::EngineWithoutTier
8549        );
8550        assert_eq!(
8551            check(Durability {
8552                subjects: vec!["a.db".into()],
8553                ..none_tier()
8554            })
8555            .unwrap_err(),
8556            DurabilityDeclError::SubjectsWithoutTier
8557        );
8558    }
8559
8560    /// A subject is joined onto a host directory by something that then writes
8561    /// to it, so traversal is refused by name rather than normalized away.
8562    /// Silently rewriting a path a human typed is how the right bytes land in
8563    /// the wrong place.
8564    #[test]
8565    fn a_subject_cannot_escape_the_volume_it_is_scoped_to() {
8566        let bad = |subjects: &[&str]| check(stream(subjects)).unwrap_err();
8567        assert_eq!(
8568            bad(&["/etc/passwd"]),
8569            DurabilityDeclError::AbsoluteSubject {
8570                subject: "/etc/passwd".into()
8571            }
8572        );
8573        assert_eq!(
8574            bad(&["../../../etc/passwd"]),
8575            DurabilityDeclError::TraversingSubject {
8576                subject: "../../../etc/passwd".into()
8577            }
8578        );
8579        assert_eq!(
8580            bad(&["data/./a.db"]),
8581            DurabilityDeclError::TraversingSubject {
8582                subject: "data/./a.db".into()
8583            }
8584        );
8585        // A blank entry truncates a list without looking like it did.
8586        assert_eq!(bad(&["a.db", " "]), DurabilityDeclError::EmptySubject);
8587        assert_eq!(
8588            bad(&["a.db", "a.db"]),
8589            DurabilityDeclError::DuplicateSubject {
8590                subject: "a.db".into()
8591            }
8592        );
8593    }
8594
8595    /// Nested subjects are legal — a workload is free to keep its databases in
8596    /// a subdirectory of the volume — so the traversal guard must reject `..`
8597    /// without rejecting every path containing a slash.
8598    #[test]
8599    fn a_subject_may_sit_in_a_subdirectory_of_the_volume() {
8600        let d = Durability {
8601            tier: DurabilityTier::Dedup,
8602            ..stream(&["db/accounts.db"])
8603        };
8604        d.check().expect("a nested relative subject is valid");
8605    }
8606}