# The conventions S0 set, and the guards that hold them

What every slice after S0 builds on (plan CORE §2 and §3.15). Each rule has a guard test that fails the build, and
each guard has a self-test that plants an offence, so a guard that stops seeing fails too (handbook 10.6).

| Rule | How to follow it | The guard |
|---|---|---|
| **Tables are singular** (Decision 4) | `Schema::create('lookup_value', …)`. Laravel's own tables keep their names: `migrations`, `jobs`, `job_batches`, `failed_jobs`, `cache`, `cache_locks`, `sessions`, `password_reset_tokens` | `Guards/SchemaGuardTest` reads the migrated database |
| **Every time is `timestamptz`** (Decision 5) | `timestampsTz()`, `softDeletesTz()`, `timestampTz()`. The app runs in UTC | `Guards/SchemaGuardTest` |
| **A record reachable by URL has a ULID `public_id`** (Decision 10) | `$table->publicId()` in the migration; `use HasPublicId` on the model. The route key is `public_id`; the bigint `id` is hidden, so it reaches no URL, prop or API response | `Guards/PublicIdGuardTest`: every bound model, every model whose table has the column, no digits-only route parameter |
| **Every word is a translation key** (§11) | `__('group.key')` in PHP, `$t('group.key')` in templates, `trans('group.key')` in scripts. English in `lang/en/<group>.php`; laravel-vue-i18n builds `lang/php_en.json` for Vue (CORE-D10). Commands speak English to the operator and are exempt | `Guards/NoLiteralTextTest`: Vue text and labels, dialog and toast words, PHP flashes, aborts and field errors, Blade views |
| **Dates, numbers and money through one formatter; a sentence is one key** (S9) | `Format::date()`, `time()`, `dateTime()`, `number()`, `money()` in PHP, `formatDate()` and its siblings from `lib/format.ts` in pages; never `->format('d M')`, `number_format`, `toLocaleString`, `new Intl.` or a typed currency. A sentence with a value in it is one key with `:placeholders`, never words joined in code. Layout is logical (`ms`/`me`, `start`/`end`, `text-start`, `inset-inline-*`, `slide-in-from-start`). See `languages.md` | `Guards/LanguageGuardTest`: one formatter, every key exists in `lang/en`, no joined words, logical layout, switched-on languages complete; `Languages/PseudoLanguageTest`: every page in a test language with no English left |
| **An endpoint is the operation, adapted** (S12) | `routes/api.php` (`/api/v1`): a Form Request authorising by permission, the action or query the page uses, a `JsonResource` (public ids, ISO 8601 UTC, no secret); a consequential POST `->middleware('idempotent')`; a request test naming its path. A token's workspace is its membership's and its abilities are permission keys within its maker's. See `api.md` | `Guards/ApiGuardTest`, `Guards/ResourceGuardTest`, `Tenancy/TenantSweepTest` (API), `Api/*` |
| **API errors are problem+json** (RFC 9457) | Throw as usual: `App\Exceptions\Problem` turns validation, 401, 403, 404, 429 and the rest into `type`, `title`, `status`, `detail`, `instance` (+ `errors`) for `/api/*` and JSON requests. Pages get `pages/Error.vue` in the brand | `Api/ProblemJsonTest` |
| **Each fact once** (handbook 5.7–5.9, plan SN) | A child of a workspace's row keys `(parent_id, tenant_id)` to its parent's `(id, tenant_id)`; a list is rows of a child table, never an array or ids in jsonb; a jsonb column is classed in `tests/Support/normal-form.php`; a value worked out from others is generated, checked or declared with its one writer; an exception says why. See `normal-form.md` | `Guards/NormalFormGuardTest` (seven rules, read from the catalogue; a baseline that only shrinks) |
| **A workspace's data stays in it** (handbook 1.2–1.7) | `use BelongsToTenant` (and a factory) on every model with a `tenant_id`; every uniqueness includes `tenant_id`; queued classes `implements TenantAware` with `CarriesTenant`; scheduled commands loop with `CurrentTenant::each()`. See `tenancy.md` | `Tenancy/TenantSweepTest` (the sweep), `Guards/TenantGuardTest` |
| **Permissions, never roles** (house rule) | Every signed-in route names its permission (`->middleware('can:key')`) or is listed in `GATE_OPEN` with why; every policy asks a permission; roles are rows changed only through `RoleEditor` (the ceiling, own access). Never disable a control by permission: don't draw it. See `roles.md` | `Guards/PermissionGateTest`, `Guards/NoRoleNamesTest`, `Guards/AccessVisibilityTest`, `Access/RoleTest` |
| **The Service sees no workspace without a lens** (S3) | Never check "is this the Service" to grant data: the gate does it, and the scope keeps workspace records closed. Platform pages need a `platform` permission; a list reading every workspace declares `crossWorkspace()`. Name the Service in logs only through `Context::actorName()` (":company support"). See `service.md` | `Service/LensTest`, `Service/ServiceIdentityTest`, `Guards/ListGuardTest` |
| **Every list through the list engine** | A `ListDefinition` in `Lists::ALL`, drawn by `DataList.vue`. A list behind a platform permission has no saved views (they belong to a workspace). See `lists.md` | `Guards/ListGuardTest`, `Lists/ListEngineTest`, and the tenant sweep reads every list |
| **The logs are append-only** | Model changes: `use Audited` (and implement `OwenIt\Auditing\Contracts\Auditable`). Other events: `AuditTrail::record()`. Sign-in events: `AuthEventRecorder::record()`. A new sign-in event is added to `AuthEvents::ALL` (the one list: the `auth_event` values in Input Fields are made from it, S5) and widens `auth_event`'s CHECK in a migration (plan SN D8). Only the purge deletes, inside `AppendOnly::purging()`; only the merge moves a row's key, inside `AppendOnly::repointing()` (plan MC) | `Audit/AuditLogTest`: UPDATE and DELETE refused by the trigger; secrets never logged; every allowed event labelled; the CHECK equal to `AuthEvents::ALL` |
| **Every log is purged by date; every export is audited** (S10) | A new append-only log is a `TableLog` in `LogPurger::logs()` with its age setting `log.<key>_days` (bounds on the entry) and its name in `log_retention`; the night's `logs:purge --due` takes it. A new audit event gets its label in `lang/en/audit.php` (`event.*`), a new audited model its `subject.*` and its name in `AuditWords::name`. An export writes an audit row (`export.requested`, `list.exported`); a file made for someone is theirs alone, until it expires. See `logs.md` | `Guards/LogGuardTest`: every append-only table purged or kept for good, each age a setting with bounds, the schedule, every event and record labelled; `Log/PurgeTest`, `Log/ExportTest` |
| **No disk access outside the vault** (S11) | Keep a file with `FileStore::make` (made by the app) or `FileIntake::accept` (an upload), for a record whose policy has `download()`; open it with a link to `/file/{public_id}` (the checked route); never `Storage::` or `->disk(` outside `app/Support/Files`, never a path or a key in a prop. See `files.md` | `Guards/VaultGuardTest`, `Tenancy/TenantSweepTest` (downloads), `Files/*` |
| **An import's check writes nothing; its rows go through the form's own doors** (S15) | A kind registers in `ImportKinds::ALL` with its permission, its two lists and its words. `check()` reads only; `write()` uses the form's rules and action (people: `PersonRules`, `AddPerson`, `RoleEditor`), as the importer, under the import's audit tag; `changed()` decides from that tag what undo must leave. See `imports.md` | `Imports/ImportTest` (every table fingerprinted; form and import refuse the same rows; undo), `Guards/ImportGuardTest` |
| **Search finds only what its finder could open** (S16) | A kind registers in `SearchKinds::ALL` naming its permission (or each item's). A record kind finds only its list's rows (`ListDefinition::records()`) through Scout on an `Indexed` model, whose generated, GIN-indexed `search_vector` and `search_text` are `$hidden`; a short list is scored by `Scorer::rank`, never in PHP. A workspace's permission needs a workspace. See `search.md` | `Search/SearchTest` (every hit opened as its finder), `Tenancy/TenantSweepTest`, `Guards/SearchGuardTest` |
| **A person's data has a part; a log seals or empties it** (S17) | A table pointing at a person, account or place, or a column copying a name, contact or device, is a `PersonalDataPart`'s (`covers()`; its copy and its erasure) or listed as not personal. The audit log seals a person's words with their `person_key` (`PersonalFields`; read through the `Sealed` cast); another log names its personal columns in its trigger, which only an erasure may empty. See `privacy.md` | `Privacy/ErasureTest` (every table read after an erasure), `Privacy/RequestTest`, `Guards/PersonalDataGuardTest` |
| **A getting-started step knows it's done from real records** (S18) | A `SetupStep` in `SetupSteps::ALL` names its permission (never a role) and answers `done()` from what's really there; the first-run wizard saves through each form's own action (`UpdateWorkspace`, `UpdateRegion`, `RoleEditor`, `AddPerson`, `InvitePerson`). See `setup.md` | `Setup/SetupTest` (each step made through its form, then ticked) |
| **No class picks its channels; nobody hears of what they can't open** (S14) | An event names its `category()` and, when it is about a record not everyone may open, `canSee(User)`; its rules name the channels (`app`, `mail`, `digest`), each person narrows them. Never a `via()`. Its template's `in_app` words never carry a one-time secret. A new event gets a maker in `Notifications/SweepTest`. See `notifications.md` | `Guards/ChannelGuardTest`, `Notifications/SweepTest`, `Guards/MailGuardTest` |
| **Every readiness check proves each state; every deploy step is in `app:deploy`** (S13) | A new check is a method on `Readiness` with its states in `STATES`, its name and words in `lang/en/operations.php`, and a case for each state in `ReadinessTest`. A step every release needs goes in `config/operations.php` `deploy` (in order, safe twice, non-zero on failure). Queued work is counted on the operations page by itself (`JobMetrics`). See `operations.md` | `Operations/ReadinessTest` (the case list must equal `STATES`), `Operations/OperationsTest` (deploy order and stop, every step a command) |
| **A backup opens off the server only; a copy is renamed, never edited by hand** (S19) | The server holds the backup key's public half (`BACKUP_PUBLIC_KEY`); a step every release needs before it changes the database goes in `deploy/server/release.sh`. A new app is `git archive` of a release, then `php artisan app:rename <Short>`; a line that must name the starter pack (the retired list, a guard's fixture, a record of where an app started) carries `brand-guard: allow`, and nothing else may. A new foundation folder joins `Foundation::DIRS`. See `deployment.md`, `../BACKUP.md` | `Backup/BackupTest`, `Core/RenameTest` (this tree renamed leaves no trace), `Operations/ReadinessTest` |
| **The brand comes from the layer** | `Identity`, `BrandWords`, `useBrand()`, `--brand-*` (see `rebrand.md`) | `Brand/BrandGuardTest`, `Brand/BrandLayerTest` (runs `brand:check`) |
| **No option list in code** | Selects, radios and `Rule::in` read `LookupCategory::options()` / `rule()`; a list the code reads is in `Catalogue` with its words in `lang/en/lookup.php`. State-machine values and sort choices are code. See `input-fields.md` | `Guards/NoHardcodedListsTest` (PHP, Vue, scripts), `Guards/InputFieldGuardTest`, `lookups:check` |
| **No mail outside the engine** | `Notify::event(new YourEvent(...))`; the event is in `EventCatalogue` with its words and templates, and the engine sends one logged row per mail. See `mail.md` | `Guards/NoHardcodedMailTest`, `Guards/MailGuardTest` |
| **Named limiters only** | A limiter in `App\Support\Settings\Limiters` (its figure a setting) and `throttle:<name>`; never `throttle:6,1`. Every sign-in write has one | `Guards/NamedLimiterTest`: no plain throttle, only catalogued limiters, every sign-in write limited; `Guards/SettingGuardTest`: every limiter's figure a setting |
| **The password again before a sensitive change** (S8) | `->middleware('password.confirm')` on the route (`RequirePassword`): a page's button asks in the shell's dialog and repeats; the window is a setting. See `sign-in.md` | `Auth/SessionHardeningTest`: the sensitive routes listed and asked |
| **One password rule** (S8) | `Password::default()` wherever a password is set; never `min:` or `Password::min()` of its own. An existing account's password changes only through `PasswordChange::apply()`, which ends its other sessions (a new account's first password is `User::create`'s). See `sign-in.md` | `Guards/PasswordRuleTest` |
| **A figure the code reads is a setting** (S6) | An entry in `App\Support\Settings\Catalogue`, its standard in `config/setting.php`, read with `Setting::int('lens.reason_min')`; never `config('setting.…')`. A security control is the platform's only. See `settings.md` | `Guards/SettingGuardTest` |
| **No browser dialogs** | `confirmDialog`, `promptDialog`, `alertDialog`, `toast` from `resources/js/lib/dialog.ts` (SweetAlert2, themed) | `Guards/DialogGuardTest` |
| **No path on a developer's Mac** | An env var with an in-app default (`storage_path(...)`) | `Guards/NoMachinePathTest` |
| **No lazy loading** | `with()` what a list reads; refused outside production | `Guards/LazyLoadingTest` |
| **Access decides what is drawn** | The menu is `App\Support\Navigation\Nav`: an item names its permission and is not sent to someone without it; a group left empty is dropped | `Shell/ShellTest` (S4 adds the props guard) |

## The front end

- Inertia 3, Vue 3.5, TypeScript, Tailwind 4, shadcn-vue (`resources/js/components/ui`), Wayfinder, laravel-vue-i18n;
  Font Awesome 6 for the app's icons; Figtree and JetBrains Mono.
- A page renders its own shell: `layouts/AppShell.vue` (menu, header with eyebrow and title, theme switch, footer) or
  `layouts/AuthShell.vue` (the brand panel beside a form). Shared props: `brand` (sent once), `auth`, `nav`,
  `tenant` (the workspace being acted in: `public_id`, `name`, `initials`, or null), `workspaces` (the person's other
  open workspaces, for the switcher), `auth.user` (`public_id`, `name`, `email`, `initials`), `locale`, `region` (the
  reader's `locale`, `intl`, `timeZone`, `currency`, `direction`: S9), `flash` (shown as a toast), `core.version`,
  `sidebarOpen` (the person's own choice once signed in).
- Forms use `components/form`: `FormField` (label on one line, red asterisk, help, the mistake beside the field),
  `FormActions` ("* required" at the start, Cancel beside the primary at the end), `ChoiceCards`, `SegmentedControl`,
  `PickOne` (a select from the server's answers, with "none of these"), `PickSearch` (the same, typed into: a long list).
- Dates, times, numbers and money go through `lib/format.ts` (`formatDate`, `formatDateTime`, `formatTime`,
  `formatNumber`, `formatMoney`), in the reader's language and time zone (`region`).
- The page's words are loaded before it renders (`app.ts`), English as the fallback; a language without built words
  reads English.
- The theme is `<html data-theme="light|dark">`, written by the server from the `appearance` cookie so nothing
  flashes; shadcn-vue's `dark:` variant reads it.

## Running it

```bash
composer setup                 # first time: install, .env, key, migrate, build
php artisan test               # the suite, on PostgreSQL (atrium_test)
npm run build                  # or npm run dev
php artisan brand:check        # the brand is whole
```

**The suite's PostgreSQL needs `max_locks_per_transaction` of 256 or more** (the default is 64): the 1,000-row import
test holds about 6,000 locks in its one transaction, and the lock table is shared by every database on the server, so
at 64 it fails as soon as anything else is busy, and refuses other connections while it runs. Set it once, then restart
the server: `ALTER SYSTEM SET max_locks_per_transaction = 256;` (this Mac: `brew services restart postgresql@18`; done
3 November 2026). `ImportTest` checks it first and says so. Production doesn't need it: there each imported row is its
own transaction.

The development server is on port 8020, the mockups on 8021 and the docs on 8022 (`launch.json`; the registry's
8020 block). The starter pack has no agent: it is started on demand. An app renders its own with `php artisan app:agents
<port> --write` (S19, `deployment.md`).
