Skip to content

Commit acabd24

Browse files
huangyiireneclaude
andauthored
fix(spec): the defaultValue literal gate prescribes the key rename, not a missing-member type error (#16409)
* wip(spec): defaultValue literal gate prefers the unrecognized_keys issue Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno * fix(spec): the defaultValue literal gate prefers the unrecognized_keys issue over a positional read Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c78c918 commit acabd24

5 files changed

Lines changed: 167 additions & 4 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
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.
6+
7+
`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.
8+
9+
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.
10+
11+
⛔ No verdict moves. Exactly the same defaults are accepted and refused, on the same evidence — only the refusal text changes.
12+
13+
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.

packages/spec/src/data/default-value-shape.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,4 +118,60 @@ describe('#7127 checkLiteralDefaultValue — the shared stored-form literal chec
118118
it('stays open where the contract is deliberately open (json)', () => {
119119
expect(checkLiteralDefaultValue({ type: 'json' }, { anything: ['at', 'all'] }).ok).toBe(true);
120120
});
121+
122+
// ── #16077: `detail` is the ACTIONABLE issue, not `issues[0]` ─────────────
123+
//
124+
// zod sorts per-member issues ahead of the object-level `unrecognized_keys`
125+
// one, so a positional read handed an author who RENAMED a key a
126+
// missing-member type error about a member they never wrote — and which of
127+
// the two they got depended on whether some unrelated member happened to
128+
// also be wrong. Each case below asserts BOTH halves: the prescription is
129+
// present, AND the half that was being shown instead is gone. Without the
130+
// second the pin cannot see a regression back to the positional read.
131+
it('#16077 prefers the rename over a MISSING-member type error (location)', () => {
132+
const v = checkLiteralDefaultValue({ type: 'location' }, { latitude: 1, longitude: 2 });
133+
expect(v.ok).toBe(false);
134+
// Positionally this rejection reads
135+
// `[invalid_type(lat), invalid_type(lng), unrecognized_keys]`.
136+
expect(v.detail).toContain('`latitude` \u2192 `lat`');
137+
expect(v.detail).toContain('`longitude` \u2192 `lng`');
138+
expect(v.detail).not.toContain('expected number, received undefined');
139+
// Edit distance cannot reach `latitude` -> `lat`; the curated `aliases`
140+
// map is the only thing that can, which is why discarding it cost the
141+
// author the whole prescription.
142+
});
143+
144+
it('#16077 prefers the rename over a WRONG-TYPED-member error (address)', () => {
145+
const v = checkLiteralDefaultValue({ type: 'address' }, { street: 5, postal_code: '98101' });
146+
expect(v.ok).toBe(false);
147+
// Every member of `address` is optional, which rules out a MISSING-member
148+
// error but says nothing about a wrong-typed declared one — it sorts ahead
149+
// just the same. This is the case that made the defect look location-only.
150+
expect(v.detail).toContain('`postal_code` \u2192 `postalCode`');
151+
expect(v.detail).not.toContain('expected string, received number');
152+
});
153+
154+
it('#16077 leaves the already-correct case exactly as it was (the asymmetry is gone)', () => {
155+
// No member error to sort ahead, so this one was always right. Pinning it
156+
// beside the two above is what states the property: the diagnosis no
157+
// longer depends on whether an unrelated member happened to also be wrong.
158+
const lucky = checkLiteralDefaultValue({ type: 'address' }, { postal_code: '98101' });
159+
const unlucky = checkLiteralDefaultValue({ type: 'address' }, { street: 5, postal_code: '98101' });
160+
expect(lucky.ok).toBe(false);
161+
expect(lucky.detail).toContain('`postal_code` \u2192 `postalCode`');
162+
expect(unlucky.detail).toContain('`postal_code` \u2192 `postalCode`');
163+
});
164+
165+
it('#16077 is a NO-OP for a class that cannot emit `unrecognized_keys`', () => {
166+
// The sweep over all sixteen classes `valueSchemaFor(def, 'stored')`
167+
// covers found only `location` and `address` backed by a `strictObject`,
168+
// so only they can emit the issue the preference looks for. For the other
169+
// fourteen the selected message is `issues[0]` exactly as before — this
170+
// pin is the one that goes red if the preference ever starts reordering
171+
// a class it has no business reordering.
172+
const v = checkLiteralDefaultValue({ type: 'datetime' }, '2026-08-10T15:00');
173+
expect(v.ok).toBe(false);
174+
expect(v.detail).toContain('ISO-8601 instant');
175+
expect(v.detail).not.toContain('Unrecognized key');
176+
});
121177
});

packages/spec/src/data/default-value-shape.ts

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,13 +96,59 @@ export function discriminateDefaultValueShape(dv: unknown): DefaultValueShape {
9696
return 'literal';
9797
}
9898

99-
/** Verdict of {@link checkLiteralDefaultValue}: `ok`, or the first contract violation. */
99+
/** Verdict of {@link checkLiteralDefaultValue}: `ok`, or the contract violation to act on. */
100100
export interface LiteralDefaultValueVerdict {
101101
ok: boolean;
102-
/** First issue message from the value contract — the "why" a refusal carries verbatim. */
102+
/**
103+
* The ACTIONABLE issue message from the value contract — the "why" a refusal
104+
* carries verbatim. Selected by {@link actionableValueIssueMessage}, not read
105+
* positionally; the field's name, type and meaning are what they always were.
106+
*/
103107
detail?: string;
104108
}
105109

110+
/**
111+
* The one parse issue an author can act on, out of everything zod reported for
112+
* this literal's value-shape rejection.
113+
*
114+
* NOT `issues[0]`. zod reports per-member issues before the object-level
115+
* `unrecognized_keys` one, so on a default whose keys were RENAMED the
116+
* actionable message sorts LAST and a positional read discards it. An
117+
* `address` default authored as `{ street: 5, postal_code: '98101' }` reports:
118+
*
119+
* [0] invalid_type street Invalid input: expected string, received number
120+
* [1] unrecognized_keys ... Did you mean `postal_code` -> `postalCode`? ...
121+
*
122+
* The rename IS the prescription, and edit distance cannot reach it
123+
* (`latitude` -> `lat`), which is exactly why `LocationValueSchema` and
124+
* `AddressValueSchema` curate an `aliases` map. Reading positionally built that
125+
* hint and threw it away, handing the author a missing-member type error about
126+
* a member they never wrote — and which of the two they got depended on whether
127+
* some unrelated member happened to also be wrong, which nobody chose and
128+
* nobody can see.
129+
*
130+
* The preference is a NO-OP for every other class rather than merely harmless
131+
* to it, which is what makes it safe as a blanket rule. Swept on THIS function
132+
* over all sixteen classes `valueSchemaFor(def, 'stored')` covers, at both
133+
* arities: only `location` and `address` can emit `unrecognized_keys` at all,
134+
* because only they are backed by a `strictObject`. The string, numeric,
135+
* boolean, calendar-date, instant, clock-time, option, reference and
136+
* file-reference classes are scalars; `composite` / `record` / `repeater` /
137+
* `vector` are open records and arrays; the open fallback is `z.unknown()`. For
138+
* the other fourteen this cannot change a single character.
139+
*
140+
* The stored-value scan reached the same reading from the other side
141+
* (objectql `record-validator.ts`'s `valueShapeDetail`). Two readings of one
142+
* rejection, one per surface — deliberately not shared, because `packages/spec`
143+
* is upstream of `objectql` and this gate answers a metadata AUTHOR while that
144+
* one answers an operator running a migration.
145+
*/
146+
function actionableValueIssueMessage(
147+
issues: ReadonlyArray<{ code: string; message: string }>,
148+
): string {
149+
return (issues.find((i) => i.code === 'unrecognized_keys') ?? issues[0])?.message ?? 'invalid value';
150+
}
151+
106152
/**
107153
* Check a LITERAL default against its owner's own stored-form value contract
108154
* (`valueSchemaFor(def, 'stored')` — ADR-0104 D1). The shared core of the
@@ -117,7 +163,7 @@ export interface LiteralDefaultValueVerdict {
117163
export function checkLiteralDefaultValue(def: ValueShapeFieldDef, dv: unknown): LiteralDefaultValueVerdict {
118164
const result = valueSchemaFor(def, 'stored').safeParse(dv);
119165
if (result.success) return { ok: true };
120-
return { ok: false, detail: result.error.issues[0]?.message ?? 'invalid value' };
166+
return { ok: false, detail: actionableValueIssueMessage(result.error.issues) };
121167
}
122168

123169
/* ────────────────────────────────────────────────────────────────────────────

packages/spec/src/data/field-default-value.test.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ type Case = {
3434
accepted: boolean;
3535
/** Substrings the refusal message must carry (rejection rows only). */
3636
contains?: string[];
37+
/**
38+
* Substrings the refusal message must NOT carry — the wrong half of a
39+
* rejection that a positional issue read would have selected (#16077).
40+
*/
41+
notContains?: string[];
3742
};
3843

3944
const CASES: Case[] = [
@@ -86,6 +91,26 @@ const CASES: Case[] = [
8691
accepted: false,
8792
contains: ['`heading`'],
8893
},
94+
// #16077 — the two rows above are the LUCKY half: their other members are
95+
// well-typed, so nothing sorted ahead of the object-level issue and the
96+
// rename surfaced by accident. These two are the unlucky half, where a
97+
// positional `issues[0]` read handed the author a type error about a member
98+
// they never wrote. `notContains` names the half that was shown instead —
99+
// without it the row cannot see a regression back to the positional read.
100+
{
101+
label: '#16077 location + the RENAMED legacy pair (a missing-member error sorts ahead)',
102+
field: { type: 'location', defaultValue: { latitude: 37.77, longitude: -122.42 } },
103+
accepted: false,
104+
contains: ['`latitude` \u2192 `lat`', '`longitude` \u2192 `lng`'],
105+
notContains: ['expected number, received undefined'],
106+
},
107+
{
108+
label: '#16077 address + a renamed key beside a WRONG-TYPED declared one',
109+
field: { type: 'address', defaultValue: { street: 5, postal_code: '98101' } },
110+
accepted: false,
111+
contains: ['`postal_code` \u2192 `postalCode`'],
112+
notContains: ['expected string, received number'],
113+
},
89114

90115
// ── Literal branch: valid literals stay accepted ──────────────────────────
91116
{ label: 'VALID number', field: { type: 'number', defaultValue: 7 }, accepted: true },
@@ -227,7 +252,7 @@ const CASES: Case[] = [
227252
];
228253

229254
describe('#7127 FieldSchema.defaultValue — three shapes, each judged on its own terms', () => {
230-
for (const { label, field, accepted, contains } of CASES) {
255+
for (const { label, field, accepted, contains, notContains } of CASES) {
231256
it(`${accepted ? 'accepts' : 'rejects'}: ${label}`, () => {
232257
const issue = defaultValueIssue(field);
233258
if (accepted) {
@@ -243,6 +268,9 @@ describe('#7127 FieldSchema.defaultValue — three shapes, each judged on its ow
243268
for (const fragment of contains ?? []) {
244269
expect(issue!.message).toContain(fragment);
245270
}
271+
for (const fragment of notContains ?? []) {
272+
expect(issue!.message).not.toContain(fragment);
273+
}
246274
});
247275
}
248276

packages/spec/src/ui/action-param-default-value.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,26 @@ describe('#6970 ActionParamSchema.defaultValue — authored defaults meet the pa
143143
});
144144
}
145145

146+
it('#16077 carries the rename, not a member type error, on a renamed structured default', () => {
147+
// The action-param gate and the field gate share ONE core
148+
// (`checkLiteralDefaultValue`), so the positional issue read cost this
149+
// surface the same prescription. Pinned here as well as at the core
150+
// because this consumer composes `verdict.detail` into its own message:
151+
// a core fix that never reached the composed text would be invisible to a
152+
// core-only pin.
153+
const issue = defaultValueIssue({
154+
name: 'site',
155+
type: 'location',
156+
defaultValue: { latitude: 37.77, longitude: -122.42 },
157+
})!;
158+
expect(issue).not.toBeNull();
159+
expect(issue.path).toEqual(['defaultValue']);
160+
expect(issue.message).toContain('`latitude` \u2192 `lat`');
161+
expect(issue.message).toContain('`longitude` \u2192 `lng`');
162+
// The half a positional `issues[0]` read selected instead.
163+
expect(issue.message).not.toContain('expected number, received undefined');
164+
});
165+
146166
it("names the author's default as the cause, not just the param", () => {
147167
const issue = defaultValueIssue({ name: 'start', type: 'datetime', defaultValue: '2026-08-10T15:00' })!;
148168
// The underlying reason is carried verbatim from the shared value contract,

0 commit comments

Comments
 (0)