Ctrl K
ComunidadGuías…

CHANGELOG.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Changelog

All notable changes to Good Phorum are recorded here, in the form of
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Good Phorum is an
application, not a CPAN distribution (ADR 0109): its versions are the
`vMAJOR.MINOR.PATCH` git tags, and each release gets a section named after
its tag and date. Changes not yet in a tag are under **Unreleased**.

Within a section, entries are newest first. Read **Operator action required**
before upgrading past a section: it lists what the operator has to do.
**Development** records changes that only affect maintainers -- tests, gates,
CI, evidence and internal refactors with no change in behaviour.

## Unreleased

Native FreeBSD CI builds and tests as an ordinary user, with PostgreSQL server
and extension prerequisites for the self-contained HTTP smoke test. Fresh-checkout
checks consume a finite amount of entropy and complete when SIGPIPE is inherited
as ignored, including on macOS. A bounded process regression covers that case.

Supervised web starts now retain their original PID in a native prefork
manager. TERM, INT and QUIT drain requests on every supported Unix platform;
an exclusive lock protects the listener and PID ownership. FreeBSD now
restarts a crashed web manager and stops through its supervisor. HUP reopens
application logs in the manager and workers, preserving the old handle on
failure. Service mode always stays foreground, including without the optional
`--foreground`; it refuses the development-only `--listen`. Reprint changed
service files and restart all long-running services after installing this release.
Native supervisor CI drills exercise crash restart, in-flight stop and the
systemd watchdog. See [the lifecycle contract](docs/ops/unix-daemons.md).

The outbox worker now stops before claiming another batch when TERM or INT
interrupts its idle wait. Service templates set umask 0027; systemd and launchd
give notification workers 150 seconds to drain before forced termination.
Every launchd job captures startup errors and command output. Deployment CI
parses all shipped systemd units and FreeBSD rc scripts.

Enabled Push installations now generate their sender service for systemd,
FreeBSD rc.d and launchd. Start/restart advice and doctor include that worker.
GUI wiring checks also fetch every named asset and verify that every optional
browser client is supplied by the layout and activated by its controls.

Passkeys now connect account settings and the login form to persistent WebAuthn
credentials, normal sessions and the existing TOTP/recovery policy. Members can
register and revoke keys; password rotation revokes all keys, exports include
metadata and erasure removes it. Apply migration 2043, refresh runtime API grants,
install the updated locked dependencies and restart. Use the configured HTTPS
origin or localhost for development. See [passkeys](docs/passkeys-registration.md).

Account recovery now serializes credential and token changes and rejects reset
links sent to a replaced address. Attachment writes recheck author and thread
locks and enforce per-owner storage and pending-review allowances. Public cache
entries follow a committed revocation epoch, queued digests recheck readable
sources at delivery, and WebSocket output is bounded at the actual socket.
Staging drills report cleanup failures and drain command output without blocking.
Apply migrations 2026–2032 and refresh runtime API-role grants before starting
the updated application and workers.

Vimeo, GitLab and Codeberg links are now available through the local plugin
contract, with explicitly allowlisted Mastodon/PeerTube instances. The admin
console adds selected public-topic Discord announcements and moderated,
deduplicated RSS/Atom imports through outbox workers. Optional pinned OIDC
supports explicit existing-account links and preserves local MFA/recovery;
email claims never merge accounts. Telegram is excluded. Apply migrations
2031/2032, refresh API-role grants, install locked dependencies and restart
before configuring these disabled network features. See the
[network integration guide](docs/integration-network-v1.md).

The database now starts from the fresh 2000–2025 baseline (ADR 0162).
Earlier migration histories are refused; the operator procedure below names
the current baseline, with retired instructions kept separately as history.

Uploaded files now default to PostgreSQL, including profile-picture originals
and their cropped versions. Audio and MP4 video accept up to 250 MiB; posts
show images and media players, and discussions can host prerecorded podcast
episodes. New uploads require a rights declaration, an original or explicitly
authorized source, and independent moderator approval after scanning. Rejected
hashes block exact reuploads. Configure source hosts and scanner limits, and
import legacy file bytes only after their metadata is present in the current
database schema. See [media and podcasts](docs/ops/media-and-podcasts.md).

Eight independent integration modules (YouTube, GitHub, Facebook, Instagram,
Spotify, Apple Music, Wikipedia and Mathematics) now use the fixed, validated Plugin API v1. Admins
manage activation, display mode and link previews at `/admin/plugins`, and
general forum settings at `/admin/configuration`. Settings persist with
revision checks and audit records; SMTP credentials remain sealed. See
[the management guide](docs/integrations.md) and
[the plugin contract](docs/plugin-api-v1.md). Wikipedia cards preserve language,
section and permanent revision citations, without fetching article content.
The separate LaTeX / KaTeX module renders inline and display equations as
accessible MathML using a pinned local renderer, with composer buttons and
an administrator equation tester.
Configuration is included in the current fresh database baseline; subsequent
plugin choices apply without a restart.

Nothing has been tagged yet. In short: the statements Perl runs are
versioned PostgreSQL functions (ADR 0126); the service listens on a Unix
socket and reaches a local PostgreSQL over one (ADR 0137); `bin/gpforum` is
the one front door. Members gained likes and reactions, replies to a post,
tags, bookmark folders, digests, ignore and block, direct messages, a second
factor and profile pictures; staff gained announcements, automod, a
first-post review queue and address bans. **Operator action required** is for
a host set up from an earlier `main`, newest first.

### Operator action required

- **Apply migrations 2050 and 2051, refresh runtime API grants, then restart
  the web service and the workers.** 2050 adds the owner of a handler claim
  (`ops_event_claim_v4`); 2051 adds the suspension target check
  (`moderation_suspension_target_v2`). The previous claim boundary stays for
  workers from the preceding release.

- **Synchronize the query-budget catalogue after deploying this change.**
  On a database already using 2000–2025, run `gpforum budgets --sync` and
  `gpforum budgets --check`; the ordinary `gpforum migrate` step also syncs
  them. Home, category index, category threads, notifications and search
  now include their measured request costs, including live configuration.
  This updates operational limits without a schema migration.

- **Start this checkout with an empty database (ADR 0162).** The current
  baseline is migrations 2000–2025. Earlier chains are refused before SQL
  runs; no data import is provided. Keep the existing database and its
  backup, create a different empty database, migrate it and provision the
  current runtime catalogue before starting the matching services. Follow
  the [upgrade guide](docs/ops/upgrade.md). The retired migration instructions
  below document previous checkouts and must not be run with this one.

- **Say who runs the forum before it opens to the public.** The legal
  pages now name the operator from `GPFORUM_OPERATOR_NAME`,
  `GPFORUM_OPERATOR_ADDRESS` and `GPFORUM_OPERATOR_EMAIL`, with
  `GPFORUM_OPERATOR_VAT_NUMBER`, `GPFORUM_OPERATOR_REGISTER` and
  `GPFORUM_PRIVACY_OFFICER_EMAIL` when they apply, and
  `/.well-known/security.txt` gives `GPFORUM_SECURITY_CONTACT` (or the
  operator email). Until a name is set, every legal page says the
  information is missing; without a contact, security.txt answers 404.
  Read the pages: they describe what the software does, and the policy and
  whether a provision applies remain the operator's.

- **Name the forum.** Pages, mail and notifications now say the name in
  `GPFORUM_SITE_NAME` instead of "GPForum"; until it is set they say
  "Forum". `GPFORUM_SITE_DESCRIPTION` is the optional line under the name on
  the home page. Add both to `/etc/gpforum/gpforum.env` and restart the
  services.

- **Move the service and its database onto Unix sockets, and install EV
  (ADR 0137).** In this order, on a host set up before them:
  1. *Dependencies.* Install them again the way `sudo -u gpforum gpforum
     upgrade` prints it: Carton now pins EV, and the service refuses to
     start on anything but the native epoll or kqueue backend.
  2. *The listener.* Hypnotoad listens on `var/run/gpforum.sock` under the
     code directory, and `gpforum start --service` refuses a TCP address:
     remove `GPFORUM_RUNTIME_LISTEN=http://127.0.0.1:8080` from the
     environment file (or set an `http+unix://` one), print the proxy's
     site again with `gpforum service print nginx` or `caddy`, and add the
     proxy's account to the `gpforum` group with the command that prints
     beside the site (`sudo usermod -aG gpforum www-data` on Debian's
     nginx), then restart the proxy.
  3. *The database.* For PostgreSQL on this host, set `host=` in
     `GPFORUM_DATABASE_DSN` to its Unix socket directory, for example
     `dbi:Pg:dbname=gpforum;host=/var/run/postgresql` on Debian/Ubuntu or
     `host=/tmp` on Homebrew/FreeBSD. A DSN without `host=` uses libpq's
     default socket directory; `gpforum doctor` reports an old loopback
     TCP setting. With the
     runtime role, add the `pg_hba.conf` and `pg_ident.conf` lines `sudo
     gpforum setup` prints, and reload PostgreSQL.

- **Install `gpforum.service` and `gpforum.conf` in place of the
  `gpforum-unix-socket` ones.** `deploy/systemd/gpforum-unix-socket.service`
  and `deploy/nginx/gpforum-unix-socket.conf` were links to the single
  templates, kept for the old names; nothing was released with them, so
  they are gone (ADR 0137). A host that installed one disables it, and
  writes the current files with `gpforum service print systemd --to DIR`
  and `gpforum service print nginx`.

- **Take the group's write permission off the stored uploads.** The service
  used to run under umask 007, so the uploads it wrote are `0660` and their
  directories `0770`, writable by the proxy's account in the `gpforum`
  group. New ones are `0640` and `0750`; for those already stored run, as
  root, `chmod -R g-w,o-rwx /opt/gpforum/var/attachments` (or the
  `GPFORUM_ATTACHMENT_ROOT` you set).

- **Write the new names of the old settings, drill variables and commands by
  hand.** Nothing was released with the old ones, so they are no longer
  read or installed, and neither the start, `gpforum setup` nor `gpforum
  doctor` mentions them (ADRs 0120 and 0125, amended). An old `GPFORUM_ENV`
  value now stops the start. In the
  environment file: `GPFORUM_ENV=production-small` or `production-medium`
  becomes `GPFORUM_ENV=production` (add `GPFORUM_EVENT_RETENTION_DAYS=730` to
  keep production-medium's retention); `GPFORUM_SMTP_SSL=on` becomes
  `GPFORUM_SMTP_TLS=starttls` and `GPFORUM_SMTP_SSL=off` becomes
  `GPFORUM_SMTP_TLS=off`; remove `GPFORUM_WORKER_PROCESSES`,
  `GPFORUM_REALTIME_PROCESSES` and `GPFORUM_OS_AFFINITY`, which had no
  effect. For the drills: `GPFORUM_PITR_PORT` and `GPFORUM_PITR_DIR` become
  `script/pitr-drill --port N --dir PATH`; `GPFORUM_STANDBY_PRIMARY_PORT`,
  `GPFORUM_STANDBY_PORT` and `GPFORUM_STANDBY_DIR` become `script/standby-drill
  --primary-port N --standby-port N --dir PATH`; `GPFORUM_EVIDENCE_DIR` and
  `GPFORUM_EVIDENCE_ENV_FILE` become `script/gpforum-evidence-live --out DIR
  --env-file PATH`. `bin/` holds `bin/gpforum` alone: a unit, crontab or
  script that still runs a `bin/gpforum-NAME` (or `script/gpforum-carton exec`
  it) runs `bin/gpforum VERB` instead, and `gpforum service print` writes the
  units again. `gpforum-migrate --apply` becomes `gpforum migrate`;
  `gpforum-admin-bootstrap --user-id ID` becomes `gpforum admin grant
  --user-id ID`; `gpforum-outbox-dispatch` becomes `gpforum outbox`;
  `gpforum-partition-maintenance` becomes `gpforum partitions`;
  `gpforum-query-budget` becomes `gpforum budgets`; `gpforum-dead-letter-replay`
  becomes `gpforum dead-letters`; `gpforum-seed-performance-data` becomes
  `gpforum performance-seed`; `gpforum-bench-hypnotoad` and
  `gpforum-bench-hypnotoad-scaling` become `gpforum hypnotoad-benchmark` and
  `gpforum hypnotoad-scaling`; every other `bin/gpforum-NAME` becomes `gpforum
  NAME`. The old spellings through the front door (`gpforum
  partition-maintenance`, `gpforum outbox_dispatch`, `gpforum
  admin-bootstrap`) are no verbs any more.

- **The size `production-small` and `production-medium` named comes from
  the host** (their renaming is above): the web processes from its CPUs, as
  before, and each web process's cache from its memory
  (`GPFORUM_LOCAL_CACHE_MAX_ENTRIES=auto`, the new default, which may give
  more or fewer than the old 4096 entries; set `4096` to keep that). The
  event log is kept `GPFORUM_EVENT_RETENTION_DAYS` days, 365 in every
  environment: a `production-medium` host that kept 730 sets
  `GPFORUM_EVENT_RETENTION_DAYS=730`.

- Run `make install-deps-production` after pulling: the lock now installs
  IO::Socket::SSL (with Net::SSLeay, built against the host's OpenSSL), which
  mail over TLS needs. On FreeBSD and macOS the service now takes its mode from
  the environment file's `GPFORUM_ENV` (the template says `production`): check
  it, then re-print and reinstall the units with `gpforum service print rc` or
  `gpforum service print launchd` and restart them. On macOS, load the
  scheduled-jobs and partition plists too -- they were copied but never
  loaded -- after creating `$(brew --prefix)/var/log/gpforum` owned by
  `gpforum`. On FreeBSD, the jobs can move to `/usr/local/etc/cron.d/` (remove
  the old crontab lines). Add a nightly `gpforum backup` to `gpforum`'s
  crontab (`docs/ops/backup-and-restore.md`).

- On macOS, create the `gpforum` account and re-copy the plists: they now
  run as `gpforum` and read the environment file. An smtp setup that does
  not set `GPFORUM_SMTP_TLS` uses TLS (STARTTLS on 587, implicit on 465) and
  needs IO::Socket::SSL; set `GPFORUM_SMTP_TLS=off` for a relay that takes
  mail in the clear.

- **`gpforum migrate` speaks, and settings errors exit 78.**
  It prints one line -- `Applied 51 migrations, 001 to 051; synced the
  query budgets (25 changed).`, or `Schema is current (051).` when nothing
  was pending -- where it printed a line per migration, and nothing at all
  the second time: a deploy script that checked for silence passes
  `--quiet`. It also syncs the endpoint query budgets after the partitions,
  so the migrating role writes `endpoint_query_budgets`. `--plan` connects
  to the database to say what is pending. `gpforum admin grant` says what
  it did in a sentence; `--json` carries the counts its `key=value` line
  had. Every command that reports settings it cannot use exits 78
  (EX_CONFIG), where it exited 1.

- **Production refuses settings that were silently wrong.** Check
  `/etc/gpforum/gpforum.env` before upgrading: production needs an `https://`
  `GPFORUM_PUBLIC_BASE_URL`, a `GPFORUM_MAIL_FROM` that is not at localhost
  and a `GPFORUM_SESSION_SECRET` of at least 32 characters (`openssl rand
  -hex 32`). Every environment refuses a `GPFORUM_ENV` that is not
  development, test, staging or production (`prod` is answered with
  `production`), a
  `GPFORUM_LOG_LEVEL` Mojolicious does not know, a `GPFORUM_DEFAULT_LOCALE`
  without a catalog (`en`, `it`; a region, as in `it_IT` or `en-GB`, is read
  as its language, and a POSIX locale such as `it_IT.UTF-8` is answered with
  `it`), a `GPFORUM_RUNTIME_LISTEN` that is not a URL
  such as `http+unix://var%2Frun%2Fgpforum.sock`, and the smtp transport without
  `GPFORUM_SMTP_HOST`. Staging and production refuse
  `GPFORUM_MAIL_TRANSPORT=log`. A start that meets any of them lists every
  one, with an example, and stops.

- **Run the outbox worker everywhere.** Without it no verification, reset or
  notification mail leaves. FreeBSD now has an rc script for it
  (`deploy/freebsd/gpforum_outbox`: copy it to `/usr/local/etc/rc.d/` and
  `sysrc gpforum_outbox_enable=YES`) and macOS a plist
  (`deploy/launchd/com.gpforum.outbox.plist`). On Linux, check that
  `systemctl is-enabled gpforum-outbox` says `enabled`: the deployment guide
  never told you to enable it.

- **Copy the nginx and Caddy configurations again.** nginx now accepts
  bodies up to 26 MiB, and its `/internal-attachments/` alias is the default
  attachment store, `/opt/gpforum/var/attachments/`; if you kept
  `/srv/gpforum/attachments/`, keep your `GPFORUM_ATTACHMENT_ROOT` and alias
  as they are. The Caddyfile now answers `/metrics` and `/metrics/` only to
  the loopback, and nginx keeps `/metrics/` there too.

- **Re-copy the service files, then `systemctl daemon-reload` and restart.**
  The units and launchd plists set only `GPFORUM_ENV` and the log path: the
  23 lines that restated `GPForum::Config`'s defaults, and `MOJO_MODE`, which
  `GPFORUM_ENV` always overrode, are gone, so a default changed in a release
  now reaches the service. An override you made by editing a unit belongs in
  `/etc/gpforum/gpforum.env`. Hypnotoad's pid file moves from
  `/opt/gpforum/hypnotoad.pid` to `/run/gpforum/hypnotoad.pid`, the directory
  `RuntimeDirectory=` makes; an absolute `GPFORUM_RUNTIME_PID_FILE` is kept
  as it is. Until you re-copy them, a unit from before this release still
  starts: under it the pid file stays in `/opt/gpforum`, and the start logs
  a line saying to copy the units again.

- **Perl 5.40 or newer is required.** The supported floor moves from 5.38 to
  5.40 (ADR 0117): Debian 13 (trixie), Ubuntu 26.04, FreeBSD ports perl 5.40,
  or Homebrew's perl. `script/gpforum-system-perl --require` and
  `script/bootstrap-deps` refuse an older interpreter. A host on Ubuntu 24.04
  (perl 5.38) must be upgraded before deploying this release; after changing
  the interpreter, rebuild the locked tree (`script/bootstrap-deps --postgres
  --rebuild-local`), because its XS modules are built for one Perl.

- Alerting or scripts that read the readiness checks (`report.problems`,
  `partition_horizon`, `replication_slots`) must send the metrics token; see
  `docs/DEPLOYMENT.md#health-endpoints`.

- Enable the new daily `gpforum-partition-maintenance` timer on every node
  (systemd `gpforum-partition-maintenance.timer`, launchd
  `com.gpforum.partition-maintenance.plist`, or the FreeBSD crontab line in
  `docs/ops/partition-maintenance.md`). It must run as the role that owns the
  partitioned tables.

- **macOS hosts use Homebrew, not MacPorts.** Install `brew install perl
  cpanminus postgresql@18`, install Carton for that perl, and rebuild the
  locked tree (`script/bootstrap-deps --postgres --rebuild-local`): its XS
  modules are built for one Perl. `script/gpforum-macports-env` is now
  `script/gpforum-homebrew-env` (`make homebrew-env`). ClamAV on macOS comes
  from `brew install clamav`, with the socket at
  `/opt/homebrew/var/run/clamav/clamd.sock` (`docs/ops/antivirus.md`).

- **A reverse proxy on another host must be listed in
  `GPFORUM_RUNTIME_TRUSTED_PROXIES`** (for example `10.0.0.5` or
  `10.0.0.0/8`). Only the loopback is trusted by default; the shipped nginx
  and Caddy units run on the same host and need nothing.

- **Members signed in before this release sign in again.** Migration 045
  revokes every session whose bearer token was stored in the command log
  by the login defect below, and removes the tokens.

- **Grant `category.read` to administrators.** Private categories are now
  readable only through a `category.read` grant. Re-run
  `gpforum admin grant --user-id ADMIN_ID` (idempotent) so the owner
  role gains it, or attach it to the roles that should read private
  categories from the admin console; scope a binding to a space or a
  category to grant just that.

- **Set the forum's time zone.** `GPFORUM_DEFAULT_TIMEZONE` (an IANA name
  such as `Europe/Rome`, default `UTC`) is what visitors and members who have
  not chosen their own see. An invalid name stops the application at boot.
  Times on the site now carry their zone abbreviation.

- **FreeBSD: re-install the rc script and create its environment file.** The
  old `deploy/freebsd/gpforum` could not start a production instance: it never
  exported the configuration, and daemon(8) lost track of Hypnotoad. Copy the
  new script to `/usr/local/etc/rc.d/gpforum` and put the service environment
  (`GPFORUM_DATABASE_DSN`, `GPFORUM_SESSION_SECRET`, ...) in
  `/usr/local/etc/gpforum/gpforum.env`, owned by `root:gpforum`, mode `0640`;
  the script refuses a file others can read. See `docs/DEPLOYMENT.md`.

- **API change for staff tools.** Revoking a role binding, suspending a user,
  approving an erasure and running an erasure job now require `confirm=1` in
  the request, and answer 400 without it. The forms carry a checkbox for it
  and state what the action does; a script that posts to these routes must
  add the field.

- **Operator action required.** Uploads are now scanned by the operating
  system's free antivirus (ClamAV) before they are served (ADR 0108). Staging
  and production default to `GPFORUM_ANTIVIRUS=clamd`: install and start the
  system package (`clamav-daemon` + `clamav-freshclam`, `pkg install clamav`,
  or Homebrew's `clamav`), set `StreamMaxLength 26M`, and run
  `gpforum antivirus-check`. Until clamd answers, new uploads stay pending and
  unserved. Attachments uploaded before stay served until the hourly
  `attachment_backfill` job has scanned them; on a large forum run it by hand
  with a large `--limit`. To run without scanning, set
  `GPFORUM_ANTIVIRUS=none`. Migration
  043 adds `attachments.scan_engine` and `scan_signature`. A media type
  mismatch after upload is now `failed`, not `infected`. The systemd units
  start after `clamav-daemon.service` and the rc.d script requires
  `clamav_clamd`: re-copy them. `gpforum scheduled-jobs` now exits 1
  when a job fails, and prints each job's error, so a timer unit whose run
  failed is marked failed. See `docs/ops/antivirus.md`.

- **Re-copy the units: the host preflight is `gpforum os-preflight`.**
  `ExecStartPre=` in `deploy/systemd/gpforum.service` and the preflight call
  in `deploy/freebsd/gpforum` run it (see `docs/ENTRYPOINTS.md`).
  **Existing installs must re-copy the unit files and run `systemctl
  daemon-reload` (Linux), or reinstall the rc.d script (FreeBSD): a host
  still running older units fails to start, because `ExecStartPre=` points
  at a path that no longer exists.** Runbooks that called the rehearsals
  directly run `gpforum dead-letter-check` and `gpforum
  mail-lifecycle-check`.

- Require the system's Perl (the distribution's package, or Homebrew's on
  macOS) for bootstrap, Carton, make, CI, and docs. `script/gpforum-system-perl`
  refuses version managers and custom PREFIX installs; document distro
  packages and `perl -V` preflight.

- GlifiStore is an optional shared L2 cache for public SSR and category
  read-model seams: `GPFORUM_GLIFISTORE_URL` is empty by default in every
  environment, and PostgreSQL stays authoritative. A host that set it only
  because staging and production once required it may remove it.

### Historical database upgrade instructions

These entries describe the retired 0.x and 1000–1065 chains. They are retained
as change history, not as an upgrade procedure for the current checkout.
Migrations 2000–2025 install a fresh database only; use the current
[upgrade guide](docs/ops/upgrade.md).

- **Apply migration 1058, then restart the application workers.** `gpforum
  migrate` installs the searches planned on a true count
  (`api.search_ranked_v16`, `api.search_thread_ranked_v8`). Nothing is
  dropped; the previous searches stay for the previous release's workers.
  With a separate API runtime role, reprovision its grants as for 1024.

- **Apply migration 1057, then restart the application workers and run
  `gpforum search-rebuild`.** `gpforum migrate` installs the `unaccent`
  extension (it ships with PostgreSQL; the database owner may install it),
  the `public.gpforum_italian` text-search configuration, the indexer that
  takes it (`api.search_index_entity_v4`) and the searches that obey
  operators in titles (`api.search_ranked_v14`,
  `api.search_thread_ranked_v6`), and rewrites every search document in
  Italian: 22 s on 200,000 documents. What a previous worker indexes before
  its restart keeps `simple` vectors; the rebuild rewrites it. Nothing is
  dropped. With a separate API runtime role, reprovision its grants as for
  1024.

- **Apply migration 1056, then restart the application workers.** `gpforum
  migrate` installs the latest-thread list that carries each thread's
  opening excerpt (`api.forum_list_public_threads_v6`). Nothing is dropped:
  the previous list and `api.forum_thread_excerpts_v2`, which the previous
  release's feed calls, stay until a later contract migration. With a
  separate API runtime role, reprovision its grants as for 1024.

- **Apply migration 1055, then restart the application and outbox
  workers.** `gpforum migrate` installs the suspension insert that queues a
  timed suspension's end (`api.moderation_suspension_insert_v6`), the hourly
  sweep that queues an end that is missing
  (`api.moderation_queue_suspension_ends_v2`) and a search lag that skips a
  message not yet due (`api.search_observe_lag_v4`), and queues the end of
  every timed suspension in force; an end already past is delivered as soon
  as the outbox worker runs. The suspension ends need the outbox worker
  (`gpforum outbox --loop`) and the hourly `gpforum-scheduled-jobs.timer`,
  which now runs `suspension_ends` too. The previous entry points stay for
  workers of the preceding release. With a separate API runtime role,
  reprovision its grants as for 1024.

- **Apply migration 1054, then restart the application workers.** `gpforum
  migrate` installs `api.moderation_lock_member_v2`, which every suspension
  and revocation takes first. Nothing is dropped, and the previous release
  runs on it unchanged. With a separate API runtime role, reprovision its
  grants as for 1024.

- **Apply migration 1053 only once no worker runs a release from before
  1052.** `gpforum migrate` drops the counter columns and
  `outbox_messages.attempts` that 1052 stopped using, and the 37 functions
  and the row type that still wrote or returned them, which the release
  before 1052 called. `gpforum migrate` applies every pending migration
  before the restart, so a host on an older `main` upgrades to the commit
  that adds 1052 first, restarts its workers, and only then to this one;
  otherwise the old workers' thread creation, transfers and outbox writes
  fail until they restart.

- **Apply migration 1052, then restart the application and outbox
  workers.** `gpforum migrate` drops `projection_generations`, with the
  readiness probe of the release before 1048, and installs the functions
  that no longer write or read the columns migration 1053 drops: the thread
  write (`api.forum_store_write_v8`, `api.forum_write_command_v10`), the
  thread transfer and its undo (`_v6`), the outbox claim and the realtime
  poll (`_v6`), the outbox write and the console's outbox list (`_v4`). The
  previous entry points stay for workers of the preceding release. With a
  separate API runtime role, reprovision its grants as for 1024.

- **Apply migration 1051, then restart the application workers.** `gpforum
  migrate` installs the release of a legal hold
  (`api.privacy_release_hold_v2`), widens `deletion_actions`' check to the
  `hold_ended` action (added unvalidated, then validated, so writes go on),
  and installs the ranked searches that find a reply in a member's own
  private thread for them (`api.search_ranked_v12`,
  `api.search_thread_ranked_v4`). The previous entry points stay for
  workers of the preceding release. With a separate API runtime role,
  reprovision its grants as for 1024.

- **Apply migrations 1049 and 1050, then restart the application
  workers.** `gpforum migrate` installs the feed's excerpt read
  (`api.forum_thread_excerpts_v2`) and an attachment link lookup without its
  unused branch (`api.attachment_links_v6`), then builds the orphan purge's
  index concurrently, with writes to the attachments going on. The previous
  entry points stay for workers of the preceding release. With a separate
  API runtime role, reprovision its grants as for 1024.

- **Apply migration 1048, then restart the application and outbox
  workers.** `gpforum migrate` installs the attachment and moderation point
  lookups planned once (twenty-four functions), the console's member list
  with one statement per filter and a readiness probe that no longer reads
  `projection_generations`, and drops ten indexes no statement reads. The
  table itself stays until the next contract migration, since the
  preceding release's readiness reads it until its workers restart. The
  previous entry points stay for workers of the preceding release. With a
  separate API runtime role, reprovision its grants as for 1024.

- **Apply migration 1047, then restart the application and outbox
  workers.** `gpforum migrate` installs new versions of six functions: the
  moderation action's reversal, which changes only an action not yet
  reversed; the audit page, planned for the filters it has; the bookmark
  folders' counts, cut to what their member may read; the target update,
  which says what it changed; the digest record, under its own advisory
  lock; and the bookmark removal, by target only. It drops
  `api.admin_permissions_for_role`, which nothing called. The previous entry
  points stay for workers of the preceding release. With a separate API
  runtime role, reprovision its grants as for 1024.

- **Apply migration 1046, then restart the application and outbox
  workers.** `gpforum migrate` installs new versions of ten functions: the
  viewer's resolution and the three forum writes, which no longer call the
  previous release's boundaries; the subscribers and the fan-out's
  candidates, where a thread subscription decides over a category follow;
  the poll write, the sweep of unsigned bodies, the erasure and the thread
  page's reads. The previous entry points stay for workers of the preceding
  release. With a separate API runtime role, reprovision its grants as for
  1024.

- **Apply migration 1045, then restart the application and outbox
  workers.** `gpforum migrate` cuts the identity commands' recorded answers
  in `command_log` down to their status and ids (a refused registration had
  recorded the password it was sent), and installs the erasure that takes a
  member's password hashes, follows, bookmarks, notification preferences and
  inbox (`api.privacy_revoke_user_v28`), the export with the member's
  bookmarks (`api.portability_export_parts_v28`) and the second-factor
  answer that no longer deadlocks with a disable
  (`api.identity_second_factor_challenge_answer_v4`). The previous entry
  points stay for workers of the preceding release. With a separate API
  runtime role, reprovision its grants as for 1024.

- **Apply migration 1044, then restart the application and outbox
  workers.** `gpforum migrate` adds a `tags` column to `held_posts` and
  installs the new versions of eleven functions: the thread transfer and its
  undo, the tag write, the draft save, the reply link, the thread activity
  bump, the realtime poll, the outbox gauges, the export and the review
  queue's hold and take. It drops two reads nothing calls
  (`api.forum_next_position`, `api.forum_thread_page_posts_scoped`). The
  previous entry points stay for workers of the preceding release. With a
  separate API runtime role, reprovision its grants as for 1024.

- **Apply migration 1043, then restart the application workers.** `gpforum
  migrate` installs `api.forum_list_public_threads_v4` and
  `api.forum_list_category_threads_v4`, the two thread lists planned once;
  the previous entry points stay for workers of the preceding release. With
  a separate API runtime role, reprovision its grants as for 1024.

- **Apply migrations 1038 to 1042, then restart the application and outbox
  workers.** `gpforum migrate` installs a poll vote that no longer locks the
  thread's row against replies (`api.forum_poll_change_v4`), an erasure that
  deletes the member's single-use tokens (`api.privacy_revoke_user_v26`),
  the mbox archive's one-statement signature read
  (`api.forum_protocol_signatures_v2`) and two reads planned once
  (`api.forum_find_listed_post_v4`, `api.notification_list_inbox_v8`), and
  drops three indexes no statement used. The previous entry points stay for
  workers of the preceding release. With a separate API runtime role,
  reprovision its grants as for 1024.

- **Apply migration 1037, then restart the application workers.** `gpforum
  migrate` installs `api.forum_thread_page_reads_v6` and
  `api.attachment_post_files_v2`; the previous reads entry point stays for
  workers of the preceding release. With a separate API runtime role,
  reprovision its grants as for 1024.

- **Apply migration 1036, then restart the application and outbox
  workers.** `gpforum migrate` installs `api.notification_fanout_candidates_v2`,
  `api.notification_fanout_deliver_v2` and `api.ops_publish_notifications_v2`,
  the functions a reply's fan-out to a thread's subscribers now runs through,
  and `api.notification_unread_count_v8`, the badge count planned once; the
  previous entry points stay for workers of the preceding release, which
  keep notifying one subscriber at a time until they restart. With a separate
  API runtime role, reprovision its grants as for 1024.

- **Apply migration 1035 (ADR 0156).** `gpforum migrate` adds two nullable
  columns and a partial index to `audit_log`, replaces the four audit chain
  functions in place and adds `api.ops_audit_chain_verify_v2`; every writer,
  the previous release's included, follows the per-aggregate chain from then
  on, so no restart is needed for it. With a separate API runtime role,
  reprovision its grants as for 1024.

- **Apply migration 1034, then restart the outbox workers.** `gpforum
  migrate` installs `api.outbox_claim_ready_v4`, the claim that reads each
  half of the ready rows from its own index; the previous entry point stays
  for workers of the preceding release. With a separate API runtime role,
  reprovision its grants as for 1024.

- **Apply migration 1024, then restart the application workers.** On an
  existing 1.0 database, `gpforum migrate` installs typed suspension
  lookups; no database rebuild or PostgreSQL restart is needed. The old
  SQL entry points remain for workers from the preceding release. With a
  separate API runtime role, drain the old workers and reprovision its
  grants before starting the new ones: [runtime role upgrade](docs/ops/sql-api-role.md).
  Password hashes remain standard Argon2id: new salts identify UTF-8 encoding and
  existing 16-byte salts keep their original encoding.

- **Rebuild a database the 0.x migrations built (ADR 0150).** The schema
  starts from the 1.0 baseline, `migrations/1000_foundation.sql` to
  `1023_api_entry_points.sql`, and `gpforum migrate` refuses a database that
  records the migrations 001 to 135, changing nothing. Make an empty
  database, migrate it, and move the data with the `pg_dump --data-only` and
  restore of [docs/ops/upgrade.md](https://github.com/gipidevdor/GPForum/blob/e7b50885be70209481e5a0fedf1c17c388bf9cfc/docs/ops/upgrade.md#from-a-forum-built-before-10).
  Copy the release so that `migrations/` holds only its files: the
  directories `001-schema/`, `002-function-api/` and `003-evolution/` are
  gone.

- **Apply migration 103 before starting the draft-enabled application.** It adds
  private composer storage and new draft/publication/export/erasure routine
  versions. Drain the old processes, migrate with the migration owner, provision
  the updated runtime catalogue and start matching web/worker processes. Keep
  the hourly scheduled-jobs timer enabled: `forum_drafts` removes expired drafts
  in bounded batches. See the [runtime role guide](docs/ops/sql-api-role.md) and
  [draft contract](docs/architecture/composer-drafts.md).

- **Upgrade the function API with migrations 100, 101 and 102.** Search now calls
  v6 and post edit/delete/restore share one context read. Migration 102 makes
  the custom search plan policy effective on PostgreSQL 16/17 using static
  PL/pgSQL, with the same query bodies and return contracts. Apply the additive
  migrations with the ordinary migration owner, drain the old web and worker
  processes, provision the runtime login with the new catalogue, then start
  the updated processes. Provisioning replaces old search v2/v4 grants with v6;
  mixed old and new workers cannot use that login simultaneously. Follow
  the [runtime role guide](docs/ops/sql-api-role.md) for rollback.

- **Install the function API before starting the updated application.**
  Apply migrations with the ordinary migration owner: 055–070 and 099
  complete the API introduced by 052–054. To enforce API-only access,
  create a separate application login, provision it with `gpforum api-role`,
  and switch web and worker credentials to it. Keep migration, restore and
  partition-maintenance credentials in a separate operator environment;
  the migration owner must have no superuser or `BYPASSRLS` attributes.
  The merge and provisioning command do not change service credentials or
  restart services. Follow the [runtime role guide](docs/ops/sql-api-role.md).

### Security

- **Suspended staff can no longer act in the privacy review.** The admin and
  moderation consoles refused a suspended staff member's writes; the privacy
  review (approve or hold a deletion, release a hold, run an erasure) checked
  only the permission, so a suspended administrator or moderator kept those
  powers. It now answers 403 and records a `suspended_user_block`, as the
  other consoles do. A suspended member still asks for their own export and
  erasure: that right is not staff work. `t/714` covers the four routes.

- **A moderator can no longer suspend an administrator (migration 2051).**
  A suspended member cannot write in the consoles, lifting a suspension
  included, so suspending the administrators locked them out of the web.
  Staff -- members holding a permission beyond read, write and create --
  are now suspended only by an actor who manages the admin console, and
  nobody suspends themselves; the console answers 403 with the reason.

- **A timed suspension ends when its time is up (migration 1055,
  `v5.0.0-2.3.2`).** It set the member's status to suspended and only a
  revocation set it back: after the end the member still read the forum as
  a visitor and every write refused them. Storing a timed suspension now
  queues its end in the outbox, due a second after `valid_to`; the worker
  delivers it on its next poll, sets the member active again unless another
  suspension holds them, and audits `user.suspension_ended` with no actor.
  A served suspension keeps its reputation penalty, which only a revocation
  gives back. An hourly sweep queues any end that is missing.

- **Two moderators acting on one member's suspension at once no longer
  double it (migration 1054, `v5.0.0-2.3.4`).** A suspension was created
  check-then-insert and revoked from an unlocked read: two suspensions of
  one member at once stored two active suspensions and audited both, two
  revocations of one suspension stamped and audited it twice, and a
  suspension made while another was being revoked was answered with the one
  about to end, so the revocation set the member free. Every write of a
  member's suspensions now takes the member's row first, so they run one
  after the other and each sees what the one before committed.

- **Drill evidence keeps no inline password.** A drill stores the errors it
  catches, and DBI's connect error repeats the DSN, `password=` included,
  in a file stamped `secrets_redacted: true`; every evidence document is now
  scrubbed of inline passwords, and `evidence-validate` refuses one that
  still carries any.

- **The command log keeps no password, address, bio or hash (migration
  1045).** An identity command's recorded answer kept what it stored: a
  registration refused for its username the password it was sent, in clear;
  an accepted one the address and two copies of the password's hash; a bio
  change the bio; an email change the address. Nothing purges the log, and
  a registration's actor is nobody, so no erasure could find them. The
  answers keep whether the command was done and the ids a replay reads, and
  the migration cuts the rows already recorded down the same way. A
  replayed registration's page names the username its request sent.

- **A suspended moderator or administrator takes no part in staff work.**
  Both staff write gates checked the binding and the second factor only, so
  a suspended moderator kept every staff write, revoking their own
  suspension included; six controllers checked participation themselves.
  The two gates refuse a suspended member once, recorded as a
  `suspended_user_block`, and a suspension store that cannot be read
  answers 503.

- **`gpforum admin grant --user-id ID --dry-run` grants nothing.** The
  by-id form ignored `--dry-run` and gave the account the owner role, with
  every administrative permission and its audit rows; it now says what it
  would grant, as the other forms do.

- **An erasure takes what was left of the member (migration 1045):** every
  password hash they had, in `credentials`; what they followed, which kept
  notifications coming to the erased account; their bookmarks, notes and
  folders; their notification preferences, digests and inbox. It no longer
  withdraws the invitations an erased staff member sent, which left someone
  else's pending invitation refused.

- **A suspended member could still react to posts.** Reactions arrived
  after the suspension check was extended to likes, so `post.react` and
  `post.unreact` were not among the writes that need participation: a
  suspended member added and removed hearts, laughs, cheers and thoughts on
  other members' posts. Both are now refused with 403 and recorded as
  `suspended_user_block`, as a like is.
- **A suspended member could still rewrite the words on their profile.**
  A new profile picture needed a member who may participate, the bio that
  sits beside it did not. `POST /settings/profile` now refuses a suspended
  member with 403, recorded as `suspended_user_block`.
- **Previewing long bodies could grow a worker by half a gigabyte.** The
  renderer keeps the HTML of the bodies it rendered, bounded only by their
  count: 2,048 distinct previews at the 50,000-character limit, each
  rendered up to six times as long, grew one process by 536 MB. The memo is
  now also bounded at 4,194,304 characters of source and HTML; the same
  2,000 previews grow it by 8 MB.
- **The lock kept CPAN releases with security fixes the advisory database
  did not list yet.** `script/cpan-audit` passed, locally and in CI with
  CPANSA-DB 20261018.001, while these were already fixed upstream. The lock
  and the `cpanfile` floors now take them:
  - **Cpanel::JSON::XS 4.80** (was 4.53), the backend the service decodes
    database JSON and encodes events and audit records with: leaks, a stack
    overflow, a use-after-free, malformed key handling and quadratic CPU in
    decode, encode and `incr_parse` (CVE-2026-110672 to CVE-2026-110688).
  - **Mojolicious 9.51** (was 9.49): `Mojo::DOM` left the content of
    `title` and `textarea` unescaped when rendering (9.50), and WebSocket
    frames sent with the handshake were read as a new request (9.51).
  - **DBI 1.656** (was 1.655): CVE-2026-108518 and CVE-2026-111879.
  - **Clone 0.51** and **Package::Stash::XS 0.32**, loaded by the service
    through DateTime and DBIx::Class: C stack growth and leaks on deep
    structures, and a crash on Perl 5.40 and later.
  - **URI 5.40** and **HTML::Parser 3.86**, maintainer tools only.
- **A realtime socket outlived the session it was opened in.** Signing
  out, a password change or a sign-out everywhere ended the session, and
  its open socket kept receiving the member's badges and followed threads.
  Each worker now asks every 30 seconds, in one call for all its sockets,
  which of their sessions are still live and closes the others with code
  1008; signing out closes that session's sockets on its own worker at once
  (ADR 0148, migration 135, `session_live_v2`).
- **Password-reset, email-change and verification tokens waited in the
  outbox in clear.** Whoever could read the outbox table, its backups or a
  dead letter could follow the link. The outbox now holds each token sealed
  with a key derived from the signing secrets and bound to its token id;
  the mail worker opens it only to send, and a token it cannot open is not
  mailed.
- **Anyone could lock a member out of signing in.** The limit on the
  sign-ins one account receives, from every address together, counted
  anyone's attempts, so twenty wrong passwords every five minutes from
  anywhere refused the member's own right password too. A browser that has
  signed in to an account now carries a signed mark of it
  (`gpforum_device`, no identifier in it), and the account's limit does not
  apply there, only its address's; every other browser still meets the
  account's limit (ADR 0147).
- **Search showed hidden and deleted content until the outbox caught up.**
  A hide or a delete leaves the index through the outbox, a while after it
  is made, and until then search and its suggestions answered from the
  document alone, title and body. Both now read the thread and the post as
  they are when the query runs and leave out what is no longer visible,
  whether its document has gone yet or not (migration 131,
  `search_ranked_v9/v10`, `search_autocomplete_v7/v8`).
- **A suspended member could still like, report and attach files.** Only
  posting and messaging checked the suspension. A like, an unlike, a report
  of a post, thread, profile or message, and an attachment upload or delete
  are now refused with 403 as well and recorded as `suspended_user_block`;
  bookmarks, subscriptions and read markers, which nobody else sees, stay
  open.
- **An email change needed no password and told the old address
  nothing.** A signed-in session alone could move an account to another
  address and, from there, reset its password. The change now asks for the
  current password, checked before the address is looked up and limited as
  a password change; the address it leaves is mailed a notice in the
  member's language (English and Italian) saying how to stop it, and a
  password reset or change now withdraws an email change still waiting
  (ADR 0143).
- **A cached page published one visitor's CSRF token, and sign-in kept
  it.** A public page was stored with the first visitor's token in its
  language and theme forms, so those forms failed for everyone else, and a
  page with a flash message showed it to the visitors after. A stored page
  now carries no token; a visitor who is not signed in is let through on
  `Sec-Fetch-Site: same-origin`, or an `Origin` of this site, while a
  signed-in member always needs their own session's token. A page with a
  flash message is not cached. The token is rotated at sign-in and at
  sign-out, and the page after sign-out is a visitor's (ADR 0141).
- **A post could be any length.** Only drafts had a maximum; a thread,
  reply or edit posted without one was bounded only by the request size, and
  each read renders it. A body now holds at most 50,000 characters wherever
  it is written -- thread, reply, edit, preview, draft -- refused in the
  reader's language, and `post_bodies` and `held_posts` check the same
  number (ADR 0140, migration 129).

- **A few kilobytes of fence lines took seconds to render.** The body
  renderer searched the rest of the body from each line opening a code
  fence, and the rest of a paragraph from each `[`: 4,000 lines of "```a"
  took over three seconds to write, and again to read. It now reads a
  body in one pass, and a test counts the characters it reads on
  adversarial bodies.

- **The proxy's account could replace stored uploads.** `gpforum start
  --service` set umask 007 before Hypnotoad so the socket would be the
  `gpforum` group's, and every file the service wrote became the group's to
  write too -- while the deployment guide puts nginx's or Caddy's account in
  that group. The service now runs under umask 027, and only the socket is
  bound `0660`, before the first worker accepts and kept across a
  zero-downtime upgrade (ADR 0086).

- **Behind the shipped proxy every client of the site shared one rate-limit
  bucket.** Over the service's Unix socket Mojolicious gives a connection no
  peer address, so the address read ahead of `X-Forwarded-For` was undef and
  the controllers fell back to a constant: ten failed logins from anywhere
  locked the login form for everyone for five minutes, registration and
  password reset after five, search after sixty a minute, and the audit rows
  and sessions hashed `unknown` for every client. A connection on the socket
  now takes its client from the last `X-Forwarded-For` entry, the one nginx
  and Caddy append; entries a client sent ahead of it are ignored, and a
  request without one (`gpforum status`, curl on the socket) is keyed by its
  own connection, never by a constant (ADR 0086). TCP listeners keep
  `GPFORUM_RUNTIME_TRUSTED_PROXIES` as before.

- **PostgreSQL can refuse direct access to forum tables.** The separately
  provisioned runtime login owns no objects, belongs to no roles and can
  execute only the 195 current API signatures. Versioned `SECURITY DEFINER`
  entry points use a fixed search path; their implementations and helpers
  have no public execution grant. Real login and HTTP tests verify allowed
  workflows and denied table access under password authentication.

- Two `gpforum secret rotate` at once lost one's new secret. Each read the
  environment file, edited it and renamed the edit over it, so the second
  rename dropped the token the first had written, one a scraper may already
  have been given. Rotations now take turns on an advisory `flock` of the
  file itself, taken again on the file a rename put in place while one
  waited, so no lock file is left beside it.

- `gpforum secret rotate metrics` promised a re-read the service refused.
  The running service does not read the tokens again from an environment
  file an account other than its owner may write (`mode & 022`), while the
  rotation and `gpforum doctor` checked only what every account may read: a
  `0660` file got "accepts both now, with no restart" and `--finish` "refuses
  them now", while the service kept the old token and refused the new one,
  and doctor said the settings were fine. One rule now judges the file for
  all three (`MetricsTokens->others_may_write`): the rotation says so with
  `chmod 0640 FILE` and names the restart, and doctor lists it under `!`
  with the same fix.

- A metrics token `gpforum secret rotate metrics --finish` dropped could
  come back in a worker Hypnotoad forked later. The tokens follow the
  environment file in each process that answers a check, and the manager
  answers none: it kept the tokens the service started with, and a worker
  forked from it -- after its accepts, or a missed heartbeat -- while the
  file could not be used (a line that is not `NAME=value`, a mode another
  account may write, no token) kept them too, and took the dropped token
  while it refused the one in use. The manager now reads the file at each
  turn of its loop, on Hypnotoad's `wait` event, so a new worker starts from
  the list it accepted last (ADR 0124, amended).

- A password change could undo the reset that took an account back. The
  change verifies the current password before its transaction; a reset that
  committed in between replaced that password, and the change rotated the
  reset's password away all the same, so whoever knew the old one set the
  last password. A reset that waited for a change answered ok and left the
  change's password, and a change that waited for another reported success
  with a password it never stored. A rotation now locks the member's active
  credential, and when another rotation commits while it waits, the
  credential that one created; a change goes on only while the credential it
  verified is that one, and answers `invalid_current_password` otherwise.

- An erasure racing a login could deadlock, and the login keep its session.
  The erasure anonymized the account row first and revoked the credentials
  after; a login holds the credential it verified while it opens its
  session, whose foreign key then shares the account row. Each waited for
  the other, PostgreSQL aborted one of them -- mostly the erasure -- and the
  login stayed signed in. The erasure now revokes the credentials first, in
  the order a login takes its locks, so it waits for that login to commit
  and revokes its session with the others; and each credential and session
  is revoked at the erasure's time or at its own creation, whichever is
  later, where a row stamped after the erasure read its clock failed the
  `revoked_after_created` checks, and the whole erasure with it.

- A password reset that waited for a login could fail and leave that login
  signed in. The reset reads its clock before it waits for a login holding
  the member's credential, and the login's session is stamped after, often a
  second later; the reset gave the session its own, earlier time, which
  `sessions_revoked_after_created_check` refuses, so the whole reset rolled
  back (two runs in five of `t/integration/postgres-login-reset-race.t`).
  A logout on a host whose clock is behind the one that signed the member in
  failed the same way. A session is now revoked at the time given or at its
  own creation, whichever is later, in one UPDATE that touches only sessions
  still live, so a revocation that landed first keeps its time.

- A login could keep a session after a password reset took the account back.
  The login verifies the password outside any transaction and opened its
  session afterwards; a reset (or a password change) that committed in
  between revoked the member's sessions before that one existed, and it
  stayed valid for whoever knew the old password. The session is now opened
  in a transaction that holds the verified credential `FOR SHARE` and opens
  none once the credential is revoked, and the rotation locks the credential
  `FOR UPDATE` before the same transaction revokes the sessions: a login
  that got there first has its session revoked, one that came after is
  refused as a wrong password.

- `GET /health/ready` no longer shows its full report to anonymous clients:
  without a valid metrics token it answers only
  `{"status":...,"check":"ready"}`. The report -- every check, its error text,
  replication slot names, `report.problems`, the runtime -- needs the same
  token as `/metrics` (`Authorization: Bearer` or `X-GPForum-Metrics-Token`,
  previous tokens accepted during a rotation). The status code is unchanged in
  every case, and a wrong token gets the status, not a 401, so a stale probe
  token cannot take nodes out of service. `GET /health` answers
  `{"status":"ok"}` without the token. Both send `Cache-Control: no-store`.
  Development and test without a configured token still show the full reports.

- Erasing a member also deletes all their export requests, whatever their
  status, and removes the bundle from their stored `privacy.export` answers in
  the command log, in the same transaction; the audit keeps the export events
  and lists the discarded requests. An export locks the member's row until it
  commits, so a concurrent erasure waits for it and then discards the bundle,
  and an export for an erased member is refused (`not_found`).

- Session revoke and validation check, in SQL, that the session belongs to the
  member id presented. A DBIx::Class `find` had dropped the member id from the
  query, so one member could revoke or validate another member's session
  given its id.

- **The replication docs no longer recommend `pg_monitor`**, which also
  grants `pg_read_all_settings` and with it a standby's `primary_conninfo`,
  replication password included; `pg_read_all_stats` is enough.

- **A `password=` in `GPFORUM_DATABASE_DSN` is redacted in command failure
  messages**, on stderr and in `--json` output; DBI's connect error printed
  it as is.

- **A static response that writes the session cookie back is marked
  `private`**, so no shared cache can store one visitor's session cookie and
  serve it to another.

- **Search no longer shows a moved thread's documents under the category it
  left** (ADR 0102): a thread moved from a public category into a private
  one stayed searchable by anyone until its reindex batch ran. Documents
  whose live placement disagrees are hidden until the outbox reindexes them.

- **A cancelled search no longer logs the visitor's search text.** The
  degraded-search log line carried the whole database error, with the SQL
  and its bound values; it now holds only the error.

- **An account that has not confirmed its e-mail is no longer a member.**
  Effective visibility (ADR 0102) counted `pending` accounts as members, so a
  session for one read members-only categories and threads. Such an account
  cannot sign in and confirming makes it `active`, so it now reads what an
  anonymous visitor reads.

- **Email::Sender 2.603** (was 2.601): CVE-2026-93012, command execution
  through an envelope address on Windows. Good Phorum does not run on Windows;
  the lock takes the fix anyway, and the `cpanfile` floor now requires it.
  DBI 1.655, Cpanel::JSON::XS 4.53 and DateTime::TimeZone 2.71 are updated
  too. 34 distributions nothing requires any more (Email::MIME, the
  WWW::Mechanize test stack, DateTime::Format::Pg and others) left the lock.
  `script/cpan-audit` reports no unexcluded advisory.

- **`X-Forwarded-For` is believed only from trusted proxies.** Any sender's
  header was believed, so a client that reached the application directly
  could name its own address, and with it its rate-limit bucket. Hypnotoad
  now trusts the loopback by default (`GPFORUM_RUNTIME_TRUSTED_PROXIES`).

- **Logins are limited per account, not only per address.** Ten tries per
  address every five minutes let many addresses try one account thousands of
  times. An account now takes at most twenty tries every five minutes, from
  every address together.

- **A session the database could not check is no longer served as its
  user.** When validating the session cookie failed, the request went on as
  the cookie's user: a session revoked by a password change or a sign-out
  everywhere acted again whenever the check failed and the page's own
  queries did not. The request now gets 503; the cookie is kept, so a valid
  session works again once the database answers.

- **Login timing no longer tells which accounts exist.** A login for an
  unknown or deleted account answered at once, while a wrong password took an
  Argon2 verification (68 ms here): timing the reply enumerated usernames and
  addresses. Every failed login now pays the same verification, against a
  per-process decoy hash (69 ms).

- **A login could be replayed without the password.** Logins were
  idempotent commands whose stored answer held the session's bearer token:
  sending the victim's command id and identifier with any password signed
  the sender in as the victim, and the token sat in `command_log` in plain.
  A login no longer passes through the command log; each attempt checks the
  password. Reproduced and pinned against the application on PostgreSQL.
- **Review fixes to effective visibility's third stage (ADR 0102).** Any
  question about a hidden thread died (a restricted-hash read), failing
  its notifications, downloads and realtime; realtime announced a private
  reply's id and author to every subscriber of a public thread; hiding or
  deleting a thread silenced every open page for good, even after a
  restore; "mark all read" counted notifications the inbox hides. The
  unread count now stops at "more than 99" instead of growing with the
  backlog on every delivery.
- **Hidden and restricted content leaves the public page cache at once.**
  A thread's cached public page was never invalidated, and hiding or
  restoring a post, locking a thread or reversing a moderation action did
  not reach the cache at all: anonymous visitors kept seeing hidden content,
  or a thread whose category turned private, until the entry expired (60 s
  by default). Thread and post events now purge the thread's page and its
  category's; moderation and category changes purge every public page.
- **New threads and replies inherit their place's visibility (ADR 0102).**
  A thread created without a visibility was public whatever its category,
  and the reply form sent the thread's own visibility: in a members-only
  category both were stored public, and became readable by anyone the day
  the category was opened. They now inherit the effective visibility of
  their category or thread, and a broader one is refused. The new-thread
  form's visibility defaults to "Same as the category".
- **Inboxes, lists and realtime channels respect effective visibility (ADR
  0102).** A notification, mention, bookmark or feed item kept showing its
  thread after the thread's category turned private or the reader lost
  their grant; the unread count and the realtime badge counted them. They
  now show only what the reader can still read. A realtime thread channel
  ignored the space, shut members out of members-only threads and never
  re-checked a subscriber; it now opens to the thread's readers and asks
  again at every broadcast.
- **Attachment downloads respect effective visibility (ADR 0102).** A file
  attached to a post in a private category was served to anyone, anonymous
  visitors included, because only the post's own visibility was checked. It
  is now served only to readers of the post or thread (its space, category,
  thread and post) and to its uploader.
- **Review fixes to effective visibility's first stage (ADR 0102).** The
  second page of a thread (`?after=`) dropped the post visibility filter for
  signed-in readers, showing private replies; restoring a thread looked it up
  without the viewer; a category page's thread list used a viewer without
  that category's grant context; a suspended author kept reading their own
  private threads; a thread-scoped `category.read` binding was read as a
  grant on its whole space. All are fixed and pinned by tests that fail on
  the old code.
- **Notifications and mentions no longer leak private content (ADR 0102).**
  A reply or mention in a private or members-only place notified every
  subscriber or mentioned user, with the thread, the post and the actor. A
  recipient is now notified only if they can read the source, and a mention
  of someone who cannot is not recorded.
- **Profiles and page metadata respect category visibility (ADR 0102).**
  A member's public profile listed their threads and replies in private
  categories, and a thread page in a members-only category asked to be
  indexed with OpenGraph data; both now judge the category and space too.
- **Search no longer shows private or members-only categories' content
  (ADR 0102, stage 2).** Search and autocomplete judged only a document's
  own visibility; they now also judge its category and space, live, for
  the reader.
- **Private and members-only categories no longer leak (ADR 0102, stage
  1).** A category or space an administrator marked private or members-only
  was listed to anonymous visitors and its pages and threads answered 200.
  Category lists and pages, thread lists and pages, and post lookups now
  apply the effective visibility of the space, the category, the thread and
  the post for each reader; denied reads are 404. Members-only threads, which
  were hidden from everyone, are now readable by members, and authors read
  their own private threads. Search, feeds, notifications and the other
  surfaces follow in the next stages.
- Harden `script/gpforum-mail-check` evidence: always set
  `secrets_redacted` / `private_beta_claimed=false`, emit `residual_gaps`, and
  scrub SMTP passwords plus the internal probe token from nested
  strings/errors (including connect failures). Docs + evidence-live updated.
- Registration remints user `id` once when the unique primary key
  conflicts, and does not return another user's account.
- Password credential writes remint `id` once when the unique primary key
  conflicts, and do not return another user's credential.
- Identity token writes remint `token_id` once when the unique primary key
  conflicts, and do not return another user's token.
- Session writes remint `session_id` once when the unique primary key
  conflicts, and do not return another user's session.
- Identity token writes retry hash allocation once when the unique
  `token_hash` key conflicts, and do not return another user's token.
- Session writes retry hash allocation once when the unique `session_hash`
  key conflicts, and do not return another user's session.
- A second unused password-reset, email-change, or email-verification
  token for the same user replaces the open row instead of inserting
  another. The previous unused hash is invalidated. Email verification
  is a legal `identity_tokens.token_type`.
- Mojolicious keeps the current `GPFORUM_SESSION_SECRET` first for new
  cookies and still validates previous secrets from
  `GPFORUM_SESSION_SECRETS`. `/metrics` accepts previous scrape tokens from
  `GPFORUM_METRICS_TOKENS`. Staging and production reject the development
  default in both the current secret and the previous list.
- Staging and production profiles send
  `Strict-Transport-Security: max-age=31536000; includeSubDomains` and mark
  session cookies Secure. Development and test omit HSTS and the Secure
  flag. `production-small` and `production-medium` follow the same TLS
  cookie/HSTS policy as `production`.
- Wired the existing `forum_retrieval` limiter on `GET /search`, and added
  `write_rate_input` hashes on `Web::ModerationAccess`, `Web::AdminAccess`,
  and `Web::PrivacyAccess` so moderation writes, admin writes, and privacy
  requests share the same CSRF/telemetry write helpers as forum
  `write_user_id`.
- Updated every locked CPAN distribution to its latest release and pruned
  32 orphaned distributions from `cpanfile.snapshot` (187 -> 157). DBI moves
  from 1.647, which carried 11 CPANSA advisories, to 1.653; URI, HTTP::Date,
  and List::SomeUtils::XS also leave vulnerable versions. Security floors for
  those transitive dependencies are now declared in `cpanfile`, and
  `cpan-audit` reports no open advisories apart from the two unfixed
  Mojolicious default-secret advisories, which `GPFORUM_SESSION_SECRET`
  enforcement already mitigates.
- Raised runtime CPAN floors to current secure releases, including
  Mojolicious 9.49, Cpanel::JSON::XS 4.52 (CVE-2026-9334,
  CVE-2026-9516), Crypt::Argon2 0.032, DateTime 1.67, DBD::Pg 3.21.2,
  and Mojo::Pg 5.0.
- Rejected non-hash JSON in `Outbox::ClaimedMessage` so Cpanel::JSON::XS
  4.42+ `allow_nonref` cannot treat scalar payloads as outbox events.

### Added

- **Legal pages built on the texts that require them.** The terms follow
  the Digital Services Act (Regulation (EU) 2022/2065, arts. 14 to 21:
  restrictions, moderation and its automated tools, notices, reasons,
  complaints); the privacy notice follows GDPR article 13 in its order;
  the cookie notice lists every cookie with its purpose and lifetime, as
  the Garante's guidelines of 10 June 2021 ask for technical cookies; a new
  legal notice (`/legal/notice`, in the footer) names the service provider
  (D.Lgs. 70/2003 art. 7) and the Act's single point of contact (arts. 11
  and 12). `/.well-known/security.txt` follows RFC 9116. Each page states
  its basis, lists its sections and says what the operator has not given.
  The copy describes only what GPForum does: where it does not yet send a
  statement of reasons or take complaints, the pages say to write to the
  point of contact.

- **Staff end a legal hold (migration 1051).** Each active hold on the
  privacy review page has its own form: with a reason, the hold ends, and
  the deletion request it held goes back to approved, when its erasure job
  waits, or to pending. It records a `hold_ended` action and
  `privacy.retention_hold_ended`. Nothing ended a hold before, so a held
  request could never be approved or erased.

- **Reactions on posts.** Beside the like, a member gives a post one of four
  reactions (heart, laugh, celebrate, think), each once and never on their
  own post, and takes it back (ADR 0145, migration 133). Each is one row in
  `post_reactions`, counted when the page is read; the thread page's one
  read (`api.forum_thread_page_reads_v3`) answers them with the likes, the
  writes are commands (`api.community_react_command_v1`,
  `api.community_unreact_command_v1`) with their events, and erasure and
  export carry them (`api.privacy_revoke_user_v19`,
  `api.portability_export_parts_v21`).

- **Address and range bans.** Staff with the admin console ban one IPv4
  or IPv6 address, or a CIDR range of at most /16 (IPv4) or /32 (IPv6),
  from `/admin/address-bans`: from registering, from signing in (and
  registering), or from every write, with a reason for staff and a length
  of an hour to a year. They edit and lift bans, see who placed, edited
  and lifted each, check which ban covers an address, and see the address
  they write from; a ban that would cover it, for sign-in or writes, is
  refused. Every write request asks, in one place around the action, for
  the live ban covering its client -- one GiST lookup in PostgreSQL --
  and a covered one gets a plain refusal naming neither reason nor range;
  reads ask nothing, and signing out, language and theme, one-click
  unsubscribe, privacy requests and the ban forms are never refused. Every
  change is audited without the reason; bans are left out of the export
  and an erased staff member's name leaves them. Migration 134, ADR 0146.

- **Profile pictures.** On the new `/settings/avatar` a member uploads a
  PNG, JPEG, GIF or WebP of up to 5 MB and 8,000 pixels a side; it goes
  through the attachment validation and the malware scan, and the system's
  image tool -- libvips' `vipsthumbnail` (`libvips-tools`, `vips`), or
  `sips` on a Mac -- crops it about its centre to a square kept at 64, 128
  and 256 pixels with nothing of the original's metadata. It is shown on
  the profile and beside the member's posts in place of the initial; the
  one shown stays while a new one is checked, and a picture the scan
  refuses or the tool cannot read is never shown. The member replaces or
  removes it; staff holding `avatar.moderate` remove one from the profile,
  audited with the reason given. Without the tool (or with
  `GPFORUM_IMAGE_TOOL=none`) pictures are off. Erasure deletes the files
  and the rows; the export says what is shown (ADR 0144, migration 131).

- **Two-step sign-in with an authenticator app.** On the new
  `/settings/security` a member gives the password, scans a QR code drawn by
  the forum itself (or types the key), and confirms with a first code; ten
  single-use recovery codes are shown once and the member's other sessions
  end. A sign-in then asks for a six-digit code, or a recovery code, after
  the password, each accepted once; five wrong codes end the attempt, ten in
  an hour pause sign-in for that hour. The password and a code turn it off,
  and the member is mailed in their language whenever it is turned on, off,
  reset or paused. Staff see on a member's admin page whether it is on --
  never the secret -- and an administrator resets it for a member who lost
  their phone; on `/admin/security` an administrator who has it on may
  require it of every staff member, whose consoles stay closed until they
  set it up. Secrets are sealed with `GPFORUM_SECOND_FACTOR_KEY`, which a
  deployed forum needs before it offers two-step sign-in (`openssl rand -hex
  32`; rotate through `GPFORUM_SECOND_FACTOR_KEYS`). Erasure deletes it and
  the export says only whether it was on (ADR 0142, migration 130).
- **Members write to each other in private.** A profile offers "Send a
  message"; two members hold one conversation, listed on the new
  `/messages` inbox with how many messages wait, and read and answered on
  its own page, plain text of up to 5000 characters, never cached and found
  by nobody else. The recipient is notified once per unread run, in the
  inbox and by email under the new "Private messages" kind of the
  notification settings, and their open pages get a realtime hint; nothing
  carries the text. A block either way refuses messages, an ignore silences
  their notices, a suspended member cannot send, the watched words and the
  link rule screen them, and a new member starts at most 3 conversations a
  day (20 for others). A member reports a message to the moderators, who
  read only that message, through a form that audits the reading. Erasure
  deletes the member's messages and the export holds the ones they wrote
  (ADR 0139, migration 128).
- **Staff keep private notes on members.** Staff holding the new
  `member_note.view` permission read notes about a member on the member's
  admin page, and the three newest beside every report about the member's
  profile, posts or threads, with who wrote and last edited each and when;
  `member_note.write` adds, edits and deletes them through forms that work
  without JavaScript, with CSRF, the moderation rate limit and suspended
  staff refused. Every change is audited without the text. The member never
  sees them; they are in no feed, search, event or realtime message, the
  data export leaves them out and erasure deletes them (ADR 0138, migration
  125).
- **Bookmarks can be filed in folders.** A member names a folder with the
  note when saving a bookmark, or moves a bookmark from the bookmarks page,
  which now names their folders with counts and lists one folder or all
  (ADR 0136, migration 121). The folder is a row beside the bookmark,
  written after the bookmark command through
  `api.community_set_bookmark_folder_v1`; the list is
  `api.community_list_bookmarks_v3`, with the folder joined, and the folders
  `api.community_list_bookmark_folders_v1`.

- **A daily or weekly email digest** (PB-08, D01, ADR 0078 amended,
  migration 119). A member who turns the Digest channel on in `/settings`,
  daily or weekly, receives one email per UTC day or week naming the
  threads they follow (and those in categories they follow) with posts
  they have not read: each thread linked, with its category and how many
  posts are new, in their language, as plain text and HTML. The hourly
  scheduled-jobs runner queues it through the outbox, once per member and
  period and at most `--limit` members a run; the worker checks the
  preferences again as it sends. The mail's one-click unsubscribe link
  turns the digest off, audited. Nothing to report sends nothing.

- **Notification preferences by kind, notification email and one-click
  unsubscribe** (PB-08, D02/D04/D05, ADR 0078 amended). In `/settings` a
  member turns replies in followed threads, mentions and moderation notices
  on or off for the inbox and for email; by default all reach the inbox
  and mentions and moderation notices are emailed. Fanout follows these
  choices, and a notification taken by email is sent through the outbox
  in the member's language, after its preferences are read again. Every
  notification mail has a signed unsubscribe link and the RFC 8058
  `List-Unsubscribe` and `List-Unsubscribe-Post` headers; opening the link
  changes nothing, its button or the mail client's one-click POST stops
  that kind of email (or all of it), audited. Account mail (verification,
  password reset, email change) is never affected.

- **Threads carry tags.** An author names up to five tags with the title,
  on the new-thread form and in the edit-title sheet (ADR 0135, migration
  117): each a link under the title on the thread page, on its category's
  rows and on the home page's latest list. `/tags` lists every tag with how
  many discussions the reader may read under it, and `/tags/:slug` those
  discussions, in every category or in one (`?category=ID`). The thread
  command is untouched: the tags are set after it, through
  `api.forum_set_thread_tags_v1`, and read per page through
  `api.forum_thread_tags_v1`; the tag pages read
  `api.forum_list_tagged_threads_v1` and `api.forum_list_tags_v1`.

- **First-post review queue** (PB-05, ADR 0134). Staff can hold a new
  member's first posts (a number from 1 to 20, set in `/admin/automod`;
  off by default) and, optionally, the posts the watched words or the link
  rule flag. A held thread or reply is kept outside threads and posts, so
  no page, count, feed, search, realtime message, notification or JSON
  answer carries it; its author sees it as awaiting review on `/pending`
  and may withdraw it. Staff publish or reject it from
  `/moderation/review`; a publication is the author's own post, written
  then with every check of a write, and the author is notified. Every hold,
  decision and rule change is audited.

- **Similar discussions while a thread is written**: as the title is typed
  on the new-thread form, the threads it resembles appear under it, from the
  same bounded and permission-aware autocomplete the search box uses, so a
  discussion already open is found before a second one is started. Nothing
  changes without the page's script.

- **Watched words and automod rules** (PB-05, ADR 0133). In the admin
  console, `/admin/automod` keeps up to 500 watched words or phrases (whole
  words, any case, a star at either end for a word that runs on), each
  blocking, flagging or replacing what it matches in new threads, replies,
  post edits and thread titles, and one link rule: at most N links in a post
  by a member below a trust level, blocked or flagged. A blocked write comes
  back saying which words, in the writer's language; a flagged one is posted
  and lands in the moderation queue as a report by *Automod*; a replaced one
  is stored replaced. Staff who moderate every thread are not screened.
  Every rule change and every decision is audited. Migration 115 adds
  `watched_words`, `automod_settings`, the `moderation_watched_word_*` and
  `moderation_automod_*` functions, and lets a report have no reporter.

- **A reply may answer one post** (ADR 0132): "Reply" under a post opens
  the composer naming it ("Replying to #12 by @name", with a way to drop
  it), and "Quote" does the same and quotes the words; once the reply is
  made it is linked to the post (`post_replies`, migration 114), and the
  page shows "In reply to #12" under its byline, a link to the post. The
  reply command is untouched; a link that cannot be written leaves the
  reply as it is.

- **Operators say something to every reader**: administrators publish a
  site announcement from `/admin/announcements` (plain text, up to 500
  characters, as a notice or a warning) and retire it when it has served;
  every page shows the active ones, newest first and at most three, under
  the site header. Publishing and retiring are audited commands with their
  events, and purge the public page cache at once (migration 113,
  `site_announcements`, ADR 0131).

- **Members ignore and block each other** (PB-06, ADR 0130). From a member's
  profile a signed-in reader ignores them (their posts collapse behind a
  "show" control in threads, and their replies and mentions leave the
  reader's inbox, unread count and mentions page) or blocks them (as ignore,
  and they can no longer mention the reader). `GET /settings/ignored` lists
  the members, at most 500, with a way to remove each. Staff and moderation
  notices are never silenced; nobody is told. Erasure empties both sides of
  the lists and the export carries the member's own. Migration 112 adds
  `member_relations`, the `community_relation_*` functions and new versions
  of the inbox, unread count, mentions, erasure and export functions.

- **A member says a few words about themselves**: Settings opens with a
  Profile section whose bio (plain text, up to 500 characters) shows on the
  public profile as paragraphs; an empty bio takes it down. Erasing the
  account clears it and a member's export carries it under `profile`
  (migration 111: the `users.bio` column, the identity update, the erasure
  update and the export each at a new version, ADR 0126 section 2).

- **Search says where to look**: the search form offers Everything, Thread
  titles or Posts (`scope`), kept with the query across filters and pages;
  the ranked search takes it as one more parameter (migration 110,
  `search_ranked_v7/v8`; the shipped versions stay, ADR 0126 section 2).

- **A member likes a post, and takes the like back** (ADR 0129): `POST
  /p/:post_id/like` and `/p/:post_id/like/remove`, each a native form under
  the post with the count beside it, pressed when the reader gave one; a
  reader not signed in sees the count. A like is one row per member and
  post (migration 109, `post_likes`), counted when the page is read, never
  on one's own post and never on a post that is not there to be read; each
  like and unlike records its event (`post.liked`, `post.unliked`), the
  thread's public page is refreshed, erasing an account deletes its likes
  and a member's export lists them.

- **Staff pin and unpin a thread**, from the thread's own tools and from the
  moderation actions page: `POST /moderation/threads/:thread_id/pin` and
  `/unpin` record a moderation action, its event (`thread.pinned`,
  `thread.unpinned`), the audit entry and the public cache purge, the way
  locking does; a pinned thread leads its category and wears its badge on
  its page as in every list. The thread's tools now also lock, unlock and
  hide it for a reader who moderates threads, each form with the reason the
  audit log keeps and a way back to the thread (`return_to`); before, every
  moderation form lived in the report queue. The reader of a request
  resolves, with their grants, whether they hold the global
  `thread.moderate` binding (migration 108: viewer resolution, moderation
  target and target update each at a new version, ADR 0126 section 2).

- Private thread and reply drafts with native Save/Preview, a drafts list,
  resume/delete and progressive JavaScript autosave/live preview. PostgreSQL
  revision checks prevent silent concurrent overwrite; successful publication
  consumes the draft atomically and failed publication preserves it. Drafts are
  limited to 100 per owner, expire 30 days after saving, appear in member exports
  and are removed by account erasure. Preview uses the existing safe post markup
  renderer. Local PostgreSQL, HTTP, JavaScript and browser behavior checks pass;
  release gates remain separate. [ADR 0128](docs/adr/0128-private-composer-drafts-and-canonical-preview.md)
  and the [composer guide](docs/architecture/composer-drafts.md) record its contracts.

- **`sudo gpforum setup` links `gpforum` into `/usr/local/bin`**, so the
  commands it offers next run from any directory; a `gpforum` already there
  that is this checkout's is left as it is, another is named with the `ln
  -sf` that replaces it.

- **`sudo bin/gpforum setup` installs the dependencies first** on a fresh
  clone, as `make install-deps-production` does (`install-deps-postgres`'s
  set for `--environment development`), saying what it runs, then goes on as
  setup; `--dry-run` says so and installs nothing. `script/bootstrap-deps
  --for-setup` is what it runs.

- **`gpforum upgrade`** prints the three commands that upgrade the forum,
  written for this host: the code and its dependencies, then `gpforum migrate`
  and the restart this host's service manager needs, then `gpforum doctor
  --upgrade`, with the backup to take before them. `gpforum help` lists it
  under Maintain, and `docs/ops/upgrade.md` gives the same lines.

- `gpforum setup` sets a host up in three questions (ADR 0122): the public
  address, the database and how mail leaves; Enter takes each suggestion. As
  root on Linux or FreeBSD it makes the `gpforum` account and its uploads
  directory; it writes the environment file (0640, `root:gpforum` as root)
  with new secrets, none printed; it makes the role and database as
  PostgreSQL's superuser, sending the password only as a SCRAM verifier, or
  prints the two `psql` commands; it migrates and names the next steps. Run
  again, it changes nothing and says so, and it replaces a setting only when
  asked or with `--force`.

- `gpforum service print [systemd|rc|launchd|nginx|caddy]` prints the service
  files for this host -- its code directory, environment file, public name and
  listen address, the outbox worker on every OS -- and the commands that copy
  and start them (ADR 0123). It installs nothing; `--to DIR` writes them to a
  directory to read first. `gpforum doctor` offers it for a missing or changed
  unit and names this OS's nginx directory.

- `gpforum backup [--to DIR]` dumps the database (`pg_dump -Fc`) and archives
  the uploads with a manifest of versions, sizes and SHA-256 into a dated
  directory only its owner reads; `gpforum restore --check DIR` checks that a
  backup can be restored, without restoring it.

- **The main pages are warmed before a pre-forking server forks.** Under
  Hypnotoad (or `prefork`) the manager renders the home page, the category
  index, the login and registration forms and the newest thread with its
  category before it forks, so every worker starts with the templates
  compiled, the statements prepared and the memos filled: the first request
  to a worker took 95 ms for the home page and takes 21. One line in the
  log says what was warmed. `GPFORUM_WARMUP_ENABLED=off` turns it off; a
  single process (`daemon`, morbo) is not warmed. ADR 0121.

- `gpforum doctor` checks a forum the way an operator would, one line each,
  in Italian or English: every setting, as the service checks them at its
  start; the host's limits; the database, with a refused connection, a wrong
  password or a missing database said in one sentence; migrations pending
  and budgets that drifted; the rest of the readiness report; the outbox
  worker, by the age of the oldest message nobody sends; mail-check's dry
  run and antivirus-check's findings; the service files installed against
  this release's `deploy/`, the web service and the outbox worker running
  the code on disk, and systemd's timers; and the public address over TLS.
  Under each problem a `Fix:` line names the variable, the file and the
  command, and the last line counts what there is to fix. `--json` gives
  the same findings; it exits 1 when something failed.
  `gpforum doctor --upgrade` checks what an upgrade leaves behind: the
  settings, the modules this release needs for the Perl running it, pending
  migrations, unit files that differ from the release's and services still
  on the code before. `docs/ops/doctor.md` explains every line, and
  `docs/ops/upgrade.md` is the upgrade in three commands ending with it.

- `gpforum status` prints the running service's whole `/health/ready`
  report, one line per check with the fix under each problem, asked with
  the metrics token from the environment file: no curl, token or jq. It
  asks where Hypnotoad listens, not the proxy, says when the service does
  not answer or keeps its report from a token it does not hold, and exits 0
  when the service is ready.

- `gpforum admin create --email --username` makes the forum's owner from the
  shell: an account active and verified at once, bound to the owner role,
  audited as `admin.bootstrap_created`. It asks for the password twice
  without echo, or reads it with `--password-stdin`, and ends with where to
  sign in. `gpforum admin grant EMAIL|USERNAME` makes an existing member the
  owner, and says when that account cannot sign in yet. The first
  administrator needs neither psql nor a mail server.
  `bin/gpforum-admin-bootstrap --user-id` keeps working, as `gpforum admin
  grant --user-id`.

- `gpforum`, the front door (ADR 0120). `bin/gpforum` runs from anywhere,
  through a symlink on the `PATH` too, and re-runs itself under Carton when
  `local/` is not on `@INC`: `gpforum migrate` where the documents said
  `script/gpforum-carton exec perl -Ilib bin/gpforum-migrate`. It reads the
  environment file the service reads -- `/etc/gpforum/gpforum.env`,
  `/usr/local/etc/gpforum/gpforum.env` on FreeBSD,
  `$(brew --prefix)/etc/gpforum/gpforum.env` on macOS, or `--env-file FILE`
  -- under what the shell sets, so a command typed by hand sees the
  service's settings without `set -a; . /etc/gpforum/gpforum.env`. Its help
  is its own, in Italian or English: the verbs grouped as Set up, Run,
  Check and Maintain, then which file it read; `gpforum help --all` adds the
  benchmarks, drills and Mojolicious's commands, and `gpforum help VERB` the
  verb's usage. Every `bin/gpforum-*` command answers to a verb
  (`gpforum partitions`, `budgets`, `outbox`, `dead-letters`) and to its old
  name, and `gpforum start --foreground` is the development server. A
  mistyped verb is answered with the nearest one.

- **Relative times** ("3 minutes ago", "3 minuti fa") in a `<time>` element
  whose title keeps the absolute local time, per the reader's locale and zone,
  without JavaScript. Cached anonymous pages never carry a signed-out
  member's time zone.

- **Badge failures reach the log and `/metrics`**: `notifications.badge_failures`
  and `last_badge_error`; each message is logged at most once every five
  minutes per process, without the SQL, bind values or a DSN password.

- **`make fresh-checkout`** clones the current commit into a temporary
  directory and runs the README quick start and each step of `make check`
  there, timing each; with `GPFORUM_DATABASE_DSN` it also creates a plain
  role and database and runs the integration tier. The README now says the
  database commands need a running PostgreSQL and how to start one.

- **Replication on `/metrics`** (ADR 0058): on a primary, each standby's
  state, replay lag and bytes behind, and each slot's retained WAL; on a
  standby, the age of its last replay. `/health/ready` gains a
  `replication_slots` check, degraded when an inactive slot keeps more than
  1 GiB of WAL. Grant `pg_read_all_stats` to see standby details.
- **The plan gate checks deep and signed-in pages**: halfway down the longest
  thread, the largest category and the latest threads; it fails when a deep
  page filters more rows than its first page or ignores its cursor.

- **`--json` on the state-reporting commands** (`migrate`,
  `partition-maintenance`, `platform-check`, `query-budget`,
  `scheduled-jobs`, `search-rebuild`, `outbox-dispatch`,
  `dead-letter-replay`): one JSON object per line with a `status`, flushed as
  printed; exit codes unchanged. `gpforum-migrate --check` exits 1 when
  migrations are pending or an applied file changed.

- **Fingerprinted static assets** (quality program 7.7): stylesheet and icon
  URLs carry `?v=<12 hex digits of the file's SHA-256>`, taken at startup; a
  current digest is served `public, max-age=31536000, immutable`, a bare or
  stale one `max-age=3600`. The shipped nginx and Caddy configs match.

- **`/admin/settings`**: every configuration variable with its effective
  value and whether it comes from the environment or the default. Secrets
  show only as set or not set, and a password inside a DSN or URL is
  redacted; the page says to edit `/etc/gpforum/gpforum.env` and restart.
- **Send test message** (`/admin/settings`): one message through the
  configured transport to the signed-in admin's own address only, audited
  as `admin.mail_test_sent`, with the transport's error shown without
  credentials, stack traces or the recipient.
- **Run antivirus check** (`/admin/settings`): the check
  `bin/gpforum-antivirus-check` runs, from the console, audited as
  `admin.antivirus_checked`.
- **`docs/ops/console-and-cli.md`** maps every `bin/` command to its console
  page or says why there is none; a test fails when a command is missing or
  a named route is not registered.

- **Migrations can build indexes without stopping writes.** A migration
  whose first line is `-- gpforum:no-transaction` runs one statement at a
  time, so `CREATE INDEX CONCURRENTLY` works; the runner sent every file
  whole, inside a transaction, so every index so far locked its table's
  writes while it built. From migration 049 on, an index on an existing
  table must be built this way (`t/209-migration-indexes.t`).

- **A streaming standby and failover** (ADR 0112,
  `docs/ops/standby-and-failover.md`): the standby follows through a
  replication slot, `GPFORUM_DATABASE_DSN` names both servers with
  `target_session_attrs=read-write`, and failover is a manual promotion.
  `script/standby-drill` (`make standby-drill`) rehearses it on throwaway
  clusters: on PostgreSQL 18.6 a write reached the standby in about 50 ms,
  promotion took about 160 ms, and a connected application reconnected to
  the new primary by itself.

- **`/health/ready` names the runbook for every check that is not ok**
  (`runbook`, a path in the repository), so a degraded or failed check says
  what to do about it.

- **`docs/THREAT_MODEL.md`**: what Good Phorum protects, from whom, the trust
  boundaries, and at each one the mitigation and the test that pins it, with
  the residual risks said plainly.

- **Search and cache maintenance on the console.** `/admin/jobs` shows
  whether search is behind and how the last console rebuild went, rebuilds
  the index through the outbox (one batch per message, retried like any
  other work) and purges the public page cache; both are audited.
- **A refused permission says which.** A permission refusal (403) from the
  admin, moderation or privacy console logs, at info, the permission
  checked, the user and the binding that would have granted it, so an
  operator can see why a moderator is refused on a category. A user's
  bindings page now shows each binding's space.
- **`bin/gpforum-search-rebuild`** (and `script/search-rebuild`): rebuilds
  the search index from the forum's threads and posts, in batches, removing
  documents whose source is gone, deleted or hidden; `--status` reports how
  far search may be behind. See `docs/ops/search-rebuild.md`.
- **Per-member time zones.** Settings has a time zone field; dates and
  times across the forum are shown in the member's zone, or the forum's
  default, and every time names its zone ("14:00 CEST"). Migration 044 adds
  `users.preferred_timezone`.
- **Partition horizon alert.** `/readyz` reports `partition_horizon` as
  degraded when any partitioned table has less than 45 days of partitions
  ahead, or when rows have spilled into a DEFAULT partition. Point readiness
  alerting at it; the fix is `bin/gpforum-partition-maintenance --apply` in a
  maintenance window (`docs/ops/partition-maintenance.md`).
- **Replay dead letters** (ADR 0056), from the Replay button on
  `/admin/jobs` or `script/dead-letter-replay --id ID`; `--list` reviews
  them from the shell. A replay enqueues a new outbox message for the same
  event and envelope with a fresh retry budget; the cancelled message and
  the dead letter stay as evidence, each dead letter replays once, and the
  audit log records it as `outbox.dead_letter_replayed`. It works for the
  thirty days a dead letter is kept, after retention has purged the
  cancelled message. See `docs/ops/dead-letters.md`.
- **Admin audit viewer.** Filters by actor, action, target, correlation id
  and a UTC date window, and pages to older entries with a stable keyset
  cursor (ADR 0079). A filter value of the wrong shape is a 400 naming the
  field; a target id that was not a UUID used to be a 500.
- Add `script/bootstrap-deps --rebuild-local` to rename an incomplete or
  foreign-Perl `local/` to `local.rebuild.<epoch>` before Carton reinstall
  (still never `rm -rf local/`). Helps operator hosts where a
  partial `local/` leaves modules like `Const::Fast` missing.
- Add operator `script/staging-host-verify` / `bin/gpforum-staging-host-verify`
  and `docs/ops/staging-host.md`: non-destructive staging bring-up verify
  (in-repo deploy/runbook artifacts; optional `--env-file` key presence with
  values redacted, `--systemd` `is-active`, `--base-url` health/metrics).
  Documents the evidence archive commands for mail-check, stress-load, and
  staging drills. Wired into `docs/ops/private-beta-checklist.md`. Optional
  `make staging-host-verify`; not part of default CI. Does not install units
  or claim private-beta readiness.
- Add `script/gpforum-macports-env` for macOS MacPorts operators: detect
  `/opt/local` PostgreSQL client bins, print `export PATH=...` lines, and
  optionally verify `psql` / `pg_dump` / `pg_config`. No-op skip on Linux CI.
  Accept MacPorts `/opt/local` perl as system Perl in
  `script/gpforum-system-perl` (still refuse perlbrew / plenv / asdf).
- Add operator-runnable `script/gpforum-mail-check` / `bin/gpforum-mail-check`
  to prove identity mail config for `test` / `smtp` / `sendmail` transports
  (dry-run Test delivery, SMTP TCP probe without leaking passwords, sendmail
  binary check, optional `--send`). Document in `docs/ops/mail-check.md` and
  `docs/DEPLOYMENT.md`. Optional `make mail-check`; not part of default CI.
- Add operator-runnable `script/stress-load` / `bin/gpforum-stress-load` with
  profiles `smoke` / `100` / `500` / `1000` concurrent request slots against a
  running Hypnotoad/Good Phorum `--base-url`, JSON/human evidence, and
  `docs/ops/stress-load.md`. Optional `make stress-load` /
  `make stress-load-dry`; not part of `make check` or default CI.
- Add `script/staging-drill` / `bin/gpforum-staging-drill` and
  `docs/ops/staging-drills.md` so operators can rehearse fresh migrate,
  upgrade-from-previous, and `pg_dump`/`pg_restore` on throwaway databases
  with pasteable pass/fail evidence. Attachment files under
  `var/attachments` remain outside the dump/restore scope; full
  nginx/systemd deploy stays a manual runbook. Optional `make staging-drill`
  is documented and is not part of default CI.
- Authors can restore their own soft-deleted thread on
  `POST /t/:thread_id/restore`. The store clears `deleted_at`/`deleted_by`
  and emits `thread.undeleted` in one transaction. Search, feed, cache, and
  realtime treat it like a visible thread again. Reputation does not apply
  `thread.restored` credit. The author still sees the deleted thread on the
  category listing and thread page. HTML writes set a success flash and
  redirect to the thread. JSON returns `restored`. Live, hidden, locked, and
  foreign threads are rejected.
- Authors can restore their own soft-deleted post on
  `POST /p/:post_id/restore`. The store clears `deleted_at`/`deleted_by`
  and emits `post.undeleted` in one transaction. Search, feed, cache, and
  realtime treat it like a visible post again. Reputation does not apply
  `post.restored` credit. HTML writes set a success flash and redirect to
  the post permalink. JSON returns `restored`. Live, hidden, locked, and
  foreign posts are rejected.
- Authors can soft-delete an attachment linked to their visible post on
  `POST /p/:post_id/attachments/:attachment_id/delete`. The store writes
  `attachment.deleted` in one transaction. Already-deleted rows replay.
  HTML writes set a success flash and redirect to the post permalink.
  JSON returns `deleted`. Non-authors receive `403`.
- Members can mark the whole notification inbox read on
  `POST /notifications/read-all`. HTML writes set a success flash and
  redirect to `/notifications`. JSON returns `all_read` with
  `marked_count`. An empty inbox is success. JSON single-row mark-read is
  unchanged.
- Public SSR pages at `/legal/terms`, `/legal/privacy`, and `/legal/cookies`
  describe how this software handles terms, stored data, export/deletion,
  and cookies. The footer links them. The sitemap includes the three URLs.
  Copy is i18n catalog text, not counsel-reviewed instance policy.
- Moderators can hide and restore a whole thread on
  `POST /moderation/threads/:thread_id/hide` and
  `/restore`. `thread.hidden` removes the thread search document, post
  search documents in that thread, and feed rows, and applies a reputation
  penalty to the thread author. `thread.restored` reindexes, reprojects the
  feed, and restores the penalty. Hide keeps `locked_at`. HTTP writes mint
  `command_id` like lock/unlock.
- A lost HTTP response can be retried with the same `command_id`. Reply,
  thread, report, hide, and export return the original resource and do
  not insert a second row.
- Authors can move their own threads to another category.
  `PostingWorkflow->move_thread` updates `category_id`, increments version,
  and emits `thread.moved` with the previous category. Locked, hidden,
  deleted, foreign threads, and missing destinations are rejected.
  `POST /t/:thread_id/move` is CSRF-protected, rate limited, and idempotent
  via `command_id`. Search reindexes, cache invalidates both categories,
  and realtime `thread.update` fires; feed, reputation, and notifications
  do not.
- Authors can soft-delete their own threads. `PostingWorkflow->delete_thread`
  stamps `deleted_at`/`deleted_by`, increments version, and emits
  `thread.deleted`. Locked, hidden, already-deleted, and foreign threads are
  rejected. `POST /t/:thread_id/delete` is CSRF-protected, rate limited, and
  idempotent via `command_id`. Search removes the thread document and post
  documents in that thread, feed removes the thread item and post items,
  cache invalidates, and realtime `thread.update` fires; reputation and
  notifications do not. Moderation hide remains a separate
  `moderation_state` path. HTML clients redirect to the category.
- Authors can edit their own thread titles. `PostingWorkflow->edit_thread`
  updates `title`/`slug`, increments version, and emits `thread.updated`.
  Locked, hidden, deleted, and foreign threads are rejected.
  `POST /t/:thread_id/edit` is CSRF-protected, rate limited, and idempotent
  via `command_id`. Search, cache, and realtime `thread.update` consume the
  event; feed, reputation, and notifications do not fire again.
- Authors can soft-delete their own visible posts. `PostingWorkflow->delete_post`
  stamps `deleted_at`/`deleted_by`, decrements the thread reply counter, and
  emits `post.deleted`. Locked threads, hidden posts, already-deleted posts,
  and non-authors are rejected. `POST /p/:post_id/delete` is CSRF-protected,
  rate limited, and idempotent via `command_id`. Search, cache, feed, and
  realtime `thread.update` consume the event; reputation and reply
  notifications do not fire. Moderation hide remains a separate `post.hidden`
  path.
- Authors can edit their own visible posts. `PostingWorkflow->edit_post`
  appends a `post_bodies`/`post_revisions` pair, points the post at the new
  revision, and emits `post.updated`. Locked threads, hidden/deleted posts,
  and non-authors are rejected. `POST /p/:post_id` is CSRF-protected, rate
  limited, and idempotent via `command_id`. Search, cache, feed, and
  realtime `thread.update` consume the event; reputation and reply
  notifications do not fire again.
- Wired `FeedProjector` and `ReputationLedger` onto the existing outbox
  worker path. Created posts and threads now project into `/feed` for the
  author and thread subscribers, and posting, hide/restore, and suspension
  events update trust snapshots instead of leaving them at registration
  defaults.
- Added a oneshot `gpforum-scheduled-jobs` command, hourly systemd timer,
  and launchd sample so expired sessions, rate-limit buckets, identity
  tokens, completed outbox rows, and dead letters are deleted in bounded
  batches. The same run calls attachment orphan cleanup and
  `PartitionLifecycle` policy/evidence only (no app-owned
  `CREATE TABLE ... PARTITION OF`). See `docs/ops/scheduled-jobs.md`.
- Forum post bodies labeled `markdown` now render a safe subset (emphasis,
  http/https/mailto links, quotes, fenced code) through
  `Service::Forum::BodyRenderer` at compose time and in post presenters.
  Source is escaped before markup is added, so script tags and unsafe URLs
  stay text.
- Delivered private-beta identity mail for password reset, email change,
  and registration verification through an injectable `Email::Sender`
  mailer. Pending accounts no longer receive a session until the
  verification token is confirmed, and the login page links to forgot
  password. `Email::Address::XS` 1.05 is a declared runtime pin so
  `Email::Sender::Simple` can load.
- Added versioned operational profiles for development, staging,
  production-small, and production-medium, plus a partition lifecycle policy
  for monthly planning, retention detach, archival states, and restore
  evidence.
- Added public discovery and syndication boundaries for canonical URLs, safe
  metadata, robots.txt rules, sitemap entries, feed items, and architecture documentation.
- Added privacy rights operation boundaries for deletion requests, erasure jobs,
  retention legal holds, staff review, and DBIx::Class mapping coverage for the
  existing platform governance tables.
- Added plugin extension boundaries for manifest validation, registry
  lifecycle, named hook dispatch, observable failure recording, and PostgreSQL
  migration coverage.
- Added import/export portability boundaries for validated import manifests,
  dry-run jobs, legacy id mapping, import failure reporting, privacy-aware
  export manifests, and PostgreSQL migration coverage.
- Added admin authorization management boundaries for role catalogs, scoped role
  bindings, permission review, and audit-backed role changes.
- Added moderation review boundaries for reports, reversible moderation actions,
  suspensions, audit-backed moderation changes, and admin audit browsing.
- Added advanced community feature boundaries for mention extraction,
  bookmarks, reputation ledger events, trust score snapshots, and rebuildable
  user feed projections.
- Added operations hardening boundaries for rate limiting, metrics snapshots,
  runbook validation, runtime sizing validation, and a JSON metrics endpoint.
- Added attachment upload intent, validation, lifecycle persistence, links,
  variants, scanning hook, media processing hook, and PostgreSQL migration.
- Added bounded keyset pagination contracts for category thread lists and
  thread post lists, including limit-plus-one fetching, stable cursors, and
  pagination metadata.
- Added process-local realtime websocket boundaries for authenticated
  connections, authorized channel subscriptions, thread updates, notification
  badge broadcasts, and explicit polling fallback.
- Added PostgreSQL-native search service boundaries for document building,
  indexing, rebuild, permission-aware querying, autocomplete, lag observation,
  and worker handoff.
- Added notification subscriptions, preferences, inbox projection, read state,
  and fanout services.
- Added worker phase boundaries, outbox dispatch, projection tracking, and
  platform governance migrations.
- Added core identity/session, forum write, event/audit, and projection schema
  foundations.

### Changed

- **The pages follow Apple's Human Interface Guidelines.** Text is set in
  the platform's face (San Francisco on Apple devices, Inter elsewhere) on
  the platform's text styles; lists are grouped on rounded white surfaces
  with inset hairlines; the bar is translucent and stays at the top, its
  search a filled field; actions are pills in one tint; the dark theme is
  the system's on black. Links and the labels of filled controls keep 7:1
  contrast. A thread's actions sit on one line with Reply first, its
  reactions show no zero counts, and the home page's categories read as a
  sidebar with their counts.
- **An installation is called by its own name.** No page shows the
  software's name, mark or licence: the header shows the forum's name, the
  browser icon is drawn from its first letter (`/icon.svg`), and the legal
  pages speak of the forum, not the software.
- **Search walks the newest documents for a common word, and a search in a
  thread reads only that thread (migration 1058).** The check that a
  document is still in its thread's category was estimated at 0.5%, so a
  word most documents hold looked rare and the planner read and sorted
  every match; on 400,000 documents a common word took 2.3 to 3.7 s and
  now takes 20 to 60 ms, with the same results. A search in one thread
  read every match in the forum and kept the thread's (919 ms for a
  one-post thread); it now starts from the thread's posts (1.7 ms), and a
  20,000-post thread costs about 170 to 210 ms whatever the forum's size.

- **Search understands Italian (migration 1057).** Documents and queries
  used the `simple` configuration, every word as written: "compilazione"
  missed a post that said "compilato", "citta" missed "città". They now
  use `public.gpforum_italian`, PostgreSQL's Italian stemmer and stopwords
  with `unaccent` first, so a word's forms share a stem and accents fold;
  Mojolicious or PostgreSQL stay as written. `Search::DocumentBuilder`
  names the configuration once: the indexer now passes it to the database,
  as the searcher always did. On 200,000 generated Italian documents a stem
  family matches two to four times the documents it did.

- **A thread's counter is its reply count, and a failed outbox message
  writes its attempt count once (migrations 1052 and 1053).** The counter's
  visible reply count, last post, last activity, version and reconciliation
  time were written when the thread opened and when a transfer recounted
  it, never by a reply, and read by nothing: after the first reply the last
  post was still the opening one. `outbox_messages.attempts`, the old name
  of `attempt_count`, took the same number on every failure. Nothing writes
  or reads them since 1052, and 1053 drops them, a release later (ADR 0126,
  section 2). `projection_generations`, which nothing ever wrote and nothing
  has read since 1048, is gone.


- **A password is hashed and verified as its UTF-8 octets, and nothing
  else.** The Latin-1 path for hashes made before the salt carried an
  encoding marker goes, and the marker with it: no released database holds
  such a hash (ADR 0150 refuses every 0.x database), and a hash made with
  the marker still verifies.

- **A notification fan-out page is twenty recipients.** Each recipient
  opens up to three subtransactions that write, and two hundred opened six
  hundred, past the 64 PostgreSQL caches a backend: every other backend's
  snapshot overflowed into pg_subtrans until the page committed.

- **The sitemap is built once an hour, not on every request.** Each page
  materialized and sorted every public category and thread; the index and
  the pages are cached, and dropped when moderation or a category changes
  what is public. A page past the last one is a 404 instead of an empty
  document.

- **The hourly orphan purge reads an index (migration 1050)**, where it read
  every attachment to find a handful of stale intents.

- **An attachment download plans nothing (migration 1048).** Its three or
  more point lookups, and the moderation store's, were planned on every
  call: 0.023 ms a file lookup against 0.006 planned once, 0.021 a post's
  against 0.005.

- **A member's public counts are read once a minute at most.** The profile
  counted every public thread and reply of the member, through three joins
  each, on every view; the counts are cached, and dropped when the member
  posts, deletes or restores, or when moderation changes what is public.

- **Ten indexes no statement read are gone (migration 1048)**, each a write
  on every token, second factor, ban, avatar, rate-limit check, event,
  command or dead letter: the plan gate now requires 22. The console's
  member list, filtered by status, reads the index built for it, where a
  generic plan of a folded filter could not.

- **A filtered audit page reads its filter's index (migration 1047).** The
  page folded every filter into one statement planned once, so an actor's
  or a correlation's entries walked the whole trail backwards, every
  partition, until a page matched. It is planned for the filters it has.

- **A member's digest and their first message to someone no longer wait
  for each other (migration 1047).** They took the same advisory lock;
  each lock class is now for one purpose, listed in the migration and held
  by t/641.

- **The forum's writes and the viewer's resolution call implementations
  only (migration 1046).** Four of them called the previous release's
  owner-rights boundaries, so the contract migration that drops those would
  have broken every signed-in request and every post, and each fan-out
  candidate paid two calls it did not need.

- **The signing sweep reads each batch from where the last one stopped
  (migration 1046).** Its first page was folded into the bound as
  `p_after IS NULL OR`, which a generic plan cannot use: every batch of
  `gpforum sign-posts` walked the bodies from the start.

- **An erasure no longer holds the threads its member voted in against
  replies (migration 1046).** It locked them FOR UPDATE to its end, where
  the poll's row lock keeps the counts right, as for a vote.

- **The hourly retention sweep keeps up.** Each purge took one batch of
  100 rows a run, while rate-limit buckets come at one per reader, action
  and minute: the table grew without end. A purge now takes batch after
  batch until one comes back short, at most 50 a run.

- **A refused request past the limit costs no audit row after the first.**
  Every refusal wrote a chained audit row behind the one `rate_limit` lock;
  the first refusal of a window is audited and the rest counted.

- **An anonymous `/health/ready` answers from a report two seconds old at
  most.** The whole check is some twenty statements, a clamd round trip
  and a GlifiStore ping, for one status word, on an endpoint open to
  anyone; an operator with the token still gets a fresh report. A
  `/metrics` scrape and a published invalidation no longer ping the
  database first.

- **A changed password is hashed before the credential's lock.** Argon2 ran
  under the lock, so a sign-in of the same member waited for it. A reset
  still hashes under the lock: its token is checked in that transaction,
  and hashing first would spend Argon2 on every invalid token.

- **The realtime backstop poll and the outbox gauges are cheap (migration
  1044).** Every web process with a socket open polls the outbox once a
  second, and its keyset was planned again each time: 0.052 ms a call, 0.006
  planned once, on the same partial index. The gauges `/metrics` reports
  counted every row of the outbox, seven days of delivered messages
  included, on each scrape: 12.5 ms on 200,000 delivered messages, 0.2 ms
  counted from the indexes that hold each gauge's rows.

- **A thread's activity bump writes only when it moves.** A repeated
  delivery, or a reply older than the last one, wrote the thread's row
  unchanged -- a non-HOT update of a row the lists' indexes carry -- and said
  it had moved it.

- **The home and category thread lists are planned once (migration 1043).**
  Both forced a custom plan, so every home page and every category page
  planned its list again: on 100,000 threads, per call, 0.354 ms against
  0.078 generic for the latest list and 0.426 against 0.179 for the
  category list. The generic plans read the same indexes, because the
  partial indexes' predicates are literals in the bodies; the viewer's
  standing stays a filter on the scan, as it was. Bodies unchanged;
  `forum_list_categories` keeps its custom plan (its count's predicate on
  thread visibility depends on the standing, and the list is cached per
  standing). The plan gate discards the session's cached plans before each
  capture: a function planned once kept the plan the gate's own forced pass
  had made with sequential scans disabled, and the next endpoint's first
  page ran it, so its deep page read a small table another way and the gate
  reported a growth that was its own. `t/integration/postgres-sql-function-plans.t`
  replaces the list versions the catalogue reaches, not v1.

- **A vote on a thread's poll no longer holds the thread against replies
  (migration 1038).** The vote locked the thread's row `FOR UPDATE` for its
  length, so a reply arriving meanwhile, which updates the thread's
  activity, waited for it. The vote now takes the row `FOR KEY SHARE`, which
  keeps the thread from being deleted under it and lets a reply through; the
  poll's own row stays locked, which is what keeps two votes from counting
  over each other.

- **The mbox archive reads a page's signatures in one statement (migration
  1041).** Each post's signature was a statement of its own inside the
  export's repeatable-read transaction, a hundred per page of posts;
  `api.forum_protocol_signatures_v2` answers a page at once, and each
  envelope is still verified before the post is written.

- **Two reads are planned once instead of on every call (migration 1042).**
  `api.forum_find_listed_post` and `api.notification_list_inbox` forced a
  custom plan although their plan never changes with the parameters: measured
  on the hot-thread seed, 0.142 and 0.288 ms per call, now 0.013 and 0.070.
  The list functions that filter by standing keep their custom plans: a
  generic plan cannot use the partial indexes whose predicate depends on the
  parameters.

- **Three indexes no statement used are dropped (migration 1040):**
  `idx_sessions_active` and `idx_sessions_lookup`, both led by the session
  id, which the primary key answers, and `idx_user_feed_items_ranked`, an
  order by score no feed reader asks for. Each was a write on every insert
  and update of its table.

- **The ordered autocomplete index stays out.** The 2026-10-23 review asked
  for a btree behind the autocomplete's `ORDER BY`;
  [the record](docs/architecture/sql-functions-optimization.md) shows it was
  measured on 2026-10-11 and rejected for giving no stable improvement.

- **The category index is cached for every reader, under their standing and
  grants.** Only the anonymous list was, so each signed-in reader of the
  index had every live thread of every category counted again for them
  (`api.forum_list_categories` counts them per request). The list is now
  cached under `categories:list:<standing>:<grants>:<limit>`: anonymous,
  member or global reader, and a digest of the category and space grants,
  so a member whose grants change reads under another key at once; the same
  tags purge it (`categories`, `forum-index`), the same 30 seconds bound it.

- **A picture's thumbnail is a real one.** The media processor wrote the
  original's bytes under a second key as the `thumbnail` variant -- a copy
  of every picture, which nothing read -- and decoded nothing. It now has
  the system's image tool (ADR 0144) make a 320-pixel square of the picture
  as PNG and records that; without an image tool, or for a picture the tool
  cannot read, no thumbnail is made and the event is done.

- **A login's per-account rate limit is keyed by the account, not its
  spelling.** The limit under `account:<identifier>` gave an address and a
  username of one member a budget each, so a guesser had two; the key is
  now the account the identifier names (`AuthStore::account_id_for`), or the
  spelling when it names none.

- **Cache invalidation tells the sibling workers once per event.** The
  outbox handler purged an event's three or four tags one by one, each a
  NOTIFY statement (`TieredCache::invalidate_tags` carries them in one), and
  every cache read pinged PostgreSQL first: the bus's take asked for the
  schema's handle through `dbh`, which checks the connection with a round
  trip; it now takes the handle as it stands (`Storage::with_handle`), and a
  connection that is gone is heard again at the next statement.

- **The files under a thread page's posts cost no statement of their own
  (migration 1037).** Each file listed under a post was four statements --
  its row, its links, the post the link names, the post's placement --
  after the page's one statement had read the links: twenty-five posts with
  a file each were a hundred statements, which the query budget never saw
  because its seed had no files. The page's one statement
  (`api.forum_thread_page_reads_v6`) now reads each link with its file and
  its post, and the listing decides from that
  (`DownloadAccess::listing_payload`); `api.attachment_post_files_v2`
  answers the same rows for a page that reads them on its own. The budget
  test seeds a file on each of the thread's first posts, so an N+1 on them
  would fail it. Downloading a file still judges every link of it.
- **A reply's notifications reach a thread's subscribers in four statements,
  however many they are (migration 1036).** The outbox worker notified each
  subscriber in turn -- the viewer, the delivery plan, the relation to the
  actor, the two rows in a transaction of their own, the unread count and
  the badge -- seven round trips per recipient, so a thread with a thousand
  followers was 7,001 statements inside one outbox message, which could
  outlive its lease and be claimed again by the next worker. The fan-out is
  now the candidates with their plans, relations and viewer rows in one
  statement (`api.notification_fanout_candidates_v2`), where the post sits
  (as before), the deliveries in one call per page of 200
  (`api.notification_fanout_deliver_v2`, each recipient in a savepoint of
  their own, the mail queued with them, the unread count counted after the
  rows so a count that fails leaves the delivery made) and the badges in one
  NOTIFY statement (`api.ops_publish_notifications_v2`). Every rule stays
  where it was: the readability of the post (ADR 0102) is judged in Perl
  from the viewers the candidates carry, the plan is the preference store's,
  the relation store says which kinds a relation silences (ADR 0130).
  Measured on a scratch cluster: 1,000 subscribers took 7,001 statements and
  3.15 s, now 8 statements and 0.32 s; 100 took 701 and 0.33 s, now 4 and
  0.045 s; 10,000 take 53 statements and 3.1 s. `Service::Notification::Fanout`
  is the module; `RecipientPolicy::notifiable_readers`,
  `ViewerResolver::viewer_from_row`, `RelationStore::silenceable` and
  `PgNotifier::notify_many` are the set forms the fan-out uses.

- **The unread count a badge carries is planned once
  (`api.notification_unread_count_v8`, migration 1036).** The count's
  function forced a custom plan, so every call planned its three readability
  subqueries again: 1.4 ms of planning for a count that runs in 0.05 ms
  (1,000 calls: 1,416 ms, against 55 ms under a generic plan). That was most
  of what a fan-out of a thousand still cost once its round trips were gone,
  and 1.4 ms of the badge after every delivery and every mark-read. The body
  is unchanged; the previous entry point stays for the preceding release's
  workers.

- **The audit chain is one chain per aggregate, linked at commit under that
  aggregate's lock (ADR 0156, migration 1035).** Every audited write took
  one forum-wide advisory lock to COMMIT and linked to a tip chosen by the
  caller's `created_at`, so two writers with out-of-order stamps left a leaf
  no row pointed to, and every write waited for every other. A row now takes
  the next `chain_position` in its target's chain (`chain_key`,
  `type:id`), under `pg_advisory_xact_lock` on that key alone; its
  `record_hash` covers the stored form (UTC time with microseconds, jsonb
  metadata), so a row read back verifies, and
  `api.ops_audit_chain_verify_v2` reports a changed, missing or relinked
  row. A caller's audit id is locked on its own. Rows from before the
  migration keep their links and stay outside every chain. The four chain
  functions are replaced in place, the one named exception to ADR 0126,
  section 2.
- **The outbox claim reads each half from its index (migration 1034).** The
  claim's WHERE was an OR of the two predicates the partial indexes serve,
  which no index can serve in order, so every claim scanned the table and
  sorted every ready row for a page of 100: 104 ms and a 14 MB external sort
  on 300,000 ready rows, growing with the backlog it was there to drain. Each
  half is read in order from its own index and cut at the page, and the page
  is locked with the readiness predicate restated, so a row another worker
  claimed meanwhile is left out as before: 0.9 ms on the same rows.
  `api.outbox_claim_ready_v4` (v3 inside); v2 stays for the previous
  release's workers.
- **The thread page renders faster.** Signed in, 13.2 ms to 11.0 ms a
  request in-process (the hot-thread seed, 25 posts, 161 forms); anonymous,
  6.4 ms to 5.6 ms. The reactions partial computes its eight texts once a
  request and builds each form's path once; the posts partial builds the
  page URL once; the i18n catalogue keeps each message's split template; the
  structured data strips tags from the renderer's own HTML instead of
  parsing 25 bodies with Mojo::DOM; the CSRF field is answered from the
  stash without the token helper's lookups; a uuid is formatted with one
  unpack. A password change verifies the new password against the current
  one as text in constant time, where it ran Argon2 a second time under the
  credential's lock.

- **The schema starts from a 1.0 baseline (ADR 0150).** The 106 migrations
  of the 0.x chain are 24 files numbered 1000 to 1023: the tables by area,
  each created once in its final shape and commented (`COMMENT ON TABLE`),
  the functions of `api` by area, each with a sentence that says what it
  does, and every `SECURITY DEFINER` entry point in one file. Every table,
  column, constraint, index, function, body and grant the code, the tests or
  the operators use is unchanged, which a catalogue fingerprint of a
  database built each way proves; what differs is listed, with why, in
  [docs/architecture/migration-baseline-1.0.md](docs/architecture/migration-baseline-1.0.md):
  - the 89 `api` functions nothing called (versions kept for a previous
    release's workers) are gone, and so are 14 tables nothing read, the
    counter-shard trigger and the six empty schemas of the first blueprint;
  - `schema_versions` records who applied each migration and how long it
    took, and `migration_safety` is gone;
  - the 154 functions that answered with a table's row return a row type of
    `api` with the same columns, and their bodies name them, so a column a
    table gains changes no shipped function;
  - four functions that read the clock are `VOLATILE`, five reads `STABLE`,
    thirteen pure helpers `PARALLEL SAFE`.
- **`gpforum migrate` refuses a database migrated by other code.** One that
  records a migration the tree does not have -- the 0.x chain's, or a newer
  release's -- is refused before anything runs, by `--apply` and `--check`,
  with what to do; the runner no longer carries the 0.x exceptions (049 and
  051 of several transactions, the gap at 120).
- **Comments say why the code is as it is, not what it used to do.** About
  fifty comments in `lib/`, `script/` and `deploy/` narrated a fixed defect
  ("used to ..."); each now states the reason in the present, or goes when
  the history was all it said.
- **A page from the public cache is gzipped once, not for every guest.**
  Each hit gzipped the same bytes again: 0.12 ms of the 0.87 a cached
  thread page took. The gzipped bytes are kept by the page's ETag beside
  the bytes they came from, and given again only for those bytes; at most
  64 pages a process, in two generations (`GPForum::Bootstrap::Core`,
  `t/704`). Gzip stays in the application, so every proxy, and none, gets
  the same response (docs/PERFORMANCE.md).
- **A thread page walks its posts once for the reader's forms.** The five
  command id maps of a member's own posts (edit, upload, delete, report,
  restore) each walked the page's posts again for them, and the likes, the
  reply targets and the like forms each read the posts' ids again; the
  page now reads them once each.
- **An id costs 1.6 µs instead of 3.8.** `GPForum::Infrastructure::Id`
  read ten random bytes with a read of its own for every id and built it as
  a list of numbers; it reads 4 KiB at a time, dropped by a forked process
  so no two processes hand out the same bytes, and sets the version and
  variant in place. A signed-in thread page mints 28 (`t/143`).
- **A function call costs a third less in Perl.**
  `GPForum::Infrastructure::SqlFunction` ran each call through DBIx::Class's
  `dbh_do`, which built a block runner, a Try::Tiny and a preserved context
  for every call: 20 of a 64 µs call. It now takes the storage's handle as
  `dbh_do` did, and outside a transaction tries once more on a new
  connection when the connection was gone, as `dbh_do` did
  (`t/integration/postgres-sql-function-reconnect.t`). A uuid parameter in
  the shape PostgreSQL and Good Phorum write is read by one pattern before the
  other spellings PostgreSQL accepts, half the cost for the 50 ids of a
  thread page's reads.
- **A signed-in thread page sends five statements instead of eleven.** The
  reads it makes once it has its posts -- the likes, the posts they answer,
  the tags, the attachment links, and a member's bookmark, subscription
  and read state -- are one call, `api.forum_thread_page_reads_v2`
  (migration 124), made before them; each store still asks for its rows
  and takes them from that call's answer
  (`GPForum::Service::Forum::ThreadPageReads`,
  `GPForum::Infrastructure::SqlFunction::answering`). A guest's page sends
  three instead of six. The seven reads took 615 µs as seven calls and 405
  as one. The query budget test holds the thread page to 5 and 3.
- **A database connection costs 27% less, and says which process made it.**
  Every connection ran six statements after connecting; the PL/pgSQL block
  that set `lc_messages` where the role may was 0.98 ms of a 3.6 ms
  connection over the socket. One `SELECT` of `set_config` calls now sets
  the session, and `application_name=gpforum-web` (or `gpforum-VERB`) and
  `connect_timeout=5` travel in the startup packet unless the DSN sets them,
  so `pg_stat_activity` tells the web processes from the workers and a
  server that does not answer fails the connection in five seconds (ADR
  0137, with the socket and TCP measurements).

- **The hot pages render in 41 to 55% less time: the signed-in thread page
  in 41% less, the home page in 55% less.** Every `include` went through
  Mojolicious's renderer as a page does, 43 microseconds each and 62 on the
  home page; `GPForum::Web::Partials` compiles each partial once and runs it
  with the stash localized as `include` does, and a partial's plain
  `t('key')` reads one escaped table a locale. Counted messages, time
  elements, the theme contracts, the locale table and the selectors'
  options are built once instead of on every question, and the post
  fragment cache is keyed by its content and kept in two generations, with
  the edit form's text out of it. `script/bench-render` measures the pages
  in process with a signed-in session, with NYTProf on request; the HTML is
  the same (docs/PERFORMANCE.md, "Rendering in process"; `t/694`-`t/696`).

- **The drills and evidence tools that read no setting no longer read the
  settings file.** `staging-drill-attachments`, `evidence-meta`,
  `evidence-validate`, `dead-letter-check` and `mail-lifecycle-check` use
  none of the service's settings, yet the front door read the host's file
  before each of them: where it is `root:gpforum 0640` -- a CI runner, an
  account that is not the service's -- they were refused with 78 before
  starting. The verb table marks them `nothing` and the front door skips the
  file for them, the one named with `--env-file` included, so the deploy
  units workflow runs them without `--env-file /dev/null` (ADR 0120,
  amended; `t/484`).

- **`benchmark`, `hypnotoad-benchmark` and `hypnotoad-scaling` say a failed
  run as one.** Every exception became misuse: the reason, the usage line
  under it, status 2, so a database that did not answer or a refused seed
  read as a mistyped command line. Misuse keeps the usage and 2; a failure
  is said as itself and exits 1, or 78 for settings the command cannot use,
  as the staging drill and ADR 0120's one command style have it (`t/681`).

- **The index says what goes on in a category, and a reader who cannot
  reply is told the way in.** Each category in the index and on the home
  page carries how many discussions the reader may read in it and when the
  last of them moved (migration 107, `api.forum_list_categories_v3`, one
  aggregate per category over the index the category page reads; the
  DBIx::Class list carries the same two columns). The home page's latest
  discussions name their category and who started them, a pinned thread
  wears a badge in every list, and the home page's header keeps one action,
  starting a discussion, where it repeated the navigation's categories and
  search. A thread page no longer hands a reader who is not signed in a
  composer that cannot post: where the reply would be written stand the two
  doors in, back to the thread. Under each post a signed-in reader can
  quote it: `?quote=POST_ID` opens the reply with the post's source quoted
  line by line under a line naming its author. The registration form says
  what a username and a password must be and where to sign in; the login
  form says where to create an account (`t/03`, `t/35`, `t/480`, `t/74`).

- **`gpforum performance-seed` no longer seeds a production database it
  read from the settings file.** As a `bin/gpforum` verb it reads the host's
  settings file for every name the shell left unset, so on a production host
  a bare `gpforum performance-seed` connected to the forum's own database and
  inserted the synthetic benchmark dataset. A production configuration
  (`GPFORUM_ENV=production`) is now seeded only where `GPFORUM_DATABASE_DSN`
  was given for the seed -- in the shell, or by a caller such as the staging
  drill, which names each throwaway database -- and is refused otherwise,
  with the fix in one sentence: point `GPFORUM_DATABASE_DSN` at a scratch
  database. `hypnotoad-benchmark --seed` and `hypnotoad-scaling --seed` seed
  through the same check. A refusal, a setting the seed cannot use or an
  unmigrated schema is now reported as itself, without the advice about
  DBD::Pg meant for a failed connection (`t/681`).

- **A registration is one call.** It ran as seven functions while Perl kept
  the command log, a transaction and four savepoints around them, 24 round
  trips. Migration 106 adds `api.identity_register_command_v1` -- the
  command log, the duplicates, the user and the credential, the
  registration's event and audit, the verification token with its audit
  and mail, with the stores' recovery for a taken id -- and its ADR 0127
  `_v2` boundary. Perl still validates, hashes the password, mints the
  ids, reads the clock and draws the token, and hands every row and
  document ready. A registration is the rate-limit check and one call. The
  answers recorded are the Perl path's key for key (ADR 0126, section 5;
  `t/integration/postgres-sql-function-identity-commands.t`, `t/653`). The
  login keeps its transaction: the race tests pin the window in which it
  holds the credential.

- **A hot deployment keeps the metrics tokens the service accepted last.**
  Hypnotoad's `USR2`, or a second `gpforum start --service`, started a new
  manager from the environment the service started with: after a rotation
  and its `--finish` it did not follow the environment file, accepted the
  dropped token and refused the current one until a restart. The manager now
  carries the list it accepts in its own environment (ADR 0124, amended).

- **Marking a thread read, bookmarking it and subscribing to it are one
  call each.** Each ran its work as a function while Perl kept the command
  log around it in a DBIx::Class transaction: a lookup, a savepoint, an
  insert, the work, an update, `BEGIN` and `COMMIT`, 12 round trips a
  request. Migration 104 adds `api.forum_read_marker_command_v1`,
  `api.community_bookmark_command_v1` and `api.community_subscribe_command_v1`
  (with their ADR 0127 `_v2` boundaries) and the command log's two shared
  steps, `api.core_command_open_v1` and `api.core_command_close_v1`; each
  request is its prologue and one call, 5 round trips, and the answer
  recorded is the Perl path's key for key, so a response stored before
  replays as it did (ADR 0126, section 5;
  `t/integration/postgres-sql-function-thread-actions.t`, `t/652`).

- **Search v6 selects custom plans on PostgreSQL 16–18.** Migration 102 keeps
  the v3 query bodies and bounded payload loading, using static PL/pgSQL
  implementations so the existing custom-plan policy takes effect on 16/17.
  The maintained 20,000-document fixture uses the ordered 1,000-candidate
  path; retained v2/v4 results, ACL checks and counts remain equivalent.

- **Search loads result payloads after the page limit.** New ranked and
  autocomplete v4 APIs preserve filtering, live placement checks, candidate
  counts, rank and deterministic order while fetching body and author fields
  only for the displayed page. Post edit, delete and restore now fetch their
  post and readable thread context together, reducing those workflows from
  three client calls to two while retaining the atomic write checks and locks.
  The catalogue contains 195 operations; shipped functions remain immutable.
  See the [optimization measurements](docs/architecture/sql-functions-optimization.md).

- **Runtime data operations call stored PostgreSQL functions.** Forum,
  identity, permissions, search, notifications, moderation, administration,
  attachments, privacy, portability, plugins, outbox, projections, workers
  and operational data access now use fixed, typed calls. DBIx::Class
  remains the schema and transaction bridge. Functions live in versioned
  SQL migration files and are installed once when migrations are applied.
  The [conversion report](docs/architecture/sql-functions-performance.md)
  maps the implementation and records the measured effect.

- **Three OS settings that changed nothing are gone**:
  `GPFORUM_OS_SENDFILE`, `GPFORUM_OS_STATIC_XSENDFILE` and
  `GPFORUM_OS_WORKER_PRIORITY` only changed a mode, a flag and an action in
  the reports, and nothing acted on them. An environment file that still
  sets them is read as before, and they are ignored. The runtime evidence
  calls static transfer `configurable` only when
  `GPFORUM_ATTACHMENT_ACCEL_REDIRECT` names a prefix. The reports no longer
  carry the retired affinity flag or the retired worker and realtime process
  counts.

- **No profile says whether GlifiStore is required**: the
  `requires_glifistore` key, always 0 since GlifiStore became optional, is
  gone from the operational profile in `/health/ready`, and ADR 0048
  ("Mandatory GlifiStore L2 Cache") is marked Superseded.

- **`script/antivirus-check`, `script/dead-letter-replay` and
  `script/search-rebuild` are gone**: type `gpforum antivirus-check`,
  `gpforum dead-letters` and `gpforum search-rebuild`.

- **`gpforum setup` writes the decisions alone**: the ten settings an
  installation decides, each under one line saying what it is, and the SMTP
  lines commented out under the mail -- 41 lines where it wrote 258. A file
  written before keeps its lines. `deploy/gpforum.env.example` lists every
  setting, as before. **`gpforum doctor` says what the host sized the forum
  for**, in English or Italian: `sized for 2 CPUs, 2 GB: 4 web processes,
  4096 cache entries a process`. The operational profiles are three,
  version 2 (`development`, `staging`, `production`), and each floor gives
  way to what the host is sized for; `/health/ready`'s profile report no
  longer carries worker, realtime or retention fields (ADR 0125).

- **The README quick start is four commands**: the dependencies, `sudo
  bin/gpforum setup --environment development` (without `sudo` on macOS),
  `gpforum admin create` and `gpforum start --foreground`. `make
  system-perl`, `script/system-preflight` and the `ln -s` onto the `PATH`
  are gone: `bin/gpforum` checks its Perl, `gpforum doctor` the host, and
  setup now links `gpforum` into Homebrew's `bin` on macOS when it is not
  run as root, as it links it into `/usr/local/bin` as root. DEPLOYMENT's
  systemd section no longer repeats the timers' `enable --now`, which the
  install's one start line already gives.

- **The README carries the production path**: the `apt` line, then eight
  commands from the clone to a signed-in owner behind TLS on Debian or
  Ubuntu -- the clone, `sudo /opt/gpforum/bin/gpforum setup`, the units into
  place and started, the certificate, the nginx site into place and
  reloaded, `admin create`. DEPLOYMENT's install drops `cd`, `make
  install-deps-production`, the link, the copies and the `rm` of the
  default site, and no longer sends mail over TLS to
  `libio-socket-ssl-perl`, which the dependencies install.

- **`gpforum service print --to` puts the files in place**: given the
  directory the host reads them from -- `/etc/systemd/system`,
  `/Library/LaunchDaemons`, `rc.d`, nginx's `sites-enabled` -- it writes its
  own names there and touches nothing else, and the steps after it are the
  reload and the start, one line under systemd (`daemon-reload &&
  enable --now`) and one `launchctl bootstrap` for every plist. Any other
  directory still holds only its files, to read before a `cp`. The nginx
  site goes straight into Debian's `sites-enabled` (no link, and the default
  site stays), and is refused before its certificate is there. The steps
  begin with the packages a missing nginx, certbot or Caddy needs (`apt`,
  `pkg`, `brew`) and, on FreeBSD and macOS too, the certbot command for the
  forum's name; FreeBSD's site names certbot's `/usr/local/etc/letsencrypt`.
  On macOS, `sudo gpforum setup` makes the `gpforum` account with `dscl`,
  under the highest id below 500 that no user and no group has, and
  `gpforum doctor` says when the services' account is missing.

- **`gpforum setup` asks less and says more.** An answer typed at the
  prompt replaces what the file has with no `Replace it? [y/N]` after it:
  typing it was the say-so (an option, `--yes` and `--force` keep the
  question or its refusal). Its `Then:` line offers `gpforum service print`'s
  own first step, the files written with `--to` (`sudo gpforum service print
  systemd --to ~/gpforum-systemd`), where a bare print wrote every unit to the
  terminal first. A database it cannot use is said under the `database:`
  label `gpforum doctor`'s line has, and the owner's step reads `gpforum
  admin create --email EMAIL --username NAME`, a placeholder that does not
  look runnable. `gpforum restore --check` given the directory of the backups
  opens with `! manifest: DIR holds backups, and is not one itself`, then
  offers the newest, where it opened with a `✗`. `make install-deps-production`
  and `make install-deps-postgres` end with the command that comes next.

- **The service files run `bin/gpforum` alone**, never `script/`: the web
  unit checks the host with `gpforum os-preflight --strict --json` and starts
  Hypnotoad with the new `gpforum start --service` (`--service --foreground`
  under launchd and FreeBSD's daemon(8)); the outbox worker and the jobs run
  `gpforum outbox`, `gpforum scheduled-jobs` and `gpforum partitions`. The rc
  scripts and the crontab put `/usr/local/bin` on their `PATH`. Units copied
  before keep starting; `gpforum doctor` says they differ from the release's
  and prints them again.

- **A metrics-token rotation needs no restart.** The running service reads
  `GPFORUM_METRICS_TOKEN` and `GPFORUM_METRICS_TOKENS` from its environment
  file again. It makes one `stat` per token check, and reads the file only when
  it changed (ADR 0124). `gpforum secret rotate metrics` and its `--finish`
  name no restart: rotate, give the scrapers the new token, finish. A file the
  service cannot use keeps the tokens it accepted, and is logged once:
  unreadable, malformed, writable by another account, or without a token.
  `/metrics` never opens to anyone. The session secret keeps its restart.

- `gpforum outbox`, `partitions`, `budgets`, `migrate --check` and
  `scheduled-jobs` answer in sentences in the operator's language (the
  `bin/gpforum-*` names keep their `key=value` lines; `--json` is unchanged).
  Bare `gpforum budgets` checks, with a verdict. `gpforum partitions` refuses
  a database the migrations have not reached. `gpforum secret rotate` on a host
  without service files points to `gpforum service print`. Runbooks and the
  guide type `gpforum` verbs; the install guide's account, directory, role,
  database, template, secrets and migrate steps are now `sudo gpforum setup`.

- `gpforum outbox` (`gpforum-outbox-dispatch`) prints `acknowledged=` and
  `lost=` after the four counts it printed, and `--json` carries them: the
  messages written `done`, and the claims another worker took before this
  one wrote the outcome. `lost` was in the dispatcher's summary since each
  claim is renewed on its own, but the command dropped it, so a batch that
  lost messages to another worker read as one that delivered them.

- **The last per-request statement of the thread page is prepared, and a
  post's row is read in one step.** The attachments of a page's posts were
  the one statement DBIx::Class still built on every request (an IN list
  of the page's post ids); `PreparedQuery` now binds a list once per
  element, kept by page size, so the page's eight statements are all
  prepared. A post's fifteen columns are read with one `get_columns`
  instead of fifteen dispatched `get_column` calls. The search page's
  "more results" link writes its parameters in one order, so the page is
  the same bytes on every render. ADR 0121.

- `GPFORUM_SMTP_TLS=starttls|implicit|off` replaces `GPFORUM_SMTP_SSL`, which
  is still read and logged with its replacement. Staging and production refuse
  a `GPFORUM_PUBLIC_BASE_URL` or `GPFORUM_MAIL_FROM` under the reserved example
  domains or `.invalid`, and production refuses `GPFORUM_MAIL_TRANSPORT=test`.
  `GPFORUM_FORUM_READ_RATE_LIMIT` is a validated setting shown on
  `/admin/settings`; `GPFORUM_LOCAL_CACHE_MAX_ENTRIES` defaults to 4096, so
  every profile passes readiness; the environment template states the quoting
  rule; the settings page names both services to restart.

- The front door keeps the operator on the file it read: every command it
  offers carries `--env-file FILE`, the flag is accepted after the verb too,
  and a setting the shell exports is reported against the shell, not the file.
  `gpforum mail-check` prints sentences (`--json` for evidence);
  `gpforum start --foreground` on a busy port says so and exits 1;
  `gpforum secret rotate` keeps a previous secret only when the service could
  have started with it, and warns when the file is readable by every account.
  `gpforum doctor` warns about such a file too, counts claims a killed worker
  left behind, tells a plain-HTTP port from a refused certificate, and
  `doctor --upgrade` reports a missing module instead of dying on it. The
  FreeBSD crontabs go through `bin/gpforum`, so cron reads the environment
  file.

- **A page renders in about half the time, byte for byte the same.** The
  signed-in thread page (25 posts, 79 KB) went from 24.7 to about 14 ms in
  process, the category page from 15.1 to about 12 and the home page from
  17.5 to about 12, the anonymous cached page from 2.7 to 1.8; the HTML and
  the headers of fourteen pages, anonymous and signed in, are unchanged. The
  CSRF token is masked once per response rather than once per form (the
  thread page has 33, and each mask opened `/dev/urandom`); formatted dates,
  rendered post bodies, route paths and asset URLs are kept once computed;
  a plain message is read from the i18n table in one step; the viewer's
  read state, bookmark, subscription and session are looked up through
  prepared statements. Responses are gzipped at zlib level 3 through
  `Compress::Raw::Zlib` (0.7 ms for the thread page where level 6 through
  `IO::Compress::Gzip` took 1.6; 900 bytes more on the wire). ADR 0121.

- `gpforum staging-host-verify --env-file` checks every setting in the file
  as the service checks them at its start -- the checks `gpforum doctor`
  reports -- under the file's `GPFORUM_ENV`, else `production`: it passed a
  file that set its four keys, and the service then refused to start. Each
  problem is in the evidence as its variable and sentence, without the
  value of a secret.

- The visual identity is now a text-free symbol: two open arcs and one amber
  node, with light and dark SVG variants and a tighter favicon crop. The
  README keeps its hero and presents features and setup more concisely,
  with CI, runtime and license badges.

- `make system-perl` and `script/system-preflight` end with a verdict, in
  Italian or English: a check mark or a cross for the Perl, Carton,
  pg_config, whether the dependencies are installed and, once they are,
  what os-preflight finds, each problem with its fix, then a count;
  `make system-perl` also names the next step. `perl -V` is printed only by
  `script/gpforum-system-perl --preflight --verbose`.
  `system-preflight --help` prints its usage instead of running the check,
  and a production install no longer fails it for lacking perlcritic, a
  develop tool. A Perl the scripts refuse is said with the command that
  installs one on this operating system, and misuse exits 2.

- `gpforum migrate` brings the database up to date in one command: the
  migrations, the partition window and the endpoint query budgets, so a
  fresh install's `/health/ready` is ok without a step of its own. It says
  so in one line, then the next step: the forum's owner when it has none, a
  restart after a change in staging and production, `gpforum start
  --foreground` in development. `--plan` (or `--dry-run`) lists the pending
  migrations from the database; `--quiet` prints nothing unless something
  failed. `bin/gpforum-migrate` is its alias and still plans without
  `--apply`.

- A command line a command cannot read is answered with what was wrong
  first -- "--aply is not an option of this command.", "--limit takes a
  whole number above zero, not 'ten'." -- and the usage once, in the
  operator's language; `admin-bootstrap` printed its usage twice and no
  reason. Through the front door, help and failures name the verb typed
  (`gpforum partitions`), not the `bin/` entrypoint. A configuration report
  ends with the environment file the command read, where it named the
  template.

- `gpforum antivirus-check` without clamd says so once -- "clamd does not
  answer at" its socket, and the operating system's reason -- where it
  repeated one connect error eight times, and follows it with the commands
  that install clamd and start it on this host (apt and systemctl, pkg and
  sysrc, or Homebrew) and `GPFORUM_ANTIVIRUS=none` as the alternative. Old
  signatures come with the command that starts freshclam, and scanning
  turned off is a warning once deployed. In Italian or English.

- `gpforum mail-check --human` says what a dry run proved and what it did
  not: a sendmail program found shows "a program is there, not that mail
  leaves this host", and an SMTP port that answers is not a server that
  takes the message; each is followed by the `--send --to` command that
  proves delivery. Deployed with sendmail, it adds that a VPS often cannot
  send on port 25 and needs SPF and a PTR record, so relay through a
  provider's SMTP. A failure names the fix: a mail server to install, or the
  smtp settings and where to set them. `docs/ops/mail-check.md` says the
  same, lists the `log` transport and gives development's default as `log`.
  The `key=value` lines are gone; `--json`, still the default, is unchanged.

- `gpforum os-preflight` speaks in sentences, in Italian or English: a
  check mark for the host, its web processes and its open-file limit, `!`
  for a warning and a cross for a failure, each problem with a `Fix:` line
  that names the setting and where to set it (or the unit's
  `LimitNOFILE=`), and a closing count. It no longer reports the retired
  worker and realtime process counts. A script that read its `key=value`
  lines reads `--json`, which keeps the whole report; each check that is
  not ok now carries the `key` and `parameters` of its sentence. Under
  `--strict --json`, what the service files run before a start, stderr
  carries the same lines for the journal, and settings that do not parse
  are reported there once: the JSON says `fail` with the problems'
  sentences as its `error` and the `variables` they name, where it used to
  write the whole report three times.

- The README quick start reaches a signed-in administrator on a laptop
  without a mail server: the outbox worker, run once, prints the
  verification link the `log` transport writes. `docs/DEPLOYMENT.md`
  describes GlifiStore as the optional shared cache it now is.

- `deploy/gpforum.env.example` is the environment file to copy: the ten
  settings every installation decides, uncommented with a one-line comment
  each and a production example (a secret with the command that generates
  it), then every other setting by section, commented out at its default.
  It is generated from `GPForum::Config`'s settings table, and t/463 fails
  when the two differ; values a shell would split, such as the DSN, are
  quoted.

- Production never sends the `X-GPForum-DB-*` benchmark headers, even with
  `GPFORUM_BENCHMARK_QUERY_HEADERS=1` left in its environment: they told
  every client how many queries each page ran. Development and staging
  benchmarks keep them.

- A configuration Good Phorum cannot use is reported whole: every problem at
  once, each naming the variable to set with an example (a line to paste
  into the environment file, quoted when it holds a space), a "did you mean"
  for a mistyped choice or the command that generates a secret, then where
  to set them, ending in a newline. `bin/gpforum` and the service exit 78
  (EX_CONFIG) instead of 255, in Italian or English as `LC_ALL`,
  `LC_MESSAGES` or `LANG` asks (owner decision D13); the `bin/gpforum-*`
  commands print the same report, in the same language, and exit 1. The command line's words
  are a catalog of their own, `locale/cli/en.po` and `it.po`.

- `GPFORUM_WEB_PROCESSES` defaults to `auto`: as many web processes as the
  CPUs carry under `cap-to-cpu` (`GPFORUM_RUNTIME_MAX_WEB_PER_CPU` a CPU), at
  most 16. A number is still taken as it is. An operational profile's web
  floor is what the host carries when that is less than the profile's.

- GlifiStore is optional in every environment (owner decision D2): without
  `GPFORUM_GLIFISTORE_URL` each process keeps its own cache, and readiness's
  `shared_cache` check is `ok` with a note saying so. Development no longer
  assumes one at `tcp://127.0.0.1:7379`.

- Development's mail transport is `log` (owner decision D7): each message,
  link included, is written to the log -- standard error for the outbox
  worker -- instead of sent, so the README quick start verifies an account
  on a laptop without a mail server. `test` stays the test suite's, and
  `mail-check` knows `log`.

- A boolean setting reads `on`/`off`, `yes`/`no`, `true`/`false` or `1`/`0`.

- `GPFORUM_WORKER_PROCESSES`, `GPFORUM_REALTIME_PROCESSES` and
  `GPFORUM_OS_AFFINITY`, which had no effect, are retired: still read but
  never refused, so an old environment file starts whatever it holds for
  them, and the start logs one line for each that is set, saying to remove
  it. Readiness no longer holds the worker and realtime counts to the
  operational profile's floors, which failed production-medium once they
  were removed. `docs/DEPLOYMENT.md#retired-settings` lists them, and the
  guides no longer show them, nor a GlifiStore every host must run.

- A command that cannot use the database says so in one sentence, in
  Italian or English as `LC_ALL`, `LC_MESSAGES` or `LANG` asks, naming the
  setting to correct, the file the service reads it from and the command
  that fixes it, instead of DBIx::Class's own text: `Cannot reach PostgreSQL
  at 127.0.0.1:5432 (connection refused): start it with sudo systemctl start
  postgresql, or correct GPFORUM_DATABASE_DSN in /etc/gpforum/gpforum.env.`
  The same for a wrong password, a pg_hba.conf refusal, a host, role or
  database that does not exist, and a schema not migrated. Under `--json`
  the document's `error` keeps the original text and `explanation` carries
  the sentence. Run by hand without the service's environment file on a host
  that has one, the command adds `This command did not read
  /etc/gpforum/gpforum.env, which holds the service's settings: run it as
  docs/DEPLOYMENT.md shows.`, so a password refused for want of the file is
  not mistaken for a wrong one.

- `docs/DEPLOYMENT.md` opens with the whole install on Debian or Ubuntu, in
  eleven numbered steps: packages, the `gpforum` user and who owns
  `/opt/gpforum`, the database with its password, the environment file from
  `deploy/gpforum.env.example` with its owner and mode, how to make a secret,
  commands run as the service runs them, mail, antivirus, the units with the
  outbox worker and both timers, the certificate and nginx, and the first
  administrator. A production host installs with `make
  install-deps-production` only. The README quick start creates the role
  with `sudo -u postgres` and `--pwprompt`, as Debian needs.

- `GPFORUM_ATTACHMENT_ACCEL_REDIRECT=/internal-attachments/` is documented:
  nginx sends an authorized download itself. The guide used to say the
  header was not implemented.

- The FreeBSD rc scripts create `/var/log/gpforum` for the `gpforum` user
  before they start.

- `os-preflight --strict` stops a start only when a check fails, as
  `os-preflight` without it always did; a degraded check is reported and the
  service starts (owner decision D10). Under `--strict`, which the systemd
  units and the FreeBSD rc script pass, each check that is not ok is also
  written to stderr on a line of its own, such as `os-preflight: degraded
  recommended_worker_count: recommended worker count below configured
  threshold`, so the journal shows it outside the JSON report.

- Errors Good Phorum raises now have classes (ADR 0118), and the outbox
  classifies a failed handler by the failure type its class declares instead
  of guessing from the message. An invalid argument, invalid configuration or
  failed check (`GPForum::X::Argument`, `X::Config`, `X::Check`) is
  `permanent`: the message is cancelled at once instead of retried. That
  covers a search rebuild for an unknown entity type or stage, a breached
  `QueryBudget->enforce`, and a handler built without a collaborator it
  requires. A request over its query budget under
  `GPFORUM_QUERY_BUDGET_ENFORCE=1`, outside the server profiles, fails with
  `X::Check` too. A dependency that did not answer (`X::Unavailable`: clamd,
  the scanner command, Minion, a reply fan-out that dropped a recipient) is
  retried as `transport`, and its dead letter says so. Config, the cache
  stack, Keyset, OS::Filesystem, Log, Migration::Plan, Migration::Runner and
  MinionRegistrar raise these classes with their old messages; a migration
  file edited after it was applied raises `X::Check`. An exception's text is
  its message alone, so outbox `error_message`, log lines and an uncaught
  startup error no longer end in " at FILE line N.".

- The stores recover a unique conflict only on the constraint they expect,
  asking `GPForum::X::Conflict->on`, and rethrow a conflict on any other
  index; several used to take any unique violation for the one they expected.
  Search documents accept their entity key or their primary key, which
  PostgreSQL names first when the same entity is inserted twice.

- Monthly partitions of `audit_log`, `event_log` and `notifications` roll
  forward with the date (ADR 0113, amending ADR 0012). `bin/gpforum-migrate
  --apply` creates the current UTC month and the next two after its
  migrations, under the configured `statement_timeout`; `--no-partitions`
  skips it, and a conflict or error exits 1 with the migrations applied. A
  daily timer runs `bin/gpforum-partition-maintenance --apply` between
  deploys; runs are serialised by advisory lock 4021970002, and one that does
  not get it reports `skipped=1` and exits 0 (migrate waits up to a minute
  first). Months are created with `CREATE TABLE ... (LIKE ...)` and `ATTACH
  PARTITION`, which holds the parent only in SHARE UPDATE EXCLUSIVE: no
  maintenance window. DEFAULT is still locked during each attach, so unpruned
  reads, and the event and audit writes that start with one, wait at most a
  0.5 s lock wait (retried five times) plus the scan of DEFAULT. Migration 049
  drops the past, empty partitions migration 038 named, one table at a time,
  keeping any that holds rows. The staging drill creates the window too.
  Detaching and dropping old months stays manual.

- ADR 0115 records that the translation catalogs are the gettext PO files,
  read at startup and looked up by key (`msgctxt`); it amends ADR 0021. The
  ADRs name the PostgreSQL integration tests that replaced the deleted store
  tests, and `t/00-load.t` loads every module added since.

- Removed `Attachment::UploadPipeline::cleanup_orphans`, which had no caller
  and could not delete files in production; the purge is
  `Attachment::Store::cleanup_orphans`, run by the scheduled jobs.

- `GPFORUM_OS_WORKER_PRIORITY=on` reports the action `supervisor-nice` and
  the policy `declared-for-the-supervisor`: nothing in Good Phorum calls
  setpriority, the supervisor applies the nice value.

- Translations are gettext PO files, `locale/en.po` and `locale/it.po`, which
  Poedit, Weblate and gettext's tools can open; `docs/i18n.md` describes the
  translator workflow. The catalogs are read once at startup, and a malformed
  file stops the application naming the file and line. A message left empty or
  marked fuzzy is shown in English (and reported to a `missing_key_logger`
  when one is set; `t/295` keeps the shipped catalogs complete); for a counted
  message one empty form is enough. Adding a locale also needs a migration that
  widens `users_preferred_locale_check`.

- The eight performance and database documents are now one,
  `docs/PERFORMANCE.md`: budgets, indexes and the plan gate, keyset pages,
  `CONCURRENTLY` migrations, query budgets, caching, search costs, profiling,
  OS tuning, and how to reproduce the evidence, re-measured on 2026-10-03
  (PostgreSQL 18.6, Perl 5.44). The old paths are gone; the May 2026 tables
  stay in git history (`git show 0d0ec4a:docs/PERFORMANCE_EVIDENCE.md`). The
  readiness check `query_budget_drift` points at
  `docs/PERFORMANCE.md#query-budgets`, which also says what
  `script/query-budget --sync` cannot fix: a stored budget for an endpoint the
  catalog no longer has used to stay `extra` until its row was deleted by
  hand; `--sync` now removes it (below).


- **Notifications, attachments and community stores are tested on
  PostgreSQL** (`t/integration/postgres-{notifications,attachments,community}.t`,
  over 550 assertions) instead of fake ORMs; concurrent-write recovery is
  tested against a real second connection. The fake had pinned two wrong
  results (a removed bookmark still listed, one member's feed showing
  another's items), now tested as correct.

- **Search runs under its own statement timeout and ranks a capped set**
  (quality program 8.10). `GPFORUM_SEARCH_STATEMENT_TIMEOUT_MS` (2000) cancels
  a slow search, which then shows a degraded page instead of holding a web
  worker; `GPFORUM_SEARCH_CANDIDATE_LIMIT` (1000) ranks only the newest
  matches, walked through a new index, and the page says when it did.
  Migration 048 drops four partial search indexes no query could use
  (checked on 134 statements, 670 plans). It is not concurrent: on a large
  forum, apply it in a maintenance window.
- **A large thread's search work is bounded**: removing it deletes its
  documents 500 per transaction, title first; renaming, moving or restoring
  it reindexes 500 posts at once and the rest as
  `search.thread_posts_requested` outbox messages. The outbox dispatcher no
  longer sleeps between full batches.

- **Replies lock their thread with `FOR NO KEY UPDATE`.** A reader's first
  "mark as read" on a busy thread no longer waits for the replies in flight;
  replies are still numbered in commit order. The concurrent-reply test that
  `docs/MVP.md` listed as required before go-live now exists.
- **After a failed GlifiStore call, a process skips GlifiStore for 15
  seconds**, invalidations and the readiness ping included, so a hung server
  no longer stalls every request. `/metrics` shows `retry_after_epoch` and
  `stats.skipped`.
- **Deep pages read only the rows they show.** Every paged list (posts,
  threads, profiles, bookmarks, mentions, feed, moderation and audit
  history) bounds its sort column, which the index answers. Page 800 of a
  50,000-post thread read 39,008 rows (5.7 ms); it now reads five (0.03 ms).

- **A cached public page is served before its queries run**, and junk query
  parameters no longer mint cache entries (quality program 8.2). Pages past
  the first are not cached.

- **Removing a thread from the feeds no longer scans them per post.**
  `user_feed_items` could only be read by user, so each removed post read
  the whole table; migration 047 indexes it by item, and a thread's posts
  leave every feed in one statement.
- **Feed fan-out is one statement.** A new post reached its thread's
  subscribers with two statements each, in one ever longer transaction; it
  now reaches all of them with one `INSERT ... SELECT FROM unnest(...) ON
  CONFLICT` (8.7). 5,000 subscribers: 10,000 statements before, five now.
- **The header fits a phone.** It holds the brand, the navigation and the
  account links; the language and theme selectors moved to the footer's
  Preferences group. Below 1100px it is two rows, with the navigation on one
  line that scrolls sideways (110px tall on a 375px phone, from about 630px).
  The header no longer shows the member's internal id.
- Import job progress skips the row write when the stored counters already
  match.
- Plugin enable and disable skip the row write when status already matches.
- Reputation events that do not change trust level do not restamp the user
  row.
- Attachment scanning skips the object read when the row is already clean
  and available, or infected and quarantined. A failed scan still rereads.
- Media thumbnail processing skips the object read and variant write when
  the thumbnail already exists.
- Query-budget schema sync skips the row write when max queries,
  transactions, and notes already match.
- Already-failed projection offsets and already-ready, active, or failed
  generations skip the status rewrite.
- Projection offset progress skips the row write when `last_event_id`
  already matches.
- Feed projection skips the `user_feed_items` write when created_at, rank,
  and version columns already match.
- Search reindex of an unchanged thread or post document keeps the original
  `indexed_at` and does not rewrite the search row.
- Password reset to the same secret still consumes the token and revokes
  sessions, but does not rotate the credential.
- Email-change request for the member's already-verified address does not
  issue a token or queue mail.
- Password change skips credential rotation when the new secret already
  matches. Email-change confirmation skips the user restamp when the
  address is already verified on that account. The token is still consumed.
- Email verification confirmation skips the user restamp when the account is
  already active and `email_verified_at` is set. The token is still consumed.
- Post edit skips the new body, revision, version bump, and event/audit
  rows when the current `source_hash` already matches.
- Thread title edit and move skip the row write, version bump, and
  event/audit/outbox rows when title and slug, or category, already match.
- A thread read marker that does not advance the stored position keeps the
  original `last_read_at` and does not rewrite the state or delta row.
- Category update skips the row write, version bump, and event/audit/outbox
  rows when title, slug, description, visibility, and position are already
  the requested values.
- Locale, theme, and notification-channel preference writes skip the row
  update when the stored value is already the requested value. `updated_at`
  is not restamped.
- Bookmark and subscription save skip the row update when the target is
  already active with the same note or preference. Restore of a deleted,
  muted, or revoked row still clears those stamps.
- A second logout of an already-revoked session keeps the original
  `revoked_at` and does not restamp the row.
- Hide, restore, lock, and unlock mint and require HTTP `command_id` and
  replay from `command_log`. A lost hide, restore, lock, or unlock response
  retried with the same key does not persist twice. Store unique `command_id`
  remains the ActionStore guard. Command hashes include actor, target, and
  reason only. Store and command-log failures return HTTP 503 without
  leaking the exception.
- Thread read-marker writes mint and require HTTP `command_id` and replay
  from `command_log`. A lost `POST /t/:thread_id/read` retried with the same
  key does not persist twice. Command hashes include actor, thread, and
  position only. Store and command-log failures return HTTP 503 without
  leaking the exception.
- Assign, release, resolve, and reverse mint and require HTTP `command_id`
  and replay from `command_log`. A lost queue or reverse response retried
  with the same key does not persist twice. Command hashes include actor,
  target, and resolution or reason only. Reverse store arity stays four
  arguments. Store and command-log failures return HTTP 503 without
  leaking the exception.
- User suspend and suspension revoke mint and require HTTP `command_id`.
  A lost suspend or revoke response retried with the same key replays from
  `command_log` and does not persist twice. Command hashes include actor,
  target, and reason only. `revoke_suspension` store arity stays four
  arguments. Store and command-log failures return HTTP 503 without
  leaking the exception.
- Member thread, post, and profile reports mint and require HTTP
  `command_id`. A lost report response retried with the same key replays
  from `command_log` and does not insert a second row even when reason or
  details would otherwise hash differently. Command hashes include reporter,
  target, reason, and details only. Unique open/triaged reporter-target
  rows remain the store-level guard. Store and command-log failures return
  HTTP 503 without leaking the exception.
- Admin catalog, binding, and category writes mint and require HTTP
  `command_id`. A lost role, permission, attach, bind, revoke, or category
  response retried with the same key replays from `command_log` and does not
  persist twice. Command hashes include actor and target fields only. Store
  and command-log failures return HTTP 503 without leaking the exception.
- Thread bookmark and subscription writes mint and require HTTP `command_id`.
  A lost bookmark, bookmark-remove, subscribe, mute, or unsubscribe response
  retried with the same key replays from `command_log` and does not persist
  twice. Command hashes include actor, target, and note or preference only.
  Store and command-log failures return HTTP 503 without leaking the
  exception.
- Guest and signed-in HTML locale and theme selector writes set a success
  flash and redirect to `return_to`. JSON is unused on those cookie
  routes.
- Marking visible posts read on `POST /t/:thread_id/read` sets a success
  flash and redirects to the thread. JSON still returns the read-state
  payload.
- Registration, login, logout, password change, locale change, theme
  change, settings notification preferences, password-reset request,
  password-reset complete, email-change request, email-change complete,
  verification resend, and verification complete mint and require HTTP
  `command_id`. The same key replays from `command_log` without creating a
  second pending account, opening a second session, revoking a session
  twice, rotating a password twice, persisting a preference twice, issuing
  a second token, or consuming a token twice. Registration command hashes
  include email and username only. Login command hashes include the
  identifier only. Logout hashes include `session_id` and `user_id` only.
  Password-change hashes include `user_id` only, never the current or new
  password. Locale and theme hashes include `user_id` and the chosen
  value. Settings hashes include `user_id` and channel rows only. Token-
  consume hashes include the token only, never a new password. Token
  consume still uses `identity_tokens` uniqueness and `used_at` as the
  store-level guard. Guest locale/theme cookie writes stay cookie-only and
  omit `command_id`. Locale and theme on `POST /settings` mint their own
  keys so they do not share the settings `command_id`.
- HTML mark-read and attachment upload now set a success flash and
  redirect. Mark-read lands on `/notifications`; upload lands on the
  post permalink. JSON responses are unchanged.
- Privacy HTML writes now set a success flash and redirect. Member
  export and deletion land on `/privacy`; staff approve, hold, and
  erasure land on `/admin/privacy`. JSON responses are unchanged.
- Author HTML writes on forum now set a success flash and redirect.
  Creating a thread or reply, editing or deleting a post or thread,
  moving a thread, reporting, bookmarking, and subscribing land on the
  thread, category, or profile page with the matching catalog message.
  JSON responses are unchanged.
- Staff HTML writes on moderation and admin now set a success flash and
  redirect. Hide and assign land on `/moderation/reports`; role create lands
  on `/admin/roles`; category create lands on `/admin/categories`. JSON
  responses are unchanged.
- A classified permanent outbox failure cancels and dead-letters on that
  attempt. Cancelled rows are not claimed again. Operator review is
  `docs/ops/dead-letters.md`.
- `GPFORUM_MINION_ENABLED=1` fails closed when the Minion PostgreSQL
  backend is missing or unreachable. `bin/gpforum-outbox-dispatch` skips
  Minion and keeps draining the canonical outbox.
- Write routes map store and command-log failures to HTTP 503
  `service unavailable` without leaking DBI text. `create_reply`,
  thread report, moderation hide, and privacy export share that
  contract. Unexpected application errors still use HTTP 500.
- App PostgreSQL sessions set `statement_timeout` (15s),
  `idle_in_transaction_session_timeout` (10s), `lock_timeout` (3s), and
  `application_name=gpforum` on connect. Override with
  `GPFORUM_DATABASE_STATEMENT_TIMEOUT_MS`,
  `GPFORUM_DATABASE_IDLE_IN_TRANSACTION_TIMEOUT_MS`, and
  `GPFORUM_DATABASE_LOCK_TIMEOUT_MS` (0 disables that timeout).
  `gpforum-migrate --apply` clears `statement_timeout` after connect so
  DDL is not capped at the web budget.
- Privacy export retries with the same `command_id` replay from
  `command_log` after the bundle is already completed. Open deletion
  requests are unique per resource (`pending`/`approved`/`held`); pending
  exports are unique per requester/subject/type/format; active retention
  holds are unique per resource while `ends_at` is null. A later
  `command_id` can still open a new export after complete.
  `DeletionWorkflow->hold_request` stays four arguments besides the
  invocant.
- Privacy export, deletion, approval, hold, and erasure HTTP writes now mint
  and require `command_id`. A second deletion request for the same open
  resource reuses the existing row; a second hold for the same active
  resource reuses the hold; a second export while one is still pending
  reuses that request. `DeletionWorkflow->hold_request` stays four
  arguments besides the invocant.
- Moderation assign, release, resolve, and reverse HTTP writes now mint a
  distinct `command_id` per form and require it at `Moderation::Workflow`.
  Hide/restore/lock/unlock already minted one shared command id; sibling
  queue forms no longer reuse it. Report and reverse stores keep
  state-based replay; `ActionStore::reverse_action` stays four arguments.
- Moderation hide, restore, lock, and unlock HTTP writes now mint and
  pass `command_id` the same way forum posting does, so ActionStore
  replay can fire on retry.
- Install and bootstrap now recognize CPAN distributions from `cpanfile`
  plus the `postgres` feature in `cpanfile.postgres`, and fetch only
  through Carton (`make install-deps` / `script/bootstrap-deps`) using
  `carton install --deployment`, `cpanfile.snapshot` pins, and the
  official MetaCPAN HTTPS mirror. Carton is resolved via
  `script/gpforum-carton` when it is not on PATH. The previous unpinned
  `cpanm --notest` PostgreSQL sideload is gone.
- Wired operational profiles into `platform-check` and readiness so undersized
  production floors fail the same gate as OS preflight and query-budget drift.

### Fixed

- **One failing scheduled job no longer stops the jobs after it.** A purge
  that died, a database restarting under it, ended the whole run, so the
  digests and the end of timed suspensions waited for every later run. Each
  job now fails alone and the summary names it.
- **A worker releases only the handler claim it holds (migration 2050).**
  A worker whose handler outlived the lease and then failed deleted the claim
  of the worker that had taken it over, and a third could run the same side
  effect. A claim now carries a token; only its run's failure releases it.
- **A completion that cannot be recorded no longer repeats the side
  effect.** The handler's result and the record of it were one failure, so a
  database lost right after an email went out released the claim and the
  retry sent the email again. The run now succeeds and the claim is left to
  its lease; a claim that cannot be released keeps the handler's own error.
- **A Discord announcement no longer holds database locks during the
  request.** The thread, category and space stayed share-locked for up to
  twelve seconds, so activity bumps and moderation on the thread hit their
  lock timeout, and a slow request could lose the record that Discord had
  the message. The topic is read and checked first; the request runs with no
  transaction open.

- **The explicit legacy ORM search comparison binds valid tag arrays.**
  Combined tag filters now pass an actual PostgreSQL array value, preserving
  the same intersection as the normal versioned-function path. A live
  PostgreSQL regression covers it.
- **Current guides describe the fresh database and media storage.** Operator
  actions now start at 2000–2025 and keep retired migration procedures in a
  historical section. SQL-role rollback guidance, partition references and
  storage ADRs match the installed baseline; no import of older database
  histories is claimed. Broken local links and section anchors are repaired.

- **A search's operators hold for titles too (migration 1057).** The
  fuzzy-title arms matched a title by similarity or by containing the
  query, whatever the query asked: `"errore di sintassi" -risolto` found
  the thread titled "Errore di sintassi risolto". They now apply only to a
  query with no negation, phrase or alternative; a plain query keeps its
  typo tolerance. The ORM path asks the same question.

- **The Atom feed and the sitemaps answer with any title.** Their documents
  were handed to the response as text, so a title, a summary or a slug with
  a character beyond ASCII -- "perché", an ellipsis -- stopped the response
  with "Wide character" before a byte was sent, and the reader saw an empty
  reply. They are sent as the UTF-8 their content types and XML
  declarations name. A thread's canonical URL escapes an accented slug
  (`/t/ID/perch%C3%A9`): a sitemap's `<loc>` must be a URI.

- **The home page shows what each of the latest threads opens with
  (migration 1056).** Its template had a line for the opening post's
  excerpt, which read a column no thread list had, so none was ever shown.
  The latest-thread list carries the first 1,000 characters of the opening
  post's safe HTML when that post is public, visible and in place, and the
  page shows its text in at most 200 characters and two lines. The Atom
  feed takes its summaries from the same rows, one statement fewer than its
  own excerpt read, and the home page sends no statement more. On
  20,000 threads and 100,000 posts, over the Unix socket, the list takes
  0.25 ms for its 21 rows with their excerpts, against 0.16 ms without --
  what the list and a separate excerpt read cost together.

- **Feed summaries and thread descriptions read an entity once.** Their
  text kept the safe HTML's entities, which the feed and the page then
  escaped again: "Tom & Jerry" reached the reader as "Tom &amp; Jerry".
  One helper, `GPForum::Infrastructure::Text::plain_excerpt`, makes the text
  for the feed, the description and the home page, and a cut text ends on a
  whole word with an ellipsis.

- **A thread row on the home page uses its whole width.** The row kept
  its article in the first of its two columns and the article split that
  again, so the title and the excerpt had 214 of the row's 822 pixels and
  16rem stood empty at its end. The rows of a thread list split into text
  and facts when the list, not the window, is 44rem wide: the home page's
  list is narrower than the window by its column of categories, and below
  that width its rows stack as on a phone.

- **`gpforum staging-drill-attachments` names each host check on its deploy
  line**, `systemd=` and `nginx=` after `host=`, so a degraded host says
  which check could not run. The drill's own text said so, and no operator
  read it.

- **`gpforum postgres-performance apply` refuses `--output` before it
  touches the cluster.** It applied the settings, then failed on `--output`
  and printed no report. Its options are read as every verb reads them, so
  `--c` no longer passes for `--controlled-instance`, and a relative
  `--plan`, `--restore`, `--journal` or `--output` reads from where the
  command was typed, not the code directory. So do `gpforum gemini`'s
  `--cert` and `--key`.

- **`gpforum mail-in`, `sign-posts` and `gemini` say what was wrong with
  their command line, then the usage.** They exited 2 with nothing on
  stderr; `sign-posts --help` is help.

- **`-h` is help on every command**, as on the front door. Fourteen took it
  for an unknown option.

- **Every command says an unknown option in the operator's language.**
  Twenty said "Unknown option" in English.

- **`query-plan-evidence` and `performance-seed` exit 1 when the database
  fails, and `performance-seed` 78 for settings it refuses**, as the
  console contract says. Both exited 2, misuse.

- **`gpforum secret rotate session` drops a list of previous secrets the
  service refused when it keeps none of them**, so the next start is not
  refused for it.

- **A DSN's inline password stays out of `performance-seed`'s failure, the
  plan and Hypnotoad benchmark reports, and setup's "Replace it?"
  question**, a quoted one holding a semicolon too.

- **Per-request query statistics close the request their token names.** Two
  asynchronous requests in one worker charged each other's statements, and
  one lost its log line.

- **`gpforum hypnotoad-benchmark` runs the checkout's Hypnotoad with this
  Perl, from any directory**, not a `carton` on PATH, which may be another
  Perl's. Its proxy keeps backend connections alive, as the shipped site
  does.

- **The PostgreSQL backend's CPU and memory are read on FreeBSD and Linux**
  (`script/bench-postgres-configuration`): ps's time column in each of its
  forms, and Linux's own count from /proc.

- **`gpforum partitions` connects once a run**, not twice.

- **Pinning and unpinning a thread are held to the moderation query
  budget**, as locking it is.

- **`gpforum doctor --calibrate-password` answers in the operator's
  language** and fails on a write it cannot make.

- **The reactor-fallback warning reaches the configured log, as JSON.** It
  went to stderr before the log was set up.

- **A warmup that fails is logged, and the server starts with cold
  workers.** It stopped the start.

- **Settings examples show the Unix socket the service listens on.** The
  runtime guide's production block and `GPFORUM_RUNTIME_LISTEN`'s message
  showed a TCP address, which `gpforum start --service` refuses.

- **A partition is due for retention once its month has ended, in any
  zone.** The registry writes a month's end in the server's zone
  (`2026-09-30 20:00:00-04`), and it was compared as text with a UTC
  cutoff, so a month west of UTC was proposed for detaching up to a day
  early.

- **A backup counts the uploads its archive holds.** It counted them before
  tar ran, so an upload or an erasure during the backup made `gpforum
  restore --check` fail a sound backup.

- **`gpforum backup --verify` says a pg_dump older than the server as
  `gpforum backup` does**, with the client to install, not its command
  line.

- **Backup and the drills read a DSN that names its database with
  `database=` or `db=`**, as the forum, setup and doctor do; they refused it
  as "missing dbname=". The DSN's own `user=` and `password=` are the last
  resort for the login.

- **A printed command quotes a path with a space:** the copy steps of
  `gpforum service print --to`, the directory the services log to, setup's
  `ln`, and the `chmod` that doctor, setup and `gpforum secret rotate` offer
  for an open settings file.

- **The host preflight recommends two web processes a CPU, as the forum
  runs.** It capped the recommendation at two on any host, so
  `GPFORUM_OS_MIN_RECOMMENDED_WORKERS` above two could never pass, and a
  one-CPU host was reported degraded although it runs two.

- **`gpforum api-role` says a catalogued function that is not there yet as
  missing**, with `gpforum migrate` to run first, not as "not a restricted
  API boundary".

- **The staging host check reads `export KEY=value` in the settings
  file**, as the service does; such a key was reported missing.

- **Search finds a reply in your own private thread (migration 1051).** A
  reply someone else wrote there, which the thread's page shows you, was
  found only by its author.

- **A WebSub subscription reaches the hub.** The publisher that owned the
  forward's connection went with the action that made it, and every
  subscription answered 502.

- **Every category in the sitemap opens.** It listed `/c/SLUG`, a route
  that does not exist, so crawlers got a 404 for each category.

- **The Atom feed carries what each thread opens with, and who wrote it
  (migration 1049).** Every summary was empty, and neither the feed nor an
  entry named an author, which RFC 4287 requires.

- **An upload that does not go through leaves no file behind.** Its bytes
  were written before its rows, inside the command: when a later step
  failed, the rows rolled back and the file stayed where the orphan purge,
  which walks rows, never looks.

- **A download whose file is gone fails instead of answering an empty
  200.** It sent a zero-byte file under the member's file name and logged
  nothing; it answers 503 and logs which object is missing.

- **Pictures get thumbnails with macOS's sips.** The thumbnail step gave the
  image tool no size, which sips crops by, and every picture failed; it now
  measures the picture first, and a canvas past a profile picture's bounds
  is not handed to the tool, which spent its whole timeout on it on every
  retry.

- **An upload whose id collided follows the row the store kept.** The
  store reissued the id and the key, but the bytes stayed under the old key
  and the scan and the link landed on the attachment holding the old id.

- **Two reversals of one moderation action record it once (migration
  1047).** Both read it unreversed, the second overwrote who reversed it,
  and each wrote its event, its outbox message and its audit entry.

- **An impossible date or cursor shows the form or the first page, not a
  500.** The audit viewer took `2026-02-30` as a day and a cursor that only
  began with one; every list took a cursor at a time of the right shape
  that does not exist. PostgreSQL refused them and the request failed.

- **A bookmark folder counts what its list shows (migration 1047).** It
  counted every live bookmark in it, the list only those its member may
  still read; a folder of threads all hidden or made private is no longer
  offered.

- **A repeated reject or withdrawal names the review.** It answered
  `review_id: null` where the first answer, and a repeated approval, named
  it.

- **A thread's own subscription decides over a category follow (migration
  1046).** A member who followed a category and set one of its threads to
  mentions, or to none, still got every reply in it: the follow gave way
  only to a thread mute.

- **Two polls created at once on one thread answer conflict (migration
  1046).** Since the vote stopped locking the thread, the second create
  failed on the poll's key and its command was rolled back instead of
  recorded.

- **The files under a post keep their order (migration 1046).** The thread
  page numbered them in the order its joins produced, which is not the
  order the attachment read answers; with three files on a post it showed.

- **The link rule screens direct messages.** It counted links only in a
  post's body field, so a message, whose field is named differently,
  carried any number of links past a rule that refuses them in a reply
  (ADR 0139).

- **Every change of a post or a suspension moves its member's score.** The
  worker keyed a reputation change by the post or the member it was about,
  so hiding a post, restoring it and revoking a suspension were skipped as
  duplicates of the first change, and a suspension lifted as a mistake
  lowered the score for good.

- **A hide reaches every web worker without GlifiStore.** With no shared
  cache configured, the documented default, each process kept a bare local
  cache with no invalidation bus: the outbox worker's purge cleared its own
  cache, the console's "purge" one web worker, and every other worker kept
  serving a hidden post or a newly private category until the entry
  expired. The stack is now a tiered cache over an empty shared layer
  whatever is configured, so the bus attaches and every process is told.

- **The migration lock waits for another deployer.** The session's
  `lock_timeout` cut the wait for `pg_advisory_lock` at three seconds, the
  failure read as "not locked", and a second deployer migrated beside the
  first; the lock now waits, and a lock PostgreSQL refuses stops the run.
  A role that may not read `schema_versions` is told so, where it read as
  every migration pending and doctor told it to migrate.

- **Doctor asks the three questions migrate asks.** It said "current" for
  a database a newer release migrated, and "run migrate" for a late
  migration migrate then refused.

- **A moderator's edit no longer lands in the moderator's feed**: an edit
  reads the post's stored author, as a restore does. **A deleted post's
  realtime hint reaches the thread's readers**: it named the post, which
  nobody may read any more, so nobody got it. **The sender's
  direct-message frame keeps its id** across the NOTIFY and the backstop
  poll, which delivered it twice.

- **The plan gate measures what runs and requires what exists.** It
  EXPLAINed the raw claim statement migration 1034 replaced, not the claim
  function the workers call, and still required the three indexes 1040
  dropped: it read `DROP INDEX IF EXISTS public.x` as dropping an index
  named `public`. Its labels lose the version suffixes the catalogue left
  behind.

- **`evidence-validate` takes JSON false for `private_beta_claimed`**, as
  its own message says.

- **A sign-in on the request that cleared a stale session keeps its
  cookie.** A browser posting the login form with a revoked or expired
  session had the cookie set to expire while the request cleared it; the
  sign-in then wrote its session into that cookie, the browser dropped it,
  and the member landed signed out with a session row and an accepted-login
  audit to show for it. A second step's challenge was lost the same way.

- **A profile's replies page apart from its discussions.** The "older"
  link carried the discussions' cursor, and the replies list read it as
  its own, repeating or skipping replies; each list now has its own cursor
  and its own link.

- **Refused fields read in the reader's language.** A form's field errors
  and the login refusal reached the page as the workflow's English words in
  every locale; the page shows the catalogue's `form.error.*` message when
  there is one.

- **A pending account stays pending through a suspension.** Suspending an
  account whose address was not confirmed made it `suspended`, and lifting
  the suspension made it `active`, without the confirmation.

- **A hold is refused on a closed deletion request, and an erasure waits
  for a hold placed meanwhile.** A hold from a stale review page put a
  completed request back to `held`; the erasure read the request and its
  holds unlocked, so a hold committed while it ran was overrun and two runs
  of one job both erased. The request is now locked first, as an approval
  and a hold lock it, and the hold is made under that lock.

- **A second-factor answer no longer deadlocks with a disable (migration
  1045).** The answer locked the challenge, then the member's factor;
  disabling and an administrator's reset take them the other way round.

- **The export carries the member's bookmarks (migration 1045)**, with
  their notes and folders.

- **The audit viewer and the dashboard show what each action did.** On
  PostgreSQL the entries' metadata arrived as the jsonb's text, and the page
  drew metadata only from a hash, so every entry showed its action, actor
  and target and never what it changed; the doubles held hashes, so the tests
  passed. The call layer now decodes the metadata of the three audit reads,
  the DBIx::Class path hands the same hash, and the presenter reads it
  inflated. Metadata with an accent or a dash in it no longer stops the
  diagnostics page, and an audit record holding such metadata verifies as
  sound: both readers decoded the jsonb's characters as UTF-8 octets.

- **A unique violation is a conflict in any language.** The call layer had
  the SQLSTATE and let PostgreSQL's English sentence decide, so on a server
  whose `lc_messages` is not C -- the ordinary production role cannot set it
  -- a command id already in the command log raised an exception with no
  message and no class, and the member got a 500 instead of the replay.

- **A thread transfer counts the replies that are not deleted (migration
  1044).** The recount after a move, split, merge or undo counted every post
  but the first, so a thread holding a deleted reply listed one reply too
  many for good; every reply, delete and restore counts the live ones (ADR
  0119).

- **A new member's thread held for review keeps its tags (migration 1044).**
  The hold kept the post and the reply link, not the tags, and the approval
  published the thread without them. The held thread keeps them normalised,
  and the approval sets them on the thread it publishes (ADR 0134, ADR 0135).

- **A reply answers only a post every reader of the reply may read
  (migration 1044).** The reply names the post it answers, its position and
  its author, to all its readers; the link was written whatever the post's
  own visibility, so a crafted form could have a public reply name a post
  only its author may read. A members-only post is answered by a reply some
  level of which is not public, a private one only from a private reply by
  its own author (ADR 0102, ADR 0132).

- **Two tag writes on one thread cannot leave it ten tags (migration 1044).**
  The title saved from two tabs at once ran two tag writes that each deleted
  what the other had not yet committed; the write now takes the thread's
  row first.

- **A draft's autosave no longer blocks its member's other writes (migration
  1044).** It locked the member's row FOR UPDATE, which every foreign-key
  check on the member waits for -- their own reply, a bookmark, a sign-in's
  session; FOR NO KEY UPDATE still serialises the saves and waits for an
  erasure.

- **An export lists each post once, with the text it shows (migration
  1044).** It joined a post to every body it ever had, so a post edited
  twice was exported, and counted, three times.

- **A category cursor without its pinned part is no cursor.** It resumed
  from the top of the pinned block, a compatibility path for cursors from
  before nothing was released; it is now the first page, as any malformed
  cursor is.

- **A refused password change, email change or profile write goes back to
  the settings page, which says why and hands out fresh command ids.** A
  wrong current password, a new password short of the rules or an overlong
  bio answered a bare 400 page; backing out of it returned the member to the
  same form, whose command id had the refusal recorded under it, so the
  corrected submission got the refusal back for as long as the page stayed
  open. The page now says the refusal (`settings.refused`,
  `settings.refused_current_password`, `settings.refused_password`; an
  address already registered is told as the generic refusal, as the
  registration tells it) and every form on it comes with a new command id.
  The preferences form, whose values only a tampered form can get wrong, and
  clients that ask for JSON still get the 400.

- **An erased member's single-use tokens go with their credentials
  (migration 1039).** A password reset, an email change carrying the new
  address, a verification: their rows in `identity_tokens` stayed until they
  expired and the retention purge took them, so the address the erasure
  removed from the account lived on in them for up to a day.

- **A refused password change, email change or password reset no longer
  sticks to its command id.** Those commands leave the password out of the
  request they fingerprint (a password is no thing to fingerprint), so the
  command log, which records a refusal as the id's answer and replays it for
  the same request, gave the corrected form sent again under its command id
  the old refusal back for as long as the page stayed open. Those three
  commands now ask the log to abandon their refusals
  (`CommandIdempotency::run`, `refusal => 'abandon'`): nothing is recorded
  and the id runs again. Every other command keeps its refusals as answers.

- **A member stays signed in for the 30 days the session says.** The
  Mojolicious cookie was stamped an hour ahead on every response, so a
  member away for an hour was a visitor again, whatever the server's row
  and `session_expires_at_epoch` said. Sign-in gives the cookie the session's
  lifetime (`expiration`); the row and the epoch still end it 30 days after
  sign-in (ADR 0018).
- **Reporting another member's post from the thread page works.** Every post
  carries a report form for a signed-in reader, but a command id was minted
  for the reader's own posts alone, so the form under any other post sent an
  empty id and was refused (400, "command_id is required"). Each post's
  report form has an id of its own (t/744).
- **A handler claim another worker holds no longer acknowledges the outbox
  message.** A worker killed mid-handler left its claim; the next worker,
  60 s later, found the claim held, read that as "skipped", and the outbox
  marked the message done without the side effect -- notifications lost for
  good, since the claim could be taken over only after 900 s. A held claim
  is now a failure the outbox retries, and the claim's lease is 300 s, inside
  the retries. A handler's exception object is rethrown as raised, so a
  declared permanent failure is classified as one instead of retried four
  times.
- **A cursor of the wrong kind shows the first page instead of failing.**
  Every list accepted a position or a timestamp cursor; a timestamp given to
  a thread page reached PostgreSQL as a bigint and failed the request (500),
  as a position did on the category and home lists. Each list now says
  which kind it sorts by (`PageWindow` plans with `sort`), and the other
  kind is no cursor. Fullwidth digits are no longer read as numbers.
- **Downloading an attachment named outside ASCII works.** The name went into
  `Content-Disposition` as typed and writing the header died ("Wide
  character"), dropping every download of a file named in Chinese or with a
  typographic dash. The quoted name is ASCII and the real one follows as
  `filename*=UTF-8''...` (RFC 6266).
- **An export holds every part it counts.** Six named parts were stored and
  the others -- direct messages, drafts, held posts, relations, likes,
  reactions, poll ballots, second factor, avatar -- counted only, so an
  export said "5 direct messages" and held none. The likes and reactions
  the SQL collects are in the bundle and counted.
- **Smaller.** The Atom feed's `<updated>` is RFC 3339 (PostgreSQL's
  `+00` zone and space were sent as read); admin and moderation page sizes
  from the URL are bounded numbers (text was a 500, a huge size unbounded);
  an upload whose row cannot be written deletes its object instead of
  leaving it where no purge sees it; a dead letter no longer reads a column
  the outbox row does not have; the thread page's one-statement reads skip a
  part they do not know instead of dying on a restricted hash; a shared
  render's "copy post link" URL carries the page's own parameters, not the
  first visitor's query string.

- **Suspension ends are compared as instants in PostgreSQL and Perl.** A
  suspension ending later today could read as expired because the database
  timestamp and ISO-8601 clock were compared as text. Migration 1024 adds
  typed API versions, and the ORM fallback compares epoch seconds. Both
  paths now find an open-ended suspension behind more than ten newer
  expired ones. Tests exercise three database time zones and the exact end.
  Automatic expiry of the member status and concurrent moderation writes
  remain open in the ASVS record.
- **Revoking a suspension preserves the current account state.** Deleted
  and pending accounts are no longer reactivated by revocation or its retry;
  a member with another active suspension stays suspended. Suspending a
  deleted account is refused. Both SQL and ORM paths have regression tests.
- **Passwords accept Unicode through both HTTP and owner creation.** New
  hashes use exact UTF-8 octets and retain 128 random salt bits. The public
  salt marker preserves existing Latin-1 hashes without accepting a second
  encoding of a different password. Tests cover Cyrillic, CJK, emoji,
  spaces, old hashes and the CLI-to-HTTP boundary.
- **Second-factor replay tests use the code actually consumed.** Recomputing
  a code from the current clock could choose the next 30-second window and
  mistake its valid code for a replay. The tests now read the recorded
  consumed step; the production replay checks are unchanged.

- **Rows written in the same second list in the order they were written.**
  An id carried its time in whole seconds: the builtin `time` left the
  milliseconds of every UUIDv7 at zero, so the ids of one second sorted on
  their random bits. Most rows take `created_at` from the same clock, to
  the second, and the lists that order by `created_at`, then by id (the
  author's held posts, the review queue, notifications, mentions,
  bookmarks, reports, the audit log) showed two rows of one second either
  way round: a held post approved a moment ago was not always first. An id
  now carries the millisecond and 4,096ths of it (RFC 9562, method 3), and
  never sorts before the one the same process minted ahead of it.
  `t/integration/postgres-first-post-review.t` failed six runs in ten on
  this; it passes 40 in 40.
- **The mails with a link were English whatever the member reads.** A
  password reset and an email change confirmation were always sent in
  English, while the notices about the same account came in the member's
  language. The outbox now carries the member's `preferred_locale` with
  them, and `Identity::Mailer` takes the subject and words of every mail
  with a link from the catalogue (`auth.*_mail_subject`, `auth.*_mail_body`,
  in English and Italian), English when none is named. The verification
  mail of a new account is still English: `api.identity_register_command`
  writes its outbox row, with no locale.
- **A push to `main` could still end with no CI run.** `ci.yml` never
  cancelled a running job on `main`, but `main` was one concurrency group,
  and GitHub keeps one pending run per group: a push made while one run was
  going and another waiting cancelled the waiting one before it started
  (39810565640 and 39812192048 on 2026-10-22 had no job). Each commit on
  `main` now has a group of its own; `t/706` holds it.
- **The architecture documents say where the SQL is.** `ARCHITECTURE.md`
  (text and diagram), ADR 0051, ADR 0107 and the README still sent the
  services through DBIx::Class after ADR 0126 moved every statement into
  versioned `api` functions; they now name the call layer and what
  DBIx::Class is still loaded for (`t/722`).
- **The early ADRs say which of their capabilities are not built.**
  Fourteen accepted decisions between 0049 and 0097 prescribe object
  storage, WebAuthn, webhooks, API tokens, OpenAPI, tracing, an SBOM or
  federation as if they ran; each status now names those as a target, not
  current state (`t/722`).
- **The guides no longer state counts the next change makes wrong.** The
  README counted the SQL functions and named a search version, the
  deployment guide's setup transcript the migrations, and the quality
  program the modules and Perl files; they now say what holds without the
  number (`t/722`).
- **The quality program no longer grades itself.** Its 8.0 / 10 stood
  after a review of `main` on 2026-10-15 confirmed defects in work it
  counted as done; the table is dated 2026-10-04 and a dated note points
  at the review, the fixes and the backlog instead of a new score
  (`t/722`).
- **The operator actions say what the tree has.** Several still told an
  operator that the old `GPFORUM_ENV` names start until v0.3.0, to run
  `bin/gpforum-admin-bootstrap`, `script/antivirus-check` and
  `script/os-preflight`, to re-copy the removed unix-socket unit, to install
  MacPorts' ClamAV, and that production requires GlifiStore; they now name
  the `gpforum` verbs, Homebrew and an optional GlifiStore, and Unreleased
  opens with a summary (`t/722`).
- **A relative link in the documents cannot point at a file that is not
  there.** Every one in `docs/`, the README, ARCHITECTURE and the CHANGELOG
  resolves today, and `t/722` now fails on the first that does not.
- **A column added to a table no longer breaks the workers that call its
  functions.** 158 functions return a table's row, so a migration adding a
  column changed their result type under the running workers: a prepared
  call was refused with `cached plan must not change result type` once its
  plan was invalidated, and a call DBD::Pg had run once unnamed crashed the
  worker when it ran again. The call layer now prepares each call on the
  server at once, and on that refusal forgets the statement and, outside a
  transaction, calls once more; `t/641` refuses a table's row as the result
  of a function from migration 136 on. ADR 0126, amended.

- **A migration and the row that says it ran commit together.** The runner
  wrote a migration's `schema_versions` row after the file had committed
  itself, so a crash or a lost connection between the two left it applied
  and unrecorded, and the next deploy ran it again. The rows are now written
  inside the file's own transaction, a failure is rolled back before the
  migration lock is let go of, a new file of several transactions is
  refused, and a migration numbered below one already applied is refused by
  `migrate --apply` and `migrate --check` unless it is a listed gap (120).
  ADR 0126, amended.

- **The coverage gate can pass.** Its first run to finish failed outside
  `lib/`: `script/coverage` instrumented `t/performance/`, whose tests drive
  the harness of `script/lib/` in timed child processes that Devel::Cover
  slowed past their deadlines and whose JSON it mixed with its own output,
  and the upload action could not walk the thousands of files of
  `cover_db/`. Coverage now runs `t/` and `t/integration/` -- the report
  selects `lib/` only, and `t/performance/` runs uninstrumented in the lint
  job -- and uploads the database as one `cover_db.tar.gz`.

- **The list of held posts no longer names a place its author lost.** The
  author's `/pending` page and its JSON named the thread and category each
  held post went to by their current title, even once the thread was
  hidden, deleted or moved somewhere the author cannot read; and an
  approved thread had no link, since the decision records only the post.
  `moderation_review_mine_v3` (migration 127) reads each place as the
  author does and finds an approved thread through its post (ADR 0134).

- **One-click unsubscribes from a mail provider all land.** The
  unsubscribe link was rate limited per client address, and a mail
  provider posts every user's RFC 8058 one-click unsubscribe from a few
  addresses: past 60 in five minutes the rest were refused with 429, and
  mail clients do not retry. A signed link is now limited per account it
  names; a link that does not verify, per address (ADR 0078).

- **The personal feed leaves out members on the reader's ignore or block
  list.** The inbox, the mentions page and the digest left out an ignored
  or blocked member's replies and posts, but the feed still listed their
  threads and posts. `community_list_feed_v3` (migration 126) judges the
  list before `LIMIT`, so a page stays full; taking the member off the
  list brings the items back (ADR 0130).

- **Approving a held post no longer publishes it for a suspended author.**
  An approval wrote the post as its author's command, but the posting
  workflow trusts the controller's participation check, which an approval
  never passes: a member suspended after posting had the post published
  on a public thread. The review queue now refuses it, as ADR 0134 says,
  and the post stays pending until the suspension ends or staff reject it.

- **Performance evidence no longer lands on `main`.** The performance
  workflow committed its history to `main`: `benchmarks/` grew by
  megabytes a day and every publication raced the pushes of the code. The
  history (`benchmarks/results/`, `latest.json`, `history.json`) now lives
  on the `performance-data` branch, which the publisher appends to and
  never creates; the baseline is read from there, every run keeps its
  evidence as an artifact for 14 days, and `main` keeps only the harness,
  schema and documentation.

- **CI finishes on `main`.** `ci.yml` was one sequential job of about two
  hours with `cancel-in-progress` on every ref, so each push to `main`
  cancelled the run before it and `main` had no finished result; the
  integration tier ran twice and no `prove` ran in parallel. It is now four
  jobs that start together: lint, unit (`prove -l -j 4 t`), integration
  (`make integration JOBS=4`, once, over PostgreSQL's Unix socket) and
  coverage, which runs nightly and on demand. A run on `main` is never
  cancelled. `docs/CI.md` lists every check before and after, and
  `t/706-ci-finishes.t` holds the workflow to that list. The backup test
  that needed a database moved into `t/integration/`.

- **`GPFORUM_RUNTIME_REQUESTS` says what it counts.** It is Hypnotoad's
  `requests`, the keep-alive requests one connection serves before it is
  closed, and the settings page and the environment file template called
  it the requests a web process serves before it is replaced (that is
  Mojolicious's `accepts`, 10,000 connections, which Good Phorum leaves as it
  is).
- **The proxy closes an idle connection to the service before the service
  does.** nginx kept an idle upstream connection for its default 60
  seconds and Caddy for 2 minutes, and the service closes one after 5
  (`GPFORUM_RUNTIME_KEEP_ALIVE_TIMEOUT`): a request sent down a connection
  the service was closing failed, and nginx does not send a POST again.
  The shipped site sets `keepalive_timeout 4s` in its upstream and the
  Caddyfile `keepalive 4s` in its transport; print the site again with
  `gpforum service print nginx` (or `caddy`) to take it.
- **A burst of connections through the proxy no longer lands on one
  worker.** Since the listener moved to a Unix socket, connections the
  proxy opened at once were all queued when the first worker woke, and that
  worker, accepting until the queue was empty, kept them: with 32 busy
  connections and 96 new ones, one of four workers held 59 to 103 of the
  128, keep-alive connections the proxy then reuses, and the new ones'
  p95 on the category page was 531 ms. Each worker now takes one
  connection at a time from a Unix socket (`single_accept=1`, unless the
  location sets it): 32 to a worker, a p95 of 175 ms, and 10% more
  requests served in all. TCP listeners are unchanged
  (docs/PERFORMANCE.md, "Through the socket").
- **An email digest is mailed at most once (ADR 0078).** The outbox marks
  a handler done only after it returns, so when that mark failed after the
  mail server had accepted the digest, the retry mailed it again. The
  worker now claims the digest's record before it sends (migration 123):
  a second delivery of the event sends nothing, and a mail the server
  refused is given back for the retry. The digest's `Message-ID` is named
  by its event, the same on every attempt.

- **Every member with the email digest on gets it, and a missed day is
  caught up (ADR 0078, amendment of 2026-10-17).** Only the last period
  could be due, taken in member-id order under the run's `--limit`: a run
  missed lost that day for everyone, and on a forum with more members than
  the hourly runs reached, the members with high ids never got one. A
  digest now covers a window from where the member's last one ended,
  at most 14 days back, so what was missed arrives in the next mail, once;
  each run reads the due members in pages, the longest waiting first. A
  switch between daily and weekly no longer re-reports posts already sent,
  a post committed just after midnight is no longer lost at the period's
  end (a period is due ten minutes after it ends), a suspended member no
  longer gets a digest, and a thread the member muted no longer appears
  through a category they follow. Migration 122.

- **The email digest's HTML keeps its own styles.** The body's inline
  style quoted its font name ("Segoe UI") inside a double-quoted
  attribute, so the attribute ended at the quote and the rest of the
  declaration -- size, colours, measure -- spilled out as stray
  attributes. Every inline style is now escaped into its attribute.

- **Development runners start Good Phorum again on macOS and FreeBSD.**
  `script/gpforum-carton exec morbo bin/gpforum`, `perl t/02-health.t` and
  `prove --norc` stopped with
  "actual libev backend is 1": they load EV before Good Phorum can choose, and
  libev's own default there is select or poll. `script/gpforum-carton exec`
  now exports `LIBEV_FLAGS` and `MOJO_REACTOR` for the native backend before
  any Perl starts; a runner that bypasses it starts a development application
  on the existing loop with a logged warning, and production still refuses
  it, naming the variables and the entry points that set them (ADR 0097).
- **Over the socket, peer authentication refused both the runtime role and
  the operator's migrations.** With Debian's `local all all peer` and the
  host-less DSN, the web service (account `gpforum`) was refused as
  `gpforum_runtime` and the operator's `gpforum migrate` (their own login)
  as `gpforum`, and no map existed anywhere. `gpforum setup` prints, on a
  deployed host whose database is on the socket, one `pg_hba.conf` line for
  the forum's database and two roles through a `gpforum` map, and the
  `pg_ident.conf` lines -- the service's account as both roles, the
  operator as the owner -- with the reload. `gpforum doctor` checks the
  operator's connection too when the file names a role for it, says a peer
  refusal as one (which account, which role) in either language, and
  repeats the lines. A new CI workflow runs a Debian cluster as packaged,
  adds the lines and connects as both accounts (ADR 0137).

- **A PostgreSQL whose socket is in `/tmp` was out of the services' reach.**
  The systemd units run with `PrivateTmp=true`, a `/tmp` of their own, so a
  server built from source, which keeps its socket in `/tmp`, answered
  `gpforum doctor` and not the service. `gpforum service print systemd` now
  binds a socket directory under `/tmp` into the units read-only, and for
  `/tmp` itself writes `PrivateTmp=false` with how to give the server a
  directory of its own; doctor fails `database_socket` when the installed
  unit hides the socket it connected through (ADR 0137).

- **`gpforum doctor` and `gpforum setup` named a socket "unix:5432", and a
  missing socket a refused connection.** A DSN without `host=` reaches
  PostgreSQL through libpq's socket directory, and both now name the socket
  file as libpq does -- `/var/run/postgresql/.s.PGSQL.5432` on Debian, the
  one the connection used when doctor made one -- and a socket that is not
  there says so, with the start command and the `host=` to correct, in
  either language (ADR 0137). The doctor, staging-drill and stress-load
  guides no longer show TCP addresses for local connections.

- **The announcements no longer cost every page a statement.** Since
  migration 113 each page read the active announcements from PostgreSQL;
  they are now kept in the application cache for 30 seconds under their own
  `announcements` tag, which an announcement published or retired drops at
  once (the admin workflow purges it with the public cache, and the
  announcement events purge it in the shared cache). The query budget of
  every page is back to its measured value; the thread page's budget
  records the two statements the likes (migration 109) and the reply
  targets (114) take, one each however many posts.
- **The paired stored-function benchmark runs again.** It calls the
  retained search versions 2 and 4, which predate the scope argument of
  migration 110, through `GPForum::Infrastructure::SqlFunction::Retained`
  (the call layer that drops the values a retained version never took,
  moved from the test library), so `script/bench-sql-functions
  --paired-functions` and its integration test no longer fail on the
  argument count.
- **Doctor recognizes current OpenSSL handshake errors.** Plain HTTP on a
  TLS port may report packet length or record layer errors during connection;
  those are classified as handshake failures, while certificate errors and
  errors during an established TLS read retain the TLS classification.

- **Hypnotoad benchmark worker discovery works with FreeBSD `ps`.**
  Each PID, parent PID and command column uses a separate `-o` argument;
  FreeBSD interpreted the combined format as one PID column with a custom
  header, leaving the benchmark's worker list empty.

- **A start with old names in the environment file warns of each once.**
  `gpforum start --service` logged each, then ran Hypnotoad in its place,
  which built the application again and logged each a second time, 0.9 s
  later. It now tells Hypnotoad, in `GPFORUM_SETTINGS_WARNED`, that they
  were said.
- **`gpforum help secret` names this host's environment file**, Homebrew's
  on macOS, where it named `/etc/gpforum/gpforum.env` on every host.

- **Doctor's sizing line in development counts the one process** `gpforum
  start --foreground` runs: `1 web process (gpforum start --foreground)`,
  where it said 14 on a 10-CPU laptop. A server's line is as before.

- **macOS production is eight commands from the clone, in one document.**
  `sudo gpforum service print --to /Library/LaunchDaemons` makes the
  directory the launchd jobs log to, the `gpforum` account's, 0750, so the
  `install -d` step after it goes, and is printed only while the directory
  is not there. DEPLOYMENT's macOS section carries the packages line, with
  `nginx certbot`, the clone and `sudo /opt/gpforum/bin/gpforum setup`,
  where it typed `sudo gpforum setup` before anything had linked `gpforum`.

- **`gpforum service print --to` says what it needs.** Into a directory only
  root can write, such as `/Library/LaunchDaemons` without `sudo`, it
  answered with Perl's own `Error in tempfile() using template ...:
  Permission denied`; it now says `Only root can write into DIR: run it with
  sudo, as sudo gpforum service print TARGET --to DIR`. An nginx site
  refused before certbot is installed names the packages first, `brew
  install nginx certbot && sudo certbot certonly ...` (apt's or pkg's on
  Debian and FreeBSD), as the bare print does.

- **`gpforum upgrade`'s backup line makes its directory first.** It named
  `gpforum backup --to /var/backups/gpforum`, whose parent is root's on
  macOS and which a fresh Debian host lacks, and the backup, run as
  `gpforum`, could not make it. Until the directory is there, the line
  begins `sudo install -d -o gpforum -m 700 /var/backups/gpforum &&`, as
  `docs/ops/upgrade.md` now shows.

- **`make install-deps-*` names the next real step.** It ended with `Next:
  check the host, with script/system-preflight`, a step the quick start no
  longer has, after an upgrade's install too. A checkout not set up yet is
  told to run `bin/gpforum setup --environment development` (with `sudo`
  but on macOS), a production host `sudo …/bin/gpforum setup` as before,
  and a host whose environment file is in place, upgrading, nothing:
  `gpforum upgrade` printed the steps after it.

- **A drill no longer removes a directory it did not make.**
  `script/pitr-drill` and `script/standby-drill` delete their working
  directory when they end; `--dir` naming one that already held files took
  them with it. Such a directory is now refused before any cluster starts,
  and a relative `--dir` is read from where the drill was run.
- **Doctor's fix for a renamed variable keeps its value.** For
  `GPFORUM_SMTP_SSL=off` it said only to remove the line, which turned TLS
  back on; it now says to write `GPFORUM_SMTP_TLS=off`, then remove the old
  line. The web processes are counted once, in the sizing line.
- **Setup writes `GPFORUM_ENV=production` over `production-small` or
  `production-medium`** instead of asking for `--force`, and the settings
  page no longer lists the three retired settings.

- **A metrics rotation in a file named with `--env-file` needs no restart
  either.** `gpforum secret rotate metrics` drops the restart whenever the
  installed web unit names the file it rotated, not only for the host's own
  file: the units `gpforum --env-file FILE service print` writes hand FILE
  to `bin/gpforum`, which tells the service in `GPFORUM_ENV_FILE`. The
  launchd jobs did not: they sourced FILE and then `bin/gpforum` read the
  host's file over it. Each plist printed for another file now passes it
  with `--env-file`. Before the services are installed, the step `secret
  rotate` offers is `gpforum service print --to` the directory the host's
  service manager reads, as setup offers it, not a bare `service print`.

- `gpforum setup` kept `GPFORUM_MAIL_FROM` at the old domain when the
  address changed. A sender it derived (`forum@` the address's host) now
  follows the new address, and setup says so (`GPFORUM_MAIL_FROM:
  forum@new.example, following the address (it was forum@old.example)`); one
  the operator wrote stays.

- `docs/ops/upgrade.md` said `gpforum migrate` names the restart. With
  nothing to apply, it names none. The upgrade's second line now ends with
  the restart whatever the migration finds, in the document and in `gpforum
  upgrade`.

- The README's Debian and Ubuntu package line installs `libssl-dev`, which
  Net::SSLeay (under IO::Socket::SSL) builds against: without it
  `make install-deps-postgres` stopped at Net::SSLeay.

- **`gpforum setup` finds a PostgreSQL superuser that takes a password, and
  says why none answered.** After libpq's own login (and, under sudo, the
  operator's) it tries the user and password the data source names, then
  `GPFORUM_DATABASE_USER` and `GPFORUM_DATABASE_PASSWORD` from the shell it
  was started in, and says the way in on the line of what it made: `made,
  with its role gpforum, as PostgreSQL's superuser postgres, from
  GPFORUM_DATABASE_USER`. When none answers it says what libpq told each
  login (`operator: role "operator" does not exist`), offers
  `PGUSER=postgres gpforum setup`, and, for a role that logged in only to
  find its database missing, prints the `CREATE DATABASE` alone, with no
  `CREATE ROLE` that would fail with "already exists" and no new password
  that would lock the role out.

- The hourly `gpforum-scheduled-jobs` timer failed on every run, so expired
  sessions, tokens and old outbox rows were never purged; the first run after
  upgrading may remove a backlog. Every `bin/gpforum-*` command runs on its
  own instead of dying on `Const::Fast`, and `bin/gpforum` run by an older
  Perl finds the supported one or says what to install. The FreeBSD rc scripts
  and launchd plists no longer override the file's `GPFORUM_ENV`. A
  `GPFORUM_MAIL_FROM` with a display name is checked like a bare address.

- **An id that is not a uuid is a 404 on the public pages too, not a 500.**
  `/t/anything`, `/c/anything`, `/attachments/anything/download` and the
  post actions under `/p/anything` bound the word for a uuid column,
  PostgreSQL refused the statement, and the page answered 500 (the writes
  503) and wrote the error log; anyone could fill it from a URL. A value
  PostgreSQL would not read as a uuid now sends no statement and finds
  nothing (`GPForum::Infrastructure::PreparedQuery`, through which the
  thread, category, post, attachment, read state, bookmark, subscription
  and session lookups now run). A uuid written as PostgreSQL also reads one
  (upper case, braces, no hyphens) still reaches the row and its 301.
- **The search endpoint's query budget is 3, not 2.** A signed-in search
  runs the session validation, the viewer's grants and the search itself;
  the budget was set when only the search was counted, and every signed-in
  search was marked over budget. Raised on that evidence
  (`t/integration/postgres-query-budget.t` had already measured 3).

- An outbox batch slower than its claim was delivered twice. A worker
  claims up to 100 messages for 60 seconds, and it acknowledged the ones it
  delivered together at the end of the batch, without ever renewing the
  claim: once a batch outlived it, another worker claimed the rest, the
  messages the first had not reached yet were delivered by both, and those it
  had delivered but not acknowledged were delivered again. Each message's
  claim is now renewed just before its dispatch and the message acknowledged
  right after it, both only while it is still the worker's own; a message
  another worker took is skipped, counted as `lost` in the batch's summary
  and logged as a warning. That costs two single-row commits a message: on
  the development laptop one worker with a transport that does nothing
  dispatches about 2,150 messages a second, where it managed 9,000 to 11,600
  (`docs/PERFORMANCE.md`).

- A worker whose outbox claim another worker had taken could still
  dead-letter the message, even one the other worker had delivered: its
  failure was written on a condition on `locked_by` whose outcome nobody
  read, and the dead letter written regardless. The failure is now written
  only while the message is still the worker's, and the dead letter only when
  that write updated the row, in the same transaction; a worker that lost the
  message records neither and logs it.

- Hypnotoad could not load the application: `hypnotoad bin/gpforum`, what
  every service unit runs, stopped with `File "bin/gpforum" did not return
  an application object.`, because the file ended in `1;` after starting the
  application. It now ends with the application, and a test loads it as
  Hypnotoad does.

- An attachment larger than 16 MiB never reached the application whole:
  Mojolicious cuts a request off at 16 MiB unless told otherwise, so even
  with nginx raised to 26 MiB an upload between 16 and 25 MiB failed
  without a reason. The application now takes requests up to 26 MiB, the
  25 MiB attachment limit and the form around it, as nginx does.

- `gpforum-admin-bootstrap` reported a database it could not reach as
  misuse: exit 2 and the usage text after the error. It now says it in the
  same one sentence as every other command, and exits 1.

- The guides now say that nginx's user must be in the `gpforum` group to
  send attachments by X-Accel-Redirect or to reach the UNIX socket, both under
  directories of mode 0750; without it every download answered 403.
  `docs/PRODUCTION_READINESS.md` and `docs/ops/staging-host.md` install with
  `make install-deps-production` and start the outbox worker as
  `docs/DEPLOYMENT.md` does, `docs/PERFORMANCE.md` no longer says that
  `--strict` fails a degraded host or that GlifiStore is required.

- `/metrics/`, which the application answers as `/metrics`, reached it from
  anywhere: nginx kept only the exact `/metrics` to the loopback, and the
  Caddyfile named only that path. Both now hold either spelling.

- On a host installed as the guide says, with the code tree root's, every
  start and every command run as `gpforum` printed `mkdir:
  /opt/gpforum/local/.perl-shim: Permission denied`: `script/gpforum-carton`
  made its interpreter shim on first use, as whoever ran it.
  `script/bootstrap-deps` (`make install-deps-production`) now makes it
  while installing; on a host installed before, run that once more.

- Upgrading the code without re-copying the systemd units stopped the
  forum: Hypnotoad wrote its pid file to `/run/gpforum` while the installed
  unit waited for `/opt/gpforum/hypnotoad.pid`, and systemd failed the start.
  Under a unit that still sets `MOJO_MODE`, which only the old ones do, the
  pid file stays where that unit looks, and the start says to copy the units.

- A command whose failure was a refused connection or a missing socket of
  something else -- clamd, the SMTP server, GlifiStore -- was told to start
  PostgreSQL, and its own reason was not printed. Only a failure DBI, DBD::Pg
  or libpq reported is now read as the database's.

- nginx refused uploads between 20 and 25 MiB with its own 413 page:
  `client_max_body_size` was 20m against Good Phorum's 25 MiB limit. It is 26m.

- The Caddyfile served `/metrics` to anyone with the token; like the nginx
  configurations it now answers the loopback only.

- The shipped units start on a host with one CPU. The preflight compared the
  configured `GPFORUM_WEB_PROCESSES` (4) with what one CPU carries (2) and
  called the host oversubscribed, although under the default `cap-to-cpu`
  policy Hypnotoad is given 2; it now checks the number Hypnotoad is given.

- `ThreadStore` checks a thread title edit, move, delete or restore against
  the same rule as the posting workflow, authorship included, under the
  thread's row lock: a direct store call could change another member's
  thread, or restore one someone else deleted.

- An erasure that cannot read the member's credentials or sessions fails and
  rolls back, and can be run again. Before, it could finish with the account
  anonymized and the credentials revoked while the member's sessions stayed
  signed in.

- A log file that cannot be opened stops startup with an `X::Config` error
  that carries the original reason. The check meant to report it never ran:
  Mojo::Log died first with its own message.

- `Antivirus->from_config` returns undef for `none`, as documented, instead
  of an empty list.

- A hypnotoad-benchmark child that cannot exec (a missing reverse-proxy
  binary, say) exits 127 with the reason in its log, instead of running on in
  a copy of the parent: that copy stopped the hypnotoad being measured and
  printed a second report.

- Admin category routes answer 404, not 503, for a space id in the body or a
  category id in the path that is not a uuid. A slug a soft-deleted category
  still holds, or an edit onto another category's slug, answers 400 naming
  the slug instead of 503: the store reports the slug taken (read from
  PostgreSQL's own message, so an over-long slug is not mistaken for one) and
  `Admin::Workflow` maps a store's errors to `invalid`.

- A retried notification delivery no longer writes a second `notifications`
  row when its leftover row is stored at another time or in another month's
  partition: the stored row is reused and the inbox row takes its time. Two
  workers recording the same caller-supplied event id at different times no
  longer both store it, and a caller-supplied audit id already stored at
  another time is written under a new id (ADR 0116).

- Every server profile ignores `GPFORUM_QUERY_BUDGET_ENFORCE=1` (production,
  production-small, production-medium, staging); before, only
  `GPFORUM_ENV=production` did, and elsewhere a breached budget left the
  response unsent. `/metrics` counts pending and failed outbox messages in one
  grouped statement instead of the same statement twice, and has its own
  budget, `metrics` (5 statements): run `script/query-budget --sync` after
  deploying. `--sync` deletes the rows of endpoints the catalog no longer has
  and names them (`removed` in `--json`).

- The privacy dashboard no longer fails for a member with an export request,
  and the export download returns the bundle as a JSON object instead of one
  JSON string: the jsonb manifest is read decoded. An erasure job that runs
  after its retention hold ends no longer keeps
  `last_error = 'retention hold active'`.

- Password credentials take `created_at` from the application clock that also
  sets `revoked_at`: an application clock behind the database clock no longer
  breaks `credentials_revoked_after_created_check` on the next password change.
  A new credential id that collides with one of the member's own revoked or
  other-type credentials (a second factor) is minted again instead of being
  returned as the active password, which left the member with none. A
  registration that reuses a leftover account writes its credential, event and
  audit in one transaction (ADR 0110).

- The hourly `attachments` job removes an orphan upload's file and its
  thumbnails from `attachment_root`, not only its row; until now those files
  stayed in storage indefinitely. The timer must see the same
  `GPFORUM_ATTACHMENT_ROOT` and working directory as the application, as the
  shipped units do.
- An abandoned upload must be at least a day old before the job removes it,
  so an upload still in progress is never touched, and `--limit` counts real
  orphans only: linked uploads at the front of the queue no longer stop the
  job reaching the orphans behind them.
- An orphan the job cannot purge (its file cannot be removed, or its row stays
  locked past `GPFORUM_DATABASE_LOCK_TIMEOUT_MS`) ends the run with
  `ok=0 attachments_errors=N` and exit 1; the other orphans are still purged
  and the failed one is retried on the next run.
- Repeating a delete, or a thumbnail request for a thumbnail that already
  exists, returns the attachment again instead of an empty one.

- A database error that is not a unique violation is no longer taken for a
  conflict because the row's data contains the words "unique constraint":
  `UniqueConflict->is_conflict_on` now requires PostgreSQL's own message to
  report the violation as well as to name the index.

- A raced notification, event or audit id is reused or minted again on
  PostgreSQL. Those tables are partitioned, and PostgreSQL names the
  partition's index in a unique violation (`notifications_default_pkey`), not
  the table's constraint, so every collision was rethrown as a 500.
  `UniqueConflict->is_conflict_on` reads the partitions' indexes from
  `pg_inherits`, matches whole identifiers, and looks only at the server's own
  sentence, not the row data DBI appends.

- **The evidence commands exit 1, not 2 or 255, when their check raises an
  error**, with a redacted reason and a JSON `status: fail` document;
  `partition-maintenance` against an unreachable database says why;
  `stress-load` without a base URL is misuse (exit 2).

- **A malformed id in a moderation or privacy URL answers 404**, word for
  word the unknown-id answer, without a query (it was a 500, or 503 for a
  suspension revoke); filters with a malformed id show an empty page. A
  signed-out privacy request and a forbidden staff privacy action get their
  401 or 403 instead of a dropped connection.

- **"Purge page cache" says when GlifiStore was not reached**: a warning,
  styled as one, names the tags left in the shared cache (they expire within
  their TTL); the JSON answer says `cache_purged_locally`, and a resubmitted
  form replays the same warning.

- **The application and `gpforum-migrate --apply` connect as an ordinary
  role.** Every connection ran `SET lc_messages`, which PostgreSQL allows only
  to a superuser, so a production role that owns its database could neither
  migrate nor serve. The locale is now asked for and, when refused, left at
  the server's setting; unique-conflict recovery reads the SQLSTATE first.

- **A moderator can hide and unhide a thread on PostgreSQL**: the action
  wrote a `hidden_at` column the threads table does not have and died, so
  the hide-thread route never worked. Locking now means `locked_at`, so a
  thread whose state said locked without a lock time is really locked.
- **An author's thread delete, restore or move no longer goes through after
  a moderator locked or hid the thread** between the check and the write:
  under the row lock it is refused (403 or 404), recorded against the
  command id.

- **Readiness no longer reports a shared cache that is not there**: with an
  in-process L2 the tiered cache's ping answers 0, so readiness says
  `local-fallback`. A realtime listener's dying snapshot no longer overwrites
  the error that made it degraded.

- **`scheduled-jobs` printed `attachments=ARRAY(0x...)`** and its `--json`
  carried each deleted attachment's row; it now counts them. `migrate
  --plan` outside the app root, a malformed setting in `os-preflight`, and a
  `partition-maintenance` failure without `--json` now exit 1 with a redacted
  reason instead of 2, 255 or a stack location.

- **A refused byte range (416) or a proxy error page is no longer cached for
  a year** under a fingerprinted asset URL: the app sends a lifetime only on
  200, 206 and 304, nginx no longer adds it to its own error pages, and Caddy
  serves only existing files from `/assets/`.

- **Search time filters keep their meaning**: a time with no offset is read
  in the database session's zone, as a day is, not as UTC; fractional seconds
  are kept to the microsecond instead of rounding into the next second.

- **Malformed search filters are ignored instead of reaching PostgreSQL**
  (a non-uuid id, a date that is not a day or an RFC 3339 time, an offset
  beyond +/-23:59); `to=<day>` includes the whole day. Each step of a console
  search rebuild records its next step and outbox message in one transaction.
  `GPFORUM_SEARCH_STATEMENT_TIMEOUT_MS` is lowered to the global statement
  timeout when that is lower.

- **A mark-read whose badge count or NOTIFY fails after the write committed
  gets the write's own answer**, not a 500; inside a post's transaction a
  failed count no longer rolls the post back (`badge_failures`).
- **A realtime listener whose snapshot dies no longer takes down /metrics or
  the console**; `/metrics` shows `realtime_listener.status` and
  `last_error`, and each outage is logged once.
- **An unknown notification channel is refused** (400, nothing saved)
  instead of silently turning off a member's in-app notifications;
  `enabled_channels` applies the defaults.

- **"Purge page cache" no longer reports "purged" when GlifiStore was paused
  or failing**: the result and its audit row record `purged_locally` and the
  tags not reached (they expire within their TTL).
- **Junk URLs no longer create public-cache entries**: a wrong thread slug or
  an id written another way gets a 301 to the real URL, a trailing slash or a
  percent-escaped character shares the page's entry, and `?limit=` is keyed
  by the page size shown. The tiered cache's readiness ping no longer dies on
  an in-process L2; `SharedCache->try_connect`, unused, is removed.

- **Two administrators revoking the same role binding at once no longer both
  succeed**: the second waits on the row lock and reports the binding as
  already revoked, with one revocation time and one audit entry. Re-attaching
  a permission whose audit entry was lost writes it even when another
  permission of the role was audited, and an id spelled in upper case, in
  braces or without hyphens no longer writes a duplicate entry.

- **A post edit, delete or restore, or a thread title edit, no longer lands
  after a moderator locked the thread or hid the post** (or its author
  deleted it) between the check and the write. Under the row locks the store
  checks again and answers as the first check would: 403 `thread is locked`
  or `post is hidden`, 404 `post not found` or `thread not found`, recorded
  against the command id so a retry gets the same answer. An edit waits for
  moderation on its thread, never for replies in flight.
- **A reply waiting while the thread's author deleted the thread is
  refused** with 404 instead of landing in the deleted thread (with its
  mentions and feed items). The author can still reply to their own deleted
  thread.

- **A cached public page with a character past Latin-1 failed with a 500**
  (a dash, a curly quote or an emoji in a thread title: "Wide character"),
  and accented text on cached pages reached the browser as Latin-1 under a
  UTF-8 header, so Italian visitors saw broken letters. The page cache now
  keeps, hashes and sends UTF-8 bytes; entries cached by the previous
  release are rebuilt on their next request.

- **A moderation purge that landed while a page was being computed could
  leave the hidden content in GlifiStore until its TTL.** Tag tokens are now
  read before the computation (a ticket), so a purge in between retires the
  entry. A public page miss costs a few more token reads, and the first page
  rendered under a tag with no token stays in the process-local cache only.
- **`/categories` and `/categories?limit=25` shared one cached page**, so an
  anonymous visitor could get a truncated category index. Each limit has its
  own entry.

- **SMTP relays that require a login work.** `Net::SMTP` answers `AUTH`
  only through `Authen::SASL`, which it loads at run time and which nothing
  declared, so every installation with `GPFORUM_SMTP_USERNAME` failed at the
  first message: sign-up confirmations, password resets and e-mail changes.
  `Authen::SASL` 2.2100 is now in the lock; `t/215-smtp-authentication.t`
  sends through a local relay that asks for `AUTH PLAIN`, and fails without
  it. Run `make install-deps-postgres` (or `script/bootstrap-deps --postgres`).

- **A process that registered for cache invalidations while the database
  was unreachable, or inside a transaction, now clears its local cache once
  its LISTEN takes effect**; before, a hidden post it had cached meanwhile
  could stay visible until the TTL.
- **A realtime broadcast whose readability check fails no longer aborts the
  listener's poll**: the other notifications are still delivered, badges are
  still re-sent after a reconnect, and the failed event is left to the outbox
  backstop (`broadcast_failures`). Badges no longer crowd thread hints out of
  the listener's duplicate filter, which made the backstop send hints twice.
- **The query-plan gate checks search with the configured
  `GPFORUM_SEARCH_CANDIDATE_LIMIT`**, not the default 1000: evidence for a
  forum with another cap described a query it never sends.

- **Realtime and cache invalidation no longer steal each other's
  notifications** (ADR 0111). The cache bus and the realtime listener read
  the same connection's notification queue and took each other's messages: a
  purge the listener took left hidden content in that process's cache. One
  queue per process now routes by channel. After a reconnect -- including to
  a backend that reuses the old PID, as a PostgreSQL restarted in a fresh
  container does -- it listens again, clears the local cache once and
  re-sends badge counts. A process that cannot listen (pointed at a standby)
  clears its local cache on every read until it can (`listen_failures` in
  `/metrics`).
- **Every badge reaches every process and node**, through `NOTIFY`; a badge
  changed by a web request used to reach only that process's sockets.
- **The realtime backstop no longer replays seven days of events** after a
  deploy or worker recycle: it starts at the head, polls only while the
  process has sockets, and a failed poll keeps its place.
- **A thread restored after being hidden no longer drops out of search**
  when two dispatchers deliver the hide after the restore: each removal step
  reads the thread again under its locks.

- **`script/gpforum-carton exec prove -v` printed Carton's version** once
  Carton was installed: real `carton exec` took the command's options as its
  own, so `make integration` ran nothing. The wrapper now runs `exec` itself
  in every case, pinned to the validated Perl.
- **`script/system-preflight` reported perlcritic missing** on hosts where it
  lives in `local/`, as the gates run it. It now asks the wrapper.

- **A reply no longer lands in a thread locked or hidden while it was being
  sent.** The reply re-reads the thread under its lock and is refused with
  403 "thread is locked" or 404 "thread not found"; a retry with the same
  command id gets the same answer (ADR 0111).
- **A moderation purge could miss a cached page, and bring back a hidden
  post.** Two cache writes at the same moment lost each other's entry in a
  tag's member list in GlifiStore. A tag is now a token: invalidating it is
  one ERASE, and an entry whose token changed is a miss. Pages cached before
  the upgrade miss once.
- **Erasing a GlifiStore key that was already absent counted as a failure**
  and dropped the connection, on almost every post. It is now a success;
  only transport, availability and protocol errors drop the connection.
- **A page copied from GlifiStore into a process's own cache outlived the
  GlifiStore entry** by up to one TTL. It now expires with it. The anonymous
  category list is now shared through GlifiStore, and a page miss looks the
  page up once, not twice.

- **An English visitor could be served the Italian page.** The public page
  cache's key named neither the language nor the theme: the first visitor's
  page was served to everyone after. The key now holds both, and `Vary`
  names `Accept-Language`.
- **`?after=` with any junk failed the page on PostgreSQL** (an invalid
  timestamp). A cursor is now checked before it reaches SQL; a bad one shows
  the first page.

- **"Latest activity" is latest activity.** `threads.last_activity_at` had
  no writer, so the home page, category pages, sitemap and feed ordered
  threads by creation. A reply now moves its thread up, from the outbox
  (`ThreadActivity`, off the reply's transaction so a busy thread's row is
  not locked per reply); migration 046 sets existing threads to their
  latest visible post.
- **Autocomplete suggests each thread once.** A post's search document
  carries its thread's title, so a thread with many replies filled every
  suggestion with the same title.
- **Search rebuild review fixes.** A rebuild racing the live search handler
  could write an older document over a newer one -- a hidden post
  searchable again; documents are now written under a lock each. `--entity
  thread` left a dead thread's posts searchable. The prune decided from one
  snapshot and could drop a post restored meanwhile. `--status` printed its
  timestamp with a space, breaking its `key=value` line.
- **`bin/gpforum` exits with its command's status.** Mojolicious ignores a
  command's return value, so misuse through the front door exited 0.
- **Fix: search missed a thread's replies after a rename or a move.** A
  post's search document carries its thread's title and category; renaming or
  moving the thread now reindexes its posts.
- **Fix: notifications to some subscribers could be dropped for good.** When
  a reply's fanout failed for a recipient, the event was still marked done;
  the handler now fails so the outbox retries, and recipients already served
  are duplicates by the inbox's primary key.
- **Fix: a retried session handed the cookie a token that was never stored**
  (after a session-hash collision), so the new login was invalid at once.
- **Fix: re-recording a stored event failed on PostgreSQL.** The reuse path
  read the stored row as a hash, which only the test doubles return, and
  built its outbox row with no event.
- **Fix: marking a notification read with a malformed id answered 503**; it
  is now 404.
- **Fix: page defects.** Thread and category breadcrumbs now end at the page
  (the thread's names its category); the search page states its help once;
  metadata lines no longer uppercase usernames.
- **Fix: data export failed for members with more than 65,535 posts.** Post
  bodies were read with one bound parameter per post, past libpq's limit;
  they are now read in batches of 10,000.
- **Fix: a failed command could commit half its work and never be retried.**
  Every workflow that runs under a command id caught its store's exception
  inside the command's transaction and returned "failed", which then
  committed whatever had been written before the failure and stored
  "failed" as the command id's answer: retrying the same form could never
  succeed. `CommandIdempotency` now rolls a failed command back and keeps
  no answer for it.
- **Fix: `SAVEPOINT savepoint_0` lines in the production log.** The query
  statistics object inherited DBIx::Class's printer, and with statistics on
  for the whole process every nested transaction wrote two lines to STDERR.
- **Fix: a failed admin write could half-commit.** The admin workflow
  caught a store's exception inside the command's transaction, which then
  committed what the store had written before failing and stored "failed"
  as the command id's answer, so a retry with the same form could not
  succeed. The exception now rolls the transaction back.
- **Fix: `/readyz` died whenever uploads were not scanned.** With
  `GPFORUM_ANTIVIRUS=none` -- the default outside staging and production --
  the readiness helper was built with an empty list in place of the
  antivirus, which shifted every later argument out of place, and each
  probe failed with "Can't locate object method". Introduced with ADR 0108;
  `t/69` now pins the wiring.
- **Fix: attachment uploads failed on PostgreSQL.** Recording an upload's
  verdict wrote the scanner's name (`local-sniffer`) into `event_log.actor_id`,
  a `uuid` column, and PostgreSQL rejected the insert, so every upload failed.
  The unit doubles accept any string and no integration test had uploaded a
  file. A verdict is now a system action with a NULL actor and a `scanned_by`
  payload field, and `t/integration/postgres-attachment-scan.t` uploads end to
  end against PostgreSQL.
- Add `t/integration/postgres-idempotency.t`: skippable-unless-DSN two-connection
  evidence for concurrent `event_idempotency_keys` `mark_done` and reputation
  source unique races inside open `txn_do`. `EventIdempotencyStore` and
  `ReputationLedger` wrap inserts with `UniqueConflict->attempt` so unique
  violations do not abort the outer transaction on live PostgreSQL.
- Add `t/integration/postgres-concurrency.t`: skippable-unless-DSN evidence
  with two real PostgreSQL connections for command_log, bookmark,
  subscription, open report, moderation hide, audit chain, privacy approval,
  and identity token consume races; wire it into CI beside `postgres.t`.
  `UniqueConflict->attempt` wraps inserts in a savepoint so unique races can
  replay inside an open `txn_do` on real PostgreSQL. Command-log payload
  updates read inflated JSON accessors, and identity token/session expiry
  compares parsed epochs so PostgreSQL timestamptz text does not false-expire.
- Plugin install remints `plugin_id` once when the unique primary key
  conflicts, and does not return another plugin.
- Plugin install reuses a leftover `plugin_id` with this name and version
  and registers missing hooks.
- Plugin hook writes remint `hook_id` once when the unique primary key
  conflicts, and do not return another hook.
- Plugin hook writes reuse a leftover `hook_id` with this plugin and hook
  name and register missing hooks.
- Import job create remints `import_job_id` once when the unique primary
  key conflicts, and does not return another job.
- Import failure recording remints `import_failure_id` once when the unique
  primary key conflicts, and does not return another failure.
- Import failure recording reuses a leftover `import_failure_id` with this
  source and does not insert a second review row.
- Role catalog writes remint `role_id` once when the unique primary key
  conflicts, and do not return another role.
- Role catalog writes reuse a leftover `role_id` with this name and insert
  the missing audit.
- Permission catalog writes remint `permission_id` once when the unique
  primary key conflicts, and do not return another permission.
- Permission catalog writes reuse a leftover `permission_id` with this
  resource and action and insert the missing audit.
- Role catalog attach writes reuse a leftover grant with this role and
  permission and insert the missing audit.
- Role binding writes remint `binding_id` once when the unique primary key
  conflicts, and do not return another binding.
- Role binding writes reuse a leftover `binding_id` with this active
  binding and insert the missing audit.
- Legacy id mapping remints `legacy_id_map_id` once when the unique primary
  key conflicts, and does not return another mapping.
- Legacy id mapping reuses a leftover `legacy_id_map_id` with this source
  and keeps the original native id.
- Category create remints `category_id` once when the unique primary key
  conflicts, and does not return another category.
- Category create reuses a leftover `category_id` with this space and slug
  and inserts the missing event.
- Default-space create remints `space_id` once when the unique primary key
  conflicts, and does not return another space.
- Default-space create reuses a leftover `space_id` with this slug and
  does not insert a second space.
- Mention recording remints `mention_id` once when the unique primary key
  conflicts, and does not return another mention.
- Mention recording reuses a leftover `mention_id` with this source and
  mentioned user and inserts the missing notification.
- Bookmark save remints `bookmark_id` once when the unique primary key
  conflicts, and does not return another bookmark.
- Bookmark save reuses a leftover `bookmark_id` with this user and target
  and does not insert a second bookmark.
- Subscription save remints `subscription_id` once when the unique primary
  key conflicts, and does not return another subscription.
- Subscription save reuses a leftover `subscription_id` with this user and
  target and does not insert a second subscription.
- Report create remints `report_id` once when the unique primary key
  conflicts, and does not return another report.
- Report create reuses a leftover `report_id` with this reporter and
  target and inserts the missing event.
- Moderation action writes remint `moderation_action_id` once when the
  unique primary key conflicts, and do not return another action.
- Moderation action writes reuse a leftover `moderation_action_id` with
  this command_id and insert the missing event.
- Retention hold create remints `retention_hold_id` once when the unique
  primary key conflicts, and does not return another hold.
- Retention hold create reuses a leftover `retention_hold_id` with this
  resource and inserts the missing event.
- Deletion request create remints `deletion_request_id` once when the
  unique primary key conflicts, and does not return another request.
- Deletion request create reuses a leftover `deletion_request_id` with
  this open resource and inserts the missing event.
- Erasure job create remints `erasure_job_id` once when the unique primary
  key conflicts, and does not return another job.
- Erasure job create reuses a leftover `erasure_job_id` with this request
  and inserts the missing approval action.
- Export request create remints `export_request_id` once when the unique
  primary key conflicts, and does not return another request.
- Export request create reuses a leftover `export_request_id` with this
  pending unique and inserts the missing event.
- Deletion action writes remint `deletion_action_id` once when the unique
  primary key conflicts, and do not return another action.
- Dead-letter recording remints `dead_letter_id` once when the unique
  primary key conflicts, and does not return another review row.
- Dead-letter recording reuses a leftover `dead_letter_id` with this
  source and does not insert a second review row.
- Reputation ledger writes remint `reputation_event_id` once when the unique
  primary key conflicts, and do not return another event.
- Reputation ledger writes reuse a leftover `reputation_event_id` with this
  source and insert the missing snapshot.
- Projection generation start remints `generation_id` once when the unique
  primary key conflicts, and does not return another generation.
- Projection generation start reuses a leftover `generation_id` with this
  source and does not insert a second generation.
- Thread create remints the opening `post_id` once when the unique primary
  key is occupied by another post, and does not return that post.
- Thread create reuses a leftover opening `post_id` with this thread and
  inserts the missing body.
- Thread create remints the opening `body_id` once when the unique primary
  key is occupied by another body, and does not reuse that body.
- Thread create reuses a leftover opening `body_id` with this post and
  inserts the missing revision.
- Thread create remints the opening `revision_id` once when the unique
  primary key is occupied by another revision, and does not reuse that
  revision.
- Thread create reuses a leftover opening `revision_id` with this post and
  inserts the missing counter.
- Thread create reuses this thread's leftover opening counter when the unique
  primary key conflicts, and does not abort the create.
- Reply create remints `post_id` once when the unique primary key is occupied
  by another post, and does not return that post.
- Reply create reuses a leftover `post_id` with this thread and inserts the
  missing body.
- Reply create remints `body_id` once when the unique primary key is occupied
  by another body, and does not reuse that body.
- Reply create reuses a leftover `body_id` with this post and inserts the
  missing revision.
- Reply create remints `revision_id` once when the unique primary key is
  occupied by another revision, and does not reuse that revision.
- Reply create reuses a leftover `revision_id` with this post and inserts
  the missing counter shard.
- Post edit remints `body_id` once when the unique primary key is occupied
  by another body, and does not reuse that body.
- Post edit reuses a leftover `body_id` with this post and inserts the
  missing revision.
- Post edit remints `revision_id` once when the unique primary key is occupied
  by another revision, and does not reuse that revision.
- Post edit reuses a leftover `revision_id` with this post and applies
  missing pointers.
- Thread create remints `thread_id` once when the unique primary key is
  occupied by another thread, and does not return that thread.
- Thread create reuses a leftover `thread_id` with this category and slug
  and inserts the missing opening post.
- Event recorder remints `audit_id` once when the unique primary key
  conflicts, and does not return another audit record.
- Event recorder remints `event_id` once when the unique primary key is
  occupied by another event, and does not return that event.
- Event recorder reuses this write's leftover `event_id` row when the unique
  primary key conflicts, inserts the missing outbox, and does not insert a
  second event.
- Notification delivery reuses a leftover `notifications_pkey` row and
  inserts the missing inbox, and does not remint `notification_id`.
- Read-state writes reuse a leftover `thread_read_state_pkey` row and
  insert the missing delta.
- Event recorder remints `outbox_id` once when the unique primary key
  conflicts, and does not return another event's handoff.
- Event recorder reuses a leftover `outbox_id` with this handoff key and
  does not insert a second outbox row.
- Suspension create remints `suspension_id` once when the unique primary
  key conflicts, and does not return another suspension.
- Plugin failure recording remints `plugin_failure_id` once when the unique
  primary key conflicts, and does not return another failure.
- Event recorder reuses outbox rows by unique `idempotency_key`, and does
  not treat another event's `event_id` as the same handoff.
- Attachment variant writes remint `attachment_variant_id` once when the
  unique primary key conflicts, and do not return another variant.
- Attachment variant writes reuse a leftover `attachment_variant_id` with
  this type and object key and do not insert a second variant.
- Attachment link writes remint `attachment_link_id` once when the unique
  primary key conflicts, and do not return another link.
- Attachment link writes reuse a leftover `attachment_link_id` with this
  target and do not insert a second link.
- Attachment intent writes remint `attachment_id` and `object_key` once
  when the unique primary key conflicts with a different object, and do
  not return another attachment.
- Attachment intent writes reuse a leftover `attachment_id` with this
  object key and insert the missing event.
- Registration reuses a leftover user `id` with this username and email
  and inserts the missing credential.
- Password credential writes reuse a leftover credential `id` with this
  user and do not insert a second active secret.
- Command log writes remint `command_id` once when the unique primary key
  conflicts, and do not replay another command.
- Command log writes reuse a leftover `command_id` with this idempotency
  key and finish the command.
- Identity token writes reuse a leftover `token_id` with this user and
  hash and do not insert a second token.
- Session writes reuse a leftover `session_id` with this user and hash
  and do not insert a second session.
- Post edit reuses unique `body_id` and `revision_id` rows and does not
  write a second event on conflict.
- Reply create reuses the unique `post_id` row and does not insert a
  second event on conflict.
- Email-change confirm returns `email_already_registered` on a unique
  `email_normalized` race and does not restamp the user.
- Thread create reuses the unique `thread_id` row and does not insert a
  second opening post or event on conflict.
- Notification mark-read reuses the unique
  `(notification_id, recipient_user_id)` row and does not insert a second
  read on conflict.
- Query budget sync reuses the unique `endpoint_name` row and does not
  rewrite the catalog when max queries, transactions, and notes already
  match.
- Trust snapshot writes apply this event's delta to the unique `user_id`
  winner and do not drop the score increment.
- Projection offset writes reuse the unique `projection_name` row and do
  not restamp `updated_at` when the event already matches.
- Reply counter shard writes apply the delta to the unique
  `(thread_id, shard_id)` winner and do not drop the increment.
- Password credential writes reuse the unique active `(user_id)` password
  row and do not insert a second active secret on conflict.
- Post edits retry revision allocation once when the unique
  `(post_id, revision_number)` key conflicts, and do not drop the edit.
- Reply writes retry position allocation once when the unique
  `(thread_id, position)` key conflicts, and do not drop the reply.
- Projection generation start reuses the unique `(projection_name,
  built_from_event_id)` row and does not insert a second rebuild on
  conflict.
- Event recorder writes reuse the unique outbox `idempotency_key` and do
  not insert a second EventLog or outbox row on conflict.
- Import failure writes reuse the unique `(import_job_id,
  source_record_type, source_record_id)` row and do not insert a second
  review row on conflict.
- Dead-letter writes reuse the unique `(source_table, source_id)` row and
  do not insert a second review row on conflict.
- Attachment variant writes reuse the unique `object_key` blob and do not
  insert a second variant on conflict.
- Attachment intent writes reuse the unique `object_key` row and do not
  insert a second event or audit on conflict.
- Projection generation activate retries the cutover once when the one-active
  unique index conflicts, and skips if this generation already won.
- Plugin install reuses unique `(plugin_id, hook_name)` hook rows and does
  not insert a second hook set on conflict.
- Thread read-state writes reuse the unique `(user_id, thread_id)` row
  and keep the original `last_read_at` on conflict when the stored
  position is already at least the requested one.
- Feed projection reuses the unique `(user_id, item_type, item_id)` row
  and keeps the original copy on conflict when created_at, rank, and
  version columns match.
- Notification preference writes reuse the unique `(user_id, channel)` row
  and keep the original `updated_at` on conflict when the values match.
- Search index reuses the unique `(entity_type, entity_id)` document and
  keeps the original `indexed_at` on conflict when the copy is unchanged.
- Notification delivery reuses the unique inbox `(recipient, notification)`
  row and does not insert a second notification on conflict.
- Attachment link and variant writes reuse the unique target or
  `(attachment_id, variant_type)` row on conflict.
- Mention recording reuses the unique `(source_type, source_id,
  mentioned_user_id)` row and does not notify again on conflict.
- Role binding create reuses the unique active `(user, role, resource,
  space)` row and does not insert a second audit on conflict.
- Default-space create reuses the unique `general` slug and does not insert
  a second space on conflict.
- Legacy id mapping reuses the unique `(legacy_type, legacy_id)` row and
  keeps the original native id.
- Category create reuses the unique `(space_id, slug)` row and does not
  insert a second event on conflict.
- Role, permission, and attach writes reuse unique catalog rows and do not
  insert a second audit on conflict.
- Plugin install reuses the unique `(name, version)` row and does not insert
  a second plugin or hook set.
- Concurrent deletion approval reuses the unique erasure job and does not
  insert a second approval action.
- Already-done erasure retries complete the deletion request if it is still
  open, skip the request restamp when already completed, and do not emit a
  second action.
- Incomplete legal-hold erasure retries restore `held` status and
  `last_error` without a second event, and skip those writes when they
  already match.
- Already-revoked suspension retries restore a still-suspended user and skip
  the user restamp when status is already active.
- Identity mail outbox retry after send and before `mark_done` resends
  from the outbox payload. EventLog still omits the raw token.
- A registration unique race on username or email returns the same
  duplicate field errors and does not insert a second pending user.
- Hide, restore, lock, and unlock that are already applied do not insert a
  second `moderation_actions` row, event, audit, or outbox message. A new
  `command_id` against the same target state returns the existing action.
- Mute, unsubscribe, and bookmark-remove keep the original timestamp when
  the store is retried and the row is already muted, revoked, or deleted.
  A second store call does not write a new stamp. HTTP `command_id` replay
  still avoids a second persist for the same command.
- Reputation events require `source_id`. A missing source is skipped without
  applying a delta. Existing null `source_id` rows backfill from
  `reputation_event_id`. The unique index covers every row, not only
  `source_id IS NOT NULL`. The reputation worker uses `aggregate_id` or
  falls back to `event_id`.
- Identity store and command-log failures return HTTP 503 without leaking
  the exception. A lost register, login, logout, password-change, locale,
  theme, settings, attachment upload, attachment delete, token-issuance,
  or token-consume response retried with the same `command_id` does not
  create a second pending account, open a second session, revoke a session
  twice, rotate a password twice, persist a preference twice, store a
  second file, soft-delete twice, issue a second token, or consume a token
  twice.
- Restoring a hidden or author-deleted thread reindexes the thread search
  document and every post in that thread. `thread.deleted` and
  `thread.hidden` already removed those post documents.
- Member data export now copies real posts, attachments, notifications,
  subscriptions, preferences, and profile fields (including email) into
  the completed `ExportRequest` manifest. Placeholder `{index}` rows are
  gone. Storage object keys and password hashes stay out of the bundle.
  EventLog still records counts only. `GET /privacy/export/:id` downloads
  the JSON for the signed-in subject; the dashboard links completed
  exports.
- Identity password-reset, email-change, and verification mail is queued
  on the outbox in the same transaction as token issuance. EventLog keeps
  `kind` and `token_id` only; the raw token lives on the outbox `mail`
  payload until the `IdentityMail` worker delivers it. A crash after
  issuance no longer drops the message.
- An outbox worker that claims a row and crashes before dispatch leaves
  the message `running`. Another worker does not take a fresh lock; after
  the lock expires the row is reclaimed and delivered once.
- Erasure that fails after credential and session revocation rolls back
  the user identity and leaves the job pending. A later `complete_job`
  anonymizes the member and revokes access.
- Thread, report, hide, and privacy approval writes roll back on a
  statement timeout during EventLog, outbox, or audit insert. Domain
  mutations inside the transaction are restored; no event, outbox, or
  audit row commits.
- A second `complete_job` while a legal hold still blocks erasure replays
  the blocked result. The job stays pending with the same `last_error`;
  no second deletion action, event, or audit is written. After the hold
  ends, a later `complete_job` can finish erasure.
  `DeletionWorkflow->hold_request` stays four arguments besides the
  invocant.
- Reputation events with a `source_id` are unique per
  `(user_id, source_type, source_id)`. `ReputationLedger->record_event`
  still skips a sequential duplicate; a unique race reloads the stored
  event and does not apply the delta twice.
- `thread.deleted` also removes post search documents and post feed rows
  for posts in that thread. `Search::Indexer->remove_thread` looks up
  posts by `thread_id` then deletes each search document;
  `FeedProjector->remove_thread` deletes the thread feed item plus every
  post item. Search documents and feed rows still have no `thread_id`
  column.
- Outbox worker handlers and the realtime fallback now wrap dispatch in
  `IdempotentJobRunner` with catalog keys `worker.<name>:{event_id}`.
  `EventIdempotencyStore` inserts into `event_idempotency_keys` only on
  `mark_done`, so a crash between `transport->dispatch` and outbox ack
  retries the message without skipping unfinished side effects. Replay of
  the same event is absorbed for search, notification, cache, attachment,
  media, feed, reputation, and realtime.
- Hidden posts now drop their `user_feed_items` rows through
  `FeedProjector->remove_item`; restore re-projects the author and thread
  subscribers with the existing `project_item` API. There is no
  `thread.hidden` event.
- Closed HIGH concurrency races on command replay, bookmarks, subscriptions,
  open reports, moderation actions, and the audit hash chain. Unique
  violations now replay the winning row, moderation hide/restore/lock take
  `FOR UPDATE`, and audit appends serialize previous-hash lookup with
  `pg_advisory_xact_lock` instead of swallowing chain-break errors.
  `migrations/026_concurrency_uniqueness.sql` makes the open-report unique
  index real and unique-indexes moderation `command_id`.
- Category create and update now invalidate the public category-list cache
  tags through the existing `CacheInvalidation` worker, so later admin
  edits do not wait for the 30s TTL.
- Admin category create and update now go through `Admin::Workflow` and
  `Admin::CategoryStore`, so the first administrator can add the first
  forum category on a fresh install without `PerformanceSeed`.
- Deploy units and reverse-proxy samples can start after
  `carton install --deployment`: supervisors invoke `script/gpforum-carton`
  (not the non-existent `local/bin/carton`), Nginx proxies
  `/attachments/:id/download` instead of intercepting `/attachments/` as
  `internal`, and Nginx/Caddy serve `/assets/` from the repo `assets/` tree.
  GlifiStore remains an operator-supplied server plus `GlifiStore::Client`
  (not a Carton/PAUSE pin). systemd units create `/run/gpforum` with
  `RuntimeDirectory`.
- Search and moderation/admin optional-query hashes no longer drop the
  following keys when a missing next-page size or empty optional param
  used a bare `return` (Perl empty list). `ForumAccess::search_more_limit`
  and controller `optional_param` now return an explicit undef.

### Development

- **`Command::Setup` is six files.** Its 1,872 lines are `Setup::Core` (saying,
  asking, root and sudo), `Setup::Answers` (reading and checking the answers),
  `Setup::Files` (the service account, the link, the environment file and the
  dry run), `Setup::Database` and `Setup::Report`, each inheriting the one
  before; `Command::Setup` keeps the entry points (`run`, `usage_text`, the
  option parsing and the orchestration in `_setup`) and the two defaults the
  tests replace (`migrate`, `owner_check`). Shared helpers lost their
  underscore; `_say`, `_said`, `_text`, `_given`, `_runner`, `_load` and a few
  others became `say_text`, `said_text`, `decode_text`, `stated`, `runner`,
  `load_environment`, so none is named like a builtin.
- **`Privacy::DeletionWorkflow` is five files.** Its 1,775 lines are
  `Deletion::Core` (the privacy rows, status changes, action record and
  event with its audit entry), `Deletion::Holds`, `Deletion::Requests` and
  `Deletion::Erasure`, each inheriting the one before; `DeletionWorkflow` keeps
  its collaborators and inherits them all. Shared helpers lost their leading
  underscore; four whose names collided with public ones were renamed
  (`open_hold`, `find_privacy_row_p`, `insert_privacy_row_p`,
  `update_privacy_row_p`, `latest_row_p`). `t/86` checks the transaction, event
  and audit boundaries over all the parts.
- **A flaky privacy test no longer depends on the clock.**
  `postgres-privacy-write-promises.t` compared two members' action lists in
  `created_at` order; the column is whole seconds, so two actions in the same
  second could swap. It orders by type.
- **`Identity::AccountStore` is four files.** Its 1,665 lines are now
  `Account::Core` (the lookups, token spending, audit and mail records the
  operations share), `Account::Passwords`, `Account::EmailChange` and
  `Account::EmailVerify`, each inheriting the one before; `AccountStore` keeps
  the collaborators it requires and inherits them all, so `Identity::Store` and
  every caller see the same object. The helpers the parts share lost their
  leading underscore (and have POD) because the critic rightly flags a private
  sub that no code in its own file calls. No behaviour changed; the three
  `ResultsetBoundary` entries moved with their code to `Account/Core.pm`.
- **`SqlFunctionCatalogue` is split by domain.** Its 381 entries (2,612 lines in
  one hash) live in nine modules under `Infrastructure/SqlFunctionCatalogue/`
  (Admin, Attachment, Community, Forum, Identity, Moderation, Notification,
  Operations, Search); the catalogue merges them and keeps a small `%FUNCTION`
  of its own, which is where `gpforum generate gpforum_service` still adds a new
  entry. No entry changed.
- **`migrations/README.md` lists every file up to 2053.** It stopped at 2041 and
  called the schema "26 files"; it now names the baseline (2000-2025) and the
  twelve changes after 2041, and says that documents citing the older 001-135
  and 1000-1058 numbers refer to definitions now found in the 2000 series.
  The PostgreSQL audit's banner says the same.
- **Every `## no critic` in `lib/` now says why.** 128 were reviewed one by
  one. Forty-one rethrows (`die $error`, to keep the original location) and the
  `local` guards already gave a reason; the 36 front-door commands' lower-case
  package names (`GPForum::CLI::dead_letter_check`) and six others (a header
  whitelist, a username class, three `can()` bypasses, the DBI `print`
  callback) did not, and do now. None was removable: Perl::Critic checks a
  package name by component, so a configuration exemption would have let every
  lower-case component through the tree.
- **More bare `croak` calls are typed.** Sixteen modules whose message ended in
  `$OS_ERROR` or `$ERRNO` (the environment-file edit, the service account,
  provisioning, the Unix listener, the systemd notifier, the filesystem storage)
  throw `X::Io`. The contract refusals of `WebSub`, integration readiness, the
  reverse-proxy benchmark, API role provisioning, anonymize and the digest queue
  are `X::Argument`; the DKIM and Push key checks are `X::Config`. Messages are
  unchanged. These now declare `permanent` where a bare string read as
  `transient` to the outbox, which is what a refusal of bad input should say.
- **Six more private copies of "read a column from a row or a hash" are
  `Row::column_of`.** `RegistrationStore`, `Community::Workflow`,
  `Privacy::Record`, `Attachment::Record`, `Identity::Support::column` and
  `AuditRecord::column` each hand-rolled the hash-then-`get_column` check; they
  now call the one helper. Behaviour is the same for the rows they receive.
- **`GPForum::Config` no longer holds the table of settings.** The row for
  each setting (variable, default, type, section, summary, rule name) moved to
  `GPForum::Config::Settings`; `Config` keeps the reading, the rules and the
  problems. 2,700 lines became 1,435 and 1,397. No setting changed; `t/212`
  reads both sources when it checks the settings page against the variables.
- **File failures have a class: `GPForum::X::Io` (`t/887`).** A local read,
  write, sync, link or lock that failed is `transient`, as a bare `croak` was,
  so the outbox still retries it, but the exception now says what it is. The
  post signer's and the Push key ring's `cannot ...` failures use it; the key
  ring's unsafe directory is `X::Config` and its "keys already exist" is
  `X::Argument`. Messages are unchanged.
- **Atomic file writes and the service lock fail as `X::Io`.** `OS::Filesystem`
  (temporary file, write, sync, rename, directory sync) and the service
  manager's lock and PID file operations throw it; messages are unchanged.
  The refusals that need an operator (a symlinked PID file, a live PID, a
  held lock) stay as they were.
- **Failed writes, reads and closes in commands and probes are `X::Io`.** About
  sixty modules (the `Command::*` writers of reports and evidence, the CLI
  entry points, the operations probes, benchmark helpers, the file reader and
  filesystem storage) throw it for a `cannot|failed to write|read|open|close`
  failure. Messages are unchanged. `Infrastructure::PerlRuntime` keeps to core
  modules and is untouched; `HypnotoadBenchmark` and two reads in
  `PostgresPerformance` stay `croak` where the heredoc and the filehandle
  span made the critic's brief-open rule fire.
- **Eleven bare `croak` calls are typed exceptions (`t/887`).** The native
  supervisor probe's seven contract failures are `GPForum::X::Argument`; the
  post signer's four refusals of an unusable key are `GPForum::X::Config`.
  Messages are unchanged. Only broken contracts and bad configuration moved:
  a typed exception declares its `failure_type` (`permanent`), where a bare
  string was `transient` to the outbox, so the file and descriptor failures
  (`cannot write ...`) stay `croak` until each has a class that says they may
  be retried.

- **Fewer private copies of the same helpers.** Three modules read a row's
  column with their own `_column`; they now use `column_of`
  (`GPForum::Infrastructure::Row`). Five modules kept their own copy of the
  code that strips Perl's ` at FILE line N.` from an error; they now use
  `error_text` (`GPForum::Infrastructure::Text`, `t/759`), each keeping its
  own fallback sentence. Where a copy differed in meaning -- a row double's
  method, a hash fallback, an exception on a bad row -- it stays.

- **A controller cannot call a service that has no asynchronous twin
  (`t/886`).** `call_p` fails a request whose service holds a database
  connection and has no `METHOD_p`; the test now checks every literal
  `call_p( $self->gp_helper, 'method' )` in the controllers, so the gap shows
  in the unit tier. All 61 helpers pass today.

- **Consolidation regression is green (2026-11-02).** The complete unit and
  PostgreSQL tiers, browser clients and performance-evidence contracts pass;
  [the quality program](docs/QUALITY_PROGRAM.md#consolidation-review-2026-11-02)
  records the commands, counts and scope. Twelve module preambles now retain
  Perl's current feature bundle after `Mojo::Base`; tag methods have POD and
  the new critic violations are fixed without adding baseline exceptions.
  Cache tests verify that the one permitted live-configuration statement is
  the configuration read, not a content query. Query budgets count that read
  and private follow state explicitly; the page test also verifies the real
  runtime observer, whose five stale endpoint limits are corrected against
  measured queries. The documentation gate checks files
  and section anchors across maintained Markdown, including schema guides.

- **Good Phorum is the project name.** GP stands for Good Phorum, with the
  P in Phorum standing for Perl. The README, maintained documentation and
  project presentation use the full name; `GP Forum` is the short form.
  [Naming conventions](docs/BRANDING.md) preserve existing commands,
  namespaces, settings, paths and contributor attribution.

- **The SQL function layer's documentation states its one retry.** Outside
  a transaction a call whose connection was lost runs once more, as
  `dbh_do` does, so an autocommit write -- a session touch, a rate-limit
  increment -- can land twice if the connection goes between the commit and
  the read. Kept for parity, now written down.

- **The 2026-10-30 review's last batch: one home for each helper lib/ kept
  copies of.** `GPForum::Infrastructure::Text` gives `has_text` and
  `trimmed`, in place of thirty-five and twenty-five copies, seven of them
  public; the evidence services' error trims use `evidence_trimmed`.
  `GPForum::Infrastructure::Row` gives `rows_of`, `belongs` and `updated`;
  `GPForum::Infrastructure::UniqueConflict` gives `conflict_of` and
  `once_more`; `GPForum::Service::Forum::ContentStore` is the post and thread
  stores' parent; `GPForum::Service::WorkflowResult` holds the workflows'
  result, missing-command and logging helpers; `GPForum::Controller::Base`
  answers `wants_json`, `current_user_id`, `not_found` and `system_failure`
  for every area; `EventPayload->value`, `SourceFunction->list` and
  `Web::Access->status_of` replace five, three and five copies. In t/lib, the
  result-set doubles share `GPForum::Test::TransactionalResultSet`, the
  copying row doubles `GPForum::Test::CopyingRow`, and seven schema doubles
  stop restating their parent's empty `storage_accessors`.

- **The 2026-10-30 review's tenth batch, delivery and hygiene.** The raw-SQL
  outbox claim goes: `Outbox::ClaimQuery`, the dispatcher's driver sniffing
  and its own guarded `UPDATE`. On PostgreSQL the claim and every write after
  it go through `api.outbox_claim_ready` and `api.outbox_owned_write`, and
  the unit tests and the dispatcher benchmark drive them through
  `GPForum::Test::OutboxFunctions` and `OutboxBenchmarkFunctions`. The Minion
  registrar's three placeholder aliases, which no release queued, go.
  `script/architecture-policies` fails on inventory entries it no longer
  sees as well as on new ones, writes one JSON layout, runs in CI, and its
  inventory is regenerated: the fourteen "new" violations were old ones
  re-hashed by edits, and three are fixed. Dead definitions go
  (`SecondFactor::challenge_seconds`, the drills' formatters and exit rules,
  `root_name`, seven constants), and t/415 pins the command's text instead.
  The unsubscribe scopes are one list. Stale migration numbers in the docs
  and tests, two idle `no critic` names, and ADR 0078 on the single
  notification mail, at least once by design, are corrected.

- **The 2026-10-30 review's ninth batch, commands and bootstrap.**
  `GPForum::Command::Usage` gives `read_arguments` and `option_given`, and
  nine commands lose their copy of the parse-or-misuse block. Getopt::Long
  leaves the last two commands that used it. Production registers its
  Minion tasks through the `gp_worker_registrar` helper the tests exercise,
  not a copy. ENTRYPOINTS.md states the script/ rule without a stale list
  and points at the exit codes. Warmup's POD and ADR 0121 say the workers
  inherit the statements' SQL: the prepared statements go with the
  manager's connection.

- **The 2026-10-30 review's eighth batch, operations tooling.**
  `GPForum::Service::Operations::EvidenceHost` holds what the staging and
  deploy drills ask of the host -- running a command, finding a tool,
  finding the checkout, trimming an error -- in place of five copies that
  had drifted: a command a signal killed no longer counts as passing, and a
  checkout that cannot be found is said. `Host->shell_word` and
  `Host->closing_chmod` replace three quoting copies. PartitionLifecycle and
  doctor lose raw-SQL fallbacks no caller reached, copies of
  `api.ops_partition_horizon` and `api.ops_doctor_outbox`.
  DatabaseProvisioning gives `dsn_values`. `deploy_host_unit_checks`,
  ServiceFiles' `template_of`, EvidenceValidate's unread `strict`, the
  deploy drill's stub scripts and dead rewrites, and both drills' unread
  options go. BackupVerifier has its first test. The architecture check is
  green again: four one-argument signatures from the third batch name their
  argument, and two optional attributes say so.

- **The 2026-10-30 review's seventh batch, discovery and attachments.**
  The single-document sitemap path, its limit and `space_url` go, with the
  attachment store's uncalled links call and the lifecycle's three
  test-only subs. t/05 counts a no-transaction migration's statements.
  `GPForum::Infrastructure::ImageBytes` holds the bounds avatars and
  thumbnails share. The WebSub test builds a publisher per request, as the
  application does.

- **The 2026-10-30 review's sixth batch, plans and indexes.** Readiness
  stops reading `projection_generations`, which nothing ever wrote: its
  check, Result class, runbook entry and label go, and ADR 0091 withdraws
  the requirement. The catalogue test derives a retained boundary's
  arguments from its catalogue entry unless it lists them. The users
  table's version columns say what they are.

- **The 2026-10-30 review's fifth batch, staff and moderation.** Methods
  only their own tests reached go: the avatars' shown id, the relation
  kinds, the message limits, the member notes' page size, the bookmark
  removal by id with its branch of the stamp function, and a role's
  permission listing with its function. The target update answers the rows
  it changed. Two comments move to the subs they describe.
  `GPForum::Infrastructure::CalendarDate` says whether a day or a timestamp
  exists. `nested_plan_without_seqscan` in the test harness reads the plans
  inside a function call; the audit viewer's plan test reads the statement
  the page runs, not the ORM query production never takes.

- **The 2026-10-30 review's fourth batch.** t/641 and the catalogue's live
  test refuse an implementation the release reaches that calls an
  owner-rights boundary. The thread page reads test puts three files on a
  post, the protocols test reads the signing sweep's plan inside its
  function, and the community tests race two poll creates, follow a
  category over a thread set to mentions and deliver a post's and a
  suspension's every change to the reputation worker.
  Seven tests the earlier batches touched meet the whole critic gate again:
  named characters, named counts, and t/753's doubles in t/lib.

- **The 2026-10-30 review's third batch, operations and delivery.**
  `GPForum::Infrastructure::Id->named_uuid` is the one name-based UUID the
  notification dispatcher and the realtime mapper use;
  `GPForum::Service::Operations::NoSharedCache` is the empty shared layer.
  A staging copy that fails says why; dead branches, stale POD and an
  orphaned comment go.

- **The 2026-10-30 review's first corrections.** `GPForum::Service::Forum::PostPosition`,
  its helper and its function go: nothing read the next position but a test,
  and only the reply's insert may decide it. PostReader no longer reaches
  for a scoped thread-posts function no caller needed and refuses a page
  read for two viewers. The savepoint probe reads AutoCommit through the
  handle as it stands, not the pinged `dbh`, a round trip on every command;
  EventRecorder asks the storage for `transaction_depth`, the name
  DBIx::Class keeps, so the doubles drop `txn_depth` and t/454's two TODOs
  pass. t/641 knows a later migration may drop a function, and keeps the
  retained boundaries that lack the catalogue's last parameter in one table.
  The attachment test's thread TODO, which passes, is gone; comments and POD
  name the functions the release calls and the baseline's migrations.

- Stabilization checks and remaining limits are recorded in
  [the 2026-10-23 verification](docs/architecture/maintenance-2026-10-23.md),
  including the complete unit and PostgreSQL tiers and the existing CI
  capacity failure. The ASVS record closes three requirements on the
  suspension timestamp and Unicode-password paths, retaining the other gaps.

- **`script/schema-fingerprint` prints a database's schema as sorted facts**,
  so two databases compare with `diff`: the baseline's proof (ADR 0150). t/641
  refuses, in every migration, a function that returns a table's row and a
  function in `api` the catalogue does not reach; t/05 asks every migration
  that creates a table to comment it, and checks the schema's invariants on
  the baseline instead of each 0.x file. The tests that replayed 0.x data
  migrations (045, 046, 049, 051) go with them, the search optimisation test
  compares the function path with the ORM path instead of retained versions,
  and `script/bench-sql-functions` loses its paired mode, which only compared
  the retained search versions, with `GPForum::Infrastructure::SqlFunction::Retained`.
- **Good Phorum measured against OWASP ASVS 5.0.0, requirement by requirement
  (ADR 0149).** `docs/security/asvs-5.0.md` lists the 246 requirements in
  scope, each met, not met or not applicable with its evidence, and ends
  with the gaps in priority order, the Level 3 areas first. No level is
  met yet: some Level 1 requirements are not. `t/721-asvs-record.t` fails
  when a cited file or line no longer exists, a tally disagrees with the
  rows, or the gap list misses a requirement not met.
- **The hot pages through the socket, end to end, before and after.**
  `docs/PERFORMANCE.md` ("Through the socket") records f377541, main
  before the socket work's statement and accept changes and main after
  them, five pages and two writes at 1 to 128 clients, with the commands
  that reproduce them, the decisions on gzip, the session's validation and
  Hypnotoad's settings, and what is left; ADR 0137 has the end-to-end
  note. The signed-in thread page: 104 → 149 → 168 requests a second at
  one client, a p95 of 487 → 333 → 216 ms at 128.
- **Security scanning is green again: the peer-authentication workflow
  passes actionlint.** Since that workflow arrived, shellcheck stopped the
  scan's lint job on two of its lines: it found initdb with `ls` and quoted
  Perl in single quotes. It now finds initdb with `find`, and the Perl
  carries a shellcheck note that its `$` signs are Perl's own.
- **`t/48-browser-security.t` passes when run with `perl` or `prove` alone
  on macOS and FreeBSD (ADR 0097).** Its production application refused
  libev's default backend, which Test::Mojo had loaded before anything
  chose kqueue. The test now loads `GPForum::OS::Reactor` first, which
  sets the backend as the front door does. It was the only unit test that
  failed when run without `script/gpforum-carton`.
- **The 5,000-connection gate can no longer pass without measuring 5,000
  connections, and says when the runner cannot meet its bar (ADR 0063).**
  A p95 over two seconds at 1,000 used to skip every 5,000 sample as
  saturation, leave the suite `partial` and exit 0, and the final check read
  only the main summary. Now the gate measures every cell it plans, an
  unmeasured one fails it, and the check also requires the gate's own
  summary to be `ok`. It refuses a `somaxconn` shorter than the burst,
  records the listen-queue drops, warms the workers at the measured
  concurrency and records each worker's connections. Each sample records its
  margin to the three-second budget and the throughput two event-loop turns
  need to fit it, 3,334 requests/s at 5,000 connections: the EPYC 7763 and
  Xeon hosts serve 2,790-3,190 and timed out in 23 of 42 samples, so a
  timeout there is reported as `runner_below_bar` and still fails.
- **`script/bench-socket` measures the hot pages through the listener the
  service runs, under concurrent clients.** Each side is a checkout with
  its own database, migrated and seeded with the medium profile by its own
  `bin/gpforum`, and its own server started as the unit starts it
  (`gpforum start --service --foreground`, production settings); members
  register and sign in through the pages. Each cell is a page at a
  concurrency, that many keep-alive connections speaking to the Unix socket
  (or, for a checkout from before the socket, TCP loopback) as a proxy's
  upstream connections do; the sides alternate, the order reversed each
  round, and the report is each cell's median. `--connections` counts the
  connections each worker holds in the middle of a cell; `--burst N` opens
  N more at once a quarter of the way in and reports what they waited.
- **A drill for EV's kqueue across Hypnotoad's fork.** A kqueue is not
  inherited by fork(2), and libev rebuilds it in a worker only through EV's
  pthread_atfork handler, which EV calls buggy on most BSDs. `t/700` forks
  with a watcher registered in the parent and requires the worker and the
  manager to keep serving on the native backend; it passes on macOS and
  `platform-freebsd.yml` now also runs when it changes. ADR 0097 records the
  risk and the fallback (`EV::loop_fork` in the worker, never poll).
- **HARNESS_PERL_SWITCHES reach the tests again, and coverage measures
  `GPForum::OS::Reactor`.** `.proverc` named an explicit interpreter to
  preload the reactor, which made prove ignore the switches that Devel::Cover
  and NYTProf use; `script/coverage` worked around it by loading the reactor
  ahead of Devel::Cover, which then reported the module as never run. With
  the backend chosen by `script/gpforum-carton exec`, `.proverc` is gone and
  `script/coverage` loads Devel::Cover through `HARNESS_PERL_SWITCHES` again
  (`t/699`).
- **The projection generation search double is gone.**
  `GPForum::Test::ProjectionGenerationSearch` stood in for the generation
  writes deleted