Skip to content

Commit 0ee32ed

Browse files
os-billclaude
andauthored
fix(spec): notNull / not_null prescribe storage.notNull, not required (#17477)
`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>
1 parent c7af6bd commit 0ee32ed

3 files changed

Lines changed: 178 additions & 8 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): `FieldSchema` no longer prescribes `required` for `notNull` / `not_null` — the flattened column-constraint spellings now name `storage: { notNull: true }` (#16867)
6+
7+
Writing `notNull: true` (or `not_null: true`) on a field was refused — correctly — and then told to write `required` instead, via a rename row in `FieldSchema`'s alias table. `required` is the one key ADR-0113 exists to say is **not** the column constraint. `required`'s own description in the same file states the opposite of what the rename prescribed: *"NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`)"*.
8+
9+
The failure mode was not the refusal — that fired, loudly, and did its job. It was the **remedy**: an author reaching for a NOT NULL column complied, wrote `required: true`, and received a nullable column plus a write-time gate, with nothing downstream to refuse it. The refusal read as though it had been satisfied.
10+
11+
All three flattened spellings — `notNull`, `not_null`, and `storageNotNull`, which already carried the correct sentence — now get one prescription naming the real key:
12+
13+
> physical column constraints live under `storage` — write `storage: { notNull: true }` (ADR-0113). There is no flat spelling of it: post-17 a column is NOT NULL because its author wrote that nested key, and for no other reason. It is NOT `required`, which is the WRITE contract (an insert must provide a value; an update may not null it out) and deliberately does NOT imply the column constraint — `required: true` alone leaves the column nullable. Write whichever of the two you meant, or both.
14+
15+
Both halves are named on purpose: the defect being repaired is that the author cannot tell which of the two axes they are getting, so a prescription naming only the column half would have fixed the measured direction and opened the mirror-image one.
16+
17+
**No accepted key moves.** `notNull` and `not_null` were refused before this change and are refused after it — a `guidance` / `guidanceSets` table decorates a rejection and never admits a key. Only the sentence attached to the refusal changed. `storage: { notNull: true }` parsed before and parses now; `isRequired` and `mandatory` are genuine spellings of the write contract, ADR-0113 moved neither, and both still rename onto `required`.
18+
19+
One mechanical note for anyone repairing a table like this: the entry moved from `aliases` to `guidanceSets`, not to exact `guidance`. `aliases` is indexed by `aliasProbe` (case- and separator-folded, so one row covered `not_null` too) while exact `guidance` is matched case-sensitively on the authored spelling — a lone `guidance.notNull` row would have quietly dropped `not_null` onto the edit-distance fallback. The two spellings are pinned separately for exactly that reason.

packages/spec/src/data/field.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1905,6 +1905,91 @@ describe('ADR-0113 — required is a write contract; storage.notNull is the colu
19051905
});
19061906
});
19071907

1908+
/**
1909+
* #16867 — the flattened column-constraint spellings must not be renamed onto
1910+
* `required`.
1911+
*
1912+
* The defect these pin against was not a silent one: the refusal fired, loudly,
1913+
* and then prescribed `required` — the one key ADR-0113 exists to say is NOT the
1914+
* column constraint. The author complied and got a nullable column plus a write
1915+
* gate, with nothing downstream to refuse it. So the assertions come in pairs:
1916+
* the ADR-0113 sentence is PRESENT, and the rename to `required` is ABSENT.
1917+
* Asserting only the first would pass on a message that carried both.
1918+
*
1919+
* `not_null` is pinned separately from `notNull` on purpose. The two channels
1920+
* fold differently — `aliases` is indexed by `aliasProbe` (case and `_`/`-`
1921+
* folded), exact `guidance` is not — so a repair that moved the entry between
1922+
* them without a pattern would keep `notNull` green while `not_null` fell
1923+
* through to the edit-distance fallback, and no single-spelling pin would see it.
1924+
*/
1925+
describe('ADR-0113 / #16867 — flat `notNull` spellings prescribe `storage.notNull`, never `required`', () => {
1926+
const refusalMessage = (field: Record<string, unknown>): string => {
1927+
const r = FieldSchema.safeParse({ type: 'text', label: 'F', ...field });
1928+
expect(r.success).toBe(false);
1929+
if (r.success) throw new Error('unreachable — the key was ADMITTED, not refused');
1930+
const issue = r.error.issues.find((i) => i.code === 'unrecognized_keys');
1931+
expect(issue, 'the key must be refused as unrecognized, not accepted').toBeDefined();
1932+
return String(issue!.message);
1933+
};
1934+
1935+
// The three flattened spellings an author actually reaches for. `notNull` and
1936+
// `not_null` are what the card measured being renamed onto `required`;
1937+
// `storageNotNull` already carried the sentence and must keep it.
1938+
for (const spelling of ['notNull', 'not_null', 'storageNotNull']) {
1939+
it(`\`${spelling}\` is refused WITH the ADR-0113 sentence and WITHOUT a rename to \`required\``, () => {
1940+
const message = refusalMessage({ [spelling]: true });
1941+
1942+
// Lit: the correct target is named, nested spelling and all.
1943+
expect(message).toContain('storage: { notNull: true }');
1944+
expect(message).toContain('ADR-0113');
1945+
1946+
// Dark: the rename channel did not answer. `Did you mean` is the rename
1947+
// channel's own template (`suggestions.zod.ts`), so its absence is what
1948+
// proves the alias row is gone rather than merely outvoted.
1949+
expect(message).not.toMatch(/Did you mean/);
1950+
expect(message).not.toMatch(/ `required`/);
1951+
});
1952+
}
1953+
1954+
it('names the write contract too, so an author who meant `required` is not sent the other way', () => {
1955+
// The defect is that the author cannot tell which of the two axes they are
1956+
// getting. A prescription naming only the column half would fix the
1957+
// measured direction and open the mirror-image one.
1958+
const message = refusalMessage({ notNull: true });
1959+
expect(message).toContain('`required`');
1960+
expect(message).toMatch(/WRITE contract/);
1961+
});
1962+
1963+
it('the genuine write-contract synonyms still RENAME onto `required` (the set is anchored)', () => {
1964+
// Guards the blast radius of the pattern: ADR-0113 moved neither of these,
1965+
// and `/^(?:storage[_-]?)?not[_-]?null$/i` must not reach them.
1966+
for (const spelling of ['isRequired', 'mandatory']) {
1967+
const message = refusalMessage({ [spelling]: true });
1968+
expect(message).toMatch(/Did you mean/);
1969+
expect(message).toContain('`required`');
1970+
expect(message).not.toContain('ADR-0113');
1971+
}
1972+
});
1973+
1974+
it('clause \u2461 — both spellings are REFUSED, before and after; only the sentence moved', () => {
1975+
// A `guidance` / `guidanceSets` table decorates a rejection and never
1976+
// admits a key (`strict-object.ts`: it "runs only from the
1977+
// `unrecognized_keys` path"). This pin is what would go red if a future
1978+
// edit turned either spelling into an accepted key.
1979+
for (const spelling of ['notNull', 'not_null', 'storageNotNull']) {
1980+
const r = FieldSchema.safeParse({ type: 'text', label: 'F', [spelling]: true });
1981+
expect(r.success, `\`${spelling}\` must stay REFUSED — it is not an authorable key`).toBe(false);
1982+
}
1983+
});
1984+
1985+
it('the instrument can still say "fine" — the real keys parse', () => {
1986+
// Negative controls, so a pin above cannot pass by refusing everything.
1987+
expect(FieldSchema.parse({ type: 'text', label: 'F' }).storage).toBeUndefined();
1988+
expect(FieldSchema.parse({ type: 'text', label: 'F', storage: { notNull: true } }).storage?.notNull)
1989+
.toBe(true);
1990+
});
1991+
});
1992+
19081993
describe('FieldSchema — authored `radio` + `multiple: true` is REFUSED (#11437, maintainer ruling 2026-08-22 on objectui#4015, Option C)', () => {
19091994
// Option C rejects the contradiction at the entrance: the data layer
19101995
// honoured the flag while the widget rendered a single-value radio group —

packages/spec/src/data/field.zod.ts

Lines changed: 74 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { z } from 'zod';
44
import { retiredKey } from '../shared/retired-key';
55
import { strictObject } from '../shared/strict-object';
6+
import type { KeySetGuidance } from '../shared/suggestions.zod';
67
// Package-internal, like `strict-object` itself — the `shared/index.ts` barrel
78
// deliberately does not re-export it, so nothing about the public API surface
89
// moves. No cycle back into this file: that module's only runtime import is
@@ -846,6 +847,68 @@ export const InlineGridColumnSchema = lazySchema(() => strictObject({
846847
requiredWhen: ExpressionInputSchema.optional().describe('Predicate (CEL) — the cell is required when TRUE. Same `record` + `parent` scope as `readonlyWhen`. PRESENTATION ONLY: this flags the cell inline-invalid in the grid; nothing on the write path reads it. The server-enforced contract is the child FIELD\'s own `requiredWhen` — a transition gate, see `Field.requiredWhen` — which hydration copies onto an identity-only column, so declaring the requirement here alone enforces nothing.'),
847848
}));
848849

850+
/**
851+
* The FLATTENED spellings of the column constraint — `notNull`, `not_null`,
852+
* `storageNotNull` — answered with one prescription that names the real key.
853+
*
854+
* ## Why this is prose and not a rename
855+
*
856+
* ADR-0113 split one knob into two axes, and adjudicated the spelling of each:
857+
* `required` is the write-time contract, and the physical constraint is
858+
* `storage: { notNull: true }` (Q1, decided 2026-07-30). The target is
859+
* therefore a NESTED key, and `aliases` renames onto a flat one — the same
860+
* reason `currency` is answered in prose a few lines below.
861+
*
862+
* ## Why it may not rename onto `required` (#16867)
863+
*
864+
* It used to: `notNull: 'required'` sat in the alias table beside `isRequired`
865+
* and `mandatory`, and because `aliases` is consulted only AFTER this channel
866+
* declines, an author who wrote `notNull` was told to write the one key ADR-0113
867+
* exists to say is not the column constraint. `required`'s own `.describe()`
868+
* below states the opposite in the same file: *"NOT a column constraint — the
869+
* physical NOT NULL is a separate explicit opt-in (`storage.notNull`)"*.
870+
*
871+
* The cost of that rename was not a wording nit. The refusal was loud and did
872+
* its job; its REMEDY produced the wrong end state, and that end state was
873+
* SILENT — the author complied, got `required: true`, and received a nullable
874+
* column plus a write gate with nothing downstream to refuse it. The same
875+
* conflation was withdrawn from the conversion registry on the same reading
876+
* (`conversions/registry.ts`, maintainer ruling 2026-09-08): *"A conversion
877+
* cannot be what decides a column constraint — that is the author's explicit
878+
* act."*
879+
*
880+
* ## Why a SET and not three `guidance` rows
881+
*
882+
* `guidance` is matched case-sensitively on the exact authored spelling, while
883+
* `aliases` is indexed by `aliasProbe` (case- and separator-folded). Moving
884+
* `notNull` from one channel to the other therefore silently narrows what it
885+
* covers: the single alias row answered `not_null` too, and a lone
886+
* `guidance.notNull` row would not have. The pattern restores the fold, and
887+
* fires once per message however many members were written.
888+
*
889+
* ## Both halves are named on purpose
890+
*
891+
* The prescription states what `required` is as well as what `storage.notNull`
892+
* is, because the defect being repaired is precisely that the author cannot
893+
* tell which of the two they are getting — answering only one half would leave
894+
* the author who meant the write contract to guess in the other direction.
895+
*/
896+
const COLUMN_CONSTRAINT_FLAT_KEYS: KeySetGuidance = {
897+
name: 'COLUMN_CONSTRAINT_FLAT_KEYS',
898+
// Anchored, so it claims only the flattened column-constraint spellings and
899+
// cannot reach a declared key: `required`, `requiredWhen` and `storage`
900+
// itself are all outside it.
901+
keys: /^(?:storage[_-]?)?not[_-]?null$/i,
902+
examples: ['notNull', 'not_null', 'storageNotNull'],
903+
prescription:
904+
'physical column constraints live under `storage` — write `storage: { notNull: true }` '
905+
+ '(ADR-0113). There is no flat spelling of it: post-17 a column is NOT NULL because its '
906+
+ 'author wrote that nested key, and for no other reason. It is NOT `required`, which is '
907+
+ 'the WRITE contract (an insert must provide a value; an update may not null it out) and '
908+
+ 'deliberately does NOT imply the column constraint — `required: true` alone leaves the '
909+
+ 'column nullable. Write whichever of the two you meant, or both.',
910+
};
911+
849912
export const FieldSchema = lazySchema(() => {
850913
const base = strictObject({
851914
surface: 'this field',
@@ -857,7 +920,10 @@ export const FieldSchema = lazySchema(() => {
857920
title: 'label', displayName: 'label',
858921
help: 'inlineHelpText', helpText: 'inlineHelpText', hint: 'inlineHelpText', tooltip: 'inlineHelpText',
859922
default: 'defaultValue', initialValue: 'defaultValue',
860-
isRequired: 'required', mandatory: 'required', notNull: 'required',
923+
// `notNull` is NOT here, deliberately — see COLUMN_CONSTRAINT_FLAT_KEYS
924+
// below. `isRequired` / `mandatory` stay: both are genuine spellings of the
925+
// WRITE contract, and ADR-0113 moved neither.
926+
isRequired: 'required', mandatory: 'required',
861927
isUnique: 'unique',
862928
values: 'options', choices: 'options', picklist: 'options', selectOptions: 'options',
863929
relatedTo: 'reference', referenceTo: 'reference', target: 'reference', targetObject: 'reference', lookupObject: 'reference',
@@ -908,13 +974,12 @@ export const FieldSchema = lazySchema(() => {
908974
referenceFilters:
909975
'`referenceFilters` (string[]) was removed in the 16.x line — the lookup picker only '
910976
+ 'ever read the structured form. Use `lookupFilters: [{ field, operator, value }]`.',
911-
// `notNull` is aliased to `required` above for the common case, but ADR-0113
912-
// makes the two deliberately distinct and the distinction IS the point, so
913-
// the flattened spelling gets its own sentence rather than a rename.
914-
storageNotNull:
915-
'physical column constraints live under `storage` — write `storage: { notNull: true }` '
916-
+ '(ADR-0113). `required` is the WRITE contract and deliberately does not imply the column '
917-
+ 'constraint.',
977+
// `notNull` / `not_null` / `storageNotNull` are answered by
978+
// COLUMN_CONSTRAINT_FLAT_KEYS (a `guidanceSets` entry, declared above this
979+
// schema) rather than by a row here: exact `guidance` is matched
980+
// CASE-SENSITIVELY on the authored spelling, so a row per spelling is the
981+
// only way this channel can cover a family, and `not_null` was the spelling
982+
// it would have missed.
918983
tracked: '`tracked` is not a field key — per-field timeline tracking is `trackHistory: true` (ADR-0052 §5b).',
919984
// Prose rather than a rename, because this surface declares BOTH forms and
920985
// the two answers have opposite polarity: renaming onto `visibleWhen` sends
@@ -927,6 +992,7 @@ export const FieldSchema = lazySchema(() => {
927992
+ 'per-record CEL predicate is `visibleWhen` (shown only when TRUE). Its siblings are '
928993
+ '`readonlyWhen` and `requiredWhen`.',
929994
},
995+
guidanceSets: [COLUMN_CONSTRAINT_FLAT_KEYS],
930996
}, {
931997
/** Identity */
932998
name: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe('Machine name (snake_case)').optional(),

0 commit comments

Comments
 (0)