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 tipb4f05b7b8(slice 1 LEG-3639; slice 2 LEG-3764 / PR #1363 @12a2d899; slice 3commit_takeoverLEG-3775 / PR #1367 @5acfeb622; slice 4 expiry sweep LEG-3791 / PR #1369 @3f04f4255). Phase 5 (player-client discovery UI) is ✅ tip-PRESENT on tip489e31851(RegionTakeoverEligiblePanel.tsx+regionTakeoverAPI; LEG-3956/3957; tip-delta vs priorb4f05b7b8). (authored 2026-09-01, re-verified 2026-09-03 vs Sectorwars2102 feat tip489e31851; 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:
region.status ∈ {'suspended', 'grace'}— reject otherwise (RegionNotAvailableForTakeover).- Caller is Galactic Citizen (
caller.is_galactic_citizen/ player flag) —GalacticCitizenRequired. - Caller does not already own a region (
existing_region_owner_subscription_for) —OneRegionPerOwnerRule. - Region has an owner to replace (implicit — suspended/grace regions retain
owner_iduntil termination).
PayPal integration (reuse, do not reinvent)¶
- Start:
paypal_service.create_regional_ownership_subscription(or thin wrapper) withmetadata={'takeover_region_id': region_id}so webhook can correlate intent. - Complete: existing
BILLING.SUBSCRIPTION.ACTIVATEDhandler must branch: if metadata carriestakeover_region_id, route tocommit_takeover(intent_id, subscription_id)instead of naive_activate_regional_ownershipname-keyed path. - Loser refund:
paypal_service.refund_subscriptioninside same transaction asTakeoverIntent.status = 'lost'per ADR-0058 A-F3.
Concurrency (ADR-0058 A-F3)¶
pg_advisory_xact_lock(hashtext('region:' || region_id))— matches existingregion_lock_keypattern inpaypal_service._activate_regional_ownership.SELECT … FROM regions WHERE id = :id FOR UPDATE.TakeoverIntentrow withstatusstate 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)¶
- 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. - GC-only vs any subscriber? Canon says GC; verify
is_galactic_citizenis the correct flag (notsubscription_tieralone). - Player-client discovery — Phase 5 is ✅ tip-PRESENT (
RegionTakeoverEligiblePanel.tsx+regionTakeoverAPI; LEG-3956/3957; tip489e31851); 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¶
region-lifecycle.md../OPERATIONS/monetization.md../DATA_MODELS/player.md#takeoverintent- ADR-0050, ADR-0058 A-F3
- Sectorwars2102:
paypal_service._activate_regional_ownership(678-734),paypal.pyregional_owner path (120-154)