---
title: Resolve generated option sets on column values, not only column params
type: task
status: completed
description: >-
  Extend the completed column-param option-set mechanism so a column can declare option_set and have
  its column_values filled from a generated, lazily-imported module, covering client collection,
  client resolution and server resolution; drift detection for column-level declarations moved to
  the retype task.
base_uri: user:task/league/resolve-generated-option-sets-on-column-values.md
created_at: '2026-09-22T04:09:42.495Z'
entity_id: 1fb68cbc-d706-40c3-8b38-400f3e2d56a4
finished_at: '2026-09-22T05:48:50.941Z'
observations:
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
public_read: true
relations:
  - follows [[█████████████████████████-███-████-████-█████-██████-███████]]
  - blocks [[user:task/league/retype-low-cardinality-text-columns-to-select.md]]
  - relates [[user:task/react-table/add-column-header-quick-filter.md]]
started_at: '2026-09-22T05:08:00.525Z'
tags:
  - user:tag/league-xo-football.md
updated_at: '2026-09-22T05:48:51.159Z'
visibility_analyzed_at: '2026-11-05T23:50:12.040Z'
---

# Implementation Plan: Resolve Generated Option Sets on Column Values

## Overview

- Extend the completed column-param option-set mechanism so a COLUMN, not only a param, can declare `option_set: '<name>'` and have its `column_values` filled from a generated, lazily-imported module
- Cover three of the four seams the param path has — client collection, client resolution and server resolution. The fourth, drift detection, could not be closed here and moved to [[user:task/league/retype-low-cardinality-text-columns-to-select.md]]; see the Design section for why
- Change no existing param behaviour and add no new vocabulary of its own

**Impact.** Today a column can only offer a picker if someone hand-writes its `column_values`. Under `data_type: SELECT` the control is picker-only, so any value missing from that hand list is not merely unlisted but **unfilterable** — which is why open sets are generated rather than written down. Without this extension, retyping any real TEXT column to SELECT means shipping a list that rots into silent data loss. With it, a column names a set and the machinery already proven for params does the rest.

**Ordering.** Follows `████████████████████████████████████████████████████████████` (Completed), whose mechanism this extends. Blocks `user:task/league/retype-low-cardinality-text-columns-to-select.md`, which is the first consumer. Independent of `user:task/react-table/add-column-header-quick-filter.md`, which can ship before or after.

## Background

**The mechanism already exists for params and is documented.** [[user:text/league/data-views/generate-param-option-sets.md]] describes it end to end: `scripts/generate-column-param-option-sets.mjs` writes one frozen module per set into `libs-shared/column-param-option-sets/` plus a generated `loaders.mjs` map of literal `import()` calls; the browser pulls one chunk at a time through `app/core/data-views/option-set-loader.mjs`; the server awaits every loader once in `libs-server/data-views/column-param-option-sets.mjs`.

**It reads params and only params.** `collect_option_set_names` (`app/core/data-views/option-set-loader.mjs:81-101`) walks `table_state.columns`, `prefix_columns` and `where`, and for each resolves `fields[column_id].column_params`, skipping the field entirely when that is absent. `resolve_option_sets_on_fields` (`:123-148`) does the same, returning the field untouched when it has no `column_params`. A column-level `option_set` is therefore silently ignored at both seams today.

**Only one declaration site exists.** `grep -rn option_set --include=*.mjs --include=*.js app/ libs-shared/ libs-server/ scripts/` outside the generated directory returns `libs-shared/nfl-plays-column-params.mjs:107` as the single field-side declaration (`option_set: 'referee'`); the coach sets are declared in the generator's own list. The extension therefore has a small blast radius.

**The generator's set list is declarative.** `scripts/generate-column-param-option-sets.mjs:161-190` holds records of `{ name, source, query }`, so adding a set is a list entry plus a knex query, not new machinery.

**Drift detection exists.** `libs-server/param-vocabulary-drift.mjs:215-224` treats a definition as vocabulary-bearing when it has `Array.isArray(definition.values) || definition.option_set`, and keys its report on `declared_definition.option_set || key`.

**Two import rules are lint-enforced and must keep holding.** No field module may statically import a set, and no generated module may be re-exported from `libs-shared/index.mjs`; `local/no-static-option-set-import` enforces both. Breaking either is silent — the app works and the page chunk grows.

### Assumptions

- **A column-level `option_set` resolves to `column_values`, which is what the react-table picker reads.** Checked against [[user:text/league/data-views/generate-param-option-sets.md]], which states the picker reads `column_values` derived from `values`; the column-level path must therefore write `column_values` directly.
- **Absent `column_values` is safe before a set loads, as absent `values` is for params.** Unchecked for the column path. The param ordering is load-bearing because `is_param_value_admissible` overwrites a non-member with the default; the column path has no equivalent judge, so the risk is a picker rendering empty rather than data loss. Verify by rendering a retyped column before its chunk arrives.
- **`react-table` needs no change.** Unchecked; `column_values` is already a column-definition field (`schema/columns/column-definition.json:112-127`), so filling it later should need nothing new. If the picker caches on first render this grows a pin bump.
- **No column currently declares `option_set`.** Checked: the grep above returns exactly one hit and it is a param.

## Design

Four seams, mirroring the param path rather than inventing a parallel one.

**Client collection** — `collect_option_set_names` gains a column-level read: for each entry it adds `fields[column_id].option_set` when present, then continues into `column_params` as it does now. The early `continue` on missing `column_params` has to move, since a column may declare a set while carrying no params at all.

**Client resolution** — `resolve_option_sets_on_fields` gains a matching branch filling `column_values` from a loaded column-level set, and must stop returning the field untouched merely because it has no `column_params`.

**Server resolution** — `libs-server/data-views/column-param-option-sets.mjs` gains a column-level counterpart to `with_resolved_option_set`, applied where the field catalog is built, so every server site that judges a column VALUE sees the resolved list at the same seam it already reads.

**Drift** — planned as extending the vocabulary-bearing test and report key, and that turned out not to be the shape of the problem. `data_view_param_vocabularies` iterates the PARAM registry and takes each entry's source from `DATA_VIEW_PARAM_SOURCES`, keyed by param key; a column naming a set has no entry there, and nothing else maps a set name to a scannable table and column. What is missing is a structured `{ table, column }` on each generator set record — today's `source` is prose (`'distinct nfl_games.referee'`) — plus a third vocabulary function reading the client field registry, keyed on the set name and deduplicated against the sets the param surface already reports.

That work is only testable once a column actually declares a set, and this task deliberately introduces none. It therefore moved to the retype task, ordered ahead of its retyping waves. A set already scanned through a param of the same name (`referee`) is covered incidentally, because the check keys on the set name.

No new dependencies, no new generated-module layout, no new import rules. The generator itself needs no change for this task: it already writes any set named in its list, and naming the sets is the consumer task's job.

**Knowledge Base Impact:** [[user:text/league/data-views/generate-param-option-sets.md]] must be reconciled — it currently states the mechanism is param-only in several places, including its title framing and the "capability it deliberately does not have" section. That doc is the canonical description and would otherwise be actively wrong. No new `text/` entity, no new tag, no guideline candidate.

**Scratch tier:** not needed.

**Orchestrator shape:** bounded to a single task.

## Notes

- **The picker-only trap is the reason this task exists.** Under `data_type: SELECT` react-table routes to a `FilterValuePicker` while every other data type falls through to a `TextField`, so a value absent from the declared list cannot be filtered on at all, and a declared option matching no rows returns an empty table reading as "no rows met this condition". Neither direction raises anything.
- **A name with no module throws rather than resolving undefined**, and that shape should carry over to the column path unchanged.
- **Do not interpolate an import path.** A dynamic-expression `import()` makes webpack emit one context module covering the whole directory, loading every vocabulary the moment any one is asked for — the exact failure the mechanism exists to prevent.

## Plan

### Client

- [x] **`app/core/data-views/option-set-loader.mjs`** — in `collect_option_set_names` (`:81-101`), add `fields[column_id].option_set` to the name set and move the `column_params` early-continue so a params-less column is still collected. In `resolve_option_sets_on_fields` (`:123-148`), fill `column_values` from a loaded column-level set and stop short-circuiting on absent `column_params`. Purpose: the two client seams that silently ignore a column-level declaration today.
  - Verify: `yarn mocha test/option-set-loader.spec.mjs` with new cases for a column-only declaration and a column carrying both a set and params

### Server

- [x] **`libs-server/data-views/column-param-option-sets.mjs`** — add a column-level counterpart to `with_resolved_option_set` and apply it where the field catalog is assembled, so value-judging sites read a resolved list at their existing seam. Purpose: the server has no bundle and must hold every set whole.
  - Verify: a request filtering a column-level set column returns rows; `yarn mocha test/` shows no regression

- [x] **`libs-server/param-vocabulary-drift.mjs`** — MOVED to [[user:task/league/retype-low-cardinality-text-columns-to-select.md]], ordered before its retyping waves. The two-line edit this step described has nothing to attach to: `data_view_param_vocabularies` iterates the PARAM registry and sources each entry from `DATA_VIEW_PARAM_SOURCES` keyed by param key, so a column naming a set has no source to scan, and nothing else maps a set name to a table and column. Closing the gap needs a structured `{ table, column }` on each generator set record plus a third vocabulary function reading the client field registry — which is only testable once a column actually declares a set, and the retype task lands the first ones. Designing the mapping here would mean shipping a scan with no declaring column to prove it fires against.

### Knowledge Base Updates

- [x] **[[user:text/league/data-views/generate-param-option-sets.md]]** — reconcile the doc to cover both param and column declarations, including its framing and its statement of what the mechanism deliberately cannot do. Purpose: it is the canonical description and would otherwise be wrong.
  - Verify: `base entity get user:text/league/data-views/generate-param-option-sets.md --body` reads correctly for both paths
