0049 — Player-Exploit Close-Outs (Batch 2)¶
Status¶
Accepted
Context¶
Six unresolved player-exploit decisions surfaced during the skeptical-audit pass (the SK series in DECISIONS.md). Each one is a small correctness gap in an existing surface where the intended behavior is clear but the API or transaction semantics aren't tight enough to prevent abuse. None requires a design pivot — all are tactical "lock the door" fixes.
A seventh exploit in the same group (SK15 — free-tier transitive natural-tunnel travel) was already closed by ADR-0043, which moved the Galactic Citizen subscription gate to the cross-region traversal layer regardless of warp type. SK15 is therefore not addressed in this ADR.
The exploits, with one-line summaries:
- SK9 — Auto-stolen-report bounty refund collusion. Owner files stolen-report (50% credits up front, places bounty); colluding hunter kills target, collects 100%; owner retracts within 24h, gets 75% refund. Net: free-money cycle.
- SK10 — Team-membership toll bypass via alt accounts. Alt joins team → traverses owner's gate (free) → leaves team. Repeats indefinitely.
- SK11 — Quantum Jump cooldown stacking via team rotation. QJ cooldown is per-ship (24h). A 4-player team with 4 Warp Jumpers achieves 4× effective long-range mobility through ship rotation.
- SK13 — Bulk procurement walk-away re-delivery. Player B accepts contract, delivers 2,500 units (banks pro-rata), walks. Player C accepts, delivers same 2,500 (re-acquired), pockets pro-rata again. Contract pays 200% for one shipment moving twice.
- SK14 — Salvage break race condition. Multiple players concurrently start salvage breaks on the same Drifting ship; no row-lock specified.
- SK16 — Lumen Crystal refining provenance dupe. Per ADR-0037, refining tags Crystals with
refined_at_faction_idprovenance. Parallel refining at two stations against the same Shard inventory could double Crystal output if Shards aren't deduped at the DB level.
Each exploit lives in a different system (ship registry, warp gates, quantum jump, contracts, ship lifecycle, refining), so the doc updates touch six surfaces. The decisions themselves are bundled into one ADR for the working coherence — they're variations on the same theme: row-lock the resource at operation start, not at completion.
Decision¶
SK9 — Bounty-refund collusion¶
POST /api/v1/ships/{id}/stolen/retract rejects with ERR_BOUNTY_ALREADY_COLLECTED the moment a kill is logged against the bountied target's ship. The retract handler runs an atomic check inside the same transaction that processes the refund:
def retract_stolen_report(ship_id, retracting_player_id):
with transaction():
ship = db.query(Ship).filter(id=ship_id).with_for_update().one()
if ship.owner_id != retracting_player_id:
raise NotShipOwner(...)
# Atomic check: has the bounty been collected against this ship?
bounty_collected = db.query(BountyClaim).filter(
target_ship_id=ship_id,
placed_at__gte=ship.stolen_reported_at,
collected_at__isnotnull(),
).exists()
if bounty_collected:
raise BountyAlreadyCollected(ship_id)
# Standard retract path: 75% refund, clear stolen_status
refund_amount = ship.stolen_report_fee * 0.75
credit_player(retracting_player_id, refund_amount)
ship.stolen_status = False
ship.stolen_reported_at = None
This closes the collusion loop at the most direct point — once a kill has fired the bounty, the report can no longer be retracted. The 24h retract grace remains for legitimate cases (false-alarm reports where no kill happened).
SK10 — Team-toll bypass¶
Toll exemption requires 24 hours of continuous team membership at the moment of traversal. The warp-gate toll endpoint validates:
def check_team_toll_exemption(player_id, gate_owner_team_id):
membership = db.query(TeamMembership).filter(
player_id=player_id,
team_id=gate_owner_team_id,
joined_at__lte=now() - timedelta(hours=24),
left_at__isnull(),
).one_or_none()
return membership is not None
A player who joined the gate-owner's team less than 24 hours ago pays the standard toll. The 24-hour window is small enough that legitimate new members can join and use gates after their first day, but large enough to defeat the alt-cycle pattern where alts join → traverse → immediately leave.
Edge case: if a player leaves and rejoins the same team, the 24-hour clock restarts — the joined_at field reflects the most recent membership row. Stale rejoin patterns get no shortcut.
SK11 — Quantum Jump cooldown per-player¶
QJ cooldown is enforced per-player, not per-ship. The QJ commit endpoint (per ADR-0030 and FEATURES/galaxy/sectors.md) gains a player-level cooldown check:
def check_qj_cooldown(player_id):
last_jump = db.query(QuantumJump).filter(
pilot_player_id=player_id,
committed_at__gte=now() - timedelta(hours=24),
).order_by(QuantumJump.committed_at.desc()).first()
if last_jump:
cooldown_until = last_jump.committed_at + timedelta(hours=24)
raise QJCooldownActive(cooldown_until=cooldown_until, last_jump_ship_id=last_jump.ship_id)
A team of 4 players with 4 Warp Jumpers can still perform 4 jumps in 24 hours — but each player can jump only once. Ship rotation no longer multiplies the player's mobility; it just lets one player choose which WJ to use for their daily jump. This preserves the canonical "QJ is a once-per-day signature ability" framing.
The per-ship cooldown remains as a secondary check (a ship that just QJ'd is locked through Phase 3 resolve regardless of who's piloting); the per-player check is the primary anti-stacking lock.
SK13 — Bulk procurement monotonic counter¶
Contract.partial_fulfilled_amount is a monotonic counter, not a per-acceptor figure. Once 2,500 units have been credited toward a 5,000-unit bulk_procurement contract, the contract's remaining quota is fixed at 2,500 — independent of who delivered the original 2,500 or whether they walked away. Subsequent acceptors can only deliver toward the remainder.
The contract's partial_fulfilled_payout accumulates the credits already paid out; the contract's escrow tracks what's left. Walk-away by an acceptor does not reset the counter — the prior delivery stays banked toward the next acceptor's quota:
posted (5,000 units, 500 cr payment, escrow 500)
→ accepted by B
→ B delivers 2,500 → partial_fulfilled_amount = 2,500, B paid 250 cr pro-rata, escrow 250
→ B walks → contract reverts to posted, partial_fulfilled_amount stays at 2,500, escrow 250
→ accepted by C
→ C tries to deliver 2,500 more → quota check: remaining = 5,000 - 2,500 = 2,500 → accepted
→ C paid 250 cr pro-rata, contract → completed, escrow 0
C cannot deliver "the same 2,500" because the contract already counts those units as delivered. C must source a fresh 2,500 to fulfill the remainder. The exploit is closed at the contract-row level — no per-batch serial tracking required.
This decision ratifies behavior already present in the contract schema landed in the prior gap-fill pass (per FEATURES/economy/contracts.md) — partial_fulfilled_amount was specified as a contract-row counter; this ADR makes the monotonic-and-walk-away-immune semantics explicit.
SK14 — Salvage break row lock¶
POST /api/v1/ships/{ship_id}/salvage-break acquires a row-level lock on the target Ship row at the start of the request. Concurrent attempts on the same ship reject with ERR_SALVAGE_BREAK_IN_PROGRESS:
def start_salvage_break(ship_id, salvager_id):
with transaction():
ship = db.query(Ship).filter(id=ship_id).with_for_update(nowait=True).one_or_none()
if ship is None:
raise ShipNotFound(ship_id)
if ship.salvage_break_in_progress_by_id and ship.salvage_break_in_progress_by_id != salvager_id:
raise SalvageBreakInProgress(
ship_id=ship_id,
in_progress_by=ship.salvage_break_in_progress_by_id,
completes_at=ship.salvage_break_started_at + ship.salvage_break_duration,
)
ship.salvage_break_in_progress_by_id = salvager_id
ship.salvage_break_started_at = now()
ship.save()
The with_for_update(nowait=True) lock ensures the second concurrent request waits zero time — it fails immediately rather than queuing — which produces a clean error message to the second salvager rather than a confusing timeout. The first salvager's break runs to completion (or is cancelled by combat per the existing salvage-break interruption rules). On completion or cancellation, salvage_break_in_progress_by_id is cleared and a new break can start.
Edge case: if the locking salvager disconnects mid-break, the salvage_break_started_at + salvage_break_duration watchdog auto-clears the lock at the duration timeout. A separate periodic sweep handles stuck locks where the salvager went idle (defaults to 2× the duration before forced clear).
SK16 — Lumen Crystal Shard inventory atomic consumption¶
The Shard-to-Crystal refining path (per ADR-0037 — 100 Shards → 1 Crystal at Class-5+ stations) consumes the Shard inventory at job-start, not job-completion. The refining endpoint validates and decrements the player's Shard count atomically:
def start_lumen_refining(player_id, station_id):
with transaction():
player = db.query(Player).filter(id=player_id).with_for_update().one()
if player.quantum_shard_inventory < 100:
raise InsufficientShards(have=player.quantum_shard_inventory, need=100)
# Atomic consume: deduct 100 shards immediately
player.quantum_shard_inventory -= 100
player.save()
# Create the refining job
job = RefiningJob.create(
player_id=player_id,
station_id=station_id,
input_resource='quantum_shards',
input_quantity=100,
output_resource='lumen_crystal',
output_quantity=1,
started_at=now(),
completes_at=now() + timedelta(hours=12),
status='in_progress',
)
return job
A second concurrent refining attempt with the same Shard inventory hits the with_for_update row-lock, then fails the < 100 check (because the first transaction already debited the Shards). The race condition is closed at the Player-row lock — no per-batch serial tracking required, no inventory deduplication table.
If the refining job is cancelled before completion (rare; admin-only path or station-destruction edge case), the 100 Shards are refunded to the player at cancellation. If the job completes successfully, the 1 Crystal is credited at completion (with refined_at_faction_id provenance per ADR-0037). If the player attempts to refine while a previous job for the same player at the same station is still in progress, the call succeeds — multiple parallel refining jobs are allowed, each consuming its own 100 Shards atomically.
Consequences¶
Positive:
- Six exploit surfaces close with the same architectural pattern: row-lock the resource at operation start. The pattern is consistent across
Ship,Player,Contract, andTeamMembership— easy for implementers to reason about and audit. - No new schema is required for any of the six fixes — all are validation-layer additions on existing API endpoints.
- Each fix is independently shippable; they don't depend on each other.
- The 24-hour team-membership window (SK10) and per-player QJ cooldown (SK11) are tunable parameters; balance teams can revisit post-launch without schema changes.
Neutral:
- Six new error codes added to the API surface:
ERR_BOUNTY_ALREADY_COLLECTED,ERR_TEAM_MEMBERSHIP_TOO_NEW,ERR_QJ_PLAYER_COOLDOWN_ACTIVE,ERR_SALVAGE_BREAK_IN_PROGRESS,ERR_SHARDS_LOCKED(plus the implicitERR_INSUFFICIENT_SHARDSalready implied by the existing inventory check). - API consumers (player client, admin tools) gain matching client-side surfaces — toll-exemption preview at the gate UI, QJ-cooldown countdown across pilots, salvage-break "in-progress-by" indicator at the sector view.
Negative:
- A legitimate team that recruits a friend and immediately wants to use a gate together has to wait 24 hours. Acceptable: the legitimate-friend case is rare; the alt-cycle exploit is real.
- A player who logs out mid-salvage-break ties up the lock for the duration of their break. The watchdog auto-clears it eventually but the second-salvager's wait is real. Acceptable: the alternative (allowing concurrent breaks) creates worse race conditions.
- Multi-pilot teams that legitimately wanted "rotate through 4 WJs to scout 4 different vectors per day" lose that capability. Acceptable: QJ as a once-per-pilot-per-day signature ability is the framing in ADR-0030; this ADR enforces that framing rather than amending it.
Alternatives considered¶
SK9: anti-grind cooldown on file-retract cycles (rejected). Add a 7-day cooldown between filing a stolen report on the same ship (so a colluding pair couldn't repeat the cycle on the same vessel weekly). Rejected because it's a softer fix that still allows one full cycle and just delays the next one. The atomic "bounty already collected" check closes the exploit fully on the first attempt.
SK10: per-day cap on free traversals (rejected). Limit team-mate free traversals to N per UTC day. Rejected because it punishes legitimate active teams (a real team that uses the gate frequently hits the cap during normal play); the 24h-membership rule cleanly targets the abuse pattern without affecting legitimate use.
SK11: per-team cooldown (rejected). Make the QJ cooldown shared across the entire team — one team-wide jump every 24h regardless of pilot count. Rejected as too punitive: a 4-player team of independent pilots loses 75% of their daily QJ capacity. Per-player is the right granularity — closes the alt/multi-pilot loophole, preserves "every pilot can QJ daily."
SK13: per-batch resource serials on contract deliveries (rejected). Track every cargo batch by serial; reject re-delivery of credited serials. Rejected as overengineered. The monotonic counter on the contract row achieves the same effect with no new schema.
SK14: queue concurrent salvage breaks (rejected). Let the second salvager wait in line; first-come-first-served. Rejected because waiting in a sector for an indefinite period is a poor UX (the salvage-break can be cancelled by combat at any time, which would suddenly hand the lock to the second salvager unexpectedly). Reject-with-info is cleaner.
SK16: per-shard-batch deduplication table (rejected). Track every refined Shard batch by serial in a deduplication table. Rejected because the player-row lock on inventory consumption already closes the race; a serial-tracking table is more state to maintain for a problem that's solved at the row level.
Related docs¶
SYSTEMS/ship-registry.md— stolen-report retract endpoint (SK9); salvage-break row lock (SK14).FEATURES/galaxy/warp-gates.md— toll exemption rule (SK10).FEATURES/galaxy/sectors.md— Quantum Jump endpoint surface (SK11); per-player cooldown check.FEATURES/economy/contracts.md— bulk procurement monotonic counter (SK13).FEATURES/galaxy/quantum-resources.md— Lumen Crystal refining (SK16).ADR/0030-q1-quantum-jump-multi-step-commit.md— Quantum Jump foundation (SK11 layered on top).ADR/0037-au3-3-lumen-crystal-supply-economy.md— Lumen Crystal refining design (SK16 closes a leak).ADR/0043-sk4-nexus-natural-warp-frontier.md— already addresses SK15 (free-tier cross-region traversal); not duplicated here.ADR/0008-s9-ship-registry-overhaul.md— ship registry foundation that SK9 + SK14 extend.