# CLAUDE.md — Forum (`app/Forum/`)

> Working notes for the rebuilt Galactic Conquest forum. Read this before
> touching anything under `app/Forum/`.

## 1. What this is

A modern replacement for `hef.cfm` — **both** the boards it served: the Forum
(`hef`) and the Help Center (`he`). One engine over two sets of tables; `?b=hef`
/ `?b=he` chooses, and `F.link()` keeps you on the current board automatically.

Built from the design mockup in `app/New_Forum/`. It is a **server-rendered CFML
app** on the **existing `he` / `hef` / `he2` / `hef2` tables** — there is no
import, no mirror, and no migration of posts. Everything written since 2006 is
read in place.

The legacy `hef.cfm` is **untouched and still works**. Both boards can run
against the same rows at once; see §6.

## 2. Layout

```
app/Forum/
├── index.cfm              front controller: routing, actions, chrome
├── views/*.cfm            one file per page, included by index.cfm
│   ├── _threadrows.cfm    shared thread-row markup (section + search)
│   ├── tickets.cfm        Help Center: your own threads — bypasses the
│   │                      section gate on purpose (ownership is the permission)
│   ├── queue.cfm          Help Center: unanswered tickets, staff only
│   ├── acl.cfm            Access Control: the level each capability and
│   │                      section needs. Owner (level 10) only.
│   └── staff.cfm          the staff directory
├── components/
│   ├── Base.cfc           session, permissions, query runner, formatting
│   ├── Text.cfc     (Base)  storage form in, SANITISED html out
│   ├── Board.cfc    (Text)  index, sections, threads, replies, search
│   ├── Posts.cfc    (Board) create, reply, edit, remove, subscribe
│   ├── Mod.cfc      (Posts) pin, lock, move, archive, reports, modlog
│   └── People.cfc   (Mod)   members, profiles, staff directory, notifications
├── assets/css/forum.css   design system (from New_Forum) + live-board additions
├── assets/js/forum.js     progressive enhancement ONLY
├── docs/                  reference material (start at docs/ARCHITECTURE.md)
└── Skills/                task playbooks (start at Skills/README.md)
```

| Doc | Read it for |
|---|---|
| [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | how it fits together: two boards, one engine, request flow |
| [`docs/ACCESS-CONTROL.md`](docs/ACCESS-CONTROL.md) | **before touching any permission** — the three-permission model |
| [`docs/DATA-MODEL.md`](docs/DATA-MODEL.md) | the legacy tables, the `forum_*` side tables, sections, indexes |
| [`docs/CONVENTIONS.md`](docs/CONVENTIONS.md) | CFML/SQL rules and every trap already hit |
| [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) | running it, migrations, testing, the performance baseline |
| [`docs/HISTORY.md`](docs/HISTORY.md) | what the legacy board was, what was wrong, what was rescued |

| Skill | Use it when |
|---|---|
| [`Skills/forum-section`](Skills/forum-section/SKILL.md) | adding or changing a section |
| [`Skills/forum-access-rule`](Skills/forum-access-rule/SKILL.md) | changing who sees what |
| [`Skills/forum-acl-section`](Skills/forum-acl-section/SKILL.md) | making a level configurable from the Access Control screen |
| [`Skills/forum-page`](Skills/forum-page/SKILL.md) | adding a page or view |
| [`Skills/forum-migration`](Skills/forum-migration/SKILL.md) | any schema change |
| [`Skills/forum-verify`](Skills/forum-verify/SKILL.md) | **before every commit** |

The components are a single inheritance chain — `People` extends `Mod` extends
`Posts` extends `Board` extends `Text` extends `Base` — so views instantiate
**one** object (`F = new components.People()`) and get everything.

Schema: **`app/Admin/sql/forum_schema.sql`** (full, idempotent).
Also **`app/Admin/sql/forum_glyph_widen.sql`** — a standalone `MODIFY` for
installs created before `forum_section.glyph` went from `varchar(8)` to
`varchar(16)`, so an existing database can take just that change without
re-running the whole schema.

**Every `.sql` file starts with a `USE`** (after the header comment, before the
first statement), or fully qualifies every object it touches. Forum tables are
all in `gcc` — but they live in `app/Admin/sql/`, which holds migrations for
`gcc_admin`, `gcc` and `gcc_log` and biases the reader toward the wrong one. A
migration run against the wrong database is a silent no-op, or creates a table
somewhere nothing will ever read it. With the `USE` present, no database
argument is needed: `mysql -u WolfrenInd -p < app/Admin/sql/forum_schema.sql`.

## 3. Non-negotiables

**No `Application.cfc` in this directory.** `app/Forum/` inherits
`app/Application.cfc` on purpose so it shares the live player session
(`session.userid`, `session.adminflag`). Adding one — as `app/Admin/` does —
would give the forum its own empty session scope and log every player out.

**Never render a post body without `F.render()`.** This is the single most
load-bearing rule in the module. HTML is stored as HTML — for everyone, with no
staff gate — so the whitelist in `Text.render()` is the *only* thing standing
between a post and the page. The board already holds iframes to third-party IPs
and a reply that disables the reply box for everyone who opens the thread
(courtesy of `f_he_new.cfm:229`, which converts `[[` to `<` *after* escaping).
`render()` makes all of it inert at output. Fixing the data instead is not an
option: 160,000 posts legitimately contain markup.

**`style` is allowed but rebuilt, never passed through** — `safeStyle()`,
property whitelist, no positioning, no `url(`, bounded `font-size`/`opacity`.
`[code]...[/code]` is the only way to get literal angle brackets into a body.

**Permissions live in `Base.cfc`, nowhere else.** `canSeeSection`,
`canReadThread`, `canPostIn`, `canReplyTo`, `canEdit`. The legacy board
re-derived these inline on four pages and they did not all agree.

**`he_type` stays the source of truth for access.** `forum_section` holds
presentation and placement only. `access` and `publicflag` are read from
`he_type` on every request, joined — never defaulted, never cached into a
permission decision.

**Levels are defaults, and every one is configurable.** `Base.aclRegistry()`
holds each capability with the level the code used to hardcode; `gcc.forum_acl`
holds any override, including `section.<he_type id>` rows that move a section's
browse level. Read them through `aclCap()` / `aclLevel()` — **never write a bare
level number into a check again**. An empty `forum_acl` means the board behaves
exactly as it did before the Access Control screen existed. Full model:
[`docs/ACCESS-CONTROL.md`](docs/ACCESS-CONTROL.md).

**Three permissions, not one.** This is the rule most likely to be broken by
someone "simplifying" the access code:

| | Rule | Where |
|---|---|---|
| **Browse** a section | `he_type.access <= level` — **except types 200+, always public** | `canSeeSection()` |
| **Create** a thread | its own rule set; in the Help Center *any* signed-in player may file in any section below 100 except Bounty, **whatever its browse level** | `canPostIn()` |
| **Read** a thread | owner always, at any level | `canReadThread()` |
| **Reply** to a thread | can read it, not muted/closed/locked — and staff, if the thread is marked staff-only | `canReplyTo()` |

Section 91 (Abuse → Re-Activate, access 4) is the case that proves it: 181 of
its live threads were opened by level-0 players who cannot browse the section
but must reach their own appeal. Types 200/201 carry `access = 5` — that is who
may **post** an announcement; everyone reads and replies.

The Forum has the same shape in **Events (106)**: `post.events` (default 3) is
who may start a thread, `he_type.access` stays 0 so everyone reads it, and the
staffer who writes one may close it to player replies — `offersReplyLock()` says
which sections offer that, `forum_thread_meta.staff_only_at` holds it, and
`canReplyTo()` enforces it. It stops players, not staff: a lock stops both.

Never re-implement these inline. Two separate bugs came from exactly that:
section 105 listing to signed-out visitors (the listing checked `publicflag`
but not section access), and Announcements vanishing from the Help Center index
for guests (`shapeIndex` compared levels itself and did not know about the 200+
exemption). Both now call `canSeeSection()`.

## 4. Performance rules (learned the hard way)

The board went from **~2.2s on every signed-in page** to 25–90ms. Four causes,
and each one is a rule worth keeping:

**Never sort more than a few hundred structs in CFML.** `arraySort` with a
closure comparator over 3,081 member structs took **1.7s** (2.1s by name, 7.9s
by date) because every one of the ~35,000 comparisons is a CFML function call.
The database does the same job in 58ms. Sorting and paging belong in SQL.

**Watch what the router calls on every page.** `myStanding()` feeds the rail and
ran that 1.7s sort *per request* — but only for signed-in users, so a signed-out
profile showed 30ms pages and looked fine. If you add anything to the router's
data block, profile it **signed in**.

**Never `ORDER BY` a value computed from a LEFT JOIN.** The listing sorted on
`pinned` (`forum_thread_meta.pinned_at IS NOT NULL OR hef_s.id IS NOT NULL`), so
MariaDB materialised all 7,416 threads in the section, computed the flag for
each, sorted, then took 25 — `Using temporary; Using filesort`. **30ms → 0.44ms**
once pinned threads became their own query (`pinnedThreads()`), excluded from
the listing via `pinnedIds()`.

**Prefer a correlated subquery to a joined derived table.** MariaDB does not
push the outer predicate into a derived table, so
`(select belongto, count(*) from hef2 group by belongto)` aggregated all 122,000
replies on every page load to decorate 25 rows. A correlated subquery hits the
`(belongto, id)` index once per returned row: **28ms → 1ms**.

Also: search uses FULLTEXT (`ft_hef_body`, `ft_hef2_nlong`) for 3+ character
terms and title-only LIKE below that; result counts are cached, because running
the union a second time for the count was half the cost of a search.

## 5. Traps this code has already hit

- **An argument named `src` shadows a method named `src()`.** The board
  validator is therefore called `board()`. Same class of bug as
  `this.endpoints` shadowing `endpoints()` in the Player API.
- **CFML arrays cannot hold null.** `[ javaCast("null",""), "cf_sql_integer" ]`
  builds an array whose element 1 *does not exist* and fails at bind time with
  "Element at position [1] does not exist in list". Nullable params use the
  three-element form `[ value, type, isNullBoolean ]`.
- **`##` in a CFML string is a literal `#`.** Regexes here use `\x23` instead.
  In `.cfm` output, `###var#` is literal-hash-then-interpolation.
- **`try/catch/finally` in a `.cfm` cfscript block** can make Lucee emit
  bytecode that fails JVM verification ("operand stack underflow"). Use
  `try/catch` and put cleanup after it.
- **A `limit` argument name breaks queries silently** — use `maxRows`.
- **Never write `<cfsomething>` in a CFC comment.** Lucee parses it.
- **A stacked table cell keeps its fixed height.** `table.tbl td` sets
  `height: var(--row-h)`; once `stack-sm` makes that cell a block it is a hard
  34px and tall content prints over the line below. `height: auto` in the
  stacked block is load-bearing.
- **`.shell` is a stacking context** (`position:relative; z-index:1`), so
  anything meant to float above the page from inside a view is ordered *within*
  the shell — a scrim rendered outside it paints over the phone nav drawer
  however high the drawer's `z-index` goes.
- **Adding a NEW method to a CFC needs a container restart** on this dev stack.
  Lucee recompiles an edited method *body* fine, but a new method signature is
  not picked up — you get "Component [x] has no function with name [y]" while
  the method is plainly there on disk, and `applicationStop()` does **not** clear
  it. `docker restart gcc-local-app`. Worth knowing before you spend twenty
  minutes hunting a phantom syntax error.

## 6. Running alongside the legacy forum

Both boards write the same shape, so they can coexist:

| Concern | How it stays consistent |
|---|---|
| New thread / reply | Written with the legacy column set; `lastuserid`/`lastusernic`/`lastpost` bumped exactly as `f_he_detail.cfm` does |
| Pinning | Written to **both** `forum_thread_meta.pinned_at` and the legacy `hef_s`; read as pinned if either says so |
| Archiving | Uses the legacy close path — copy to `hef_old`/`hef2_old`, delete live rows |
| Post bodies | Stored in the 2006 storage form (entities, backtick apostrophe, `<br>`) so `hef.cfm` renders them correctly |

**Deduplication is load-bearing.** "Tag as answered" (`f_he_detail.cfm`,
`url.co=101`) copies a thread into `_old` *without* deleting the live row, so
2,400 `hef2` rows and 73 `he` rows exist in both tables. Every union filters the
archive branch with `NOT EXISTS`. Remove that and threads list twice.

## 7. The rescued threads

**1,341 forum threads carry `type=1`** — posted 2006–2023, 1,236 of them still
open. `f_he.cfm:139` lists the Forum with `type>=100 and type<=199`, so nothing
in the old UI could reach them; only direct links resolved. They are surfaced
here as **The Archive** — no date range in the name, because threads can still
land there (`forum_section` row `hef`/`1`,
`postable = 0`).

If you ever add a section, add its `forum_section` row or its threads become
invisible the same way.

## 8. Testing

There is no committed test harness. The pattern used during the build:

- Drop a scratch `.cfm` in `app/Forum/`, hit it with `curl`, delete it after.
- Simulate a viewer by setting `session.adminflag` / `session.userid` directly,
  and clear `request.forum*` keys between simulated viewers or the request-scoped
  caches make the next level pass by accident.
- Write tests must record every id they create and delete it afterwards —
  these run against the **real** tables.

Verified at build time: 28 pages render clean; 31 write-path assertions pass
(create / CSRF / flood / reply / edit / pin / lock / move / answer / report /
archive / restore / subscribe / notify / unread); access boundary correct at
levels 0–3; sanitiser neutralises all nine hostile payloads on the live board;
legacy table row counts unchanged after the run.

## 9. Open items

- The Help Center's **per-category intake forms** are not ported. The legacy
  composer built a different form per section (`f_he_new.cfm`, 781 lines):
  "paste your receipt" for Donation, "who are you reporting" for Cheating.
  Every section now uses the one composer, with `he_type.detail` shown as
  guidance. Worth revisiting for Donation and Cheating, where the prompts
  genuinely shaped what staff received.
- `he.emailflag` (the legacy per-thread notify choice on Help Center tickets)
  is read but not written — new tickets use `forum_subscribe` instead. The old
  column still drives `hef.cfm`'s notifications, so a ticket opened here will
  not email through the legacy path.
- No reactions UI yet — `forum_react` exists and is unused.
- `forum_prefix` is seeded and selectable on compose, but there is no admin
  screen to edit prefixes.
- Search is `LIKE '%term%'`, which cannot use an index. Fine at 122k replies;
  if the board grows an order of magnitude, that query wants FULLTEXT.
- The legacy `hef.cfm` has no link pointing at the new board yet — deliberate,
  so the switchover is Amanda's call.
