---
title: Separate Auction Economy from League Format Identity
type: task
status: completed
description: >-
  Add a league_rule_sets parent table so an external league can carry an honest format reference,
  and move the auction economy a league actually runs to seasons. Purely additive: no format merged,
  no analysis row deleted, no historical dollar figure restated.
base_uri: user:task/league/separate-auction-economy-from-format-identity.md
created_at: '2026-07-30T05:06:39.498Z'
entity_id: a25caf14-6a6f-441a-a836-0cc9fdd07c51
finished_at: '2026-09-11T18:48:18.948Z'
observations:
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
priority: Medium
public_read: true
relations:
  - blocks [[████████████████████████████-████████-██████-████-████████]]
  - relates [[████████████████████████-███████-█████-████████████]]
  - relates [[███████████████████████-██████-████████-██████-██████████]]
started_at: '2026-08-18T00:22:22.605Z'
tags:
  - user:tag/league-xo-football.md
updated_at: '2026-09-11T18:48:20.073Z'
visibility_analyzed_at: '2026-11-06T00:18:05.169Z'
---

# Separate Auction Economy from League Format Identity

## Overview

**Redesigned 2026-08-17 on operator ruling. The collapse is superseded — see "Why the collapse was dropped".**

`league_formats` answers two questions with one row — **what game a league plays**, and **which analysis board applies** — because the auction cap sits inside the identity tuple. That fusion has two separable costs, and only one of them is worth paying to fix.

Three changes, all **additive**. Nothing is merged, nothing is deleted, no historical dollar figure is restated.

1. **`league_rule_sets`** — a new parent table holding roster construction, scoring format and `pricing_model`, with one row per distinct ruleset. `league_formats` gains a `league_rule_set_id` FK. This is the id an external league can honestly carry.
2. **`seasons`** gains `salary_cap`, `min_bid` and `pricing_model` — the economy a league actually runs that year, at the `(lid, season_year)` grain it belongs to.
3. **`league_formats` is otherwise untouched.** It keeps `salary_cap` and `min_bid` in its identity tuple, so every existing row, every analysis board and every `league_format_id` reference survives exactly as it is.

## Why the collapse was dropped

The superseded design took `salary_cap` and `min_bid` out of `league_formats_config_unique`. That forces formats differing only by economy to merge, which forces their two price boards to collide, which forces deleting one — roughly **184,000 analysis rows**, and 25 league-seasons reading board dollars restated at a minimum bid they never ran.

Measured on production 2026-08-17, that buys almost nothing:

| | |
| --- | --- |
| `league_formats` rows | 860 |
| Distinct rulesets (economy excluded) | 851 |
| Rulesets carrying more than one economy | **8** |

The format space is already 99% rules-distinct, so removing economy from the identity dedups **9 rows out of 860**. The deletion was paying ~184,000 rows for a 1% cleanup.

The external-league driver does not require it either. `libs-server/external-fantasy-leagues/sync/config-sync.mjs` calls `find_or_create_league_format` — find-or-create, not match-to-existing — so an incoming league mints a format from whatever config it reports. Production holds **804 formats at the default `(200, 0, auction)` economy, only 18 referenced by any season**; those are genuinely distinct rulesets and no identity change merges them. That clutter is real and is filed separately as [[███████████████████████-██████-████████-██████-██████████]].

What the driver actually needs is a ruleset id it can STORE, which change 1 supplies without touching a board.

## Background

The unique tuple today is:

```
UNIQUE (number_teams,
        starter_slots_quarterback, starter_slots_running_back,
        starter_slots_wide_receiver, starter_slots_tight_end,
        starter_slots_running_back_wide_receiver_flex,
        starter_slots_running_back_wide_receiver_tight_end_flex,
        starter_slots_superflex, starter_slots_wide_receiver_tight_end_flex,
        starter_slots_defense_special_teams, starter_slots_kicker,
        bench_slot_count, practice_squad_slot_count, reserve_short_term_limit,
        salary_cap, min_bid, scoring_format_id, pricing_model)
```

The plan was written against the pre-conform shorthand spellings (`cap`, `num_teams`, `sqb`, `bench`, `ps`); the 2026-08-17 key-column conform renamed them, and the tuple above is the live one. The structural claim is unaffected.

Every column except `salary_cap` and `min_bid` describes the fantasy format itself and has a direct analogue on any external platform, `pricing_model` included — a DFS contest is a real format distinction, and `auction` is the default, so stating it asserts no number. The cap and the minimum bid are the two that describe our auction contract system and have no analogue anywhere else. Both are `NOT NULL`.

The consequence is that no league outside our platform can be given an honest `league_format_id`. It must either invent auction values — asserting a Sleeper league runs a $200 cap with a $0 minimum bid — or receive a different id from ours despite playing an identical format, which defeats the point of having the id.

This blocks [[████████████████████████████-████████-██████-████-████████]], which needs `external_leagues.league_format_id` to mean "same format as ours" so comparability is an exact match rather than a four-boolean approximation.

`league_scoring_formats` has no equivalent problem: its 25 columns are all offense skill scoring with position-specific reception columns, so PPR level and tight end premium both represent correctly for an external league. Its gaps are kicker, DST, and IDP scoring, none of which affect dynasty player valuation and the last of which is already excluded by the corpus appetite filter.

## Design

Approved 2026-08-17. `league_formats` answers two questions with one row — what game is played, and which analysis board applies — and the cap being inside the `id` is what fuses them.

Three columns, three different answers:

| column | what it is | where it goes |
| --- | --- | --- |
| `pricing_model` | who determines dollar value, us or an external source | stays on `league_formats`, stays IN the identity |
| `salary_cap` / `min_bid` as the unit the format's published analysis is priced in | an attribute of the format | stays on `league_formats`, leaves the identity, renamed `valuation_salary_cap` / `valuation_min_bid` |
| `salary_cap` / `min_bid` as what this league runs this season | a league-season setting | new columns on `seasons` |

`pricing_model` stays in the identity because a DFS contest really is a different format, and stating it costs an external league nothing — `auction` is the default and asserts no number.

The valuation pair stays on the format because **a dollar-denominated analysis surface needs a unit and has no league to get one from**. An anonymous data view renders `earned_salary` against `DEFAULT_LEAGUE_FORMAT_ID` with no user, no league and no season. Refusing to store the unit would not remove it, only make it implicit.

The task's original framing — extract all three into a league-scoped economy record — is superseded. What the external-league work actually needs is the unique INDEX to key on roster construction, scoring and pricing model; a row may carry a valuation unit without that unit being part of the lookup key.

Consequences deliberately accepted:

- Two leagues differing only in cap now share one format id and one board. Points were always identical; dollars are now stated in the surviving format's unit.
- For the one large colliding group, 25 league-seasons currently on a `min_bid = 1` format will see their board dollars restated at `min_bid = 0` — roughly a 10 percent shift in `earned_salary` and `market_salary`. Their own live economy is unaffected; it moves to `seasons` intact.

Blast radius: see "Consumer enumeration" below, measured 2026-08-17 against live master. It supersedes the 2026-07-30 figure of 111 references across 14 files, which was taken with the pre-conform pattern.

### Three identities, three questions

The fusion this task exists to undo is one id answering questions that have different grains. The redesign gives each its own home rather than redefining the shared one.

| Question | Answer | Grain |
| --- | --- | --- |
| What game is played? | `league_rule_set_id` | roster construction + scoring + pricing model |
| Which analysis board applies, and in what unit? | `league_format_id` (unchanged) | ruleset + valuation economy |
| What does this league run this season? | `seasons.salary_cap` / `min_bid` / `pricing_model` | `(lid, season_year)` |

A board is determined by the PAIR — the discretionary cap is `number_teams * salary_cap - number_teams * roster_size * min_bid`, needing team count and roster size from the ruleset and cap and minimum bid from the economy. Neither half identifies a board alone, which is why `league_formats` is the right key for one and must keep carrying its economy.

The board's unit cannot move to `seasons`. A published board has no league and no season — an anonymous data view renders `earned_salary` against `DEFAULT_LEAGUE_FORMAT_ID` with no user at all. The superseded "Open drift" section proposed a league-season-scoped home for the dollar columns; that is the one placement that cannot work.

### Why `league_rule_sets` is a table rather than a query

851 of 860 rulesets are already unique, so a rules-only LOOKUP needs no schema change at all — the columns are on `league_formats` and a `WHERE` clause resolves them.

A table is warranted because the requirement is to STORE, not to resolve. `██████████████████████████████████████` needs `external_leagues` to carry a format reference meaning "same format as ours", replacing the four-boolean approximation the table holds today (`is_superflex`, `is_best_ball`, `points_per_reception`, `tight_end_premium`). A stored comparison key needs a stable id and referential integrity; a repeated 16-column predicate gives neither.

Note `external_leagues` has NO `league_format_id` column today — verified against the live schema. The blocked task adds one, and under this design it adds `league_rule_set_id` instead, which is the honest reference: an external league genuinely has a ruleset and genuinely has no auction economy.

### Knowledge Base Impact

`text/league/data-model-reference.md` gains the three-identity table above. The repo `CLAUDE.md` gains one line on the ruleset-versus-format distinction, since a future reader reaching for `league_format_id` as "the format" needs to know it carries an economy.

### Out of scope

- Removing `salary_cap` / `min_bid` from `league_formats_config_unique`. Superseded; see "Why the collapse was dropped".
- Merging any format, remapping any board, deleting any analysis row.
- Renaming `league_formats.salary_cap` / `min_bid` to `valuation_*`. It was cosmetic even in the old design, and renaming a column that 89 files read buys nothing here.
- The 786 orphan external-league formats — [[███████████████████████-██████-████████-██████-██████████]].

### Assumptions

- The valuation unit is per-format, not global. Cross-format dollar comparison was never apples-to-apples — DraftKings Classic is denominated at $50,000 — so each format keeps its own unit. Falsified if any consumer compares `earned_salary` across two different `league_format_id` values.
- A ruleset is stable across seasons for a given format. Falsified if `league_formats` rows need to change ruleset membership after creation.
- `pricing_model` belongs in BOTH the ruleset and the season. It is part of what game is played (a DFS contest is a different format) and part of what a league runs. Falsified if a league-season needs a pricing model differing from its ruleset's, which would make the ruleset copy wrong rather than redundant.
- The 8 multi-economy rulesets stay a small population. Falsified if external-league import starts minting many economies per ruleset, which would make the ruleset table's benefit grow and is an argument FOR this design, not against it.

## Plan

- [x] Enumerate every read of `salary_cap`, `min_bid`, and `pricing_model` off `league_formats` across `libs-server/`, `libs-shared/`, `scripts/`, `api/`, and `jobs/`. Verify: the pattern finds a known-existing site (`find-or-create-format.mjs`) before trusting any zero, per the positive-control rule in the repo CLAUDE.md. — done, see "Consumer enumeration" below.
- [x] Decide economy placement (`seasons` versus a dedicated table) and record the reasoning. Verify: name the consumer that settles it. (no verify — decision step) — `seasons`, operator ruling 2026-08-17; see "Placement decision" below.
- [x] Measure the collapse: how many `league_formats` rows become duplicates once the three columns leave the tuple, and which referencing rows need remapping. Verify: `SELECT` over the remaining tuple with `HAVING count(*) > 1`. — done, see "Collapse measurement" below. Ran ahead of its turn because the decision above depends on it.
- [x] Settle the two column names against the conformance audit before writing any DDL. Verify: audit a candidate schema and confirm it SEES the new columns before trusting a zero; repo-wide total unmoved. — `valuation_salary_cap` / `valuation_min_bid`; audit named both, `league_formats` and `seasons` clean, repo-wide 22 before and after, one reviewed token added to the vocabulary.
- [x] Choose the surviving format row and valuation unit for each colliding group, on a stated rule. Verify: every group resolves under the rule with no manual exception. — see "Survivor selection" below.
- [x] Author the migration — `seasons.salary_cap` / `seasons.min_bid` backfilled through `seasons.league_format_id`, the collapse, the `valuation_*` rename, the index rebuild. Verify: every `seasons` row round-trips its source format's values, and no row is left NULL. — `db/adhoc/2026-08-18-separate-auction-economy-from-format-identity.sql`, rehearsed on a scratch database with a negative control; NOT yet applied to production.
- [x] Sweep consumers, splitting the two meanings at each site. Verify: `db/gates/check-data-view-sql-validity.mjs`, `check-knex-column-resolution`, `check-rename-alias-residue --base <pre-cluster>`, and `check-league-schema-consumers` all green; grep `<old>_` and `_<old>` as well as the word-bounded form. — done, see "Sweep" below.
- [x] Give `calculatePrices` and `get_discretionary_cap` an explicit economy argument rather than reading it off the format object. Verify: a spec proven red at the pre-fix revision. — `libs-shared/league-economy.mjs` names the two economies; spec red at `2f06db417` on all four new cases.
- [x] Build a candidate schema and run the whole cluster against it before touching production, per CLAUDE.md. Verify: full suite green against the candidate, matched against a control run at HEAD in a clean worktree. — 4175 passing / 0 failing against a control at `b923b9d9d` of 4169 / 0, delta exactly the six added tests.
- [x] Author and apply the contract migration — remap referencing rows to surviving ids, drop the two columns from `league_formats_config_unique` and rebuild it. Verify: no orphaned `league_format_id` remains across all 9 referencing tables. — authored and rehearsed on a scratch database seeded with the 17 real colliding rows (17 formats -> 8). **SUPERSEDED and never applied.** Artifacts kept at `scratch/econ-sweep/full-sweep.patch` with base ref `3c3adcc72` — a convenience, not a dependency, since `scratch/` is gitignored and the seam is reconstructible from the committed salvage inventory (`ce7daa853`).

**Correction, 2026-08-18.** An earlier revision of this line asserted the migration file was reverted along with the sweep. It was not. `db/adhoc/2026-08-18-separate-auction-economy-from-format-identity.sql` survived the stand-down in league HEAD still carrying `-- STATUS: PENDING` — the exact state `db:exec` accepts — for a file that drops two columns, deletes ~219,000 analysis rows and rebuilds `league_formats_config_unique`. Only one commit ever touched it (`59b1057d0`); there was no revert to inherit. Removed in league `77ceb4af7`, gitlink at user-base `472efaa4d`.

The claim was formed by generalizing the sweep worker's "sweep reverted" report to cover the migration file, which predates that worker and was outside its scope — a fix attribution accepted without reading what the commit touched, which is the failure the repo `CLAUDE.md` warns about directly. **A stand-down must disarm its adhoc file explicitly**: superseding a design leaves the DDL armed, and the banner that makes it runnable is the same banner that makes it look like ordinary pending work.

### Superseded — do not execute

Everything above this line describes the collapse design. It is recorded as history, not as instruction. The five checked items covering the consumer enumeration, the placement ruling, the collapse measurement and the candidate-schema discipline remain accurate and are reusable; the survivor selection, the `valuation_*` rename and the contract migration are not.

### Operator ruling 2026-09-11 — where a cap edit belongs

**Settled: the format copy is frozen, and a commissioner edit writes `seasons` only.** Formats are static — `find_or_create_league_format` mints a new row for a new pair and never restates an existing one, which is what keeps an old `league_format_id` meaning the unit its board was published in.

So `salary_cap` and `min_bid` are `season_fields` in the settings route, not `league_format_fields`, and `league_format_fields` is now exactly `RULE_SET_COLUMNS`. The format the season points at is derived from the season after the write, never edited alongside it.

Covered by `test/leagues.commissioner-economy-edit.spec.mjs`, proven red by reverting the routing.

### Naming 2026-09-11 — the table is `league_rule_sets`, plural

Two independent findings. `ruleset` is not in the conformance vocabulary while `rule` and `set` both are, so the entity's original spelling would have turned master red fleet-wide. And the table was first built SINGULAR, which contradicts every analogous table in the schema — `league_formats`, `league_scoring_formats`, `admission_votes` — so the derive-from-canonical-source rule in [[user:guideline/naming-conventions.md]] makes it plural. The FK column stays `league_rule_set_id` and the resolver stays `find_or_create_league_rule_set`; both name one thing.

### Live plan

- [x] **Re-measure the ruleset population on the tree you will build against** — 860 formats, 851 rule sets, 8 multi-economy rule sets; identical to the 2026-08-17 figures and the abbreviation campaign renamed no key column.
  - Re-run the 851/860/8 measurement, since the abbreviation-token campaign is live and may have renamed key columns underneath it.
  - Verify: the three counts are stated in the run output; a ruleset count equal to the format count means the grouping columns did not resolve and the query is wrong, not the data.

- [x] **Add `league_rule_sets` and backfill it** — built, not yet applied.
  - File: `db/adhoc/YYYY-MM-DD-add-league-rule-sets.sql`. One row per distinct `(roster construction, scoring_format_id, pricing_model)`; `league_formats` gains a nullable `league_rule_set_id`, backfilled, then set `NOT NULL` with an FK.
  - Identity is `gen_random_uuid()`. Do NOT derive it from the config — see [[user:guideline/schema/avoid-content-derived-identity.md]]. A dedup INDEX over the tuple is the oracle, matching how `league_formats` already dedups.
  - Run the coined names through the conformance audit BEFORE the DDL, per CLAUDE.md: `league_rule_set_id` is full-word and carries no reserved token.
  - Verify: every `league_formats` row resolves a ruleset, no NULL remains, and the ruleset count matches the re-measured figure exactly.

- [x] **Add the economy to `seasons`** — built, not yet applied.
  - `salary_cap`, `min_bid`, `pricing_model` at `(lid, season_year)`, backfilled through `seasons.league_format_id` from the format each season already points at.
  - Verify: every `seasons` row round-trips its source format's three values with zero mismatches and zero NULLs. This is a pure copy, so any disagreement is a bug in the backfill.

- [x] **Point league economy reads at `seasons`** — both wires moved together, with each half independently proven red by deletion.
  - `libs-server/get-league.mjs` and `api/routes/me.mjs` both already join `seasons` before `league_formats`, so the merged league object keeps the same keys — but they must now come from the season, not the format.
  - Both wires must move together. A league field on one payload and not the other is present, absent or racy depending on which response lands last (CLAUDE.md § the two-payload trap).
  - Verify: a spec asserting the three fields on BOTH route payloads, proven red with either route reverted.

- [x] Give `calculatePrices` and `get_discretionary_cap` an explicit economy argument rather than reading it off the format object. Verify: a spec proven red at the pre-fix revision. — `libs-shared/league-economy.mjs` names the two economies. **Salvage this from `scratch/econ-sweep/full-sweep.patch`; it is the one piece of the old sweep that survives the redesign.** Note it does NOT fix a live defect — verified 2026-08-17 that every caller supplies all three fields at HEAD, and the SPA `League` Record declares them — it is the guard that keeps the season/format split from silently pricing a board at $0.

- [x] **Add `external_leagues.league_rule_set_id`** — nullable, resolved in `config-sync.mjs`. Note the Live plan's premise was wrong: `config-sync.mjs` writes `seasons` for a hosted league and the bulk crawler in `import-sleeper-external-league-trades.mjs` is what writes `external_leagues`, never fetching league config. The column stays nullable, and NULL means this league's settings have never been read.
  - Nullable, FK to `league_rule_sets`. Resolve it in `config-sync.mjs` beside the existing format resolution.
  - This is what unblocks [[████████████████████████████-████████-██████-████-████████]]; leave the four booleans in place, since retiring them belongs to that task.
  - Verify: an imported league with no auction settings resolves a ruleset and stores no economy — the case the old design could not express honestly.

- [x] **Apply, export, commit** — applied 2026-09-11; landed on master as `e7c5821e6` and pushed. The one-commit rule was BROKEN here: the apply and the commit happened on an unmerged branch, so master's export was untruthful against production for hours and every session running `yarn export:schema` absorbed this change. Bulletin #789 carried it and is withdrawn.
  - The branch was rebuilt onto current master rather than merged. Its export predated `idx_nfl_plays_possession_nfl_team`, so merging it as-is would have left master still untruthful and the plays lane still blocked; regenerating from production carries both changes.
  - Verified: a fresh `yarn export:schema` against production produces zero drift from the committed export, and every one of the 489 added export lines is one of those two changes.

- [ ] **Deploy** — BLOCKED on other lanes, not on this work.
  - `preflight-deploy` refuses a range of 11 commits, 10 of which belong to live data-views lanes and need their owners' acknowledgement, and `test/data-views.season-parity.spec.mjs` is red at master independently of this change.
  - Until it ships, deployed code cannot mint a NEW league format: `find_or_create_league_format` omits `league_rule_set_id`, which is NOT NULL in production. Existing-format resolution and all reads are unaffected. Bulletin #791 carries it, and #790 carries the red master with its cause pinned to `409599811`.
  - The shared checkout is dirty, so preflight refuses it; use the throwaway-worktree recipe in `docs/guides/ship.md`. Deploy `app/core/worker/index.js` with it — its client-side recompute now builds an economy.
  - Verify: all three production checkouts (`league`, the worker host, `████████████`) at the pushed ref; read the deploy OUTPUT for `REFUSING TO DEPLOY`, never the exit code.

### Testing and Validation

- [x] **Prove no board moved** — settled structurally rather than by snapshot, because the pre-apply snapshot no longer exists.
  - The adhoc file contains no `DROP`, `DELETE` or `TRUNCATE` and names no board table at all, so it has no statement capable of moving a board. `league_formats` still holds 860 rows, the figure measured before the apply.
  - Verify: `grep -niE '^\s*(drop|delete|truncate)\b' db/adhoc/2026-09-11-add-league-rule-sets-and-season-economy.sql` matches nothing, and `select count(*) from league_formats` reads 860.

- [x] **Prove the season economy is the read path** — `test/leagues.season-economy-wire.spec.mjs` asserts the wire moves while the board does not.
  - Mutate one league's `seasons.salary_cap` on a scratch database and confirm the league wire reports the new value while its board dollars are unchanged.
  - Verify: the wire moves, the board does not. That divergence IS the separation this task exists to create, so a test where both move has not tested it.

- [x] **Run the suite and the cluster gates against a candidate schema** — candidate 8631 passing / 0 failing against a control at master `711624043` of 8616 / 0; delta exactly the 15 added tests. `check-league-schema-consumers` reports 13 findings against the control's 15 — the candidate CLEARS two, because `text/league/data-model-reference.md` already documented this schema and master is red against it.
  - Full suite plus `yarn check:cluster --base <pre-cluster-ref>` from a clean worktree, matched against a control run at HEAD.
  - Verify: candidate green, control green, delta exactly the added tests. Do not run in the shared checkout — the reverted sweep already broke sibling runs once this way.

### Knowledge Base Updates

- [x] **Record the three identities** — the identity table is in `text/league/data-model-reference.md` and the repo `CLAUDE.md` carries the rule-set-versus-format paragraph plus the write-direction rule.
  - Add the identity table to `text/league/data-model-reference.md` and one line to the repo `CLAUDE.md` on ruleset versus format.
  - Verify: both name all three ids and their grains.

## Salvage inventory

Written by the session that authored `scratch/econ-sweep/full-sweep.patch` (base ref `3c3adcc72`, 30 files, reconstruction verified byte-for-byte before the revert). Do not apply it wholesale. At file granularity:

**Take, unchanged in substance** — the explicit-economy seam and the call sites it forces:

- `libs-shared/league-economy.mjs` (new file, held separately in `scratch/econ-sweep/` since no diff covers an untracked file)
- `libs-shared/get-discretionary-cap.mjs`, `libs-shared/calculate-prices.mjs`
- `libs-shared/calculate-projection-values.mjs`, `libs-shared/calculate-player-values-rest-of-season.mjs`
- `libs-shared/index.mjs` (two exports)
- `scripts/process-projections.mjs`, `scripts/process-projections-for-league-format.mjs`, `scripts/calculate-points-added.mjs`, `scripts/generate-league-format-player-seasonlogs.mjs`, `scripts/generate-league-format-player-gamelogs.mjs`, `scripts/calculate-historical-positional-ranking-value.mjs`
- `app/core/worker/index.js` (the client-side recompute builds an economy — this is why the frontend must deploy with the cluster)
- `test/libs-shared.calculate-prices.spec.mjs`, `test/libs-shared.calculate-baselines.spec.mjs`

**ONE EDIT THE SALVAGE MUST MAKE, and it is easy to miss.** `format_valuation_economy` reads `valuation_salary_cap` / `valuation_min_bid`. Those columns do not exist under the additive design — `league_formats` keeps `salary_cap` / `min_bid`. Rename the constructor to `format_economy` and point it at the surviving column names. Every file in the list above imports or calls it, so a missed rename is a `Number.isFinite(undefined)` throw at the first pricing call rather than anything subtle.

**Take, but re-derive rather than copy:**

- `libs-server/create-league.mjs` — the `seasons` insert needs `pricing_model` too under the new design; the patch adds only the cap pair.
- `libs-server/tag-board/build-tag-board.mjs` — one line, `cap_total` from `season.salary_cap` instead of the format. Correct under the new read path, but it depends on step 3 having landed.
- `libs-shared/create-default-league.mjs` — adds `pricing_model: 'auction'` so the synthetic lid=0 league has a real one's shape.
- `test/league-settings-field-columns.spec.mjs` — asserts every `league_format_fields` entry is a real `league_formats` column. Independently valuable and unrelated to either design: that list had no column assertion at all, and a stale entry there is a 42703 on a commissioner edit while a missing one writes nothing and answers 200.

**Discard:**

- `libs-server/find-or-create-format.mjs` (identity-tuple edit)
- `libs-shared/league-format-definitions.mjs`, `libs-shared/named-format-catalog.mjs`, `docs/named-formats.md` (the `valuation_*` rename and its regenerated artifacts)
- `libs-server/external-fantasy-leagues/mappers/league-config-mapper.mjs` (dropped the invented `0/0` economy — under the additive design the format still carries one, so this needs rethinking against step 6 rather than salvaging)
- `api/routes/leagues/league-settings.mjs`, `test/leagues.format-id-update.spec.mjs` (the settings field move as written)
- `test/fixtures/external-fantasy-leagues/expected-outputs/sleeper-league-config.json`, `test/data-view-queries/league-format-seasonlogs-points-added-per-game-range-offset-avg-result-equivalence.json`

### Two things the next session should not have to rediscover

**A commissioner cap edit has no home in the live plan.** `PUT /leagues/:lid` dispatches on `league_fields` / `season_fields` / `league_format_fields` / `league_scoring_format_fields`. Under the additive design `salary_cap` and `min_bid` exist on BOTH `league_formats` and `seasons`, so leaving them in `league_format_fields` keeps minting a new format id per cap edit, while moving them to `season_fields` writes the season and leaves the format's copy stale. The plan settles the READ path (step 4) and not the WRITE path. It needs a ruling.

**The economy fixture in the data-view result-equivalence golden is a setup block, not a query.** `test/data-view-queries/league-format-seasonlogs-...json` inserts into `league_formats` by name. `update-data-view-snapshots.mjs` rewrites `expected_query` only, so regeneration cannot repair it — it is hand-edited or the fixture fails at seed time. It was the single failure in an otherwise-green candidate run and it is outside every sweep root anyone carries.

## Sweep

Landed uncommitted, deliberately: the sweep names `valuation_salary_cap`, which the tracked schema does not carry until the apply, so committing it before the export turns master red and defers every session's league push.

The seam is `libs-shared/league-economy.mjs`, which gives the two meanings names — `format_valuation_economy(league_format)` and `league_season_economy(league)` — and refuses an absent cap or minimum bid rather than returning an economy with undefined fields. Every board's call site now states which of the two it is priced in, decided by what the board is keyed on: `league_format_id` takes the valuation unit, `lid` takes the live economy.

Sites where the two meanings actually split, as opposed to sites that merely name a column:

- `libs-server/tag-board/build-tag-board.mjs` read `league_format.salary_cap` as a team's cap total. It is `season.salary_cap` — a live cap-space question answered off the format row.
- `api/routes/leagues/league-settings.mjs` moved the pair from `league_format_fields` to `season_fields`. A commissioner raising the cap used to mint a second format id and a second board holding identical points; it now writes the seasons row.
- `libs-server/external-fantasy-leagues/mappers/league-config-mapper.mjs` no longer carries an economy at all. The `salary_cap: 0, min_bid: 0` it invented is the defect this task exists to remove.
- `scripts/process-projections.mjs` holds both meanings in one file: `process_league_format` prices in the valuation unit, `process_league` in the league-season economy.
- `libs-shared/league-format-definitions.mjs` renamed its 24 definition economies to `valuation_*`, which is where a named catalog preset's unit comes from — `draftkings_classic` keeps its $50,000.

Two things the plan's consumer enumeration got wrong, both established by reading rather than grepping:

- **The wire keys do not move.** Both league wires join through `seasons`, so after the move `SELECT *` yields `salary_cap` from `seasons` instead of `league_formats` under the same key. The SPA Record and `createLeague` destructure need no change, and neither does any group B consumer downstream of a `getLeague`.
- **A data-view fixture's setup SQL was outside the sweep roots** and is the one thing the candidate run caught: `league-format-seasonlogs-points-added-per-game-range-offset-avg-result-equivalence.json` inserts into `league_formats` by name, and regeneration cannot repair a setup block.

Gate results against the candidate schema, from a clean worktree: `check-knex-column-resolution`, `check-rename-alias-residue --base b923b9d9d` (2 columns lost across 1 table, 0 findings), `check-data-view-sql-validity` (4590 statements), and the conformance ratchet (22, unmoved) are all green. `check-league-schema-consumers` fails on 12 findings that a control run at HEAD reproduces identically — the uid-retirement cluster, CLAUDE.md prose about other renames, and user-base nfl query docs. None belong to this cluster.

## Parked before the apply — RESOLVED, apply abandoned

**Closed 2026-08-17.** The operator ruled the collapse out entirely, so the apply this section gated will never run. Nothing was ever applied to production and the sweep is reverted.

For the record, since this section was the live gate for several hours: it asked the operator to confirm that 25 league-seasons' board dollars would be restated at `min_bid = 0`, and the answer was to not restate them at all. The apply would have deleted roughly 184,000 analysis rows, irreversibly short of a restore.

## Survivor selection — SUPERSEDED

**Closed 2026-08-17.** No format is merged, so no survivor is chosen and no board is discarded. Retained as the record of what the collapse would have done.

Rule, applied in order: prefer the row carrying analysis data (so the large tables need no remap); else the row a `seasons` row references; else the table-wide modal economy `(200, 0, auction)`; else the lowest id. The survivor's own economy becomes the format's valuation unit.

Every one of the 8 groups resolves under the rule with no manual exception. Six are a referenced row against an orphan carrying nothing. Group 4 is two rows each with one season reference and no data, resolved by the modal-economy clause. Group 7 is the only one with data:

| id | cap | min bid | seasons | projection | seasonlogs | careerlogs | holdings | pick values |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| `f663e70c` **survives** | 200 | 0 | 21 | 68,391 | 16,907 | 3,768 | 2,355 | 87 |
| `776e3d91` | 200 | 1 | 25 | 13,482 | 16,907 | 3,768 | 0 | 0 |
| `1bd7220f` | 300 | 1 | 1 | 0 | 0 | 0 | 0 | 0 |

`f663e70c` survives on the first clause despite having fewer season references — it is the only row in the group holding `roster_asset_holding` and `league_format_draft_pick_value` rows, so choosing it leaves those untouched. Its `(200, 0)` is also the modal economy, so the two clauses agree.

This is the choice that restates 25 league-seasons' board dollars at `min_bid = 0`, noted in Design.

## Placement decision

**`seasons`.** Operator ruling, 2026-08-17: cap, minimum bid and pricing model can change from one season to the next and belong to the season.

`salary_cap`, `min_bid` and `pricing_model` move onto `seasons`, grain `(lid, season_year)`, beside the settings of exactly that grain already there — `starting_free_agent_acquisition_budget`, the four franchise-tag salaries, `season_due_amount`. No `league_economies` table: it would key on the same tuple `seasons` already keys on, so it would be a second row per league-season carrying nothing `seasons` cannot.

The consumer that makes this cheap is the league wire. Both `libs-server/get-league.mjs:42` and `api/routes/me.mjs:205-209` already reach `league_formats` BY JOINING THROUGH `seasons`, so after the move the same join yields the same merged object. Every group B consumer downstream of a `getLeague` — the SPA Record, `Roster`, the context docs, the cap-violation check, `generate-league-season-teams` — is untouched.

### Where a named catalog preset's economy lives

The consumer that forces this sub-question is `scripts/process-projections.mjs`. It iterates FORMAT ids, and its work list is built from hosted leagues AND from the named-format catalog — analysis presets belonging to no league, with no `seasons` row to read.

Presets get no database economy. A preset's economy is an analysis parameter, and it stays where it already is: in the definition in `libs-shared/league-format-definitions.mjs`, passed explicitly into the pricer. Nothing writes it to a column. This is close to what the code already does — the work list at `process-projections.mjs:665-685` already sources `pricing_model` from outside the table for both populations (`'auction'` hardcoded for hosted leagues, `named.pricing_model` for presets), and already threads it into `process_league_format` as an argument.

### Seam for the sweep

`libs-shared/calculate-prices.mjs` is the single chokepoint where economy enters the valuation pipeline. `get_discretionary_cap` has exactly one production caller (`calculate-prices.mjs:103`); every other reference is a test. So the sweep gives `calculatePrices` an explicit economy argument `{ salary_cap, min_bid, pricing_model }` rather than letting it read those off the `league_format` object, and `get_discretionary_cap` takes the economy and the format separately — `number_teams` and roster size stay on the format, where they are identity.

That also converts the failure mode. Today `get_discretionary_cap` destructures identity and economy out of one object, so an absent economy yields `NaN` with nothing thrown; as a separate required argument, a missed call site is visible at the call site.

## Collapse measurement — SUPERSEDED as instruction, accurate as measurement

**Closed 2026-08-17.** The 17-formats-to-8 figure below is correct and is precisely why the collapse was dropped: against 860 formats spanning 851 rulesets, it dedups 9 rows. Read it as evidence, not as a work item.

Run 2026-08-17 against `league_production`, grouping on the 15 surviving identity columns.

| | |
| --- | --- |
| formats today | 860 |
| distinct identities after the three columns leave | 851 |
| colliding groups | 8 |
| formats inside a collision | 17 |
| rows retired by remapping | 9 |
| most distinct economies in one group | 3 |

Distinct economy tuples across all 860 formats: 16, overwhelmingly `(200, 0, auction)` at 804 rows and `(200, 1, auction)` at 40.

### The collision that matters

One group is three-way and it is not orphan data:

| format id | cap | min bid | seasons referencing | projection value rows |
| --- | --- | --- | --- | --- |
| `f663e70c` | 200 | 0 | 21 | 68,391 |
| `776e3d91` | 200 | 1 | 25 | 13,482 |
| `1bd7220f` | 300 | 1 | 1 | 0 |

The first two also hold 16,907 `league_format_player_seasonlogs` rows each, every one priced.

In the other 7 groups the collision is between a format a season references and one or more siblings that nothing references, so those remap cleanly.

## Open drift — SUPERSEDED, needs no approval

**Closed 2026-08-17.** This section only describes a problem the collapse creates, and the collapse is dropped: one format id continues to carry exactly one economy, so the two format-keyed dollar columns stay well-defined and nothing below is live.

Read it as the argument that killed the collapse rather than as an open question. Note its proposed remedy — moving the dollar columns to "a league-season-scoped home" — is itself wrong, for the reason in "Three identities, three questions": a published board has no league and no season to get a unit from.

Its heading stood unresolved long enough to park a worker session on a decision nobody owed, which is its own lesson: a section titled "needs approval" must be closed in the same change that closes the question.

### Original text

The collapse has a consequence the plan does not carry: **two format-keyed tables hold dollar columns that become ill-defined once one identity can carry several economies.**

- `league_format_player_projection_values` — `pts_added` is economy-independent, `market_salary` is not
- `league_format_player_seasonlogs` — the `points_added_*` family is economy-independent, `earned_salary` and `points_added_net_cap_dollars` are not

Both are keyed by `league_format_id` alone. Today the id encodes the economy, so a board is unambiguous; after the extraction `f663e70c` and `776e3d91` become one id holding two contradictory priced boards. Measured on the same player, same season, same surviving identity, differing only by `min_bid`:

| pid | season | points added earned | salary at min bid 0 | salary at min bid 1 |
| --- | --- | --- | --- | --- |
| `LADA-TOML-004575` | 2006 | 312.7 | 62.00 | 56.00 |
| `TOMX-BRAD-007735` | 2007 | 283.3 | 54.00 | 49.00 |
| `PATR-MAHO-005785` | 2018 | 277.8 | 51.00 | 47.00 |

Points agree exactly; dollars do not. Resolving it means either re-admitting an economy dimension onto the two tables — which re-conflates what this task exists to separate — or moving the three dollar columns to a league-season-scoped home and leaving the format board carrying points only. The second is the clean end state, and it is a materially bigger change than the plan's remaining items describe.

## Consumer enumeration

Measured 2026-08-17 at league master `e4afd60f4`, sweeping `salary_cap|min_bid|pricing_model` across `libs-server libs-shared scripts api app test db docs server jobs private`. Positive control: `libs-server/find-or-create-format.mjs` returns 5 hits, so a zero elsewhere is trustworthy. `jobs/` and `private/` are genuinely zero — both were re-probed with a token known present in them.

Raw: 302 token matches across 68 files. That is not the sweep surface — the token also names `teams.salary_cap`, a different column. Judged per `(table, column)`, the population splits four ways.

### A. Sites naming the physical `league_formats` columns

The migration surface proper.

- `libs-server/find-or-create-format.mjs:55-56,138-150` — column whitelist plus the upsert that mints a format id
- `libs-server/get-league-format.mjs` — `select('league_formats.*')`, a WHOLESALE read naming no column, so it is invisible to any name grep and is the silent-key-loss shape the repo CLAUDE.md warns about
- `libs-server/get-league.mjs:42` — `seasons` left-joined to `league_formats`, wholesale
- `api/routes/me.mjs:205-209` — the second league wire, same join, wholesale
- `scripts/generate-league-formats.mjs:91-92,173-174,263` — enumerates the config space and seeds rows
- `scripts/generate-named-formats.mjs:103,133-146,196` and `scripts/verify-named-format-catalog.mjs`
- `scripts/process-projections-for-league-format.mjs:33-40` — a direct `select('pricing_model')` off the table
- `api/routes/leagues/league-settings.mjs:224-225,280-313` — the commissioner-editable field whitelist
- `db/fixtures/league.mjs`, `db/schema.postgres.sql` (DDL plus `league_formats_config_unique`)

### B. Object-property reads off a format or league record

These name no table at all — they read `.salary_cap` / `.min_bid` / `.pricing_model` off an object produced by group A. No consumer gate in the repo can see them, and this is where the real breakage lands.

- `libs-shared/get-discretionary-cap.mjs:17-20` — destructures `{ number_teams, salary_cap, min_bid }`; an absent key here yields `NaN` silently
- `libs-shared/calculate-prices.mjs:86-87` — `league_format.pricing_model` gates the whole auction pricing path
- `libs-shared/roster.mjs:102` — `this._league.salary_cap`
- `libs-server/tag-board/build-tag-board.mjs:311`
- `libs-server/context-docs/generate-league-rules.mjs:116,121`, `generate-league-rosters.mjs:82,110`
- `scripts/generate-league-format-player-seasonlogs.mjs:168-188`
- `scripts/process-projections.mjs:295-306,378-395,643-734`
- `scripts/generate-league-season-teams.mjs:93` and `api/routes/leagues/teams.mjs:533` — propagate the format cap into `teams.salary_cap`
- `scripts/check-restricted-free-agency-salary-cap-violations.mjs:161`
- SPA: `app/core/leagues/league.js:52,97,178,208,286,321` (Record declaration and `createLeague` destructure, both required per the Immutable-Record rule), `app/views/components/restricted-free-agency-confirmation/`, `app/views/components/league-settings-general/`

The plan's Design section names `libs-shared/calculate-player-values-rest-of-season.mjs` and `scripts/calculate-points-added.mjs` as sites where cap and min bid drive the calculation. Neither names any of the three columns: both pass `league_format: league` down into `calculate-values.mjs`, which delegates to `calculate-prices.mjs` and `get-discretionary-cap.mjs`. The claim is right about the dependency and wrong about the site.

### C. Seed and definition data carrying economy as part of a format definition

- `libs-shared/league-format-definitions.mjs` — 51 hits, each definition carrying `salary_cap: 200, min_bid: 0`
- `libs-shared/named-format-catalog.mjs` — generated, 24 `pricing_model` values
- `libs-shared/create-default-league.mjs:42,74`
- `libs-server/external-fantasy-leagues/mappers/league-config-mapper.mjs:305-311,450-451` — the motivating site: it fills `salary_cap: 0, min_bid: 0` for an external league that has no such thing

### D. Same token, different column — out of scope

`teams.salary_cap` (a per-team remaining-cap counter, `DEFAULT 0`): `api/sockets/auction.mjs:838`, `app/core/teams/team.js:11,41,73`, plus the write sites listed in B. External wire fields `available_salary_cap` and siblings in `adapters/espn.mjs:938-940` and `adapters/sleeper.mjs:287`. History and adjudication files under `db/adhoc/`, `db/archive/`, `db/gates/`, and the `docs/` prose.

### Bearing on the placement question

`seasons` is keyed `(lid, season_year)` and already carries `league_format_id`, `scoring_format_id`, `starting_free_agent_acquisition_budget`, the four franchise-tag salaries and `season_due_amount` — so the economy would sit beside settings of exactly its grain, and both league wires (`get-league.mjs`, `me.mjs`) already join through `seasons`, meaning group A's readers and every group B consumer downstream of them need no change at all.

The consumer that argues against it is `scripts/process-projections.mjs`. It iterates FORMAT ids rather than leagues, building its work list from real leagues AND from the named-format catalog — analysis presets that belong to no league and have no `seasons` row. It then calls `get_league_format({ league_format_id })` and prices off that record. A `seasons`-keyed economy cannot serve those presets, so the placement decision has to say where a catalog preset's economy lives. Settling that is the next plan item.

## Notes

Named formats in `libs-shared/named-format-catalog.mjs` carry economy values as part of their definition. Settle during implementation whether the catalog keeps them (seeding both the format and its economy) or sheds them.
