Files
edr-platform/CLAUDE.md
2026-08-25 00:11:39 +03:00

445 lines
28 KiB
Markdown

# EDR Platform — Developer Guide
> This file is the contract. If something here contradicts the code, the code is the
> truth and this file is a bug — fix it in the same PR.
**Looking for where something lives? Read [`docs/MAP.md`](docs/MAP.md) first.** It routes
you to the right module or page without a repo-wide grep.
## Overview
Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Freight
Management and Passenger Management applications, a payment microservice, plus shared
types, NestJS utilities, and React component libraries.
The freight domain is the largest and most active area. Its core flow is:
**booking → receive to warehouse → store → load onto train → dispatch → arrive → unload
→ customer truck (self-haul) or EDR last mile → handover → exit paper → delivered.**
Fees (storage, demurrage, double handling, truck detention) and allocation rules
(warehouse/yard/zone) hang off the warehouse stage.
## Apps
The two domains are **not built the same way**. Check which stack you are in before
copying a pattern across:
| App | Package name | Stack | Default port |
| ------------------------------ | --------------------------- | ---------------------- | ------------ |
| `edr-freight-api` | `@edr/freight-api` | NestJS + **TypeORM** | 3001 |
| `edr-freight-web/portal` | `@edr/freight-portal` | React + **Vite** | 5273 |
| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React + **Vite** | 5283 |
| `edr-passenger-api` | `@edr/passenger-api` | NestJS + **Prisma** | 4000 |
| `edr-passenger-web/portal` | `@edr/passenger-portal` | **Next.js** | 5174 |
| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | **Next.js** | 5184 |
| `edr-payment-api` | `@edr/payment-api` | NestJS + **TypeORM** | 3003 |
| `edr-hr-api` | `@edr/hr-api` | NestJS + **TypeORM** | 3005 |
| `edr-hr-web` | `@edr/hr-web` | React + **Vite** | 5185 |
| `finance-api` | `@edr/finance-api` | NestJS + **TypeORM** | 3004 |
| `finance-web` | `@edr/finance-web` | React + **Vite** | 5186 |
Those are the **fallbacks compiled into the code**, not what you will be running. Every
port is overridden by `PORT` in the app's `.env` / `.env.development`; the freight vite
apps read it in `vite.config.ts` (`Number(env.PORT) || 5273`). This machine is shared by
the whole team and the low ports are contested — see the workspace root `CLAUDE.md` and
`./wt ports` for who currently holds what.
`edr-freight-web` and `edr-passenger-web` are grouping folders, not workspace packages.
Each holds a `portal/` and `backoffice/` sub-app, both independent pnpm workspace
packages (see `pnpm-workspace.yaml`).
`apps/edr-landing/` exists on disk but has **no `package.json`** — it is not a workspace
package and is not built, linted, or type-checked. Leave it alone unless asked.
`apps/edr-gps-tracker/` is a separate service with its own `.env.example`.
`apps/edr-hr-api/` owns the `hr` schema and is the HR extension of IAM: employee
profiles hang off `iam.employees`, "departments" are `iam.units` with an HR
satellite (`hr.unit_hr_profiles`), and job positions extend `iam.positions`. HR
stores no copy of an employee's name, unit or organization — those are read
through and joined at query time.
IAM access is split in two, and the split is the design:
- **Reads** go through `IamDirectoryService` — raw SQL projections over `iam.*`,
no entities involved.
- **Writes** go through `IamOperationsService`, which resolves IAM's own
services out of the container (`ModuleRef.get(..., {strict: false})`) and
calls them. HR never writes an `iam.*` table itself, so IAM's validation,
transactions and audit trail all still apply.
That means hr-api **does** import `IamModule` (`IamModule.forRoot({...})`) and
**does** register `@tria-plc/iamapi-common` entities. It sidesteps the stale
`iamEntities` trap below by registering them as **globs over the package's
`dist/`** rather than a hand-maintained class list, with `autoLoadEntities: false`
— see `apps/edr-hr-api/src/config/database.config.ts`. A package bump cannot
leave that list stale, because there is no list.
One consequence worth knowing before touching lifecycle code: IAM's
`deactivateEmployee` ends every position the employee holds, and
`activateEmployee` does **not** put them back. HR surfaces this rather than
hiding it (`accessAlignment` on the profile response).
`apps/finance-api/` owns the `finance` schema — general ledger, chart of
accounts, receivables, payables, budgets and fixed assets. It follows hr-api's
shape exactly (embedded `IamModule.forRoot`, IAM entities registered as globs
over both package dists, `autoLoadEntities: false`, migrations in
`finance.migrations` run only by `migration:run`).
Two rules define it, and neither is negotiable:
- **Finance writes only `finance.*`.** Revenue, cash and payroll are projected
**read-only** out of `freight`, `passenger`, `edr_payment` and `hr` with
schema-qualified raw SQL, plus payment events off `PAYMENT_EVENTS_EXCHANGE`.
No source app writes `finance.*`, and Finance writes none of theirs.
- **Money is `numeric(14,2)` in major units.** The column NAMES lie upstream:
everything on the payment-intent path is called `*Minor` but holds MAJOR
amounts, while passenger's Prisma `Int` columns really are minor. Never infer
the unit from the name — `apps/finance-api/src/common/money.ts` is the
authority, and it also records which upstream rows are known bad.
Note both finance-api and hr-api serve `POST /api/v1/auth/login`: embedding
`IamModule` brings IAM's auth controller with it. finance-web therefore
authenticates against finance-api itself and needs no `x-client-app` header
(both verified against the running services). `edr-hr-web` points its login at
freight-api instead, on the assumption that an IAM-embedding app has no auth
routes — that assumption is wrong, and the coupling is unnecessary.
## Packages
| Package | Location | Purpose |
| ----------------------- | ----------------------------- | ------------------------------------------------------------- |
| `@edr/types` | `packages/types` | Shared TypeScript interfaces and enums |
| `@edr/api-common` | `packages/api-common` | NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository |
| `@edr/ui-common` | `packages/ui-common` | Shared React components and theme |
| `@edr/iam-seed` | `packages/iam-seed` | IAM baseline seeder for apps sharing the `iam` schema |
| `@edr/payment-providers`| `packages/payment-providers` | Payment gateway integrations |
| `@edr/eslint-config` | `packages/config/eslint-config` | Shared ESLint configs (base/nestjs/react) |
| `@edr/tsconfig` | `packages/config/tsconfig` | Shared TypeScript configs |
| `@edr/prettier-config` | `packages/config/prettier-config` | Shared Prettier config |
The three `config/*` packages are nested one level deeper than the rest — `packages/config`
itself is not a package.
**`@edr/types` is consumed as its built `dist/`** (`main: ./dist/index.js`). Editing a
type in `packages/types/src` changes nothing for consumers until you rebuild:
```bash
pnpm turbo build --filter=@edr/types
```
If a type-check fails on a field you just added to `@edr/types`, this is why.
## Commands
| Command | Description |
| ----------------------------- | ---------------------------------------- |
| `pnpm install` | Install all workspace dependencies |
| `pnpm dev` | Run every app in dev mode |
| `pnpm dev:freight` | Freight API + portal + backoffice |
| `pnpm dev:freight:api` | Freight API only |
| `pnpm dev:freight:portal` | Freight portal only |
| `pnpm dev:freight:backoffice` | Freight backoffice only |
| `pnpm dev:passenger` | Passenger API + web |
| `pnpm dev:payment` | Payment API |
| `pnpm build` | Build every package and app |
| `pnpm test` | Run all tests (turbo) |
| `pnpm type-check` | Type-check every package |
| `pnpm format` | Format all files with Prettier |
| `pnpm lint` | **Does not work** — see below |
**`pnpm lint` fails.** `eslint` is not installed anywhere in the workspace, so
`turbo run lint` dies with `eslint: not found` even though every package declares a
`lint` script and `@edr/eslint-config` exists. Until someone adds the dependency,
tsc's `noUnusedLocals` is the only working unused-code check. Do not claim a change is
"lint clean".
`pnpm format` uses bare `prettier`, which ignores `@edr/prettier-config` — it is wired to
nothing. On the single-quoted passenger apps it will re-quote the whole file. Pass
`--config` explicitly there.
Prefer targeted turbo filters over whole-repo runs — they are minutes faster:
```bash
pnpm turbo type-check --filter=@edr/freight-api --filter=@edr/freight-backoffice
```
`apps/edr-freight-api` also carries many `seed:*` scripts (demo bookings, wagons, trains,
gate-pass scenarios). Read the script before running one; several write real rows.
## Environment & database
- **One database, schema-separated.** Every app connects to the SAME Postgres database
and is isolated by schema, not by database — `iam`, `freight`, `passenger`,
`edr_payment`, `audit`. This mirrors production, where the Smart Office database holds
the `freight` schema alongside the rest (see `dump-smart_office_prod-*.sql`). There is
no longer a per-domain database; do not add one.
- Postgres is **external** to `docker-compose.yaml` (no service there). For a local one,
`infrastructure/docker/docker-compose.db.dev.yml` starts a single `postgres` on 5432
(`edr_database`) and creates every schema via `infrastructure/docker/initdb/`.
- Freight API connection comes from `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`,
`DB_NAME` (defaults: `localhost:5432`, `edr_database`). Development points these at a
remote database. `edr-gps-tracker` and `edr-payment-api` read the same DB_* convention;
`edr-passenger-api` uses `DATABASE_URL` (Prisma, `?schema=passenger`) plus
`DATABASE_*`/`DATABASE_SCHEMA=iam` for its read-only TypeORM IAM connection — all three
must resolve to this one database.
- The connection sits behind a **connection pooler**. Do **not** pass
`extra.options: '-c search_path=…'` — the pooler rejects it with
`08P01 unsupported startup parameter in options: search_path`. `search_path` is applied
per-connection in a pool `connect` handler instead. See
`apps/edr-freight-api/src/config/database.config.ts` before touching connection options.
- Each app owns its own **schema**, and its own migration history in that schema:
`iam.typeorm_migrations` and `freight.migrations` (both applied by freight-api),
`passenger._prisma_migrations` (Prisma), and payment-api's own table in `edr_payment`.
An app never writes another app's schema.
- IAM tables live in the `iam` schema (`iam.users`, `iam.user_credentials`), freight
tables in `freight`. **`edr-freight-api` is the authoritative owner of the `iam`
schema** — it ships the `iam:migration:run|show|revert` scripts. Passenger's IAM
connection is read-only (`synchronize: false`, `migrationsRun: false`).
- **Cross-schema references stay soft.** Co-location makes hard FKs possible, but there
are zero FKs from `freight.*` into `iam.*` and that is deliberate: references are plain
UUID columns plus denormalized display fields, so IAM stays independently deployable and
the audit trail outlives a deleted user. Keep it that way in new modules.
- The e2e harnesses are the **one exception** and stay hermetic: `edr_freight_e2e` on 5533
(`e2e/freight/`) and the passenger test DB on 5544 (`e2e/docker-compose.yml`). Do not
point them at the shared database.
- `psql` **is** installed (Homebrew, v17.9 — verified 2026-08-19); the earlier claim that
it was not is out of date. Either use it directly against the local database, or use the
`edr-db` skill (below) / a short Node script using `pg` run from `apps/edr-freight-api`,
where `pg` resolves.
## Hard rules
These are non-negotiable. Everything else is a strong default.
- **pnpm only.** Never run `npm install` or `yarn`.
- **TypeScript strict mode** is on in every package and app. Do not weaken it, and do not
reach for `any` to make an error go away.
- **Never `synchronize: true`.** Not in production, not anywhere. It is currently `false`
in every config and it has already corrupted this database twice (see *Migrations*).
All schema changes go through migrations.
- **All entities** use UUID primary keys (`@PrimaryGeneratedColumn('uuid')`).
- **All entities** extend `BaseEntity` from `@edr/api-common``createdAt`, `updatedAt`,
`deletedAt` (soft delete).
- **All columns** are `snake_case` in the database (`@Column({ name: 'snake_case' })`);
TypeScript properties are `camelCase`.
- **Controllers contain no business logic.** They validate, delegate, and shape the response.
- **Conventional commits.** `fix(warehouses): …`, `feat(bookings): …`.
- **Do not commit or push unless asked.** Propose the change; let the human decide when it lands.
- **Do not break working behaviour to add new behaviour.** When a fix is risky, say so and
offer the safe version.
## Architecture
### NestJS module shape
`module → controller → service → repository`, with `entities/` and `dto/` alongside.
`docs/MAP.md` lists the ~60 freight modules grouped by domain.
### Data access — the real model
There are two sanctioned ways to read and write, and you must pick the right one:
1. **Entity CRUD → the custom repository class.** Extends `BaseRepository<Entity>` from
`@edr/api-common`. Services inject the repository class, never `Repository<T>` directly.
2. **Read projections, queue endpoints, cross-table reports → raw SQL** via
`this.dataSource.query(...)` or `manager.query(...)` inside a transaction.
Raw SQL is normal here, not a smell — the warehouse and scheduling modules are built on it.
It carries one obligation:
> **HARD RULE — validate every raw SQL statement against a real database before you ship it.**
> A typo'd column name is a runtime 500 that no type-checker will catch. Run it through
> `EXPLAIN` against the dev database. Column drift is real (see *Migrations*).
Writes inside a transaction use `manager.getRepository(Entity)`, not the injected repository,
so they join the caller's transaction.
**Never do slow I/O inside a database transaction.** Queue the work and fan it out after
commit. An SMS awaited inside a transaction once held capacity locks open for the whole
gateway timeout. Any outbound HTTP call must set an explicit `timeout` — axios defaults to
no timeout and will wait forever.
### Migrations
Migrations are the most dangerous surface in this repo. Two production-grade incidents have
already come from it. **Freight and payment use TypeORM migrations; passenger uses Prisma**
(`apps/edr-passenger-api/prisma/migrations`) — the rules below are about the TypeORM side.
- `migrationsRun: false`**migrations do NOT run on API boot.** They run as a separate
one-shot step, via the Dockerfile's `migration` build target (`docker build --target
migration`), with `migrationsTransactionMode: 'each'`.
- CI: `.github/workflows/deploy.yml` builds the `migration` image and runs it
(`docker run --rm --env-file ...`) *before* building/deploying the app image.
- e2e: `docker-compose.e2e.yaml`'s `freight-migration-e2e` service runs once and
`freight-api-e2e` depends on it (`condition: service_completed_successfully`).
- Local dev (`docker-compose.yaml`) has no equivalent migration service yet — run
migrations yourself before `docker compose up freight-api`, e.g.
`docker build --target migration -f apps/edr-freight-api/Dockerfile -t freight-migration .`
then `docker run --rm --env-file apps/edr-freight-api/.env freight-migration`. Don't
use `pnpm run migrate` for this — it runs via `ts-node`, which never writes compiled
output to `dist/`, and the freight migrations glob only matches `dist/migrations/*.js`.
It silently applies zero freight migrations while exiting 0.
- Consequences you must design for:
- A watch-mode hot reload does **not** re-run migrations. If you add a column that new
code reads, apply it to the dev database yourself (idempotently) or fully restart.
- `apps/edr-freight-api/src/config/database.config.ts`'s `iamEntities` array is a
hand-maintained list of `@tria-plc/iamapi-common` entity classes. The live app never
notices when it's stale (`autoLoadEntities: true` papers over gaps via IAM's own
`forFeature()` registrations), but the standalone migration `DataSource`
(`data-source.ts`, no `autoLoadEntities`) does not have that fallback — a missing
entity throws `Entity metadata for X#y was not found` at `initialize()`, before a
single migration runs. **Every `@tria-plc/iamapi-common` version bump is a candidate
for this to break again** — diff the package's entity classes against `iamEntities`
when bumping it.
- **Give every migration a unique timestamp.** `apps/edr-freight-api/src/migrations` holds
39 files, and 8 timestamps are shared by two or more of them. TypeORM orders by timestamp
and breaks ties non-deterministically. Check before adding one:
```bash
ls apps/edr-freight-api/src/migrations | grep -oE '^[0-9]+' | sort | uniq -d
```
The prefix must be unused *and* higher than the newest recorded row. Note the
`freight.migrations` table has far more rows (~309) than this folder has files — most
come from `@tria-plc/iamapi-common`'s own migrations, which run from the same data source.
- **Write idempotent DDL**: `ADD COLUMN IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, and
backfills guarded by `WHERE col IS NULL`.
- **Never assume a recorded migration actually applied.** `AddGrnNumberToWarehouseInventory`
was recorded in `migrations` while its column was absent — it had been dropped out of band.
TypeORM will never re-run a recorded migration, so the fix is a *new repair migration*.
- **A repair migration's `down()` should be a no-op.** Reverting a repair must not
re-introduce the outage it fixed.
### Auth
Auth **is implemented in this repo.** Do not add TODO stubs, and do not write your own.
- `@CurrentUser()` (`@edr/api-common`) is a real `createParamDecorator`, not a metadata stub.
- Route protection uses `@UseGuards(JwtGuard)` and `@UseGuards(PermissionGuard([...]))`.
- Freight-domain checks use `hasFreightPermission(user, FREIGHT_PERMS.<area>.<action>)`.
- Permissions are declared in `apps/edr-freight-api/src/seed/freight-permissions.registry.ts`.
Add a permission there before referencing it.
- Login is freight-api's own `POST /api/auth/login` (SharedAuthModule from
`@tria-plc/api-common`). Every login call needs an **`x-client-app` header** —
`backoffice` for employees, `portal` for customers. Without it the API 403s with
"Missing or unrecognized x-client-app header". Browsers send it; curl must add it.
- IAM has its own migrations, run ahead of freight migrations from the same data source, and
its own CLI scripts (`iam:migration:run`, `iam:seed:run`).
Ownership checks are separate from permission checks. A staff user passes
`hasFreightPermission`; a customer must additionally pass an ownership assertion such as
`assertCustomerCanAccessBooking`. Do not drop the ownership check because the permission check passed.
## Frontend conventions
- The **freight** web apps use **Mantine v9** (`^9.3.0`). Its APIs differ from v6/v7 —
check the installed version before copying a snippet.
- `@edr/ui-common` holds shared components and theme; it is imported in ~94 files across the
freight web apps. Prefer it over re-implementing a component.
- **Blob downloads need the async error decoder.** A request with `responseType: 'blob'`
delivers the JSON error body as a `Blob`, so the synchronous `extractErrorMessage` finds no
`.message` and degrades to `"Request failed with status code 400"`. Use
`await extractDownloadErrorMessage(error)` in every PDF/blob catch block. Mutation catches
keep the synchronous version — their bodies are already parsed JSON.
- Server-side guards must be reflected in the UI. If the API will reject the action, the
button should be disabled, hidden, or explain the blocker — not fire and surface a 400.
- Prefer disabling a control with a visible reason over silently hiding it.
## Notifications
In-app notifications resolve recipients from the company's **linked portal users**. If a
company has none, `notify()` logs `0 recipients — skipped` and stores nothing, with no error.
SMS and email still send, because they address the company's phone and email directly. Check
this before debugging a "missing notification".
## PDF generation
Chromium is not installed in every environment. PDF paths must fall back to the hand-rolled
generators (`styled-pdf.util.ts`, `buildFallbackPdf`, `buildTabularFallbackPdf`) rather than
assume a headless browser exists.
## Adding a new module to a NestJS app
1. Create `modules/<feature>/` with `entities/`, `dto/`, and the four
`<feature>.{module,controller,service,repository}.ts` files.
2. The entity extends `BaseEntity` from `@edr/api-common`.
3. The repository extends `BaseRepository<Entity>` from `@edr/api-common`.
4. The service injects the repository class (not `Repository<T>` directly).
5. The controller uses `@ApiTags()` + `@ApiOperation()` for Swagger, and guards the route.
6. Register the module in the app's `app.module.ts`.
## Adding a new shared component to `@edr/ui-common`
1. Create `src/components/<Name>/<Name>.tsx` and `src/components/<Name>/index.ts`.
2. Export from `src/index.ts`.
3. Component is a functional component with a `ComponentNameProps` interface
(named-exported alongside the default).
## Definition of done
A change is done when **all** of these hold. State explicitly which you ran.
1. **It type-checks.** `pnpm turbo type-check --filter=<each touched package>` passes.
If you edited `packages/types`, you ran `pnpm turbo build --filter=@edr/types` first.
2. **Raw SQL is verified.** Every new or edited SQL statement ran under `EXPLAIN` against the
dev database without error.
3. **Migrations are safe.** Unique timestamp, idempotent DDL, and — if the migration adds
something the new code reads — applied to the dev database, since watch mode will not run it.
4. **No new test failures.** `pnpm test` for `@edr/freight-api` has been red on `dev`, so a
fully green suite is not the bar — but confirm that for yourself rather than assuming it,
then run the specs covering what you touched and confirm you introduced no new failure.
5. **Formatting is clean** for the files you touched. Git hooks do **not** run automatically
(see below), and `pnpm lint` does not work at all, so `noUnusedLocals` from the
type-check is your only unused-code signal.
6. **The behaviour was actually observed**, not merely compiled — you drove the flow, hit the
endpoint, or ran the query. If you could not, say so plainly.
7. **Report honestly.** If a check was skipped, tests failed, or a fix is unverified, say it in
the summary. Never describe unverified work as done.
### Hooks do not run
`commitlint.config.js` and a `lint-staged` config both exist, and husky's shims are installed
at `.husky/_/`. But there are **no user hook scripts** (`.husky/pre-commit`,
`.husky/commit-msg`), so husky's shim exits 0 and **neither lint-staged nor commitlint ever
fire.** Nothing validates your commit message or formats your staged files. Run the checks by
hand; do not assume the hook caught it.
## Known traps
| Trap | What happens | What to do |
| --- | --- | --- |
| Schema drift | A recorded migration's column is missing; queries and inserts 500 | Write a new repair migration; never edit the recorded one |
| Duplicate migration timestamps | Non-deterministic ordering; a migration can be skipped | Pick a fresh, higher timestamp |
| `@edr/types` not rebuilt | Consumers can't see your new field | `pnpm turbo build --filter=@edr/types` |
| Slow I/O in a transaction | Locks held for the gateway timeout | Queue it; fan out after commit; always set an HTTP timeout |
| Blob error bodies | Real 400 message replaced by "Request failed with status code 400" | `await extractDownloadErrorMessage(error)` |
| Company with no portal user | In-app notification silently vanishes | Check portal users before debugging |
| Watch-mode reload | New code, old schema → 500 | Apply the migration to the dev DB or restart fully |
| Login 403 from curl | "Missing or unrecognized x-client-app header" | Send `x-client-app: backoffice` or `portal` |
| Copying a passenger pattern into freight | Passenger is Prisma + Next.js, freight is TypeORM + Vite | Check which stack you are in first |
| Binding `payment.*` on the payment exchange | Silently receives NOTHING — wire keys are three words (`payment.passenger.succeeded`) and AMQP `*` matches exactly one | Bind `payment.<service>.*` for one service, or `payment.#` for all |
| Summing money across currencies | Passenger bookings are charged in ETB, DJF **and** USD; adding them overstates ETB revenue (6.3M on the dev replica) | Group by currency; convert only at an explicitly recorded rate |
| Reading a `DATE` column in raw SQL | node-postgres parses it to LOCAL midnight, so `toISOString().slice(0,10)` returns the PREVIOUS day east of UTC — a month-end lands in the wrong period | Cast in SQL: `period_end::text`. Never round-trip a DATE through a JS `Date` |
| A `CHECK` listing enum values wider than the column | `varchar(16)` accepted every status until the 17-character one was first used, then failed mid-operation | Size the column for the LONGEST permitted value |
| A service opening its own transaction inside a caller's | The inner write commits independently; a later failure leaves an orphaned posted row | Pass the caller's `EntityManager` through (see `JournalsService.createPosted`) |
## Project skills
Reusable workflows live in `.claude/skills/`. Use them instead of re-deriving the steps:
| Skill | Use for |
| --- | --- |
| `edr-db` | Query / `EXPLAIN`-validate / inspect the remote dev DB (`node .claude/skills/edr-db/query.cjs …`). psql is not installed — this is the sanctioned path. Also carries the 400/500 diagnosis loop. |
| `verify` | The definition-of-done runner: targeted type-check, `@edr/types` rebuild, SQL validation, migration checklist, honest test bar. Run before calling anything finished. |
| `standup` | "What did I do today / this week" reports for tickets, grounded in `git log` — including the check that commit subjects match their contents. |
## Working style
- **Verify before asserting.** Read the code or query the database. Do not infer behaviour
from a filename.
- **Investigate, then propose.** For anything risky or wide-reaching, present the plan and the
trade-off before changing files.
- **Small, reviewable commits**, one logical change each, conventional message.
- **Branch from `dev`; PRs target `dev`.**
- When a finding turns out to be wrong, say so and retract it. A rejected finding is a result.