Compress AGENTS.md and add a standing rule to trim it on every edit
CI / full-gate (push) Successful in 2m34s
Mirror / github-mirror (push) Successful in 3s

This commit was merged in pull request #61.
This commit is contained in:
2026-08-05 21:52:56 +02:00
parent ab5c24deb7
commit e8b91ecd09
+296 -379
View File
@@ -3,327 +3,270 @@
Guidance for AI agents and contributors working in this repo. Read `README.md` for Guidance for AI agents and contributors working in this repo. Read `README.md` for
commands and layout. commands and layout.
## Maintaining this file
Every agent session reads this file in full, so its length is a cost paid on every task.
Keep it the shortest thing that still changes what someone does.
- **Trim as you add.** After any edit, re-read the whole file and compress: merge overlapping
entries, cut prose that restates a rule, drop what the code or `README.md` already says.
Question each section — same information, fewer words.
- **Record the decision and the reason it turns on, nothing else.** Not the investigation, not
what was tried first, not how it was verified — that belongs in the PR that made the change.
- **Give every accepted risk an expiry** ("valid while X"), and delete the entry once X stops
holding.
- **One home per fact.** Link to it rather than restating it — the same sentence in five files
is five things to update and five chances to drift.
## How to work with tasks ## How to work with tasks
Use the file `todo.md`. Use the file `todo.md`.
For each todo item, interview the user extensively to deeply understand the scope and goal of each. When done, check the completed task in `todo.md`. Commit all changes and push to a new branch, create a PR and merge it when the CI/CD turns green. For each todo item, interview the user extensively to deeply understand the scope and goal of
each. When done, check the completed task in `todo.md`. Commit all changes and push to a new
branch, create a PR and merge it when the CI/CD turns green.
## Project priorities (do not erode) ## Project priorities (do not erode)
1. **Simplicity** — prefer the solution that is easiest to understand, smallest, and most readable. 1. **Simplicity** — prefer the solution that is easiest to understand, smallest, and most readable.
2. **Few dependencies** — runtime deps stay minimal (today `ejs`, `lucide-static`, 2. **Few dependencies** — runtime deps stay minimal (today `ejs`, `lucide-static`, `@larvit/log`).
`@larvit/log` — the last itself zero-dependency, for structured/OTLP logging). Prefer the Node standard library; justify any new dependency; do not add frameworks. The app is
Prefer the Node standard library; justify any new dependency; do not add **stateless — no database**. Auth/identity/OAuth are **Ory sidecar services** (Kratos/Keto/Hydra,
frameworks. The app is backed by Postgres), reached over their REST APIs with built-in `fetch` — no SDK. New
**stateless — no database**. Auth/identity/OAuth are **Ory sidecar services** capabilities ship as **plugin folders** under `plugins/` that fetch their data from upstream
(Kratos/Keto/Hydra, backed by Postgres), reached over their REST APIs with services, not as core code.
built-in `fetch` — no SDK dependency. New capabilities ship as **plugin
folders** under `plugins/` that fetch their data from upstream services, not as
core code. See `README.md` for the architecture.
3. **Strict TypeScript**`tsconfig.json` is strict (incl. `noUncheckedIndexedAccess`, 3. **Strict TypeScript**`tsconfig.json` is strict (incl. `noUncheckedIndexedAccess`,
`exactOptionalPropertyTypes`, `verbatimModuleSyntax`). Keep it that way. Prefer `exactOptionalPropertyTypes`, `verbatimModuleSyntax`). Keep it that way. Prefer exact types;
exact types and limit nullable and multi option types when possible. KISS. limit nullable and multi-option types.
4. **Environment-agnostic** — the app never asks *which environment* it runs in; there is 4. **Environment-agnostic** — the app never asks *which environment* it runs in; no `NODE_ENV`
no `NODE_ENV` (or equivalent) branching. Every behaviour is an **explicit config branching. Every behaviour is an **explicit config toggle** (e.g. `CACHE_TEMPLATES`,
toggle** (e.g. `CACHE_TEMPLATES`, `REQUIRE_SECURE_SECRETS`, a future "disable email"), `REQUIRE_SECURE_SECRETS`), read once in `src/config.ts`. Compose files set them per deployment.
read once in `src/config.ts`. Compose files set the toggles per deployment. 5. **Semantic, accessible DOM** — use the right element for the job (landmarks, one `<h1>` per page
5. **Semantic, accessible DOM** — markup is a first-class concern. Use the right element + sane heading order, lists, `<table>` with row/column headers, `<fieldset>`/`<legend>`,
for the job (landmarks, one `<h1>` per page + sane heading order, lists, `<table>` with `<button>` vs `<a>`); add ARIA only to fill real gaps (`aria-current`, `aria-sort`, labels).
row/column headers, `<fieldset>`/`<legend>`, `<button>` vs `<a>`); add ARIA only to fill Classes/ids name *meaning*, not looks. Prefer native semantics over `div` + ARIA. New views and
real gaps (`aria-current`, `aria-sort`, labels). Classes/ids name *meaning*, not looks. partials keep this bar.
Prefer native semantics over `div` + ARIA. New views and partials keep this bar. 6. **Full, parallel E2E** — every user-facing flow (each page, form, guard, plugin route) has a
6. **Full, parallel E2E** — every user-facing flow (each page, form, guard, plugin route) Playwright E2E test, shipped in the same change as the surface. Tests stay independent and
has a Playwright E2E test, and a new surface ships *with* its E2E in the same change. side-effect-free so the suite runs `fullyParallel` — never serialise on shared state.
Tests stay independent and side-effect-free so the suite runs `fullyParallel` — keep it 7. **Powerful, fail-loud plugins** — the plugin API is the product's main surface and the only way
that way as it grows (never serialise on shared state); parallelism is what keeps it to add domain features. It optimises for being **powerful, predictable, and overloadable** (a
fast. E2E runs in Docker against the live stack — see `README.md`. plugin can take over as much of a page as it wants), and the host **fails loud at boot/discovery**
7. **Powerful, fail-loud plugins** — the plugin API is the product's main surface and the (bad manifest, version mismatch, conflict) rather than sandboxing at runtime. Runtime
only way to add domain features. It optimises for being **powerful, predictable, and crash-isolation is a deliberate **non-goal** — diagnose at deploy time, not in production.
overloadable** (a plugin can take over as much of a page as it wants), and the host
**fails loud at boot/discovery** (bad manifest, version mismatch, or conflict stops
startup with a clear message) rather than sandboxing at runtime. Runtime crash-isolation
is a deliberate **non-goal** — diagnose at deploy time, not in production. Keep this
contract stable; see `README.md` → Building plugins.
## Deliberate architectural deviations (don't re-flag) ## Deliberate architectural deviations (don't re-flag)
Intentional, reasoned choices — an architecture review should honor them, not re-raise Intentional, reasoned choices — an architecture review should honor them, not re-raise them.
them. Revisit only if the stated reason stops holding. Revisit only if the stated reason stops holding.
- **`src/` is grouped by concern**, not flat — `http/` (request pipeline), `auth/` ### Structure & contracts
(session-JWT hot path, guards, and the Ory REST clients), `i18n/` (locale resolution + the
catalogs, `locales/` holding the data), `plugin-host/` - **`src/` is grouped by concern**, not flat — `http/` (request pipeline), `auth/` (session-JWT hot
(discovery/router/hooks/view-resolver + the `plugin-api.ts` author barrel + `system.ts`, the path, guards, Ory REST clients), `i18n/` (locale resolution + catalogs), `plugin-host/`
`ctx.system` capability surface), and `ui/` (design-system view-models + menu/chrome); (discovery/router/hooks/view-resolver + the `plugin-api.ts` author barrel + `system.ts` behind
`server.ts`/`config.ts`/`logger.ts` and the topology-guard `*.test.ts` stay at the root. Tests `ctx.system`), `ui/` (design-system view-models + menu/chrome). `server.ts`/`config.ts`/`logger.ts`
are co-located (`foo.test.ts` beside `foo.ts`). Add a new module to the folder that owns its and the topology-guard `*.test.ts` stay at the root; tests are co-located. Add a new module to the
concern rather than to the root; don't reintroduce a flat tree. The core ships **no domain folder owning its concern; don't reintroduce a flat tree. The core ships **no domain screens**
screens** — even the admin GUI (users/groups/permissions) is a drop-in plugin (`examples/plugins/admin/`), even the admin GUI is a drop-in plugin (`examples/plugins/admin/`).
not `src/` code. - **Plugins and config import the host only via package.json `imports`** — `#plugin-api`
- **`ctx.chrome` is lazily memoized — do not make it unconditional** or move it into the `src/plugin-host/plugin-api.ts`, `#menu-config``src/ui/menu-config.ts`, never a relative
base request context. It protects the I/O-free hot path on the public, bot-hit landing `../../src/*` path. These two barrels are the whole contract surface; the `src/*` behind them may
(`/`). (Declined twice.) be refactored freely. Don't "fix" a `#`-import back to a relative path. Two consequences:
- **Email is delegated to Kratos** (it renders + sends recovery/verification mail); `web` - `#plugin-api` re-exports the Ory client types (`KratosAdmin`/`KetoClient`/`HydraAdmin` + their
never touches SMTP. Customization is Kratos' built-in `courier.template_override_path`, DTOs and error classes), so those shapes are **contract-visible** — changing them needs a major
not app code — keeping `web` stateless and dependency-light (see [Email](README.md#email)). `apiVersion` bump, not a free refactor.
- **Plugins and config import the host only via package.json `imports`** — `#plugin-api` - **A plugin/config folder must stay a plain folder — no `package.json` of its own**, which would
`src/plugin-host/plugin-api.ts`, `#menu-config``src/ui/menu-config.ts` — never a become its own scope and stop `#`-specifiers resolving. A plugin kept in its own repo therefore
relative `../../src/*` path. These two barrels are the whole author/operator contract typechecks against the barrel only when mounted under the host tree (or with a vendored stub).
surface; the `src/*` behind them may be refactored freely. Depth-independent and - **`examples/` mirrors the drop-in mount dirs** — `examples/plugins/<id>/` copies to `plugins/<id>/`,
refactor-stable by design — don't "fix" a `#`-import back to a relative path. `examples/config/menu.ts` to `config/menu.ts`. Both mirrors are in `tsconfig.include` and resolve
**One caveat:** `#plugin-api` re-exports the Ory client types for the `ctx.system` surface the host via `#`-imports, so each typechecks in place *and* copies across unchanged. Never commit
(`KratosAdmin`/`KetoClient`/`HydraAdmin` + their DTOs and error classes). Those shapes are real plugins/config into the root mount dirs — they ship empty.
therefore **contract-visible** — changing them is a plugin-API break needing a major - **`ctx.chrome` is lazily memoized — do not make it unconditional** or move it into the base request
`apiVersion` bump, not a free refactor. Keep the Ory clients stable, or bump the version. context. It protects the I/O-free hot path on the public, bot-hit landing (`/`). (Declined twice.)
- **A plugin/config folder must stay a plain folder — no `package.json` of its own.** Node - **A plugin-owned render always runs on that plugin's context.** The landing slots (`home`,
resolves `#`-specifiers against the nearest parent `package.json`; a `package.json` inside `dashboard`) and an `onRequest` short-circuit build their context with `contextFor(pluginId)`
the folder becomes its own scope and `#plugin-api`/`#menu-config` stop resolving. Accepted exactly as a plugin route does — otherwise `ctx.t` is the core translator and the plugin's own keys
cost of the `#`-import contract (fits the stateless, no-per-plugin-deps ethos). A plugin render as bare keys on the pages it owns.
kept in its own repo typechecks against the barrel only when mounted under the host tree - **Email is delegated to Kratos** (it renders + sends recovery/verification mail); `web` never
(or by adding a local `imports` map / vendored stub). touches SMTP. Customization is Kratos' `courier.template_override_path`, not app code — keeping
- **`examples/` mirrors the drop-in mount dirs** — `examples/plugins/<id>/` copies to `web` stateless and dependency-light.
`plugins/<id>/`, `examples/config/menu.ts` to `config/menu.ts`. Both mirror folders are in
`tsconfig.include` and resolve the host surface via `#`-imports, so each example typechecks ### Authorization
in place *and* copies across unchanged. Never commit real plugins/config into the root
mount dirs (`plugins/`, `config/`) — they ship empty (`.gitkeep`, git-ignored otherwise). - **Vocabulary: `User``Group``Permission`, and there is no `Role`.** Keto ships no namespaces —
- **Authorization vocabulary: `User``Group``Permission`, and there is no `Role`.** Keto ships all four in `ory/keto/namespaces.keto.ts` are ours. A permission is one operation ("read shifts");
no namespaces — all four in `ory/keto/namespaces.keto.ts` are ours. `Permission` follows RBAC, a role is a *bundle*, which here is just a group with several grants (groups nest). Ory's own
where a permission is one operation ("read shifts") and a role is a *bundle* of them; a route "permission" (the `Resource` `permits`: view/edit/delete) is the separate per-row tier.
gates on one operation, so it gates on a permission, and a bundle is just a group with several
grants (groups nest). Ory's own "permission" (the `Resource` `permits`: view/edit/delete) is the
separate per-row tier.
- **A stricter manifest rule breaks already-copied plugins, and while `HOST_API_VERSION` is frozen the
failure names a symptom rather than the cause.** `plugins/` is an operator-owned drop-in mount that
ships empty, so no test ever sees a *stale* copy — an operator's is whatever version they took. On
2026-08-05 the `<resource>:<action>` rule stopped a pre-existing `plugins/admin` at boot with
"route gates on admin", which reads as the operator's bug rather than an out-of-date copy.
**Accepted during development** (maintainer, 2026-08-05): `checkApiVersion` is already the right
mechanism — a breaking manifest change bumps the major and a stale plugin is refused by *version*,
which says plainly what happened. That only starts working once the freeze lifts, so until then a
stricter rule ships with a README → Upgrading entry and the discovery error carries the re-copy
hint. **Valid while `HOST_API_VERSION` stays frozen at 1.0.0** — when the first external plugin
lifts it (see the Rules section), the version check takes over and this note can go.
Fail-loud stays right either way: the alternative is a route gating on a name nobody can be
granted, i.e. a permanent silent 403.
- **A permission name is always `<resource>:<action>`** — `scheduling:read`, `users:write`. A bare - **A permission name is always `<resource>:<action>`** — `scheduling:read`, `users:write`. A bare
word names *who someone is* a role and roles are groups here; the old catch-all `admin` word names *who someone is* (a role), and roles are groups here; the old catch-all `admin` was
permission was exactly that mistake, split into `users:`/`groups:`/`permissions:`/`oauth2-clients:` exactly that mistake. **Enforced at discovery** (`isValidPermissionName` in `plugin-host/plugin.ts`,
× `read`/`write` 2026-08-05. **Enforced at discovery** (`isValidPermissionName` in checked by `shapeError` over every route/nav `permission` and every declared name), fail-loud like
`plugin-host/plugin.ts`, checked by `shapeError` over every route/nav `permission` and every any other manifest rule — not only in the admin GUI, which an operator removes by not copying it in.
declared name), fail-loud like every other manifest rule — not only in the admin GUI, which an - **Names are authored in plugin code; only grants live in Keto.** The host collects every installed
operator removes by not copying the example in. Decisions around it: plugin's declarations into one catalog (`declaredPermissions``ctx.declaredPermissions`), and
- **Names are authored in plugin code; only grants live in Keto.** The host collects every that catalog *is* the list the admin screens offer. Hence **no Permissions admin screen**: nothing
installed plugin's declarations into one catalog (`declaredPermissions``ctx.declaredPermissions`), in a GUI invents a name, and holding one is a property of a user or group, edited as a checkbox
and that catalog *is* the fixed list the admin screens offer. So there is **no Permissions admin list there. A Keto tuple naming something no installed plugin declares gates nothing, is not
screen**: nothing in a GUI invents a name, and holding one is a property of a user or a group, offered, and is never revoked by an unrelated save — the picker only speaks for what it showed.
edited as a checkbox list on those two screens. A tuple in Keto naming something no installed - `<resource>` is **global, not plugin-scoped** (hence `oauth2-clients`, not `clients`): users are
plugin declares gates nothing and is not offered — and a save never revokes it, since the picker the *host's*, and cross-plugin sharing is a goal. Cost: collision-freedom is a convention rather
only speaks for what it showed. Decided with the maintainer 2026-08-05, replacing the CRUD than structural. Accepted — the alternative penalizes the sharing case.
Permissions screen. - **Declaring a permission stays optional.** Mandatory declaration would let `findConflicts` see all
- `<resource>` is **global, not plugin-scoped** (hence `oauth2-clients`, not `clients`). Deliberate overlaps, but would then warn on exactly that legitimate sharing case. Shape is enforced;
cross-plugin sharing is a goal, so the pre-2026-08-05 `<id>:<action>` guidance was wrong: users declaration is not.
are the *host's*, not the admin plugin's. Cost: collision-freedom became a convention rather than - `ADMIN_PERMISSIONS` **defaults to empty** (every permission is owned by the plugin gating on it),
structural. Accepted the alternative penalizes the sharing case. and **an unusable value is dropped with a warning, never fatal** — fail-loud belongs at the
- **Declaring a permission stays optional.** Requiring every gated route to declare its permission manifest boundary where a developer authored the mistake, whereas `bootstrap` gates `web`, so
would make `findConflicts` see all overlaps, but would then warn on exactly the legitimate refusing operator env takes the whole stack down. `e2e-tests/compose.auth.yml` seeds a bad value
sharing case above. Shape is enforced; declaration is not. so the container proves it survives one. The seed is a function of what `bootstrap` discovers, so
- **There is no name-minting path in the GUI at all**, which is what makes the discovery check the a plugin dropped in after first boot needs `docker compose up -d` (re-runs the one-shot), not
whole story: the only way a name comes into being is a plugin declaring it, and discovery refuses `restart web`. `bootstrap`'s matching `./plugins` mount belongs in `compose.override.yml` and
a badly-shaped declaration at boot. An earlier revision of this branch enforced the rule in the nowhere else: in the base file it would desynchronise prod and collide with the e2e stacks, which
Permissions screen's create form instead and needed a second guard for the assign form, which bind individual plugins *inside* `/app/plugins` (a nested mount into a read-only parent is EROFS
could also mint one — deleting the screen removed both. and the container never starts). Valid while bootstrap is the only writer of grants.
- `ADMIN_PERMISSIONS` **defaults to empty**: every permission is owned by the plugin that gates on
it, and a host-invented default would gate nothing. **An unusable value there is dropped with a
warning, never fatal** — fail-loud belongs at the manifest boundary, where a developer authored
the mistake; `bootstrap` gates `web`, so refusing operator env takes the whole stack down. This
is not hypothetical: `admin` was this setting's own default until 2026-08-05, so a boot-breaking
value is the *expected* leftover on upgrade, and a revision of this branch shipped exactly that
bug past a green CI. `e2e-tests/compose.auth.yml` now seeds `ADMIN_PERMISSIONS: admin,users:read`
so the container proves it survives one; verified by negative control (re-adding the throw fails
that suite at stack-up). This makes the seed a function of what
`bootstrap` discovers, and a plugin dropped in after first boot therefore needs
`docker compose up -d` (which re-runs the one-shot), not `restart web`. The base file gives
`bootstrap` and `web` the same baked `plugins/`; only `compose.override.yml`'s dev-only `.:/app`
makes `web` diverge onto the host tree, so the matching `./plugins` mount for `bootstrap` lives
**there and only there** — in the base file it would desynchronise prod and collide with the e2e
stacks, which bind individual plugins *inside* `/app/plugins` (a nested mount into a read-only
parent is EROFS and the container never starts). Valid while bootstrap is the only writer of
grants.
- **`actionForMethod` is plugin-local and must not migrate into `#plugin-api`.** Inside the admin - **`actionForMethod` is plugin-local and must not migrate into `#plugin-api`.** Inside the admin
example it buys one thing: the route table and the in-handler guard derive from one function, so example it keeps the route table and the in-handler guard deriving from one function, so 29 routes
29 routes × 2 gate sites cannot drift. As a general mechanism it would make authorization a × 2 gate sites cannot drift. Generalised, it would make authorization a function of the transport
function of the transport verb, and a route table must answer "what does this need?" on its own. verb — a route table must answer "what does this need?" on its own.
- **A `:read`-only holder must never be shown a write affordance.** The split created a real read-only - **A `:read`-only holder must never be shown a write affordance.** The list/detail models carry
operator (a helpdesk account with `users:read`), and the host's 403 is the backstop, not the UX: the `canWrite` and the views drop create/save/delete/add/remove; the permission picker still renders,
list/detail models carry `canWrite` and the views drop create/save/delete/add/remove, while the disabled, because *seeing* who holds what is the point of `:read`. A **write-intent GET** (a create
permission picker still renders — disabled — because *seeing* who holds what is the point of `:read`. form, a delete-confirm page) is the exception to `actionForMethod` and gates on `:write`, since a
A *write-intent GET* — a create form or a delete-confirm page — is the exception to page whose only purpose is to start a write should refuse a reader rather than render a form whose
`actionForMethod`: it gates on `:write` (declared in the route table and passed to the handler's submit 403s. Two grant-specific guards go with it: you cannot revoke your own **direct** grants
guard, so the two still agree), because a page whose only purpose is to start a write should refuse (self-lockout would need a `curl` against Keto to undo), and a permission held *through a group*
a reader rather than render a form whose submit 403s. renders ticked-but-disabled, because unticked stated the opposite of the truth. **Known gap:** the
Two grant-specific guards go with it, both restoring behaviour the deleted Permissions screen had: group paths are unguarded — unticking a permission on a group you belong to, leaving it, or deleting
you cannot revoke your own **direct** grants on the Users screen (self-lockout would need a `curl` it can still strip your own access. The robust "last effective holder" check needs a reverse Keto
against Keto to undo, which the operator persona can't do — same shape as the self-deactivate/ query and is deferred.
self-delete guards), and a permission held *through a group* renders ticked-but-disabled rather than - **`users:write` and `groups:write` are equivalent to full administrative access**: `groups:write`
unticked, because showing it unticked stated the opposite of the truth and unticking it wrote adds you to any group, including one holding every permission; `users:write` mints a recovery code
nothing while looking like a successful revoke. **Known gap, same scope the deleted screen had:** for any account. The containment the split buys is real on the **read** half only (`users:read` is
the group paths are unguarded — unticking a permission on a group you belong to, removing yourself a safe helpdesk grant). Don't let the per-resource naming imply otherwise in docs.
from it, or deleting it can all still strip your own effective access. The robust "last effective - **Plainpages says "user" everywhere; Ory's word is "identity".** Ory's own docs use the terms
holder" check needs a reverse Keto query and is deferred. Raised by the architecture + product + interchangeably, so this is house style, not a renamed concept. The single exception is the
stability reviews 2026-08-05. `Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors Kratos' wire shape — don't rename it.
- **`users:write` and `groups:write` are equivalent to full administrative access**, and the split
does not change that: `groups:write` adds you to any group, including one holding every permission; ### i18n
`users:write` mints a recovery code for any account. The containment the split buys is real on the
**read** half only (`users:read` is a safe helpdesk grant). Don't let the per-resource naming imply
otherwise in docs. Raised by the architecture review 2026-08-05.
- **Plainpages says "user" everywhere; Ory's word for it is "identity".** Kratos calls the record
an identity, but Ory's own docs state it uses that term *interchangeably* with "users" and
"accounts" — so this is house style, not a renamed concept, and "user" is the word readers
already know (Nielsen's heuristic #2: match between the system and the real world). One note in
README → Auth records the mapping so nobody has to rediscover it. The single exception is the
`Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors Kratos' wire shape and keeps Ory's
name — don't rename that one.
- **The locale lives in the URL, never in a cookie.** `?locale=sv-SE``Accept-Language``en-US`, - **The locale lives in the URL, never in a cookie.** `?locale=sv-SE``Accept-Language``en-US`,
and when the URL asked for one the host carries it onto the links it renders (`ctx.localeHref`). and when the URL asked for one the host carries it onto the links it renders. A cookie would make a
A cookie would make a page's language invisible in its address and unshareable; the cost is that a page's language invisible in its address and unshareable; the cost is that a plugin wraps its own
plugin must wrap its own hrefs. Matching is exact on a full tag (`sv-FI``sv-SE`), except that a hrefs. Matching is exact on a full tag (`sv-FI``sv-SE`), except that a lone language from
lone language from `Accept-Language` takes the first regional catalog for it. Decided 2026-08-03. `Accept-Language` takes the first regional catalog for it.
- **Catalogs are checked at boot, not at render.** Every locale is compared against its set's `en-US`
— keys, string-vs-plural kind, and the plural categories `Intl.PluralRules` says that locale needs —
and a mismatch stops startup, same fail-loud contract as a bad manifest. A plugin may ship fewer
locales than the host (its strings fall back to `en-US` per key), never one the host lacks.
- **The core building blocks carry the locale; a plugin doesn't have to.** The shell (breadcrumbs), - **The core building blocks carry the locale; a plugin doesn't have to.** The shell (breadcrumbs),
`pagination`, `filter-bar`, `data-table`, `auth-card`, `flow-body`, `field` and `menu` wrap every `pagination`, `filter-bar`, `data-table`, `auth-card`, `flow-body`, `field` and `menu` wrap every
href they render in `localeHref`; the nav and the sign-in link are wrapped upstream in `chrome.ts`; href in `localeHref`; nav and sign-in are wrapped in `chrome.ts`; the two GET forms carry it as a
and the two GET forms hidden `locale` input, since a GET submit replaces the whole query string. **A form's `action`
(filter bar, rows-per-page) carry it as a hidden `locale` input, since a GET submit replaces the counts as a link** — sign-out, consent and auth-card forms carry it too, or picking a language and
whole query string and no href wrapper can reach it. Putting the obligation on each call site was then saving anything drops back to `Accept-Language`. Putting the obligation on each call site was
tried first and missed five of eight sites inside one commit — including the admin screens. tried and missed five of eight sites in one commit. `ctx.localeHref` remains for hrefs a plugin's
`ctx.localeHref` remains for hrefs a plugin's own markup emits (the admin example's delete links). own markup emits. The one round-trip that cannot carry it is the Kratos sign-in POST (absolute
**A form's `action` counts as a link** — a POST replaces the URL as completely as a GET submit, so off-site URL).
the sign-out, consent and auth-card forms carry it too; without that, picking a language and then - **`locale` is a host-owned query param** — in `parseListQuery`'s reserved set, so a localized list
saving anything drops back to `Accept-Language`. The one round-trip that cannot carry it is the page doesn't hand a plugin a phantom `locale` filter. The i18n view locals (`t`, `locale`, `locales`,
Kratos sign-in POST, whose action is an absolute off-site URL. `localeHref`, `localeParam`, `localeSwitch`, `dir`) are likewise reserved, merged after a handler's
Decided 2026-08-03 after an architecture review; a second pass then found breadcrumbs still raw, `data` so a collision loses the key instead of breaking the shell.
so: when a link renders from the core chrome, it is the chrome's job to carry the locale. - **Catalogs are checked at boot, not at render.** Every locale is compared against its set's `en-US`
- **`locale` is a host-owned query param.** It is in `parseListQuery`'s reserved set (`list-query.ts`), — keys, string-vs-plural kind, and the plural categories `Intl.PluralRules` requires — and a
so a localized list page doesn't hand a plugin a phantom `locale` filter; the i18n view locals (`t`, `locale`, `locales`, `localeHref`, mismatch stops startup. A plugin may ship fewer locales than the host (its strings fall back to
`localeParam`, `localeSwitch`, `dir`) are likewise reserved names, merged after a handler's `data` `en-US` per key), never one the host lacks.
so a collision loses the key instead of breaking the shell. - **`locales/` at the repo root is a drop-in mount**, like `plugins/` and `config/``locales/<tag>.ts`
- **The language picker is on every page, POST-rendered ones included.** Maintainer's call for the core, `locales/plugins/<id>/<tag>.ts` for a plugin; a new tag adds a language, an existing
2026-08-04, overriding an earlier decision to hide it there. The problem it was hiding is real: a one replaces that catalog wholesale. Adding a language must never require forking the image. The
POST-rendered URL frequently answers no GET (`POST /admin/users/:id/recovery`), so a link back to SHIPPED `en-US` stays the parity baseline even when the mount replaces it, so a mounted catalog is
it dead-ends on a 405. The host therefore resolves the picker's target (`app.ts``switchBase`): checked rather than trusted (one compared only against itself would boot green with the whole UI
this path when it answers GET, else the same-origin Referer, else `/`. Accepted cost: switching rendering keys).
language on such a page leaves that POST's own result behind (a re-rendered form's input, or a - **The language picker is on every page, POST-rendered ones included.** A POST-rendered URL often
one-time recovery code). Valid while the picker is expected on literally every page — if that ever answers no GET (`POST /admin/users/:id/recovery`), so the host resolves the picker's target
softens, hiding it after a POST is the simpler answer. (`app.ts``switchBase`): this path when it answers GET, else the same-origin Referer, else `/`.
- **A plugin-owned render always runs on that plugin's context.** The landing slots (`home`, Accepted cost: switching language there leaves that POST's own result behind. Valid while the picker
`dashboard`) and an `onRequest` short-circuit dispatch a plugin's handler, so they build the is expected on literally every page — if that softens, hiding it after a POST is simpler.
context with `contextFor(pluginId)` exactly as a plugin route does — otherwise `ctx.t` is the core - **An unknown translation key renders as itself.** That single rule lets a nav label, branding, or a
translator and the plugin's own keys render as bare keys on the pages it owns. Found by review menu `rename` be either a key or plain text without a second field or a migration. Don't "fix" it
2026-08-03 after all three paths shipped with the host's context. into a loud failure: a manifest with plain labels must keep working.
- **`locales/` at the repo root is a drop-in mount, like `plugins/` and `config/`** — `locales/<tag>.ts` - **`t()` returns raw text; the view escapes it.** Messages go through `<%= %>` like any other value;
for the core and `locales/plugins/<id>/<tag>.ts` for an installed plugin, each adding a language or one carrying markup uses `<%- %>`, and then its `{{vars}}` are escaped at the call site. Don't move
replacing that tag's catalog wholesale. Adding a language must never require forking the image or a escaping into `t()` — every other value in a view would become the odd one out.
vendored plugin folder. The SHIPPED `en-US` (core's, or the plugin's own) stays the parity baseline - **RTL is out of scope until there is a real use case.** `textDirection` sets `<html dir>` because
even when the mount replaces it, so a mounted catalog is checked rather than trusted (one compared that is free and correct, but the stylesheet keeps physical `left`/`right` properties; a genuine RTL
only against itself would boot green with the whole UI rendering keys), and each half is reported locale needs those moved to logical ones first. Don't convert the CSS or file findings about it on
under the folder it actually lives in. spec. Valid while no deployment needs an RTL language.
- **RTL is out of scope until there is a real use case.** `textDirection` sets `<html dir>` from the
locale's script because that is free and correct, but the stylesheet keeps physical `left`/`right` ### UI
properties — a genuine RTL locale needs those moved to logical ones first. Don't convert the CSS or
file findings about it on spec. Maintainer's call 2026-08-04; valid while no deployment needs an
RTL language. A catalog there
for a new tag adds a language; one for a tag the image ships replaces that catalog wholesale, held
to the same parity check. Adding a language must not require forking the image.
- **An unknown translation key renders as itself.** That single rule is what lets a nav label,
branding, or a menu `rename` be either a key or plain text without a second field or a migration.
Don't "fix" it into a loud failure: a manifest with plain labels must keep working.
- **`t()` returns raw text; the view escapes it.** Messages go through `<%= %>` like any other value,
so nothing is double-escaped; a message carrying markup uses `<%- %>`, and then its `{{vars}}` are
escaped at the call site (see `views/partials/pagination.ejs`). Don't move escaping into `t()`
every other value in a view would then be the odd one out.
- **CI docker logins share the runner host's Docker config.** The act_runner is host-mode, so
`docker login`/`logout` in the workflows mutate one shared `~/.docker/config.json`:
concurrent jobs can race (one job's logout can 401 another's push — recover by re-running),
and tokens sit in that file between login and logout. Same class: concurrent runs share the
workspace dir, so ci.sh's web-image build races another run's container creation on the
`<project>-web` tag. Accepted for a single-maintainer cadence; serialize with a workflow
`concurrency` group if it ever bites.
- **The docs-only CI skip is `*.md` anywhere in the tree, not just the root.** No test, build step or
workflow reads a markdown file (`README-dockerhub.md` is pasted into Docker Hub by hand), so a
nested `examples/plugins/admin/README.md` edit is as safe to skip as `README.md`, and narrowing it
would spend the full gate on one. Both git channels in `ci.sh`'s `docs_only()` pass `--no-renames`:
rename detection names only the destination, so `git mv src/app.ts notes.md` otherwise read as docs
and skipped the gate over a source file that was gone. `src/ci-gate.test.ts` locks the flags as a
*text* guard — the test image (`node:24.19.0-alpine3.24`) ships neither `git` nor `bash`, so it
cannot exercise the function; behaviour was verified against a scratch repo across ten scenarios.
Revisit if a `.md` ever becomes load-bearing. Decided 2026-08-05.
- **Plainpages is pre-announcement: no tags, no releases.** The repo carried tags up to `v0.2.2` from
the `auto-release` job; all of them — and the semver container tags — were deleted 2026-08-05, and
the job is gated behind the `AUTO_RELEASE` Actions variable (unset ⇒ skipped, the fail-safe
direction on every unknown-`vars` path). A version only communicates to consumers, and there are
none; same reasoning that freezes `HOST_API_VERSION` at 1.0.0. Note the coupling:
`registry-cleanup` keeps a hash image only while its commit is a branch head *or* release-tagged,
so with zero tags only branch heads survive the nightly prune — a hand-cut tag must sit on `main`'s
tip. `mirror.yml` pushes tags with `--prune` so the deletions actually reach the public GitHub
mirror; that makes the runner's tag view load-bearing (hence `fetch-tags: true`) and means a tag
or Release created on GitHub is swept away, so releases are cut on Gitea only. Valid until the
maintainer says Plainpages is ready to show people.
- **A dropdown is a `<button popovertarget>` + `[popover]`, never a `<details>`.** The browser then - **A dropdown is a `<button popovertarget>` + `[popover]`, never a `<details>`.** The browser then
owns open/close, which is the only zero-JS way to dismiss a menu by clicking outside it (the whole owns open/close the only zero-JS way to dismiss by clicking outside — and the panel sits in the
point), and the panel sits in the top layer so a row kebab is no longer clipped by `.table-wrap`'s top layer, so a row kebab is no longer clipped by `.table-wrap`'s `overflow`. Four rules hold it
`overflow`. Four rules hold it together, none of them cosmetic. The panel carries together: the panel carries **`position-anchor: auto`** (a bare `anchor()` resolves to nothing in
**`position-anchor: auto`** — a bare `anchor()` resolves to nothing in Chromium, Firefox *and* all three engines); it stays the trigger's **next sibling inside the `.menu` wrapper**, which the
WebKit alike, which is why the popover test in `visual.spec.ts` runs in all three rather open-state style and the old-browser fallback both read; the partial **requires a caller-named `id`**
than resting on a one-time manual measurement. The panel stays the trigger's **next sibling inside and fails loud without one, since that is the `popovertarget` idref (generated ids were tried and
the `.menu` wrapper**, because the open-state style and the old-browser fallback both read that rejected — nondeterministic HTML forecloses the caching decision); and **neither `aria-expanded` nor
adjacency, and a two-element partial cannot be dropped into an arbitrary layout. The `menu` partial `aria-haspopup` is written**, because a zero-JS invoker cannot keep the first truthful and the second
**requires a caller-named `id`** and fails loud without one: it is the `popovertarget` idref, and would promise `role="menu"` semantics these panels don't implement. `<details>` stays where it means
generated random ids were tried and rejected the same day — nondeterministic HTML forecloses the disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the profile
still-open caching decision and names nothing a reader can use. And **neither `aria-expanded` nor menu (its trigger composes escaped user values and its one item is a CSRF POST form, neither of which
`aria-haspopup` is written**: a zero-JS invoker cannot keep the first truthful, and the second would the partial's `Item` shapes cover) — keep the two in step.
promise `role="menu"` keyboard semantics these panels do not implement. `<details>` stays where it
means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the
profile menu because its trigger composes escaped user values and its one item is a CSRF POST form,
neither of which the partial's `Item` shapes cover — keep the two in step, or fold it in if
`todo.md`'s "does the profile dropdown still earn a dropdown" settles the other way. Decided 2026-08-05.
- **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract.** It is - **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract.** It is
deliberately not re-exported from `#plugin-api`, and README → Nav & permission gates already tells an deliberately not re-exported from `#plugin-api`; README → Nav & permission gates tells an author that
author that using a new icon means registering it there. So the palette may narrow when the last a new icon means registering it there. So the palette may narrow when the last reference to an id
reference to an id goes — `i-gear` left with the settings menu 2026-08-05 — and a plugin needing one goes, and a plugin needing one gets it re-registered in the same change. Accepted cost: an unknown
gets it re-registered in the same change. Accepted cost: an unknown sprite id renders a blank icon sprite id renders blank instead of failing loud (the `every icon <use> resolves` e2e test catches
instead of failing loud; the `every icon <use> resolves to a defined <symbol>` e2e test catches it for anything reaching the nav). Removing an id is a core edit — weigh it per icon rather than sweeping.
anything reaching the nav. Removing an id is a core edit, so weigh it per icon rather than sweeping the
registry — a few ids are registered ahead of a caller (see `todo.md`). ### Build, test & release
- **Anything the browser logs fails the E2E test that provoked it.** Every spec takes its `test` from
`e2e-tests/console-guard.ts`, which watches every page a test opens: a console error or warning, or
an uncaught exception, fails that test. A zero-JS app has nothing to say in the console, so the bar
is *zero* rather than a curated list of tolerated noise — and the two exceptions are explicit and
narrow: one module-level allowance for the COOP header Chromium drops because the e2e stacks serve
plain http over container hostnames (a deployment serves https, where it applies), and
`allowConsole(re)` for a test whose own page provokes a message on purpose — the 404 spec, whose
navigation Chromium and WebKit log. Each record carries the message's origin URL, so that allowance
can name the page under test and still see a sub-resource of it 404. `src/e2e-console-guard.test.ts`
locks the wiring in the *unit* gate: a spec importing `test` straight from Playwright — or minting a
page with a raw `newPage()` instead of `watchedPage()` — would run unwatched and green. The buffer
clears at teardown rather than setup so a `beforeAll` is watched too (full-flow runs a whole login in
one); the accepted cost is that a page outliving its test, as a serial describe's does, can log late
and fail the next test instead of its own. Verified by negative control in all three engines.
- **The Ory-free specs run in all three engines; the Ory-backed ones stay on Chromium.**
`visual.spec.ts` + `language.spec.ts` are side-effect-free, so three parallel runs don't collide,
and a console message only appears in the engine that renders the page — the reason the per-test
`@engines` tag is gone: the whole Ory-free suite is the engine matrix now (`ORY_FREE` in
`e2e-tests/playwright.config.ts`). The rest write users, groups and sessions to one shared backend,
where a second engine's run would race the first, so widening them means giving each engine its own
stack. Screenshots are written per project name for the same reason. Decided 2026-08-05.
- **Deps install to `/node_modules`, above `WORKDIR /app`** — Node resolves upward, so dev's `.:/app` - **Deps install to `/node_modules`, above `WORKDIR /app`** — Node resolves upward, so dev's `.:/app`
bind mount has nothing to shadow. Not a volume at `/app/node_modules`: the daemon creates a mount bind mount has nothing to shadow. Not a volume at `/app/node_modules`: the daemon creates a mount
destination as root whatever `--user` says, leaving a root-owned dir in the checkout. Nothing may destination as root whatever `--user` says, leaving a root-owned dir in the checkout. Nothing may
sit at that path now — it shadows `/node_modules` silently (`src/compose.test.ts` guards the compose sit at that path now — it shadows `/node_modules` silently (`src/compose.test.ts` guards the compose
files, `.dockerignore` the image). Decided 2026-08-05. files, `.dockerignore` the image).
- **Anything the browser logs fails the E2E test that provoked it.** Every spec takes its `test` from
`e2e-tests/console-guard.ts`, which fails a test on a console error/warning or uncaught exception on
any page it opened. A zero-JS app has nothing to say in the console, so the bar is *zero* rather than
a curated tolerance list; the two exceptions are narrow — a module-level allowance for the COOP header
Chromium drops (the e2e stacks serve plain http over container hostnames), and `allowConsole(re)` for
a test whose own page provokes a message on purpose. `src/e2e-console-guard.test.ts` locks the wiring
in the *unit* gate, since a spec importing `test` straight from Playwright — or minting a page with
a raw `newPage()` instead of `watchedPage()` — would run unwatched and green. The buffer clears at
teardown so a `beforeAll` is watched too; accepted cost is that a page outliving its test can log
late and fail the next one.
- **The Ory-free specs run in all three engines; the Ory-backed ones stay on Chromium.**
`visual.spec.ts` + `language.spec.ts` are side-effect-free, so parallel runs don't collide, and a
console message only appears in the engine that renders the page (`ORY_FREE` in
`e2e-tests/playwright.config.ts`). The rest write users, groups and sessions to one shared backend,
so widening them means a stack per engine. Screenshots are written per project name.
- **The docs-only CI skip is `*.md` anywhere in the tree, not just the root.** No test, build step or
workflow reads a markdown file, so a nested `examples/plugins/admin/README.md` edit is as safe to
skip as `README.md`. Both git channels in `ci.sh`'s `docs_only()` pass `--no-renames`: rename
detection names only the destination, so `git mv src/app.ts notes.md` otherwise read as docs and
skipped the gate over a source file that was gone. `src/ci-gate.test.ts` locks the flags as a *text*
guard — the test image ships neither `git` nor `bash`. Revisit if a `.md` ever becomes load-bearing.
- **CI docker logins share the runner host's Docker config.** The act_runner is host-mode, so
`docker login`/`logout` in the workflows mutate one shared `~/.docker/config.json`: concurrent jobs
can race (one job's logout can 401 another's push — recover by re-running), and tokens sit in that
file between login and logout. Same class: concurrent runs share the workspace dir, so ci.sh's
web-image build races another run's container creation on the `<project>-web` tag. Accepted for a
single-maintainer cadence; serialize with a workflow `concurrency` group if it ever bites.
- **Plainpages is pre-announcement: no tags, no releases.** All tags and semver container tags were
deleted, and `auto-release` is gated behind the `AUTO_RELEASE` Actions variable (unset ⇒ skipped,
the fail-safe direction on every unknown-`vars` path). A version only communicates to consumers and
there are none — the same reasoning that freezes `HOST_API_VERSION`. Two couplings: `registry-cleanup`
keeps a hash image only while its commit is a branch head *or* release-tagged, so with zero tags a
hand-cut tag must sit on `main`'s tip; and `mirror.yml` pushes tags with `--prune` (so its
`fetch-tags: true` is load-bearing), meaning a tag or Release created on GitHub is swept away and
releases are cut on Gitea only. Valid until the
maintainer says Plainpages is ready to show people.
- **A stricter manifest rule breaks already-copied plugins, and while `HOST_API_VERSION` is frozen the
failure names a symptom rather than the cause.** `plugins/` is an operator-owned drop-in mount, so an
operator's copy is whatever version they took. `checkApiVersion` is the right mechanism — a breaking
manifest change bumps the major and a stale plugin is refused by *version* — but that only works once
the freeze lifts. Until then a stricter rule ships with a README → Upgrading entry and a re-copy hint
in the discovery error. Fail-loud stays right either way: the alternative is a route gating on a name
nobody can be granted, i.e. a permanent silent 403. **Valid while `HOST_API_VERSION` stays frozen.**
## Docker only — no host tooling ## Docker only — no host tooling
@@ -341,102 +284,76 @@ docker compose -f compose.yml up --build -d # production
`README.md` serves two readers, in this order — preserve it when editing: `README.md` serves two readers, in this order — preserve it when editing:
1. **First-time reader (top).** A one/two-sentence tagline, then a **Quick start** that gets 1. **First-time reader (top).** A one/two-sentence tagline, then a **Quick start** that gets the
the stack up (`docker compose up`, sign in) and a *minimal* plugin live. Nothing comes stack up and a *minimal* plugin live. Nothing comes before Quick start — no philosophy, no
before Quick start — no philosophy, no rationale. Keep its commands copy-pasteable and the rationale. Keep its commands copy-pasteable; deeper detail lives in its own section, linked.
example plugin as small as possible; deeper detail lives in its own section, linked. 2. **Returning developer (rest).** A **Contents** ToC right after Quick start, then sections ordered
2. **Returning developer (rest).** A **Contents** ToC immediately after Quick start, then by **what an adopter reaches for first**, not by architectural layering: Overview → Users, groups
sections ordered by **what a developer adopting Plainpages reaches for, in priority & permissions → Building plugins → menu/blocks/interactivity → Configuration → Auth → Email →
order** — not by architectural layering. The value that sets the order: getting up and Architecture → Testing → Production → Observability → JWT-rotation runbook → Project-layout file
running **building plugins** comes first, then **configuring and securing** the system map → Extending. Place a new section by how early an adopter needs it. **Users, groups &
(Configuration, Auth); the **inner workings** (Architecture) and ops/runbooks are permissions precedes Building plugins** because a manifest's `permission:` gate is unreadable
deliberately deferred — they're not top of mind when starting out. Concretely: Overview → without the model, and it is the one home for that model.
Users, groups & permissions → Building plugins → menu/blocks/interactivity →
Configuration → Auth → Email → Architecture → Testing → Production → Observability → the
JWT-rotation runbook → the Project-layout file map → Extending. When adding a section, place
it by this value (how early an adopter needs it), not by where it sits in the stack.
**Users, groups & permissions precedes Building plugins** because a manifest's When editing: put content in the section it belongs to; keep the ToC in sync when you add/rename/
`permission:` gate is unreadable without the model, and operators need it as much as plugin remove an `H2`/`H3`; state each fact in one home and link to it.
authors. It is the one home for that model — the plugin and auth sections link to it rather
than restating it.
When editing: put content in the section it belongs to (don't prepend rationale above Quick **Don't document internals here.** How a script reaches a decision, what a function guards — a
start); keep the ToC in sync when you add/rename/remove an `H2`/`H3`; and state each fact in developer can read that off the code in seconds, and it only makes the README longer for humans and
one home, linking to it rather than restating (credentials, env vars, rotation steps). machines alike. It belongs in the code, or nowhere. The README earns its length on what you cannot
dig out: how to use and operate Plainpages, the external contracts, and one-time setup. Same test
**Don't document internals here.** How a script reaches a decision, why one run behaved before adding a row to a table or the file map — a clause, not a paragraph.
differently from another, what a function guards — a developer doesn't need it day to day and
can read it off the code or a run's log in seconds. Prose like that only makes the README
longer and harder to consume, for humans and machines alike. It belongs in the code it
describes, or nowhere. The README earns its length on what you cannot dig out: how to use and
operate Plainpages, the external contracts, and one-time setup (secrets, accounts, tokens).
Same test before adding a row to a table or the file map — a clause, not a paragraph.
## Rules ## Rules
- Node 24 runs `.ts` directly (type stripping). Keep all TypeScript **erasable** - Node 24 runs `.ts` directly (type stripping). Keep all TypeScript **erasable**
(`erasableSyntaxOnly` is on): no `enum`, `namespace`, parameter properties, or (`erasableSyntaxOnly` is on): no `enum`, `namespace`, parameter properties, or decorators. Import
decorators. Import local modules with their `.ts` extension. local modules with their `.ts` extension.
- **No `.mjs`.** Write modules as `.ts` (Prio 1) — even standalone scripts run in bare - **No `.mjs`.** Write modules as `.ts` — even standalone scripts run in bare `node:24` containers
`node:24` containers (the e2e mock servers, `examples/shifts-upstream/server.ts`): Node (the e2e mock servers, `examples/shifts-upstream/server.ts`). If a file genuinely must be plain
strips types and detects ESM from syntax, no package.json needed. If a file genuinely JavaScript, use `.js`; `"type": "module"` is set in both `package.json`s, so `.js` is ESM.
must be plain JavaScript, use `.js` (Prio 2); `"type": "module"` is already set in both
`package.json`s, so `.js` is ESM.
- **No build step** and no compiled artifacts — do not add a bundler or `tsc` emit. - **No build step** and no compiled artifacts — do not add a bundler or `tsc` emit.
- Before finishing a change, run the typecheck and tests above; both must pass. - Before finishing a change, run the typecheck and tests above; both must pass.
- Tests use the built-in `node --test` runner — no test framework dependency. - Tests use the built-in `node --test` runner — no test framework dependency.
- English everywhere. Keep code comments short and information-dense. Self explained code - English everywhere. Keep code comments short and information-dense; self-explained code with no
without any comment at all is the preferred solution. comment at all is preferred.
- Do not comment about history in the code or README. Like "This function included X before, - Do not comment about history ("this moved from X"), or about the absence of things.
but it moved to Y". - Pin all dependencies and Docker images to exact, human-readable **semantic versions** — never
- Do not comment about the absence of things, if it is not very unexpected. Banned is things ranges (`^`, `~`) and never digests. npm deps via `.npmrc` (`save-exact=true`) + `npm ci`; images
like "This function does not calculate pi, that is done in function Z". by tag.
- Pin all dependencies and Docker images to exact, human-readable **semantic
versions** — never ranges (`^`, `~`) and never digests/hashes. npm deps are kept
exact by `.npmrc` (`save-exact=true`) + `npm ci`; the base image by tag (e.g.
`node:24.16.0-alpine3.24`).
- **`HOST_API_VERSION` is frozen at 1.0.0 until the first external install**, even for additive - **`HOST_API_VERSION` is frozen at 1.0.0 until the first external install**, even for additive
contract changes (i18n added four `RequestContext` fields and several barrel exports without a contract changes. With no third-party plugin in the wild a bump can only produce noise. The
minor bump). Valid while nothing is installed against it: with no third-party plugin in the wild, promotion trigger is the first external plugin — from then on follow the versioning table in
a version bump can only produce noise. The promotion trigger is the first external plugin — from README → Contract versioning. **The frozen surface includes `views/partials/*.ejs`**: the view
then on, follow the versioning table in README → Contract versioning as written. Decided 2026-08-03. resolver makes every core partial an `include()` root for a plugin's views, so their option names
**The frozen surface includes `views/partials/*.ejs`**, not just the manifest and the barrel: the and emitted markup are author-visible. Know the hole that leaves — discovery fails loud on a bad
view resolver makes every core partial an `include()` root for a plugin's views, so their option `apiVersion`, but `include("menu", { open: true })` silently ignores a dropped option. Promotion
names and emitted markup are author-visible (under this freeze the popover change dropped the `menu` must cover the partial vocabulary too.
partial's `open?` and rewrote its markup). Know the hole that leaves — discovery fails loud on a bad - A plugin's `apiVersion` is a **hand-written literal** semver — the host version it was built
`apiVersion`, but `include("menu", { open: true })` silently ignores the option and a plugin styling against — bumped by hand on rebuild, **never** the host's `HOST_API_VERSION` constant. Importing
`.menu > summary` silently loses it. Promotion must cover the partial vocabulary too. Added 2026-08-05. the constant makes every plugin always equal the host, so `checkApiVersion` can never fire.
- A plugin's `apiVersion` is a **hand-written literal** semver — the host version the - **Plugin route handlers are thin and per-route, keyed on `ctx.params`.** Register one handler per
plugin was built against — bumped by hand on rebuild, **never** the host's `{method, path}` in the manifest (the host extracts `:id`/`:name` and 404s malformed `%`-encoding).
`HOST_API_VERSION` constant. Importing the constant makes every plugin always equal the Don't funnel many routes into one dispatcher that re-parses `ctx.url.pathname`: it duplicates the
host, so `checkApiVersion` can never fire and a breaking change slips through silently. URL shape, ignores the router's params, and has to re-handle HEAD. Factor shared per-request setup
- **Plugin route handlers are thin and per-route, keyed on `ctx.params`.** Register one handler into a small `withX` wrapper — see `examples/plugins/admin/`.
per `{method, path}` in the manifest (the host extracts `:id`/`:name` and 404s malformed
`%`-encoding — no manual path-slicing/decoding). Don't funnel many routes into one dispatcher
that re-parses `ctx.url.pathname`: it duplicates the URL shape, ignores the router's params, and
has to re-handle HEAD. Factor shared per-request setup (auth gate, `ctx.system` capability
resolution, target fetch) into a small `withX` wrapper — see `examples/plugins/admin/`.
- **`handleRequest` (`src/http/app.ts`) is a known complexity hotspot** — ~160 lines tracking - **`handleRequest` (`src/http/app.ts`) is a known complexity hotspot** — ~160 lines tracking
canonical host, static, locale, session + re-mint, CSRF, chrome, hooks, plugin routing, builtin canonical host, static, locale, session + re-mint, CSRF, chrome, hooks, plugin routing, builtin
routing, 405/404 and error mapping. The pure parts are already extracted and separately tested; what routing, 405/404 and error mapping. The pure parts are already extracted and separately tested;
remains is orchestration. Planned split along those seams; don't grow it further without taking one what remains is orchestration. Planned split along those seams; don't grow it further without
out. Raised by the architecture review 2026-08-03, deliberately not done inside the i18n change. taking one out.
- Reviews are maintainer-triggered (e.g. via the larv-review skill) — never auto-run reviewer - Reviews are maintainer-triggered (e.g. via the larv-review skill) — never auto-run reviewer agents.
agents. Decided 2026-08-02, replacing the earlier run-after-every-implementation rule.
- **A user-visible string belongs in a catalog, not in the code or a view.** Core strings go in - **A user-visible string belongs in a catalog, not in the code or a view.** Core strings go in
`src/i18n/locales/en-US.ts` (then every other locale, or the boot fails); a plugin's go in its own `src/i18n/locales/en-US.ts` (then every other locale, or the boot fails); a plugin's go in its own
`i18n/`. Operator/developer-facing text — boot errors, log messages, guard messages — stays English. `i18n/`. Operator/developer-facing text — boot errors, log messages, guard messages — stays English.
A pure view-model builder takes an optional `t` defaulting to its own English, so a unit test reads A pure view-model builder takes an optional `t` defaulting to its own English, so a unit test reads
in words; handlers pass `ctx.t`. in words; handlers pass `ctx.t`.
- **One verb per action in the English UI: sign in, sign out, create account.** Not "log in", - **One verb per action in the English UI: sign in, sign out, create account.** Not "log in", "log
"log out" or "sign up", inflections included — a second spelling for one button reads as a second out" or "sign up", inflections included — a second spelling for one button reads as a second thing;
thing; the noun ("a sign-in error", "the sign-in identifier") is unaffected. A plugin's catalog and the noun ("a sign-in error") is unaffected. A plugin's catalog and every other locale follow the
every other locale follow the same rule in their own language. An unmapped Kratos id renders same rule in their own language. An unmapped Kratos id renders Kratos' own wording — map the id when
Kratos' own wording — map the id when it matters. **Held by the author, never by a test:** as the it matters. **Held by the author, never by a test:** slightly different wording is often the right
UI grows, slightly different wording is often the right call, and a check that fails the build on call, and a build-failing check takes that judgment away.
a word takes that judgment away. Maintainer's call 2026-08-05, dropping the guard that shipped - Use well formed, standard compliant, rich URIs. Prefer state in the URL over POSTing it, for
with the rule. example on list pages with filters and pagination. Do `ids=x&ids=y`, not `ids[]=x&ids[]=y` and not
- Use well formed, standard compliant, rich URIs. Prefer state in the URL over POST:ing in for `ids=x,y`.
for example list pages with filters and pagination. Do: "ids=x&ids=y" and not "ids[]=x&ids[]=y"
and not "ids=x,y".