Skip to content

0055 — Destruction-handler transaction composition (Group D)

Status

Accepted.

Context

Six audit findings (X-D2, S-V2, S-V3, S-F1, S-F2, S-F4) cluster around the same code path: the ship-destruction handler. When a ship is destroyed, several systems fire — cargo-wreck spawn, insurance payout, bounty collection, stolen-report resolution, kill-log insert. The transaction model, ordering, and several edge cases were under-specified:

  • X-D2 — ordering between the destruction sub-handlers was not pinned down. Cargo wrecks, insurance, bounty, and kill-log rows are written by independent paths; a partial failure could leave half-finished state.
  • S-V2 — concurrent attackers on the same target had no documented serialization, so two near-simultaneous killing-blow claims could either both pay out or both see an empty bounty.
  • S-V3 — nothing in the schema stopped the same owner from filing repeated stolen-reports on the same ship, or the same placer from stacking multiple active bounties on the same target.
  • S-F1 — self-bounty is rejected, but bounty placed by a placer and collected by a placer's team-mate (the laundering case) was not closed.
  • S-F2 — the cargo-wreck grace period currently exempts the original owner and their team but not the killer. This biases combat toward boarding/disabling rather than destruction (the killer can't legally scoop their own kill).
  • S-F4 — the recovery_mode choice on stolen-report (with_bounty | no_bounty, per ADR-0052 SK37) defaulted to with_bounty regardless of the ship's insurable flag. For non-insurable hulls, with-bounty incentivises destruction → total loss; no-bounty leaves the ship potentially recoverable. The default should bias toward recovery for non-insurable hulls.

Decision

X-D2 — Single destruction transaction with fixed ordering

The destruction handler runs inside one DB transaction. Within that transaction, the steps fire in this order:

  1. Mark ship destroyed — set Ship.status = DESTROYED, destruction_cause, destroyed_at.
  2. Auto-eject pilot — spawn escape pod (preserves Player.current_ship_id continuity).
  3. Wreck-suppression check — short-circuit for WARP_GATE_ANCHOR and SELF_DESTRUCT per ADR-0052 SK36.
  4. Insurance payout — debit insurance pool, credit owner wallet (gated by recovery_mode per SK37).
  5. Cargo-wreck creation — spawn CargoWreck row from Ship.cargo_jsonb with the appropriate damage band.
  6. Bounty collection — pay any active BountyClaim rows where target_player_id = ship.current_pilot_id and collected_at IS NULL, with collector_player_id = killing_blow_pilot_id. Rows are claimed with SELECT ... FOR UPDATE inside the same tx.
  7. Stolen-report resolution — if Ship.stolen_status = True, mark the report resolved with the destruction event as the resolution cause.
  8. Kill-log insertPirateKillLog if pirate; combat audit row otherwise.
  9. Combat log + reputation hooks — existing.

All nine steps share the same transaction. Realtime events (ship.destroyed, bounty.collected, wreck.spawned, etc.) are queued via the transactional outbox introduced by ADR-0054 and flushed post-commit.

S-V2 — Concurrent-attacker serialization

Closed transitively by the combat resolver's Phase 1 row-lock (per ../SYSTEMS/combat-resolver.md: "Phase 1 Initiation: lock attacker + defender rows"). The defender row-lock serializes concurrent combat invocations on the same target — at most one resolver call commits a destruction event for a given ship; concurrent attackers acquire the lock in serial order, and any whose damage hits a target whose hull <= 0 is already true (committed by a prior holder of the lock) resolves as a no-op (the destruction transaction has already fired with the prior attacker as killing-blow pilot). Bounty collection therefore runs once with collector_player_id = killing_blow_pilot_id per X-D2 step 6; there is no "two concurrent collectors" surface remaining.

S-V3 — Per-(placer, target) bounty uniqueness

A partial unique index lands on BountyClaim:

  • BountyClaim: UNIQUE (placer_player_id, target_player_id) WHERE collected_at IS NULL — one active bounty per (placer, target) pair. Multiple distinct placers can still stack bounties on the same target (intentional, per ADR-0054 X-V2); a single placer cannot stack against the same target.

The "one active stolen-report per ship" half of the original finding is already enforced by Ship.stolen_status BOOLEAN being a single-valued column on the ship row — no separate index is needed.

S-F1 — Same-team collusion block

Two new rejection rules:

  • Stolen-report filing: rejected with ERR_THIEF_IS_TEAM_MATE if the alleged thief (ship.current_pilot_id at file-time) shares a team with the placer. Filing against own team is not a real theft.
  • Bounty collection: rejected with ERR_COLLECTOR_SAME_TEAM_AS_PLACER if collector_player_id shares a team with placer_player_id at collection-time. Cross-team bounty hunting still works; same-team laundering is blocked. Team membership is checked via the team_members join, not via stale snapshots.

The team-share check uses the live team membership at the moment of the gated action (file or collect), not a snapshot at bounty-place-time — players changing teams to dodge the rule are blocked the moment the gated action fires.

S-F2 — Killer first-scoop on cargo wrecks

The cargo-wreck grace window (1 hour from creation) gains a third exempt party alongside the original owner and the original owner's team: the killing-blow pilot. During grace:

  • Original owner + original team: free salvage (existing rule).
  • Killing-blow pilot (individual, not their team): free salvage (new).
  • Anyone else: Suspect Status applies (existing rule).

After grace expires, anyone may salvage without consequence.

The killer's team is not extended grace by this ADR — only the individual pilot whose hit destroyed the ship. The team-extension question is left open as a future-tunable; the conservative pick is "individual only" so a pirate squad doesn't get implicit mass-salvage rights from one kill.

S-F4 — Non-insurable default recovery_mode

The recovery_mode on stolen-report changes its default behaviour by ShipSpecification.insurable:

  • insurable = true ships: default recovery_mode = with_bounty (existing behaviour).
  • insurable = false ships: default recovery_mode = no_bounty (new). Recovery-preferred makes sense when destruction is total loss for the owner.

The owner can still override either way in the report-stolen request body. The ARIA narration on file fires a one-line trade-off summary when the filer is on a non-insurable hull.

Consequences

  • The destruction handler becomes a single atomic operation. A partial failure (e.g., bounty-collection rejected by a constraint) rolls back the wreck spawn and the insurance payout — the destruction itself is then re-attempted by the resolver. This is the correct behaviour: better to fail-fast and retry than to leave half-finished state.
  • The transactional outbox is now load-bearing for two flows (region-lifecycle cleanup per ADR-0054, and ship destruction). Upstream of ADR-0054 the outbox was specced; this ADR is its second consumer.
  • The same-team collusion check requires a live team-membership query inside both the report-stolen and bounty-collect transactions. Index (team_id, player_id) and (player_id, team_id) on team_members keep this O(1).
  • The killer first-scoop rule changes a published gameplay rule (cargo-wreck grace was previously owner-only). This is a rule clarification, not a code-level breaking change — pre-existing wrecks have no killing-blow snapshot; they simply continue to follow the original-owner-only grace until they expire.
  • For the killer first-scoop rule to function, the wreck row must remember the killing-blow pilot. New column on CargoWreck: killing_blow_pilot_id UUID FK players.id, nullable — null for non-combat wrecks (HAZARD, ABANDONMENT_EXPIRED).

Alternatives considered

  • Same-team rule, soft flag instead of hard block (S-F1). Rejected because admin-review queues create their own latency and dispute surface; cleaner to block the cash-out outright.
  • No grace at all on cargo wrecks (S-F2). Rejected — removes meaningful "first scoop" gameplay and rewards lurkers who didn't fight.
  • Force a recovery-mode choice for non-insurable hulls (S-F4). Rejected for friction reasons; a sensible default plus visible toggle is better UX.
  • Bounty collection outside the destruction tx, with a separate poll-and-claim job. Rejected — adds latency, opens a window where a dead pilot could in principle generate further events, and the row-lock approach is straightforward.