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