Repository navigation
feat(spec)!: FieldSchema refuses a select / radio with neither options nor picklist - #21390
Conversation
… nor picklist The choice door beside the reference check: the ADR-0078 completeness predicate (checkFieldCompleteness, its error-severity field/choice-without-options finding) is applied at parse, so options: [] is the same hole, a picklist reference is a source, and the types are exactly select / radio. The options and picklist TSDoc and describes state the rule. Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude <noreply@anthropic.com>
…n becomes a refusal pin Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude <noreply@anthropic.com>
… the choice door's stored-row disposition Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude <noreply@anthropic.com>
…cribes Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude <noreply@anthropic.com>
…shooting pages Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude <noreply@anthropic.com>
…ired marker Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 11 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 138 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 0969ed145600f546d4d07c37b1993a4ee833f3c9 && git checkout 0969ed145600f546d4d07c37b1993a4ee833f3c9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ee75aae1a8ebc2625e57074527d446151535634b ee5b089bbe2533d9d3044b93f4b64d6d57b5bb3a && git checkout -B drift-repro ee75aae1a8ebc2625e57074527d446151535634b && git merge --no-ff ee5b089bbe2533d9d3044b93f4b64d6d57b5bb3a
node scripts/docs-audit/affected-docs.mjs --json ee75aae1a8ebc2625e57074527d446151535634b
|
Contract reviewServed-tier: Inputs read: card #20827 (body and all 11 comments, the ruling ① Derived judgmentsEvery accept-set and public-surface change the diff implies, each judged:
Governed surfaces: none in the file list ( ② Semver level
③ Boundary flags
New flag, not raised by the dev — ADR-0078's text. §4 of Check-runs on the head at 2026-10-02T10:24Z: 27 Implemented-by: VERDICT: PASS Generated by Claude Code |
…hat decided them (stage 6 of objectstack-ai#20595) (objectstack-ai#21396) Part of objectstack-ai#20595 Clause-②: no ## What changed Stage 6 of the `domain:engine` lane of the dead-citation sweep: `packages/metadata/**`, comment and docblock prose only, per the claim (`5949772544`). Stages 1 to 5 landed as `a7d9768ec`, `d150c3039`, `4bf4e7e70`, `13a24ece2` and `db0cf2231`; objectstack-ai#20595 stays open for the next stage. Every comment or docblock site in the package that cited a tracker number answering 404 is rewritten in ruling C+D's form C (record `5749154545` on objectstack-ai#19123): the ADR when one records the decision, otherwise the commit in this repository's history that made it. That is **75 sites on 74 lines in 24 files, covering 18 numbers**: - **41 census sites** (41 lines, 12 files under `src/`): the whole `allocated-but-absent` population of the gate's own census in this package at the base, the slash-joined `objectstack-ai#5108/objectstack-ai#14423` at `ambiguous-metadata-stem.ts:40` from the post-landing census (`5923084795`) included; - **2 sites on 1 line outside the census glob, inside the claimed surface**: `tsup.config.ts:17` (`objectstack-ai#14399/objectstack-ai#14680` in a `//` comment), stage 1's `tsup.config.ts` precedent; - **32 test-comment sites** (32 lines, 11 test files), which the census defers. They carry 13 numbers: 10 the census itself reads as dead in this package's `src`, 1 it reads as dead elsewhere in the repository (objectstack-ai#12914, in `metadata-core`), and 2 it never judges on this tree because they stand only in test files (objectstack-ai#13072, objectstack-ai#16657), which the board and a single read each settle. No comment-id citation stands in the package (see Census). **Anchors: 17 numbers by commit, 0 by ADR, 0 by words alone, and 1 respelled with its repository.** `objectstack-ai#6111` never meant this repository's issue: both sites read `objectui#6110 + objectstack-ai#6111`, a pair of objectui issues whose second number the same test file spells `objectui#6111` at `:229`, so they now read `objectui#6110 + objectui#6111`, the exact respelling the spec lane gave the same pair (`2123fcca3`). 11 numbers reuse the anchor (or respelling) another lane or stage already used for them; 7 were measured here. 17 distinct shas. No ADR or ruling record names any of the 18 (`git grep` over `docs/adr` and `scripts/adr-anchors` finds none of them; control: objectstack-ai#15041 finds ADR-0104). `ADR-0049`, already beside objectstack-ai#10485 and objectstack-ai#13135, stays. Only comments changed. Every file keeps its line count (74 lines out, 74 in, plus the changeset), so no line citation into any of them moves. No code token moves (the guard below). **No citation number is added**: on every changed line, the numbers on the new text are a subset of those on the old (for `objectstack-ai#6111` the same number, now qualified), and the diff-scoped gate judged the citations left on changed lines (see Gates). **A `patch` changeset**: 23 of the 42 rewritten non-test lines are in the published `dist` (the `.d.ts` keeps JSDoc on exported members, and esbuild keeps some comments in the JS), and `dist` is not byte-identical with the base text (see Changeset). ## H0: the largest remaining `domain:engine` package The gate's own `node scripts/check-issue-citations.mjs --census --json` at base `ee75aae1a` (the before run below), `allocated-but-absent` per package over every `domain:engine` family in `.claude/skills/pm-dispatch/SKILL.md`'s lane table: | package | sites | |---|---| | `metadata` | **41** | | `core` | 40 | | `platform-objects` | 27 | | `metadata-core` | 19 | | `drivers/driver-turso` | 14 | | `drivers/driver-mongodb` | 9 | | `formula` | 4 | | `metadata-fs` | 2 | | `metadata-protocol`, `objectql`, `plugins/plugin-pinyin-search`, `drivers/driver-memory`, `drivers/driver-sql`, `drivers/driver-sqlite-wasm` | 0 each | `metadata` is still the largest (lane total 156), so this stage went ahead. The margin is one site: `core` reads 40. ## Census: `metadata`, before and after **Instrument (A1).** The gate's own `node scripts/check-issue-citations.mjs --census --json`, read-only and unchanged. The count is its `allocated-but-absent` findings under `packages/metadata/`. | reading | tree | board | whole-repo `allocated-but-absent` | sites | lines | files | numbers | |---|---|---|---|---|---|---|---| | before | base `ee75aae1a`, run 10:00:40Z to 10:04:24Z | enumerated, 193 pages, frontier objectstack-ai#21388, 19,209 records (newest number read before and after the run: objectstack-ai#21388) | 271 | **41** | 41 | 12 | 15 | | after | `7f9836c0e`, run 10:17:35Z to 10:20:58Z | enumerated, 193 pages, frontier objectstack-ai#21394, 19,215 records (newest before: objectstack-ai#21392, after: objectstack-ai#21394) | 230 | **0** | 0 | 0 | 0 | The whole-repo drop is 41, and the two finding sets differ by exactly the 41 rows of this package, removed; none was added. `resolves` (34,962) and `resolves-as-pull-request` (2,380) did not move; `cross-repo-unjudged` went 1,169 to 1,170, which is `plugin.ts:916`'s `objectui#6111`, now read as objectui's. The card's 41-site figure for `metadata` was stage 5's re-measure at `5fd4855a9`; the base here reads the same 41. The head's later commits are the changeset (outside the census surface) and one merge of `main` that touches no file under `packages/metadata`. **Supplementary instrument, the whole package.** The census reads neither test files nor strings nor files outside `src`. A second reading runs the gate's own exported `extractCitations` (whole-file and comment-prose projections) over every tracked file in the package (100: 91 `.ts`, `package.json`, `tsconfig.json`, three `__fixtures__` artifact `.json` files, `README.md`, `ROADMAP.md`, `CHANGELOG.md` and `LICENSE`), and classifies each citation with the gate's `classifyCitation` against one board enumerated by the gate's `enumerateBoard` (193 pages, frontier objectstack-ai#21390, 19,211 records, 10:05:02Z to 10:08:30Z). Every one of the 18 numbers in the population was then read on its own over the issues endpoint (10:09Z): **all 18 answer 404**, and the lit controls `objectstack-ai#5286` and `objectstack-ai#12624` answer 200. | reading | citations | dead | src comment | test comment | test string | `ROADMAP.md` | changelog | |---|---|---|---|---|---|---|---| | before, `ee75aae1a` | 1,102 | **119** | 43 | 32 | 22 | 2 | 20 | | after, `7f9836c0e` | 1,029 | **44** | 0 | 0 | 22 | 2 | 20 | `src comment` here is the 41 census sites plus the 2 in `tsup.config.ts`. The citation count drops by 73: the 75 rewritten sites less the 2 `objectui#6111` respellings, which are still citations and now classify as cross-repo (src comment 3 to 4, test comment 7 to 8). The live counts did not move (src comment: 277 resolve, 7 as pull requests; test comment: 275 and 13). A third, raw reading (every `#` followed by 2 to 6 digits, whatever surrounds it) counts 1,111 before and 1,038 after: a drop of 73, for the same reason. **Comment ids.** No comment-id citation stands in the package: `git grep -E '[0-9]{10}'` over `packages/metadata` (its `CHANGELOG.md` aside) finds no line (exit 1); the same grep over `packages/metadata-protocol` finds 13 lines, 5 of them `comment 5…` citations (control). So no comment id is in the population. ## Per-number table `src` counts the census sites plus the two in `tsup.config.ts`; `test` counts test-comment sites. Every sha below matches exactly one commit (`git rev-parse --disambiguate`, count 1) and is an ancestor of the base `ee75aae1a` (`git merge-base --is-ancestor`, exit 0 for all 17 shas; the clone is not shallow, `--is-shallow-repository` false). The `+` lines carry exactly these 17 nine-hex spans and no other: 16 are new, and `3ecb7dc1a` was already on both of its lines. The message or the diff of each one names the number it replaces: 9 in the message and the diff (`35ad101bc`, `6a180e42d`, `2e471e362`, `3c1bbd2a8`, `a56baa2bd`, `4b4d5a331`, `3bd9b3498`, `c1d274de7`, `5a95b0e93`), 5 in the diff alone (`f4e7ae5c7` 11 added lines, `200d255e7` 2, `9e0ba21a1` 80, `4cda78c9b` 11, `efc5447ee` 5), 1 in the message alone (`f887e5249`, the squash of PR objectstack-ai#13074, whose two commits each name objectstack-ai#12914), and 2 only in the subject's squash suffix (`3ecb7dc1a` and `ac8ed476f`, where the dead number was that pull request's own). `git blame` at the base puts 49 of the 75 sites on their anchor. The other 26 were written by a later commit that cites the number as an earlier change (for example `c1d274de7` citing objectstack-ai#14486 and objectstack-ai#14423, `efc5447ee` citing objectstack-ai#14486 and objectstack-ai#14341, `4b4d5a331` citing objectstack-ai#14341 and objectstack-ai#14627, `3e7ef9c23` citing objectstack-ai#14409), or are the two `objectstack-ai#6111` sites `200d255e7` wrote. In each case the anchor is the commit that made the change the sentence credits to the number. `source` says whether another lane or stage already used this anchor for this number (`reused`) or it was measured here (`measured`). | number | src | test | anchor | kind | source | what it decided | |---|---|---|---|---|---|---| | `objectstack-ai#6111` | 1 | 1 | `objectui#6111` | repository qualifier | reused (spec lane stage 5, `2123fcca3`, which read objectui's issues endpoint for 6111 at 200) | objectui's own issue: evaluate a form section's `visibleWhen` with the host predicate scope bound (`200d255e7`'s message), paired with `objectui#6110` in the same sentence | | `objectstack-ai#10485` | 1 | 0 | `35ad101bc` | commit | reused (stages 1 and 3; the spec, qa, cli, rest and runtime lanes) | retire the `themes` carrier key and `ThemeSchema` (ADR-0049) | | `objectstack-ai#12140` | 4 | 1 | `f4e7ae5c7` | commit | measured | gate the metadata HMR door on an explicit `NODE_ENV=development` posture; the route ledger's row moves with it | | `objectstack-ai#12914` | 0 | 1 | `f887e5249` | commit | measured | a form SECTION `visibleWhen` binds `current_user` too: re-measure the stale contract text | | `objectstack-ai#13072` | 0 | 1 | `200d255e7` | commit | measured | the unbound-root notice stops flagging a section-level `current_user` predicate (it inverted this very case in place) | | `objectstack-ai#13135` | 1 | 2 | `9e0ba21a1` | commit | reused (the spec lane) | retire the paper metadata-customization protocol, the manager's overlay limb included (ADR-0049) | | `objectstack-ai#13279` | 3 | 0 | `6a180e42d` | commit | reused (stage 4; the types, rest, runtime and services lanes) | move `isMissingTableError` / `isSchemaAlreadyExistsError` to `@objectstack/types`; its message records the maintainer's 2026-08-30 ruling | | `objectstack-ai#13324` | 1 | 0 | `4cda78c9b` | commit | reused (stages 1 and 4; the types lane) | require a missing-table error to name the table that was read | | `objectstack-ai#14341` | 3 | 5 | `2e471e362` | commit | measured | `FilesystemLoader` keys loader-held items by the name it can resolve (the squash of PR objectstack-ai#14497) | | `objectstack-ai#14399` | 3 | 0 | `3c1bbd2a8` | commit | reused (stage 3) | derive a view container's object through the shared `deriveViewContainerObject` | | `objectstack-ai#14409` | 1 | 1 | `3ecb7dc1a` | commit | reused (stage 1; the rest lane), already on both lines | measure what each dialect materialises for a datetime JS cannot hold (that pull request's squash) | | `objectstack-ai#14423` | 6 | 3 | `a56baa2bd` | commit | reused (stage 3; the runtime lane) | `listNames` gains `loadMany`'s per-loader fault parity, a keyed plural read lands beside `loadMany`, and the action audit reads the store key | | `objectstack-ai#14486` | 6 | 11 | `4b4d5a331` | commit | measured | `FilesystemLoader.list()` reports only names `findFile()` can resolve; its message records the maintainer's 2026-09-02 ruling (option A) and is 「Part of objectstack-ai#14486」 | | `objectstack-ai#14627` | 1 | 2 | `ac8ed476f` | commit | measured | that pull request's squash (repair the package's hidden test-type errors and wire `typecheck`), the concurrent claim the sentences name | | `objectstack-ai#14680` | 3 | 0 | `3bd9b3498` | commit | reused (stage 3) | the leaf `/view-container` subpath, measured on the built artifacts | | `objectstack-ai#14921` | 8 | 2 | `c1d274de7` | commit | reused (the runtime lane) | refuse an ambiguous metadata stem with both paths named | | `objectstack-ai#15037` | 1 | 1 | `efc5447ee` | commit | measured | `RemoteLoader.list()` no longer reports a nameless body as a literal `undefined` | | `objectstack-ai#16657` | 0 | 1 | `5a95b0e93` | commit | reused (stages 1 and 4; the types lane) | read the dialect text out of `cause` for operator-facing records | ## Wordings to check Most rewrites swap a tag in place (`[#N]` to `[commit SHA]`, `(#N)` to `(commit SHA)`, `#N's X` to `commit SHA's X`), the form the landed stages use. These say more than the tag: - **`objectstack-ai#6111`** (`plugin.ts:916`, `plugin-unbound-form-predicate-roots.test.ts:94`): 「objectui#6110 + objectstack-ai#6111」 became 「objectui#6110 + objectui#6111」. `200d255e7`'s message, which wrote both sites, says 「objectui#6110 threads the host shell's predicate scope into `isSectionVisible`, and objectui#6111 evaluates the authored section `visibleWhen`」. - **`objectstack-ai#14409`** (`database-loader.ts:82`, `database-loader-14078-invalid-date-total-arm.test.ts:19`): 「(objectstack-ai#14409, landed `3ecb7dc1a`)」 became 「(commit `3ecb7dc1a`)」 and 「PR objectstack-ai#14409 (landed `3ecb7dc1a`):」 became 「Commit `3ecb7dc1a`:」, stage 1's spelling for the same sentence: the dead pull-request number goes and the squash already beside it stays. - **`objectstack-ai#14486` where it was written while that card was open** (`filesystem-loader-keyed-items.test.ts:42`, `:180`): `2e471e362` wrote them a day before `4b4d5a331`, which is 「Part of objectstack-ai#14486」 and left this file's RECORD cases standing (the file was under a concurrent claim). A bare swap would credit `4b4d5a331` with inverting them, so they read 「the card commit 4b4d5a3 took in part」. - **Rulings the anchor's message does not carry, so the date stays and the site reads 「landed as」** (stage 1's `objectstack-ai#9741` precedent): `objectstack-ai#14341` (`2e471e362`'s message states the rule, not the 2026-09-02 PM ruling) became 「PM ruling of 2026-09-02 — option D, landed as commit 2e471e3」 (`filesystem-loader-keyed-items.test.ts:18`) and 「PM ruling of 2026-09-02, landed as commit 2e471e3」 (`filesystem-loader.ts:181`); `objectstack-ai#14921` (`c1d274de7`'s message states the refusal, not the 2026-09-05 ruling) became 「(maintainer, via the director seat, 2026-09-05; landed as commit c1d274d)」 (`ambiguous-metadata-stem.ts:24`) and 「(maintainer ruling, landed as commit c1d274d, via the director seat,」 (`filesystem-loader-ambiguous-stem.test.ts:28`), whose next line keeps the date and the verbatim 「同意」. - **Rulings the anchor's message does carry**: `objectstack-ai#14486`'s (`4b4d5a331`: 「Ruled by the maintainer via the director seat (2026-09-02) as option A over the reverse-unify」) reads 「recorded in commit 4b4d5a3」 on four sites (`filesystem-loader-list-reachability.test.ts:26`, `filesystem-loader.ts:368`, `remote-loader-list-nameless.test.ts:42`, `remote-loader.ts:130`), and 「objectstack-ai#14486 ruling (…)」 became 「ruling in commit 4b4d5a3 (…)」 (`filesystem-loader.ts:203`); `objectstack-ai#13279`'s (`6a180e42d`: 「Maintainer ruling 2026-08-30, verbatim: 第一批其余同意」) reads 「the maintainer's 2026-08-30 ruling (commit 6a180e4)」 (`errors.ts:27`). - **The concurrent claim** (`filesystem-loader.ts:207`, `filesystem-loader-list-reachability.test.ts:51`, `:238`): 「(PR objectstack-ai#14627)」 became 「(squashed as commit ac8ed47)」. `ac8ed476f` landed at 16:18Z on 2026-09-03, after `4b4d5a331` (14:30Z the same day), so the claim was open when this landed. Its final diff does not touch `filesystem-loader-keyed-items.test.ts`; the sentence says only that the file was under its claim. - **Where the number named the defect, not the change**: the title 「objectstack-ai#15037 — `RemoteLoader.list()` declares … and maps a nameless body straight through」 became 「The defect commit efc5447 fixed — …」 (`remote-loader-list-nameless.test.ts:4`); 「that axis is the whole of objectstack-ai#14423」 became 「the whole of the defect commit a56baa2 fixed」 (`metadata-manager.ts:2841`). - **Item references**: 「objectstack-ai#14423 items 1 and 2」 became 「Items 1 and 2 of the card commit a56baa2 answered」 (`metadata-manager-keyed-plural-read.test.ts:4`), and 「the objectstack-ai#14423 cost question (the ruling's item 6)」 became 「the cost question commit a56baa2 answered (the ruling's item 6)」 (`:356`); `a56baa2bd`'s commits include 「pin the by-name rung's per-name cost — one findOne per probe」. - **Pins and RECORDs**: 「landed objectstack-ai#14341 pin」 became 「landed pin of commit 2e471e3」, and 「three landed objectstack-ai#14341 pins」 / 「the three landed objectstack-ai#14341 pins」 became 「three pins commit 2e471e3 landed」 / 「the three pins commit 2e471e3 landed」; 「(objectstack-ai#14486 RECORD)」 became 「(commit 4b4d5a3's RECORD)」, the RECORD cases that commit pinned. - **Slash pairs**: 「(objectstack-ai#5108/objectstack-ai#14423)」 became 「(objectstack-ai#5108 and commit a56baa2)」; 「(objectstack-ai#14341/objectstack-ai#14205 fixed items going MISSING」 became 「(commit 2e471e3 and objectstack-ai#14205 fixed …」; 「(objectstack-ai#14399/objectstack-ai#14680)」 became 「(commits 3c1bbd2 and 3bd9b34)」 (`tsup.config.ts:17`). objectstack-ai#5108 and objectstack-ai#14205 are live and stay. - **Capitalisation**: where the number opened a sentence, the new text opens with 「Commit」 (five sites), or with 「Items」, 「The defect」 or 「The card」 (one each). - No line was reflowed, so some are longer than their block's wrap (`eslint.config.mjs` declares no line-length rule, and a reflow would move neighbouring lines and every line citation into the file). ## Sites left - **In comments (src, test, `tsup.config.ts`): none.** - **String literals: 22 test-string sites, 7 numbers, 7 files** (`describe` and `it` titles, assertion arguments): `objectstack-ai#14921` 5, `objectstack-ai#14486` 4, `objectstack-ai#12140` 3, `objectstack-ai#15037` 3, `objectstack-ai#16657` 3, `objectstack-ai#14341` 2, `objectstack-ai#14423` 2. Every one of the 7 is in this stage's population, so the table above holds an anchor for each. Non-test strings carry none. Strings are outside this stage's surface. - **`ROADMAP.md`: 2 sites (`objectstack-ai#13135`, at `:32` and `:173`).** Markdown prose, not a comment or docblock, and not in the package's `files` (`dist`, `README.md`, `CHANGELOG.md`), so outside this stage's surface; counted, not edited. Its anchor is in the table (`9e0ba21a1`). - **Outside `src`:** the release-owned `CHANGELOG.md` names dead numbers on 20 sites (16 numbers); left. `package.json`, `tsconfig.json`, the three fixture artifacts, `README.md` and `LICENSE` cite no dead number. ## Mechanical guard: no code token moves The guard (stages 2 to 5's) compares base `ee75aae1a` against the working tree at `7f9836c0e` over all 24 touched files, with TypeScript 6.0.3: - **Reading 1**: the parser's leaf nodes, from a `forEachChild` walk. Comments are trivia there, and JSDoc is never visited. A leaf that is not itself a token is re-scanned with trivia skipped. - **Reading 2**: the full token stream in parser context, from a `getChildren` walk, JSDoc nodes skipped. String, template and numeric literals are compared in full on both readings. Results: - Real run: 56,628 base tokens, **0 files with a token change** (exit 0). - Comment control (「Matched first, read second.」 to 「Matched FIRST」, `filesystem-loader.ts`): 0 files changed (exit 0). - Positive control, an identifier (`deriveViewContainerObject` to `deriveViewContainerObjectX`, `view-container.ts`): DIFFER on both readings (exit 1). - Positive control, a string literal (`'AMBIGUOUS_METADATA_STEM'` to `…X`, `ambiguous-metadata-stem.ts`): DIFFER on both readings (exit 1). - Positive control, a numeric literal (`err.status = 409` to `410`, `metadata-manager.ts`): DIFFER on both readings (exit 1). Each mutation went through `scripts/ablation-replace.mjs` (wrap mode, anchor hit 1 to 0, replacement 0 to 1) under a shell trap that restores by absolute path from `HEAD`. Each restore was proven equal to its `HEAD` blob (`9cb56cd6daac`, `1479bc09334e`, `7f0d7600dadb`, `8cf73463b771`), with `git diff HEAD` empty and a clean tree afterwards. ## Changeset: `patch` (`dist` measured) `files[]` is `dist`, `README.md` and `CHANGELOG.md`, and the package is not private. In one script under the shared verify lock (VERDICT command-exit 0), at `7f9836c0e`: the dependency closure was built first (`turbo run build --filter='@objectstack/metadata^...'`, 9 of 9 tasks), then the package's own `build` (tsup plus `check-dts-emitted`) ran three times: - **Leg 1**, the head text: 30 `dist` files hashed. Of the 42 rewritten non-test lines, 23 appear verbatim in `dist`: 10 from `filesystem-loader.ts`, 5 from `metadata-manager.ts`, 3 from `ambiguous-metadata-stem.ts`, 2 each from `remote-loader.ts` and `view-container.ts`, 1 from `plugin.ts`; in `index` / `node` `.d.ts` / `.d.cts` and `.js` / `.cjs`, and `view-container.d.ts` / `.d.cts`. - **Leg 2**, the base text put back in the 13 non-test touched files (13 of 13 proven equal to their base blob): 10 of the 30 files differ from leg 1 (`index.js`, `index.cjs`, `index.d.ts`, `index.d.cts`, `node.js`, `node.cjs`, `node.d.ts`, `node.d.cts`, `view-container.d.ts`, `view-container.d.cts`; no sourcemap moves), and `scripts/ablation-dist-preflight.mjs` finds the base marker 「[objectstack-ai#14921] The refusal a metadata source tree earns by naming one item twice.」 in 2 built files (exit 0). - **Leg 3**, after the proven restore (13 of 13 equal to their `HEAD` blob, `git diff HEAD` empty, porcelain empty): all 30 files are byte-identical to leg 1, and the preflight's `--absent` reading exits 0, so the build is deterministic and the difference is the rewrite. So the rewrite ships, and `.changeset/20595-metadata-provenance-anchors.md` declares a `patch` for `@objectstack/metadata`, comment text only, with the claim's `Clause-②: no` line. The later changeset commit and the merge of `main` touch no file under `packages/metadata`. ## Gates (head `61a589d48`) - **Derived gates:** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` at `61a589d48` (25 paths against merge base `51550933d`, 165 changed lines) derived 65 commands. All 65 ran, each exit code captured before any pipe: 65 exit 0. `--ran` reports 「65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN」 (a derived zero) and exits 0. - **Roster families the derivation flags under a touched directory**, also run, each exit 0: `node scripts/check-changeset-fixed.mjs`, `pnpm check:authz-resolver`, `pnpm check:error-code-casing`, `pnpm check:filter-alias-parity`. - **Named readings:** `node scripts/check-issue-citations.mjs` exits 0 (「every citation this change adds resolves (or is a declared cross-repo reference)」: 3 judged across 12 files, 1 resolves, 2 cross-repo, the `objectui#6110` / `objectui#6111` pair on `plugin.ts:916`); `pnpm check:issue-citations` exits 0 (self-test, 173 cases, 9 batteries); `pnpm check:doc-authoring` exits 0 (the sibling-package prose-id baseline holds, no growth); `pnpm check:nul-bytes` exits 0 (9,684 files, no raw control bytes), and a control-byte grep over the 25 changed files finds none (exit 1). - **Build before the gates:** `turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2` at `61a589d48`, 71 of 71 tasks, VERDICT command-exit 0. - **Tests and typecheck, under the verify lock, at `61a589d48`** (one command joined by `&&`, VERDICT command-exit 0): `pnpm --filter @objectstack/metadata test`: 56 test files pass (56), 836 tests pass (836); `pnpm --filter @objectstack/metadata typecheck` (`tsc --noEmit`) exits 0. `tsc --listFilesOnly` puts all 56 tracked test files and all 23 changed `src` files in that program; `tsup.config.ts` is outside it, and the token guard covers it. - **Lint, as a proven narrowing:** eslint with inline config disabled, over the 24 touched `.ts` files plus `dist/index.js` as the control: 25 results, 0 errors and 1 warning, the control's ignore notice; none of the 24 is reported ignored. `eslint.config.mjs` never enables type-aware linting (its lines 327 and 328 say so), so a comment edit cannot move the verdict on an untouched file. The repo-wide `pnpm lint` is CI's run. ## Acceptance notes - **Base.** The branch was cut at `ee75aae1a` and merged with `main` once, at `51550933d`, before the gates (merge `61a589d48`, no conflict; `main` brought one `packages/cli` commit and its changeset, and neither `check-issue-citations.mjs` nor `dispatch-gates.mjs`). `617f25f8a` (the last change under `packages/metadata` before this stage) is in the base (`merge-base --is-ancestor` exit 0; control `db0cf2231`, exit 0). The net diff against `main` is the 24 rewritten files (+74/−74) and the changeset (+17). - **`objectui#6111` was not read in this session.** This session's GitHub access does not reach `objectstack-ai/objectui` (its proxy answers 403 for the issues and pulls endpoints there, and the web page answers 403 for a control number too, so neither reading means anything). The 200 reading is the spec lane's, recorded in `2123fcca3`'s message for the same pair; `git ls-remote` on objectui shows no `refs/pull/6111/head` (control: `refs/pull/6112/head` is there), which fits an issue rather than a pull request. - **The margin over `core` is one site** (41 against 40 at the base). - **The same dead numbers stand in `packages/metadata-core`**: `objectstack-ai#6111` on 3 census sites and `objectstack-ai#12914` on 1, in its own stage of this card; this stage's `objectui#6111` respelling and `f887e5249` are there to reuse where those sentences say the same thing. - **Wording only:** no line without a number was changed. --- _Generated by [Claude Code](https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ect options (objectstack-ai#21402) Fixes objectstack-ai#21392 Clause-②: no ## What changed `skills/objectstack-upgrade/references/examples-upgrade.md`, § The v17-canonical shapes, compiled — two edits, nothing else in the file: 1. The `status` field in the block carrying the `os:check` marker gains a two-entry `options` list (`New` / `Qualified`, CRM-lead vocabulary), in the shape `skills/objectstack-data/references/examples-objects.md:73` already teaches. Every other line of the block is unchanged. 2. The section's lead sentence is narrowed to what `check:skill-examples` actually does — a type check. The clause claiming the block "cannot rot into teaching a shape that no longer compiles" is removed: PR objectstack-ai#21390 is the counter-example (the block type-checked green and parsed red at the same time), and the deletion is what pays for the `options` list under the file's token ceiling. The section title is untouched (`SKILL.md:461` references it by text). No parse limb is added to `check:skill-examples` — triage 5950866103: not this card, new gates default to no. ## Why PR objectstack-ai#21390 (ruling A on objectstack-ai#20827, record 5910124148) is on `main` at `c52c49d9`: `FieldSchema` refuses at parse a `select` / `radio` with neither a non-empty `options` list nor a `picklist` reference. The published block was teaching exactly that shape to every AI author who reads the skill, while the gate watching the block (`tsc --noEmit`) stayed green — `options` is optional in the type, and the refusal is a Zod refine. `ObjectSchema.create()` runs that same parse, so the block as published would also throw at module evaluation. ## Measured Tree: `05733de0` (this PR's head, the branch's only commit). Base: `ecb6ca02`. **Parse probe (one-shot script in the scratchpad, not committed, no gate added)** — the `Lead` literal extracted from the block and handed to `ObjectSchema.safeParse` from `packages/spec/dist` built at this head: - before (the block at `ecb6ca02`): `success=false`, one issue, `code=custom path=fields.status.options`, message begins "A `select` field needs its choices: declare a non-empty `options` list …" - after (this head): `success=true` **Token ratchet** (`node scripts/check-skills-token-ratchet.mjs`, unit = ceil(utf8 bytes / 4)): | | bytes | tokens | ceiling | headroom | lines | |---|---|---|---|---|---| | before | 4751 | 1188 | 1197 | 9 | 119 | | after | 4780 | 1195 | 1197 | 2 | 119 | Net +29 bytes / +7 tokens / 0 lines: the `options` list costs +94 bytes, the lead-sentence narrowing pays −65. The ceiling is not moved, and no re-wrap is used as currency. **Gates** — the 24 families `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derives at `05733de0` (set-equal to the dispatch's list), each exit captured before any pipe, then `--ran` reconciled: 0 unrun, every family exit 0. Two refused on first run with a printed prerequisite (exit 3 — not a verdict): `check:skill-examples` until `packages/client-react/dist` existed, `check:doc-formula-expressions` until `@objectstack/formula` and `@objectstack/lint` were built. After `turbo run build --filter=@objectstack/client-react --filter=@objectstack/lint` (34 tasks, under the shared verify lock) both ran green: `check:skill-examples` — "259 prose examples type-check across 3 surface(s) — every marked block parsed, so tsc ran the SEMANTIC pass on all of them"; `check:doc-formula-expressions` — 22 record-scoped examples and 14 field-level `*When` predicates judged clean. Not run locally, declared to CI: repo-wide `pnpm lint`; package test suites (the diff touches no package, and `skills/**` is in no package's `files[]`, hence `skip-changeset`). ## Risk and rollback A two-line example-and-prose change in a published skill reference; no runtime, package or gate code moves. Rollback is a revert of `05733de0`. Governed surface (`skills/**`, Tier H): this PR stays draft until an authorized APPROVED review; no seat readies, queues or arms it. ## Acceptance notes - The lead sentence overclaimed what the gate checks; the mechanism-level remedy (a parse limb on `check:skill-examples`) is a new gate and explicitly not this card. Noted, not filed; carrier: none. - `check:skill-examples` and `check:doc-formula-expressions` each exit 3 with a printed remedy on an unbuilt tree — a prerequisite refusal by design, not a finding. - `ObjectSchema.create()` runs the full parse at module evaluation, so the block before this PR would have thrown when executed, not only under `os validate`. Observation only; the gate that reads the block never executes it. ## 维护者速读(草稿) **改了什么**:升级技能(`skills/objectstack-upgrade`)参考文件里「v17 规范形状」示例中的 `status` 单选字段补上两项 `options`(New / Qualified);该节引导句从「经类型检查所以不会腐烂」收窄为「经类型检查」。文件其余部分、节标题、门禁脚本均未动。 **为什么改**:PR objectstack-ai#21390 已合入,spec 现在在 parse 时拒收没有选项的 `select` 字段;这段示例正是在教 AI 写一个会被拒收的形状,而守着它的门禁只做类型检查,看不见这一点。写给 AI 的技能说错一句等于产品缺陷(NORTH-STAR 优先规则 4)。 **风险与代价(含回滚)**:纯文档示例,两处改动,不影响任何运行时与发布包;token 账本余量从 9 降到 2,未抬上限。回滚即 revert 本 PR 的单个 commit。 **席位意见**:(留空) **你要做的**:审阅通过即在本 PR 上 APPROVE,席位随后落地;本 PR 应与 objectstack-ai#21390 同随下一个 Version Packages 发布。 --- _Generated by [Claude Code](https://claude.ai/code/session_01FNKm1SmPpuJASnbjxWGtsJ)_ Co-authored-by: Claude <noreply@anthropic.com>
…ainst a boolean field is refused like a non-boolean string (objectstack-ai#21382) (objectstack-ai#21404) Fixes objectstack-ai#21382 Clause-②: yes (narrowing) ## What this changes One verdict, widened, as triage directed on the card (grade `5949762416`, inheriting the number door's direction `5877498426`): the published boolean-comparand verdict now refuses any comparand against a declared `boolean` / `toggle` field (or a `formula` returning `boolean`) that is outside its accepted set. The engine's boolean arm consumes that verdict and nothing else; it carries no second rule. This is the boolean twin of PR objectstack-ai#20545 (`b05743433b`) on the number door, and it follows that PR's shape and words where the two contracts have the same parts. - **The verdict**, `packages/spec/src/data/filter-boolean-comparand-declared-type.ts`. `booleanComparandDoorVerdict(field, comparand)` answers `door-refusal` (`INVALID_FILTER` / 400) for a number other than `1` / `0` (`2`, `-1`, `0.5`, `NaN`), a `Date` and an array, at a scalar slot or as a list member. The string rule is byte-for-byte what PR objectstack-ai#21372 published. The accepted set is unchanged: `true`, `false`, `1`, `0`, `"true"`, `"false"`, `"1"`, `"0"`; `null` is still the null test. - **A `bigint` is read as the number it names**: `1n` / `0n` narrow like `1` / `0`, and any other `bigint` is refused as a number. See *The bigint reading* below for why this is the only reading that gives one answer at every position. - **What still passes**: a boolean, `null`, a `{ $field }` reference, and every value outside the comparand-type door's accepted set (`undefined`, a plain object, a `Map`). That door already refuses those on every field, in its own words (see *Objects* below). - Three additive exports: `NON_BOOLEAN_VALUE_FORMS` (`number`, `date`, `array`) and the types `NonBooleanValueForm` / `NonBooleanComparandForm`. The refusal's `form` widens from `NonBooleanStringForm` to `NonBooleanComparandForm`, on `BooleanComparandDoorVerdict`, `BooleanComparandRefusalSite` and `BooleanComparandDoorRefusalCase`. The site's `value` was already `unknown`; it now carries a non-string. `booleanComparandDoorVerdict`'s signature is unchanged (relevant to objectstack-ai#21376, which will consume it). - Three refusal clauses. None names a backend, because PostgreSQL's server error was measured at `where` only. The engine evaluates the per-aggregation `filter` and `having` itself, and this contract has no driver-bound site flag. A `Date` renders as `Date(ISO)` and a non-finite number by name, not as JSON (`NaN` would otherwise print as `null`, the null test). - The derived case table gains a `value` group: `-1` at `$ne` on every judged field, and `2` and a `Date` at every judged position of `f_boolean`. An array sits at every judged position except the equality slots, where the comparand-shape door refuses one first. Beside them are four passing rows. The `2` / `-1` reading rows, and a new `0.5` row, now derive refusals. `freshComparand` copies a `Date` and an array per filter, as the number table does. No `bigint` row is in either table, so every filter a suite builds survives `JSON.stringify`. - **The arm**, `packages/objectql/src/boolean-comparand-declared-type-door.ts`: comments only. The walk already routed every comparand to the verdict and carried the refused value as written. `number-comparand-declared-type-door.ts` (the shared walk) is untouched. - **Tests**: the spec contract suite, the objectql arm suite, and a new REST three-dialect cell `packages/rest/src/data-boolean-comparand-door.test.ts`, beside `data-number-comparand-door.test.ts`. In that cell SQLite always runs, and PostgreSQL / MySQL are named skips without their URL. - **Generated**: `packages/spec/api-surface/data.json` and `export-origins/data.json`, three added lines each, regenerated by `gen:api-surface` / `gen:export-origins` after `check:generated` named exactly those two. - **Changesets**, one per published package moved, both BREAKING `minor` with `!`, a `Clause-②: yes (narrowing)` line and one ADR-0087 marker (`not-required (no-migration-prescription)`): `.changeset/21382-spec-boolean-comparand-non-string.md` and `.changeset/21382-objectql-boolean-comparand-non-string.md`. ## Clause-②, measured `node scripts/pm/check-widening-tells.mjs` on `git diff 69a12a0...HEAD`: - `--declaration no`: exit 4, three T3 tells. These are the three new rows in `packages/spec/api-surface/data.json` (lines 469, 505, 507). - `--declaration yes`: exit 0. So this PR declares `Clause-②: yes (narrowing)`. It adds an export listing row, and what it changes in behaviour is a narrowing. No T1 / T2 / T4 tell fired. ## Before and after A declared `boolean` field, two rows (`rt` true, `rf` false), through `engine.find` / `engine.aggregate`. The probe is a scratch script against freshly built dists, not a committed test. A `where` cell reads implicit, `$eq`, `$ne`, and a `$in` member beside `false`. Before: base `69a12a0952`. After: this branch, spec and objectql source as at `196afd75cd`. PostgreSQL ran on a private PostgreSQL 16.14 cluster in this container, started for the run and stopped after. | position | comparand | before: memory · SQLite · PostgreSQL 16 | after, all three | |:--|:--|:--|:--| | `where` | `2`, `-1`, `0.5`, a `Date` (the card) | no row, no row, both, the false row · the same · **500 `DATABASE_ERROR` at every slot** | 400 `INVALID_FILTER` at every slot | | per-aggregation `filter` | the same | count 0, 0, 2, 1 on all three | 400 | | `having` on a groupBy of the field | the same | no group, no group, both, the false group on all three | 400 | | `where` | an array `[true]` as a `$in` member (the card) | **the false row (200)** · a driver 400 · a driver 400 | 400, in the contract's words | | per-aggregation `filter` / `having` | the same | count 1 / the false group on all three: the member silently dropped | 400 | | all three | `[true]` at implicit / `$eq` / `$ne` | 400 (the comparand-shape door) | the same | | `where` | `2n` (in-process) | no row · no row · 500 | 400 | | `where` | `1n` (in-process) | **no row · the true row · the true row** (`$ne 1n`: both rows on memory) | the true row on all three | | all three | `{ "a": 1 }` | 400 (the comparand-type door) | the same, same words | | all three | `true`, `1`, `"true"` (the controls) | the true row, count 1, the true group | the same | ## Premise check, and the dispatch's hypotheses - **H1 holds.** On `69a12a0952`, `readBooleanComparand` returned `null` for every non-string except `1` / `0`, the verdict answered `passes`, and the reading rows read `unread(2)` / `unread(-1)`. The card's table reproduces on memory, SQLite and PostgreSQL (rows above). The number contract's non-string branch was the template. - **H2 holds: no walk change.** `judgeBooleanComparand` already passed `value: comparand` and `form: verdict.form` straight through, so widening the verdict reaches `where` (both spellings), the per-aggregation `filter` and `having` with no code change in objectql. The shared walk file is not in the diff. - **H3**: the after-column is re-measured on memory, SQLite and PostgreSQL 16.14 (above). MySQL is NOT MEASURED, because no MySQL server is available in this container. The REST cell's MySQL leg is a named skip. - **H4 holds.** A `formula` returning `boolean` is still refused one door earlier, by the unmaterializable-field door, with `INVALID_FIELD` / 400. The arm suite's NAMED DIVERGENCE test drives every formula row, including the new `value` row on `f_formula_boolean`. ### The bigint reading The ruling does not name `bigint`. Measured on the base, `1n` against the boolean field gave different answers per driver at `where`: no row on InMemoryDriver, the true row on SQLite and PostgreSQL. `2n` gave PostgreSQL's 500. That is the card's defect class, through an in-process caller. The comparand-type door rewrites a `bigint` to its number. It runs AFTER this door on the object spelling of `where` and on a per-aggregation `filter`, and BEFORE it on the `FilterArray` spelling and at `having`. So: - passing a `bigint` leaves the divergence; - refusing every `bigint` would refuse `1n` on one spelling and narrow it on the other; - reading it as the number it names gives one answer at every position. This is not a widening. A `bigint` passed the verdict before, so it was accepted, and now it is either narrowed correctly or refused. The arm suite pins it on both spellings and at all three positions. ### Objects: served by the comparand-type door, deliberately Triage's list names objects. On the base, an object comparand against the boolean field is already refused with `INVALID_FILTER` / 400 by the comparand-type door, on all three drivers and at all three positions (table above). The widened verdict passes such a value rather than refusing it a second time. The reason is the one PR objectstack-ai#20545 measured: the engine runs the two doors in a different order per position, so a second refusal would answer one mistake with two sets of words. The arm suite pins it: for a plain object, `undefined` and a `Map`, the engine's message equals the comparand-type door's own message. ## Tests (at `196afd75cd`, the final head) Each heavy run went through `scripts/pm/os-verify-lock.sh` with exit codes from the lock's `VERDICT command-exit` lines. - **`@objectstack/spec`**: `filter-boolean-comparand-declared-type.test.ts` 34/34. `--project local`, run as two `--shard` halves to fit the container's foreground cap: 300 files / 8983 passed + 1 todo, and 299 files / 8580 passed (599 files in all, 0 failed). `typecheck` exit 0; `check:test-typecheck` holds its ledger (52 files / 246 errors, none added). - **`@objectstack/objectql`**: `engine-boolean-comparand-declared-type-door.test.ts` 34/34, and `engine-number-comparand-declared-type-door.test.ts` still green (its boolean-arm partition reads the widened verdict). `--project local` as two shards: 182 files / 3583 passed, and 182 files / 3777 passed. `typecheck` exit 0 (ledger 40 / 234, none added). - **`@objectstack/rest`**: `data-boolean-comparand-door.test.ts` with `OS_TEST_POSTGRES_URL` set: 6 passed, 3 skipped (the MySQL leg). The SQLite and PostgreSQL legs both ran. `--project local` (no live URL): 255 files, 4808 passed, 322 skipped. `typecheck` exit 0 (test layer 0 / 0). - **Lint, a proven narrowing** (the repo-wide `pnpm lint` is CI's): 1. Population, from `eslint.config.mjs` itself: all 5 touched lintable files answer `isPathIgnored` false. The other 4 changed files are JSON / Markdown, outside its globs. 2. `eslint --no-inline-config --format json` over them: 5 files, 0 errors, 0 warnings. 3. The config never enables type-aware linting (no `parserOptions.project`, no `projectService`), so this diff cannot move any untouched file's verdict. - **Gates**: `node scripts/pm/dispatch-gates.mjs --commands` (no paths) at `196afd75cd` derives 90 families; all 90 ran with exit 0, and `--ran` reconciles 90 derived / 90 run / 0 NOT-MEASURED. `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET, because the workspace was not fully built. It was re-run after a full `turbo run build`, and exited 0. ## Ablation (reverse verification) From the committed head `196afd75cd`, with `scripts/ablation-replace.mjs` in wrap mode. The mutation lives only while the tool's trap is armed. It removes the widening at its one routing line in the verdict: `if (form === null) return { verdict: 'passes' };` gains `|| (globalThis as Record[string, unknown]).ABLATION_21382 === undefined` (angle brackets spelled as square brackets here), so every non-string passes again. The string rule is untouched. - **Mutated leg**: anchor 1 to 0, blob `41a24373` to `064410ff`. After `pnpm --filter @objectstack/spec build`, `ablation-dist-preflight` found the marker in 4 built files (exit 0). Results: - spec suite: **3 failed** / 31 passed (the non-string verdict, the bigint reading, the `value` group); - objectql arm suite: **5 failed** / 29 passed (the GUARD's form set, and the `where`, per-aggregation `filter`, `having` and bigint tests); - REST cell: **4 failed** / 2 passed / 3 skipped (the refusal tests on both the SQLite and PostgreSQL legs). - The control tests stayed green on every suite. The direction is the expected one: red. - **Restore leg**: blob back to `41a24373` equal to HEAD, `git diff HEAD` empty, and `git status --porcelain` empty for the whole tree. After a rebuild, `ablation-dist-preflight --absent` found the marker absent from all 230 built files (exit 0). Results: spec 34/34, objectql 34/34, REST 6 passed / 3 skipped. ## Acceptance notes - `comparandPreview` is a private copy in the boolean module. The number module's copy is private, and PR objectstack-ai#21390 holds that file, so this PR does not touch it. Moving both copies into `filter-comparand-refusal-text.ts` is a consolidation for whichever PR next touches both. No carrier is named. - Two comments outside this claim's file surface still describe the string half only: `engine.ts` around the `narrowNumberComparands` call ("any other string is refused"), and the shared walk's `[objectstack-ai#21333]` header section. Both are incomplete, not false. No carrier is named. - The REST cell's live legs are red-capable and un-run in CI (no job provisions `OS_TEST_POSTGRES_URL` for `packages/rest`), as with the number cell. This PR carries their local PostgreSQL run. --- _Generated by [Claude Code](https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20827
Clause-②: yes
Ruling A (record
5910124148), step two:FieldSchemarefuses aselect/radiofield with neitheroptionsnorpicklistat parse, on thelookup-without-referenceprecedent (0fb8760bec). Step one (5917187437) measured the census and the Studio order; the objectui half landed and.objectui-sha31971ff1e28fcarries it (5947557233).The door
packages/spec/src/data/field.zod.ts,FieldSchema's superRefine, beside thereferencecheck: acustomissue on theoptionspath whose message names the field type and both remedies (options: [{ label, value }], orpicklist: 'NAME'for a shared list), says what the hole costs (an empty control, and no server-side value validation), and offerstextwhen any value is meant to be allowed. No tracker number in the runtime string.checkFieldCompleteness(field)has afield/choice-without-optionsfinding aterrorseverity. That module imports nothing at runtime, so the new edge (data to kernel) closes no cycle, and no export is added. Three facts follow from it rather than being restated:options: []is the same hole as a missing key; apicklistreference is a source; the types are exactlyselect/radio(checkboxesstays a warning,multiselect/tagsstay free-form). The same reading is what objectui's guard derivation test uses (deriveChoiceTypesRequiringOptions).optionsandpicklistTSDoc and.describe()texts state the rule; the reference pages were regenerated throughcheck:generated --fix(onlycheck:docswas stale).Fixture census, measured with the door on
Re-measured at base
5fd4855a9awith the full@objectstack/specsuite (6 red in 5 files) and the full@objectstack/metadata-protocolsuite (9 red in 2 files). That matches step one exactly.field.test.ts:2155(radio +multiple: false)multiple, not choicesfield-autonumber-default-unique.test.ts:45(minimalField)select/radioget one optionfilter-number-comparand-declared-type.tsfixture (fixtureFieldFor)SINGLE_OPTION_TYPES); the fixture interface gains an optionaloptionsmemberfilter-text-operator-declared-type.tsfixture (fixtureFieldFor)picklist.test.ts:129, 2 testscheckFieldCompletenessassertions are kept unchangedmetadata-protocol: 9 tests fromlegacyObjectRow'sstatus: { type: 'select' }(protocol.stored-conversions.test.ts,protocol.stored-migration.test.ts)conditionalRequiredsent, the value itsrequiredWhenreads)One fixture beyond the census:
canonicalObjectRowinprotocol.stored-migration.test.tscarries the same optionlessselect. It caused no red (the pass never validates a row that needs no conversion), but its doc says "already canonical — the shape every row ends up in", which the door makes false, so it got the same option.Stored rows: the disposition
No migration can invent the options an author meant, so a stored row is read and named, never rewritten. That was step one's reading, and it is now pinned in
@objectstack/metadata-protocol:getMetaItemstill serves such a row, with_diagnostics.valid: falsenamingfields.status.options;loadMetaFromDbcounts itinvalid: 1and still registers it;migrateStoredMetadata({ apply: true })on a legacy row that also carries an optionlessselectreports itfailed(spec validation), writes no history row, and leaves the stored bytes as they were.The changeset tells an operator to find such rows through
GET /api/v1/meta/diagnosticsor the boot log'sfield/choice-without-optionslines, and says theos migrate meta --storedpreview does not find them, because it does not validate.ADR-0087 marker:
not-required (no-migration-prescription). Two existing keys are narrowed in validity, and none is removed, renamed or re-shaped. No conversion entry can supply the missing intent, and the gate's other categories are closed on facts (the marker says why). That is the precedent's category. Productionsys_metadatacannot be measured from here, so the marker text takes the ruling's "some rows exist" arm and names the read-and-name path, not a migration.Semver
@objectstack/spec:minorwith a BREAKING header, as the precedent shipped. The changeset is.changeset/20827-choice-door-select-radio-needs-options.md. Gate lines:check-adr-0087-registration:1 declared-breaking changeset(s), each carrying an ADR-0087 disposition·[BREAKING+bang] not-required (no-migration-prescription)check-changeset-no-major:This diff introduces no major bumpcheck-empty-changeset:No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added)No other package publishes a change: the
metadata-protocoledits are tests only, andcontent/docsdoes not publish.Reverse verification (ablation)
The door's predicate was mutated through
scripts/ablation-replace.mjs(FIELD_CHOICE_WITHOUT_OPTIONSto the literal'ablation-20827-never'; anchor 1 to 0, blobea9313c768to9654443823). Then@objectstack/specwas rebuilt, andablation-dist-preflightfound the marker in 24 built files.field.test.tsand the 2 rewritten pins inpicklist.test.ts. 3metadata-protocolpins red: the twostored-conversionspins and thestored-migrationfailedpin. The positive controls stayed green (one option,picklistonly, andmultiselect/checkboxes/tagswith neither).git checkout HEAD --: blob equal to HEAD andgit diff HEADempty. After a rebuild,--absentreported the marker in 0 of 230 built files and the tree clean. Then 325/325 spec tests and 56/56metadata-protocoltests passed.Tests (head
ee5b089bbe, after mergingorigin/main)@objectstack/specfull suite: 646 files, 18391 passed, 1 todo.@objectstack/spectypecheck: green, includingcheck:test-typecheck.@objectstack/speccheck:generated: all 15 artifacts up to date.@objectstack/metadata-protocoltypecheck: green.a9473b8d91. The merge brought no change to these packages' sources.@objectstack/metadata-protocol: 201 files, 2983 passed, 19 skipped.@objectstack/objectql: 363 files, 7277 passed. This includes the two engine door suites that consume the changed spec fixtures.@objectstack/lint: 119 files, 5575 passed.@objectstack/metadata: 56 files, 836 passed.@objectstack/runtime: 306 files, 5081 passed, 11 skipped.dispatch-gates --ran: 110 derived families, 109 run, 1 NOT MEASURED, 0 unrun. The one:check:dual-build-cjs-loadsexits 3 because it needs every package'sdist. As a declared narrowing, all 19requireentries of@objectstack/specload under CJS, and the door is live throughdist/data/index.js.check:doc-authoringandcheck:nul-bytesare green.Acceptance notes
packages/spec/src/ai/solution-blueprint.zod.ts,BlueprintFieldSchema.options(optional) andStrictField.options(.nullable()), still let a blueprintselect/radiocarry no options. Inside objectstack and objectui, nothing expands a blueprint intoFieldSchemainput.apply_blueprintlives outside both repositories; objectui only renders its progress and plan cards. The module's own header says that the expansion validates against the per-type schema at write time. So once this door ships, an optionless blueprintselectis refused loudly at that write, not silently stored. The gap is that the blueprint accepts what the door refuses, so the refusal lands one step late, on the expanded artifact, not on the AI's structured output. Not edited here. For the PM to file: alignBlueprintFieldSchema/StrictFieldoptionswith the door forselect/radio.packages/data-objectstack/src/object-metadata-write-guard.derivation.test.ts, the describe "the installed server still ACCEPTS a choice with no options", goes red when objectui next bumps@objectstack/specto a release carrying this door. Its own comment prescribes the follow-up: rewrite the guard's docblock paragraph into the relationship form and turn the block into a refusal pin atoptions. That is the objectui lane's follow-up. No objectui edit here.skills/**hit, not edited (governed surface).skills/objectstack-upgrade/references/examples-upgrade.md:105teachesstatus: { type: 'select', required: true, storage: { notNull: true } }insideObjectSchema.create(...). With this door that example is refused at parse. It needs oneoptionsentry, in a skills-lane PR.content/docs/deployment/troubleshooting.mdxhad an entry quoting an error message no code emits ("Required property missing: options") and listingmultiselect/checkboxesas refused. It now quotes the real refusal, the right two types and the stored-row reading.validation-rules.mdxcalledoptions"Required" on all four option types;multiselect/checkboxesnow read optional, as the runtime treats them.field-types.mdxnamespicklistas the alternative. Theformulas.mdxObjectSchema.createexample got options. No content page said an optionlessselectparses or is only a lint finding.yes). The diff narrowsFieldSchema's accept set, which is BREAKING and is carried by the changeset banner. It also widens one published type: the two exported door-fixture interfaces gain an optionaloptionsmember. Read together,yes (narrowing)is the fuller spelling. The claim's spelling is kept, and the two gates pass on it.Generated by Claude Code