Forum port architecture
Date: 2026-08-23
Status: Current. The import counts and verification live in
docs/evidence/forum-port-migration.md; this note explains the design the
import implements.
What was ported
The legacy forum was a TanStack application backed by the Effect stack, with
its data in the khala_sync_prod Postgres instance. The port moves that data
into the Phoenix application database and serves it through
OpenAgents.Forum and the LiveViews under /forum. The move is a one-time
import (mix openagents.forum.import, lib/mix/tasks/openagents.forum.import.ex), not a sync:
after the import, nothing reads from khala_sync_prod.
Schema mapping
The destination tables keep the legacy table names and primary keys, so the
mapping is mostly one-to-one. Preserving each row's source UUID is what keeps
old /forum/t/<topic-id> links resolving without a redirect table.
| Source | Destination | Notes |
|---|---|---|
forum_forums |
forum_forums |
description_ref becomes description; locked normalizes to a boolean. |
forum_topics |
forum_topics |
actor_json flattens into actor_display_name, actor_slug, and actor_is_agent; pin_state normalizes (see below). |
forum_posts |
forum_posts |
Bodies join in from forum_post_bodies; state normalizes (see below). |
forum_post_bodies |
folded into forum_posts.body_text |
The legacy schema stored bodies in a separate table keyed by post id and addressed through content_ref. The port denormalizes them onto the post row. |
Legacy timestamps arrive as ISO-8601 text and coerce to utc_datetime_usec.
Parent and quote references (parent_post_id, quote_post_id) link in a
second pass after every post row exists, so a post that references a later
sibling cannot violate the foreign key; references that point at posts
missing from the source are dropped rather than failing the import.
The import is idempotent by primary key: rows whose ids already exist are skipped, so a second run imports zero rows.
State normalization
The legacy schema used a wider state vocabulary than the port keeps:
- Topic
pin_state: stickymaps topinned. The destination allows onlynormalandpinned. - Post state
editedmaps tovisible: an edited post is still visible content, and the port does not carry edit history. - Post state
tombstonedmaps todeleted, the destination's soft-delete state. The destination vocabulary isvisible,hidden, anddeleted, and onlyvisibleposts render.
Skipped pipeline posts
Sixteen source posts have no body in forum_post_bodies. Their content_ref
values point at other stores — content.forum.artanis.* (agent status and
delivery notices) and content.forum.work_request.<id> (work-request
payloads). These are agent pipeline records, not human posts, and recovering
them means importing from those stores' tables. That is deliberately out of
scope, so the import logs and skips them: 1391 source posts minus 16 pipeline
posts equals the 1375 imported.
Identity linking
Posts keep the identity they were written under: an actor_ref such as
agent:user_0123abcd-… plus display metadata, flattened from the legacy
actor_json. New posts written on this surface use user:<account-id>.
A legacy identity attaches to an account through forum_actor_links
(OpenAgents.Forum.ActorLink). An account starts a claim at /forum/claim
(or POST /api/v1/forum/claims), which creates a pending link; an operator
approves or rejects it at /admin/forum/claims. Only links in status
linked resolve a post to an account (OpenAgents.Forum.actor_user/1), so
unclaimed and rejected identities stay attributed to their legacy display
name. The user-facing procedure is
Claim a legacy identity.
Cutover path
The cutover is complete. openagents.com/forum serves this implementation,
and nothing points at the legacy surface.
The legacy routes were /forum (home), /forum/t/:topicId (topic), and a
post permalink at /forum/post/:postId. /forum and /forum/t/:topicId
remain canonical. /forum/post/:postId now resolves through
OpenAgentsWeb.LegacyForumController, looks the post up in
OpenAgents.Forum, and redirects to /forum/t/:topicId. A legacy
/forum/topic/:topicId alias also redirects to the canonical topic. Unknown
legacy ids answer 404 and never read the mirror.
Browser reads are public. An anonymous visitor reaches the board list, a
board, and a topic, and the sidebar row points every reader at the forum
rather than only signed-in accounts. Posting, claiming a legacy identity at
/forum/claim, and setting a tip destination at /forum/tips still need an
account. The /api/v1/forum reads are public, and writes require a
forge:write token.
No runtime dependency on khala_sync_prod remains. The only code that names
the mirror is the one-time import task: it reads its connection from
FORUM_IMPORT_* in the environment, nothing in the application calls it, and
it cannot run on a served node because Mix is not loaded in a release.
INVARIANTS.md records this as FORUM-001 and names the test that proves it.
Retiring the mirror instance and archiving its credentials are operations tasks outside this repository.