Skip to content

Commit c17b494

Browse files
os-billclaude
andauthored
feat(spec): declare id_field a retirement with no successor on FieldSchema (#17060)
`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. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent a016f08 commit c17b494

3 files changed

Lines changed: 78 additions & 1 deletion

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
`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).
6+
7+
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.
8+
9+
**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.
10+
11+
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.

packages/spec/src/data/authoring-key-lint.test.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
type UnknownAuthoringKeyFinding,
2121
} from './authoring-key-lint';
2222
import { ObjectSchema } from './object.zod';
23-
import { FieldSchema } from './field.zod';
23+
import { FieldSchema, InlineGridColumnSchema } from './field.zod';
2424

2525
const shapeKeys = (s: unknown) => Object.keys((s as { shape: Record<string, unknown> }).shape);
2626

@@ -111,3 +111,56 @@ describe('the guidance tables do not rot', () => {
111111
}
112112
});
113113
});
114+
115+
/**
116+
* The gap every other test in this file leaves open (#16632).
117+
*
118+
* The tests above prove the table is CONSISTENT — every `to` names a live key,
119+
* no entry names a key the schema declares. None of them proves an entry is
120+
* ever CONSULTED, so a row filed under a spelling nobody writes passes all of
121+
* them: an assertion that cannot fail and an assertion that passed look
122+
* identical from the outside.
123+
*
124+
* These read the channel that actually answers an authored field key today.
125+
* `FieldSchema` is a `strictObject` and pulls this table in through
126+
* `fieldKeyGuidanceAsStrictOptions()`, so the PARSE is loud first and the
127+
* walker stays silent on the field surface by its own posture rule
128+
* (`kernel/metadata-authoring-lint.ts`: `strict` → silent). Reaching for the
129+
* lint to prove reachability here would prove nothing — it never fires.
130+
*/
131+
describe('the `id_field` retirement is reached, not merely declared (#16632)', () => {
132+
const authored = { name: 'account_id', type: 'lookup', reference: 'crm_account' } as const;
133+
const unrecognizedKeyMessage = (value: unknown): string => {
134+
const r = FieldSchema.safeParse(value);
135+
expect(r.success).toBe(false);
136+
if (r.success) throw new Error('unreachable');
137+
const issue = r.error.issues.find((i) => i.code === 'unrecognized_keys');
138+
expect(issue, 'the field parse did not refuse the unknown key at all').toBeTruthy();
139+
return issue!.message;
140+
};
141+
142+
it('an authored `id_field` is answered with THIS table\'s sentence, verbatim', () => {
143+
// The load-bearing assertion: the prescription reaches the author. Change
144+
// the entry's key face to `idField` and this goes red, which is the whole
145+
// point — the guidance channel is an exact, case-sensitive match.
146+
const why = FIELD_KEY_GUIDANCE.id_field?.why;
147+
expect(why, 'FIELD_KEY_GUIDANCE has no `id_field` entry').toBeTruthy();
148+
expect(unrecognizedKeyMessage({ ...authored, id_field: 'name' })).toContain(why!);
149+
});
150+
151+
it('the retirement suppresses the rename channel — no "did you mean"', () => {
152+
// A `why` row with no `to` must not also offer an edit-distance guess: the
153+
// `pii` → `min` failure class this table exists to suppress.
154+
expect(unrecognizedKeyMessage({ ...authored, id_field: 'name' })).not.toContain('Did you mean');
155+
});
156+
157+
it('the same-named GridColumn key is a different schema and stays live', () => {
158+
// `git grep idField packages/spec/src/data/field.zod.ts` reads as "the
159+
// target already exists" — but the hit belongs to the `inlineColumns`
160+
// mirror, and `GridField` (a path in a comment) contains the substring
161+
// `idField` besides. Both facts are pinned so nobody "unifies" the two.
162+
expect(InlineGridColumnSchema.safeParse({ name: 'account_id', idField: 'id' }).success).toBe(true);
163+
expect(shapeKeys(FieldSchema)).not.toContain('idField');
164+
expect(shapeKeys(InlineGridColumnSchema)).toContain('idField');
165+
});
166+
});

packages/spec/src/data/authoring-key-lint.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,19 @@ export const FIELD_KEY_GUIDANCE: Readonly<
131131
// would be advice to delete a live key — the "no guidance entry names a key
132132
// the schema now declares" test enforces the absence.
133133
cached: { why: 'computed-field caching was pruned in 2026-06 (#3733); nothing read it.' },
134+
// The one snake_case key in this table, and the spelling is LOAD-BEARING.
135+
// The two channels this table feeds do not agree on the key face: a `to`
136+
// becomes a `strictObject` ALIAS, matched through `aliasProbe` (case folded,
137+
// `_` / `-` / space stripped), so one camelCase row covers every separator
138+
// spelling. A `why` becomes strict GUIDANCE, and that channel is documented
139+
// and implemented as an exact, case-SENSITIVE match on the authored spelling
140+
// (`shared/suggestions.zod.ts`), as is this file's own comparator
141+
// (`guidance[key]`). So a camelCase row here would never be reached by the
142+
// key authors actually write, and every test in this file would still pass —
143+
// none of them asks whether an entry is ever consulted.
144+
// ⛔ Do not "normalise" this to `idField` to match its neighbours; the
145+
// reachability test in `authoring-key-lint.test.ts` fails if you do.
146+
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.' },
134147
});
135148

136149
/**

0 commit comments

Comments
 (0)