CityRP Documentation Repository

Clans — Core Module

Last updated

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.withdraw does not use this kind — see bank.md: the per-transaction cap is a single global config value, not a per-rank quota, so bank.withdraw is BOOLEAN. NUMERIC_LIMIT currently 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_leader flag — 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:

  1. Look up (clan, rankid, permission_id) in clan_rank_permissions.
  2. If present, use it (type-checked against the declared kind).
  3. Otherwise use the module's declared default.
  4. If the permission is not registered by any loaded module, treat as NULL and log a warning via cityrp.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_ranks also gates editing a rank's own permission values, including core.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_actor against it. The only path to a new leader is leadership transfer.
  • If two officers simultaneously promote each other: both rejected independently. An Officer's setrank threshold 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_to rankid, reassigning holders atomically before removal. migrate_to may 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. rankid is opaque; only rank_order is 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 — the utf8mb4 worst 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
1 Hybrid capability registry vs. plain booleans Resolved — hybrid. See Permission model
2 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
3 Whether core.manage_ranks should be delegable Resolved — keep delegable, with a UI warning when a leader grants it to another rank
4 Reserved abbreviation list Resolved — two separate screens: reserved words for staff impersonation, prohibited terms for slurs, both via a shared library. See Prohibited content
5 How the UI presents tiers containing several ranks Moot — co-equal ranks were scrapped, so a plain ordered list is correct. The menu route is in ui.md
6 What 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
7 Curation of the word lists Resolved — split ownership. Product owns the category-to-field matrix; moderation owns list contents. See Prohibited content