Clans — Core Module
Last updated
May be stale
This page was last updated ago, on 25 August 2026. Check what it says against the code before relying on it.
Module 1. Owns clan identity, membership, the rank ladder, permissions, radio and MOTD, and exposes the registration API every other module builds on.
Status: In Design. See design.md for the module framework and plan.md for rollout.
Scope
Core is deliberately close to the existing feature set. It is "your clan radio, your rank system, general membership administration" — the value of this module is the framework and the correctness, not new mechanics.
In scope: identity (name, abbreviation, motto, description), MOTD, membership lifecycle, ranks and permissions, clan radio, administration. Out of scope: bank (Module 2), inventory, vehicles. Retired: clan models — see design.md.
Permission model
Decision: hybrid capability registry
Permissions are declared by modules and stored per rank in a single generic table. Each permission has a kind that determines how its value is interpreted.
| Kind | Value domain | Meaning |
|---|---|---|
BOOLEAN |
true / false |
Flat capability, not targeted at another member |
THRESHOLD |
NULL or a rankid |
May act on any member at or below the referenced rank; NULL = no permission |
NUMERIC_LIMIT |
NULL, -1, or a non-negative number |
Amount-gated capability; -1 = unlimited, NULL = no permission |
NUMERIC_LIMIT is retained as a general-purpose utility kind, not to avoid a
migration — migrations are cheap here, so that would not be a reason. It earns
its place because "you may do this, up to N" is a recurring shape a future
inventory or vehicle module wanting per-rank quotas could use, rather than
reinventing it. Its -1 sentinel is an in-band magic value in the same column
as real amounts; that is a known wart, tolerated because NULL already carries
the "no permission" case cleanly.
bank.withdrawdoes not use this kind — see bank.md: the per-transaction cap is a single global config value, not a per-rank quota, sobank.withdrawisBOOLEAN.NUMERIC_LIMITcurrently has no consumer; revisit if nothing claims it by the time Core ships.
This keeps the genuinely good idea in the legacy model — seniority-scoped authority, "you may only act on people below you" — while generalising it so any module can use it for any targeted action, rather than it existing only for the two actions someone hardcoded columns for.
NULL is the single "no permission" sentinel across all three kinds. This
resolves the legacy defect where 0 meant both "no permission" and, numerically,
"the most senior possible rank" — forcing an inconsistent special case at every
call site.
Decided. The alternative considered was plain boolean capabilities plus a separate seniority ordinal and an explicit
is_leaderflag — simpler to reason about, but a boolean cannot express "kick, but only people junior to me" without a second mechanism bolted alongside it, and it cannot satisfy the hard requirement that a module declare its own permissions without a schema migration. Module extensibility is the point of the rebuild, so the hybrid wins. The rejected model's other recommendations — atomic leadership transfer, re-checking permission at commit time — are adopted and were compatible with either choice.
Schema: rankid vs rank_order
The legacy rankid column conflates stable identity with seniority position.
Split it:
| Column | Stability | Purpose |
|---|---|---|
rankid |
Immutable, never reused | Foreign-key identity. Allocated from a per-clan monotonic counter (clans.next_rankid), never from array position |
rank_order |
Mutable, renumbered on structural change | Contiguous 1..N, 1 = most senior. The only column ever used for seniority comparison |
clan_ranks(clan, rankid, rank_order, rankname, ...),
PRIMARY KEY (clan, rankid), UNIQUE (clan, rank_order).
This makes the non-contiguous rank IDs already present in live data (8, 170)
harmless: they are opaque identity and are never compared.
THRESHOLD stores a rankid, not a raw ordinal — so "may kick Lieutenant and
below" keeps meaning what the leader configured even if a new rank is later
inserted above Lieutenant.
Ranks are a strict ladder — one rank per position, no ties. Co-equal ranks
were considered and rejected: no clan has ever had them, the migration cannot
produce them (rankids are already distinct per clan, so backfilling rank_order
yields no ties), and they would have shipped with no users, an awkward two-part
invariant, and a documented expressiveness hole in threshold configuration.
Relaxing this later is one migration in the easy direction — dropping a unique constraint — so nothing is foreclosed.
Permission value column
clan_rank_permissions.value is INT NULL. This is not incidental.
DB:QueryParams binds every parameter as a quoted string literal
(db.lua:239) — there is no numeric binding path. Against an INT column MySQL
coerces the literal and comparisons stay numeric. Against a VARCHAR column
they become lexicographic, and '9' > '10' is true. Live clans already have
28 ranks, so ordinals above 9 are reachable today — a VARCHAR value column
would silently invert the threshold check for exactly the clans with the most
complex ladders.
All three kinds fit an integer: BOOLEAN as 0/1, THRESHOLD as a rankid,
NUMERIC_LIMIT as the amount. Do not use VARCHAR or JSON.
Join rank
Each clan stores an explicit clans.join_rankid rather than deriving the join
rank from the ladder.
This removes the legacy defect where the join rank was whichever rank row
happened to be loaded last — a value derived from iteration order rather than
intent, which is how live data ended up with join ranks of 8 and 170.
Deleting the designated join rank requires nominating a replacement, in the same
operation.
Declaration format
A module registers each permission once at load time. Example shown with
NUMERIC_LIMIT since that kind's shape is the least obvious of the three —
inventory.take_items is hypothetical (inventory is a deferred future module,
not part of this rebuild), used here only to illustrate the kind, not because
it is being built:
{
id = "inventory.take_items", -- unique, "<module>.<name>"
module = "inventory", -- must match the registering module
label = "Take items from clan inventory",
kind = NUMERIC_LIMIT,
default = nil, -- fallback when no stored value exists
unit = "items_per_transaction",
}
default is resolved at read time, never materialised — this is what lets a
module installed after a clan already exists apply to every existing rank with
no migration.
Resolution order for any permission check:
- Look up
(clan, rankid, permission_id)inclan_rank_permissions. - If present, use it (type-checked against the declared
kind). - Otherwise use the module's declared
default. - If the permission is not registered by any loaded module, treat as
NULLand log a warning viacityrp.logging. Fail closed — a disabled or renamed module must never result in a silent grant.
Core permission set
| Permission | Kind | Gates | Leader | Officer | Lieutenant | Private | Recruit |
|---|---|---|---|---|---|---|---|
core.invite |
boolean | Invite a non-member | ✓ | ✓ | ✓ | ✗ | ✗ |
core.manage_invites |
boolean | Cancel pending invites | ✓ | ✓ | ✗ | ✗ | ✗ |
core.kick |
threshold | Remove a member | Officer | Lieutenant | Private | — | — |
core.setrank |
threshold | Change a member's rank | Officer | Lieutenant | — | — | — |
core.motd_edit |
boolean | Edit clan MOTD | ✓ | ✓ | ✗ | ✗ | ✗ |
core.edit_identity |
boolean | Edit name / abbreviation / motto / description | ✓ | ✗ | ✗ | ✗ | ✗ |
core.manage_ranks |
boolean | Create, rename, reorder, delete ranks; edit stored permission values | ✓ | ✗ | ✗ | ✗ | ✗ |
core.delete_clan |
boolean | Permanently delete the clan | ✓ | ✗ | ✗ | ✗ | ✗ |
core.kick and core.setrank are independent axes, not bundled — a Lieutenant
may remove a Private without being able to re-rank one.
Intrinsic actions, not stored permissions: leadership transfer is always
and only "whoever currently holds rank_order = 1". Storing it as configurable
data would allow a misconfiguration to hand leadership transfer to a non-leader.
Inalienable permission
Rank 1 permanently holds core.manage_ranks. It cannot be revoked, and the
write path rejects any attempt to set it false on rank 1 regardless of who is
asking.
Without this, a leader can remove core.manage_ranks from their own rank and
lock the clan out of its own configuration permanently — recoverable only by
staff intervention.
core.manage_ranks is the only permission that needs this treatment. A
weaker rule such as "at least one rank must hold it" is insufficient, because
that rank may have no members — the invariant would hold while nobody could
actually use it, and guarding every membership change instead gets expensive.
Pinning it to rank 1 piggybacks on invariant 1, which already guarantees exactly
one member holds that rank.
Nothing else needs protecting, because core.manage_ranks is sufficient to
restore anything: a leader who removes core.delete_clan or
core.edit_identity from their own rank can grant it back. Those stay freely
editable.
Modules must not assume more than this. Core guarantees only that rank 1 can always reach the permission editor; a module that can lock itself out through its own permissions is a bug in that module.
⚠️
core.manage_ranksalso gates editing a rank's own permission values, includingcore.delete_clan. Granting it to a non-Leader rank is effectively granting clan co-ownership. This is intentional, but the UI must warn the leader when enabling it for anyone else.
Permission check formulas
Threshold
allowed = defined ∧ (ord_target ≥ ord_thresh) ∧ (ord_target > ord_actor)
| Symbol | Type | Range | Description |
|---|---|---|---|
defined |
bool | — | Acting rank has a non-NULL value for this permission |
ord_actor |
int | 1..N | rank_order of the acting member's rank |
ord_target |
int | 1..N | rank_order of the target member's rank |
ord_thresh |
int | 1..N | rank_order of the rank referenced by the stored threshold |
Worked example — ladder Leader(1)/Officer(2)/Lieutenant(3)/Private(4)/Recruit(5),
Officer's core.kick = Lieutenant (ord 3):
- Officer kicks Private (4):
4≥3✓,4>2✓ → allowed - Officer kicks Lieutenant (3):
3≥3✓,3>2✓ → allowed (boundary) - Officer kicks another Officer (2):
2≥3✗ → denied - Officer kicks Leader (1):
1≥3✗ → denied
Rank 1 needs no special case — its protection falls out of the ordering. Clause
three (ord_target > ord_actor) is what forbids acting on your own rank, and it
holds independently of how the threshold is configured.
Numeric limit
effective_cap = (L == -1) ? ∞ : L
allowed = (L ≠ NULL) ∧ (A ≤ effective_cap)
Where L is the stored limit for the actor's rank and A is the requested
amount. The request is accepted or rejected whole — never silently clamped down
to the cap.
In-game menu
The menu is the only clan management surface. See ui.md.
Accepting an invite from the no-clan screen re-validates at accept time: the clan must still exist and the player must still be unaffiliated.
Membership lifecycle
| Current state | Action | Actor requirement | Resulting state |
|---|---|---|---|
| None | Invite sent | core.invite; target holds no other membership |
Invited |
| Invited | Cancelled or TTL expired | core.manage_invites, or system |
None |
| Invited | Clan deleted | System (cascade) | None |
| Invited | Accepted | Self; re-validated: clan active and target still unaffiliated | Member (join rank) |
| Member | Promoted / demoted | core.setrank; both current and new rank must satisfy the threshold |
Member (new rank) |
| Member | Kicked | core.kick; target rank must satisfy the threshold |
None |
| Member (not sole leader) | Leaves | Self — no permission gate | None |
| Leader, others remain | Attempts to leave | Self | Rejected — must transfer leadership first |
| Leader + target member | Leadership transferred | Self, must hold rank_order = 1 |
Atomic swap of the two ranks |
| Leader, sole member | Leaves | Self | None, and clan deleted |
| Clan active | Deleted | core.delete_clan |
Clan deleted; all members and invites cascade to None |
Leadership transfer must be a single atomic statement, not a demote followed by a promote — two statements reopen a race window where a clan has zero or two leaders.
Edge cases
- If the sole leader attempts to leave while members remain: reject with "Transfer leadership before leaving". No auto-succession — it introduces a tie problem between same-rank members for no real benefit.
- If someone attempts to demote the leader: structurally unreachable. Rank 1
has the minimum ordinal, and every threshold must reference a rank strictly
junior to its owner, so no actor can satisfy
ord_target > ord_actoragainst it. The only path to a new leader is leadership transfer. - If two officers simultaneously promote each other: both rejected
independently. An Officer's
setrankthreshold requires the new rank to also satisfy the threshold, and Officer is senior to that threshold — so no Officer can ever assign Officer or above. Structurally impossible, not a race to be locked against. - If a member is kicked while a bank withdrawal from them is in flight: the withdrawal handler re-checks membership and permission at commit time, inside the same critical section as the ledger write. If the kick landed first, the withdrawal aborts and no funds move.
- If a rank is deleted while members hold it: rejected by default, reporting
the holder count. Optionally accepts a
migrate_torankid, reassigning holders atomically before removal.migrate_tomay not be rank 1. - If a permission references a deleted rank as its threshold target: reset to
NULL. Fail closed — never auto-widen to a neighbouring rank. - If rank IDs are non-contiguous: harmless.
rankidis opaque; onlyrank_orderis compared, and it is contiguous by invariant. - If a player is invited while already in a clan: rejected at send time, before any invite row is created. Re-checked again at accept time to close the race.
- If a player accepts a stale invite to a deleted clan: accept re-validates the clan is active, rejects, and deletes the stale row. Deletion should already cascade invites proactively; this is the defensive fallback.
Invariants
| # | Invariant | Checkable condition | On violation in live data |
|---|---|---|---|
| 1 | Exactly one leader per active clan | Exactly one member holds rank_order = 1, where member_count > 0 |
Flag for manual review. Do not auto-resolve silently; if unattended, keep oldest join timestamp and log every change |
| 2 | rank_order contiguous, unique, ≥2 ranks |
COUNT(*) = MAX(rank_order) and MAX ≥ 2 |
Renumber by sorting on legacy rankid; synthesise a second rank if fewer than 2 survive |
| 2a | The join rank exists and is a real rank of this clan | clans.join_rankid resolves to a row in clan_ranks for the same clan |
Backfill to the rank at MAX(rank_order); log the choice |
| 2b | Permission values are integers and compared numerically | clan_rank_permissions.value is INT NULL; no comparison is ever performed on a string column |
Fail the migration rather than proceeding — see Permission value column |
| 3 | One active membership per player | DB uniqueness on clan_members.steamid plus an application pre-check |
Constraint already exists; only the pre-check is new |
| 4 | No threshold resolves to a rank at or above its owner | Resolve target rank_order > owner rank_order on every write and in a periodic sweep |
Clamp to owner_order + 1 and log |
| 4a | Rank 1 always holds core.manage_ranks |
The stored value for (clan, rank 1, 'core.manage_ranks') is never false |
Restore it and log — a clan in this state is locked out of its own configuration |
| 5 | Every stored permission key is registered and type-matched | Boot-time sweep after all modules register | Exclude orphans from checks (fail closed) and report — do not delete; the module may return |
| 6 | rankid unique and never reused per clan |
Allocation from clans.next_rankid |
Seed next_rankid = MAX(rankid) + 1 per clan |
| 7 | No active clan has zero members | COUNT(members) ≥ 1 OR state ≠ active |
Soft-delete zombie clans |
| 8 | Rank and member counts bounded | 2 ≤ rank_count ≤ 50, member_count ≤ 100 |
Live maxima are 28 ranks and 18 members, so no clan is currently over cap. Grandfather any that appear; never truncate live rosters |
| 9 | Rank seed data never shared by reference | Code property — deep copy the seed template at clan creation | Covered by a unit test creating two clans and mutating one |
Invariant 9 is the fix for the legacy defect where all clans created in one server session shared a single mutable ranks table.
Identity field validation
Enforced identically at every entry point — creation, edit, game and web. A rule applied at intake and skipped at update is not a rule.
| Field | Limit | Counted in | Charset | Notes |
|---|---|---|---|---|
| Name | 3–32 | characters (128-byte ceiling) | Letters, digits, space, hyphen. No quotes or apostrophes | Trim and collapse whitespace; reject if no alphanumeric remains; reject case-insensitive collision with an existing clan |
| Abbreviation | 2–7 | characters (28-byte ceiling) | [%a%d] only |
Renders inline on every member's name. Uppercase-normalise. Requires both reserved-word and prohibited-content screening — see below. No column exists yet |
| Motto | 0–80 | characters (320-byte ceiling) | Name charset plus . , ! ? - |
Single line; strip newlines and chat colour codes |
| Description | 0–500 | bytes | Broad printable, multi-line | Total length only — no line-count or per-line cap |
| MOTD | 0–2000 | bytes | As Description | Total length only. If images are ever permitted, URLs must go through cityrp.imageHosts — there is exactly one allowlist |
Short identity fields are character-counted; long-form fields are byte-counted. This split is deliberate, not an inconsistency:
- Name, abbreviation and motto are rendered inline in chat, on the scoreboard and
above players' heads, so their visible length must be predictable. Counted
via
utf8.len, backed by a byte ceiling of 4× the character limit — theutf8mb4worst case — as defence in depth. - Description and MOTD are long-form panel text where exact character counts matter far less than bounding storage and network payload predictably.
Known consequence: a byte-counted field gives less content in non-Latin scripts — 500 bytes is roughly 250 Cyrillic or 166 CJK characters, and about 125 emoji. Acceptable for a predominantly English-speaking server, but it is a real asymmetry and should be a deliberate choice rather than a discovered one.
Truncation must never split a codepoint. Validate and reject rather than silently trimming.
Because Description and MOTD have no line-structure limits, the renderer must hard-wrap. A single unbroken 2000-byte token with no whitespace is valid input, so wrapping cannot rely on word boundaries — it must break on character boundaries when no break opportunity exists, and the panel must clip or scroll rather than expand. Responsibility for layout safety moves from validation to rendering; it does not disappear.
Storage uses DB_MAIN:QueryParams exclusively — never concatenated Run.
Web rendering HTML-escapes at output time regardless of write-time validation,
because output encoding is what protects against data that entered by another
path.
Schema is not a constraint
Gamemode migrations are cheap and routine here, so design the schema the system needs and migrate to it. Do not contort the design to fit the existing tables, and do not preserve a legacy column merely because it exists.
Concretely, this means: add abbreviation varchar(7) rather than repurposing
short (varchar(2000), selected by loadclan but never used); drop
clan_ranks.invite / kick / setrank / motd / forum once
clan_rank_permissions replaces them; drop the dead clan, clan_has_players
and clan_has_invite tables; and size name, motto and description to the
limits below rather than inheriting varchar(40) / varchar(70).
The one thing worth checking before dropping anything is whether it holds live
data worth keeping — clans.short in particular. That is a data-preservation
question at migration time, not a design constraint.
Similarly, clan_members should be keyed (clan, steamid) with a separate
unique constraint on steamid if one-clan-per-player is retained. The current
PRIMARY KEY (steamid) expresses the rule as a side effect of the key rather
than as an explicit constraint, which is why violations surface as duplicate-key
errors instead of clean rejections.
Charset
Join keys and text fields need different charsets, and the split must be deliberate.
| Column class | Charset | Why |
|---|---|---|
Join keys — clan_members.steamid, clan_invites.steamid, ledger SteamIDs |
latin1, matching players._SteamID |
SteamIDs are ASCII. These columns are joined against players constantly, and a cross-charset comparison forces an implicit conversion that prevents index use and can raise Illegal mix of collations |
| Player-authored text — clan name, motto, description, MOTD | utf8mb4 |
Must accept non-ASCII names, and the character-count limits assume it |
The clan tables migrate to match players, not the reverse. players is
central and widely joined; migrating it is the larger, riskier change, and the
clan tables are small.
Note this makes fixing defect 8 insufficient on its own: setleader's offline
path joins players._SteamID (latin1) to clan_members.steamid (utf8).
Correcting the clan_member → clan_members typo leaves a cross-charset join
behind it.
Two different things called _Clan
self._Clan is the numeric clan ID set by the clans plugin
(sv_player.lua:583 compares self._Clan > 0). self.cityrp._Clan is an
unrelated free-text latin1 string defaulted from
cityrp.configuration["Default Clan"] and compared against "none"
(sv_player.lua:1480, :1536).
Two concepts, one name, distinguished only by whether the access goes through
.cityrp. Core's public API must not expose the raw _Clan field at all — use
named accessors, and treat any bare _Clan in existing code as ambiguous until
read in context.
Prohibited content
Screening is provided by a shared library, cityrp.wordfilter, with multiple
named categories rather than one flat list. Categories differ in what they
protect against, who curates them, and which fields they apply to — merging them
makes all three worse.
| Category | Protects against | Example entries |
|---|---|---|
reserved |
Impersonating staff or the server | admin, mod, staff, owner, limelight |
slur |
Hate speech and targeted abuse | — curated by moderation |
profanity |
General swearing | — curated by moderation |
The set is extensible; other features will want categories clans does not.
Category-to-field policy. Each field declares which categories it rejects. The distinction that matters is consent: a clan name and abbreviation are broadcast to every player constantly and non-consensually, whereas a description or MOTD is only seen by someone who opens it.
| Field | reserved |
slur |
profanity |
|---|---|---|---|
| Clan name | reject | reject | reject |
| Abbreviation | reject | reject | reject |
| Motto | — | reject | reject |
| Description | — | reject | allow |
| MOTD | — | reject | allow |
Ownership is split. Product owns this matrix — which categories gate which fields is a policy call. Moderation owns the contents of each list. Neither should be hardcoded by engineering.
The reserved category is load-bearing precisely because the abbreviation limit
moved to 7 characters: at 4 only MOD fitted, whereas ADMIN, STAFF and
OWNER all fit in 7.
Screening is necessary rather than sufficient, and should be specified honestly:
- Normalise before matching, or it is bypassed in seconds: casefold, strip
separators and punctuation, fold leetspeak digits (
4→a,3→e,0→o,1→i/l) and common homoglyphs. An unnormalised list stops nobody. - Match whole-field for the abbreviation, substring for the longer free-text fields. Substring matching on short fields produces false positives — the classic case being innocuous words that contain a blocked substring — and a 2–7 character field has too little context to disambiguate.
- No list is complete. The real backstop is player reporting plus staff action, which requires a staff force-rename / force-retag tool. A clan that slips through must be fixable without deleting it and refunding it. This tool is a hard requirement of shipping the feature, not a nice-to-have.
cityrp.wordfilter is a gamemode library, not part of the clans module. No
profanity or slur filter currently exists anywhere in the gamemode, so this is
the first — which makes it the moment to centralise. Clan names, abbreviations,
mottos, descriptions and MOTDs all need it, and so will the next feature that
accepts player-authored text. This codebase already has a documented incident
where the same allowlist was independently copied into four places and drifted
apart; one library consumed by clans avoids repeating it.
Clans is its first consumer, so the library ships in Phase 1 — but it should be designed for the gamemode, not for clans.
Open decisions
| # | Decision | Recommendation |
|---|---|---|
| Resolved — hybrid. See Permission model | ||
MAX_RANKS and MAX_MEMBERS values |
Resolved — 50 ranks, 100 members. Live maxima are 28 and 18; these are abuse backstops with headroom, not design targets | |
core.manage_ranks should be delegable |
Resolved — keep delegable, with a UI warning when a leader grants it to another rank | |
| Resolved — two separate screens: reserved words for staff impersonation, prohibited terms for slurs, both via a shared library. See Prohibited content | ||
| Moot — co-equal ranks were scrapped, so a plain ordered list is correct. The menu route is in ui.md | ||
clans.short actually holds |
Resolved as a design question — migrations are cheap, so design the schema needed. Remains a data-preservation check at migration time only | |
| Resolved — split ownership. Product owns the category-to-field matrix; moderation owns list contents. See Prohibited content |