Price Format travels with every price, and is snapshotted on the order
Status: Accepted. Extends ADR-0024 (0024-haar-jeet-wire-is-provider-native.md) by ratifying its
unshipped PR2 and supplying the order-price snapshot that ADR-0024 named as PR2's gate.
ADR-0024 established that HAAR_JEET wire prices stay provider-native and that every consumer must attend on the market's declared Price Format. PR1 landed; PR2 did not. In the resulting half-state a Provider-Native Price and its Accounting Odds both circulate, no consumer outside the betting grids carries the Price Format, and no order records the unit it was placed in. This ADR closes that state.
Decisions
-
PR2 is executed as ratified. The client carries the Provider-Native Price labelled by Price Format, and never the decimal echo. The alternative of transmitting both units is rejected here for the same reason ADR-0024 rejected it: it makes inconsistent units structurally possible.
-
Every order carries an immutable placement-time price snapshot: Price Format plus Provider-Native Price, alongside the existing decimal Accounting Odds. The snapshot is written for all orders, not only provider-submitted ones — Betfair orders (Price Format
DECIMALby provider definition), Bifrost orders, mock orders (Order.isMock), and self-book / PT=0 orders.ProviderPlacementIntentremains Bifrost-only placement evidence and is not a substitute: it does not exist for Betfair orders, mock orders, or self-book orders, and a provider intent must never be fabricated for an order that was not submitted to a provider. -
An order whose Price Format is not evidenced is rendered as decimal Accounting Odds, explicitly labelled as such — never inferred. No backfill may derive a Price Format from market ID prefix, market shape, numeric range, or sport.
BifrostLegacyPriceEvidenceremains the only audited path to bind a real Price Format to a legacy order, and it stays operator-approved and per-order. -
market_dictionary.provider_price_formatis dropped. It is populated by the inferring resolver (mapPriceFormat, which falls back to market structure) and is observably wrong — it labels soccerMATCH_ODDS_SBas HAAR_JEET while the live book declaresDECIMAL. A column that is usually right is worse than no column, because it invites a join.resolveBookPriceFormatStrictover the market's own declaration becomes the single authority; a book whose declaration does not resolve is quarantined, not guessed (consistent with ADR-0021). -
The preview seam mirrors placement, and fails loud rather than degrading. Placement already forwards the Provider-Native Price on the legacy-decimal quote shape (
orderService.ts—...(input.nativeOdds !== undefined ? { nativeOdds } : {})); the preview seam omits that spread, which is the whole reason placement succeeds on HAAR_JEET markets while preview returns 500. Preview is brought to parity, so the punter gets a correct Max rather than no Max.Where the evidence is genuinely absent — a HAAR_JEET market reached with no Provider-Native Price, which means a stale or foreign client — the request raises
DOMAIN_INTEGRITYwith a distinct error code and alerts Slack. It does not silently degrade to "uncapped", and it does not default. A punter seeing an explicit error is recoverable; a punter shown a cap derived from a guessed unit is not. This supersedes the earlier compatibility-shim formulation: there is no shim, because parity removes the need for one.Settled while implementing (S0):
- Parity is structural, not asserted. Both seams build the legacy-decimal quote price through
one shared
buildLegacyDecimalQuotePrice(backend/src/exchanges/core/orderPrice.ts). The two seams had independently constructed that object, which is how one came to carry the native price and the other not. A test over two independent builders would have caught this drift once; a shared builder makes it unrepresentable.nativeOddsis spread only when present — an absent native price stays absent, never 0 and never the decimal value restated. - The decline is a typed provider error, not a sniffed message. The adapter raises
ProviderNativePriceMissingError(code: 'NATIVE_PRICE_REQUIRED', carrying the market id and the declared format) instead of a bareError. Its message text is deliberately unchanged, so the placement path and its existing assertions behave exactly as before; only the preview seam reads the new type. Matching on the message string would have coupled a financial decline to operator wording. - The decline is 422, not 500. The request is well-formed but its price evidence is incomplete — a stale tab or a foreign client, which a refresh fixes. A 5xx would bury a client-recoverable condition in server-error noise and page the wrong person.
- The Slack alert is throttled on a fixed 15-minute window, against two distinct floods. Preview is a keystroke-frequency endpoint, so an unthrottled alert would drown the channel the way the catalogue integration did. One market, many requests is collapsed by a per-window market set. Many markets, few requests each — a foreign client sweeping the catalogue — is bounded by a per-window alert cap, without which the throttle's own tracking map would also grow with every market id the process had ever seen. When the cap is reached, one final alert says so: silence must never be readable as the problem having stopped. Throttling governs the alert only — every request still declines and every decline is still logged, so no punter is ever shown a guessed Max while the channel is quiet.
- The pre-check this could become. S0 detects the missing native price at the provider
boundary because, before S1, only the market book knows the declared format. Once
MarketMeta.priceFormatexists the preview seam can refuse before requesting a quote at all. That is an optimisation, not a correctness change: the decline is identical either way.
- Parity is structural, not asserted. Both seams build the legacy-decimal quote price through
one shared
-
The snapshot is two columns on
Order(price_format,native_odds), not a joined table. Immutability is enforced as a constraint — a database trigger rejecting UPDATE on those columns — rather than by table shape. A 1:1 side table would reintroduce on every read surface exactly the join whose absence caused this class of bug.Settled while implementing (S2):
-
The trigger is WRITE-ONCE, not never-writable. It blocks changing a value that is already set — including back to
NULL— but deliberately permitsNULL → value. That transition is the only way a legacy order ever gets a snapshot: decision 9's backfill and the operator-approvedBifrostLegacyPriceEvidencepath both need it. A trigger rejecting every UPDATE would have made the sanctioned backfill impossible and pushed it toward disabling the trigger to run — and a guard that gets switched off is not a guard. Verified against real Postgres: settlement's unrelated updates pass,NULL → valuepasses, re-applying the same value passes (so the backfill is idempotent), and every rewrite of a set value is refused. -
This is the repository's first database trigger. Nothing in
prisma/migrationsused one before. It is justified here because the writers that must not touch these columns — settlement, void, cancellation, matching, reversal — are numerous, already rewrite neighbouring columns, and will grow; application discipline cannot cover all of them for the lifetime of the table. -
The snapshot is resolved before the balance transaction opens. An order whose price unit cannot be evidenced therefore declines while nothing has moved, rather than aborting mid-flight and relying on rollback.
-
Evidence is ranked, and the ranking is the design. An authorized provider quote wins outright and is taken verbatim — no re-derivation and no cross-check against a MarketMeta that may have ticked since. With no quote, the market's own declaration (S1) decides, and on a
DECIMALmarket the native price is the decimal price by definition, so nothing is invented. AHAAR_JEETmarket with no quote and no native token refuses the bet: the format is known but the token is not, and rebuilding it from the decimal is precisely what INV-2 forbids. -
native_oddsis TEXT, kept verbatim.'4.50'and'4.5'are the same number and a different token; the punter saw one of them. Mirrorsprovider_placement_intents.native_oddsrather than parsing into a numeric column whose precision we would be choosing. -
A client-supplied native token is NOT evidence, and the signature refuses to take one. The first implementation accepted
input.nativeOddsas the snapshot for a HAAR_JEET market with no provider quote. Review caught that this is unsound, and it is: on that path the Accounting Odds are themselves the client'sodds, so one request could set the settlement price to 2.0 and the display price to 500 — an order accounted at one price and rendered forever at another, frozen by the very trigger meant to protect it. Note thatNATIVE_PRICE_PROVENANCE_UNAVAILABLEdoes not cover this: it gates onlypriceShape: 'provider_native', while the legacy-decimal shape carriesnativeOddsas an optional companion straight past it.Converting the token and comparing it to the Accounting Odds was considered and rejected on two grounds: it would move a haar-jeet conversion outside the adapter boundary, which INV-3 forbids, and it only proves two client-supplied numbers are self-consistent, not that either is a real price on the market. So the parameter was removed outright — the hole is closed by the type rather than by a runtime check a later change could relax.
-
Consequently,
resolveOrderPriceSnapshotperforms no conversion at all, which keeps INV-3 measurably intact: this slice adds zero conversion call sites in either direction. -
Immutability alone would happily freeze a WRONG value, so the database also states the snapshot's domain:
price_formatrestricted to the two legal values, both columns null or both set, and no blank token. All three permit both-null, which is the legacy state and the permanent state of the orphan orders in decision 9. The CHECKs are validated immediately rather thanNOT VALID: prodordersis 8,019 rows / 13 MB so the scan is milliseconds, the migration already holds the same lock for twoADD COLUMNs and aCREATE TRIGGER, and a migration runs once against the table as it exists at deploy time — so "the table only grows" does not apply. Splitting would also not have helped in one file: this repo's existingNOT VALIDprecedent shipsVALIDATEas a deliberately separate migration, because same-file validation runs in the same transaction and holds the lock throughout. -
A refusal is classified as a decline, not a fault. Both refusal paths throw a typed
OrderPriceUnevidencedError, which the placement path maps to a 4xx carryingPRICE_FORMAT_UNEVIDENCEDplus the sameDOMAIN_INTEGRITYlog and Slack alert the neighbouring market-meta gates use. Left as bareErrors they reachederrorHandler's "Programmer error (unexpected)" branch and became HTTP 500 "An unexpected error occurred": no code for the client to render from, and — worse operationally — a market going un-bettable would have shown up as a 500 rate, indistinguishable from a real server fault. -
The quote and the cached declaration are compared, and a disagreement is alerted. Placement trusts the quote, but every other surface — preview, the betting grids, exposure — still reads the cached declaration, so a divergence means that market is being displayed in one unit while orders are placed in another. Decision 10 says such a change is detected rather than designed around; the placement path is the only place holding both values, so the observation is made there.
-
The DB guards have regression coverage, not just installation coverage. Sanity test 21 writes through raw SQL — bypassing the application layer entirely, which is the only reason a trigger is warranted over a code guard — and asserts every permit and refuse case. Without it, deleting the trigger block from the migration would have left CI fully green.
Warning for S5. Its acceptance criterion says the
NATIVE_PRICE_PROVENANCE_UNAVAILABLEblock can be removed "since S2 supplies the provenance". S2 supplies storage, not provenance. Real provenance for a quote-less order has to come from validating a price against the live market book the adapter already holds. Deleting that block without adding such validation reopens exactly the hole described above. -
-
MarketBook.oddsTypeis the sole price-format authority, per Bifrost's own instruction ("please rely on market odds type", 2026-02-24; semantics confirmed with Michael Cheremuhin, 2026-03-09).MarketCatalogue.oddsType(LINE|ODDS) is market structure and never selects price format.MarketMetagains apriceFormatfield sourced fromresolveBookPriceFormatStrictso pre-placement paths can read the declaration without a quote round-trip.Settled while implementing (S1):
-
Resolved in the mapper, not at the cache write. The declaration is resolved from the same book observation the rest of the market was mapped from, and carried on
CanonicalMarket.declaredPriceFormat. Re-reading the book at MarketMeta-write time would have allowed the declared format to come from a different book revision than the prices it labels.declaredPriceFormatis deliberately separate from the existing display-axisoddsType: the two disagree when the book declares nothing, and that disagreement is the point — display may fall back on structure, the money path must seeundefined. -
MarketMeta.priceFormatis optional in the type but required at the write. The cache has no TTL, so rows written before this change carry no format. Typing the field as required and throwing on read would have stopped betting on every market at once and only recovered as the cache repopulated — an availability incident in exchange for no extra safety, since the write path already guarantees every new row has it. InsteadsetMarketMetarefuses a write without a resolved format, andrequireMarketMetaPriceFormatis the only sanctioned reader, so a consumer cannot quietly write?? 'DECIMAL'.undefinedtherefore means exactly one thing: a legacy row, which drains on the next catalogue refresh. -
Quarantine reuses the existing refusal mechanism. An unresolved declaration is skipped and warn-logged, exactly as a blank
marketNamealready is: the batch continues, the market is not added to the written set, and it fails closed at read time (cache miss → bet declined) until the provider declares a format that resolves. Verified: one quarantined market does not abort its batch.The two gates sit on different paths, which matters for observability. The adapter gate catches the ordinary case — a book that declares nothing — and returns before
setMarketMeta, sosetMarketMeta's own refusal is never reached there. The adapter warning therefore carries theDOMAIN_INTEGRITYmarker itself: it is the only signal ops gets for the quarantine that actually occurs in production, and aDOMAIN_INTEGRITYgrep that missed it would show a clean board while markets were being withheld.setMarketMeta's refusal remains the backstop for any other caller, with its own marker. -
Betfair states
DECIMALat its own write site, not through a mapper resolver. Betfair carries nooddsTypefield to resolve — the format is a provider definition, so it is asserted where the meta is built, with the reasoning recorded there. -
Scope boundary. This closes the money axis only.
mapPriceFormatstill readsMarketCatalogue.oddsTypefor the display axis and the dictionary column; removing that is S6, which is what makesresolveBookPriceFormatStrictthe codebase's only price-format resolver.
-
-
The price triple is the type for every provider, not only Bifrost. A Betfair price is
{ format: 'DECIMAL', native === decimal }, labelled explicitly. Betfair markets currently carry nooddsTypeat all and render correctly only becauseundefinedfalls through to the decimal branch — an accidental default, not a design. Leaving any provider on a bare-number path keeps aformatOdds(number)overload alive and voids the compile-time guarantee that prevents regression. -
Forward correctness is the requirement; historical backfill is optional. Betfair rows backfill to
DECIMAL(definitional, no inference) and the 116 Bifrost orders with aProviderPlacementIntentbackfill automatically from that intent. The 680 orphans stay unlabelled and render as labelled decimal. Shipping with no backfill at all is acceptable — the binding requirement is that no order placed after this change can ever lack its Price Format. -
A price-format change on an open market is not designed for; it is detected. Bifrost's declared format is understood to be fixed once a market opens, and no case of it changing has been observed in operation. Rather than build a re-confirmation flow for a condition that does not occur, the ingest path raises a Slack alert if a market's declared
MarketBook.oddsTypeever changes while the market is open. If the alert fires, the assumption is wrong and the design is revisited with real evidence instead of speculation.Settled while implementing (S7):
- The comparison lives on the market BOOK message, not on the MarketMeta write.
MarketMeta.priceFormatis the obvious comparand and is the wrong one.BifrostAdapter.populateMarketMetaCacheis only ever called with markets absent fromwrittenMarketMetaIds, and that set is never cleared, so a market's MarketMeta is written exactly ONCE per process lifetime. A comparison there is unreachable on the case the detector exists for — a format changing mid-process, on a market whose meta was written hours before.BifrostQueueManager.handleMarketBooksees every book message for every market, independent of whether anyone is browsing the fixture, and already reads the previous book before the cache overwrites it (for the market-unsettlement check). The book cache is therefore itself the record of what a market last declared, so the detector adds no state and no I/O to the hot path. setMarketBookreturningfalsemeans two different things, and only one of them means "nothing changed". A book rejected as STALE (lower version, same status) is superseded news that never becomes the market's declaration; Bifrost recovery replays resend old versions, so comparing those would manufacture alerts for changes that already didn't happen. But the cache ALSO returnsfalsefor a redundant TERMINAL tick — a same-statusSETTLED/CLOSEDbook — and it short-circuits there for CPU reasons before it ever reads the declaration. A format change on such a book is real, and orders can still be awaiting settlement on a closed market, so gating onacceptedalone silently swallowed it. The detector therefore also observes the redundant-terminal case, flaggeddeclarationRecorded: false. The terminal predicate is imported fromBifrostCache(isTerminalMarketStatus, whose docblock already required one definition shared by the short-circuit andsweepExpired) rather than restated, so the gate cannot drift from the short-circuit it mirrors.- Repeat suppression follows whether the baseline can advance. On the accepted path each observation is genuinely new — the cache advanced, so a repeat means the format changed again — and every occurrence is logged. On the frozen path the cache kept the previous book, so every later tick of a re-streaming dead market re-observes the identical transition; those repeats carry no information and the log is collapsed to the first. This is what keeps "never suppress a new observation" and "don't emit an error line per tick for a dead market" from being in conflict.
- Quarantined is not changed. Both sides of the comparison resolve through
resolveBookPriceFormatStrict, and an unresolvable declaration on either side ends it. A market going "declares nothing" →HAAR_JEEThas not changed its unit, it has finally stated one. Alerting there would invent a previous unit the market never declared and page someone for decision 4's ordinary quarantine — the mirror image of the?? 'DECIMAL'this ADR bans, and wrong for the same reason: absence is not a value in either direction. - Status is reported, not used as a filter. The alert fires on a change at ANY market
status. Live orders keep holding their native token while a market is suspended or awaiting
settlement, so the hazard is not confined to
OPEN; gating onOPENwould drop real incidents to make the trigger match the sentence describing it. - Default channel, CRITICAL — deliberately not the
cataloguechannel. That channel has no fallback to the default webhook by design (decision recorded inslackAlertService.ts: it exists to keep ingestion volume off the shared channel), so whereverSLACK_CATALOGUE_WEBHOOKis unset it sends nothing at all. Silence is an acceptable outcome for catalogue noise and an unacceptable one for a financial incident. - Flood-bounded without ever going silent. The unchanged-declaration check delivers the
no-flapping requirement, but a market that oscillates would still raise a fresh CRITICAL on
every tick, so Slack is deduped per
market:FROM->TOtransition — one alert per direction. Thelogger.erroris never suppressed, so a quiet channel can never be read as the problem having stopped (the same posture decision 5's throttle takes). - The live-order count is keyed on
(bookmaker, marketId)and deliberately omitsfixtureId. Bifrost fixture ids exist in several representations on order rows, so narrowing by fixture would silently under-count; understating the blast radius of a financial incident is worse than a slower query, and this query only runs once a change is detected. A failed count is reported as an explicitUNAVAILABLE, never as0— a fabricated zero reads as "no orders affected, stand down".
- The comparison lives on the market BOOK message, not on the MarketMeta write.
-
Display and placement stay in the provider's declared format, and conversion stays one-directional and confined. A price is shown in the format the provider declared for that market, and placement submits the exact Provider-Native Price from the authorized quote — never a value reconstructed from decimal. Conversion exists only to derive Accounting Odds for netting, exposure, margin, and settlement. Measured on
origin/devat decision time: 8 native → decimal call sites, all insideBifrostAdapter.ts/BifrostMapper.ts, and zero decimal → native call sites in backend or frontend. No change may add a reverse conversion or move a conversion outside the adapter boundary. PR2 reduces how far the derived decimal propagates, not how often conversion happens. -
An unresolved price format refuses the bet. Where the declaration is absent or does not resolve — including the mock and self-book / PT=0 paths, which fetch no provider quote — placement raises
DOMAIN_INTEGRITYand declines. There is no fallback toDECIMAL. This is the same posture ADR-0024 already takes when it blocks those paths from provider-native prices.
Evidence at time of decision (2026-08-10)
- Production order price-snapshot coverage, by whether a
ProviderPlacementIntentexists: Betfair 7,108 orders with no intent (by design); Bifrost 119 with an intent (116 HAAR_JEET, 3 DECIMAL); Bifrost 680 with no intent (placed 2026-06-23 → 2026-07-29, pre-intent). - Production HAAR_JEET orders render their Accounting Odds where the punter saw a Provider-Native
Price: 25 orders placed at native
100display as2.00; 3 placed at native4.5display as1.05. The order read surfaces call the single-argumentformatOdds(order.odds), so no Price Format reachesformatProviderOdds, which already handles the labelled pair correctly. - The dev preview 500s are
FINANCIAL_INTEGRITY: Bifrost HAAR_JEET quote … is missing the displayed provider-native odds, raised because the preview path requests alegacy_decimalquote shape on a HAAR_JEET book.
Consequences
- Price is a
(Price Format, Provider-Native Price, Accounting Odds)triple at every boundary that renders, validates, transmits, or persists a price. Signatures accept the triple, not a bare number, so a dropped Price Format is a compile error rather than a wrong number on a screen. - Every order read surface changes: my-bets, agent panel, admin orders, agent reports, and any statement or export that prints a price. Each reads the snapshot off the order; none infers.
- The snapshot is placement-time and immutable. Settlement, void, cancellation, and reversal never rewrite it. Matched Odds remain separate and keep their existing semantics.
- The 680 pre-intent Bifrost production orders carry no recoverable Price Format and render as
labelled decimal until an operator binds evidence. The 116 HAAR_JEET-intent orders are repairable
by backfill from
ProviderPlacementIntent; Betfair orders backfill toDECIMAL. - Decision 10 makes an operational assumption explicit and instruments it rather than defending against it. The alert is the safety net: if a declared format ever changes on an open market, an order's stored native token could be reinterpreted under a new unit, so the alert must be treated as a financial incident, not a warning to triage later.
- Decision 12 means a market whose book has not yet arrived cannot be bet on mock or self-book. That is a deliberate availability cost paid to avoid a wrong unit on a real order.
- Dropping
provider_price_formatrequires confirming no consumer joins on it before the migration. - The frontend and backend wire flip must deploy together; a partial rollout puts one unit on the wire and the other in the reader.
Numbering note
docs/adr/ on origin/dev contains two ADRs numbered 0024
(0024-haar-jeet-wire-is-provider-native.md and
0024-viewed-fixtures-outrank-in-play-in-stream-truncation.md), and ADRs 0017–0022 exist only as
uncommitted local files. This ADR takes 0025, which is free in both views, and renumbers nothing.
The collision is recorded here so it stays visible.