Skip to main content

PAL emits a complete CanonicalOrder (incl. canonicalStatus); no provider-specific downstream normalize

Status: ACCEPTED (Bhargav, 2026-07-09). Refines ADR-0012. Reshapes #950's orderState/normalize.ts.

The canonical order status is derived and attached in the PAL adapter layer, so PAL's contract to downstream is a complete CanonicalOrder — vector + canonicalStatus + settlementResult. There is no separate, provider-specific normalization layer downstream of PAL. A downstream normalize.ts that re-classifies per provider (as #950's normalizePollUpdate/normalizeClearedGroup do) leaks provider knowledge out of the abstraction PAL is meant to seal it inside — the boundary violation this ADR closes.

Decision

  • Home: getCanonicalStatus, orderToSizeVector, SizeVector, CanonicalStatus live in PAL core (backend/src/exchanges/core/orderStatus/). CanonicalOrder gains canonicalStatus + sizeVoided.
  • Single classification point: a shared finalizeCanonicalOrder(order) in exchanges/core stamps canonicalStatus = getCanonicalStatus(orderToSizeVector(order)) (+ carries settlementResult), called at the tail of EVERY adapter mapper (betfair/bifrost/pinnacle) — one implementation, no per-adapter copy. The classifier is a PURE function (no I/O); the adapter is the impure boundary that calls it — same pure-core / impure-shell principle as #950.
  • Downstream consumes, never re-derives: the router reads canonicalOrder.canonicalStatusapplyTransition STOREs it + routes the netting transition; reads canonicalOrder.settlementResult → settlement loop. #950's provider-specific classification in normalize.ts dissolves into thin routing over CanonicalOrder fields.

Feasibility (grounded)

  • Adapter mappers already assemble the full vector (requestedStake=priceSize.size, matchedStake, sizeMatched/Remaining/Lapsed/Cancelled) — the classifier runs in place.
  • Betfair current-orders AND stream uo carry CUMULATIVE sizes → a single observation is a complete vector; classification is per-observation, not delta-dependent.
  • The mapper already classifies status today (mapExecutionCompleteStatus) — this replaces that provider-specific classifier with the one canonical function (less code, provider-agnostic).

What #950 must change (joint redesign — needs Pran, not a rebase surprise)

  • orderState/normalize.ts classification (matched-vs-sent, hasLiveRemainder, residue if-ladders) → removed; moves into the canonical classifier.
  • Betfair mapExecutionCompleteStatus / mapPlacementStatus / isLiveRestingPartial (size-based) → retired, replaced by the one canonical call in the mapper.
  • states.ts keeps the vocabulary + evaluators + the mapLegacyStatus migration mapping, but is no longer a derivation site.
  • Update sources not yet CanonicalOrder in the adapter — Betfair cleared-orders (BetfairClearedOrderGroup, currently service-layer), Betfair stream (raw lapse events), Bifrost queue (raw in BifrostBetConsumer) — must be mapped to CanonicalOrder INSIDE their adapters.

Explicit non-goals / exceptions (still downstream, still provider-agnostic)

  • "An order we hold open is ABSENT from the provider poll → infer lapsed" is a set-diff between our open set and the provider's returned set — no observation to canonicalize, so it stays in the sync loop. Not a provider normalize.
  • Idempotency (high-water / residueCancelledAt) stays in #950's pure transition + applier.

Consequences

  • OUR PR becomes deeper (PAL classifier + finalizeCanonicalOrder + adapter mapping moves) and reshapes HIS (normalize dissolves) — a coordinated change across #950/#951, aligned with Pran directly.
  • Bifrost/Pinnacle: the classifier is provider-agnostic and ready; wiring their adapters to emit canonicalStatus for all sources is staged (Betfair first, matching #950's Betfair-v1 scope).