Skip to main content

caixa_core/
layout.rs

1//! Layout invariants — the Rust-enforced package structure.
2//!
3//! This is the caixa analog of Cargo's implicit `src/lib.rs` vs `src/main.rs`
4//! rule: the Rust type system dictates the package shape, and the invariant
5//! checker runs before any build step. [`StandardLayout`] encodes the
6//! canonical layout:
7//!
8//! - `caixa.lisp`           — always required
9//! - `lib/<nome>.lisp`      — required when `:kind Biblioteca` and
10//!                            `:bibliotecas` is empty
11//! - each `:bibliotecas`    — must resolve on disk
12//! - each `:exe`            — must resolve on disk, under `exe/`
13//! - each `:servicos`       — must resolve on disk, under `servicos/`
14//!
15//! Filesystem I/O is injected through [`StandardLayout::with_path_exists`]
16//! so tests can run without touching disk.
17
18use std::path::{Path, PathBuf};
19use std::sync::Arc;
20
21use thiserror::Error;
22
23use crate::{Caixa, CaixaKind};
24
25/// Contract — a caixa layout checker.
26pub trait LayoutInvariants {
27    /// Verify every declared path resolves + kind-specific invariants hold.
28    fn verify(&self, caixa: &Caixa, root: &Path) -> Result<(), LayoutError>;
29}
30
31type ExistsFn = Arc<dyn Fn(&Path) -> bool + Send + Sync>;
32
33/// The default layout contract.
34#[derive(Default, Clone)]
35pub struct StandardLayout {
36    path_exists: Option<ExistsFn>,
37}
38
39impl StandardLayout {
40    #[must_use]
41    pub fn new() -> Self {
42        Self::default()
43    }
44
45    /// Override how file existence is tested. Useful for in-memory tests.
46    #[must_use]
47    pub fn with_path_exists<F>(mut self, f: F) -> Self
48    where
49        F: Fn(&Path) -> bool + Send + Sync + 'static,
50    {
51        self.path_exists = Some(Arc::new(f));
52        self
53    }
54
55    fn exists(&self, p: &Path) -> bool {
56        self.path_exists
57            .as_ref()
58            .map_or_else(|| p.exists(), |f| f(p))
59    }
60}
61
62impl std::fmt::Debug for StandardLayout {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        f.debug_struct("StandardLayout")
65            .field("custom_exists", &self.path_exists.is_some())
66            .finish()
67    }
68}
69
70impl LayoutInvariants for StandardLayout {
71    fn verify(&self, caixa: &Caixa, root: &Path) -> Result<(), LayoutError> {
72        let manifest = root.join("caixa.lisp");
73        if !self.exists(&manifest) {
74            return Err(LayoutError::MissingManifest(manifest));
75        }
76
77        // Caixa-identity value-shape gates on the two universal axes
78        // (`:nome`, `:versao`) every substrate-side artifact's
79        // `metadata.name` / version derivation flows through. The
80        // [`Caixa::validate_nome`] / [`Caixa::validate_versao`] doc-
81        // comments name the canonical authoring footguns verbatim —
82        // `:nome` "MyApp" / "my_app" / "team.app" / "-app" / "café"
83        // (DNS-1123 violations the K8s apiserver refuses at admission
84        // time on every derived `metadata.name`: `lareira-<nome>`,
85        // programs.yaml entry, CiliumNetworkPolicy / HTTPRoute names,
86        // `LABEL_APLICACAO` value); `:versao` "0.1" / "v0.1.0" / "latest"
87        // / "^0.1" / "0.1.0.0" (SemVer-2 violations Helm / OCI tag /
88        // `feira publish` git tag / lacre `concrete_versao` /
89        // `:upgrade-from :from` peer matching each refuse downstream).
90        // Until this wire-up landed both validators existed as `pub fn`
91        // on [`Caixa`] (with full per-arm test coverage in
92        // `manifest::tests`) but no production code path called them —
93        // `feira build` (the canonical author-time gate) silently
94        // accepted a malformed `:nome` / `:versao` and the failure
95        // surfaced at `helm install` / `kubectl apply` / `feira publish`
96        // time on the *first* downstream consumer to strict-parse the
97        // value, far from the source `caixa.lisp` and without any field
98        // naming the offending Caixa identity axis. The gate runs
99        // *after* [`LayoutError::MissingManifest`] (no caixa to check
100        // when the manifest is missing) and *before* every kind-coherence
101        // gate (each of which carries `caixa.nome` verbatim in its
102        // diagnostic — running them first on a structurally-invalid
103        // identity would surface a "this kind has slot X" diagnostic
104        // against an unrecoverable name). Cross-axis precedence is
105        // `:nome` → `:versao` — the canonical declaration order on
106        // [`Caixa`] and the same author-grep ordering the
107        // [`ManifestError`] family uses. Same per-axis `*Violation
108        // { caixa, issue }` envelope every peer per-axis wrap exposes
109        // ([`LayoutError::CodePathViolation`] b868442,
110        // [`LayoutError::LimitsViolation`] / [`LayoutError::BehaviorViolation`]
111        // / [`LayoutError::UpgradeViolation`] / [`LayoutError::SupervisorViolation`]
112        // / [`LayoutError::AplicacaoViolation`]).
113        caixa
114            .validate_nome()
115            .map_err(|err| LayoutError::NomeViolation {
116                caixa: caixa.nome().to_string(),
117                issue: err.to_string(),
118            })?;
119        // `:nome`-side joint-length budget on the canonical
120        // `lareira-<nome>` chart-name shape — the second arm on the
121        // shared `:nome` axis after the bare-DNS-1123 gate above. Runs
122        // through the same [`LayoutError::NomeViolation`] envelope so
123        // every per-axis diagnostic on `:nome` carries one wrap shape,
124        // peer with the [`Caixa::validate_nome`] → `NomeInvalid`
125        // routing already at this site. The chart-name budget is the
126        // second-axis ceiling [`Caixa::validate_nome`] cannot see — a
127        // 56-byte DNS-1123-valid `:nome` passes the bare-`:nome` shape
128        // but produces a 64-byte `lareira-<nome>` chart name the
129        // apiserver / `helm lint` rejects at admission, far from the
130        // source `caixa.lisp` and naming none of the joint-length
131        // overflow's three carriers (DNS-1123 cap, prefix, `:nome`
132        // length). Closing it at this wire-up turns the
133        // [`lareira_chart_name`] doc-comment's explicit M4-admission
134        // deferral (caixa-core/src/render.rs:3198) into a build-time
135        // structural property of every emitted artifact.
136        caixa
137            .validate_nome_chart_name_budget()
138            .map_err(|err| LayoutError::NomeViolation {
139                caixa: caixa.nome().to_string(),
140                issue: err.to_string(),
141            })?;
142        caixa
143            .validate_versao()
144            .map_err(|err| LayoutError::VersaoViolation {
145                caixa: caixa.nome().to_string(),
146                issue: err.to_string(),
147            })?;
148
149        // `:deps` / `:deps-dev` per-entry shape gate. The third Caixa-
150        // level orphan validator on the universal authoring surface (peer
151        // of [`Caixa::validate_nome`] / [`Caixa::validate_versao`] wired
152        // immediately above): [`Caixa::validate_deps`] walks every
153        // [`Dep::validate`] arm — empty / non-DNS-1123 `:nome`, empty /
154        // unparseable `:versao` requirement, malformed `:fonte` repo /
155        // pin / `:caminho`, malformed `:caracteristicas` Cargo-feature
156        // name (de68c0c) — and then closes the per-list set-not-multiset
157        // duplicate-`:nome` invariant on each of `:deps` and `:deps-dev`
158        // (359fba5). Until this wire-up landed `validate_deps` existed as
159        // `pub fn` on [`Caixa`] with full per-arm unit coverage in
160        // `manifest::tests` + `dep::tests` (validate_deps_rejects_*,
161        // 53 dep-axis tests) but no production code path called it —
162        // `feira build` (the canonical author-time gate;
163        // `caixa-feira/src/cmd/build.rs:29` routes through
164        // `StandardLayout::verify`) silently accepted a malformed `:deps`
165        // entry and the failure surfaced at the *first* downstream
166        // consumer to strict-parse it: at lacre-resolve time as a
167        // `semver::Error` not naming the offending dep (`:versao` per-
168        // entry); at `git clone` time as a fetch failure quoting the
169        // shell-escape `repo` (`:fonte :repo`); at the resolver's
170        // `HashMap<:nome>` collapse as a silent "second-wins" overwrite
171        // (within-list `:nome` duplicate); at `cargo metadata` time as a
172        // feature-name rejection on the *target* caixa rather than the
173        // dep entry referencing it (`:caracteristicas`); at `helm
174        // install` / `kubectl apply` time as an apiserver `metadata.name`
175        // rejection on the rendered `lareira-<nome>` chart's per-dep
176        // derivation (DNS-1123-violating `:deps :nome`) — each far from
177        // the source `caixa.lisp`, none naming the offending `:deps` /
178        // `:deps-dev` axis. Runs *after* the Caixa-identity gates (the
179        // diagnostic carries `caixa.nome().to_string()` verbatim, which the
180        // peer [`Caixa::validate_nome`] gate above has just guaranteed is
181        // a valid DNS-1123 label) and *before* every kind-coherence gate
182        // (the dep surface is universal — every kind has `:deps` /
183        // `:deps-dev` — so its shape diagnostic is more fundamental than
184        // the kind-coherence partitions on `:bibliotecas` / `:exe` /
185        // `:servicos` / `:membros` / `:children` / M2 slots that follow).
186        // Same per-axis `*Violation { caixa, issue }` envelope every peer
187        // per-axis wrap exposes ([`LayoutError::NomeViolation`] /
188        // [`LayoutError::VersaoViolation`] (1f74a5f),
189        // [`LayoutError::CodePathViolation`] (b868442),
190        // [`LayoutError::LimitsViolation`] / [`LayoutError::BehaviorViolation`]
191        // / [`LayoutError::UpgradeViolation`] / [`LayoutError::SupervisorViolation`]
192        // / [`LayoutError::AplicacaoViolation`]). Threads [`DepError`]
193        // Display through verbatim — every per-arm reason already names
194        // the offending dep's `:nome` (e.g. `":deps entry "caixa-teia"
195        // :versao "^bad" is not a valid semver requirement: …"`), so the
196        // wrap envelope's `issue` carries a self-locating "which dep,
197        // which axis, why" without re-shaping the per-arm parser-side
198        // reason. With this wire-up the canonical author-time gate
199        // refuses every ill-formed `:deps` / `:deps-dev` value-shape by
200        // construction — closing the second-to-last orphan-validator gap
201        // on the typed Caixa surface (`validate_restart_window` is the
202        // remaining orphan, Supervisor-axis specific and wired into the
203        // Supervisor branch below alongside `view.validate()`).
204        // Compound per-Caixa entry gate on the dep-graph axis: the
205        // layout pipeline's two-dispatch `:deps` / `:deps-dev` cascade
206        // — the per-entry + within-list duplicate-`:nome` gate (the
207        // [`crate::Dep::validate`] + [`crate::render::insert_first_seen`]
208        // cascade `Caixa::validate_deps` opened on, 359fba5) and the
209        // cross-slot self-edge gate
210        // ([`crate::dep::validate_no_self_dep`], ad4abf1) — folded
211        // onto the [`crate::Caixa::validate_deps`] substrate primitive.
212        // The two arms run in the same canonical order at the primitive
213        // (per-entry + cross-entry duplicate → cross-slot self-edge) so
214        // the fold is byte-for-byte equivalent to the pre-fold
215        // two-block cascade this call site formerly carried, pinned by
216        // the paired
217        // `validate_deps_folds_{per_entry,self_edge}_arm_matches_gate`
218        // equivalence pins and the
219        // `validate_deps_per_entry_arm_fires_before_self_edge_arm`
220        // ordering pin in the [`crate::Caixa::validate_deps`] pin
221        // family (`manifest.rs`).
222        //
223        // Same lift discipline the peer per-slot compound gates
224        // ([`crate::AplicacaoSpec::validate_contratos`] and its
225        // `:membros` / `:entrada` / `:placement` / `:politicas` peers,
226        // [`crate::MeshPolicy::validate`],
227        // [`crate::SupervisorSpec::validate_children`],
228        // [`crate::Caixa::validate_upgrade_from`] d6801df) each carry —
229        // one named substrate-primitive gate per typed slot folds every
230        // structural + cross-slot axis on that slot onto one call, so
231        // every future consumer that wants to re-check the dep-graph
232        // after a per-entry patch (the deferred
233        // `caixa.pleme.io/v1alpha1/Caixa` CR materializer's admission
234        // webhook, a future `feira validate --deps` per-caixa admission
235        // verb, a per-`:deps` overlay resolver) reaches the two-arm
236        // compound gate through one dispatch rather than re-inlining
237        // the two-dispatch cascade in lockstep with this wire-up.
238        caixa
239            .validate_deps()
240            .map_err(|err| LayoutError::DepsViolation {
241                caixa: caixa.nome().to_string(),
242                issue: err.to_string(),
243            })?;
244
245        // `:etiquetas` per-entry empty + cross-entry duplicate gate. The
246        // fourth universal-axis Caixa-level value-shape gate (peer of
247        // [`Caixa::validate_nome`] / [`Caixa::validate_versao`] /
248        // [`Caixa::validate_deps`] wired immediately above and
249        // [`Caixa::validate_code_paths`] wired below the kind-coherence
250        // gates) on the typed Caixa surface. `:etiquetas` is the
251        // registry-search-tag axis every kind carries (universal
252        // `Vec<String>` slot on [`Caixa`]) and lands verbatim as the
253        // Helm chart `Chart.yaml` `keywords:` array on every Servico
254        // (`caixa-helm/src/lib.rs:236` folds it through a
255        // [`std::collections::BTreeSet`]). Until this wire-up landed
256        // `:etiquetas` had no shape gate at any layer — an empty entry
257        // (`(:etiquetas (""))` — the canonical paste-from-blank-doc
258        // footgun) silently rendered as `keywords: [""]` in `Chart.yaml`,
259        // and duplicate entries (`(:etiquetas ("demo" "demo"))` — the
260        // copy-paste-the-wrong-tag footgun) were silently dedup'd by
261        // the renderer's `BTreeSet` collect — a "second wins / one
262        // silently disappears" shape divergent from every peer typed-
263        // graph set gate (`:membros :caixa`, `:placement :clusters`,
264        // `:entrada :paths`, `:contratos`, `:deps :nome`,
265        // `:upgrade-from :from`, the per-instruction-class singularity
266        // gates on `:upgrade-from :instructions`). Runs *after* the
267        // peer universal `:nome` / `:versao` / `:deps` gates (declaration
268        // order on [`Caixa`] is `:nome` → `:versao` → `:edicao` →
269        // `:descricao` → `:repositorio` → `:licenca` → `:autores` →
270        // `:etiquetas` → `:deps` → `:deps-dev`, but the gate order
271        // follows the same identity-axis-first cascade the peer gates
272        // establish: `:nome` → `:versao` are the load-bearing identity
273        // axes that flow into every diagnostic's caixa prefix, and
274        // `:deps` is the universal dep surface that dominates every
275        // kind-coherence gate; `:etiquetas` runs after this trio so the
276        // diagnostic carries an already-validated `:nome` and the
277        // peer universal axes' narrower diagnostics surface first when
278        // multiple axes are malformed) and *before* the kind-coherence
279        // gates ([`Self::MeshSlotsOnNonAplicacao`] /
280        // [`Self::SupervisorSlotsOnNonSupervisor`] /
281        // [`Self::ServicoSlotsOnNonServico`] / [`Self::ForeignCodeSlot`])
282        // — `:etiquetas` is universal so its shape diagnostic is more
283        // fundamental than the kind-coherence partitions on kind-
284        // exclusive slot sets.
285        //
286        // Same per-axis `*Violation { caixa, issue }` envelope every peer
287        // per-axis wrap exposes ([`Self::NomeViolation`] /
288        // [`Self::VersaoViolation`] 1f74a5f, [`Self::DepsViolation`]
289        // aa77d0f, [`Self::CodePathViolation`] b868442,
290        // [`Self::RestartWindowViolation`] 10e321a). Threads
291        // [`ManifestError::EtiquetaEmpty`] / [`ManifestError::EtiquetaDuplicate`]
292        // Display through verbatim — each per-arm reason already names
293        // the offending tag (for the duplicate arm) or the structural
294        // "empty entry" defect (for the empty arm), so the wrap
295        // envelope's `issue` carries a self-locating "which axis, which
296        // entry, why" without re-shaping the per-arm reason.
297        caixa
298            .validate_etiquetas()
299            .map_err(|err| LayoutError::EtiquetasViolation {
300                caixa: caixa.nome().to_string(),
301                issue: err.to_string(),
302            })?;
303
304        // `:autores` per-entry empty + cross-entry duplicate gate. The
305        // fifth universal-axis Caixa-level value-shape gate (peer of
306        // [`Caixa::validate_nome`] / [`Caixa::validate_versao`] /
307        // [`Caixa::validate_deps`] / [`Caixa::validate_etiquetas`] wired
308        // immediately above and [`Caixa::validate_code_paths`] wired
309        // below the kind-coherence gates) on the typed Caixa surface.
310        // `:autores` is the maintainer-axis every kind carries
311        // (universal `Vec<String>` slot on [`Caixa`]) and lands verbatim
312        // as the Helm chart `Chart.yaml` `maintainers:` array on every
313        // Servico (`caixa-helm/src/lib.rs:251` maps each entry to a
314        // `Maintainer { name, email: None }` without dedup). Until this
315        // wire-up landed `:autores` had no shape gate at any layer — an
316        // empty entry (`(:autores (""))` — the canonical paste-from-
317        // blank-doc footgun) silently rendered as
318        // `maintainers: [{name: "", email: null}]` in `Chart.yaml`, and
319        // duplicate entries (`(:autores ("pleme-io" "pleme-io"))` —
320        // the copy-paste-the-wrong-author footgun) stacked verbatim in
321        // the chart. Unlike the peer `:etiquetas` axis (where the
322        // renderer's `BTreeSet` collect silently dedups the `keywords:`
323        // array at chart render — a "second wins / one silently
324        // disappears" shape), `maintainers:` has *no* renderer-side
325        // dedup, so duplicate `:autores` entries render as two identical
326        // maintainer records by construction — a strictly worse footgun
327        // than the peer `:etiquetas` shape. Runs *after* the peer
328        // universal `:nome` / `:versao` / `:deps` / `:etiquetas` gates
329        // (the gate order follows the canonical identity-axis-first
330        // cascade the peer gates establish; `:autores` and `:etiquetas`
331        // are the two Vec-shaped universal metadata axes — they sit
332        // adjacent in the cascade after the load-bearing identity +
333        // dep trio) and *before* the kind-coherence gates
334        // ([`Self::MeshSlotsOnNonAplicacao`] /
335        // [`Self::SupervisorSlotsOnNonSupervisor`] /
336        // [`Self::ServicoSlotsOnNonServico`] / [`Self::ForeignCodeSlot`])
337        // — `:autores` is universal so its shape diagnostic is more
338        // fundamental than the kind-coherence partitions on kind-
339        // exclusive slot sets.
340        //
341        // Same per-axis `*Violation { caixa, issue }` envelope every peer
342        // per-axis wrap exposes ([`Self::NomeViolation`] /
343        // [`Self::VersaoViolation`] 1f74a5f, [`Self::DepsViolation`]
344        // aa77d0f, [`Self::EtiquetasViolation`] 360a499,
345        // [`Self::CodePathViolation`] b868442,
346        // [`Self::RestartWindowViolation`] 10e321a). Threads
347        // [`ManifestError::AutorEmpty`] / [`ManifestError::AutorDuplicate`]
348        // Display through verbatim — each per-arm reason already names
349        // the offending author (for the duplicate arm) or the structural
350        // "empty entry" defect (for the empty arm), so the wrap
351        // envelope's `issue` carries a self-locating "which axis, which
352        // entry, why" without re-shaping the per-arm reason.
353        caixa
354            .validate_autores()
355            .map_err(|err| LayoutError::AutoresViolation {
356                caixa: caixa.nome().to_string(),
357                issue: err.to_string(),
358            })?;
359
360        // `:repositorio` git-repo-URL shape gate. The sixth
361        // universal-axis Caixa-level value-shape gate (peer of
362        // [`Caixa::validate_nome`] / [`Caixa::validate_versao`] /
363        // [`Caixa::validate_deps`] / [`Caixa::validate_etiquetas`] /
364        // [`Caixa::validate_autores`] wired immediately above and
365        // [`Caixa::validate_code_paths`] wired below the kind-coherence
366        // gates) on the typed Caixa surface. `:repositorio` is the
367        // universal git-shaped homepage axis every kind carries
368        // (universal `Option<String>` slot on [`Caixa`]) and routes
369        // through two load-bearing substrate consumers:
370        // [`caixa-helm`] folds it verbatim into the rendered
371        // `lareira-<nome>` Helm chart's `Chart.yaml` `home:` field
372        // (`build_chart_yaml` at `caixa-helm/src/lib.rs:268`) and into
373        // the chart `README.md` `repo = …` interpolation
374        // (`caixa-helm/src/lib.rs:359`); [`caixa-flux`] folds it
375        // verbatim into the standalone `ClusterBundleOpts::for_caixa`
376        // `git_url:` field (`caixa-flux/src/lib.rs:293`), which
377        // becomes the FluxCD `GitRepository.spec.url` the cluster's
378        // source-controller polls — the load-bearing deploy-time axis.
379        // Both consumers use `Option::unwrap_or_else(|| <fallback>)`
380        // to substitute a placeholder when the slot is absent (`None`
381        // → the fallback fires); a `Some("")` *skips the fallback*
382        // and silently passes the empty string through to
383        // `Chart.yaml home: ""` / `GitRepository url: ""`. Until this
384        // wire-up landed `:repositorio` had no shape gate at any
385        // layer — empty (`(:repositorio "")` — the canonical
386        // paste-from-blank-doc footgun) and malformed (whitespace,
387        // control char / CRLF, leading `-` CLI-arg-injection,
388        // missing `:` separator) values silently landed in the
389        // rendered artifacts and broke at `helm template` / FluxCD
390        // reconcile time far from the source `caixa.lisp`.
391        //
392        // Runs *after* the peer universal `:nome` / `:versao` /
393        // `:deps` / `:etiquetas` / `:autores` gates (the gate order
394        // follows the canonical identity-axis-first cascade the peer
395        // gates establish; `:repositorio` is the universal git-URL
396        // axis — it sits adjacent to `:autores` in the cascade after
397        // the load-bearing identity + dep trio + the two Vec-shaped
398        // universal metadata axes) and *before* the kind-coherence
399        // gates ([`Self::MeshSlotsOnNonAplicacao`] /
400        // [`Self::SupervisorSlotsOnNonSupervisor`] /
401        // [`Self::ServicoSlotsOnNonServico`] / [`Self::ForeignCodeSlot`])
402        // — `:repositorio` is universal so its shape diagnostic is
403        // more fundamental than the kind-coherence partitions on
404        // kind-exclusive slot sets.
405        //
406        // Same per-axis `*Violation { caixa, issue }` envelope every
407        // peer per-axis wrap exposes ([`Self::NomeViolation`] /
408        // [`Self::VersaoViolation`] 1f74a5f, [`Self::DepsViolation`]
409        // aa77d0f, [`Self::EtiquetasViolation`] 360a499,
410        // [`Self::AutoresViolation`] 86c769b, [`Self::CodePathViolation`]
411        // b868442, [`Self::RestartWindowViolation`] 10e321a). Threads
412        // [`ManifestError::RepositorioEmpty`] /
413        // [`ManifestError::RepositorioInvalid`] Display through
414        // verbatim — each per-arm reason already names the offending
415        // `:repositorio` value (for the invalid arm) or the
416        // structural "empty entry" defect (for the empty arm), so
417        // the wrap envelope's `issue` carries a self-locating "which
418        // axis, which value, why" without re-shaping the per-arm
419        // reason. With this gate the two `git URL`-shaped surfaces on
420        // the typed Caixa (`:repositorio` here, `:deps :fonte :repo`
421        // peer routed through the same shared
422        // [`crate::render::is_git_repo_url`] predicate via
423        // [`crate::DepSource::validate`]) are now structurally
424        // equivalent — every value past validate is
425        // guaranteed-acceptable by the shared predicate's constraint
426        // union, by construction.
427        caixa
428            .validate_repositorio()
429            .map_err(|err| LayoutError::RepositorioViolation {
430                caixa: caixa.nome().to_string(),
431                issue: err.to_string(),
432            })?;
433
434        // `:descricao` non-empty shape gate. The seventh universal-
435        // axis Caixa-level value-shape gate (peer of
436        // [`Caixa::validate_nome`] / [`Caixa::validate_versao`] /
437        // [`Caixa::validate_deps`] / [`Caixa::validate_etiquetas`] /
438        // [`Caixa::validate_autores`] / [`Caixa::validate_repositorio`]
439        // wired immediately above and [`Caixa::validate_code_paths`]
440        // wired below the kind-coherence gates) on the typed Caixa
441        // surface. `:descricao` is the universal free-form-prose
442        // summary axis every kind carries (universal `Option<String>`
443        // slot on [`Caixa`]) and routes through two load-bearing
444        // [`caixa-helm`] consumers: `build_chart_yaml` folds it
445        // verbatim into the rendered `lareira-<nome>` Helm chart's
446        // `Chart.yaml` `description:` field
447        // (`caixa-helm/src/lib.rs:232-235`), and `build_readme` folds
448        // it verbatim into the chart `README.md` header
449        // (`caixa-helm/src/lib.rs:333-336`). Both consumers use
450        // `Option::unwrap_or_else(|| <fallback>)` to substitute a
451        // `caixa.nome`-derived placeholder when the slot is absent
452        // (`None` → the fallback fires); a `Some("")` *skips the
453        // fallback* and silently passes the empty string through to
454        // `Chart.yaml description: ""` / a blank `README.md` header
455        // — exact same footgun shape as the peer `:repositorio`
456        // surface above. Until this wire-up landed `:descricao` had
457        // no shape gate at any layer — the empty
458        // (`(:descricao "")` — the canonical paste-from-blank-doc
459        // footgun) silently landed in the rendered artifacts and
460        // broke at `helm lint` time (`WARNING [chart.metadata.description]:
461        // description is required` on `apiVersion: v2` charts) far
462        // from the source `caixa.lisp`.
463        //
464        // Runs *after* the peer universal `:nome` / `:versao` /
465        // `:deps` / `:etiquetas` / `:autores` / `:repositorio` gates
466        // (the gate order follows the canonical identity-axis-first
467        // cascade the peer gates establish; `:descricao` is the
468        // universal free-form-prose axis — it sits adjacent to
469        // `:repositorio` in the cascade after the load-bearing
470        // identity + dep trio + the two Vec-shaped universal
471        // metadata axes + the universal git-URL axis) and *before*
472        // the kind-coherence gates ([`Self::MeshSlotsOnNonAplicacao`]
473        // / [`Self::SupervisorSlotsOnNonSupervisor`] /
474        // [`Self::ServicoSlotsOnNonServico`] /
475        // [`Self::ForeignCodeSlot`]) — `:descricao` is universal so
476        // its shape diagnostic is more fundamental than the kind-
477        // coherence partitions on kind-exclusive slot sets.
478        //
479        // Same per-axis `*Violation { caixa, issue }` envelope every
480        // peer per-axis wrap exposes ([`Self::NomeViolation`] /
481        // [`Self::VersaoViolation`] 1f74a5f, [`Self::DepsViolation`]
482        // aa77d0f, [`Self::EtiquetasViolation`] 360a499,
483        // [`Self::AutoresViolation`] 86c769b,
484        // [`Self::RepositorioViolation`] 577b0a9,
485        // [`Self::CodePathViolation`] b868442,
486        // [`Self::RestartWindowViolation`] 10e321a). Threads
487        // [`ManifestError::DescricaoEmpty`] Display through verbatim
488        // — the per-arm reason already names the offending
489        // `:descricao` slot + cites the renderer-side footgun, so
490        // the wrap envelope's `issue` carries a self-locating
491        // "which axis, why" without re-shaping the per-arm reason.
492        caixa
493            .validate_descricao()
494            .map_err(|err| LayoutError::DescricaoViolation {
495                caixa: caixa.nome().to_string(),
496                issue: err.to_string(),
497            })?;
498
499        // `:licenca` non-empty shape gate. The eighth universal-axis
500        // Caixa-level value-shape gate (peer of [`Caixa::validate_nome`]
501        // / [`Caixa::validate_versao`] / [`Caixa::validate_deps`] /
502        // [`Caixa::validate_etiquetas`] / [`Caixa::validate_autores`] /
503        // [`Caixa::validate_repositorio`] / [`Caixa::validate_descricao`]
504        // wired immediately above and [`Caixa::validate_code_paths`]
505        // wired below the kind-coherence gates) on the typed Caixa
506        // surface. `:licenca` is the universal SPDX-shaped license-
507        // expression axis every kind carries (universal `Option<String>`
508        // slot on [`Caixa`]) and routes through one load-bearing
509        // [`caixa-helm`] consumer: `build_readme` folds it verbatim into
510        // the rendered `lareira-<nome>` Helm chart's `README.md` `##
511        // License` section (`caixa-helm/src/lib.rs:361`) via
512        // `caixa.licenca.clone().unwrap_or_else(|| "MIT".into())`. The
513        // consumer's fallback only fires when the slot is absent (`None`
514        // → the `MIT` fallback fires); a `Some("")` *skips the
515        // fallback* and silently passes the empty string through to a
516        // chart `README.md` whose `License` section renders as a bare
517        // trailing period — exact same footgun shape as the peer
518        // `:repositorio` (577b0a9) and `:descricao` (4e6db38) surfaces
519        // above. Until this wire-up landed `:licenca` had no shape
520        // gate at any layer — the empty (`(:licenca "")` — the
521        // canonical paste-from-blank-doc footgun) silently landed in
522        // the rendered chart `README.md` far from the source
523        // `caixa.lisp`.
524        //
525        // Runs *after* the peer universal `:nome` / `:versao` /
526        // `:deps` / `:etiquetas` / `:autores` / `:repositorio` /
527        // `:descricao` gates (the gate order follows the canonical
528        // identity-axis-first cascade the peer gates establish;
529        // `:licenca` sits adjacent to `:descricao` in the cascade
530        // after the load-bearing identity + dep trio + the two
531        // Vec-shaped universal metadata axes + the universal
532        // git-URL + free-form-prose axes) and *before* the kind-
533        // coherence gates ([`Self::MeshSlotsOnNonAplicacao`] /
534        // [`Self::SupervisorSlotsOnNonSupervisor`] /
535        // [`Self::ServicoSlotsOnNonServico`] /
536        // [`Self::ForeignCodeSlot`]) — `:licenca` is universal so
537        // its shape diagnostic is more fundamental than the kind-
538        // coherence partitions on kind-exclusive slot sets.
539        //
540        // Same per-axis `*Violation { caixa, issue }` envelope every
541        // peer per-axis wrap exposes ([`Self::NomeViolation`] /
542        // [`Self::VersaoViolation`] 1f74a5f, [`Self::DepsViolation`]
543        // aa77d0f, [`Self::EtiquetasViolation`] 360a499,
544        // [`Self::AutoresViolation`] 86c769b,
545        // [`Self::RepositorioViolation`] 577b0a9,
546        // [`Self::DescricaoViolation`] 4e6db38,
547        // [`Self::CodePathViolation`] b868442,
548        // [`Self::RestartWindowViolation`] 10e321a). Threads
549        // [`ManifestError::LicencaEmpty`] Display through verbatim
550        // — the per-arm reason already names the offending
551        // `:licenca` slot + cites the renderer-side footgun, so
552        // the wrap envelope's `issue` carries a self-locating
553        // "which axis, why" without re-shaping the per-arm reason.
554        caixa
555            .validate_licenca()
556            .map_err(|err| LayoutError::LicencaViolation {
557                caixa: caixa.nome().to_string(),
558                issue: err.to_string(),
559            })?;
560
561        // `:edicao` non-empty shape gate. The ninth (and last
562        // un-gated) universal-axis Caixa-level value-shape gate
563        // (peer of [`Caixa::validate_nome`] / [`Caixa::validate_versao`]
564        // / [`Caixa::validate_deps`] / [`Caixa::validate_etiquetas`] /
565        // [`Caixa::validate_autores`] / [`Caixa::validate_repositorio`]
566        // / [`Caixa::validate_descricao`] / [`Caixa::validate_licenca`]
567        // wired immediately above and [`Caixa::validate_code_paths`]
568        // wired below the kind-coherence gates) on the typed Caixa
569        // surface. `:edicao` is the universal language-edition axis
570        // every kind carries (universal `Option<String>` slot on
571        // [`Caixa`]) that selects the tatara-lisp macro surface +
572        // compatibility flags the substrate applies when building
573        // the caixa. The canonical [`Caixa::template`] scaffold every
574        // `feira init` emits carries `:edicao "2026"` verbatim
575        // (`caixa-core/src/manifest.rs:1193`) and every renderer-side
576        // fixture carries `edicao: Some("2026".into())` by
577        // construction (`caixa-helm/src/lib.rs:375`,
578        // `caixa-flux/src/lib.rs:445`, `caixa-mesh/src/lib.rs:629`,
579        // `caixa-core/src/render.rs:2510`). Until this wire-up landed
580        // `:edicao` had no shape gate at any layer — the empty
581        // (`(:edicao "")` — the canonical paste-from-blank-doc
582        // footgun) silently landed as a bare `(:edicao "")` line
583        // in the rendered caixa.lisp and a future renderer-side
584        // consumer that folds the value through
585        // `Option::unwrap_or_else` would skip its fallback (which
586        // only fires on `None`) and pass the empty edition through
587        // to the substrate's build-time edition selector far from
588        // the source `caixa.lisp` — exact same
589        // `Some("")`-skips-`unwrap_or_else` footgun shape as the
590        // peer `:repositorio` (577b0a9), `:descricao` (4e6db38),
591        // and `:licenca` (3d1e535) surfaces above.
592        //
593        // Runs *after* the peer universal `:nome` / `:versao` /
594        // `:deps` / `:etiquetas` / `:autores` / `:repositorio` /
595        // `:descricao` / `:licenca` gates (the gate order follows
596        // the canonical identity-axis-first cascade the peer gates
597        // establish; `:edicao` sits at the tail of the cascade
598        // after the load-bearing identity + dep trio + the two
599        // Vec-shaped universal metadata axes + the three universal
600        // `Option<String>` chart-metadata axes) and *before* the
601        // kind-coherence gates ([`Self::MeshSlotsOnNonAplicacao`] /
602        // [`Self::SupervisorSlotsOnNonSupervisor`] /
603        // [`Self::ServicoSlotsOnNonServico`] /
604        // [`Self::ForeignCodeSlot`]) — `:edicao` is universal so
605        // its shape diagnostic is more fundamental than the kind-
606        // coherence partitions on kind-exclusive slot sets.
607        //
608        // Same per-axis `*Violation { caixa, issue }` envelope every
609        // peer per-axis wrap exposes ([`Self::NomeViolation`] /
610        // [`Self::VersaoViolation`] 1f74a5f, [`Self::DepsViolation`]
611        // aa77d0f, [`Self::EtiquetasViolation`] 360a499,
612        // [`Self::AutoresViolation`] 86c769b,
613        // [`Self::RepositorioViolation`] 577b0a9,
614        // [`Self::DescricaoViolation`] 4e6db38,
615        // [`Self::LicencaViolation`] 3d1e535,
616        // [`Self::CodePathViolation`] b868442,
617        // [`Self::RestartWindowViolation`] 10e321a). Threads
618        // [`ManifestError::EdicaoEmpty`] Display through verbatim
619        // — the per-arm reason already names the offending
620        // `:edicao` slot + cites the renderer-side footgun, so
621        // the wrap envelope's `issue` carries a self-locating
622        // "which axis, why" without re-shaping the per-arm reason.
623        // With this gate every universal-axis `Option<String>`
624        // surface on the typed Caixa (`:repositorio` 577b0a9,
625        // `:descricao` 4e6db38, `:licenca` 3d1e535, `:edicao` here)
626        // now carries the same structural empty-arm gate by
627        // construction.
628        caixa
629            .validate_edicao()
630            .map_err(|err| LayoutError::EdicaoViolation {
631                caixa: caixa.nome().to_string(),
632                issue: err.to_string(),
633            })?;
634
635        // Supervisors, Aplicacaos, and Acaos don't run code; reject
636        // bibliotecas/exe/servicos declarations BEFORE checking those
637        // paths exist (which would otherwise produce a less-helpful
638        // "missing entry" error first).
639        let has_code = !caixa.bibliotecas().is_empty()
640            || !caixa.exe().is_empty()
641            || !caixa.servicos().is_empty();
642        if caixa.kind().is_supervisor() && has_code {
643            return Err(LayoutError::SupervisorOwnsCode(caixa.nome().to_string()));
644        }
645        if caixa.kind().is_aplicacao() && has_code {
646            return Err(LayoutError::AplicacaoOwnsCode(caixa.nome().to_string()));
647        }
648        // An Acao's sole payload is its `:ci` slot (CANTEIRO §7.1-C) —
649        // like Supervisor/Aplicacao it runs no code of its own, so a
650        // declared :bibliotecas/:exe/:servicos is the same "silently
651        // ignored" footgun the two gates above already close for the
652        // other two no-code kinds.
653        if caixa.kind().is_acao() && has_code {
654            return Err(LayoutError::AcaoOwnsCode(caixa.nome().to_string()));
655        }
656
657        // Kind ↔ slot coherence: the M3 mesh slots (:membros,
658        // :contratos, :politicas, :placement, :entrada) compose the
659        // typed graph of a :kind Aplicacao (MESH-COMPOSITION §III.1).
660        // `Caixa::aplicacao_view` only folds them into a validatable
661        // AplicacaoSpec when the kind is Aplicacao, and the
662        // caixa-mesh/-flux/-helm renderers only emit them for an
663        // Aplicacao — so on any *other* kind a declared mesh slot is the
664        // manifest field's documented "ignored otherwise": it silently
665        // passes verify and then vanishes (never validated, never
666        // rendered), far from the source caixa.lisp. Reject it here —
667        // before the path-existence loops — mirroring the
668        // SupervisorOwnsCode / AplicacaoOwnsCode kind-coherence gates
669        // above: a slot foreign to the kind is a build error, not a
670        // silent drop. `declared_mesh_slots` is the single typed source
671        // of the mesh-slot set + its canonical diagnostic order.
672        if !caixa.kind().is_aplicacao() {
673            let mesh_slots = caixa.declared_mesh_slots();
674            if !mesh_slots.is_empty() {
675                return Err(LayoutError::MeshSlotsOnNonAplicacao {
676                    caixa: caixa.nome().to_string(),
677                    kind: caixa.kind(),
678                    slots: mesh_slots.join(" "),
679                });
680            }
681        }
682
683        // Kind ↔ slot coherence (mirror of the mesh-slot gate above on
684        // the supervisor-tree slot set): the supervisor slots
685        // (:estrategia, :max-restarts, :restart-window, :children)
686        // compose the typed OTP supervisor of a :kind Supervisor
687        // (INSPIRATIONS §II.2). `Caixa::supervisor_view` only folds them
688        // into a validatable SupervisorSpec when the kind is Supervisor,
689        // and the wasm-operator's hierarchical reconciler only consumes
690        // them for one — so on any *other* kind a declared supervisor
691        // slot is the manifest field's documented "ignored otherwise":
692        // it silently passes verify and then vanishes (never validated,
693        // never reconciled), far from the source caixa.lisp. Reject it
694        // here — beside the mesh-slot gate, before the path-existence
695        // loops — naming the offending kind + slot(s). `declared_
696        // supervisor_slots` is the single typed source of the
697        // supervisor-slot set + its canonical diagnostic order.
698        if !caixa.kind().is_supervisor() {
699            let supervisor_slots = caixa.declared_supervisor_slots();
700            if !supervisor_slots.is_empty() {
701                return Err(LayoutError::SupervisorSlotsOnNonSupervisor {
702                    caixa: caixa.nome().to_string(),
703                    kind: caixa.kind(),
704                    slots: supervisor_slots.join(" "),
705                });
706            }
707        }
708
709        // Kind ↔ slot coherence (mirror of the mesh-slot + supervisor-slot
710        // gates above on the M2 Servico-runtime slot set): the M2 slots
711        // (:limits, :behavior, :upgrade-from) configure the runtime of a
712        // long-running wasm component, i.e. a :kind Servico — :limits is
713        // Lunatic per-process sandboxing (INSPIRATIONS §III.1), :behavior
714        // the OTP gen_server callback set (§II.3), :upgrade-from the OTP
715        // appup hot-reload table (§II.4). The caixa-helm / caixa-flux
716        // renderers gate on `require_kind(_, Servico)` and only emit these
717        // slots for a Servico — so on any *other* kind a declared M2 slot
718        // is the manifest field's documented "ignored otherwise": its
719        // well-formedness is checked by the M2 invariant blocks below, but
720        // the value is never rendered into a chart / programs.yaml entry —
721        // it silently passes verify and then vanishes, far from the source
722        // caixa.lisp. Reject it here — beside the mesh- and supervisor-slot
723        // gates, before the M2 validate blocks (which would otherwise spend
724        // their diagnostics on a value the kind can never render) — naming
725        // the offending kind + slot(s). `declared_servico_slots` is the
726        // single typed source of the M2-slot set + its canonical
727        // diagnostic order.
728        if !caixa.kind().is_servico() {
729            let servico_slots = caixa.declared_servico_slots();
730            if !servico_slots.is_empty() {
731                return Err(LayoutError::ServicoSlotsOnNonServico {
732                    caixa: caixa.nome().to_string(),
733                    kind: caixa.kind(),
734                    slots: servico_slots.join(" "),
735                });
736            }
737        }
738
739        // Kind ↔ slot coherence (mirror of the three gates above on the
740        // Acao `:ci` slot, CANTEIRO §7.1-C): `:ci` carries a typed CI
741        // run — a canteiro_types::CiRun — that only the caixa-actions
742        // renderer decomposes + validates, and only for a :kind Acao.
743        // On any *other* kind a declared `:ci` is the manifest field's
744        // documented "ignored otherwise": it silently passes verify and
745        // then vanishes (never decomposed, never rendered), far from the
746        // source caixa.lisp. Reject it here — beside the mesh-,
747        // supervisor-, and servico-slot gates — naming the offending
748        // kind. Unlike its three siblings this axis is a single
749        // `Option` field, not a Vec-of-named-slots, so the gate reads
750        // `caixa.ci().is_some()` directly rather than reaching for a
751        // `declared_*_slots()` helper.
752        if caixa.ci().is_some() && !caixa.kind().is_acao() {
753            return Err(LayoutError::CiOnNonAcao {
754                caixa: caixa.nome().to_string(),
755                kind: caixa.kind(),
756            });
757        }
758
759        // Kind ↔ slot coherence on the fourth and final axis — the
760        // code-surface slot set (the trio M2/Supervisor/Aplicacao gates
761        // above close on the M2 runtime, supervisor-tree, and M3 mesh
762        // axes; this gate closes the symmetric "kind owns this code
763        // shape" relation on `:exe` + `:servicos`). `:exe` is the nix-
764        // built executable surface owned only by Binario; `:servicos`
765        // is the wasm-component daemon surface owned only by Servico.
766        // The caixa-helm / caixa-flux / caixa-flake renderers gate on
767        // `require_kind(_, <owning-kind>)` and only emit the slot for
768        // its owning kind — so on any *other* code-running kind a
769        // declared `:exe` / `:servicos` is the manifest field's
770        // documented "ignored otherwise" (see the field docs on
771        // `Caixa::exe` + `Caixa::servicos`): the path is validated by
772        // the per-kind path-existence loops below, but the value is
773        // never rendered into a build target or programs.yaml entry —
774        // it silently passes `feira build` and then vanishes, far from
775        // the source caixa.lisp.
776        //
777        // Reject it here — beside the M2/Supervisor/Aplicacao slot
778        // gates, after the `SupervisorOwnsCode` / `AplicacaoOwnsCode`
779        // OwnCode gates which dominate on those two no-code kinds (a
780        // Supervisor / Aplicacao with any of `:bibliotecas` / `:exe` /
781        // `:servicos` surfaces the OwnCode diagnostic first), and
782        // before the path-existence loops which would otherwise spend
783        // a less-helpful `MissingEntry` diagnostic on the foreign
784        // slot's path. `declared_foreign_code_slots` is the single
785        // typed source of the foreign-code-slot set + its canonical
786        // diagnostic order (`:exe` → `:servicos`).
787        //
788        // Mirrors the 9d37f98 / 510c00a / 760a430 kind ↔ slot
789        // coherence trio's "declared-but-inert" footgun closure on the
790        // M2 / supervisor-tree / M3 axes, now extended onto the code-
791        // surface axis — every code-running kind's exclusive code
792        // surface is structurally fenced from every other code-running
793        // kind. `:bibliotecas` is deliberately excluded from the foreign
794        // set on Binario / Servico (a `lib/` helper bundled into the
795        // nix flake's build or the wasm-component's source tree is a
796        // legitimate cross-kind authoring shape); on Biblioteca it is
797        // the native slot, and on Supervisor / Aplicacao the OwnCode
798        // gates above already close it.
799        let foreign_code_slots = caixa.declared_foreign_code_slots();
800        if !foreign_code_slots.is_empty() {
801            return Err(LayoutError::ForeignCodeSlot {
802                caixa: caixa.nome().to_string(),
803                kind: caixa.kind(),
804                slots: foreign_code_slots.join(" "),
805            });
806        }
807
808        // Per-entry path-shape gate on the three Caixa-level code-surface
809        // path lists (`:bibliotecas`, `:exe`, `:servicos`): each entry must
810        // be non-empty, relative, and free of `..` components — the same
811        // [`crate::render::is_sandboxed_relative_path`] discipline the
812        // peer `:behavior :on-*` (b0c8389) and
813        // `:upgrade-from :state-change :script` (26da2c7) axes already
814        // route through. Runs *after* the kind-coherence gates above (so
815        // a `:exe` on a Servico surfaces ForeignCodeSlot rather than a
816        // per-entry shape diagnostic, and a Supervisor/Aplicacao with any
817        // code surface surfaces OwnCode first) and *before* the existence
818        // loops below (so an empty / absolute / parent-escaping entry
819        // surfaces its self-locating per-slot diagnostic rather than a
820        // downstream `MissingEntry` / `ExeOutsideDir` /
821        // `ServicoOutsideDir` against the resolved sandbox-escape path).
822        caixa
823            .validate_code_paths()
824            .map_err(|err| LayoutError::CodePathViolation {
825                caixa: caixa.nome().to_string(),
826                issue: err.to_string(),
827            })?;
828
829        if caixa.kind().requires_lib() && caixa.bibliotecas().is_empty() {
830            let expected = root
831                .join(crate::render::LAYOUT_DIR_LIB)
832                .join(format!("{}.lisp", caixa.nome()));
833            if !self.exists(&expected) {
834                return Err(LayoutError::MissingLib {
835                    caixa: caixa.nome().to_string(),
836                    expected,
837                });
838            }
839        }
840
841        if caixa.kind().requires_exe() && caixa.exe().is_empty() {
842            return Err(LayoutError::BinarioWithoutExe(caixa.nome().to_string()));
843        }
844
845        if caixa.kind().requires_servicos() && caixa.servicos().is_empty() {
846            return Err(LayoutError::ServicoWithoutServicos(
847                caixa.nome().to_string(),
848            ));
849        }
850
851        // Required-slot gate on the fifth [`CaixaKind`] arm — mirror of
852        // the `BinarioWithoutExe` / `ServicoWithoutServicos` pair above.
853        // An `Acao`'s sole payload is its `:ci` slot; an `Acao` caixa
854        // that doesn't declare one has no CI run to decompose or
855        // validate, so `feira build` refuses it here rather than
856        // letting a downstream `caixa-actions::validate` call fail with
857        // a less-helpful "no :ci" surprise far from the source
858        // caixa.lisp.
859        if caixa.kind().requires_ci() && caixa.ci().is_none() {
860            return Err(LayoutError::MissingCi(caixa.nome().to_string()));
861        }
862
863        for p in caixa.bibliotecas() {
864            let full = root.join(p);
865            if !self.exists(&full) {
866                return Err(LayoutError::MissingEntry {
867                    kind: crate::render::LAYOUT_MISSING_ENTRY_KIND_BIBLIOTECA,
868                    path: full,
869                });
870            }
871        }
872
873        let exe_dir = root.join(crate::render::LAYOUT_DIR_EXE);
874        for p in caixa.exe() {
875            let full = root.join(p);
876            if !self.exists(&full) {
877                return Err(LayoutError::MissingEntry {
878                    kind: crate::render::LAYOUT_MISSING_ENTRY_KIND_EXE,
879                    path: full,
880                });
881            }
882            if !full.starts_with(&exe_dir) {
883                return Err(LayoutError::ExeOutsideDir(full));
884            }
885        }
886
887        let servicos_dir = root.join(crate::render::LAYOUT_DIR_SERVICOS);
888        for p in caixa.servicos() {
889            let full = root.join(p);
890            if !self.exists(&full) {
891                return Err(LayoutError::MissingEntry {
892                    kind: crate::render::LAYOUT_MISSING_ENTRY_KIND_SERVICO,
893                    path: full,
894                });
895            }
896            if !full.starts_with(&servicos_dir) {
897                return Err(LayoutError::ServicoOutsideDir(full));
898            }
899        }
900
901        // ── M2 typed-substrate invariants ────────────────────────────────
902
903        // Lunatic-style per-process limits: every declared axis must
904        // be meaningfully non-zero. A zero on any axis is the same
905        // authorial-intent footgun as a 0-failure circuit-breaker or
906        // a 0-port :entrada — wasmtime would consume the value as
907        // "trap immediately" rather than the author's "an unspecified
908        // bound". See `LimitsSpec::validate` for the full rationale.
909        if let Some(l) = caixa.limits() {
910            l.validate().map_err(|err| LayoutError::LimitsViolation {
911                caixa: caixa.nome().to_string(),
912                issue: err.to_string(),
913            })?;
914        }
915
916        // Behavior callbacks: every declared callback must (a) be
917        // value-shape valid (no empty / absolute / parent-escaping
918        // path values that would silently subvert the layout
919        // checker's `root.join(p)` sandbox), then (b) resolve on disk.
920        // The shape pass runs first so the diagnostic names *which
921        // :behavior slot* is malformed before the existence check
922        // would otherwise surface a less-helpful "missing entry".
923        if let Some(b) = caixa.behavior() {
924            b.validate().map_err(|err| LayoutError::BehaviorViolation {
925                caixa: caixa.nome().to_string(),
926                issue: err.to_string(),
927            })?;
928            for p in b.declared_paths() {
929                let full = root.join(p);
930                if !self.exists(&full) {
931                    return Err(LayoutError::MissingEntry {
932                        kind: crate::render::LAYOUT_MISSING_ENTRY_KIND_BEHAVIOR_CALLBACK,
933                        path: full,
934                    });
935                }
936            }
937        }
938
939        // Compound per-Caixa entry gate on `:upgrade-from`: the layout
940        // pipeline's three-dispatch M2 `:upgrade-from` cascade — the
941        // per-entry shape + cross-entry duplicate-`:from` gate
942        // ([`crate::upgrade::validate_upgrade_from`]), the cross-slot
943        // `:from < :versao` SemVer-2 precedence gate
944        // ([`crate::upgrade::validate_upgrade_from_against_versao`]), and
945        // the cross-slot `:state-change` ↔ `:on-state-change` composition
946        // gate ([`crate::upgrade::validate_upgrade_from_against_behavior`])
947        // — folded onto the [`crate::Caixa::validate_upgrade_from`]
948        // substrate primitive. The three dispatches run in the same
949        // canonical order at the primitive (per-entry → versao → behavior)
950        // so the fold is byte-for-byte equivalent to the pre-fold
951        // three-block cascade this call site formerly carried, pinned by
952        // the paired
953        // `validate_upgrade_from_folds_{per_entry,versao,behavior}_arm_matches_gate`
954        // equivalence pins and the
955        // `validate_upgrade_from_{per_entry_arm_fires_before_versao_arm,
956        // versao_arm_fires_before_behavior_arm}` ordering pins in the
957        // [`crate::Caixa::validate_upgrade_from`] pin family
958        // (`manifest.rs`).
959        //
960        // Runs BEFORE the existing per-instruction script-path existence
961        // pass below so a malformed typed slot surfaces its own
962        // self-locating diagnostic rather than the less-helpful "missing
963        // upgrade-script" (which doesn't fire for non-script axes at all).
964        // Same lift discipline the peer per-slot compound gates
965        // ([`crate::AplicacaoSpec::validate_contratos`] and its
966        // `:membros` / `:entrada` / `:placement` / `:politicas` peers,
967        // [`crate::MeshPolicy::validate`],
968        // [`crate::SupervisorSpec::validate_children`]) each carry — one
969        // named substrate-primitive gate per typed slot folds every
970        // structural axis on that slot onto one call, so every future
971        // consumer that wants to re-check `:upgrade-from` after a
972        // per-entry patch (the deferred `caixa.pleme.io/v1alpha1/Caixa`
973        // CR materializer's admission webhook, a future `feira validate
974        // --upgrade` per-caixa admission verb, a per-`:upgrade-from`
975        // overlay resolver) reaches the three-arm compound gate through
976        // one dispatch rather than re-inlining the three-dispatch
977        // cascade in lockstep with this wire-up.
978        //
979        // The per-instruction on-disk existence-probe walk below stays
980        // open-coded at the layout wire-up site — that arm needs the
981        // filesystem oracle on the [`LayoutInvariants`] trait, not on
982        // the pure per-Caixa typed-shape surface the compound gate
983        // folds. Same posture [`crate::Caixa::validate_code_paths`] takes
984        // on the sibling code-path axes: the typed-shape gate fires on
985        // the per-Caixa surface, the on-disk existence check fires on
986        // the [`StandardLayout`] surface.
987        caixa
988            .validate_upgrade_from()
989            .map_err(|err| LayoutError::UpgradeViolation {
990                caixa: caixa.nome().to_string(),
991                issue: err.to_string(),
992            })?;
993        for entry in caixa.upgrade_from() {
994            for instr in entry.instructions() {
995                if let Some(p) = instr.declared_path() {
996                    let full = root.join(p);
997                    if !self.exists(&full) {
998                        return Err(LayoutError::MissingEntry {
999                            kind: crate::render::LAYOUT_MISSING_ENTRY_KIND_UPGRADE_SCRIPT,
1000                            path: full,
1001                        });
1002                    }
1003                }
1004            }
1005        }
1006
1007        // Supervisor invariants (typed shape — children, restart strategy).
1008        // The "supervisor doesn't own code" check is at the top of verify()
1009        // so it fires before the existence-check loops.
1010        if caixa.kind().is_supervisor() {
1011            // Raw `:restart-window` parse gate on the flat
1012            // `Caixa::restart_window: Option<String>` axis — the last
1013            // orphan-validator on the typed Caixa surface flagged by the
1014            // [`Self::validate_deps`] wire-up's closing comment (the
1015            // "Supervisor-axis specific" remainder) and the
1016            // [`Caixa::supervisor_view`] doc-comment's "the future
1017            // layout-side wire-up" pin. Until this gate landed
1018            // [`Caixa::validate_restart_window`] existed as `pub fn` on
1019            // [`Caixa`] with full per-arm unit coverage in `manifest::tests`
1020            // (`validate_restart_window_rejects_*` — fractional seconds,
1021            // decimal-shaped integer, half-unit minute, leading sign,
1022            // unknown unit, garbage, empty-after-trim; eight rejection
1023            // arms total), but no production code path called it —
1024            // `feira build` (the canonical author-time gate; routes
1025            // through [`StandardLayout::verify`]) silently accepted a
1026            // malformed `:restart-window` and [`Caixa::supervisor_view`]
1027            // soft-swallowed the parse failure as `restart_window: None`
1028            // (i.e. the canonical "omit the slot to express no reset"
1029            // sentinel), turning every malformed window into a never-reset
1030            // supervisor far from the source `caixa.lisp`, with no field
1031            // naming the offending `:restart-window`. The Erlang/OTP
1032            // `MaxIntensity / Period` invariant the typed [`SupervisorSpec`]
1033            // gate (`view.validate()` immediately below) enforces on the
1034            // `Option<Duration>` value never reached the gate at all on
1035            // these inputs: the parse error was already laundered to
1036            // `None`, and `None` is the canonical "never reset" shape
1037            // that always validates cleanly. Lifting the parse gate to
1038            // the layout-pipeline wire-up closes the laundering — every
1039            // value past this gate either parses through the shared
1040            // `crate::supervisor::duration_codec::parse` (and therefore
1041            // round-trips canonically) or fires the new
1042            // [`Self::RestartWindowViolation`] envelope at the source.
1043            //
1044            // Runs *inside* the `kind == Supervisor` branch (rather than
1045            // alongside the peer flat-Caixa gates `validate_nome` /
1046            // `validate_versao` / `validate_deps` / `validate_code_paths`
1047            // above the kind dispatch) because `:restart-window` is in
1048            // the Supervisor slot set per [`Caixa::declared_supervisor_slots`]
1049            // — every non-Supervisor caixa with `:restart-window` set
1050            // already errors upstream via the
1051            // [`Self::SupervisorSlotsOnNonSupervisor`] kind-coherence gate
1052            // (line 243-252), so reaching this gate on a non-Supervisor
1053            // kind would be a no-op (the field is `None` by construction).
1054            // Runs *before* `view.validate()` so the parse-side diagnostic
1055            // surfaces first on the raw-string axis — a `:restart-window
1056            // "1.5s"` lands on the more self-locating
1057            // `RestartWindowViolation` (which names the offending raw
1058            // string verbatim) rather than the laundered-to-`None`
1059            // soft-pass that the typed view would silently let through.
1060            //
1061            // Same per-axis `*Violation { caixa, issue }` envelope every
1062            // peer flat-Caixa wrap exposes ([`Self::NomeViolation`] /
1063            // [`Self::VersaoViolation`] 1f74a5f,
1064            // [`Self::DepsViolation`] aa77d0f, [`Self::CodePathViolation`]
1065            // b868442). Threads [`ManifestError::RestartWindowMalformed`]
1066            // Display through verbatim — the per-arm reason already names
1067            // the offending raw value (e.g. `":restart-window \"1.5s\" is
1068            // not a canonical duration: …"`), so the wrap's `issue`
1069            // carries a self-locating "which axis, which value, why"
1070            // without re-shaping the parser-side reason.
1071            caixa
1072                .validate_restart_window()
1073                .map_err(|err| LayoutError::RestartWindowViolation {
1074                    caixa: caixa.nome().to_string(),
1075                    issue: err.to_string(),
1076                })?;
1077            let view = caixa
1078                .supervisor_view()
1079                .expect("Supervisor kind must have a supervisor_view");
1080            view.validate()
1081                .map_err(|err| LayoutError::SupervisorViolation {
1082                    caixa: caixa.nome().to_string(),
1083                    issue: err.to_string(),
1084                })?;
1085            // Cross-slot coherence: `:children :caixa` must not name the
1086            // supervisor's own `:nome`. The typed `SupervisorSpec` view
1087            // carries the children but not the parent `:nome`, so this
1088            // self-parent gate reads one slot against another here, the
1089            // same wire-up shape `validate_upgrade_from_against_versao`
1090            // uses for the `:from`/`:versao` precedence gate. Runs after
1091            // `view.validate()` so the per-child shape + duplicate
1092            // diagnostics surface first; a self-referential child is
1093            // always a valid DNS-1123 label (it equals the already-valid
1094            // `:nome`), so this ordering never masks a narrower defect.
1095            //
1096            // Routes the `parent_nome` arg through the typed
1097            // [`Caixa::nome`] accessor (`caixa.nome()`) rather than
1098            // the raw `&caixa.nome` `&String`-borrow of the underlying
1099            // field — same one-typed-dispatch-per-`:nome`-consumer
1100            // discipline the sibling caixa-mesh (980c059) / caixa-helm
1101            // (22461ef) / caixa-flux (162e2e2) / caixa-crd (61d3429) /
1102            // caixa-feira (ef83332) `caixa.nome`-arg raw-borrow converges
1103            // established on the peer renderer / CR-materializer / CLI
1104            // crates, extended here onto the substrate's own
1105            // [`LayoutInvariants::verify`] cross-slot self-edge gate
1106            // wire-up on the supervisor-tree kind arm — the
1107            // [`crate::supervisor::validate_no_self_supervision`] helper
1108            // accepts `parent_nome: &str`, so the accessor's `&str`
1109            // return threads through without an intermediate deref-
1110            // coerce, closing the raw `&caixa.nome` arg-passing axis at
1111            // this call site.
1112            crate::supervisor::validate_no_self_supervision(caixa.children(), caixa.nome())
1113                .map_err(|err| LayoutError::SupervisorViolation {
1114                    caixa: caixa.nome().to_string(),
1115                    issue: err.to_string(),
1116                })?;
1117        }
1118
1119        // Aplicacao invariants — typed graph composition. Like
1120        // Supervisor, an Aplicacao runs no code itself.
1121        if caixa.kind().is_aplicacao() {
1122            let view = caixa
1123                .aplicacao_view()
1124                .expect("Aplicacao kind must have an aplicacao_view");
1125            view.validate()
1126                .map_err(|err| LayoutError::AplicacaoViolation {
1127                    caixa: caixa.nome().to_string(),
1128                    issue: err.to_string(),
1129                })?;
1130            // Cross-slot coherence: `:membros :caixa` must not name the
1131            // Aplicacao's own `:nome`. The typed `AplicacaoSpec` view
1132            // carries the membros but not the parent `:nome`, so this
1133            // self-edge gate reads one slot against another here, the
1134            // same wire-up shape `crate::supervisor::validate_no_self_supervision`
1135            // (ad4abf1) uses on the peer supervision-tree axis. Runs
1136            // after `view.validate()` so the per-member shape +
1137            // duplicate diagnostics surface first; a self-referential
1138            // member is always a valid DNS-1123 label (it equals the
1139            // already-valid `:nome`), so this ordering never masks a
1140            // narrower defect. Because `:contratos` `:de`/`:para` and
1141            // `:entrada :para` are already required to be members of
1142            // `:membros`, this single gate also transitively closes
1143            // those two axes — every validated `:contratos` edge and
1144            // every validated `:entrada :para` cannot name the
1145            // Aplicacao itself, without re-deriving the partition.
1146            //
1147            // Routes the `parent_nome` arg through the typed
1148            // [`Caixa::nome`] accessor (`caixa.nome()`) rather than
1149            // the raw `&caixa.nome` `&String`-borrow of the underlying
1150            // field — peer of the sibling supervision-tree-arm
1151            // [`crate::supervisor::validate_no_self_supervision`] arg
1152            // converge immediately above on the same
1153            // [`LayoutInvariants::verify`] cross-slot self-edge gate
1154            // wire-up, extended here onto the Aplicacao-kind arm. Both
1155            // per-kind arms now key off exactly one typed dispatch on
1156            // the substrate primitive for the parent-`:nome` self-edge
1157            // arg, closing the raw `&caixa.nome` arg-passing axis on
1158            // the cross-slot self-edge gate wire-up across both
1159            // typed-name-graph kinds ([`crate::supervisor::validate_no_self_supervision`]
1160            // on the supervision tree, this call on the Aplicacao
1161            // graph). The [`crate::aplicacao::validate_no_self_membership`]
1162            // helper accepts `parent_nome: &str`, so the accessor's
1163            // `&str` return threads through without an intermediate
1164            // deref-coerce.
1165            crate::aplicacao::validate_no_self_membership(caixa.membros(), caixa.nome()).map_err(
1166                |err| LayoutError::AplicacaoViolation {
1167                    caixa: caixa.nome().to_string(),
1168                    issue: err.to_string(),
1169                },
1170            )?;
1171        }
1172
1173        Ok(())
1174    }
1175}
1176
1177#[derive(Debug, Error, PartialEq, Eq)]
1178pub enum LayoutError {
1179    #[error("manifest missing: {}", .0.display())]
1180    MissingManifest(PathBuf),
1181    #[error("caixa '{caixa}' is a Biblioteca but has no lib entry — expected {}", expected.display())]
1182    MissingLib { caixa: String, expected: PathBuf },
1183    #[error("caixa '{0}' is a Binario but has no :exe entries")]
1184    BinarioWithoutExe(String),
1185    #[error("caixa '{0}' is a Servico but has no :servicos entries")]
1186    ServicoWithoutServicos(String),
1187    #[error("declared {kind} entry missing: {}", path.display())]
1188    MissingEntry { kind: &'static str, path: PathBuf },
1189    #[error("exe entry outside exe/ directory: {}", .0.display())]
1190    ExeOutsideDir(PathBuf),
1191    #[error("servico entry outside servicos/ directory: {}", .0.display())]
1192    ServicoOutsideDir(PathBuf),
1193    #[error("caixa '{caixa}' has invalid :nome: {issue}")]
1194    NomeViolation { caixa: String, issue: String },
1195    #[error("caixa '{caixa}' has invalid :versao: {issue}")]
1196    VersaoViolation { caixa: String, issue: String },
1197    #[error("caixa '{caixa}' has invalid :deps / :deps-dev entry: {issue}")]
1198    DepsViolation { caixa: String, issue: String },
1199    #[error("caixa '{caixa}' has invalid :etiquetas entry: {issue}")]
1200    EtiquetasViolation { caixa: String, issue: String },
1201    #[error("caixa '{caixa}' has invalid :autores entry: {issue}")]
1202    AutoresViolation { caixa: String, issue: String },
1203    #[error("caixa '{caixa}' has invalid :repositorio: {issue}")]
1204    RepositorioViolation { caixa: String, issue: String },
1205    #[error("caixa '{caixa}' has invalid :descricao: {issue}")]
1206    DescricaoViolation { caixa: String, issue: String },
1207    #[error("caixa '{caixa}' has invalid :licenca: {issue}")]
1208    LicencaViolation { caixa: String, issue: String },
1209    #[error("caixa '{caixa}' has invalid :edicao: {issue}")]
1210    EdicaoViolation { caixa: String, issue: String },
1211    #[error("caixa '{caixa}' has invalid code-path entry: {issue}")]
1212    CodePathViolation { caixa: String, issue: String },
1213    #[error("caixa '{caixa}' has invalid :limits: {issue}")]
1214    LimitsViolation { caixa: String, issue: String },
1215    #[error("caixa '{caixa}' has invalid :behavior callback: {issue}")]
1216    BehaviorViolation { caixa: String, issue: String },
1217    #[error("caixa '{caixa}' has invalid :upgrade-from entry: {issue}")]
1218    UpgradeViolation { caixa: String, issue: String },
1219    #[error("supervisor caixa '{caixa}' violates typed shape: {issue}")]
1220    SupervisorViolation { caixa: String, issue: String },
1221    #[error("supervisor caixa '{caixa}' has invalid :restart-window: {issue}")]
1222    RestartWindowViolation { caixa: String, issue: String },
1223    #[error(
1224        "supervisor caixa '{0}' must not declare :bibliotecas, :exe, or :servicos — supervisors don't run code, they orchestrate other caixas"
1225    )]
1226    SupervisorOwnsCode(String),
1227    #[error("aplicacao caixa '{caixa}' violates typed shape: {issue}")]
1228    AplicacaoViolation { caixa: String, issue: String },
1229    #[error(
1230        "aplicacao caixa '{0}' must not declare :bibliotecas, :exe, or :servicos — aplicacaos compose Servicos, they don't run code themselves"
1231    )]
1232    AplicacaoOwnsCode(String),
1233    #[error(
1234        "acao caixa '{0}' must not declare :bibliotecas, :exe, or :servicos — acaos carry a typed CI run (:ci), they don't run code themselves"
1235    )]
1236    AcaoOwnsCode(String),
1237    #[error(
1238        "caixa '{caixa}' is :kind {kind:?} but declares Aplicacao-only mesh slot(s): {slots} — \
1239         :membros / :contratos / :politicas / :placement / :entrada compose a :kind Aplicacao's \
1240         typed graph (MESH-COMPOSITION §III.1) and are silently ignored on every other kind \
1241         (never validated, never rendered); move them to a :kind Aplicacao caixa or remove them"
1242    )]
1243    MeshSlotsOnNonAplicacao {
1244        caixa: String,
1245        kind: CaixaKind,
1246        slots: String,
1247    },
1248    #[error(
1249        "caixa '{caixa}' is :kind {kind:?} but declares Supervisor-only slot(s): {slots} — \
1250         :estrategia / :max-restarts / :restart-window / :children compose a :kind Supervisor's \
1251         typed OTP supervisor (INSPIRATIONS §II.2) and are silently ignored on every other kind \
1252         (never validated, never reconciled); move them to a :kind Supervisor caixa or remove them"
1253    )]
1254    SupervisorSlotsOnNonSupervisor {
1255        caixa: String,
1256        kind: CaixaKind,
1257        slots: String,
1258    },
1259    #[error(
1260        "caixa '{caixa}' is :kind {kind:?} but declares Servico-only slot(s): {slots} — \
1261         :limits / :behavior / :upgrade-from configure the runtime of a long-running :kind Servico \
1262         wasm component (INSPIRATIONS §III.1 / §II.3 / §II.4) and are silently ignored on every \
1263         other kind (never rendered into a chart or programs.yaml entry); move them to a :kind \
1264         Servico caixa or remove them"
1265    )]
1266    ServicoSlotsOnNonServico {
1267        caixa: String,
1268        kind: CaixaKind,
1269        slots: String,
1270    },
1271    #[error(
1272        "caixa '{caixa}' is :kind {kind:?} but declares foreign code-surface slot(s): {slots} — \
1273         :exe is the nix-built executable surface owned only by :kind Binario, :servicos is the \
1274         wasm-component + ComputeUnit daemon surface owned only by :kind Servico; \
1275         caixa-helm / caixa-flux / caixa-flake gate emission on `require_kind(_, <owning-kind>)`, \
1276         so a declared :exe / :servicos on the wrong code-running kind is silently ignored — the \
1277         path is validated by the layout's path-existence loops but never rendered into a build \
1278         target or programs.yaml entry. Move the slot to its owning kind, change :kind to match \
1279         (Binario for :exe, Servico for :servicos), or drop the slot entirely"
1280    )]
1281    ForeignCodeSlot {
1282        caixa: String,
1283        kind: CaixaKind,
1284        slots: String,
1285    },
1286    #[error("caixa '{0}' is an Acao but has no :ci slot")]
1287    MissingCi(String),
1288    #[error(
1289        "caixa '{caixa}' is :kind {kind:?} but declares the Acao-only :ci slot — \
1290         :ci carries a typed CI run (canteiro_types::CiRun, CANTEIRO §7.1-C) that only the \
1291         caixa-actions renderer validates for :kind Acao, and is silently ignored on every \
1292         other kind (never decomposed, never rendered); move it to a :kind Acao caixa or \
1293         remove it"
1294    )]
1295    CiOnNonAcao { caixa: String, kind: CaixaKind },
1296}
1297
1298#[cfg(test)]
1299mod tests {
1300    use super::*;
1301    use crate::{Caixa, CaixaKind};
1302    use std::path::PathBuf;
1303
1304    fn caixa(kind: CaixaKind) -> Caixa {
1305        Caixa {
1306            nome: "demo".into(),
1307            versao: "0.1.0".into(),
1308            kind,
1309            edicao: None,
1310            descricao: None,
1311            repositorio: None,
1312            licenca: None,
1313            autores: vec![],
1314            etiquetas: vec![],
1315            deps: vec![],
1316            deps_dev: vec![],
1317            exe: vec![],
1318            bibliotecas: vec![],
1319            servicos: vec![],
1320            // M2 typed-substrate slots default to absent.
1321            limits: None,
1322            behavior: None,
1323            upgrade_from: vec![],
1324            estrategia: None,
1325            max_restarts: None,
1326            restart_window: None,
1327            children: vec![],
1328            // M3 Aplicacao slots default to absent.
1329            membros: vec![],
1330            contratos: vec![],
1331            politicas: None,
1332            placement: None,
1333            entrada: None,
1334            ci: None,
1335        }
1336    }
1337
1338    #[test]
1339    fn missing_manifest_errors() {
1340        let layout = StandardLayout::new().with_path_exists(|_| false);
1341        let err = layout
1342            .verify(&caixa(CaixaKind::Biblioteca), Path::new("/tmp/x"))
1343            .unwrap_err();
1344        assert!(matches!(err, LayoutError::MissingManifest(_)));
1345    }
1346
1347    #[test]
1348    fn biblioteca_needs_default_lib_path() {
1349        let root = PathBuf::from("/tmp/x");
1350        let expect_manifest = root.join("caixa.lisp");
1351        let layout = StandardLayout::new().with_path_exists(move |p| p == expect_manifest);
1352        let err = layout
1353            .verify(&caixa(CaixaKind::Biblioteca), &root)
1354            .unwrap_err();
1355        assert!(matches!(err, LayoutError::MissingLib { .. }));
1356    }
1357
1358    #[test]
1359    fn biblioteca_passes_when_default_lib_exists() {
1360        let root = PathBuf::from("/tmp/x");
1361        let manifest = root.join("caixa.lisp");
1362        let default_lib = root.join("lib").join("demo.lisp");
1363        let layout =
1364            StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
1365        layout
1366            .verify(&caixa(CaixaKind::Biblioteca), &root)
1367            .expect("should pass");
1368    }
1369
1370    #[test]
1371    fn binario_without_exe_errors() {
1372        let root = PathBuf::from("/tmp/x");
1373        let manifest = root.join("caixa.lisp");
1374        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
1375        let err = layout
1376            .verify(&caixa(CaixaKind::Binario), &root)
1377            .unwrap_err();
1378        assert!(matches!(err, LayoutError::BinarioWithoutExe(_)));
1379    }
1380
1381    #[test]
1382    fn exe_outside_dir_errors() {
1383        // A relative entry that lives under the caixa root but *not*
1384        // under `exe/` — the canonical case the `starts_with(exe_dir)`
1385        // fence catches. The prior parent-escape shape this test used
1386        // (`"../sibling/tool"`) is now caught at validate time by
1387        // [`Caixa::validate_code_paths`] with the narrower
1388        // [`crate::ManifestError::CodePathParentEscape`] diagnostic
1389        // (see the layout-level integration pin
1390        // `code_path_violation_on_parent_escape_fires_before_existence_check`),
1391        // so this fence pin uses a non-`..` non-absolute shape outside
1392        // `exe/` to preserve coverage of the ExeOutsideDir surface.
1393        let root = PathBuf::from("/tmp/x");
1394        let manifest = root.join("caixa.lisp");
1395        let outside = root.join("lib/tool");
1396        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest || p == outside);
1397        let mut c = caixa(CaixaKind::Binario);
1398        c.exe = vec!["lib/tool".into()];
1399        let err = layout.verify(&c, &root).unwrap_err();
1400        assert!(matches!(err, LayoutError::ExeOutsideDir(_)));
1401    }
1402
1403    // ── code-path shape gate (lifted to layout-level verify) ─────────────
1404
1405    #[test]
1406    fn code_path_violation_on_empty_bibliotecas_entry() {
1407        let root = PathBuf::from("/tmp/x");
1408        let manifest = root.join("caixa.lisp");
1409        let default_lib = root.join("lib").join("demo.lisp");
1410        let layout =
1411            StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
1412        let mut c = caixa(CaixaKind::Biblioteca);
1413        c.bibliotecas = vec![String::new()];
1414        let err = layout.verify(&c, &root).unwrap_err();
1415        // The wire-up wraps `ManifestError` Display into the
1416        // CodePathViolation envelope (peer of LimitsViolation /
1417        // BehaviorViolation / UpgradeViolation), so the issue string
1418        // names the offending slot at the source.
1419        let LayoutError::CodePathViolation { caixa, issue } = err else {
1420            panic!("expected LayoutError::CodePathViolation, got {err:?}");
1421        };
1422        assert_eq!(caixa, "demo");
1423        assert!(
1424            issue.contains(":bibliotecas"),
1425            "issue must name the offending slot: {issue}",
1426        );
1427    }
1428
1429    #[test]
1430    fn code_path_violation_on_absolute_servicos_entry() {
1431        let root = PathBuf::from("/tmp/x");
1432        let manifest = root.join("caixa.lisp");
1433        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
1434        let mut c = caixa(CaixaKind::Servico);
1435        c.servicos = vec!["/etc/servicos/escape.yaml".into()];
1436        let err = layout.verify(&c, &root).unwrap_err();
1437        let LayoutError::CodePathViolation { caixa, issue } = err else {
1438            panic!("expected LayoutError::CodePathViolation, got {err:?}");
1439        };
1440        assert_eq!(caixa, "demo");
1441        assert!(
1442            issue.contains(":servicos"),
1443            "issue must name the offending slot: {issue}",
1444        );
1445        assert!(
1446            issue.contains("/etc/servicos/escape.yaml"),
1447            "issue must quote the offending path: {issue}",
1448        );
1449    }
1450
1451    #[test]
1452    fn code_path_violation_on_parent_escape_fires_before_existence_check() {
1453        // The new gate runs BEFORE the existence loops, so a
1454        // parent-escaping `:exe` entry surfaces CodePathViolation
1455        // (naming `:exe` at the source) rather than the downstream
1456        // ExeOutsideDir / MissingEntry against the resolved sandbox-
1457        // escape path. Even if the resolved escape target exists
1458        // on disk (which we simulate here by claiming it does), the
1459        // shape diagnostic wins.
1460        let root = PathBuf::from("/tmp/x");
1461        let manifest = root.join("caixa.lisp");
1462        let resolved_escape = root.join("exe/../../escape.lisp");
1463        let layout =
1464            StandardLayout::new().with_path_exists(move |p| p == manifest || p == resolved_escape);
1465        let mut c = caixa(CaixaKind::Binario);
1466        c.exe = vec!["exe/../../escape.lisp".into()];
1467        let err = layout.verify(&c, &root).unwrap_err();
1468        let LayoutError::CodePathViolation { caixa, issue } = err else {
1469            panic!("expected LayoutError::CodePathViolation, got {err:?}");
1470        };
1471        assert_eq!(caixa, "demo");
1472        assert!(
1473            issue.contains(":exe"),
1474            "issue must name the offending slot: {issue}",
1475        );
1476    }
1477
1478    // ── etiquetas universal-axis gate wired into verify ─────────────────
1479    //
1480    // Pins the layout-pipeline wire-up of [`Caixa::validate_etiquetas`]:
1481    // the fourth universal-axis Caixa-level value-shape gate (peer of
1482    // `validate_nome` / `validate_versao` / `validate_deps` /
1483    // `validate_code_paths`), wired before the kind-coherence gates so
1484    // a structurally-invalid `:etiquetas` entry on any kind surfaces
1485    // the per-axis `EtiquetasViolation { caixa, issue }` envelope at
1486    // the source rather than silently rendering as `keywords: [""]`
1487    // in `Chart.yaml` (Servico kind, via caixa-helm's `BTreeSet`
1488    // collect) or silently dedup'ing at chart render (every kind).
1489    // Until this wire-up landed `:etiquetas` had no shape gate at any
1490    // layer — the registry-search-tag axis was the largest universal
1491    // authoring surface on the typed Caixa surface with no validate
1492    // discipline.
1493    //
1494    // Same per-axis `*Violation { caixa, issue }` envelope every peer
1495    // per-axis wrap exposes; the wire-up runs after `validate_deps`
1496    // (universal axis ordering: `:nome` → `:versao` → `:deps` →
1497    // `:etiquetas`) and before every kind-coherence gate
1498    // (`:etiquetas` is universal so its shape diagnostic is more
1499    // fundamental than the partition-on-kind diagnostics).
1500
1501    #[test]
1502    fn etiquetas_violation_on_empty_entry() {
1503        // Canonical paste-from-blank-doc footgun on every kind. The
1504        // wrap envelope wraps [`ManifestError::EtiquetaEmpty`]'s
1505        // Display through verbatim, so the issue string names the
1506        // offending `:etiquetas` axis at the source — the author can
1507        // grep their caixa.lisp for `:etiquetas` and fix the empty
1508        // entry in one edit. Mirrors the peer
1509        // `code_path_violation_on_empty_bibliotecas_entry` shape
1510        // (b868442) on the `:bibliotecas` axis.
1511        let root = PathBuf::from("/tmp/x");
1512        let manifest = root.join("caixa.lisp");
1513        let default_lib = root.join("lib").join("demo.lisp");
1514        let layout =
1515            StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
1516        let mut c = caixa(CaixaKind::Biblioteca);
1517        c.etiquetas = vec![String::new()];
1518        let err = layout.verify(&c, &root).unwrap_err();
1519        let LayoutError::EtiquetasViolation { caixa, issue } = err else {
1520            panic!("expected LayoutError::EtiquetasViolation, got {err:?}");
1521        };
1522        assert_eq!(caixa, "demo");
1523        assert!(
1524            issue.contains(":etiquetas"),
1525            "issue must name the offending slot: {issue}",
1526        );
1527    }
1528
1529    #[test]
1530    fn etiquetas_violation_on_duplicate_entry() {
1531        // Canonical copy-paste-the-wrong-tag footgun. Without the wire-
1532        // up the duplicate was silently dedup'd by caixa-helm's
1533        // `BTreeSet` collect at chart render — a "second wins / one
1534        // silently disappears" shape. The wrap envelope names the
1535        // offending tag verbatim through the inner
1536        // [`ManifestError::EtiquetaDuplicate`]'s Display.
1537        let root = PathBuf::from("/tmp/x");
1538        let manifest = root.join("caixa.lisp");
1539        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
1540        let mut c = caixa(CaixaKind::Servico);
1541        c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
1542        c.etiquetas = vec!["demo".into(), "demo".into()];
1543        // The servicos path doesn't exist in this fixture, but the
1544        // `:etiquetas` gate fires before the existence loop (universal
1545        // axis dominates kind-specific existence checks). Wire is
1546        // intact iff the wrap envelope surfaces first.
1547        let err = layout.verify(&c, &root).unwrap_err();
1548        let LayoutError::EtiquetasViolation { caixa, issue } = err else {
1549            panic!("expected LayoutError::EtiquetasViolation, got {err:?}");
1550        };
1551        assert_eq!(caixa, "demo");
1552        assert!(
1553            issue.contains("demo"),
1554            "issue must quote the offending tag: {issue}",
1555        );
1556    }
1557
1558    #[test]
1559    fn etiquetas_violation_fires_before_kind_coherence_mesh_slot() {
1560        // Cross-axis precedence pin: a Biblioteca with malformed
1561        // `:etiquetas` *and* declared mesh slots (`:membros`) surfaces
1562        // the universal `:etiquetas` diagnostic first, not the
1563        // kind-coherence `MeshSlotsOnNonAplicacao` diagnostic.
1564        // `:etiquetas` is universal (every kind owns the slot), so its
1565        // shape diagnostic is more fundamental than the partition-on-
1566        // kind diagnostic. Mirrors the peer
1567        // `deps_violation_fires_before_*` precedence pins (aa77d0f) on
1568        // the universal `:deps` axis vs the same kind-coherence gates.
1569        let root = PathBuf::from("/tmp/x");
1570        let manifest = root.join("caixa.lisp");
1571        let default_lib = root.join("lib").join("demo.lisp");
1572        let layout =
1573            StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
1574        let mut c = caixa(CaixaKind::Biblioteca);
1575        c.etiquetas = vec![String::new()];
1576        c.membros = vec![crate::aplicacao::Membro {
1577            caixa: "x".into(),
1578            versao: "^0.1".into(),
1579        }];
1580        let err = layout.verify(&c, &root).unwrap_err();
1581        assert!(
1582            matches!(err, LayoutError::EtiquetasViolation { .. }),
1583            "got {err:?}",
1584        );
1585    }
1586
1587    #[test]
1588    fn etiquetas_violation_fires_after_deps_violation() {
1589        // Cross-axis precedence pin (inside the universal-axis trio):
1590        // a caixa with both a malformed `:deps` entry *and* a malformed
1591        // `:etiquetas` entry surfaces `DepsViolation` first — `:deps`
1592        // is the third universal axis in declaration order
1593        // (`:nome` → `:versao` → `:deps` → `:etiquetas`) and runs first
1594        // in `verify`. Mirrors the peer
1595        // `nome_violation_fires_before_versao_violation` shape on the
1596        // identity-axis pair.
1597        let root = PathBuf::from("/tmp/x");
1598        let manifest = root.join("caixa.lisp");
1599        let default_lib = root.join("lib").join("demo.lisp");
1600        let layout =
1601            StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
1602        let mut c = caixa(CaixaKind::Biblioteca);
1603        c.deps = vec![crate::Dep::simple("Caixa-Teia", "^0.1")]; // uppercase :nome
1604        c.etiquetas = vec![String::new()];
1605        let err = layout.verify(&c, &root).unwrap_err();
1606        assert!(
1607            matches!(err, LayoutError::DepsViolation { .. }),
1608            "got {err:?}",
1609        );
1610    }
1611
1612    #[test]
1613    fn etiquetas_violation_accepts_canonical_template() {
1614        // Positive control sanity pin: the canonical `Caixa::template`
1615        // shape (`:etiquetas ()` — empty list) passes the gate
1616        // trivially. Mirrors the peer
1617        // `validate_code_paths_accepts_canonical_template` pin.
1618        let root = PathBuf::from("/tmp/x");
1619        let manifest = root.join("caixa.lisp");
1620        let default_lib = root.join("lib").join("demo.lisp");
1621        let layout =
1622            StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
1623        let c = caixa(CaixaKind::Biblioteca);
1624        layout.verify(&c, &root).expect("template must pass");
1625    }
1626
1627    #[test]
1628    fn etiquetas_violation_on_non_chart_keyword_shape() {
1629        // Canonical CSV-list-separator-confusion footgun: the author
1630        // confused the CSV-style separator with the `:etiquetas` list
1631        // grammar. The shape gate fires past the empty + duplicate
1632        // arms via [`Caixa::validate_etiquetas`]'s new
1633        // `is_chart_keyword_shape` cascade, and the layout envelope
1634        // wraps [`ManifestError::EtiquetaInvalid`]'s Display through
1635        // verbatim — the issue string names both the offending slot
1636        // and the offending value (debug-escaped). Peer with the
1637        // `autores_violation_on_non_chart_maintainer_shape` pin on
1638        // the sibling universal-axis `Vec<String>` surface — the
1639        // second layout pin on the Vec<String> per-entry shape
1640        // cascade.
1641        let root = PathBuf::from("/tmp/x");
1642        let manifest = root.join("caixa.lisp");
1643        let default_lib = root.join("lib").join("demo.lisp");
1644        let layout =
1645            StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
1646        let mut c = caixa(CaixaKind::Biblioteca);
1647        c.etiquetas = vec!["mesh,http,grpc".into()];
1648        let err = layout.verify(&c, &root).unwrap_err();
1649        let LayoutError::EtiquetasViolation { caixa, issue } = err else {
1650            panic!("expected LayoutError::EtiquetasViolation, got {err:?}");
1651        };
1652        assert_eq!(caixa, "demo");
1653        assert!(
1654            issue.contains(":etiquetas"),
1655            "issue must name the offending slot: {issue}",
1656        );
1657        assert!(
1658            issue.contains("mesh,http,grpc"),
1659            "issue must quote the offending value: {issue}",
1660        );
1661    }
1662
1663    // ── autores universal-axis gate wired into verify ───────────────────
1664    //
1665    // Pins the layout-pipeline wire-up of [`Caixa::validate_autores`]:
1666    // the fifth universal-axis Caixa-level value-shape gate (peer of
1667    // `validate_nome` / `validate_versao` / `validate_deps` /
1668    // `validate_etiquetas` / `validate_code_paths`), wired immediately
1669    // after `validate_etiquetas` so the two Vec-shaped universal
1670    // metadata axes sit adjacent in the cascade. Until this wire-up
1671    // landed `:autores` had no shape gate at any layer — the
1672    // maintainer-axis was the second largest universal authoring
1673    // surface on the typed Caixa surface with no validate discipline,
1674    // and unlike `:etiquetas` (caixa-helm dedups the rendered
1675    // `keywords:` array via `BTreeSet` collect at chart render),
1676    // `maintainers:` has *no* renderer-side dedup, so duplicate
1677    // `:autores` entries render verbatim as two identical
1678    // `Maintainer { name, email: None }` records — a strictly worse
1679    // footgun than the peer `:etiquetas` shape.
1680
1681    #[test]
1682    fn autores_violation_on_empty_entry() {
1683        // Canonical paste-from-blank-doc footgun on every kind. The
1684        // wrap envelope wraps [`ManifestError::AutorEmpty`]'s Display
1685        // through verbatim, so the issue string names the offending
1686        // `:autores` axis at the source — the author can grep their
1687        // caixa.lisp for `:autores` and fix the empty entry in one
1688        // edit. Mirrors the peer `etiquetas_violation_on_empty_entry`
1689        // shape (360a499) on the `:etiquetas` axis.
1690        let root = PathBuf::from("/tmp/x");
1691        let manifest = root.join("caixa.lisp");
1692        let default_lib = root.join("lib").join("demo.lisp");
1693        let layout =
1694            StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
1695        let mut c = caixa(CaixaKind::Biblioteca);
1696        c.autores = vec![String::new()];
1697        let err = layout.verify(&c, &root).unwrap_err();
1698        let LayoutError::AutoresViolation { caixa, issue } = err else {
1699            panic!("expected LayoutError::AutoresViolation, got {err:?}");
1700        };
1701        assert_eq!(caixa, "demo");
1702        assert!(
1703            issue.contains(":autores"),
1704            "issue must name the offending slot: {issue}",
1705        );
1706    }
1707
1708    #[test]
1709    fn autores_violation_on_duplicate_entry() {
1710        // Canonical copy-paste-the-wrong-author footgun. Unlike the
1711        // peer `:etiquetas` axis (silently dedup'd by caixa-helm's
1712        // `BTreeSet` collect at chart render), `:autores` duplicates
1713        // stack verbatim in the rendered `maintainers:` — the gate
1714        // closes the footgun at validate time before any renderer
1715        // sees it. The wrap envelope names the offending author
1716        // verbatim through the inner [`ManifestError::AutorDuplicate`]'s
1717        // Display.
1718        let root = PathBuf::from("/tmp/x");
1719        let manifest = root.join("caixa.lisp");
1720        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
1721        let mut c = caixa(CaixaKind::Servico);
1722        c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
1723        c.autores = vec!["pleme-io".into(), "pleme-io".into()];
1724        // The servicos path doesn't exist in this fixture, but the
1725        // `:autores` gate fires before the existence loop (universal
1726        // axis dominates kind-specific existence checks). Wire is
1727        // intact iff the wrap envelope surfaces first.
1728        let err = layout.verify(&c, &root).unwrap_err();
1729        let LayoutError::AutoresViolation { caixa, issue } = err else {
1730            panic!("expected LayoutError::AutoresViolation, got {err:?}");
1731        };
1732        assert_eq!(caixa, "demo");
1733        assert!(
1734            issue.contains("pleme-io"),
1735            "issue must quote the offending author: {issue}",
1736        );
1737    }
1738
1739    #[test]
1740    fn autores_violation_fires_before_kind_coherence_mesh_slot() {
1741        // Cross-axis precedence pin: a Biblioteca with malformed
1742        // `:autores` *and* declared mesh slots (`:membros`) surfaces
1743        // the universal `:autores` diagnostic first, not the
1744        // kind-coherence `MeshSlotsOnNonAplicacao` diagnostic.
1745        // `:autores` is universal (every kind owns the slot), so its
1746        // shape diagnostic is more fundamental than the partition-on-
1747        // kind diagnostic. Mirrors the peer
1748        // `etiquetas_violation_fires_before_kind_coherence_mesh_slot`
1749        // pin (360a499) on the `:etiquetas` axis vs the same kind-
1750        // coherence gates.
1751        let root = PathBuf::from("/tmp/x");
1752        let manifest = root.join("caixa.lisp");
1753        let default_lib = root.join("lib").join("demo.lisp");
1754        let layout =
1755            StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
1756        let mut c = caixa(CaixaKind::Biblioteca);
1757        c.autores = vec![String::new()];
1758        c.membros = vec![crate::aplicacao::Membro {
1759            caixa: "x".into(),
1760            versao: "^0.1".into(),
1761        }];
1762        let err = layout.verify(&c, &root).unwrap_err();
1763        assert!(
1764            matches!(err, LayoutError::AutoresViolation { .. }),
1765            "got {err:?}",
1766        );
1767    }
1768
1769    #[test]
1770    fn autores_violation_fires_after_etiquetas_violation() {
1771        // Cross-axis precedence pin (inside the Vec-shaped universal
1772        // metadata pair): a caixa with both a malformed `:etiquetas`
1773        // entry *and* a malformed `:autores` entry surfaces
1774        // `EtiquetasViolation` first — `:etiquetas` is the fourth
1775        // universal axis in the cascade and runs before `:autores`,
1776        // peer with the canonical identity-axis-first cascade the
1777        // peer gates establish. Mirrors the peer
1778        // `etiquetas_violation_fires_after_deps_violation` precedence
1779        // pin (360a499) on the dep-axis-before-tag-axis pair.
1780        let root = PathBuf::from("/tmp/x");
1781        let manifest = root.join("caixa.lisp");
1782        let default_lib = root.join("lib").join("demo.lisp");
1783        let layout =
1784            StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
1785        let mut c = caixa(CaixaKind::Biblioteca);
1786        c.etiquetas = vec![String::new()];
1787        c.autores = vec![String::new()];
1788        let err = layout.verify(&c, &root).unwrap_err();
1789        assert!(
1790            matches!(err, LayoutError::EtiquetasViolation { .. }),
1791            "got {err:?}",
1792        );
1793    }
1794
1795    #[test]
1796    fn autores_violation_accepts_canonical_template() {
1797        // Positive control sanity pin: the canonical `Caixa::template`
1798        // shape (`:autores ()` — empty list) passes the gate trivially.
1799        // Mirrors the peer `etiquetas_violation_accepts_canonical_template`
1800        // pin (360a499).
1801        let root = PathBuf::from("/tmp/x");
1802        let manifest = root.join("caixa.lisp");
1803        let default_lib = root.join("lib").join("demo.lisp");
1804        let layout =
1805            StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
1806        let c = caixa(CaixaKind::Biblioteca);
1807        layout.verify(&c, &root).expect("template must pass");
1808    }
1809
1810    #[test]
1811    fn autores_violation_on_non_chart_maintainer_shape() {
1812        // Canonical paste-from-multiline-doc footgun: the author
1813        // pasted a multi-line block of author records into one
1814        // `:autores` entry instead of splitting into one entry per
1815        // author. The shape gate fires past the empty + duplicate arms
1816        // via [`Caixa::validate_autores`]'s new
1817        // `is_chart_maintainer_name_shape` cascade, and the layout
1818        // envelope wraps [`ManifestError::AutorInvalid`]'s Display
1819        // through verbatim — the issue string names both the offending
1820        // slot and the offending value (debug-escaped). Peer with the
1821        // `descricao_violation_on_non_chart_shape` pin on the sibling
1822        // universal-axis `Option<String>` surface and the
1823        // `licenca_violation_on_non_spdx_shape` /
1824        // `edicao_violation_on_non_year_shape` peers — and the first
1825        // layout pin on the Vec<String> per-entry shape cascade.
1826        let root = PathBuf::from("/tmp/x");
1827        let manifest = root.join("caixa.lisp");
1828        let default_lib = root.join("lib").join("demo.lisp");
1829        let layout =
1830            StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
1831        let mut c = caixa(CaixaKind::Biblioteca);
1832        c.autores = vec!["alice\nbob".into()];
1833        let err = layout.verify(&c, &root).unwrap_err();
1834        let LayoutError::AutoresViolation { caixa, issue } = err else {
1835            panic!("expected LayoutError::AutoresViolation, got {err:?}");
1836        };
1837        assert_eq!(caixa, "demo");
1838        assert!(
1839            issue.contains(":autores"),
1840            "issue must name the offending slot: {issue}",
1841        );
1842        assert!(
1843            issue.contains("alice\\nbob"),
1844            "issue must quote the offending value (debug-escaped): {issue}",
1845        );
1846    }
1847
1848    // ── repositorio universal-axis gate wired into verify ────────────────
1849    //
1850    // Pins the layout-pipeline wire-up of [`Caixa::validate_repositorio`]:
1851    // the sixth universal-axis Caixa-level value-shape gate (peer of
1852    // `validate_nome` / `validate_versao` / `validate_deps` /
1853    // `validate_etiquetas` / `validate_autores` / `validate_code_paths`),
1854    // wired immediately after `validate_autores` so the universal
1855    // git-URL axis sits adjacent to the two Vec-shaped universal
1856    // metadata axes (`:etiquetas`, `:autores`) in the cascade. Until
1857    // this wire-up landed `:repositorio` had no shape gate at any
1858    // layer — the universal git-shaped homepage axis was the third
1859    // largest universal authoring surface on the typed Caixa with no
1860    // validate discipline, routing the same string through two
1861    // load-bearing substrate consumers (`caixa-helm`'s `Chart.yaml
1862    // home:` field and `caixa-flux`'s FluxCD `GitRepository.spec.url`)
1863    // via `Option::unwrap_or_else` fallbacks that only fire on `None` —
1864    // a `Some("")` silently passed every fallback and rendered as an
1865    // empty URL in both consumers, breaking at `helm template` /
1866    // FluxCD reconcile time far from the source `caixa.lisp`. The
1867    // gate closes the divergence and makes the two `git URL`-shaped
1868    // surfaces on the typed Caixa (`:repositorio` here, `:deps :fonte
1869    // :repo` peer routed through the same shared
1870    // `crate::render::is_git_repo_url` predicate) structurally
1871    // equivalent by construction.
1872
1873    #[test]
1874    fn repositorio_violation_on_empty_some() {
1875        // Canonical paste-from-blank-doc footgun on every kind. The
1876        // wrap envelope wraps [`ManifestError::RepositorioEmpty`]'s
1877        // Display through verbatim, so the issue string names the
1878        // offending `:repositorio` axis at the source — the author
1879        // can grep their caixa.lisp for `:repositorio ""` and fix the
1880        // empty value in one edit. Mirrors the peer
1881        // `autores_violation_on_empty_entry` shape (86c769b) on the
1882        // `:autores` axis.
1883        let root = PathBuf::from("/tmp/x");
1884        let manifest = root.join("caixa.lisp");
1885        let default_lib = root.join("lib").join("demo.lisp");
1886        let layout =
1887            StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
1888        let mut c = caixa(CaixaKind::Biblioteca);
1889        c.repositorio = Some(String::new());
1890        let err = layout.verify(&c, &root).unwrap_err();
1891        let LayoutError::RepositorioViolation { caixa, issue } = err else {
1892            panic!("expected LayoutError::RepositorioViolation, got {err:?}");
1893        };
1894        assert_eq!(caixa, "demo");
1895        assert!(
1896            issue.contains(":repositorio"),
1897            "issue must name the offending slot: {issue}",
1898        );
1899    }
1900
1901    #[test]
1902    fn repositorio_violation_on_malformed_shape() {
1903        // Canonical CLI-argument-injection footgun: a leading `-`
1904        // value (`-upload-pack=evil`) escapes the `git clone <repo>`
1905        // subprocess argument boundary at clone time. The shared
1906        // `is_git_repo_url` predicate — the same parser the peer
1907        // `:deps :fonte :repo` axis routes through via
1908        // `DepSource::validate` — refuses every leading-`-` shape at
1909        // validate time. The wrap envelope names the offending value
1910        // verbatim through the inner [`ManifestError::RepositorioInvalid`]'s
1911        // Display.
1912        let root = PathBuf::from("/tmp/x");
1913        let manifest = root.join("caixa.lisp");
1914        let default_lib = root.join("lib").join("demo.lisp");
1915        let layout =
1916            StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
1917        let mut c = caixa(CaixaKind::Biblioteca);
1918        c.repositorio = Some("-upload-pack=evil".into());
1919        let err = layout.verify(&c, &root).unwrap_err();
1920        let LayoutError::RepositorioViolation { caixa, issue } = err else {
1921            panic!("expected LayoutError::RepositorioViolation, got {err:?}");
1922        };
1923        assert_eq!(caixa, "demo");
1924        assert!(
1925            issue.contains("-upload-pack=evil"),
1926            "issue must quote the offending value: {issue}",
1927        );
1928    }
1929
1930    #[test]
1931    fn repositorio_violation_fires_before_kind_coherence_mesh_slot() {
1932        // Cross-axis precedence pin: a Biblioteca with malformed
1933        // `:repositorio` *and* declared mesh slots (`:membros`)
1934        // surfaces the universal `:repositorio` diagnostic first, not
1935        // the kind-coherence `MeshSlotsOnNonAplicacao` diagnostic.
1936        // `:repositorio` is universal (every kind owns the slot), so
1937        // its shape diagnostic is more fundamental than the
1938        // partition-on-kind diagnostic. Mirrors the peer
1939        // `autores_violation_fires_before_kind_coherence_mesh_slot`
1940        // pin (86c769b) on the `:autores` axis vs the same
1941        // kind-coherence gates.
1942        let root = PathBuf::from("/tmp/x");
1943        let manifest = root.join("caixa.lisp");
1944        let default_lib = root.join("lib").join("demo.lisp");
1945        let layout =
1946            StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
1947        let mut c = caixa(CaixaKind::Biblioteca);
1948        c.repositorio = Some(String::new());
1949        c.membros = vec![crate::aplicacao::Membro {
1950            caixa: "x".into(),
1951            versao: "^0.1".into(),
1952        }];
1953        let err = layout.verify(&c, &root).unwrap_err();
1954        assert!(
1955            matches!(err, LayoutError::RepositorioViolation { .. }),
1956            "got {err:?}",
1957        );
1958    }
1959
1960    #[test]
1961    fn repositorio_violation_fires_after_autores_violation() {
1962        // Cross-axis precedence pin (inside the universal metadata
1963        // trio): a caixa with both a malformed `:autores` entry *and*
1964        // a malformed `:repositorio` value surfaces `AutoresViolation`
1965        // first — `:autores` is the fifth universal axis in the
1966        // cascade and runs before `:repositorio`, peer with the
1967        // canonical identity-axis-first cascade the peer gates
1968        // establish. Mirrors the peer
1969        // `autores_violation_fires_after_etiquetas_violation`
1970        // precedence pin (86c769b) on the tag-axis-before-author-axis
1971        // pair.
1972        let root = PathBuf::from("/tmp/x");
1973        let manifest = root.join("caixa.lisp");
1974        let default_lib = root.join("lib").join("demo.lisp");
1975        let layout =
1976            StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
1977        let mut c = caixa(CaixaKind::Biblioteca);
1978        c.autores = vec![String::new()];
1979        c.repositorio = Some(String::new());
1980        let err = layout.verify(&c, &root).unwrap_err();
1981        assert!(
1982            matches!(err, LayoutError::AutoresViolation { .. }),
1983            "got {err:?}",
1984        );
1985    }
1986
1987    #[test]
1988    fn repositorio_violation_accepts_canonical_template() {
1989        // Positive control sanity pin: the canonical `Caixa::template`
1990        // shape (omits `:repositorio` entirely → `None` on the typed
1991        // surface) passes the gate trivially — the gate is a no-op
1992        // when the author didn't author a value. Mirrors the peer
1993        // `autores_violation_accepts_canonical_template` pin (86c769b).
1994        let root = PathBuf::from("/tmp/x");
1995        let manifest = root.join("caixa.lisp");
1996        let default_lib = root.join("lib").join("demo.lisp");
1997        let layout =
1998            StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
1999        let c = caixa(CaixaKind::Biblioteca);
2000        layout.verify(&c, &root).expect("template must pass");
2001    }
2002
2003    #[test]
2004    fn repositorio_violation_accepts_canonical_github_shorthand() {
2005        // Positive control pin on the canonical pleme-io `:repositorio`
2006        // shape: the `github:org/repo` shorthand the README quickstart
2007        // and the `caixa-helm` / `caixa-mesh` / `caixa-flux` fixtures
2008        // all use passes the gate end-to-end. Closes the structural
2009        // equivalence between this surface and the peer `:deps :fonte
2010        // :repo` axis — both consume `crate::render::is_git_repo_url`
2011        // and both must agree on the same accepted shape set.
2012        let root = PathBuf::from("/tmp/x");
2013        let manifest = root.join("caixa.lisp");
2014        let default_lib = root.join("lib").join("demo.lisp");
2015        let layout =
2016            StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
2017        let mut c = caixa(CaixaKind::Biblioteca);
2018        c.repositorio = Some("github:pleme-io/hello-rio".into());
2019        layout.verify(&c, &root).expect("canonical shape must pass");
2020    }
2021
2022    // ── descricao universal-axis gate wired into verify ──────────────────
2023    //
2024    // Pins the layout-pipeline wire-up of [`Caixa::validate_descricao`]:
2025    // the seventh universal-axis Caixa-level value-shape gate (peer of
2026    // `validate_nome` / `validate_versao` / `validate_deps` /
2027    // `validate_etiquetas` / `validate_autores` / `validate_repositorio` /
2028    // `validate_code_paths`), wired immediately after `validate_repositorio`
2029    // so the universal free-form-prose axis sits adjacent to the
2030    // universal git-URL axis in the cascade. Until this wire-up landed
2031    // `:descricao` had no shape gate at any layer — the empty
2032    // `Some("")` silently passed both `caixa-helm` consumers'
2033    // `Option::unwrap_or_else(|| <fallback>)` (which only fire on
2034    // `None`) and rendered as `Chart.yaml description: ""` plus a
2035    // blank `README.md` header, breaking at `helm lint` time
2036    // (`WARNING [chart.metadata.description]: description is required`
2037    // on `apiVersion: v2` charts) far from the source `caixa.lisp`.
2038    // Closes the same `Some("")` skips-`unwrap_or_else` footgun the
2039    // peer `:repositorio` gate (577b0a9) closed, on the universal
2040    // free-form-prose summary axis.
2041
2042    #[test]
2043    fn descricao_violation_on_empty_some() {
2044        // Canonical paste-from-blank-doc footgun on every kind. The
2045        // wrap envelope wraps [`ManifestError::DescricaoEmpty`]'s
2046        // Display through verbatim, so the issue string names the
2047        // offending `:descricao` axis at the source — the author can
2048        // grep their caixa.lisp for `:descricao ""` and fix the empty
2049        // value in one edit. Mirrors the peer
2050        // `repositorio_violation_on_empty_some` shape (577b0a9) on
2051        // the sibling `Option<String>` `:repositorio` axis.
2052        let root = PathBuf::from("/tmp/x");
2053        let manifest = root.join("caixa.lisp");
2054        let default_lib = root.join("lib").join("demo.lisp");
2055        let layout =
2056            StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
2057        let mut c = caixa(CaixaKind::Biblioteca);
2058        c.descricao = Some(String::new());
2059        let err = layout.verify(&c, &root).unwrap_err();
2060        let LayoutError::DescricaoViolation { caixa, issue } = err else {
2061            panic!("expected LayoutError::DescricaoViolation, got {err:?}");
2062        };
2063        assert_eq!(caixa, "demo");
2064        assert!(
2065            issue.contains(":descricao"),
2066            "issue must name the offending slot: {issue}",
2067        );
2068    }
2069
2070    #[test]
2071    fn descricao_violation_fires_before_kind_coherence_mesh_slot() {
2072        // Cross-axis precedence pin: a Biblioteca with empty
2073        // `:descricao` *and* declared mesh slots (`:membros`)
2074        // surfaces the universal `:descricao` diagnostic first, not
2075        // the kind-coherence `MeshSlotsOnNonAplicacao` diagnostic.
2076        // `:descricao` is universal (every kind owns the slot), so
2077        // its shape diagnostic is more fundamental than the
2078        // partition-on-kind diagnostic. Mirrors the peer
2079        // `repositorio_violation_fires_before_kind_coherence_mesh_slot`
2080        // pin (577b0a9) on the `:repositorio` axis vs the same
2081        // kind-coherence gates.
2082        let root = PathBuf::from("/tmp/x");
2083        let manifest = root.join("caixa.lisp");
2084        let default_lib = root.join("lib").join("demo.lisp");
2085        let layout =
2086            StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
2087        let mut c = caixa(CaixaKind::Biblioteca);
2088        c.descricao = Some(String::new());
2089        c.membros = vec![crate::aplicacao::Membro {
2090            caixa: "x".into(),
2091            versao: "^0.1".into(),
2092        }];
2093        let err = layout.verify(&c, &root).unwrap_err();
2094        assert!(
2095            matches!(err, LayoutError::DescricaoViolation { .. }),
2096            "got {err:?}",
2097        );
2098    }
2099
2100    #[test]
2101    fn descricao_violation_fires_after_repositorio_violation() {
2102        // Cross-axis precedence pin (inside the universal metadata
2103        // cascade): a caixa with both a malformed `:repositorio` *and*
2104        // an empty `:descricao` surfaces `RepositorioViolation`
2105        // first — `:repositorio` is the sixth universal axis in the
2106        // cascade and runs before `:descricao`, peer with the
2107        // canonical identity-axis-first cascade the peer gates
2108        // establish. Mirrors the peer
2109        // `repositorio_violation_fires_after_autores_violation`
2110        // precedence pin (577b0a9) on the autores-axis-before-
2111        // repositorio-axis pair.
2112        let root = PathBuf::from("/tmp/x");
2113        let manifest = root.join("caixa.lisp");
2114        let default_lib = root.join("lib").join("demo.lisp");
2115        let layout =
2116            StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
2117        let mut c = caixa(CaixaKind::Biblioteca);
2118        c.repositorio = Some(String::new());
2119        c.descricao = Some(String::new());
2120        let err = layout.verify(&c, &root).unwrap_err();
2121        assert!(
2122            matches!(err, LayoutError::RepositorioViolation { .. }),
2123            "got {err:?}",
2124        );
2125    }
2126
2127    #[test]
2128    fn descricao_violation_accepts_none() {
2129        // Positive control sanity pin: a caixa that omits
2130        // `:descricao` entirely (the canonical `Caixa::template` shape
2131        // carries `Some("FIXME — describe this caixa")`, but the
2132        // layout-test fixture defaults to `None`) passes the gate
2133        // trivially — the gate is a no-op when the author didn't
2134        // author a value. Mirrors the peer
2135        // `repositorio_violation_accepts_canonical_template` pin
2136        // (577b0a9).
2137        let root = PathBuf::from("/tmp/x");
2138        let manifest = root.join("caixa.lisp");
2139        let default_lib = root.join("lib").join("demo.lisp");
2140        let layout =
2141            StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
2142        let c = caixa(CaixaKind::Biblioteca);
2143        layout.verify(&c, &root).expect("None must pass");
2144    }
2145
2146    #[test]
2147    fn descricao_violation_accepts_canonical_summary() {
2148        // Positive control pin on the canonical pleme-io `:descricao`
2149        // shape: a short free-form prose summary the `caixa-helm` /
2150        // `caixa-flux` / `caixa-mesh` fixtures all carry passes the
2151        // gate end-to-end.
2152        let root = PathBuf::from("/tmp/x");
2153        let manifest = root.join("caixa.lisp");
2154        let default_lib = root.join("lib").join("demo.lisp");
2155        let layout =
2156            StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
2157        let mut c = caixa(CaixaKind::Biblioteca);
2158        c.descricao = Some("Canonical Rust→wasm32-wasip2 caixa Servico.".into());
2159        layout
2160            .verify(&c, &root)
2161            .expect("canonical summary must pass");
2162    }
2163
2164    #[test]
2165    fn descricao_violation_on_non_chart_shape() {
2166        // Shape-predicate wire-up pin: a malformed `:descricao` value
2167        // that's a non-empty `Some(s)` but carries a paste-from-
2168        // multiline-doc embedded newline surfaces the
2169        // `DescricaoViolation` envelope via the manifest-layer
2170        // `ManifestError::DescricaoInvalid` arm. Mirrors the peer
2171        // `descricao_violation_on_empty_some` shape on the empty arm
2172        // of the same axis and the peer
2173        // `licenca_violation_on_non_spdx_shape` shape on the sibling
2174        // `:licenca` axis. Until this gate landed a value like
2175        // `"Checkout\nflow."` (an embedded newline) or `"Checkout
2176        // flow. "` (a trailing whitespace) silently passed
2177        // `StandardLayout::verify` and landed in the rendered
2178        // Chart.yaml `description:` field as a YAML-illegal
2179        // multi-line scalar or a silently-trimmed whitespace
2180        // round-trip far from the source caixa.lisp.
2181        let root = PathBuf::from("/tmp/x");
2182        let manifest = root.join("caixa.lisp");
2183        let default_lib = root.join("lib").join("demo.lisp");
2184        let layout =
2185            StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
2186        let mut c = caixa(CaixaKind::Biblioteca);
2187        c.descricao = Some("Checkout\nflow.".into());
2188        let err = layout.verify(&c, &root).unwrap_err();
2189        let LayoutError::DescricaoViolation { caixa, issue } = err else {
2190            panic!("expected LayoutError::DescricaoViolation, got {err:?}");
2191        };
2192        assert_eq!(caixa, "demo");
2193        assert!(
2194            issue.contains(":descricao"),
2195            "issue must name the offending slot: {issue}",
2196        );
2197        // The wrapped `ManifestError::DescricaoInvalid` Display uses
2198        // `{descricao:?}` (Debug) so the embedded newline surfaces
2199        // debug-escaped as `\n` in the issue string.
2200        assert!(
2201            issue.contains("Checkout\\nflow."),
2202            "issue must quote the offending value (debug-escaped): {issue}",
2203        );
2204    }
2205
2206    // ── :licenca empty-Some shape wired into verify (universal axis) ──
2207    //
2208    // Until this wire-up landed `Caixa::validate_licenca` did not
2209    // exist — the universal SPDX-shaped license-expression axis had
2210    // no shape gate at any layer, so an empty `Some("")` silently
2211    // passed `Caixa::from_lisp` and `StandardLayout::verify` and
2212    // landed as a bare trailing period in the rendered
2213    // `lareira-<nome>` chart's `README.md` `## License` section via
2214    // the `caixa-helm` consumer's `caixa.licenca.clone().unwrap_or_else(||
2215    // "MIT".into())` (which only fires on `None`) at
2216    // `caixa-helm/src/lib.rs:361`. Closes the same `Some("")`
2217    // skips-`unwrap_or_else` footgun the peer `:repositorio`
2218    // (577b0a9) and `:descricao` (4e6db38) gates closed, on the
2219    // universal license-expression axis.
2220
2221    #[test]
2222    fn licenca_violation_on_empty_some() {
2223        // Canonical paste-from-blank-doc footgun on every kind. The
2224        // wrap envelope wraps [`ManifestError::LicencaEmpty`]'s
2225        // Display through verbatim, so the issue string names the
2226        // offending `:licenca` axis at the source — the author can
2227        // grep their caixa.lisp for `:licenca ""` and fix the empty
2228        // value in one edit. Mirrors the peer
2229        // `descricao_violation_on_empty_some` shape (4e6db38) on
2230        // the sibling `Option<String>` `:licenca` axis.
2231        let root = PathBuf::from("/tmp/x");
2232        let manifest = root.join("caixa.lisp");
2233        let default_lib = root.join("lib").join("demo.lisp");
2234        let layout =
2235            StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
2236        let mut c = caixa(CaixaKind::Biblioteca);
2237        c.licenca = Some(String::new());
2238        let err = layout.verify(&c, &root).unwrap_err();
2239        let LayoutError::LicencaViolation { caixa, issue } = err else {
2240            panic!("expected LayoutError::LicencaViolation, got {err:?}");
2241        };
2242        assert_eq!(caixa, "demo");
2243        assert!(
2244            issue.contains(":licenca"),
2245            "issue must name the offending slot: {issue}",
2246        );
2247    }
2248
2249    #[test]
2250    fn licenca_violation_fires_before_kind_coherence_mesh_slot() {
2251        // Cross-axis precedence pin: a Biblioteca with empty
2252        // `:licenca` *and* declared mesh slots (`:membros`)
2253        // surfaces the universal `:licenca` diagnostic first, not
2254        // the kind-coherence `MeshSlotsOnNonAplicacao` diagnostic.
2255        // `:licenca` is universal (every kind owns the slot), so
2256        // its shape diagnostic is more fundamental than the
2257        // partition-on-kind diagnostic. Mirrors the peer
2258        // `descricao_violation_fires_before_kind_coherence_mesh_slot`
2259        // pin (4e6db38) on the `:descricao` axis vs the same
2260        // kind-coherence gates.
2261        let root = PathBuf::from("/tmp/x");
2262        let manifest = root.join("caixa.lisp");
2263        let default_lib = root.join("lib").join("demo.lisp");
2264        let layout =
2265            StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
2266        let mut c = caixa(CaixaKind::Biblioteca);
2267        c.licenca = Some(String::new());
2268        c.membros = vec![crate::aplicacao::Membro {
2269            caixa: "x".into(),
2270            versao: "^0.1".into(),
2271        }];
2272        let err = layout.verify(&c, &root).unwrap_err();
2273        assert!(
2274            matches!(err, LayoutError::LicencaViolation { .. }),
2275            "got {err:?}",
2276        );
2277    }
2278
2279    #[test]
2280    fn licenca_violation_fires_after_descricao_violation() {
2281        // Cross-axis precedence pin (inside the universal metadata
2282        // cascade): a caixa with both an empty `:descricao` *and*
2283        // an empty `:licenca` surfaces `DescricaoViolation`
2284        // first — `:descricao` is the seventh universal axis in the
2285        // cascade and runs before `:licenca`, peer with the
2286        // canonical identity-axis-first cascade the peer gates
2287        // establish. Mirrors the peer
2288        // `descricao_violation_fires_after_repositorio_violation`
2289        // precedence pin (4e6db38) on the repositorio-axis-before-
2290        // descricao-axis pair.
2291        let root = PathBuf::from("/tmp/x");
2292        let manifest = root.join("caixa.lisp");
2293        let default_lib = root.join("lib").join("demo.lisp");
2294        let layout =
2295            StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
2296        let mut c = caixa(CaixaKind::Biblioteca);
2297        c.descricao = Some(String::new());
2298        c.licenca = Some(String::new());
2299        let err = layout.verify(&c, &root).unwrap_err();
2300        assert!(
2301            matches!(err, LayoutError::DescricaoViolation { .. }),
2302            "got {err:?}",
2303        );
2304    }
2305
2306    #[test]
2307    fn licenca_violation_accepts_none() {
2308        // Positive control sanity pin: a caixa that omits `:licenca`
2309        // entirely (the layout-test fixture defaults to `None`)
2310        // passes the gate trivially — the gate is a no-op when the
2311        // author didn't author a value. Mirrors the peer
2312        // `descricao_violation_accepts_none` pin (4e6db38).
2313        let root = PathBuf::from("/tmp/x");
2314        let manifest = root.join("caixa.lisp");
2315        let default_lib = root.join("lib").join("demo.lisp");
2316        let layout =
2317            StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
2318        let c = caixa(CaixaKind::Biblioteca);
2319        layout.verify(&c, &root).expect("None must pass");
2320    }
2321
2322    #[test]
2323    fn licenca_violation_accepts_canonical_expression() {
2324        // Positive control pin on the canonical pleme-io `:licenca`
2325        // shape: a non-empty SPDX expression the `caixa-helm` /
2326        // `caixa-flux` / `caixa-mesh` fixtures all carry (`"MIT"`)
2327        // passes the gate end-to-end.
2328        let root = PathBuf::from("/tmp/x");
2329        let manifest = root.join("caixa.lisp");
2330        let default_lib = root.join("lib").join("demo.lisp");
2331        let layout =
2332            StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
2333        let mut c = caixa(CaixaKind::Biblioteca);
2334        c.licenca = Some("Apache-2.0 OR MIT".into());
2335        layout
2336            .verify(&c, &root)
2337            .expect("canonical SPDX expression must pass");
2338    }
2339
2340    #[test]
2341    fn licenca_violation_on_non_spdx_shape() {
2342        // Shape-predicate wire-up pin: a malformed `:licenca` value
2343        // that's a non-empty `Some(s)` but falls outside the SPDX
2344        // expression alphabet floor surfaces the `LicencaViolation`
2345        // envelope via the manifest-layer `ManifestError::LicencaInvalid`
2346        // arm. Mirrors the peer `licenca_violation_on_empty_some`
2347        // shape on the empty arm of the same axis and the peer
2348        // `edicao_violation_on_non_year_shape` shape on the sibling
2349        // `:edicao` axis. Until this gate landed a value like
2350        // `"Apache_2.0"` (an underscore-instead-of-hyphen typo) or
2351        // `"MIT, Apache-2.0"` (a comma-instead-of-`OR`-keyword
2352        // colloquial idiom) silently passed `StandardLayout::verify`
2353        // and landed in the rendered chart `README.md` `## License`
2354        // section + a future SPDX-aware Chart.yaml `license:`
2355        // emitter would refuse the value at `helm lint` time far
2356        // from the source caixa.lisp.
2357        let root = PathBuf::from("/tmp/x");
2358        let manifest = root.join("caixa.lisp");
2359        let default_lib = root.join("lib").join("demo.lisp");
2360        let layout =
2361            StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
2362        let mut c = caixa(CaixaKind::Biblioteca);
2363        c.licenca = Some("Apache_2.0".into());
2364        let err = layout.verify(&c, &root).unwrap_err();
2365        let LayoutError::LicencaViolation { caixa, issue } = err else {
2366            panic!("expected LayoutError::LicencaViolation, got {err:?}");
2367        };
2368        assert_eq!(caixa, "demo");
2369        assert!(
2370            issue.contains(":licenca"),
2371            "issue must name the offending slot: {issue}",
2372        );
2373        assert!(
2374            issue.contains("Apache_2.0"),
2375            "issue must quote the offending value: {issue}",
2376        );
2377    }
2378
2379    // ── :edicao empty-Some shape wired into verify (universal axis) ──
2380    //
2381    // Until this wire-up landed `Caixa::validate_edicao` did not
2382    // exist — the universal language-edition axis had no shape gate
2383    // at any layer, so an empty `Some("")` silently passed
2384    // `Caixa::from_lisp` and `StandardLayout::verify` and landed as a
2385    // bare `(:edicao "")` line in the rendered caixa.lisp, ready for
2386    // a future renderer-side consumer's `Option::unwrap_or_else`
2387    // (which only fires on `None`) to skip its fallback. Closes the
2388    // same `Some("")`-skips-`unwrap_or_else` footgun the peer
2389    // `:repositorio` (577b0a9), `:descricao` (4e6db38), and
2390    // `:licenca` (3d1e535) gates closed, on the universal language-
2391    // edition axis — the last un-gated universal-axis
2392    // `Option<String>` Caixa-level value-shape surface.
2393
2394    #[test]
2395    fn edicao_violation_on_empty_some() {
2396        // Canonical paste-from-blank-doc footgun on every kind. The
2397        // wrap envelope wraps [`ManifestError::EdicaoEmpty`]'s
2398        // Display through verbatim, so the issue string names the
2399        // offending `:edicao` axis at the source — the author can
2400        // grep their caixa.lisp for `:edicao ""` and fix the empty
2401        // value in one edit. Mirrors the peer
2402        // `licenca_violation_on_empty_some` shape (3d1e535) on the
2403        // sibling `Option<String>` `:edicao` axis.
2404        let root = PathBuf::from("/tmp/x");
2405        let manifest = root.join("caixa.lisp");
2406        let default_lib = root.join("lib").join("demo.lisp");
2407        let layout =
2408            StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
2409        let mut c = caixa(CaixaKind::Biblioteca);
2410        c.edicao = Some(String::new());
2411        let err = layout.verify(&c, &root).unwrap_err();
2412        let LayoutError::EdicaoViolation { caixa, issue } = err else {
2413            panic!("expected LayoutError::EdicaoViolation, got {err:?}");
2414        };
2415        assert_eq!(caixa, "demo");
2416        assert!(
2417            issue.contains(":edicao"),
2418            "issue must name the offending slot: {issue}",
2419        );
2420    }
2421
2422    #[test]
2423    fn edicao_violation_fires_before_kind_coherence_mesh_slot() {
2424        // Cross-axis precedence pin: a Biblioteca with empty
2425        // `:edicao` *and* declared mesh slots (`:membros`) surfaces
2426        // the universal `:edicao` diagnostic first, not the
2427        // kind-coherence `MeshSlotsOnNonAplicacao` diagnostic.
2428        // `:edicao` is universal (every kind owns the slot), so
2429        // its shape diagnostic is more fundamental than the
2430        // partition-on-kind diagnostic. Mirrors the peer
2431        // `licenca_violation_fires_before_kind_coherence_mesh_slot`
2432        // pin (3d1e535) on the `:licenca` axis vs the same
2433        // kind-coherence gates.
2434        let root = PathBuf::from("/tmp/x");
2435        let manifest = root.join("caixa.lisp");
2436        let default_lib = root.join("lib").join("demo.lisp");
2437        let layout =
2438            StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
2439        let mut c = caixa(CaixaKind::Biblioteca);
2440        c.edicao = Some(String::new());
2441        c.membros = vec![crate::aplicacao::Membro {
2442            caixa: "x".into(),
2443            versao: "^0.1".into(),
2444        }];
2445        let err = layout.verify(&c, &root).unwrap_err();
2446        assert!(
2447            matches!(err, LayoutError::EdicaoViolation { .. }),
2448            "got {err:?}",
2449        );
2450    }
2451
2452    #[test]
2453    fn edicao_violation_fires_after_licenca_violation() {
2454        // Cross-axis precedence pin (inside the universal metadata
2455        // cascade): a caixa with both an empty `:licenca` *and* an
2456        // empty `:edicao` surfaces `LicencaViolation` first —
2457        // `:licenca` is the eighth universal axis in the cascade
2458        // and runs before `:edicao`, peer with the canonical
2459        // identity-axis-first cascade the peer gates establish.
2460        // Mirrors the peer
2461        // `licenca_violation_fires_after_descricao_violation`
2462        // precedence pin (3d1e535) on the descricao-axis-before-
2463        // licenca-axis pair.
2464        let root = PathBuf::from("/tmp/x");
2465        let manifest = root.join("caixa.lisp");
2466        let default_lib = root.join("lib").join("demo.lisp");
2467        let layout =
2468            StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
2469        let mut c = caixa(CaixaKind::Biblioteca);
2470        c.licenca = Some(String::new());
2471        c.edicao = Some(String::new());
2472        let err = layout.verify(&c, &root).unwrap_err();
2473        assert!(
2474            matches!(err, LayoutError::LicencaViolation { .. }),
2475            "got {err:?}",
2476        );
2477    }
2478
2479    #[test]
2480    fn edicao_violation_accepts_none() {
2481        // Positive control sanity pin: a caixa that omits `:edicao`
2482        // entirely (the layout-test fixture defaults to `None`)
2483        // passes the gate trivially — the gate is a no-op when the
2484        // author didn't author a value. Mirrors the peer
2485        // `licenca_violation_accepts_none` pin (3d1e535).
2486        let root = PathBuf::from("/tmp/x");
2487        let manifest = root.join("caixa.lisp");
2488        let default_lib = root.join("lib").join("demo.lisp");
2489        let layout =
2490            StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
2491        let c = caixa(CaixaKind::Biblioteca);
2492        layout.verify(&c, &root).expect("None must pass");
2493    }
2494
2495    #[test]
2496    fn edicao_violation_accepts_canonical_value() {
2497        // Positive control pin on the canonical pleme-io `:edicao`
2498        // shape: the `"2026"` edition every `caixa-helm` /
2499        // `caixa-flux` / `caixa-mesh` / `caixa-core/src/render.rs`
2500        // fixture carries by construction passes the gate end-to-end.
2501        let root = PathBuf::from("/tmp/x");
2502        let manifest = root.join("caixa.lisp");
2503        let default_lib = root.join("lib").join("demo.lisp");
2504        let layout =
2505            StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
2506        let mut c = caixa(CaixaKind::Biblioteca);
2507        c.edicao = Some("2026".into());
2508        layout
2509            .verify(&c, &root)
2510            .expect("canonical edition must pass");
2511    }
2512
2513    #[test]
2514    fn edicao_violation_on_non_year_shape() {
2515        // Shape-predicate wire-up pin: a malformed `:edicao` value
2516        // that's a non-empty `Some(s)` but not a 4-digit ASCII
2517        // decimal year surfaces the `EdicaoViolation` envelope via
2518        // the manifest-layer `ManifestError::EdicaoInvalid` arm.
2519        // Mirrors the peer `edicao_violation_on_empty_some` shape
2520        // on the empty arm of the same axis. Until this gate landed
2521        // a value like `"v2026"` (a familiar git-tag idiom that
2522        // doesn't apply to the year-shaped edition axis) silently
2523        // passed `StandardLayout::verify` and broke at the
2524        // substrate's build-time edition selector far from the
2525        // source caixa.lisp.
2526        let root = PathBuf::from("/tmp/x");
2527        let manifest = root.join("caixa.lisp");
2528        let default_lib = root.join("lib").join("demo.lisp");
2529        let layout =
2530            StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
2531        let mut c = caixa(CaixaKind::Biblioteca);
2532        c.edicao = Some("v2026".into());
2533        let err = layout.verify(&c, &root).unwrap_err();
2534        let LayoutError::EdicaoViolation { caixa, issue } = err else {
2535            panic!("expected LayoutError::EdicaoViolation, got {err:?}");
2536        };
2537        assert_eq!(caixa, "demo");
2538        assert!(
2539            issue.contains(":edicao"),
2540            "issue must name the offending slot: {issue}",
2541        );
2542        assert!(
2543            issue.contains("v2026"),
2544            "issue must quote the offending value: {issue}",
2545        );
2546    }
2547
2548    // ── Caixa-identity gates (`:nome`, `:versao`) wired into verify ────
2549    //
2550    // Until this wire-up landed `Caixa::validate_nome` and
2551    // `Caixa::validate_versao` lived as `pub fn` on `Caixa` with full
2552    // per-arm unit coverage in `manifest::tests`, but no production
2553    // path called them — `feira build` silently accepted malformed
2554    // `:nome` / `:versao` and the failure surfaced at `helm install` /
2555    // `kubectl apply` / `feira publish` / lacre-resolve / `:upgrade-from
2556    // :from` matching time, far from the source `caixa.lisp`. The
2557    // following pins fence the layout-pipeline wire-up: every layout
2558    // verify on a structurally-invalid Caixa identity axis surfaces
2559    // the per-axis `*Violation { caixa, issue }` envelope before any
2560    // kind-coherence, code-path, or downstream gate sees it.
2561
2562    #[test]
2563    fn nome_violation_on_uppercase() {
2564        let root = PathBuf::from("/tmp/x");
2565        let manifest = root.join("caixa.lisp");
2566        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
2567        let mut c = caixa(CaixaKind::Biblioteca);
2568        c.nome = "MyApp".into();
2569        let err = layout.verify(&c, &root).unwrap_err();
2570        let LayoutError::NomeViolation { caixa, issue } = err else {
2571            panic!("expected LayoutError::NomeViolation, got {err:?}");
2572        };
2573        assert_eq!(caixa, "MyApp");
2574        assert!(
2575            issue.contains("MyApp"),
2576            "issue must quote the offending nome: {issue}",
2577        );
2578    }
2579
2580    #[test]
2581    fn nome_violation_on_underscore() {
2582        let root = PathBuf::from("/tmp/x");
2583        let manifest = root.join("caixa.lisp");
2584        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
2585        let mut c = caixa(CaixaKind::Biblioteca);
2586        c.nome = "my_app".into();
2587        let err = layout.verify(&c, &root).unwrap_err();
2588        assert!(
2589            matches!(err, LayoutError::NomeViolation { ref caixa, .. } if caixa == "my_app"),
2590            "got {err:?}",
2591        );
2592    }
2593
2594    #[test]
2595    fn nome_violation_on_empty() {
2596        // Empty `:nome` surfaces NomeViolation wrapping the narrower
2597        // `ManifestError::NomeEmpty` arm — the empty-first cascade the
2598        // peer per-axis name gates already use.
2599        let root = PathBuf::from("/tmp/x");
2600        let manifest = root.join("caixa.lisp");
2601        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
2602        let mut c = caixa(CaixaKind::Biblioteca);
2603        c.nome = String::new();
2604        let err = layout.verify(&c, &root).unwrap_err();
2605        let LayoutError::NomeViolation { caixa, issue } = err else {
2606            panic!("expected LayoutError::NomeViolation, got {err:?}");
2607        };
2608        assert!(caixa.is_empty());
2609        assert!(
2610            issue.contains(":nome is empty"),
2611            "issue must surface the empty-arm diagnostic: {issue}",
2612        );
2613    }
2614
2615    #[test]
2616    fn versao_violation_on_missing_patch() {
2617        // `"0.1"` — the canonical "I shortened it" footgun. Helm /
2618        // OCI / lacre-resolve / `:upgrade-from :from` all strict-parse
2619        // through `semver::Version::parse`, which refuses a two-part
2620        // shape.
2621        let root = PathBuf::from("/tmp/x");
2622        let manifest = root.join("caixa.lisp");
2623        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
2624        let mut c = caixa(CaixaKind::Biblioteca);
2625        c.versao = "0.1".into();
2626        let err = layout.verify(&c, &root).unwrap_err();
2627        let LayoutError::VersaoViolation { caixa, issue } = err else {
2628            panic!("expected LayoutError::VersaoViolation, got {err:?}");
2629        };
2630        assert_eq!(caixa, "demo");
2631        assert!(
2632            issue.contains("0.1"),
2633            "issue must quote the offending versao: {issue}",
2634        );
2635    }
2636
2637    #[test]
2638    fn versao_violation_on_git_tag_shape() {
2639        // `"v0.1.0"` — the git-tag-shape-leaking-into-versao typo.
2640        let root = PathBuf::from("/tmp/x");
2641        let manifest = root.join("caixa.lisp");
2642        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
2643        let mut c = caixa(CaixaKind::Biblioteca);
2644        c.versao = "v0.1.0".into();
2645        let err = layout.verify(&c, &root).unwrap_err();
2646        assert!(
2647            matches!(err, LayoutError::VersaoViolation { ref issue, .. }
2648                if issue.contains("v0.1.0")),
2649            "got {err:?}",
2650        );
2651    }
2652
2653    #[test]
2654    fn versao_violation_on_empty() {
2655        let root = PathBuf::from("/tmp/x");
2656        let manifest = root.join("caixa.lisp");
2657        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
2658        let mut c = caixa(CaixaKind::Biblioteca);
2659        c.versao = String::new();
2660        let err = layout.verify(&c, &root).unwrap_err();
2661        let LayoutError::VersaoViolation { caixa, issue } = err else {
2662            panic!("expected LayoutError::VersaoViolation, got {err:?}");
2663        };
2664        assert_eq!(caixa, "demo");
2665        assert!(
2666            issue.contains(":versao is empty"),
2667            "issue must surface the empty-arm diagnostic: {issue}",
2668        );
2669    }
2670
2671    #[test]
2672    fn nome_violation_fires_before_versao_violation() {
2673        // Precedence pin: when both `:nome` and `:versao` are malformed,
2674        // `:nome` surfaces first — the canonical declaration-order
2675        // precedence the `ManifestError` family establishes, the same
2676        // grep-order the author follows when fixing in `caixa.lisp`.
2677        let root = PathBuf::from("/tmp/x");
2678        let manifest = root.join("caixa.lisp");
2679        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
2680        let mut c = caixa(CaixaKind::Biblioteca);
2681        c.nome = "MyApp".into();
2682        c.versao = "0.1".into();
2683        let err = layout.verify(&c, &root).unwrap_err();
2684        assert!(
2685            matches!(err, LayoutError::NomeViolation { .. }),
2686            "got {err:?} — nome must fire before versao",
2687        );
2688    }
2689
2690    #[test]
2691    fn nome_violation_fires_before_kind_coherence() {
2692        // Precedence pin: a Biblioteca caixa with a malformed `:nome`
2693        // AND a declared mesh slot surfaces NomeViolation, not
2694        // MeshSlotsOnNonAplicacao — the identity-axis gate is more
2695        // fundamental than the kind-coherence gate (which carries
2696        // `caixa.nome` verbatim in its diagnostic, and so depends on the
2697        // name being structurally valid to render a useful message).
2698        let root = PathBuf::from("/tmp/x");
2699        let manifest = root.join("caixa.lisp");
2700        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
2701        let mut c = caixa(CaixaKind::Biblioteca);
2702        c.nome = "MyApp".into();
2703        c.membros = vec![crate::aplicacao::Membro {
2704            caixa: "x".into(),
2705            versao: "^0.1".into(),
2706        }];
2707        let err = layout.verify(&c, &root).unwrap_err();
2708        assert!(
2709            matches!(err, LayoutError::NomeViolation { .. }),
2710            "got {err:?} — nome must fire before MeshSlotsOnNonAplicacao",
2711        );
2712    }
2713
2714    #[test]
2715    fn nome_violation_fires_before_owncode() {
2716        // Precedence pin: a Supervisor with a malformed `:nome` AND
2717        // declared `:bibliotecas` surfaces NomeViolation, not
2718        // SupervisorOwnsCode — same rationale as above.
2719        let root = PathBuf::from("/tmp/x");
2720        let manifest = root.join("caixa.lisp");
2721        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
2722        let mut c = caixa(CaixaKind::Supervisor);
2723        c.nome = "MyApp".into();
2724        c.bibliotecas = vec!["lib/x.lisp".into()];
2725        let err = layout.verify(&c, &root).unwrap_err();
2726        assert!(
2727            matches!(err, LayoutError::NomeViolation { .. }),
2728            "got {err:?} — nome must fire before SupervisorOwnsCode",
2729        );
2730    }
2731
2732    #[test]
2733    fn versao_violation_fires_before_kind_coherence() {
2734        // Precedence pin: a Biblioteca with a valid `:nome` but a
2735        // malformed `:versao` AND a declared servico slot surfaces
2736        // VersaoViolation before ServicoSlotsOnNonServico.
2737        let root = PathBuf::from("/tmp/x");
2738        let manifest = root.join("caixa.lisp");
2739        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
2740        let mut c = caixa(CaixaKind::Biblioteca);
2741        c.versao = "v0.1.0".into();
2742        c.limits = Some(crate::LimitsSpec {
2743            memory: Some(64 * 1024 * 1024),
2744            ..Default::default()
2745        });
2746        let err = layout.verify(&c, &root).unwrap_err();
2747        assert!(
2748            matches!(err, LayoutError::VersaoViolation { .. }),
2749            "got {err:?} — versao must fire before ServicoSlotsOnNonServico",
2750        );
2751    }
2752
2753    #[test]
2754    fn nome_violation_fires_before_missing_lib() {
2755        // Precedence pin: a Biblioteca with a malformed `:nome` and no
2756        // lib entry surfaces NomeViolation, not MissingLib — the
2757        // identity-axis gate is more fundamental than the layout's
2758        // `lib/<nome>.lisp` default-path check (which derives the
2759        // expected path from `:nome` itself, so would surface a
2760        // misleading "expected lib/MyApp.lisp" diagnostic against an
2761        // unrecoverable name).
2762        let root = PathBuf::from("/tmp/x");
2763        let manifest = root.join("caixa.lisp");
2764        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
2765        let mut c = caixa(CaixaKind::Biblioteca);
2766        c.nome = "MyApp".into();
2767        let err = layout.verify(&c, &root).unwrap_err();
2768        assert!(
2769            matches!(err, LayoutError::NomeViolation { .. }),
2770            "got {err:?} — nome must fire before MissingLib",
2771        );
2772    }
2773
2774    #[test]
2775    fn nome_versao_violations_fire_after_missing_manifest() {
2776        // Precedence pin: `MissingManifest` still dominates — there's
2777        // no caixa to identity-check when the manifest is missing.
2778        let root = PathBuf::from("/tmp/x");
2779        let layout = StandardLayout::new().with_path_exists(|_| false);
2780        let mut c = caixa(CaixaKind::Biblioteca);
2781        c.nome = "MyApp".into();
2782        c.versao = "0.1".into();
2783        let err = layout.verify(&c, &root).unwrap_err();
2784        assert!(
2785            matches!(err, LayoutError::MissingManifest(_)),
2786            "got {err:?} — MissingManifest must dominate identity gates",
2787        );
2788    }
2789
2790    #[test]
2791    fn valid_nome_versao_passes_to_downstream_gates() {
2792        // Sanity pin: the canonical "demo" / "0.1.0" identity passes
2793        // both axes; downstream gates (MissingLib here) take over.
2794        let root = PathBuf::from("/tmp/x");
2795        let manifest = root.join("caixa.lisp");
2796        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
2797        let err = layout
2798            .verify(&caixa(CaixaKind::Biblioteca), &root)
2799            .unwrap_err();
2800        assert!(
2801            matches!(err, LayoutError::MissingLib { .. }),
2802            "got {err:?} — valid identity must pass to MissingLib",
2803        );
2804    }
2805
2806    // ── :deps / :deps-dev shape gate (lifted to layout-level verify) ─────
2807    //
2808    // Until this wire-up landed `Caixa::validate_deps` lived as `pub fn`
2809    // on `Caixa` with full per-arm unit coverage in `manifest::tests` +
2810    // `dep::tests` but no production path called it — `feira build`
2811    // silently accepted a malformed `:deps` / `:deps-dev` entry and the
2812    // failure surfaced at lacre-resolve / `git clone` / `cargo metadata`
2813    // / `helm install` time on the *first* downstream consumer to
2814    // strict-parse the value, far from the source `caixa.lisp` and
2815    // without any field naming the offending `:deps` axis. The following
2816    // pins fence the layout-pipeline wire-up: every layout verify on a
2817    // structurally-invalid `:deps` value-shape surfaces the per-axis
2818    // `DepsViolation { caixa, issue }` envelope (peer of
2819    // `NomeViolation` / `VersaoViolation` / `CodePathViolation` /
2820    // `LimitsViolation` / `BehaviorViolation` / `UpgradeViolation` /
2821    // `SupervisorViolation` / `AplicacaoViolation`) before any kind-
2822    // coherence, code-path, or downstream gate sees it.
2823
2824    #[test]
2825    fn deps_violation_on_empty_dep_nome() {
2826        // Empty `:nome` on a `:deps` entry surfaces the narrower
2827        // `DepError::NomeEmpty` arm through the wrap envelope.
2828        use crate::Dep;
2829        let root = PathBuf::from("/tmp/x");
2830        let manifest = root.join("caixa.lisp");
2831        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
2832        let mut c = caixa(CaixaKind::Biblioteca);
2833        c.deps = vec![Dep::simple("", "^0.1")];
2834        let err = layout.verify(&c, &root).unwrap_err();
2835        let LayoutError::DepsViolation { caixa, issue } = err else {
2836            panic!("expected LayoutError::DepsViolation, got {err:?}");
2837        };
2838        assert_eq!(caixa, "demo");
2839        assert!(
2840            issue.contains(":deps") && issue.contains(":nome"),
2841            "issue must name the offending slot + axis: {issue}",
2842        );
2843    }
2844
2845    #[test]
2846    fn deps_violation_on_uppercase_dep_nome() {
2847        // Uppercase `:nome` on a `:deps` entry surfaces
2848        // `DepError::NomeInvalid` (DNS-1123 violation) through the wrap.
2849        use crate::Dep;
2850        let root = PathBuf::from("/tmp/x");
2851        let manifest = root.join("caixa.lisp");
2852        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
2853        let mut c = caixa(CaixaKind::Biblioteca);
2854        c.deps = vec![Dep::simple("Caixa-Teia", "^0.1")];
2855        let err = layout.verify(&c, &root).unwrap_err();
2856        let LayoutError::DepsViolation { caixa, issue } = err else {
2857            panic!("expected LayoutError::DepsViolation, got {err:?}");
2858        };
2859        assert_eq!(caixa, "demo");
2860        assert!(
2861            issue.contains("Caixa-Teia"),
2862            "issue must quote the offending dep nome verbatim: {issue}",
2863        );
2864    }
2865
2866    #[test]
2867    fn deps_violation_on_unparseable_dep_versao() {
2868        // Unparseable `:versao` requirement on a `:deps` entry surfaces
2869        // `DepError::VersaoInvalid` through the wrap — the canonical
2870        // "the semver::Error reached the resolver, far from the source"
2871        // footgun closed at author time.
2872        use crate::Dep;
2873        let root = PathBuf::from("/tmp/x");
2874        let manifest = root.join("caixa.lisp");
2875        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
2876        let mut c = caixa(CaixaKind::Biblioteca);
2877        c.deps = vec![Dep::simple("caixa-teia", "not-a-req")];
2878        let err = layout.verify(&c, &root).unwrap_err();
2879        let LayoutError::DepsViolation { caixa, issue } = err else {
2880            panic!("expected LayoutError::DepsViolation, got {err:?}");
2881        };
2882        assert_eq!(caixa, "demo");
2883        assert!(
2884            issue.contains("caixa-teia") && issue.contains("not-a-req"),
2885            "issue must quote the dep nome + offending versao: {issue}",
2886        );
2887    }
2888
2889    #[test]
2890    fn deps_violation_on_duplicate_nome_in_deps() {
2891        // Within-list `:deps :nome` duplicate surfaces
2892        // `DepError::DuplicateNome { list: ":deps" }` through the wrap.
2893        use crate::Dep;
2894        let root = PathBuf::from("/tmp/x");
2895        let manifest = root.join("caixa.lisp");
2896        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
2897        let mut c = caixa(CaixaKind::Biblioteca);
2898        c.deps = vec![
2899            Dep::simple("caixa-teia", "^0.1"),
2900            Dep::simple("caixa-teia", "^0.2"),
2901        ];
2902        let err = layout.verify(&c, &root).unwrap_err();
2903        let LayoutError::DepsViolation { caixa, issue } = err else {
2904            panic!("expected LayoutError::DepsViolation, got {err:?}");
2905        };
2906        assert_eq!(caixa, "demo");
2907        assert!(
2908            issue.contains("caixa-teia") && issue.contains(":deps"),
2909            "issue must quote the duplicated nome + list: {issue}",
2910        );
2911    }
2912
2913    #[test]
2914    fn deps_violation_on_duplicate_nome_in_deps_dev() {
2915        // Within-list `:deps-dev :nome` duplicate surfaces the same
2916        // diagnostic on the dev-only axis — neither list is a
2917        // second-class citizen of the typed surface.
2918        use crate::Dep;
2919        let root = PathBuf::from("/tmp/x");
2920        let manifest = root.join("caixa.lisp");
2921        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
2922        let mut c = caixa(CaixaKind::Biblioteca);
2923        c.deps_dev = vec![
2924            Dep::simple("caixa-teia", "^0.1"),
2925            Dep::simple("caixa-teia", "^0.2"),
2926        ];
2927        let err = layout.verify(&c, &root).unwrap_err();
2928        let LayoutError::DepsViolation { caixa, issue } = err else {
2929            panic!("expected LayoutError::DepsViolation, got {err:?}");
2930        };
2931        assert_eq!(caixa, "demo");
2932        assert!(
2933            issue.contains(":deps-dev"),
2934            "issue must name the offending list: {issue}",
2935        );
2936    }
2937
2938    #[test]
2939    fn deps_violation_in_deps_fires_before_deps_dev() {
2940        // Precedence pin: when *both* `:deps` and `:deps-dev` carry a
2941        // malformed entry, the `:deps` walk fires first — the canonical
2942        // declaration-order precedence `Caixa::validate_deps` establishes
2943        // (the same author-grep ordering the typed-graph peers use on
2944        // every other Vec-shaped surface).
2945        use crate::Dep;
2946        let root = PathBuf::from("/tmp/x");
2947        let manifest = root.join("caixa.lisp");
2948        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
2949        let mut c = caixa(CaixaKind::Biblioteca);
2950        c.deps = vec![Dep::simple("Bad-In-Deps", "^0.1")];
2951        c.deps_dev = vec![Dep::simple("Bad-In-Deps-Dev", "^0.1")];
2952        let err = layout.verify(&c, &root).unwrap_err();
2953        let LayoutError::DepsViolation { caixa: _, issue } = err else {
2954            panic!("expected LayoutError::DepsViolation, got {err:?}");
2955        };
2956        assert!(
2957            issue.contains("Bad-In-Deps") && !issue.contains("Bad-In-Deps-Dev"),
2958            "issue must name the :deps offender, not :deps-dev: {issue}",
2959        );
2960    }
2961
2962    #[test]
2963    fn deps_violation_fires_after_versao_violation() {
2964        // Precedence pin: when both the top-level `:versao` and a `:deps`
2965        // entry are malformed, the Caixa-identity gate fires first — the
2966        // canonical declaration order on `Caixa` (`:nome` → `:versao` →
2967        // ... → `:deps`) and the same identity-axis-dominates-content-
2968        // axis discipline the peer `validate_nome` / `validate_versao`
2969        // wire-up established (1f74a5f). A malformed `:versao` would
2970        // otherwise quote `caixa.nome` against a downstream-shaped
2971        // diagnostic.
2972        use crate::Dep;
2973        let root = PathBuf::from("/tmp/x");
2974        let manifest = root.join("caixa.lisp");
2975        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
2976        let mut c = caixa(CaixaKind::Biblioteca);
2977        c.versao = "v0.1.0".into();
2978        c.deps = vec![Dep::simple("Bad-Dep", "^0.1")];
2979        let err = layout.verify(&c, &root).unwrap_err();
2980        assert!(
2981            matches!(err, LayoutError::VersaoViolation { .. }),
2982            "got {err:?} — versao must fire before DepsViolation",
2983        );
2984    }
2985
2986    #[test]
2987    fn deps_violation_fires_before_kind_coherence() {
2988        // Precedence pin: a Supervisor with a malformed `:deps` entry
2989        // AND declared `:bibliotecas` (the canonical SupervisorOwnsCode
2990        // shape) surfaces DepsViolation, not SupervisorOwnsCode — the
2991        // dep surface is universal across all kinds and its shape gate
2992        // is more fundamental than the kind-coherence partitions on
2993        // `:bibliotecas` / `:exe` / `:servicos`. The author can fix the
2994        // dep typo without first being told to move their `:bibliotecas`
2995        // off a Supervisor.
2996        use crate::Dep;
2997        let root = PathBuf::from("/tmp/x");
2998        let manifest = root.join("caixa.lisp");
2999        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
3000        let mut c = caixa(CaixaKind::Supervisor);
3001        c.deps = vec![Dep::simple("Bad-Dep", "^0.1")];
3002        c.bibliotecas = vec!["lib/x.lisp".into()];
3003        let err = layout.verify(&c, &root).unwrap_err();
3004        assert!(
3005            matches!(err, LayoutError::DepsViolation { .. }),
3006            "got {err:?} — DepsViolation must fire before SupervisorOwnsCode",
3007        );
3008    }
3009
3010    #[test]
3011    fn deps_violation_fires_after_missing_manifest() {
3012        // Precedence pin: `MissingManifest` still dominates — there's no
3013        // caixa to deps-check when the manifest is missing.
3014        use crate::Dep;
3015        let root = PathBuf::from("/tmp/x");
3016        let layout = StandardLayout::new().with_path_exists(|_| false);
3017        let mut c = caixa(CaixaKind::Biblioteca);
3018        c.deps = vec![Dep::simple("Bad-Dep", "^0.1")];
3019        let err = layout.verify(&c, &root).unwrap_err();
3020        assert!(
3021            matches!(err, LayoutError::MissingManifest(_)),
3022            "got {err:?} — MissingManifest must dominate the deps gate",
3023        );
3024    }
3025
3026    #[test]
3027    fn deps_violation_on_self_dep_in_deps() {
3028        // Cross-slot self-edge: a caixa whose `:deps` lists its own
3029        // `:nome` is rejected at the layout wire-up, the diagnostic
3030        // surfaces through the `DepsViolation` envelope with both the
3031        // offending list tag (`":deps"`) and the parent's `:nome`
3032        // verbatim. Until this wire-up landed the self-dep silently
3033        // passed `feira build` and the resolver's lacre-pipeline
3034        // closure walk either rejected mid-traversal (infinite
3035        // recursion detected far from the source caixa.lisp) or, on
3036        // the unbounded path, recursed until it exhausted its stack.
3037        // Mirrors the supervision-tree
3038        // [`supervisor_violation_on_self_supervision`] and the
3039        // Aplicacao-membership self-edge wire-up tests on the peer
3040        // typed-name-graph axes.
3041        use crate::Dep;
3042        let root = PathBuf::from("/tmp/x");
3043        let manifest = root.join("caixa.lisp");
3044        let default_lib = root.join("lib").join("demo.lisp");
3045        let layout =
3046            StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
3047        let mut c = caixa(CaixaKind::Biblioteca);
3048        c.deps = vec![Dep::simple("demo", "^0.1")];
3049        let err = layout.verify(&c, &root).unwrap_err();
3050        let LayoutError::DepsViolation { caixa, issue } = err else {
3051            panic!("expected LayoutError::DepsViolation, got {err:?}");
3052        };
3053        assert_eq!(caixa, "demo");
3054        assert!(
3055            issue.contains(":deps") && issue.contains("demo"),
3056            "issue must name the offending list + parent :nome: {issue}",
3057        );
3058    }
3059
3060    #[test]
3061    fn deps_violation_on_self_dep_in_deps_dev() {
3062        // Same cross-slot self-edge gate on the `:deps-dev` axis —
3063        // neither dep list is a second-class citizen of the typed
3064        // surface. The diagnostic names `:deps-dev` so the author can
3065        // grep their caixa.lisp for the offending block directly.
3066        use crate::Dep;
3067        let root = PathBuf::from("/tmp/x");
3068        let manifest = root.join("caixa.lisp");
3069        let default_lib = root.join("lib").join("demo.lisp");
3070        let layout =
3071            StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
3072        let mut c = caixa(CaixaKind::Biblioteca);
3073        c.deps_dev = vec![Dep::simple("demo", "^0.1")];
3074        let err = layout.verify(&c, &root).unwrap_err();
3075        let LayoutError::DepsViolation { caixa, issue } = err else {
3076            panic!("expected LayoutError::DepsViolation, got {err:?}");
3077        };
3078        assert_eq!(caixa, "demo");
3079        assert!(
3080            issue.contains(":deps-dev"),
3081            "issue must name the offending list: {issue}",
3082        );
3083    }
3084
3085    #[test]
3086    fn self_dep_fires_after_per_entry_dep_shape() {
3087        // Precedence pin: the per-entry shape gates of
3088        // [`Caixa::validate_deps`] (DNS-1123 / SemVer / fonte / etc.)
3089        // fire first on a self-dep entry whose `:nome` is malformed.
3090        // Same ordering posture every peer cross-slot gate uses
3091        // (`validate_no_self_supervision` after `SupervisorSpec::validate`,
3092        // `validate_no_self_membership` after `AplicacaoSpec::validate`).
3093        // A malformed self-dep `:nome` surfaces the narrower
3094        // per-entry diagnostic (which already names the parser-side
3095        // reason) before the self-edge gate sees the entry.
3096        use crate::Dep;
3097        let root = PathBuf::from("/tmp/x");
3098        let manifest = root.join("caixa.lisp");
3099        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
3100        let mut c = caixa(CaixaKind::Biblioteca);
3101        // The parent is "demo" (DNS-1123 valid); the dep is "DEMO"
3102        // (DNS-1123 invalid). The per-entry shape gate fires on the
3103        // upper-case nome, masking the self-edge gate (and that's the
3104        // canonical precedence — fix the dep shape first, then the
3105        // structural self-edge becomes the next live diagnostic).
3106        c.deps = vec![Dep::simple("DEMO", "^0.1")];
3107        let err = layout.verify(&c, &root).unwrap_err();
3108        let LayoutError::DepsViolation { caixa: _, issue } = err else {
3109            panic!("expected LayoutError::DepsViolation, got {err:?}");
3110        };
3111        assert!(
3112            issue.contains("DNS-1123"),
3113            "issue must be the per-entry shape diagnostic, not the self-edge gate: {issue}",
3114        );
3115    }
3116
3117    #[test]
3118    fn valid_deps_pass_to_downstream_gates() {
3119        // Positive control pin: the canonical authoring shape (one
3120        // `:deps` entry naming a DNS-1123 nome + Cargo-shaped requirement,
3121        // one `:deps-dev` entry on a distinct nome) passes the dep gate;
3122        // downstream gates (MissingLib here) take over. Drift here =
3123        // a future tighten that rejects any canonical shape surfaces as
3124        // a regression at this layout-level pin.
3125        use crate::Dep;
3126        let root = PathBuf::from("/tmp/x");
3127        let manifest = root.join("caixa.lisp");
3128        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
3129        let mut c = caixa(CaixaKind::Biblioteca);
3130        c.deps = vec![Dep::simple("caixa-teia", "^0.1")];
3131        c.deps_dev = vec![Dep::simple("caixa-lint", "^0.2")];
3132        let err = layout.verify(&c, &root).unwrap_err();
3133        assert!(
3134            matches!(err, LayoutError::MissingLib { .. }),
3135            "got {err:?} — valid deps must pass to MissingLib",
3136        );
3137    }
3138
3139    #[test]
3140    fn code_path_gate_runs_after_foreign_code_slot_gate() {
3141        // Precedence pin: a Servico that declares `:exe` (foreign code
3142        // surface) surfaces ForeignCodeSlot, *not* a per-entry path
3143        // shape diagnostic, even when the `:exe` entry is itself
3144        // malformed. The kind-coherence gate is the load-bearing
3145        // diagnostic at this site — once the slot is moved off the
3146        // wrong kind, the per-entry shape gate becomes the next live
3147        // diagnostic.
3148        let root = PathBuf::from("/tmp/x");
3149        let manifest = root.join("caixa.lisp");
3150        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
3151        let mut c = caixa(CaixaKind::Servico);
3152        c.servicos = vec!["servicos/ok.yaml".into()];
3153        c.exe = vec!["/etc/foreign".into()];
3154        let err = layout.verify(&c, &root).unwrap_err();
3155        assert!(
3156            matches!(err, LayoutError::ForeignCodeSlot { .. }),
3157            "expected ForeignCodeSlot (kind-coherence wins over per-entry shape), got {err:?}",
3158        );
3159    }
3160
3161    // ── M2 typed-substrate invariants ────────────────────────────────────
3162
3163    #[test]
3164    fn behavior_callback_path_must_exist() {
3165        use crate::BehaviorSpec;
3166        use std::path::PathBuf;
3167        let root = PathBuf::from("/tmp/x");
3168        let manifest = root.join("caixa.lisp");
3169        let mut c = caixa(CaixaKind::Servico);
3170        c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
3171        let svc = root.join("servicos/demo.computeunit.yaml");
3172        c.behavior = Some(BehaviorSpec {
3173            on_init: Some(PathBuf::from("lib/init.lisp")),
3174            ..Default::default()
3175        });
3176        let manifest_clone = manifest.clone();
3177        let svc_clone = svc.clone();
3178        let layout =
3179            StandardLayout::new().with_path_exists(move |p| p == manifest_clone || p == svc_clone);
3180        let err = layout.verify(&c, &root).unwrap_err();
3181        assert!(matches!(
3182            err,
3183            LayoutError::MissingEntry { kind, .. }
3184                if kind == crate::render::LAYOUT_MISSING_ENTRY_KIND_BEHAVIOR_CALLBACK
3185        ));
3186
3187        // Now declare the path exists — passes.
3188        let init = root.join("lib/init.lisp");
3189        let layout =
3190            StandardLayout::new().with_path_exists(move |p| p == manifest || p == svc || p == init);
3191        layout.verify(&c, &root).unwrap();
3192    }
3193
3194    #[test]
3195    fn behavior_absolute_callback_is_violation_not_missing() {
3196        // An absolute path silently subverts `root.join(p)` (Path::join
3197        // replaces the base when the right side is absolute). Before
3198        // BehaviorSpec::validate ran, an `:on-init "/etc/passwd"` would
3199        // surface as a confusing "missing behavior-callback /etc/passwd"
3200        // — or, worse, pass when /etc/passwd happens to exist. Now it's
3201        // a value-shape error naming the slot.
3202        use crate::BehaviorSpec;
3203        let root = PathBuf::from("/tmp/x");
3204        let manifest = root.join("caixa.lisp");
3205        let svc = root.join("servicos/demo.computeunit.yaml");
3206        let mut c = caixa(CaixaKind::Servico);
3207        c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
3208        c.behavior = Some(BehaviorSpec {
3209            on_init: Some(PathBuf::from("/etc/passwd")),
3210            ..Default::default()
3211        });
3212        // Path exists check would *succeed* on /etc/passwd (proving the
3213        // sandbox bypass) — value-shape pass must fire first.
3214        let layout = StandardLayout::new()
3215            .with_path_exists(move |p| p == manifest || p == svc || p == Path::new("/etc/passwd"));
3216        let err = layout.verify(&c, &root).unwrap_err();
3217        assert!(
3218            matches!(err, LayoutError::BehaviorViolation { ref caixa, .. } if caixa == "demo"),
3219            "got {err:?}",
3220        );
3221    }
3222
3223    #[test]
3224    fn behavior_empty_callback_is_violation() {
3225        use crate::BehaviorSpec;
3226        let root = PathBuf::from("/tmp/x");
3227        let manifest = root.join("caixa.lisp");
3228        let svc = root.join("servicos/demo.computeunit.yaml");
3229        let mut c = caixa(CaixaKind::Servico);
3230        c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
3231        c.behavior = Some(BehaviorSpec {
3232            on_call: Some(PathBuf::new()),
3233            ..Default::default()
3234        });
3235        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest || p == svc);
3236        let err = layout.verify(&c, &root).unwrap_err();
3237        assert!(matches!(err, LayoutError::BehaviorViolation { .. }));
3238    }
3239
3240    #[test]
3241    fn upgrade_from_duplicate_surfaces_as_upgrade_violation() {
3242        // Wiring pin: the cross-entry duplicate-`:from` gate in
3243        // `validate_upgrade_from` lands on the same
3244        // `LayoutError::UpgradeViolation` axis the per-entry
3245        // `UpgradeFromEntry::validate` already does (26da2c7), so a
3246        // caixa.lisp with two `(:from "0.1.0" …)` blocks surfaces at
3247        // `feira build` time naming the offending caixa rather than
3248        // silently passing into the wasm-operator's non-deterministic
3249        // dispatch. Mirrors `behavior_empty_callback_is_violation` on
3250        // the peer M2 typed slot.
3251        use crate::{UpgradeFromEntry, UpgradeInstruction};
3252        let root = PathBuf::from("/tmp/x");
3253        let manifest = root.join("caixa.lisp");
3254        let svc = root.join("servicos/demo.computeunit.yaml");
3255        let mut c = caixa(CaixaKind::Servico);
3256        c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
3257        c.upgrade_from = vec![
3258            UpgradeFromEntry {
3259                from: "0.1.0".into(),
3260                instructions: vec![UpgradeInstruction::Restart],
3261            },
3262            UpgradeFromEntry {
3263                from: "0.1.0".into(),
3264                instructions: vec![UpgradeInstruction::Restart],
3265            },
3266        ];
3267        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest || p == svc);
3268        let err = layout.verify(&c, &root).unwrap_err();
3269        let LayoutError::UpgradeViolation { caixa, issue } = err else {
3270            panic!("expected LayoutError::UpgradeViolation for duplicate `:from`, got {err:?}");
3271        };
3272        assert_eq!(caixa, "demo");
3273        assert!(
3274            issue.contains("0.1.0"),
3275            "UpgradeViolation issue must name the offending `:from` verbatim, got {issue:?}"
3276        );
3277    }
3278
3279    #[test]
3280    fn upgrade_from_downgrade_surfaces_as_upgrade_violation() {
3281        // Wiring pin: the cross-slot precedence gate in
3282        // `validate_upgrade_from_against_versao` lands on the same
3283        // `LayoutError::UpgradeViolation` axis the per-entry and
3284        // cross-entry gates already do (26da2c7, 7c6aef2), so a
3285        // caixa.lisp whose `:upgrade-from :from` is greater than the
3286        // caixa's own `:versao` surfaces at `feira build` time
3287        // naming the offending caixa rather than silently passing
3288        // into the wasm-operator's `:from`-match dispatch where the
3289        // entry would sit dormant forever. Mirrors
3290        // `upgrade_from_duplicate_surfaces_as_upgrade_violation` on
3291        // the peer cross-entry gate.
3292        use crate::{UpgradeFromEntry, UpgradeInstruction};
3293        let root = PathBuf::from("/tmp/x");
3294        let manifest = root.join("caixa.lisp");
3295        let svc = root.join("servicos/demo.computeunit.yaml");
3296        let mut c = caixa(CaixaKind::Servico);
3297        c.versao = "0.1.5".into();
3298        c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
3299        c.upgrade_from = vec![UpgradeFromEntry {
3300            from: "0.2.0".into(),
3301            instructions: vec![UpgradeInstruction::Restart],
3302        }];
3303        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest || p == svc);
3304        let err = layout.verify(&c, &root).unwrap_err();
3305        let LayoutError::UpgradeViolation { caixa, issue } = err else {
3306            panic!(
3307                "expected LayoutError::UpgradeViolation for downgrade-shaped `:from`, got {err:?}"
3308            );
3309        };
3310        assert_eq!(caixa, "demo");
3311        assert!(
3312            issue.contains("0.2.0") && issue.contains("0.1.5"),
3313            "UpgradeViolation issue must name both `:from` and `:versao` verbatim, got {issue:?}"
3314        );
3315    }
3316
3317    #[test]
3318    fn upgrade_from_equal_to_versao_surfaces_as_upgrade_violation() {
3319        // Self-upgrade no-op arm: `:from "0.1.0"` while
3320        // `:versao "0.1.0"` declares "upgrade from myself to
3321        // myself", which the operator's dispatch either skips
3322        // silently or trivially "succeeds" with no observable
3323        // transition. Surfaces at validate time naming both values
3324        // so the author can fix in one edit.
3325        use crate::{UpgradeFromEntry, UpgradeInstruction};
3326        let root = PathBuf::from("/tmp/x");
3327        let manifest = root.join("caixa.lisp");
3328        let svc = root.join("servicos/demo.computeunit.yaml");
3329        let mut c = caixa(CaixaKind::Servico);
3330        c.versao = "0.1.0".into();
3331        c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
3332        c.upgrade_from = vec![UpgradeFromEntry {
3333            from: "0.1.0".into(),
3334            instructions: vec![UpgradeInstruction::Restart],
3335        }];
3336        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest || p == svc);
3337        let err = layout.verify(&c, &root).unwrap_err();
3338        let LayoutError::UpgradeViolation { caixa, issue } = err else {
3339            panic!(
3340                "expected LayoutError::UpgradeViolation for self-upgrade `:from == :versao`, got \
3341                 {err:?}"
3342            );
3343        };
3344        assert_eq!(caixa, "demo");
3345        assert!(
3346            issue.contains("0.1.0"),
3347            "UpgradeViolation issue must name the equal `:from`/`:versao` verbatim, got {issue:?}"
3348        );
3349    }
3350
3351    #[test]
3352    fn upgrade_from_strict_upgrade_passes_layout() {
3353        // Positive control for the precedence gate at the
3354        // LayoutInvariants level: a valid `:from < :versao` chain
3355        // (`0.1.0 → 0.2.0`) must not regress into a false-positive
3356        // `UpgradeViolation`. Mirrors `behavior_callback_path_must_exist`'s
3357        // positive-control arm.
3358        use crate::{UpgradeFromEntry, UpgradeInstruction};
3359        let root = PathBuf::from("/tmp/x");
3360        let manifest = root.join("caixa.lisp");
3361        let svc = root.join("servicos/demo.computeunit.yaml");
3362        let mut c = caixa(CaixaKind::Servico);
3363        c.versao = "0.2.0".into();
3364        c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
3365        c.upgrade_from = vec![UpgradeFromEntry {
3366            from: "0.1.0".into(),
3367            instructions: vec![UpgradeInstruction::Restart],
3368        }];
3369        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest || p == svc);
3370        layout.verify(&c, &root).unwrap();
3371    }
3372
3373    #[test]
3374    fn upgrade_script_path_must_exist() {
3375        use crate::{BehaviorSpec, UpgradeFromEntry, UpgradeInstruction};
3376        use std::path::PathBuf;
3377        let root = PathBuf::from("/tmp/x");
3378        let manifest = root.join("caixa.lisp");
3379        let svc = root.join("servicos/demo.computeunit.yaml");
3380        let on_state_change = root.join("lib/migrations.lisp");
3381        let mut c = caixa(CaixaKind::Servico);
3382        // `:versao` past the entry's `:from` so the cross-slot
3383        // precedence gate (`FromNotBeforeVersao`) lets this case
3384        // through to the path-existence pass under test.
3385        c.versao = "0.2.0".into();
3386        c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
3387        // `:on-state-change` declared so the cross-slot composition
3388        // gate (`validate_upgrade_from_against_behavior`) lets the
3389        // `:state-change` entry through to the path-existence pass
3390        // under test. Without the callback the missing-callback gate
3391        // would surface first and the path-existence pass wouldn't be
3392        // exercised.
3393        c.behavior = Some(BehaviorSpec {
3394            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
3395            ..Default::default()
3396        });
3397        // A `:load-module` precedes the `:state-change` so the entry
3398        // satisfies the within-entry state-change-ordering gate
3399        // (`StateChangeWithoutPriorLoad`) and the path-existence pass
3400        // under test is the gate actually exercised. `:load-module`
3401        // carries no on-disk path, so it adds no existence requirement.
3402        c.upgrade_from = vec![UpgradeFromEntry {
3403            from: "0.1.0".into(),
3404            instructions: vec![
3405                UpgradeInstruction::LoadModule {
3406                    module: "demo".into(),
3407                },
3408                UpgradeInstruction::StateChange {
3409                    script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
3410                },
3411            ],
3412        }];
3413        let manifest_clone = manifest.clone();
3414        let svc_clone = svc.clone();
3415        let on_state_change_clone = on_state_change.clone();
3416        let layout = StandardLayout::new().with_path_exists(move |p| {
3417            p == manifest_clone || p == svc_clone || p == on_state_change_clone
3418        });
3419        let err = layout.verify(&c, &root).unwrap_err();
3420        assert!(matches!(
3421            err,
3422            LayoutError::MissingEntry { kind, .. }
3423                if kind == crate::render::LAYOUT_MISSING_ENTRY_KIND_UPGRADE_SCRIPT
3424        ));
3425    }
3426
3427    #[test]
3428    fn layout_missing_entry_kind_m2_consts_pin_canonical_kebab_case_labels() {
3429        // Byte-identity pin: the two per-M2-slot leaf-kind labels the
3430        // [`LayoutError::MissingEntry`] `kind: &'static str`
3431        // discriminator surfaces under (the M2 `:behavior` per-callback
3432        // on-disk-leaf axis, the M2 `:upgrade-from :instructions`
3433        // per-`:state-change` script-path on-disk-leaf axis) route
3434        // through the lifted [`crate::render::LAYOUT_MISSING_ENTRY_KIND_BEHAVIOR_CALLBACK`]
3435        // / [`crate::render::LAYOUT_MISSING_ENTRY_KIND_UPGRADE_SCRIPT`]
3436        // consts, so a future rebrand that reaches the const but not
3437        // the production emit / test probe (or vice versa) surfaces
3438        // here at build time rather than at runtime as a downstream
3439        // [`LayoutError::MissingEntry`] `kind: <stale-label>`
3440        // diagnostic mismatch far from the rename's commit. Mirror of
3441        // the peer
3442        // [`crate::aplicacao::tests::contrato_author_key_consts_pin_canonical_kebab_case_labels`]
3443        // (f50c875) and
3444        // [`crate::upgrade::tests::upgrade_instruction_kind_consts_pin_canonical_kebab_case_tags`]
3445        // (56120ef) byte-identity pins on the sibling M3 `:contratos`
3446        // per-entry endpoint-label + M2 `:upgrade-from :instructions`
3447        // per-variant kind-tag axes.
3448        assert_eq!(
3449            crate::render::LAYOUT_MISSING_ENTRY_KIND_BEHAVIOR_CALLBACK,
3450            "behavior-callback"
3451        );
3452        assert_eq!(
3453            crate::render::LAYOUT_MISSING_ENTRY_KIND_UPGRADE_SCRIPT,
3454            "upgrade-script"
3455        );
3456    }
3457
3458    #[test]
3459    fn layout_missing_entry_kind_m0_consts_pin_canonical_code_slot_labels() {
3460        // Byte-identity pin: the three per-M0-code-slot leaf-kind
3461        // labels the [`LayoutError::MissingEntry`] `kind: &'static
3462        // str` discriminator surfaces under (the `:bibliotecas`
3463        // per-entry axis, the `:exe` per-entry axis, the `:servicos`
3464        // per-entry axis) route through the lifted
3465        // [`crate::render::LAYOUT_MISSING_ENTRY_KIND_BIBLIOTECA`] /
3466        // [`crate::render::LAYOUT_MISSING_ENTRY_KIND_EXE`] /
3467        // [`crate::render::LAYOUT_MISSING_ENTRY_KIND_SERVICO`] consts,
3468        // so a future rebrand that reaches the const but not the
3469        // production emit (or vice versa) surfaces here at build time
3470        // rather than at runtime as a downstream
3471        // [`LayoutError::MissingEntry`] `kind: <stale-label>`
3472        // diagnostic mismatch far from the rename's commit. Mirror of
3473        // the peer M2-tier pin
3474        // [`layout_missing_entry_kind_m2_consts_pin_canonical_kebab_case_labels`]
3475        // (95c9c4c) on the sibling `:behavior` / `:upgrade-from`
3476        // per-slot leaf-kind axes.
3477        assert_eq!(
3478            crate::render::LAYOUT_MISSING_ENTRY_KIND_BIBLIOTECA,
3479            "biblioteca"
3480        );
3481        assert_eq!(crate::render::LAYOUT_MISSING_ENTRY_KIND_EXE, "exe");
3482        assert_eq!(crate::render::LAYOUT_MISSING_ENTRY_KIND_SERVICO, "servico");
3483    }
3484
3485    #[test]
3486    fn layout_missing_entry_kind_m0_consts_align_with_caixa_kind_as_str() {
3487        // Cross-axis byte-identity pin: the two `:kind`-namesake M0
3488        // leaf-kind labels ([`crate::render::LAYOUT_MISSING_ENTRY_KIND_BIBLIOTECA`]
3489        // = `"biblioteca"`,
3490        // [`crate::render::LAYOUT_MISSING_ENTRY_KIND_SERVICO`] =
3491        // `"servico"`) must equal [`crate::CaixaKind::Biblioteca`] /
3492        // [`crate::CaixaKind::Servico`]'s
3493        // [`crate::CaixaKind::as_str`] outputs verbatim — the
3494        // substrate's canonical human-readable-kind axis and the
3495        // layout diagnostic's per-slot leaf-kind axis share one
3496        // vocabulary for these two arms by design (both label the
3497        // caixa's code-producing shape by its Portuguese-native
3498        // idiom), so drift between the two lands as a build-time
3499        // pattern-arm miss here rather than as a runtime diagnostic
3500        // that reads inconsistently across `feira build`'s
3501        // per-invocation output.
3502        //
3503        // The third M0 arm ([`crate::render::LAYOUT_MISSING_ENTRY_KIND_EXE`]
3504        // = `"exe"`) is deliberately *distinct* from
3505        // [`crate::CaixaKind::Binario`]'s [`crate::CaixaKind::as_str`]
3506        // output (`"binario"`) — the `:exe` code slot names the
3507        // per-directory leaf-kind at the `exe/` subtree, whereas
3508        // [`crate::CaixaKind::Binario`] names the caixa's own runtime
3509        // kind. Two axes, two labels — the inequality assertion here
3510        // pins the split so a future accidental collapse of the two
3511        // onto one scalar (a rebrand that reroutes either axis to
3512        // match the other) trips at build time.
3513        assert_eq!(
3514            crate::render::LAYOUT_MISSING_ENTRY_KIND_BIBLIOTECA,
3515            CaixaKind::Biblioteca.as_str()
3516        );
3517        assert_eq!(
3518            crate::render::LAYOUT_MISSING_ENTRY_KIND_SERVICO,
3519            CaixaKind::Servico.as_str()
3520        );
3521        assert_ne!(
3522            crate::render::LAYOUT_MISSING_ENTRY_KIND_EXE,
3523            CaixaKind::Binario.as_str(),
3524            "`exe` leaf-kind label names the per-directory code-slot \
3525             axis; `binario` names the caixa-kind axis — the two must \
3526             not silently collapse onto one scalar"
3527        );
3528    }
3529
3530    #[test]
3531    fn layout_missing_entry_kind_consts_are_pairwise_distinct() {
3532        // Distinctness pin: the five [`LayoutError::MissingEntry`]
3533        // `kind: &'static str` accept-set members
3534        // ([`crate::render::LAYOUT_MISSING_ENTRY_KIND_BIBLIOTECA`] /
3535        // [`crate::render::LAYOUT_MISSING_ENTRY_KIND_EXE`] /
3536        // [`crate::render::LAYOUT_MISSING_ENTRY_KIND_SERVICO`] on the
3537        // M0 code-slot arms plus
3538        // [`crate::render::LAYOUT_MISSING_ENTRY_KIND_BEHAVIOR_CALLBACK`]
3539        // / [`crate::render::LAYOUT_MISSING_ENTRY_KIND_UPGRADE_SCRIPT`]
3540        // on the M2 slot arms) must be pairwise distinct — an
3541        // accidental copy-paste flip that reroutes one label's byte-
3542        // string to also match another silently collapses two
3543        // per-slot diagnostics onto one, so an operator running
3544        // `feira build` reads `kind: "biblioteca"` for what should
3545        // have surfaced as a `:behavior :on-init` script-not-found
3546        // diagnostic (or vice versa). This pin catches any such
3547        // flip at build time. Mirror of the peer
3548        // [`crate::render::tests::m2_limits_key_consts_are_pairwise_distinct`]
3549        // / peer distinctness pins on other closed-set typed axes.
3550        let entries: &[(&str, &str)] = &[
3551            (
3552                "LAYOUT_MISSING_ENTRY_KIND_BIBLIOTECA",
3553                crate::render::LAYOUT_MISSING_ENTRY_KIND_BIBLIOTECA,
3554            ),
3555            (
3556                "LAYOUT_MISSING_ENTRY_KIND_EXE",
3557                crate::render::LAYOUT_MISSING_ENTRY_KIND_EXE,
3558            ),
3559            (
3560                "LAYOUT_MISSING_ENTRY_KIND_SERVICO",
3561                crate::render::LAYOUT_MISSING_ENTRY_KIND_SERVICO,
3562            ),
3563            (
3564                "LAYOUT_MISSING_ENTRY_KIND_BEHAVIOR_CALLBACK",
3565                crate::render::LAYOUT_MISSING_ENTRY_KIND_BEHAVIOR_CALLBACK,
3566            ),
3567            (
3568                "LAYOUT_MISSING_ENTRY_KIND_UPGRADE_SCRIPT",
3569                crate::render::LAYOUT_MISSING_ENTRY_KIND_UPGRADE_SCRIPT,
3570            ),
3571        ];
3572        for (i, (name_a, value_a)) in entries.iter().enumerate() {
3573            for (name_b, value_b) in entries.iter().skip(i + 1) {
3574                assert_ne!(
3575                    value_a, value_b,
3576                    "LAYOUT_MISSING_ENTRY_KIND_* consts must be \
3577                     pairwise-distinct byte-strings — {name_a} and \
3578                     {name_b} both resolve to {value_a:?}"
3579                );
3580            }
3581        }
3582    }
3583
3584    #[test]
3585    fn layout_dir_consts_pin_canonical_directory_names() {
3586        // Scalar-value pin for the three [`crate::render::LAYOUT_DIR_*`]
3587        // consts naming the CSE-invariant per-[`CaixaKind`]
3588        // on-disk-directory-name axes the substrate's layout invariants
3589        // pin (`lib/` for [`CaixaKind::Biblioteca`], `exe/` for
3590        // [`CaixaKind::Binario`], `servicos/` for [`CaixaKind::Servico`]).
3591        // A future rebrand of any of the three on-disk directory landing
3592        // conventions must reach this pin — the const-edit lands on one
3593        // arm, the assertion here re-pins the new byte-string, and every
3594        // downstream consumer (the caixa-feira `init` / `fmt` / `lint` /
3595        // `tofu` scaffolders, the [`crate::LayoutInvariants::verify`]
3596        // sandbox reconstruction, the future
3597        // `feira app deploy`-cluster scaffolder) picks up the new
3598        // directory name at build time. Mirror of the peer
3599        // [`layout_missing_entry_kind_m0_consts_pin_canonical_code_slot_labels`]
3600        // (fe2a898) on the sibling
3601        // [`crate::LayoutError::MissingEntry`] `kind:` discriminator
3602        // axis this on-disk-directory axis composes with.
3603        assert_eq!(crate::render::LAYOUT_DIR_LIB, "lib");
3604        assert_eq!(crate::render::LAYOUT_DIR_EXE, "exe");
3605        assert_eq!(crate::render::LAYOUT_DIR_SERVICOS, "servicos");
3606    }
3607
3608    #[test]
3609    fn layout_dir_consts_are_pairwise_distinct() {
3610        // Distinctness pin: the three per-[`CaixaKind`]
3611        // on-disk-directory-name arms must resolve to pairwise-distinct
3612        // byte-strings — a future accidental copy-paste flip that
3613        // reroutes any one of the three onto another's value silently
3614        // collapses two per-kind on-disk sandboxes onto one, so
3615        // [`crate::LayoutInvariants::verify`] would gate a
3616        // [`CaixaKind::Binario`] caixa's `:exe` entries against the
3617        // wrong sub-tree (or a `:kind Servico` caixa's `:servicos`
3618        // entries against `lib/` and pass every entry `feira build`
3619        // should have rejected as [`crate::LayoutError::ServicoOutsideDir`]).
3620        // Mirror of the peer
3621        // [`layout_missing_entry_kind_consts_are_pairwise_distinct`]
3622        // (fe2a898) on the sibling leaf-kind label accept-set.
3623        let entries: &[(&str, &str)] = &[
3624            ("LAYOUT_DIR_LIB", crate::render::LAYOUT_DIR_LIB),
3625            ("LAYOUT_DIR_EXE", crate::render::LAYOUT_DIR_EXE),
3626            ("LAYOUT_DIR_SERVICOS", crate::render::LAYOUT_DIR_SERVICOS),
3627        ];
3628        for (i, (name_a, value_a)) in entries.iter().enumerate() {
3629            for (name_b, value_b) in entries.iter().skip(i + 1) {
3630                assert_ne!(
3631                    value_a, value_b,
3632                    "LAYOUT_DIR_* consts must be pairwise-distinct \
3633                     byte-strings — {name_a} and {name_b} both resolve \
3634                     to {value_a:?}"
3635                );
3636            }
3637        }
3638    }
3639
3640    #[test]
3641    fn layout_dir_exe_matches_layout_missing_entry_kind_exe() {
3642        // Cross-axis byte-identity pin: [`crate::render::LAYOUT_DIR_EXE`]
3643        // (the on-disk-directory-name arm) equals
3644        // [`crate::render::LAYOUT_MISSING_ENTRY_KIND_EXE`] (the
3645        // [`LayoutError::MissingEntry`] `kind:` leaf-kind categorization
3646        // arm) verbatim — the M0 `:kind Binario` on-disk-directory axis
3647        // and the [`crate::LayoutError::MissingEntry`] `kind:` leaf-kind
3648        // discriminator name the same three-byte sub-tree (`exe/`), a
3649        // coincidence [`crate::LayoutInvariants::verify`] itself relies
3650        // on: it joins `root` with [`crate::render::LAYOUT_DIR_EXE`] to
3651        // reconstruct `exe_dir` and emits [`crate::LayoutError::MissingEntry
3652        // { kind: LAYOUT_MISSING_ENTRY_KIND_EXE, path: <under exe_dir> }`]
3653        // for every non-resolving entry. Making the coincidence
3654        // load-bearing means a future rebrand touching either axis
3655        // without the other (a per-consumer disambiguation collapsing
3656        // the leaf-kind label onto `"binary"` while the directory stays
3657        // `"exe"`, or vice versa) trips at caixa-core build time rather
3658        // than surfacing at runtime as a mismatched
3659        // [`crate::LayoutInvariants::verify`] diagnostic whose `kind:`
3660        // reads one label while the `path:` sits under a differently-named
3661        // sub-tree.
3662        assert_eq!(
3663            crate::render::LAYOUT_DIR_EXE,
3664            crate::render::LAYOUT_MISSING_ENTRY_KIND_EXE,
3665            "LAYOUT_DIR_EXE must equal LAYOUT_MISSING_ENTRY_KIND_EXE — \
3666             both name the M0 `:kind Binario` sub-tree by the same \
3667             three-byte scalar"
3668        );
3669    }
3670
3671    #[test]
3672    fn layout_dir_bib_is_distinct_from_layout_missing_entry_kind_bib() {
3673        // Cross-axis distinctness pin: [`crate::render::LAYOUT_DIR_LIB`]
3674        // (`"lib"`, the Cargo-style abbreviated on-disk directory name)
3675        // is *deliberately* distinct from
3676        // [`crate::render::LAYOUT_MISSING_ENTRY_KIND_BIBLIOTECA`]
3677        // (`"biblioteca"`, the full-form Portuguese-native leaf-kind
3678        // label) — the substrate splits the on-disk convention terse
3679        // (`lib/`) from the diagnostic vocabulary full (`biblioteca`),
3680        // matching Cargo's `src/lib.rs` abbreviation of the `library`
3681        // crate-type discriminator. A future accidental collapse of the
3682        // two axes onto one scalar (a rebrand aligning either arm with
3683        // the other for schema-clarity, an English-uniformity pass that
3684        // renames `LAYOUT_DIR_LIB` to `LAYOUT_DIR_BIBLIOTECA` or the
3685        // diagnostic label to `"lib"`) would silently reroute either
3686        // consumer onto the other's byte-string. This pin catches the
3687        // collapse at build time. Peer of the sibling
3688        // [`layout_missing_entry_kind_m0_consts_align_with_caixa_kind_as_str`]
3689        // (fe2a898) that pins the analogous *equality* between the M0
3690        // `:kind Biblioteca` diagnostic-label arm and
3691        // [`crate::CaixaKind::Biblioteca`]'s [`crate::CaixaKind::as_str`]
3692        // output — the two pins jointly encode the "which of the three
3693        // Biblioteca-related scalars are load-bearing-equal, which are
3694        // load-bearing-distinct" invariant across the substrate's
3695        // per-kind vocabulary.
3696        assert_ne!(
3697            crate::render::LAYOUT_DIR_LIB,
3698            crate::render::LAYOUT_MISSING_ENTRY_KIND_BIBLIOTECA,
3699            "LAYOUT_DIR_LIB (`\"lib\"`) and LAYOUT_MISSING_ENTRY_KIND_BIBLIOTECA \
3700             (`\"biblioteca\"`) name two distinct axes — the on-disk \
3701             directory convention (Cargo-style abbreviated) and the \
3702             layout-diagnostic leaf-kind label (full-form Portuguese) — \
3703             and must not silently collapse onto one scalar"
3704        );
3705    }
3706
3707    #[test]
3708    fn layout_dir_servicos_is_distinct_from_layout_missing_entry_kind_servico() {
3709        // Cross-axis distinctness pin: [`crate::render::LAYOUT_DIR_SERVICOS`]
3710        // (`"servicos"`, the Portuguese-*plural* on-disk directory
3711        // name) is *deliberately* distinct from
3712        // [`crate::render::LAYOUT_MISSING_ENTRY_KIND_SERVICO`]
3713        // (`"servico"`, the singular leaf-kind label) — the on-disk
3714        // sub-tree houses one-or-more ComputeUnit YAML descriptors per
3715        // caixa (hence the plural), the diagnostic label names the
3716        // caixa's own kind (singular). A future accidental collapse
3717        // onto one scalar (a per-consumer disambiguation aligning the
3718        // two, a hypothetical English-uniformity pass renaming
3719        // `"servicos"` → `"services"` while retaining `"servico"` on
3720        // the diagnostic arm — or vice versa) would silently reroute
3721        // either consumer onto the other's byte-string. Peer of the
3722        // sibling [`layout_dir_bib_is_distinct_from_layout_missing_entry_kind_bib`]
3723        // pin on the M0 `:kind Biblioteca` split axis; two of the three
3724        // per-kind on-disk / leaf-kind splits carry a distinctness
3725        // pin here, the third ([`crate::render::LAYOUT_DIR_EXE`] vs
3726        // [`crate::render::LAYOUT_MISSING_ENTRY_KIND_EXE`]) carries an
3727        // equality pin under
3728        // [`layout_dir_exe_matches_layout_missing_entry_kind_exe`].
3729        assert_ne!(
3730            crate::render::LAYOUT_DIR_SERVICOS,
3731            crate::render::LAYOUT_MISSING_ENTRY_KIND_SERVICO,
3732            "LAYOUT_DIR_SERVICOS (`\"servicos\"`, plural on-disk sub-tree) \
3733             and LAYOUT_MISSING_ENTRY_KIND_SERVICO (`\"servico\"`, singular \
3734             leaf-kind label) name two distinct axes and must not silently \
3735             collapse onto one scalar"
3736        );
3737    }
3738
3739    #[test]
3740    fn layout_invariants_reconstruct_sandbox_roots_through_lifted_layout_dir_consts() {
3741        // Production-through-const pin: [`LayoutInvariants::verify`]
3742        // routes its three per-kind sandbox-root joins
3743        // (`root.join(LAYOUT_DIR_LIB)` for the `:kind Biblioteca`
3744        // default `lib/<nome>.lisp` reconstruction, `root.join(LAYOUT_DIR_EXE)`
3745        // for the [`LayoutError::ExeOutsideDir`] gate,
3746        // `root.join(LAYOUT_DIR_SERVICOS)` for the
3747        // [`LayoutError::ServicoOutsideDir`] gate) through the three
3748        // lifted consts, not through inline `"lib"` / `"exe"` /
3749        // `"servicos"` `&str` literals. This test drives the
3750        // [`LayoutError::ExeOutsideDir`] arm through a `:kind Binario`
3751        // caixa whose declared `:exe` entry deliberately escapes
3752        // `root.join(LAYOUT_DIR_EXE)` (a sibling `bin/tool` path) —
3753        // if the production emit reads the wrong const (or reverts to
3754        // an inline literal that drifts from the const) the diagnostic
3755        // arm surfaces the wrong variant, catching the drift at build
3756        // time rather than as a per-invocation runtime mismatch.
3757        //
3758        // Mirror of the peer production-through-const pin
3759        // [`crate::dep::tests::validate_no_self_dep_deps_field_routes_through_dep_author_key`]
3760        // (4da6fba) on the sibling M0 `:deps` `list:` diagnostic axis.
3761        use std::path::PathBuf;
3762        let root = PathBuf::from("/tmp/x");
3763        let manifest = root.join("caixa.lisp");
3764        let bin_entry_outside = root.join("bin/tool");
3765        let mut c = caixa(CaixaKind::Binario);
3766        c.exe = vec!["bin/tool".into()];
3767        let manifest_clone = manifest.clone();
3768        let outside_clone = bin_entry_outside.clone();
3769        let layout = StandardLayout::new()
3770            .with_path_exists(move |p| p == manifest_clone || p == outside_clone);
3771        let err = layout.verify(&c, &root).unwrap_err();
3772        match err {
3773            LayoutError::ExeOutsideDir(path) => {
3774                assert_eq!(
3775                    path, bin_entry_outside,
3776                    "ExeOutsideDir must carry the resolved `:exe` entry that \
3777                     escapes `root.join(LAYOUT_DIR_EXE)`"
3778                );
3779                // Byte-identity check: the escape must be against the
3780                // lifted `LAYOUT_DIR_EXE` sub-tree, not a stale inline
3781                // literal — a future const-edit that drifts from `"exe"`
3782                // reroutes `exe_dir` off the sandbox `bin/tool` escapes
3783                // from, and this pattern-arm miss re-surfaces here.
3784                assert!(
3785                    !path.starts_with(root.join(crate::render::LAYOUT_DIR_EXE)),
3786                    "resolved `:exe` entry {path:?} must escape the \
3787                     `root.join(LAYOUT_DIR_EXE)` sub-tree the production \
3788                     emit uses to gate the [`LayoutError::ExeOutsideDir`] arm"
3789                );
3790            }
3791            other => panic!("expected ExeOutsideDir, got {other:?}"),
3792        }
3793    }
3794
3795    #[test]
3796    fn upgrade_state_change_without_behavior_callback_surfaces_as_upgrade_violation() {
3797        // Wiring pin for the cross-slot composition gate
3798        // (`validate_upgrade_from_against_behavior`): a caixa whose
3799        // `:upgrade-from` declares a `(:state-change "lib/m.lisp")`
3800        // instruction but does not declare `:behavior :on-state-change`
3801        // surfaces at `feira build` time as a `LayoutError::UpgradeViolation`
3802        // naming the offending caixa + the entry's `:from` + the
3803        // offending script — not at hot-upgrade dispatch when the
3804        // operator reaches for the missing callback. Mirrors
3805        // `upgrade_from_downgrade_surfaces_as_upgrade_violation` on the
3806        // peer `:from` ↔ `:versao` cross-slot precedence gate.
3807        use crate::{UpgradeFromEntry, UpgradeInstruction};
3808        use std::path::PathBuf;
3809        let root = PathBuf::from("/tmp/x");
3810        let manifest = root.join("caixa.lisp");
3811        let svc = root.join("servicos/demo.computeunit.yaml");
3812        let mut c = caixa(CaixaKind::Servico);
3813        c.versao = "0.2.0".into();
3814        c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
3815        // `:behavior` is None (the canonical "I added the upgrade path
3816        // but never declared :behavior" footgun the gate closes); a
3817        // peer arm covers the BehaviorSpec-Some-but-on-state-change-
3818        // None shape in `upgrade::tests::behavior_gate_rejects_state_
3819        // change_when_on_state_change_is_none`.
3820        c.upgrade_from = vec![UpgradeFromEntry {
3821            from: "0.1.0".into(),
3822            instructions: vec![
3823                UpgradeInstruction::LoadModule {
3824                    module: "demo".into(),
3825                },
3826                UpgradeInstruction::StateChange {
3827                    script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
3828                },
3829            ],
3830        }];
3831        let manifest_clone = manifest.clone();
3832        let svc_clone = svc.clone();
3833        let layout =
3834            StandardLayout::new().with_path_exists(move |p| p == manifest_clone || p == svc_clone);
3835        let err = layout.verify(&c, &root).unwrap_err();
3836        match err {
3837            LayoutError::UpgradeViolation { caixa, issue } => {
3838                assert_eq!(caixa, "demo", "diagnostic must name the offending caixa");
3839                assert!(
3840                    issue.contains(crate::render::M2_BEHAVIOR_AUTHOR_KEY_ON_STATE_CHANGE),
3841                    "diagnostic must name the missing callback slot for self-locating fix, \
3842                     got {issue:?}"
3843                );
3844                assert!(
3845                    issue.contains("0.1.0"),
3846                    "diagnostic must name the offending entry's :from, got {issue:?}"
3847                );
3848                assert!(
3849                    issue.contains("v01-to-v02.lisp"),
3850                    "diagnostic must name the offending :script for self-locating fix, \
3851                     got {issue:?}"
3852                );
3853            }
3854            other => panic!("expected UpgradeViolation, got {other:?}"),
3855        }
3856    }
3857
3858    #[test]
3859    fn supervisor_must_have_children() {
3860        use crate::RestartStrategy;
3861        let root = PathBuf::from("/tmp/x");
3862        let manifest = root.join("caixa.lisp");
3863        let manifest_clone = manifest.clone();
3864        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
3865        let mut c = caixa(CaixaKind::Supervisor);
3866        c.estrategia = Some(RestartStrategy::OneForOne);
3867        c.max_restarts = Some(5);
3868        // No children → should fail
3869        let err = layout.verify(&c, &root).unwrap_err();
3870        assert!(matches!(err, LayoutError::SupervisorViolation { .. }));
3871    }
3872
3873    #[test]
3874    fn supervisor_self_referential_child_is_violation() {
3875        // A Supervisor whose `:children` names its own `:nome` is a
3876        // one-node supervision cycle. The cross-slot gate fires at
3877        // verify time, surfacing as a SupervisorViolation that names the
3878        // offending supervisor — not at the cluster apply far from
3879        // source. The `caixa()` helper's `:nome` is "demo".
3880        use crate::{ChildSpec, RestartPolicy, RestartStrategy};
3881        let root = PathBuf::from("/tmp/x");
3882        let manifest = root.join("caixa.lisp");
3883        let manifest_clone = manifest.clone();
3884        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
3885        let mut c = caixa(CaixaKind::Supervisor);
3886        c.estrategia = Some(RestartStrategy::OneForOne);
3887        c.max_restarts = Some(5);
3888        c.children = vec![
3889            ChildSpec {
3890                caixa: "worker".into(),
3891                versao: "^0.1".into(),
3892                restart: RestartPolicy::Permanent,
3893            },
3894            ChildSpec {
3895                caixa: "demo".into(),
3896                versao: "^0.1".into(),
3897                restart: RestartPolicy::Permanent,
3898            },
3899        ];
3900        let err = layout.verify(&c, &root).unwrap_err();
3901        let LayoutError::SupervisorViolation { caixa, issue } = err else {
3902            panic!("expected SupervisorViolation for self-referential child, got {err:?}");
3903        };
3904        assert_eq!(caixa, "demo");
3905        assert!(
3906            issue.contains("demo") && issue.contains("itself"),
3907            "issue must name the self-supervising caixa, got {issue:?}"
3908        );
3909    }
3910
3911    #[test]
3912    fn supervisor_distinct_children_pass_self_supervision_gate() {
3913        // Positive control: a Supervisor whose children are all distinct
3914        // from its own `:nome` verifies cleanly.
3915        use crate::{ChildSpec, RestartPolicy, RestartStrategy};
3916        let root = PathBuf::from("/tmp/x");
3917        let manifest = root.join("caixa.lisp");
3918        let manifest_clone = manifest.clone();
3919        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
3920        let mut c = caixa(CaixaKind::Supervisor);
3921        c.estrategia = Some(RestartStrategy::OneForOne);
3922        c.max_restarts = Some(5);
3923        c.children = vec![ChildSpec {
3924            caixa: "worker".into(),
3925            versao: "^0.1".into(),
3926            restart: RestartPolicy::Permanent,
3927        }];
3928        layout.verify(&c, &root).unwrap();
3929    }
3930
3931    #[test]
3932    fn cross_slot_self_edge_gates_route_parent_nome_through_lifted_accessor() {
3933        // Composition pin: every cross-slot self-edge gate fired from
3934        // `LayoutInvariants::verify` — the supervision-tree arm's
3935        // `crate::supervisor::validate_no_self_supervision` call, the
3936        // Aplicacao arm's `crate::aplicacao::validate_no_self_membership`
3937        // call, and the dep-graph arm's
3938        // `crate::dep::validate_no_self_dep` call — must key its
3939        // `parent_nome` arg off the typed [`Caixa::nome`] accessor, not
3940        // the raw `&caixa.nome` `&String`-borrow of the underlying
3941        // field.
3942        //
3943        // Structurally: a rename of the storage field or a hypothetical
3944        // accessor rebrand (a per-cluster alias table pinned through a
3945        // future `:placement`-scoped slot, the M4 CR materializer's
3946        // per-CR namespace-qualified rewrite, a `:nome-suffix` overlay
3947        // the MESH-COMPOSITION §III.2 roadmap acknowledges) would land
3948        // through the accessor by construction; a raw-borrow bypass
3949        // would silently disagree with every peer consumer that already
3950        // routes through `caixa.nome()` (the caixa-mesh 980c059,
3951        // caixa-helm 22461ef, caixa-flux 162e2e2, caixa-crd 61d3429,
3952        // caixa-feira ef83332 raw-borrow converges), reintroducing the
3953        // drift surface the sibling converges closed. Each arm fires
3954        // its per-kind `LayoutError` variant (`SupervisorViolation` /
3955        // `AplicacaoViolation` / `DepsViolation`) whose `caixa` field
3956        // carries the offending parent name verbatim through
3957        // `caixa.nome().clone()`; asserting the field equals
3958        // `caixa.nome()` on the mutated fixture pins the accessor-
3959        // routed parent-nome projection at every call site — a future
3960        // silent detour that had the gate observe a stale / aliased
3961        // name at the arg boundary would surface here as a
3962        // `caixa != "demo"` inequality.
3963        //
3964        // Peer of the sibling per-caixa-crate `nome`-arg raw-borrow
3965        // convergence pin discipline (54bf2f3 / 22461ef / 162e2e2 on the
3966        // renderer crates; ef83332 on the CLI) — extends the "one typed
3967        // dispatch per `:nome` consumer" discipline onto the substrate's
3968        // own [`LayoutInvariants::verify`] cross-slot self-edge gate
3969        // wire-up on all three typed-name-graph kinds.
3970        use crate::{
3971            ChildSpec, Dep, Membro, Placement, PlacementStrategy, RestartPolicy, RestartStrategy,
3972        };
3973        let root = PathBuf::from("/tmp/x");
3974        let manifest = root.join("caixa.lisp");
3975        let manifest_clone = manifest.clone();
3976        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
3977
3978        // Supervisor arm — the `caixa()` helper's `:nome` is "demo",
3979        // and the accessor's return `caixa.nome()` must equal the
3980        // parent-nome that the self-supervision gate observes.
3981        let mut sup = caixa(CaixaKind::Supervisor);
3982        sup.estrategia = Some(RestartStrategy::OneForOne);
3983        sup.max_restarts = Some(5);
3984        sup.children = vec![ChildSpec {
3985            caixa: "demo".into(),
3986            versao: "^0.1".into(),
3987            restart: RestartPolicy::Permanent,
3988        }];
3989        let parent_nome_via_accessor = sup.nome();
3990        assert_eq!(
3991            parent_nome_via_accessor, "demo",
3992            "the caixa() fixture helper's `:nome` must be \"demo\" — \
3993             the accessor's return is the pin's ground truth for the \
3994             cross-slot gate's parent-nome arg",
3995        );
3996        let err = layout.verify(&sup, &root).unwrap_err();
3997        let LayoutError::SupervisorViolation { caixa: c_nome, .. } = err else {
3998            panic!("expected SupervisorViolation for self-referential child, got {err:?}");
3999        };
4000        assert_eq!(
4001            c_nome, parent_nome_via_accessor,
4002            "the SupervisorViolation's `caixa` field must equal \
4003             `sup.nome()` — the cross-slot self-supervision gate's \
4004             `parent_nome` arg must route through the lifted \
4005             [`Caixa::nome`] accessor, not the raw `&caixa.nome` \
4006             `&String`-borrow of the underlying field",
4007        );
4008
4009        // Aplicacao arm — same discipline on the peer typed-name-graph
4010        // kind. Constructed alongside the supervisor arm so any future
4011        // accessor drift lands on both arms in the same pin.
4012        let mut app = caixa(CaixaKind::Aplicacao);
4013        app.placement = Some(Placement {
4014            estrategia: PlacementStrategy::Replicated,
4015            clusters: vec!["rio".into()],
4016            affinity: None,
4017            shard_key: None,
4018        });
4019        app.membros = vec![Membro {
4020            caixa: "demo".into(),
4021            versao: "^0.1".into(),
4022        }];
4023        let parent_nome_via_accessor = app.nome();
4024        assert_eq!(
4025            parent_nome_via_accessor, "demo",
4026            "the caixa() fixture helper's `:nome` must be \"demo\" on \
4027             the Aplicacao arm too — same accessor-ground-truth as the \
4028             sibling supervisor arm above",
4029        );
4030        let err = layout.verify(&app, &root).unwrap_err();
4031        let LayoutError::AplicacaoViolation { caixa: c_nome, .. } = err else {
4032            panic!("expected AplicacaoViolation for self-referential membro, got {err:?}");
4033        };
4034        assert_eq!(
4035            c_nome, parent_nome_via_accessor,
4036            "the AplicacaoViolation's `caixa` field must equal \
4037             `app.nome()` — the cross-slot self-membership gate's \
4038             `parent_nome` arg must route through the lifted \
4039             [`Caixa::nome`] accessor, not the raw `&caixa.nome` \
4040             `&String`-borrow of the underlying field",
4041        );
4042
4043        // Dep-graph arm — third typed-name-graph kind on the
4044        // `parent_nome` arg boundary. Same discipline as the peer
4045        // supervision-tree and Aplicacao-membership arms above.
4046        // Constructed alongside so any future accessor drift lands on
4047        // all three arms in the same pin. Needs a distinct layout
4048        // fixture from the supervisor / aplicacao arms above because
4049        // the `Biblioteca` kind's code-path existence gate demands the
4050        // canonical `lib/<nome>.lisp` path also `exists`, so the shim
4051        // covers both `caixa.lisp` and `lib/demo.lisp`.
4052        let default_lib = root.join("lib").join("demo.lisp");
4053        let manifest_dep = manifest.clone();
4054        let default_lib_clone = default_lib.clone();
4055        let layout_dep = StandardLayout::new()
4056            .with_path_exists(move |p| p == manifest_dep || p == default_lib_clone);
4057        let mut lib = caixa(CaixaKind::Biblioteca);
4058        lib.deps = vec![Dep::simple("demo", "^0.1")];
4059        let parent_nome_via_accessor = lib.nome();
4060        assert_eq!(
4061            parent_nome_via_accessor, "demo",
4062            "the caixa() fixture helper's `:nome` must be \"demo\" on \
4063             the Biblioteca arm too — same accessor-ground-truth as the \
4064             sibling supervisor + Aplicacao arms above",
4065        );
4066        let err = layout_dep.verify(&lib, &root).unwrap_err();
4067        let LayoutError::DepsViolation { caixa: c_nome, .. } = err else {
4068            panic!("expected DepsViolation for self-referential :deps entry, got {err:?}");
4069        };
4070        assert_eq!(
4071            c_nome, parent_nome_via_accessor,
4072            "the DepsViolation's `caixa` field must equal \
4073             `lib.nome()` — the cross-slot self-dep gate's `parent_nome` \
4074             arg must route through the lifted [`Caixa::nome`] accessor, \
4075             not the raw `&caixa.nome` `&String`-borrow of the underlying \
4076             field",
4077        );
4078    }
4079
4080    #[test]
4081    fn upgrade_against_versao_gate_routes_current_versao_through_lifted_accessor() {
4082        // Composition pin: the cross-slot `:upgrade-from :from` ↔
4083        // `:versao` precedence gate fired from
4084        // `LayoutInvariants::verify` — the
4085        // `crate::upgrade::validate_upgrade_from_against_versao` call —
4086        // must key its `versao` arg off the typed [`Caixa::versao`]
4087        // accessor, not the raw `&caixa.versao` `&String`-borrow of
4088        // the underlying field.
4089        //
4090        // Same "arg-boundary reads through the lifted accessor"
4091        // discipline as the sibling
4092        // [`cross_slot_self_edge_gates_route_parent_nome_through_lifted_accessor`]
4093        // pin above on the `:nome`-arg axis of the three typed-name-
4094        // graph self-edge gates — extended here onto the `:versao`-arg
4095        // axis of the substrate's remaining `LayoutInvariants::verify`
4096        // cross-slot arg-carrying call site. Structurally byte-equal
4097        // today (the accessor is `pub fn versao(&self) -> &str { &self.versao }`,
4098        // so both paths coerce to the same `&str`); the pin catches a
4099        // future silent detour (an accessor rebrand that no longer
4100        // shipped the raw slot verbatim — a per-`:edicao` overlay,
4101        // a promotion of `:versao` to a `CaixaVersion` newtype with a
4102        // canonicalizing accessor, an M4 CR-materializer-side pinning
4103        // through a resolver-annotated `:versao-resolved` slot) that
4104        // would silently split the substrate's own precedence gate
4105        // from every peer consumer already routing `:versao` reads
4106        // through the lifted accessor.
4107        //
4108        // The gate fires an `UpgradeViolation { caixa, issue }` when a
4109        // `:upgrade-from` entry's `:from` is not strictly less than
4110        // the top-level `:versao` — the `issue` string names both the
4111        // offending prior version and the current version verbatim,
4112        // so asserting the substring `caixa.versao()` appears in the
4113        // fired diagnostic pins the accessor-routed current-versao
4114        // projection at the arg boundary. A raw-borrow bypass would
4115        // still surface the same bytes today, but the presence of
4116        // this pin makes any future divergence between the accessor's
4117        // return and the raw slot's contents a build-time failure at
4118        // this call site.
4119        use crate::{UpgradeFromEntry, UpgradeInstruction};
4120        let root = PathBuf::from("/tmp/x");
4121        let manifest = root.join("caixa.lisp");
4122        let servico_path = root.join("servicos").join("demo.computeunit.yaml");
4123        let manifest_clone = manifest.clone();
4124        let servico_clone = servico_path.clone();
4125        let layout = StandardLayout::new()
4126            .with_path_exists(move |p| p == manifest_clone || p == servico_clone);
4127        let mut svc = caixa(CaixaKind::Servico);
4128        svc.versao = "0.1.0".into();
4129        svc.servicos = vec!["servicos/demo.computeunit.yaml".into()];
4130        // `:from` >= current `:versao` — trips the precedence gate the
4131        // `validate_upgrade_from_against_versao` cross-slot call
4132        // enforces. `:load-module` carries no on-disk path so the
4133        // path-existence gate downstream stays inert; the precedence
4134        // gate is what fires. No `:on-state-change` needed because the
4135        // instruction list carries no `:state-change` entry, so the
4136        // sibling `validate_upgrade_from_against_behavior` gate is
4137        // inert too.
4138        svc.upgrade_from = vec![UpgradeFromEntry {
4139            from: "0.2.0".into(),
4140            instructions: vec![UpgradeInstruction::LoadModule {
4141                module: "demo".into(),
4142            }],
4143        }];
4144        let current_versao_via_accessor = svc.versao().to_string();
4145        assert_eq!(
4146            current_versao_via_accessor, "0.1.0",
4147            "the mutated fixture's `:versao` must be observable through \
4148             the accessor before layout verification fires — a drift on \
4149             `Caixa::versao` would surface here as a `!= \"0.1.0\"` \
4150             inequality",
4151        );
4152        let err = layout.verify(&svc, &root).unwrap_err();
4153        let LayoutError::UpgradeViolation {
4154            caixa: c_nome,
4155            issue,
4156        } = err
4157        else {
4158            panic!("expected UpgradeViolation for :from >= :versao, got {err:?}");
4159        };
4160        assert_eq!(c_nome, svc.nome(), "wrap envelope names the caixa");
4161        assert!(
4162            issue.contains(&current_versao_via_accessor),
4163            "the UpgradeViolation's `issue` must quote the current \
4164             `:versao` byte-string verbatim — the cross-slot precedence \
4165             gate's `versao` arg must route through the lifted \
4166             [`Caixa::versao`] accessor, not the raw `&caixa.versao` \
4167             `&String`-borrow of the underlying field. issue: {issue}",
4168        );
4169    }
4170
4171    #[test]
4172    fn layout_violation_envelopes_carry_caixa_nome_through_lifted_accessor() {
4173        // Wrap-envelope drift-detection pin: every per-axis
4174        // `LayoutError::*Violation { caixa, issue }` envelope fired
4175        // from `LayoutInvariants::verify` must key its offending-caixa
4176        // field off the typed [`Caixa::nome`] accessor's
4177        // `.to_string()` extension, not the raw
4178        // `caixa.nome.clone()` `String::clone()` of the underlying
4179        // field. Structurally byte-equal today (each accessor is
4180        // `pub fn nome(&self) -> &str { &self.nome }`, so
4181        // `caixa.nome().to_string()` and `caixa.nome.clone()` produce
4182        // the same bytes); the pin catches a future silent detour
4183        // (an accessor rebrand that no longer shipped the raw slot
4184        // verbatim — a per-cluster alias table pinned through a
4185        // future `:placement`-scoped slot, the M4 CR materializer's
4186        // per-CR namespace-qualified rewrite, a `:nome-suffix`
4187        // overlay the MESH-COMPOSITION §III.2 roadmap acknowledges)
4188        // that would silently split the substrate's own layout
4189        // invariant verifier's diagnostic surface from every peer
4190        // caixa-crate consumer that already routes `:nome` reads
4191        // through the lifted accessor (the caixa-mesh 980c059,
4192        // caixa-helm 22461ef, caixa-flux 162e2e2, caixa-crd 61d3429,
4193        // caixa-feira ef83332 raw-borrow converges).
4194        //
4195        // Exercises a representative variant on each of the three
4196        // wrap-envelope arm shapes the substrate's per-axis fan-out
4197        // carries: (1) `LayoutError::NomeViolation` (the leading arm
4198        // in the `verify` order — the `:nome` axis's DNS-1123 shape
4199        // gate fires immediately after the manifest-existence gate),
4200        // (2) `LayoutError::BinarioWithoutExe` (a tuple-variant on
4201        // the kind-coherence family — different envelope shape than
4202        // the struct-variant `*Violation { caixa, issue }` family
4203        // but the same converge target on the `caixa.nome().to_string()`
4204        // arg), and (3) `LayoutError::ServicoWithoutServicos` (the
4205        // sibling tuple-variant on the same kind-coherence family).
4206        // Together they cover the two `LayoutError` envelope shapes
4207        // (struct-variant + tuple-variant) the layout invariants file
4208        // emits on `:nome`-carrying arms.
4209        //
4210        // Peer of the sibling
4211        // [`cross_slot_self_edge_gates_route_parent_nome_through_lifted_accessor`]
4212        // pin above — extends the "wrap-envelope `caixa:` field
4213        // reads through the lifted accessor" discipline from the
4214        // cross-slot self-edge gates' `parent_nome` arg boundary
4215        // onto the per-axis `LayoutError::*Violation` envelope's
4216        // `caixa:` field boundary.
4217
4218        let root = PathBuf::from("/tmp/x");
4219        let manifest = root.join("caixa.lisp");
4220        let manifest_clone = manifest.clone();
4221        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4222
4223        // (1) `NomeViolation` on the struct-variant envelope: force a
4224        // DNS-1123-invalid `:nome` (uppercase byte — `is_dns_1123_label`
4225        // rejects) and assert the fired envelope's `caixa:` field
4226        // byte-equals `c.nome().to_string()`.
4227        let mut c = caixa(CaixaKind::Biblioteca);
4228        c.nome = "BAD_NAME".into();
4229        c.bibliotecas = vec!["lib/demo.lisp".into()];
4230        let expected_nome_via_accessor = c.nome().to_string();
4231        assert_eq!(
4232            expected_nome_via_accessor, "BAD_NAME",
4233            "the mutated fixture's `:nome` must be observable through \
4234             the accessor before layout verification fires — a drift \
4235             on `Caixa::nome` would surface here as a `!= \"BAD_NAME\"` \
4236             inequality",
4237        );
4238        let err = layout.verify(&c, &root).unwrap_err();
4239        let LayoutError::NomeViolation { caixa: c_nome, .. } = err else {
4240            panic!("expected NomeViolation for DNS-1123-invalid :nome, got {err:?}");
4241        };
4242        assert_eq!(
4243            c_nome, expected_nome_via_accessor,
4244            "the NomeViolation's `caixa` field must equal \
4245             `c.nome().to_string()` — the wrap envelope's per-axis \
4246             projection must route through the lifted [`Caixa::nome`] \
4247             accessor's `.to_string()` extension, not the raw \
4248             `caixa.nome.clone()` `String::clone()` of the underlying \
4249             field",
4250        );
4251
4252        // (2) `BinarioWithoutExe` on the tuple-variant envelope: a
4253        // Binario-kind caixa with an empty `:exe` list fires the
4254        // kind-coherence gate whose payload is a bare `String`, so the
4255        // pattern is `LayoutError::BinarioWithoutExe(String)` rather
4256        // than the struct-variant `{ caixa, issue }` family. The
4257        // converge target is the same — `caixa.nome().to_string()` — but
4258        // the envelope shape is different, so the pin exercises both.
4259        let mut c = caixa(CaixaKind::Binario);
4260        // `:exe` empty is the trigger — the fixture helper defaults
4261        // it to `vec![]`, so no mutation is needed.
4262        c.nome = "binario-demo".into();
4263        let expected_nome_via_accessor = c.nome().to_string();
4264        assert_eq!(
4265            expected_nome_via_accessor, "binario-demo",
4266            "the mutated fixture's `:nome` must be observable through \
4267             the accessor before layout verification fires",
4268        );
4269        let err = layout.verify(&c, &root).unwrap_err();
4270        let LayoutError::BinarioWithoutExe(c_nome) = err else {
4271            panic!("expected BinarioWithoutExe for empty :exe list on Binario kind, got {err:?}");
4272        };
4273        assert_eq!(
4274            c_nome, expected_nome_via_accessor,
4275            "the BinarioWithoutExe's payload must equal \
4276             `c.nome().to_string()` — the tuple-variant envelope's \
4277             per-axis projection must route through the lifted \
4278             [`Caixa::nome`] accessor's `.to_string()` extension, not \
4279             the raw `caixa.nome.clone()` `String::clone()` of the \
4280             underlying field",
4281        );
4282
4283        // (3) `ServicoWithoutServicos` on the sibling tuple-variant
4284        // envelope: same discipline on the peer kind-coherence
4285        // partition arm. Constructed alongside the Binario arm so any
4286        // future accessor drift lands on both arms in the same pin.
4287        let mut c = caixa(CaixaKind::Servico);
4288        // `:servicos` empty is the trigger — the fixture helper
4289        // defaults it to `vec![]`, so no mutation is needed.
4290        c.nome = "servico-demo".into();
4291        let expected_nome_via_accessor = c.nome().to_string();
4292        assert_eq!(
4293            expected_nome_via_accessor, "servico-demo",
4294            "the mutated fixture's `:nome` must be observable through \
4295             the accessor before layout verification fires",
4296        );
4297        let err = layout.verify(&c, &root).unwrap_err();
4298        let LayoutError::ServicoWithoutServicos(c_nome) = err else {
4299            panic!(
4300                "expected ServicoWithoutServicos for empty :servicos list on Servico kind, \
4301                 got {err:?}"
4302            );
4303        };
4304        assert_eq!(
4305            c_nome, expected_nome_via_accessor,
4306            "the ServicoWithoutServicos's payload must equal \
4307             `c.nome().to_string()` — same converge discipline as the \
4308             sibling `BinarioWithoutExe` tuple-variant arm above",
4309        );
4310    }
4311
4312    #[test]
4313    fn supervisor_must_not_have_bibliotecas() {
4314        use crate::{ChildSpec, RestartPolicy, RestartStrategy};
4315        let root = PathBuf::from("/tmp/x");
4316        let manifest = root.join("caixa.lisp");
4317        let manifest_clone = manifest.clone();
4318        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4319        let mut c = caixa(CaixaKind::Supervisor);
4320        c.estrategia = Some(RestartStrategy::OneForOne);
4321        c.max_restarts = Some(5);
4322        c.bibliotecas = vec!["lib/code.lisp".into()];
4323        c.children = vec![ChildSpec {
4324            caixa: "worker".into(),
4325            versao: "^0.1".into(),
4326            restart: RestartPolicy::Permanent,
4327        }];
4328        let err = layout.verify(&c, &root).unwrap_err();
4329        assert!(matches!(err, LayoutError::SupervisorOwnsCode(_)));
4330    }
4331
4332    // ── Caixa::validate_restart_window wired into Supervisor verify ─────
4333    //
4334    // Until this wire-up landed `Caixa::validate_restart_window` lived as
4335    // `pub fn` on `Caixa` with full per-arm unit coverage in
4336    // `manifest::tests` (`validate_restart_window_rejects_*` — fractional,
4337    // decimal-shaped integer, half-unit minute, leading sign, unknown
4338    // unit, garbage, empty-after-trim) but no production path called it;
4339    // `feira build` silently accepted malformed `:restart-window` and
4340    // `Caixa::supervisor_view` soft-swallowed the parse failure as
4341    // `restart_window: None` (the canonical "no reset" sentinel), turning
4342    // every authoring footgun into a never-reset supervisor far from the
4343    // source caixa.lisp. The following pins fence the layout-pipeline
4344    // wire-up: every layout verify on a structurally-invalid `:restart-
4345    // window` axis surfaces the per-axis `RestartWindowViolation { caixa,
4346    // issue }` envelope before the typed `SupervisorSpec::validate` gate
4347    // sees the laundered `None`.
4348
4349    fn supervisor_with_window(window: Option<&str>) -> Caixa {
4350        use crate::{ChildSpec, RestartPolicy, RestartStrategy};
4351        let mut c = caixa(CaixaKind::Supervisor);
4352        c.estrategia = Some(RestartStrategy::OneForOne);
4353        c.max_restarts = Some(5);
4354        c.restart_window = window.map(str::to_string);
4355        c.children = vec![ChildSpec {
4356            caixa: "worker".into(),
4357            versao: "^0.1".into(),
4358            restart: RestartPolicy::Permanent,
4359        }];
4360        c
4361    }
4362
4363    #[test]
4364    fn restart_window_violation_on_fractional_seconds() {
4365        // `"1.5s"` is the canonical fractional-seconds drift footgun the
4366        // shared integer-magnitude codec (1c55a2a) rejects: round-trips
4367        // through `render` as `"1500ms"` on first serialize, breaking
4368        // THEORY.md §V.2.7 render-determinism. Before this wire-up
4369        // `supervisor_view` soft-swallowed the parse error as
4370        // `restart_window: None`, masking the drift as a never-reset
4371        // supervisor.
4372        let root = PathBuf::from("/tmp/x");
4373        let manifest = root.join("caixa.lisp");
4374        let manifest_clone = manifest.clone();
4375        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4376        let c = supervisor_with_window(Some("1.5s"));
4377        let err = layout.verify(&c, &root).unwrap_err();
4378        let LayoutError::RestartWindowViolation { caixa, issue } = err else {
4379            panic!("expected LayoutError::RestartWindowViolation, got {err:?}");
4380        };
4381        assert_eq!(caixa, "demo");
4382        assert!(
4383            issue.contains("1.5s"),
4384            "issue must quote the offending raw value: {issue}",
4385        );
4386    }
4387
4388    #[test]
4389    fn restart_window_violation_on_decimal_shaped_integer() {
4390        // `"1.0s"` — decimal-shaped integer the codec also rejects (a
4391        // canonical authoring form is `"1s"`). Sibling of the fractional
4392        // case; same codec arm.
4393        let root = PathBuf::from("/tmp/x");
4394        let manifest = root.join("caixa.lisp");
4395        let manifest_clone = manifest.clone();
4396        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4397        let c = supervisor_with_window(Some("1.0s"));
4398        let err = layout.verify(&c, &root).unwrap_err();
4399        assert!(
4400            matches!(
4401                err,
4402                LayoutError::RestartWindowViolation { ref caixa, ref issue }
4403                    if caixa == "demo" && issue.contains("1.0s")
4404            ),
4405            "got {err:?}",
4406        );
4407    }
4408
4409    #[test]
4410    fn restart_window_violation_on_leading_sign() {
4411        // `"+30s"` / `"-30s"` — leading-sign drift the codec rejects.
4412        // Canonical form is `"30s"`. Pin both signs separately because
4413        // a future relaxation might accept one but not the other.
4414        for raw in ["+30s", "-30s"] {
4415            let root = PathBuf::from("/tmp/x");
4416            let manifest = root.join("caixa.lisp");
4417            let manifest_clone = manifest.clone();
4418            let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4419            let c = supervisor_with_window(Some(raw));
4420            let err = layout.verify(&c, &root).unwrap_err();
4421            assert!(
4422                matches!(
4423                    err,
4424                    LayoutError::RestartWindowViolation { ref caixa, ref issue }
4425                        if caixa == "demo" && issue.contains(raw)
4426                ),
4427                "leading-sign {raw:?} got {err:?}",
4428            );
4429        }
4430    }
4431
4432    #[test]
4433    fn restart_window_violation_on_unknown_unit() {
4434        // `"30x"` — unknown duration unit. The codec admits only
4435        // `ms`/`s`/`m`/`h`.
4436        let root = PathBuf::from("/tmp/x");
4437        let manifest = root.join("caixa.lisp");
4438        let manifest_clone = manifest.clone();
4439        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4440        let c = supervisor_with_window(Some("30x"));
4441        let err = layout.verify(&c, &root).unwrap_err();
4442        assert!(
4443            matches!(
4444                err,
4445                LayoutError::RestartWindowViolation { ref caixa, .. } if caixa == "demo"
4446            ),
4447            "got {err:?}",
4448        );
4449    }
4450
4451    #[test]
4452    fn restart_window_violation_on_garbage() {
4453        // `"abc"` — pure garbage. The codec's parse fails before the
4454        // unit dispatch; the wrap envelope still surfaces the
4455        // self-locating diagnostic at the source.
4456        let root = PathBuf::from("/tmp/x");
4457        let manifest = root.join("caixa.lisp");
4458        let manifest_clone = manifest.clone();
4459        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4460        let c = supervisor_with_window(Some("abc"));
4461        let err = layout.verify(&c, &root).unwrap_err();
4462        assert!(
4463            matches!(err, LayoutError::RestartWindowViolation { ref caixa, .. } if caixa == "demo"),
4464            "got {err:?}",
4465        );
4466    }
4467
4468    #[test]
4469    fn restart_window_violation_on_empty_string() {
4470        // `""` — empty after trim. The shared codec's digit-only gate
4471        // refuses an empty magnitude. Distinguished here from the
4472        // `None` ("omit the slot") canonical authoring shape: an empty
4473        // string is an authored-but-empty slot, never the author's
4474        // intent.
4475        let root = PathBuf::from("/tmp/x");
4476        let manifest = root.join("caixa.lisp");
4477        let manifest_clone = manifest.clone();
4478        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4479        let c = supervisor_with_window(Some(""));
4480        let err = layout.verify(&c, &root).unwrap_err();
4481        assert!(
4482            matches!(err, LayoutError::RestartWindowViolation { ref caixa, .. } if caixa == "demo"),
4483            "got {err:?}",
4484        );
4485    }
4486
4487    #[test]
4488    fn verify_accepts_supervisor_without_restart_window() {
4489        // `None` is the canonical "omit the slot to express no reset"
4490        // shape — never reaches the codec, validates cleanly.
4491        let root = PathBuf::from("/tmp/x");
4492        let manifest = root.join("caixa.lisp");
4493        let manifest_clone = manifest.clone();
4494        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4495        let c = supervisor_with_window(None);
4496        layout.verify(&c, &root).unwrap();
4497    }
4498
4499    #[test]
4500    fn verify_accepts_supervisor_with_canonical_restart_window() {
4501        // Every canonical form the shared codec round-trips losslessly
4502        // must pass — `"500ms"`, `"30s"`, `"60s"`, `"1m"`, `"2m"`,
4503        // `"1h"`. Pin every form so a future tightening of the codec's
4504        // accepted set surfaces here as a test failure.
4505        for form in ["500ms", "30s", "60s", "1m", "2m", "1h"] {
4506            let root = PathBuf::from("/tmp/x");
4507            let manifest = root.join("caixa.lisp");
4508            let manifest_clone = manifest.clone();
4509            let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4510            let c = supervisor_with_window(Some(form));
4511            layout
4512                .verify(&c, &root)
4513                .unwrap_or_else(|e| panic!("canonical {form:?} must validate, got {e:?}"));
4514        }
4515    }
4516
4517    #[test]
4518    fn restart_window_violation_fires_before_supervisor_view_validate() {
4519        // Diagnostic-precedence pin: a Supervisor with a malformed
4520        // `:restart-window` AND a typed-shape defect on the typed view
4521        // (zero `:max-restarts`, which `SupervisorSpec::validate`'s
4522        // `ZeroMaxRestarts` arm rejects) surfaces the raw-string
4523        // diagnostic first — the narrower self-locating gate wins. Until
4524        // this wire-up landed `supervisor_view` would silently launder
4525        // the malformed `:restart-window` to `None` and then the typed
4526        // view's `ZeroMaxRestarts` gate would surface, masking the
4527        // raw-string footgun.
4528        use crate::{ChildSpec, RestartPolicy, RestartStrategy};
4529        let root = PathBuf::from("/tmp/x");
4530        let manifest = root.join("caixa.lisp");
4531        let manifest_clone = manifest.clone();
4532        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4533        let mut c = caixa(CaixaKind::Supervisor);
4534        c.estrategia = Some(RestartStrategy::OneForOne);
4535        c.max_restarts = Some(0);
4536        c.restart_window = Some("1.5s".into());
4537        c.children = vec![ChildSpec {
4538            caixa: "worker".into(),
4539            versao: "^0.1".into(),
4540            restart: RestartPolicy::Permanent,
4541        }];
4542        let err = layout.verify(&c, &root).unwrap_err();
4543        assert!(
4544            matches!(err, LayoutError::RestartWindowViolation { .. }),
4545            "got {err:?} — RestartWindowViolation must fire before SupervisorViolation",
4546        );
4547    }
4548
4549    #[test]
4550    fn supervisor_slots_on_non_supervisor_fires_before_restart_window_violation() {
4551        // Order pin: a non-Supervisor caixa with a malformed
4552        // `:restart-window` surfaces `SupervisorSlotsOnNonSupervisor`
4553        // (the kind-coherence gate at the top of verify) before the
4554        // raw-string parse gate inside the Supervisor branch — because
4555        // `:restart-window` is foreign to non-Supervisor kinds, the
4556        // kind-coherence diagnostic is the load-bearing one. Mirrors
4557        // the existing `nome_violation_on_*` ordering tests that fence
4558        // the precedence between universal and kind-specific gates.
4559        let root = PathBuf::from("/tmp/x");
4560        let manifest = root.join("caixa.lisp");
4561        let default_lib = root.join("lib").join("demo.lisp");
4562        let layout =
4563            StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
4564        let mut c = caixa(CaixaKind::Biblioteca);
4565        c.restart_window = Some("1.5s".into());
4566        let err = layout.verify(&c, &root).unwrap_err();
4567        assert!(
4568            matches!(err, LayoutError::SupervisorSlotsOnNonSupervisor { .. }),
4569            "got {err:?} — kind-coherence must fire before RestartWindowViolation",
4570        );
4571    }
4572
4573    #[test]
4574    fn restart_window_violation_diagnostic_carries_offending_value() {
4575        // Diagnostic-shape pin: the wrap envelope's `issue` carries the
4576        // codec's parser-shaped reason verbatim (which names the
4577        // offending raw value), so the author can grep their caixa.lisp
4578        // for `:restart-window "<value>"` and fix in one edit. Mirrors
4579        // `nome_violation_*_carries_offending_*` shape pins.
4580        let root = PathBuf::from("/tmp/x");
4581        let manifest = root.join("caixa.lisp");
4582        let manifest_clone = manifest.clone();
4583        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4584        let c = supervisor_with_window(Some("0.5m"));
4585        let err = layout.verify(&c, &root).unwrap_err();
4586        let LayoutError::RestartWindowViolation { caixa, issue } = err else {
4587            panic!("expected LayoutError::RestartWindowViolation, got {err:?}");
4588        };
4589        assert_eq!(caixa, "demo");
4590        assert!(
4591            issue.contains("0.5m"),
4592            "issue must quote the offending raw value verbatim: {issue}",
4593        );
4594        assert!(
4595            !issue.is_empty(),
4596            "issue must carry the codec's parser-shaped reason",
4597        );
4598    }
4599
4600    // ── Aplicacao layout tests ──────────────────────────────────────────
4601
4602    #[test]
4603    fn aplicacao_must_have_membros() {
4604        use crate::{Membro, Placement, PlacementStrategy};
4605        let root = PathBuf::from("/tmp/x");
4606        let manifest = root.join("caixa.lisp");
4607        let manifest_clone = manifest.clone();
4608        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4609        let mut c = caixa(CaixaKind::Aplicacao);
4610        c.placement = Some(Placement {
4611            estrategia: PlacementStrategy::Replicated,
4612            clusters: vec!["rio".into()],
4613            affinity: None,
4614            shard_key: None,
4615        });
4616        // No membros → fails
4617        let err = layout.verify(&c, &root).unwrap_err();
4618        assert!(matches!(err, LayoutError::AplicacaoViolation { .. }));
4619
4620        // With membros → passes
4621        c.membros = vec![Membro {
4622            caixa: "service-a".into(),
4623            versao: "^0.1".into(),
4624        }];
4625        layout.verify(&c, &root).unwrap();
4626    }
4627
4628    #[test]
4629    fn aplicacao_self_referential_membro_is_violation() {
4630        // An Aplicacao whose `:membros` names its own `:nome` is a
4631        // one-node lacre-closure recursion. The cross-slot gate fires
4632        // at verify time, surfacing as an AplicacaoViolation that
4633        // names the offending aplicacao — not at lacre-resolve time
4634        // far from source. The `caixa()` helper's `:nome` is "demo".
4635        // Peer of `supervisor_self_referential_child_is_violation`
4636        // on the supervision-tree axis.
4637        use crate::{Membro, Placement, PlacementStrategy};
4638        let root = PathBuf::from("/tmp/x");
4639        let manifest = root.join("caixa.lisp");
4640        let manifest_clone = manifest.clone();
4641        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4642        let mut c = caixa(CaixaKind::Aplicacao);
4643        c.placement = Some(Placement {
4644            estrategia: PlacementStrategy::Replicated,
4645            clusters: vec!["rio".into()],
4646            affinity: None,
4647            shard_key: None,
4648        });
4649        c.membros = vec![
4650            Membro {
4651                caixa: "service-a".into(),
4652                versao: "^0.1".into(),
4653            },
4654            Membro {
4655                caixa: "demo".into(),
4656                versao: "^0.1".into(),
4657            },
4658        ];
4659        let err = layout.verify(&c, &root).unwrap_err();
4660        let LayoutError::AplicacaoViolation { caixa, issue } = err else {
4661            panic!("expected AplicacaoViolation for self-referential membro, got {err:?}");
4662        };
4663        assert_eq!(caixa, "demo");
4664        assert!(
4665            issue.contains("demo") && issue.contains("lists itself"),
4666            "issue must name the self-membering aplicacao, got {issue:?}"
4667        );
4668    }
4669
4670    #[test]
4671    fn aplicacao_distinct_membros_pass_self_membership_gate() {
4672        // Positive control: an Aplicacao whose membros are all distinct
4673        // from its own `:nome` verifies cleanly.
4674        use crate::{Membro, Placement, PlacementStrategy};
4675        let root = PathBuf::from("/tmp/x");
4676        let manifest = root.join("caixa.lisp");
4677        let manifest_clone = manifest.clone();
4678        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4679        let mut c = caixa(CaixaKind::Aplicacao);
4680        c.placement = Some(Placement {
4681            estrategia: PlacementStrategy::Replicated,
4682            clusters: vec!["rio".into()],
4683            affinity: None,
4684            shard_key: None,
4685        });
4686        c.membros = vec![
4687            Membro {
4688                caixa: "service-a".into(),
4689                versao: "^0.1".into(),
4690            },
4691            Membro {
4692                caixa: "service-b".into(),
4693                versao: "^0.1".into(),
4694            },
4695        ];
4696        layout.verify(&c, &root).unwrap();
4697    }
4698
4699    #[test]
4700    fn aplicacao_self_membership_fires_after_view_validate() {
4701        // Diagnostic-precedence pin: a self-referential membro alongside
4702        // a duplicate-:caixa shape surfaces the more-fundamental
4703        // `MembroDuplicate` (from `view.validate()`) first; only when the
4704        // per-membros shape diagnostics pass does the cross-slot
4705        // self-membership gate fire. Mirrors the ordering pin
4706        // `supervisor_self_referential_child_is_violation` carries on
4707        // the peer supervision-tree axis (`view.validate()` runs first,
4708        // then the cross-slot gate). Without this ordering a future
4709        // refactor that swaps the two calls would silently mask the
4710        // narrower per-membro defect.
4711        use crate::{Membro, Placement, PlacementStrategy};
4712        let root = PathBuf::from("/tmp/x");
4713        let manifest = root.join("caixa.lisp");
4714        let manifest_clone = manifest.clone();
4715        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4716        let mut c = caixa(CaixaKind::Aplicacao);
4717        c.placement = Some(Placement {
4718            estrategia: PlacementStrategy::Replicated,
4719            clusters: vec!["rio".into()],
4720            affinity: None,
4721            shard_key: None,
4722        });
4723        c.membros = vec![
4724            Membro {
4725                caixa: "service-a".into(),
4726                versao: "^0.1".into(),
4727            },
4728            Membro {
4729                caixa: "service-a".into(),
4730                versao: "^0.2".into(),
4731            },
4732            Membro {
4733                caixa: "demo".into(),
4734                versao: "^0.1".into(),
4735            },
4736        ];
4737        let err = layout.verify(&c, &root).unwrap_err();
4738        let LayoutError::AplicacaoViolation { issue, .. } = err else {
4739            panic!("expected AplicacaoViolation, got {err:?}");
4740        };
4741        // The per-membros duplicate diagnostic (from view.validate())
4742        // surfaces ahead of the cross-slot self-membership gate, so the
4743        // `service-a` duplicate is named — not the `demo` self-reference.
4744        assert!(
4745            issue.contains("service-a") && issue.contains("more than once"),
4746            "duplicate-:caixa diagnostic must surface before self-membership gate, \
4747             got {issue:?}"
4748        );
4749    }
4750
4751    #[test]
4752    fn mesh_slots_on_servico_rejected() {
4753        // The canonical real-world footgun: an author adds :entrada to a
4754        // :kind Servico expecting it to expose ingress. aplicacao_view
4755        // returns None for Servico, so the slot is the manifest's
4756        // "ignored otherwise" — never validated, never rendered. The
4757        // kind-coherence gate rejects it at build time (before the
4758        // :servicos existence loop), naming the offending slot + kind.
4759        use crate::Entrada;
4760        let root = PathBuf::from("/tmp/x");
4761        let manifest = root.join("caixa.lisp");
4762        let manifest_clone = manifest.clone();
4763        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4764        let mut c = caixa(CaixaKind::Servico);
4765        c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
4766        c.entrada = Some(Entrada {
4767            host: "demo.example.com".into(),
4768            para: "demo".into(),
4769            paths: vec![],
4770            port: 8080,
4771        });
4772        let err = layout.verify(&c, &root).unwrap_err();
4773        match err {
4774            LayoutError::MeshSlotsOnNonAplicacao { caixa, kind, slots } => {
4775                assert_eq!(caixa, "demo");
4776                assert_eq!(kind, CaixaKind::Servico);
4777                assert_eq!(slots, crate::render::M3_AUTHOR_KEY_ENTRADA);
4778            }
4779            other => panic!("expected MeshSlotsOnNonAplicacao, got {other:?}"),
4780        }
4781    }
4782
4783    #[test]
4784    fn mesh_slots_on_non_aplicacao_lists_slots_in_canonical_order() {
4785        // All five mesh slots declared on a Biblioteca → the diagnostic
4786        // enumerates them in canonical declaration order, deterministic
4787        // across runs. The gate fires on declared-ness only (the values
4788        // need not be a *valid* AplicacaoSpec — aplicacao_view is never
4789        // called for a non-Aplicacao kind).
4790        use crate::{Entrada, Membro, MeshPolicy, Placement, PlacementStrategy, WitContract};
4791        let root = PathBuf::from("/tmp/x");
4792        let manifest = root.join("caixa.lisp");
4793        let manifest_clone = manifest.clone();
4794        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4795        let mut c = caixa(CaixaKind::Biblioteca);
4796        c.membros = vec![Membro {
4797            caixa: "a".into(),
4798            versao: "^0.1".into(),
4799        }];
4800        c.contratos = vec![WitContract {
4801            de: "a".into(),
4802            para: "a".into(),
4803            wit: "wasi:http/proxy".into(),
4804            endpoint: Some("/x".into()),
4805            subject: None,
4806            slot: None,
4807        }];
4808        c.politicas = Some(MeshPolicy::default());
4809        c.placement = Some(Placement {
4810            estrategia: PlacementStrategy::Replicated,
4811            clusters: vec!["rio".into()],
4812            affinity: None,
4813            shard_key: None,
4814        });
4815        c.entrada = Some(Entrada {
4816            host: "x.example.com".into(),
4817            para: "a".into(),
4818            paths: vec![],
4819            port: 8080,
4820        });
4821        let err = layout.verify(&c, &root).unwrap_err();
4822        match err {
4823            LayoutError::MeshSlotsOnNonAplicacao { slots, .. } => {
4824                assert_eq!(
4825                    slots,
4826                    format!(
4827                        "{} {} {} {} {}",
4828                        crate::render::M3_AUTHOR_KEY_MEMBROS,
4829                        crate::render::M3_AUTHOR_KEY_CONTRATOS,
4830                        crate::render::M3_AUTHOR_KEY_POLITICAS,
4831                        crate::render::M3_AUTHOR_KEY_PLACEMENT,
4832                        crate::render::M3_AUTHOR_KEY_ENTRADA,
4833                    )
4834                );
4835            }
4836            other => panic!("expected MeshSlotsOnNonAplicacao, got {other:?}"),
4837        }
4838    }
4839
4840    #[test]
4841    fn servico_without_mesh_slots_still_verifies() {
4842        // Pass-after control: a well-formed Servico carrying no mesh
4843        // slots must remain accepted — the gate keys off declared-ness,
4844        // so it must not over-fire on the common case.
4845        let root = PathBuf::from("/tmp/x");
4846        let servico = root.join("servicos/demo.computeunit.yaml");
4847        let manifest = root.join("caixa.lisp");
4848        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest || p == servico);
4849        let mut c = caixa(CaixaKind::Servico);
4850        c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
4851        layout.verify(&c, &root).unwrap();
4852    }
4853
4854    #[test]
4855    fn supervisor_slots_on_servico_rejected() {
4856        // Mirror of `mesh_slots_on_servico_rejected` on the
4857        // supervisor-tree slot set: an author adds `:children` to a
4858        // `:kind Servico` expecting it to spawn workers. supervisor_view
4859        // returns None for Servico, so the slot is the manifest's
4860        // "ignored otherwise" — never validated, never reconciled. The
4861        // kind-coherence gate rejects it at build time (before the
4862        // :servicos existence loop), naming the offending slot + kind.
4863        use crate::{ChildSpec, RestartPolicy};
4864        let root = PathBuf::from("/tmp/x");
4865        let manifest = root.join("caixa.lisp");
4866        let manifest_clone = manifest.clone();
4867        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4868        let mut c = caixa(CaixaKind::Servico);
4869        c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
4870        c.children = vec![ChildSpec {
4871            caixa: "worker".into(),
4872            versao: "^0.1".into(),
4873            restart: RestartPolicy::Permanent,
4874        }];
4875        let err = layout.verify(&c, &root).unwrap_err();
4876        match err {
4877            LayoutError::SupervisorSlotsOnNonSupervisor { caixa, kind, slots } => {
4878                assert_eq!(caixa, "demo");
4879                assert_eq!(kind, CaixaKind::Servico);
4880                assert_eq!(slots, crate::render::SUPERVISOR_AUTHOR_KEY_CHILDREN);
4881            }
4882            other => panic!("expected SupervisorSlotsOnNonSupervisor, got {other:?}"),
4883        }
4884    }
4885
4886    #[test]
4887    fn supervisor_slots_on_non_supervisor_lists_slots_in_canonical_order() {
4888        // All four supervisor slots declared on a Biblioteca → the
4889        // diagnostic enumerates them in canonical declaration order
4890        // (`:estrategia` → `:max-restarts` → `:restart-window` →
4891        // `:children`), deterministic across runs. The gate fires on
4892        // declared-ness only (the values need not be a *valid*
4893        // SupervisorSpec — supervisor_view is never called for a
4894        // non-Supervisor kind). Mirror of
4895        // `mesh_slots_on_non_aplicacao_lists_slots_in_canonical_order`.
4896        use crate::{ChildSpec, RestartPolicy, RestartStrategy};
4897        let root = PathBuf::from("/tmp/x");
4898        let manifest = root.join("caixa.lisp");
4899        let manifest_clone = manifest.clone();
4900        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4901        let mut c = caixa(CaixaKind::Biblioteca);
4902        c.estrategia = Some(RestartStrategy::OneForOne);
4903        c.max_restarts = Some(5);
4904        c.restart_window = Some("60s".into());
4905        c.children = vec![ChildSpec {
4906            caixa: "worker".into(),
4907            versao: "^0.1".into(),
4908            restart: RestartPolicy::Permanent,
4909        }];
4910        let err = layout.verify(&c, &root).unwrap_err();
4911        match err {
4912            LayoutError::SupervisorSlotsOnNonSupervisor { slots, .. } => {
4913                assert_eq!(slots, ":estrategia :max-restarts :restart-window :children");
4914            }
4915            other => panic!("expected SupervisorSlotsOnNonSupervisor, got {other:?}"),
4916        }
4917    }
4918
4919    #[test]
4920    fn aplicacao_with_supervisor_slots_rejected() {
4921        // Cross-kind pin: an Aplicacao (the other no-code orchestrator
4922        // kind) that declares a supervisor slot is rejected by the
4923        // supervisor-slot gate, just as a Supervisor declaring a mesh
4924        // slot is rejected by the mesh-slot gate — the two kind ↔ slot
4925        // coherence gates are symmetric and mutually exclusive. The
4926        // gate fires before the Aplicacao typed-graph validation, so
4927        // the diagnostic names the foreign supervisor slot rather than
4928        // a downstream AplicacaoViolation.
4929        use crate::{Membro, Placement, PlacementStrategy, RestartStrategy};
4930        let root = PathBuf::from("/tmp/x");
4931        let manifest = root.join("caixa.lisp");
4932        let manifest_clone = manifest.clone();
4933        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4934        let mut c = caixa(CaixaKind::Aplicacao);
4935        c.membros = vec![Membro {
4936            caixa: "service-a".into(),
4937            versao: "^0.1".into(),
4938        }];
4939        c.placement = Some(Placement {
4940            estrategia: PlacementStrategy::Replicated,
4941            clusters: vec!["rio".into()],
4942            affinity: None,
4943            shard_key: None,
4944        });
4945        c.estrategia = Some(RestartStrategy::OneForAll);
4946        let err = layout.verify(&c, &root).unwrap_err();
4947        match err {
4948            LayoutError::SupervisorSlotsOnNonSupervisor { caixa, kind, slots } => {
4949                assert_eq!(caixa, "demo");
4950                assert_eq!(kind, CaixaKind::Aplicacao);
4951                assert_eq!(slots, crate::render::SUPERVISOR_AUTHOR_KEY_ESTRATEGIA);
4952            }
4953            other => panic!("expected SupervisorSlotsOnNonSupervisor, got {other:?}"),
4954        }
4955    }
4956
4957    #[test]
4958    fn servico_without_supervisor_slots_still_verifies() {
4959        // Pass-after control: a well-formed Servico carrying no
4960        // supervisor slots must remain accepted — the gate keys off
4961        // declared-ness, so it must not over-fire on the common case.
4962        let root = PathBuf::from("/tmp/x");
4963        let servico = root.join("servicos/demo.computeunit.yaml");
4964        let manifest = root.join("caixa.lisp");
4965        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest || p == servico);
4966        let mut c = caixa(CaixaKind::Servico);
4967        c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
4968        layout.verify(&c, &root).unwrap();
4969    }
4970
4971    #[test]
4972    fn servico_slots_on_biblioteca_rejected() {
4973        // Mirror of `mesh_slots_on_servico_rejected` /
4974        // `supervisor_slots_on_servico_rejected` on the M2
4975        // Servico-runtime slot set: an author adds `:limits` to a
4976        // `:kind Biblioteca` expecting per-process sandboxing. The
4977        // caixa-helm / caixa-flux renderers gate on `require_kind(_,
4978        // Servico)`, so the slot is the manifest's "ignored otherwise" —
4979        // never rendered into any artifact. The kind-coherence gate
4980        // rejects it at build time (before the M2 validate blocks),
4981        // naming the offending slot + kind.
4982        use crate::LimitsSpec;
4983        let root = PathBuf::from("/tmp/x");
4984        let manifest = root.join("caixa.lisp");
4985        let lib = root.join("lib").join("demo.lisp");
4986        let manifest_clone = manifest.clone();
4987        let layout =
4988            StandardLayout::new().with_path_exists(move |p| p == manifest_clone || p == lib);
4989        let mut c = caixa(CaixaKind::Biblioteca);
4990        c.limits = Some(LimitsSpec {
4991            fuel: Some(1_000_000),
4992            ..Default::default()
4993        });
4994        let err = layout.verify(&c, &root).unwrap_err();
4995        match err {
4996            LayoutError::ServicoSlotsOnNonServico { caixa, kind, slots } => {
4997                assert_eq!(caixa, "demo");
4998                assert_eq!(kind, CaixaKind::Biblioteca);
4999                assert_eq!(slots, crate::render::M2_AUTHOR_KEY_LIMITS);
5000            }
5001            other => panic!("expected ServicoSlotsOnNonServico, got {other:?}"),
5002        }
5003    }
5004
5005    #[test]
5006    fn servico_slots_on_non_servico_lists_slots_in_canonical_order() {
5007        // All three M2 slots declared on a Biblioteca → the diagnostic
5008        // enumerates them in canonical declaration order (`:limits` →
5009        // `:behavior` → `:upgrade-from`), deterministic across runs. The
5010        // gate fires on declared-ness only (the values need not pass the
5011        // M2 validate blocks — those run only after the kind-coherence
5012        // gate, and never for a non-Servico declared-slot caixa). Mirror
5013        // of the mesh/supervisor `*_lists_slots_in_canonical_order` pins.
5014        use crate::{BehaviorSpec, LimitsSpec, UpgradeFromEntry, UpgradeInstruction};
5015        let root = PathBuf::from("/tmp/x");
5016        let manifest = root.join("caixa.lisp");
5017        let manifest_clone = manifest.clone();
5018        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
5019        let mut c = caixa(CaixaKind::Biblioteca);
5020        c.limits = Some(LimitsSpec {
5021            fuel: Some(1_000_000),
5022            ..Default::default()
5023        });
5024        c.behavior = Some(BehaviorSpec {
5025            on_init: Some(PathBuf::from("lib/init.lisp")),
5026            ..Default::default()
5027        });
5028        c.upgrade_from = vec![UpgradeFromEntry {
5029            from: "0.1.0".into(),
5030            instructions: vec![UpgradeInstruction::Restart],
5031        }];
5032        let err = layout.verify(&c, &root).unwrap_err();
5033        match err {
5034            LayoutError::ServicoSlotsOnNonServico { slots, .. } => {
5035                assert_eq!(
5036                    slots,
5037                    format!(
5038                        "{} {} {}",
5039                        crate::render::M2_AUTHOR_KEY_LIMITS,
5040                        crate::render::M2_AUTHOR_KEY_BEHAVIOR,
5041                        crate::render::M2_AUTHOR_KEY_UPGRADE_FROM,
5042                    )
5043                );
5044            }
5045            other => panic!("expected ServicoSlotsOnNonServico, got {other:?}"),
5046        }
5047    }
5048
5049    #[test]
5050    fn aplicacao_with_servico_slots_rejected() {
5051        // Cross-kind pin (mirror of `aplicacao_with_supervisor_slots_rejected`):
5052        // an Aplicacao that declares an M2 Servico-runtime slot is
5053        // rejected by the Servico-slot gate, just as a Supervisor
5054        // declaring a mesh slot is rejected by the mesh-slot gate — the
5055        // three kind ↔ slot coherence gates are symmetric and mutually
5056        // exclusive. The gate fires before the Aplicacao typed-graph
5057        // validation, so the diagnostic names the foreign M2 slot rather
5058        // than a downstream AplicacaoViolation about missing :membros.
5059        use crate::{Membro, Placement, PlacementStrategy, UpgradeFromEntry, UpgradeInstruction};
5060        let root = PathBuf::from("/tmp/x");
5061        let manifest = root.join("caixa.lisp");
5062        let manifest_clone = manifest.clone();
5063        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
5064        let mut c = caixa(CaixaKind::Aplicacao);
5065        c.membros = vec![Membro {
5066            caixa: "service-a".into(),
5067            versao: "^0.1".into(),
5068        }];
5069        c.placement = Some(Placement {
5070            estrategia: PlacementStrategy::Replicated,
5071            clusters: vec!["rio".into()],
5072            affinity: None,
5073            shard_key: None,
5074        });
5075        c.upgrade_from = vec![UpgradeFromEntry {
5076            from: "0.1.0".into(),
5077            instructions: vec![UpgradeInstruction::Restart],
5078        }];
5079        let err = layout.verify(&c, &root).unwrap_err();
5080        match err {
5081            LayoutError::ServicoSlotsOnNonServico { caixa, kind, slots } => {
5082                assert_eq!(caixa, "demo");
5083                assert_eq!(kind, CaixaKind::Aplicacao);
5084                assert_eq!(slots, crate::render::M2_AUTHOR_KEY_UPGRADE_FROM);
5085            }
5086            other => panic!("expected ServicoSlotsOnNonServico, got {other:?}"),
5087        }
5088    }
5089
5090    #[test]
5091    fn servico_with_servico_slots_still_verifies() {
5092        // Pass-after control: a well-formed Servico carrying all three M2
5093        // slots must remain accepted — the gate is guarded by `kind !=
5094        // Servico`, so it must not over-fire on the kind these slots
5095        // exist for. Mirror of `servico_without_{mesh,supervisor}_slots_
5096        // still_verifies` on the legitimate-declaration axis.
5097        use crate::{BehaviorSpec, LimitsSpec, UpgradeFromEntry, UpgradeInstruction};
5098        let root = PathBuf::from("/tmp/x");
5099        let manifest = root.join("caixa.lisp");
5100        let svc = root.join("servicos/demo.computeunit.yaml");
5101        let init = root.join("lib/init.lisp");
5102        let layout =
5103            StandardLayout::new().with_path_exists(move |p| p == manifest || p == svc || p == init);
5104        let mut c = caixa(CaixaKind::Servico);
5105        c.versao = "0.2.0".into();
5106        c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
5107        c.limits = Some(LimitsSpec {
5108            fuel: Some(1_000_000),
5109            ..Default::default()
5110        });
5111        c.behavior = Some(BehaviorSpec {
5112            on_init: Some(PathBuf::from("lib/init.lisp")),
5113            ..Default::default()
5114        });
5115        c.upgrade_from = vec![UpgradeFromEntry {
5116            from: "0.1.0".into(),
5117            instructions: vec![UpgradeInstruction::Restart],
5118        }];
5119        layout.verify(&c, &root).unwrap();
5120    }
5121
5122    #[test]
5123    fn aplicacao_must_not_have_bibliotecas() {
5124        use crate::{Membro, Placement, PlacementStrategy};
5125        let root = PathBuf::from("/tmp/x");
5126        let manifest = root.join("caixa.lisp");
5127        let manifest_clone = manifest.clone();
5128        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
5129        let mut c = caixa(CaixaKind::Aplicacao);
5130        c.bibliotecas = vec!["lib/code.lisp".into()];
5131        c.membros = vec![Membro {
5132            caixa: "x".into(),
5133            versao: "^0.1".into(),
5134        }];
5135        c.placement = Some(Placement {
5136            estrategia: PlacementStrategy::Replicated,
5137            clusters: vec!["rio".into()],
5138            affinity: None,
5139            shard_key: None,
5140        });
5141        let err = layout.verify(&c, &root).unwrap_err();
5142        assert!(matches!(err, LayoutError::AplicacaoOwnsCode(_)));
5143    }
5144
5145    #[test]
5146    fn acao_must_not_have_bibliotecas() {
5147        // Mirror of `supervisor_must_not_have_bibliotecas` /
5148        // `aplicacao_must_not_have_bibliotecas` on the third no-code
5149        // kind. `has_code` fires before the `:ci`-presence gates below
5150        // it, so this must surface `AcaoOwnsCode` even though the
5151        // caixa also lacks a `:ci` slot (which would otherwise surface
5152        // as `MissingCi`) — the more-fundamental "this kind runs no
5153        // code at all" diagnostic wins.
5154        let root = PathBuf::from("/tmp/x");
5155        let manifest = root.join("caixa.lisp");
5156        let manifest_clone = manifest.clone();
5157        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
5158        let mut c = caixa(CaixaKind::Acao);
5159        c.bibliotecas = vec!["lib/code.lisp".into()];
5160        let err = layout.verify(&c, &root).unwrap_err();
5161        assert!(matches!(err, LayoutError::AcaoOwnsCode(_)));
5162    }
5163
5164    #[test]
5165    fn acao_without_ci_errors() {
5166        // Mirror of `binario_without_exe_errors` on the fifth required-
5167        // slot axis.
5168        let root = PathBuf::from("/tmp/x");
5169        let manifest = root.join("caixa.lisp");
5170        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
5171        let err = layout.verify(&caixa(CaixaKind::Acao), &root).unwrap_err();
5172        assert!(matches!(err, LayoutError::MissingCi(_)));
5173    }
5174
5175    #[test]
5176    fn acao_with_ci_passes() {
5177        let root = PathBuf::from("/tmp/x");
5178        let manifest = root.join("caixa.lisp");
5179        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
5180        let mut c = caixa(CaixaKind::Acao);
5181        c.ci = Some(canteiro_types::CiRun {
5182            workspace: "pleme-io".into(),
5183            repo: "caixa".into(),
5184            nodes: vec![],
5185        });
5186        layout
5187            .verify(&c, &root)
5188            .expect("an Acao caixa with a declared :ci slot passes layout verify");
5189    }
5190
5191    #[test]
5192    fn ci_on_non_acao_errors() {
5193        // Mirror of `mesh_slots_on_non_aplicacao_lists_slots_in_canonical_order`
5194        // on the Acao-only `:ci` axis — declaring `:ci` on any other
5195        // kind is the same "silently ignored" footgun the sibling
5196        // mesh-/supervisor-/servico-slot gates already close.
5197        let root = PathBuf::from("/tmp/x");
5198        let manifest = root.join("caixa.lisp");
5199        let manifest_clone = manifest.clone();
5200        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
5201        let mut c = caixa(CaixaKind::Biblioteca);
5202        c.ci = Some(canteiro_types::CiRun {
5203            workspace: "pleme-io".into(),
5204            repo: "caixa".into(),
5205            nodes: vec![],
5206        });
5207        let err = layout.verify(&c, &root).unwrap_err();
5208        match err {
5209            LayoutError::CiOnNonAcao { caixa, kind } => {
5210                assert_eq!(caixa, "demo");
5211                assert_eq!(kind, CaixaKind::Biblioteca);
5212            }
5213            other => panic!("expected CiOnNonAcao, got {other:?}"),
5214        }
5215    }
5216
5217    // ── ForeignCodeSlot — kind ↔ code-surface coherence ────────────────
5218
5219    #[test]
5220    fn biblioteca_with_exe_rejected() {
5221        // Fail-before-pass-after pin: a `:kind Biblioteca` declaring
5222        // `:exe` is the "I added a CLI to my library" footgun — the nix
5223        // flake renderer for Binario gates on `require_kind(_, Binario)`,
5224        // so on a Biblioteca the `:exe` path is silently dropped past
5225        // the layout's path-existence check (no executable target is
5226        // ever generated). The diagnostic names the offending kind +
5227        // slot verbatim so the author can grep their caixa.lisp for
5228        // `:exe` and fix in one edit (drop the slot or change
5229        // `:kind Biblioteca` → `:kind Binario`).
5230        let root = PathBuf::from("/tmp/x");
5231        let manifest = root.join("caixa.lisp");
5232        let lib = root.join("lib").join("demo.lisp");
5233        let exe_path = root.join("exe").join("tool");
5234        let layout = StandardLayout::new()
5235            .with_path_exists(move |p| p == manifest || p == lib || p == exe_path);
5236        let mut c = caixa(CaixaKind::Biblioteca);
5237        c.exe = vec!["exe/tool".into()];
5238        let err = layout.verify(&c, &root).unwrap_err();
5239        match err {
5240            LayoutError::ForeignCodeSlot { caixa, kind, slots } => {
5241                assert_eq!(caixa, "demo");
5242                assert_eq!(kind, CaixaKind::Biblioteca);
5243                assert_eq!(slots, ":exe");
5244            }
5245            other => panic!("expected ForeignCodeSlot, got {other:?}"),
5246        }
5247    }
5248
5249    #[test]
5250    fn biblioteca_with_servicos_rejected() {
5251        // Symmetric to `biblioteca_with_exe_rejected` on the
5252        // `:servicos` axis: a `:kind Biblioteca` declaring a Servico
5253        // computeunit silently passed validate and the daemon's
5254        // ComputeUnit / lareira chart never materialized (caixa-helm /
5255        // caixa-flux gate emission on `require_kind(_, Servico)`).
5256        let root = PathBuf::from("/tmp/x");
5257        let manifest = root.join("caixa.lisp");
5258        let lib = root.join("lib").join("demo.lisp");
5259        let svc = root.join("servicos").join("demo.computeunit.yaml");
5260        let layout =
5261            StandardLayout::new().with_path_exists(move |p| p == manifest || p == lib || p == svc);
5262        let mut c = caixa(CaixaKind::Biblioteca);
5263        c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
5264        let err = layout.verify(&c, &root).unwrap_err();
5265        match err {
5266            LayoutError::ForeignCodeSlot { caixa, kind, slots } => {
5267                assert_eq!(caixa, "demo");
5268                assert_eq!(kind, CaixaKind::Biblioteca);
5269                assert_eq!(slots, ":servicos");
5270            }
5271            other => panic!("expected ForeignCodeSlot, got {other:?}"),
5272        }
5273    }
5274
5275    #[test]
5276    fn biblioteca_with_exe_and_servicos_lists_slots_in_canonical_order() {
5277        // Both foreign code slots declared on a Biblioteca → the
5278        // diagnostic enumerates them in canonical declaration order
5279        // (`:exe` → `:servicos`), deterministic across runs. Mirrors the
5280        // mesh/supervisor/servico-slot `*_lists_slots_in_canonical_order`
5281        // pins on the peer kind ↔ slot algebra axes; drift in the
5282        // [`Caixa::declared_foreign_code_slots`] iteration order surfaces
5283        // here.
5284        let root = PathBuf::from("/tmp/x");
5285        let manifest = root.join("caixa.lisp");
5286        let manifest_clone = manifest.clone();
5287        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
5288        let mut c = caixa(CaixaKind::Biblioteca);
5289        c.exe = vec!["exe/tool".into()];
5290        c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
5291        let err = layout.verify(&c, &root).unwrap_err();
5292        match err {
5293            LayoutError::ForeignCodeSlot { slots, .. } => {
5294                assert_eq!(slots, ":exe :servicos");
5295            }
5296            other => panic!("expected ForeignCodeSlot, got {other:?}"),
5297        }
5298    }
5299
5300    #[test]
5301    fn binario_with_servicos_rejected() {
5302        // The peer footgun on the Binario kind: declaring a Servico
5303        // computeunit on a `:kind Binario` caixa. The caixa-helm /
5304        // caixa-flux renderers gate on `require_kind(_, Servico)`, so
5305        // the `:servicos` slot vanishes past the layout's path-
5306        // existence check — no ComputeUnit, no Helm chart. `:exe` stays
5307        // valid (Binario's native code surface), so the kind-coherence
5308        // diagnostic targets only `:servicos`.
5309        let root = PathBuf::from("/tmp/x");
5310        let manifest = root.join("caixa.lisp");
5311        let exe_path = root.join("exe").join("tool");
5312        let svc = root.join("servicos").join("demo.computeunit.yaml");
5313        let layout = StandardLayout::new()
5314            .with_path_exists(move |p| p == manifest || p == exe_path || p == svc);
5315        let mut c = caixa(CaixaKind::Binario);
5316        c.exe = vec!["exe/tool".into()];
5317        c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
5318        let err = layout.verify(&c, &root).unwrap_err();
5319        match err {
5320            LayoutError::ForeignCodeSlot { caixa, kind, slots } => {
5321                assert_eq!(caixa, "demo");
5322                assert_eq!(kind, CaixaKind::Binario);
5323                assert_eq!(slots, ":servicos");
5324            }
5325            other => panic!("expected ForeignCodeSlot, got {other:?}"),
5326        }
5327    }
5328
5329    #[test]
5330    fn servico_with_exe_rejected() {
5331        // Symmetric to `binario_with_servicos_rejected` on the other
5332        // code-running peer: a `:kind Servico` declaring an `:exe` is
5333        // the "I added a host-side CLI to my wasm component" footgun —
5334        // the nix flake's Binario target gates on `require_kind(_,
5335        // Binario)`, so the `:exe` path vanishes past the layout's
5336        // path-existence check.
5337        let root = PathBuf::from("/tmp/x");
5338        let manifest = root.join("caixa.lisp");
5339        let svc = root.join("servicos").join("demo.computeunit.yaml");
5340        let exe_path = root.join("exe").join("tool");
5341        let layout = StandardLayout::new()
5342            .with_path_exists(move |p| p == manifest || p == svc || p == exe_path);
5343        let mut c = caixa(CaixaKind::Servico);
5344        c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
5345        c.exe = vec!["exe/tool".into()];
5346        let err = layout.verify(&c, &root).unwrap_err();
5347        match err {
5348            LayoutError::ForeignCodeSlot { caixa, kind, slots } => {
5349                assert_eq!(caixa, "demo");
5350                assert_eq!(kind, CaixaKind::Servico);
5351                assert_eq!(slots, ":exe");
5352            }
5353            other => panic!("expected ForeignCodeSlot, got {other:?}"),
5354        }
5355    }
5356
5357    #[test]
5358    fn binario_without_servicos_still_verifies() {
5359        // Pass-after control: a well-formed Binario carrying only its
5360        // native `:exe` surface must remain accepted — the gate keys off
5361        // declared-ness of the *foreign* slots, so it must not over-fire
5362        // on the legitimate same-kind case. Mirror of
5363        // `servico_with_servico_slots_still_verifies` on the peer axis.
5364        let root = PathBuf::from("/tmp/x");
5365        let manifest = root.join("caixa.lisp");
5366        let exe_path = root.join("exe").join("tool");
5367        let layout =
5368            StandardLayout::new().with_path_exists(move |p| p == manifest || p == exe_path);
5369        let mut c = caixa(CaixaKind::Binario);
5370        c.exe = vec!["exe/tool".into()];
5371        layout.verify(&c, &root).unwrap();
5372    }
5373
5374    #[test]
5375    fn biblioteca_with_only_bibliotecas_still_verifies() {
5376        // Pass-after control: a well-formed Biblioteca carrying only
5377        // its native `:bibliotecas` surface (or the default
5378        // `lib/<nome>.lisp`) must remain accepted. The gate keys off
5379        // declared-ness of `:exe` + `:servicos` only — `:bibliotecas`
5380        // is deliberately excluded from the foreign-set on every
5381        // code-running kind (`declared_foreign_code_slots` doc), so a
5382        // Biblioteca with the canonical lib surface alone passes.
5383        let root = PathBuf::from("/tmp/x");
5384        let manifest = root.join("caixa.lisp");
5385        let lib = root.join("lib").join("demo.lisp");
5386        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest || p == lib);
5387        layout
5388            .verify(&caixa(CaixaKind::Biblioteca), &root)
5389            .expect("Biblioteca with default lib must verify");
5390    }
5391
5392    #[test]
5393    fn binario_with_bibliotecas_helper_still_verifies() {
5394        // Pass-after control on the deliberate `:bibliotecas`-as-helper
5395        // shape: a `:kind Binario` may legitimately bundle a `lib/`
5396        // helper its nix flake build consumes (the same shape a
5397        // `:kind Servico` may bundle for its wasm-component source).
5398        // The foreign-code-slot gate must NOT fire on `:bibliotecas` for
5399        // either code-running kind; pinned here so a future tightening
5400        // that adds `:bibliotecas` to the foreign set on Binario /
5401        // Servico surfaces as a test failure rather than as a silent
5402        // over-reach.
5403        let root = PathBuf::from("/tmp/x");
5404        let manifest = root.join("caixa.lisp");
5405        let exe_path = root.join("exe").join("tool");
5406        let lib = root.join("lib").join("helper.lisp");
5407        let layout = StandardLayout::new()
5408            .with_path_exists(move |p| p == manifest || p == exe_path || p == lib);
5409        let mut c = caixa(CaixaKind::Binario);
5410        c.exe = vec!["exe/tool".into()];
5411        c.bibliotecas = vec!["lib/helper.lisp".into()];
5412        layout.verify(&c, &root).unwrap();
5413    }
5414
5415    #[test]
5416    fn supervisor_with_exe_still_surfaces_owns_code() {
5417        // Diagnostic-precedence pin: a `:kind Supervisor` declaring
5418        // `:exe` is *both* "Supervisor with code" and "foreign code
5419        // slot". The more-fundamental `SupervisorOwnsCode` must win
5420        // (Supervisor doesn't run code at all — the foreign-slot
5421        // diagnostic would mislead the author toward changing `:kind`
5422        // when the underlying defect is that supervisors orchestrate
5423        // children, not code). Guards the call order in `verify`
5424        // against silent reordering.
5425        use crate::{ChildSpec, RestartPolicy, RestartStrategy};
5426        let root = PathBuf::from("/tmp/x");
5427        let manifest = root.join("caixa.lisp");
5428        let manifest_clone = manifest.clone();
5429        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
5430        let mut c = caixa(CaixaKind::Supervisor);
5431        c.estrategia = Some(RestartStrategy::OneForOne);
5432        c.max_restarts = Some(5);
5433        c.exe = vec!["exe/tool".into()];
5434        c.children = vec![ChildSpec {
5435            caixa: "worker".into(),
5436            versao: "^0.1".into(),
5437            restart: RestartPolicy::Permanent,
5438        }];
5439        let err = layout.verify(&c, &root).unwrap_err();
5440        assert!(
5441            matches!(err, LayoutError::SupervisorOwnsCode(_)),
5442            "Supervisor-with-:exe must surface as SupervisorOwnsCode (the more-fundamental \
5443             no-code-at-all diagnostic), got {err:?}"
5444        );
5445    }
5446
5447    #[test]
5448    fn declared_foreign_code_slots_returns_canonical_order() {
5449        // Unit-level pin for the lifted method: the canonical iteration
5450        // order is `:exe` → `:servicos`, independent of which subset is
5451        // populated. Empty input + each single-slot subset + the full
5452        // pair are all checked so a future axis added to the method
5453        // (a hypothetical fifth code-surface slot) is one extension
5454        // point + one assertion update here, not a coordinated rewrite
5455        // across the layout-test sites that reach for the canonical
5456        // order.
5457        let mut c = caixa(CaixaKind::Biblioteca);
5458        assert!(c.declared_foreign_code_slots().is_empty());
5459        c.exe = vec!["exe/tool".into()];
5460        assert_eq!(c.declared_foreign_code_slots(), vec![":exe"]);
5461        c.exe.clear();
5462        c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
5463        assert_eq!(c.declared_foreign_code_slots(), vec![":servicos"]);
5464        c.exe = vec!["exe/tool".into()];
5465        assert_eq!(c.declared_foreign_code_slots(), vec![":exe", ":servicos"]);
5466    }
5467
5468    #[test]
5469    fn aplicacao_with_unknown_contrato_member_fails() {
5470        use crate::{Membro, Placement, PlacementStrategy, WitContract};
5471        let root = PathBuf::from("/tmp/x");
5472        let manifest = root.join("caixa.lisp");
5473        let manifest_clone = manifest.clone();
5474        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
5475        let mut c = caixa(CaixaKind::Aplicacao);
5476        c.membros = vec![Membro {
5477            caixa: "service-a".into(),
5478            versao: "^0.1".into(),
5479        }];
5480        c.contratos = vec![WitContract {
5481            de: "service-a".into(),
5482            para: "phantom".into(),
5483            wit: "wasi:http/proxy".into(),
5484            endpoint: Some("/x".into()),
5485            subject: None,
5486            slot: None,
5487        }];
5488        c.placement = Some(Placement {
5489            estrategia: PlacementStrategy::Replicated,
5490            clusters: vec!["rio".into()],
5491            affinity: None,
5492            shard_key: None,
5493        });
5494        let err = layout.verify(&c, &root).unwrap_err();
5495        assert!(matches!(err, LayoutError::AplicacaoViolation { .. }));
5496    }
5497
5498    #[test]
5499    fn limits_zero_axis_surfaces_as_layout_violation() {
5500        use crate::LimitsSpec;
5501        let root = PathBuf::from("/tmp/x");
5502        let manifest = root.join("caixa.lisp");
5503        let svc = root.join("servicos/demo.computeunit.yaml");
5504        let mut c = caixa(CaixaKind::Servico);
5505        c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
5506        c.limits = Some(LimitsSpec {
5507            fuel: Some(0),
5508            ..Default::default()
5509        });
5510        let manifest_clone = manifest.clone();
5511        let svc_clone = svc.clone();
5512        let layout =
5513            StandardLayout::new().with_path_exists(move |p| p == manifest_clone || p == svc_clone);
5514        let err = layout.verify(&c, &root).unwrap_err();
5515        let LayoutError::LimitsViolation { caixa, issue } = err else {
5516            panic!("expected LimitsViolation, got {err:?}");
5517        };
5518        assert_eq!(caixa, "demo");
5519        assert!(issue.contains(":fuel"), "issue must name the axis: {issue}");
5520    }
5521
5522    #[test]
5523    fn limits_well_formed_passes_layout() {
5524        use crate::LimitsSpec;
5525        use std::time::Duration;
5526        let root = PathBuf::from("/tmp/x");
5527        let manifest = root.join("caixa.lisp");
5528        let svc = root.join("servicos/demo.computeunit.yaml");
5529        let mut c = caixa(CaixaKind::Servico);
5530        c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
5531        c.limits = Some(LimitsSpec {
5532            memory: Some(64 * 1024 * 1024),
5533            fuel: Some(1_000_000),
5534            wall_clock: Some(Duration::from_secs(30)),
5535            cpu: Some(500),
5536        });
5537        let manifest_clone = manifest.clone();
5538        let svc_clone = svc.clone();
5539        let layout =
5540            StandardLayout::new().with_path_exists(move |p| p == manifest_clone || p == svc_clone);
5541        layout.verify(&c, &root).unwrap();
5542    }
5543
5544    #[test]
5545    fn supervisor_with_valid_children_passes() {
5546        use crate::{ChildSpec, RestartPolicy, RestartStrategy};
5547        let root = PathBuf::from("/tmp/x");
5548        let manifest = root.join("caixa.lisp");
5549        let manifest_clone = manifest.clone();
5550        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
5551        let mut c = caixa(CaixaKind::Supervisor);
5552        c.estrategia = Some(RestartStrategy::OneForOne);
5553        c.max_restarts = Some(5);
5554        c.children = vec![
5555            ChildSpec {
5556                caixa: "worker".into(),
5557                versao: "^0.1".into(),
5558                restart: RestartPolicy::Permanent,
5559            },
5560            ChildSpec {
5561                caixa: "cache".into(),
5562                versao: "^0.1".into(),
5563                restart: RestartPolicy::Transient,
5564            },
5565        ];
5566        layout.verify(&c, &root).unwrap();
5567    }
5568
5569    // ── :upgrade-from entry validation pipes through layout ─────────────
5570
5571    #[test]
5572    fn upgrade_invalid_module_surfaces_as_layout_violation() {
5573        // End-to-end pin that
5574        // [`crate::UpgradeFromEntry::validate`] runs *inside*
5575        // `LayoutInvariants::verify` and surfaces value-shape
5576        // violations through the new `UpgradeViolation` arm
5577        // (parallel to `BehaviorViolation`, `LimitsViolation`,
5578        // `SupervisorViolation`, `AplicacaoViolation`). Until this
5579        // wiring landed the entry validator was unreachable from any
5580        // build-pipeline caller — an `:upgrade-from
5581        // ((:from "0.1.0" :instructions ((:load-module "Hello")))` (uppercase
5582        // module name the K8s apiserver would reject on the per-
5583        // ComputeUnit `metadata.name` axis) silently passed
5584        // `feira lint` / `feira build` and surfaced only at wasm-engine
5585        // hot-upgrade time as a per-backend "module not found" /
5586        // `code:load_module/1` `badarg` runtime error, far from the
5587        // source caixa.lisp. Pinning the wiring here so a future
5588        // refactor that drops the `entry.validate()` call surfaces as
5589        // a build-pipeline regression at this test, not as a runtime
5590        // surprise per consumer.
5591        use crate::{UpgradeFromEntry, UpgradeInstruction};
5592        use std::path::PathBuf;
5593        let root = PathBuf::from("/tmp/x");
5594        let manifest = root.join("caixa.lisp");
5595        let svc = root.join("servicos/demo.computeunit.yaml");
5596        let mut c = caixa(CaixaKind::Servico);
5597        c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
5598        c.upgrade_from = vec![UpgradeFromEntry {
5599            from: "0.1.0".into(),
5600            instructions: vec![UpgradeInstruction::LoadModule {
5601                module: "Hello".into(), // uppercase — not DNS-1123
5602            }],
5603        }];
5604        let manifest_clone = manifest.clone();
5605        let svc_clone = svc.clone();
5606        let layout =
5607            StandardLayout::new().with_path_exists(move |p| p == manifest_clone || p == svc_clone);
5608        let err = layout.verify(&c, &root).unwrap_err();
5609        let LayoutError::UpgradeViolation { caixa, issue } = err else {
5610            panic!("expected UpgradeViolation, got {err:?}");
5611        };
5612        assert_eq!(caixa, "demo");
5613        assert!(
5614            issue.contains(crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE),
5615            "issue must name the lisp-form of the offending instruction: {issue}"
5616        );
5617        assert!(
5618            issue.contains("Hello"),
5619            "issue must name the offending :module verbatim: {issue}"
5620        );
5621    }
5622
5623    #[test]
5624    fn upgrade_empty_module_surfaces_as_layout_violation() {
5625        // Companion to the DNS-1123 footgun above on the narrower
5626        // empty arm. Every Module-bearing variant's empty value
5627        // reaches the layout pipeline through the kind-tagged
5628        // `ModuleEmpty` diagnostic naming its lisp-form.
5629        use crate::{UpgradeFromEntry, UpgradeInstruction};
5630        use std::path::PathBuf;
5631        let root = PathBuf::from("/tmp/x");
5632        let manifest = root.join("caixa.lisp");
5633        let svc = root.join("servicos/demo.computeunit.yaml");
5634        let mut c = caixa(CaixaKind::Servico);
5635        c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
5636        c.upgrade_from = vec![UpgradeFromEntry {
5637            from: "0.1.0".into(),
5638            instructions: vec![UpgradeInstruction::SoftPurge {
5639                module: String::new(),
5640            }],
5641        }];
5642        let manifest_clone = manifest.clone();
5643        let svc_clone = svc.clone();
5644        let layout =
5645            StandardLayout::new().with_path_exists(move |p| p == manifest_clone || p == svc_clone);
5646        let err = layout.verify(&c, &root).unwrap_err();
5647        let LayoutError::UpgradeViolation { caixa, issue } = err else {
5648            panic!("expected UpgradeViolation, got {err:?}");
5649        };
5650        assert_eq!(caixa, "demo");
5651        assert!(
5652            issue.contains(crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE),
5653            "issue must name the lisp-form of the empty instruction: {issue}"
5654        );
5655    }
5656
5657    #[test]
5658    fn upgrade_invalid_state_change_script_surfaces_as_layout_violation() {
5659        // Pins that the b0c8389 script value-shape gates
5660        // (AbsoluteScript / ParentEscapeScript) — previously
5661        // unreachable from any build-pipeline caller — now fire
5662        // through the same `UpgradeViolation` arm before the path-
5663        // existence pass would otherwise emit the less-helpful
5664        // "missing upgrade-script" (or, worse, *succeed* against
5665        // /etc/passwd, proving the sandbox bypass — same defect
5666        // the b0c8389 BehaviorSpec wiring closed on the peer M2
5667        // slot).
5668        use crate::{UpgradeFromEntry, UpgradeInstruction};
5669        use std::path::PathBuf;
5670        let root = PathBuf::from("/tmp/x");
5671        let manifest = root.join("caixa.lisp");
5672        let svc = root.join("servicos/demo.computeunit.yaml");
5673        let etc_passwd = PathBuf::from("/etc/passwd");
5674        let mut c = caixa(CaixaKind::Servico);
5675        c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
5676        c.upgrade_from = vec![UpgradeFromEntry {
5677            from: "0.1.0".into(),
5678            instructions: vec![UpgradeInstruction::StateChange {
5679                script: PathBuf::from("/etc/passwd"),
5680            }],
5681        }];
5682        let manifest_clone = manifest.clone();
5683        let svc_clone = svc.clone();
5684        let etc_passwd_clone = etc_passwd.clone();
5685        // Critically: /etc/passwd "exists" in our mock — without the
5686        // value-shape pre-check, the existence loop would *succeed*
5687        // and the path-traversal exit from the project sandbox would
5688        // pass `feira build` silently.
5689        let layout = StandardLayout::new().with_path_exists(move |p| {
5690            p == manifest_clone || p == svc_clone || p == etc_passwd_clone
5691        });
5692        let err = layout.verify(&c, &root).unwrap_err();
5693        let LayoutError::UpgradeViolation { caixa, issue } = err else {
5694            panic!("expected UpgradeViolation, got {err:?}");
5695        };
5696        assert_eq!(caixa, "demo");
5697        assert!(
5698            issue.contains("absolute") || issue.contains("Absolute"),
5699            "issue must name the violation kind (absolute): {issue}"
5700        );
5701    }
5702
5703    #[test]
5704    fn upgrade_well_formed_passes_layout() {
5705        // Positive control — every documented authoring shape
5706        // (`:load-module`, `:state-change` with a relative path,
5707        // `:soft-purge`, `:purge`, sole `:restart`) passes the wired
5708        // gate. The typed sequence (`:load-module` → `:state-change`
5709        // → `:soft-purge` → `:purge`) lives in one entry; the sole
5710        // `:restart` fallback lives in a *separate* entry on a
5711        // different `:from` (the within-entry restart-exclusivity
5712        // gate added in this commit rejects mixing the fallback with
5713        // the typed sequence — per the UpgradeInstruction::Restart
5714        // doc, `:restart` is terminal and any other instructions in
5715        // the same entry are dead code). Drift here = a future
5716        // tighten that rejects any canonical shape surfaces as a
5717        // regression at this layout-level pin, not piecemeal across
5718        // per-renderer call sites.
5719        //
5720        // `:soft-purge` and `:purge` target *distinct* old-version
5721        // modules (`hello-rio-old` and `hello-rio-oldest`) so the
5722        // within-entry cleanup-singularity gate
5723        // (`UpgradeError::DuplicateCleanup`) passes — that gate
5724        // rejects more than one cleanup per module per entry (one
5725        // semantic per old version; mixing drain + discard on one
5726        // module is the soft-then-hard fallback footgun the author
5727        // shouldn't write because the operator handles cleanup
5728        // failure escalation itself). The two distinct names cover
5729        // the legitimate "drain a recent old, hard-discard an
5730        // older-still" shape — both authoring forms remain load-
5731        // bearing in this positive-control enumeration.
5732        use crate::{BehaviorSpec, UpgradeFromEntry, UpgradeInstruction};
5733        use std::path::PathBuf;
5734        let root = PathBuf::from("/tmp/x");
5735        let manifest = root.join("caixa.lisp");
5736        let svc = root.join("servicos/demo.computeunit.yaml");
5737        let migration = root.join("lib/migrations/v01-to-v02.lisp");
5738        let on_state_change = root.join("lib/migrations.lisp");
5739        let mut c = caixa(CaixaKind::Servico);
5740        // `:versao` past both entries' `:from` so the cross-slot
5741        // precedence gate (`FromNotBeforeVersao`) lets this canonical
5742        // authoring shape through to the positive-control assertion.
5743        c.versao = "0.2.0".into();
5744        c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
5745        // `:on-state-change` declared alongside the `(:state-change …)`
5746        // instruction below — the cross-slot composition gate
5747        // (`validate_upgrade_from_against_behavior`) rejects a
5748        // `:state-change` without the callback, so the canonical
5749        // authoring shape this positive control pins now includes the
5750        // runtime delivery hook (the `gen_server:code_change/3` analog
5751        // that the per-version script is invoked through during hot
5752        // upgrade per the upgrade.rs module doc "Composes with"
5753        // promise).
5754        c.behavior = Some(BehaviorSpec {
5755            on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
5756            ..Default::default()
5757        });
5758        c.upgrade_from = vec![
5759            UpgradeFromEntry {
5760                from: "0.1.0".into(),
5761                instructions: vec![
5762                    UpgradeInstruction::LoadModule {
5763                        module: "hello-rio".into(),
5764                    },
5765                    UpgradeInstruction::StateChange {
5766                        script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
5767                    },
5768                    UpgradeInstruction::SoftPurge {
5769                        module: "hello-rio-old".into(),
5770                    },
5771                    UpgradeInstruction::Purge {
5772                        module: "hello-rio-oldest".into(),
5773                    },
5774                ],
5775            },
5776            UpgradeFromEntry {
5777                from: "0.0.9".into(),
5778                instructions: vec![UpgradeInstruction::Restart],
5779            },
5780        ];
5781        let manifest_clone = manifest.clone();
5782        let svc_clone = svc.clone();
5783        let migration_clone = migration.clone();
5784        let on_state_change_clone = on_state_change.clone();
5785        let layout = StandardLayout::new().with_path_exists(move |p| {
5786            p == manifest_clone
5787                || p == svc_clone
5788                || p == migration_clone
5789                || p == on_state_change_clone
5790        });
5791        layout.verify(&c, &root).unwrap();
5792    }
5793
5794    #[test]
5795    fn upgrade_from_restart_mixed_surfaces_as_upgrade_violation() {
5796        // Wiring pin: the within-entry `(:restart)`-exclusivity gate
5797        // (`UpgradeFromEntry::validate_restart_exclusive`) lands on
5798        // the same `LayoutError::UpgradeViolation` axis the per-entry
5799        // shape gate (26da2c7), the cross-entry duplicate-`:from`
5800        // gate (7c6aef2), and the cross-slot `:from < :versao`
5801        // precedence gate (de7ab1a) already do. A caixa.lisp whose
5802        // `:upgrade-from` entry mixes `(:restart)` with a typed
5803        // instruction surfaces at `feira build` time naming the
5804        // offending caixa + the entry's `:from` rather than silently
5805        // passing into the wasm-operator with semantically dead code
5806        // in the operator's dispatch table. Mirrors
5807        // `upgrade_from_duplicate_surfaces_as_upgrade_violation` on
5808        // the peer cross-entry gate.
5809        use crate::{UpgradeFromEntry, UpgradeInstruction};
5810        let root = PathBuf::from("/tmp/x");
5811        let manifest = root.join("caixa.lisp");
5812        let svc = root.join("servicos/demo.computeunit.yaml");
5813        let mut c = caixa(CaixaKind::Servico);
5814        c.versao = "0.2.0".into();
5815        c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
5816        c.upgrade_from = vec![UpgradeFromEntry {
5817            from: "0.1.0".into(),
5818            instructions: vec![
5819                UpgradeInstruction::LoadModule {
5820                    module: "hello-rio".into(),
5821                },
5822                UpgradeInstruction::Restart,
5823            ],
5824        }];
5825        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest || p == svc);
5826        let err = layout.verify(&c, &root).unwrap_err();
5827        let LayoutError::UpgradeViolation { caixa, issue } = err else {
5828            panic!("expected LayoutError::UpgradeViolation for restart-mixed entry, got {err:?}");
5829        };
5830        assert_eq!(caixa, "demo");
5831        assert!(
5832            issue.contains("0.1.0"),
5833            "UpgradeViolation issue must name the offending entry's `:from` verbatim, got \
5834             {issue:?}"
5835        );
5836        assert!(
5837            issue.contains(crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART),
5838            "UpgradeViolation issue must name the `:restart` axis verbatim, got {issue:?}"
5839        );
5840        assert!(
5841            issue.contains(crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE),
5842            "UpgradeViolation issue must name the non-:restart peer instruction's lisp-form \
5843             verbatim, got {issue:?}"
5844        );
5845    }
5846
5847    #[test]
5848    fn upgrade_from_restart_duplicated_surfaces_as_upgrade_violation() {
5849        // Companion arm: the duplicate-`(:restart)` mode of
5850        // `RestartNotExclusive` (no typed peers, just multiple
5851        // `Restart` variants) surfaces through the same wiring as the
5852        // mixed-with-typed mode above. The diagnostic still names the
5853        // offending entry's `:from` verbatim even when `other_kinds`
5854        // is empty.
5855        use crate::{UpgradeFromEntry, UpgradeInstruction};
5856        let root = PathBuf::from("/tmp/x");
5857        let manifest = root.join("caixa.lisp");
5858        let svc = root.join("servicos/demo.computeunit.yaml");
5859        let mut c = caixa(CaixaKind::Servico);
5860        c.versao = "0.2.0".into();
5861        c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
5862        c.upgrade_from = vec![UpgradeFromEntry {
5863            from: "0.1.0".into(),
5864            instructions: vec![UpgradeInstruction::Restart, UpgradeInstruction::Restart],
5865        }];
5866        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest || p == svc);
5867        let err = layout.verify(&c, &root).unwrap_err();
5868        let LayoutError::UpgradeViolation { caixa, issue } = err else {
5869            panic!(
5870                "expected LayoutError::UpgradeViolation for duplicate-restart entry, got \
5871                 {err:?}"
5872            );
5873        };
5874        assert_eq!(caixa, "demo");
5875        assert!(
5876            issue.contains("0.1.0"),
5877            "UpgradeViolation issue must name the offending entry's `:from` verbatim, got \
5878             {issue:?}"
5879        );
5880        assert!(
5881            issue.contains("(:restart)")
5882                || issue.contains(crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART),
5883            "UpgradeViolation issue must name the `:restart` axis verbatim, got {issue:?}"
5884        );
5885    }
5886
5887    #[test]
5888    fn upgrade_from_invalid_surfaces_as_layout_violation() {
5889        // The `:from` semver gate (`UpgradeError::FromInvalid`)
5890        // was likewise unreachable before this wiring landed — a
5891        // typo-shaped `:from "v0.1.0"` (git-tag-shape leaking into
5892        // the semver slot) silently passed `feira build` and
5893        // surfaced only when the operator's hot-upgrade decision
5894        // engine tried to match against the version key it couldn't
5895        // parse. Now wired through `UpgradeViolation` with the
5896        // peer-shaped `{ from, reason }` payload — the
5897        // parser-shaped `reason` flows through `Display` so the
5898        // wrapped issue string carries both the offending value
5899        // *and* the SemVer-2 parser's wording (peer with the
5900        // `VersaoInvalid` / `MembroVersaoInvalid` envelopes on the
5901        // sibling SemVer-2 axes).
5902        use crate::{UpgradeFromEntry, UpgradeInstruction};
5903        use std::path::PathBuf;
5904        let root = PathBuf::from("/tmp/x");
5905        let manifest = root.join("caixa.lisp");
5906        let svc = root.join("servicos/demo.computeunit.yaml");
5907        let mut c = caixa(CaixaKind::Servico);
5908        c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
5909        c.upgrade_from = vec![UpgradeFromEntry {
5910            from: "v0.1.0".into(), // git-tag-shape, not semver
5911            instructions: vec![UpgradeInstruction::Restart],
5912        }];
5913        let manifest_clone = manifest.clone();
5914        let svc_clone = svc.clone();
5915        let layout =
5916            StandardLayout::new().with_path_exists(move |p| p == manifest_clone || p == svc_clone);
5917        let err = layout.verify(&c, &root).unwrap_err();
5918        let LayoutError::UpgradeViolation { caixa, issue } = err else {
5919            panic!("expected UpgradeViolation, got {err:?}");
5920        };
5921        assert_eq!(caixa, "demo");
5922        assert!(
5923            issue.contains("v0.1.0"),
5924            "UpgradeViolation issue must name the offending :from value verbatim, got {issue:?}"
5925        );
5926        assert!(
5927            issue.contains(":from"),
5928            "UpgradeViolation issue must name the :from slot verbatim, got {issue:?}"
5929        );
5930        // Pin the parser-shaped reason flow-through: the renamed
5931        // `FromInvalid { from, reason }` carries the SemVer-2 parser's
5932        // wording verbatim, and the [`UpgradeError`] Display routes it
5933        // into the wrapped `issue` string so the layout envelope
5934        // surfaces both the offending value *and* the parser's
5935        // diagnosis. Mirrors the peer flow-through on
5936        // `ManifestError::VersaoInvalid` (top-level `:versao`) and
5937        // `AplicacaoError::MembroVersaoInvalid` (`:membros :versao`).
5938        assert!(
5939            issue.contains("SemVer-2"),
5940            "UpgradeViolation issue must carry the parser-shaped reason (\"SemVer-2\"), got {issue:?}"
5941        );
5942    }
5943
5944    #[test]
5945    fn missing_lib_gate_routes_through_kind_requires_lib_and_caixa_nome() {
5946        // Fail-before-pass-after pin on the two-part converge landed
5947        // at layout.rs:844-847:
5948        //   (a) `caixa.kind().is_biblioteca()` →
5949        //       `caixa.kind().requires_lib()` — routes the biblioteca
5950        //       required-slot gate onto the same `requires_*()`
5951        //       predicate family the three sibling required-slot
5952        //       gates (`requires_exe()` at :856, `requires_servicos()`
5953        //       at :860, `requires_ci()` at :874) already key off.
5954        //       All four gates in the block now share one convention;
5955        //       a future kind that gains its own required-slot gate
5956        //       (an M4/M5 typed arm the CAIXA-SDLC §I six-kind roster
5957        //       may grow) reaches for the same predicate family and
5958        //       inherits the accessor discipline for free.
5959        //   (b) raw `caixa.nome` → `caixa.nome()` — routes the
5960        //       `expected` path composition through the typed
5961        //       [`crate::Caixa::nome`] accessor, closing the last
5962        //       unlifted raw `caixa.nome` production field-access
5963        //       site in `caixa-core/src/layout.rs` (every peer
5964        //       diagnostic in the file already routes through
5965        //       `caixa.nome().to_string()`).
5966        //
5967        // The behavioral pin: for a Biblioteca kind with no fallback
5968        // `lib/<nome>.lisp` file, MissingLib fires and its `expected`
5969        // path composes through `Caixa::nome()`; for every other
5970        // kind, MissingLib does NOT fire (the gate short-circuits on
5971        // kinds where `requires_lib()` returns false), even when the
5972        // fallback file is likewise absent. A future regression that
5973        // reroutes the gate off `requires_lib()` (e.g. onto
5974        // `is_biblioteca()` again, or onto a hand-authored
5975        // `matches!(caixa.kind(), CaixaKind::Biblioteca)`) that
5976        // *happens* to agree byte-for-byte on today's arm-set trips
5977        // this test the moment a future kind's `requires_lib()`
5978        // returns true for a non-`Biblioteca` arm (or the sibling
5979        // required-slot gates diverge from the same convention).
5980        let root = PathBuf::from("/tmp/x");
5981        let manifest = root.join("caixa.lisp");
5982        let manifest_only = manifest.clone();
5983        let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_only);
5984
5985        // Biblioteca kind + no lib fallback → MissingLib fires with
5986        // the expected path composed through `Caixa::nome()`.
5987        let bib = caixa(CaixaKind::Biblioteca);
5988        assert!(
5989            bib.kind().requires_lib(),
5990            "requires_lib() must return true for Biblioteca — the four-required-\
5991             slot-gate family's routing depends on this arm's assignment"
5992        );
5993        let err = layout.verify(&bib, &root).unwrap_err();
5994        let LayoutError::MissingLib {
5995            caixa: cname,
5996            expected,
5997        } = err
5998        else {
5999            panic!("expected MissingLib for Biblioteca kind with no lib fallback, got {err:?}");
6000        };
6001        assert_eq!(
6002            cname,
6003            bib.nome(),
6004            "MissingLib `caixa:` carrier must byte-equal Caixa::nome()"
6005        );
6006        assert_eq!(
6007            expected,
6008            root.join(crate::render::LAYOUT_DIR_LIB)
6009                .join(format!("{}.lisp", bib.nome())),
6010            "MissingLib `expected:` path must compose through Caixa::nome() \
6011             verbatim — a raw-field-access regression would silently drift \
6012             the composed path on any future `:nome` axis extension \
6013             (namespace-qualified rewrite, per-cluster alias overlay)"
6014        );
6015
6016        // Non-Biblioteca kinds → the MissingLib gate short-circuits.
6017        // Different kinds fail on their own required-slot gate
6018        // (BinarioWithoutExe, ServicoWithoutServicos, MissingCi) or
6019        // on downstream M2/M3 invariants; none of them may surface as
6020        // MissingLib, because `requires_lib()` returns false for each.
6021        for kind in [
6022            CaixaKind::Binario,
6023            CaixaKind::Servico,
6024            CaixaKind::Supervisor,
6025            CaixaKind::Aplicacao,
6026            CaixaKind::Acao,
6027        ] {
6028            assert!(
6029                !kind.requires_lib(),
6030                "requires_lib() must return false for {kind:?} — the \
6031                 four-required-slot-gate family's arm assignment pins \
6032                 exactly one kind (Biblioteca) as the arm that requires \
6033                 a `lib/` entry"
6034            );
6035            let c = caixa(kind);
6036            let result = layout.verify(&c, &root);
6037            assert!(
6038                !matches!(result, Err(LayoutError::MissingLib { .. })),
6039                "MissingLib gate at layout.rs:844 must short-circuit for \
6040                 kinds where requires_lib() returns false; unexpectedly \
6041                 fired for {kind:?}: {result:?}"
6042            );
6043        }
6044    }
6045}