Skip to content

Region GC-Subscription Takeover — Implementation Design Pass

Status: 🚧 Partial — dedicated design pass for LEG-286. Not station economic/military takeover (../FEATURES/economy/port-ownership.md — shipped). Phases 1–4 are shipped on feat tip b4f05b7b8 (slice 1 LEG-3639; slice 2 LEG-3764 / PR #1363 @ 12a2d899; slice 3 commit_takeover LEG-3775 / PR #1367 @ 5acfeb622; slice 4 expiry sweep LEG-3791 / PR #1369 @ 3f04f4255). Phase 5 (player-client discovery UI) is ✅ tip-PRESENT on tip 489e31851 (RegionTakeoverEligiblePanel.tsx + regionTakeoverAPI; LEG-3956/3957; tip-delta vs prior b4f05b7b8). (authored 2026-09-01, re-verified 2026-09-03 vs Sectorwars2102 feat tip 489e31851; LEG-4113.)

Problem statement

When a region owner's subscription lapses, the region enters suspended → grace → terminated. During suspended/grace, any Galactic Citizen may assume ownership by starting a PayPal region_owner subscription and completing payment. Canon describes this in region-lifecycle.md and ../OPERATIONS/monetization.md. Init + webhook commit paths ship on feat tip b4f05b7b8:

Artifact Tip reality (b4f05b7b8)
POST /api/v1/regions/{region_id}/takeover Shipped — api/routes/regions.py:59-89 (takeover_region) + region_lifecycle_service.py:414+ (execute_takeover); LEG-3764 / merge 12a2d899
TakeoverIntent model Shipped — models/takeover_intent.py + additive migration (LEG-3639)
commit_takeover webhook branch Shipped — region_lifecycle_service.py:547+; PayPal BILLING.SUBSCRIPTION.ACTIVATED routes custom_id prefix region_takeover_ → commit_takeover (paypal_service.py:487-495); LEG-3775 / PR #1367 @ 5acfeb622; unit tests in test_region_takeover_paypal.py
TakeoverIntent expiry sweep Shipped — sweep_expired_takeover_intents @ region_lifecycle_service.py:661+, daily governance sweep (economy_governance_sweeps.py); LEG-3791 / PR #1369 @ 3f04f4255; unit tests in test_region_takeover_expiry.py
PayPal create_regional_ownership_subscription Shipped — paypal_service.py:283+ (sets custom_id=region_takeover_{intent_id} when takeover_intent_id passed from execute_takeover)
_activate_regional_ownership ownership flip Shipped separately — legacy regional_owner_{user}_{name} path; region GC takeover completion uses the dedicated commit_takeover branch above, not this name-keyed path

Why LEG-286 stayed blocked: real-money PayPal flow + race handling requires human payments GO (LEG-DEC-810) and threat review before build — not a single monolithic WO.

Design target (unchanged from canon)

Authoritative prose remains in region-lifecycle.md and ../DATA_MODELS/player.md#takeoverintent. This pass adds phasing, dependencies, and review gates only.

Preconditions (caller validation)

All checked inside takeover_region before PayPal flow starts:

  1. region.status ∈ {'suspended', 'grace'} — reject otherwise (RegionNotAvailableForTakeover).
  2. Caller is Galactic Citizen (caller.is_galactic_citizen / player flag) — GalacticCitizenRequired.
  3. Caller does not already own a region (existing_region_owner_subscription_for) — OneRegionPerOwnerRule.
  4. Region has an owner to replace (implicit — suspended/grace regions retain owner_id until termination).

PayPal integration (reuse, do not reinvent)

  • Start: paypal_service.create_regional_ownership_subscription (or thin wrapper) with metadata={'takeover_region_id': region_id} so webhook can correlate intent.
  • Complete: existing BILLING.SUBSCRIPTION.ACTIVATED handler must branch: if metadata carries takeover_region_id, route to commit_takeover(intent_id, subscription_id) instead of naive _activate_regional_ownership name-keyed path.
  • Loser refund: paypal_service.refund_subscription inside same transaction as TakeoverIntent.status = 'lost' per ADR-0058 A-F3.

Concurrency (ADR-0058 A-F3)

  1. pg_advisory_xact_lock(hashtext('region:' || region_id)) — matches existing region_lock_key pattern in paypal_service._activate_regional_ownership.
  2. SELECT … FROM regions WHERE id = :id FOR UPDATE.
  3. TakeoverIntent row with status state machine: pending → won → transferred | failed | lost | expired.

What already ships (do not duplicate)

paypal_service._activate_regional_ownership  # lines 678-734 — ownership flip + advisory lock
paypal.py POST /subscriptions               # regional_owner tier creation
Region.status state machine                 # region_lifecycle_service (suspended/grace/terminated)
multi_account_detection _PAID_TIERS         # includes region_owner

Remaining gap: cancel-old-owner PayPal sub automatically (open question § below). Phase 5 player-client discovery UI is ✅ tip-PRESENT (RegionTakeoverEligiblePanel.tsx + regionTakeoverAPI; LEG-3956/3957; tip 489e31851).

Phased work orders (spawn from LEG-286)

Execute in order. Each phase has explicit Accept + Proof. Human payments GO required before Phase 1 build (safety list).

Phase Component Title Depends Accept (summary)
0 SW2102-Docs This design pass landed + threat-review checklist complete — Doc on main; checklist § below signed off in ticket
1 Gameserver TakeoverIntent model + additive migration Phase 0 + human GO Shipped LEG-3639 — migration + model match player.md @ feat tip b4f05b7b8
2 Gameserver POST /regions/{id}/takeover handler + validation Phase 1 Shipped LEG-3764 @ merge 12a2d899 — regions.py:59-89, execute_takeover @ region_lifecycle_service.py:414+; unit tests in test_region_takeover.py
3 Gameserver Webhook commit_takeover branch + race/refund Phase 1–2 Shipped LEG-3775 @ PR #1367 merge 5acfeb622 — winner transferred + region flip under lock; concurrent losers lost; suspended/grace re-check; unit tests in test_region_takeover_paypal.py
4 Gameserver TakeoverIntent expiry sweep (scheduler) Phase 1 Shipped LEG-3791 @ PR #1369 merge 3f04f4255 — sweep_expired_takeover_intents; late ACTIVATED refunds via commit_takeover; unit tests in test_region_takeover_expiry.py
5 Player Client "Regions eligible for takeover" surface (optional follow-on) Phase 3 merged ✅ tip-PRESENT — RegionTakeoverEligiblePanel.tsx + regionTakeoverAPI (LEG-3956/3957; tip 489e31851; tip-delta vs b4f05b7b8)

Threat-review checklist (required before Phase 1)

Cipher / human review must explicitly sign off each item:

# Risk Mitigation in design
T1 Forged webhook activates takeover Existing ADR-0058 signature validation + idempotency (processed_webhook_events) — verify no bypass on takeover branch
T2 Double-spend / escrow without ownership FOR UPDATE + atomic lost + refund_subscription same tx
T3 Takeover of active region (not lapsed) Hard gate on region.status in handler and in commit_takeover re-check
T4 Non-GC or multi-region owner assumes region GalacticCitizenRequired + OneRegionPerOwnerRule
T5 PayPal metadata tampering takeover_region_id from server-created intent row, not client body
T6 Idempotency replay flips owner twice Webhook dedupe + intent status guards (pending only)
T7 Old owner subscription left active Document: old paypal_subscription_id implicitly terminated (canon: no refund) — confirm PayPal cancel API called or documented deferral
T8 Region name vs UUID confusion Takeover uses region_id UUID; reuse existing region row, not create_regional_ownership_subscription name path

Open questions (raise DECISION before Phase 1 if unresolved)

  1. Cancel old owner's PayPal sub automatically? Canon says "implicitly terminated" — confirm whether paypal_service.cancel_subscription(old_sub_id) is in scope Phase 3 or deferred.
  2. GC-only vs any subscriber? Canon says GC; verify is_galactic_citizen is the correct flag (not subscription_tier alone).
  3. Player-client discovery — Phase 5 is ✅ tip-PRESENT (RegionTakeoverEligiblePanel.tsx + regionTakeoverAPI; LEG-3956/3957; tip 489e31851); launch blocker for backend-only ship is closed for PC discovery.

Test plan (gameserver)

  • Unit: validation matrix (status, GC, one-region rule).
  • Unit: state machine transitions (pending→lost, won→transferred, won→failed).
  • Integration: concurrent takeover intents on same region (mock PayPal).
  • Integration: webhook idempotency replay does not double-flip.

Rollback

Feature-flag or route-not-mounted is sufficient pre-launch. Migration is additive (takeover_intents table) — rollback = stop routing webhooks to commit_takeover; table can remain empty.

References