Skip to content

Commit b3d7a70

Browse files
fix(spec): ObjectSchema.fields refuses constructor / prototype at the offending key, not at the fields slot (#21070)
Closes #20997 Clause-②: no (no key is added and the accept set does not move: only the issue path of an existing refusal changes, read against `scripts/pm/clause2-line.mjs`) ## What changes `ObjectSchema.fields` still refuses a field named `constructor` or `prototype`. The issue is now reported at the key (`fields.constructor`, `fields.prototype`) instead of at `fields`, as `__proto__` and the key grammar's `invalid_key` already were. A document with both names gets two issues instead of one. The issue code (`custom`) and the message are unchanged. ## How One `.refine(bannedKeys([name]), { path: [name] })` per reserved name replaces the single two-name refine. A `.superRefine()` with a computed path was not used: it has no readable predicate, so the published JSON Schema would lose the ban (#19346). The published `fields` node now carries one `propertyNames` clause per name in the same `allOf`. `bannedKeys` (`shared/refinement-projection.ts`) is not edited. Its other caller, `system/tracing.zod.ts` (`bannedKeys(['dialect'])`, `abort: true`), keeps its slot-level refusal on purpose, as its own comment says. `record-proto-key-guard.ts` changes only in its docblock. ## Tests In `object.test.ts`: - each name is refused at `fields.NAME`, with no slot-level issue left; - both names give two issues; - a `Bad Name` control; - the published-node and census pins are updated. The local runs and ablations are in the `os-dev-report` on #20997. `dispatch-gates.mjs --commands` was not run locally, so CI on this head is the measurement. The consumer half is objectui#11302 (the designer page that drops the issue detail). It is not addressed here. --- _Generated by [Claude Code](https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent c27404f commit b3d7a70

4 files changed

Lines changed: 126 additions & 56 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec): `ObjectSchema.fields` refuses `constructor` and `prototype` at the offending key (`fields.constructor`, `fields.prototype`), not at the `fields` slot
6+
7+
Clause-②: no
8+
9+
A field map carrying a key named `constructor` or `prototype` is refused, as before. The
10+
refusal used to be reported at the path `fields`, which names no field, so a form reading the
11+
structured issues of a refused save could not point at the field that caused it. It is now
12+
reported at that key, like the `__proto__` refusal (`fields.__proto__`) and the key grammar's
13+
`invalid_key` refusal (`fields.Bad Name`) already are. A document carrying both names gets two
14+
issues, one at each key, where it used to get one at the slot.
15+
16+
Nothing else moves. The same keys are refused and the same documents are accepted. The issue
17+
code (`custom`) and the message are unchanged. The published JSON Schema states the same ban:
18+
it is now written as one `propertyNames` clause per name inside `allOf` instead of one clause
19+
naming both, which accepts and refuses exactly the same documents.

‎packages/spec/src/data/object.test.ts‎

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2774,20 +2774,46 @@ describe('ObjectSchema.fields — __proto__ / constructor / prototype key refusa
27742774
});
27752775
expect(result.success).toBe(false);
27762776
if (result.success) return;
2777-
// ⚠️ The refusal is located at the SLOT and no longer at the offending
2778-
// key. The `banned-keys` arm is a RECORD-level predicate — the only shape
2779-
// the closed projection list can publish — and `.refine()` carries no
2780-
// per-key path, so before #19346 this read `fields.<the key>` with code
2781-
// `invalid_key` and the reason nested one level under zod's fixed
2782-
// "Invalid key in record". The ban list is closed and two names long, so
2783-
// the slot is still named and the message names both keys in full.
2784-
const issue = result.error.issues.find((i) => i.path.join('.') === 'fields');
2777+
// [#20997] Located AT the offending key — `fields.<the key>` — the path
2778+
// the `__proto__` guard and the key grammar already report at, so
2779+
// `saveMetaItem`'s structured issues can point a Studio form at the
2780+
// field. The `banned-keys` arm is still a RECORD-level predicate (the
2781+
// only shape the closed projection list can publish); what puts the path
2782+
// on the key is one single-name refine per reserved name, each carrying
2783+
// its own name as a static `path`.
2784+
const issue = result.error.issues.find((i) => i.path.join('.') === `fields.${reserved}`);
27852785
expect(issue).toBeDefined();
27862786
expect(issue?.code).toBe('custom');
27872787
expect(issue?.message).toMatch(/constructor.*prototype|prototype.*constructor/s);
2788+
// Exactly one refusal, and none left at the bare slot — a pin that only
2789+
// looked for the keyed issue would pass beside a slot-level duplicate.
2790+
expect(result.error.issues.map((i) => i.path.join('.'))).toEqual([`fields.${reserved}`]);
27882791
},
27892792
);
27902793

2794+
it('refuses `constructor` and `prototype` together as TWO issues, each at its own key', () => {
2795+
const result = ObjectSchema.safeParse(
2796+
JSON.parse(
2797+
'{"name":"lead","label":"Lead","fields":{"constructor":{"type":"text","label":"C"},"prototype":{"type":"text","label":"P"}}}',
2798+
),
2799+
);
2800+
expect(result.success).toBe(false);
2801+
if (result.success) return;
2802+
expect(result.error.issues.map((i) => [i.code, i.path.join('.')])).toEqual([
2803+
['custom', 'fields.constructor'],
2804+
['custom', 'fields.prototype'],
2805+
]);
2806+
});
2807+
2808+
it('CONTROL: a key the snake_case grammar refuses keeps its own path and code (`invalid_key` at the key)', () => {
2809+
const result = ObjectSchema.safeParse(
2810+
JSON.parse('{"name":"lead","label":"Lead","fields":{"title":{"type":"text","label":"T"},"Bad Name":{"type":"text","label":"B"}}}'),
2811+
);
2812+
expect(result.success).toBe(false);
2813+
if (result.success) return;
2814+
expect(result.error.issues.map((i) => [i.code, i.path.join('.')])).toEqual([['invalid_key', 'fields.Bad Name']]);
2815+
});
2816+
27912817
it('still accepts an ordinary document with no reserved field names', () => {
27922818
const result = ObjectSchema.safeParse({
27932819
name: 'lead',
@@ -2865,7 +2891,13 @@ describe('ObjectSchema.fields — __proto__ / constructor / prototype key refusa
28652891
// The record's own key-TYPE rule survives — the ban is CONJOINED, never
28662892
// substituted, so the reference tables keep the shape they always printed.
28672893
expect(node.propertyNames).toEqual({ type: 'string', pattern: '^[a-z_][a-z0-9_]*$' });
2868-
expect(node.allOf).toEqual([{ propertyNames: { not: { enum: ['constructor', 'prototype'] } } }]);
2894+
// One clause per reserved name (#20997): each name is its own single-name
2895+
// refine so its refusal can carry the name as its path, and the generator
2896+
// conjoins the two bans — the same rule as one two-name `enum`.
2897+
expect(node.allOf).toEqual([
2898+
{ propertyNames: { not: { enum: ['constructor'] } } },
2899+
{ propertyNames: { not: { enum: ['prototype'] } } },
2900+
]);
28692901
});
28702902

28712903
it('the runtime and the published keywords agree on every document in the corpus', () => {
@@ -2876,6 +2908,7 @@ describe('ObjectSchema.fields — __proto__ / constructor / prototype key refusa
28762908
JSON.parse('{"constructor":{"type":"text","label":"R"}}'),
28772909
JSON.parse('{"prototype":{"type":"text","label":"R"}}'),
28782910
JSON.parse('{"title":{"type":"text","label":"T"},"constructor":{"type":"text","label":"R"}}'),
2911+
JSON.parse('{"constructor":{"type":"text","label":"R"},"prototype":{"type":"text","label":"R"}}'),
28792912
// LIT CONTROLS — names the ban does NOT cover. A rule that banned by
28802913
// prefix, or that asked `in` instead of own-property equality, fails
28812914
// here: `to_string` is not `toString`, and `constructors` is not
@@ -2897,6 +2930,6 @@ describe('ObjectSchema.fields — __proto__ / constructor / prototype key refusa
28972930
const census = collectDroppedRefinements('data/Object', ObjectSchema);
28982931
expect(census.dropped.map((site) => site.path)).not.toContain('fields.out.keyType');
28992932
const site = census.projected.find((s) => s.path === 'fields.out');
2900-
expect(site?.declaredPatterns).toEqual(['banned-keys']);
2933+
expect(site?.declaredPatterns).toEqual(['banned-keys', 'banned-keys']);
29012934
});
29022935
});

‎packages/spec/src/data/object.zod.ts‎

Lines changed: 51 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1954,45 +1954,58 @@ const ObjectSchemaBase = strictObject(
19541954
* Data Model
19551955
*/
19561956
fields: refuseRecordProtoKey(
1957-
z.record(
1958-
z.string()
1959-
.regex(/^[a-z_][a-z0-9_]*$/, {
1960-
message: 'Field names must be lowercase snake_case (e.g., "first_name", "company", "annual_revenue")',
1957+
// [#17852, #19346] The three JS-prototype names are refused by
1958+
// TWO mechanisms, because zod reaches them at two different depths:
1959+
//
1960+
// - `__proto__` reaches NO schema at all — zod's record parser skips it
1961+
// with an unconditional `continue` ABOVE the key schema — so it is
1962+
// refused on the RAW input by `refuseRecordProtoKey` around this.
1963+
// - `constructor` and `prototype` DO reach the parse (ordinary
1964+
// lowercase words the snake_case regex below already admits) and are
1965+
// refused HERE, on the RECORD, through the closed projection list's
1966+
// `banned-keys` arm.
1967+
//
1968+
// ⭐ The arm — and not the key-schema `.refine()` it replaces (#19147) —
1969+
// is what carries the refusal into `packages/spec/json-schema/**`.
1970+
// `z.toJSONSchema()` has no arm for a `custom` check, so a rule written
1971+
// on the KEY schema was enforced by the runtime and ABSENT from the
1972+
// published file: nine `fields.out.keyType` rows in
1973+
// `dropped-refinements.baseline.json`, one per embedding schema, and a
1974+
// validator reading the published contract answered PASS on a document
1975+
// the platform refuses by name. Declared through `bannedKeys`, the same
1976+
// rule is published as `propertyNames` + `not` and those nine rows leave
1977+
// the ledger (#19346, closing a piece of #18670). The accept set is
1978+
// unchanged in both directions — `bannedKeys` reads OWN properties, which
1979+
// is exactly what the key schema judged.
1980+
//
1981+
// ⭐ [#20997] ONE refine PER NAME, each located AT its key
1982+
// (`fields.constructor`, `fields.prototype`) — the path the `__proto__`
1983+
// guard and the key grammar's `invalid_key` already report at, and the one
1984+
// `saveMetaItem`'s structured issues need for a Studio form to highlight
1985+
// the offending field. `.refine()`'s `path` is STATIC, so a single
1986+
// two-name predicate could only ever name the slot; one single-name
1987+
// predicate per name makes the path a constant again, and the name is ONE
1988+
// string read twice — into the ban and into the path — so the two cannot
1989+
// drift. ⛔ Not a `.superRefine()` with a computed path: that check holds
1990+
// no readable predicate, so the generator could not project it and the
1991+
// published file would go wide again (the nine rows above would return).
1992+
// The published keywords are the same rule, conjoined one clause per name
1993+
// (`not enum [a, b]` ≡ `not enum [a]` ∧ `not enum [b]`), and the message
1994+
// and `custom` code are byte-identical to the one-refine spelling.
1995+
(['constructor', 'prototype'] as const).reduce(
1996+
(record, reserved) =>
1997+
record.refine(bannedKeys([reserved]), {
1998+
message: 'Field names must not be "constructor" or "prototype" (reserved JavaScript prototype property names).',
1999+
path: [reserved],
19612000
}),
1962-
FieldSchema,
1963-
)
1964-
// [#17852, #19346] The three JS-prototype names are refused by
1965-
// TWO mechanisms, because zod reaches them at two different depths:
1966-
//
1967-
// - `__proto__` reaches NO schema at all — zod's record parser skips it
1968-
// with an unconditional `continue` ABOVE the key schema — so it is
1969-
// refused on the RAW input by `refuseRecordProtoKey` below.
1970-
// - `constructor` and `prototype` DO reach the parse (ordinary
1971-
// lowercase words the snake_case regex above already admits) and are
1972-
// refused HERE, on the RECORD, through the closed projection list's
1973-
// `banned-keys` arm.
1974-
//
1975-
// ⭐ The arm — and not the key-schema `.refine()` it replaces (#19147) —
1976-
// is what carries the refusal into `packages/spec/json-schema/**`.
1977-
// `z.toJSONSchema()` has no arm for a `custom` check, so a rule written
1978-
// on the KEY schema was enforced by the runtime and ABSENT from the
1979-
// published file: nine `fields.out.keyType` rows in
1980-
// `dropped-refinements.baseline.json`, one per embedding schema, and a
1981-
// validator reading the published contract answered PASS on a document
1982-
// the platform refuses by name. Declared through `bannedKeys`, the same
1983-
// rule is published as `propertyNames` + `not` and those nine rows leave
1984-
// the ledger (#19346, closing a piece of #18670). The accept set is
1985-
// unchanged in both directions — `bannedKeys` reads OWN properties, which
1986-
// is exactly what the key schema judged.
1987-
//
1988-
// ⚠️ The refusal is located at the SLOT (`fields`, code `custom`) rather
1989-
// than at the offending key (`fields.constructor`, code `invalid_key`):
1990-
// a record-level predicate is the only shape the closed list can project,
1991-
// and `.refine()` carries no per-key path. The ban list is closed and two
1992-
// names long, so the message names both in full.
1993-
.refine(bannedKeys(['constructor', 'prototype']), {
1994-
message: 'Field names must not be "constructor" or "prototype" (reserved JavaScript prototype property names).',
1995-
}),
2001+
z.record(
2002+
z.string()
2003+
.regex(/^[a-z_][a-z0-9_]*$/, {
2004+
message: 'Field names must be lowercase snake_case (e.g., "first_name", "company", "annual_revenue")',
2005+
}),
2006+
FieldSchema,
2007+
),
2008+
),
19962009
'fields',
19972010
).describe('Field definitions map. Keys must be snake_case identifiers; "__proto__", "constructor" and "prototype" are refused.'),
19982011
indexes: z.array(IndexSchema).optional().describe('Database performance indexes'),

‎packages/spec/src/shared/record-proto-key-guard.ts‎

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -54,14 +54,19 @@ import { z } from 'zod';
5454
* refuse a name for slots whose accept set no ruling has narrowed.
5555
*
5656
* `ObjectSchema.fields` (`data/object.zod.ts`) is the slot that does, and since
57-
* #19346 it refuses them with a RECORD-level `bannedKeys(['constructor',
58-
* 'prototype'])` rather than in its key grammar. The reason is the PUBLISHED
59-
* file and not the runtime: a `.refine()` on the key schema is a `custom`
60-
* check, which `z.toJSONSchema()` has no arm for, so such a rule reaches the
61-
* runtime and never `packages/spec/json-schema/**` — it held nine
62-
* `fields.out.keyType` rows in `dropped-refinements.baseline.json` saying
63-
* exactly that. Declared through the closed projection list's `banned-keys`
64-
* arm, the same rule is published as `propertyNames` plus `not`.
57+
* #19346 it refuses them with RECORD-level `bannedKeys` refines rather than in
58+
* its key grammar. The reason is the PUBLISHED file and not the runtime: a
59+
* `.refine()` on the key schema is a `custom` check, which `z.toJSONSchema()`
60+
* has no arm for, so such a rule reaches the runtime and never
61+
* `packages/spec/json-schema/**` — it held nine `fields.out.keyType` rows in
62+
* `dropped-refinements.baseline.json` saying exactly that. Declared through
63+
* the closed projection list's `banned-keys` arm, the same rule is published
64+
* as `propertyNames` plus `not`.
65+
*
66+
* Since #20997 that is ONE single-name refine per name, whose static `path`
67+
* is that name, so each refusal is located at `fields.<name>` exactly as this
68+
* guard's own refusal is located at `fields.__proto__` — a record-level
69+
* predicate keeps the projection, and a per-name one keeps the path.
6570
*
6671
* ⛔ That route is not open to `__proto__`, here or anywhere: this wrapper's
6772
* guard is a pre-parse `z.preprocess` node, which the projection cannot see

0 commit comments

Comments
 (0)