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