diff --git a/.changeset/29366-field-patches-key-grammar.md b/.changeset/29366-field-patches-key-grammar.md new file mode 100644 index 00000000000..101bafe4aed --- /dev/null +++ b/.changeset/29366-field-patches-key-grammar.md @@ -0,0 +1,24 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: an object extension's `fieldPatches` is keyed by the field map's own key grammar, so a key no field name can be is refused where it is written + +`ObjectExtensionSchema.fieldPatches` (ADR-0146 D10) was keyed by any string. A field patch addresses one of the extended object's own fields by name, and every field name is lowercase snake_case, so a key such as `Industry` or `Annual Revenue` could never match a field. It still parsed, and was refused only downstream, where the target judge reaches. + +Clause-②: no (narrowing) — `ObjectExtensionSchema` refuses a `fieldPatches` key outside the snake_case grammar of an object's `fields` keys, which it accepted. Nothing is widened, and no public export is added. + +Metadata-protocol: narrowed (an object extension may no longer key a field patch by anything but a field name) + +Skills-docs: updated here — `content/docs/data-modeling/object-extensions.mdx` ("A patch must name something real") gains the key grammar. `skills/objectstack-data/SKILL.md` keys its `fieldPatches` example by `industry`, so every taught line stays true and no skill line changes. + + + +**BREAKING** accept-set narrowing on `ObjectExtensionSchema` (`@objectstack/spec`), at every door that parses an object extension: `defineObjectExtension`, `defineStack({ objectExtensions })`, `os validate`, the marketplace install and the `/api/v1/meta` `object_extension` save door. + +- **Before.** `fieldPatches: { Industry: { label: 'Sector' } }` parsed in `defineStack`, was stored by the save door and booted: the registry fold named the patch once in the log and applied nothing, and `os validate` refused it only when the target object was in the same stack. +- **Now.** One `invalid_key` issue at `fieldPatches.Industry`, naming the grammar and an example. The key schema is the one `ObjectSchema.fields` reads, hoisted to one constant, so the two cannot drift. +- **Unchanged.** A snake_case key parses as before, and a `__c` key is still refused by the `__c` rule with its own prescription. +- **Who sees it.** No extension in this repository's examples, skills, docs or templates writes such a key. A stored `object_extension` row that carries one no longer parses, so the sync names the row (`INVALID_METADATA`) and registers none of it, its `fields` included; its next save is refused until the key is renamed. `os migrate meta --stored` does not list such a row beforehand: the stored pass has no stack collection for an `object_extension` row and hands it back untouched, so check each stored extension's `fieldPatches` keys against the grammar before upgrading. + +**FROM → TO.** `fieldPatches: { Industry: { label: 'Sector' } }` → `fieldPatches: { industry: { label: 'Sector' } }`: key each patch by the name of the field the extended object declares (the key of its `fields` entry), never by its label. The upgrade guide entry is `object-extension-field-patch-key-grammar-refused`. diff --git a/content/docs/data-modeling/object-extensions.mdx b/content/docs/data-modeling/object-extensions.mdx index 40e1ffeecf5..197306d66ba 100644 --- a/content/docs/data-modeling/object-extensions.mdx +++ b/content/docs/data-modeling/object-extensions.mdx @@ -142,9 +142,11 @@ reason. can be (`Tech Sector`, a label) is refused too. - **Conflicts resolve like `label`.** Two extensions patching the same key: the higher `priority` wins. A tenant who has edited that text keeps their own, [below](#extensions-and-tenant-customization). -- **A patch must name something real.** A field the target does not declare, or an option - value its field does not have, is refused when the package installs and by - `os validate` when the target is in the same stack. A field whose options come from a +- **A patch must name something real.** A key that cannot be a field name (`Industry`, + `Annual Revenue`, a label) is refused when it parses: `fieldPatches` is keyed by the + field's name, lowercase snake_case like every key of `fields`. A field the target does + not declare, or an option value its field does not have, is refused when the package + installs and by `os validate` when the target is in the same stack. A field whose options come from a shared picklist has no option labels of its own to patch. At boot, an unmatched patch is not applied and is named once in the log; the boot carries on. - **Options are the owner's too.** A patch is judged against the options the owner diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 7f11854bdf6..355375fdff2 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -578,7 +578,7 @@ const result = ApiMethod.parse(data); | :--- | :--- | :--- | :--- | | **extend** | `string` | ✅ | Target object name (FQN) to extend | | **fields** | `Record; description?: string; … }>` | optional | Fields to add/override | -| **fieldPatches** | `Record` | optional | Display patches of the target object's own fields, keyed by field name: change a vendor field's display properties (BASE_FIELD_DISPLAY_WHITELIST: `label`, `description`, `inlineHelpText`, `placeholder` on the field, `label` on an option under `options`, keyed by its stored value) without taking the field over, so its type, values and rules stay the vendor's and its upgrades still apply (ADR-0146 D10). Applied by the registry merge (`mergeObjectDefinitions`) after the additive merges, on every object read: a tenant's own text keeps winning, and between two extensions the higher `priority` wins. A patch naming a field or option value the target does not have is refused at install and by `os validate`; a field the same extension replaces in `fields` is refused here. Example: `fieldPatches: { industry: { label: 'Sector', inlineHelpText: 'The market the account sells into', options: { technology: { label: 'Tech' } } } }`. | +| **fieldPatches** | `Record` | optional | Display patches of the target object's own fields, keyed by field name (lowercase snake_case, the key grammar of an object's `fields`): change a vendor field's display properties (BASE_FIELD_DISPLAY_WHITELIST: `label`, `description`, `inlineHelpText`, `placeholder` on the field, `label` on an option under `options`, keyed by its stored value) without taking the field over, so its type, values and rules stay the vendor's and its upgrades still apply (ADR-0146 D10). Applied by the registry merge (`mergeObjectDefinitions`) after the additive merges, on every object read: a tenant's own text keeps winning, and between two extensions the higher `priority` wins. A patch naming a field or option value the target does not have is refused at install and by `os validate`; a field the same extension replaces in `fields` is refused here. Example: `fieldPatches: { industry: { label: 'Sector', inlineHelpText: 'The market the account sells into', options: { technology: { label: 'Tech' } } } }`. | | **label** | `string` | optional | Override label for the extended object | | **pluralLabel** | `string` | optional | Override plural label for the extended object | | **description** | `string` | optional | Override description for the extended object | diff --git a/packages/spec/src/data/field-customization.test.ts b/packages/spec/src/data/field-customization.test.ts index ecfccdb9302..85f47c40d0f 100644 --- a/packages/spec/src/data/field-customization.test.ts +++ b/packages/spec/src/data/field-customization.test.ts @@ -10,7 +10,7 @@ import { describe, expect, it } from 'vitest'; import type { z } from 'zod'; import { BASE_FIELD_DISPLAY_WHITELIST, EXTENSION_FIELD_SUFFIX, isExtensionFieldName } from './field-customization'; import { FieldSchema, SelectOptionSchema } from './field.zod'; -import { ObjectExtensionSchema } from './object.zod'; +import { ObjectExtensionSchema, ObjectSchema } from './object.zod'; /** The object shape behind a (possibly optional) schema. */ function shapeOf(schema: unknown): Record { @@ -349,3 +349,65 @@ describe('`fieldPatches` — what the slot refuses beyond its keys, and the rena } }); }); + +/** Parse an extension whose `fieldPatches` is `fieldPatches`; return the issues. */ +function fieldPatchesIssues(fieldPatches: Record): Issue[] { + const r = ObjectExtensionSchema.safeParse({ extend: 'crm_account', fieldPatches }); + return r.success ? [] : (r.error.issues as unknown as Issue[]); +} + +/** Does `ObjectSchema.fields` admit `key` as a field name? Read off its own parse, so the grammar is not restated. */ +function objectFieldsAdmit(key: string): boolean { + const r = ObjectSchema.safeParse({ name: 'crm_account', label: 'Account', fields: { [key]: { type: 'text', label: 'X' } } }); + const issues = r.success ? [] : (r.error.issues as unknown as Issue[]); + return !issues.some((i) => i.code === 'invalid_key' && i.path.join('.') === `fields.${key}`); +} + +describe('`fieldPatches` keys — the field map\'s own key grammar', () => { + it.each([['Industry'], ['Annual Revenue'], ['annual-revenue'], ['2nd_phone']])( + 'refuses `%s`, which no field name can be: one `invalid_key` at its key, naming the grammar and an example', + (key) => { + const issues = fieldPatchesIssues({ [key]: { label: 'Sector' } }); + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('invalid_key'); + expect(issues[0]!.path).toEqual(['fieldPatches', key]); + expect(issues[0]!.message).toMatch(new RegExp(`^\`${key}\` cannot be a field name`)); + expect(issues[0]!.message).toContain('lowercase snake_case such as `industry`'); + expect(issues[0]!.message).not.toMatch(/#\d{3,}/); + }, + ); + + it('control: a snake_case key parses, and so does a `__c` key, which stays the `__c` rule\'s to refuse', () => { + expect(fieldPatchesIssues({ annual_revenue: { label: 'Revenue' } })).toEqual([]); + const issues = fieldPatchesIssues({ region__c: { label: 'Region' } }); + expect(issues.map((i) => [i.code, i.path.join('.')])).toEqual([['custom', 'fieldPatches.region__c']]); + }); + + it('control: the authored example of the object-extension guide still parses whole', () => { + // `content/docs/data-modeling/object-extensions.mdx`, "Relabelling a vendor field". + const parsed = ObjectExtensionSchema.parse({ + extend: 'crm_account', + fieldPatches: { + industry: { + label: 'Sector', + inlineHelpText: 'The market the account sells into.', + options: { technology: { label: 'Tech' } }, + }, + }, + }); + expect(parsed.fieldPatches?.industry?.label).toBe('Sector'); + }); + + it('the key schema IS the one `ObjectSchema.fields` reads: both admit exactly the same keys', () => { + const keys = ['industry', 'annual_revenue', '_internal', 'region__c', 'a1', 'Industry', 'Annual Revenue', 'annual-revenue', '2nd_phone', 'industryName', '']; + const admitted = keys.filter((key) => objectFieldsAdmit(key)); + // Guard against a vacuous agreement: the sample holds keys on both sides of the grammar. + expect(admitted.length).toBeGreaterThan(0); + expect(admitted.length).toBeLessThan(keys.length); + for (const key of keys) { + const refusedAtKey = fieldPatchesIssues({ [key]: { label: 'L' } }) + .some((i) => i.code === 'invalid_key' && i.path.join('.') === `fieldPatches.${key}`); + expect(refusedAtKey, key).toBe(!admitted.includes(key)); + } + }); +}); diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 67228dde3fe..f24cd618462 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -2768,6 +2768,18 @@ function refuseDuplicateActionNames(objectName: unknown, actions: unknown, ctx: } } +/** + * The key grammar of an object's field map: a field name is lowercase + * snake_case. ONE schema, read by every record keyed by a field name of an + * object — `ObjectSchema.fields` and `ObjectExtensionSchema.fieldPatches` + * (#29366) — so a key the field map can never hold is refused where it is + * written, never accepted as a patch that no field can match. Declared above + * `ObjectSchemaBase` for the order reason the block below gives. + */ +const FieldNameKeySchema = z.string().regex(/^[a-z_][a-z0-9_]*$/, { + message: 'Field names must be lowercase snake_case (e.g., "first_name", "company", "annual_revenue")', +}); + // ⚠️ ORDER IS LOAD-BEARING (#5593). This map used to live ~700 lines BELOW // `ObjectSchemaBase`, and the error map that reads it was built lazily // (`objectUnknownKeyErrorImpl ??= …`) purely to step around the temporal dead @@ -3189,13 +3201,7 @@ const ObjectSchemaBase = strictObject( message: 'Field names must not be "constructor" or "prototype" (reserved JavaScript prototype property names).', path: [reserved], }), - z.record( - z.string() - .regex(/^[a-z_][a-z0-9_]*$/, { - message: 'Field names must be lowercase snake_case (e.g., "first_name", "company", "annual_revenue")', - }), - FieldSchema, - ), + z.record(FieldNameKeySchema, FieldSchema), ), 'fields', ).describe('Field definitions map. Keys must be snake_case identifiers; "__proto__", "constructor" and "prototype" are refused.'), @@ -4933,7 +4939,8 @@ function refuseFieldPatchOfReplacedField(fields: unknown, fieldPatches: unknown, /** [ADR-0146 D10] The `fieldPatches` description, its member list read from {@link BASE_FIELD_DISPLAY_WHITELIST}. */ function fieldPatchesDescription(): string { return ( - 'Display patches of the target object\'s own fields, keyed by field name: change a vendor field\'s ' + + 'Display patches of the target object\'s own fields, keyed by field name (lowercase snake_case, the ' + + 'key grammar of an object\'s `fields`): change a vendor field\'s ' + `display properties (BASE_FIELD_DISPLAY_WHITELIST: ${backtickList(BASE_FIELD_DISPLAY_WHITELIST.field)} ` + `on the field, ${backtickList(BASE_FIELD_DISPLAY_WHITELIST.option)} on an option under \`options\`, ` + 'keyed by its stored value) without taking the field over, so its type, values and rules stay the ' + @@ -5030,7 +5037,9 @@ export const ObjectExtensionSchema = lazySchema(() => strictObject({ /** * [ADR-0146 D10, second rule] Per-field display patch of the target's own - * fields. Keys are DERIVED from `BASE_FIELD_DISPLAY_WHITELIST`; structural + * fields. Each record key is a field name, judged by the field map's own + * key grammar ({@link FieldNameKeySchema}, as `ObjectSchema.fields`). A + * patch's keys are DERIVED from `BASE_FIELD_DISPLAY_WHITELIST`; structural * keys are refused by name with the D6 prescription; an `__c` key, and a * field the same extension replaces in `fields`, are refused by the root * refinement below; `__proto__` is refused on the raw input. Pure shape: whether the target has the field @@ -5039,7 +5048,19 @@ export const ObjectExtensionSchema = lazySchema(() => strictObject({ * `os validate`, named in the boot log — and the merge that applies the patch * is `@objectstack/objectql`'s `mergeObjectDefinitions`. */ - fieldPatches: refuseRecordProtoKey(z.record(z.string(), FieldDisplayPatchSchema), 'fieldPatches') + fieldPatches: refuseRecordProtoKey(z.record(FieldNameKeySchema, FieldDisplayPatchSchema, { + // [#29366] Keyed by the field map's own key grammar: a key no field name + // can be (`Industry`, `Annual Revenue`) is refused here, where it is + // written, instead of parsing and reaching only the target judge. + error: (issue) => { + if (issue.code === 'invalid_key') { + return `\`${String(issue.input)}\` cannot be a field name, so no field sits under it: \`fieldPatches\` is ` + + 'keyed by the name of a field the extended object declares, lowercase snake_case such as ' + + '`industry` or `annual_revenue` (the key of its `fields` entry), never by its label.'; + } + return undefined; + }, + }), 'fieldPatches') .optional() .describe(fieldPatchesDescription()), diff --git a/packages/spec/src/migrations/entries/semantic/18.object-extension-field-patch-key-grammar-refused.ts b/packages/spec/src/migrations/entries/semantic/18.object-extension-field-patch-key-grammar-refused.ts new file mode 100644 index 00000000000..7199aa8b717 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.object-extension-field-patch-key-grammar-refused.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #29366: an object extension's `fieldPatches` is keyed by the key schema +// `ObjectSchema.fields` reads. A semantic entry and not a conversion: the key +// that was meant is the name of a field the target declares, and a rewrite of +// the spelling (lower-casing, `-` to `_`) can land on a different field or on +// none, so which field is the author's to name. +// +// No `REFUSED_STORED_SHAPES` detector lists a stored row in this shape, because +// none could be reached: `applyConversionsToStoredItem` maps a row type onto a +// stack collection, and `object_extension` has none (it is in neither +// `SINGULAR_TO_PLURAL` nor the stored-only map), so the stored pass hands the +// row back untouched and runs no conversion or detector over it. The criteria +// below say so, and name the query that finds such a row instead. +export const entry: SemanticMigration = { + id: 'object-extension-field-patch-key-grammar-refused', + surface: + '`objectExtensions[].fieldPatches` keys (`defineObjectExtension({ fieldPatches })`) that are not a ' + + 'field name: any key outside the lowercase snake_case grammar of an object\'s `fields` keys, such as ' + + 'a label (`Industry`, `Annual Revenue`) or a kebab spelling (`annual-revenue`).', + replacement: + 'The name of the field the extended object declares, the key of its `fields` entry: ' + + '`fieldPatches: { industry: { label: \'Sector\' } }`, never `fieldPatches: { Industry: { … } }`.', + reason: + 'A field patch addresses one of the target\'s own fields by name, and every field name is lowercase ' + + 'snake_case, so a key outside that grammar can never match a field. The schema used to take any ' + + 'string, so such a patch parsed in `defineStack`, was stored by the `object_extension` save door, ' + + 'and booted: the registry fold only named it in the log and applied nothing, and `os validate` ' + + 'refused it only when the target object was part of the composed stack. The author read "Sector" ' + + 'in their package while the screen kept the vendor\'s label. The key is now judged where it is ' + + 'written, by the same key schema the field map uses.', + acceptanceCriteria: + 'Every `fieldPatches` key in the stack is the name of a field its `extend` target declares, and ' + + '`os validate` passes. A key outside the grammar is refused at parse with one `invalid_key` issue ' + + 'at `fieldPatches.KEY` that names the grammar and an example. A stored `object_extension` row whose ' + + 'body still carries such a key no longer parses, so the sync that registers stored extensions names ' + + 'the row (`INVALID_METADATA`) and registers none of it, its `fields` included, and its next save is ' + + 'refused until the key is renamed to the field it meant. `os migrate meta --stored` does not list such ' + + 'a row: the stored pass maps no `object_extension` row onto a stack collection, so it reads the row as ' + + 'already on protocol. Find one before upgrading by reading each stored `object_extension` row\'s ' + + '`fieldPatches` keys against the snake_case grammar of `fields` keys.', +};