---
title: Declare every prop market type once, then enforce it
type: task
status: planned
description: >-
  90 market types carrying 239,389 selections have no settlement declaration and zero have ever been
  graded. Merges the per-type declaration registry into market_type_mappings, moves it to
  libs-shared, derives every hand-kept vocabulary list from it, and adds four enforcement layers in
  declare-then-enforce order.
base_uri: user:task/league/declare-prop-market-types-once.md
created_at: '2026-09-19T23:42:40.829Z'
entity_id: 977dd713-c60a-4386-8b04-2642f0596ca6
observations:
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
priority: High
public_read: true
relations:
  - relates [[█████████████████████████-████-██████-█████-████████]]
  - relates [[user:task/league/standardize-prop-market-selection-side-vocabulary.md]]
  - relates [[████████████████████-██████-██████-█████████-██████-████████]]
  - relates [[████████████████████████-█████-████-██████-████-████████████]]
  - relates [[█████████████████████████-███████-████-█████████-███████]]
  - relates [[user:task/league/model-unmodelled-prop-market-shapes.md]]
  - relates [[user:task/league/raise-pinnacle-and-betmgm-market-type-coverage.md]]
  - relates [[██████████████████████-████-██████-████-███████-███████]]
tags:
  - user:tag/league-xo-football.md
  - user:tag/software-task.md
updated_at: '2026-09-26T00:10:02.160Z'
visibility_analyzed_at: '2026-11-06T00:17:48.299Z'
---

# Implementation Plan: Declare every prop market type once, then enforce it

## Overview

- Give every prop market type exactly ONE declaration, carrying its side vocabulary, line shape, selection identity and settlement, and derive every existing vocabulary list from it so they cannot disagree again.
- Expected outcome: `market_type_mappings` moves to `libs-shared`, widens from a settlement table into the single per-type declaration, and covers all 358 constants rather than 253. Every hand-kept membership set becomes a selector over it. Four enforcement layers follow, in an order where none can fire against an undeclared type.
- **Impact.** 90 market types carrying 239,389 distinct selections across 44,515 markets have no settlement declaration at all, and ZERO of them have ever been graded. A further 2,345 rows under `GAME_PLAYER_ANYTIME_SACK` are declared yes/no in one file and threshold-settled in another, so settlement throws on all of them. Both are the same defect: a type's facts live in several places and nothing requires them to agree, or to exist.
- **Ordering.** Adopts Pillar 1 from [[████████████████████-██████-██████-█████████-██████-████████]], deferred 2026-09-16 as "real but not what was costing anything today". Settles the `selection_type` enum question ahead of the rename in [[█████████████████████████-████-██████-█████-████████]] without requiring an enum change, so that rename is unblocked rather than blocked. Carries forward the ruled four-rung ladder collapse from [[user:task/league/standardize-prop-market-selection-side-vocabulary.md]].

## Background

Measured 2026-09-19 against production via `mcp__db__query_league`, joined on all three of `source_id`, `source_market_id` and `time_type`. Every figure below was re-derived rather than inherited.

### The ledger of 358 constants

| State | Count |
|---|---|
| Declared with a working settlement handler | 156 |
| Declared UNSUPPORTED with a stated reason | 97 |
| **No `market_type_mappings` entry at all** | **105** |

The 97 are the honest state: someone looked, ruled, and recorded a reason via `declare_family_unsupported` at `market-type-mappings.mjs:54`. The 105 are indistinguishable from nobody having looked. **90 of those 105 are live in production data.**

### The 90, and the fact that makes this urgent

| Block | Types | Index rows | Distinct selections | Markets | Graded |
|---|---|---|---|---|---|
| Period-scoped game lines | 29 | 229,112 | 122,209 | 25,315 | **0** |
| Team grain (`GAME_TEAM_*`) | 14 | 146,506 | 76,080 | 12,112 | **0** |
| `GAME_PLAYER_LEADER_*` | 3 | 63,392 | 32,725 | 6,564 | **0** |
| Futures (`SEASON_`, `PLAYOFF`) | 44 | 14,743 | 8,375 | 524 | **0** |
| **Total** | **90** | **453,753** | **239,389** | **44,515** | **0** |

Name the unit on every figure quoted from this table. The OPEN/CLOSE ratio is 1.90, not 2.00, so halving a row count to recover selections is wrong in the direction that looks right.

The sharpest single instance: `GAME_MATCHUP_SPREAD` is declared as settling without a side. `GAME_MATCHUP_FIRST_QUARTER_SPREAD` is the same product at a different period and is declared nowhere.

### The residue is 17 types, not 337

- 44 futures take one `declare_family_unsupported` call. They never settle against a gamelog.
- 29 period-scoped game lines each resolve to a mapped base by stripping the period modifier. Confirmed: `GAME_MATCHUP_SPREAD`, `TOTAL`, `MONEYLINE`, `ALT_SPREAD` and `ALT_TOTAL` are all mapped.
- That leaves 14 team-grain types and 3 `LEADER` families genuinely needing a hand-written ruling.

### The mint layer is NOT where the hole is

Set difference of the 337 distinct production `market_type` strings against the 358 constants exported by `bookmaker-constants.mjs` is **EMPTY**. Every type in the data is a declared constant. A mint gate against the constants export is a no-op today.

This corrects the framing this plan was briefed with. The hole is the settlement and vocabulary declaration, one layer down. The mint gate is still worth three lines as a ratchet, and this plan says so honestly rather than leading with it.

**It also has a named blind spot.** A mint gate validates that a type string is declared, not that it is the RIGHT type for the market. Two DraftKings types are catch-all buckets that would pass it cleanly: `GAME_MATCHUP_FIRST_SCORING_PLAY_TYPE` holds fifteen distinct field-goal products beside its genuine 269 markets, and `GAME_MATCHUP_LAST_TO_SCORE` holds "Each Team to Score 1+ TD" and "Any Team to Score 30+ Points". All are zero-graded. Catching that half needs the cross-book check absorbed from [[████████████████████████-█████-████-██████-████-████████████]], because every market-type check in the registry today is intra-book and a self-consistently wrong book reads 1.0000.

### Why MERGE rather than a registry beside the mappings

`GAME_PLAYER_ANYTIME_SACK` is the worked example and the argument. It is a member of `yes_no_market_types`, and its mapping is `{handler: PLAYER_DEFENDER_GAMELOG, metric_columns: ['defensive_sacks']}` with no `special_logic` — a bare threshold comparison requiring OVER or UNDER. Every one of its 2,345 stored rows is YES, 930 of them in live 2026, and **zero are graded** because settlement throws.

A `side_vocabulary: yes_no` declaration against a settlement configuration with no yes/no branch is a STATIC inconsistency. In one table it is catchable at declaration time by a rule. Across two files it is invisible until a settlement run crashes.

That generalizes: `side_vocabulary`, `metric_line_source` and `selection_identity` are settlement PRECONDITIONS, not independent metadata. Splitting them from the handler separates a contract from its enforcement.

### Assumptions

- **`market-type-mappings.mjs` is pure data and its `libs-server` position is history, not a constraint.** Checked: 1,313 lines, exactly one import (`#libs-shared/bookmaker-constants.mjs`), and zero references to `knex`, `require(`, `process.env` or `node:`.
- **Moving it to `libs-shared` does not meaningfully burden the client bundle.** Checked: total built client JS is 7.8 MB; the file is 57 KB of highly compressible string data, and `bookmaker-constants.mjs` at 56 KB already ships whole to the client through the `export * as` namespace re-export at `libs-shared/index.mjs:8`, which is tree-shake-hostile.
- **The client genuinely needs `side_vocabulary`.** Checked: `app/core/data-views-fields/betting-market-table-fields.js:56,61` selects between `['YES','NO']` and `['OVER','UNDER']` on `yes_no_market_types` membership. 159 app files import `#libs-shared`.
- **`market_type` is last-write-wins on re-observation.** Checked: present in `MARKET_INDEX_MERGE_COLUMNS` at `insert-prop-markets.mjs:72`, absent from `MARKET_INDEX_STAMP_COLUMNS` at `:101`. Enforcement must tolerate a type changing under a live market.
- **UNCHECKED — whether the 14 team-grain types are gradeable.** 76,080 distinct selections. Each needs a handler or an `UNSUPPORTED` reason, and that is an operator ruling rather than a code decision.
- **UNCHECKED — `GAME_PLAYER_LEADER_*` settlement.** A per-game argmax over `player_gamelogs` with tie resolution to settle; 32,725 distinct selections. Same shape of ruling.
- **UNCHECKED — whether any existing consumer depends on `market_type_mappings` being absent from the client.** The move is mechanical but the import-path sweep spans roughly 20 consumers.

## Design

### One module, five facets, no second source of truth

`libs-server/prop-market-settlement/market-type-mappings.mjs` moves to `libs-shared/market-type-registry.mjs` and widens. One entry per market type, keyed by the type string exactly as today, covering all 358 constants.

| Facet | Values | Replaces |
|---|---|---|
| `side_vocabulary` | `over_under` \| `yes_no` \| `named_outcome` | `yes_no_market_types`, the null ambiguity |
| `metric_line_source` | `vendor_posted` \| `implicit` \| `absent` | `occurrence_market_implicit_lines`, the predicate/ladder split |
| `line_cardinality` | `single` \| `ladder` | `ladder_market_types`, the mainline-versus-alternate semantic |
| `selection_identity` | `player` \| `team` \| `outcome_name` | `team_subject_market_types`, `team_grain_market_types`, `get_market_types_requiring_selection_pid` |
| settlement | the existing `handler`, `special_logic`, `metric_columns`, `quarter_filter`, `half_filter` fields, unchanged | itself |

Naming check run at plan time per [[user:guideline/write/write-software.md]]: every identifier is full-word and searchable; each value set is closed and names the distinguishing property; `metric_line_source` binds to the `selection_metric_line` column it describes rather than to the odds sense of "line"; `line_cardinality` reuses the `single` / `ladder` vocabulary `classify_prop_market_shape` already returns.

**Period scope is deliberately NOT a facet.** Pillar 1 listed it. It is recoverable from the constant name by the same strip that resolves 29 of the 90, and settlement already reads it from `quarter_filter` and `half_filter`. A sixth field restating it would drift.

**Which books emit a type is deliberately NOT a facet.** The 2026-09-16 ruling rejected that clause of Pillar 1 on the ground that normalization is the book's decision and a central book list is a parallel list that can drift. That rejection is kept. Nothing in this design names books.

### What the registry structurally CANNOT do, stated rather than oversold

A per-market-type registry declares per-type facts. `GAME_PLAYER_FIRST_TOUCHDOWN_SCORER` covers 1,886 many-player field markets AND 407 single-player two-sided markets, so that shape varies WITHIN the type and no facet can carry it. The coining ruling belongs to [[user:task/league/model-unmodelled-prop-market-shapes.md]], which is left independent for exactly this reason.

Ruled 2026-10-09: no new type. Pinnacle's 407 two-sided Yes/No markets stay under `GAME_PLAYER_FIRST_TOUCHDOWN_SCORER`. The ~9,000 untyped two-sided markets are mostly DraftKings 18514/18483 two-player "Either Player to Score 1st TD" markets, all legs pid-null, which the structural predicate misreads. They stay declined until a multi-subject selection grain exists, so this plan owes no backfill guard.

### Ruling: null stays, and no fourth `selection_type` member

Of 701,025 rows under the 19 types in `get_market_types_settled_without_selection_type()`, the split is clean and has no overlap:

- 8 types where the side IS the named outcome (spread, alt spread, moneyline, the five three-way winners): 263,467 rows, **100.00 percent null, zero exceptions**.
- 10 genuine yes/no types: 437,558 rows, 99.9 percent sided. Their 32,285 nulls are 32,054 from ONE type at ONE book.

The importers already agree on what these markets are; nothing records it. Declaring `side_vocabulary: named_outcome` makes null decidable — under `named_outcome` it means "the side is the named outcome", under `over_under` or `yes_no` it means "the vendor did not say". A `NOT_APPLICABLE` member would cost an enum migration, a 263,467-row backfill and a write-path change in every importer, to record a fact the registry states for free. **No enum change is required, so the rename in the grain redesign is unblocked.**

### Ruling: code in `libs-shared`, not a database table

The "queryable from the data checks without a round trip" argument does not hold: the private data-check registry already imports these vocabularies as JavaScript. There is no round trip today. Against that, `classify_prop_market_shape` is client-reachable and would be unable to read a database registry, `format-standard-selection-id.mjs:162` is a pure function with no database handle, and the mint path runs on three hosts with no registry dependency today.

Rejected with reasons: a Postgres enum on `market_type` (the column is deliberately last-write-wins, so re-classification would become a DDL event) and a database CHECK constraint (`market_type` and `selection_type` live on different tables, so it needs a trigger).

### Enforcement, in the only order the constraint permits

Declare first, enforce second. Nothing below may come on while any type is undeclared.

1. **Completeness, as a spec.** Every constant has a registry entry; every entry has a settlement facet, possibly UNSUPPORTED with a reason. Fires at PR time.
2. **Facet coherence, as a spec.** The `ANYTIME_SACK` class: a `side_vocabulary` that the declared settlement configuration cannot consume is a static contradiction.
3. **At write.** A `selection_type` outside the declared vocabulary is rejected. Under `named_outcome` that means any non-null value.
4. **At mint.** A `market_type` absent from the registry cannot be written. A no-op ratchet with a named blind spot, per Background.
5. **As a registered data check**, graded per `(book, season)`, derived from the registry.

### Knowledge Base Impact

- [[user:text/league/betting-market-pipeline.md]] records the ladder-to-ALT rule and the four-constants standing exception; both become facets and the prose goes stale.
- [[user:guideline/nfl/league/prop-market-selection-side-vocabulary.md]] becomes registry-derived; its hand-kept framing needs replacing.
- [[███████████████████████████████-██████████-██████████]] already gained the `source_id` join-fabrication trap and the 1.90 ratio note in `195c6ccc08` during planning.
- No new tag. Relations to the grain redesign and the two tasks left independent.

### Scratch and orchestrator

**No scratch slug.** Artifacts are source files and one module.

**One task entity, not an orchestrator.** The phases are sequential against one design rather than independent children with cross-cutting decisions.

## Cross-lane dependencies, which the merge must not discover mid-implementation

Two market types are being decided in more than one place at once. A merged registry declares what a type IS; settlement wiring decides how it GRADES. Those must agree, and today nothing makes them.

**`GAME_PLAYER_ANYTIME_SACK` — this plan's justification is also a live defect owned elsewhere.** The facet-coherence argument rests on it: declared `yes_no` against a `PLAYER_DEFENDER_GAMELOG` threshold handler with no `special_logic`, so settlement throws on all 2,345 rows, 930 of them live in 2026, zero graded. The bookmaker-consensus research deciding how that type actually grades is carried by the `██████████████████████████████` thread, which has minted a separate settlement task covering the same territory.

Consequence for sequencing: the facet-coherence spec must be written to fail on this type as it stands, but the REPAIR of it belongs to whichever lane lands first. Coordinate before writing the repair; do not let both lanes declare a `side_vocabulary` for it independently.

**`GAME_TEAM_FIRST_TOUCHDOWN` — three lanes, one type.** This plan declares it (`side_vocabulary: yes_no`, and it currently has NO settlement mapping at all while sitting in both `yes_no_market_types` and `predicate_occurrence_market_types`, so every side written onto its 1,230 selections is unusable). A settlement lane is wiring how it grades. A third lane established, by a six-book cross-book control, that the semantic negative leg is NO — the Kalshi importer was writing YES onto "No team scores a TD".

That control is EVIDENCE FOR the `yes_no` declaration here and is already recorded as an observation on its owning task. Cite it rather than re-deriving it. The three rulings must converge, and the declaration is the one the other two read from.

## Plan

### Declare

- [ ] **Move the mappings to `libs-shared` and rename**
  - File: `libs-server/prop-market-settlement/market-type-mappings.mjs` to `libs-shared/market-type-registry.mjs`
  - Purpose: a client-reachable single home. Pure mechanical move plus import-path updates across roughly 20 consumers.
  - Do NOT add it to the `libs-shared/index.mjs` barrel in this step; import by direct subpath, so bundle impact is deliberate rather than incidental.
  - Verify: `yarn test` green, and `yarn build` completes with total `dist/*.js` within 100 KB of its pre-move size. Record the pre-move total before starting: `du -shc dist/*.js`.

- [ ] **Add the four facets to the 156 gradeable entries**
  - File: `libs-shared/market-type-registry.mjs`
  - Purpose: these already have settlement; they need the declaration half.
  - Verify: every one of the 156 carries all four facets, asserted by a spec that enumerates the entries rather than sampling. The spec must fail if a facet is absent on any entry.

- [ ] **Declare the 44 futures UNSUPPORTED in one call**
  - Changes: one `declare_family_unsupported` call, reason naming that they never settle against a gamelog.
  - Verify: the unmapped-and-in-data count falls from 90 to 46, re-measured by the Background query rather than asserted.

- [ ] **Declare the 29 period-scoped game lines from their base**
  - Purpose: each resolves to a mapped base by stripping the period modifier; the facets copy from the base and the period filter is set.
  - Verify: for each of the 29, its four facets equal its base type's, asserted in a spec. Separately confirm the count falls from 46 to 17.

- [ ] **Rule on the 14 team-grain types and the 3 `LEADER` families — OPERATOR GATE**
  - Purpose: 76,080 and 32,725 distinct selections respectively. Each gets a handler or an `UNSUPPORTED` reason.
  - This is the one item requiring a decision this plan does not make. Surface the 17 as a list with volumes and settle them as prose before writing.
  - Verify: the unmapped-and-in-data count reaches ZERO, re-measured.

- [ ] **Rewrite every hand-kept set as a selector over the registry**
  - Files: `bookmaker-constants.mjs:66`; `occurrence-market-defaults.mjs:105,:121`; `market-type-mappings` successor `:1268,:1296`; `prop-hit-rate.mjs:124`
  - Note: `ladder_market_types`, `team_subject_market_types` and `team_grain_market_types` are ALREADY derived and only need re-pointing.
  - `occurrence-market-defaults.mjs` is owned by the producer lane under [[██████████████████████-████-██████-████-███████-███████]]; sequence after their work lands rather than concurrently.
  - Verify: each rewritten selector returns a set IDENTICAL to the literal it replaces, asserted per set in one spec, run before the literals are deleted. A set that changes membership here is a bug, not an improvement.

- [ ] **Retire `all_market_types` and the three shadowing book-local sets**
  - Files: `format-standard-selection-id.mjs:24`; `fanduel-wagers.mjs:94`; `prizepicks.mjs:13`; `draftkings-helpers.mjs:305`
  - Purpose: `all_market_types` is a module-private hand-assembled literal, which is why completeness is currently unenforceable. The three local sets shadow shared vocabulary with DIFFERENT membership.
  - Verify: for each of the three local sets, diff its membership against the registry selector and record the difference BEFORE deleting it — a non-empty diff is a behaviour change that needs its own ruling, not a silent deletion.

### Enforce

- [ ] **Completeness spec**
  - Verify: passes on the declared tree; demonstrated red by removing one entry and observing failure. A spec never seen red is not an oracle.

- [ ] **Facet-coherence spec**
  - Purpose: the `GAME_PLAYER_ANYTIME_SACK` class — a `side_vocabulary` the declared settlement cannot consume.
  - Verify: red on `ANYTIME_SACK` as it stands today BEFORE its repair, green after. Both readings recorded.

- [ ] **Repair `GAME_PLAYER_ANYTIME_SACK`**
  - Purpose: 2,345 rows, 930 live in 2026, all YES, zero graded because settlement throws.
  - Verify: settlement runs over those rows without throwing and grades a non-zero count; re-measure the graded count from zero.

- [ ] **Write-layer vocabulary rejection**
  - Verify: a selection carrying `OVER` on a `yes_no` type is rejected, and one carrying any non-null side on a `named_outcome` type is rejected. Assert both against fixtures, plus a negative control that a correct side passes.

- [ ] **Mint-layer registry membership**
  - File: `insert-prop-markets.mjs`, in `process_market` beside the throws at `:347` and `:351`
  - Verify: a synthetic unregistered type throws; a real type passes. State in the commit that this is a ratchet with no current violations rather than implying it fixes something.

- [ ] **Register the per-(book, season) data check**
  - File: the private data-check registry module
  - Verify: demonstrate red by perturbation on the same corpus and record both readings in the calibration.

- [ ] **Register the cross-book mainline cardinality check**
  - Purpose: absorbs [[████████████████████████-█████-████-██████-████-████████████]], now derivable from `line_cardinality`.
  - Its table has turned over since it was written: Fanatics is REPAIRED at 1.03 mainlines per event-day, down from 44.6, and BetRivers is now worst at 20.18 with zero alt markets while appearing nowhere in that table. Re-measure at implementation rather than trusting either reading.
  - Verify: reads clean for Caesars, DraftKings, FanDuel and Fanatics, and reports BetRivers, BetMGM and Pinnacle. Park the three with adjudications naming their cause; do NOT widen the threshold.

### Carry forward

- [ ] **Collapse the four touchdown rungs**
  - Purpose: carried verbatim from [[user:task/league/standardize-prop-market-selection-side-vocabulary.md]], ruled 2026-09-17 as one market type plus a retained market-grain rung column, sequenced there into seven commits. Do NOT re-decide it; that task's body holds the sequence and the persisted-view exposure.
  - The registry makes it cheaper: the rungs' vocabulary change becomes a facet edit on four entries.
  - Verify: verdict-neutrality measured in-process against a control in the same batch that MUST move. The prior ruling called this an argument rather than a measurement; it is now measured — PrizePicks and Pinnacle already store these rungs as OVER/UNDER at N-0.5 and all 12,504 settled rows agree with `metric_result_value`, zero disagreeing.

- [ ] **Keep DraftKings 18514/18483 declined until a multi-subject selection grain exists**
  - Purpose: these two-player first-touchdown subcategories have no single subject, so typing them into `GAME_PLAYER_FIRST_TOUCHDOWN_SCORER` would pool them with the field market. Ruled 2026-10-09 on [[user:task/league/model-unmodelled-prop-market-shapes.md]].
  - Verify: after the registry lands, both ids are still declined in `libs-server/draftkings/draftkings-constants.mjs` and no row under them carries a market type.

### Knowledge Base Updates

- [ ] **Reconcile the stale reference docs**
  - Files: [[user:text/league/betting-market-pipeline.md]], [[user:guideline/nfl/league/prop-market-selection-side-vocabulary.md]]
  - Verify: grep both for `yes_no_market_types`, `ladder_market_types` and `occurrence_market_implicit_lines`; every hit either describes the registry or is gone.

### Testing and Validation

- [ ] **Population-level validation that the registry is non-degenerate**
  - Purpose: a registry where every entry carries the same facet value would pass completeness and be useless. Asset-level fixtures cannot catch that.
  - Verify: distribution of each facet across all 358 entries, requiring at least two populated values per facet and no single value above 90 percent. State the failure mode: a bulk-declaration step that stamped one value across a whole block.

- [ ] **Re-measure the headline population end to end**
  - Verify: the Background table re-run returns ZERO types with no settlement declaration, and the previously-zero graded counts are non-zero for every block given a working handler. Record the reproducing query beside the values.
