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_modechoice on stolen-report (with_bounty | no_bounty, per ADR-0052 SK37) defaulted towith_bountyregardless of the ship'sinsurableflag. 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:
- Mark ship destroyed — set
Ship.status = DESTROYED,destruction_cause,destroyed_at. - Auto-eject pilot — spawn escape pod (preserves
Player.current_ship_idcontinuity). - Wreck-suppression check — short-circuit for
WARP_GATE_ANCHORandSELF_DESTRUCTper ADR-0052 SK36. - Insurance payout — debit insurance pool, credit owner wallet (gated by
recovery_modeper SK37). - Cargo-wreck creation — spawn
CargoWreckrow fromShip.cargo_jsonbwith the appropriate damage band. - Bounty collection — pay any active
BountyClaimrows wheretarget_player_id = ship.current_pilot_idandcollected_at IS NULL, withcollector_player_id = killing_blow_pilot_id. Rows are claimed withSELECT ... FOR UPDATEinside the same tx. - Stolen-report resolution — if
Ship.stolen_status = True, mark the report resolved with the destruction event as the resolution cause. - Kill-log insert —
PirateKillLogif pirate; combat audit row otherwise. - 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_MATEif the alleged thief (ship.current_pilot_idat 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_PLACERifcollector_player_idshares a team withplacer_player_idat collection-time. Cross-team bounty hunting still works; same-team laundering is blocked. Team membership is checked via theteam_membersjoin, 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 = trueships: defaultrecovery_mode = with_bounty(existing behaviour).insurable = falseships: defaultrecovery_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)onteam_memberskeep 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.
Related¶
- ADR-0007 — cargo wreck mechanic.
- ADR-0008 — ship registry overhaul.
- ADR-0049 — anti-collusion bounty lock.
- ADR-0052 — SK37 stolen-report recovery_mode.
- ADR-0054 — BountyClaim retarget to
target_player_id; transactional outbox. ../SYSTEMS/combat-resolver.md— destruction-handler invoker.../SYSTEMS/ship-registry.md— stolen-report flow, recovery_mode, anti-collusion lock.../FEATURES/gameplay/ships.md— destruction sequence, cargo-wreck grace.../FEATURES/gameplay/ship-insurance.md— non-insurable ship list.../DATA_MODELS/cargo-wrecks.md—CargoWreckschema.../DATA_MODELS/gameplay.md—BountyClaim,StolenReportschemas.