CityRP Documentation Repository

Clans — Acceptance Criteria

Last updated

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
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)