Skip to content

Trade Contracts

Status: 🚧 Partial β€” cargo_delivery, express_delivery, hazardous_transport, and bulk_procurement lifecycle shipped; refugee_transport, acquisition_bounty, and escort enum values have no generator yet. (re-verified 2026-08-21 vs Sectorwars2102 HEAD 46bce720.)

Trade contracts let players (and NPCs) commit to deliveries with deadlines and rewards. They are pure economic exchange between two parties (NPC-to-player or player-to-player). Faction reputation moves emergently per ADR-0032 β€” honoring a contract issued by a faction-aligned NPC moves rep through the trade-volume channel like any other transaction, not a quest-completion bonus.

🚧 Partial. The Contract model, contract_service.py, contract_generator.py, an expiry-sweep scheduler, and POST/GET routes/contracts.py (/board, /mine, /{id}, /{id}/accept, /{id}/complete, /{id}/abandon, create, /{id}/cancel) are all shipped and wired. The posted β†’ accepted β†’ completed / abandon / expire lifecycle is live for cargo_delivery, express_delivery, hazardous_transport, and bulk_procurement β€” the NPC generator produces all four contract_types; the remaining three values in the schema below (refugee_transport, acquisition_bounty, escort) exist in the enum but have no generator yet. Player-to-player posting (post_player_contract, cargo_delivery or bulk_procurement) and cancellation are also shipped. Bulk procurement is fulfilled via a rented station locker, not a direct per-delivery payout β€” the acceptor deposits the commodity across installments, and the deposit that brings the locker to the contract's full quantity auto-completes the contract for the full payment in one settlement; a walk-away or deadline-lapse charges a penalty computed from the locker's fill at that moment (see Bulk procurement below). Status markers are omitted on individual subsections β€” per-section prose describes the target spec; the shipped scope is exactly the lifecycle above plus bulk procurement's locker-fulfillment path.

Overview

Two contract directions:

Direction Issuer Browsed at
NPC-to-player A station, corporation, or colony NPC The station's contract board
Player-to-player Any player who posts an offer One or more chosen stations' boards

Both directions share the same lifecycle, payment model, and dispute rules. Differences live in how the contract is generated and who pays the penalty on failure.

A ship too small to carry a contract's full required quantity in one trip can rent a storage locker at the destination station and fulfill it over multiple installments instead.

Contract schema

The Contract table is the central ledger for every contract β€” NPC-posted cargo runs, player acquisition bounties, escort jobs. Each row tracks state, parties, escrow, and deadlines.

Column Type Constraints Notes
id UUID PK
issuer_type Enum (npc, player) not null Source of the posting
issuer_id UUID / Integer not null FK β†’ Player.id (player) or the posting station's Station.id (npc β€” NPC contracts are posted by a station's own board, not a distinct NPC identity)
acceptor_player_id UUID nullable, FK Player.id Set on accept; remains for the lifecycle (single-acceptor at launch)
contract_type Enum not null cargo_delivery, bulk_procurement, express_delivery, hazardous_transport, refugee_transport, acquisition_bounty, escort
status Enum not null, default posted posted, accepted, in_progress, partial_fulfilled, completed, cancelled, disputed, expired (state-transition matrix below)
origin_station_id UUID nullable, FK Station.id Null for multi-source contracts (e.g. acquisition_bounty)
destination_station_id UUID not null, FK Station.id Final delivery point
commodity_type String(50) not null (or null for escort) Validated at the service layer against the live ResourceType registry (resource.py), not a fixed enum β€” the older "canonical seven" phrasing predates the registry and a hardcoded column-level enum would drift the moment a new commodity ships; or colonists for refugee contracts
quantity Integer not null (or null for escort) Units to deliver
payment Decimal(19,2) not null Base payment, frozen at posting
penalty Decimal(19,2) not null Penalty on failure (default 1.0Γ— payment for cargo)
acceptance_fee_pct Decimal(5,2) not null, default 2.0 Percentage of payment charged on accept
escrow_amount Decimal(19,2) not null Player contracts: payment + insurance_pool_reserve. NPC: 0
escrow_state Enum not null, default held held, released, disputed, refunding
faction_id UUID nullable, FK Faction.id NPC contracts: issuing faction
reputation_reward Integer nullable Frozen at posting
reputation_penalty Integer nullable Frozen at posting
deadline Timestamp not null Game-world time
posted_at Timestamp not null
accepted_at Timestamp nullable Set on accepted
completed_at Timestamp nullable Set on completed
partial_fulfilled_amount Integer nullable Reserved for a future multi-acceptor partial-fulfillment model (πŸ“ design-only). The shipped single-acceptor bulk-procurement fulfillment path (a rented storage locker) tracks deposited units through the locker's own ContractCargoDeposit rows, not this column β€” it stays null on every bulk contract today.
partial_fulfilled_payout Decimal(19,2) not null, default 0 Reserved alongside partial_fulfilled_amount for the same future model β€” stays 0 on every bulk contract today. The shipped locker path pays the full payment in one settlement at completion, not pro-rata.
dispute_filed_at Timestamp nullable Triggers escrow_state := disputed
dispute_resolution Enum nullable full_payout, partial_payout, refund, split, penalty
dispute_resolved_at Timestamp nullable
dispute_notes Text nullable Free-form evidence
escalated_to_admin Boolean not null, default false Per ADR-0062 E-I3 β€” set when the dispute meets one of: (a) both parties dispute, (b) evidence trail incomplete (combat / market / delivery log gap), or (c) disputed value > 100,000 cr. Other disputes resolve via the automated rule engine. Escalated disputes enter the admin review queue per ADR-0058.
insurance_coverage_tier Enum nullable basic, standard, hazard β€” premiums 2% / 5% / 10% of contract commodity value respectively; see Risk & insurance
insurance_premium_paid Decimal(19,2) not null, default 0 Held in escrow. On completion: released to insurer (not refunded). On mid-term cancellation per ADR-0062 E-I2: pro-rata refund based on (1 - elapsed/duration), minus a 10% cancellation fee retained by the insurer. Refund credits to the contract holder in the same transaction as the cancellation.
insurance_claim_filed Boolean not null, default false True after acceptor claims insurance for ship loss
posting_stations UUID[] not null (player contracts) Stations where the contract is visible

Indexes: (status, destination_station_id, posted_at DESC) for board listings; (issuer_id, status) for "my posted"; (acceptor_player_id, status) for "my accepted"; (deadline) for expiration polling; (status, dispute_filed_at) for dispute queue.

State transitions

posted
β”œβ”€ issuer cancels (pre-accept) β†’ cancelled, escrow β†’ refunding (issuer refund: 99%)
β”œβ”€ accept β†’ accepted (acceptance fee debited)
└─ deadline expires unaccepted β†’ expired (escrow returned to issuer)

accepted
β”œβ”€ load cargo / begin escort β†’ in_progress
β”œβ”€ bulk_procurement: deposit reaches full quantity β†’ completed, escrow β†’ released (no in_progress bridge β€” see below)
β”œβ”€ acceptor walks (bulk_procurement) β†’ expired (dynamic penalty from the locker's fill; locker β†’ claimable, acceptor keeps deposited goods)
└─ mutual cancel β†’ cancelled, escrow β†’ refunding (kill-fee = 2% accept + 10% cancel)

in_progress
β”œβ”€ cargo verified at destination β†’ completed, escrow β†’ released
β”œβ”€ deadline expires β†’ expired (penalty applied; reputation penalty)
β”œβ”€ cargo destroyed in transit β†’ cancelled (insurance pays if held)
└─ acceptor files dispute (within 48h) β†’ disputed (escrow frozen)

disputed
└─ resolution β†’ completed | cancelled | expired (per outcome)

Bulk procurement is fulfilled by depositing the commodity into a rented storage locker at the destination station; the contract stays accepted through every deposit installment β€” there is no in_progress bridge for the locker-fulfillment path. The deposit that brings the locker's accumulated total to the contract's full quantity auto-completes the contract in the same transaction, paying the full payment in one settlement (see Bulk procurement below). πŸ“ A future multi-acceptor partial-fulfillment model (tracked via partial_fulfilled_amount/partial_fulfilled_payout) is reserved but unbuilt; single-acceptor is the shipped model.

Contract boards

🚧 Partial. Each station exposes a contract board β€” GET /board?station_id= is shipped (routes/contracts.py:189-217, get_contract_board) and returns the union of contracts where issuer_id == station_id (NPC-issued, and player-issued once posted with that station as issuer) or posting_stations.any(station_id) (player-posted contracts listing this station), status-filtered to POSTED and not-yet-expired. βœ… Shipped (player UI): ContractBoardVenue.tsx (SpaceDock / GameDashboard) β€” board browse, my-contracts, accept/complete/abandon/insure/dispute/cancel/post via contractsAPI. Not yet built on the board route: reputation-gated hiding (faction-gated NPC contracts are not filtered by minimum reputation threshold today) and issuer-blocklist hiding for player contracts β€” both are πŸ“ design-only.

Boards refresh on the same tick cadence as market prices (see trading.md Β§ Pricing).

A station's board capacity is bounded by station class, ranging from a Class-0 trade hub down to a Class-8 black hole β€” this ladder was adopted by ADR-0093 item 37 (folded from ADR-0093, re-verified 2026-08-07). βœ… Shipped: CONTRACT_BOARD_CAPACITY_BY_CLASS + board_capacity_for_class in contract_generator.py; get_contract_board applies .limit(capacity) on the board route (api/routes/contracts.py). (re-verified 2026-08-16 β€” LEG-13 landed; LEG-36 doc sync)

Station class 0 1 2 3 4 5 6 7 8
Board capacity 40 32 26 20 16 12 10 8 6

The generator fills up to capacity on each tick, biased toward contract types that match the station's class trading pattern. (Station classes.)

NPC-issued contracts

🚧 Partial. generate_npc_contracts (contract_generator.py) seeds new entries on a tick and prunes expired ones for four contract types β€” cargo_delivery, express_delivery, hazardous_transport, and bulk_procurement (compute_cargo_delivery_payment / compute_express_delivery_payment / compute_hazardous_transport_payment / compute_bulk_procurement_payment respectively). The remaining three contract types below (refugee_transport, acquisition_bounty, escort) are πŸ“ design-only; the enum values exist but no generator produces them yet. Generator inputs:

  • Station class and current commodity surplus / deficit.
  • Faction control of the surrounding region.
  • Time of day in the game world (express-delivery rates spike during high-traffic windows).
  • Active galaxy-wide events (war, plague, blockade) that modulate refugee and hazardous demand.

Categories:

Cargo delivery

Pick up commodity X at station A, deliver to station B by time T. Payment on delivery. Cargo is reserved at the origin: accepting the contract grants the player a one-time pickup right at a fixed price (often free or below-market).

Bulk procurement

βœ… Shipped. A station generates a bulk_procurement contract when its live sell-stock at the origin drops below MIN_CONTRACT_QUANTITY Γ— 2 (a 40-unit deficit floor) β€” the station is short on the commodity, not sitting on a surplus to move out. The generated contract's quantity is pinned to MAX_CONTRACT_QUANTITY (150 units), the same per-haul ceiling every other NPC-generated type uses, regardless of how thin the triggering stock was. Players can post a bulk_procurement contract too β€” see Delivery contract below.

Gather N units of a commodity from anywhere and deliver to one station. No fixed origin β€” the acceptor sources however they like.

ARIA surfaces the contract board on first dock at any station with available contracts, and narrates contract-acceptance moments β€” see ../gameplay/aria-companion.md#aria-narration-hooks-event-catalog entries P-F7 + P-I1.

Fulfillment is via a rented station locker at the destination, not a direct per-delivery payout. The acceptor (acceptor_player_id, single-acceptor at launch) rents a storage locker at the contract's destination station and deposits the commodity across as many trips as it takes. The contract stays accepted through every installment β€” the deposit that brings the locker's accumulated total to the contract's full quantity auto-completes the contract in the same transaction, paying the acceptor the full payment in one settlement. The 2% acceptance fee is debited once on the initial accept, regardless of how many installments fill the locker.

Walking away β€” by explicit abandon or by letting the deadline lapse mid-fill β€” charges the acceptor a penalty computed from the locker's fill at that moment: payment Γ— (undelivered units Γ· quantity). A near-complete locker costs far less to walk away from than an empty one. The penalty is a destroyed sink (not paid to the issuer), reduced by any insurance offset the acceptor holds. The acceptor keeps whatever was already deposited β€” the locker converts to claimable rather than forfeiting its contents (that forfeiture is a separate consequence, triggered only by failing to pay the locker's own rent β€” see storage-lockers.md Β§ Missing the deadline). The issuer's escrow refunds in full either way: immediately on an explicit abandon, or after the standard 48-hour dispute window on a deadline lapse (see Escrow handling). This terminal walk-away path also closes the historical SK13 re-delivery exploit (ADR-0049 SK13 / ADR-0062 E-V1) β€” there is no reject-then-re-accept delivery loop under the locker model, so a monotonic fulfillment_count counter is not required.

πŸ“ Multi-acceptor partial fulfillment (where two or more players each fulfill a slice of N) is reserved for a future iteration. The escrow split, fee proration, and per-acceptor walk-away semantics are non-trivial and aren't load-bearing for launch β€” single-acceptor with a locker covers the common case (a player commits to the haul, sources flexibly across trips, and either completes it or walks away, paying the dynamic penalty above).

Express delivery

High-priority cargo with a tight deadline. Payment is higher than standard cargo delivery, plus an early-arrival bonus (see Rewards). Express contracts use a stricter penalty on failure.

Hazardous transport

Illegal or contraband cargo routed via black-market channels. Issued by criminal NPCs at black-market terminals. See black-market.md for the goods list and detection mechanics. Hazardous transport pays significantly more, applies a faction penalty if completed, and exposes the carrier to scans during transit.

Refugee transport

Move colonists from one region to another. Interregional only β€” single-region jobs use the standard colonist trade flow described in planets/colonization.md. Refugee contracts are gated behind a passenger-rated ship and pay per surviving colonist on arrival.

Player-issued contracts

Players post offers visible at one or more stations they control or have docking rights at. The poster pays the contract value into escrow at posting time.

Delivery contract

🚧 Partial. Shipped: post_player_contract (contract_service.py:1778) β€” escrow debit, validation, and the 10-per-region posting cap. The player has cargo they need moved to a destination they can't easily reach. The player nominates origin, destination, commodity, quantity, deadline, and offered payment. Other players accept and execute the run.

A player can post a bulk_procurement contract through this same function instead of a cargo_delivery β€” same escrow math (payment + insurance_pool_reserve), same posting cap. Fulfillment, the walk-away penalty, and NPC-generation parity are described under Bulk procurement above; nothing about the player-issued path changes that model.

Acquisition bounty

πŸ“ The player offers credits for any cargo of type X delivered to port Y by the deadline. Multiple acceptors can fulfill partial quantities until the bounty is met or the deadline lapses.

Escort contract

πŸ“ The player pays another player to fly with them through a sequence of sectors (combat support during traversal). Escort contracts complete when the protected player arrives at the destination intact, or when a defined number of hostile encounters are survived. Cross-link: ships.md.

Escrow handling

Player-issued contracts use server-held escrow. NPC contracts draw from the NPC's credit pool (no lock; treated as infinite).

Phase Trigger Issuer Acceptor Escrow row
Post (player) 🚧 POST /contracts βˆ’(payment + insurance_pool) β€” escrow_amount := payment + insurance_pool, state := held
Accept 🚧 POST /contracts/{id}/accept β€” βˆ’(payment Γ— 0.02) acceptance fee unchanged
Insure βœ… POST /contracts/{id}/insure β€” βˆ’premium insurance_premium_paid := premium β€” contract_insurance.insure / _guarded_insure (contract_insurance.py)
Deposit (bulk) βœ… POST /storage/lockers/{locker_id}/deposit β€” βˆ’(units, from ship cargo, into the locker) unchanged β€” the contract stays accepted; the locker's own deposit rows track cumulative fill
Complete 🚧 (cargo_delivery) / βœ… (bulk) POST /contracts/{id}/complete (cargo_delivery); the completing deposit call above (bulk β€” same transaction, no separate request) βˆ’(early bonus, if any) +(payment + bonus βˆ’ premium) state := released
Expired (cargo_delivery) deadline lapse (accepted contract) +(escrow refund in full, after the 48h dispute window) β€” state := refunding, per the repo-root DECISIONS.md entry contract-escrow-deadline-refund. Acceptor forfeits acceptance fee + reserved cargo; insured acceptor: insurer pays penalty
Expired / abandoned (bulk) βœ… deadline lapse, or acceptor abandon (POST /contracts/{id}/abandon) +(escrow refund in full β€” immediate on abandon, deferred to the 48h dispute window on deadline lapse) βˆ’(payment Γ— undelivered Γ· quantity, from the locker's fill at that moment; insurance offsets the cash debit) state := refunding. The locker converts to claimable β€” the acceptor keeps whatever was already deposited
Cancel pre-accept 🚧 POST /contracts/{id}/cancel +(escrow Γ— 99%) β€” state := refunding. 1% posting-fee sink
Cancel post-accept (mutual) 🚧 same +(escrow βˆ’ accept_fee βˆ’ 10% kill-fee) 0 state := refunding. Kill-fee β†’ escrow sink
Disputed βœ… POST /contracts/{id}/dispute β€” β€” state := disputed. Escrow frozen pending arbitration β€” contract_dispute.file_dispute / _guarded_file_dispute (contract_dispute.py)

Escrow is never directly transferable between players β€” all settlement runs through the contract row.

The Cancel pre-accept and Cancel post-accept actions above are the first venue for the platform-wide inline-confirm UX standard: any credit consequence over β‚‘1,000 (which most contract cancellations cross, via escrow refund or kill-fee) confirms inline rather than via modal. See ../../OPERATIONS/ui-flows.md Β§ 3.6 Confirmation standard for the pattern definition.

Bulk-procurement walk-away example

Player A posts a bulk_procurement for 1,000 units of ore, payment 1,000 cr β†’ escrow 1,000. Player B accepts, debited 20 cr (2% acceptance fee). B rents a locker at the destination and deposits 800 of the 1,000 units across two trips, then fails to finish β€” either by explicitly abandoning or by letting the deadline lapse with the locker still at 800/1,000.

Penalty = 1,000 Γ— (200 Γ· 1,000) = 200 cr, debited from B's wallet as a destroyed sink (not paid to A). B keeps the 800 deposited units β€” the locker converts to claimable rather than forfeiting its contents. A's 1,000 cr escrow refunds in full: immediately if B explicitly abandoned, or after the 48-hour dispute window if the deadline lapsed instead. Had B deposited nothing before failing, the penalty would be the full 1,000 cr; had B completed all 1,000 units, B would instead be paid the full 1,000 cr in one settlement on the completing deposit. The 20 cr acceptance fee is never refunded either way.

Contract lifecycle

Status Trigger Effect
posted Issuer creates the contract Visible on contract board(s); awaiting acceptance
accepted A player accepts Acceptance fee charged; cargo (if delivery) reserved at origin; clock starts
in_transit Cargo loaded into the acceptor's ship Deadline timer running; player carries the load
completed Cargo delivered at destination station before deadline Payment + reputation reward issued; insurance refunded if held
expired Deadline expires or cargo lost in transit Penalty applied; reputation penalty; escrow paid to issuer
cancelled Cancellation before acceptance, or by mutual agreement after Partial penalty (kill-fee) β€” see Anti-griefing

Status transitions are one-way except posted β†’ cancelled. Once accepted, the only exits are completed, expired, or cancelled (with kill-fee). This table's terminal-state name follows the canonical expired naming used by the schema (see Contract schema) and contract.py's state-machine docstring.

posted ──accept──▢ accepted ──load──▢ in_transit ──deliver──▢ completed
   β”‚                  β”‚                    β”‚
   β”‚                  β”‚                    └──deadline_expired──▢ expired
   β”‚                  β”‚                    └──cargo_destroyed───▢ expired
   β”‚                  └──mutual cancel──▢ cancelled (kill-fee)
   └──issuer withdraw──▢ cancelled (no fee)

API surface

βœ… Shipped endpoints under /api/v1/contracts/ β€” every route in the table below is live in routes/contracts.py (confirmed by name/verb: /board, /mine, /{id}, POST /contracts, /{id}/accept, /{id}/complete, /{id}/abandon, /{id}/cancel, matching the header status line above). Bulk-procurement fulfillment itself happens through the separate /api/v1/storage/lockers/ surface β€” see storage-lockers.md Β§ Renting a locker and Β§ Depositing cargo β€” rather than a /contracts/{id}/... endpoint of its own.

Method Path Purpose
GET /api/v1/contracts/board?station_id=... List contracts visible at a station
GET /api/v1/contracts/mine List the caller's posted + accepted contracts
GET /api/v1/contracts/{id} Detail for a single contract
POST /api/v1/contracts Post a new player-issued contract (cargo_delivery or bulk_procurement, escrow check)
POST /api/v1/contracts/{id}/accept Accept a posted contract (charges acceptance fee)
POST /api/v1/contracts/{id}/insure Buy insurance on an accepted contract
POST /api/v1/contracts/{id}/complete Mark delivered (server verifies cargo at destination) β€” for bulk_procurement this fires automatically from the completing locker deposit rather than a direct call
POST /api/v1/contracts/{id}/cancel Cancel β€” kill-fee applied per state
POST /api/v1/contracts/{id}/abandon Walk away from an accepted contract β€” for bulk_procurement, charges the dynamic locker-fill penalty and converts the locker to claimable
POST /api/v1/contracts/{id}/dispute File a dispute on a failed contract
POST /api/v1/admin/contracts/{id}/resolve-dispute Admin: issue final dispute ruling

WebSocket events fire on every status transition for the issuer, acceptor, and any subscribed faction-management clients.

Request and response shapes

POST /api/v1/contracts

// request
{
  "contract_type": "bulk_procurement",
  "destination_station_id": "stat-uuid",
  "commodity_type": "ore",
  "quantity": 5000,
  "payment": 500,
  "deadline": "2026-05-08T14:00:00Z",
  "posting_stations": ["stat-uuid-1", "stat-uuid-2"],
  "insurance_pool_reserve": 50
}

// response 201
{
  "id": "contract-uuid",
  "status": "posted",
  "escrow_amount": 550,
  "escrow_state": "held",
  "posted_at": "2026-05-04T12:00:00Z",
  "acceptance_fee_pct": 2.0
}

Validation: caller has credits β‰₯ payment + insurance_pool_reserve; destination exists and not offline; deadline β‰₯ 1 hour out; active postings by caller < 10 per region; caller not blocklisted.

POST /api/v1/contracts/{id}/accept

// response 200
{
  "id": "contract-uuid",
  "status": "accepted",
  "acceptor_player_id": "player-uuid",
  "accepted_at": "2026-05-04T12:05:00Z",
  "acceptance_fee_charged": 10,
  "remaining_balance": 490,
  "deadline": "2026-05-08T14:00:00Z"
}

Validation: wallet β‰₯ acceptance_fee; status is posted; caller β‰  issuer; not blocklisted.

Bulk-procurement fulfillment

Bulk-procurement deliveries go through the storage-locker API, not a /contracts/{id}/... endpoint β€” see storage-lockers.md Β§ Renting a locker and Β§ Depositing cargo for the request/response shapes. The deposit that brings the locker to the contract's full quantity completes the contract automatically, in the same call.

POST /api/v1/contracts/{id}/dispute

// request
{ "reason": "Cargo manifest shows delivery occurred", "evidence_snapshot": "manifest-url" }

// response 202
{
  "status": "disputed",
  "dispute_filed_at": "2026-05-05T09:00:00Z",
  "escrow_frozen": 500,
  "estimated_resolution": "2026-05-06T09:00:00Z",
  "arbitration_tier": "automated"
}

Rewards

Base payment is a function of:

payment = base_rate
        Γ— commodity_value(commodity_type, quantity)
        Γ— distance_factor(origin, destination)
        Γ— urgency_factor(deadline_tightness)
        Γ— contract_type_multiplier

commodity_value derives from the live midpoint price (see trading.md Β§ Pricing). distance_factor uses warp-jump count between origin and destination. urgency_factor rises as deadline tightness increases (express deliveries pay roughly 1.5–2.0Γ— their non-express equivalents).

Bonuses

  • βœ… Early-completion bonus β€” shipped for express_delivery. Up to +25% of payment if delivered with greater than 50% of the time window remaining, linear scale between 0–25% above the 50% threshold β€” _compute_early_arrival_bonus (contract_service.py:327-364), wired into complete() at contract_service.py:453 and returned as early_arrival_bonus in the completion response. Gated to contract_type == EXPRESS_DELIVERY only; every other contract type still gets payment with no bonus.
  • Reputation reward β€” completion grants reputation with the issuing faction (NPC contracts) or a small mutual reputation bump between poster and acceptor (player contracts).
  • Insurance refund β€” if the player paid an insurance premium and completed cleanly, the unused premium is not refunded β€” see Risk & insurance for why.

Penalties

On failure (deadline expired or cargo lost):

  • Forfeit reserved cargo (if a delivery contract).
  • Reputation penalty with the issuing faction (NPC) or the posting player.
  • πŸ“ Design-only β€” cooldown on contract eligibility from that issuer (default 24 game-hours). services/gameserver/src/services/contract_dispute.py's own module docstring: "no cooldown/ban model exists anywhere in this codebase (grepped)."
  • Acceptance fee is not refunded.
  • Penalty credits are debited from the acceptor's account; if insufficient, the deficit is recorded as a debt that must be cleared before posting new contracts.

Worked example

πŸ“ A Class-2 station posts a cargo_delivery for 150 units of organics to a Class-3 station 8 jumps away, deadline 90 minutes:

base_rate                   = 1.0
commodity_value             = 150 Γ— midpoint(organics) β‰ˆ 150 Γ— 16.5 = 2,475 cr
distance_factor             = 1.0 + 0.05 Γ— 8 = 1.40
urgency_factor (90 min)     = 1.10
contract_type_multiplier    = 1.0  (standard cargo_delivery)
payment                     β‰ˆ 2,475 Γ— 1.40 Γ— 1.10 β‰ˆ 3,810 cr
acceptance_fee              β‰ˆ 76 cr (2%, refundable)
early-completion bonus cap  β‰ˆ +953 cr (25%) if delivered with > 45 min left
penalty on failure          = forfeit reserved cargo + 1Γ— payment debit

Numbers are illustrative; the actual coefficients live in contract_service.py config.

Risk & insurance

βœ… Shipped β€” optional contract insurance is a per-contract add-on bought after accept via POST /contracts/{id}/insure (contract_insurance.insure / _guarded_insure; premiums INSURANCE_PREMIUM_PCT BASIC/STANDARD/HAZARD = 2% / 5% / 10% of contract value β€” the premium percentages were ratified by ADR-0093 items 7/32, folded from ADR-0093, re-verified 2026-08-07). On expiry/failure, coverage is a penalty offset (not a positive credit payout): apply_claim_offset draws from insurance_pool_reserve after the deductible ladder (BASIC 5% / STANDARD 10% / HAZARD 15%), so the acceptor owes less rather than receiving credits (contract_insurance.py).

Coverage tier Premium Covers
Basic 2% of contract commodity value Ship loss in low-security space during in-transit
Standard 5% Ship loss anywhere + 50% cargo replacement
Hazard 10% Ship loss anywhere + 100% cargo replacement + extended deadline grace

The tier table's narrative coverage bullets (low-sec-only / cargo-replacement / deadline grace) remain descriptive of the product intent; the live claim path today is the penalty-offset on expiry/abandon sweeps above. Insurance does not cover wilful abandonment as a free walk-away (abandon still charges the dynamic/static penalty, then offsets it), and insurance_claim_filed is unused schema (no separate player-filed claim endpoint). Deductible model parallels the ship insurance system β€” same three-rung deductible ladder (5% / 10% / 15%), per ADR-0061, but the premium percentages differ between the two systems and the top rung is named differently: contract insurance's Hazard tier (10% premium) maps to the identical 15% deductible slot as ship insurance's Premium tier (22% premium) β€” same deductible, different premium base and different name. Do not assume the two tier tables share a premium scale just because they share a deductible ladder.

Reputation effects

🚧 Partial. reputation_penalty is now read on completion for hazardous_transport β€” contract_service.py:517-527 applies a Federation apply_faction_rep_delta penalty via the first real reader of that column (contract_dispute.py's own module docstring previously called it write-only; that note is now stale for this one path). reputation_reward on NPC complete is βœ… Shipped on tip (88883100 / PR #629 MERGED β†’ tip 46bce720): first real reader at contract_service.py:530-554 via apply_faction_rep_delta(..., reason="npc_contract_reputation_reward") when issuer_type=NPC and a truthy row value is present. NPC generator freeze of non-null reputation_reward when faction_id resolves is βœ… Shipped on tip (6ef60c0c / PR #631 MERGED β†’ tip 46bce720): NPC_CONTRACT_FACTION_REP_REWARD = abs(HAZARDOUS_TRANSPORT_FEDERATION_REP_PENALTY) (30) β€” magnitude is the existing [NO-CANON] penalty-symmetry pin already used by the hazardous Federation penalty writer, not a ratified FEATURES balance number. abandon() still does not read either column. The mechanics below describe the target behavior beyond those completion hooks.

Completing NPC contracts boosts faction standing with the issuing faction. Failing damages it. The reward/penalty values are stored on the contract row at posting time, so they're stable across the lifecycle even if the faction's general standing thresholds shift.

πŸ“ Design-only. Persistent failure β€” 3 or more failures in a row with the same faction β€” bans the player from accepting that faction's contracts for a cooldown (default 7 game-days). No cooldown/ban model exists anywhere in this codebase today (contract_dispute.py module docstring, grepped) β€” this describes target behavior, not shipped mechanics. The cooldown clears by waiting it out, or sooner by accruing positive faction rep through emergent activity (defending a faction sector, trade volume at faction ports, etc. β€” see ../gameplay/factions-and-teams.md#reputation-triggers).

Player-to-player contracts also feed a lightweight trader-reputation stat (separate from faction reputation). Persistent contract reliability on the trader side becomes a public visible badge on the player's profile.

Anti-griefing

🚧 Partial. Rules to keep contracts from being weaponised:

  • πŸ“ Contracts cannot be accepted from players the acceptor has active hostility with (negative direct-relationship reputation between the two parties).
  • βœ… An acceptance fee (small, fixed percentage of contract value, default 2%) is charged at accept time and is not refunded on completion, failure, or cancellation β€” shipped, a pure friction sink. This discourages frivolous picks that lock the contract for the deadline window without intent to complete.
  • The issuer cannot cancel a contract after it has been accepted without paying a kill-fee equal to the acceptance fee plus 10% of the contract value, paid to the acceptor.
  • A player cannot post a contract whose escrow they cannot afford. Escrow is held server-side at posting time.
  • 🚧 Contract boards rate-limit per-player postings to prevent spam (default 10 active postings per player per region) β€” shipped.
  • πŸ“ A posting-side player blocklist β€” _is_player_blocklisted (contract_service.py:254) is a documented no-op seam, always returning False, until a real blocklist model exists to wire it to.

Disputes

βœ… Shipped β€” acceptor-only filing via POST /contracts/{id}/dispute (contract_dispute.file_dispute / _guarded_file_dispute). Verified window: DISPUTE_FILING_WINDOW_HOURS = 48 in contract_dispute.py (wall-clock hours from contract.deadline as the failure-timestamp proxy; module pins wall-clock to match sibling contract timed checks). Filing flips status/escrow_state β†’ disputed and freezes held escrow. πŸ“ Reputation-penalty pause remains Design-only in effect β€” _is_reputation_penalty_paused is a real gate. Completion-path reputation is no longer wholly unread (see Reputation effects for the hazardous_transport penalty + LEG-122 NPC reputation_reward reader + LEG-125 generator freeze, both tip-merged); abandon() and the dispute path still do not apply those columns.

Resolution runs in two tiers:

Tier 1: automated arbitration (within 1 game-hour)

Tier-1 runs synchronously inside file_dispute (milliseconds, not a separate sweep). Of the three canon cases:

  • Cargo manifest match β€” πŸ“ Design-only seam (_tier1_cargo_manifest_match always returns False; no per-contract delivery-event log exists yet).
  • Destination unreachable β€” βœ… Shipped proxy: current destination Station.status == ABANDONED (_tier1_destination_unreachable) β†’ cancels, refunds acceptance fee; no historical offline/destroyed/inaccessible snapshot.
  • Issuer unilateral cancellation β€” πŸ“ Design-only seam (_tier1_issuer_unilateral_cancellation always returns False; cancel-after-accept is not a live transition that leaves a disputable EXPIRED row).

Unresolvable cases escalate to Tier 2 (escalated_to_admin per ADR-0062 E-I3 criteria).

Tier 2: admin review (within 24 game-hours)

βœ… Admin reviews dispute_notes and issues a ruling via POST /admin/contracts/{id}/resolve-dispute (contract_dispute.resolve_dispute, routed in admin_contract_disputes.py):

Outcome Settlement Reputation Cooldown
full_payout Escrow β†’ acceptor in full Reward applied retroactively None
partial_payout Pro-rata: (delivered / expected) Γ— payment β†’ acceptor; remainder β†’ issuer Proportional reward None
refund (acceptor non-negligent) Escrow β†’ issuer; acceptance fee β†’ acceptor Penalty β†’ issuer (acceptor absolved) Acceptor: 24h cooldown on that issuer
penalty (acceptor fault or fabrication) Escrow β†’ issuer; acceptance fee forfeit Acceptor penalty doubled (orig + βˆ’50) Acceptor: 72h cooldown; account flag on repeat
split (shared responsibility) Escrow split 50/50; acceptance fee refunded Half reward + half penalty Acceptor: 24h cooldown

A player who files 2+ false disputes in 30 days is flagged for manual review (potential contract-system suspension).

Black-market contracts

Hazardous-transport NPC contracts and certain illegal-goods player contracts route through the black-market system. They pay 2–4Γ— standard rates, carry detection risk during transit, and apply a faction penalty on completion (the law-side faction loses standing). See black-market.md for the full mechanics, terminal locations, and detection model.

Player-facing affordances

  • βœ… Contract board venue β€” ContractBoardVenue.tsx mounted from SpaceDockInterface.tsx and GameDashboard.tsx; board / mine / post tabs plus accept / complete / abandon / insure / dispute / cancel / post via contractsAPI (origin/feat 46bce720).
  • βœ… Locker multi-trip bulk procurement β€” acceptor fulfills bulk_procurement by renting a station locker and depositing across trips (storageAPI + locker deposit routes); the deposit that reaches full quantity auto-completes the contract; walk-away / abandon charges the dynamic locker-fill penalty (see Bulk procurement).
  • πŸ“ refugee_transport, acquisition_bounty, escort contract types β€” enum values exist; no NPC generator or dedicated player UI on tip; do not conclude these flows are live.
  • πŸ“ Board reputation-gated hiding + issuer blocklist β€” design-only on get_contract_board today (see Contract boards).

Source map

Concern Path Status
Contract model services/gameserver/src/models/contract.py βœ… Shipped
Contract service services/gameserver/src/services/contract_service.py (2,131 lines) βœ… Shipped
NPC contract generator services/gameserver/src/services/contract_generator.py (859 lines) βœ… Shipped (cargo_delivery, express_delivery, hazardous_transport, bulk_procurement; remaining three types Design-only)
Expiry-sweep scheduler services/gameserver/src/services/scheduler/contract_sweeps.py βœ… Shipped
Contract API routes services/gameserver/src/api/routes/contracts.py (10 routes) βœ… Shipped
Contract insurance services/gameserver/src/services/contract_insurance.py (insure, _guarded_insure, apply_claim_offset) βœ… Shipped
Contract disputes services/gameserver/src/services/contract_dispute.py (file_dispute, resolve_dispute; window DISPUTE_FILING_WINDOW_HOURS = 48) βœ… Shipped
Admin dispute routes services/gameserver/src/api/routes/admin_contract_disputes.py βœ… Shipped
Admin UI β€” dispute arbitration services/admin-ui/src/components/pages/ContractDisputeArbitration.tsx (/contract-disputes) βœ… Shipped
Contract board UI services/player-client/src/components/spacedock/ContractBoardVenue.tsx (wired via SpaceDockInterface.tsx + GameDashboard.tsx; contractsAPI) βœ… Shipped
Contract board UI tests services/player-client/src/components/spacedock/ContractBoardVenue.test.tsx (+ bulkPost.test.tsx for posting path) βœ… Shipped

Status

🚧 Partial. See the header above for the full shipped/unbuilt breakdown. Remaining build order:

  1. NPC generators for the remaining three contract types (refugee_transport, acquisition_bounty, escort) β€” the enum values exist; cargo_delivery, express_delivery, hazardous_transport, and bulk_procurement all generate today.
  2. Multi-acceptor bulk-procurement partial fulfillment; wire the two Tier-1 dispute seams (cargo-manifest log + issuer-unilateral-cancel history) and reputation application on dispute/complete β€” insurance purchase/claim-offset and dispute filing/Tier-2 resolve are already shipped (see markers above).
  3. Anti-griefing hostility-block and posting blocklist (currently a permanent no-op stub, see contract_service.py:254).