Skip to main content

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 &quot;the first entry in expose.mesh.ports&quot; — 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://&lt;ident&gt;:&lt;port&gt;\\\"` 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}