# 2026.08.23.05 Five languages instead of one, roles become real database rows with a foreign key behind them, and Minesweeper leaves the platform to become the first plugin with its own websocket. **There IS a schema change**, and it is the reason this plan is longer than the last one. Two foreign keys and one dropped CHECK, on `users` and `role_permissions`. **Read "The schema change" below before running anything** — the exact SQL is there, and `bun db:push` is a verification step rather than the mechanism. **No dependency change.** `bun.lock` is untouched. **Do not run `bun install`.** ## What is in it - **Five locales, complete and in step.** `en-GB`, `pt-PT`, `es-ES`, `de-DE`, `fr-FR` — 1,129 keys each. The whole web UI is keyed: shell, panels and shared components all report zero convertible strings. - **Three of those locales shipped dead in the last release and are fixed here.** `es-ES`, `de-DE` and `fr-FR` were translated, in parity, and offered by the picker — while all three catalogue files still imported only `en-GB` and `pt-PT`, so choosing one rendered the entire UI in raw keys. The parity tests could not have caught it: they compare JSON files to each other, and a file nobody imports is as parity-correct as one that is loaded. `lib/catalogues.test.ts` is now the thing that knows. - **Roles are a table with a foreign key, not a TypeScript union.** `USER_ROLES` is gone, `UserRole` is `string`, and the compile-time guard moves to a runtime check at every write path that takes a role from outside. Renaming a role now moves its holders and grants atomically, as Postgres's job. - **Minesweeper is a plugin** (`gitea.officer.dev/plugins/minesweeper`), not part of the platform. The permission registry drops from 22 entries to 21 — `execution` goes 4 to 3. - **Plugins can have websockets.** `/api/plugin-ws/` relays onto the plugin's own sidecar, which serves it on the single port it announced at registration. Authorisation is the plugin's own permission, so there is no second grant to forget. - **A plugin can declare the architecture its packages need.** `multiarch: 'i386'` runs `dpkg --add-architecture` and `apt-get update` before installing, on apt hosts, skipped when already enabled. A closed enum rather than a command, because it runs as root. - **Foreign keys are named explicitly across the fleet's plugins**, ending the `db:push` churn where Postgres truncated an over-long generated name and drizzle-kit re-created the constraint every run. ## The schema change Both foreign keys reference `roles.name`, so **every value in `users.role` and `role_permissions.role` must already exist as a row in `roles`**, or the constraint cannot be created. **That was checked on every host before this tag was cut.** All five are on `deploy/2026.08.23.04`, which is the release that created and seeded `roles`; `alpha`, `fofos`, `production-pertento-bridge` and `deadpool-rezio-bridge` each report `roles = 4` and `0` orphans. **`macbookpro` was NOT verified** — it has no `psql` client, so only its tag could be read. Check it there before running the SQL, by whatever means that host offers. Run this **first**, before `bun db:push`. It is idempotent and safe to run twice. ```sql -- Refuse to proceed if any role in use is not a real row. Raises rather than half-applying. DO $$ DECLARE bad int; BEGIN SELECT count(*) INTO bad FROM ( SELECT u.role FROM users u LEFT JOIN roles r ON r.name = u.role WHERE r.name IS NULL UNION ALL SELECT p.role FROM role_permissions p LEFT JOIN roles r ON r.name = p.role WHERE r.name IS NULL ) x; IF bad > 0 THEN RAISE EXCEPTION 'STOP: % role value(s) have no matching roles row. Do not continue; report this.', bad; END IF; END $$; ALTER TABLE role_permissions DROP CONSTRAINT IF EXISTS ck_role_permissions_role; DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_users_role') THEN ALTER TABLE users ADD CONSTRAINT fk_users_role FOREIGN KEY (role) REFERENCES roles(name) ON UPDATE CASCADE; END IF; END $$; DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_role_permissions_role') THEN ALTER TABLE role_permissions ADD CONSTRAINT fk_role_permissions_role FOREIGN KEY (role) REFERENCES roles(name) ON UPDATE CASCADE; END IF; END $$; ``` `ck_role_permissions_not_owner` stays. It is a different constraint and this release does not touch it. Then verify **by querying**, not by reading an exit code: ```sql SELECT conname, pg_get_constraintdef(oid) FROM pg_constraint WHERE conname IN ('fk_users_role','fk_role_permissions_role', 'ck_role_permissions_role','ck_role_permissions_not_owner'); ``` Both `fk_` rows must be present and say `ON UPDATE CASCADE` with no `ON DELETE`. `ck_role_permissions_role` must be **absent**. `ck_role_permissions_not_owner` must be **present**. Put that output in the report — for this release it is the evidence, and "schema ✓" without it is a guess. ## Every host 1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.23.05` 2. **The SQL above**, then the verification query. Stop on the `RAISE EXCEPTION` and report it. 3. `bun db:push` — expect **no changes**. If it still wants to alter something after step 2, the SQL was incomplete; stop and report rather than letting it improvise. 4. `bun run plugins:update` 5. `pm2 restart officer` `officer` only at step 5. Step 4 restarts each plugin's own sidecar, which belongs in the report. **`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately. ## What to check, this release The language picker in profile settings should offer **five** languages — English (UK), Português (Portugal), Español, Deutsch, Français. Pick one that is not English and look at a **panel**, not just the shell: the last release's defect was invisible in the shell and total in the panels. A host still offering two languages did not pick up the frontend bundle. Minesweeper should be **absent** from the dock and `/minesweeper` should 404. It is a plugin now and no host has it installed. That is correct, and installing it is not part of this release. ## Hosts - **`edge-pertento-bridge`** — skipped, per `docs/servers.md`. Name it and spend nothing on it. - **`macbookpro`** — the one host whose roles table could not be verified before the tag. Check it, then proceed; do not skip it on that account. - All five others were on `deploy/2026.08.23.04` when this was cut, so each is exactly one release behind. ## `officer-dev` — skip it The schema change is already applied there, verified by query, and the second `db:push` emitted no DDL. Restarting `officer` on the host running the rollout ends the rollout. Name it as skipped and say it is current.