Skip to content

0054 — Region-Lifecycle Cascade Composition (Group B)

Status

Accepted

Context

Six findings in the audit Group B identified composition gaps in the region-lifecycle cascade where two systems compose poorly: suspended-state player access ([X-D1]), GC lapse with foreign-region assets ([X-D3]), in-flight realtime events crossing transaction rollback ([X-V1]), stolen-ship auto-bounty escrow trapped on destroyed-ship rows ([X-V2]), wallet read race during cleanup ([X-I2]), and cascade-compensation Bank access for GC-lapsed owners ([X-I3]).

Individually each is small. Together they're the "what happens at the edges of the lifecycle state machine" surface that the original ADR-0050 happy-path framing didn't fully cover. This ADR closes the six gaps as a single composition pass — they share the same surfaces (SYSTEMS/region-lifecycle.md, OPERATIONS/monetization.md, SYSTEMS/realtime-bus.md, the bounty schema) and a coherent batch is easier to reason about than six surgical patches.

Decision

X-D1 — Suspended-region stakeholder ingress

While Region.status = 'suspended' or 'grace', the cross-region traversal layer applies a stakeholder-gated ingress rule:

  • Outbound (any sector inside the region → Nexus warp out): always allowed.
  • Inbound (any cross-region traversal landing in the region):
  • Allowed if the traversing player is a stakeholder — owns at least one of: a planet in the region, a station in the region, a captured pirate holding in the region, a player-built warp gate endpoint anchored in the region, or a registered ship currently parked / drifting inside the region.
  • Allowed if the traversing player is the region's owner.
  • Allowed for a takeover claimant after the takeover transaction commits (the region flips to active mid-transaction, so the post-commit traversal sees the new state).
  • Rejected otherwise with ERR_REGION_NEW_RESIDENTS_BLOCKED.

The rule keeps stakeholders connected to their assets (a player with a planet in the region needs to be able to evacuate the safe; a station owner needs to relocate). It blocks new commitments — guest traders looking to start a fresh trade route, players considering a colonization run, etc. They get a clear error and can pick another region.

The stakeholder check runs at the warp-traversal endpoint (Nexus warp landing into the region's Frontier outer-reach sector per ADR-0043). It is one SQL query covering planet ownership, station ownership, holding ownership, gate endpoints, and ship presence — all are indexed by player_id.

X-D3 — GC-lapse 7-day liquidation window for foreign-region assets

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. ARIA notifies the player on next login:

"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:

  • Use the GC-bypass transport — a 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). Implements as POST /api/v1/players/me/gc-emergency-relocation with the destination chosen from a list of the player's foreign-region assets.
  • Withdraw planet safes physically at the destination (no transport fee — they're on-site, per the standard manual-evacuation path in ADR-0050).
  • Sell stations to NPCs at the 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 taken: foreign-region assets enter the standard 30-day abandonment cascade per ADR-0047/ADR-0050. Same 50% gate refund + 20% safe-transport semantics fire on cleanup.

The player's home region assets are not affected by GC lapse — only cross-region reachability is gated. A free-tier player can continue to play normally in their home region indefinitely (a Region Owner subscription is independent of GC; if Region Ownership is also lapsed, the home region itself enters the lifecycle cascade per ADR-0050).

X-V1 — Transactional outbox pattern for realtime events

All realtime events emitted from multi-table transactions (the cleanup orchestrator in particular, but the pattern applies generally) accumulate in an in-process pending_realtime_events list during the transaction. The list flushes to the realtime bus only after db.session.commit() succeeds.

def cleanup_orchestrator(region):
    pending_events = []
    try:
        with transaction():
            # ... cascade work ...
            pending_events.append(('warp_gate_destroyed', {...}))
            pending_events.append(('player_relocated', {...}))
            # ... more cascade work ...
            db.session.commit()  # if this raises, pending_events is discarded
    except:
        # Rollback path — pending_events never flush
        log.exception(...)
        return

    # Post-commit flush
    for event_type, payload in pending_events:
        try:
            realtime_bus.emit(event_type, payload)
        except Exception:
            # Best-effort; data is committed, event-emission failure is recoverable
            log.warning("Post-commit event emit failed", event_type)

This is the standard transactional outbox pattern. Failure to emit a queued event after commit is logged but doesn't roll back the DB transaction (the data is committed; clients can re-derive state on next session sync).

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

X-V2 — Auto-bounties on Player.id, not Ship.id

The stolen-ship auto-bounty mechanism per ADR-0049 currently attaches the bounty to the stolen ship row. If the ship is destroyed before capture, the bounty escrow is trapped on a destroyed-ship row.

Decision: auto-bounties target the thief's Player.id, not the stolen ship.

Schema:

ALTER TABLE bounty_claims
  ADD COLUMN target_player_id UUID FK player.id,
  -- target_ship_id stays nullable for non-stolen-report bounties (future use)
  ALTER COLUMN target_ship_id DROP NOT NULL;

The stolen-report flow updates:

  1. Owner files stolen report on Ship X with recovery_mode = with_bounty.
  2. Server identifies current pilot of Ship X = thief player T.
  3. Auto-bounty placed: BountyClaim(target_player_id=T, amount=ship.value*0.5, ...).
  4. The bounty stays on T regardless of what happens to Ship X. If T destroys the ship, the bounty persists. If T transfers to a different ship (e.g., they steal another), the bounty still applies — they're the target.
  5. Bounty pays out when T is killed by any hunter, in any ship.

Consequences:

  • Multiple owners filing stolen reports on the same thief stack bounties on T. Cumulative pressure. Per-owner-per-ship "1 active stolen-report" rule still applies.
  • Ship destruction continues to clear Wanted Status (existing rule), but the bounty persists on the thief's player.
  • Owner whose ship is destroyed by the thief still gets recovery via insurance (per the standard insurance flow); the bounty is separate "stop the thief" pressure, not ship-recovery escrow.
  • Migration: existing target_ship_id rows backfill to target_player_id from the ship's pilot_id at migration time; rows where the pilot is unknown are dropped (rare).

X-I2 — Cleanup wallet row-lock

The cleanup orchestrator acquires SELECT ... FOR UPDATE on the affected Player row at the start of each per-player sub-transaction:

def evacuate_player(player_id, region):
    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 are now 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.

X-I3 — Bank access_override for cascade compensation

Cascade-driven Bank deposits carry an access_override flag enabling withdrawal at any port (not just Starport Prime in the Central Nexus). The flag is per-deposit, per-ledger-entry; consumed on withdrawal:

class PlayerCentralBankAccount:
    # ... existing fields ...

    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):
        # Check if there's enough access_override balance to cover the withdrawal
        override_balance = sum(
            entry['amount'] for entry in self.ledger
            if entry['type'] == 'deposit'
            and entry.get('access_override')
            and not entry.get('consumed')
        )
        is_starport_prime = current_port.is_starport_prime()
        if not is_starport_prime and amount > override_balance:
            raise BankAccessDenied("Withdrawal exceeds access-override balance; dock at Starport Prime to access full account")
        # ... debit wallet, mark consumed in ledger ...

The cascade orchestrator deposits compensation with access_override=True; standard Bank deposits (currently none — the Bank is cascade-only) would default to access_override=False. A GC-lapsed owner with cascade compensation can withdraw at any port their region permits docking; they don't need to reach the Central Nexus.

Consequences

Positive:

  • The lifecycle cascade now has explicit rules at six previously-undefined edges. Implementers don't have to guess.
  • Stakeholder-gated ingress (X-D1) preserves the design intent of "no new commitments" while not punishing existing asset owners.
  • GC-lapse liquidation window (X-D3) gives the player a fair runway to recover assets before the long abandonment cascade fires.
  • Transactional outbox (X-V1) is the canonical pattern for event-driven systems; closes a class of cascade rollback bugs.
  • Bounty player-targeting (X-V2) closes the trapped-escrow case and consolidates "stop this thief" pressure on the target's player row, where it logically belongs.
  • Wallet row-lock (X-I2) is a one-line addition; standard concurrency-control pattern.
  • Bank access_override (X-I3) is a per-deposit boolean; doesn't change the Bank's broader access model.

Neutral:

  • One new column on BountyClaim (target_player_id); one new field on Bank ledger entries (access_override). Both are forward-only Alembic migrations.
  • One new endpoint: POST /api/v1/players/me/gc-emergency-relocation (consumed once per GC lapse cycle).
  • Stakeholder check on cross-region inbound traversal adds one SQL query per traversal. Indexed; cost is negligible.

Negative:

  • A guest who left a region earlier in the day with cargo at a station inside it cannot return after suspension fires (they're not a stakeholder). They lose access to that cargo. Acceptable: guest cargo at stations is transient by design.
  • The 7-day liquidation window assumes the player logs in within 7 days of GC lapse. A player who's away for 8+ days misses the window and goes straight to the standard 30-day cascade. Their assets still convert via cascade compensation — they don't lose value, just don't get the on-site liquidation path.

Alternatives considered

X-D1 — full ingress block (rejected). Block all inbound traversal during suspended/grace, including for asset owners. Rejected because it punishes legitimate stakeholders trying to evacuate. The stakeholder check is one SQL query; correctness wins.

X-D3 — immediate forfeiture on GC lapse (rejected). Skip the 7-day window; assets enter cascade the moment GC lapses. Rejected because it gives players no recovery path; punitive. The 7-day grace mirrors the broader cascade philosophy (paying players have a real runway to recover assets).

X-V1 — idempotent event-IDs with client-side deduplication (rejected). Every event carries an event_id; clients keep a recent-event cache and discard duplicates. Rejected as more complex than transactional outbox; outbox doesn't require client cooperation.

X-V2 — bounty escrow refunded to owner on ship destruction (rejected). When the ship is destroyed, refund the bounty escrow back to the original owner. Rejected because the bounty's purpose is "stop the thief," not "recover the hull"; a thief who self-destructs their stolen ship to escape consequences should still leave the owner's "stop the thief" pressure on the table.

X-I2 — optimistic concurrency with retry (rejected). Read wallet, compute fees, attempt write; if version-mismatch, retry. Rejected for the cleanup orchestrator specifically — cleanup is a high-stakes, low-frequency operation; pessimistic locking is appropriate.

X-I3 — special transport mechanic to Starport Prime (rejected). Free one-time transport for offline-lapsed players to claim Bank balance. Rejected as more mechanic complexity than needed; a per-deposit flag is simpler.