Contracts & types

The shared language that keeps every surface honest.

T3 Trade splits its shared language into two packages. One holds the trading domain's types and the pure math that decides what is allowed. The other holds what crosses a process boundary. Every surface speaks one of them, and neither will accept a vague answer.

domain: packages/trading-contracts wire: packages/contracts schema: Effect Schema
type change trading-contracts wire union contracts / rpc.ts decider.ts pure decision TradingMissionReactor exchange writes useMissionControls apps/web desktop loads the web bundle

One contract change, four consumers. Editing a shared type in packages/contracts/src/trading.ts reaches the server decider, the trading reactor, the web client, and the desktop shell in the same build, because they all import the same schemas.

Two packages, two jobs.

packages/trading-contracts defines the trading domain. It contains types such as TradingAuthority, MarketRef, and order intents. It also contains pure policy and accounting functions that take already-reconciled, exchange-authoritative data and return a verdict or number. These functions perform no IO, read no clocks, and call no exchange APIs. The module doc is explicit: "Property tests pin every equation. The functions take already-reconciled inputs (Hyperliquid is canonical, §18.2); they never read the exchange" (packages/trading-contracts/src/lossAccounting.ts:15-18).

packages/contracts, published as @t3tools/contracts, is the wire. Commands, events, and typed WebSocket RPCs cross process boundaries through it: the server validates what clients send, clients validate what the server pushes, and both sides fail to compile rather than guess when a shape changes. A schema change here must be handled by every active producer and consumer, which is the fan-out rule this page keeps returning to.

The split is deliberate. Safety decisions live in pure functions you can property-test, not in prompt prose or client state. The wire carries only what both sides need to agree on.

two packages, mapped
packages/trading-contracts/   domain + pure policy
  entry.ts            deriveFeasibleSize
  lossAccounting.ts   evaluateLossBudget
  stopAdjustment.ts   checkStopAdjustment
  authority.ts        TradingRiskPolicy
  execution.ts        order intent, cloid type
  primitives.ts       MarketRef, venue

packages/contracts/            the wire
  orchestration.ts    commands + events
  trading.ts          trading wire family
  rpc.ts              typed WS RPCs
45event members in the OrchestrationEvent union (packages/contracts/src/orchestration.ts:1553)
6equations in the §16.2 loss budget, all pinned by property tests (packages/trading-contracts/src/lossAccounting.ts:106-138)
16bytes in a deterministic cloid: SHA-256 truncated, hex as 0x plus 32 chars (packages/trading-contracts/src/execution.ts:75-88)
94numbered migrations so far, 001 through 094_TradingControlResults.ts (apps/server/src/persistence/Migrations/)

The core policy functions.

Three pure functions enforce the main policies in trading-contracts. Each accepts data the caller has already read, so its limits can be tested without connecting to the exchange (packages/trading-contracts/src/entry.ts:116-118).

deriveFeasibleSize
packages/trading-contracts/src/entry.ts:221

Position sizing takes the minimum. The submitted size is the smallest of the size requested by the agent runtime, called the harness in code, and five named ceilings: gross_notional, leverage, planned_loss_ceiling, loss_budget, and account_margin when the account's own capacity is known. The result is truncated toward zero at the exchange's base-unit precision (entry.ts:208-211), so a size never rounds up past a ceiling.

Two validations run before the sizing math. A stop on the wrong side of entry returns stop_on_wrong_side before any arithmetic runs (entry.ts:226-241). And a size that was asked for is a ceiling, not a suggestion: "Nothing below raises it" (entry.ts:283-287). The harness's only lever for taking less risk than the mandate allows is the number it puts in the request.

evaluateLossBudget
packages/trading-contracts/src/lossAccounting.ts:106

Spec §16.2 as six equations. Realized mission result is closed PnL plus net funding minus all paid fees; realized loss used is clamped at zero, so a profitable mission lowers loss-used but never raises the ceiling; open positions and pending entries add their reserved risk; remaining is floored at zero; and exhausted flips when remaining reaches zero (lossAccounting.ts:106-138).

Under exhaustion, only cancel, reduce, close, and modify_stop pass. The comment says why: §16.4 "blocks taking on risk, not managing the risk already open" (lossAccounting.ts:145-158).

checkStopAdjustment
packages/trading-contracts/src/stopAdjustment.ts:148

Stop movement as staged refusals, in a fixed order chosen so the most fundamental objection wins: wrong_side, then risk_envelope, then breakeven_ratchet, then step_too_large, noise_floor, target_encroachment, and finally adjustment_budget (stopAdjustment.ts:148-234).

The ratchet is the memorable one. Once a stop has crossed to the winning side of entry it stays there: "A trade that can no longer lose does not get to become one that can again" (stopAdjustment.ts:179-180). Comparisons use a PRICE_EPSILON of 1e-9 so a stop moved exactly to a cap is not refused by floating-point residue (stopAdjustment.ts:236-241).

openPositionRisk
packages/trading-contracts/src/lossAccounting.ts:47

Missing values do not invent directional risk. When a stop or weighted entry is missing, the directional loss-to-stop term contributes zero rather than full notional. Booking a missing stop as a zero price would book the entire notional of a long against the budget and exhaust it instantly. Instead "a missing stop is reported honestly as 'no directional risk reserved,' never as 'maximum risk'" (lossAccounting.ts:33-59). Fees and slippage still count; only the unknown term is zero.

entry.ts:243-289 · every ceiling as a size, the smallest one wins
const caps = [
  { by: "gross_notional", size: notionalHeadroom(input.maximumGrossNotionalUsd) },
  ...(input.accountMarginCapacityUsd === undefined
    ? []
    : [{ by: "account_margin", size: notionalHeadroom(input.accountMarginCapacityUsd) }]),
  { by: "leverage", size: notionalHeadroom(input.maximumLeverage * input.allocatedCapitalUsd) },
  { by: "planned_loss_ceiling", size: input.maximumPlannedRiskPerPositionUsd / stopDistance },
  { by: "loss_budget", size: Math.max(0, input.remainingCumulativeLossUsd) / reservedRiskPerUnit },
];
const binding = caps.reduce((tightest, cap) => (cap.size < tightest.size ? cap : tightest));
// "A size that was asked for is a CEILING, not a suggestion."
const allowed = Math.min(requestedSize, binding.size);

The law the schema itself enforces

Most safety rules are a boolean someone could flip. This one is a type. positivePnlExpandsLossBudget is not declared as Schema.Boolean; it is Schema.Literal(false). The only value accepted by the current schema is false, so configuration and persisted data cannot enable profit-funded risk expansion. Enabling it would require an explicit code and schema change (packages/trading-contracts/src/authority.ts:37-43).

The accounting honors it implicitly: a positive result drives loss-used toward zero through the max(0, -result) clamp and "never raises maximumCumulativeLossUsd" (packages/trading-contracts/src/lossAccounting.ts:100-112). The defaults agree: fee rate from hyperliquid_user_fees with a 5 bps per-side fallback, 25 bps stop-slippage reserve, and positivePnlExpandsLossBudget: false (authority.ts:105-110).

authority.ts:37-43
export const TradingRiskPolicy = Schema.Struct({
  feeRateSource: Schema.Literal("hyperliquid_user_fees"),
  fallbackTakerFeeBpsPerSide: Schema.Number,
  stopSlippageReserveBps: Schema.Number,
  positivePnlExpandsLossBudget: Schema.Literal(false), // not Boolean. false is the only inhabitant.
});

What crosses the boundary.

The wire package separates what a client may dispatch from what only the server may raise. That split is visible in three places: the command unions, the event base fields, and the typed RPCs.

Command unions, split by trust

ClientOrchestrationCommand is 23 project and thread command shapes plus DispatchableTradingCommand, the trading family a client may send. InternalOrchestrationCommand is 8 server-raised shapes plus InternalTradingCommand: session writes, assistant deltas, revert completion, mission status changes. The full OrchestrationCommand union is both, but only the client union is accepted from a socket (packages/contracts/src/orchestration.ts:1089-1236).

orchestration.ts:1118-1236, elided
export const ClientOrchestrationCommand = Schema.Union([
  ProjectCreateCommand,
  ThreadCreateCommand,
  /* 21 more project and thread commands */
  DispatchableTradingCommand,
]);

const InternalOrchestrationCommand = Schema.Union([
  ThreadSessionSetCommand,
  ThreadMessageAssistantDeltaCommand,
  /* 6 more server-raised commands */
  InternalTradingCommand,
]);

export const OrchestrationCommand = Schema.Union([
  DispatchableClientOrchestrationCommand,
  InternalOrchestrationCommand,
]);

Every event carries its own trail

Each of the 45 event members spreads EventBaseFields: a global sequence, its own eventId, the aggregate it belongs to, and three correlation handles, plus metadata that can name the client origin (surface, appVersion) the engine stamps after the decider returns (orchestration.ts:1520-1551). The decider stays pure; attribution is an engine concern.

orchestration.ts:1541-1551
const EventBaseFields = {
  sequence: NonNegativeInt,
  eventId: EventId,
  aggregateKind: OrchestrationAggregateKind,
  aggregateId: Schema.Union([ProjectId, ThreadId, TradingMissionId]),
  occurredAt: IsoDateTime,
  commandId: Schema.NullOr(CommandId),
  causationEventId: Schema.NullOr(EventId),
  correlationId: Schema.NullOr(CommandId),
  metadata: OrchestrationEventMetadata,
} as const;

Typed RPCs, built on Effect

Every WebSocket call is an Rpc.make definition with a payload schema, a success schema, and an error union. Dispatching a command returns a DispatchResult whose first field is sequence: proof the command found its place in the ordered stream, and deliberately not proof of any outcome (packages/contracts/src/rpc.ts:906-913, orchestration.ts:1858-1866).

rpc.ts:906-913
export const WsOrchestrationDispatchCommandRpc = Rpc.make(
  ORCHESTRATION_WS_METHODS.dispatchCommand,
  {
    payload: ClientOrchestrationCommand,
    success: OrchestrationRpcSchemas.dispatchCommand.output, // DispatchResult { sequence, ... }
    error: Schema.Union([OrchestrationDispatchCommandError, EnvironmentAuthorizationError]),
  },
);

One member, on purpose

TradingVenue = Schema.Literals(["hyperliquid"]). A one-member enum sounds redundant until you read why it exists: every market identity already carries its venue, "so that adding a second one is a data question rather than a rewrite" (packages/trading-contracts/src/primitives.ts:73-85). The data model records the venue now, but the code does not add multi-venue abstractions before a second venue exists.

The same restraint shows in TradingMarket: it used to be the literals "ETH" | "BTC", and is now an opaque string, because Hyperliquid's MarketResolver already decides whether an asset exists (primitives.ts:87-99).

One command, every layer.

The fan-out rule says a schema change must be handled by every active producer and consumer. Here is what that means in practice: the full life of one trading.mission.risk-control press, from a workspace button to the correlated result event.

press · apps/web

The button does not trust its own dispatch

useMissionControls binds one dispatcher per control and keeps the press pending until a durable result arrives. Its module doc states the rule as RC06: "a dispatched command only proves the request was accepted" (apps/web/src/components/trading/useMissionControls.ts:14, the hook at :126, the risk dispatcher at :131). §14.7 defines these controls so a workspace button invokes them directly, with no harness turn and no dependency on the bound provider being online (packages/contracts/src/trading.ts:1226-1235).

mint · client-runtime

A typed command is born

tradingRiskControl mints a fresh commandId and createdAt, then dispatches the trading.mission.risk-control member with the mission, thread, control, and an optional reductionPercent for reduce_position (packages/client-runtime/src/operations/commands.ts:449-462). The command struct itself is a schema (packages/contracts/src/trading.ts:1236-1245).

wire · packages/contracts

It crosses as a union member, and comes back as a number

The command travels inside ClientOrchestrationCommand through WsOrchestrationDispatchCommandRpc. The success reply is a DispatchResult led by sequence (packages/contracts/src/rpc.ts:906-913). At this point the client knows only that the request landed in the ordered stream.

decide · apps/server

The decider asks, and refuses to answer

The pure decider maps the command to a trading.mission-risk-control-requested event. It deliberately resolves no target status here, because "what the mission becomes depends on what the exchange does, so the reactor decides it after the fact" (apps/server/src/orchestration/decider.ts:1475-1499). The event is persisted before any side effect runs.

act · apps/server

The reactor does the exchange work, then reports

TradingMissionReactor answers the question. Its module doc: the decider's *-requested event "is a question, not an answer", and the ordering is the whole point, because "The UI never sees a status the domain refused" (apps/server/src/trading/TradingMissionReactor.ts:4-10). When the exchange work completes, fails, or cannot be confirmed, it raises the internal trading.mission.control-result command.

correlate · every client

The result names the press that caused it

The control-result payload carries requestEventSequence: the sequence of the requested event the reactor answered, "so a client can correlate the result to the press that caused it and ignore results belonging to another request" (packages/contracts/src/trading.ts:1247-1257, payload at :1628-1645). Status is one of completed, failed, or unknown, because an exchange that timed out is an honest state, not a guess.

same code · apps/desktop

Desktop inherits the whole path

The desktop shell loads the web app in its window (apps/desktop/src/window/DesktopWindow.ts:623) and builds on the same @t3tools/client-runtime. There is no second trading producer to update, which is exactly why the fan-out rule matters: one contract edit reaches every file on this timeline in one rebuild, and misses none of them.

Idempotency in three layers.

Retries are a fact of network life. The contracts make each retry land somewhere safe: at the command layer, at the exchange layer, and at the wake layer.

sticky command receipts
apps/server/src/orchestration/Layers/OrchestrationEngine.ts:149-177

Replaying an accepted commandId returns the original result sequence, not a second execution. A rejected one fails forever after with OrchestrationCommandPreviouslyRejectedError: a rejection is sticky. And a receipt aimed at a different aggregate raises OrchestrationCommandIdConflictError, because "A receipt only proves this exact command was handled" for that aggregate. Replaying it elsewhere would report success for work that never happened.

deterministic cloids
packages/trading-contracts/src/execution.ts:75-88

Every execution derives its client order id from SHA-256 over missionId, executionSequence, and actionType, truncated to 16 bytes and hex-encoded with the 0x prefix the exchange validates. Retries of the same action reuse every input, so they reuse the cloid.

One nuance from the implementation: "A cloid is a correlation id, not an idempotency key." Retry safety belongs to the executor, which refuses to resubmit a record that already reached the exchange; the stable cloid is what lets a fill be joined back to the execution that caused it (packages/hyperliquid/src/Cloid.ts:4-14). The prefix is load-bearing: an unprefixed cloid was once silently stored as null by the exchange, leaving a fill unjoinable to its own entry (Cloid.ts:20-23).

inbox deduplication
apps/server/src/persistence/Migrations/035_TradingDomain.ts:154-159

Watch fires and other wake sources land in a table with a unique index on (mission_id, deduplication_key), so "reconnects and replays drop duplicates before they generate a second wake-up". A watch fires exactly once even when the market feed reconnects mid-evaluation.

execution.ts:87 · 035_TradingDomain.ts:156-159
export const TradingCloid = Schema.String.check(Schema.isPattern(/^0x[0-9a-f]{32}$/));

CREATE UNIQUE INDEX IF NOT EXISTS idx_trading_event_inbox_dedupe
ON trading_event_inbox (mission_id, deduplication_key)

Growing the language without breaking it.

A shared schema is a promise to old data. Four habits keep that promise while the system keeps changing.

habit 1

Additive optionality

New fields arrive as Schema.optional. The maxWakes budget on an authority version is the pattern in one sentence: absent means unlimited, and "every mission created before this field existed keeps its old behavior" (packages/trading-contracts/src/authority.ts:85-94). Old rows decode; new rows carry more.

habit 2

Numbered migrations

Every durable shape change is a numbered file, 94 so far, from 001_OrchestrationEvents.ts through 094_TradingControlResults.ts (apps/server/src/persistence/Migrations/). The sequence is the audit trail: when a behavior changed and which tables it touched is answerable from the directory listing alone.

habit 3

Deprecated events stay decodable

A retired event is not deleted from the union. It stays so event stores written by older builds still decode, and the projector simply ignores it (packages/contracts/src/orchestration.ts:1634-1640). Removing it would make the event store unreadable at the exact row that predates the removal.

habit 4

Tolerance readers for old payloads

Persisted JSON that predates a schema axis is read through a reader that knows both shapes. decodeMarketRef still accepts a bare "ETH" from rows written before venues existed, reading it as the default venue, while new payloads carry the full pair (packages/trading-contracts/src/primitives.ts:125-142).

orchestration.ts:1634-1640 · primitives.ts:131-132
// Deprecated: no emitter remains. Kept only so event stores written by
// older builds still decode; the projector ignores it.
Schema.Struct({
  ...EventBaseFields,
  type: Schema.Literal("thread.workspace-mode-set"),
  ...
}),

export const decodeMarketRef = (value: unknown): MarketRef | null => {
  // a bare "ETH" means Hyperliquid, because that is the only venue
  // there has ever been. New payloads carry the pair and decode unchanged.
  if (typeof value === "string" && value.trim().length > 0) return marketRef(value.trim());

Accounting the contracts refuse to get wrong.

Fees are never counted twice. Paid entry fees already live inside the realized mission result, so they are not also reserved as unpaid open-position fees; a queued entry reserves both estimated entry and exit fees because neither has been paid yet, and a filled position reserves only what remains unpaid (packages/trading-contracts/src/lossAccounting.ts:6-14).

closedPnl is always the exchange's attribution, never a number T3 computes. The per-fill field is documented as the "Realised PnL the exchange attributed to this fill (§16.2 closedPnl)" (packages/contracts/src/trading.ts:133). Local code reconciles toward that truth; it does not rival it. How that convergence runs is the Reconciliation story.

And the rounding policy errs toward exactness: precision is applied by field name, at significant figures, so "A field that is forgotten stays exact, which is the safe direction to be wrong in" (packages/trading-contracts/src/precision.ts:1-56).

lossAccounting.ts:106-122 · the first two equations
export function evaluateLossBudget(input: LossBudgetInput): TradingLossBudget {
  // Eq 1: realised mission result = closedPnl + netFunding - allPaidFees.
  const realizedMissionResultUsd =
    input.closedPnlUsd + input.netFundingUsd - input.allPaidTradingFeesUsd;

  // Eq 2: realised loss used = max(0, -result). Profits clamp this to zero.
  const realizedLossUsedUsd = max0(-realizedMissionResultUsd);

  ...

  // Eq 6: remaining = max(0, ceiling - used). Never negative.
  const remainingCumulativeLossUsd =
    max0(input.maximumCumulativeLossUsd - lossBudgetUsedUsd);

Go deeper.

Every file below was read for this page. Start with the two that carry the most policy per line.

Pure deciders

  • Sizing and the five ceilings: packages/trading-contracts/src/entry.ts:116-337
  • The six budget equations: packages/trading-contracts/src/lossAccounting.ts:1-158
  • Staged stop refusals: packages/trading-contracts/src/stopAdjustment.ts:140-241
  • Mandate ceilings and defaults: packages/trading-contracts/src/authority.ts:37-189
  • Versioned policy, V3 active: packages/trading-contracts/src/policy.ts:272-289

The wire

  • Command and event unions: packages/contracts/src/orchestration.ts:1089-1782
  • Trading wire family: packages/contracts/src/trading.ts:1188-1318
  • Typed WS RPCs: packages/contracts/src/rpc.ts:906-913
  • Cloid type and contract: packages/trading-contracts/src/execution.ts:49-88
  • Cloid derivation: packages/hyperliquid/src/Cloid.ts:1-60

Fan-out touchpoints

  • Client command builder: packages/client-runtime/src/operations/commands.ts:449-462
  • Web press handling: apps/web/src/components/trading/useMissionControls.ts:14-131
  • Server decider case: apps/server/src/orchestration/decider.ts:1475-1499
  • Reactor answering: apps/server/src/trading/TradingMissionReactor.ts:1-18
  • Desktop loading the web bundle: apps/desktop/src/window/DesktopWindow.ts:623

Neighbors in the atlas: the Event spine page shows the engine that orders these events; Execution follows an intent to a signed order; Risk control walks the budget gates these equations feed; Signer & authority covers who may sign what the contracts permit; Provider adapters translate agent protocols at the boundary; and Safety invariants collects the laws, including the literal one.