Skip to content

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:

  1. Aggregates sum(weight) per candidate_id.
  2. Picks plurality winner (most weight) by default.
  3. 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.
  4. Writes RegionalElection.results JSONB with full tallies.
  5. Sets status = COMPLETED.
  6. Emits election_completed event 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:

  1. Lock the policy row.
  2. Verify quorum met. If not, set status = REJECTED.
  3. If is_passing, set status = PASSED.
  4. Apply proposed_changes to the region (e.g., change tax_rate, modify trade_bonuses). If the change touches Region.treasury_balance, write a RegionalTreasuryEntry row in the same transaction (per ADR-0059 N-I4).
  5. Set status = IMPLEMENTED.
  6. Emit policy_enacted event.
  7. 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

  1. Region A's owner / governor proposes: POST /api/v1/regions/{a}/treaties with target region b, type, and terms.
  2. Region B's owner / governor receives the proposal in their treaty inbox.
  3. Region B accepts (creating the row with status=active) or rejects.
  4. 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)