diff --git a/CLAUDE.md b/CLAUDE.md index e04af115e..87987f9d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,156 +1,491 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +Guidance for Claude Code (claude.ai/code) working in this repository. Keep it to +what changes how code gets written here; history and rationale live in `docs/`. -## Stack +## What this is -Python 3.12 + FastAPI + SQLModel + Alembic on the backend; Inertia.js + React + Tailwind 4 + Vite on the frontend. `uv` workspaces for Python, `npm` workspaces for JS. Package managers: `uv sync --all-packages` and `npm install`. +A **modular-monolith framework**: feature modules are Python packages loaded into +one FastAPI app, each with its own SQLModel tables, REST + Inertia endpoints and +React pages. There is no host–module API boundary. -## Local database & services (shared dev-services stack) +Stack: Python 3.12 + FastAPI + SQLModel + Alembic; Inertia.js v3 + React + +Tailwind 4 + Vite. `uv` workspaces for Python, `npm` workspaces for JS. +Published as ~20 PyPI packages (`simple_module_*`) and 3 npm packages +(`@simple-module-py/*`) in lockstep (`docs/release.md`). -**Do NOT start your own Postgres/Redis container.** All repos in `~/Repos` share -one stack defined in `~/Repos/dev-services` (one PostGIS + Redis + MinIO + Adminer -on the `devnet` Docker network). `make docker-up` here brings that shared stack up -(it no longer starts a local postgres/redis); `make docker-down` only stops this -repo's own containers (worker/beat), never the shared stack. - -This repo's slice: - -- **Postgres**: `localhost:5432`, `postgres`/`postgres`, database - **`simple_module_python`** (the default `SM_DATABASE_URL` is SQLite; uncomment - the Postgres line in `.env.example` to use the shared DB). -- **Redis**: `localhost:6379`, logical DBs **4** (broker) / **5** (result backend). - -If a port is taken by an old per-project container, stop that container rather -than remapping ports. To add a database, edit `dev-services/init/01-databases.sql`. +Layout: `framework/{core,db,hosting,inertia,testing,cli}` (the framework), +`modules/` (plugin modules), `host/` (the dev host app and all Alembic +migrations), `packages/{ui,i18n,tsconfig}` (shared JS), `skills/` (agent skills +shipped to users via `smpy skills add`), `.claude/skills/` (skills for this repo). ## Commands -All day-to-day tasks go through `make`: +Everything goes through `make`. Full list: `docs/reference/make-commands.md`. | Command | Purpose | |---|---| -| `make install` | Install Python + JS deps | -| `make dev` | Docker up + regen module pages + API (8000) and Vite (5050) in parallel | -| `make kill` | Free ports 8000/5050/5173 | -| `make test` | Run `test-py` then `test-js` (e2e excluded by default) | -| `make test-py` / `make test-js` | Run a single suite (`test-py` runs in parallel via `pytest-xdist`, `-n auto`; `PYTEST_WORKERS=0` for serial) | -| `make test-py-pg` | Python suite on Postgres (`SM_TEST_PG_URL`, default db `sm_test`, schema is dropped per test, so it runs serially; the plugin refuses `-n` on Postgres) | -| `make test-e2e` | Playwright smoke tests (requires `make dev` running + `uv run playwright install chromium`) | -| `make lint` | Ruff format-check + Ruff + `ty` + Biome + per-workspace `tsc` + 300-line file cap | -| `make doctor` | Module diagnostics (orphan pages, coupling violations, migration drift, locale checks) — same checks run at prod boot | -| `make migrate` / `make migration msg="..."` | Apply / autogenerate Alembic migrations (autogenerated on SQLite? review per `docs/module-authoring.md` § Migrations) | -| `make migrations-roundtrip-pg` | Postgres portability gate: `upgrade heads` → `downgrade base` → `upgrade heads` → `alembic check` on `SM_MIGRATIONS_PG_URL` (a disposable DB; CI runs it) | -| `make new-module name=` | Scaffold a new module package end-to-end | -| `make gen-pages` | Regenerate `host/client_app/modules.{manifest.json,generated.ts,generated.css}` from installed modules | - -Single test: `uv run pytest path/to/test_file.py::test_name` (in-process — `-n` is passed by `make test-py`, not `addopts`) (root `pyproject.toml` sets `asyncio_mode=auto` and `-m 'not e2e and not perf'`). A single JS test: `npx vitest run `. - -Entry point: `host/main.py` (`uv run --project host uvicorn host.main:app --reload`). Alembic runs from the repo root (`host/alembic.ini`) so it shares the `.env` / `SM_DATABASE_URL` with the API. +| `make install` | `uv sync --all-packages` + `npm install` | +| `make dev` | Shared Docker stack up + `gen-pages` + API (:8000) and Vite (:5050) | +| `make test` / `make test-py` / `make test-js` | Pytest (SQLite, e2e/perf excluded; parallel via `pytest-xdist`, `-n auto --dist loadgroup`, `PYTEST_WORKERS=0` for serial) then Vitest | +| `make test-py-pg` | Pytest on Postgres (`SM_TEST_PG_URL`, default db `sm_test`); serial, the plugin refuses `-n` there; not run in CI | +| `make test-e2e` | Playwright smoke tests against a running `make dev` | +| `make lint` | Ruff format + Ruff + `ty` + Biome + per-workspace `tsc` + file-size cap + hardcoded-string check + untranslated-string check + package metadata/README checks | +| `make doctor` | Module diagnostics (`SM0xx`); a dev boot runs the same plus `SM012`/`SM025` | +| `make migrate` / `make downgrade` / `make migration-history` | Alembic `upgrade heads` / `-1` / history | +| `make migration msg="..."` | Autogenerate; **currently fails** ("Multiple heads") — use the recipe in § Database | +| `make migrations-roundtrip-pg` | Postgres portability gate (upgrade → downgrade base → upgrade → `alembic check`); CI runs it | +| `make gen-pages` | Regenerate `host/client_app/modules.{manifest.json,generated.ts,generated.css}` (gitignored; needed before `tsc`/Vite) | +| `make gen-i18n` | Regenerate `packages/i18n/src/{keys.generated,generated-resources}.ts` (committed; CI diffs them) | +| `make new-module name=` | Scaffold a module package (see § Adding a module for what it does *not* do) | +| `make openapi` | Export `openapi.json` for typed clients | +| `make worker` / `make beat` | Celery worker / beat for `background_tasks` | + +Single tests: `uv run pytest path/to/test_file.py::test_name` from the repo root +(`asyncio_mode=auto`, `-m 'not e2e and not perf'` are set in `pyproject.toml`; +`-n` is passed by `make test-py`, not by `addopts`, so this runs in-process); +`npx vitest run ` for JS. Entry point: `host/main.py` +(`uv run --project host uvicorn host.main:app --reload`). Alembic runs from the +repo root via `host/alembic.ini`, so it shares `.env`/`SM_DATABASE_URL` with the +API. + +### Local environment + +- **No env var is required.** The default DB is SQLite (`./app.db`), so tests and + a plain local run need no services. `make dev` starts the author's *shared* + dev-services stack from `../dev-services/compose.yml` (Postgres on + `localhost:5432`, db `simple_module_python`; Redis on `localhost:6379`, one + `SM_REDIS_URL=redis://localhost:6379/4` seeds broker and backend). Never start + your own Postgres/Redis containers; `make docker-down` only stops this repo's + worker/beat. +- **Cloud/CI sessions have no Docker.** Launch API + Vite directly per + `.claude/skills/running-the-stack/SKILL.md` (spare ports, SQLite file DB, + `smpy users create-admin`). Prefer `make kill`'s *intent* over its command: it + runs `pkill -f vite` and kills other projects' servers, so kill by port. +- If a repo-root `.env` exists, read it (don't copy values) and move it aside + for `make test-py`, then restore it: the `settings` fixture does not pin + `vite_dev_url`, so a `.env` value leaks into a dashboard test that asserts + Vite on `:5050`. Never export `SM_ENVIRONMENT=production` in a shell that + runs pytest: the test plugin builds `BackgroundTasksSettings` eagerly and + production validators (localhost broker, placeholder secrets) abort it. +- The invalidation tests need `redis-server` on `PATH` or `SM_TEST_REDIS_URL`; + without either they skip locally and **fail** when `CI` is set. + +## Before you push + +Run what CI runs, in this order; a red push costs a review cycle. + +1. `make gen-pages` once per fresh checkout (generated frontend files are + gitignored; `tsc` and Vite fail without them). +2. `make lint`. Note `ruff format --check .` also formats Python fences inside + `.md` files, so run `uv run ruff format docs/` after editing docs. +3. `make test-py` and `make test-js`. +4. Touched any `locales/*.json`? `make gen-i18n` and commit the two generated + files; CI runs the generator and fails on a diff. The generator refuses to + drop a namespace (an unsynced venv looks like a deleted module); + `make gen-i18n ARGS=--allow-removals` is only for a module you really + removed or a deliberate `SM_MODULES_ENABLED` subset. +5. Added a module, a table or a migration? Also run `make doctor`, and review + the revision per `docs/module-authoring.md` § Reviewing a revision + autogenerated on SQLite (boolean `server_default` as `sa.true()/sa.false()`, + expression indexes by hand, enum downgrades via `drop_enums_if_postgres`). + `make migrations-roundtrip-pg` proves Postgres portability against a + disposable DB; where no Postgres is available (cloud sessions) skip it and + let CI run it. +6. User-visible change? Add a `## [Unreleased]` entry to `CHANGELOG.md` (Keep a + Changelog; cite issue numbers and migration revision ids; mark behaviour + changes). Every feature/fix PR in recent history does this. + +CI (`.github/workflows/pr.yml`, aggregate job `pr-checks`): python-lint (includes +the i18n up-to-date diff), python-typecheck, python-tests (Redis service), +js-tests, js-lint (includes the untranslated check), js-typecheck, js-build +(module CSS build guards), migrations-roundtrip-pg, e2e-smoke, perf-guards, +file-size-check, package-build. **Local-only checks** (in `make lint`, not in +CI, reviewers still reject on them): hardcoded strings, `check_metadata.py`, +`check_readmes.py`. e2e-smoke and perf-guards enable a hardcoded +`SM_MODULES_ENABLED` JSON list; a new module is not exercised there until added. ## Architecture -This is a **modular-monolith framework**. There is no host–module API boundary: modules are Python packages loaded into one FastAPI app. +**Discovery.** Modules declare `[project.entry-points.simple_module]` pointing +at a `ModuleBase` subclass with `meta = ModuleMeta(...)`. +`simple_module_core.discovery.discover_modules()` loads them, topologically sorts +by `ModuleMeta.depends_on`, and the host calls each `register_*` hook in that +order (`docs/framework/discovery.md`). + +**Two names per module.** `ModuleMeta.name` (scaffold default: PascalCase, +`FeatureFlags`) keys Inertia render paths, `depends_on` and `SM_MODULES_ENABLED`. +The Python package name (`feature_flags`) keys the i18n namespace, +`app.state.` and table prefixes. Don't swap them: `keys.` breaks if +the namespace is the meta name. -**Discovery.** Modules declare `[project.entry-points.simple_module]` in their `pyproject.toml` pointing at a `ModuleBase` subclass. `simple_module_core.discovery.discover_modules()` loads them, topologically sorts by `ModuleMeta.depends_on`, and the host invokes `register_*` hooks in that order. In production (`SM_ENVIRONMENT != development`) discovery is **strict** — any entry-point failure or missing/invalid `meta` raises at boot. +**Module layout** (`make new-module` scaffolds the files below except +`constants.py`, `tasks.py` and the CSS, plus `pyproject.toml`, `README.md`, +`LICENSE`, `package.json`, `tsconfig.json`, `tests/`): -**Module layout** (scaffolded by `make new-module`): ``` modules/// -├── module.py # ModuleBase subclass with meta = ModuleMeta(...) -├── models.py # SQLModel tables -├── contracts/ # SQLModel DTOs (public surface) — add a Protocol only for real extension points -├── service.py # business logic -├── deps.py # FastAPI dependencies -├── endpoints/api.py # REST (JSON) -├── endpoints/views.py # Inertia view endpoints -├── pages/*.tsx # auto-discovered by Vite via modules.generated.ts -├── theme.css # optional — @theme tokens; imported UNLAYERED -├── styles.css # optional — component rules; imported into layer(components) +├── module.py # ModuleBase subclass, meta = ModuleMeta(...) +├── constants.py # add it: PERM_*, ROLE_*, _PAGE_*, _MODULE_* — see § Conventions +├── models.py # SQLModel tables (table=True) +├── contracts/ # SQLModel DTOs — the public surface; add a Protocol only for real extension points +├── settings.py # DbBackedSettings subclass +├── services.py # app.state. container built in register_settings +├── service.py # business logic +├── deps.py # FastAPI dependencies +├── endpoints/api.py # REST (JSON) under /api/ +├── endpoints/views.py # Inertia view endpoints under view_prefix +├── pages/*.tsx # Inertia pages; lowercase-leading subdirs (components/) are helpers, not pages +├── tasks.py # optional Celery tasks (see § Conventions) +├── theme.css # optional @theme tokens, imported UNLAYERED +├── styles.css # optional component rules, imported into layer(components) └── locales/.json ``` -Both CSS files are optional and auto-detected; `gen-pages` emits an -`@import "#module//..."` for each, so nothing is added to the host's -`styles.css` by hand. The split is load-bearing: a `@theme` block inside a -cascade layer is inert, while unlayered CSS beats every Tailwind utility — -hence `SM022`/`SM023`. See `docs/module-authoring.md` § Styling. - -**Lifecycle hooks** (in `framework/core/simple_module_core/module.py`) — all no-op by default; subclasses override as needed: -`register_settings` → `register_menu_items` / `register_permissions` / `register_feature_flags` / `register_event_handlers` / `register_invalidations` / `register_health_checks` / `register_public_routes` / `register_csp_sources` / `register_setup_steps` → `register_exception_handlers` → `register_middleware` → `register_routes(api_router, view_router)` / `register_admin_routes(admin_router)` → async `on_startup` / `on_shutdown` (reverse order). `register_admin_routes` is only for modules that serve **both** public and admin pages: a module gets exactly one router per `view_prefix`, which `users` cannot express (sign-in at `/users/login`, management at `/admin/users`). Setting `ModuleMeta.admin_view_prefix` mounts a second view router there. A module whose views are *all* administrative just points `view_prefix` at `/admin/` and keeps using `register_routes`. The prefix is a URL convention, not a permission — guard these routes exactly as you would any other. `register_csp_sources(registry)` lets a module whitelist external asset origins (`registry.add("style-src", "https://rsms.me")`) — fetch directives only, validated at boot; an origin read from DB-backed settings uses `registry.add_provider(fn)`, evaluated per response. `register_public_routes(registry)` lets a module exempt anonymous/read-only routes (STAC/OGC, webhooks) from `AuthMiddleware`; rules are method-aware (`registry.add_regex(r"…/tilejson$", methods={"GET"})`), so a GET read route can be public while sibling POST/PATCH mutations under the same prefix stay gated. See [docs/framework/public-routes.md](docs/framework/public-routes.md). `register_setup_steps(registry)` lets a module declare what a usable install still needs; while any required step is incomplete `SetupMiddleware` serves the first-run wizard at `/setup` instead of the app. The wizard ships with `simple_module_hosting` (`setup_wizard/` — routes mounted by `create_app`, page `Setup/Wizard` registered by `gen-pages`), so hosts carry no setup code; a step completes from the browser through its `SetupAction`, which runs only while *that* step is pending. A module that registers nothing never gates — that is how `keycloak` opts out, since its local users table is legitimately empty forever and a host-level superuser count would lock those installs out permanently. `register_invalidations(bus, app)` subscribes a module's **per-process caches** to `InvalidationBus`, so another worker's write drops this worker's entry instead of leaving it stale for its whole TTL; handlers may only *forget*, since there is no delivery guarantee. Publishing takes no hook — `await request.app.state.sm.invalidation.publish(channel, key=...)` from a `db.on_commit` callback. Cross-process delivery needs a transport, which `background_tasks` installs on its Redis connection (`SM_BG_TASKS_BROADCAST_INVALIDATIONS`); with none the bus is in-process and every cache still needs its TTL as a floor. See [docs/framework/invalidation.md](docs/framework/invalidation.md). - -`MenuRegistry.add_provider(fn)` (from `register_menu_items`) contributes per-request menu items evaluated in `InertiaLayoutDataMiddleware` after auth/tenant resolution, and `PermissionRegistry.add_source(name, provider)` (from `register_permissions`) contributes runtime-defined permissions from a sync in-memory cache, refreshed with `invalidate_source(name)`; see [docs/framework/permissions.md](docs/framework/permissions.md). - -**Middleware pipeline** (Starlette `add_middleware` is LIFO — last added runs first). Execution order on a request: -`(ProxyHeaders, if SM_TRUSTED_PROXY) → CorrelationId → RequestLogging → BodyLimit → GZip → SecurityHeaders → Session → → Tenant (opt-in) → Locale → InertiaLayoutData → InertiaCache → RateLimit → Setup → Maintenance → CommitBeforeResponse → app`. `BodyLimit` refuses an oversized request body (`HostSettings.max_request_body_bytes`, default 10 MiB; per-path overrides via `register_body_limits`) on `Content-Length` or by counting streamed chunks, before GZip and every module see it; `RateLimit` sits *after* auth and Locale/layout data so it can tell anonymous from signed-in and render a translated 429, and by default throttles only anonymous requests to routes the public-route registry exempts (`rate_limit_public`, per client IP, Redis via `SM_REDIS_URL` else per-worker). See [docs/framework/request-guards.md](docs/framework/request-guards.md). `InertiaCache` answers for `InertiaLayoutData` merging per-user `auth`/`menus` into every payload: a response to an `X-Inertia` request is forced to `private, no-store` with its ETag dropped, and both representations of a URL gain `Vary: X-Inertia` — so no cache can store the JSON payload or hand it back for a page request. A module wanting its public page content cached should set `Cache-Control` and an ETag on the *document*; that path is left alone. `GZip` compresses any response over 500 bytes, including the `/static` mount — the built CSS is ~139 KB raw versus ~21 KB gzipped, and uncompressed assets dominated cold page load. `ProxyHeaders` (uvicorn's `ProxyHeadersMiddleware`) is installed only when `SM_TRUSTED_PROXY` is set, sitting outermost so the `X-Forwarded-*`-corrected scheme/client IP reach everything downstream (request logs). Inertia does not depend on it: the page url is rewritten to the root-relative form the protocol specifies (`_inertia_url.py`), so no scheme travels in the payload to disagree with the document's — the cross-scheme `pushState` `SecurityError` of GH #223 cannot recur on an install that never set the variable. When two modules add middleware at the same dependency tier, the module that sorts **later** wraps outermost. Use `depends_on` to express relative order — don't rely on names. `Maintenance` serves a 503 page to everyone but admins while `maintenance_mode` is set on `HostSettings`; it sits inside `InertiaCache` because its 503 is an Inertia payload produced by short-circuiting, and outside the cache guard that payload would ship storable. `Setup` runs just before it, for the same cache reason and because an install that was never set up has nothing meaningful to put into maintenance. - -**Database**: per-module `Base` via `create_module_base("")`. On SQLite `init_db` enables WAL, an explicit `busy_timeout`, and **`foreign_keys=ON`** — so both sides of an FK must use the same column type (`sa.Uuid` and fastapi-users' `GUID` are identical on Postgres but not on SQLite), and parents must be flushed before children. Every module owns its own `MetaData` (so Alembic autogenerate can attribute tables to a module), but all tables live in the host's single schema. `__tablename__` must be prefixed with the module name to avoid collisions (`orders_order`). Postgres and SQLite share the same layout. - -Standard mixins in `simple_module_db.mixins`: `AuditMixin`, `SoftDeleteMixin` (bypass the read filter with `stmt.execution_options(include_deleted=True)`; purge by deleting an already-trashed row, or with `hard_delete(session, obj)`), `MultiTenantMixin`, `VersionedMixin`. The soft-delete/tenant filters cover every statement shape, not only selects that name the entity — joins, ORM subqueries, `select(func.count()).select_from(Model)`, and Core statements over `Model.__table__` (GH #332). **Tenancy fails closed**: with `multi_tenant` on, a query or insert on a `MultiTenantMixin` model with no `current_tenant_id` raises `TenantIsolationError` instead of reading every tenant; cross-tenant code says so with `all_tenants()` / `execution_options(all_tenants=True)`, and jobs/CLI act for one tenant with `tenant_context(id)`. With `multi_tenant` off and no tenant bound, inserts are stamped `DEFAULT_TENANT_ID` (`"default"`, from `simple_module_db`) and reads stay unfiltered; adoption migrations backfill with the same constant. Tenant roles reach the principal as `tenant:` — map them with `tenant_role(TenantRole.MEMBER)` from `simple_module_core.tenancy`, never by importing `tenants`; tests use the `tenant_client(role)` fixture. Unique keys on such tables must include `tenant_id` (`SM024`). The `tenants` module owns organisations, memberships and `app.state.tenant_resolver`; tenant-level routes act on the *active* tenant, never a tenant id from the URL. See [docs/framework/multi-tenancy.md](docs/framework/multi-tenancy.md). The per-request session (`get_db`) auto-commits **only if** there are pending writes (via `after_flush` listener); otherwise rollback. Service code should **not** call `session.commit()` — flush if you need DB-assigned values. DML executed through the session (`session.execute(update(Model)...)`) counts as a write; a raw `text("UPDATE ...")` does not, and needs `mark_written(session)`. The commit fires in `CommitBeforeResponseMiddleware`, at the ASGI `http.response.start` message, so a client that creates a row and immediately reads it back in a second request sees it — FastAPI runs a `yield` dependency's exit code *after* the response is delivered, which used to make that a deterministic 404 (GH #257). `get_db` keeps the same commit in its own exit code as a fallback for when the middleware isn't in the stack; whichever runs first wins. - -**Migrations** live in `host/migrations/versions/` — not in module packages. `host/alembic/env.py` calls `build_module_metadata()` + `make_include_object()` so autogenerate covers every installed module and ignores host-owned tables. First migration of each module should set `branch_labels = ("",)`. The label is only a **named target** (`alembic upgrade/downgrade @`, and the `module` column of the Doctor migration list): because autogenerate chains each first revision off the current head, the history is one linear chain, and `downgrade @base` walks the **entire chain beneath that revision, other modules included** — it is not a per-module rollback. To remove one module's schema, downgrade to the `down_revision` of its first revision, which is safe only while no other module's revisions sit above it; otherwise write a dedicated migration that drops that module's tables. See [docs/database/migrations.md](docs/database/migrations.md) § Removing one module's schema. - -**Postgres extensions & types.** A module needing an extension overrides `ModuleBase.db_extensions()` (`["ltree"]`); `host/migrations/env.py` calls `ensure_db_extensions` before the revisions run (`CREATE EXTENSION IF NOT EXISTS`; on SQLite it raises `UnsupportedDatabaseError`, SM029 warns early). `simple_module_db.types.LTree` / `TSVector` are `TEXT` on SQLite. Tests that need Postgres carry `@pytest.mark.postgres` and skip unless `SM_TEST_DATABASE_URL` is Postgres. See [docs/database/extensions.md](docs/database/extensions.md). - -**Admin section**. Administrative screens live under `/admin/*`, register into `MenuSection.ADMIN_SIDEBAR`, and render in `AdminLayout` — all three together, not one of the three. `SidebarLayout` renders whichever menu its `menuKey` names, so a page left on `AuthenticatedLayout` after its menu item moved shows a sidebar that no longer contains it. `group=` sub-clusters *within* the admin sidebar (`Access`, `Appearance`, `System`); it is no longer used to carve an admin area out of the main sidebar. `/admin` itself is a host route (`host/routes.py`) that renders from the `adminSidebar` shared prop, so an installed module contributes a card without touching it. Old URLs 301 from `host/routes_legacy.py`. Only view URLs moved — `/api/*` is a separate contract and stays put. - -**Inertia**. `inertia.render("/", ...)` maps to `modules///pages/.tsx`, where `` is the PascalCase of the module directory (`blog_posts` → `BlogPosts`). Host-level pages under `host/client_app/pages/` use a bare ``. `InertiaLayoutDataMiddleware` populates shared props (`auth`, `menus`, `i18n`); use `InertiaDep` from `simple_module_hosting.inertia_deps`. Mismatched keys fire `SM003` (orphan page) / `SM004` (phantom render). The adapter is the in-repo package `simple_module_inertia` (Inertia v3 protocol); import `InertiaResponse` and the prop factories (`optional`, `always`, `defer`, `merge`, `once`, `scroll`) from it. -**CSRF defence**. Baseline protection comes from `SameSite=Lax` on the session cookie (Starlette default): browsers don't attach the cookie to cross-site POST/PUT/DELETE, so a forged form-submit from another origin is unauthenticated. Raw `fetch()` calls in page code don't need a token header by default. Modules wanting defence in depth opt into `simple_module_hosting.csrf` — `RequiresCsrf` as a router dependency plus `get_csrf_token(request)` exposed as a view prop; callers echo it as `X-CSRF-Token` on unsafe methods. - -## Conventions to follow - -- **SQLModel is the project-wide standard for every model** — both DB tables (`table=True`) and DTOs (plain `SQLModel` subclasses). Do not use Pydantic `BaseModel` or SQLAlchemy `DeclarativeBase` + `Mapped[...]` in module code. -- **300-line cap** on `.py`/`.ts`/`.tsx` files, enforced by `scripts/check_file_size.py` in CI (exempts vendored shadcn components under `packages/ui/src/components/ui/**`). If you approach the cap, split by responsibility — don't rewrite to squeeze under. -- **Per-module settings**: env-var prefix `SM__*`, stored on `app.state.` inside `register_settings(app)` as a module-owned dataclass. `SM012` warns if `register_settings` is overridden without adding `app.state.`. -- **Configuration lives in the DB, not `.env`.** Only `SM_DATABASE_URL` is required; `SM_REDIS_URL` and the optional `SM_USERS_BOOTSTRAP_*` are the only others an operator normally sets. `SM_ENVIRONMENT`/`SM_DEBUG`/`SM_VITE_DEV_URL` describe the process rather than the configuration. Precedence is always **env → DB → default**; env must keep winning or existing deployments change behaviour silently on upgrade. -- **Settings are read twice per boot.** `create_app` builds the module list, the i18n registry and the middleware stack in Phases 1 and 8 — all before the lifespan opens the DB — so `_preapp_config.merge_host_settings` does one short-lived read *before* Phase 1. The lifespan then hydrates again for runtime edits. A `HostSettings` field consumed at build time is inert without that first read: this is why `i18n_*` and `multi_tenant` edits in the admin UI used to do nothing. **`host/main.py` must call `merge_host_settings()`, not `Settings()`** — it passes settings into `create_app`, so `create_app`'s own fallback never fires there. -- **`SM_SECRET_KEY` is optional**: unset generates a key and stores it (atomically — concurrent workers must converge, or they invalidate each other's sessions). An explicit env value wins and is never copied into the DB. -- **Framework vs plugin coupling**: `SM009` is an error if `framework/*` directly imports from a plugin module. Framework code must not reach into `modules/`. -- **Zod schemas with translated messages** must be constructed inside a hook (`useT()`) — never at module scope, or they freeze against the first render's locale. -- **Locales**: ship `/locales/.json` and declare in `ModuleBase.locale_dirs()` with the module's lowercase name as the namespace. `{"browse": {"title": "X"}}` flattens to `.browse.title`. Pluralize with CLDR suffixes (`_one`, `_other`, ...). -- **No user-visible string literals in `.tsx`** — enforced by `make ci-check-untranslated`, see § CI. Every rendered string — JSX text, `placeholder`, `aria-label`, ``, `toast.*()`, confirm text — goes through `t(keys..…)` from `@simple-module-py/i18n`. Exempt: shell commands, env-var names, and JSON examples shown as literal ``. In non-component modules (a `retry.ts` helper) import the non-hook `t` and call it *inside* the function, never at module scope, or it freezes against the boot locale. -- **Menu labels**: set `label_key`/`group_key` on `MenuItem` next to `label`/`group`; group headers use the shared `ui.nav_groups.*` keys. Menus are translated server-side in `MenuRegistry.get_for_user(translate=…)`, and an unresolved key falls back to the literal `label`. See [docs/framework-conventions.md](docs/framework-conventions.md) § Shared props. -- Regenerate `packages/i18n/src/{keys.generated,generated-resources}.ts` after touching any catalog — booting the host in development does it, and `t()` only accepts keys present there. -- **Ty (type checker) false positives** from SQLModel: `unresolved-attribute`, `unsupported-operator`, `unknown-argument`, `no-matching-overload`, `invalid-argument-type` are all globally ignored in `pyproject.toml` because SQLModel declares fields with plain Python types while runtime instruments them as SQLAlchemy attributes. Do not re-enable these rules — real bugs surface in tests. - -## Diagnostic codes - -Meaningful codes when reading `make doctor` output: `SM001` missing meta (error), `SM003` orphan page / `SM004` phantom render (warn), `SM007` module overrides no hooks (info), `SM008` duplicate name (error), `SM009` framework→plugin import (error), `SM010` DB revision behind head (error), `SM011` module table not in migration history (warn), `SM012` `register_settings` overridden but nothing on `app.state.` (warn, fires at dev boot only), `SM013`–`SM016` locale issues, `SM017` module ships `.tsx` pages but is missing `package.json`/`tsconfig.json` (warn), `SM018` Inertia `router.{post,patch,put,delete}()` in a page targets a JSON `/api/*` endpoint (warn — Inertia rejects non-Inertia responses), `SM019` module registers view routes (non-empty `view_prefix` + overrides `register_routes`) but overrides neither `register_menu_items` nor `register_permissions` (warn — pages exist with no sidebar entry and no role-editor visibility; admins can't reach them through the UI). Modules whose views are sub-pages of another module typically register permissions to stay discoverable in the role editor without needing their own sidebar entry. `SM020` multiple auth provider modules installed (error), `SM021` no auth provider module installed (warn), `SM022` `@theme`/`@custom-variant`/`@utility` in a module's `styles.css`, where `layer(components)` makes them inert (warn), `SM023` an unlayered rule in a module's `theme.css`, which outranks every Tailwind utility (warn). `SM024` a unique key on a `MultiTenantMixin` table that omits `tenant_id` (warn). `SM025` `multi_tenant` is on but no module registered `app.state.tenant_resolver` (warn — checked at boot after module registration, in every environment, not by the `make doctor` CLI). `SM026` the database is SQLite and a model declares an expression index it cannot verify (info — autogenerate and `alembic check` skip those on SQLite; add them by hand and run `make migrations-roundtrip-pg`). `SM027` a host locale override (`host/locales/overrides/.json`) names a key no catalog defines (warn — skipped; reported by `make doctor` and at dev boot). `SM028` a module's `tasks.py` imports a heavy package (`torch`, `tensorflow`, `ultralytics`, `osgeo`, `rasterio`, `cv2`, `sklearn`) at module level (warn — import it inside the task). `SM029` a module declares `db_extensions()` and the database is SQLite (info — `ensure_db_extensions` refuses to run migrations there). In production, errors fail boot. +`gen-pages` writes `modules.generated.css` with an `@import` per CSS file that +exists. A `@theme` inside a cascade layer is inert and an unlayered rule beats +every Tailwind utility, so keep tokens in `theme.css` and rules in `styles.css` +(`SM022`/`SM023`). See `docs/module-authoring.md` § Styling. + +**Lifecycle hooks** on `ModuleBase` (all no-op by default; order and examples in +`docs/framework/lifecycle.md`): `register_settings`, `register_menu_items`, +`register_permissions`, `register_feature_flags`, `register_event_handlers`, +`register_invalidations`, `register_health_checks`, `register_setup_steps`, +`register_public_routes`, `register_body_limits`, `register_csp_sources`, +`register_design_packs`, +`register_audit_links`, `register_exception_handlers`, `register_middleware`, +`register_routes(api_router, view_router)`, `register_admin_routes(admin_router)` +(only with `ModuleMeta.admin_view_prefix`), then async `on_startup` / +`on_shutdown` (reverse order). Non-hook overrides: `locale_dirs()`, +`db_extensions()`, `template_dirs()`, `static_mounts()`. + +- A module gets one view router per prefix. All-admin modules point + `view_prefix` at `/admin/`; modules with both public and admin pages set + `admin_view_prefix` and use `register_admin_routes`. A prefix is a URL + convention, not a permission: guard every route. +- `register_public_routes` exempts routes from `AuthMiddleware`, method-aware + (`registry.add_regex(r"…/tilejson$", methods={"GET"})`): + `docs/framework/public-routes.md`. +- `register_invalidations` subscribes per-process caches to `InvalidationBus`; + handlers may only *forget* (no delivery guarantee), so every cache keeps its + TTL as a floor. Publish from a `db.on_commit` callback with + `await request.app.state.sm.invalidation.publish(channel, key=...)`. + Cross-process delivery needs the Redis transport `background_tasks` installs + (`SM_BG_TASKS_BROADCAST_INVALIDATIONS`); without it the bus is in-process. + `docs/framework/invalidation.md`. +- `register_csp_sources(registry)` whitelists external asset origins + (`registry.add("style-src", "https://rsms.me")`): fetch directives only, + validated at boot; an origin that comes from DB settings uses + `registry.add_provider(fn)`, evaluated per response. +- `register_setup_steps` declares what a usable install still needs; while a + required step is pending `SetupMiddleware` serves the wizard at `/setup` (it + ships in `simple_module_hosting`, hosts add nothing). A step's `SetupAction` + runs only while that step is pending. A module that registers nothing never + gates. `docs/module-authoring.md` § First-run setup steps. +- Events: subscribe in `register_event_handlers`, publish with + `await bus.publish(Event(...))`; dispatch keys on the exact class. Durable + delivery (outbox via `background_tasks`): `bus.subscribe_durable` + + `await bus.publish_durable(event, db)` inside the request transaction; a + durable handler must not commit `ctx.db`. `docs/framework/events.md`. +- Menus: `MenuRegistry.add_provider(fn)` for per-request items; permissions: + `PermissionRegistry.add_source(name, provider)` + `invalidate_source(name)`. + `docs/framework/permissions.md`. + +**Middleware** (Starlette `add_middleware` is LIFO). Execution order per +request: `(ProxyHeaders if SM_TRUSTED_PROXY) → CorrelationId → RequestLogging → +BodyLimit → GZip → SecurityHeaders → Session → → Tenant +(opt-in) → Locale → InertiaLayoutData → InertiaCache → RateLimit → Setup → +Maintenance → CommitBeforeResponse → app`. Rules that follow from it: + +- Modules at the same dependency tier: the one that sorts later wraps outermost. + Use `depends_on` to order, never names. +- Any `X-Inertia` response is forced `private, no-store`; to cache a public + page, set `Cache-Control`/ETag on the *document* response only. +- `RateLimit` runs after auth, so by default it throttles only anonymous + traffic to public routes (`rate_limit_public`; `rate_limit_authenticated` is + off by default; Redis via `SM_REDIS_URL`, else per-worker). +- Request bodies over `max_request_body_bytes` (10 MiB) are refused before any + module sees them; per-path overrides via `register_body_limits`. + +Per-middleware detail: `docs/framework/middleware.md`, +`docs/framework/request-guards.md`. + +**Admin section.** Admin screens live under `/admin/*`, register into +`MenuSection.ADMIN_SIDEBAR` and render in `AdminLayout`, all three together. +`SidebarLayout` renders the menu its `menuKey` names, so a page left on +`AuthenticatedLayout` after its item moved shows a sidebar without it. +`group=` sub-clusters inside a sidebar (`Access`, `Appearance`, `System`). +`/admin` itself is a host route (`host/routes.py`) rendered from the +`adminSidebar` shared prop; old URLs 301 from `host/routes_legacy.py`. +`/api/*` paths never move. + +**Inertia.** `inertia.render("/")` maps to +`modules///pages/.tsx`; host pages under +`host/client_app/pages/` use the bare name. Mismatches fire `SM003`/`SM004`. +Use `InertiaDep` from `simple_module_hosting.inertia_deps`; `InertiaResponse` +and prop factories (`optional`, `always`, `defer`, `merge`, `once`, `scroll`) +come from `simple_module_inertia`. Shared props `auth`, `menus`, `i18n` are set +by `InertiaLayoutDataMiddleware`; `auth.user` is null unless +`app.state.principal_serializer` is set, and `i18n.messages` is null on every +`X-Inertia` request unless the locale or audience changed. Inertia +`router.post/patch/put/delete` must target view routes, never `/api/*` +(`SM018`). `docs/frontend/`. + +**CSRF.** Baseline is `SameSite=Lax` on the Starlette session cookie; raw +`fetch()` in pages needs no token. Defence in depth: `RequiresCsrf` router +dependency + `get_csrf_token(request)` as a view prop, echoed as `X-CSRF-Token` +(`simple_module_hosting.csrf`). + +## Database + +- Per-module `Base` via `create_module_base("")`; every module owns its + `MetaData` but all tables share one schema. Prefix `__tablename__` with the + module name (`orders_order`); the scaffold does it, nothing enforces it. +- Mixins in `simple_module_db.mixins`: `AuditMixin`, `SoftDeleteMixin`, + `MultiTenantMixin`, `VersionedMixin`. Soft-delete and tenant filters cover + every statement shape (joins, subqueries, `select_from`, Core over + `Model.__table__`). Bypass with `execution_options(include_deleted=True)`; + purge with `hard_delete(session, obj)` from `simple_module_db` or by deleting + an already-trashed row. `docs/database/mixins.md`. +- A bare `datetime` field already maps to a UTC-aware type. When you declare + an explicit column type, use `sa_type=AwareDateTime()` + (`simple_module_db.types`), never `DateTime(timezone=True)`, which reads back + naive on SQLite. +- SQLite runs with WAL (file DBs), `busy_timeout` and **`foreign_keys=ON`**: + both sides of an FK need the same column type (`sa.Uuid` vs fastapi-users' + `GUID` differ on SQLite) and parents must be flushed before children. +- **Sessions.** The request session (`get_db`) commits in + `CommitBeforeResponseMiddleware` only if something was written (ORM flush or + `session.execute(update/insert/delete)`); otherwise it rolls back. Raw + `text("UPDATE …")` needs `mark_written(session)`. Service and request code + must not call `session.commit()`; flush when you need DB-assigned values. Use + `session.on_commit(cb)` for post-commit side effects. + `docs/database/sessions.md`. +- **Tenancy fails closed.** With `multi_tenant` on, a query or insert on a + `MultiTenantMixin` model with no current tenant raises `TenantIsolationError`. + Cross-tenant code says so with `all_tenants()` / + `execution_options(all_tenants=True)`; jobs act for one tenant with + `tenant_context(id)` (both in `simple_module_db.tenancy`). Off, rows are + stamped `default_tenant` when set, else `DEFAULT_TENANT_ID` (`"default"`); + adoption migrations backfill with the same constant. Unique keys on tenant + tables must include `tenant_id` (`SM024`). Tenant-level routes act on the + *active* tenant, never a tenant id from the URL. The `tenants` module owns + memberships and `app.state.tenant_resolver` (`SM025` if missing); map tenant + roles with `tenant_role(TenantRole.MEMBER)` from `simple_module_core.tenancy`, + never by importing `tenants`. `docs/framework/multi-tenancy.md`. +- **Migrations** live in `host/migrations/versions/`, never in module packages. + `host/migrations/env.py` builds metadata from every installed module and runs + `ensure_db_extensions` first. The history has **several heads** (one per + labelled branch), so apply with `upgrade heads` (what `make migrate` does) + and autogenerate against one branch, since plain `revision --autogenerate` + refuses with "Multiple heads": + + ``` + uv run --project host alembic -c host/alembic.ini revision --autogenerate \ + -m "" --head @head + ``` + + A module's first revision sets `branch_labels = ("",)` (older + modules lack it); the label is a named target, not a per-module rollback: + `downgrade @base` walks everything beneath it. + `docs/database/migrations.md`. +- Postgres extensions: override `ModuleBase.db_extensions()`; `LTree`/`TSVector` + types are `TEXT` on SQLite; tests needing Postgres carry + `@pytest.mark.postgres`. `docs/database/extensions.md`. + +## Conventions + +- **SQLModel everywhere**: tables are `SQLModel, table=True`; DTOs are plain + `SQLModel` subclasses. No Pydantic `BaseModel`, no SQLAlchemy + `DeclarativeBase`/`Mapped[...]` in module code. +- **No magic strings in non-test Python** (`scripts/check_hardcoded_strings.py`): + `RequiresPermission(...)`, `registry.map_role(...)`, `registry.add_group(...)` + items, `inertia.render(...)` keys and `depends_on=[...]` entries must be + constants from `constants.py` (`PERM_*`, role names, `_PAGE_*`, `_MODULE_*`). +- **300-line cap** on `.py`/`.ts`/`.tsx`, tests included, untracked files + included (`scripts/check_file_size.py`). Exempt: shadcn components under + `packages/ui/src/components/ui/**`, generated i18n files, + `host/migrations/versions/**`. Split by responsibility; don't squeeze. +- **Configuration lives in the DB, not `.env`.** Operators normally set only + `SM_DATABASE_URL`, `SM_REDIS_URL` and `SM_USERS_BOOTSTRAP_*`; + `SM_ENVIRONMENT`/`SM_DEBUG`/`SM_VITE_DEV_URL` describe the process. Do not + add new configuration as env vars. Host settings: precedence + **env → DB → default**, and env must keep winning or deployments change + behaviour on upgrade. `host/main.py` must call `merge_host_settings()`, not + `Settings()`: `create_app` builds modules, i18n and middleware before the + lifespan opens the DB, so a `HostSettings` field read at build time needs + that pre-read. `SM_SECRET_KEY` is optional: unset generates and stores a key + atomically; an explicit value wins and is never copied into the DB. + Module settings are a `DbBackedSettings` subclass (constructor + DB, no env + source) registered in `register_settings` through + `settings.registration.register_module_settings(app, package, SettingsCls, + services_factory)`, which mounts the services object on `app.state.` + (`SM012`). The exceptions that read env are secrets and process wiring + (`SM_BG_TASKS_*`, `SM_KEYCLOAK_*`, `SM_USERS_*_TOKEN_SECRET`). + `docs/framework/settings.md`, `docs/reference/env-vars.md`. +- **Framework must not import plugins** (`SM009`): nothing under `framework/` + imports from `modules/`; the framework exposes registries and callbacks + instead. A module may import the modules it lists in `depends_on` directly. +- **Background tasks**: `tasks.py` with `@shared_task(name=".")`; + heavy imports (`torch`, `cv2`, …) inside the task body (`SM028`). Enqueue from + a transaction with `background_tasks.enqueue_after_commit(db, task, …)`, never + `.delay()`. Long tasks call `heartbeat()`. `docs/modules/background_tasks.md`. +- **Audit links / design packs**: `register_audit_links` takes + `AuditLink(entity_type=Model.__name__, …)` (class name, not table name, or it + silently never matches); `register_design_packs` slugs must match + `^[a-z0-9][a-z0-9-]*$` and the host must still import the CSS. +- **i18n.** Ship `/locales/.json`, declare in `locale_dirs()` with + the lowercase package name as namespace; `{"browse": {"title": "X"}}` becomes + `.browse.title`; plurals use CLDR suffixes (`_one`, `_other`). Menu items + set `label_key`/`group_key` beside `label`/`group` (an unresolved key falls + back to the literal, silently); group headers use `ui.nav_groups.*`. + **No user-visible string literals in `.tsx`**: JSX text, + `title`/`placeholder`/`aria-label`-style attributes, `toast.*()`/`confirm()` + arguments and ternary branches all go through `t(keys..…)` from + `@simple-module-py/i18n`. Exempt: text inside ``/`
`/``/
+  ``/``, a line marked `// i18n-exempt: ` (or the line
+  above), or `i18n-exempt-file: ` within the first 800 characters.
+  Strings passed through variables or config objects are not seen, so still
+  translate them. Zod schemas with translated messages, and the non-hook `t` in
+  helpers, are built inside the function, never at module scope.
+  `docs/framework/i18n.md`.
+- **JS tests** go in `modules//tests-js/*.test.ts(x)`, `packages/**` or
+  `host/client_app/**`; Vitest collects nothing else. Biome (single quotes,
+  2 spaces, width 100) lints `host/client_app`, `packages`, module `pages/` and
+  `components/` dirs; `tsc` runs per workspace, and a module with `.tsx` but no
+  `tsconfig.json` fails `make ci-js-typecheck`.
+- **`ty` false positives** from SQLModel (`unresolved-attribute`,
+  `unsupported-operator`, `unknown-argument`, `no-matching-overload`,
+  `invalid-argument-type`, `invalid-assignment`) are ignored in `pyproject.toml`.
+  Don't re-enable them; tests catch the real bugs.
+
+## Adding a module or package
+
+`make new-module name=` scaffolds the package, wires it into
+`host/pyproject.toml` and the root `testpaths`/`extra-paths`, and runs
+`uv sync`. Expect `make lint` to fail right after: the scaffold still emits a
+literal `inertia.render("/Create")`, permission and menu strings
+without constants or `label_key`, and sets `app.state.` directly instead
+of calling `register_module_settings`. Fix those first. It also does **not**:
+
+1. Run `npm install`, so regenerate and commit `package-lock.json` (CI uses
+   `npm ci`).
+2. Add `simple_module_` to the `publish-pypi` matrix in
+   `.github/workflows/release.yml`; a test and `check_metadata.py` fail until
+   you do. `pyproject.toml` needs description, `readme`, `license = "MIT"`,
+   keyword `simple-module`, `urls.Repository`; `README.md` needs ≥ 500 bytes, an
+   H1 and Install/Usage sections.
+3. Write `docs/modules/.md`, add it to `docs/modules/index.md` and the
+   VitePress sidebar in `docs/.vitepress/config.ts`.
+4. Set `branch_labels` on the first migration, or add a `CHANGELOG.md` entry.
+
+A new `framework/*` package is wired by hand: root `pyproject.toml`
+`testpaths` + `[tool.ty.environment].extra-paths` and the `publish-pypi`
+matrix. A new `packages/*` npm package goes into the `publish-npm` matrix.
+Unlisted test directories are silently never collected.
+
+## Diagnostics
+
+`make doctor` and a development boot run the `SM0xx` checks; the table with
+triggers and fixes is `docs/reference/diagnostic-codes.md`. **Production does
+not run the suite** (only `SM025` is reported there). What fails a production
+boot is strict discovery (a bad entry point or missing `meta`), the
+auth-provider check, placeholder secrets (`SM_SECRET_KEY`, users token
+secrets), a localhost Celery broker, and the `SM010` migration check (except
+on a never-migrated first run, which goes to `/setup`). Catch everything else
+with `make doctor` before shipping. Codes that
+change how you write code: `SM003`/`SM004` render-key mismatch, `SM009`
+framework→plugin import, `SM011` table missing from migrations, `SM012`
+settings not on `app.state`, `SM013`–`SM016` locale drift, `SM017` module
+pages without `package.json`/`tsconfig.json`, `SM018` Inertia call to `/api`,
+`SM019` views with no menu item or permission, `SM022`/`SM023` CSS in the wrong
+file, `SM024` unique key without `tenant_id`, `SM028` heavy import in
+`tasks.py`.
 
 ## Tests & fixtures
 
-The `simple_module_test` plugin provides app-level fixtures available to every test directory — auto-loaded via its `pytest11` entry point (defined in `framework/testing/simple_module_test/fixtures.py`), so the root `conftest.py` is intentionally thin:
-- `settings` — in-memory SQLite `Settings` with `multi_tenant=True`. Set `SM_TEST_DATABASE_URL=postgresql+asyncpg://…` to run the fixtures (and the tenancy DB tests) on Postgres instead; each test then starts from an empty `public` schema, reset once per test so `app` and `db_session` share it (`simple_module_test.database`). `make test-py-pg` runs the whole suite that way, with `-p no:anyio`: an `@pytest.mark.anyio` test would run on a different event loop from its async fixtures, and an asyncpg connection cannot cross loops.
-- `db_state`, `engine`, `db_session` — fresh in-memory `DatabaseState` per test; `db_session` also creates all module tables and stamps `alembic_version` at head so the boot-time migration check passes.
-- `app` — `create_app(settings)` with lifespan started/stopped.
-- `client` / `authenticated_client` — `httpx.AsyncClient`; `authenticated_client` seeds an admin via `users.bootstrap.create_admin` and carries a forged session cookie.
-- Importing the plugin drops `PasswordHelper()`'s Argon2/bcrypt work factor to the minimum (hashes stay real `$argon2`/`$2b`); at production cost hashing the seeded admin was ~90 ms of every `app` fixture. Pass `password_hash=` explicitly to test real parameters, or set `SM_TEST_REAL_PASSWORD_HASHING=1` to opt a whole run out.
-- Tests must collect identically in every xdist worker: no `uuid4()`/random values in `parametrize` ids, and anything shared across processes (a Redis channel, a file) needs a per-worker name.
-
-E2E tests live in `tests/e2e/` behind the `e2e` pytest marker and run against a live server — see [docs/e2e-testing.md](docs/e2e-testing.md).
-
-## CI
-
-`.github/workflows/pr.yml` runs Python lint / typecheck / tests, JS lint / typecheck / tests, the 300-line file-size check, and the untranslated-string check as parallel jobs; `make lint` locally runs the same checks serially. Branch protection requires the aggregate `pr-checks` job.
-
-`make ci-check-untranslated` (`scripts/check_untranslated_strings.mjs`) parses every `.tsx` and fails on user-visible text rendered as a literal — JSX text, a `title`/`placeholder`/`aria-label`-style attribute, or a `toast.*()`/`confirm()` argument — including copy hidden in `cond ? 'A' : 'B'`. It parses with `@babel/parser` rather than grepping, because no regex over JSX can tell `

Save

` from `Promise`. It does **not** see strings passed through a variable or a config object (`const THEME = { mobileTitleLabel: 'Admin' }`), so those still need care. - -To exempt a genuinely technical literal: wrap it in ``/`
`, or mark the line `// i18n-exempt: `; `i18n-exempt-file: ` in a file's first lines skips the whole file.
-
-## Delegating to subagents
-
-Pick the subagent's model for the task, not the most capable one available. Don't run everything on `opus` or `fable`:
-
-- **`haiku`**: search, file discovery, and mechanical work like renames, checking docs against code, or collecting test output.
-- **`sonnet`**: routine implementation, functional and end-to-end testing, and regression runs.
-- **`opus`**: design, security and isolation reasoning, and adversarial review, where a wrong answer is expensive.
-
-Pass `model` explicitly on every `Agent` call, even when the default would be correct.
+`simple_module_test` (`framework/testing/`, auto-loaded via `pytest11`) provides
+app-level fixtures to every test directory; the root `conftest.py` is thin.
+`docs/testing/fixtures.md`.
+
+- `settings`: in-memory SQLite, `multi_tenant=True`, `auth_provider="users"`.
+  `SM_TEST_DATABASE_URL=postgresql+asyncpg://…` switches the whole suite to
+  Postgres with a per-test schema reset (`make test-py-pg` does this, with
+  `-p no:anyio` because an asyncpg connection cannot cross event loops).
+- `db_state`, `engine`, `db_session`: fresh `DatabaseState` per test;
+  `db_session` creates all module tables and stamps `alembic_version` at heads.
+- `app`: `create_app(settings)` with lifespan, seeds `admin@test`; `client` /
+  `authenticated_client` (`httpx.AsyncClient`, the latter with a forged admin
+  session); `tenant_client(role)` for tenant-scoped requests;
+  `setup_pending_app`, `fake_event_bus`, `build_test_app`, `redis_server`.
+- Module-specific fixtures live in each module's `tests/conftest.py`
+  (`background_tasks`, `file_storage` override some of the above).
+- Importing the plugin drops `PasswordHelper()`'s Argon2/bcrypt work factor to
+  the minimum (hashes stay real `$argon2`/`$2b`); production-cost hashing of
+  the seeded admin was ~90 ms per `app` fixture. Pass `password_hash=` to test
+  real parameters, or set `SM_TEST_REAL_PASSWORD_HASHING=1` for a whole run.
+- The suite runs under xdist, so tests must collect identically in every
+  worker: no `uuid4()`/random values in `parametrize` ids, and anything shared
+  across processes (a Redis channel, a file) needs a per-worker name.
+- E2E lives in `tests/e2e/` behind the `e2e` marker against a live server
+  (`docs/e2e-testing.md`); it needs `uv run playwright install chromium` once
+  and `SM_RATE_LIMIT_PUBLIC=1000/minute` on the server.
+
+## Working in parallel
+
+Split work into independent slices and run them at once instead of serially:
+verification of claims against code, surveys across many files, test runs per
+package, and adversarial review are all parallel-safe. Keep sequential what
+depends on an earlier result. One brief per slice: state the goal, what is
+already ruled out, the files to read, and the exact output wanted. Treat every
+report as claims to verify, not as instructions.
+
+**Pick the subagent model for the task; never run everything on the largest
+model.** Pass `model` explicitly on every `Agent` call:
+
+- `haiku`: search, file discovery, mechanical work (renames, checking docs
+  against code, collecting test output).
+- `sonnet`: routine implementation, functional and end-to-end testing,
+  regression runs, convention surveys.
+- `opus`: design, security and isolation reasoning, adversarial review, where a
+  wrong answer is expensive.
+
+**Use Codex as a subagent when it is available.** The Codex CLI
+(`@openai/codex`) is installed and authenticated on some machines, not on cloud
+sessions. Probe once per session with `command -v codex && codex login status`;
+if it is missing or prints `Not logged in`, use Claude subagents only, and do
+not install it, `npx` it or retry. When it is authenticated, treat it as a
+first-class subagent and run it in parallel with Claude subagents, each on its
+own slice, with the same brief discipline and the same per-slice verification:
+
+- Read-only slices (verification, surveys, review):
+  `codex exec -s read-only ""`, and
+  `codex exec -s read-only review --base main` (or `--uncommitted`) for a diff
+  review. A second model's independent read is the main value here, so route
+  review and fact-check slices to Codex first.
+- Write slices (an isolated implementation or test-fixing task): give Codex
+  its own checkout, never the one this session is editing:
+  `git worktree add ../-codex- `, then
+  `codex exec -s workspace-write -C ../-codex- ""`.
+  Review its diff and run the repo checks before merging it into the branch.
+- Never pass `danger-full-access`, `--approve-for-me` or
+  `--dangerously-bypass-approvals-and-sandbox` here, and never let two agents
+  write to one working tree.
+
+## Repo skills
+
+`skills/*/SKILL.md` are the agent skills this repo **ships to users**
+(`smpy skills add`, `npx skills add antosubash/simple_module_python`); when a
+convention in this file changes, update the matching skill in the same PR.
+`.claude/skills/running-the-stack` is the verified recipe for launching the
+stack in a sandbox.
 
 ## Authoritative references
 
-When conventions are unclear, these docs are the source of truth (don't reverse-engineer the code):
-- [docs/framework-conventions.md](docs/framework-conventions.md) — invariants module authors rely on
-- [docs/module-authoring.md](docs/module-authoring.md) — authoring a module for distribution (entry points, migrations, API stability)
-- [docs/e2e-testing.md](docs/e2e-testing.md)
-- [docs/plans/](docs/plans/) — dated design docs; the most recent is the intended state
+When conventions are unclear, read these instead of reverse-engineering code:
+`docs/framework-conventions.md` (invariants), `docs/module-authoring.md`
+(entry points, migrations, API stability), `docs/framework/*.md` (lifecycle,
+middleware, settings, events, i18n, tenancy), `docs/database/*.md`,
+`docs/testing/*.md`, `docs/reference/*.md` (make commands, env vars,
+diagnostic codes), `docs/plans/` (dated design docs; the newest is the intended
+state).