The backend is the API, indexer and LLM pipeline around the OracleDesk Soroban contracts. The contracts decide everything that matters for money: prices, positions, caps, resolution. The backend never overrides them.
Express 5 with helmet, hpp, an env CORS allowlist, rate limits (global, /auth/*, /markets/generate), JSON bodies capped at 1 MB, and pino logging with secrets redacted. BigInts serialise as decimal strings through the app's JSON replacer. Every response uses the { ok, data, error, meta } envelope. The full contract with the frontend is api.md.
src/lib/context.ts keeps an AsyncLocalStorage context with a requestId and, where relevant, a cycleId or jobId. The first Express middleware assigns it: a well-formed inbound X-Request-Id ([A-Za-z0-9._:-]{8,64}, only when TRUST_INBOUND_REQUEST_ID=true) is kept, anything else is replaced and never logged. The id is echoed in the X-Request-Id response header and in error.requestId. Each cron tick and indexer poll runs in its own context with a fresh cycleId, and POST /markets/generate runs the cycle with a jobId plus the originating requestId. A pino mixin adds these ids to every log line, and writeAgentLog() (src/lib/agent-log.ts, the only writer of AgentLog rows) stores them in data.correlation. To follow one request or cycle, search the logs for requestId=<id> (or the JSON field).
auth.service.ts builds a challenge transaction with WebAuth.buildChallengeTx, signed by AUTH_SIGNING_SECRET (a key that holds nothing and is not the agent key). It stores the transaction hash in Redis for 5 minutes. /auth/verify reads the challenge, enforces its time bounds without the SDK's grace period, consumes it atomically (GETDEL), checks the user's signature with verifyChallengeTxSigners, and issues a JWT. Admin rights are an env allowlist of G-addresses checked server-side.
Three hardening layers sit around this (#48, #50). Every stored challenge names its issuing key; verification looks that key up in the current AUTH_SIGNING_SECRET plus AUTH_PREVIOUS_SIGNING_KEYS, so a signing key can rotate with an overlap and a retired key stops being accepted (an unknown or retired issuer is INVALID_CHALLENGE, and the record is still consumed). Issuance is bounded per address and per hashed client IP by a single Lua script (redis-scripts.ts) that prunes expired entries and evicts the oldest, and above AUTH_POW_THRESHOLD_PER_MIN per minute clients must present a hashcash-style proof of work over a single-use salt. Keys live in one Redis Cluster slot under the auth:{auth}: hash tag — challenge:<hash>, outstanding:addr:<address>, outstanding:ip:<sha256>, issued:<minute>, pow:<salt> — and every one carries a TTL. All of this is fail-closed: with Redis down, checkRedisPolicy() at boot logs the degraded state and challenge issuance/verification returns 503 AUTH_UNAVAILABLE rather than accepting an unverifiable challenge.
sep45.service.ts serves C… (smart-wallet) accounts. GET /auth/contract/challenge builds two web_auth_verify authorization entries with a shared argument map and a random single-use nonce, signs the server entry with authorizeEntry, stores the nonce, and returns the pair as base64 XDR. POST /auth/contract/verify re-decodes the pair, checks the arguments and the server's ed25519 signature, spends the nonce with GETDEL, checks expirations, then simulates the invocation in enforce auth mode; only a successful simulation issues a JWT tagged accountType: "contract", which is rejected by the admin check. Disabled unless SEP45_ENABLED=true and WEB_AUTH_CONTRACT_ID is set.
services/stellar/clients.ts builds the generated binding clients. Reads are simulations. Writes go through chain.service.ts:
- Agent writes go through the treasury (
agent_create_market,agent_buy,agent_sell) so its per-trade, per-market and daily caps apply on-chain, plusreasoning_registry.publish_trace. dry-run(default) builds and simulates, logs the result and returns it without signing.livesigns withAGENT_SECRET_KEYviabasicNodeSigner, and config validation refusesliveoff testnet.- Contract errors are named from the
Error(Contract, #N)code in the simulation text through each binding'sErrorstable.
The contracts repo's agents/stellar/adapter.ts covers similar ground, but its dry-run doesn't simulate and it lacks agent_sell, so this service mirrors its method names instead of depending on it. The intended long-term split is that the agents package owns signing and this repo stays the API, indexer and LLM pipeline (see contract-requests.md).
Every Stellar transaction consumes one sequence number of its source account. Several paths write with the same agent account (the market-maker cron, the trader cron's fire-and-forget trace publication, and the admin POST /markets/generate), and every backend instance runs its own crons. Two builds that read the same RPC account produce two transactions with the same sequence and only one can land.
services/stellar/sequence.ts serialises live writes per source account:
withSourceAccount(account, fn)takes a fair lock: waiters take a monotonic ticket fromlock:src:<net>:<account>:fenceand only the head of the<lock>:queuesorted set may take the lock, so one busy cron cannot starve an admin call. The lock itself is a singleSET NX PX(SEQ_LOCK_TTL_MS, renewed by a heartbeat everySEQ_LOCK_HEARTBEAT_MS), released by a compare-and-delete Lua script, so a crashed holder cannot block the queue for longer than its TTL. A waiter that exceedsSEQ_LOCK_WAIT_MSfails with a retryableSEQUENCE_LOCK_TIMEOUTinstead of hanging.- The fencing token (the ticket) is written next to the cached sequence (
seq:<net>:<account>,seq:<net>:<account>:fence) by an atomic Lua script that refuses to move the sequence backwards, so a holder that lost its lock can never corrupt the counter. BeforesendTransaction, the caller callsctx.assertHeld(); a stale holder aborts without submitting. - The cached sequence is initialised from
rpc.getAccountat startup and after atxBadSeqrejection, and advanced by exactly one only when the network accepted the envelope. A rejected submission never advances it. A timed-out submission re-syncs before the next build. - Dry-run takes no lock and never touches the sequence cache. Simulation does not consume a sequence. Redis being unavailable makes a live write fail closed (
SEQUENCE_LOCK_UNAVAILABLE), never submit unlocked. - The binding clients build through
rpc.getAccountthemselves, so the allocated sequence cannot be handed to them directly. After simulation,rebuildWithSequencerewrites the built transaction withTransactionBuilder.cloneFrom(replacing the source account and re-applying the Soroban data), which is a clean seam that does not editsrc/generated. Auth entries are unaffected because the agent uses source-account credentials.
The lock covers one attempt, from sequence allocation until sendTransaction returns; confirmation polling happens after release, which is safe because the sequence is already reserved.
A live write failure is classified from the actual Stellar result codes (services/stellar/failures.ts) into one of TRANSIENT_RPC, BAD_SEQUENCE, INSUFFICIENT_FEE, EXPIRED, RESOURCE_OR_STATE, CONTRACT_ERROR, AUTH, INSUFFICIENT_BALANCE, UNKNOWN_OUTCOME or UNKNOWN. Only the first five are retried, and only when the envelope is provably not applied:
TRANSIENT_RPC(e.g.TRY_AGAIN_LATER, a connection error before send) rebuilds.BAD_SEQUENCEre-syncs the cached sequence and rebuilds.INSUFFICIENT_FEE,EXPIREDandRESOURCE_OR_STATErebuild, because the previous envelope was rejected, not applied.CONTRACT_ERROR,AUTH,INSUFFICIENT_BALANCEandUNKNOWNare permanent and never retried.UNKNOWN_OUTCOME(a send timeout, aDUPLICATE, aNOT_FOUNDpoll) is ambiguous: the transaction may have been applied. It is never re-submitted with a rebuilt transaction; the cached sequence is re-synced and the failure surfaces as aTransactionErrorwithsubmitted: true.
Backoff is jittered exponential, min(CHAIN_RETRY_MAX_MS, CHAIN_RETRY_BASE_MS × 2^attempt) × U(0.5, 1), capped by CHAIN_RETRY_MAX_ATTEMPTS attempts and CHAIN_RETRY_DEADLINE_MS total. Trade execution re-quotes min_shares_out on each rebuild so a retry never reuses a bound a moved price has invalidated, and it marks a PENDING trade FAILED only for a permanent classification — transient or ambiguous failures stay retryable. The market-maker's on-chain creation likewise leaves a market PENDING rather than failed.
A live chain write is not atomic with anything the backend does around it: the process can die between signing and sending, or after the network accepted the envelope, and the caller would then record the wrong outcome (a market stuck PENDING after the treasury spent the seed, a FAILED trade whose shares the treasury holds). Every live write therefore persists a chain_transactions row and moves it through an explicit state machine with compare-and-set transitions:
BUILT ──→ SIGNED ──→ SUBMITTED ──→ PENDING ──→ SUCCESS
│ │ │ │ └──→ FAILED
│ │ │ └──────→ EXPIRED
└──────────┴───────────┴── (crash) ──→ resolved by the recovery worker
BUILT: built and simulated;kind,refId,argsand the simulated value are durable.SIGNED: the signed envelope, hash, sequence and timebounds are in the database beforesendTransaction. A write whose built transaction has nomaxTimeis refused: past the RPC retention window (~7 days on testnet), timebounds are the only way left to resolve a row.SUBMITTED: the network has the envelope (PENDINGandDUPLICATEsend results both count;ERRORis terminalFAILED, e.g.txBadSeqcan never land;TRY_AGAIN_LATERthrows retryable without consuming the row's hash).PENDING: the inline wait (OUTBOX_INLINE_WAIT_MS) expired; the write returned aCHAIN_PENDINGerror carrying the outbox id and hash, the caller recorded an intermediate state (the Trade staysPENDING, neverFAILED), and the recovery worker owns resolution.- Terminal states record
landedHash,ledgerand the decodedreturnValue.
The recovery worker (startOutboxWorker) runs one pass at boot — before the indexer and crons issue new writes — then every OUTBOX_POLL_MS, under a best-effort Redis lock (outbox:recover) so one instance recovers at a time (transitions are compare-and-set, so a second recoverer cannot double-apply either way). A pass resolves every non-terminal row by polling getTransaction over all known hashes: landed rows transition and run their completion handler; NOT_FOUND past maxTime becomes EXPIRED; a SIGNED row older than 30 s (crash between sign and send) is resubmitted byte-for-byte from the stored envelope — safe because the hash and sequence are fixed, so at most one copy can execute; a BUILT row older than the grace was never signed and fails. Terminal rows whose handler never ran (crash in between) are swept too. The indexer short-circuits all of this: a stored event whose txHash matches a non-terminal row triggers recovery immediately.
Completion handlers, registered per kind in the owning service, are the single writers of write success state and must be idempotent: market.create links the landed market id (linkLandedMarket, never overwriting a different existing link), trade.buy marks the Trade EXECUTED and creates the Position (guarded on PENDING; Position.tradeId is unique), trade.sell applies the exit only while it is still SUBMITTED, and trace.publish links onChainTraceId when still null. The inline path runs the SUCCESS handler before write() resolves, so callers read their success state back instead of writing it; failures thrown to a live caller are marked handled (the caller's error path — e.g. #40 classification or the #42 expiry rebuild — stands in for the handler), so worker-driven failure handlers only fire for rows no caller was around to see. Attempt retries under #40 build a fresh envelope and a fresh row; the superseded row is terminal FAILED, so recovery can never resubmit an envelope the retry replaced.
A transaction whose inclusion fee is below the surge price is not rebuilt: a rebuilt transaction is a new hash with a new sequence that can execute alongside the original, which would double-execute a treasury trade. Instead the stored inner envelope is wrapped unchanged in a FeeBumpTransaction (services/stellar/fee-bump.ts) paid by FEE_PAYER_SECRET, so at most one envelope can ever land.
The outer fee is resourceFee + inclusionPerOp × (operations + 1) — the +1 accounts for the fee-bump operation — where inclusionPerOp = max(prev × FEE_BUMP_MULTIPLIER, p90 surge price, innerInclusion + 1) and resourceFee is read from the inner sorobanData.resourceFee(). It is refused with FEE_BUMP_CAP_EXCEEDED above FEE_BUMP_MAX_STROOPS. The SDK builder only computes baseFee × (operations + 1), so the total is set explicitly after asserting the builder's base-fee semantics, then the outer transaction is signed by the fee payer (the inner signatures are never touched).
Both hashes are tracked: the inner hash is the outbox identity and dedup key, and every build appends its outer hash to feeBumpHashes before submission. Recovery (services/stellar/outbox.ts) polls [hash, ...feeBumpHashes]; whichever lands first resolves the row and sets landedHash. If the inner lands first, an outstanding bump is automatically invalid (txFeeBumpInnerFailed/txBadSeq) and its error is ignored after a re-poll. Domain rows store the landed hash in txHash/creationTxHash/publishTxHash and the inner hash in the matching *InnerTxHash column, and the indexer resolves an event's outer hash back to the inner hash through the outbox. Rows are only bumped while pending past FEE_BUMP_AFTER_LEDGERS, under FEE_BUMP_MAX_ATTEMPTS, and never within one ledger of the inner maxTime (a bump cannot extend timebounds). A fee bump cannot change resourceFee, so a resource/state failure stops bumping rather than escalating.
Stellar Soroban fees follow a two-part model:
- Inclusion fee: Classic Stellar transaction bid that prioritises the transaction in the ledger queue. The fee oracle (
src/services/stellar/fees.ts) queries RPCgetFeeStatswith single-flight caching, selects the configured percentile for the operation class (market_create,trade,trace_publish,maintenance), and clamps it between the classfloorandcap. If stats are missing, stale, or RPC fails, it falls back to the last good stats withinFEE_STATS_MAX_AGE_MSor to the class floor. - Resource fee: Soroban execution and storage fee computed during simulation (
tx.simulation.minResourceFee). The transaction envelope's built fee equalsinclusionFee + minResourceFee.
Operation Class Policies and Budgets:
market_create: percentilep90, floor 0.01 XLM (100,000 stroops), cap 0.5 XLM (5,000,000 stroops), resource budget 5 XLM (50,000,000 stroops).trade: percentilep80, floor 0.001 XLM (10,000 stroops), cap 0.2 XLM (2,000,000 stroops), resource budget 2.5 XLM (25,000,000 stroops).trace_publish: percentilep50, floor 0.001 XLM (10,000 stroops), cap 0.1 XLM (1,000,000 stroops), resource budget 1.5 XLM (15,000,000 stroops).maintenance: percentilep50, floor 0.001 XLM (10,000 stroops), cap 0.1 XLM (1,000,000 stroops), resource budget 1.5 XLM (15,000,000 stroops).
Overrides can be provided at boot via FEE_POLICY_JSON.
Budget Enforcement and Calibration:
Before any live transaction is signed, minResourceFee is checked against the class budget. If it exceeds the budget, execution halts immediately with FEE_BUDGET_EXCEEDED (carrying simulation details and no secrets). In dry-run mode, the breach is logged as a warning and simulation continues.
To calibrate budgets for production:
- Run cycles in dry-run mode (
CHAIN_EXECUTION_MODE=dry-run). - Search application logs for
dry-run: simulated, not submittedand inspect the recordedsimulated.feeandminResourceFeeacross operation classes. - Set each class budget in
FEE_POLICY_JSONto ~1.3x–1.5x the observed peak simulation fee to accommodate ledger state growth and contract rent variations without risking unbounded agent spend. Confirmed transactions record fee accounting inchain_fees, exposed viaGET /admin/feeswith daily p50/p95 charged quantiles and oracle health snapshots.
Every transaction built by chain.service.ts carries explicit timebounds (minTime = 0, maxTime = submissionTime + timeoutInSeconds) passed through generated binding options (MethodOptions.timeoutInSeconds).
Per-class Timeouts:
market_create:TIMEOUT_MARKET_CREATE_SECONDS(default: 60 s, range: 15–300 s)trade:TIMEOUT_TRADE_SECONDS(default: 30 s, range: 10–120 s)trace_publish:TIMEOUT_TRACE_PUBLISH_SECONDS(default: 45 s, range: 10–180 s)maintenance:TIMEOUT_MAINTENANCE_SECONDS(default: 30 s, range: 10–120 s)
Auth Entry Expiration:
authExpirationLedger is derived strictly from the transaction deadline: latestLedger + ceil(timeoutInSeconds / 5) + AUTH_EXPIRY_MARGIN_LEDGERS (default margin: 12 ledgers). An auth entry never expires before its enclosing transaction, nor outlives it by more than the margin.
Explicit Polling and Expiry Determination:
Instead of relying on SDK internal polling, chain.service.ts submits the transaction via sendTransaction and polls getTransaction every 2 seconds. The pure function decideExpiry() decides the state using Stellar ledger close time from the RPC response (never the local host clock):
LANDED: RPC statusSUCCESS. Returns unwrapped execution value.FAILED: RPC statusFAILED. Throws typedChainError.EXPIRED: RPC statusNOT_FOUND, latest ledger close time is strictly pastmaxTime + skew(skew: 10 s), and the submission is within the RPC retention window. ThrowsExpiredError(codeCHAIN_TX_EXPIRED) carryingattemptIdandtxHash.UNKNOWN: RPC statusNOT_FOUNDwhere submission is older than the RPC retention window (oldestLedgerCloseTime). Non-inclusion cannot be proven. ThrowsChainErrorwith codeCHAIN_TX_UNKNOWN, preserving the transaction hash.PENDING: Transaction not yet included, but ledger time has not definitively passedmaxTime + skew. If the poll loop reaches its local cap while stillPENDINGorUNKNOWN, it reports an ambiguous timeout (CHAIN_SUBMISSION_TIMEOUT) preservingtxHash, never a failure or expiry.
All submissions are tracked in chain_submissions (tx_hash, op_class, min_time, max_time, auth_expiration_ledger, submitted_ledger, submitted_at_close_time, decision, rebuild_count, attempt_id).
Safe Rebuild Rules:
- Trades: An expired trade catches
ExpiredError. IfrebuildCount < MAX_REBUILD_COUNT, the caller re-runsquoteBuyto obtain the fresh on-chain price and verifies that the edge still exceedsMIN_EDGE_THRESHOLD(8%). If edge degraded, the trade cancels withINSUFFICIENT_EDGE. If edge still holds, it resubmits with incrementedrebuildCount, linking the old attempt. - Trace Publication: Trace publication is idempotent by
trace_hash. OnExpiredError, it links the previous attempt and marks the queue rowREADYfor immediate resubmission. - Market Creation: Rebuilds only after a definitive
EXPIREDdecision, and never afterUNKNOWN.
To allow concurrent writes by autonomous agent workers without sequence collisions, the backend maintains a pool of channel accounts (src/services/stellar/channels.ts):
- Lease Coordination: Channel accounts are leased across processes using Redis exclusive keys (
channel:lease:<public_key>,SET NX PXwith a Lua compare-and-delete release). Round-robin acquisition with jittered backoff distributes load. - Signer Composition: A leased channel serves as the transaction source account (signing the outer transaction envelope with
basicNodeSigner(channelKeypair)). Privileged contract invocations require invocation authentication signed by the agent key (AGENT_SECRET_KEY). - Simulated Auth Verification: Before signing, auth entries from simulation are decoded:
needsNonInvokerSigningBy()must return strictly the agent's public key (or empty); credentials must be address credentials for the agent; and the root invocation must match the expected method without unexpected sub-invocations. - Expiration and Re-simulation: Auth entries are signed with an expiration ledger derived from the transaction deadline (
authExpirationLedgerfrom #42). The transaction is re-simulated after signing auth entries to account for signature verification footprint before the channel signs the envelope. - Lease Retention: The channel account remains leased until the transaction leaves
PENDING(confirmingLANDED,FAILED,EXPIRED, orUNKNOWN), preventing sequence skew from lagging RPC nodes. - Balance Monitoring: A periodic background monitor inspects on-chain XLM balances via RPC, automatically removing channels below
CHANNEL_MIN_BALANCE_XLM(default: 5 XLM) from rotation and emitting warnings with public keys only. - Dry-run and Fallback: In
dry-runor when no channels are configured (CHANNEL_SECRET_KEYSempty),write()uses the agent account as source and skips leasing completely.
market-chain.service.ts turns a generated proposal into an on-chain market:
- Build a signer-mode resolution spec (
RESOLUTION_SIGNERS, default the agent). - Hash it as
sha256(XDR(ResolutionSpec))with the resolver binding's own spec (lib/resolution-spec.ts). Tests check this against the contracts repo's Rustspec-hashtool. - Store the full spec and its hash next to the market, so anyone can later reveal it to
resolver.register_signer_spec. - Pin the canonical question JSON;
question_hashis the sha256 of those exact bytes andmeta_uriisipfs://<cid>. - Call
treasury.agent_create_market. In live mode the returned id becomesonChainMarketIdand the market goesACTIVE; in dry-run it staysPENDING.
Because market creation locks treasury capital (seed_amount), duplicate creations spend capital twice. market-chain.service.ts wraps creation in an idempotent protocol:
- Question Document Hash Invariant: The question document pinned to IPFS embeds
backendMarketId: market.id. If a proposal already has a storedquestionHash, re-pinning must produce the exact same sha256 hash or creation aborts (QUESTION_HASH_MISMATCH). - Atomic Claim & Fencing: An attempt record is created in
market_creation_attemptswithINSERT ... ON CONFLICT DO NOTHINGfollowed bySELECT ... FOR UPDATE. Stale or retried attempts increment theattemptscounter with a fencing check, preventing split-brain execution across slow or resumed workers. - Discovery Before Submission: Before calling the treasury, the backend checks if the market already exists on-chain:
- Queries RPC
getTransactionif atxHashis stored on the attempt. - Searches indexed
MarketCreatedevents inchain_eventsmatchingquestion_hashwherecreatoris the treasury contract. - Executes a bounded backwards scan of on-chain markets (
marketCountdownwards up to 50 markets). Every candidate is confirmed by readingget_marketand validating thatquestion_hash,resolution_hash, andcreator === treasurymatch. If found, it links immediately without submitting.
- Queries RPC
- Pre-Submission Count: Before submitting, the current on-chain
market_countis recorded with the attempt. - Handling Ambiguous Outcomes: Any error after
sendTransactionmarks the attempt asUNKNOWN, recordstxHash, and immediately triggers discovery. A retry is permitted only whendecideExpiryreturnsEXPIRED. - Atomic Linking: Linking updates both
markets(onChainMarketId,status: ACTIVE) andmarket_creation_attempts(status: LANDED) in one transaction. A unique constraint violation ononChainMarketIdlogsCREATION_ID_CONFLICTand never overwrites existing markets. - Periodic Reconciliation: A sweep in the ingestion cron periodically inspects unresolved attempts (
UNKNOWNorEXPIRED) and links any landed markets.
Backend categories are mapped to the contract enum by toContractCategory().
A CRYPTO proposal that names a registered asset can commit to a Reflector
(SEP-40) price feed instead of a signer set. This needs no human: after close,
resolver.resolve_price reads the feed at close_time, compares it to the
threshold and finalises market-core directly.
- Feed registry: addresses are configuration, never hard-coded. They come
from
REFLECTOR_FEEDS_JSON(keyed by network passphrase → symbol → feed), validated inconfig/index.ts. Every entry carries asourcecitation. An empty registry disables price mode, so every CRYPTO market falls back to signer mode. - Structured proposals: the generation schema carries optional
price_asset/price_threshold/price_direction/price_close_timestampfields.planPriceMarket()parses them and, on any problem (parse, unregistered asset, dead feed, or more fractional digits thandecimals()), logsPRICE_SPEC_FALLBACKand returns null so creation keeps the signer spec. - Exact threshold:
toFixedPoint(human, decimals)uses string arithmetic (neverparseFloat) and rejects excess precision rather than rounding, since a wrongly-scaled threshold silently flips outcomes. - Deterministic text:
renderPriceQuestion()renders the question from the structured fields, and that rendering replaces the LLM text for price markets, so the human question and the committed spec cannot disagree. The pinned question document gains apriceResolutionblock with the raw threshold, decimals and direction. - Pre-creation checks:
checkFeed()asserts the feed supports the asset and thatlastpriceexists and is younger thanmaxStaleness— a dead feed today is dead at close. - Reveal: right after a live creation
register_price_speccommits the spec;MarketAlreadyConfiguredcounts as success. Failures are retried by the 5-minute cron, which reveals any price market the resolver still reportsUnconfigured. - Resolution: the same cron calls
resolve_pricefor closed price markets.MarketNotClosedretries (ledger time may lag).PriceUnavailableandStalePriceretry with exponential backoff up toPRICE_RESOLUTION_MAX_WAIT_SECS, then log anERRORPRICE_RESOLUTION_STUCKso a guardian can decide onguardian_void_market.AlreadyFinalizedends the loop quietly. Resolution always uses the stored spec, never the current registry, so changing feeds never affects existing markets.
validateResolutionSpec() requires maxStaleness > 0 (matching the contract's
InvalidConfig), and, only at reveal time, rejects a negative threshold; the
spec-hash fixtures still hash unchanged.
The resolver is strict about time: attest is rejected before
market.close_time, and finalize_signers only succeeds after
proposed_at + dispute_window. Before a proposal is persisted or pinned,
resolvability.service.ts (pure, no I/O) checks that its text, close time and
source fit that timeline:
event deadline ──► source publishes ──► close_time ──► attestation ──► dispute window ──► finalise
(+ publication lag) [lag, lag+6h] (≤ 48 h SLA) (RESOLUTION_DISPUTE_WINDOW_SECS)
earliestClose = deadline + lag;latestClose = earliestClose + 6 h(bounds trading after the answer is public).close + dispute window + 48 hmust be within 120 days of now.- The deadline comes from a table-driven grammar (
by,on,before,for <month>,in Q3,end of …,at its <month> meeting); "by X" is end of day X in the source's time zone (built as local 23:59:59 then converted, never+86400 s). Two distinct deadlines areAMBIGUOUS_DEADLINE. - Meetings resolve through
resolvability.calendar.ts(FOMC/ECB). Update it yearly; a test fails when the table has no future meeting. resolution_oraclemust map to an allowlisted source (RESOLUTION_SOURCES) that can resolve the category; the source carries the publication lag, time zone, URL and signer instructions.- If only the close time is off and the deadline is unambiguous, it is clamped into
[max(earliestClose, now + 1 day), latestClose](PROPOSAL_EXPIRY_REPAIRED); otherwise the proposal is rejected (PROPOSAL_UNRESOLVABLE, with reason codes). Repair happens beforepinJson, so the pinned document carries the finalcloseTime. The resolution hash does not cover close time and is unaffected. - The resolution block is stored in
Market.resolutionDetailsand pinned in question document v2 (seedocs/api.md).
Every market and trade is justified by signals (NewsAPI articles, FRED observations, GDELT events). They are stored as normalised rows with stable identities, so a pinned trace can be audited against the exact evidence long after the fetch. src/services/ingestion.service.ts fetches; signal-normalize.ts holds the pure rules; signal-store.service.ts holds the store (prismaSignalStore, and MemorySignalStore for tests).
| Table | Role |
|---|---|
signals |
One row per article or observation. dedupKey is unique: sha256 of the canonical URL, or of FRED:<series>:<observation date>. Keeps url, publisher, publishedAt, firstSeenAt / lastSeenAt / seenCount, category + categoryConfidence, clusterId, numericValue (FRED) and a 64-bit simhash. Title and summary are stored already sanitised (#70). |
signal_fetches |
One row per provider call: request fingerprint (credentials removed), status ok / error / timeout / skipped_no_key, duration, item and rejected counts. |
signal_raw_payloads |
The provider response with credential-named keys stripped, kept SIGNAL_RAW_RETENTION_DAYS (default 7). |
signal_cache is no longer written and is dropped in a follow-up migration.
Identity. The canonical URL lowercases scheme and host, drops www., the fragment and tracking parameters (utm_*, fbclid, gclid, ref), and sorts the remaining parameters. NewsAPI [Removed] placeholders and articles without a URL are dropped; GDELT seendate (YYYYMMDDTHHMMSSZ) is parsed as UTC and an unparseable date is rejected, never stored as "now"; FRED "." (missing) observations are skipped. Rejections are counted in signal_fetches.rejectedCount. FRED's api_key is sent in params (the request URL is unchanged), so it can be stripped from every stored echo; no stored row contains a key (tests/ingestion.test.ts).
Writes. One INSERT … ON CONFLICT ("dedupKey") DO UPDATE SET "lastSeenAt" = …, "seenCount" = "seenCount" + 1 per 100 rows via $queryRaw, safe across replicas. There is no per-row Prisma upsert.
Near-duplicates. Syndicated copies have different URLs, so they are different rows that share a clusterId. A new signal is compared with signals of the same category published in the last 48 h: SimHash Hamming distance ≤ 3, or Jaccard ≥ 0.6 over 5-word shingles of title + first 200 chars of summary, makes it a near-duplicate. Including the summary keeps templated headlines ("Stocks rise as …") apart. Cluster assignment is not atomic across replicas, so it is made idempotent: a signal always joins the oldest matching cluster (earliest firstSeenAt, then smallest id), and only rows without a clusterId are updated. FRED observations never cluster.
Reads. A source is fetched only when its last ok fetch is older than SIGNAL_REFRESH_MINUTES (default 15); otherwise the agents read the store. aggregateSignals() takes signals last seen in the past 24 h and returns one representative per cluster (highest source weight, then newest; for FRED, the latest observation per series), each with signalId, url, publisher and clusterSize, sorted by
relevanceScore = sourceWeight × recencyDecay × log2(1 + clusterSize)
recencyDecay = 0.5 ^ (ageHours / 12)
sourceWeight is SOURCE_WEIGHTS (NEWSAPI 0.7, GDELT 0.6, FRED 0.9). Age is measured from publishedAt, except for FRED, whose observation date is the start of the reporting period: it ages from lastSeenAt. Syndication raises a story's score only logarithmically.
Traces. sourcesUsed entries carry signalId, url, publisher and publishedAt (built by evidenceFromSignals), and their weight is the relevance score instead of a constant. The market maker records the signals its proposal cited in grounding_signal_ids. The free previewSources keep only {source, publisher, signal}. Traces written before #73 have no signalId; readers treat these fields as optional, and pinned payloads are never rewritten.
Retention. Nightly at 03:41 (startMonitorCron): raw payloads older than SIGNAL_RAW_RETENTION_DAYS, fetch rows older than 30 days, and signals last seen more than 30 days ago that no trace cites. "Cited" is reasoning_traces."sourcesUsed" @> '[{"signalId": …}]', served by a GIN jsonb_path_ops index on sourcesUsed (chosen over a trace_signals join table: the containment probe is an index lookup and needs no second write path). Deletes run in batches of 1 000.
A trace is canonicalised once (sorted keys, same rules as agents/core/trace.ts), pinned byte for byte with Pinata's pinFileToIPFS, and trace_hash is the sha256 of those bytes. It is published to reasoning-registry once its market is on-chain. POST /traces/verify re-fetches the bytes by the on-chain CID and compares them with the on-chain hash, like x402/trace-verification.ts.
Publication is durable (#131). Each trace that should be on-chain gets a trace_publications row holding the exact document, worked by src/jobs/trace-publication.worker.ts every TRACE_PUBLISH_CRON (default 30 s):
| Status | Meaning |
|---|---|
AWAITING_PIN |
Pin the stored payload. A missing Pinata config parks the row for an hour; other pin errors retry. |
AWAITING_MARKET |
Wait for Market.onChainMarketId (re-checked every 5 min, not counted as an attempt). A CANCELLED market fails the row with MARKET_CANCELLED. |
READY |
Never publish twice: a trace already linked by the indexer, or the same agent + trace_hash among the market's 10 newest registry traces (market_trace_at), is confirmed without a write. Otherwise publish_trace is called once. |
SUBMITTED |
Waiting for the indexer. After TRACE_CONFIRM_TIMEOUT_MS the submitted trace id is re-read on-chain before any retry, so a landed transaction isn't repeated. |
CONFIRMED |
Set by the indexer's TracePublished event (matching trace_hash) or by the chain checks above. |
FAILED |
AgentNotRegistered / EmptyAction / EmptyCid, a voided market, or TRACE_PUBLISH_MAX_ATTEMPTS reached. lastError says which. |
Transient errors retry with backoff min(2^attempts × 30 s, 1 h). Rows are claimed with FOR UPDATE SKIP LOCKED and leased, and chain calls run after the claim commits, so several processes can run the worker. verified is set only by indexer confirmation (or the duplicate checks, which read the same on-chain record) and by POST /traces/verify; dry-run never confirms a row or sets verified. The creation trace is pinned with onChainMarketId: null when its market is still pending and is not re-pinned later: that would change trace_hash; the market_id argument of publish_trace provides the link. Action strings live in src/lib/trace-actions.ts.
A pinned trace used to prove only that these bytes were committed. It now
also says what produced the decision. tracePayload.provenance
(oracledesk.trace.provenance/1, documented in docs/api.md) is added before
canonicalisation, so it is inside the pinned bytes and the on-chain hash. It is
built by callLLMStructured (provider, model, prompt id/version/hash, params,
usage, latency, attempts, fallbackFrom, outputSha256) and assembled in
generateReasoningTrace, which merges the market proposal's entry (passed as
upstreamProvenance by the market maker) with its own hedge-conditions entry.
Hedge conditions that came from the hard-coded defaults are recorded as
source: "default", with no model fields.
Prompts live in src/lib/prompts/, one file per version. contentHash covers
the template text, so editing a template changes the hash;
tests/prompts.test.ts compares every { id, version, contentHash } with
tests/fixtures/prompts.snapshot.json and fails with "template changed; bump
version". A change ships as a new version file and a new registry entry; old
versions stay importable for replay and evaluation.
What provenance proves: that the backend claimed, at pin time and under the
agent's commitment, that this provider, model and prompt version produced the
output whose hash is outputSha256. What it does not prove: that the model
really produced it. Nothing is signed by the provider, and the raw prompt and
output are not published (they contain third-party text), so a reader can check
a copy of the output against outputSha256 but cannot reproduce the call.
Provider error messages are never recorded, only a bounded errorClass.
The paid product relies on one invariant: the bytes pinJson pins are the
bytes whose sha256 is recorded on-chain, and they are exactly what the
contracts repo's x402 service re-hashes before serving. tests/x402-e2e.test.ts
proves it by running the submodule's real createTraceApi (loaded in place from
contracts/oracledesk-stellar/x402/ through tsx, nothing copied into src/)
against traces produced by generateReasoningTrace and pinAndPublishDocument.
IPFS is an in-memory map, the facilitator is an in-process stub (it accepts any
well-formed payload: the test covers the server path and content parity, not
payment cryptography) and global fetch is guarded so any non-loopback request
fails the test. It checks the 402 challenge, a paid 200 whose content equals the
canonical payload, a tampered-bytes 502, an unknown-trace 404 and that a failed
settlement serves nothing. tests/canonical-parity.test.ts compares
canonicalJson with the contracts canonicalize over an edge-case corpus; the
one known divergence is Date (ISO string vs {}), so pinJson rejects
non-plain objects (IPFS_NON_PLAIN_DOCUMENT) and a test walks tracePayload
for class instances.
git submodule update --init
npm run test:x402:prepare # npm ci in contracts/oracledesk-stellar/x402
npm run test:e2e:x402 # builds the tests and runs the e2e file
Locally the tests skip with guidance when the submodule or its dependencies are
missing; with CI=true they fail instead. The bindings package the x402
package.json links to is only dynamically imported by
registry-trace-repository.ts, so it does not need building. CI caches the
package's node_modules keyed on its lockfile.
A daily pass is granted only after payment-verification.service.ts reads the transaction with Stellar RPC getTransaction and confirms all of the following:
- it succeeded;
- it is a single
transferon the USDC contract; fromis the logged-in wallet;toisPAYMENTS_RECIPIENT;- the amount is at least
DAILY_PASS_PRICE_RAW.
Each hash is usable once (unique PaymentEvent.txHash). There is no development bypass; tests inject a fake RPC reader. Per-trace unlocks are sold by the x402 service in the contracts repo, which the frontend calls directly.
Agent positions are share counts, not futures-style floats. agent_buy returns the shares; the position stores sharesRaw, costBasisRaw and feesPaidRaw (NUMERIC(39,0), converted with decimalToBigint, never through a JS number). Each trader cycle starts with markOpenPositions(): it reads reserves once per market (newest indexed market-core Trade event when the indexer is current, otherwise get_market; fee_bps is fixed per market and cached) and computes, for the held outcome with src/generated/fpmm.ts:
- mid value =
shares × price_bps / 10000 - liquidation value =
collateralOutForShares(reserve_sold, reserve_other, shares, fee_bps),0withilliquidif the pool can't absorb the sale - unrealised PnL = liquidation value − cost basis
Worked example: reserves YES 400 / NO 600 USDC, fee 1%, a YES position of 10 USDC of shares (100,000,000 raw), cost 5.5 USDC. Price of YES is 600 / 1000 = 60%, mid value 6.00 USDC. Selling all shares into the pool returns 5.9161929 USDC (liquidation value), so unrealised PnL is +0.4161929 USDC. The same shares on NO (reserve_sold = NO) liquidate at 3.9362880 USDC: swapping the reserves is the classic bug. Stop-loss triggers when unrealisedPnlRaw ≤ −15% × costBasisRaw, hedge detection at −10%. Run npx tsx scripts/backfill-position-shares.ts once after deploying to fill pre-existing rows (exact from the indexed Trade event, otherwise estimated with calcBuy, dry-run leftovers reported and skipped).
exitPosition() (exit.service.ts) sells through treasury.agent_sell; closePosition() only closes the database row. market-core sell takes an exact collateral_out, so the plan inverts the FPMM: collateralOutForShares, verified with calcSell (lowered a unit at a time until it burns no more than the target), cross-checked with quote_sell, and bounded by max_shares_in = min(held, target × (1 + 1%)). Shares held are read for the treasury contract (the holder), not the agent key. A position changes only from a confirmed live result; dry-run exits are recorded SIMULATED. Every attempt writes a position_exits row. SlippageExceeded re-plans (twice), InsufficientShares re-reads the balance (once), MarketClosed/MarketNotOpen/Paused fail without retry, and an ambiguous submission stays SUBMITTED, blocking new exits for that position until the next call reconciles it with the on-chain balance. Exits within 60 s of close_time are left to settlement.
agent_controls holds a pause per scope (ALL, MARKET_MAKER, TRADER) shared by all instances; agent_control_events is the audit trail. See the Agent controls section of docs/api.md for the endpoints, automatic pauses and the guarantee boundary.
Outcomes are decided on-chain by the resolver contract. The backend only reads resolver.state and market_core.get_market; the old endpoint that let any user set an outcome is gone. When the indexer sees MarketResolved, it updates the database and closes positions.
Markets created with a signers spec commit to an m-of-n signer set. signer-portal.service.ts serves the people in that set; it coordinates and never holds a signer key.
- Authority is the committed spec, not the environment.
RESOLUTION_SIGNERScan change after a market exists, so membership is read fromMarket.resolutionSpec, and that JSON is trusted only after it re-hashes (resolutionHash()) toMarket.resolutionHashand to the market's on-chainresolution_hash. Any mismatch is409 SPEC_MISMATCH. - Prepare builds
resolver.attestwith a client whosepublicKeyis the signer and which has no signer callback, so the signer is the transaction source and source-account auth satisfiessigner.require_auth(). The transaction is simulated, must need no other signers, carries time bounds, and is stored with its hash insigner_attestations. - Submit parses the signed envelope, requires its hash to equal the prepared hash (signatures do not change a hash, a different transaction or network does), checks expiry, verifies the wallet's signature, claims the row (
PREPARED→SUBMITTED) so a double submit broadcasts once, then polls for the result. - Counts are api-observed. The resolver emits no per-attestation event and has no count view, so progress counts only attestations confirmed through this API. The proposal comes from the indexed
ResolutionProposedevent, and a proposal the observed counts cannot explain is reported asattestationsOutsideApi. - No help equivocating. A second outcome for a signer with a confirmed attestation is refused (
CONFLICTING_ATTESTATION) unlessallowConflict=true.
This is the runbook for the guardian: the resolver admin, configured here as RESOLVER_GUARDIAN_ADDRESS (the resolver has no admin view, so this is configuration; a wrong value shows up as GUARDIAN_MISMATCH).
Why it exists. In signer mode an outcome is not final when the threshold is reached. The resolver stores a proposal, emits ResolutionProposed, and anyone may call finalize_signers once dispute_window seconds of ledger time have passed. Until then only the guardian can call cancel_proposal. With the default configuration (the agent as sole signer, threshold 1) a compromised key or a faulty attestation pipeline can propose a wrong outcome with no one else involved, so something has to notice.
What the monitor does. For each ResolutionProposed the indexer inserts one DisputeReview per event id (idempotent). A separate step, not the indexer tick, then:
- re-hashes the stored spec and compares it to
Market.resolutionHash(a mismatch isERRORandCRITICAL: a proposal on a market whose rules the backend cannot verify is itself suspicious); - fetches the question document from IPFS and checks its sha256 against
Market.questionHash(a mismatch isERROR); - evaluates the market independently from the resolution source. The evaluator is blind: it is not given the proposed outcome, the signers' evidence or any
ReasoningTrace. It uses its own prompt and, by default, a different primary model from attestation (dispute_reviewtask). Evidence is sanitised, delimited as data, instruction-like items are dropped and a decision must cite evidence ids that were really sent; - compares the result with the proposal and alerts.
Verdicts (DISPUTE_AGREE_MIN_CONFIDENCE, default 0.8):
| Verdict | Meaning | Alert |
|---|---|---|
AGREE |
Independent outcome equals the proposal, confidence at or above the threshold. | None, except INFO when the market has a single signer with threshold 1 and it is the agent: there is no second opinion on-chain. |
DISAGREE |
Independent outcome differs, confidence at or above the threshold. | CRITICAL immediately. |
INSUFFICIENT |
Low confidence, no determination, or evidence that cannot be trusted alone. | WARN immediately; CRITICAL when less than 25% of the window remains and nobody has acknowledged it. |
ERROR |
Spec or question document does not verify. | CRITICAL immediately. |
On a market holding at least DISPUTE_HIGH_VALUE_LIQUIDITY USDC, AGREE also needs a cited primary (non-news) source, so a manipulated news story cannot fool the signers and the review alike. A proposal observed after its window already ended (indexer catch-up) gets INFO only. A proposal found only by the 30-minute polling pass (event missed) gets a WARN, with proposedAt unknown.
Time. Remaining window is measured against the latest ledger close time (RPC getLatestLedger, then the indexer's newest event), because the contract compares ledger time. Alerts and the admin API show it; the API also shows server time and which source was used.
Alerts. Sinks: ALERT_WEBHOOK_URLS (JSON, signed X-OracleDesk-Signature: sha256=<hmac> with ALERT_WEBHOOK_SECRET) and ALERT_SLACK_WEBHOOK_URL. Each has a 5 s timeout, 3 attempts with backoff, and is deduplicated by reviewId:level:sink in alert_deliveries (so restarts and multiple instances do not repeat an alert, and a failed delivery is retried by the next 5-minute pass). Webhook URLs contain tokens: they are never logged, never put in an error message and never stored; sinks are identified by a hash of the URL, and the logger redacts them. If RESOLVER_GUARDIAN_ADDRESS is unset, alerts say so.
What to do when you get one.
GET /admin/disputesfor the evidence and time left. Open the cited sources yourself; the review is a prompt to look, not a ruling.- If the proposal is wrong,
POST /admin/disputes/:id/cancel-tx. It returns{ xdr, networkPassphrase, validUntil }: a simulatedcancel_proposalwhose source is the guardian. Sign it with your own wallet or the Stellar CLI (for examplestellar tx signwith the guardian key, thenstellar tx send), withinvalidUntil. The backend never signs or sends it. - Cancelling does not clear attestation counts. The same signers cannot re-attest the same outcome (
AlreadyAttested), and an outcome that already had threshold votes keeps them. If the whole signer set was wrong, new signers will not help for that market; the remedy isPOST /admin/disputes/:id/void-tx(guardian_void_market), which voids the market through market-core (the indexer then records aMarketResolvedwithout an outcome and marks the marketCANCELLED). Choose between the two before acting. (A resolver that cleared counts on cancel would be a contract change; seedocs/contract-requests.md.) POST /admin/disputes/:id/ackto record that you looked; acknowledged reviews stop escalating.
Timings. The review runs within seconds of the indexed event (poll interval plus evaluation time). Escalation and retries run every 5 minutes, the missed-event pass every 30. With the default 24 h window CRITICAL for an unconfirmed proposal arrives with six hours left. cancel_proposal only works on a proposal that has not been finalized, and anyone may finalize once the window ends, so treat the window end as the deadline.
Socket events used to be emitted by the process that indexed them, so with several API instances clients on the others missed events. Now:
indexer ──publish──▶ Redis Stream (oracledesk:events) ──▶ one fan-out leader ──io.emit──▶ socket.io Redis Streams adapter ──▶ every instance's clients
- Identity. Each bus message carries
id = chain_events.id(the RPC event id),ledger,marketKey(the on-chain market id) and the payload (bigints as strings). - Publication is tied to the committed row.
chain_events.publishedis false when an event is stored. After side effects,publishPending()publishes unpublished rows in(ledger, id)order and flags each one once the bus accepted it. A crash between store and publish loses nothing; the next pass publishes it. It stops at the first failure so a later event never overtakes an earlier one, and the live path and recovery share one queue per process. If Redis is down the indexer keeps storing events, the server keeps serving, and the backlog drains when Redis returns. Rows that existed before the migration are marked published. - Delivery semantics: at-least-once publication, effectively-once delivery. A crash between XADD and the flag, or two indexing instances racing, can put the same id on the stream twice. The fan-out records every delivered id (
seenkeys with a TTL) and skips ids it has already delivered, so clients still get each event once. - Fan-out design. One instance, elected with
SET leader NX PX(renewed by compare-and-set), reads the bus with a blockingXREADon its own connection (getBlockingRedis, never the shared client) and callsio.emit; the streams adapter carries that to every instance. Alternative considered: every instance consumes and emits only to its own sockets, with no adapter. It is simpler, but each future feature (per-market rooms, private per-user events) would have to reimplement cross-instance routing, and private events need the adapter anyway. The leader persists the stream id of the last event it delivered (oracledesk:fanout:cursor), so a successor resumes there with no gap; theseencheck prevents the one event that may straddle the switch from being delivered twice. Leadership is re-verified (fenced) before delivering, so a paused leader that lost the lock stops emitting. - Ordering. One stream and one consumer preserve publish order, which is
(ledger, id)order, so per-market order holds. - Retention.
XADD ... MAXLEN ~ EVENT_BUS_MAXLEN(default 100000) bounds the stream, a delivery buffer only.chain_eventsis the durable history; anything that must replay far back (for example a resumable SSE feed) should read that table. - Not covered here. Leader election for the indexer and splitting API/worker roles (the design works whether one or all instances index), coalescing during a large historical catch-up (the stream stays bounded but is not throttled), and per-market rooms (the
marketKeyfield is there for them). - Boot without Redis. The server starts and logs the degraded state. The streams adapter reports a rejected subscribe as an unhandled rejection while Redis is down (it is logged, not fatal).
EVENT_BUS_ENABLED=falseswaps in an in-process bus and no adapter, for a single instance.
- The handshake accepts the REST API's JWT in
auth.tokenthroughverifyAccessToken(), the same functionrequireAuthuses (HS256 only, previous-secret rotation included). No token is an anonymous socket; a bad one fails the handshake with a code; a token in the query string is refused. - Private events (
PAYMENT_CONFIRMED,PASS_EXPIRING,COPY_TRADE_CONFIRMED) go only throughemitToUser/notifyUser, which targetuser:<userId>. The public emitter wrapsassertPublic, and a test fails if any file undersrc/emits a private event name directly. - A session ends at the token's
exp(re-armed in chunks, becausesetTimeoutoverflows above about 24.8 days). The socket getsSESSION_EXPIREDand leaves its user room but stays connected for public events, so a long-lived page keeps live prices;reauthwith a token for the same user restores private events. Switching users on a live socket is refused. - Emission happens after the database commit:
PAYMENT_CONFIRMEDafter the payment transaction (not on the idempotent re-submit),COPY_TRADE_CONFIRMEDafter the status update,PASS_EXPIRINGafter claiming the pass with a conditional update onexpiryNotifiedAt, which makes it at-most-once even with several instances. - Tokens are never logged; only the user id is.
indexer.service.ts polls Stellar RPC getEvents for the four contract ids every INDEXER_POLL_MS:
- Events are decoded with each binding's spec (
Spec.parseEvent) and stored inchain_eventskeyed by the RPC event id, so re-reads are idempotent. - The ledger cursor lives in
indexer_cursors. If it is older than RPC retention (about 7 days on testnet), the indexer warns and resumes from the oldest retained ledger; anything in between is lost to it. - New
TradeandTracePublishedevents are published to the event bus and delivered to clients over socket.io asTRADE_EXECUTEDandREASONING_PUBLISHED(see Event bus and socket fan-out). ResolutionProposedandResolutionFinalizedopen and close dispute reviews (see Disputes and guardian actions). The independent evaluation never runs inside the poll.
The indexer and the reconciler (below) both write markets.chainSyncedLedger, the ledger a row's chain-derived state reflects. A price or status write applies only when it is not older than that, so the two can never overwrite each other with stale information, and applyMarketFinality (market-finality.ts) is the one place a market becomes RESOLVED/CANCELLED and its positions close.
market-reconciler.service.ts runs every ten minutes (3,13,…,53, RECONCILE_ENABLED) on one instance (a Postgres advisory lock held by a transaction, so a pooled connection cannot leak it). It reads market_core.get_market with the ledger the simulation ran against, for linked markets in bounded batches (RECONCILE_MAX_MARKETS, RECONCILE_CONCURRENCY, RECONCILE_MAX_RPC_CALLS; open markets first, then never/least-recently reconciled), and classifies each difference with the pure classifyDrift:
| Class | Action |
|---|---|
MISSED_RESOLUTION / MISSED_VOID |
repaired through applyMarketFinality, resolvedAt = observation time |
CLOSED_BUT_ACTIVE |
ACTIVE → RESOLVING (close_time is ledger time: compared with the observation ledger's close time plus 60 s grace) |
PRICE_STALE (> 0.5 pp) |
currentYesProb from the reserves |
CLOSE_TIME_MISMATCH |
expiryTimestamp from chain, unless the row also has a hash mismatch |
HASH_MISMATCH, STATUS_REGRESSION, NOT_FOUND_ON_CHAIN |
reported only: MARKET_INTEGRITY_DRIFT agent log at ERROR and a MARKET_DRIFT socket event to admins |
Resolution-class findings are deferred while the indexer cursor trails the observation by more than RECONCILE_INDEXER_LAG_LEDGERS (the indexer may be about to process the event); after three deferrals in a row they are repaired and flagged forced. RPC errors other than MarketNotFound are not drift: they are counted and the market is retried first next run. The reference ledger's close time comes from getLedgers; if that fails the newest indexed event's time (or the wall clock) stands in. Runs and findings persist in reconcile_runs / market_drift_reports and are readable through the admin endpoints in api.md.
The trader caps correlated exposure before opening a position (5% of the bankroll, pairs with |ρ| ≥ 0.60). The correlation behind that comes from CorrelationSnapshot, recomputed by the monitor cron at 2,17,32,47 (just before the trader) by computeCorrelationSnapshot; the trading path only reads the latest snapshot (cached five minutes) and never computes inline.
- Series.
price-history.service.tsrebuilds each market's YES price from storedMarketCreated/Trade/MarketResolvedevents (priceYesBpson the reserves after each trade, BigInt throughout), collapses same-ledger trades to the last one and resamples onto aCORRELATION_GRID_SECSgrid with last-observation-carried-forward; the series isnullbefore the first point and from the resolution on. An index on((params->>'market_id')::numeric, ledger)serves the per-market query. - Estimate. Logit-price changes (prices clamped to the contract's 1%–99%) over intervals where both markets had an external trade (carried-forward values would give zero returns and bias ρ toward 0; trades by the treasury count for price levels but not as activity, since the agent's own trades would make markets it bought together look correlated). Fewer than
CORRELATION_MIN_OVERLAPsuch intervals, or zero variance, uses the category prior (method: "prior"). Otherwiseρ̂ = (1 − δ)·ρ_sample + δ·ρ_prior,δ = clamp(κ/(κ+n), 0.1, 1), κ =CORRELATION_PRIOR_KAPPA. This is Ledoit–Wolf-inspired fixed-target shrinkage, not the full optimal-intensity estimator: pairwise-complete data breaks its assumptions. - Scope. Markets with open positions plus ACTIVE markets expiring within 14 days (≤ 100); pairs use backend ids with
a < b. The newest 48 snapshots are kept. - Sizing. A snapshot older than two hours is ignored (
CORRELATION_SNAPSHOT_STALEis logged) and any pair it lacks falls back to the matrix; each pair records which one was used. A pair counts when |ρ| ≥ 0.60 and contributesρ_eff × size, withρ_eff = ρfor holdings on the same side and−ρon opposite sides: positive ρ on the same side adds risk, positive ρ on opposite sides hedges, negative ρ on the same side (YES+YES on complements) hedges, negative ρ on opposite sides adds risk. Net exposure never goes below zero. - Reporting.
GET /portfolioalso returns the full matrix projected onto the nearest PSD matrix (eigenvalue clipping with a small Jacobi solver, ≤ 50 positions); sizing keeps the raw pairwise values.
The trader trades where its own probability differs from the price by at least 0.08. That estimate comes from forecast.service.ts, and no forecaster ever sees the price: ForecastInput has no current or initial YES probability and no creator interval. (Before #75 the "estimate" was a weighted mean of signals whose rawValue was the price, so the edge was always zero.)
- Inputs. The question, category, close time, resolution source, up to eight relevant signals (sharing meaningful tokens with the question, or of its category; the dedup tokeniser) and the pinned question document. The document is fetched by
metaUriand used only if its bytes hash toquestionHash(verifyTraceHash); onlyquestion,category,closeTime,resolutionSource,resolutionandpriceResolutionare kept. A failed fetch or a mismatch forecasts without it. - LLM forecaster (prompt
forecastv1, taskforecasting, schema-constrained): base rate, per-signal adjustments citing signal ids,p, a 50% interval and a rationale. σ = (p_high_50 − p_low_50) / 1.349, p clamped to [0.02, 0.98].FORECAST_LLM_SAMPLES> 1 takes the median p and adds the samples' variance. Untrusted text is sanitised and nonce-delimited as in #70; an instruction-like question document is dropped. - Base-rate forecaster.
forecast.base-rates.json: reference-class frequencies by category and question pattern, each with its source and a deliberately wide σ (≥ 0.15). Unmatched questions get p = 0.5, σ = 0.25. The figures are approximate tallies; change them as a reviewed code change. - Ensemble. Log-odds, weights ∝ 1/s² with s = σ/(p(1−p)) and σ floored at 0.05 (so no member dominates by claiming certainty), pooled variance 1/Σ(1/s²), mean multiplied by (1 −
FORECAST_SHRINK, default 0.15),ci = logistic(μ ± 1.645·σ)(90%). The trace'sforecastblock holds every member's p, σ, weight, rationale andinputsDigest; the trace'sconfidenceIntervalis the ensemble's, not the jackknife over sources. - Sizing. Kelly runs on the conservative end of the interval for the side being bought: YES uses
ci.loweragainst the YES price; NO uses1 − ci.upperagainst the NO price1 − p_market(the NO side used to be compared with the YES price). A point edge above 0.08 whose conservative end is not 0.08 better than the price sizes to zero; that is intended and logged asSIZE_ZERO_WIDE_CI. - Cost. At most
FORECAST_MAX_MARKETS_PER_CYCLE(5) markets andFORECAST_MAX_LLM_CALLS_PER_CYCLE(10) LLM calls per cycle; candidates without a recent forecast go first, soonest close first. Results are cached in Redis underforecast:{marketId}:{digest}(question hash + relevant signals) forFORECAST_CACHE_TTL_HOURS(6). A forecast with no LLM member (budget, cap or provider failure) is not traded on. - Residual risk. A model can still read a price out of news ("traders see 70% odds"). The prompt tells it to ignore quoted odds, and the recorded adjustments show which signal moved the estimate.
The treasury contract enforces max_trade per call, cumulative max_market per market and a rolling max_daily on every agent spend. The trader sizes from the same numbers (treasury-budget.service.ts), read once per cycle with available_capital(), get_config() and daily_usage():
- Bankroll =
available_capital()(the treasury's free USDC: it includes subscription revenue paid to the treasury and excludes capital locked in markets). Kelly, the daily drawdown check and the correlated-exposure cap all use it. A failed read skips the cycle (TREASURY_READ_FAILED): there is no constant to fall back to. - Daily headroom =
max_daily − daily_usage().amount. The view already rolls the window over (a window anchored at the first spend after expiry, not UTC midnight), so an expired window reads as 0 used; it resets atday_start + 86 400. - Market headroom =
max_market −the market's cumulative treasury spend: the latest indexedTradeAuthorized.market_totalwhen present, otherwise executed trades plus the seed for agent-created markets (markedestimated). Sells never reduce it. - Clamp. In 7-decimal base units, before any trace exists:
final = min(kelly → correlation, max_trade, market headroom, daily headroom, available), withTREASURY_HEADROOM_SAFETY_BPS(default 5%) taken off the market and daily headroom for races with market creation and window rollover. Orders below 1 USDC are skipped. A budget older than 60 s is re-read, and each buy is debited from the cycle's copy so later orders see it. - Trace.
sizing: { kellyRaw, correlationRaw, marketExposureRaw, marketExposureEstimated, finalRaw, binding, safetyBps, budget }, withbindingone ofKELLY | CORRELATION | MAX_TRADE | MAX_MARKET | DAILY | AVAILABLE. - Execution.
executeTradesubmitsamountRawascollateral_in(no float round trip). ATradeCapExceeded/MarketCapExceeded/DailyCapExceededfrom the chain is logged asTREASURY_CAP_REJECTEDwith the budget snapshot, so drift between estimate and chain is visible. - Backtests answer
budget()andmarketExposure()from the simulated treasury, so the replay sizes against the same caps.
GET /portfolio reports the same budget (see docs/api.md).
seed-policy.service.ts (pure decideSeed) sizes the treasury capital a new market is seeded with and the price it opens at. planMarketCreation reads the budget (treasury.get_config, daily_usage, available_capital, market-core's default fee) and runs it before the market maker pins or writes anything; a read failure fails closed (SEED_SKIPPED, INSUFFICIENT_CAPITAL).
- Price: shrunk toward 0.5 by confidence-interval width (see api.md).
- Base seed:
SEED_CATEGORY_BASE_USDC[category] × clamp(√(days to close / 30), 0.5, 1.5), at most 2× the LLM'sminimum_liquidity_usdc(the LLM can lower it, not inflate it), at least the on-chainMIN_SEED(100 USDC). - Bounds (the smallest wins, and is recorded as
binding): treasurymax_tradeandmax_market(each with a 5% safety margin, since the trader can spend between the read and the write), the market maker'sMM_DAILY_SHARE_BPSofmax_dailyminus its seeds in the current window, the remainingmax_dailyheadroom,MAX_SEED_FRACTION_BPSof available capital,MAX_UNRESOLVED_SEEDED_RAWminus the seed already locked in unresolved markets, and the LP-loss budget. - Expected LP loss (
LP_LOSS_BUDGET_RAWper market): an informed trader buys the underpriced side until the price reaches the confidence-interval bound farthest from the opening price; the loss is the seed minus the value of the LP's reserves and creator shares at that price. It is computed withseedReserves/calcBuy/priceYesBpsfromsrc/generated/fpmm.ts(integer math, the contract's rounding) and a bigint bisection (≤ 40 steps) finds the largest seed within budget. Trading fees accrue outside the pool and are ignored. - A seed below
MIN_SEEDis a skip whose reason names the binding bound (BELOW_MIN_SEED,DAILY_BUDGET,AGGREGATE_CAP,LP_LOSS_BUDGET,INSUFFICIENT_CAPITAL). If the treasury rejects the seed anyway (DailyCapExceeded,MarketCapExceeded,TradeCapExceeded),SEED_CAP_RACEis logged and the market staysPENDINGfor the reconciler.
scripts/backtest.ts replays stored market-core history through the trader's real decision code. The decisions live in src/agents/trader-policy.ts (runTraderPolicy(env, params)), which touches only a TraderEnv (trader-env.ts: a clock, reads of markets, positions and signals, execution, and a decision sink). prodTraderEnv() wraps Prisma and the chain service and runTraderCycle() is runTraderPolicy(prodTraderEnv(), DEFAULT_PARAMS); tests/backtest.test.ts records every Prisma and chain call a cycle makes and asserts the sequence is unchanged.
npm run backtest -- --from 2026-10-01 --to 2026-10-15 --out ./bt \
--forecaster oracle-noise:0.15 --seed 7 --max-trade-raw 5000000000
# offline, from a JSON file {events, signals, forecasts}:
npm run backtest -- --events-file tests/fixtures/backtest/events.json --out /tmp/btThe runner only issues findMany (events up to --to, because pools are rebuilt from MarketCreated and everything after it, plus signal_cache and the live agent's stored forecasts) and writes report.json and report.md. Options: --from, --to (required against the database; with --events-file they default to the file's span), --bankroll-raw, --max-trade-raw, --max-market-raw, --max-daily-raw (7-decimal base units; defaults 10 000 / 500 / 1 000 / 2 000 USDC), --impact reset|propagate|both (default both), --forecaster prior|oracle-noise:<σ>|recorded, --seed, --fee-bps (inferred from the first historical buy), --events-file, --out.
How it works. A simulated UTC clock steps every 15 minutes. Before each step every event with ledgerClosedAt ≤ now is applied in order (pools are seeded by seedReserves; external trades move them; MarketResolved settles positions with settleValue, void at half a share's value), then the policy runs against a BacktestEnv built only from that state: it never reads a live row, so it cannot see the future. Agent orders fill against the simulated pool with calcBuy/calcSell, update its reserves, and spend through a SimTreasury that enforces max_trade, cumulative max_market and a rolling 24-hour max_daily whose window starts at the first spend after the previous one expired (selling is uncapped). Trading after close_time is refused like market-core's MarketClosed. Floats exist only in the policy; usdcFromNumber converts at the buy boundary and everything else is base-unit bigint.
Impact models. Historical trades were made against the historical pool, not one containing the agent's trades, so replaying their reserves would erase the agent's impact while replaying their collateral changes their share amounts. reset snaps to the historical reserves after every external trade: an optimistic upper bound, since impact decays instantly. propagate re-executes each external trade's collateral on the simulated pool, so the agent's impact persists (trades the pool cannot fill are counted as skipped). Both are reported by default; the gap between them is how much of the result depends on the agent not moving prices.
Forecasters. The LLM estimate is replaced by a deterministic one: prior (the market's opening price), oracle-noise:σ (the resolved outcome plus seeded Gaussian noise: an upper bound on skill, since it reads the future by design) or recorded (the forecast stored in reasoning_traces.probabilityEstimate at that time, no forecast if none). Noise is seeded from (seed, market, time).
Reading the report. Per impact model: PnL (cash plus liquidation value via collateralOutForShares at the end), max drawdown, a Sharpe-like ratio (daily equity returns, annualised), turnover, win rate, stop-loss count, cycles paused by the daily-drawdown rule, cap hits per cap (TradeCapExceeded, MarketCapExceeded, DailyCapExceeded, …), average edge at entry against the forecast and realised edge of resolved positions, impact cost (fill versus mid, fees included) and per-market attribution. configHash and inputsHash are sha256 over the canonical config and the time-sorted inputs; the same inputs, config and seed give a byte-identical report.json. Differences from production: the clock is UTC (production's drawdown check uses local midnight); pool liquidity is the reserve sum; correlations use the category matrix (there are no snapshots in a replay); and hedges execute against the pool (one per pair), whereas production's autoHedge only records a PENDING trade.
Caveats. The numbers evaluate the policy as written. Two properties of it show up quickly in a replay: the 15% stop-loss compares |price − entry| / entry, so it also fires on favourable moves and on the agent's own impact (in a thin pool a 250 USDC entry can stop itself out on the next step), and the NO-side Kelly sizing compares the NO probability with the YES price. Neither was changed here: the harness exists to evaluate the rules, not to change them. Avoid tuning constants on the same window you evaluate them on (see Bailey et al., "The Probability of Backtest Overfitting").
/livez checks only the process; /readyz probes Postgres, Redis, RPC and the indexer lag. HEALTH_CRITICAL decides which failures return 503. By default Redis and Postgres are critical (login and every data endpoint need them) and RPC is not: an instance without RPC stays ready and keeps serving database reads, while on-chain state, resolution, trace and payment endpoints return 502. The indexer fails when it is more than INDEXER_MAX_LAG_LEDGERS behind or its cursor is older than INDEXER_MAX_STALENESS_MS; with no cursor yet it reports initialising. On SIGTERM /readyz returns 503 draining for SHUTDOWN_DRAIN_MS before the server stops accepting connections.
src/generated/ is copied from the pinned contracts submodule by scripts/sync-contracts.mjs, with a do-not-edit header and // @ts-nocheck. CI fails if it's stale.
The sync also writes src/generated/contracts-manifest.json: for each binding, a sha256 over its embedded spec entries, a per-entry { kind, name, sha256 } index, and the expected wasm hash when the submodule's deployments/*.json records one (see docs/contract-requests.md). The manifest is derived from the submodule only — --check needs no network — so a binding bump shows up in code review as changed spec hashes.
The backend assumes the contracts at the CONTRACTS addresses run exactly the wasm the bindings were generated from. Deployed contracts can be upgraded, redeployed from another build, or replaced by an env override, and when they drift the failures appear late and far from the cause: a renamed entrypoint fails simulations with MissingValue, a changed struct decodes with missing fields, a changed event makes Spec.parseEvent return null or throw inside the indexer poll.
Every deployed Soroban contract carries its interface: the wasm's contractspecv0 custom section holds the same ScSpecEntry XDR the bindings embed, and the contract instance records its wasm hash. At boot, src/services/stellar/spec-compat.ts:
- fetches each contract's instance and code ledger entries over RPC (
getLedgerEntries), reads the wasm hash and parses the embedded spec (contract.Spec.fromWasm), cached per wasm hash; - diffs the deployed spec structurally against the synced binding — docs are ignored, types compare canonically (
Option/Vec/Map/BytesN/UDT-by-name) — and classifies each difference as breaking or compatible against a usage table of the entrypoints the services actually call and the events the indexer projects (a unit test greps the services and fails when the table is stale); - checks the wasm hash against the manifest pin when one is recorded and the contract id is not overridden by env;
- only confirms the USDC contract's executable is a Stellar Asset (its interface is a SAC, never checked).
Classification rules: a used function removed or changed is breaking, an unused or new one is compatible; a struct/union/enum reachable from a used function or projected event is breaking when changed, except a union/enum case appended at the end, which is compatible for read-only types and breaking for types the backend encodes (e.g. Category); an error enum code reassigned under an existing name is breaking (error naming depends on it), new codes are compatible; a projected event changed in topics or data fields is breaking. A contract that cannot be verified (archived code entry, RPC failure) is breaking — the gate never fails open.
In live mode (default SPEC_COMPAT_ENFORCE=enforce) any breaking difference or wasm-hash mismatch refuses startup, listing every breaking item. Dry-run (default warn) logs a structured warning and keeps running; off skips the check. The indexer keeps storing events whose spec changed in a breaking way but stops projecting and publishing them (and a parseEvent throw skips that event instead of aborting the poll). Admins can read the last report at GET /api/v1/admin/chain/spec-compat.
All generation goes through src/lib/llm/router.ts. Each LlmTask
(market_generation, hedge_conditions, forecasting, attestation,
generic_json, generic_text) maps to an ordered list of ModelSpecs
(provider, model, temperature, max tokens, timeout, optional base URL). The
default policy reproduces the old Anthropic → Gemini behaviour. Override any
task with LLM_POLICY (JSON) or LLM_POLICY_FILE; missing tasks keep the
default, and an invalid task/spec fails at boot with a per-task message.
Providers live in src/lib/llm/providers/ (Anthropic, Gemini,
OpenAI-compatible). They normalise SDK errors into LlmError.kind
(rate_limited, quota_exhausted, auth, invalid_request, timeout,
server, content_filtered, empty, parse). The router:
- skips providers with no key (without counting a failure);
- retries inline only when
retryAfterMs <= 5s; longer waits cool the provider down and fail over; - opens a 1-hour circuit on quota exhaustion;
- cools a provider for 60s after 3 server/timeout failures;
- fails over on auth/invalid-request/empty/parse;
- parses JSON in the router, so an invalid response tries the next model;
- returns provenance (
provider,model,attempts,latencyMs,usage) for agent logs and trace payloads (llm: { provider, model } | null).
Text mode never sets a JSON response format; only mode: "json" does.
Output that must fit a shape (market proposals, hedge conditions) is never
parsed out of prose. src/lib/llm-schema.ts is the single registry: each shape
is a zod schema from which defineStructured() derives the Anthropic tool
input_schema (z.toJSONSchema, draft-7) and the Gemini responseSchema
(toGeminiSchema, an OpenAPI-subset dialect: types mapped to SchemaType,
const becomes a one-value enum, propertyOrdering added, and
additionalProperties, $schema, minLength, pattern, format and
exclusive bounds dropped, since Gemini answers 400 on them). Wire schemas avoid
.catch, .default and transforms; the lenient domain parse
(marketProposalSchema) applies defaults separately, and a bad currency is an
error on the wire. Rules JSON Schema cannot express (the confidence interval
must contain the probability, expiry must be in the future) are postValidate
functions whose message goes into the repair turn.
callLLMStructured(spec, { system, user }, { maxTokens, ctx, task }) returns
{ value, provider, model, attempts }:
- Anthropic:
tools: [{ name, description, input_schema }]with a forcedtool_choice; thetool_useblock is searched for (a text block may come first).stop_reason: "max_tokens"is never repaired at the same budget: the budget is doubled once, then the call fails with a specific reason. - Gemini:
responseMimeType: "application/json"plusresponseSchema; the text isJSON.parsed with no fence stripping. - Each provider gets one repair round: the zod issues (or the
postValidatemessage) go back as anis_errortool_result(Anthropic) or a correction turn (Gemini). Anthropic that still fails falls back to Gemini. - Failure is
AppError(422, "LLM_STRUCTURED_OUTPUT_FAILED", { provider, issues })when output never validated, otherwise the last transport/guard error.INVALID_LLM_JSON,SCHEMA_VALIDATION_FAILEDandINVALID_CONFIDENCE_INTERVALremain on thevalidateMarketQuestion()wrapper.
Every attempt, repairs included, takes a lease on the shared guards below. Prompts
and outputs are not logged at info; counts and provider/model are. callLLMText
falls back to a plain-text Gemini call (never a JSON one). Tests replace the
providers through setAnthropicClient() / setGeminiModelFactory() and replay the
shapes in tests/fixtures/llm/ (hand-built examples of the API shapes, not
captures from a live account).
The lexical check (token Jaccard > 0.55 or 3+ shared bigrams) misses
paraphrases and flags unrelated questions that share boilerplate. With
EMBEDDING_PROVIDER=gemini|openai the market maker compares embeddings in
pgvector instead; with none (the default) nothing changes.
Storage. markets.questionEmbedding vector(768) and embeddingModel, added
by migration …_market_embeddings (CREATE EXTENSION vector). Prisma cannot read
or write Unsupported columns, so every access is $queryRaw / $executeRaw
with the vector bound as $n::vector (a '[0.1,…]' string), never interpolated.
The index is markets_question_embedding_hnsw, HNSW with vector_cosine_ops,
partial on status IN ('PENDING','ACTIVE'): pgvector filters after the index
scan, so a full index would fill its 40-candidate list with old resolved markets
and hide a near duplicate. The dedup query repeats the predicate, which is what
lets the planner use it; at a few hundred rows the planner correctly prefers an
exact scan and sort, which has perfect recall. The index is not in
schema.prisma: prisma migrate dev must not be allowed to drop it (check with
prisma migrate diff before accepting a generated migration). The dimension is
fixed at 768 (Gemini text-embedding-004; OpenAI text-embedding-3-small with
dimensions: 768); EMBEDDING_DIM must equal it, a provider with another size
is rejected, and a mismatch stops the server at boot.
Embedding. normalizeQuestion() lowercases, collapses dates to <date> (the
second stage compares dates itself), writes numbers as plain digits ($70,000,
70k → 70000), strips punctuation and caps the text at 512 characters. A new
market is embedded fire-and-forget after createMarketFromProposal; the
ingestion cron then sweeps questionEmbedding IS NULL rows (one batch of 32 per
tick), and npm run backfill:embeddings [-- --reembed] embeds everything else.
Vectors from two models are never compared: the query filters on
embeddingModel, so after changing EMBEDDING_MODEL run
backfill:embeddings -- --reembed.
Decision (checkDuplicate in market.service.ts, returns
{ duplicate, method, match? }, logged to AgentLog as DEDUP_CHECK /
DUPLICATE_SKIPPED):
- Candidates: the 10 nearest PENDING/ACTIVE neighbours by cosine similarity;
those at or above
DEDUP_SIM_THRESHOLDcontinue. - Confirmation (
confirmDuplicate, pure), in order: (a) the resolution dates are withinDEDUP_DATE_WINDOW_DAYS(default 3), both the expiries and any date written in both questions; (b) the questions do not each state a figure the other lacks ($70,000vs$90,000are two rungs of a ladder); (c) they share an entity from the rule-based extractor (dedup-entities.ts: a gazetteer of central banks, assets, indicators, countries and sources, plus proper nouns) and neither adds an entity the other lacks (Lakers vs Celtics). Similarity ≥ 0.95 skips (c) only, never the date or figure checks. - Fallback: markets the vector search cannot see (no embedding yet, or from
another model) are compared lexically so nothing is skipped silently. With no
provider, or if embedding or the query throws, the lexical check runs and the
result says
method: "lexical"; the cycle is never blocked.
Calibrating the threshold. tests/fixtures/dedup-pairs.json holds 158 labelled
pairs (44 paraphrases and 16 formatting variants that are duplicates; different
date, different entity, boilerplate collision, different strike and unrelated
pairs that are not). npm run calibrate:dedup embeds them, sweeps thresholds
0.50–0.99 and reports precision, recall and F1 for semantic only, semantic +
confirmation, and the lexical rule, and recommends the threshold with the highest
recall at precision ≥ 0.95. --provider gemini|openai --record writes the
vectors to tests/fixtures/dedup-vectors.<model>.json, and --vectors <file>
replays them without a key.
The committed run uses --provider fake, a deterministic hashing embedder with
a small synonym map. It validates the harness and the confirmation stage, not
a model: on it the lexical rule scores precision 17.9 % / recall 28.3 %
(boilerplate makes it flag unrelated questions), and semantic + confirmation
scores precision 100 % at t = 0.90 with recall 26.7 % because the fake vectors do
not place paraphrases close together. DEDUP_SIM_THRESHOLD=0.85 is therefore a
provisional default; run the calibration with the real provider and replace it
before enabling EMBEDDING_PROVIDER in production.
Infrastructure. pgvector is needed by the migration: docker-compose.yml, CI
and the integration containers use pgvector/pgvector:pg16 (Debian, glibc; the
old postgres:16-alpine is musl, so recreate an existing dev volume or REINDEX
text indexes before switching). Managed Postgres needs CREATE EXTENSION vector.
src/services/calibration.service.ts answers whether agent probabilities are
calibrated. Postgres does the heavy lifting in one query
(reasoning_traces JOIN markets for RESOLVED markets with an outcome and an
onChainMarketId, traces strictly before resolvedAt); TypeScript weights,
slices and scores the rows with pure functions (brier, logLoss,
reliability, murphy, fitShrinkage). Metric definitions, the weighting
policy and the exclusions are in docs/api.md ("Agent calibration").
- Snapshots.
computeAndStoreSnapshots()stores every window (30d,90d,all) × grouping × key incalibration_snapshots(append-only; the API serves the newestcomputedAt). A cron at 03:10 runs it inside a transaction holdingpg_try_advisory_xact_lock, so only one instance computes. - Index.
reasoning_traces (marketId, createdAt)serves the join. Postgres uses it for selective windows (a nested loop of one index range scan per market: 7 ms for 334 markets / 3 340 traces in a test database) and prefers a sequential scan when most markets match (all, 55 ms for 200 000 traces). - Trader shrinkage (
CALIBRATION_SHRINKAGE_ENABLED, default off). Before sizing, the trader moves its estimate to0.5 + k (p − 0.5)withkfrom the latestall-window(TRADER, category)snapshot, then(TRADER), then 1, only when at least 50 resolved markets back the fit;kis bounded to[0.3, 1], so it can only shrink. The factor is recorded in the trace as aCALIBRATIONentry insourcesUsed. - Limits. Few resolved markets give noisy numbers: read the Wilson
intervals and
nEff. Calibration says nothing about the trader's edge while its probability still equals the market price (rawValue: marketProb), andMARKET_CREATIONhas no meaningful market baseline.
npm run eval:markets measures whether a prompt, model or schema change makes
proposals better or worse. It is offline: the corpus pins the inputs and
recordings pin the model's answers, so a run is deterministic and needs no
keys, database or network.
-
Corpus (
tests/eval/corpus/<id>.json, 44 cases): anasOfdate, the signals (titles and short snippets only, as the prompt truncates them), the fixed list of existing questions used for dedup, and the humanlabels(category,expectDuplicateOf,notes). The committed cases are synthetic, including adversarial ones (past-dated signals, ambiguous events, a sports story with no fixture date, a paraphrase of an existing market, an injected headline). Add real ones fromSignalCacheafter stripping keys and PII. -
Prompts are built by
buildMarketPrompts(signals, existing, now, { nonceSeed }), a pure function: no Prisma, no clock. The runner passesnow = asOfand a nonce seed derived from the case id, so the prompt is a pure function of the case.isDuplicateOf(question, existing)is the pure form of the lexical dedup. -
Transport:
setLlmTransport()(src/lib/llm.ts). Replay readstests/eval/recordings/<model>/<id>.json(and<id>.judge.json), each keyed bysha256(system + user). A prompt change changes the key, so replay fails withrecording missing … re-run with --live --recordinstead of grading stale output.--liveuses the real providers;--recordwrites the recordings after refusing any key-looking string. -
Graders (
scripts/eval/graders.ts), each{ pass, score, detail }:Grader Rule Judge schemaValidvalidateMarketQuestionparses it– binaryone yes/no question; no "or", "how many", "which", bucket ranges binaryresolutionSourceresolution_oracleis inALLOWED_ORACLESverifiabledateSanityasOf + 1 d ≤ expiry ≤ asOf + 90 d, and within 2 days of the latest date written in the question– categoryequals the label (unlabelled cases are skipped) – duplicateAccuracyisDuplicateOfagrees withexpectDuplicateOf– ciConsistencylower < p < upper, width ≤ 0.4– A parse failure fails every grader for that case, so a bad prompt cannot hide behind a smaller denominator. The judge (
scripts/eval/judge-prompt.v1.ts, id, version and hash recorded in each report) can fail a case but never rescues a hard rule failure; rule/judge disagreements are listed separately. -
Thresholds (
tests/eval/thresholds.json) are minimum pass rates per grader. The report shows counts and Wilson 95 % intervals next to each rate: with 44 cases a difference of a few points is noise. Exit codes: 0 ok, 1 a threshold regressed, 2 the run failed (for example a missing recording). -
Reports:
eval-results/<mode>-<model>.jsonand.md(prompt and corpus hashes, git sha, per-grader and per-category rates, the worst 10 cases).
CI runs npm run eval:markets -- --replay after npm test.
Add a case: write tests/eval/corpus/<id>.json, run it live once
(ANTHROPIC_API_KEY=… npm run eval:markets -- --live --record --model <name> --case <id>)
and commit the corpus file and the two recordings. After a prompt change:
re-record the whole set the same way (without --case), review the diff, and
commit; only then adjust thresholds.json.
The synthetic-baseline recordings shipped with the harness are hand-written
(scripts/eval/seed-synthetic.ts), not captures of a live model, and their
thresholds only guard the graders; the first live run should replace them.
Headlines, descriptions, GDELT URLs and earlier market questions are untrusted,
and an accepted proposal makes the treasury seed a market. Layers
(src/services/llm-guard.service.ts, all pure):
- Sanitise at ingestion (
sanitizeSignal, applied by all three fetchers beforecacheSignals, and again on cache reads): NFKC, removal of\p{Cc}and\p{Cf}(zero-width, bidi overrides), whitespace collapse, 200/300 character caps, URLs moved tourl(GDELT stores its link there andcontent: ""). Flags:stripped_control,had_url,truncated,instruction_like. - Quarantine:
instruction_likesignals (instruction phrasing such as "ignore all previous instructions", "system prompt", "you are now", or our JSON keys; not the bare word "ignore") are never sent to the model. FRED rows are built from numbers by our code and are never quarantined. If nothing is left, the cycle is skipped (NO_USABLE_SIGNALS). - Delimit:
renderUntrustedBlockemits one JSON line per signal inside<untrusted_signals nonce="…">(6 random bytes per call, never derived from content); look-alikes of the tags inside the data become‹untrusted_signals. Existing markets use the same treatment (existing_markets). Signals have citable idsn1…,f1…,m1…. - Hierarchy: both system prompts say the block is data that may contain
instructions and must not be followed; proposals must cite
grounding_signal_ids. - Policy (
checkProposal, after schema validation): reasonsUNGROUNDED(unknown ids, or no cited signal shares two meaningful tokens with the question),PROBABILITY_OUT_OF_POLICY(outside [0.05, 0.95], rejected not clamped),EXPIRY_OUT_OF_RANGE(outside 1–90 days),ORACLE_NOT_ALLOWED(ALLOWED_ORACLES),BANNED_TOPIC(frozen patterns),URL_IN_QUESTION(URLs, handles, code fences),INSTRUCTION_ECHO,NON_ASCII_CONTROL. A failure throwsPROPOSAL_REJECTED_BY_GUARD(422).
Rejections write PROPOSAL_REJECTED and quarantines SIGNALS_QUARANTINED to
agent_logs with reason codes, counts and sha256(canonicalJson(signal)), never
raw titles. tests/fixtures/injection/*.json holds 28 attack and 18 benign cases
with recorded model outputs; tests/llm-injection.test.ts replays them through
generateMarketQuestion (model replaced by setLlmJsonCaller).
src/lib/llm-budget.ts (state in Redis, atomic Lua in llm-budget-store.ts)
replaces the per-process interval guards and circuit breaker. Every provider
attempt is acquire() → call → record():
- Token bucket per provider (
LLM_ANTHROPIC_RPM,LLM_GEMINI_RPM, capacity = RPM). An empty bucket makesacquirewait with jitter for up to 30 s. - Circuit breaker per provider:
closed → open → half-open. A daily quota error opens it (Gemini: until at least the next UTC midnight);LLM_BREAKER_FAILURE_THRESHOLDconsecutive errors open it forLLM_BREAKER_OPEN_MS. After that exactly one probe is admitted (its holder has a TTL, so a hung probe cannot block recovery); success closes the breaker, failure reopens it.LlmCircuitOpenis503 LLM_CIRCUIT_OPEN; the router then tries the next provider. - Daily budgets per UTC day across providers and instances:
LLM_DAILY_TOKEN_BUDGETandLLM_DAILY_COST_BUDGET_MICROS. A call reservesceil(chars/4) + maxTokens;record()releases the reservation (also on errors and responses without usage) and adds the real input/output tokens and an estimated cost fromLLM_PRICES(an estimate; update it when prices change). Crashed calls are reclaimed when their lease TTL passes. Cron work stops atbudget × (1 − LLM_ADMIN_RESERVE_PCT%); admin work runs to the full budget.LlmBudgetExceededis429 LLM_BUDGET_EXCEEDEDand never falls back to another provider (the budget is global). The market cycle logsLLM_BUDGET_EXCEEDEDonce per day (RedisSET NX) and returnsnull. - Redis down:
LLM_REDIS_FAIL_MODE=closed(recommended) refuses calls withLLM_CIRCUIT_OPEN/GUARD_STORE_UNAVAILABLE;openfalls back to a per-process limiter and warns at most once a minute.
Time comes from Redis TIME inside the scripts, so clock skew between instances
does not matter; this is also why budgets reset at UTC midnight, unrelated to
checkDailyDrawdown's local midnight. The Anthropic client uses maxRetries: 0 so
the limiter sees every HTTP attempt. Callers tag calls with a CallContext
(purpose: market_generation, trace_hedge, admin_generation; priority:
cron or admin); POST /markets/generate runs as admin. getLlmUsage() (tokens,
cost, reserved, breaker states) is part of GET /admin/status and logged after each
cycle.
Tuning: raise LLM_DAILY_* for more volume; keep LLM_ADMIN_RESERVE_PCT large
enough that an admin can still trigger a market when cron has stopped; set the RPM
values at or below the provider's account limits; look for repeated
LLM call recorded lines with outcome: "error" before lowering the breaker
threshold.
pinJson() writes the exact pinned bytes to ipfs_content (Postgres
bytea) after the pin succeeds; a store failure is logged and does not fail
the pin. fetchVerifiedBytes(cid, expectedSha256, verifiedBy):
- reads the local row and re-hashes its bytes;
- returns it only when both the stored hash and the freshly computed hash equal the expected hash (on-chain or pin hash);
- otherwise consults the gateway, stores bytes only after they verify, and returns unverified bytes without caching so callers can report a mismatch.
sha256 is the admission criterion; the CID is only a lookup key. A CID can
never map to two hashes (IPFS_CID_HASH_CONFLICT). Objects above
IPFS_CACHE_MAX_OBJECT_BYTES are not stored. An hourly job evicts
GATEWAY_VERIFIED rows by least-recent access, then 30-day-old PINNED rows,
never touching content referenced by an unpublished trace or a PENDING
market (one SQL NOT EXISTS query, batches of 500).
src/services/transparency-report.service.ts builds a deterministic
oracledesk.transparency-report v1 document from chain reads and
chain_events only (no DB floats). Amounts are base-unit decimal strings.
The daily job pins the canonical bytes, publishes the hash through the
reasoning registry under the sentinel u64::MAX market id with action
report, and persists the index row. A crash after pin re-publishes the
stored bytes rather than recomputing. See docs/transparency-reports.md.
The Postgres database enforces key invariants at the storage layer via Postgres enums and CHECK constraints:
- Enums:
PaymentEventType(DAILY_PASS,PER_TRACE,REFUND),PaymentEventStatus(PENDING,CONFIRMED,FAILED,REFUNDED),GenerationJobStatus(PROCESSING,COMPLETED,FAILED), andSettlementCurrency(USDC). - Probabilities:
Market.initialYesProb,Market.currentYesProb,ReasoningTrace.probabilityEstimate,ReasoningTrace.marketProbability,Position.entryPrice, andPosition.currentPriceare constrained to[0, 1]. - Confidence intervals:
confidenceIntervalJSON is constrained to object shape{ "lower": number, "upper": number }withlower <= upper. - Hashes and IDs: 64-character lowercase hex strings (
^[0-9a-f]{64}$) for transaction hashes, question hashes, resolution hashes, and trace hashes. On-chain IDs (onChainMarketId,onChainTraceId) must be non-negative. - Amounts: Base-unit decimal strings ending in
Raw(seedAmountRaw,amountRaw) must consist solely of digits. Numeric amounts (Trade.amount,CopyTrade.amount,Subscription.amountPaid,SpendingAllowancelimits) must be non-negative. - Foreign keys and cascading:
PaymentEvent.traceIdandPosition.hedgeMarketIdreference their targets withON DELETE SET NULL. Audit history (copy_trades->market,copy_trades->trace) is protected usingON DELETE RESTRICTso market deletion cannot silently erase copy-trade records. - Constraint migrations: Implemented using the
NOT VALID-> repair ->VALIDATEpattern, withscripts/audit-constraints.tsproviding pre-migration and CI verification.
External dependencies (Stellar RPC, Pinata IPFS, Gemini LLM, GDELT, and FRED) are wrapped with:
- Bulkheads: Per-dependency concurrency and queue capacity limits via
Bulkheadinsrc/lib/resilience.tsto prevent pool exhaustion and cascading failures. - Timeouts & Deadlines: Explicit per-call timeouts (
withTimeout) and end-to-end deadlines for multi-step agent cycles (market-maker,trader). - Signal propagation: Express request aborts (
req.signal) propagate down to external network calls. - Graceful shutdown:
src/lib/shutdown.tscoordinates teardown across HTTP, database, Redis, and background cron loops.
Reasoning traces are audited and verified against the on-chain registry (reasoning_registry):
trace-audit.service.tsperiodically audits published and pending traces, classifying them asVERIFIED_ON_CHAIN,UNLINKED_IN_DB,CONTENT_HASH_MISMATCH,MISSING_ON_CHAIN, orARCHIVED_ON_CHAIN.- Live published traces start unverified (
verified: false) until independently verified by the auditor or verified explicitly viaPOST /traces/verify. - Admin endpoints (
/admin/trace-audits) allow triggering on-demand audits and inspecting audit histories.
Copy trades are verified against on-chain transaction metadata:
- Copy trade requests are tracked through states (
PENDING,CONFIRMING,CONFIRMED,FAILED,EXPIRED). - When a user submits an on-chain transaction hash (
POST /trade/copy/:id/confirm),copy-trade.service.tsinspects the transaction status, envelope, and contract events (CONTRACTS.marketCoreTradeevent inresultMetaXdr) to verify market ID, trader wallet, outcome, direction (is_buy), and collateral amount. - Pending transactions return
202 CONFIRMINGand are swept by the indexer projection and a background cron job.
src/config/secrets.ts is the only runtime source of secret values.
SECRETS_PROVIDER=env(default) reads the environment;filereads<NAME>_FILEmounts and re-reads them on refresh;vaultreads a Vault KV v2 path using a token file. AWS Secrets Manager can be added through the sameSecretsProviderinterface.- Boot resolves files in
loadConfig, theninitSecretsvalidates and stores raw values as Buffers. Empty or invalid refresh values keep the previous value; a provider failure keeps everything. - JWT rotation keeps the previous key for
JWT_PREVIOUS_SECRET_GRACE_SECS(default 24h), then zeroes it. Expired tokens are never retried against the previous key. - SDK clients (Gemini, Anthropic) rebuild when the key version changes. Values already copied into an SDK are immutable strings; zeroing the store only guarantees the long-lived copy is gone.
AGENT_SECRET_KEYis deliberately not in the runtime secret store: rotating the agent identity is an on-chain registration change, so it stays env-only and requires a restart.DATABASE_URLmust be available at boot (env orDATABASE_URL_FILE); rotating it through Vault would not rebuild Prisma.
npm run db:seed builds a deterministic dataset through src/seed/scenario.ts.
Every question/trace hash is computed with the production helpers and the exact
bytes are written under .ipfs-local/ (CIDv1 raw/sha2-256). It refuses
production, live-chain mode and non-local databases unless
SEED_ALLOW_REMOTE=1.
npm run snapshot:export -- --out <dir> streams anonymised NDJSON. The column
map is fail-closed: a new column without an anonymisation rule stops the export.
Hashed identifiers use SNAPSHOT_SALT; Stellar addresses are mapped to
deterministic throwaway G-addresses.
Custom-format dumps plus a manifest (migrations, cursor, max ledger, counts)
and checksums. Restore then replays the chain gap with
scripts/post-restore-reconcile.ts; see the runbook.