<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>

<p align="center">
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>

## Content migration recovery (14 October 2026)

The testing site was restored to `c45856a` after cancelling the October 10 reputation migration. Arfu subsequently pulled `c811004` and successfully rebuilt its frontend on the evening of October 14. The new application still needs the schema preparation below; a successful frontend build does not establish API or feature readiness. A successful code rollback does not remove structures created before the interrupted migration; do not use `migrate:rollback` to clean up a migration still marked **Pending**.

The content tables have FULLTEXT indexes. MySQL cannot instantly add columns to those tables, and the original migrations separately added two columns to `post` and three to `thread`. Adding the `thread.merged_into_id` foreign key with foreign-key checks enabled also requires a table copy. The [MySQL online DDL documentation](https://dev.mysql.com/doc/refman/8.0/en/innodb-online-ddl-operations.html) describes these restrictions. Local metadata confirms the FULLTEXT indexes and an estimated 4.77 million posts. Processlist elapsed time and mysqld CPU usage do not provide percent complete or an ETA.

Arfu's read-only testing-server inspection at 08:40 on 14 October confirmed MySQL `8.0.46-0ubuntu0.24.04.4`: both `post` columns and all three `thread` columns above are absent; `attachment.legacy_readperm` is absent; `reputation_vote` exists; the merge foreign key is absent. Both original FULLTEXT indexes (`post_body_text_fulltext`, `thread_title_fulltext`) remain. The site's restored release loads normally. These results confirm the interrupted schema state; they do not prove data integrity beyond those checks.

The revised four migrations check these content changes before DDL, reject a known rebuild on populated MySQL tables, and explicitly request `ALGORITHM=INSTANT` for column additions to populated tables. MySQL rejects unsupported operations without a COPY fallback. Secondary indexes use `INPLACE, LOCK=NONE`; they can still take time to build. The five-second session `lock_wait_timeout` bounds metadata-lock waits, **not total DDL runtime**, and is restored on success and failure. Empty fixture tables can use COPY. Existing matching columns, indexes and foreign keys are reused, counter values are preserved, and missing constraints on empty partially created tables are completed. Incompatible structures or missing foreign keys on populated tables stop for review instead of resetting data or disabling foreign-key checks.

With the revised code available in a local or isolated review checkout, inspect the configured database without changing tables:

```sh
php artisan post76:schema-check
```

The command is new and is **not available in the restored `c45856a` server checkout**. `migrate --pretend` cannot provide the live metadata needed by this guard; use the check command instead. A passing check only clears the known content-schema blockers, not every migration or external-service acceptance check. This patch does not automatically prepare a populated FULLTEXT table and is **not an instruction to redeploy yet**.

Before another testing-server update, rehearse a separate schema preparation on a restored copy. Combine required changes per content table where supported, preserve existing data and search indexes, measure runtime and disk demand, and verify interruption/retry behaviour. Any approach that temporarily removes or rebuilds FULLTEXT needs an explicit search-restoration and maintenance plan. Do not promise a duration from small fixture tests. Recheck the prepared schema before running the remaining application migrations.

### Grouped DDL compatibility rehearsal

On local MySQL 9.7.1, explicitly requesting INPLACE while keeping FULLTEXT failed immediately. Removing and re-adding FULLTEXT in the same ALTER, including after a separate index drop, also failed with the grouped column additions. A separate index drop followed by grouped INPLACE column changes and then FULLTEXT recreation succeeded. Search matches, incoming foreign keys, self-references, and SET NULL/CASCADE actions were verified with tiny synthetic records. This remains expensive work on a large dataset: grouped column additions rebuild the table, and restoring FULLTEXT builds an index and may require another rebuild. It is not a zero-downtime or instant upgrade. Dropping FULLTEXT alone does not guarantee that INSTANT becomes available; MySQL's retained hidden FTS structures are documented in [verified bug 104899](https://bugs.mysql.com/bug.php?id=104899).

`ci/rehearseContentDdl.php` makes this compatibility check reproducible without altering the application's existing tables. Run it from an application checkout, passing the exact expected database name. It creates only three random `p76_ddl_probe_<random>_*` tables with a few synthetic records, tests the DDL and search/foreign-key behaviour, then removes those tables in `finally`. The source explicitly names every table it alters; it does not run application migrations. Its session-level foreign-key setting is used only for the newly added, entirely NULL fixture column and fixture cleanup. No other connection's settings are changed. A forcibly terminated process or lost connection can leave its named fixture tables; do not delete unrelated tables during cleanup.

Local invocation:

```sh
php ci/rehearseContentDdl.php post76
```

The check passed locally on MySQL 9.7.1. Arfu also ran it successfully on testing-server MySQL `8.0.46-0ubuntu0.24.04.4` at 22:56 on October 14: grouped column changes, restored search, original/new foreign keys, and fixture cleanup all passed. The 1.86-second result used tiny fixtures and is not an estimate for the existing dataset. In the `c811004` checkout, its path is:

```sh
cd /var/www/html/post76-testing
php ci/rehearseContentDdl.php post76_21291639
```

A PASS establishes small-fixture compatibility, not full-size runtime. At 22:58, `post76:schema-check` still reported both FULLTEXT blockers and the missing merge foreign key, with estimates of 4,793,116 posts, 291,186 threads and 1,080,683 attachments. All six new columns were still absent. The testing site's schema still needs preparation.

### Direct testing upgrade — selected by Arfu at 23:16

Arfu chose to skip the full-data rehearsal while retaining all existing data. The earlier `1044 Access denied` occurred at `USE post76_rehearsal_20261014`, before any table changes. Creating or granting access to that rehearsal database is no longer a prerequisite for this route.

`ci/prepareTestingContentSchema.php` uses the same grouped DDL runner through an explicit entry point that accepts **only** configured database `post76_21291639`. It does not weaken the copy entry point's target restriction. Direct apply requires Laravel maintenance mode; Arfu must also stop workers, scheduler and any other writers, retain a usable current backup, and ensure sufficient disk for table/index rebuilding. Database operations remain manual server work performed by Arfu.

Synchronize **both** `ci/prepareTestingContentSchema.php` and the updated `ci/prepareContentSchemaCopy.php` before running the following from the testing checkout in a persistent terminal session:

```sh
set -o pipefail
php artisan down &&
php ci/prepareTestingContentSchema.php post76_21291639 --apply 2>&1 | tee storage/logs/testing-schema-upgrade.log &&
php artisan migrate --force &&
php artisan up
```

The direct route verifies exact before/after row counts, search samples and original foreign keys. It skips the full row-content SHA-256 scans and database-copy creation; unchanged row counts are not proof of byte-for-byte integrity. It adds the missing columns without resetting existing content, temporarily removes and rebuilds the two search indexes, and prepares the merge foreign key. These actual rebuilds still take time; there is no promised short runtime. The MySQL 8.0.46 fixture check established DDL compatibility, not full-size duration.

Any failure leaves the website in maintenance: the `&&` chain stops before migrations or `up`. Do not run those steps manually after a failed preparation. Preserve the last START/FAIL output; a partially prepared schema is deliberately rejected on retry, and the runner never drops existing data columns or resets the database. Inspect the completed steps before choosing how to finish. This route and its row-count verification passed the local synthetic MySQL check; the direct testing guard also rejected unrelated targets. It has not been run against the remote dataset by Codex.

### Optional full-data copy preparation

Arfu confirmed that **all existing data must be retained**. Keep the original database `post76_21291639` and a verified full backup. Restore the complete database into a new independent database, for example `post76_rehearsal_20261014`, on the same MySQL version. Verify available disk space for the restored database and DDL temporary files before beginning. A copy on the same server still consumes shared disk and CPU resources. This is an operator-managed restore; no live credentials or remote services are accessed by local development.

For a copy intended to replace the testing database, pause all source writers before the consistent backup (web, workers, scheduler and other clients), and keep them paused until cutover. If writes continue during a rehearsal, its copy must not later replace the source without a new consistent restore or a verified reconciliation of those changes. The rehearsal copy must have no application, worker or event writing to it. Preserve foreign keys in the restore, ensure there are no cross-database references, and give the existing application database account access to this new copy without changing the website's configured database.

`ci/prepareContentSchemaCopy.php` requires the already restored copy and the `c811004` helper. It refuses the configured application database and any name outside `post76_rehearsal_*`. It only changes its own connection's selected database, disables automatic reconnect, and rejects unexpected table/index definitions, visible cross-database foreign keys, triggers and a partly prepared schema. The default mode is a read-only plan:

```sh
php ci/prepareContentSchemaCopy.php post76_rehearsal_20261014 --plan
```

After the restore, capacity and plan are reviewed, run the apply step in a persistent terminal session and retain its log:

```sh
set -o pipefail
php ci/prepareContentSchemaCopy.php post76_rehearsal_20261014 --apply 2>&1 | tee storage/logs/content-copy-20261014.log
```

The runner prints the database connection ID and START/DONE timestamps for each operation. It groups the two `post` additions and three `thread` additions, restores their ngram FULLTEXT indexes, prepares the merge foreign key, and explicitly requires INSTANT for the attachment column. The merge column is new and entirely NULL; only that session temporarily disables foreign-key checks to add its constraint. Before and after DDL, streaming scans of every original column compare row counts and SHA-256 digests, including binary values. Scans report each 100,000 rows and do not buffer the entire table in PHP memory. Search checks compare the first 20 matching IDs for `音響`; an empty baseline stops before DDL. Original foreign-key definitions must remain unchanged. These checks themselves take time, and the DDL timing excludes their scan time.

The five-second timeout only limits metadata-lock waits. There is no automatic DDL runtime cutoff, percent-complete estimate or guaranteed ETA. If a statement fails or is cancelled, the copy can be partially altered and its search index can be absent. Keep the log; inspect or restore **only the rehearsal copy** from the retained backup before a new attempt. The runner deliberately refuses a second apply to a partly prepared copy rather than trying to infer its history. It does not delete databases or restore them, run the remaining application migrations, change website configuration, or perform cutover. A final PASS establishes these content-schema checks on this copy; remaining migrations, application/search acceptance and an operator-managed cutover remain separate steps.

The runner passed local MySQL 9.7.1 checks using an isolated synthetic database, including protected-source/name rejection, read-only plan, baseline failure before DDL, full preparation, binary-value preservation, searches, incoming/new foreign-key actions and refusal of repeat apply. Its complete runner has not yet been executed on the testing server or full dataset. Reproduce the local check with `php ci/checkContentSchemaCopyMysql.php`; it requires loopback MySQL and only creates/removes its random synthetic database.

Local verification:

```sh
php ci/run.php test --filter ContentSchemaUpgradeTest
php ci/run.php test
php ci/checkContentSchemaMysql.php
```

The MySQL check refuses non-loopback hosts, creates a randomly named `post76_schema_fixture_*` database, uses only synthetic content, and removes that database in `finally`. It tests actual FULLTEXT/INSTANT rejection, stop-before-DDL behaviour, fresh migrations, partial-constraint recovery, retry without resetting data, session timeout restoration, and reversal. It requires the local connection to be allowed to create and drop its temporary database. It never runs migrations on the existing application tables. Automated SQLite tests also cover interrupted migration states and the read-only check command. Remote execution and operational rollout remain Arfu's responsibility.

## Post76 App API contract

`openapi.json` is generated from the running `/v1` Laravel routes and their FormRequest rules:

```sh
php ci/artisan.php api:openapi
php ci/artisan.php api:openapi --check
```

The OpenAPI 3.1 file covers all 130 operations: 128 JSON read/write operations and two binary media downloads. JSON responses include typed nested resources, rich document trees, all 11 homepage block variants, and dictionaries keyed by post ID. `x-post76-response-data` marks these schemas as `complete`; generation fails if a JSON route has no response schema. Media upload/import responses are JSON. Public reads accept optional bearer authentication, while private message attachments use server-issued signed download URLs.

Conditional account, database, and content rules still run on the server. Validation errors retain a top-level `errors` map alongside `{ data, message, status }`. HTTP 423 responses document the required two-factor `setup` or `challenge`; clients retain their current credential while completing it. Class-based PHPUnit tests validate actual responses with a JSON Schema 2020-12 validator, exercise populated nested payloads and empty dictionaries, reject incorrect field types, and check contract drift. Run `php ci/run.php test` for the regression suite with isolated synthetic configuration.

## Forum reports and retention

Verified staff with **報表與分析** read permission can select up to 90 calendar days, compare an equally sized previous period, inspect daily growth and interaction trends, review board health and weekly registration cohorts, and export the displayed report as CSV. `GET /analytics/get` and `GET /analytics/export` belong to the admin API. The website timezone controls date bounds; today's comparison ends at the same clock time. Report navigation, requests and downloads discard responses from an old identity or date selection. Search terms require the separate **搜尋分析** permission in both JSON and CSV.

Signups include subsequently deleted accounts. Content and reaction totals count currently published, nondeleted posts and threads; admin aggregates include hidden boards without exposing post text or member identities. Posting members and active members are distinct within the selected period. Board health includes empty boards, unanswered new threads, contributors, reactions, current pending reports and moderation items. Search analytics reuse the existing anonymous daily aggregates and identify periods beyond their 90-day retention.

Successful authenticated website and App use queues a daily activity record on the existing default queue, once per member per calendar day. Anonymous or failed requests, pending second factors, impersonation and admin browsing do not count. Retries and out-of-order jobs preserve the earliest observation. Account export includes the member's activity days; deleting the account removes them, and an old job cannot recreate them. Records contain no request URL, IP address or device identifier.

Historical activity is unavailable before collection starts. Daily charts leave gaps, comparisons require complete collection, and W0–W8 retention cells distinguish uncollected history, unfinished weeks, empty cohorts and a measured zero. Cohorts follow Monday-based registration weeks; their subsequent activity is observed up to the report's current time even when the signup period is historical. The reversible migration creates `member_activity_daily` and `analytics_state`; synthetic local tests verify both directions. CSV uses a UTF-8 BOM, neutralizes formula-like strings and disables response caching.

Eleven report tests and six activity tests cover authorization, date boundaries, deduplication, privacy, coverage, cohorts, exports and bounded board queries. Eight JavaScript tests exercise date/chart helpers, stale responses, validation/retry, downloads and the actual Vue/PrimeVue server-rendered report with separate search permission. Run these locally with `node --test tests/js/*.test.mjs resources/js/web/editor/tableDocument.test.mjs`.

## Saved searches and keyword alerts

The website search page and App offer private saved searches: name, keyword, thread author, board, date bounds, ordering, title-only and image filters. Each member can keep up to 20 searches, rerun them, rename them, replace their criteria with the current search, enable/pause alerts and delete them. Author-only searches can be saved with alerts off. The shared authenticated API exposes `GET /search/saved/list`, `POST /search/saved/save` and `POST /search/saved/delete`; ownership is enforced for every read/write. Save requests use `alerts=1|2`, while resources return a boolean.

New published posts and moderation approvals enqueue keyword matching on the existing default queue. Matching uses the full literal phrase, normalizes whitespace and ignores English case. Reply alerts require a keyword in the new reply; title-only alerts apply to opening posts. Board, thread-author, thread-date and image filters are retained. Alerts start at enablement, including after criteria changes or re-enablement; older queued publications do not backfill. Indexed phrase anchors limit candidate reads, and one member/post delivery record prevents duplicates across matching searches and retried jobs.

Only active public posts and threads without rank, points or legacy read restrictions can match. Inactive recipients, self-posts and blocks in either direction are excluded. Notification event 8 (**關鍵字提醒**) uses the existing in-app, push and email preferences. Pending push/email checks the current saved search and content visibility again; paused, deleted or changed searches stop pending delivery. Existing in-app history remains readable after pausing but is redacted when the source becomes unavailable. Account export includes owned searches; deleting the account purges its saved searches and delivery records. Browser and App screens discard responses from an old credential.

Twenty backend tests cover API ownership, limits, literal matching, filters, publication timing, idempotency, rollback/retry, preferences, queued delivery and privacy. Five App tests and three JavaScript tests cover management, restored criteria, validation/retry, account changes and notification wording. All external delivery remains mocked in local verification.

## Contribution rankings

The website and App offer **累計 / 本週 / 本月** on the ranking page. `/ranking/get` accepts optional `period=all|week|month`; omitting it preserves the lifetime view. Lifetime metrics use the existing imported counters. Current-week and current-month metrics use published public content and event timestamps, with a top ten and stable member-ID ordering for ties. Empty lists and failed requests have visible feedback and retry controls; switching periods discards late responses.

Weeks start on Monday and months on the first day in `APP_TIMEZONE` (default `Asia/Hong_Kong`). The API returns `starts_at`, `ends_at` and `timezone`; current-period metrics stop at the request's current time. Reply counts exclude opening posts, thread counts use creation time, and received reactions use the reaction timestamp, including reactions on older posts. Pending/deleted posts or threads, hidden/disabled boards, rank/legacy/price-protected threads and inactive/deleted contributors are excluded from current-period metrics. The popular-thread section is labelled **熱門主題（全期）** and retains its existing lifetime heat ordering while excluding protected threads.

The generated OpenAPI contract documents the optional period and actual option values. Its CSV decoding handles Laravel's quoted validation options without publishing extra quotes. Seven ranking API/contract tests, three option-schema tests and two App interaction tests pass locally, including date boundaries, privacy, limits/ties, late responses, refresh and retry.

## Two-factor verification

Members can manage TOTP at **帳戶設定 → 雙重驗證**. Administrators, moderators and staff with any admin module permission must complete enrollment and verification before using protected website, admin or App operations. Setup expires after ten minutes and becomes active only after a valid six-digit code. The server generates the QR code locally and encrypts secrets and recovery-code hashes at rest. Ten recovery codes are shown once; each can be used once.

Password and social sign-ins require the second factor when enabled. Remembered sessions, existing App tokens, account linking, impersonation and queued push delivery also check the current verification requirement. Pending App credentials expire after ten minutes, cannot renew or access member operations, and become normal device credentials only after verification. Changing recovery codes invalidates other credentials' verification. Members can disable TOTP after confirming their identity and a valid second factor; staff cannot disable it.

Setup and recovery-code changes require the current password or a primary sign-in within ten minutes. Code verification is limited to six attempts per minute per account across the related endpoints, and accepted TOTP time steps cannot be reused. Recovery codes remain in frontend memory until acknowledged and are never placed in persistent client storage. Real authenticator enrollment and recovery still need iOS and Android device acceptance testing.

## Linked sign-in accounts

The website's **帳戶設定 → 連結登入方式** and the App's **我 → 連結登入方式** support Google and Apple linking and unlinking. Members confirm the provider account after browser authorization. Starting a link or unlink requires a primary sign-in within ten minutes or the current password. Unlinking preserves a password or another configured provider; checking and deleting happen under a user row lock. Changes are audited without provider IDs, email addresses or tokens in the audit payload.

The shared account API provides `POST /account/oauth/link/start`, `GET /account/oauth/link/get?flow=…`, `POST /account/oauth/link/confirm` and `POST /account/oauth/delete`. A handoff expires after ten minutes, consumes its random ticket once and remains bound to its original browser credential or App token and two-factor version. The App token never enters the browser URL or signs into the browser. Authorization only prepares an encrypted provider profile; the authenticated original client must confirm before an identity is attached. New flows invalidate older pending flows, and the scheduler prunes handoffs after one day.

Google uses state validation and PKCE; Apple retains its nonce cookie and same-site callback bounce. Callbacks and handoff pages disable caching and referrers. Available providers require the existing Google or Apple configuration. For App handoffs, the configured web domain and port must match `WEB_BASE_URL`; distributed builds require HTTPS. The new `oauth_link` table is created by the normal reversible migration. Real Apple configuration, provider sign-in and browser return on iOS and Android still need acceptance testing.

## Admin roles and permissions

Full administrators can create staff roles in **角色與權限** and assign them through **會員 → 身份與權限**. Each of the 19 modules supports no access, read access or management access where available. Multiple roles combine using their highest permission. The administrator, moderator and member system roles cannot be rewritten. Existing moderator permissions and individual board overrides remain separate; a staff role does not grant private forum reading privileges on the website or App.

Module middleware authorizes every admin read and write before validation. Shared board, prefix, badge and moderator permission options permit only the specific reference reads needed by another authorized module. Read access filters navigation and disables editing controls. Dashboard audit previews also require audit read access; module links appear only when authorized. The private `GET /auth/access/get` endpoint refreshes current permissions every minute and after a forbidden admin request; it requires the current second factor. Stale responses cannot replace a different actor's permissions. Suspended accounts and impersonation cannot use the admin API.

Role changes, permission replacement and their audit record commit together. Version checks reject stale edits. Roles still assigned to members cannot be deleted. Role administration and assignment, impersonation, changes to staff accounts, global IP/email bans, global moderator rules, system settings writes, migration account recovery and Horizon access require a full administrator. Scoped member managers can change ordinary members. Dashboard, search, audit and analytics modules offer read access only; settings and migration management remain reserved. Announcement approval also checks the approving staff member's current broadcast management permission and TOTP version during queued delivery.

## Announcement campaigns

Verified staff with broadcast management permission can use **公告推播** to save a plain-text draft, select active members by role or member ID, preview recipient/device estimates, and confirm immediate or scheduled delivery. Read access permits inspecting campaigns and previews. Platform selection applies to mobile push devices; each matching member still receives a private in-app announcement. Preview counts are estimates: membership, device consent, token verification and quiet hours are checked during delivery. Linked topics must remain public and unrestricted. Web and App read the complete announcement through the owner-only `GET /notification/get` endpoint.

`notification:broadcast-dispatch` runs every minute through Laravel's scheduler. It starts due campaigns and recovers unfinished work on the existing `push` queue. Each batch creates at most 200 notices, saving the campaign cursor and delivery receipts in the same transaction. A recipient receives one in-app notice per campaign. Push receipts are published before being marked queued, so interrupted publication can be retried; push jobs are delivered at least once and may repeat after a crash. The mobile banner stays generic: **Post76 玩樂網 / 你有新通知**. Full text appears after opening the authenticated App page.

Scheduled edits return the campaign to draft and clear its previous approval. Cancellation stops later batches and pending push work; existing notices and already delivered pushes remain. Removing the approving staff member's broadcast management access or changing their status or TOTP version stops further delivery. Failed campaigns require newly verified staff with management access to explicitly retry the saved progress. Progress counts distinguish notices created, pushes queued, pending receipts and skipped recipients; queued counts do not confirm device receipt. Production delivery needs the existing asynchronous worker, scheduler and Firebase configuration. The sync queue processes one batch per scheduler pass.

## Container stack

`compose.yaml` defines Nginx, PHP-FPM, Horizon, the scheduler, Reverb, MySQL 8.4, Redis and Meilisearch. A separate `init` service waits for healthy data services, runs additive migrations and seeds only system roles. It does not create sample users, default administrator passwords or import legacy data. The website, admin and App API use three distinct hostnames.

The PHP and Nginx images share the same compiled Vite assets. Composer and npm use their lockfiles. Build context rules exclude environment files, private directories, uploads, local dependencies, caches and secret material. Application containers use a read-only filesystem, writable temporary directories and a persistent uploads volume. MySQL, Redis and Meilisearch have separate volumes and no published host ports. Nginx serves uploads through Laravel's protected routes; it has no uploads or legacy-file mount. Old `.php` URLs also reach Laravel's redirect handlers rather than being served as source files.

Secrets use file mounts. The host directory must remain mode `0700`; individual files use `0444` so the application, database and proxy users can read their separately granted mounts. Source directory permissions protect host access; Docker mounts each granted file read-only. `docker/create-secrets.php` creates a fresh directory atomically and refuses to overwrite an existing one. Preserve the current application key and database credentials when upgrading. Ordinary Compose does not support injecting environment-sourced secrets into read-only containers; these definitions therefore use file sources. See [Docker's secrets reference](https://docs.docker.com/reference/compose-file/secrets/) and [Compose's injection implementation](https://github.com/docker/compose/blob/v5.6.0/pkg/compose/secrets.go).

For a **new local or staging installation**, from `web/` on a host with Docker Engine and the Compose plugin:

```sh
cp docker/stack.env.example docker/stack.env
chmod 600 docker/stack.env
docker run --rm --user "$(id -u):$(id -g)" \
    --mount "type=bind,source=$PWD/docker,target=/setup" \
    --entrypoint php php:8.3.35-fpm-bookworm /setup/create-secrets.php
docker compose --env-file docker/stack.env -f compose.yaml config --quiet
docker compose --env-file docker/stack.env -f compose.yaml up --detach --build --wait --wait-timeout 240
```

The default endpoints are `http://web.post76.localhost:8080`, `http://admin.post76.localhost:8080` and `http://api.post76.localhost:8080/v1`. Port 8080 binds to loopback. Set the domains and public URL in the private `docker/stack.env` before using other hostnames. A fresh database has no accounts or forum content. Restore the existing application database, or complete the supported read-only legacy import, before creating accounts when retaining old member IDs.

Staging defaults to the `array` mailer, disables FCM, and skips news synchronization until a source is configured. To use the customer's sendmail channel, configure `POST76_MAIL_MAILER=sendmail` and the host relay's hostname, port, TLS mode and optional username. Containers use an msmtp sendmail-compatible client; `host.docker.internal` resolves to the host gateway. An authenticated relay reads `mail_password` from the private directory. The host MTA must accept the Docker bridge connection. Production rejects discarded/logged mail, non-HTTPS URLs and insecure cookies. Actual mail delivery and DNS authentication remain separate acceptance checks.

For **TLS**, install the PEM certificate chain and private key as `tls_certificate` and `tls_private_key` inside the private directory, retaining the directory and file permissions. The certificate must cover all three domains. The TLS override publishes port 443, configures the HTTPS public URL, secure cookies and the public Reverb endpoint, and rejects unknown TLS server names:

```sh
docker compose --env-file docker/stack.env -f compose.yaml -f compose.tls.yaml config --quiet
docker compose --env-file docker/stack.env -f compose.yaml -f compose.tls.yaml up --detach --build --wait --wait-timeout 240
```

The HTTP port remains loopback for health checks. Set `POST76_APP_ENV=production` only after configuring the real domains, certificate and delivery mailer. Set `POST76_HTTPS_PORT` if the public TLS port differs from 443. Redis and internal Reverb publishing stay on private container addresses; browsers and native clients receive only the public socket address. `REVERB_PUBLISH_HOST`, `REVERB_PUBLISH_PORT` and `REVERB_PUBLISH_SCHEME` default to the existing public settings outside this stack.

Add **legacy data** with `-f compose.legacy.yaml`. Fill `POST76_LEGACY_DB_HOST`, database, port and a SELECT-only username, and provide `legacy_db_password`. Set `POST76_LEGACY_ATTACHMENT_DIR` and `POST76_LEGACY_AVATAR_DIR` to the existing server directories. The mounts are read-only, require existing host paths and are available only to application services; no files are copied or publicly mounted. The override retains the new uploads volume. It does not run an import automatically.

Add **external integrations** with `-f compose.integrations.yaml`. Populate the appropriate non-secret IDs in the private environment file and replace the corresponding empty secret files with the configured Google client secret, reCAPTCHA secret, Apple `.p8` key or Firebase service-account JSON. Set `POST76_FCM_ENABLED=true` only when the Firebase project and App configuration are ready for delivery testing. The override retains existing core secret mounts and runtime settings. Adding all overrides uses:

```sh
docker compose --env-file docker/stack.env -f compose.yaml -f compose.tls.yaml \
    -f compose.legacy.yaml -f compose.integrations.yaml config --quiet
```

Default service limits total approximately 3.44 GiB, excluding the temporary initialization service. PHP-FPM has two workers; Horizon has one notification worker and one background worker. Meilisearch indexes on one thread with 256 MiB of indexing memory. Redis persists its queue with AOF and uses `noeviction`; capacity errors require attention rather than losing queued keys. Automatic RDB saving is disabled; the backup command explicitly saves the queue during maintenance. These are bounded starting values for the supplied 4 GB development host, not a verified capacity estimate for the full imported dataset. `POST76_*_MEMORY` overrides adjust service limits. Backup and monitoring preparation are described below; runtime capacity and alert delivery remain PF-14 acceptance work.

For an **upgrade**, preserve the project name and all volumes, back up the database and uploads, then build the new images. Stop the application services before running initialization so old workers cannot continue during the migration and the temporary service has memory available. Use the same override files selected for the installation in every command:

```sh
docker compose --env-file docker/stack.env -f compose.yaml build
docker compose --env-file docker/stack.env -f compose.yaml stop nginx app queue scheduler reverb
docker compose --env-file docker/stack.env -f compose.yaml run --rm --no-deps init
docker compose --env-file docker/stack.env -f compose.yaml up --detach --wait --force-recreate app queue scheduler reverb nginx
docker compose --env-file docker/stack.env -f compose.yaml ps
```

This is a single-host workflow with an interruption during the upgrade. Do not remove the persistent volumes. For administrative commands, `docker compose ... run --rm --no-deps app artisan <command>` loads the mounted secrets before invoking Artisan. Container configuration and route caches use a separate empty environment directory, including fresh Laravel application instances.

Local verification uses the official Compose 5.6.0 parser for the base file and merged overrides, synthetic secret fixtures, PHP and shell syntax checks, runtime configuration tests, and an isolated SQLite migration/seed/config/route/view cache run. Image compilation, container startup, persistent-volume behavior, TLS and memory use still require a running Docker daemon. No host deployment or real credential/delivery check has been performed; PF-12 remains partially complete.

## Encrypted backups and recovery

`ops/backup.py` requires Python 3.11 or newer, Docker Engine and the Compose plugin on a Unix host. It uses only the local Docker socket. `compose.backup.yaml` adds an on-demand Restic 0.19.1 utility with read-only sources, a read-only root filesystem, a 512 MiB memory limit and no published ports or container access to the Docker socket. The image is the third image in a prepared release. For a source installation, build the matching image tag before using operations:

```sh
docker build --target backup --tag post76-backup:local .
```

Use the installed stack's image prefix and tag if they differ. The operator must configure a credential-free HTTPS S3 endpoint/bucket/prefix in `POST76_BACKUP_REPOSITORY` and its region in the private Compose environment file. `s3:https://s3.example.invalid/post76-backup` illustrates the format. The dedicated `backup_s3_credentials` file must contain an explicit `[default]` profile with `aws_access_key_id` and `aws_secret_access_key`; temporary credentials also require their session token. Inherited AWS credentials, metadata services and alternate profiles are not used. No real provider has been configured or contacted. See [Restic repository setup](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html).

New secret fixtures include a random `backup_password` and an empty `backup_s3_credentials`. Existing installations must add these two files without regenerating application/database keys. Keep the private source directory at `0700` and the mounted files at `0444`, owned by the intended operator. Store the repository password separately in the operator's recovery vault: both backup files are deliberately excluded from captures, and losing that password prevents repository recovery.

The capture includes:

- The application MySQL schema/data in a single-transaction SQL dump, plus its byte count and SHA-256 in a manifest.
- The complete persistent Laravel uploads volume.
- An explicit Redis `SAVE` snapshot, including pending queues; the changing AOF files are excluded. Automatic RDB saves are disabled to keep this capture stable. [`SAVE` is synchronous](https://redis.io/docs/latest/commands/save/) and is used after the application has paused.
- The installed private key directory and Compose environment file, including the existing Laravel application key needed to decrypt stored data. The repository password and S3 transport credential file are excluded.
- Optionally, both old attachment and avatar trees with `--include-legacy`. Their configured directories must already exist. Without this option the manifest explicitly records legacy files as an external recovery dependency; the new uploads backup does not cover the old approximately 240 GB image collection.

Meilisearch indexes are derived data and require rebuilding after recovery. Other deployments or an old website writing to the same data/files must also be paused by the operator. Key rotation, imports and independent manual writes must stay outside the capture window.

Every command must retain the original Compose project name and all overrides selected for the installation. From `web/`, this TLS example initializes a new repository, performs a capture and lists completed captures:

```sh
python3 ops/backup.py --project post76 --env-file docker/stack.env \
    --compose-file compose.tls.yaml init-repository
python3 ops/backup.py --project post76 --env-file docker/stack.env \
    --compose-file compose.tls.yaml backup --quiesce
python3 ops/backup.py --project post76 --env-file docker/stack.env \
    --compose-file compose.tls.yaml list-snapshots
```

Add repeated `--compose-file` options for the installed legacy/integration overrides and use `--include-legacy` consistently for captures and restores containing old files. `--local-repository <private-directory>` is available for isolated acceptance tests; a repository on the same host does not provide offsite protection. Operations never prune or delete snapshots; a retention policy and provider lifecycle must be agreed before enabling a schedule.

**Captures require an agreed maintenance window.** `--quiesce` stops the originally running Nginx, PHP application, Horizon, scheduler and Reverb services, allowing workers up to 900 seconds to finish. MySQL, Redis and search remain running. The writers stay paused throughout the dump and file capture, then only originally running services resume. The first large legacy-file capture can therefore cause a long interruption. A shorter window for that dataset requires a separate filesystem snapshot/capture design and capacity measurements.

Plain SQL is staged only in a private `0700` workspace with `0600` files, then removed before services resume. A per-workspace lock rejects overlapping operations. Failed or partial Restic captures never receive the `verified` marker and cannot be restored through this CLI. That marker means the capture exited successfully; it does not substitute for a database recovery drill. Use the full 64-character snapshot ID printed after marking, because adding a Restic tag changes the ID. See [Restic exit-status handling](https://restic.readthedocs.io/en/stable/075_scripting.html).

If a service fails to resume, or the host loses power/the operation is forcibly killed, the private maintenance journal blocks another backup. Inspect the recorded state, resume with the same project/environment/images/overrides, and remove any abandoned private capture files after recovery:

```sh
python3 ops/backup.py --project post76 --env-file docker/stack.env \
    --compose-file compose.tls.yaml recover-services
```

`restore-files` accepts only a verified snapshot for that project and a new destination. It refuses symbolic-link entries, asks Restic to verify restored contents, rechecks the SQL checksum/size and requires the Redis snapshot. It preserves file ownership inside a private outer directory; upload files owned by container users may require administrative inspection. It does not import into the live database or replace volumes/keys. Use a full listed ID for `<snapshot-id>` and a fresh private path for `<new-directory>`:

```sh
python3 ops/backup.py --project post76 --env-file docker/stack.env \
    --compose-file compose.tls.yaml restore-files \
    --snapshot '<snapshot-id>' --destination '<new-directory>'
python3 ops/backup.py --project post76 --env-file docker/stack.env \
    --compose-file compose.tls.yaml check-repository
```

`restore-drill` uses the same arguments as `restore-files`, then imports SQL into a new MySQL 8.4 container with fresh credentials, no network and no published ports. It checks for imported tables and removes only its generated container/anonymous volumes. Run drills on a recovery host with at least 6 GiB of Docker memory; the supplied 4 GB live host is rejected. This is a starting resource bound, not proof that the full dataset fits. File recovery, SQL import and application acceptance remain distinct checks. Redis recovery also requires starting a separate instance from the restored RDB, verifying the pending queues and rebuilding AOF before enabling writers; do not mix the restored RDB with an unrelated old AOF. Meilisearch rebuilding, key continuity, uploads and application checks belong in the eventual cutover rehearsal.

The `ops/systemd/` files are **uninstalled templates**. They demonstrate a 03:20 Hong Kong nightly capture and a monthly full repository data check. Adapt `/srv/post76`, the operator user/group, private file ownership and selected overrides using `ops/operations.env.example`; protect the installed operations environment file at `0600` and create its workspace at `0700`. The nightly timer disables catch-up execution so a missed window does not unexpectedly stop the website. Review the window before installing/enabling any timer. A repository data check verifies encrypted storage; periodic SQL/application recovery drills still need scheduling on the recovery host.

Operations emit private `status.json` plus `metrics/backup.prom` with the last successful timestamp, last attempt, service-resume result, duration and maintenance state. They preserve the last good timestamp after failure and contain no member data or keys. The collector below includes these aggregate backup fields in its authenticated endpoint. Offsite provider acceptance, retention, periodic full recovery, legacy-file coverage and schedule activation remain pending, so PF-14 is partially complete.

Local verification passed 27 operations tests, including a real encrypted Restic roundtrip with synthetic SQL, keys, uploads and Redis fixture bytes, checksum verification, a fresh SQLite import, a full repository data check and wrong-password rejection. Mocked tests cover pause/dump/Redis/capture/resume failures, recovery journals, unsafe restore entries and isolation. The native fixture does not establish MySQL or Redis server recovery. The Docker CI job contains an encrypted capture/MySQL drill gate, but it has not run locally because the Docker daemon is unavailable. No production files, credentials or services were accessed, and no timer was activated.

## Operational monitoring preparation

`compose.monitoring.yaml` is an independent monitoring project with Prometheus 3.13.4 LTS, Alertmanager 0.34.1, Grafana 13.2.3 and an optional authenticated Nginx exporter. `compose.errors.yaml` adds GlitchTip 6.2.6 and a separate PostgreSQL database. These files prepare a local installation; container startup, dashboards, HTTPS access and actual alert delivery remain acceptance checks. See the [Prometheus configuration reference](https://prometheus.io/docs/prometheus/latest/configuration/configuration/) and [GlitchTip installation guide](https://glitchtip.com/documentation/install/).

**Use separate monitoring capacity.** All six configured limits total 1,504 MiB, taking application plus monitoring limits to approximately 4.91 GiB before the host OS and temporary work. They cannot fit the supplied 4 GB application host. A separate monitoring host can run Prometheus, Alertmanager, Grafana and GlitchTip while the application host runs only the 32 MiB exporter. Alternatively, upgrade the host and measure startup, steady-state and imported-dataset peaks before activation. The collector's PHP process has a 96 MiB limit inside the existing application container; its peak also needs measurement.

### Aggregate collection

Collection requires opting in with `POST76_METRICS_ENABLED=true` in the application's private Compose environment file and recreating the affected application containers using the installed images and selected overrides. The default is disabled. Application metrics have a fixed shape: seven queue names, ready/delayed/reserved/expired-lease counts, failed-job counts and response status classes for the three application domains. Queue payloads, error messages, SQL bindings, member identifiers, request paths, headers and bodies are excluded. No public Laravel metrics route is added.

HTTP gauges summarize **five completed minute buckets**. They count Laravel's final rendered responses, including reportable failures; they do not include Nginx-generated 502/504/413 or static-file responses. Edge response instrumentation remains development work. If response counting fails, a private timestamp marks the HTTP window incomplete for six minutes. Collection then fails visibly; it does not publish an apparently complete zero-error window.

The host collector uses the existing project name, private environment file and installed overrides through the local Unix Docker socket. It does not need a backup repository or credentials. The following is an operator command, from `web/`, after enabling the flag:

```sh
python3 ops/metrics.py --project post76 --env-file docker/stack.env \
    --compose-file compose.tls.yaml --work-dir "$PWD/ops/workspace"
```

Use the same workspace as backups to include maintenance and last-success fields. Add installed legacy/integration overrides with repeated `--compose-file` options. The collector reads service health and filesystem capacity for MySQL, Redis, search, uploads and its workspace. It atomically publishes `metrics/metrics.prom` at `0644` inside a `0755` leaf directory; its outer workspace and state remain private at `0700`/`0600`. Mount only that aggregate leaf into the exporter.

If application or capacity collection fails, previous values retain their original successful timestamp and are marked stale. Fresh service status remains visible independently. An initial failed collection does not invent queue or disk samples. Collector and backup locks are separate so maintenance remains visible. The uninstalled `post76-metrics.service` and timer demonstrate a collection every 30 seconds using `ops/operations.env.example`; adapt paths and user permissions before any installation. No schedule has been activated.

### Exporter and central monitoring

Generate a **new monitoring key directory**, separate from existing application keys, and copy the public environment example. The helper refuses an existing directory and prints no key values:

```sh
php docker/create-monitoring-secrets.php docker/monitoring-secrets
cp docker/monitoring.env.example docker/monitoring.env
chmod 600 docker/monitoring.env
```

The generated directory is `0700` with four individually mounted `0444` files. Preserve its metrics access key, Grafana administrator password and GlitchTip application/database keys. Do not recreate the application key directory. Set `POST76_METRICS_DIR` to the already collected absolute aggregate directory; the mount refuses to create a missing source. A local installation on a host with sufficient capacity uses:

```sh
docker compose --env-file docker/monitoring.env -f compose.monitoring.yaml \
    --profile local-source config --quiet
docker compose --env-file docker/monitoring.env -f compose.monitoring.yaml \
    --profile local-source up --detach
```

For a **separate monitoring host**, Arfu can start only the exporter service on the application host:

```sh
docker compose --env-file docker/monitoring.env -f compose.monitoring.yaml \
    --profile local-source up --detach metrics-exporter
```

It binds to `127.0.0.1:9107` and accepts `GET /metrics` with a Bearer key; `/health` is public and all other paths are rejected. Its access log is disabled. Expose the endpoint through a dedicated HTTPS proxy or private transport with appropriate access controls, preserving the Authorization header. Public routing, certificate setup and reachability require operator configuration; do not expose port 9107 directly. Securely provide the same metrics key to the central collector's mounted file, retaining the other host's independent administrator/database keys. On the central host, prepare a fresh public target configuration:

```sh
python3 ops/render_monitoring.py --endpoint https://metrics.example.invalid/metrics \
    --destination docker/monitoring/prometheus.remote.yml
```

Replace the example hostname, set `POST76_PROMETHEUS_CONFIG` to that file, and start `compose.monitoring.yaml` **without** the `local-source` profile. The generator requires credential-free HTTPS `/metrics`, verifies TLS and loads the Bearer key from a file. The example's aggregate directory setting is unused on the central host.

Prometheus, Grafana and GlitchTip ports bind to loopback at 9090, 3000 and 8009; their TLS/admin access needs operator setup. Grafana disables anonymous access, signup and telemetry, and provisions an eight-panel dashboard. No monitoring service mounts the Docker socket or application database. A prepared release contains the public monitoring files but exports only the three application/backup images; monitoring images must be available separately.

### Alerts and error tracking

Two freshness recording rules support twelve alerts: unavailable exporter, failed/stale collector, unhealthy services, warning/critical disk capacity, ready-job backlog, expired leases, failed jobs, Laravel HTTP 5xx ratio, stale backup, failed resume and prolonged maintenance. Disk thresholds start at 15% or 5 GiB for warning and 5% or 1 GiB for critical. Ready backlog starts at 100; intentionally delayed jobs are excluded. HTTP warning needs at least 20 responses and more than 5% 5xx. Stale data suppresses value-based alerts; backup maintenance suppresses expected interruption alarms while failed resume remains visible. Adjust the one-hour maintenance threshold for a measured first legacy-file capture.

Alertmanager currently has an **unconfigured receiver with no delivery action**. Email/webhook recipient, relay settings and a controlled delivery acceptance check are still needed. The monitoring configuration does not restart or repair application services automatically.

The optional `errors` profile starts GlitchTip and its independent PostgreSQL database using the merged files:

```sh
docker compose --env-file docker/monitoring.env -f compose.monitoring.yaml \
    -f compose.errors.yaml --profile errors up --detach
```

Set `POST76_GLITCHTIP_URL` to its eventual HTTPS URL before use. The startup wrapper reads only its separately mounted files, constructs database connection settings inside the process and disables developer bootstrap, public signup and optional uptime/log/MCP features. Email uses a non-delivering console backend. Administrator/project setup, dedicated DSNs, ingestion and notification delivery remain pending. Monitoring volumes and its private keys are outside the application's backup scope; the operator must define their retention and recovery separately.

The backend includes the official Sentry PHP SDK 4.34.0, compatible with GlitchTip. `POST76_ERROR_REPORTING_ENABLED` defaults to false. Laravel's reportable HTTP/console/queue exceptions retain their normal local logging and can produce a separate sanitized event when enabled; expected authentication and not-found errors retain Laravel's normal exclusions. The SDK does not install global integrations, request capture, tracing, profiling, breadcrumbs, logs, metrics or source-code reading.

Every event is rebuilt at the SDK boundary. It keeps only exception types, at most five linked exceptions and fifty relative PHP code frames per exception, deployment revision, a fixed environment name and two bounded backend tags. Messages, SQL/bindings, arguments, local variables, absolute paths, source lines, request/user/context data and attachments are omitted. Errors with the same type and code location may be grouped together because their messages are removed. Reporting is synchronous with a one-second HTTP timeout; transport/configuration failure produces a generic local warning and preserves the application error. Long-running workers reuse their SDK client, including its server rate-limit state. This does not replace a measured event retention/quota policy.

For operator activation, create the GlitchTip project and place its HTTPS DSN in a private `error_dsn` file. New installation keys include an empty placeholder; existing installations must add the empty `error_dsn` and `error_browser_dsn` placeholders before selecting the telemetry override and retain all existing keys. Keep the containing directory `0700` and the individually mounted file `0444`. Set `POST76_ERROR_DSN_PATH` to the file, `POST76_RELEASE` to the full application revision, and enable the flag in the private Compose environment file. Add `-f compose.telemetry.yaml` alongside the installed overrides when validating/recreating the application, queue, scheduler and Reverb services, and retain it in subsequent operations commands. The DSN is resolved from the mounted file inside the process. No real DSN has been installed, and no error event has been sent. Confirm sanitized ingestion and failure latency on staging before production activation.

The browser uses the official Sentry JavaScript SDK 11.6.0 with separate configuration. `POST76_BROWSER_ERROR_REPORTING_ENABLED` defaults to false; the disabled web/admin shells publish no DSN and install no reporting handlers. When enabled, `error_browser_dsn` supplies a dedicated **public ingestion DSN**; it is visible in the page. Keep administrator credentials, private API tokens and the backend DSN out of this field. Set `POST76_BROWSER_ERROR_DSN_PATH` and retain `compose.telemetry.yaml` with the installed overrides. Existing installations need the two empty `error_dsn` / `error_browser_dsn` placeholders before using that override; preserve their other keys.

Vue, window errors and unhandled rejections retain their normal handling. Both the event hook and transport rebuild each event, accepting only one error event and refusing attachments or other event types. Frames retain only known JavaScript filenames from the current Vite manifest, numeric line/column positions and built-in error types. Full URLs, query strings, function names, messages, users, request/context data and breadcrumbs are removed. Reporting buffers at most ten sanitized early events, accepts twenty reports per minute, reuses the SDK's server rate limits and limits fetch to two seconds with cookies/referrer omitted. The SDK loads dynamically when enabled; its current build is about 19 KiB gzip and contains no replay/feedback modules. Test ingestion, permitted browser origins/CORS, CSP and reverse-proxy logging on staging before activation. Client flags and rate limits do not secure a public ingestion endpoint; define server quotas, retention and access controls.

The Flutter App also includes a disabled-by-default Dart SDK 10.0.0 adapter. Its separate compile-time public ingestion DSN, filename generation, privacy limits and main-isolate coverage are documented in [the App README](../app/README.md#error-reporting). Native fatal/OOM crashes and background isolates require separate acceptance work. No real DSN has been installed or error event delivered by these checks.

Local verification passed 38 operations tests (27 backup/recovery plus 11 monitoring), 19 official `promtool` alert scenarios, configuration syntax, aggregate exposition, remote HTTPS target generation, and six-service Compose parsing. PHP tests cover disabled collection, atomic counters, both Redis client call shapes, final response status, malformed hosts and failure windows. Nine backend error tests and three browser-shell tests verify privacy and disabled configuration; eight browser and eight Flutter error tests inspect actual SDK envelopes using memory-only transports or fake HTTP responses. The complete backend suite passed 798 tests, the browser/document suite 16, and the App suite 209; PHPStan, Flutter analysis, Vue build and six pipeline tests passed. Android debug APK and unsigned iOS simulator builds also passed; iOS uses the existing CocoaPods lockfile with an explicit project setting, while SwiftPM migration remains a separate check. Five application services also parse with both reporting flags disabled and separate synthetic DSN mounts. CI includes metrics capture, rule/config checks and an exporter Nginx syntax gate; those container checks remain unexecuted locally because Docker Engine is unavailable. No real monitoring keys, application data, receivers or server services were accessed.

## CI and release preparation

The web repository contains two GitHub Actions workflows. `Web quality` runs on pull requests, pushes to `main`, and manual requests. It installs the Composer and npm lockfiles without Composer scripts, checks PHP/JavaScript/shell/Python syntax, runs Larastan/PHPStan at level 5 for PHP 8.3, runs PHPUnit and the JavaScript/pipeline tests, builds both Vue applications, checks OpenAPI, and exercises migration, role-only seeding and config/route/view caches against a separate SQLite database. The committed baseline records **1,061 existing application/seeder type warnings**; CI rejects additional warnings and stale baseline entries. It does not regenerate the baseline. Laravel's `casts()` methods are analyzed so enums and dates retain their types.

The `ci/` PHP entry points force synthetic configuration and a private empty environment directory, including disabled error reporting and empty Post76/Sentry DSNs. Analysis, tests and cache checks have separate cache paths; they do not load the developer's environment file. Vite uses `ci/vite.config.mjs`, which disables environment-file loading. Composer runs in a private CI home. Its platform targets PHP 8.3.35 to match the application image; the lockfile uses compatible Symfony 7.4 and Apple sign-in clock dependencies. Both lockfiles are explicitly allowed by this repository's `.gitignore`; include them when committing the pipeline.

After quality checks pass, a separate job builds the PHP, Nginx and backup targets and checks a disposable Compose project. It uses fresh fixture keys, the local Unix Docker socket, a private Docker configuration, a unique project name and a loopback port. It waits for healthy services, checks the API and blocked private paths, recreates the application to check upload persistence, captures an encrypted backup and imports its restored SQL into a separate MySQL container without networking. It also collects aggregate metrics and checks alert rules, Alertmanager configuration and exporter syntax using synthetic fixtures. Cleanup removes only generated test containers and volumes. Test reports contain no mounted keys or configuration caches. This job still requires an actual Docker run; local checks do not establish that container acceptance has passed.

`Prepare web release` is manual and accepts only `main`. It reruns the same quality and container jobs before exporting the three checked images with the full commit SHA as their tag. The release artifact contains `images.tar.gz`, the Compose files, explicitly listed public setup and operations files, `release.json` and `SHA256SUMS`. It excludes private environment files, secret directories, uploads and caches. Workflow actions are pinned to verified upstream commit SHAs, checkout does not persist credentials, and the workflows have read-only repository permissions.

For a prepared artifact, Arfu can verify and load it on the intended host, from the extracted release directory:

```sh
sha256sum --check SHA256SUMS
docker load --input images.tar.gz
```

Set `POST76_IMAGE_TAG` in that host's private environment file to the exact revision in `release.json`. For an upgrade, with existing healthy data services and the selected TLS/legacy/integration overrides in every command, use:

```sh
docker compose --env-file docker/stack.env -f compose.yaml stop nginx app queue scheduler reverb
docker compose --env-file docker/stack.env -f compose.yaml run --rm --no-deps init
docker compose --env-file docker/stack.env -f compose.yaml up --no-build --detach --wait --force-recreate --no-deps app queue scheduler reverb nginx
docker compose --env-file docker/stack.env -f compose.yaml ps
```

The bundle uses its exported images; it does not include Docker build sources. The host retains its existing project name, database, uploads and secret files. Back up before the upgrade. Container image import does not migrate data or restart services by itself. A fresh installation instead uses `up --no-build --detach --wait` for the complete stack after setting up its private files.

SSH deployment, registry publishing, repository/environment approval settings and an executed GitHub run remain pending; this workflow currently prepares a reviewable release artifact. PF-13 remains partially complete. No workflow has been pushed or triggered, and no server has been contacted. Configure the deployment destination and its protected access separately before connecting a release to that host.

Local quality commands, from `web/` with the locked dependencies installed:

```sh
python3 ci/lint.py
php ci/run.php analyse
php ci/run.php test
node --test tests/js/realtime.test.mjs resources/js/web/editor/tableDocument.test.mjs
python3 -m unittest discover -s ci -p "test_*.py"
npx --no-install vite build --config ci/vite.config.mjs
php ci/artisan.php api:openapi --check
```

## Background workers

Laravel Horizon and Predis are installed. With `QUEUE_CONNECTION=redis`, use `CACHE_STORE=redis` for shared locks and run `php artisan horizon`. Redis connection settings come from Laravel's existing Redis configuration. The dashboard is at **管理後台 → 背景工作** (`/operations/queues`) and requires an active administrator, verified TOTP and an original session. Local environments enforce the same access checks. The dashboard uses the admin domain unless `HORIZON_DOMAIN` is explicitly configured.

The notification worker consumes `realtime`, `push`, `mail` and `default`; the background worker consumes `search`, `media` and `migration`. Each has one process by default. Queued writes become visible after their database transaction commits. The 900-second minimum reservation lease exceeds the largest worker timeout. Retry limits and backoff apply to mail, push, migration invitations and image variants. Daily digests queue one job per recipient, suppress duplicate pending jobs and check current preferences and visibility before sending. Scout indexing uses the `search` queue.

Uploads keep an immediately usable original. JPEG orientation and EXIF removal finish before publication; variants are generated on the `media` queue. Missing variants fall back to the original with a short cache lifetime. Duplicate jobs avoid regenerating variants, and deletion during rendering cleans up generated files. Legacy image originals stay untouched.

The database queue remains available for local development without Redis:

```sh
php artisan queue:work --queue=realtime,push,mail,default,search,media,migration --tries=3 --timeout=660
php artisan schedule:work
```

`composer dev` chooses Horizon for Redis or the named database queues automatically in a local environment. Horizon metrics are scheduled every five minutes when Redis is selected; old failed jobs and batches are pruned daily. Deployment still needs persistent Redis, a supervised worker and scheduler, and real email/push delivery checks.

## Live badges and replies

Laravel Reverb is installed. The website, admin and native App subscribe through the Pusher protocol. Notification and private message changes enqueue small invalidation hints on the `realtime` queue after commit. Clients fetch current unread counts through the authenticated API. Thread hints prompt the reader to open the newest visible floor and preserve their unfinished reply. Reconnects fetch current state, with a 60-second polling fallback.

Configure `BROADCAST_CONNECTION=reverb`, an application ID/key/secret, `REVERB_HOST`, `REVERB_PORT`, `REVERB_SCHEME` and `REVERB_ALLOWED_ORIGINS`. The public key is safe to send to clients; the secret stays on the server. The origin list contains the web and admin hostnames, including the App's `WEB_BASE_URL` hostname. The App obtains the socket address from `/realtime/config/get` and sends that website origin. Use `https` with a public TLS reverse proxy for distribution; loopback `http` is supported for local checks. `REVERB_SERVER_HOST`/`PORT` control the listener separately from the address clients and broadcast jobs use. Configure matching application values on the Reverb listener and Laravel workers.

```sh
php artisan reverb:start
php artisan queue:work --queue=realtime,push,mail,default,search,media,migration --tries=3 --timeout=660
```

Horizon includes `realtime` when Redis is used. In a local environment, `composer dev` starts Reverb once when broadcasting is configured and starts the selected worker. Disabled broadcasting keeps polling available. The server restricts origins and rejects client-originated events. Personal subscriptions require the current actor and two-factor proof version; thread authorization repeats the HTTP reading policy. Suspended users and impersonation cannot obtain private tickets. Hints contain no counts, message text, post text or titles. Delivery checks the current proof version and public thread visibility again. Logout, pending verification, backgrounding and the App's biometric lock close subscriptions; stale responses cannot restore another actor's badge.

Local verification covers actual queued delivery through Reverb, native Dart sockets, desktop/mobile website and admin badges, reconnection, draft preservation, policy gates and transaction rollback. The hosted TLS endpoint, supervised listener/worker and iOS/Android device acceptance still need deployment checks.

## About Laravel

Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:

- [Simple, fast routing engine](https://laravel.com/docs/routing).
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).

Laravel is accessible, powerful, and provides tools required for large, robust applications.

## Learning Laravel

Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.

In addition, [Laracasts](https://laracasts.com) contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.

You can also watch bite-sized lessons with real-world projects on [Laravel Learn](https://laravel.com/learn), where you will be guided through building a Laravel application from scratch while learning PHP fundamentals.

## Agentic Development

Laravel's predictable structure and conventions make it ideal for AI coding agents like Claude Code, Cursor, and GitHub Copilot. Install [Laravel Boost](https://laravel.com/docs/ai) to supercharge your AI workflow:

```bash
composer require laravel/boost --dev

php artisan boost:install
```

Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices.

## Contributing

Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).

## Code of Conduct

In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).

## Security Vulnerabilities

If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.

## License

The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
