Safety invariants

What the system refuses to break

T3 Trade lets a language model submit signed testnet orders under fixed constraints. This page lists 17 invariants the system will not weaken. Each includes its purpose, enforcement point, and operational cost. A safety auditor should read this page first.

preview §16.3 checklist guard guardAction §16.4 budget evaluateLossBudget signer signInNonceLane exchange POST /exchange reconcile §18.2 · 8 triggers
17
articles in this constitution, grouped under capital, truth, control, and isolation
1
code path that spends capital, of the whole server apps/server/src/trading/HyperliquidExecutionService.ts:4
8
moments that force a reconciliation pass apps/server/src/trading/HyperliquidReconciler.ts:66-76
3
attempts maximum in a bounded emergency close apps/server/src/trading/TradingEmergencyCloseService.ts:51

Trading safety outranks convenience

Every article below can make a flow fail; none of them can be weakened to make a flow pass. That is the working rule of the repository, stated in its priorities: never weaken an execution guard, a loss budget, a protection requirement, an authority check, an idempotency boundary, or a reconciliation rule to make an agent or UI flow pass AGENTS.md:11. The seventeen articles are grouped into four clauses: how capital moves, what counts as truth, who holds control, and what stays isolated.

Capital: through one door, written down first

Seven articles govern the only path that can spend. Their shared shape: the decision is persisted before it is irreversible, deduplicated locally because no one else will, and checked against risk twice.

Art. 01

One door to the exchange

Exactly one code path spends testnet capital.

Why

If two paths can sign an order, each is a place a guard can be forgotten. The file states its monopoly in its first sentence.

At

apps/server/src/trading/HyperliquidExecutionService.ts:4. Seven submit entry points share one signer resolution, one nonce lane, and one response inspector.

Cost

The file is 1,224 lines, and every new order shape must fold into it rather than fork it.

Art. 02

The record is written before the signature

The execution record and its risk reservation are persisted before anything is signed, and a retry is refused unless the record still sits in a pre-submission status.

Why

A crash between signing and the response must leave a durable fact behind, and an unresolved submission is exactly the case that duplicates a position if retried blind. The code says so at apps/server/src/trading/HyperliquidExecutionService.ts:296-299.

At

Persist at apps/server/src/trading/HyperliquidExecutionService.ts:668-784, signature at apps/server/src/trading/HyperliquidExecutionService.ts:798; allowed retry statuses previewed, reserved, signed at apps/server/src/trading/HyperliquidExecutionService.ts:301-305, refusal at apps/server/src/trading/HyperliquidExecutionService.ts:770-772.

Cost

A record stuck in submitted is never resubmitted; it waits for reconciliation to settle it into accepted, filled, or failed.

Art. 03

Idempotency is local, because the exchange will not provide it

T3 deduplicates its own submissions; Hyperliquid does not deduplicate marketable IOCs.

Why

Cloid uniqueness is enforced only among resting orders, and a marketable IOC never rests, so a resubmitted IOC opens a second order and fills again. The module doc records that this was verified live, in packages/hyperliquid/src/executionLive.test.ts.

At

apps/server/src/trading/HyperliquidExecutionService.ts:17-23.

Cost

The idempotency record is load-bearing safety state: it is persisted, read back, and trusted over any caller's claim to be a retry.

Art. 04

The checklist runs twice

The §16.3 position-increase checklist runs at preview, and the mandatory-stop gate runs again against the price actually going on the wire.

Why

For a marketable IOC the wire price is the BBO-derived limit, not the requested one. An increase whose stop stopped making sense between preview and mapping must be refused before a nonce is spent.

At

The item list at apps/server/src/trading/TradingPreviewService.ts:40-70, fourteen increase rows plus two exit-only; the second evaluation at apps/server/src/trading/HyperliquidExecutionService.ts:605-624.

Cost

Work is done and then refused. Late refusals are the price of never signing a nonsensical stop.

Art. 05

No acknowledged exposure without confirmed protection

No acknowledged position increase may remain without a confirmed exchange-native reduce-only stop beyond the bounded reconciliation window.

Why

A grouped submission proves nothing about whether the stop child is live, so coverage is read back from the exchange's open orders every time. The exchange-authoritative set includes orders T3 never chose, because a linked TP/SL child still protects apps/server/src/trading/HyperliquidReconciler.ts:108-113.

At

The invariant at apps/server/src/trading/TradingProtectionService.ts:4-9; coverage confirmed from exchange-authoritative orders at apps/server/src/trading/HyperliquidReconciler.ts:1129-1145; a window that closes uncovered escalates to the §17.5 emergency close at apps/server/src/trading/TradingMissionReactor.ts:2542.

Cost

Overlapping reduce-only stops are tolerated as harmless clutter, the escalation path will realize losses to keep the law, and manual entries get a 10 second grace so an entry's own stop is not double-placed apps/server/src/trading/TradingProtectionService.ts:70.

Art. 06

Loss exhaustion blocks, and the harness cannot unblock itself

When the cumulative loss budget is exhausted, increasing orders are cancelled, new increases are refused, and only an explicit user resume clears the blocked state.

Why

The exchange must not keep filling against an exhausted budget while the mission reads blocked, and a model must not be able to vote itself back into capital. resume_blocked is one of the guard's refusal literals, apps/server/src/trading/TradingExecutionGuard.ts:43-49.

At

apps/server/src/trading/TradingExecutionGuard.ts:1-14; the blocking pass also cancels resting increasing orders and reports unconfirmed cancels visibly, apps/server/src/trading/TradingExecutionGuard.ts:96-105.

Cost

Mission productivity stops until a human acts. That is the intended shape, not a bug to route around.

Art. 07

Profits can never expand the loss budget

The risk policy type forbids profit-funded budget expansion outright.

Why

Realized losses clamp toward zero, but realized profits never raise the ceiling, so a winning streak cannot buy risk headroom the user never granted.

At

positivePnlExpandsLossBudget: Schema.Literal(false) at packages/trading-contracts/src/authority.ts:41. Not a boolean: the literal false, so no payload can carry true and decode.

Cost

Raising a ceiling requires an explicit new authority grant, never a good day on the market.

Truth: the exchange is the only record

Three articles decide what counts as a fact. The exchange is authoritative for positions, orders, and fills. This section calls that exchange state canonical. Local data is corrected to match it, and unconfirmed outcomes remain unknown.

Art. 08

The exchange is the only truth

Local tables are overwritten with canonical exchange state; local state never outranks Hyperliquid.

Why

Positions, fills, and open orders belong to the exchange. A local belief that disagrees is drift to be repaired, not a rival account of what happened.

At

apps/server/src/trading/HyperliquidReconciler.ts:1117-1152; the eight triggers that force a pass, from server startup to a periodic backstop while a position is open, at apps/server/src/trading/HyperliquidReconciler.ts:66-76.

Cost

Every downstream read model must tolerate being corrected underneath it, and reconciliation runs even when nothing appears to have changed.

Art. 09

An unconfirmed outcome is never a success

A cancellation succeeds only on an explicit exchange acknowledgement; a submitted but unconfirmed close is reported as unknown.

Why

RC02 found a transport-level success being narrated as a cancelled order while the exchange rejected the action. The fix reads only an explicit per-order success, because proving absence is a canonical order lookup, not text matching apps/server/src/trading/HyperliquidExecutionService.ts:995-1007.

At

The acknowledgement gate above; the unconfirmed close line CLOSE_OUTCOME_UNKNOWN at apps/server/src/trading/TradingControlService.ts:78-84, which never reports a numeric size, and never zero, for an unconfirmed close.

Cost

The agent runtime and UI must preserve typed unknowns. RC03 threaded CancellationReport with separate acknowledged and unconfirmed lists through every caller so nothing rounds an unconfirmed cancel up.

Art. 10

Take-profit is a wake, not a resting order

Profit targets are handled by waking the bound agent runtime, not by placing server-managed resting orders; watches fire exactly once; a mission holding a position is never deaf.

Why

Two take-profit rungs once filled silently between wakes and the model misattributed the position drop. The fix retired the server-side take-profit lane and made the target a wake, per the note at apps/server/src/trading/TradingProtectionService.ts:34-40.

At

The two durable exactly-once guards, an atomic flip of an active watch plus a per-watch inbox dedup key, at apps/server/src/trading/WatchEvaluator.ts:16-23; the coverage floor at packages/trading-contracts/src/watch.ts:1288.

Cost

Exits depend on the wake path being alive. The floor buys that: while holding a position, a mission is reassessed at least every 3 bars, clamped between 2 and 15 minutes.

Control: the human outranks the model

Two articles place the operator above the machinery. The buttons that reduce risk answer with no model running at all, and the knobs that tune authority can only make it smaller in kind, never broader.

Art. 11

The user's buttons answer while the model is dead

None of the seven deterministic controls may require a harness turn, the subsystem's name for a bound agent-runtime turn. The controls work while the provider process is stopped, the session is unreachable, or the model is generating a response.

Why

A user who wants out of a position must be able to get out. The harness checklist needs a current strategy version, a matching authority version, and a decision lease: three things absent precisely when these buttons matter most.

At

apps/server/src/trading/TradingControlService.ts:1-31. The controls are pause, resume, cancelEntries, reducePosition at 25/50/75/100 percent, closePosition, revoke, and closeAndRevoke. The bypass is preview-only: every control still goes through the signer, the nonce lane, canonical reconciliation, and protection reconciliation, apps/server/src/trading/TradingControlService.ts:19-24.

Cost

The buttons are deliberately dumb. Fixed reduction steps, reduce-only physics, and honest unknowns instead of clever exits.

Isolation: nothing runs twice, nothing leaks

Five articles keep writers single and boundaries hard. One mission owns a market, one run owns a decision, one process owns the database, one lane owns every signature, and trading code never becomes a second provider runtime.

Art. 13

One mission, one market, one owner

One active mission per installation, and a market belongs either to a mission or to the user's manual trading, never both.

Why

Two authorities writing the same position cannot be reconciled by reading. D4 makes exclusivity a precondition instead of a dispute.

At

The one-active-mission invariant in apps/server/src/trading/TradingMissionService.ts:124-132; the manual side refuses the ticket with market_owned_by_mission, named so the refusal reads as the rule it is, apps/server/src/trading/TradingManualEntryService.ts:13-16.

Cost

You cannot trade around your own agent in the same market, and the ticket tells you exactly which rule stopped you.

Art. 14

One decision lease, one writer

One run at a time may own a mission's decision lease, and one process at a time may run the trading runtime against a database.

Why

Concurrent runs would double-trade the same authority, and a second process's housekeeping once killed a live soak.

At

The unique partial index idx_trading_harness_runs_one_active_per_mission turns a concurrent insert into queued_behind_active_run, apps/server/src/trading/TradingTurnCoordinator.ts:5-11. The single-writer lock file, created with the exclusive wx flag, at apps/server/src/trading/TradingRuntimeLease.ts:12-22.

Cost

A fired watch behind an active run queues and retries. The lease itself documents one corner where two processes can both believe they hold it for at most one heartbeat interval, roughly 10 seconds, and lease-gated loops bound that to dual belief, never dual writing, apps/server/src/trading/TradingRuntimeLease.ts:51-71.

Art. 15

The signer fails closed

No armed signer means no signed action, reported as a refusal, and a key the rest of the machine can read is refused loudly.

Why

Not armed and armed-with-a-readable-key need different answers, and only one of them is fixed by a chmod apps/server/src/trading/InterimSignerConfig.ts:213-220. Under vitest the key-file source is skipped entirely, so no test can ambiently arm live execution with the developer's real key, apps/server/src/trading/InterimSignerConfig.ts:248-259.

At

resolveSigner maps an unarmed gate to the typed refusal signer_not_configured, apps/server/src/trading/HyperliquidExecutionService.ts:508-518; a loose permission mode raises insecure_key_permissions.

Cost

Research mode is a real mode, not a degraded one: charts, backtests, and validations all work, and every order is refused until a key exists.

Art. 16

Trading code never spawns a provider

The trading extension may not start provider processes or create a second provider session directory, and a static test scans for every process-spawning pattern.

Why

§6.3 keeps ProviderService the only provider runtime, so trading code can never quietly become a second one, per the test's own header, apps/server/src/trading/ProviderBoundary.test.ts:8-11.

At

The pattern list, from node:child_process imports to Command.make, at apps/server/src/trading/ProviderBoundary.test.ts:44-53; the one audited exemption is ArchiveSupervisor, which spawns the in-repo market archiver and holds no key, apps/server/src/trading/ProviderBoundary.test.ts:56-64.

Cost

Any future subprocess need must widen one audited exemption, in the open, or fail this test.

Art. 17

Signing is local, deterministic, and serialized

Every signature is taken inside one serialized nonce lane, and the testnet-ness of the signature domain is derived from the resolved endpoints.

Why

The lane keeps nonces strictly monotonic so two actions can never race for the same nonce. Deriving testnet-ness from the endpoints means the signature domain can never disagree with the exchange the action is sent to. A second copy of the signing block would be a second chance to sign outside the lane.

At

packages/hyperliquid/src/NonceCoordinator.ts:4-18; the single signing site signInNonceLane at apps/server/src/trading/HyperliquidExecutionService.ts:527-552; isTestnet derived from endpoints at apps/server/src/trading/HyperliquidExecutionService.ts:499-501.

Cost

Signature throughput is bounded by one lane. Nonces fast-forward to current Unix milliseconds, so a restart needs no persisted nonce state: real time has always moved past anything signed before it, packages/hyperliquid/src/NonceCoordinator.ts:8-12.

When protection cannot be confirmed, the system stops trying

The protection watchdog does not re-place a stop forever. When the window closes uncovered, apps/server/src/trading/TradingProtectionService.ts:30-32 reports escalate and the §17.5 bounded emergency close takes over: block increases, cancel non-reduce-only orders, read fresh canonical state, submit a reduce-only IOC, reconcile, retry with fresh reads, at most three attempts, then an honest report of what is left apps/server/src/trading/TradingEmergencyCloseService.ts:12-32, apps/server/src/trading/TradingEmergencyCloseService.ts:51. An unbounded retry loop against a market that will not take the size is a way to keep paying fees forever; three attempts and the truth is the specified behavior.

The gauntlet a request passes

Every position-increasing request clears the same layers, in the ribbon above and in code. Each layer has a named refusal, and none of them can be skipped to make a flow pass.

Layer 1 · preview

§16.3 checklist

Fourteen increase rows plus two exit-only items, each with its own rejection reason and a test pinning it. Refuses as TradingPreviewRejection naming the first failing item. apps/server/src/trading/TradingPreviewService.ts:40-70

Layer 2 · guard

guardAction, §16.4

The exhaustion gate, run before any nonce is spent. Refuses as budget_exhausted or action_not_permitted_under_exhaustion; cancel, reduce, close, and modify_stop stay permitted because §16.4 blocks taking on risk, not managing risk already open. apps/server/src/trading/TradingExecutionGuard.ts:43-49, packages/trading-contracts/src/lossAccounting.ts:145-153

Layer 3 · budget

evaluateLossBudget, §16.2

The pure decider over reconciled inputs computes what remains, and the reservation against that budget is persisted with the record before signing. packages/trading-contracts/src/lossAccounting.ts:106, apps/server/src/trading/HyperliquidExecutionService.ts:774-784

Layer 4 · signer

Fail-closed resolution

Refuses as signer_not_configured when no key is armed, and as insecure_key_permissions when the key file is readable by group or other. One signature site, inside the serialized nonce lane. apps/server/src/trading/HyperliquidExecutionService.ts:508-518, apps/server/src/trading/HyperliquidExecutionService.ts:527-552

Layer 5 · exchange

POST /exchange, then inspect everything

Every per-order status in the response is inspected, and a grouped response is attributed to legs positionally. A cancel is confirmed only by an explicit per-order success. apps/server/src/trading/HyperliquidExecutionService.ts:14, apps/server/src/trading/HyperliquidExecutionService.ts:995-1007

Layer 6 · reconcile

§18.2 convergence

Eight triggers force a pass that reads canonical account, orders, and fills, and overwrites local tables with what the exchange says. apps/server/src/trading/HyperliquidReconciler.ts:66-76, apps/server/src/trading/HyperliquidReconciler.ts:1117-1152

Where each law is told in full

Each article here is enforced inside a subsystem with its own page in the atlas. The reader-facing titles below carry the detail this constitution cites.

The submit sequence step by step, on the one path that spends capital.

The loss budget equations and the checklist that guards every increase.

The eight triggers, the drift classes, and the repair loop that keeps the exchange canonical.

Key resolution and permissions, the nonce lane, and the authority ceilings.

Mission statuses, per-market exclusivity, and the wake loop that keeps a holder never deaf.

The typed spine every control, event, and refusal literal rides.

The incidents that wrote these laws, RC02 through RC06, told as postmortems.

Go deeper

The spending path

  • apps/server/src/trading/HyperliquidExecutionService.ts:4 the one path that spends
  • apps/server/src/trading/HyperliquidExecutionService.ts:605-624 second stop gate, wire price
  • apps/server/src/trading/HyperliquidExecutionService.ts:668-784 persist before signing
  • apps/server/src/trading/HyperliquidExecutionService.ts:770-772 retry refusal
  • apps/server/src/trading/HyperliquidExecutionService.ts:995-1007 cancel acknowledgement

Protection and exhaustion

  • apps/server/src/trading/TradingProtectionService.ts:4-9 the stop invariant
  • apps/server/src/trading/TradingEmergencyCloseService.ts:12-32 the bounded close order
  • apps/server/src/trading/TradingExecutionGuard.ts:1-14 §16.4 enforcement
  • packages/trading-contracts/src/authority.ts:41 Literal(false)
  • apps/server/src/trading/TestnetAuthority.ts:13-23 the four knobs

Truth and receipts

  • apps/server/src/trading/HyperliquidReconciler.ts:66-76 the eight triggers
  • apps/server/src/trading/HyperliquidReconciler.ts:1117-1152 local never outranks
  • apps/server/src/trading/TradingControlService.ts:78-84 the unknown-close line
  • apps/server/src/trading/WatchEvaluator.ts:16-23 exactly-once guards
  • packages/trading-contracts/src/watch.ts:1288 the coverage floor

Isolation

  • apps/server/src/trading/TradingTurnCoordinator.ts:5-11 the decision lease
  • apps/server/src/trading/TradingRuntimeLease.ts:51-71 bounded dual belief
  • apps/server/src/trading/InterimSignerConfig.ts:248-259 vitest cannot arm the key
  • apps/server/src/trading/ProviderBoundary.test.ts:44-64 no provider spawns
  • packages/hyperliquid/src/NonceCoordinator.ts:4-18 one serialized lane