Repository source: docs/runbooks/production_deploy.md
Production Deploy Runbook
For operator phone-to-Railspike intake, see deployment and Codex worker configuration.
This runbook describes the intended production deployment model for the PHP forum rewrite.
Deployment Model
The intended production shape is:
Apache serves
public/as theDocumentRootPHP handles dynamic requests through
public/index.phpApache directly serves only existing
/assets/*files andfavicon.icothe PHP front controller serves eligible pages from an atomically selected static release
the canonical writable repository lives outside
public/derived state under
state/is writable by the web user
This application assumes a conservative shared-host style environment with:
PHP 8.1+
PDO SQLite
mbstring
standard filesystem functions
shell access sufficient for non-interactive
gitcommandsApache with
mod_rewrite
Documentation uses the project-owned renderer; no third-party Markdown bundle or Composer step is required. After renderer updates, refresh generated Docs pages and verify the reported document. See Markdown rendering, regeneration and rollback.
Required Directory Layout
One workable layout:
/srv/forum-rewrite/
app/ application checkout
public/
src/
templates/
scripts/
repository/ writable canonical content checkout
records/
.git/
state/
cache/
private/
static_html/
releases/
current -> releases/release-... active forum HTML release
static_html-docs/
releases/
current -> releases/docs-... active complete docs library
forum-rewrite.lock
read_model_stale.json
Recommended mapping:
application root:
/srv/forum-rewrite/appApache
DocumentRoot:/srv/forum-rewrite/app/publicwritable repository root:
/srv/forum-rewrite/repositoryread-model database:
/srv/forum-rewrite/state/cache/post_index.sqlite3static release root:
/srv/forum-rewrite/state/static_html
Optional runtime setting:
FORUM_EXECUTION_LOCK_TIMEOUT_SECONDS: seconds a request waits for the shared write/read-model lock before returning a busy error. The default is5. It may also be set in the private config file (../forum-private/secrets.php); an environment value takes precedence. Prefer the private config file overSetEnvinpublic/.htaccess, which is tracked and blocksgit pullwhen edited.FORUM_UNICODE_AUTHORED_TEXT: when set totrue, subject/body prose may contain visible UTF-8 text such as Cyrillic. The default is disabled.FORUM_APP_VERSION_NOTIFICATION: when set tofalse, disables browser-side app version polling and the reload banner. The default is enabled.LLM_CONVERSATION_RECORDING_ENABLED: controls private exact-prompt/response capture. The default is enabled.LLM_CONVERSATION_UI_ENABLED: controls approved-user/operator web visibility of captured exchanges. The default is enabled.LLM_EXCHANGE_DATABASE_PATH: optional private SQLite path; defaults to<application-root>/state/private/llm_exchanges.sqlite3.VISITOR_STATISTICS_DATABASE_PATH: optional private SQLite path for aggregate visitor statistics; defaults to<application-root>/state/private/visitor_statistics.sqlite3.FORUM_TASK_QUEUE_DATABASE_PATH: optional private SQLite path for queued internal maintenance; defaults to<application-root>/state/private/internal_tasks.sqlite3.FORUM_TASK_QUEUE_EMERGENCY_LAUNCH_ENABLED: optionaltrueenables one detached, allowlisted queue-worker launch when a classified schema recovery finds no fresh executor heartbeat. Leave it unset on hosts that prohibit child processes.FAST_SCORING_DATABASE_PATH: optional private SQLite score-state path; defaults to<application-root>/state/private/fast_scores.sqlite3.FAST_SCORING_AUTOMATIC_ENQUEUE_ENABLED: whentruealongsideFAST_SCORING_ENABLED, creates private score work for newly published posts only; defaults tofalse.PRIVATE_MESSAGE_DATABASE_PATH: optional private SQLite mailbox path; defaults to<application-root>/state/private/messages.sqlite3.PRIVATE_MESSAGE_HISTORY_SYNC_DATABASE_PATH: separate private history-transfer SQLite path; defaults to<application-root>/state/private/message_history_sync.sqlite3. Must resolve to a different file from the mailbox.
Writable Paths
The web user must be able to write:
the canonical repository checkout at
FORUM_REPOSITORY_ROOTthe parent directory of
FORUM_DATABASE_PATHthe lock file directory next to
FORUM_DATABASE_PATHFORUM_STATIC_HTML_ROOTand itsreleases/directoryits
-docssibling andreleases/directory for the docs publisherstate/private/agent-reply/under the application root if agent reply fulfillment is enabledthe parent directory of
LLM_EXCHANGE_DATABASE_PATHif LLM conversation recording is enabledthe parent directory of
VISITOR_STATISTICS_DATABASE_PATHwhen visitor statistics are in usethe parent directory of
FORUM_TASK_QUEUE_DATABASE_PATHwhen the internal task queue is enabledthe parent directory of
FAST_SCORING_DATABASE_PATHwhen Fastmod sweeps are enabledthe parent directories of
PRIVATE_MESSAGE_DATABASE_PATHandPRIVATE_MESSAGE_HISTORY_SYNC_DATABASE_PATHwhen private messaging is enabled
Static HTML is derived state. A write removes the current release pointer, so
subsequent public requests use PHP until a fresh complete release is published.
Old release directories are retained and are never edited in place. Docs have
a separate lock and pointer; forum writes, imports, builds and cleanup do not
render or bulk scan/copy docs. A small legacy reference retains docs from an old
combined release until independent publication succeeds.
Environment Variables
Use these in production:
FORUM_REPOSITORY_ROOTFORUM_DATABASE_PATHFORUM_STATIC_HTML_ROOT
Suggested values:
FORUM_REPOSITORY_ROOT=/srv/forum-rewrite/repository
FORUM_DATABASE_PATH=/srv/forum-rewrite/state/cache/post_index.sqlite3
FORUM_STATIC_HTML_ROOT=/srv/forum-rewrite/state/static_html
FORUM_STATIC_HTML_ROOT defaults to <application-root>/state/static_html.
It is not under public/; Apache continues to route content requests through
PHP, which selects the forum current release or the independent docs release
for /docs/*, its retained assets and /api/docs-version. Keep these routes
through PHP, including requests with cookies and private-site profiles; docs are
public anonymous content. Do not expose either release directory as a general
public file tree.
Content routing and image storage
The front controller evaluates membership and image feature flags before serving
content, including QDB hot paths. The checked-in .htaccess no longer serves
QDB artifacts directly through public/.static; remove custom direct-content
shortcuts. Matching generated HTML still uses the PHP static fast path.
Image support is default off. Before enabling it, configure a private image root, PHP upload/request limits and temporary export capacity, then verify backup and restore. Follow Image backup and recovery. Text remains usable on HTTP; image authoring requires HTTPS or localhost Web Crypto. Do not expose the image directory through Apache aliases, symlinks or a public CDN.
Safe Deployment Procedure
1. Deploy the application code first. Do not copy generated HTML into `public/`.
This version deliberately ignores old sibling `public/*.html` files, so the
application remains available through its dynamic path before the first new
release is ready.
2. From the deployed application directory, build and publish the forum
snapshot:
```bash
time ./v3 build-static
```
The command builds a candidate SQLite model and candidate HTML directory
outside the live request path, then replaces the database and `current`
release pointer only after validation. It may take time proportional to the
repository, but it must not hold the live read-model write lock while it
walks Git history or renders pages.
For a presentation-only deployment with an already active release, use the
fast shared-page refresh instead:
```bash
time ./v3 build-static --shared-only
```
It preserves the existing tag, thread, post, and profile pages, refreshes
shared pages and their fingerprinted assets, and does not rebuild data. It
is not a substitute for the complete build after content changes.
Publish the complete docs library separately on initial setup and after docs,
renderer, profile or asset changes, with the same destination/profile settings:
```bash
time ./v3 docs publish
# Explicit destinations (also supports --json):
./v3 docs publish --repository-root="$FORUM_REPOSITORY_ROOT" --static-html-root="$FORUM_STATIC_HTML_ROOT"
```
Docs publication changes only the sibling docs release. Failure retains the
prior library, including a legacy combined-release library; fix the cause and
retry the same command. A forum build/shared refresh/import never repairs docs.
Check `/docs/`, a reader/download, and `/api/docs-version`; an open docs page
should offer Reload after docs publication, without prompting for forum imports.
3. Smoke-test both an anonymous window and an existing signed-in browser:
`/`, a thread, `/account/key/`, and `/invites/`. A normal reload must be
sufficient; HTML revalidates with an ETag, while fingerprinted assets are
immutable and versioned.
4. Confirm the active release rather than editing it:
```bash
readlink "$FORUM_STATIC_HTML_ROOT/current"
readlink "${FORUM_STATIC_HTML_ROOT}-docs/current"
```
If the publish command fails, the previous current release remains active.
If a content write occurs during or after a publish, its static pointer is
withdrawn and PHP serves current content until the next successful publish.
Do not run the old static-build script from a pre-release checkout: upgrade the
code before invoking ./v3 build-static.
Manual OpenPGP Release Check
Run these checks manually after a release. They are intentionally excluded from CI, cron, browser page loads, and automatic deployment hooks.
1. First run the read-only asset gate from a machine that can reach the public
site:
```bash
./v3 openpgp smoke
```
It must report `PASS` for both HTTP (selected OpenPGP v5) and HTTPS
(selected OpenPGP v6), as well as both legacy raw bundle URLs. A redirect,
non-JavaScript content type, missing runtime map, or failed request is a
release failure. Repair and redeploy before continuing; do not compensate
by clearing browser storage or changing Apache/vhost configuration.
For a staging investigation, use the staging hostname instead:
```bash
./v3 openpgp smoke --origin=staging.example
```
Then use separate fresh browser profiles to submit a disposable post through
both `http://staging.example/compose/thread` (v5) and
`https://staging.example/compose/thread` (v6). If identity bootstrap fails,
allow the existing automatic retry to finish and record the browser's
diagnostic code and attempt ID. Join that ID to the
`identity_bootstrap_signature_verification_failed` server event; retain only
its safe attempt, bundle, retry, exit-result, `VALIDSIG`, and GnuPG
status-code fields. Do not copy key armor, signatures, canonical records,
raw GnuPG output, or user IDs into an issue. A successful path should create
one staging identity and post, then reuse that identity without another
username prompt.
2. Only after the asset gate passes, run the visible new-user canary exactly
once. From the operator workstation, install the pinned browser driver once
for the checkout, then pass the local Chromium executable explicitly:
```bash
npm install
./v3 openpgp canary --confirm-production-write
```
The command automatically uses `which chromium`. Use any locally installed
Chromium or Chrome executable; no Snap install is required. Supply
`--browser-executable=/path/to/chromium` only if the automatic lookup is
unsuitable. `--headed` is available when a visible browser window is useful.
The command uses a fresh isolated
**HTTP** browser profile, creates a release-check identity, publishes
“New release just dropped, making sure it works,” reloads it, and verifies
that the same identity is ready without a second username prompt.
The username is consistently `release-check`; each run still generates a
fresh browser-held keypair because it uses an isolated browser profile.
The identity and post are intentional durable community records. If the
command reports an ambiguous failure after posting, do **not** rerun it:
inspect its reported post URL and browser output first. HTTP and HTTPS have
separate browser storage, so this HTTP canary does not substitute for an
existing HTTPS identity.
Public Session Restoration
Public pages remain readable without a viewer session. A browser that already has an authenticated session resumes it on ordinary HTML navigation. When that session is absent, a browser with a usable saved approved-member key can prove key possession and restore its session automatically; writes and API requests are never retried automatically. Invitation issuance continues to require the restored session plus its existing browser signature verification.
LLM exchange records are private runtime data. Keep LLM_EXCHANGE_DATABASE_PATH outside public/, the canonical repository, and the published read-model database; restrict the file to the deployment/web users. The exchange UI is available only to approved viewers and can be disabled independently with LLM_CONVERSATION_UI_ENABLED=false.
Internal task-queue records are also private runtime data. Keep FORUM_TASK_QUEUE_DATABASE_PATH outside public/, the canonical repository, and the published read-model database. The queue is deliberately separate from the rebuildable read model so a rebuild task retains its state and final outcome. Its executor history, detached-launch outcomes, and rebuild checkpoints live in that same private database; terminal history is bounded while active work and automatic-recovery circuit state are retained.
Visitor statistics are private runtime data. Keep VISITOR_STATISTICS_DATABASE_PATH outside public/, the canonical repository, and published artifacts. It retains only bounded UTC hourly aggregates for up to 90 days: eligible-request counters, anonymous/server-authenticated request counters, and mergeable client/authenticated-user cardinality sketches. It never retains raw visitor, session, key, identity, route, referrer, header, or network records. A deployment begins a new hourly collection epoch; legacy daily aggregates are not converted into hourly history. Its protected page reports only server-observed eligible traffic; direct edge/static delivery outside PHP is not included.
Site Profile
FORUM_SITE_ID selects which SiteProfileRegistry entry (site name, default theme, composer copy) this deployment renders. Values: zenmemes, chouse, qdb. Unset, empty, or unrecognized values resolve to zenmemes.
Set it alongside the three path variables above, per vhost:
FORUM_SITE_ID=zenmemes
It only selects branding — it does not select content or database paths. Those remain on FORUM_REPOSITORY_ROOT, FORUM_DATABASE_PATH, and FORUM_STATIC_HTML_ROOT, set independently per vhost. A vhost with a mismatched FORUM_SITE_ID and content paths is a misconfiguration, not a supported mode; the two must be kept paired by whoever edits the vhost config.
Precedence and rollback match the other FORUM_* variables in this runbook: environment variable wins, code default (zenmemes) applies when absent, and rollback is deleting the SetEnv line.
For local/CLI runs, set the same variable before the command:
FORUM_SITE_ID=chouse ./v3 start
Omitting FORUM_REPOSITORY_ROOT and FORUM_DATABASE_PATH uses the same default local instance state for every site profile. Only the disposable static HTML cache remains profile-specific by default, preventing rendered presentation from crossing profiles. Use explicit repository and database paths when developing genuinely separate instances from one checkout.
LLM Provider Config
Post analysis and agent reply drafting use provider-neutral LLM_* private config. Create or inspect the private config with:
./v3 private-config --force
./v3 private-config view
./v3 private-config refresh-template
Use refresh-template after upgrades to rewrite the private config with current comments and provider examples while preserving existing effective values.
Private Message Mailbox Operations
PRIVATE_MESSAGE_DATABASE_PATH holds encrypted message envelopes and routing
metadata. It is authoritative runtime state: unlike the read model and static
artifacts, it cannot be rebuilt from the canonical repository. It must remain
outside the document root, canonical repository, static release root, and
offline snapshot tree. Encrypted history transfers and sync checkpoints/receipts
live separately in PRIVATE_MESSAGE_HISTORY_SYNC_DATABASE_PATH. Preserve both
stores: losing retained transfers can remove a newer device's historical access
when no donor remains. The sync store initializes lazily and can fail without
blocking ordinary messaging. See the history-sync API and operations guide.
Use a dedicated directory readable only by the deployment and web-service
users (for example, directory mode 0700 and SQLite database/sidecar mode
0600). Check both databases plus any -journal, -wal and -shm sidecars after deploys
or ownership changes. The application does not retain browser private keys;
backing up this database preserves envelopes, not users' ability to decrypt a
lost browser key.
Quiesce message and sync writes and drain requests across both captures for a common backup point. Resolve both configured paths first. Use SQLite's consistent backup mechanism, not a live raw file copy. For example, with writes stopped:
sqlite3 "$PRIVATE_MESSAGE_DATABASE_PATH" ".backup '/secure/backups/messages-$(date +%F).sqlite3'"
sqlite3 "/secure/backups/messages-$(date +%F).sqlite3" 'PRAGMA integrity_check;'
sqlite3 "$PRIVATE_MESSAGE_HISTORY_SYNC_DATABASE_PATH" ".backup '/secure/backups/message-history-sync-$(date +%F).sqlite3'"
sqlite3 "/secure/backups/message-history-sync-$(date +%F).sqlite3" 'PRAGMA integrity_check;'
Test restoration in a non-production path before relying on a backup. Stop
message/sync writes or take the application out of service for the restore,
replace both files with their verified matching backups and compatible approval
state, restore owner and restrictive permissions, then reopen Messages and
verify sent/received history, reload and donor-assisted recovery. Mismatched
captures reject changed/orphaned bindings and reopen missing coverage; lost
transfers can still require a returning donor. On code rollback retain both files;
prior code ignores sync storage and re-upgrade resumes it. Do not place private
backups under public/, the canonical repository, or a static release.
There is no automatic message deletion in this release. Set and document a local retention period appropriate to the forum's low-value-content policy; take and validate a backup before a reviewed, targeted retention purge. Keep the retention tooling separate from public artifact publication so purging mailboxes cannot affect posts, profiles, or offline snapshots.
Dedalus Labs, the OpenAI-compatible endpoint previously used as the default for existing installs, has been discontinued. Installs still configured with LLM_PROVIDER => 'dedalus' (or with no LLM_PROVIDER set at all) must pick one of the providers below — there is no default.
Supported providers:
openai: direct OpenAI APIopenrouter: OpenRouter API, with optional attribution headersanthropic: direct Anthropic Messages APIstub: deterministic local analysis for smoke tests and offline operationcustom OpenAI-compatible gateways such as LiteLLM, vLLM, llama.cpp, or internal routers
The common keys are:
'LLM_PROVIDER' => '', // required: openai, openrouter, anthropic, stub, or a custom gateway name
'LLM_API_KEY' => 'replace-with-real-key',
'LLM_API_BASE_URL' => '', // required for every provider except stub; see examples below
'LLM_MODEL' => '', // required for every provider except stub; see examples below
'LLM_TIMEOUT_SECONDS' => 60,
'LLM_EXTRA_HEADERS' => [],
'LLM_POST_ANALYSIS_PROMPT_PATH' => 'prompts/dedalus_post_analysis_system.txt',
Provider examples:
// Direct OpenAI
'LLM_PROVIDER' => 'openai',
'LLM_API_BASE_URL' => 'https://api.openai.com',
'LLM_MODEL' => 'gpt-5-nano',
// Direct Anthropic
'LLM_PROVIDER' => 'anthropic',
'LLM_API_BASE_URL' => 'https://api.anthropic.com',
'LLM_MODEL' => 'claude-haiku-4-5-20251001',
// OpenRouter
'LLM_PROVIDER' => 'openrouter',
'LLM_API_BASE_URL' => 'https://openrouter.ai/api',
'LLM_MODEL' => 'openai/gpt-5-nano',
'LLM_EXTRA_HEADERS' => [
'HTTP-Referer' => 'https://forum.example',
'X-Title' => 'Forum',
],
// LiteLLM or another OpenAI-compatible gateway
'LLM_PROVIDER' => 'litellm',
'LLM_API_BASE_URL' => 'https://llm-gateway.example',
'LLM_MODEL' => 'openai/gpt-5-nano',
OpenAI-compatible providers are called at LLM_API_BASE_URL + /v1/chat/completions. Anthropic is called at LLM_API_BASE_URL + /v1/messages. Legacy DEDALUS_* LLM settings are still read as fallbacks for current deployments, but new config writes use LLM_*, and LLM_PROVIDER must now be set explicitly since Dedalus Labs is no longer available.
Agent Reply Requests
Approved users can request a reply-agent response from eligible post cards. The button records a durable request and one general-purpose agent_reply task, then returns without waiting for provider analysis or canonical posting.
The task-queue worker fulfills the task with the same repository, database, private LLM config, reply-agent key directory, and artifact paths as the web app. Its exclusive lock, per-reply task identity, and reply publication reservation prevent duplicate posts. A worker run also reconciles durable requested rows that lack an active task.
Useful manual commands:
./v3 agent-reply test
./v3 agent-reply test-local
./v3 agent-reply status <post-id>
./v3 agent-reply status --limit=25
./v3 task-queue status
./v3 task-queue run --dry-run
./v3 task-queue run --limit=1
Internal Task Queue
The internal task queue handles allowlisted maintenance work outside visitor requests, including requested agent replies, read-model rebuild/recovery, Fastmod sweeps, and public offline-snapshot publication. It does not run user-supplied commands and remains separate from the Codex-handoff queue.
Install the cron worker with the current-path reference:
./v3 task-queue cron
Remove any legacy agent-reply cron entry, then install only the line printed by this command. The worker processes one task per minute and exits successfully when another queue worker is active. The default cron-output path is application-private (state/private/task_queue_cron.log), so ensure its directory is writable by the cron user before installing the line. Successful site updates enqueue the deduplicated offline-snapshot task. Useful operator commands are:
./v3 task-queue enqueue-rebuild
./v3 task-queue enqueue-offline-snapshot
./v3 task-queue reset-recovery
./v3 task-queue status
./v3 task-queue run --dry-run
./v3 task-queue run --limit=1
Site Feature Flags
The site exposes registered public flags at /tools/feature-flags/.
Root-approved users can change mutable site flags from that page. Successful changes update:
records/instance/feature-flags.txt
and commit the change to the content repository git log.
Runtime precedence is:
environment/private override
git-backed site value
code default
Use FORUM_* environment variables for emergency or deployment-level overrides. While an environment variable is present, the corresponding flag is effectively pinned by the process and the site UI reports the environment source.
Approved-members-only access
FORUM_APPROVED_MEMBERS_ONLY=true enables the private-site boundary
independently of the site profile or theme. Unapproved visitors are limited
to /lobby/, /account/key/, and their own authenticated /profiles/<slug>
page. When a browser keypair is already saved, Lobby automatically publishes
the public key and completes identity setup before authentication. Other
routes, feeds, APIs, downloads, backups, and generated HTML return 404 or are
routed through PHP for the access decision. Required static assets remain
directly servable.
Enable it in the instance feature-flags record:
FORUM_APPROVED_MEMBERS_ONLY: true
For a deployment-level pin, set FORUM_APPROVED_MEMBERS_ONLY=true in the
vhost environment. Keep the flag off for public instances. The checked-in
rewrite rules route every non-asset request through PHP regardless of whether
the effective flag comes from the vhost or the instance feature-flags record,
so old public HTML artifacts cannot bypass a site-level flag change.
Audit site-level changes with:
git -C /srv/forum-rewrite/repository log -- records/instance/feature-flags.txt
git -C /srv/forum-rewrite/repository show <commit>:records/instance/feature-flags.txt
Rollback options:
set the previous value through
/tools/feature-flags/or revert the relevant content-repository commit
If production serves prebuilt static HTML artifacts, rebuild artifacts after changing flags outside the web write path. Private instances do not serve those content artifacts; every content request reaches the application access gate first.
Automatic guest keypairs
FORUM_AUTOMATIC_GUEST_KEYPAIR_ENABLED defaults to false. When enabled through /tools/feature-flags/, the instance feature-flags record, or a deployment environment override, a fresh browser receives a browser-local guest keypair before its first signed action. The server receives only public-key publication requests; it never receives the private key.
The browser-level Account Key setting controls publication timing. Its default is first signed use, which avoids creating public identities for visitors who never interact. A visitor may explicitly switch it to immediate publication. Existing browser keypairs are retained and are not replaced by this feature.
For a deployment-level enablement:
FORUM_AUTOMATIC_GUEST_KEYPAIR_ENABLED=true ./v3 start
To roll back, disable the flag on /tools/feature-flags/ or remove/set the environment override to false; this stops new automatic generation but intentionally does not delete keypairs already stored in visitors' browsers. Rebuild static artifacts after changing the site-record value outside the web write path.
Redeemable board invitations
Approved, authenticated members can use Invite from any board page to generate a signed, single-use invitation. New links use a 128-bit, 32-character hexadecimal bearer secret in the browser fragment; share it only with the intended recipient. The Activity feed records issued, revoked, and redeemed events with the verification hash, never the secret or the share URL.
Recipients create a disposable browser key in Lobby, redeem the invitation, authenticate with that key, and then enter the selected internal destination or the board. Invitations expire after seven days. An issuer can revoke an unused invitation from the Invite page using its invitation ID and verification hash.
If an invitation is sent to the wrong person or its secret leaks, revoke it immediately. A redeemed invitation has already produced an auditable approval; use the normal membership/approval recovery process rather than deleting canonical invitation records. Never paste bearer secrets into board posts, Activity, tickets, logs, or shell history.
App Version Notification
FORUM_APP_VERSION_NOTIFICATION=false disables the browser-side /api/version polling and the "A new version is available." reload banner.
The default is enabled. /api/version remains available when the notification is disabled.
If production serves prebuilt static HTML artifacts, rebuild those artifacts after changing this flag so rendered pages include or omit the notification markup consistently.
HTTP, HTTPS, and Browser OpenPGP
Plain HTTP remains a supported production access path. The default Apache example serves the app on port 80 and must not be replaced with a forced HTTP-to-HTTPS redirect unless the local operator intentionally accepts the extra TLS failure modes.
Do not enable HSTS by default. Strict-Transport-Security can prevent users from reaching the site after certificate expiry, hostname changes, proxy mistakes, subdomain gaps, captive-portal interference, or other TLS failures.
Browser-held OpenPGP identity uses:
public/assets/openpgp_loader.jspublic/assets/openpgp.min.jsfor OpenPGP.js v6 on HTTPS and secure loopback originspublic/assets/openpgp.v5.11.3.min.jsas the patched v5 fallback on public HTTP
OpenPGP.js v6 is preferred, but browsers do not expose crypto.subtle on public HTTP origins. The loader therefore uses the v5 fallback on public HTTP so authored browser-identity posting can still work where the legacy bundle works.
If browser OpenPGP is unavailable, compose forms expose an explicit anonymous submit button. That path submits with an empty author_identity_id; the server writes a normal canonical post without Author-Identity-ID. This is intentional and is separate from authored OpenPGP posting.
Server-side user identity generation is intentionally out of scope. Do not generate, store, or manage user private keys on the server for this fallback.
Unicode Authored Text Rollout
FORUM_UNICODE_AUTHORED_TEXT=true enables visible UTF-8 prose in post subjects and bodies.
Scope:
affected: human-authored post
subjectandbodyunchanged: post IDs, thread IDs, parent IDs, board tags, reaction tags, profile slugs, identity IDs, routes, and artifact paths
The server still rejects invalid UTF-8, control characters, format characters, bidirectional controls, private-use characters, noncharacters, unsupported spacing characters, and symbols such as emoji.
Operational notes:
keep the flag disabled for first deploys unless Unicode prose has been explicitly tested on the target host
verify
git diff, rebuild scripts, static artifact generation, RSS, and terminal inspection all run under a UTF-8-capable localePHP
intlis optional in this implementation; when it is unavailable, combining-mark input is rejected instead of normalized, while precomposed readable Unicode such as normal Cyrillic remains supportedrollback is
FORUM_UNICODE_AUTHORED_TEXT=false; existing Unicode records remain valid UTF-8 canonical text and should continue to parse and render
Rollout smoke test:
enable
FORUM_UNICODE_AUTHORED_TEXT=truecreate a test thread with subject
Приветand bodyПривет мирverify the thread page, post page, activity page, RSS feed, read-model rebuild, and static artifact build
verify a post with a zero-width or bidirectional control character is rejected
disable the flag if submit-time Unicode acceptance must be rolled back
Automatic Agent Replies
Automatic replies can be disabled with:
DEDALUS_AGENT_REPLIES_ENABLED=false
Agent replies are drafted by the post-analysis prompt and posted from the stored engagement.suggested_response when respondability gates pass. Production uses the configured LLM_* provider for that analysis call.
On first successful agent reply, the app bootstraps a canonical reply-agent OpenPGP identity and stores the private key under:
<application-root>/state/private/agent-reply/
This directory must not be under public/ and should be readable only by the deployment user and web user. To rotate the key, disable automatic replies, archive the old private key, remove or supersede the canonical reply-agent identity through an operator-reviewed migration, rebuild the read model, then re-enable replies so a new key can be bootstrapped.
First-Time Setup
Check out the application code.
Create the writable canonical repository checkout.
Ensure the repository is a real git checkout with
records/and.git/.Create the writable state directory.
Configure Apache to serve
public/.Set the production environment variables.
Run the initial read-model rebuild. On a public instance, it automatically publishes the first offline snapshot when one is not already served; treat a reported snapshot-readiness failure as a launch blocker.
Optionally build sibling static HTML artifacts.
Example commands:
php scripts/rebuild_read_model.php /srv/forum-rewrite/repository /srv/forum-rewrite/state/cache/post_index.sqlite3
php scripts/build_static_artifacts.php /srv/forum-rewrite/repository /srv/forum-rewrite/state/cache/post_index.sqlite3 /srv/forum-rewrite/app/public
For a public instance, before launch verify the anonymous endpoint from an operator machine:
./v3 offline diagnose --url=https://your-public-domain
The command must report a successful public snapshot response. In approved-members-only mode, public snapshots are intentionally unavailable.
Pre-Launch Checklist
Apache
DocumentRootpoints topublic/AllowOverridepermits.htaccessif using the checked-in rewrite filemod_rewriteis enabledPHP can open PDO SQLite
the writable repository is a git checkout
the web user can create commits in the writable repository
the web user can write the read-model database and lock files
the web user can invalidate
public/*.htmlartifacts if sibling artifacts are enabled/api/read_model_statusreturnsstatus=readyon public instances,
./v3 offline diagnose --url=https://your-public-domainreports a successful public snapshot responseHTTP requests to
/account/key/,/compose/thread, and/assets/openpgp_loader.jsreturn app/asset responses, not forced HTTPS redirectsdefault responses do not emit
Strict-Transport-Security
Manual Verification
Before launch, verify:
board route loads
thread route loads
anonymous
/offline/snapshot.sqlite3returns a successful response on public instancesprofile route loads
account route loads
compose thread/reply routes load
anonymous queryless board/thread/profile requests can be served from sibling
*.htmlartifacts through the front controllercookie-bearing requests bypass static artifacts and fall back to PHP
thread creation works
reply creation works
authored browser identity bootstrap works on HTTPS or secure loopback with the v6 OpenPGP path
authored browser identity bootstrap works on public HTTP with the v5 OpenPGP fallback where supported by the browser
explicit anonymous compose works when browser OpenPGP is unavailable
Recommended Launch Sequence
deploy application code
verify Apache config
verify writable repository/config paths
rebuild read model and resolve any offline snapshot-readiness failure
build static artifacts
open
/api/read_model_statussmoke-test core read routes
smoke-test one write flow
Related Docs
On-demand content import
After deployment, operators can use ./v3 import-instance to download and merge
public forum content from another instance. No migration or cron setup is needed.
Use the running instance's repository/database/static-root configuration and
validate a disposable import with the target host's PHP, SQLite, zlib/OpenSSL,
Git, and gzip tooling first. Follow the
import deployment check
for preview, coverage review, served-view verification, and repeat-run checks.
Retraction sweep operations
See local cleanup coverage and recovery. Deploy web/worker changes together and keep the existing single queue cron entry. Preview then run immediately or enqueue a sweep; inspect status and retry failures without signing another retraction. Canonical/history and backup removal are outside this operation. Pause/drain writers and workers before rollback; preserve signed evidence and avoid restoring diagnostic payloads.
Frontend status target
After deployment, run ./v3 status from the application checkout with its normal
repository/database environment. A unique verified loopback port-80 endpoint is
detected automatically. If the site requires a virtual host or HTTPS, set
FORUM_STATUS_URL in the existing private configuration to its full URL, then
rerun plain status and confirm the URL/source and Frontend verdict. Use
--url=... for a one-time override. Discovery WARN means the intended endpoint
could not be established; a configured endpoint's outage remains FAIL.
See operator recovery.