Skip to content

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_team check at trade-price computation. Implementation reads Station.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_demand to Station. The migration is forward-only; existing demand_score becomes player_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.