Skip to content

ADR-0027: Canonical Territory Skeleton

Accepted Cross-Project Universal

Date: 2026-05-27 · Amended: 2026-07-13 (Frontend Test Layout & Canonical Vitest Config — see §Amendment) · 2026-07-14 (Canonical Arch-Test Skeleton — see §Amendment)

Context

War-room doctrine prescribes patterns (Actions, DTOs, domain-driven frontend, audit logging) but not the directory layout that hosts them. Each territory has improvised its own skeleton:

  • kendobackend/app/ has 24 top-level directories (Actions/, Audit/, Broadcasting/, Console/, DataTransferObjects/, Enums/, Events/, Exceptions/, Features/, Helpers/, Http/, Jobs/, Listeners/, Mail/, Mcp/, Models/, Policies/, Providers/, Rules/, Services/, Tenancy/, …). Tests at tests/Arch/. Frontend src/apps/{central,mcp,tenant}/ + src/shared/.
  • brick-inventory-orchestratorbackend/app/ has 13 top-level directories (Actions/, Console/, Contracts/, DataTransferObjects/, Enums/, Exceptions/, Http/, Jobs/, Mail/, Models/, Policies/, Providers/, Services/). Tests at tests/Architecture/. Frontend src/apps/{admin,families,showcase}/ + src/shared/.
  • ismsbackend/app/ has 3 directories (Http/, Models/, Providers/) — Laravel stock scaffold from slice A (2026-05-26). Tests at tests/Architecture/. Frontend src/{pages,router}/ flat — no apps/ split.

The shape converges on backend/ + frontend/ and apps/ + shared/ on the frontend side. The divergences are real:

  • Cargo cult risk — kendo's 24 dirs include territory-specific concerns (Tenancy/ for multi-tenancy, Mcp/ for its MCP server, Broadcasting//Events//Listeners/ for its real-time stack, Agent/ for AI-specific work, PHPStan//Rules/ for its self-published custom rules). Adopting the full kendo set into every territory would create twenty empty directories that pretend to be doctrine.
  • Naming drifttests/Arch/ (kendo) vs tests/Architecture/ (BIO + ISMS). Both work; the inconsistency is friction at zero benefit.
  • Where DTO conventions live — ADR-0020 split is App\DataTransferObjects\Input\<Domain>\* / App\DataTransferObjects\Result\<Domain>\*. Both kendo and BIO host them under DataTransferObjects/ but the in-tree shape is convention-by-grep, not directory-explicit.

The problem for AI agents (and for "apply everywhere")

Without an explicit canonical skeleton, soldiers reaching for "where do I put this" pattern-match the territory's existing tree — and accumulate the territory's idiosyncratic dirs. Greenfield work (ISMS) reverts to Laravel-stock layout. Cross-territory migrations (e.g. promoting a service from one territory to another) hit naming friction.

The Commander has stated the goal: codify a baseline that ISMS can scaffold to today, and that the other territories can converge toward as natural opportunities arise. This ADR is the doctrine for that baseline.

Decision

The canonical territory skeleton is BIO's structure, plus Audit/, with the kendo-specific dirs explicitly out of the baseline (territories may still add them when warranted — they are not banned, only not mandatory).

Backend — app/

backend/app/
├── Actions/                       final readonly + execute() (ADR-0011)
├── Audit/                         hash-chain audit infrastructure (ADR-0001)
├── Console/                       artisan commands
├── Contracts/                     service interfaces
├── DataTransferObjects/
│   ├── Input/                     Action inputs (ADR-0020)
│   └── Result/                    Action returns (ADR-0020)
├── Enums/
├── Exceptions/
├── Http/
│   ├── Controllers/
│   ├── Middleware/
│   ├── Requests/                  FormRequests with toDto() (ADR-0012)
│   └── Resources/                 ResourceData subclasses (ADR-0009)
├── Jobs/
├── Mail/
├── Models/
├── Policies/
├── Providers/
└── Services/

Not in the baseline (allowed when warranted; not required from day 1):

DirectoryWhen to add
Agent/, Mcp/Territory ships an AI/MCP surface
Broadcasting/, Events/, Listeners/Real-time stack adopted
Features/Pennant-style feature-flag namespace established
Helpers/Standalone helper namespace beyond app/Support/
PHPStan/, Rules/Territory publishes custom static-analysis rules
Tenancy/Multi-tenancy beyond the package-default surface (ADR-0008)
ValueObjects/A type genuinely earns first-class VO treatment (encapsulated invariants, equality semantics, used across multiple Actions). ublgenie has the directory; kendo and BIO route the same concerns through DTOs without a separate VO namespace. Add when a real VO lands, not pre-emptively.

Backend — tests/

backend/tests/
├── Architecture/                  Pest arch tests
├── Feature/
├── Unit/
├── Pest.php
└── TestCase.php

Tests directory naming is Architecture/ (long form), not Arch/. Self-documenting; converges with BIO and ISMS; kendo carries the short form as a known deviation to converge over time, not a forcing function for an immediate rename.

Frontend — src/

frontend/src/
├── apps/
│   └── <app>/                     one entry-point per audience/role
│       ├── App.vue
│       ├── main.ts
│       ├── index.html
│       ├── router/
│       ├── domains/               vertical slices (ADR-0014)
│       │   └── <feature>/
│       │       ├── components/
│       │       └── pages/
│       └── services/              app-local services
└── shared/                        cross-app scaffolding
    ├── components/
    ├── composables/
    ├── errors/
    ├── helpers/
    ├── services/
    └── types/

One apps/<name>/ per audience or auth boundary, not per feature. The split decision is "who logs in here" (anonymous public, authenticated tenant, ops/admin, role-scoped), not "what does this page do." Features live under domains/ inside an app.

Additional optional dirs at the app level (modals/, stores/, types/, middleware/, enums/, constants/, __mocks__/) are allowed but not mandatory — they emerge from the territory's domain shape.

Frontend specs live in frontend/tests/ (a sibling of src/, not under it) and the canonical Vitest runner config — see the 2026-07-13 amendment.

Trip-point arch tests (Level 1 enforcement)

Each territory's tests/Architecture/ must include — from day 1 of skeleton adoption — these arch tests:

TestAsserts
ActionPatternTestApp\Actions\* are final readonly, single execute(), no facades (ADR-0011)
DtoSplitTestApp\DataTransferObjects\Input\* do not depend on App\Models\*; Result/* allowed (ADR-0020)
EnumPatternTestApp\Enums\* are PHP backed enums
ControllerCurrentUserTestControllers use #[CurrentUser] container attribute, not Auth::user() / Request::user() / auth()->user() (war-room principle 9; companion to EnforceCurrentUserAttributeRule in phpstan-warroom-rules)
ServiceFinalTestApp\Services\* are final

When a baseline dir holds no production class yet, the arch test passes vacuously — that is the intended state. The tests exist as trip points: the moment the first class lands in the dir, structural compliance is checked at CI time.

Options Considered

OptionVerdictReason
BIO-aligned baseline + Audit/ (chosen)AcceptedLean enough to be defensible everywhere; adds the one dir ISMS demonstrably needs (compliance audit infrastructure under the highest compliance burden in the war room). Kendo-specific dirs explicitly allowed but not required.
BIO + Audit/ + ValueObjects/RejectedNeither model territory (kendo, BIO) has ValueObjects/; only ublgenie does. Scaffolding the directory empty would be exactly the "dir pretending to be doctrine" failure mode this ADR warns against in the §Risks section. When a real value object lands, the territory adds the directory then — zero scaffolding cost deferred to the moment of actual need.
Strict BIO baseline (13 dirs)RejectedForces Audit/ to be invented per-territory; ISMS would need to add it immediately under the highest compliance burden in the war room. Better to codify once.
Full kendo baseline (24 dirs)RejectedCargo cult — would force Tenancy/, Mcp/, Broadcasting/, PHPStan/ directories into territories that have no such concern. Empty dirs masquerading as doctrine.
Per-territory freedom (status quo)RejectedThe Commander explicitly requested a canonical that "applies everywhere eventually." Freedom is what produced the kendo/BIO/ISMS three-way divergence in the first place.
Generate skeleton via a Composer scaffolding packageRejectedPremature tooling. The skeleton is ~20 directories with .gitkeep files plus 5 arch tests — well below the scaffolding-package break-even. Revisit if a fifth Laravel territory needs adoption.

Consequences

Positive

  • One layout to learn. Cross-territory navigation, agent dispatches, and rotating maintainers all benefit from convergent shape.
  • Doctrine becomes scaffoldable. A new Laravel territory starts at the canonical baseline by copy-paste; ADR-0027 is its checklist.
  • Trip-point arch tests catch the first violation, not the fifth. Empty dirs with passing arch tests are cheap and outlive the human who set them up.
  • DTO split (ADR-0020) gets a physical home that's identical everywhere. No more grep-to-find-the-convention.

Negative

  • kendo carries an Arch/Architecture/ deviation indefinitely until a future rename mission. Renaming costs CI history continuity (job names, coverage reports) — acceptable to defer.
  • Existing territories will not be retrofit in one campaign. Each territory adopts the baseline opportunistically (when a Structural Reform mission touches the relevant layer). Convergence is on order of months, not weeks.
  • Some baseline dirs will sit empty for a long time in some territories (e.g. Mail/ in ISMS until the first notification ships). The cost is one .gitkeep per dir — accepted in exchange for the trip-point arch test being live.

Risks

  • Risk: territories accumulate the baseline + their pre-existing extras, producing trees larger than either kendo or BIO today. — Mitigation: the "Not in the baseline" table is the explicit exclusion list. The Adjutant's quarterly registry sweep flags territories whose app/ tree adds dirs outside both the baseline and the allowed extras.
  • Risk: the worker / colleague two-app split for ISMS proves to be the wrong cleavage (e.g. colleagues turn out to need write actions later). — Mitigation: Vite multi-app config is reversible. Merging two apps back into one is a one-day Engineer dispatch; the cost of being wrong is bounded.

Enforcement

WhatMechanismScope
Action pattern (final readonly + single execute + no facades)ActionPatternTest (Pest arch)App\Actions\*
DTO split (Input not depending on Models)DtoSplitTest (Pest arch)App\DataTransferObjects\Input\*
Enum shape (backed enums)EnumPatternTest (Pest arch)App\Enums\*
Controller user resolutionControllerCurrentUserTest (Pest arch) + EnforceCurrentUserAttributeRule (PHPStan)App\Http\Controllers\*
Service finalityServiceFinalTest (Pest arch)App\Services\*
Baseline dir presenceAdjutant registry sweep (Level 6 passive monitoring)per-territory app/ and frontend/src/ tree

Directory presence itself is not gated at CI — the trip-point arch tests handle the actual structural compliance. A territory with a missing baseline dir is surfaced by Adjutant on its next registry pass.

Implementation

TerritoryStateNotes
ismsIn ProgressFirst territory to adopt the canonical at scaffold time (greenfield; this ADR is the trigger for the scaffold).
brick-inventory-orchestratorPartialBackend at 13 of 14 baseline dirs (missing Audit/). Frontend already at apps/ + shared/. Tests already at Architecture/. Adopt Audit/ when first audit-trail work lands.
kendoPartialBackend has all baseline dirs plus extras (allowed). Tests at Arch/ (deviation; rename on next Architecture-test mission). Frontend already at apps/ + shared/.
ublgeniePartialBackend has the baseline dirs plus ValueObjects/ (allowed-when-warranted example). Adopt remaining baseline alignment on next Structural Reform touching app/.
emmieNot StartedAdopt during next Structural Reform touching app/ or frontend/src/ root.
entreezuilCompleteFull adoption campaign 2026-06-26, merged 2026-06-30 (campaigns/entreezuil/2026-06-25-canonical-skeleton-restructure-adr0027.md). R1 skeleton (backend/+frontend/) merged (#249); R2 apps-split admin/kiosk landed via #270 (#262 superseded/closed); R3 doctrine (#263); R4 backend baseline + backend/tests/Architecture/ rename + 5 mandated arch tests (#264) — all on development. Allowed extras: Rules/, Support/, Http/Notification/. Two ratified exemptions: CMSmsUser non-final (ServiceFinalTest, tracked WR-0229).
codebookNot StartedAdopt during next Structural Reform touching app/ or frontend/src/ root.
daymate-apiNot StartedLaravel 9 / PHP 8.2 — confirm canonical compatibility with older Laravel scaffold defaults before adopting.

Amendment (2026-07-13): Frontend Test Layout & Canonical Vitest Config

The original ADR codified the backend tests/ layout (Architecture//Feature//Unit/) and the frontend src/ tree, but left the frontend test side unspecified — where Vue specs live, and what runs them. A 2026-07-13 fleet survey found the structure already convergent (Vitest 4.x everywhere, .spec.ts under tests/, no test globals) but two substantive knobs — DOM environment and coverage provider — split near-evenly, and the config shape diverged three ways (standalone / folded-into-vite.config / mergeConfig). This amendment ratifies the convergent baseline and gives it a canonical artifact. Reasoning: the survey + fork resolution in this session; the artifact lives at templates/vitest.config.ts with full override commentary in templates/README.md.

Frontend — tests/

frontend/tests/
└── **/*.spec.ts                   Vue specs, mirroring the src/ tree they cover

Specs are a sibling of src/ (frontend/tests/), not nested under it — parallel to the backend tests/ sibling of app/. The tests/js/** layout (kendo's) is a subset of tests/** and resolves identically under the canonical include glob. .spec.ts is the naming (isms's .test.ts is the lone outlier, to converge opportunistically).

Canonical runner config — templates/vitest.config.ts

A base you extend, not a copy-paste drop-in (unlike oxlintrc.json): plugins and resolve.alias are inherently territory-specific — each repo mirrors its own vite.config + tsconfig paths. What the canonical fixes is the four invariants every app territory keeps:

InvariantValueRationale
Standalone configvitest.config.ts imports from vitest/config; vite.config stays build-onlyProduction build never pulls the test runner into the bundle (town-crier's rationale). mergeConfig(viteConfig, …) (scripthub) is an allowed deviation that trades bundle-purity for zero alias duplication.
DOM environmenthappy-domLighter/faster than jsdom; fleet-leading (17 vs 11 at survey).
Coverage providerv8Native, zero-instrumentation; fleet-leading (19 vs 17).
Test globalsoff — explicit import {describe, it, expect, vi}Keeps the strict tsconfig free of an injected vitest/globals type surface.

Documented territory overrides expected (full detail in templates/README.md): plugins/alias (always); environment: 'jsdom' (specs needing jsdom's fuller browser API); coverage.provider: 'istanbul' (a territory at a hard 100% coverage gate — wijs — buys istanbul's more accurate branch accounting); coverage.include = resources/js/** for Laravel-inlined frontends vs src/** for standalone; and coverage.thresholds (the base ships none — territories that gate add the floor and document it).

Out of scope: fs-packages (per-package config + vitest.workspace.ts — a library-monorepo shape, not app shape) and the-laboratory gadgets (sovereign posture). The canonical targets app territories (Vue frontends).

Enforcement reality

This amendment is Level 4 (territory doctrine) + a template, not Level 1. The four invariants are conventions a copying maintainer or soldier reads — nothing at CI catches a territory that drifts to jsdom or istanbul against the baseline. That gap is deliberate for now: the two overrides are legitimate per-territory choices, so a hard gate would fight the documented exception list. The path to teeth is a /canon-check axis that flags off-baseline environment/provider/globals as a drift row (visibility, not a block) — noted as a follow-up, not built here. Until then, convergence is opportunistic (a Structural Reform or Fortification mission touching a territory's frontend test setup adopts the base) and surfaced by the Adjutant registry sweep alongside the backend-skeleton presence check.

Implementation

ScopeStateNotes
Fleet baselineEstablishedVitest 4.x, .spec.ts under tests/, no globals — already universal at 2026-07-13 survey.
happy-dom / v8PartialFleet-leading but not universal; territories on jsdom/istanbul either override deliberately (wijs istanbul) or converge opportunistically.
templates/vitest.config.tsCompleteCanonical base authored 2026-07-13; README override commentary shipped alongside.

Amendment (2026-07-14): Canonical Arch-Test Skeleton

The original ADR mandated five trip-point arch tests ("§Trip-point arch tests (Level 1 enforcement)": ActionPatternTest, DtoSplitTest, EnumPatternTest, ControllerCurrentUserTest, ServiceFinalTest) and named them in §Enforcement — but shipped no canonical file. The result is the failure mode this ADR warns about applied to itself: every adopting territory re-derived the five from scratch, and they drifted — three directory names (tests/Architecture/, kendo's tests/Arch/, wijs's tests/Unit/Architecture/), divergent BypassFinals handling, and per-territory reinvention of the file-scanning helper. A mandated test with no artifact is Level-4 doctrine wearing a Level-1 badge. This amendment gives the mandate its artifact.

The canonical artifact

templates/tests-architecture/ — a base to adapt, not a byte-identical drop-in (the App\* namespaces are universal; the BypassFinals whitelist, external-service roster, and identity block are territory-shaped). It realizes the five mandated tests as concrete, proven files (extracted from town-crier's tests/Arch/, the cleanest full-ladder realization in the fleet), plus the shared Support/ArchTestHelper.php (copy verbatim) and a Pest.php.fragment. Full override commentary lives in templates/README.md.

A whole Pest.php is deliberately NOT shipped — a whole-file Pest template was rejected as cargo-cult (war-room memory reference_bypass_finals_arch_scoping); only the load-bearing BypassFinals suite-wiring fragment is templated.

New: the 6th mandated arch test (conditional) — ExternalHttpTimeoutTest

The original five did not cover Principle #8 (explicit timeouts on external HTTP), whose Level-1 enforcement is an arch test that greps every outbound-HTTP class for a per-call ->timeout(). This amendment adds ExternalHttpTimeoutTest as a conditionally-mandated sixth: required from the day a territory makes its first outbound HTTP call, omitted (not shipped empty) before then. town-crier's ExternalHttpTimeoutTest (its first outbound-HTTP gate, TC-0014) is the exemplar.

The BypassFinals invariant (load-bearing)

The single most important thing this skeleton encodes: the Architecture suite must never enable BypassFinals. BypassFinals strips the final keyword at autoload so unit tests can mock final classes — but if it is active when the arch suite loads, toBeFinal() silently passes on a codebase with zero final keywords. The gate goes blind, not red. The discipline: isolate the arch suite in its own ->in('Architecture') binding that never calls enable() (robust — unlike the argv-sniffing guard, which a bare php artisan test defeats), always setWhitelist([...]) your own namespaces (a bare enable() FATALs on vendor readonly), and optionally plant a portable canary (a genuinely-final fixture asserted isFinal()) so a future accidental process-wide enable turns a canary red instead of the gate green.

Enforcement reality

Unlike the vitest amendment, this is Level 1 — the arch tests run in CI (composer test:arch, a backend-lint matrix gate in the canonical CI base) and fail the required ci-passed rollup on violation. The residual Level-6 gap is presence: nothing at CI catches a territory that never adopts the skeleton at all (directory presence is not itself gated, per the original §Enforcement). That stays an Adjutant registry-sweep + /canon-check arch-axis concern (both already check arch-dir presence). The trip-point design means an adopted-but-empty skeleton is correct and cheap — the tests go live the moment the first class lands.

Implementation

ScopeStateNotes
templates/tests-architecture/CompleteCanonical skeleton authored 2026-07-14 (6 test files + Support/ArchTestHelper.php + Pest.php.fragment); README override commentary shipped alongside.
town-crierComplete (source)The extraction source — full ladder, tests/Arch/.
entreezuilComplete5 mandated tests + tests/Architecture/ rename landed in the ADR-0027 R4 campaign (#264, 2026-06-30); the CMSmsUser ServiceFinalTest exemption is the ratified-exemption exemplar.
ublgenie / codebook / wijsPartialCarry arch tests (tests/Arch/, tests/Unit/Architecture/) — converge on the canonical file set + directory name on the next arch-test mission.
kendoPartialtests/Arch/ short-form deviation (per the original ADR); converge opportunistically.
emmie / ismsOpportunisticAdopt the skeleton on the next Structural Reform touching app/.

Architecture documentation for contributors and collaborators.