diff --git a/.changeset/default-value-literal-gate-prefers-rename.md b/.changeset/default-value-literal-gate-prefers-rename.md new file mode 100644 index 0000000000..563e0c9a6c --- /dev/null +++ b/.changeset/default-value-literal-gate-prefers-rename.md @@ -0,0 +1,13 @@ +--- +"@objectstack/spec": patch +--- + +The authoring-time `defaultValue` gate now prescribes the key rename an author actually made, instead of a type error about a member they never wrote. + +`checkLiteralDefaultValue` — the shared core of the field gate (`FieldSchema.defaultValue`) and the action-param gate (`ActionParamSchema.defaultValue`) — read a value-contract rejection positionally, `result.error.issues[0]`. zod reports per-member issues before the object-level `unrecognized_keys` one, so on a default whose keys were **renamed** the actionable message sorted last and was discarded. An `address` default authored as `{ street: 5, postal_code: '98101' }` answered `Invalid input: expected string, received number`, and a `location` default authored as the legacy `{ latitude, longitude }` pair answered `Invalid input: expected number, received undefined` — while `AddressValueSchema` and `LocationValueSchema` had each built the rename prescription and thrown it away. Which of the two the author got depended on whether some unrelated member happened to also be wrong: nobody chose that, and nobody could see it. + +The gate now prefers the undeclared-key issue when the rejection carries one. `LiteralDefaultValueVerdict.detail` keeps its name, its type and its documented meaning — "the 'why' a refusal carries verbatim"; what changes is which of several already-reachable messages it carries. + +⛔ No verdict moves. Exactly the same defaults are accepted and refused, on the same evidence — only the refusal text changes. + +Scoped by measurement rather than inherited: the sixteen classes `valueSchemaFor(def, 'stored')` covers were swept again on this function, at both arities. Only `location` and `address` can emit `unrecognized_keys` at all, because only they are backed by a key-closed object schema — for the other fourteen the preference cannot change a single character. Both classes it does reach curate the alias map that makes the undeclared key the more actionable half of the rejection. diff --git a/packages/spec/src/data/default-value-shape.test.ts b/packages/spec/src/data/default-value-shape.test.ts index 140ddfcea9..2672ae31a4 100644 --- a/packages/spec/src/data/default-value-shape.test.ts +++ b/packages/spec/src/data/default-value-shape.test.ts @@ -118,4 +118,60 @@ describe('#7127 checkLiteralDefaultValue — the shared stored-form literal chec it('stays open where the contract is deliberately open (json)', () => { expect(checkLiteralDefaultValue({ type: 'json' }, { anything: ['at', 'all'] }).ok).toBe(true); }); + + // ── #16077: `detail` is the ACTIONABLE issue, not `issues[0]` ───────────── + // + // zod sorts per-member issues ahead of the object-level `unrecognized_keys` + // one, so a positional read handed an author who RENAMED a key a + // missing-member type error about a member they never wrote — and which of + // the two they got depended on whether some unrelated member happened to + // also be wrong. Each case below asserts BOTH halves: the prescription is + // present, AND the half that was being shown instead is gone. Without the + // second the pin cannot see a regression back to the positional read. + it('#16077 prefers the rename over a MISSING-member type error (location)', () => { + const v = checkLiteralDefaultValue({ type: 'location' }, { latitude: 1, longitude: 2 }); + expect(v.ok).toBe(false); + // Positionally this rejection reads + // `[invalid_type(lat), invalid_type(lng), unrecognized_keys]`. + expect(v.detail).toContain('`latitude` \u2192 `lat`'); + expect(v.detail).toContain('`longitude` \u2192 `lng`'); + expect(v.detail).not.toContain('expected number, received undefined'); + // Edit distance cannot reach `latitude` -> `lat`; the curated `aliases` + // map is the only thing that can, which is why discarding it cost the + // author the whole prescription. + }); + + it('#16077 prefers the rename over a WRONG-TYPED-member error (address)', () => { + const v = checkLiteralDefaultValue({ type: 'address' }, { street: 5, postal_code: '98101' }); + expect(v.ok).toBe(false); + // Every member of `address` is optional, which rules out a MISSING-member + // error but says nothing about a wrong-typed declared one — it sorts ahead + // just the same. This is the case that made the defect look location-only. + expect(v.detail).toContain('`postal_code` \u2192 `postalCode`'); + expect(v.detail).not.toContain('expected string, received number'); + }); + + it('#16077 leaves the already-correct case exactly as it was (the asymmetry is gone)', () => { + // No member error to sort ahead, so this one was always right. Pinning it + // beside the two above is what states the property: the diagnosis no + // longer depends on whether an unrelated member happened to also be wrong. + const lucky = checkLiteralDefaultValue({ type: 'address' }, { postal_code: '98101' }); + const unlucky = checkLiteralDefaultValue({ type: 'address' }, { street: 5, postal_code: '98101' }); + expect(lucky.ok).toBe(false); + expect(lucky.detail).toContain('`postal_code` \u2192 `postalCode`'); + expect(unlucky.detail).toContain('`postal_code` \u2192 `postalCode`'); + }); + + it('#16077 is a NO-OP for a class that cannot emit `unrecognized_keys`', () => { + // The sweep over all sixteen classes `valueSchemaFor(def, 'stored')` + // covers found only `location` and `address` backed by a `strictObject`, + // so only they can emit the issue the preference looks for. For the other + // fourteen the selected message is `issues[0]` exactly as before — this + // pin is the one that goes red if the preference ever starts reordering + // a class it has no business reordering. + const v = checkLiteralDefaultValue({ type: 'datetime' }, '2026-08-10T15:00'); + expect(v.ok).toBe(false); + expect(v.detail).toContain('ISO-8601 instant'); + expect(v.detail).not.toContain('Unrecognized key'); + }); }); diff --git a/packages/spec/src/data/default-value-shape.ts b/packages/spec/src/data/default-value-shape.ts index b73b487f17..71867fd33f 100644 --- a/packages/spec/src/data/default-value-shape.ts +++ b/packages/spec/src/data/default-value-shape.ts @@ -96,13 +96,59 @@ export function discriminateDefaultValueShape(dv: unknown): DefaultValueShape { return 'literal'; } -/** Verdict of {@link checkLiteralDefaultValue}: `ok`, or the first contract violation. */ +/** Verdict of {@link checkLiteralDefaultValue}: `ok`, or the contract violation to act on. */ export interface LiteralDefaultValueVerdict { ok: boolean; - /** First issue message from the value contract — the "why" a refusal carries verbatim. */ + /** + * The ACTIONABLE issue message from the value contract — the "why" a refusal + * carries verbatim. Selected by {@link actionableValueIssueMessage}, not read + * positionally; the field's name, type and meaning are what they always were. + */ detail?: string; } +/** + * The one parse issue an author can act on, out of everything zod reported for + * this literal's value-shape rejection. + * + * NOT `issues[0]`. zod reports per-member issues before the object-level + * `unrecognized_keys` one, so on a default whose keys were RENAMED the + * actionable message sorts LAST and a positional read discards it. An + * `address` default authored as `{ street: 5, postal_code: '98101' }` reports: + * + * [0] invalid_type street Invalid input: expected string, received number + * [1] unrecognized_keys ... Did you mean `postal_code` -> `postalCode`? ... + * + * The rename IS the prescription, and edit distance cannot reach it + * (`latitude` -> `lat`), which is exactly why `LocationValueSchema` and + * `AddressValueSchema` curate an `aliases` map. Reading positionally built that + * hint and threw it away, handing the author a missing-member type error about + * a member they never wrote — and which of the two they got depended on whether + * some unrelated member happened to also be wrong, which nobody chose and + * nobody can see. + * + * The preference is a NO-OP for every other class rather than merely harmless + * to it, which is what makes it safe as a blanket rule. Swept on THIS function + * over all sixteen classes `valueSchemaFor(def, 'stored')` covers, at both + * arities: only `location` and `address` can emit `unrecognized_keys` at all, + * because only they are backed by a `strictObject`. The string, numeric, + * boolean, calendar-date, instant, clock-time, option, reference and + * file-reference classes are scalars; `composite` / `record` / `repeater` / + * `vector` are open records and arrays; the open fallback is `z.unknown()`. For + * the other fourteen this cannot change a single character. + * + * The stored-value scan reached the same reading from the other side + * (objectql `record-validator.ts`'s `valueShapeDetail`). Two readings of one + * rejection, one per surface — deliberately not shared, because `packages/spec` + * is upstream of `objectql` and this gate answers a metadata AUTHOR while that + * one answers an operator running a migration. + */ +function actionableValueIssueMessage( + issues: ReadonlyArray<{ code: string; message: string }>, +): string { + return (issues.find((i) => i.code === 'unrecognized_keys') ?? issues[0])?.message ?? 'invalid value'; +} + /** * Check a LITERAL default against its owner's own stored-form value contract * (`valueSchemaFor(def, 'stored')` — ADR-0104 D1). The shared core of the @@ -117,7 +163,7 @@ export interface LiteralDefaultValueVerdict { export function checkLiteralDefaultValue(def: ValueShapeFieldDef, dv: unknown): LiteralDefaultValueVerdict { const result = valueSchemaFor(def, 'stored').safeParse(dv); if (result.success) return { ok: true }; - return { ok: false, detail: result.error.issues[0]?.message ?? 'invalid value' }; + return { ok: false, detail: actionableValueIssueMessage(result.error.issues) }; } /* ──────────────────────────────────────────────────────────────────────────── diff --git a/packages/spec/src/data/field-default-value.test.ts b/packages/spec/src/data/field-default-value.test.ts index b671593ebc..e2b89c7188 100644 --- a/packages/spec/src/data/field-default-value.test.ts +++ b/packages/spec/src/data/field-default-value.test.ts @@ -34,6 +34,11 @@ type Case = { accepted: boolean; /** Substrings the refusal message must carry (rejection rows only). */ contains?: string[]; + /** + * Substrings the refusal message must NOT carry — the wrong half of a + * rejection that a positional issue read would have selected (#16077). + */ + notContains?: string[]; }; const CASES: Case[] = [ @@ -86,6 +91,26 @@ const CASES: Case[] = [ accepted: false, contains: ['`heading`'], }, + // #16077 — the two rows above are the LUCKY half: their other members are + // well-typed, so nothing sorted ahead of the object-level issue and the + // rename surfaced by accident. These two are the unlucky half, where a + // positional `issues[0]` read handed the author a type error about a member + // they never wrote. `notContains` names the half that was shown instead — + // without it the row cannot see a regression back to the positional read. + { + label: '#16077 location + the RENAMED legacy pair (a missing-member error sorts ahead)', + field: { type: 'location', defaultValue: { latitude: 37.77, longitude: -122.42 } }, + accepted: false, + contains: ['`latitude` \u2192 `lat`', '`longitude` \u2192 `lng`'], + notContains: ['expected number, received undefined'], + }, + { + label: '#16077 address + a renamed key beside a WRONG-TYPED declared one', + field: { type: 'address', defaultValue: { street: 5, postal_code: '98101' } }, + accepted: false, + contains: ['`postal_code` \u2192 `postalCode`'], + notContains: ['expected string, received number'], + }, // ── Literal branch: valid literals stay accepted ────────────────────────── { label: 'VALID number', field: { type: 'number', defaultValue: 7 }, accepted: true }, @@ -227,7 +252,7 @@ const CASES: Case[] = [ ]; describe('#7127 FieldSchema.defaultValue — three shapes, each judged on its own terms', () => { - for (const { label, field, accepted, contains } of CASES) { + for (const { label, field, accepted, contains, notContains } of CASES) { it(`${accepted ? 'accepts' : 'rejects'}: ${label}`, () => { const issue = defaultValueIssue(field); if (accepted) { @@ -243,6 +268,9 @@ describe('#7127 FieldSchema.defaultValue — three shapes, each judged on its ow for (const fragment of contains ?? []) { expect(issue!.message).toContain(fragment); } + for (const fragment of notContains ?? []) { + expect(issue!.message).not.toContain(fragment); + } }); } diff --git a/packages/spec/src/ui/action-param-default-value.test.ts b/packages/spec/src/ui/action-param-default-value.test.ts index a5d61e3dff..0802e9341d 100644 --- a/packages/spec/src/ui/action-param-default-value.test.ts +++ b/packages/spec/src/ui/action-param-default-value.test.ts @@ -143,6 +143,26 @@ describe('#6970 ActionParamSchema.defaultValue — authored defaults meet the pa }); } + it('#16077 carries the rename, not a member type error, on a renamed structured default', () => { + // The action-param gate and the field gate share ONE core + // (`checkLiteralDefaultValue`), so the positional issue read cost this + // surface the same prescription. Pinned here as well as at the core + // because this consumer composes `verdict.detail` into its own message: + // a core fix that never reached the composed text would be invisible to a + // core-only pin. + const issue = defaultValueIssue({ + name: 'site', + type: 'location', + defaultValue: { latitude: 37.77, longitude: -122.42 }, + })!; + expect(issue).not.toBeNull(); + expect(issue.path).toEqual(['defaultValue']); + expect(issue.message).toContain('`latitude` \u2192 `lat`'); + expect(issue.message).toContain('`longitude` \u2192 `lng`'); + // The half a positional `issues[0]` read selected instead. + expect(issue.message).not.toContain('expected number, received undefined'); + }); + it("names the author's default as the cause, not just the param", () => { const issue = defaultValueIssue({ name: 'start', type: 'datetime', defaultValue: '2026-08-10T15:00' })!; // The underlying reason is carried verbatim from the shared value contract,