0011. Audit-first order path
- Status: Proposed
- Date: 2026-09-30
Context
Phase 3 places real orders with one click. The failures that cost money are an order sent twice (a double click, a replayed request, a retry after a timeout), an order sent without passing the limits, and an order whose fate is unknown after a lost response. The owner must also be able to show what the app asked for and what the venue answered, and tell the app’s orders from anyone else’s. The Phase 0 security review asked for an idempotency key per intent (F6) and an append-only audit log that cannot be edited quietly (F15).CLAUDE.md forbids retrying a non-idempotent venue request. The Phase 3
recheck found that signing and sending in one call lost the order hash when the process
stopped before the response row (P3R-06).
Decision
Every order goes throughengine/orders; there is no other way to submit one.
- The browser mints a UUIDv7
client_order_idper user intent, at pointer-down. It is the idempotency key; a repeat press within 350 ms reuses it. engine/orderswrites an intent row toorder_audit. The key is unique: a repeatedclient_order_idreturns the first result and sends nothing.engine/riskchecks the intent;engine/orderswrites a decision row (approved, or the reject code).- Only an approved intent is signed:
core.Trading.Signsigns it and sends nothing. A signing failure is final as rejected (no_credentials); nothing was sent. engine/orderswrites a signed row with the venue order id (Polymarket: the order hash) and the payload hash (SHA-256 of the exact request body). If the row cannot be written, nothing is sent.core.Trading.Sendposts that exact body once.engine/orderswrites a response row: the order id and status, the venue reject, orunknown. A send error or a missing result isunknown, with the signed order id.
- Rows are separate and keyed by
client_order_idand kind, never one row updated. Onlystore.Writerinserts;BEFORE UPDATEandBEFORE DELETEtriggers abort any change. - Kinds:
intent,decision,signedandresponseas above;reconcilefor what reconciliation found;endedwhen the order leaves the book (matched, cancelled, or no longer open), so it no longer explains later account activity (P3R-03);cancelbefore a cancel is sent andcancel_resultafter;baselineonce, when the first start adopts the orders already open (P3R-04). - Each row carries a hash over its contents and the previous row’s hash.
task audit-verifyrecomputes the chain and reports the first row that does not match. - Rows hold a hash of the signed payload, never the signature, L2 headers or a key.
unknown placement is never sent again. It is
reconciled by its signed order id and nothing else: the open orders holding that id, a
user-feed fill of it, or a lookup of it at the venue. Lookups run 2 to 30 s after the
unknown outcome, then every 60 s while the venue does not answer; meanwhile the
placement holds its reservation and counts in unresolved_placements (P3R-07). An id
the venue does not know after the last attempt is final as rejected. At start, every
approved placement without a final row is queued with its stored order id. Account
activity not explained by order_audit (trades, outgoing transfers, splits, merges,
conversions) raises an alert and switches to SAFE (security F5, P3R-02).
Consequences
- A double click, a replayed request or a browser retry places at most one order.
- A crash after the signed row is reconciled at the next start by its order id. A crash before it leaves nothing on the book, because nothing was sent.
- The owner can compare the app’s orders with Polymarket’s history row by row, and the chain shows whether rows were changed. It does not stop someone with write access from rewriting the whole chain; the comparison with the venue’s records covers that.
- The intent, decision and signed inserts sit on the latency path of every click. SQLite runs in WAL mode; one placement timeout covers signing, the signed row and the send.
- An
unknownorder leaves the user unsure whether to click again. The UI shows it asChecking…, and the incident runbook says to wait.
Alternatives considered
- One row per order, updated as it progresses. Rejected: updates lose history and make an edit look like a normal state change.
- Retry a placement once after a timeout. Rejected: if the first request reached the venue, the retry places a second order. The venue has no idempotency key for orders.
- Sign and send in one adapter call. Rejected: a crash after the send and before the response row loses the order hash, so the order can only be reported as foreign (P3R-06).
- Write the audit row after the venue call. Rejected: a crash after sending would leave an order on the book with no record.
- Keep the audit trail in the application log. Rejected: logs rotate and are not append-only.