Clans — Acceptance Criteria
Last updated
May be stale
This page was last updated ago, on 26 September 2026. Check what it says against the code before relying on it.
Testable conditions for the rebuilt Clans system. Each is verifiable without reading the design docs.
Status: Draft, reconciled against all decisions to date. Companion to design.md, core.md, bank.md, ui.md.
Evidence types
| Type | Gate | Meaning |
|---|---|---|
| pure-fn | BLOCKING | Formula or validator in isolation. No engine, no database |
| db-script | BLOCKING | Real MySQL, real generated SQL. Required wherever the generated statement is the thing under test |
| integration | BLOCKING | Needs a running server or real multi-table state |
| manual | ADVISORY | UI walkthrough |
Check lua/tests/ for existing infrastructure before assuming a framework.
lua/tests/cityrp2/libraries/sv_achievements.lua has a callback-queuing mock
pattern worth reusing for ordering tests.
1. Permission model
Threshold formula
allowed = defined ∧ (ord_target ≥ ord_thresh) ∧ (ord_target > ord_actor)
Ladder for all cases: Leader(1) / Officer(2) / Lieutenant(3) / Private(4) / Recruit(5).
| ID | Given | When | Then | Evidence |
|---|---|---|---|---|
| PERM-T1 | Officer's core.kick = Lieutenant(3) |
Kicks Private(4) | Allowed — 4≥3, 4>2 |
pure-fn |
| PERM-T2 | Same | Kicks Lieutenant(3) | Allowed — boundary case, 3≥3 |
pure-fn |
| PERM-T3 | Same | Kicks another Officer(2) | Denied — 2≥3 false |
pure-fn |
| PERM-T4 | Same | Kicks Leader(1) | Denied — rank 1 unreachable with no special case | pure-fn |
| PERM-T5 | Leader's core.kick = Officer(2), the loosest invariant 4 permits |
Kicks themselves | Denied — 1>1 false. Proves clause 3 protects the actor's own rank independently |
pure-fn |
| PERM-T6 | A hand-injected state where ord_thresh equals the actor's own ordinal, bypassing storage (invariant 4 normally forbids it) |
Actor targets own rank | Denied by clause 3 alone. This is the only case isolating clause 3 from clause 2 | pure-fn, called directly with hand-set ordinals |
| PERM-T7 | defined is false (NULL) |
Any target | Denied without resolving ord_thresh — no dereference of a NULL rankid |
pure-fn |
Numeric limit
effective_cap = (L == -1) ? ∞ : L; allowed = (L ≠ NULL) ∧ (A ≤ effective_cap)
Tests the kind, which currently has no consumer — bank.withdraw is boolean.
| ID | Given | When | Then | Evidence |
|---|---|---|---|---|
| PERM-N1 | L = NULL |
Requests any amount, including 0 | Denied — the action is unauthorised, not the amount | pure-fn |
| PERM-N2 | L = -1 |
Requests 10,000,000 | Allowed — isolate from any balance check | pure-fn |
| PERM-N3 | L = 25000 |
Requests exactly 25000 | Allowed — boundary, ≤ |
pure-fn |
| PERM-N4 | Same | Requests 25001 | Denied in full — never silently clamped to 25000 | pure-fn |
Resolution order
| ID | Given | Then | Evidence |
|---|---|---|---|
| PERM-R1 | Module default exists; rank has an explicit stored value | The stored value wins | pure-fn |
| PERM-R2 | A module registers a new permission; a pre-existing clan has no stored row on any rank | The module default applies with no migration | pure-fn |
| PERM-R3 | A stored row exists for an id no loaded module registers | Denied (NULL) and a warning logged via cityrp.logging. Never a silent grant |
pure-fn + integration |
| PERM-R4 | A stored value's shape does not match its declared kind |
Fail closed — treat as NULL | pure-fn |
Numeric comparison safety
| ID | Given | When | Then | Evidence |
|---|---|---|---|---|
| PERM-SQL1 | A clan with 11 ranks; the rank at ord 2 has a threshold referencing ord 10 | Targets ord 9, then ord 10 | ord 9 denied (9≥10 false), ord 10 allowed. A lexicographic comparison inverts both |
db-script — must run the real generated SQL. A pure-fn test cannot catch this; the bug is in how ordinals reach the comparison, not the formula |
DB:QueryParams binds every parameter as a quoted string literal, so this only
holds if clan_rank_permissions.value is INT. Live clans have 28 ranks, so
double-digit ordinals are real.
2. Invariants
| ID | Given | Then | Evidence |
|---|---|---|---|
| INV-1a | A newly created clan | Exactly one member holds rank_order = 1 |
pure-fn |
| INV-1b | A clan with zero or two rank-1 holders (injected) | Flagged for manual review, not silently auto-resolved | db-script |
| INV-2a | A new clan | COUNT(*) = MAX(rank_order) and MAX ≥ 2 |
pure-fn |
| INV-2b | Ranks with rank_order [1,2,4] (gap) |
Renumber yields [1,2,3], relative order preserved | pure-fn |
| INV-2c | A clan with exactly 2 ranks | Deleting either is rejected — cannot drop below 2 | pure-fn |
| INV-2d | A new clan | clans.join_rankid resolves to a real rank of that clan |
pure-fn |
| INV-2e | Deleting the current join rank without nominating a replacement | Rejected. With migrate_to, join_rankid updates atomically |
pure-fn |
| INV-3a | A player with no clan | Invite and accept succeed | pure-fn |
| INV-3b | A player already in Clan A | Invited to Clan B → rejected at send time, no invite row created | integration |
| INV-3c | Invited to B, then joins C before accepting | Accepting the B invite is re-validated and rejected | integration |
| INV-4a | Threshold references a rank strictly junior | Accepted | pure-fn |
| INV-4b | Threshold references own or a senior rank | Rejected at write time | pure-fn |
| INV-4c | A reorder would invalidate an existing threshold | Blocked synchronously at reorder time | pure-fn |
| INV-4d | Any attempt to unset core.manage_ranks on rank 1 |
Rejected regardless of actor. A clan can never be locked out of its own configuration | pure-fn + db-script |
| INV-5 | A stored permission id no module registers | Excluded from checks (fail closed), reported, not deleted | pure-fn |
| INV-6 | Ranks 1,2,3 created; 2 deleted; a new rank created | New rank gets rankid 4 — freed ids are never reused | pure-fn |
| INV-7 | A clan's sole member (its leader) leaves | Membership removal and clan deactivation are atomic — no observable zero-member active clan | integration |
| INV-8a | A clan at 50 ranks / 100 members | A 51st rank / 101st member is rejected, at both invite-send and invite-accept | pure-fn |
| INV-8b | A clan already over cap | Never truncated — only further growth blocked | pure-fn |
| INV-9 | Two clans created from the same seed template; one's ranks mutated | The other is unaffected, and the tables are not the same reference | pure-fn |
3. Membership lifecycle
| ID | Transition | Then | Evidence |
|---|---|---|---|
| LIFE-1 | None → Invited | Requires core.invite; target must be unaffiliated |
pure-fn |
| LIFE-2 | Invited → None | Cancel requires core.manage_invites; TTL expiry also clears |
pure-fn |
| LIFE-3 | Invited → None (cascade) | Clan deletion removes outstanding invites | integration |
| LIFE-4 | Invited → Member | Re-validated at accept: clan active and invitee unaffiliated. Joins at join_rankid |
integration |
| LIFE-5 | Rank change | Both current and new rank must satisfy the actor's threshold | pure-fn |
| LIFE-6 | Member → None (kick) | Target rank must satisfy the threshold | pure-fn |
| LIFE-7 | Member leaves | Self-service, no permission gate | pure-fn |
| LIFE-8 | Leader leaves with members remaining | Rejected — must transfer leadership first. No auto-succession | pure-fn |
| LIFE-9 | Leadership transfer | Single atomic statement, not demote-then-promote. Must pass with an offline target (defect 8) | db-script |
| LIFE-10 | Sole leader leaves | Membership removal and clan deletion are atomic | integration |
| LIFE-11 | Clan deleted | Members and invites cascade to None | integration |
Edge cases
| ID | Case | Then | Evidence |
|---|---|---|---|
| EDGE-1 | Two officers promote each other simultaneously | Both rejected independently. No concurrency harness needed — an Officer's threshold requires the new rank to satisfy it too, so Officer can never assign Officer. A property of fixed inputs, not of ordering | pure-fn |
| EDGE-2 | Member kicked while a withdrawal from them is in flight | Withdrawal re-checks membership at commit time; if the kick landed first it aborts and no funds move | db-script |
| EDGE-3 | Rank deleted while members hold it | Rejected by default with holder count; migrate_to reassigns atomically and may not target rank 1 |
pure-fn |
| EDGE-4 | A threshold references a rank that is deleted | Reset to NULL — never auto-widened to a neighbour | pure-fn |
| EDGE-5 | Rank ids 8 and 170 present (live data) | All seniority comparisons correct — proves no path compares raw rankid |
pure-fn |
| EDGE-6 | Stale invite to a deleted clan is accepted | Rejected; stale row deleted as a defensive fallback | integration |
4. Identity validation
Length boundaries
| Field | Counted in | Reject | Accept min | Accept max | Reject |
|---|---|---|---|---|---|
| Name | characters | 2 | 3 | 32 | 33 |
| Abbreviation | characters | 1 | 2 | 7 | 8 |
| Motto | characters | — | 0 | 80 | 81 |
| Description | bytes | — | 0 | 500 | 501 |
| MOTD | bytes | — | 0 | 2000 | 2001 |
All pure-fn, BLOCKING. There are no line-count or per-line caps.
| ID | Given | Then | Evidence |
|---|---|---|---|
| ID-1 | Name "O'Brien's Crew" |
Rejected — no quotes or apostrophes | pure-fn |
| ID-2 | Name "---" |
Rejected — no alphanumeric remains | pure-fn |
| ID-3 | "THE REAPERS" when "The Reapers" exists |
Rejected — case-insensitive collision | integration |
| ID-4 | Name " The Reapers " |
Stored as "The Reapers" — trimmed and collapsed, not rejected |
pure-fn |
| ID-5 | Abbreviation "A-B" |
Rejected — [%a%d] only, stricter than Name |
pure-fn |
| ID-6 | Abbreviation "abc" |
Stored as "ABC" |
pure-fn |
| ID-7 | Motto with an embedded newline and a chat colour code | Stored single-line, both stripped | pure-fn |
| ID-8 | Description of 500 four-byte emoji (2000 bytes) | Rejected — Description is byte-counted; 2000 > 500 | pure-fn |
| ID-9 | Name of 32 four-byte emoji (128 bytes) | Accepted — Name is character-counted, and 128 is exactly the ceiling | pure-fn |
| ID-10 | A Name rejected at creation is submitted via the edit path | Rejected identically. "A rule applied at intake and skipped at update is not a rule" | integration |
| ID-11 | A Description containing an apostrophe is saved and reloaded | Round-trips intact, no SQL error — behavioural proxy for QueryParams use |
integration |
Word filter matrix
| ID | Field | reserved | slur | profanity |
|---|---|---|---|---|
| WF-1..3 | Name | reject | reject | reject |
| WF-4..6 | Abbreviation | reject | reject | reject |
| WF-7..9 | Motto | allow | reject | reject |
| WF-10..12 | Description | allow | reject | allow |
| WF-13..15 | MOTD | allow | reject | allow |
WF-12 is the highest-value row — the one most likely to be over-blocked by an implementation that does not vary the category set per field.
| ID | Given | Then |
|---|---|---|
| WF-N1 | Abbreviation "4DM1N" |
Normalises to ADMIN, rejected |
| WF-N2 | Abbreviation "A-D-M-I-N" |
Separators stripped, rejected |
| WF-N3 | Homoglyph substitution (Cyrillic а for Latin a) | Belongs to cityrp.wordfilter's own test suite. Clans criteria confirm the library is invoked correctly per field, not that its internals work |
5. Bank
Amount validation
One validateBankAmount, called by deposit and withdrawal. All pure-fn, BLOCKING.
| ID | Amount | Then |
|---|---|---|
| BV-1 | nil / non-numeric |
Reject |
| BV-2 | NaN | Reject |
| BV-3 | math.huge (tonumber("1e400")) |
Reject |
| BV-4 | 100.50 |
Reject — non-integer |
| BV-5 | 0 |
Reject |
| BV-6 | -100 |
Reject |
| BV-7 | > MAX_TRANSACTION ($100,000,000) |
Reject |
| BV-8 | 100 |
Accept — proves no over-rejection |
| BV-9 | -500 on the deposit path |
Rejected identically. A negative deposit is an unauthenticated withdrawal; deposit must not be exempt |
| BV-10 | > per_withdrawal_cap on withdrawal |
Rejected in Lua — the cap is global config, not in SQL |
Atomic ledger append
| ID | Given | Then | Evidence |
|---|---|---|---|
| BA-1 | Balance 100 | Withdrawing 101 inserts 0 rows | db-script |
| BA-2 | Actor demoted between request and execution | 0 rows — permission is a predicate inside the statement, not an earlier check | db-script |
| BA-3 | A rank with no stored row, relying on the module default | Still permitted — LEFT JOIN + COALESCE(p.value, ?). An inner JOIN would wrongly deny |
db-script |
| BA-4 | Two writers race, computing the same seq |
Exactly one inserts; the other gets a duplicate-key error on (clan, seq) and retries. Never a user-facing failure |
db-script, two connections |
| BA-5 | Clan with no ledger rows | First deposit yields seq = 1, balance_after = amount. Withdrawal against empty fails HAVING |
db-script |
| BA-6 | A successful append | The fire-and-forget cache update follows; if it fails, the operation still succeeded | integration |
| BA-7 | Cache and SUM(delta) disagree |
Ledger wins; cache rebuilt; logged at Error | integration |
Caps, gate and latch
| ID | Given | Then | Evidence |
|---|---|---|---|
| BC-1 | Clan rolling 24h withdrawals at 70,000 of 75,000 | A further 10,000 is rejected in full | pure-fn |
| BC-2 | Clan has withdrawn 75,000 today via other officers; a freshly promoted officer has withdrawn 0 | Rejected — the cap tracks the clan, closing promote→withdraw→demote cycling | db-script |
| BC-3 | Clan 2 days old, 5 members | Bank locked — age not met | pure-fn |
| BC-4 | Clan 10 days old, 2 members | Bank locked — member count not met | pure-fn |
| BC-5 | Clan 3+ days, 3+ members | Unlocked | pure-fn |
| BC-6 | An unlocked clan drops to 2 members | Stays unlocked. The latch is stored, never recomputed. Funds are never trapped and kicking cannot freeze the bank | pure-fn + integration |
| BC-7 | Player performs any bank action | A second action within 10s is rejected; the cooldown is shared across deposit, withdraw and reclaim | integration |
| BC-8 | A sequence-collision retry occurs | Not subject to the cooldown — it is not an entry-route invocation | integration |
Self-reclaim
| ID | Given | Then | Evidence |
|---|---|---|---|
| BR-1 | 1000 deposited 100s ago (window 600s) | Reclaim allowed | pure-fn |
| BR-2 | Same, 601s ago | Rejected regardless of balance | pure-fn |
| BR-3 | 1000 deposited; balance now 700 | Receives 700 — lesser of unreclaimed remainder and current balance | pure-fn |
| BR-4 | Recruit with no bank.withdraw |
Reclaim allowed — not gated by withdraw permission | integration |
| BR-5 | Clan rolling cap exhausted | Reclaim still allowed — exempt, closing reclaim-starvation | integration |
| BR-6 | 1000 deposited; 400 reclaimed; 700 more attempted in window | Capped to the 600 remainder, not rejected outright | pure-fn |
Consent
| ID | Given | Then | Evidence |
|---|---|---|---|
| BCO-1 | Never deposited to this clan | Prompt shown before the deposit completes | pure-fn |
| BCO-2 | Last deposit or withdrawal 31 days ago (limit 30) | Prompt shown again | pure-fn |
| BCO-3 | Last deposit 10 days ago | No prompt | pure-fn |
| BCO-4 | Only viewed the balance/ledger for 40 days | Prompt shown — passive activity is not a bank interaction | pure-fn |
| BCO-5 | Consented in Clan A | First deposit to Clan B prompts independently — consent is per (steamid, clan) |
integration |
| BCO-6 | Prompt displayed | States all four facts, including whether this player holds withdraw permission | manual |
Dissolution
Fixture — balance 900. A (member): +1000/−200 → net 800. B (member): +500/−700 → net −200. C (kicked): +300 → net 300. Claims = 1100.
| ID | Then | Evidence |
|---|---|---|
| BD-1 | A receives floor(900×800/1100) = 654; B receives 0; C, an ex-member, receives floor(900×300/1100) = 245; the 1 remainder is discarded |
pure-fn |
| BD-2 | Negative net clamps to zero — B never owes anything | pure-fn |
| BD-3 | Total claims zero → equal split among zero-net depositors, else all to the leader, and an Error is logged — the branch should be unreachable under event sourcing | pure-fn |
| BD-4 | A deposit of 500 with a 200 reclaim yields net 300 — reclaims count as withdrawals for net purposes while remaining tagged reclaim |
pure-fn |
| BD-5 | After deletion, ledger rows still exist — the audit record outlives the clan | integration |
| BD-6 | Deletion is immediate: no hold, no countdown, no cancellation window | integration |
| BD-7 | Confirmation states the balance and the distribution, and requires the clan name typed | manual |
| BD-8 | Online recipients get GiveMoney then SaveData |
integration |
| BD-9 | Offline recipients get a remote row that returns without done() until they connect |
integration |
| BD-10 | On a noSave server (build/beta/beta2/event) the row returns without done(), deferring to a real server |
integration |
| BD-11 | Two servers poll the same payout row | Exactly one claims it via UPDATE ... SET done=1 WHERE id=? AND done=0; the other affects 0 rows and does not grant |
Undo
| ID | Given | Then | Evidence |
|---|---|---|---|
| BU-1 | Undo within 300s of creation | Full 50,000 refunded; no distribution logic invoked | integration |
| BU-2 | Balance is nonzero, however that arose | /clan undo rejected, directing to /clan delete. A defensive guard that must hold independently of the unlock gate |
pure-fn |
6. Regression — the twelve defects
Each must fail against the current implementation.
| ID | Defect | Then | Evidence |
|---|---|---|---|
| REG-1 | 1 — unchecked accept | A player already in Clan A accepting a Clan B invite is rejected outright; success is never reported unless the write affected a row | integration |
| REG-2 | 2 — return from inside async callback | A kicked or promoted online member's live state updates in the same session, no relog | integration |
| REG-3 | 3 — results/result typo |
State recovery repopulates clans; never net-clears | integration |
| REG-4 | 4 — string/number mismatch, missing done() |
deleteclan reaches a terminal state; never retries forever |
db-script |
| REG-5 | 5 — hardcoded server list | One row per server from configuration; v4b1_fl2 receives its row. Not server='all', which only the first poller consumes |
db-script |
| REG-6 | 6 — ranks by reference | Same test as INV-9 — write once | pure-fn |
| REG-7 | 7 — permissions that gate nothing | Every permission with a config surface actually gates its claimed action | integration |
| REG-8 | 8 — clan_member typo |
Leadership transfer to an offline target succeeds. Note the underlying charset mismatch must also be fixed | db-script |
| REG-9 | 9 — shared timer name | /clan model is not a registered command; no cityrp.model.register call for clan models; models unreachable via the normal flow (fail-closed) |
integration |
| REG-10 | 10 — unguarded nil index | A player with no clan invoking clan-info gets a clean message, no server error | integration |
| REG-11 | 11 — missing return after done() |
reloadclan/deleteclan with cityrp.clans absent return cleanly, no nil index |
integration |
| REG-12 | 12 — model.remove no-op |
cityrp.model.remove actually deregisters; conds does not grow across reloads |
pure-fn |
7. Concurrency — how to actually test it
GMod Lua is single-threaded and cooperative, so two Lua calls in one process
cannot race. The real contention is between database writers — this server,
another server, a future admin tool — and what protects against it is the
UNIQUE (clan, seq) constraint, not anything in Lua.
Two simultaneous withdrawals. Seed a balance of 30,000. Open two MySQL
connections. Fire the real generated INSERT for 25,000 from each with minimal
delay, or widen the window with held-open transactions. Assert exactly one
succeeds, the other hits the duplicate key and retries, and the final balance is
exactly 5,000 — never negative, never double-deducted.
Cheaper secondary check: count database round-trips per attempt using the
callback-queuing mock in lua/tests/cityrp2/libraries/sv_achievements.lua. A
read-then-write implementation issues two queries where the correct one issues
one — detectable without any real race.
Kick during withdrawal. Do not chase true simultaneity. Test both orderings deterministically, since correctness under either is the property: kick commits first → withdrawal inserts 0 rows, balance unchanged; withdrawal commits first → it succeeds and the kick still succeeds after. Assert the outcome is always one of those two, never a third.
Simultaneous promotion needs no harness at all — see EDGE-1.
Open items
| # | Item |
|---|---|
| 1 | Confirm lua/tests/ infrastructure and CI wiring before assuming any framework is available. GLuaTest is wired (ci.test.yml) and its unit and integration jobs are required on every pull request, the integration run against a real database; a criterion is enforced once it has a case — see strategy.md |
| 2 | Whether a core.invite-only holder sees the pending invite list (ui.md) |