Skip to content

Region Lifecycle System

Status: 📐 Design-only — Entirely design-only as the page's own marker states: no region_lifecycle_service, no extended status enum (grace/generation_corrupt absent), no daily cron, no takeover endpoint … (impl audit 2026-06-16)

📐 Design-only. Cascade orchestrator and Central Nexus Bank are not committed yet; this page is the prescriptive runtime spec. Canonical decision in ADR-0050. Player-facing pricing/tier surfaces in ../OPERATIONS/monetization.md.

The runtime that governs a region's lifecycle from generation through subscription lapse, takeover, termination, and final hard-delete. Implements the five-state state machine, the takeover endpoint, the asset preservation cascade (ship evacuation, station relocation, planet-safe Bank transfer, planet compensation), and the audit-row preservation across all of it. Composes with the existing PayPal webhook handlers and the galaxy generator's Phase 14 attachment.

State machine

                        ┌─────────────┐
                        │   pending   │  (during generation; phases 0–13)
                        └──────┬──────┘
                               │ Phase 13 commits
                               ▼
                        ┌─────────────┐ ──────────► ┌─────────────────────┐
                        │   active    │             │ generation_corrupt  │ (rollback fails)
                        │  (paying)   │             └─────────────────────┘
                        └──────┬──────┘                       │
                               │ payment failure              │ ops decides per case:
                               ▼                              │ - manual recover
                        ┌─────────────┐                       │ - hard delete
                        │  suspended  │                       │ - replace
                        └──────┬──────┘                       │
                          7 days                              │
                               ▼                              │
                        ┌─────────────┐                       │
                        │    grace    │                       │
                        └──────┬──────┘                       │
                          30 days                             │
                               ▼                              │
                        ┌─────────────┐                       │
                        │ terminated  │                       │
                        └──────┬──────┘                       │
                          7 days + cascade complete           │
                               ▼                              │
                        ┌─────────────┐                       │
                        │ hard_delete │ ◄─────────────────────┘
                        │   (final)   │
                        └─────────────┘

  ── Takeover (POST /regions/{id}/takeover) ──
  Available from `suspended` AND `grace` only. Returns Region.status to `active`.

State transition triggers

From → To Trigger Service
pendingactive Phase 13 validation gate commits + Phase 14 attachment runs galaxy_service:GalaxyGenerator.commit_region
pendinggeneration_corrupt Phase 13 strict-rollback fails (FK error, disk full, etc.) galaxy_service:_handle_rollback_failure
activesuspended PayPal BILLING.SUBSCRIPTION.CANCELLED or BILLING.SUBSCRIPTION.SUSPENDED webhook paypal_service:_handle_subscription_cancelled
suspendedgrace 7 days elapsed since Region.suspended_at, payment unrecovered region_lifecycle_service:advance_to_grace (daily cron)
graceterminated 30 days elapsed since Region.suspended_at, payment unrecovered region_lifecycle_service:advance_to_terminated (daily cron)
suspended / graceactive Takeover by another GC subscriber (POST /regions/{id}/takeover succeeds) OR original owner pays balance region_lifecycle_service:execute_takeover / paypal_service:_handle_payment_completed
terminated → cleanup running → final delete 7 days post-termination + cleanup orchestrator success region_lifecycle_service:cleanup_orchestrator then region_lifecycle_service:hard_delete_region

Region.status enum (extended)

Per ADR-0050, Region.status extends to:

pending | active | suspended | grace | terminated | generation_corrupt

Field timestamps:

Column Notes
Region.suspended_at DateTime nullable. Set on transition to suspended; cleared on takeover/payment-recovery
Region.terminated_at DateTime nullable. Set on transition to terminated
Region.scheduled_hard_delete_at DateTime nullable. terminated_at + 7 days; the cleanup orchestrator targets this
Region.takeover_available computed: status IN ('suspended', 'grace')

Suspended-state traversal rules

Per ADR-0054, while Region.status ∈ {'suspended', 'grace'}, cross-region traversal applies a stakeholder-gated ingress rule:

  • Outbound (any in-region sector → Nexus warp out): always allowed.
  • Inbound (cross-region traversal landing in the region): allowed if the traversing player is a stakeholder, the region's owner, or a successful takeover claimant; else rejected with ERR_REGION_NEW_RESIDENTS_BLOCKED.

A player is a stakeholder if they own at least one of:

  • A planet in the region (Planet.owner_player_id)
  • A station in the region (Station.owner_player_id)
  • A captured pirate holding in the region (PirateHolding.owner_player_id)
  • A player-built warp gate endpoint anchored in the region (WarpTunnel.created_by_player_id with either source or destination in the region)
  • A registered ship currently parked, drifting, or hangared inside the region

The check runs at the cross-region warp-traversal endpoint:

def check_suspended_region_ingress(player_id, destination_region):
    if destination_region.status not in ('suspended', 'grace'):
        return  # rule does not apply

    if destination_region.owner_id == player_id:
        return  # owner can always enter

    # Stakeholder check — single SQL query covering the five asset types
    has_stake = db.session.execute("""
        SELECT 1 WHERE EXISTS (
            SELECT 1 FROM planets WHERE region_id = :rid AND owner_player_id = :pid UNION ALL
            SELECT 1 FROM stations WHERE region_id = :rid AND owner_player_id = :pid UNION ALL
            SELECT 1 FROM pirate_holdings WHERE region_id = :rid AND owner_player_id = :pid UNION ALL
            SELECT 1 FROM warp_tunnels WHERE created_by_player_id = :pid
                AND (source_sector_region_id = :rid OR destination_sector_region_id = :rid) UNION ALL
            SELECT 1 FROM ships WHERE owner_id = :pid AND current_region_id = :rid
        )
    """, {"rid": destination_region.id, "pid": player_id}).scalar()

    if not has_stake:
        raise RegionNewResidentsBlocked(
            region_id=destination_region.id,
            region_status=destination_region.status,
        )

The check is one indexed SQL query; cost is negligible per traversal.

Rationale: stakeholders can re-enter to evacuate planet safes, sell stations, withdraw cargo, or manage their captured holdings. Non-stakeholders are blocked from making new commitments — guest traders looking to start a fresh route or players considering a colonization run are turned away with a clear error and can pick another region.

Cargo-at-station edge case: a guest who left earlier with cargo at an in-region station is not a stakeholder (cargo at a station is a transient service, not asset ownership). They lose access to that cargo. Acceptable: guest cargo is transient by design.

Takeover endpoint

POST /api/v1/regions/{region_id}/takeover

Request: empty body (the act of POSTing is the offer).

def takeover_region(region_id, caller_user_id):
    with transaction():
        # SK18: per-region advisory lock
        db.session.execute(
            text("SELECT pg_advisory_xact_lock(hashtext('region:' || :rid))"),
            {'rid': region_id}
        )

        region = db.query(Region).filter(id=region_id).with_for_update().one()
        caller = db.query(User).filter(id=caller_user_id).one()

        # Validation
        if region.status not in ('suspended', 'grace'):
            raise RegionNotAvailableForTakeover(region_id, region.status)
        if not caller.is_galactic_citizen:
            raise GalacticCitizenRequired(caller_user_id)
        if existing_region_owner_subscription_for(caller_user_id):
            raise OneRegionPerOwnerRule(caller_user_id)

        # Initiate PayPal flow (returns approval URL)
        approval = paypal_service.create_subscription(
            user=caller,
            tier='region_owner',
            amount=2500,  # $25/mo region subscription
            metadata={'takeover_region_id': region_id},
        )

        # Record the takeover intent (state pending PayPal callback)
        intent = TakeoverIntent.create(
            region_id=region_id,
            caller_user_id=caller_user_id,
            approval_url=approval.url,
            expires_at=now() + timedelta(hours=1),  # PayPal flow window
        )
        return intent

The PayPal callback (BILLING.SUBSCRIPTION.ACTIVATED for the takeover) commits the ownership flip:

def commit_takeover(takeover_intent_id, paypal_subscription_id):
    with transaction():
        intent = db.query(TakeoverIntent).filter(id=takeover_intent_id).with_for_update().one()
        region = db.query(Region).filter(id=intent.region_id).with_for_update().one()

        # Re-check race conditions
        if region.status not in ('suspended', 'grace'):
            paypal_service.refund_subscription(paypal_subscription_id)
            raise RegionNoLongerAvailableAfterPayment(intent.region_id)

        # Atomic ownership flip
        old_owner_id = region.owner_id
        region.owner_id = intent.caller_user_id
        region.paypal_subscription_id = paypal_subscription_id
        region.status = 'active'
        region.suspended_at = None
        region.terminated_at = None
        region.scheduled_hard_delete_at = None

        # Old owner's region-subscription line is implicitly terminated by the takeover.
        # No refund — they got their service to date.

        intent.status = 'completed'
        intent.completed_at = now()

        emit_event('region_taken_over', {
            'region_id': region.id,
            'region_name': region.name,
            'new_owner_id': region.owner_id,
            'old_owner_id': old_owner_id,
        })

Race handling: if multiple players race to take over, the first PayPal callback to land claims the lock. Losers see their PayPal subscription rejected and get an automatic refund through paypal_service.refund_subscription.

Transactional outbox for cleanup events

Per ADR-0054, the cleanup orchestrator uses the transactional outbox pattern for realtime-bus events. Events accumulate in an in-process pending_realtime_events list during the transaction; on successful commit, a post-commit hook flushes them to the bus. On rollback, the list is discarded.

def cleanup_orchestrator(region):
    pending_events = []
    try:
        with transaction():
            region = db.query(Region).filter(id=region.id).with_for_update().one()
            if region.status != 'terminated':
                return

            for player in identify_affected_players(region):
                # Per-player sub-transaction with row-lock (X-I2)
                evacuate_player_with_lock(player, region, pending_events)
                relocate_stations(player, region, pending_events)
                transfer_planet_safes_to_bank(player, region, pending_events)
                grant_planet_compensation(player, region, pending_events)
                cancel_active_contracts(player, region, pending_events)
                refund_bounties(player, region, pending_events)
                cancel_construction_jobs(player, region, pending_events)

            process_anchored_gates(region, pending_events)
            hard_delete_region(region, pending_events)
            pending_events.append(('region_terminated_cleanup_complete', {
                'region_id': region.id,
                'region_name': region.name,
            }))
            db.session.commit()
    except:
        log.exception("Cleanup orchestrator failed", region_id=region.id)
        return  # pending_events discarded; no events leave the server

    # Post-commit flush — best-effort
    for event_type, payload in pending_events:
        try:
            realtime_bus.emit(event_type, payload)
        except Exception:
            log.warning("Post-commit event emit failed", event_type=event_type)

The pattern applies anywhere a multi-step transaction emits realtime events: cleanup, takeover commit, gate destruction, holding capture. Single-table transactional events (a player updates one field → emit one event) can continue to use the direct emit pattern.

Per-player wallet row-lock during cleanup

Per ADR-0054 X-I2, the cleanup orchestrator's per-player sub-transaction acquires SELECT ... FOR UPDATE on the affected Player row at start:

def evacuate_player_with_lock(player_id, region, pending_events):
    with transaction():
        player = db.query(Player).filter(id=player_id).with_for_update().one()
        # Compute relocation fees, debit wallet, deposit Bank, etc.
        # All wallet reads/writes serialized for this player.
        ...

Concurrent player actions (trades, ARIA dialogues, anything that reads/writes wallet) block until cleanup releases. Typical hold duration is < 1 second per player; acceptable trade-off vs wallet drift.

Cleanup orchestrator (terminated → cleanup → hard_delete)

Runs as a daily cron. For each region with status = 'terminated' and scheduled_hard_delete_at <= now():

def cleanup_orchestrator(region):
    with transaction():
        region = db.query(Region).filter(id=region.id).with_for_update().one()
        if region.status != 'terminated':
            return  # state changed; abort

        # Mark cleanup in progress
        region.cleanup_started_at = now()

        # 1. Process each player with assets in the region
        affected_players = identify_affected_players(region)
        for player in affected_players:
            evacuate_player(player, region)
            relocate_stations(player, region)
            transfer_planet_safes_to_bank(player, region)
            grant_planet_compensation(player, region)
            cancel_active_contracts(player, region)
            refund_bounties(player, region)
            cancel_construction_jobs(player, region)

        # 2. Process player-built warp gates anchored to/from region
        process_anchored_gates(region)  # per ADR-0052 SK38

        # 3. Audit-row preservation handled at row level via region_id_snapshot
        #    (set at row creation, survives cascade delete)

        # 4. Hard-delete region content
        hard_delete_region(region)

        emit_event('region_terminated_cleanup_complete', {
            'region_id': region.id,
            'region_name': region.name,
            'affected_player_count': len(affected_players),
        })

Player evacuation

def evacuate_player(player, region):
    nexus = get_central_nexus()
    arrival_sector = deterministic_player_arrival_sector(player.id, nexus)

    # Piloted ship: evacuate with player
    if player.current_ship and player.current_ship.current_region_id == region.id:
        player.current_ship.current_sector_id = arrival_sector.id
        player.current_ship.current_region_id = nexus.id

    # Drifting/parked ships owned by player in region: route to Abandoned Hangar
    drifting_ships = db.query(Ship).filter(
        owner_id=player.id,
        current_region_id=region.id,
        current_pilot_id=None,
        registration_status__in=('drifting', 'borrowed'),
    ).all()
    for ship in drifting_ships:
        if ship.is_inside_carrier_hangar():
            # Hangared ships ride along with their host carrier; handle in carrier evac
            continue
        ship.current_sector_id = ABANDONED_HANGAR_SECTOR_ID
        ship.current_region_id = nexus.id
        ship.registration_status = 'awaiting_owner_claim'

Station relocation

def relocate_stations(player, region):
    stations = db.query(Station).filter(owner_player_id=player.id, region_id=region.id).all()
    for station in stations:
        destination = pick_destination_region(player, station)  # default Central Nexus
        fee = compute_station_relocation_fee(station)  # 30% × (acquisition + upgrades)

        if station.relocation_prepaid:
            # Path B — fee already paid during grace
            execute_station_move(station, destination, deduct_treasury=False)
        else:
            # Path A — automatic
            if station.treasury_balance >= fee:
                station.treasury_balance -= fee
                execute_station_move(station, destination, deduct_treasury=False)
            elif player.credits >= (fee - station.treasury_balance):
                deficit = fee - station.treasury_balance
                station.treasury_balance = 0
                player.credits -= deficit
                execute_station_move(station, destination, deduct_treasury=False)
            else:
                # Strip upgrades to cover fee
                while not enough_to_cover_fee(station, fee) and station.upgrades:
                    strip_highest_cost_upgrade(station)
                if enough_to_cover_fee(station, fee):
                    execute_station_move(station, destination, deduct_treasury=False)
                else:
                    # Final fallback: lose station, pay credit compensation to Central Bank
                    compensation = 0.5 * station.acquisition_cost + station.last_30d_avg_revenue
                    bank.deposit_credits(player, compensation)
                    db.session.delete(station)

Planet-safe transfer to Bank

def transfer_planet_safes_to_bank(player, region):
    planets = db.query(Planet).filter(owner_id=player.id, region_id=region.id).all()
    for planet in planets:
        safe = planet.safe_vault
        if safe.transport_prepaid:
            # Path B — full transfer
            bank.deposit_credits(player, safe.credits)
            for commodity, qty in safe.commodities.items():
                bank.deposit_commodity(player, commodity, qty)
        else:
            # Path A — 20% loss
            bank.deposit_credits(player, int(safe.credits * 0.80))
            for commodity, qty in safe.commodities.items():
                bank.deposit_commodity(player, commodity, qty * 80 // 100)

        bank.add_ledger_entry(player, {
            'type': 'cascade_safe_transfer',
            'region_name': region.name,
            'planet_name': planet.name,
            'transport_loss_applied': not safe.transport_prepaid,
        })

Planet compensation

def grant_planet_compensation(player, region):
    planets = db.query(Planet).filter(owner_id=player.id, region_id=region.id).all()
    for planet in planets:
        comp = PLANET_COMPENSATION_TABLE[planet.citadel_level]
        # Genesis devices to player inventory
        for device_kind, count in comp.genesis_devices.items():
            player.inventory.add(device_kind, count)
        # Credits to player wallet
        player.credits += comp.credits
        # Notification
        send_player_notification(player, {
            'type': 'planet_loss_compensation',
            'planet_name': planet.name,
            'citadel_level': planet.citadel_level,
            'compensation': comp,
        })

PLANET_COMPENSATION_TABLE = {
    1: {'genesis_devices': {'basic': 1}, 'credits': 50_000},
    2: {'genesis_devices': {'basic': 1, 'advanced': 1}, 'credits': 250_000},
    3: {'genesis_devices': {'advanced': 2}, 'credits': 1_000_000},
    4: {'genesis_devices': {'advanced': 3}, 'credits': 5_000_000},
    5: {'genesis_devices': {'advanced': 5}, 'credits': 25_000_000},
}

Player-built warp gate cascade

Per ADR-0052 SK38, gates anchored to/from the terminating region are processed atomically:

def process_anchored_gates(region):
    """
    Destroy player-built warp gates with either endpoint in the terminating region.
    Pay 50% construction-cost refund to owner.
    """
    affected_gates = db.query(WarpGate).filter(
        or_(
            WarpGate.source_sector.has(region_id=region.id),
            WarpGate.destination_sector.has(region_id=region.id),
        )
    ).all()
    for gate in affected_gates:
        with transaction():
            owner = db.query(Player).filter(id=gate.owner_player_id).with_for_update().one()
            refund = gate.construction_cost // 2  # 50%
            opposite_region = (
                gate.destination_sector.region if gate.source_sector.region_id == region.id
                else gate.source_sector.region
            )

            # Pay refund — wallet if online, Bank if offline
            if owner.is_online():
                owner.credits += refund
            else:
                bank.deposit_credits(owner, refund)
                bank.add_ledger_entry(owner, type='cascade_gate_refund',
                                      gate_name=gate.name, amount=refund)

            # Atomic delete the entire gate (both endpoints)
            db.session.delete(gate)

            emit_event('warp_gate_destroyed', {
                'gate_id': gate.id,
                'gate_name': gate.name,
                'owner_player_id': owner.id,
                'reason': 'region_terminated',
                'terminated_region_name': region.name,
                'refund_credited': refund,
            })
            send_aria_message(owner, {
                'type': 'gate_destroyed_in_cascade',
                'gate_name': gate.name,
                'terminated_region_name': region.name,
                'opposite_region_name': opposite_region.name,
                'refund_credited': refund,
            })

If both endpoints' regions terminate in overlapping windows, whichever orchestrator fires first deletes the gate; the second region's cleanup finds no gate. Owner is paid once.

Audit-row preservation

Audit tables (combat_log, enhanced_market_transactions, bounty_claim, npc_death_log, aria_observation_log, pirate_kill_log, future cargo_wreck_log) gain a region_id_snapshot UUID NULL column. The column is populated at row creation by the service writing the row (set to the sector's region_id at the moment of the event). The existing sector_id FK becomes ON DELETE SET NULL.

When hard_delete_region(region) runs: 1. Sectors in the region are deleted (CASCADE chains through to dependent rows like ports, planets). 2. Audit rows referencing those sectors get sector_id = NULL automatically. 3. region_id_snapshot retains the original region pointer for historical queries. 4. A subsequent query like "show me my combat log from when I was in the Aurora region" still works via WHERE region_id_snapshot = '<aurora_uuid>', even though the region row itself is also deleted.

Pre-pay flows (during grace state)

Players visiting their assets during Suspended/Grace state can pre-pay transport fees to lock 100% transfers.

Pre-pay station relocation

POST /api/v1/stations/{id}/prepay-relocation:

def prepay_station_relocation(station_id, caller_user_id):
    with transaction():
        station = db.query(Station).filter(id=station_id).with_for_update().one()
        if station.owner_player_id != caller_user_id:
            raise NotStationOwner(...)
        if station.region.status not in ('suspended', 'grace'):
            raise PrepayNotAvailable(...)

        fee = compute_station_relocation_fee(station)
        player = db.query(Player).filter(id=caller_user_id).with_for_update().one()
        if player.credits < fee:
            raise InsufficientCredits(player.credits, fee)

        player.credits -= fee
        station.relocation_prepaid = True
        station.relocation_prepaid_at = now()
        station.relocation_prepaid_amount = fee

If the region is taken over (returns to active), the prepay is refunded:

def refund_relocation_prepays(region):
    stations = db.query(Station).filter(region_id=region.id, relocation_prepaid=True).all()
    for station in stations:
        owner = station.owner_player
        owner.credits += station.relocation_prepaid_amount
        station.relocation_prepaid = False
        station.relocation_prepaid_amount = 0

Pre-pay planet safe

POST /api/v1/planets/{id}/safe/prepay-transport:

Same pattern — calculate 20% of safe value at current market prices, debit from player wallet, set transport_prepaid flag. Refunded if region returns to active or player manually evacuates the safe.

Cascade compensation access_override flag

Per ADR-0054 X-I3, Bank deposits made during cascade compensation carry an access_override flag enabling withdrawal at any port (not just Starport Prime). The flag is per-deposit, per-ledger-entry; consumed on withdrawal in priority order (FIFO) until exhausted.

class PlayerCentralBankAccount:
    def deposit(self, amount, source: str, access_override: bool = False):
        self.credits += amount
        self.ledger.append({
            "timestamp": now(),
            "type": "deposit",
            "amount": amount,
            "source": source,
            "access_override": access_override,
            "consumed": False,
        })

    def withdraw(self, amount, current_port):
        is_starport_prime = current_port.is_starport_prime()
        if is_starport_prime:
            self.credits -= amount  # standard withdrawal, no override needed
            return amount

        # Non-Starport-Prime port: only access_override balance is available
        override_balance = sum(
            entry['amount'] for entry in self.ledger
            if entry['type'] == 'deposit'
              and entry.get('access_override')
              and not entry.get('consumed')
        )
        if amount > override_balance:
            raise BankAccessDenied(
                "Withdrawal exceeds access-override balance; dock at Starport Prime to access full account",
                available_at_this_port=override_balance,
            )

        # Consume access-override entries FIFO
        remaining = amount
        for entry in self.ledger:
            if remaining <= 0:
                break
            if entry['type'] == 'deposit' and entry.get('access_override') and not entry.get('consumed'):
                if entry['amount'] <= remaining:
                    remaining -= entry['amount']
                    entry['consumed'] = True
                else:
                    entry['amount'] -= remaining
                    remaining = 0
        self.credits -= amount
        return amount

The cascade orchestrator deposits cascade-compensation funds with access_override=True. A GC-lapsed owner with cascade compensation can withdraw at any port; standard Bank deposits default to access_override=False and remain Starport-Prime-locked.

Central Nexus Bank service

class CentralNexusBankService:
    def deposit_credits(self, player, amount):
        with transaction():
            account = self.get_or_create_account(player.id)
            account.credits += amount
            self.add_ledger_entry(player, type='deposit_credits', amount=amount)

    def deposit_commodity(self, player, commodity_kind, qty):
        with transaction():
            account = self.get_or_create_account(player.id)
            account.commodities[commodity_kind] = account.commodities.get(commodity_kind, 0) + qty
            self.add_ledger_entry(player, type='deposit_commodity', commodity=commodity_kind, qty=qty)

    def withdraw_credits(self, player, amount, dock_location):
        if not is_starport_prime(dock_location):
            raise BankAccessDeniedAtNonStarportPrime(dock_location)
        with transaction():
            account = self.get_or_create_account(player.id)
            if account.credits < amount:
                raise InsufficientBankCredits(account.credits, amount)
            account.credits -= amount
            player.credits += amount
            self.add_ledger_entry(player, type='withdraw_credits', amount=amount)

    def withdraw_commodity(self, player, commodity_kind, qty, ship, dock_location):
        if not is_starport_prime(dock_location):
            raise BankAccessDeniedAtNonStarportPrime(dock_location)
        with transaction():
            account = self.get_or_create_account(player.id)
            available = account.commodities.get(commodity_kind, 0)
            if available < qty:
                raise InsufficientBankCommodity(commodity_kind, available, qty)
            free_cargo = ship.cargo_capacity - ship.cargo_used
            if qty > free_cargo:
                raise InsufficientCargoSpace(free_cargo, qty)

            turn_cost = (qty + 99) // 100  # 1 turn per 100 units, rounded up
            charge_player_turns(player, turn_cost)

            account.commodities[commodity_kind] -= qty
            ship.add_cargo(commodity_kind, qty)
            self.add_ledger_entry(player, type='withdraw_commodity', commodity=commodity_kind, qty=qty, turn_cost=turn_cost)

Phase 14 Nexus-warp placement

📐 Design-only. The Region ↔ Nexus connection is a natural warp tunnel placed in the Frontier-zone outer reaches of each player region. It is a WarpTunnel row with type = NATURAL — there is no constructed gate, no WarpGate infrastructure object, no toll, and no owner; it is an ordinary natural Frontier tunnel except for its special role linking the region to the Central Nexus. The warp is is_bidirectional = true (enabling cross-region travel and return) and is_latent = true by default, so it is invisible until a Warp Jumper scan in the destination Frontier sector reveals it for that player and their corp. The persisted endpoint is Region.nexus_warp_sector (an Integer sector number); the Nexus end lands in a deterministic per-region Gateway Plaza cluster sector hashed from Region.id.

Phase 14 places the warp by these rules:

  • Eligible sectors — Frontier outer reaches: sectors with zone_type = FRONTIER and graph distance to the Capital ≥ 60% of the region's diameter, so the warp can't be bumped into on the way to the starter cluster.
  • Sparseness preference — among eligible sectors, prefer those with no station, no planet, and the lowest 25th percentile of incident warp count in the region, so the warp lives in genuinely empty space rather than on a busy intersection.
  • No-formation preference — prefer sectors not already touched by a Special Formation, keeping the discovery moment uncluttered.
  • Selection — pick the eligible sector farthest from the Capital by graph distance; ties broken by lowest (x_coord, y_coord, z_coord) for determinism.
  • Fallback — if no Frontier sector meets the sparseness and no-formation preferences, widen to all Frontier sectors; then to Border; if still none, fail with ERR_NO_NEXUS_LANDING_SECTOR and roll the region back per Phase 13's strict policy.

The Galactic Citizen subscription gate is enforced at traversal, not at the warp's existence: every region carries the warp regardless of subscription tier, and the cross-region traversal endpoint checks the traversing player's tier, rejecting free-tier players with ERR_GALACTIC_CITIZEN_REQUIRED. The natural-vs-constructed type does not affect this universal check.

Phase 14 attachment retry

Per SK22, the warp tunnel insert in Phase 14 retries on failure with exponential backoff. Idempotency key is region.id + attempt_n; the insert is INSERT ... ON CONFLICT DO NOTHING.

RETRY_SCHEDULE = [1, 5, 30, 300, 1800, 21600]  # seconds: 1s, 5s, 30s, 5m, 30m, 6h

def phase_14_attempt(region, attempt_n=0):
    try:
        execute_phase_14(region)  # creates the natural-warp-tunnel row to Central Nexus per ADR-0043
    except Exception as e:
        if attempt_n + 1 >= len(RETRY_SCHEDULE):
            region.status = 'attachment_pending'
            emit_event('region_attachment_failed', {
                'region_id': region.id,
                'last_attempt_at': now(),
                'last_error': str(e),
            })
            send_ops_alert(region, e)
            send_owner_aria_message(region.owner, "Your home region's Nexus connection is delayed.")
            return

        delay = RETRY_SCHEDULE[attempt_n + 1]
        schedule_retry('phase_14_attempt', region.id, attempt_n + 1, delay)

GC-lapse foreign-asset liquidation window

Per ADR-0054 X-D3, when a player's Galactic Citizen subscription enters payment-failure state, a 7-day asset-liquidation window opens for assets they own in regions outside their home region.

Trigger: BILLING.SUBSCRIPTION.SUSPENDED webhook for a GC-tier subscription. Server flips User.gc_status = 'lapsed' and sets User.gc_lapsed_at = now().

On next player login (or immediately if online), ARIA emits a notification:

"Your Galactic Citizen subscription is lapsed. You have 7 days to withdraw assets from regions outside your home region. After that, captured holdings, foreign-region planet safes, and station ownerships enter the standard 30-day abandonment cascade."

During the 7 days, the player can:

  • GC-bypass transportPOST /api/v1/players/me/gc-emergency-relocation { destination_holding_id }. One-time, free, system action that teleports the player and their current ship to one of their foreign-region holdings. Consumed once per lapse cycle (renewed on next GC re-subscription).
  • Withdraw planet safes physically at the destination (no transport fee — they're on-site).
  • Sell stations to NPCs at standard 50%-acquisition-cost depreciated buyback. Treasury balance + cargo transfer to the player; structure is operator-cleaned.
  • Voluntarily surrender holdings — triggers the standard 30-day abandonment cascade immediately rather than waiting through the 7-day grace.

After 7 days post-lapse with no action: foreign-region assets enter the standard 30-day abandonment cascade per ADR-0047 and ADR-0050. 50% gate refund + 20% safe-transport semantics fire on cleanup. Compensation deposits to Bank carry access_override=True (per X-I3 above) so the player can claim them at any port.

Home region is unaffected. A free-tier player can continue to play normally in their home region indefinitely. If their Region Owner subscription is also lapsed, the home region itself enters the lifecycle cascade per ADR-0050 (independent state machine).

Owner relocation flow (post-purchase)

When a player completes a Region Owner subscription and the region is active, ARIA prompts them on first dock:

def offer_owner_relocation(player, owned_region):
    if player.current_region_id == owned_region.id:
        return  # already there
    aria_message(player, {
        'type': 'owner_relocation_offer',
        'region_name': owned_region.name,
        'capital_sector': owned_region.capital_sector_number,
        'fee': 50_000,  # cr
        'action_url': f'/api/v1/players/{player.id}/relocate-to-region/{owned_region.id}',
    })

def execute_owner_relocation(player, region):
    fee = 50_000
    if player.credits < fee:
        raise InsufficientCredits(player.credits, fee)
    with transaction():
        player.credits -= fee
        capital_sector = region.capital_sector
        player.current_sector_id = capital_sector.id
        player.current_region_id = region.id
        if player.current_ship:
            player.current_ship.current_sector_id = capital_sector.id
            player.current_ship.current_region_id = region.id

Daily cron — advance_region_lifecycle_states

Per ADR-0053 WR9. One daily UTC-midnight cron handles all three lifecycle transitions in a single job:

def advance_region_lifecycle_states():
    """Daily cron — advances regions through the lifecycle state machine."""

    # Suspended → grace
    db.query(Region).filter(
        Region.status == 'suspended',
        Region.suspended_at + timedelta(days=7) <= now(),
    ).update({'status': 'grace'})

    # Grace → terminated
    db.query(Region).filter(
        Region.status == 'grace',
        Region.suspended_at + timedelta(days=30) <= now(),
    ).update({
        'status': 'terminated',
        'terminated_at': now(),
        'scheduled_hard_delete_at': now() + timedelta(days=7),
    })

    # Terminated → cleanup → hard_delete
    for region in db.query(Region).filter(
        Region.status == 'terminated',
        Region.scheduled_hard_delete_at <= now(),
    ).all():
        cleanup_orchestrator(region)

The same daily scheduler tick also runs the anchor-repair service (WR12) and the daily ARIA storage-prune (per ADR-0051 SK31). All three are independent within the tick — failure of one does not block the others.

Source map

Concern Path (target)
Region lifecycle service services/gameserver/src/services/region_lifecycle_service.py
Cleanup orchestrator region_lifecycle_service.py:cleanup_orchestrator
Takeover endpoint services/gameserver/src/api/routes/regions.py:takeover_region
Central Nexus Bank services/gameserver/src/services/central_bank_service.py
Bank API services/gameserver/src/api/routes/central_bank.py
Phase 14 retry scheduler services/gameserver/src/services/galaxy_service.py:GalaxyGenerator.phase_14_attempt
Owner relocation services/gameserver/src/services/player_service.py:execute_owner_relocation
Pre-pay endpoints services/gameserver/src/api/routes/stations.py, services/gameserver/src/api/routes/planets.py
TakeoverIntent model services/gameserver/src/models/takeover_intent.py
PlayerCentralBankAccount model services/gameserver/src/models/central_bank_account.py

Failure modes

Mode Detection Handling
Cleanup orchestrator fails mid-run Cleanup transaction rollback; cleanup_started_at set but cleanup_completed_at null Daily cron retries from the failed point; idempotent operations (Bank deposits use idempotency keys, station relocations check relocated_at flag)
TakeoverIntent expires before PayPal callback TakeoverIntent.expires_at < now() Periodic sweep marks intent as expired; PayPal subscription that lands after is refunded
Multiple takeover offers race Per-region advisory lock First-to-pay wins; losers' PayPal subs auto-refunded
Phase 14 final retry exhausted RETRY_SCHEDULE list exhausted Region.status = 'attachment_pending'; ops alert; ARIA narration to owner; not auto-refunded
Bank withdrawal exceeds available account.credits < amount or commodity short Reject with INSUFFICIENT_BANK_BALANCE
Audit-row sector_id orphan after region delete ON DELETE SET NULL sector_id becomes NULL; region_id_snapshot preserves region pointer; queries handle NULL gracefully