Skip to content

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

  1. Block any new upgrade attempts that depend on that building. API returns ERR_CITADEL_PREREQUISITE_OFFLINE with the missing building name.
  2. Cancel any in-progress upgrade that depends on the offline building (per N-F3 below).
  3. 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_cancelled published on personal:{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.weight is set to membership.voting_power at the instant of cast and is immutable afterward. Subsequent changes to the player's voting_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 with ERR_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 formula max(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_cancelled is 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 RegionalTreasuryEntry table 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.