---
name: forum-access-rule
description: Change who can browse, create, read or reply on the GCC Forum or Help Center — the three-permission model, the owner carve-out, and the fail-closed defaults. Use before modifying anything in Base.cfc that decides visibility, or any query that filters on access, publicflag or userid.
---

# Changing a forum access rule

**Read [`../../docs/ACCESS-CONTROL.md`](../../docs/ACCESS-CONTROL.md) first.**
This skill is the procedure; that document is the model.

The failure mode here is silent. A leaked support ticket renders as a perfectly
working page, and nobody reports a thread they were not supposed to see.

## The model in one table

| | Rule | Function |
|---|---|---|
| **Browse** a section | `he_type.access <= level` — **except types 200+, always public** | `canSeeSection()` |
| **Create** a thread | its own rules; 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 | the owner, **always, at any level** | `canReadThread()` |
| **Reply** | can read it, not muted, confirmed, not closed/locked/archived — and staff, on a thread marked staff-only | `canReplyTo()` |

These are not interchangeable. Two live cases prove it:

- **Section 91** (Abuse › Re-Activate, `access 4`) — 181 of its live threads
  belong to level-0 players who cannot browse it but must reach their appeal.
- **Sections 200/201** (`access 5`) — that is who may *post*. Everyone reads.
- **Section 106** (Events, `access 0`) — everyone browses and reads it, only
  `post.events` (default 3) starts a thread there, and a thread can be closed to
  player replies without being closed to staff. Three permissions, three
  answers, one section.

## Checklist

1. **Decide which of the four permissions you are changing.** If the answer is
   "all of them", you are probably about to reintroduce the conflation.
2. **Change it in `Base.cfc` and nowhere else.** `canSeeSection`, `canPostIn`,
   `canReadThread`, `canReplyTo`, `canEdit`.
3. **Grep for re-implementations** before you finish:
   ```bash
   grep -rn "level GTE val(sec.access)\|level LT val(sec.access)\|adminflag GTE" app/Forum/
   ```
   Any hit outside `Base.cfc` is a second copy waiting to drift.
4. **Check the SQL filters agree.** `visibleTypeList()` and `visibilitySQL()` on
   `Board` turn the rules into query fragments; both must be built from session
   state only, never from a request value.
5. **Verify in both directions** — `forum-verify`.

## Where each rule already lives

```cfml
// Base.cfc
canSeeSection(typeId)            // browse. 200+ bypass. Unknown id → false.
canPostIn(typeId, src)           // create. Board-specific; does NOT call canSeeSection for `he`.
canReadThread(thread)            // read. Owner carve-out + publicflag.
canReplyTo(thread)               // reply. Includes mute / confirm / active / lock / archive.
canEdit(authorId, postedAt)      // 30-minute author window, or mod.
offersReplyLock(src, typeId)     // may a thread here be closed to player replies?
creatableSections(src)           // the compose menu — asks canPostIn per section
```

```cfml
// Board.cfc — the SQL side
visibleTypeList(src)             // "1,2,100,200"  — never empty ("-1" matches nothing)
visibilitySQL(alias)             // "and (x.publicflag = 1 or x.userid = 4711)"
```

## Levels are configurable — read them, never write them

Every level in the table above is a **default**. `Base.aclRegistry()` holds the
capabilities, `gcc.forum_acl` holds any override, and the Access Control screen
(`?p=acl`, Owner only) moves them.

```cfc
if (m.level GTE aclCap("mod"))            // right
if (m.level GTE 2)                        // wrong: no longer configurable
canSeeSection()  ->  aclLevel("section." & id, val(t.access))
```

An empty `forum_acl` means every gate sits exactly where this document says, so
the assertions below hold on a fresh install. To make a NEW level configurable,
use [`forum-acl-section`](../forum-acl-section/SKILL.md).

## Defaults must fail closed

- An **unknown `he_type` id** gets access 255 in `sectionMap()`, not 0. An id
  nobody has classified is not something to publish.
- `he_type` is **joined from the database on every request**, never read from
  the `application.s_hetype*` cache alone. On a cold scope that lookup missed
  and `access` fell back to 0, publishing the Guide & Admin section to everyone.
- `visibleTypeList()` returns `"-1"` rather than an empty string when a viewer
  has no readable sections — an empty `IN ()` is a syntax error, and "fix" it
  the wrong way and it matches everything.

## Ownership is a permission

`Board.myTickets()` deliberately **bypasses `canSeeSection()`**. The
`userid = me` predicate *is* the access control. Do not add anything to that
query that narrows it, and do not "tidy" it to go through the section gate — the
whole page exists because the section gate would refuse.

## Three bugs this skill exists to prevent

1. **Section 105 listed to signed-out visitors.** `threads()` filtered
   `publicflag` but never checked section access. Section 104 looked safe only
   because all its threads happen to be `publicflag = 0`.
2. **Announcements vanished from the Help Center index for guests.**
   `shapeIndex()` compared levels inline and did not know about the 200+
   exemption.
3. **Profiles hid an author's Announcement posts.** `People` carried its own
   copies of the visibility helpers, with the same blind spot.

All three: the rule written down twice, and the copies drifting.

## Verify

Assert **both** directions for every level. A "staff can see it" test alone
passed bug 1.

```cfml
say("owner cannot browse 91",            !F.canSeeSection(91), "");
say("owner CAN read own appeal",         F.canReadThread(th), "");
say("OTHER player cannot read it",       !F2.canReadThread(th), "");
say("guest CAN browse 200",              G.canSeeSection(200), "");
say("player may file in 91",             arrayFind(ids, 91) GT 0, "");
say("player may NOT post 200",           arrayFind(ids, 200) EQ 0, "");
say("deactivated may post only 10,91,92", arrayToList(ids) EQ "10,91,92", "");
```

Clear `request.forum*` between simulated viewers or the next level passes on the
previous one's cached section map. Full harness in `forum-verify`.
