Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/id-field-retirement-declared.md
Original file line number Diff line number Diff line change
@@ -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.
55 changes: 54 additions & 1 deletion packages/spec/src/data/authoring-key-lint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> }).shape);

Expand Down Expand Up @@ -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');
});
});
13 changes: 13 additions & 0 deletions packages/spec/src/data/authoring-key-lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.' },
});

/**
Expand Down
Loading