fix(spec): notNull / not_null prescribe storage.notNull, not required (#16867) - #17477
Conversation
…equired`
`FieldSchema` refused the flattened column-constraint spellings and then
prescribed `required` — the one key ADR-0113 exists to say is NOT the column
constraint. `required`'s own `.describe()` in the same file says the opposite
of what the rename prescribed: "NOT a column constraint — the physical NOT
NULL is a separate explicit opt-in (`storage.notNull`)".
The refusal was never the problem; the REMEDY was. An author reaching for a
NOT NULL column complied, wrote `required: true`, and got a nullable column
plus a write gate, with nothing downstream to refuse it — the loud failure
resolved into a silent wrong end state.
`notNull: 'required'` leaves the alias table. All three flattened spellings —
`notNull`, `not_null`, `storageNotNull` — are answered by one
`guidanceSets` entry naming `storage: { notNull: true }`, and naming
`required` as the write contract too, since the defect is precisely that the
author cannot tell the two axes apart.
The entry moved to `guidanceSets` rather than exact `guidance` because the
two channels fold differently: `aliases` is indexed by `aliasProbe` (case and
separator folded, so one row also covered `not_null`) while exact `guidance`
is matched case-sensitively. A lone `guidance.notNull` row would have dropped
`not_null` onto the edit-distance fallback.
No accepted key moves: a guidance table decorates a rejection and never
admits a key, so both spellings are refused before and after. `isRequired`
and `mandatory` are genuine write-contract synonyms and still rename.
The stale comment claiming `notNull` "gets its own sentence rather than a
rename" is replaced — it described an arrangement that had not landed.
Fixes #16867
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 12 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 — 135 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 43bdc95448889361a56ce1f1e5e351111bd0bcd6 && git checkout 43bdc95448889361a56ce1f1e5e351111bd0bcd6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 65ad77d5aea371d67cee8cac2aa4634457fc409b d060473c4de61effec2835fae034ae35344d802e && git checkout -B drift-repro 65ad77d5aea371d67cee8cac2aa4634457fc409b && git merge --no-ff d060473c4de61effec2835fae034ae35344d802e
node scripts/docs-audit/affected-docs.mjs --json 65ad77d5aea371d67cee8cac2aa4634457fc409b
|
ACCEPT — the fence passed and CORRECTED triage.
|
Fixes #16867
Clause-②: no — no accepted key moves.
notNull,not_nullandstorageNotNullare refused before this change and refused after it; only the sentence attached to the refusal moves. This was measured, not assumed:strict-object.tsrecords that analiases/guidancetable "runs only from theunrecognized_keyspath", so it decorates a rejection and never admits a key, and the five-probe run below shows all three spellings still failing parse afterwards. A pin (clause ② — both spellings are REFUSED, before and after) holds that property.Authored by the
domain:specexecution seat in sessionsession_01MkQhmuuJAVDjmeWNixwDDH(https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH).The fence came first, and it is what determined the wording
The card declined to propose a target, calling it a contract question. Triage answered it from the card's characterisation of ADR-0113 and explicitly required this seat to verify against the ADR itself. Read on
origin/main(docs/adr/0113-required-write-contract-vs-column-constraint.md), it determines the target cleanly, and by its own adjudication rather than by inference:One correction to triage's ruling, in the direction of the ADR. Triage wrote that "the message should name
storageNotNull".storageNotNullis not a correct key — it is itself a rejected flat spelling, which the positive control below proves (it fails parse). The ADR's Q1 adjudicated the spelling as the nestedstorage: { notNull: true }, so that is what all three flat spellings now name. The reasoning triage gave is untouched and holds exactly: ADR-0113 excludesrequiredfrom column constraints, thereforerequiredcannot be the target.Reading 2 in the card —
notNullas a legacy synonym ofrequiredfrom before the split — does not survive contact with the record. The pre-split conflation lived inrequired(the knob ADR-0113's TL;DR describes as binding three meanings, with the driver'sif (field.required) col.notNullable()); there was and is no authorablenotNullfield key for it to be a legacy synonym of. ADR-0113 then assigned the wordnotNullto the column constraint deliberately, on the argument that it is "vocabulary a model already knows from the SQL corpus". The same reading was ruled on twice more in this tree:conversions/registry.ts(maintainer ruling 2026-09-08) — "A conversion cannot be what decides a column constraint — that is the author's explicit act" — andmigrations/registry.ts— "Post-17 a column is NOT NULL because its author wrotestorage: { notNull: true }, and for no other reason."So no fork, and no guessed wording: the prescription is the ADR's own sentence.
Premises re-measured on the current tree
The card measured against built
dist/atc5ea982d97plus an unmerged branch. Re-run throughObjectStackDefinitionSchema.safeParseonorigin/main3644fadc8e, keeping the card's control structure. Every premise reproduced; nothing was already fixed.notNull: trueDid you meannotNull→required?storage: { notNull: true }not_null: trueDid you meannot_null→required?storageNotNull: truerequired=false,storage=undefinedstorage: { notNull: true }storage={"notNull":true}The two negative controls are what prove the instrument can say "fine" — and they caught an error in the first run of the probe itself, where a stray
namekey in the envelope made every case fail for an unrelated reason.What changed
packages/spec/src/data/field.zod.tsonly, plus its test and a changeset.notNull: 'required'leaves the alias table.isRequiredandmandatorystay: both are genuine spellings of the write contract, and ADR-0113 moved neither.guidanceSetsentry,COLUMN_CONSTRAINT_FLAT_KEYS, whose anchored pattern/^(?:storage[_-]?)?not[_-]?null$/icannot reachrequired,requiredWhenorstorageitself.:911-913is gone. It claimednotNull"gets its own sentence rather than a rename", describing an arrangement that never landed on the spellings it was written for. It is replaced by a docblock on the new constant that records what the arrangement actually is, why the rename was wrong, and the mechanical trap below.The mechanical trap, for whoever repairs the next table like this
The entry moved from
aliasestoguidanceSets, not to exactguidance.aliasesis indexed byaliasProbe(case- and separator-folded, so the single row also coverednot_null), while exactguidanceis matched case-sensitively on the authored spelling. A loneguidance.notNullrow would have looked like a complete repair and quietly droppednot_nullonto the edit-distance fallback. The two spellings are pinned separately for exactly that reason, and a single-spelling pin would not have seen it.Evidence
field.test.ts. Each absence claim is a pair — the ADR-0113 sentence is present and the rename templateDid you meanis absent. Asserting only the first would pass on a message carrying both.field.zod.tsreverted toorigin/mainwith the new pins kept): 3 pins go red, restored bygit checkout HEAD -- ...and verified by an emptygit diff HEADplus a blob-hash match againstHEAD(977b998b…). On-disk mutation was proven by anchor count1 → 0and a changed hash, not by the editor's exit code. ThestorageNotNullpin stays green under ablation, honestly: it already carried the correct sentence and is a regression guard, not a defect detector.packages/specbuild,typecheck, and the full suite (470 files / 13259 tests) green;check:generatedreports all 15 generated artifacts up to date with zero working-tree drift;alias-integrity.test.ts(whose [finding] alias-integrity cannot see an alias row made unreachable by a guidanceSet — a vis-shaped alias on VISIBILITY_STRICT_OPTIONS surfaces would be dead code with no gate saying so #7889 check fails exactly the dead-alias shape this repair could have left behind) green; repo-widepnpm lintgreen.check:skill-examplesis NOT MEASURED — it refuses on an unbuiltpackages/client-react/distin a fresh worktree rather than return a false green; this diff adds zero prose example blocks, so it has no input to that gate.One observation, not a card
Triage flagged that a correct key nobody writes is the next card in this family. The measurement narrows it: the undiscoverable spelling is
storageNotNull, which is not a correct key at all but a flat catcher, while the genuinely correctstorage: { notNull: true }is declared, described, and parses — so the "correct key nobody can discover" premise is measurably false here, and the refusal now hands the author that exact nested spelling. Recorded for the next reader; ⛔ not fixed here and ⛔ not filed without a named carrier.⛔ Not audited and out of scope, per the card: whether other rename tables point at keys an ADR has since split off, and whether
isRequired/mandatoryare affected.Generated by Claude Code