# 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 `