workload_spec/validate.rs
1//! Shape validators for [`WorkloadSpec`].
2//!
3//! Called by clients (desktop, agent, CLI) before sending a spec over RPC.
4//! Sync, no I/O. Returns `Ok(warnings)` on pass or `Err(ShapeError)` on the
5//! first hard constraint violation.
6//!
7//! Layers: shape (this file, no I/O) → semantic (yubaba-side, R090-F3) →
8//! environment (deploy-time, R090-F4).
9
10use std::fmt;
11use std::sync::OnceLock;
12
13use regex::Regex;
14use thiserror::Error;
15
16use crate::{
17 DurabilityDeclError, EnvValue, EnvVar, ImageRef, LifecycleArchetype, MachineId, MeshIdent, MeshLookup,
18 RestartPolicy, SecretRef, SecretTarget, StaticAssetWorkload, Supply, VolumeSource,
19 WorkloadSpec,
20};
21
22// ── Field paths ───────────────────────────────────────────────────────────────
23
24/// Identifies the field that caused a shape error or warning.
25///
26/// Structured as an enum so promoting to all-errors mode (collecting into
27/// `Vec<FieldError>` instead of returning on the first hit) is mechanical.
28#[derive(Debug, Clone, PartialEq)]
29pub enum FieldPath {
30 Name,
31 MeshIdentity,
32 TailscaleTag,
33 Replicas,
34 ImageTag,
35 Tier,
36 /// `volumes[index].<sub>` — e.g. `Volume(0, "source")`.
37 Volume(usize, &'static str),
38 /// Public port not found in `expose.mesh.ports`.
39 ExposeMeshPort(u16),
40 /// `expose.mesh.ports[index]` — a malformed port declaration (R844-F17):
41 /// an empty entry, a bad name, or a name/number repeated within the list.
42 MeshPort(usize),
43 /// `secrets[index].<sub>` — e.g. `Secret(0, "target.path")`.
44 Secret(usize, &'static str),
45 /// `healthcheck.<sub>`.
46 Healthcheck(&'static str),
47 RestartPolicy,
48 /// `image` — registry says the image/tag is unknown.
49 Image,
50 /// `depends_on[index]` — mesh ident is not a known deployed workload.
51 DependsOn(usize),
52 /// `requires[index]` — a malformed requirement (R860-T1): a `supply` /
53 /// `provides` mismatch, a provider whose spec names a different identity,
54 /// a nested `self` supply, or a repeated / self-naming ident.
55 Requires(usize),
56 /// `expose.public.hostname` — hostname is not in an owned CF zone.
57 Hostname,
58 /// `resources` — machine lacks sufficient capacity.
59 Resources,
60 /// `aliases[key]` — alias target filename is not in the `[[asset]]` catalog.
61 AssetAlias(String),
62 /// `asset[index].<sub>` — e.g. `Asset(0, "source")` for the XOR rule.
63 Asset(usize, &'static str),
64 /// `annotations["<key>"]` — today only a key from a family R896-F3 retired
65 /// into typed fields.
66 Annotation(String),
67 /// `durability.<sub>` — e.g. `Durability("subjects")`.
68 Durability(&'static str),
69 /// `db` — a `[[db]]` row failed [`WorkloadSpec::db_rows`].
70 Db,
71}
72
73impl fmt::Display for FieldPath {
74 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75 match self {
76 FieldPath::Name => write!(f, "name"),
77 FieldPath::MeshIdentity => write!(f, "expose.mesh.identity"),
78 FieldPath::TailscaleTag => write!(f, "expose.operator.tailscale_tag"),
79 FieldPath::Replicas => write!(f, "replicas"),
80 FieldPath::ImageTag => write!(f, "image.tag"),
81 FieldPath::Tier => write!(f, "tier"),
82 FieldPath::Volume(i, sub) => write!(f, "volumes[{i}].{sub}"),
83 FieldPath::ExposeMeshPort(port) => write!(f, "expose.public.port ({port})"),
84 FieldPath::MeshPort(i) => write!(f, "expose.mesh.ports[{i}]"),
85 FieldPath::Secret(i, sub) => write!(f, "secrets[{i}].{sub}"),
86 FieldPath::Healthcheck(sub) => write!(f, "healthcheck.{sub}"),
87 FieldPath::RestartPolicy => write!(f, "restart_policy"),
88 FieldPath::Image => write!(f, "image"),
89 FieldPath::DependsOn(i) => write!(f, "depends_on[{i}]"),
90 FieldPath::Requires(i) => write!(f, "requires[{i}]"),
91 FieldPath::Hostname => write!(f, "expose.public.hostname"),
92 FieldPath::Resources => write!(f, "resources"),
93 FieldPath::AssetAlias(key) => write!(f, "aliases[{key}]"),
94 FieldPath::Asset(i, sub) => write!(f, "asset[{i}].{sub}"),
95 FieldPath::Annotation(key) => write!(f, "annotations[\"{key}\"]"),
96 FieldPath::Durability(sub) => write!(f, "durability.{sub}"),
97 FieldPath::Db => write!(f, "db"),
98 }
99 }
100}
101
102/// The `durability.<sub>` a [`DurabilityDeclError`] is about, so the path in a
103/// refusal points at the key to fix rather than at the whole table.
104fn durability_error_field(e: &DurabilityDeclError) -> &'static str {
105 use DurabilityDeclError as E;
106 match e {
107 E::RetiredAnnotation { .. } => "tier",
108 E::MissingStore { .. } | E::StoreWithoutTier => "store",
109 E::RpoOnNonStreamTier { .. } => "rpo_seconds",
110 E::MissingEngine { .. } | E::EngineWithoutTier => "engine",
111 E::MissingSubjects { .. }
112 | E::SubjectsWithoutTier
113 | E::EmptySubject
114 | E::AbsoluteSubject { .. }
115 | E::TraversingSubject { .. }
116 | E::DuplicateSubject { .. } => "subjects",
117 }
118}
119
120// ── Hard errors ───────────────────────────────────────────────────────────────
121
122/// A hard constraint violation that makes a spec impossible to deploy.
123///
124/// V1 surfaces the first error found. When the UI needs per-field
125/// highlighting, wrap in `Vec<ShapeError>` and collect instead of returning
126/// early — the `FieldPath` enum is already the common currency.
127#[derive(Debug, Error, PartialEq)]
128pub enum ShapeError {
129 #[error("field {path}: {reason}")]
130 Field { path: FieldPath, reason: String },
131}
132
133// ── Soft warnings ─────────────────────────────────────────────────────────────
134
135/// A soft check that passed but may indicate misconfiguration.
136#[derive(Debug, Clone, PartialEq)]
137pub struct ShapeWarning {
138 pub path: FieldPath,
139 pub message: String,
140}
141
142impl fmt::Display for ShapeWarning {
143 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144 write!(f, "warning at {}: {}", self.path, self.message)
145 }
146}
147
148// ── Internal helpers ──────────────────────────────────────────────────────────
149
150/// V1 known tier values. Unknown tiers produce a warning, not an error
151/// (cluster config may add custom tiers).
152const KNOWN_TIERS: &[&str] = &["public", "tenant", "private", "infra"];
153
154fn dns_label_re() -> &'static Regex {
155 static RE: OnceLock<Regex> = OnceLock::new();
156 RE.get_or_init(|| Regex::new(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$").unwrap())
157}
158
159fn env_name_re() -> &'static Regex {
160 static RE: OnceLock<Regex> = OnceLock::new();
161 RE.get_or_init(|| Regex::new(r"^[A-Z_][A-Z0-9_]*$").unwrap())
162}
163
164/// Validates a single DNS label: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`, ≤ 63 chars.
165fn check_dns_label(value: &str, path: FieldPath) -> Result<(), ShapeError> {
166 if value.len() > 63 {
167 return Err(ShapeError::Field {
168 path,
169 reason: format!("length {} exceeds maximum 63", value.len()),
170 });
171 }
172 if !dns_label_re().is_match(value) {
173 return Err(ShapeError::Field {
174 path,
175 reason: format!(
176 "{:?} must match ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$",
177 value
178 ),
179 });
180 }
181 Ok(())
182}
183
184/// Validates a dot-separated mesh identity where each segment is a DNS label.
185/// Total length ≤ 63. Example valid value: `"noisetable-api.pdx"`.
186fn check_mesh_ident(value: &str, path: FieldPath) -> Result<(), ShapeError> {
187 if value.len() > 63 {
188 return Err(ShapeError::Field {
189 path,
190 reason: format!("length {} exceeds maximum 63", value.len()),
191 });
192 }
193 for segment in value.split('.') {
194 if !dns_label_re().is_match(segment) {
195 return Err(ShapeError::Field {
196 path,
197 reason: format!(
198 "segment {:?} in {:?} must match ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$",
199 segment, value
200 ),
201 });
202 }
203 }
204 Ok(())
205}
206
207/// The longest a port name may be. Matches IANA's service-name limit, which is
208/// what Kubernetes uses for the same field and what any tool that has to render
209/// a port name in a fixed column already assumes.
210const MESH_PORT_NAME_MAX: usize = 15;
211
212/// Validates `expose.mesh.ports` (R844-F17): every entry states something, every
213/// name is a DNS label short enough to be a service name, and nothing is
214/// declared twice.
215///
216/// The uniqueness rules are the load-bearing half. A repeated *name* would make
217/// `name -> port` ambiguous at exactly the moment a consumer asks for it —
218/// `ServiceRecord::port("http")` and the `PORT_HTTP` variable both resolve
219/// through that map — and a repeated *number* is a workload asking to bind one
220/// socket twice. Both are caught here rather than at bring-up because the
221/// author can still see the manifest.
222fn check_mesh_ports(
223 mesh: &crate::MeshExpose,
224 warnings: &mut Vec<ShapeWarning>,
225) -> Result<(), ShapeError> {
226 let mut seen_names: Vec<&str> = Vec::new();
227 let mut seen_numbers: Vec<u16> = Vec::new();
228
229 for (i, port) in mesh.ports.iter().enumerate() {
230 let path = FieldPath::MeshPort(i);
231
232 if port.name.is_none() && port.number.is_none() {
233 return Err(ShapeError::Field {
234 path,
235 reason: "declares neither a name nor a number — write a number \
236 (8080), a name (\"http\"), or both \
237 ({ name = \"http\", port = 8080 })"
238 .to_string(),
239 });
240 }
241
242 if let Some(name) = port.name.as_deref() {
243 if name.len() > MESH_PORT_NAME_MAX {
244 return Err(ShapeError::Field {
245 path,
246 reason: format!(
247 "port name {name:?} is {} characters; the maximum is \
248 {MESH_PORT_NAME_MAX}",
249 name.len()
250 ),
251 });
252 }
253 if !dns_label_re().is_match(name) {
254 return Err(ShapeError::Field {
255 path,
256 reason: format!(
257 "port name {name:?} must match \
258 ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$"
259 ),
260 });
261 }
262 if seen_names.contains(&name) {
263 return Err(ShapeError::Field {
264 path,
265 reason: format!(
266 "port name {name:?} is declared twice; a name is how a \
267 consumer selects one port, so it has to pick out \
268 exactly one"
269 ),
270 });
271 }
272 seen_names.push(name);
273 }
274
275 match port.number {
276 Some(number) => {
277 if seen_numbers.contains(&number) {
278 return Err(ShapeError::Field {
279 path,
280 reason: format!(
281 "port {number} is declared twice; a workload cannot \
282 bind the same port on two listeners"
283 ),
284 });
285 }
286 seen_numbers.push(number);
287 }
288 // Still a warning rather than an error, but R844-F21 changed what
289 // it has to say. A name-only port IS allocated now — on the native
290 // backend, which owns the workload's network namespace: kamaji
291 // picks the number, remembers it per `(ident, name)` across a
292 // restart, and hands it to the process as `PORT_<NAME>`.
293 //
294 // It is still unbindable on a container backend, where the ports
295 // are the image's and both backends refuse the spelling outright
296 // (`kamaji::reject_unresolved_ports`). Shape validation cannot tell
297 // which backend a spec will land on — placement decides that later
298 // — so naming the split is the most this layer can honestly say,
299 // and it is why an error here would be wrong.
300 None => warnings.push(ShapeWarning {
301 path,
302 message: format!(
303 "port {:?} declares no number: the native backend allocates \
304 one and tells the process via PORT_{}, but a container \
305 backend refuses it — a container's ports are its image's. \
306 State the number ({{ name = {:?}, port = <n> }}) if this \
307 workload runs as a container.",
308 port.name.as_deref().unwrap_or_default(),
309 port.name
310 .as_deref()
311 .unwrap_or_default()
312 .to_uppercase()
313 .replace(|c: char| !c.is_ascii_alphanumeric(), "_"),
314 port.name.as_deref().unwrap_or_default(),
315 ),
316 }),
317 }
318 }
319
320 Ok(())
321}
322
323/// Validates `requires` (R860-T1 / W338): the `supply` / `provides` pairing,
324/// the identity a self-provisioned provider claims, the depth bound on the
325/// recursion, and ident uniqueness.
326///
327/// The depth bound is the load-bearing rule. `Requirement::provides` makes
328/// `WorkloadSpec` recursive, and a provider that may itself self-provision
329/// turns "a workload plus its sidecars" into an unbounded tree that placement
330/// would have to flatten before it could schedule anything. One level is what
331/// the design asks for, so one level is what is representable.
332fn check_requires(spec: &WorkloadSpec) -> Result<(), ShapeError> {
333 let mut seen: Vec<&str> = Vec::new();
334
335 for (i, req) in spec.requires.iter().enumerate() {
336 let path = || FieldPath::Requires(i);
337 let ident = req.ident.0.as_str();
338
339 if ident == spec.expose.mesh.identity.0 {
340 return Err(ShapeError::Field {
341 path: path(),
342 reason: format!(
343 "requires its own identity {ident:?}; a workload cannot be \
344 its own provider"
345 ),
346 });
347 }
348 if seen.contains(&ident) {
349 return Err(ShapeError::Field {
350 path: path(),
351 reason: format!(
352 "ident {ident:?} is declared twice; one requirement per \
353 provider, since a second entry could only contradict the \
354 first's locality or supply"
355 ),
356 });
357 }
358 seen.push(ident);
359
360 match (req.supply, &req.provides) {
361 (Supply::SelfProvision, None) => {
362 return Err(ShapeError::Field {
363 path: path(),
364 reason: format!(
365 "supply = \"self\" on {ident:?} but no `provides` spec; \
366 a self-provisioned requirement is the one that carries \
367 its provider, so there is nothing to stand up"
368 ),
369 });
370 }
371 (Supply::Wait, Some(_)) => {
372 return Err(ShapeError::Field {
373 path: path(),
374 reason: format!(
375 "supply = \"wait\" on {ident:?} but a `provides` spec is \
376 present; a waiting requirement names a provider someone \
377 else declares, so this spec would have no owner — set \
378 supply = \"self\" to deploy it here"
379 ),
380 });
381 }
382 (Supply::Wait, None) => {}
383 (Supply::SelfProvision, Some(provided)) => {
384 if provided.expose.mesh.identity.0 != ident {
385 return Err(ShapeError::Field {
386 path: path(),
387 reason: format!(
388 "`provides` declares expose.mesh.identity {:?} but the \
389 requirement names {ident:?}; the provider keeps its \
390 own mesh identity and it has to be the one this \
391 requirement asks for (its `name` is {:?})",
392 provided.expose.mesh.identity.0, provided.name
393 ),
394 });
395 }
396 if let Some(nested) = provided
397 .requires
398 .iter()
399 .find(|r| matches!(r.supply, Supply::SelfProvision))
400 {
401 return Err(ShapeError::Field {
402 path: path(),
403 reason: format!(
404 "`provides` spec {ident:?} itself requires {:?} with \
405 supply = \"self\"; composition is bounded at one \
406 level, so a provider may only wait on things it \
407 does not deploy — hoist that requirement up to this \
408 spec's own `requires`",
409 nested.ident.0
410 ),
411 });
412 }
413 }
414 }
415 }
416
417 Ok(())
418}
419
420// ── Public API ────────────────────────────────────────────────────────────────
421
422/// Run shape validation — sync, no I/O.
423///
424/// Returns `Ok(warnings)` when all hard constraints pass; the `Vec` is empty
425/// for a clean spec. Returns `Err` on the first hard constraint violation.
426/// Callers that only need hard errors can discard the Ok value with
427/// `.map(|_| ())`.
428///
429/// Hard constraints checked:
430/// - `name`, `expose.mesh.identity`: DNS-label format, ≤ 63 chars.
431/// - `expose.operator.tailscale_tag`: `"tag:<dns-label>"`, ≤ 63 chars.
432/// - `replicas`: 0–100.
433/// - `image.tag`: non-empty when `digest` is `None`.
434/// - `volumes[*].source = Bind`: only allowed when `tier = "infra"`.
435/// - `expose.mesh.ports[*]`: each entry states a name and/or a number; names are
436/// DNS labels ≤ 15 chars; no name and no number is declared twice (R844-F17).
437/// - `expose.public.port`: must appear in `expose.mesh.ports`.
438/// - `secrets[*].target`: file paths must be absolute; env-var names must
439/// match `^[A-Z_][A-Z0-9_]*$`.
440/// - `requires[*]`: `supply = "self"` carries a `provides` spec and
441/// `supply = "wait"` does not; a `provides` spec declares the identity its
442/// requirement names; a `provides` spec carries no `self` supply of its own
443/// (depth 1); idents are unique and none is the spec's own (R860-T1).
444///
445/// Soft checks (produce warnings, not errors):
446/// - `expose.mesh.ports[*]`: a name with no number — nothing allocates from the
447/// manifest yet, so nothing binds it (R844-F17).
448/// - Unknown tier value.
449/// - `RestartPolicy::Never` without `annotations["yah.forge"] = "true"`.
450/// - `healthcheck.initial_delay < stop_policy.grace_period * 2`.
451pub fn shape(spec: &WorkloadSpec) -> Result<Vec<ShapeWarning>, ShapeError> {
452 let mut warnings: Vec<ShapeWarning> = Vec::new();
453
454 // name: single DNS label, ≤ 63 chars
455 check_dns_label(&spec.name, FieldPath::Name)?;
456
457 // expose.mesh.identity: dot-separated DNS name, ≤ 63 total
458 check_mesh_ident(&spec.expose.mesh.identity.0, FieldPath::MeshIdentity)?;
459
460 // expose.operator.tailscale_tag: "tag:<dns-label>", ≤ 63 chars (optional)
461 if let Some(op) = &spec.expose.operator {
462 let tag = &op.tailscale_tag;
463 if tag.len() > 63 {
464 return Err(ShapeError::Field {
465 path: FieldPath::TailscaleTag,
466 reason: format!("length {} exceeds maximum 63", tag.len()),
467 });
468 }
469 let rest = tag.strip_prefix("tag:").ok_or_else(|| ShapeError::Field {
470 path: FieldPath::TailscaleTag,
471 reason: format!("{:?} must start with \"tag:\"", tag),
472 })?;
473 if !dns_label_re().is_match(rest) {
474 return Err(ShapeError::Field {
475 path: FieldPath::TailscaleTag,
476 reason: format!(
477 "the part after \"tag:\" in {:?} must match \
478 ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$",
479 tag
480 ),
481 });
482 }
483 }
484
485 // replicas: 0..=100
486 if spec.replicas > 100 {
487 return Err(ShapeError::Field {
488 path: FieldPath::Replicas,
489 reason: format!("{} exceeds maximum 100", spec.replicas),
490 });
491 }
492
493 // image.tag: non-empty (informational identifier; digest is normally the
494 // source of truth — but see the digest check just below, which is where
495 // "normally" earns its qualifier).
496 if spec.image.tag.is_empty() {
497 return Err(ShapeError::Field {
498 path: FieldPath::ImageTag,
499 reason: "tag is empty; provide a human-readable tag alongside the digest".into(),
500 });
501 }
502
503 // image.digest: empty is legal ONLY for a spec whose exec substrate never
504 // pulls the image — `wants_native_exec` and `wants_microvm` both document
505 // their `image` as "identity metadata only, nothing is pulled". A
506 // Container-substrate spec has no such backend: it reaches containerd or
507 // docker, both of which resolve the ref by digest before doing anything
508 // else. An empty digest there is not a missing image, it is a
509 // construction bug — a spec built for native/microVM that never got the
510 // `yah.exec` marker (R931-B4: `InnerDoorPlan::workload` did exactly this,
511 // and the failure surfaced as containerd 500ing on a cr-less pull of
512 // `local/passway:inner-door@`, an empty-digest ref containerd was never
513 // meant to see). Caught here, at the one shape gate every deploy and
514 // `/workloads/validate` call runs before admission or the backend, so a
515 // future construction site that forgets the annotation is refused by name
516 // instead of reaching a backend that mis-reads "nothing to pull" as
517 // "pull it and 500".
518 if spec.image.digest.is_empty() && spec.exec_substrate() == crate::ExecSubstrate::Container {
519 return Err(ShapeError::Field {
520 path: FieldPath::Image,
521 reason: format!(
522 "digest is empty, which is only valid for a spec marked for native or \
523 microVM execution (annotations[{:?}] = {:?} or {:?}) — this spec requests \
524 the container substrate, which pulls {}/{}:{} by digest and has nothing to \
525 pull",
526 crate::NATIVE_EXEC_ANNOTATION,
527 crate::NATIVE_EXEC_VALUE,
528 crate::MICROVM_EXEC_VALUE,
529 spec.image.registry,
530 spec.image.repository,
531 spec.image.tag,
532 ),
533 });
534 }
535
536 // tier: warn on unknown (cluster config may add custom tiers)
537 if !KNOWN_TIERS.contains(&spec.tier.0.as_str()) {
538 warnings.push(ShapeWarning {
539 path: FieldPath::Tier,
540 message: format!(
541 "\"{}\" is not in the known tier set (public/tenant/private/infra); \
542 yubaba may reject it if the cluster config does not include this tier",
543 spec.tier.0
544 ),
545 });
546 }
547
548 // volumes[*]: Bind rejected unless tier = "infra"
549 for (i, vol) in spec.volumes.iter().enumerate() {
550 if matches!(&vol.source, VolumeSource::Bind { .. }) && spec.tier.0 != "infra" {
551 return Err(ShapeError::Field {
552 path: FieldPath::Volume(i, "source"),
553 reason: format!(
554 "Bind mounts are only allowed when tier = \"infra\" \
555 (current tier: {:?})",
556 spec.tier.0
557 ),
558 });
559 }
560 }
561
562 // expose.mesh.ports[*]: names well-formed, nothing declared twice (R844-F17)
563 check_mesh_ports(&spec.expose.mesh, &mut warnings)?;
564
565 // requires[*]: supply/provides pairing, provider identity, depth bound,
566 // ident uniqueness (R860-T1)
567 check_requires(spec)?;
568
569 // expose.public.port must appear in expose.mesh.ports
570 if let Some(public) = &spec.expose.public {
571 if !spec.expose.mesh.declares_number(public.port) {
572 return Err(ShapeError::Field {
573 path: FieldPath::ExposeMeshPort(public.port),
574 reason: format!(
575 "port {} must appear in expose.mesh.ports {:?} \
576 before it can be exposed publicly",
577 public.port,
578 spec.expose.mesh.numbers()
579 ),
580 });
581 }
582 }
583
584 // secrets[*]: target paths absolute; env-var names valid identifiers
585 for (i, secret) in spec.secrets.iter().enumerate() {
586 match &secret.target {
587 SecretTarget::File { path, .. } => {
588 if !path.is_absolute() {
589 return Err(ShapeError::Field {
590 path: FieldPath::Secret(i, "target.path"),
591 reason: format!("{:?} is not an absolute path", path),
592 });
593 }
594 }
595 SecretTarget::EnvVar { name } => {
596 if !env_name_re().is_match(name) {
597 return Err(ShapeError::Field {
598 path: FieldPath::Secret(i, "target.name"),
599 reason: format!(
600 "{:?} is not a valid env-var identifier (^[A-Z_][A-Z0-9_]*$)",
601 name
602 ),
603 });
604 }
605 }
606 }
607 }
608
609 // soft: RestartPolicy::Never without yah.forge=true annotation
610 if matches!(spec.restart_policy, RestartPolicy::Never) {
611 let is_forge = spec
612 .annotations
613 .get("yah.forge")
614 .map(|v| v == "true")
615 .unwrap_or(false);
616 if !is_forge {
617 warnings.push(ShapeWarning {
618 path: FieldPath::RestartPolicy,
619 message: "restart_policy=Never is intended for forge runs; \
620 add annotation yah.forge=true to suppress this warning"
621 .into(),
622 });
623 }
624 }
625
626 // R896-F3: the `yah.limits.*` / `yah.placement.memory-request-mb` /
627 // `yah.durability.*` annotations became typed fields. Nothing reads those
628 // keys any more, and a key nothing reads fails silently — a pids ceiling
629 // falls back to the default, a durability tier means "no backup" — so a
630 // surviving one refuses the spec and names where the value goes now.
631 if let Some((key, field)) = spec.retired_annotation() {
632 return Err(ShapeError::Field {
633 path: FieldPath::Annotation(key.to_string()),
634 reason: format!(
635 "annotation {key:?} is no longer read; since R896-F3 it is the typed field \
636 `{field}` — move the value there, because an ignored annotation silently \
637 falls back to the default"
638 ),
639 });
640 }
641
642 // durability: a malformed declaration is hard, and a stateful workload with
643 // no declaration at all is soft (R850-P4).
644 //
645 // The asymmetry is deliberate. Refusing every undeclared appliance would
646 // fail every spec in the tree on the day the declaration shipped; reading a
647 // *malformed* one as "undeclared" would let a half-written declaration mean
648 // "no backups" silently, which is the failure this whole surface exists to
649 // stop. See `WorkloadSpec::durability`.
650 let durability = spec.durability().map_err(|e| ShapeError::Field {
651 path: FieldPath::Durability(durability_error_field(&e)),
652 reason: e.to_string(),
653 })?;
654 spec.db_rows().map_err(|e| ShapeError::Field {
655 path: FieldPath::Db,
656 reason: e.to_string(),
657 })?;
658 // R850-F1: a bytes-shipping tier's subjects are volume-relative, so there
659 // has to be exactly one volume for them to be relative *to*. Zero means the
660 // declaration names files that will never exist; two or more means the
661 // hydrate helper would have to guess which host directory to restore into,
662 // and a wrong guess writes somebody's database over somebody else's.
663 //
664 // A **bind** counts, not only a yubaba-managed named volume. The rule was
665 // named-only until R858-F17 named the case it excluded: headscale keeps its
666 // state at `/var/lib/yah-cloud/headscale/`, is a native-exec appliance, and
667 // has no named volume and never will — so the narrow rule made the one
668 // workload whose loss took this camp's mesh down for 37 hours the one
669 // workload that could not declare durability. A bind is already restricted
670 // to `tier = "infra"` below, which is exactly the class this applies to.
671 if let Some(d) = durability.as_ref().filter(|d| d.tier.ships_bytes()) {
672 let roots: Vec<String> = spec
673 .volumes
674 .iter()
675 // A secret-materializer bind (R858-B26) is excluded for the same
676 // reason Tmpfs is: it is not where durable subjects live, it is
677 // just where yubaba parked a decrypted file.
678 .filter(|v| !v.from_secret_mount)
679 .filter_map(|v| match &v.source {
680 VolumeSource::Named { name } => Some(name.clone()),
681 VolumeSource::Bind { host_path } => Some(host_path.display().to_string()),
682 // Tmpfs is deliberately not a candidate: it is the declaration
683 // that this data does not survive the process, so counting it
684 // would let a spec claim durable state on a ramdisk.
685 VolumeSource::Tmpfs { .. } => None,
686 })
687 .collect();
688 if roots.len() != 1 {
689 return Err(ShapeError::Field {
690 path: FieldPath::Durability("subjects"),
691 reason: format!(
692 "durability.tier = \"{}\" declares subjects {:?}, which are \
693 relative to one volume, but this spec declares {} named-or-bind volumes{}; \
694 a tier that ships bytes needs exactly one (tmpfs does not count — it is a \
695 declaration that the data does not survive)",
696 d.tier,
697 d.subjects,
698 roots.len(),
699 if roots.is_empty() {
700 String::new()
701 } else {
702 format!(" ({})", roots.join(", "))
703 }
704 ),
705 });
706 }
707 }
708
709 if durability.is_none()
710 && spec.effective_archetype() == LifecycleArchetype::Appliance
711 && spec
712 .volumes
713 .iter()
714 .any(|v| matches!(v.source, VolumeSource::Named { .. }))
715 {
716 warnings.push(ShapeWarning {
717 path: FieldPath::Durability("tier"),
718 message: "appliance with a yubaba-managed named volume declares no durability \
719 tier, so that volume is the only copy of its state and losing the node \
720 loses it; declare durability.tier = \"none\" if that is intended, or a \
721 real tier if it is not"
722 .into(),
723 });
724 }
725
726 // soft: healthcheck.initial_delay >= stop_policy.grace_period * 2
727 if let Some(hc) = &spec.healthcheck {
728 let min_recommended = spec.stop_policy.grace_period.as_ms().saturating_mul(2);
729 if hc.initial_delay.as_ms() < min_recommended {
730 warnings.push(ShapeWarning {
731 path: FieldPath::Healthcheck("initial_delay"),
732 message: format!(
733 "initial_delay ({}ms) is less than stop_policy.grace_period * 2 ({}ms); \
734 a SIGTERM during startup may catch a still-initialising container",
735 hc.initial_delay.as_ms(),
736 min_recommended
737 ),
738 });
739 }
740 }
741
742 Ok(warnings)
743}
744
745// ── StaticAsset validator ─────────────────────────────────────────────────────
746
747/// Shape-validate a `kind = "static-asset"` workload.
748///
749/// Enforces the closed-catalog invariant: every value in `[aliases]` must be a
750/// `filename` present in `[[asset]]`. A mirror's `[asset_aliases]` overrides
751/// are bound by the same rule and are validated separately at sync time when
752/// both the workload and mirror are loaded together.
753pub fn shape_static_asset(workload: &StaticAssetWorkload) -> Result<(), ShapeError> {
754 // XOR rule (W164 / R438-T2): every [[asset]] row must set exactly one of
755 // `source` (legacy local bytes) or `derive` (fetch + optional transform).
756 // Both-set is ambiguous (which one wins?); neither-set leaves the
757 // reconciler with no bytes to upload.
758 for (i, entry) in workload.assets.iter().enumerate() {
759 match (entry.source.is_some(), entry.derive.is_some()) {
760 (true, true) => {
761 return Err(ShapeError::Field {
762 path: FieldPath::Asset(i, "source"),
763 reason: format!(
764 "asset {:?}: both `source` and `derive` are set; pick exactly one",
765 entry.filename
766 ),
767 });
768 }
769 (false, false) => {
770 return Err(ShapeError::Field {
771 path: FieldPath::Asset(i, "source"),
772 reason: format!(
773 "asset {:?}: neither `source` nor `derive` is set; pick exactly one",
774 entry.filename
775 ),
776 });
777 }
778 _ => {}
779 }
780 }
781
782 let filenames: std::collections::HashSet<&str> =
783 workload.assets.iter().map(|a| a.filename.as_str()).collect();
784
785 for (alias_key, alias_target) in &workload.aliases {
786 if !filenames.contains(alias_target.as_str()) {
787 return Err(ShapeError::Field {
788 path: FieldPath::AssetAlias(alias_key.clone()),
789 reason: format!(
790 "alias target {:?} is not present in the [[asset]] catalog; \
791 add a matching [[asset]] row or correct the filename",
792 alias_target
793 ),
794 });
795 }
796 }
797
798 Ok(())
799}
800
801// ── Semantic layer ────────────────────────────────────────────────────────────
802
803/// Transient error from a [`ValidationContext`] lookup.
804///
805/// Distinct from a semantic "resource not found" failure. `ContextError` means
806/// the lookup itself could not complete (network timeout, auth failure, etc.),
807/// not that the resource is definitively absent.
808#[derive(Debug, Error, Clone, PartialEq)]
809#[error("context lookup failed: {0}")]
810pub struct ContextError(pub String);
811
812/// A semantic constraint violation: the spec references a resource that is not
813/// known to the cluster at validation time.
814#[derive(Debug, Error, PartialEq)]
815pub enum SemanticError {
816 #[error("field {path}: {reason}")]
817 Unknown { path: FieldPath, reason: String },
818}
819
820/// Top-level validation error spanning both shape and semantic layers.
821///
822/// `Shape` always wins: if the spec is structurally invalid, semantic checks
823/// never run.
824#[derive(Debug, Error, PartialEq)]
825pub enum WorkloadValidationError {
826 /// Hard shape constraint failed — spec is structurally invalid.
827 #[error("shape: {0}")]
828 Shape(ShapeError),
829
830 /// Semantic check failed — spec references an unknown cluster resource.
831 #[error("semantic: {0}")]
832 Semantic(SemanticError),
833
834 /// Transient ValidationContext lookup failure — the check itself failed.
835 #[error("context: {0}")]
836 Context(ContextError),
837}
838
839impl From<ShapeError> for WorkloadValidationError {
840 fn from(e: ShapeError) -> Self { WorkloadValidationError::Shape(e) }
841}
842
843impl From<ContextError> for WorkloadValidationError {
844 fn from(e: ContextError) -> Self { WorkloadValidationError::Context(e) }
845}
846
847/// Read-only view of yubaba state used for semantic validation.
848///
849/// Defined here so clients (desktop, CLI, agents) can run semantic checks
850/// without depending on the yubaba crate. Yubaba implements this trait.
851///
852/// Each method returns `Result<bool, ContextError>` so transient failures are
853/// distinguishable from definitive "not found" answers.
854pub trait ValidationContext {
855 /// True when the registry confirms the image exists.
856 fn image_exists(&self, image: &ImageRef) -> Result<bool, ContextError>;
857
858 /// True when the named secret exists in the yubaba secret store.
859 fn secret_exists(&self, secret: &SecretRef) -> Result<bool, ContextError>;
860
861 /// True when `ident` is a known deployed workload OR appears in `batch`
862 /// (the set of specs co-deployed in the same request — allows forward
863 /// references within a single deployment batch).
864 fn mesh_ident_known(&self, ident: &MeshIdent, batch: &[MeshIdent]) -> Result<bool, ContextError>;
865
866 /// True when `hostname` falls under a Cloudflare zone owned by this cluster.
867 fn cf_zone_owned(&self, hostname: &str) -> Result<bool, ContextError>;
868
869 /// True when `tag` (e.g. `"tag:noisetable-ops"`) is in the cluster's
870 /// Tailscale ACL tag list.
871 fn tailscale_tag_known(&self, tag: &str) -> Result<bool, ContextError>;
872
873 /// True when `machine_id` has sufficient remaining capacity to host the
874 /// given spec's resource requirements.
875 ///
876 /// Implementors: read memory via [`WorkloadSpec::memory_request_mb`], not
877 /// `spec.resources.memory_mb`. The latter is a cgroup ceiling, and using
878 /// it as a capacity floor is what made every build-worker smaller than
879 /// `for_forge`'s 32 GiB ceiling unschedulable in `admit_workload`. Only a
880 /// test implementation of this trait exists today, so the bug is not live
881 /// here — this note is to keep it from arriving with the first real one.
882 fn capacity_for(&self, spec: &WorkloadSpec, machine_id: &MachineId) -> Result<bool, ContextError>;
883}
884
885/// Run semantic validation — requires yubaba state via [`ValidationContext`].
886///
887/// Shape validation is NOT run here. Callers MUST run [`shape`] first; use
888/// [`all`] to enforce this automatically.
889///
890/// `machine_id` is the target machine for admission-control capacity checks.
891/// `batch` is the set of mesh idents being co-deployed (pass `&[]` for
892/// single-spec deployment); these count as "known" for `depends_on` resolution.
893pub fn semantic(
894 spec: &WorkloadSpec,
895 ctx: &dyn ValidationContext,
896 machine_id: &MachineId,
897 batch: &[MeshIdent],
898) -> Result<(), WorkloadValidationError> {
899 if !ctx.image_exists(&spec.image)? {
900 return Err(WorkloadValidationError::Semantic(SemanticError::Unknown {
901 path: FieldPath::Image,
902 reason: format!(
903 "image {}/{}:{} not found in registry",
904 spec.image.registry, spec.image.repository, spec.image.tag
905 ),
906 }));
907 }
908
909 for (i, secret) in spec.secrets.iter().enumerate() {
910 if !ctx.secret_exists(&secret.source)? {
911 return Err(WorkloadValidationError::Semantic(SemanticError::Unknown {
912 path: FieldPath::Secret(i, "source"),
913 reason: format!("secret source at index {i} not found in yubaba secret store"),
914 }));
915 }
916 }
917
918 for (i, dep) in spec.depends_on.iter().enumerate() {
919 if !ctx.mesh_ident_known(dep, batch)? {
920 return Err(WorkloadValidationError::Semantic(SemanticError::Unknown {
921 path: FieldPath::DependsOn(i),
922 reason: format!("mesh ident {:?} is not a known deployed workload", dep.0),
923 }));
924 }
925 }
926
927 if let Some(public) = &spec.expose.public {
928 if !ctx.cf_zone_owned(&public.hostname)? {
929 return Err(WorkloadValidationError::Semantic(SemanticError::Unknown {
930 path: FieldPath::Hostname,
931 reason: format!(
932 "hostname {:?} is not under a Cloudflare zone owned by this cluster",
933 public.hostname
934 ),
935 }));
936 }
937 }
938
939 if let Some(op) = &spec.expose.operator {
940 if !ctx.tailscale_tag_known(&op.tailscale_tag)? {
941 return Err(WorkloadValidationError::Semantic(SemanticError::Unknown {
942 path: FieldPath::TailscaleTag,
943 reason: format!(
944 "tailscale tag {:?} is not in the cluster's ACL tag list",
945 op.tailscale_tag
946 ),
947 }));
948 }
949 }
950
951 if !ctx.capacity_for(spec, machine_id)? {
952 return Err(WorkloadValidationError::Semantic(SemanticError::Unknown {
953 path: FieldPath::Resources,
954 reason: format!(
955 "machine {:?} lacks capacity (memory={}MB cpu_millis={})",
956 machine_id.0, spec.resources.memory_mb, spec.resources.cpu_millis
957 ),
958 }));
959 }
960
961 Ok(())
962}
963
964// ── Mesh resolution layer ─────────────────────────────────────────────────────
965
966/// Failure surface for [`MeshResolver`] lookups.
967///
968/// `NotDeployed` means the dependency hasn't been observed in mesh state yet
969/// (yubaba's deploy step waits on this — see [`crate::EnvValue::FromMesh`]).
970/// `NoPorts` means the dependency is deployed but its `MeshExpose.ports`
971/// list is empty, so a port-based lookup can't render a value. `Lookup`
972/// covers transient failures from the underlying state read.
973#[derive(Debug, Error, Clone, PartialEq)]
974pub enum MeshError {
975 #[error("mesh ident {ident:?} is not yet deployed")]
976 NotDeployed { ident: String },
977
978 #[error(
979 "mesh ident {ident:?} exposes no ports; {lookup:?} requires at least one"
980 )]
981 NoPorts { ident: String, lookup: MeshLookup },
982
983 /// The peer exposes several ports and the lookup did not say which
984 /// (R844-B22). Deliberately an error rather than a pick — see
985 /// [`MeshLookup`]'s docs.
986 #[error(
987 "mesh ident {ident:?} exposes {} ports ({}) and none is named \"http\", \
988 so {lookup:?} cannot say which one to use. Name the port in the \
989 lookup (kind = \"port_named\", name = \"…\"), or name one of the \
990 peer's ports \"http\" in its expose.mesh.ports.",
991 .names.len(),
992 .names.join(", ")
993 )]
994 AmbiguousPort {
995 ident: String,
996 lookup: MeshLookup,
997 names: Vec<String>,
998 },
999
1000 /// The lookup named a port the peer does not expose (R844-B22).
1001 #[error(
1002 "mesh ident {ident:?} exposes no port named {name:?}; it has {}",
1003 if .available.is_empty() { "none".to_string() } else { .available.join(", ") }
1004 )]
1005 NoSuchPort {
1006 ident: String,
1007 name: String,
1008 available: Vec<String>,
1009 },
1010
1011 #[error("mesh state lookup failed: {0}")]
1012 Lookup(String),
1013}
1014
1015/// The port name a peer's sole/default listener carries. Agrees with
1016/// `kamaji::DEFAULT_PORT_NAME` by convention rather than by import: kamaji sits
1017/// *above* workload-spec in the publish DAG (`yah-base <- {qed,kamaji} <-
1018/// yubaba`), so depending on it here would invert the graph. The two are pinned
1019/// together by `default_port_name_agrees_with_the_supervisor` in this file's
1020/// tests.
1021pub const DEFAULT_PORT_NAME: &str = "http";
1022
1023/// Pick the port a [`MeshLookup`] refers to out of a peer's `name -> port` map
1024/// (R844-B22) — the one place the rule lives, so every resolver answers the
1025/// same way.
1026///
1027/// - A **named** lookup takes that port, or errors naming what the peer does
1028/// have. No fallback: a lookup that asked for `wss` and silently got `http`
1029/// would be the positional guess wearing a name.
1030/// - An **unnamed** lookup takes the sole port when there is one, else the port
1031/// named [`DEFAULT_PORT_NAME`], else errors. It never takes "the first",
1032/// which is what this function exists to stop.
1033pub fn select_mesh_port(
1034 ident: &str,
1035 ports: &std::collections::BTreeMap<String, u16>,
1036 lookup: &MeshLookup,
1037) -> Result<u16, MeshError> {
1038 if let Some(name) = lookup.port_name() {
1039 return ports.get(name).copied().ok_or_else(|| MeshError::NoSuchPort {
1040 ident: ident.to_string(),
1041 name: name.to_string(),
1042 available: ports.keys().cloned().collect(),
1043 });
1044 }
1045
1046 let mut entries = ports.iter();
1047 match (entries.next(), entries.next()) {
1048 (None, _) => Err(MeshError::NoPorts {
1049 ident: ident.to_string(),
1050 lookup: lookup.clone(),
1051 }),
1052 (Some((_, &only)), None) => Ok(only),
1053 _ => ports
1054 .get(DEFAULT_PORT_NAME)
1055 .copied()
1056 .ok_or_else(|| MeshError::AmbiguousPort {
1057 ident: ident.to_string(),
1058 lookup: lookup.clone(),
1059 names: ports.keys().cloned().collect(),
1060 }),
1061 }
1062}
1063
1064/// Resolve [`crate::EnvValue::FromMesh`] references to literal env values.
1065///
1066/// Defined in workload-spec so clients (agents, desktop, CLI) can render
1067/// specs against fake mesh state without depending on the yubaba crate.
1068/// Yubaba's production implementation (in `yubaba::deploy::mesh_resolve`)
1069/// reads from raft state.
1070///
1071/// **Resolution rules** (R844-B22 replaced the positional ones):
1072/// - [`MeshLookup::Host`] — the bare DNS-ish identifier as authored (e.g.
1073/// `"noisetable-db.pdx"`). Needs no port and resolves for a portless peer.
1074/// - [`MeshLookup::Url`] — `"http://<ident>:<port>"`.
1075/// - [`MeshLookup::Port`] — that port stringified, e.g. `"5432"`.
1076/// - [`MeshLookup::UrlNamed`] / [`MeshLookup::PortNamed`] — the same, at the
1077/// peer's port of that name.
1078///
1079/// **Which port** is [`select_mesh_port`]'s decision, and implementations must
1080/// route through it rather than re-deriving: a sole port, else the one named
1081/// [`DEFAULT_PORT_NAME`], else an error. It is emphatically *not* "the first
1082/// entry", which is what this trait's doc used to promise — declaration order
1083/// is not a statement about which listener a dependent should dial, and acting
1084/// as if it were is how a workload gets handed a metrics port as its API URL.
1085///
1086/// Because the rule is name-based rather than positional, a `Url` and a `Port`
1087/// resolved in the same deploy agree by construction; the old doc had to ask
1088/// implementations to make the lookup atomic to get that.
1089pub trait MeshResolver {
1090 fn resolve(&self, ident: &MeshIdent, kind: MeshLookup) -> Result<String, MeshError>;
1091}
1092
1093/// Render every [`EnvValue::FromMesh`] entry in `env` to a [`EnvValue::Literal`]
1094/// using `resolver`; pass through `Literal` and `FromSecret` values unchanged.
1095///
1096/// Returns the first resolution error encountered. Callers should run this
1097/// after yubaba's stage-3 mesh peering completes (see
1098/// `yubaba::deploy::env_validate::run` doc), at containerd-spec assembly.
1099///
1100/// `FromSecret` values are deliberately untouched here — secret resolution
1101/// is the secrets layer's job (R090-F5), not the mesh resolver's.
1102///
1103/// @yah:ticket(R844-B22, "MeshLookup::Url and ::Port resolve "the first entry in expose.mesh.ports" — the positional guess this relay abolishes, in the env-injection path")
1104/// @yah:status(review)
1105/// @yah:at(2026-09-04T14:10:34Z)
1106/// @yah:assignee(agent:bundle-anthropic-ashguard)
1107/// @yah:parent(R844)
1108/// @yah:severity(medium)
1109/// @yah:gotcha("THE CLAIM IS IN THE TRAIT'S OWN DOC, so this is confirmed rather than inferred. `MeshResolver` (oss/yah-base/crates/workload-spec/src/validate.rs, \\\"Resolution rules\\\") states: `MeshLookup::Url` resolves to `\\\"http://<ident>:<port>\\\"` where port is \\\"the first entry in the referenced workload's `MeshExpose.ports`\\\", and `MeshLookup::Port` is \\\"the first port stringified\\\". That is precisely the index-guess `kamaji::name_anonymous_ports` refuses to make and that R844-F15's `ServiceRecordFanout::port_for` was rewritten to stop making — an ingress rule resolved off declaration order can publish a hostname at a metrics listener, and this path can hand a DEPENDENT WORKLOAD the same wrong number in its environment. Nothing has hit it because no fronted or depended-on workload declares two ports yet; that is the same reason F15 gave for the passway gap it left, and it stops being true the moment someone uses R844-F17's new spelling.")
1110/// @yah:next("THIS IS NOW FIXABLE, WHICH IS WHY IT IS FILED — before R844-F17 a manifest could not name a port, so \\\"first\\\" was the only selector available and the doc was describing a limitation rather than a bug. `MeshExpose::named_numbers()` and `kamaji::declared_port_names()` now give a name-keyed answer.")
1111/// @yah:next("SHAPE: add a named variant to `MeshLookup` (e.g. `Port { name: String }` / `Url { name: String }`) and make the unnamed forms resolve through the `http` rule instead of index 0 — i.e. one port resolves as today, several resolve to `http` if one is named that, and NONE otherwise. Returning an error when several ports are unnamed is the whole point: it sends the author to the manifest rather than handing a dependent a plausible wrong number. MIND THE WIRE: `MeshLookup` rides `EnvValue::FromMesh` inside `WorkloadSpec`, which crosses the postcard kamaji UDS — see the V6/V7 stanza in oss/kamaji/crates/kamaji-proto/src/version.rs. Adding a field to an existing variant is a bump; appending a whole new variant is not.")
1112/// @yah:handoff("FIXED — the positional guess is gone from the env-injection path. `MeshLookup` gained `UrlNamed { name }` / `PortNamed { name }`, APPENDED rather than added as fields on `Url`/`Port` exactly as the ticket instructed, so every existing postcard encoding stays byte-identical (an enum is encoded by variant index) and NO ProtocolVersion bump was needed — verified by the kamaji-proto codec suite passing untouched. The unnamed forms no longer mean \"index 0\": they resolve through `workload_spec::validate::select_mesh_port`, which takes the sole port when there is one, else the port named `http`, else RETURNS AN ERROR. `MeshLookup` also lost `Copy` (it now owns a String); the one call site that relied on it is `resolve_env_from_mesh`, now cloning.")
1113/// @yah:handoff("THE RULE LIVES IN ONE PLACE, which is the actual repair — the bug was not that a rule was wrong, it was that the rule was RE-DERIVED at every site, so all of them agreed about something false. `select_mesh_port(ident, &BTreeMap<String,u16>, &MeshLookup)` in workload-spec is now the only implementation; yubaba `StateMeshResolver` calls it, the workload-spec test fake calls it (it had been carrying its OWN copy of \"the first entry\", which is why the test suite confirmed the bug rather than catching it), and the `MeshResolver` trait doc now REQUIRES implementations to route through it instead of describing the rule for them to copy. A named lookup deliberately does NOT fall back to `http`: that would be the positional guess wearing a name.")
1114/// @yah:handoff("REQUIRED A TYPE CHANGE THE TICKET DID NOT NAME, and it is where the names were actually being lost: `yubaba::deploy::mesh_resolve::MeshAddress.ports` was a `Vec<u16>`. A bare number list CANNOT answer \"which of these is the API port\", so positional resolution was not a shortcut in that file — it was the only thing the type permitted, and the trait doc had written that limitation down as a rule. It is now the same `BTreeMap<String,u16>` that `kamaji::WorkloadState::ports` and `ServiceRecord::resolved_ports` already carry, so a name survives from manifest to dependent environment. Cheap to change: `MeshAddress` is constructed in exactly one file and only by tests.")
1115/// @yah:handoff("ONE CROSS-CRATE CONSTANT, pinned rather than duplicated silently. `select_mesh_port` needs the default port name and CANNOT import `kamaji::DEFAULT_PORT_NAME` — kamaji sits above workload-spec in the publish DAG (`yah-base <- {qed,kamaji} <- yubaba`), so the dep would invert the graph. So `workload_spec::validate::DEFAULT_PORT_NAME` states it, and `kamaji::tests::default_port_name_agrees_with_the_mesh_resolver` asserts all three spellings (kamaji DEFAULT_PORT_NAME, ports::HTTP, the validate const) are one string. Without that pin a future rename would make a dependent `FromMesh` URL and its own `PORT` env disagree about which listener is the default, silently.")
1116/// @yah:verify("cargo test --manifest-path oss/yah-base/Cargo.toml -p yah-workload-spec = 146/0 + 94/0 (87 before, so +7). The behaviour change is pinned by name: `several_unnamed_ports_is_an_error_not_the_first_one` (the case that previously rendered 5432 out of [5432,9100] and had a test LOCKING THAT IN), `several_ports_resolve_through_http_when_one_is_named_that`, `a_named_lookup_selects_that_port_and_nothing_else`, `a_named_lookup_for_an_absent_port_errors_rather_than_falling_back`, `a_sole_port_resolves_whatever_it_is_called` (sole beats the http rule on purpose — no ambiguity exists with one listener), `an_empty_port_map_is_no_ports_not_ambiguous`, `host_resolves_for_a_portless_peer`.")
1117/// @yah:verify("cargo test --manifest-path oss/yubaba/Cargo.toml -p yubaba --lib = 634 passed / 0 failed (632 before). Two new cases run the PRODUCTION resolver, not the fake: `several_unnamed_ports_error_rather_than_resolving_to_the_first` and `a_named_lookup_selects_that_port_through_the_state_resolver`. THREE PRE-EXISTING TESTS ASSERTED THE BUG and were rewritten rather than worked around — `url_renders_first_port_with_http_prefix` / `port_renders_first_port_as_string` (both crates) took a two-port peer and asserted the first one came back; their fixtures are now single-port and the multi-port case is its own explicitly-named test.")
1118/// @yah:verify("WHOLE-TREE on a settled tree: cargo test --manifest-path oss/kamaji/Cargo.toml --workspace --all-features = every target ok (kamaji lib 185/0, kamaji-bin 278/0, kamaji-proto codec suite untouched and green — the no-wire-bump evidence); yah-cloud --lib 1019/0/4 ignored; yah-local-driver --lib 99/0; cargo test -p yah --lib 1364 passed / 0 failed / 1 ignored; cargo check --workspace --all-targets = ZERO errors. R844 PURITY CANARY HELD: cargo test -p xtask --test main mirror_ingress = 11 passed / 0 failed.")
1119/// @yah:gotcha("MEASURED SCOPE, so nobody over- or under-reads this fix: THE FromMesh ENV PATH HAS NO PRODUCTION CALLER TODAY. `resolve_env_from_mesh` is called only from workload-spec own tests; `StateMeshResolver` is constructed only in its own module tests; and kamaji-bin `resolve_env` (native.rs:208) REFUSES an unresolved `EnvValue::FromMesh` outright with \"Yubaba must resolve mesh refs before dispatching to Kamaji\" — but nothing in yubaba calls the resolver on the deploy path. So the wrong rule had not yet handed a real workload a wrong number; it was a loaded gun, not a fired one. That is also why the fix was cheap (no call sites to migrate) and why it was worth doing NOW rather than after the path is wired.")
1120/// @yah:gotcha("GENERATED ARTIFACTS REGENERATED, and they carry MORE than this ticket. `.yah/schema/workload.toml.schema.json` and `packages/yah/workload-spec/index.ts` are generated from these Rust types and the pre-commit regen was disabled 2026-08-15, so they had gone stale at R844-F17 — the TS binding still said `ports: Array<number>` weeks after `MeshExpose.ports` became `Vec<MeshPort>`. Running `cargo run -p xtask -- emit-schemas` and the workload-spec `export-ts` bin swept BOTH F17 drift and this ticket. Diff is confined to the two expected surfaces (`MeshExpose.ports`, `MeshLookup`) and nothing else. Flagging per the shared-tree rule that a derived file is nobody property: whoever owns R844-F17 should know their type change is now reflected in the bindings.")
1121pub fn resolve_env_from_mesh(
1122 env: &[EnvVar],
1123 resolver: &dyn MeshResolver,
1124) -> Result<Vec<EnvVar>, MeshError> {
1125 env.iter()
1126 .map(|var| match &var.value {
1127 EnvValue::FromMesh { ident, kind } => {
1128 let value = resolver.resolve(ident, kind.clone())?;
1129 Ok(EnvVar {
1130 name: var.name.clone(),
1131 value: EnvValue::Literal { value },
1132 })
1133 }
1134 _ => Ok(var.clone()),
1135 })
1136 .collect()
1137}
1138
1139/// Run shape then semantic validation in the correct order.
1140///
1141/// Shape always runs first. If shape fails, `WorkloadValidationError::Shape`
1142/// is returned and semantic checks are skipped — callers never see a
1143/// `Semantic` error for a structurally invalid spec.
1144///
1145/// `machine_id` is forwarded to the capacity admission-control check.
1146/// `batch` is the set of co-deployed mesh idents for forward-reference
1147/// resolution; pass `&[]` for single-spec deployment.
1148pub fn all(
1149 spec: &WorkloadSpec,
1150 ctx: &dyn ValidationContext,
1151 machine_id: &MachineId,
1152 batch: &[MeshIdent],
1153) -> Result<(), WorkloadValidationError> {
1154 shape(spec)?;
1155 semantic(spec, ctx, machine_id, batch)
1156}