Regional Governance¶
A Region is a player-owned (or platform-owned) territory of 100–1500 sectors with its own rules, taxes, language pack, and governance model. Regional governance is how the people who live in a region collectively shape it — through elections, policy votes, and treaties with neighboring regions.
This page describes the player-visible governance loop. Subsystem invariants (tally math, lock contracts, scheduler windows) live in SYSTEMS/regional-governance.md.
🚧 Partial — Region, RegionalMembership, RegionalElection, RegionalVote, RegionalPolicy, RegionalTreaty models; service-layer create / vote / configure operations; treaty CRUD.
🚧 Partial — automated election close & winner-determination scheduler, automatic policy enactment when threshold met, treaty-expiry sweeper, voting-power recalc on membership change.
📐 Design-only — coalition / multi-candidate elections beyond plurality, secret ballot vs open ledger toggle per region, recall elections, weighted treaty terms beyond the current free-form terms JSONB.
Governance models¶
A Region's governance_type is one of:
| Code | Friendly name | Decision authority |
|---|---|---|
autocracy |
Autocracy | The owner decides everything; no votes required |
democracy |
Democracy | Citizens vote on policies and elect officials |
council |
Council | Elected council members vote on policies; citizens elect council |
The owner (Region.owner_id) is the human who pays the subscription. In autocracy mode they have unilateral authority; in democracy or council mode they retain veto power and the ability to call elections, but daily policy is voted on.
✅ Shipped (governance_type field). 🚧 Partial — autocracy is fully wired; democracy and council use the same vote machinery but the enactment path differs: autocracy applies changes immediately, democracy/council require a passing vote.
Membership and voting power¶
Players become members of a region when they spend turns in it or set it as their home region. RegionalMembership stores:
| Field | Meaning |
|---|---|
membership_type |
visitor, resident, or citizen |
reputation_score |
−1000 to +1000 (regional, separate from global personal reputation) |
local_rank |
Free-form text rank ("Senator", "Magistrate") set by region |
voting_power |
Decimal 0.0–5.0; default 1.0 |
joined_at, last_visit, total_visits |
Engagement record |
Voting eligibility¶
membership.can_vote = (
membership_type in ("citizen", "resident")
and voting_power > 0
and player.created_at + timedelta(days=60) <= now() # ADR-0056 N-V3
and player.personal_rep >= NEUTRAL_REP_THRESHOLD # ADR-0056 N-V3
and not multi_account_flag.blocks_vote(player.id) # ADR-0056 E-V5 / N-V3
)
Visitors do not vote. Citizens have full rights; residents typically have voting rights but reduced ceremonial standing.
ARIA surfaces a Region Owner governance tutorial on first login as a new region owner (P-I2 in the catalog) and narrates regional taxation visibility on first commerce in a non-home region (P-F9).
Three additional gates (per ADR-0056 N-V3):
- Account age ≥ 60 days. Set above the typical "spin up alts, play for two weeks" exploit horizon. Genuine new players in a fresh region wait two cycles to vote — acceptable trade-off given regional elections are the most concentrated multi-account exploit surface.
- Personal rep ≥ neutral. Wanted-status accounts and deeply-negative-rep accounts cannot vote. Threshold defined in
./ranking.md. - Multi-account clear or paid-tier. Per ADR-0056 E-V5 — paid-subscription accounts vote at full weight regardless of household clustering. Free-tier accounts in a flagged cluster have their vote weight discounted (0.5× on soft signals, 0× on hard signals) by the
MultiAccountDetectionService(see../../OPERATIONS/multi-account-detection.md).
🚧 Partial — RegionalMembership.can_vote property (extension to layer in the three new gates is 📐 design-only).
Voting power calculation¶
Default voting_power is 1.0. Region owners can adjust (clamped 0.0–5.0) based on:
- Membership tier: residents may default to 1.0, citizens to 1.5 (target spec).
- Local rank: senators get 2.0, magistrates 3.0 etc. (region-defined).
- Reputation: high regional reputation can grant a bonus (target spec — currently a static field).
📐 Design-only — automatic voting-power recalculation when reputation or membership changes.
Quorum¶
A vote is valid only if total voting power cast meets a minimum threshold. Per ADR-0059 N-D5, the formula is region-owner-configurable with safe defaults and a hard 2-voter floor:
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))
)
Region.governance_quorum_pct is a Decimal(3,2) clamped to [0.25, 0.60], default 0.33. Region owners dial the participation threshold in the admin surface — 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.
Examples at default 33%:
| Eligible voters | Quorum |
|---|---|
| 1 | 1 |
| 4 | 2 |
| 10 | 4 |
| 50 | 17 |
| 200 | 66 |
Below quorum, the policy fails regardless of approval %. The legacy formula max(0.10 × total, 5) is retired (it locked 4-citizen regions out of every vote).
✅ Shipped — the quorum formula (2-voter floor, configurable quorum_pct, single-voter short-circuit) is a pure helper in regional_governance_service.py, shared by the async vote path and the sync sweep, and gates policy resolution (verified vs all five ADR-0059 examples). ✅ Shipped — Region.governance_quorum_pct is a real Numeric(3,2) column (default 0.33, CHECK [0.25, 0.60]) per migration c5a8e2f1b9d3; the quorum helper reads the column and falls back to the canon default only when it is NULL.
Elections¶
RegionalElection is created via RegionalGovernanceService.start_election:
position = "governor" | "council_member" | "ambassador" | ...
candidates = JSONB list [{player_id, platform}]
voting_opens_at = now()
voting_closes_at = now() + voting_duration_days (default 7)
status = ACTIVE
A region can only have one active election per position at a time — the service rejects creation if an active election already exists.
Voting¶
POST /api/v1/regions/{region_id}/elections/{election_id}/vote
{
"candidate_id": "..."
}
Validations:
- Voter must be a member of the region.
- Voter's can_vote is true.
- Election is ACTIVE and now is within [voting_opens_at, voting_closes_at].
- Voter has not already voted in this election (UniqueConstraint('election_id', 'voter_id')).
A RegionalVote row is created with weight = membership.voting_power. ✅ Shipped — cast_election_vote (and cast_policy_vote) in regional_governance_service.py plus the POST /{region_id}/elections/{election_id}/vote and /policies/{policy_id}/vote routes enforce membership + can_vote eligibility, the open [opens_at, closes_at] window, and one-vote-per-voter. Live authed-vote proof is pending dev infra (deep /api/v1/regions/{id}/* paths currently 502 at the dev gateway — infra, not a code defect).
Vote-cast finality (per ADR-0059 N-F5). RegionalVote.weight is set 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. Vote recasting is not allowed — the existing UNIQUE (election_id, voter_id) constraint enforces single-vote-per-election; 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.
Result determination¶
When voting_closes_at passes, the scheduler:
- Aggregates
sum(weight) per candidate_id. - Picks plurality winner (most weight) by default.
- For high-stakes positions (governor), require supermajority — winner must clear
Region.voting_threshold(default 0.51, range 0.10–0.90) to take the position. If no candidate clears threshold, the election runs off or is voided. - Writes
RegionalElection.resultsJSONB with full tallies. - Sets
status = COMPLETED. - Emits
election_completedevent with winner and tallies.
✅ Shipped — tally_election aggregates sum(weight) per candidate, picks the plurality winner, applies the supermajority gate for governor, transitions ACTIVE → tallying → COMPLETED, and is idempotent (no re-tally). The NPC scheduler's _run_governance_sweep_sync drives close-and-tally forward on the durable voting windows (advisory-locked, per-row with_for_update + per-row commit). Live authed close-and-tally proof is pending dev infra (deep /api/v1/regions/{id}/* 502 at the dev gateway — infra, not a code defect).
Election duration¶
Min 1 day, max 30 days. Default 7. Set per-election via voting_duration_days. Region's election_frequency_days (default 90, range 30–365) controls how often a recurring office must hold a new election.
Policy proposals¶
RegionalPolicy is the core policy mechanism. Any citizen with sufficient reputation (target threshold: regional reputation ≥ 100) can propose:
POST /api/v1/regions/{region_id}/policies
{
"policy_type": "tax_rate" | "pvp_rules" | "trade_policy" | ...,
"title": "...",
"description": "...",
"proposed_changes": { ... }
"voting_duration_days": 7
}
The proposal enters the VOTING state with voting_closes_at = now + duration.
Voting on policies¶
Citizens cast yes/no votes. Each vote contributes the voter's voting_power to either votes_for or votes_against.
votes_for = Σ voting_power of yes-voters
votes_against = Σ voting_power of no-voters
total_votes = votes_for + votes_against
approval_pct = votes_for / total_votes
is_passing = approval_pct >= region.voting_threshold
✅ Shipped — RegionalPolicy.is_passing property and tally fields.
Enactment¶
When voting_closes_at passes:
- Lock the policy row.
- Verify quorum met. If not, set
status = REJECTED. - If
is_passing, setstatus = PASSED. - Apply
proposed_changesto the region (e.g., changetax_rate, modifytrade_bonuses). If the change touchesRegion.treasury_balance, write aRegionalTreasuryEntryrow in the same transaction (per ADR-0059 N-I4). - Set
status = IMPLEMENTED. - Emit
policy_enactedevent. - If failed, set
status = REJECTED.
✅ Shipped — finalize_policy resolves a closed policy (PASS → enact → IMPLEMENTED / REJECT, no double-enact) and enact_changes_onto_region applies proposed_changes to the region, clamping every value to the region CHECK bounds (tax_rate, voting_threshold, election_frequency, quorum, trade multipliers) and skipping reserved non-multiplier keys. Run automatically by _run_governance_sweep_sync. Live authed enactment proof is pending dev infra (deep /api/v1/regions/{id}/* 502 at the dev gateway — infra, not a code defect). ✅ Shipped — RegionalTreasuryEntry is a real append-only table (migration c5a8e2f1b9d3); finalize_policy writes a row in the same transaction on any balance-affecting enactment.
Treasury reconciliation¶
Per ADR-0059 N-I4, every balance-affecting event on Region.treasury_balance writes a RegionalTreasuryEntry row capturing before_balance, after_balance, delta, cause_type, cause_id, reason, at. Cause types: policy_enactment, tax_collection, expenditure, transfer_in, transfer_out, manual_admin_adjustment. Manual admin adjustments require the appropriate scope per ADR-0058 and the admin user's identity is captured in reason.
A daily reconciliation sweep (per ADR-0053) verifies SUM(treasury_entries.delta) == Region.treasury_balance for every active region. Mismatches fire a non-blocking ops alert with the region ID and discrepancy amount — the treasury keeps functioning, the alert surfaces a code-path that mutated the balance without writing an entry.
Policy types¶
| Type | Affects |
|---|---|
tax_rate |
Region.tax_rate (5%–25% range) |
pvp_rules |
Stored in Region.governance JSONB; sectors check it |
trade_policy |
Region.trade_bonuses JSONB |
cultural_identity |
Region.language_pack, Region.aesthetic_theme, Region.traditions |
governance_change |
Region.governance_type (requires supermajority) |
starting_credits |
Region.starting_credits — credits issued to citizens on first arrival (100–10,000 range; default 1,000) — 📐 Design-only |
economic_specialization |
Region.economic_modifiers JSONB — per-resource production multiplier (1.0×–3.0× range, e.g. {"organics": 2.5, "ore": 1.2}); high specialization on one resource biases the regional economy 📐 Design-only |
immigration_policy |
Region.governance JSONB — OPEN (anyone may enter), RESTRICTED (citizenship required for resource extraction / planet ownership), or CLOSED (border control: enter only via Treaty or invitation). 📐 Design-only |
Supermajority¶
Constitutional changes (governance_change, voting_threshold itself) require approval ≥ 0.66 regardless of region default. 📐 Design-only — type-specific threshold table.
Treaties between regions¶
Two regions can sign a treaty via mutual approval. RegionalTreaty:
| Field | Meaning |
|---|---|
region_a_id, region_b_id |
Signatories (must differ) |
treaty_type |
trade_agreement, defense_pact, non_aggression, cultural_exchange |
terms |
JSONB of negotiated terms |
signed_at, expires_at |
Time bounds |
status |
active, expired, cancelled |
Negotiation flow¶
- Region A's owner / governor proposes:
POST /api/v1/regions/{a}/treatieswith target regionb, type, and terms. - Region B's owner / governor receives the proposal in their treaty inbox.
- Region B accepts (creating the row with
status=active) or rejects. - Treaty is bilateral once accepted.
🚧 Partial — service has get_regional_treaties and uniqueness on (region_a, region_b, type). The proposal-and-acceptance flow and the inbox / approval UI are design-stage.
Treaty effects¶
| Type | Effect |
|---|---|
trade_agreement |
Reduced tariffs at each region's stations for citizens of the other region |
defense_pact |
Visiting fleets receive defensive aid in PvP encounters |
non_aggression |
PvP between citizens of the two regions costs +50% turns and is reputation-penalized |
cultural_exchange |
Shared language_pack entries; UI shows partner region's flavor text |
📐 Design-only — most effect application paths.
Expiry¶
Treaties with expires_at set are automatically status=expired once the timestamp passes. A scheduled sweeper handles this.
📐 Design-only — sweeper.
Region statistics¶
The governance UI pulls from get_regional_stats:
{
"total_population": 1247,
"citizen_count": 312,
"resident_count": 488,
"visitor_count": 447,
"average_reputation": 42.3,
"active_elections": 1,
"pending_policies": 3,
"treaties_count": 4
}
✅ Shipped.
UI surface¶
A region's governance page shows: - Population breakdown. - Active elections with candidate platforms and current tallies. - Pending policies with description, vote counts, time remaining. - Active treaties with partner regions. - For the owner: configuration controls (tax rate slider, governance toggle, election scheduler).
🚧 Partial — admin UI is solid; player-facing voting UI is functional but minimal.
Source map¶
| Concern | Path (target) |
|---|---|
| Region model | services/gameserver/src/models/region.py (Region, enums) |
| Membership | same file (RegionalMembership) |
| Election + Vote models | same file (RegionalElection, RegionalVote) |
| Policy model | same file (RegionalPolicy) |
| Treaty model | same file (RegionalTreaty) |
| Service | services/gameserver/src/services/regional_governance_service.py |
| Routes | services/gameserver/src/api/routes/regional_governance.py (target) |
| Scheduler (close elections, enact policies, expire treaties) | services/gameserver/src/services/regional_governance_scheduler.py (target) |
Related¶
OPERATIONS/multi-regional.md— region lifecycle, subscription model, Central Nexus.SYSTEMS/regional-governance.md— invariants, scheduler timing, vote-tally math.factions-and-teams.md— distinguishing regional citizenship from NPC factions.SYSTEMS/genesis-deploy.md— how regions are created.