Skip to content

Regional Governance

Status: 📐 Design-only — region ownership rules, taxes, and governance tooling documented as target spec; runtime governance stack not on tip. (re-verified 2026-08-21 vs Sectorwars2102 46bce720.)

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 📐 Design-only, corrected 2026-08-04. ADR-0093 item 20 rules a closed vocabulary (administrator / moderator / None), PATCH-validated server-side — but the fold-pass previously asserted this as shipped without re-checking the code. Verified: models/region.py:267 is a plain String(50) column, and api/routes/regional_governance.py's own PATCH-body docstring states "local_rank is free text bounded by the column's String(50) limit" — no enum validation exists anywhere.
voting_power Decimal 0.0–5.0; default 1.0
joined_at, last_visit, total_visits Engagement record

local_rank is an honorific, not an authority grant — actual region-scoped authority (who can configure policy, moderate, or act on the region's behalf) was INTENDED to be enforced by RegionalPermission on region-scoped routes, per ADR-0093 item 20's "go both" ruling. 📐 Design-only, re-corrected 2026-08-08 — that enforcement does not exist in any form. services/gameserver/src/services/regional_auth_service.py, which defined RegionalPermission and check_regional_permission, was deleted in commit 1619912d ("promote(slice-9): orphan cleanup — 9 dead files missed by slices 5/6") as one of nine orphaned dead files; the deletion removed all 459 lines and the path is absent from the tree (git ls-files returns nothing for it, and grep -rn "RegionalPermission\|check_regional_permission" services/gameserver/src/ returns no hits). Only stale __pycache__/regional_auth_service.cpython-3*.pyc bytecode remains in build caches — not source. The doc's own 2026-08-04 "correction," which claimed the module still existed and was merely uncalled, was itself wrong. Item 20's enforcement half is therefore not just unwired but unbuilt from scratch, same as the local_rank validation above. Both halves are RBAC/admin-gating-adjacent — building the enforcement is a Max-gated fix, not autonomous.

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.

[NO-CANON, added 2026-08-06] voting_power = 0.0 is a deliberate allowance, not just the floor of the clamp range — it lets an owner disenfranchise a specific member outright (can_vote already gates on voting_power > 0), a mechanic the doc doesn't call out explicitly. Ref: api/routes/regional_governance.py:143.

Member policy proposals and treaty visibility

[NO-CANON, added 2026-08-06] Two undocumented decisions on the member-facing (non-owner) routes:

  • Proposal eligibility gate. This page's "citizen tier target 1.5" language targets a "regional reputation ≥ 100" proposal gate, but no regional-reputation field exists on RegionalMembership today — only the separate per-region reputation_score used for candidacy (a different canon number). The shipped gate instead checks citizen/resident membership via can_vote (same predicate the vote-casting routes use). Flagged to DECISIONS for the real threshold. Ref: api/routes/regional_governance.py:1213.
  • Treaty terms redaction. The member-scoped treaty list (GET .../treaties, any region member) redacts the negotiated terms payload — members see type/partner/status/expiry only. The owner-scoped /my-region/treaties read is unaffected and still returns full terms. Ref: api/routes/regional_governance.py:1316-1338.

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. (folded from ADR-0059 N-D5, re-verified 2026-08-07)

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. (folded from ADR-0059 N-F5, re-verified 2026-08-07)

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. (folded from ADR-0059 N-I4, re-verified 2026-08-07)

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. ✅ Shipped — reconcile_region_treasuries in services/scheduler/economy_governance_sweeps.py runs the sweep. (folded from ADR-0059 N-I4, re-verified 2026-08-07)

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). ✅ Shipped — owner proposal / inbox / accept / reject / terminate lifecycle UI (RegionTreatyPanel mounted from RegionOwnerControls; API: listMyTreaties, proposeTreaty, acceptTreaty, rejectTreaty, terminateTreaty) — Sectorwars2102 056fc377 / PR #413. Treaty gameplay effects and the expiry sweeper remain independently statused below.

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

Status: Consolidated ✅/🚧/📐 markers live in Player-facing affordances below. This section retains the governance-page design sketch; do not read unmarked bullets as tip-live.

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 — GS stats + treaty lifecycle UI are player-live on tip; treaty gameplay effects, expiry sweeper, and economic_specialization policy enactment remain 📐 (see PF section).

Player-facing affordances

  • Region owner controls bundleRegionOwnerControls.tsx mounted from StatusBar.tsx / LocationDropdown.tsx; opens governance, treaty, invite, and trade-dock panels for the player's owned region (origin/feat 46bce720).
  • Treaty inbox lifecycleRegionTreatyPanel.tsx (list / propose / accept / reject / terminate) via listMyTreaties, proposeTreaty, acceptTreaty, rejectTreaty, terminateTreaty on the regional API client (origin/feat 46bce720).
  • Elections + policy votingGovernancePanel.tsx with ElectionCard.tsx, PolicyCard.tsx, and ProposePolicyForm.tsx for active elections, pending policies, and owner policy proposals (origin/feat 46bce720).
  • Citizenship badge + invitesCitizenshipBadge.tsx (membership tier / regional standing chrome) and RegionInvitePanel.tsx (invite flow for regional membership) (origin/feat 46bce720).
  • Region trade dock panelRegionTradeDockPanel.tsx for region-scoped trade-dock controls mounted from the owner bundle (origin/feat 46bce720).
  • 📐 Treaty gameplay effectstrade_agreement tariff reduction, defense_pact aid, non_aggression turn/rep penalties, and cultural_exchange language-pack sharing described in Treaty effects; effect application paths are not player-visible on tip.
  • 📐 Treaty expiry sweeper — automatic status=expired when expires_at passes (Expiry); scheduled sweeper is design-only on tip.
  • 📐 economic_specialization policy enactment — policy type exists in canon; dedicated enactment UI / downstream economic hooks remain design-only on tip.

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)
Owner controls shell services/player-client/src/components/governance/RegionOwnerControls.tsx
Treaty panel services/player-client/src/components/governance/RegionTreatyPanel.tsx
Governance panel + election/policy cards services/player-client/src/components/governance/GovernancePanel.tsx, ElectionCard.tsx, PolicyCard.tsx, ProposePolicyForm.tsx
Citizenship + invites services/player-client/src/components/governance/CitizenshipBadge.tsx, RegionInvitePanel.tsx
Region trade dock services/player-client/src/components/governance/RegionTradeDockPanel.tsx
StatusBar / location mount points services/player-client/src/components/layouts/StatusBar.tsx, LocationDropdown.tsx
Player-client tests RegionOwnerControls.test.tsx, RegionTreatyPanel.test.tsx, GovernancePanel.test.tsx, ElectionCard.test.tsx, PolicyCard.test.tsx, CitizenshipBadge.test.tsx, RegionInvitePanel.test.tsx, RegionTradeDockPanel.test.tsx under components/governance/__tests__/