# Clubs

2026-08-08. The eighth and last application, and the one deliberately left
until the others existed — a club *contains* forums, galleries, calendars and
blogs, so building it first would have meant building all of them twice.

Everything below follows from one question: what does "contains" actually mean
in a codebase where each application is independent and none of them may know
about Clubs.

---

## 🚨 The prerequisite nobody had written down: forums cannot be private

Before any of this could be designed, a measurement. **There is no such thing
as a private forum.** There is no `forum_groups` table, no permission column on
`forums`, and no visibility check anywhere in the forum module. Every forum on
a Convoro site is readable by everybody, including signed-out visitors.

This was known and recorded — `modules/forum/Forum.php` carries a note saying
that the day per-forum permissions land, search has to filter by them or topic
titles leak. That day is today, because **a club whose forum everybody can read
is not a club.**

So Clubs has a hard prerequisite, and it is built first: forums gain
visibility, the same way pages, blogs, articles, calendars and albums already
have it. This is worth having on its own — a staff-only forum is something
every community expects — and it is not scope creep. It is the thing that was
in the way.

### Where a forum can leak

Restricting a forum is not one check. It is every place a forum, a topic in
one, or a post in one is rendered or counted:

- the forum index and any forum card
- the forum itself, and a topic opened directly by its address
- **search** — both topics and posts, and this is the one that leaked page
  titles on 2026-08-08 in exactly this way
- activity streams
- the topics and posters widgets, which draw on pages the operator is not
  thinking about
- "last post" pointers on a forum card, which carry a title
- site statistics and counters

🚨 The rule the codebase already learned twice: **enforced in the service, not
in the template.** A template-level check is one forgotten `@if` from a leak,
and three of the surfaces above have no template of their own at all.

### Inheritance

A restriction on a category applies to everything under it. A forum with its
own restriction uses that; otherwise it takes the nearest ancestor's. Walking
up rather than intersecting, because "this category is for staff" is the thing
operators actually mean, and an intersection is impossible to predict from the
screen.

### 🚨 The fast path is part of the design

On a site with no private forums — which is most sites, and every site that
exists today — none of these queries may get slower. So the visibility service
first asks whether **any** restriction exists at all, and if none does it
returns "everything" without a join, an `IN` clause or a second query. The cost
of the feature is paid only by sites using it.

---

## A club is a group

This is the decision the whole application rests on, and it is what makes
"contains" cheap instead of catastrophic.

Every application already has exactly one grouping level whose visibility is
decided by a `<container>_groups` join table, checked against the viewer's
group ids: a blog, a calendar, an album, a Knowledge Base article, a page, a
custom database — and now a forum. `viewerGroupIds` is resolved once per
request and *everything* funnels through it.

A club is a set of people, plus the things only those people can see. **That is
a group.** So every club owns one, automatically managed and never edited by
hand: joining the club inserts a `group_members` row, leaving removes it, and
the club's forum, album, calendar and blog are restricted to that group.

What this buys, and the reason it is the right shape:

- **Not one line of Blog, Events, Gallery, Downloads or Pages changes.** They
  never learn that clubs exist. Their manifests still say `requires: [system]`
  and their independence tests still pass.
- Search, widgets, menus, streams and Gallery's byte-serving route already
  honour it — because they already honour groups. A club's photograph is
  unreachable by address for a non-member with no gallery code involved.
- A future application gets club support for free, by having the visibility
  every application already has.

The alternative was a `club_id` on every container and a club check inside
every application's `maySee`. That is five modules edited, five modules made
dependent on a sixth, and five independence tests deleted — the exact failure
the application-model note argued against and was right about.

### 🚨 What a club group must never become

A club group is not a member group and must not appear where one is offered.
Left visible it would put every club in the Groups admin screen, in the "who
may read this page" checkboxes, and in the group picker of every application —
a site with two hundred clubs would have a two-hundred-item checkbox list on
every restriction form.

So groups gain a `club_id`, and there is one seam — `Groups::selectable()` —
that every picker and every "is this a real group id" validation goes through.
Both kinds of call site matter: a picker that shows club groups is untidy, but
a *validation* that accepts one lets a hand-made POST restrict a site page to
the members of somebody's private club.

🚨 A club's group also cannot be deleted on its own. It belongs to the club and
goes when the club goes.

## Ownership is a join table, not a column

Membership is a group. **Ownership** — which forum belongs to which club — is
`club_contents`, a table Clubs owns entirely: club, kind, container id.

Not a `club_id` column on `forums`, `blogs` and the rest, because adding one
means a Clubs migration altering another module's table, which fails outright
on a site where that application is not installed. A join table Clubs owns
works for applications that are switched off, and for applications that do not
exist yet.

## Three kinds of club

| | Listed in the directory | Getting in | Its content |
|---|---|---|---|
| **Open** | yes | join instantly | members only |
| **Closed** | yes | ask, and a leader approves | members only |
| **Private** | no — invisible unless you are in it or invited | invitation only | members only |

Open and Closed differ only in the door. Closed and Private differ in whether
the club's *existence* is public: a Closed club is a room you can see and knock
on, a Private one is a room you cannot see.

### 🚨 What is deliberately missing, and why

A fourth kind — **a club anybody may read but only members may post in** — is
not built, and it is not an oversight.

Reading is a group restriction and works today. *Writing* is not: there is no
per-forum, per-album or per-calendar write permission anywhere in Convoro, and
posting goes through each application's own routes, which do not know clubs
exist. Building it would mean either teaching five applications about clubs —
the thing this design exists to avoid — or shipping a club that says
"members only post" and does not enforce it.

A restriction that is advertised and not enforced is worse than one that is
absent. It waits for per-container write permissions, which is its own piece of
work and wants its own note.

## Roles

Owner, leader, member. One owner, who cannot leave without handing the club to
somebody else — a club with no owner is a club nobody can administer, the same
failure the last-administrator-group rule already guards against elsewhere.

Leaders approve requests, invite, remove members and edit the club. Members
read and take part.

A site administrator can do anything in any club, including a Private one they
are not in. That is not a back door to hide: an administrator can already read
the database.

## What this note does not decide

- **Club-level custom fields.** Wanted eventually; nothing needs them yet.
- **A club feed or its own activity stream.** The site stream already filters
  by what the viewer may see, so club content appears there for members. A
  dedicated club stream is a nicety.
- **Nested clubs.** No.
- **Moving existing content into a club.** The reverse — a club creating its
  own containers — covers what people ask for. Moving a forum with ten thousand
  topics into a club is a migration, not a button, and it changes who can read
  every one of those topics at once.
