Unpublished source build: registry install examples below apply after publication. For now, build the checkout; see versions and local setup.
The safe trade lifecycle
TotalFinance gives an agent — coded, learned, or an LLM — one lifecycle through which a decision may
become an order, and no other: observe → analyze → propose → preflight → authorize → execute → reconcile. Every stage is an explicit, content-addressed artifact; every write is an explicit,
capability-gated operation; free text never executes a trade. This stage ships the paper adapter;
live execution is a later stage behind the same lifecycle (mode: 'live' is a typed refusal today).
| Verb | What it does |
|---|---|
normalizeTradePlan |
a trade intent (quantities or target weights) or a rebalance proposal → one execution plan, priced at the mark, hashed |
preflightTradePlan |
the plan against the ledger, the market, and the policy: before/after through the ledger's own fold, every check, one decision |
createAuthorizationGrant / verifyAuthorizationGrant |
human authority bound to the plan, the preflight, the snapshots, the account, a variance envelope, an expiry, a key set |
createPaperBroker |
the first execution adapter: submit, step (fills through the engines' own fill path), cancel, deliver, halt |
applyJournalEvents / journalOrderStates |
the execution journal folded to per-order states with the transition table enforced |
reconcileExecution |
the journal joined to the ledger's fills and, with an external snapshot, reconcilePortfolio verbatim |
tradePack |
the six operations — preflight, authorize, submit, cancel, reconcile, record_events — on every transport |
The laws the lifecycle keeps
- Preflight never upgrades missing information. Every check has a verdict; an unverifiable
check is
require-approval(ordenyunder the stricter policy); a failed check isdeny; an allow comes only from evidence, never from silence. The before and after states are the ledger's own fold of the hypothetical fills and the monitor's judgment of the result. - A grant binds what preflight saw. The plan hash, the preflight hash, the portfolio and
market hashes, the market instant and an age allowance, the mode, the account, a variance
envelope, an expiry, the approver, and a closed set of idempotency keys. The broker checks those
bindings at first submission acceptance and lists every failed one; a changed plan needs new
authority. It does not re-check grant expiry on each fill or when returning an existing receipt.
Supplied
ordersmust retain every authorized order exactly once: omitting an independent hedge is material too. Quantity/notional variance is not permission to remove orders or change their execution type, time-in-force or stop. To trade a subset, preflight and authorize that plan. - A hash is integrity, not approval authority. Workflow submission requires an unchanged
approved grant in the configured trusted authorization store, whether supplied by
grantHashor inline asgrant. Only the approval path needstrade:approve;trade:papercannot mint its own authority by editing and rehashing a grant. - Idempotency belongs to a journal and its authoritative store. Retain both
sourceIdand the store on retries: the same key and plan returns the same receipt without another order; the same key with another plan istrade.idempotency_conflict. Order identities include the intent's economic-body hash within that simulation. - A grant licenses one durably prepared submission. Before committing its journal, the authorization
store atomically records the storage identity, journal, receipt, key and exact validated events;
a retry with that key into that original store/journal recovers the
same receipt, and any other use — a second licensed key, another
sourceId, another store — istrade.grant_consumed. New authority means a new grant. (Pre-publish repairs B6.) - An order fills only against a market it has seen. The plan stamps
plannedTimestampMs; the broker stampssubmittedTimestampMsat submission, journals it on thesubmittedevent, and never fills an order against an observation older than that stamp (observation-before-submissioninunfilled). - A multi-leg order is one order. Intent lines that share a
comboIdare legs of a combo; the plan lists each combo with its legs in plan order and, when the intent gave one, itsnetLimitPriceper combo unit (net debit positive, net credit negative; a combo unit is the legs' quantities divided by their greatest common divisor for integer quantities, or by the smallest leg quantity otherwise). When allowed quantity scaling changes that unit size, the broker translates the limit to the submitted unit and persists it: the authorized economics remain unchanged, including after restart. Preflight judges undefined risk on the combo's after-state — a short call beside its longer-or-equal-dated long call is defined risk — the grant binds the combo ids, and the paper broker fills a combo all-or-none against one observation instant, only within the net limit (combo-leg-unfilled,combo-net-limit). A hedge is allocated once across existing and proposed positions; removing a hedge is also checked. Submit every authorized order, preserving each combo's quantity ratios even within a variance allowance. Cancelling one leg cancels all remaining legs. Labels are plan-local: two plans can both call their comboverticalwithout sharing limits or cancellation. External partial fills or a closed leg block further simulated combo fills until reconciled/cancelled. - The journal is not the ledger. The execution journal is an append-only story of each order
(
submitted,acknowledged,partially-filled,filled,cancelled, …) with its transition table enforced; the ledger'strade.fillevents are the accounting truth. A fill after a cancellation is a late fill: journaled, ledgered, and the order leftunresolveduntil reconciliation says otherwise. A duplicate delivery is recorded once. - Paper, backtest, and replay share one fill path.
fillOrderWithPolicyis the engine's fill sequence — the fill model, slippage, the half-spread, impact, commission, the ledger's fill — and the paper broker calls the same function; the difference is who supplies the observations. Slippage, spread and impact already included inpricePerUnitare not charged again.executionPriceAdjustmentreports their absolute cash-equivalent deviation for attribution;costsholds only additional cash charges. The ledger does not debit attribution fields. - Writes are capabilities.
trade.preflightandportfolio.rebalance_proposalneedtrade:propose(held by default);trade.authorizeneedstrade:approve;trade.submit,trade.cancel, andtrade.reconcileneedtrade:paper;portfolio.record_eventsneedsportfolio:write. The runtime refuses a missing capability withoperation.capability_missingbefore it parses the input. - One order vocabulary. Every order on the surface spells
sideas coreOrderSide,typeas coreOrderType(kebab-case:market,limit,stop,stop-limit,market-on-open,market-on-close) andtimeInForceas coreTimeInForce;sideOfandsignOfare the one bridge between a signed exposure and a sided order. An intent line sizes aquantityor anotionalWeight(a fraction of NAV at the mark); market data is keyed bysymbol, ledger and trade objects byinstrumentId.
A first paper trade, directly
This is the pure SDK path: the application owns approval and supplies the domain grant itself.
createAuthorizationGrant creates a representation; it does not establish trusted runtime approval.
For registry/CLI/HTTP/MCP execution, use totalfinance.trade.authorize against the same authorization
store that submission will use, as in the transport example below.
import { createMarketSnapshot } from '@insiderfinance/totalfinance/core/artifacts';
import {
applyPortfolioEvents,
createPortfolioLedger,
} from '@insiderfinance/totalfinance/portfolio';
import {
createAuthorizationGrant,
normalizeTradePlan,
preflightTradePlan,
reconcileExecution,
} from '@insiderfinance/totalfinance/portfolio/trade';
import { createPaperBroker } from '@insiderfinance/totalfinance/backtest/paper';
const NOW = Date.UTC(2026, 0, 7, 21);
const ledger = createPortfolioLedger({ portfolioId: 'primary', baseCurrency: 'USD', events });
const spots = Object.fromEntries([
['AAPL', { price: 110, currency: 'USD' }],
['MSFT', { price: 50, currency: 'USD' }],
]);
const market = createMarketSnapshot({ asOf: NOW, observations: { spots } });
// 1. propose — an intent in the frozen order grammar; the rationale is metadata
const plan = normalizeTradePlan({
intent: {
kind: 'totalfinance.trade-intent',
schemaVersion: 1,
accountId: 'main',
asOf: NOW,
orders: [{ instrumentId: 'MSFT', side: 'buy', quantity: 200, type: 'market' }],
rationale: 'rotate a fifth of AAPL into MSFT',
},
portfolio: ledger.state,
market,
asOf: NOW,
});
// 2. preflight — every check with its verdict, one decision
const preflight = preflightTradePlan({
plan,
portfolio: ledger.state,
market,
asOf: NOW,
policy: {
mode: 'paper',
limits: { maximumPositionWeight: 0.5 },
maximumOrderNotional: 50_000,
marketMaximumAgeMs: 86_400_000,
},
costs: { commissionBps: 5, slippageBps: 10 },
});
if (preflight.decision.verdict !== 'allow') throw new Error(preflight.decision.verdict);
// 3. authorize — bounded, content-addressed, expiring
const grant = createAuthorizationGrant({
plan,
preflight,
marketMaximumAgeMs: 86_400_000,
mode: 'paper',
variance: { maximumQuantityRatio: 0.1, maximumNotionalRatio: 0.1, maximumSlippageBps: 50 },
expiresAt: NOW + 86_400_000,
approvedBy: 'trey',
idempotencyKeys: { prefix: 'run', count: 3 },
now: NOW,
});
// 4. execute on paper — submit, then meet the market
const broker = createPaperBroker({
baseCurrency: 'USD',
sourceId: 'paper:main',
accountId: 'main',
});
const receipt = broker.submit({
plan,
grant,
idempotencyKey: 'run:1',
now: NOW + 1_000,
portfolioHash: preflight.portfolioHash,
marketHash: preflight.marketHash,
marketAsOf: NOW,
});
const bar = {
kind: 'bar',
bar: {
symbol: 'MSFT',
timestampMs: NOW + 60_000,
open: 50,
high: 51,
low: 49,
close: 50.5,
volume: 10_000,
},
};
const stepped = broker.step({
observations: Object.fromEntries([['MSFT', bar]]),
asOf: NOW + 60_000,
});
const after = applyPortfolioEvents({ previousState: ledger.state, events: stepped.events });
// 5. reconcile — the journal against the ledger's fills
const report = reconcileExecution({
journal: broker.journal(),
ledger: after,
sourceId: 'paper:main',
fills: stepped.fills,
asOf: NOW + 120_000,
tolerance: { quantity: 1e-9, cashAmount: 0.01 },
});
// receipt.orders → acknowledged; stepped.fills → one fill of 200 MSFT at the open; report.reconciled → true
A retry of broker.submit with run:1 returns receipt itself; run:1 with another plan is
trade.idempotency_conflict; broker.cancel, broker.deliver (a late fill, a duplicate delivery,
an asynchronous rejection), and broker.halt are the hazard surface, and every one of them leaves
the journal consistent and the unresolved state visible.
The same lifecycle on every transport
The six operations of tradePack run through the registry, the CLI, the HTTP server, and the MCP
server with one result. The runtime holds the caller's capabilities and the lifecycle's stores as
explicit parameters:
import {
createMemoryArtifactStore,
createMemoryAuthorizationStore,
createMemoryExecutionJournalStore,
createOperationRegistry,
defaultPacks,
journeyPacks,
tradePack,
} from '@insiderfinance/totalfinance/workflows';
const registry = createOperationRegistry({
packs: [...defaultPacks(), ...journeyPacks(), tradePack()],
});
const options = {
artifacts: createMemoryArtifactStore(),
stores: {
authorization: createMemoryAuthorizationStore(),
journal: createMemoryExecutionJournalStore(),
},
capabilities: [
'portfolio:read',
'analytics:run',
'trade:propose',
'trade:approve',
'trade:paper',
'portfolio:write',
],
createdTimestampMs: Date.parse('2026-09-06T12:00:00Z'),
};
const { plan, ...preflight } = registry.run({
id: 'totalfinance.trade.preflight',
input: { intent, portfolio: ledgerHandle.uri, market, asOf, policy },
...options,
}).structured;
const { grant } = registry.run({
id: 'totalfinance.trade.authorize',
input: {
plan,
preflight,
approvedBy,
expiresAt,
now,
variance,
marketMaximumAgeMs,
idempotencyKeys,
createdTimestampMs,
},
...options,
}).structured;
const submitted = registry.run({
id: 'totalfinance.trade.submit',
input: {
plan,
grantHash: grant.contentHash,
idempotencyKey,
now,
portfolioHash,
marketHash,
marketAsOf,
sourceId,
accountId,
baseCurrency,
observations,
asOf,
},
...options,
}).structured;
const recorded = registry.run({
id: 'totalfinance.portfolio.record_events',
input: { portfolio: ledgerHandle.uri, events: submitted.recovery.events, createdTimestampMs },
...options,
}).structured;
const reconciled = registry.run({
id: 'totalfinance.trade.reconcile',
input: {
journalId: 'paper:main:journal',
ledger: recorded.handle.uri,
sourceId,
fills: submitted.recovery.fills,
asOf,
tolerance,
},
...options,
}).structured;
On the CLI, select --profile full to include tradePack. First run
totalfinance run totalfinance.trade.authorize --profile full --input authorize.json --capability trade:approve --store ~/.totalfinance/store,
then use that approved grant's hash in
totalfinance run totalfinance.trade.submit --profile full --input submit.json --capability trade:paper --store ~/.totalfinance/store.
The same store directory holds the artifacts, authorizations, and journals. Without the required
capability the CLI exits with operation.capability_missing before reading the input. These local
CLI calls do not use HTTP bearer authentication.
The HTTP server additionally requires authenticationToken when write operations are enabled,
including approval even though its operation declares sideEffect: 'none'. Configure the server's
capabilities and stores explicitly; generate a token with at least 32 cryptographically random
bearer-token characters and share it privately with trusted clients. The server CLI reads
--token-file <path> or TOTALFINANCE_HTTP_TOKEN, never a secret CLI argument. Clients send
Authorization: Bearer <token> and Content-Type: application/json on JSON POSTs. A token does
not grant capabilities or replace stored approval. Job creation/cancellation also requires the
token; unauthenticated inline read-only analytics remain available. See the
HTTP guide for executable setup, Host/Origin restrictions, and status codes.
The MCP server takes capabilities, stores, and readOnly: false to list the write tools.
Tool annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint) derive from
the operation's effect fields; those annotations do not replace capability or approval checks.
Continuing a paper order and upgrading stores
A workflow call rebuilds its broker from the authoritative journal store at <sourceId>:journal.
To advance an unmatched or partially filled order, retry totalfinance.trade.submit with the same
source, account, plan, idempotency key, and stores, and provide later observations and asOf.
Changing sourceId or switching the authoritative store requires fresh approval; replaying a
consumed grant there refuses with trade.grant_consumed, even when the key is unchanged.
The workflow still checks trade:paper and matching approval in the trusted store on each submission
call. Grant expiry is checked at first acceptance, not on a same-key receipt retry or subsequent
step. Expiry does not cancel already-accepted orders: an accepted GTC order can continue filling.
Use totalfinance.trade.cancel or the SDK broker's halt to stop outstanding orders; order time-in-force
expiry rules still apply. This is not per-fill reauthorization or an account-wide revocation model.
The receipt stays the same, but the response can contain new fills,
ledger events, expiries, and the current open set. Top-level fills/events are current-call
deltas; recovery is cumulative for that receipt, as explained below. Journal restoration
preserves fill identities across calls.
Use totalfinance.trade.cancel with trade:paper and the same authoritative journal to cancel outstanding
orders. Cancellation uses the persisted order context and has no grant input; it does not require
a new grant or trade:approve. It does require the original authorization store as well as the
journal, so any durably prepared submission is recovered before cancellation allocates event IDs.
HTTP cancellation still requires the server's bearer credential.
The pure broker also supports restoring journal: broker.journal() and then calling step,
cancel, or deliver.
Recovering a lost submission response
A journal commit can succeed even when its HTTP/CLI response is lost. Retry the original submission with the same source, authoritative store, plan, and key. Capability and trusted-store approval checks still apply; grant expiry was checked at first acceptance. Receipt recovery remains possible after grant expiry. Do not treat an empty current-call delta as proof that nothing previously filled.
The same retry also recovers a failure between the authorization claim and journal commit. The authorization store retains the exact prepared event batch; every submit or cancel into the original journal recovers all its pending batches before allocating new identities. The journal appends them idempotently before reconstructing receipts. No prices, quantities or identities are recalculated. An authorization-write failure before the claim leaves no journal effects. An ambiguous persistence failure keeps authority claimed and fails closed until the original stores can recover it; never delete the claim to retry. Memory stores survive only within their process.
| Submission field | Meaning |
|---|---|
receipt |
immutable submission identity; the same receipt on retry |
fills, events |
only the new fills and ledger events produced by this call |
recovery.fills, recovery.events |
cumulative persisted fills and reproducible ledger events for this receipt's orders, including this call |
Apply recovery.events with totalfinance.portfolio.record_events against the latest ledger handle,
as in the example above. Reapplying an identical event ID and body is idempotent in the ledger,
so this recovers committed-but-lost fills without recording already-applied economics twice.
Use recovery.fills for reconciliation of those same orders. Recovery grows with later fills;
it neither re-runs the fill engine nor includes unrelated receipts from the journal. Preserve
the returned event IDs and bodies: do not generate replacement IDs or modify timestamps on retry.
Authoritative stores and legacy upgrades
Submit and cancel require stores.journal and stores.authorization; an optional inline journal is an exact snapshot
assertion, not a way to import or replace persisted history. Custom ExecutionJournalStore
implementations must provide a stable, non-empty storeId and transact: one synchronous transaction serializes restoration,
idempotency checks, identity allocation, and the appended event batch, committing before returning
the result. Use the supplied memory/file stores unless implementing that contract deliberately.
Custom authorization stores must atomically compare-and-set consume, persist its exact
journalEvents before returning, and return detached consumption snapshots. A different claim
must fail before it can commit journal effects. The file store's journals/store-id belongs to
the backup together with authorization records and journal history: do not delete, edit, or copy
it into an independent simulation. Do not run a restored backup and its original as independent
active stores. This is one-host local persistence, not distributed fencing or live-broker exactly-once delivery.
Before upgrading file stores, stop all old-version writers. Journals now use an identity-checked
envelope in journals/v2/<sha256-of-exact-id>.json; list() returns original journal IDs. A legacy
sanitized file is readable only when every embedded ID matches the exact requested journal ID.
The next successful transaction migrates it and retains the old file. Do not rename a foreign or
colliding file to force adoption. Malformed JSON, invalid event bodies, mixed identities, and
filename/identity mismatches fail closed; corruption is not treated as an empty journal and does
not trigger migration or a replacement write. A valid foreign collision can be ignored only after
its full body and identity have been validated. Incomplete legacy paper-order context refuses restoration:
recover the original order/instrument/account/execution facts, rather than inventing them or
silently discarding open orders. File transactions require a coherent local filesystem on one host,
not a distributed/shared-network lock service.
Pre-preview consumption records lacking storage identity or write-ahead events fail closed;
recover the original facts explicitly or start a separately approved simulation. An empty
replacement store never silently inherits an earlier grant.
What is deferred, and why
- Live execution (
AT8): the lifecycle is the same; the adapter is not written, and amodeof'live'istrade.live_unavailableeverywhere. - Declared execution policies on the wire: the paper broker on a transport runs the simplified policy; a declared policy (fill models, cost models) is SDK-only, as it is for the environment.
Read next: trading-environment.md for the environment the lifecycle
composes with, and the contract
trade-lifecycle-and-paper-execution.md for every
decision and its evidence.