Skip to main content

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 through engine/orders; there is no other way to submit one.
  1. The browser mints a UUIDv7 client_order_id per user intent, at pointer-down. It is the idempotency key; a repeat press within 350 ms reuses it.
  2. engine/orders writes an intent row to order_audit. The key is unique: a repeated client_order_id returns the first result and sends nothing.
  3. engine/risk checks the intent; engine/orders writes a decision row (approved, or the reject code).
  4. Only an approved intent is signed: core.Trading.Sign signs it and sends nothing. A signing failure is final as rejected (no_credentials); nothing was sent.
  5. engine/orders writes 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.
  6. core.Trading.Send posts that exact body once.
  7. engine/orders writes a response row: the order id and status, the venue reject, or unknown. A send error or a missing result is unknown, with the signed order id.
The table:
  • Rows are separate and keyed by client_order_id and kind, never one row updated. Only store.Writer inserts; BEFORE UPDATE and BEFORE DELETE triggers abort any change.
  • Kinds: intent, decision, signed and response as above; reconcile for what reconciliation found; ended when the order leaves the book (matched, cancelled, or no longer open), so it no longer explains later account activity (P3R-03); cancel before a cancel is sent and cancel_result after; baseline once, 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-verify recomputes 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.
No automatic re-send. An 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 unknown order leaves the user unsure whether to click again. The UI shows it as Checking…, 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.