From c001ce7f3da5fc2db25d373b8c3eda3f0d5ba992 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 03:54:12 +0000 Subject: [PATCH] feat(spec): declare `id_field` a retirement with no successor on FieldSchema `FIELD_KEY_GUIDANCE` gains an `id_field` entry so objectui's ingestion choke point has the declared spec-side fact it needs to canonicalise the retired spelling (objectui#7650 ruling A). A lookup stores the referenced record's id and the picker resolves record identity itself, so there is no `FieldSchema` member to fold onto; the prescription names `displayField` and a seed dataset's `externalId` instead. The entry is keyed in snake_case on purpose: `to` rows become `strictObject` aliases matched through `aliasProbe` (case and separators folded), while `why` rows become strict guidance matched exactly and case-sensitively, so a camelCase row would never be reached. Three new assertions read the parse channel that actually answers an authored field key, which is the reachability the existing table tests never asserted. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH --- .changeset/id-field-retirement-declared.md | 11 ++++ .../spec/src/data/authoring-key-lint.test.ts | 55 ++++++++++++++++++- packages/spec/src/data/authoring-key-lint.ts | 13 +++++ 3 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 .changeset/id-field-retirement-declared.md diff --git a/.changeset/id-field-retirement-declared.md b/.changeset/id-field-retirement-declared.md new file mode 100644 index 0000000000..503f9c965e --- /dev/null +++ b/.changeset/id-field-retirement-declared.md @@ -0,0 +1,11 @@ +--- +"@objectstack/spec": patch +--- + +`id_field` now gets a named answer instead of a bare refusal: `FIELD_KEY_GUIDANCE` declares it a retirement with **no successor**, which is the spec-side fact objectui's ingestion choke point needs before it can canonicalise the key (objectui#7650 ruling A — retired spellings are folded once, at ingestion, never at the consumer). + +The direction was a factual finding, not a preference, and it went the way the cheaper branch happens to point — so here is the evidence rather than the verdict alone. A lookup stores the referenced record's id, and which field holds that value is not an authored per-field choice: the picker resolves record identity itself. Nothing on `FieldSchema` names it, nothing in `objectql` / `runtime` / `metadata-protocol` reads a per-field id key, and the two places the platform does let a reference be stored by something other than an id are declared elsewhere — `APPROVER_VALUE_BINDINGS.valueField` (per approver type, e.g. `position` routing by `sys_position.name`) and a seed dataset's `externalId`, the channel lookup references already resolve through. So there is no member to fold onto, and the prescription says what to reach for instead: `displayField` for the candidate's label, a dataset `externalId` for a portable natural key. + +**The entry is keyed `id_field`, in snake_case, and that is deliberate.** The two channels this table feeds disagree about the key face. A `to` becomes a `strictObject` alias, matched through `aliasProbe` — case folded, separators stripped — so one camelCase row covers every spelling. A `why` becomes strict guidance, matched exactly and case-sensitively on the authored spelling. A camelCase row would therefore never be reached by the key authors write, and every existing test in the file would still pass, because none of them asks whether an entry is ever consulted. + +That gap is closed too. Three assertions read the channel that actually answers an authored field key — `FieldSchema.safeParse`, since the schema is strict and the authoring-key walker stays silent on a strict surface by its own posture rule — and pin that the refusal carries this table's sentence verbatim, that a retirement suppresses the rename channel, and that the same-named `idField` on the `inlineColumns` GridColumn mirror is a different schema that stays live. diff --git a/packages/spec/src/data/authoring-key-lint.test.ts b/packages/spec/src/data/authoring-key-lint.test.ts index 159ce6edfa..c6c1fc0f10 100644 --- a/packages/spec/src/data/authoring-key-lint.test.ts +++ b/packages/spec/src/data/authoring-key-lint.test.ts @@ -20,7 +20,7 @@ import { type UnknownAuthoringKeyFinding, } from './authoring-key-lint'; import { ObjectSchema } from './object.zod'; -import { FieldSchema } from './field.zod'; +import { FieldSchema, InlineGridColumnSchema } from './field.zod'; const shapeKeys = (s: unknown) => Object.keys((s as { shape: Record }).shape); @@ -111,3 +111,56 @@ describe('the guidance tables do not rot', () => { } }); }); + +/** + * The gap every other test in this file leaves open (#16632). + * + * The tests above prove the table is CONSISTENT — every `to` names a live key, + * no entry names a key the schema declares. None of them proves an entry is + * ever CONSULTED, so a row filed under a spelling nobody writes passes all of + * them: an assertion that cannot fail and an assertion that passed look + * identical from the outside. + * + * These read the channel that actually answers an authored field key today. + * `FieldSchema` is a `strictObject` and pulls this table in through + * `fieldKeyGuidanceAsStrictOptions()`, so the PARSE is loud first and the + * walker stays silent on the field surface by its own posture rule + * (`kernel/metadata-authoring-lint.ts`: `strict` → silent). Reaching for the + * lint to prove reachability here would prove nothing — it never fires. + */ +describe('the `id_field` retirement is reached, not merely declared (#16632)', () => { + const authored = { name: 'account_id', type: 'lookup', reference: 'crm_account' } as const; + const unrecognizedKeyMessage = (value: unknown): string => { + const r = FieldSchema.safeParse(value); + expect(r.success).toBe(false); + if (r.success) throw new Error('unreachable'); + const issue = r.error.issues.find((i) => i.code === 'unrecognized_keys'); + expect(issue, 'the field parse did not refuse the unknown key at all').toBeTruthy(); + return issue!.message; + }; + + it('an authored `id_field` is answered with THIS table\'s sentence, verbatim', () => { + // The load-bearing assertion: the prescription reaches the author. Change + // the entry's key face to `idField` and this goes red, which is the whole + // point — the guidance channel is an exact, case-sensitive match. + const why = FIELD_KEY_GUIDANCE.id_field?.why; + expect(why, 'FIELD_KEY_GUIDANCE has no `id_field` entry').toBeTruthy(); + expect(unrecognizedKeyMessage({ ...authored, id_field: 'name' })).toContain(why!); + }); + + it('the retirement suppresses the rename channel — no "did you mean"', () => { + // A `why` row with no `to` must not also offer an edit-distance guess: the + // `pii` → `min` failure class this table exists to suppress. + expect(unrecognizedKeyMessage({ ...authored, id_field: 'name' })).not.toContain('Did you mean'); + }); + + it('the same-named GridColumn key is a different schema and stays live', () => { + // `git grep idField packages/spec/src/data/field.zod.ts` reads as "the + // target already exists" — but the hit belongs to the `inlineColumns` + // mirror, and `GridField` (a path in a comment) contains the substring + // `idField` besides. Both facts are pinned so nobody "unifies" the two. + expect(InlineGridColumnSchema.safeParse({ name: 'account_id', idField: 'id' }).success).toBe(true); + expect(shapeKeys(FieldSchema)).not.toContain('idField'); + expect(shapeKeys(InlineGridColumnSchema)).toContain('idField'); + }); +}); diff --git a/packages/spec/src/data/authoring-key-lint.ts b/packages/spec/src/data/authoring-key-lint.ts index 77ae295052..d9d2b4daed 100644 --- a/packages/spec/src/data/authoring-key-lint.ts +++ b/packages/spec/src/data/authoring-key-lint.ts @@ -131,6 +131,19 @@ export const FIELD_KEY_GUIDANCE: Readonly< // would be advice to delete a live key — the "no guidance entry names a key // the schema now declares" test enforces the absence. cached: { why: 'computed-field caching was pruned in 2026-06 (#3733); nothing read it.' }, + // The one snake_case key in this table, and the spelling is LOAD-BEARING. + // The two channels this table feeds do not agree on the key face: a `to` + // becomes a `strictObject` ALIAS, matched through `aliasProbe` (case folded, + // `_` / `-` / space stripped), so one camelCase row covers every separator + // spelling. A `why` becomes strict GUIDANCE, and that channel is documented + // and implemented as an exact, case-SENSITIVE match on the authored spelling + // (`shared/suggestions.zod.ts`), as is this file's own comparator + // (`guidance[key]`). So a camelCase row here would never be reached by the + // key authors actually write, and every test in this file would still pass — + // none of them asks whether an entry is ever consulted. + // ⛔ Do not "normalise" this to `idField` to match its neighbours; the + // reachability test in `authoring-key-lint.test.ts` fails if you do. + id_field: { why: '`id_field` was never a FieldSchema key: a lookup stores the referenced record\'s id, and which field holds that value is not an authored per-field choice — the picker resolves record identity itself. Use `displayField` to change which field is SHOWN as a candidate\'s label, and a seed dataset\'s `externalId` to author references by a portable natural key. The live `idField` is the one on an `inlineColumns` entry — the GridColumn mirror in `field.zod.ts`, which is a DIFFERENT schema.' }, }); /**