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,CanonicalStatuslive in PAL core (backend/src/exchanges/core/orderStatus/).CanonicalOrdergainscanonicalStatus+sizeVoided. - Single classification point: a shared
finalizeCanonicalOrder(order)inexchanges/corestampscanonicalStatus = getCanonicalStatus(orderToSizeVector(order))(+ carriessettlementResult), 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.canonicalStatus→applyTransitionSTOREs it + routes the netting transition; readscanonicalOrder.settlementResult→ settlement loop. #950's provider-specific classification innormalize.tsdissolves into thin routing overCanonicalOrderfields.
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
uocarry 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.tsclassification (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.tskeeps the vocabulary + evaluators + themapLegacyStatusmigration 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 inBifrostBetConsumer) — must be mapped toCanonicalOrderINSIDE 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
canonicalStatusfor all sources is staged (Betfair first, matching #950's Betfair-v1 scope).