0062 — Economy: tariffs, contracts, and market-data hardening (Group F)¶
Status¶
Accepted.
Context¶
Twelve audit findings cluster around the economy surfaces — tariff knobs, contract walk-away exploits, market-data gaps. Two are critical (tariff arbitrage cycle, walk-away re-delivery). The rest are correctness, table completeness, and policy-gap fixes that compose cleanly into one ADR.
E-V1 and R-V2 reference the same fix (ADR-0049 SK13 monotonic counter, ratification incomplete in contracts.md); they collapse into one work item.
Decision¶
E-F1 — Same-owner trades skip the price-adjustment lever¶
The combined (tariff + price_adjustment_lever) stack lets a region owner who also owns multiple stations cycle ore (or any commodity) between two of their own stations and arbitrage the spread. The closure: same-owner trades skip the lever entirely.
def compute_trade_price(buyer_station, seller_station, commodity, base_price):
rep_modifier = trader.faction_rep_modifier(seller_station.controlling_faction)
tariff = seller_station.region.tariff_rate
lever = (
seller_station.price_adjustment_lever
if not is_same_owner_or_team(buyer_station, seller_station)
else 0.0
)
return base_price * (1 + rep_modifier) * (1 + tariff) * (1 + lever)
is_same_owner_or_team returns true if the two stations share an owner_player_id, share a controlling_team_id, or any combination of both. Cross-owner trades see the full lever; same-owner pure-internal transfers run at the rep + tariff modifiers only. The arbitrage cycle no longer profits.
E-V1 + R-V2 — Walk-away re-delivery exploit ratification¶
Per ADR-0049 SK13 the canonical fix is a monotonic counter on contract delivery: each delivery increments Contract.fulfillment_count; a buyer cannot reject a delivery, then accept a re-delivery from the same seller for the same units, because the counter has advanced past the original-rejection state. contracts.md is updated to:
- Remove the now-obsolete exploit illustration.
- State the monotonic counter as the canonical defense.
- Cross-link ADR-0049 SK13 from the contract delivery flow.
This is doc-cleanup, not new design; the counter is already canonical.
E-D1 — Add precious_metals to COMMODITY_PRICE_RANGES¶
The existing COMMODITY_PRICE_RANGES table (in market-pricing.md) carries 8 commodities: ore, organics, gourmet_food, fuel, equipment, exotic_technology, luxury_goods, colonists. Per ../FEATURES/economy/mining.md, precious_metals is a canonical mining drop with a target price band (80–180 cr/unit) but is not yet in COMMODITY_PRICE_RANGES — that's the gap E-D1 closes.
The new row:
| Commodity | min | max | Notes |
|---|---|---|---|
| precious_metals | 80 | 180 | per ../FEATURES/economy/mining.md target — slotted between equipment and exotic_technology |
Any commodity that ships at Launch must have a COMMODITY_PRICE_RANGES entry. Future commodities require a doc PR adding the row plus a ResourceType enum entry in the gameserver code.
E-D2 — Bang generator commodity coverage¶
The bang world-generator must emit the full set of Launch commodities across appropriate station classes during procedural station seeding. The existing bang output covers only 3 of the 9 Launch-canonical commodities (ore, organics, equipment) — the remaining six need to surface at procedural stations matching their station-class affinity. Per-class commodity coverage in ../OPERATIONS/bang-integration.md:
| Station class | Commodities (procedural seed) |
|---|---|
| Class 1 (Frontier outpost) | ore, organics, fuel |
| Class 2 (Border trade post) | ore, organics, fuel, equipment |
| Class 3 (Industrial hub) | ore, organics, fuel, equipment, precious_metals |
| Class 4 (Capital trade dock) | All except colonists (colonists shipped via TradeDocks per existing rule) |
| Class 5 (Nexus mega-station) | All 9 |
Bang validates coverage at generation time and fails the seed if any station class is missing a required commodity. The CLASS_TRADE_PATTERN constant in market-pricing.md is the source of truth; bang reads the pattern at world-generation time.
E-D3 — Trade price stack order¶
Multiplicative composition order, general → specific:
final_price = base_price
× (1 + reputation_modifier) # per-faction trade modifier
× (1 + region_tax_rate) # 0–25%, per region governance
× (1 + region_tariff) # 0–25% default cap, capped lower per E-F2
× (1 + station_price_lever) # ±10%, skipped on same-owner trades per E-F1
The order is intentional: reputation is the relationship signal applied first; tax is the regional revenue layer; tariff is the regional commerce policy; the lever is the station-specific marketing knob. Each multiplier composes independently; nothing is additive.
region_tax_rate and region_tariff are separate concepts — tax is a flat percentage applied to all in-region commerce (regional treasury revenue per ADR-0059 treasury entries), tariff is a per-trade modifier on top.
E-F2 — Sliding tariff cap by station count¶
Per the user pick, sparse regions can't extract aggressively. The tariff cap scales by station count:
| Stations in region | Max tariff |
|---|---|
| < 3 | 5% |
| 3–5 | 15% |
| ≥ 6 | 25% (default) |
Region owners must invest in commerce density before they can dial tariff up. Implementation: the tariff-set endpoint validates against count(stations WHERE region_id = X) at submission time. Existing tariffs above the new cap (post-rule deploy) clamp to the cap on next region-state read.
The cap is a Region-level rule; per-station tariff carve-outs aren't supported.
E-F4 — Black-market detection uses personal_reputation¶
The black-market detection formula references Player.personal_reputation (legality posture), not faction reputation. Faction rep handles secondary effects of a black-market transaction (Fringe Alliance gain, Federation loss per the action-delta table) but the detection signal is personal rep:
def black_market_detection_chance(player, station):
if player.personal_reputation > 0: # honest reputation reduces suspicion
return 0.05
if player.personal_reputation > -250: # gray
return 0.20
return 0.50 # outlaw / criminal
Doc clarification only; no schema change.
E-V4 — NPC orders separated from player-trade demand signal¶
Station gains two distinct demand fields:
player_demand_score— derived from player buy/sell activity. Drives the player-facing market UI's demand indicator.npc_restock_demand— derived from NPC trader orders. Drives NPC restocking logic only.
The two never blend. The player-facing market UI shows only player_demand_score. NPC trader manipulation can't skew the player signal because they touch different fields. Sufficient for closure; no multi-account-style discount layer needed for this surface.
Migration: existing demand_score reads route to player_demand_score; the NPC logic gets a new npc_restock_demand field populated by the NPC trader service.
E-I1 — Station production rates table¶
market-pricing.md gains a CLASS_BASELINE_PRODUCTION_RATES table per (station_class, commodity) (units/hour). Class 1 + 2 produce raw materials; class 3 adds finished goods; classes 4–5 scale by multiplier:
| Station class | Commodity | Production (units/hour) |
|---|---|---|
| 1 | ore | 50 |
| 1 | organics | 30 |
| 1 | fuel | 20 |
| 2 | ore | 80 |
| 2 | organics | 50 |
| 2 | fuel | 35 |
| 2 | equipment | 10 |
| 3 | ore | 120 |
| 3 | organics | 80 |
| 3 | fuel | 60 |
| 3 | equipment | 25 |
| 3 | precious_metals | 5 |
| 3 | gourmet_food | 15 |
| 4 | (all class-3 commodities) | 1.5× class 3; adds exotic_technology @ 8/hr, luxury_goods @ 12/hr |
| 5 | (all class-4 commodities) | 2.0× class 3; adds colonists @ 4/hr |
Numbers are Launch balance targets — live tuning happens in the table, not in code.
E-I2 — Contract insurance pro-rata refund¶
When a contract is cancelled mid-term, the insurance premium refunds pro-rata based on remaining contract duration, minus a 10% cancellation fee retained by the insurer:
def cancellation_refund(contract, cancelled_at):
elapsed = cancelled_at - contract.started_at
remaining_fraction = max(0.0, 1 - elapsed / contract.duration)
refund_base = contract.insurance_premium * remaining_fraction
return refund_base * 0.90 # 10% cancellation fee
The 10% fee covers fixed underwriting costs (the insurer can't fully recover from a same-day cancel). Refund credits to the contract holder in the same transaction as the cancellation.
E-I3 — Dispute escalation criteria¶
Admin escalation triggers under any of:
- Both parties dispute — buyer and seller each file dispute claims; auto-escalates.
- Evidence trail incomplete — combat / market / delivery log rows missing for the disputed timeframe (e.g., a server outage gap).
- Disputed value > 100,000 cr — high-value disputes always go to admin review, even with complete logs.
Disputes that meet none of the criteria resolve via the automated rule engine (delivery counter, market history, combat outcome). Disputes that escalate enter the admin review queue per ADR-0058 (admin.audit.view scope reads, action-specific scopes resolve).
E-F3 — Precious metals laser cap at L2 (canonical)¶
Past L2, no further laser-tier upgrades. The cap is explicit, not a flat-ladder ambiguity. Future tier expansions require a separate ADR with a balance pass; until then, L2 is the ceiling.
Consequences¶
- E-F1 introduces an
is_same_owner_or_teamcheck at trade-price computation. Implementation readsStation.owner_player_id+Station.controlling_team_id; team membership reads live (consistent with the bounty-collection same-team check from ADR-0055 S-F1). - E-D3 establishes a canonical price-stack order. Code paths that compute trade prices in different services (player-trade, NPC-trade, market-feed, ARIA market summaries) must converge on this order. Existing inconsistencies are doc-vs-code concerns for the gameserver repo.
- E-F2 is enforced at the tariff-set endpoint. Existing tariffs above the new cap don't auto-clamp at deploy — they clamp on next read, which surfaces the new rule to operators without a one-time migration sweep.
- E-V4 adds
npc_restock_demandtoStation. The migration is forward-only; existingdemand_scorebecomesplayer_demand_score. - E-I2 refunds at 90% of pro-rata. The 10% retention is a one-line config (
INSURANCE_CANCELLATION_FEE = 0.10), live-tunable. - E-I3 routes high-value disputes through the admin queue. Operations teams must commit to acknowledging the queue or the value-threshold path becomes a backlog.
Alternatives considered¶
- Hard cap on total tariff+lever adjustment (E-F1). Considered; rejected — preserves the exploit at smaller magnitudes and limits legitimate marketing levers cross-owner.
- Removing the price-adjustment lever entirely (E-F1). Rejected — kills a legitimate per-station tool that operators use for marketing differentiation.
- Hard tariff cap regardless of region size (E-F2). Considered; rejected per user pick — strips region-owner agency uniformly. Sliding cap rewards investment in commerce density.
- Blended NPC + player demand at 0.25× weight (E-V4). Considered; rejected per user pick. Separate signals are simpler to reason about and close the manipulation vector at the schema level.
- Multi-account discount applied to NPC demand (E-V4). Considered; rejected per user pick — the surface is small enough that schema-level separation is sufficient.
Related¶
- ADR-0009 — quantum crystal venue split (E-D1, E-D2 reference).
- ADR-0049 SK13 — walk-away re-delivery monotonic counter (E-V1, R-V2 ratification).
- ADR-0055 — same-team check pattern (E-F1 reuses).
- ADR-0056 — multi-account discount layer (considered for E-V4).
- ADR-0058 — admin scopes for E-I3 escalations.
- ADR-0059 —
RegionalTreasuryEntry(E-D3 tax/tariff distinction references). ../SYSTEMS/market-pricing.md—COMMODITY_PRICE_RANGES,CLASS_BASELINE_PRODUCTION_RATES, stack order.../FEATURES/economy/contracts.md— walk-away ratification, dispute criteria, insurance refund.../FEATURES/economy/black-market.md— detection formula clarification.../OPERATIONS/bang-integration.md— per-class commodity coverage.../DATA_MODELS/economy.md—Station.player_demand_score,Station.npc_restock_demandsplit;Region.tariff_ratecap rule.