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