0059 — Governance and citadel-defense fixes (Group I)¶
Status¶
Accepted.
Context¶
Six audit findings (N-D5, N-D4, N-F3, N-F5, N-V5, N-I4) cluster around two adjacent surfaces: regional governance (quorum, vote weight, treasury reconciliation) and planetary citadel defense (prerequisite checks, upgrade cancellation, grief detection). They share a common shape — small regressions where a system's pre-existing rule didn't account for an edge case discovered post-launch — and resolve cleanly into one batch.
N-V3 (governance voting tenure / rep gate) already closed under ADR-0056. N-V5 collapses into N-F3 (same surface, same fix). Real workload is five distinct picks.
Decision¶
N-D5 — Region-owner-configurable quorum, safe defaults¶
The launch quorum formula max(0.10 × total, 5) locked small regions out of any successful vote (a 4-citizen region needs 5 voters present, which is impossible). The fix is to make the participation threshold a region-owner-configurable policy with safe defaults and a hard 2-voter floor.
New Region columns:
| Column | Type | Constraint | Default | Notes |
|---|---|---|---|---|
governance_quorum_pct |
Decimal(3,2) | 0.25 ≤ x ≤ 0.60 |
0.33 |
Fraction of eligible voters who must cast a vote for the result to count. |
Quorum formula (final):
total_eligible = count of memberships with can_vote = true
quorum = (
total_eligible if total_eligible <= 1 # single-voter region; quorum is moot
else max(2, ceil(total_eligible * region.governance_quorum_pct))
)
Examples at default 33%:
- 1 voter → quorum 1 (everyone present is a quorum; the region owner makes their own calls).
- 4 voters → quorum max(2, ceil(1.32)) = 2.
- 10 voters → quorum max(2, ceil(3.3)) = 4.
- 50 voters → quorum max(2, ceil(16.5)) = 17.
- 200 voters → quorum max(2, ceil(66)) = 66.
The region owner can dial the participation threshold in the admin surface within the [25%, 60%] band — tighter for high-engagement regions, looser for casual ones. The 2-voter hard floor (when 2+ eligible) prevents single-voter rubberstamps. The configurability cap (60%) prevents griefing the region with an impossible quorum.
N-D4 — Citadel prerequisite check on specific buildings¶
The bug: the prerequisite check used the scalar citadel_level instead of the actual buildings. A destroyed Turret Network with a still-L4 citadel passed the check silently.
Fix: prerequisite evaluation reads from ImprovedPlanet.installed_buildings (or the equivalent building-state collection) and matches against a per-citadel-level required-buildings list. The list lives in ../FEATURES/planets/citadels.md — concrete entries per level, not a level scalar.
When evaluation finds a previously-installed prerequisite building offline (destroyed, deactivated, or pending repair):
- Block any new upgrade attempts that depend on that building. API returns
ERR_CITADEL_PREREQUISITE_OFFLINEwith the missing building name. - Cancel any in-progress upgrade that depends on the offline building (per N-F3 below).
- The existing citadel level stays — passive defense bonuses persist. Only future-progression friction is felt. (Per the user pick: rebuilding the prerequisite restores upgrade access without forcing a re-acquisition cycle.)
N-F3 + N-V5 — Prerequisite-loss surfaces with realtime + ARIA narration¶
When a prerequisite-loss event fires (per N-D4), the existing silent-failure path is replaced with a narrated cancellation flow:
- Realtime event
citadel.upgrade_cancelledpublished onpersonal:{owner_user_id}with payload:
{
"planet_id": "<uuid>",
"cancelled_upgrade": "<upgrade_name>",
"reason": "prerequisite_building_offline",
"lost_building": "<building_name>",
"credits_refunded": <int>,
"at": "<iso8601>"
}
- ARIA narration: "Your
<cancelled_upgrade>was cancelled —<lost_building>is offline. Rebuild it to resume the upgrade." - Refund: any credits committed to the in-progress upgrade refund to the player's wallet (in the same transaction as the cancellation).
This event becomes the grief-detection signal for N-V5: a sudden flurry of cancellation events on the same planet within a short window indicates a coordinated attack and surfaces in the operator dashboard.
N-F5 — Vote weight snapshot at cast-time; first vote sticks¶
Per the user pick, the snapshot semantics are explicit and recasting is not allowed:
RegionalVote.weightis set tomembership.voting_powerat the instant of cast and is immutable afterward. Subsequent changes to the player'svoting_power(membership tier change, rep change, region exit-and-rejoin) do not affect already-cast votes.- A player can vote once per election. The existing
UNIQUE (election_id, voter_id)constraint enforces this; a second cast attempt rejects withERR_ALREADY_VOTED. - ARIA narrates the finality at vote-cast time: "Your vote is recorded. Votes are final once cast."
This trades flexibility for predictability — vote tallies don't drift mid-window. Players who anticipate weight changes vote with the timing that maximizes their preferred weight; others get stable, auditable tallies.
N-I4 — Treasury reconciliation after policy enactment¶
A new RegionalTreasuryEntry table captures every balance-affecting event so the running Region.treasury_balance is auditable.
Schema (full definition in ../DATA_MODELS/gameplay.md):
| Column | Notes |
|---|---|
id |
UUID PK |
region_id |
FK regions.id |
before_balance |
Treasury balance immediately before the event |
after_balance |
Treasury balance immediately after the event |
delta |
after_balance - before_balance (signed; positive for inflow, negative for outflow) |
cause_type |
Enum: policy_enactment, tax_collection, expenditure, transfer_in, transfer_out, manual_admin_adjustment |
cause_id |
UUID — RegionalPolicy.id, Tax.id, etc. |
reason |
Free-text snapshot of the event |
at |
DateTime |
Every policy enactment that touches the treasury writes a row in the same transaction as the balance mutation. Manual admin adjustments (per ADR-0058 admin.subscriptions.modify-equivalent) write a row with cause_type = manual_admin_adjustment and the admin user in reason.
Reconciliation sweep (per ADR-0053): a daily job verifies SUM(treasury_entries.delta) == Region.treasury_balance for every active region. Mismatches fire an ops alert with the region ID and the discrepancy amount; the alert is non-blocking (the treasury keeps functioning) but signals a code-path that mutated the balance without writing an entry.
Consequences¶
- The configurable quorum (
governance_quorum_pct) ships as a new column with a default backfill of 0.33 for existing regions. The legacy formulamax(0.10 × total, 5)is retired. - The citadel prerequisite check moves from a scalar comparison to a building-state read. Existing planets with previously-incomplete prerequisite buildings (rare, but possible) get a one-time check at deploy and downgrades any over-leveled state with an explanatory ARIA narration.
- The realtime event
citadel.upgrade_cancelledis a new event type added to the realtime-bus event taxonomy. Schema versioning rules already in place handle the addition. - Vote-recasting is explicitly prohibited; ARIA's vote-cast narration becomes load-bearing for player understanding. The UI must clearly show "this is final" before submission.
- The
RegionalTreasuryEntrytable is append-only and has no GDPR exposure (no player-identifiable data — only region-level balance changes). Retention: indefinite. - The reconciliation sweep is a defensive measure. If it fires regularly, that's a code-path bug to fix; the alert is the canonical signal.
Alternatives considered¶
- Single closed-form quorum formula (
max(min(5, ceil(total × 0.5)), ceil(total × 0.10))). Considered (was the recommended pick); rejected per user direction. Region-owner configurability gives operators local control over governance pace without a code change. - Allow vote recasting during the voting window. Considered (was the recommended pick); rejected per user direction. First-vote-sticks is simpler — no UX for "are you sure", no "wait, what was my weight again", clean tallies.
- Auto-downgrade citadel level on prerequisite loss. Rejected — too punitive for combat losses, and creates re-acquisition treadmills that don't reward defensive recovery investment.
- Soft-flag the silent-grief case without realtime + narration. Rejected — a grief without notification is not a grief the player can defend against. The narration is the defense.
Related¶
- ADR-0050 — region lifecycle states (governance assumes
activefor normal voting). - ADR-0053 — periodic-service surface for the treasury reconciliation sweep.
- ADR-0056 — voting eligibility gates (closed N-V3).
- ADR-0058 — admin scopes for treasury manual adjustments.
../FEATURES/gameplay/regional-governance.md— quorum formula, vote-cast semantics, treasury entries.../FEATURES/planets/citadels.md— citadel prerequisite-buildings list.../SYSTEMS/realtime-bus.md—citadel.upgrade_cancelledevent.../DATA_MODELS/gameplay.md—RegionalTreasuryEntryschema;Region.governance_quorum_pctcolumn.