From bdd897e3ab196cf2bb1512ffed27ee45c912be7c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 01:59:14 +0000 Subject: [PATCH 1/5] feat(spec)!: key an object extension's fields by the field map's own key grammar ObjectExtensionSchema.fields was keyed by a bare string, so an extension could add a field under a key (`Region Code`, `region-code`) the target's own field map refuses. It now reads FieldNameKeySchema, with an invalid_key prescription at fields.KEY, an ADR-0087 semantic entry, and a REFUSED_STORED_SHAPES detector that lists such a stored row. Claude-Session: https://claude.ai/code/session_01WGufzf5kvTFdbWYDbe8bHE Co-Authored-By: Claude --- .../docs/data-modeling/object-extensions.mdx | 5 ++ .../src/conversions/refused-shapes.test.ts | 59 +++++++++++++++++ .../spec/src/conversions/refused-shapes.ts | 63 ++++++++++++++++-- .../spec/src/data/field-customization.test.ts | 64 +++++++++++++++++++ packages/spec/src/data/object.zod.ts | 40 +++++++++--- ...ect-extension-field-key-grammar-refused.ts | 44 +++++++++++++ 6 files changed, 262 insertions(+), 13 deletions(-) create mode 100644 packages/spec/src/migrations/entries/semantic/18.object-extension-field-key-grammar-refused.ts diff --git a/content/docs/data-modeling/object-extensions.mdx b/content/docs/data-modeling/object-extensions.mdx index 197306d66ba..dd923d082e0 100644 --- a/content/docs/data-modeling/object-extensions.mdx +++ b/content/docs/data-modeling/object-extensions.mdx @@ -160,6 +160,11 @@ reason. ## Naming the fields you add +Each key of an extension's `fields` is a field name of the target, so it follows the +grammar of the target's own `fields` keys: lowercase snake_case, such as `region_code` +(`region_code__c` in a `.custom` package). A key outside it (`Region Code`, +`region-code`, a label) is refused when the extension parses, at `fields.`. + Field names in an extension are **not** namespace-prefixed by the platform. The namespace rule (`_`) governs the names of objects a package *defines*; it does not walk `objectExtensions`, and it could not — the target's name belongs to diff --git a/packages/spec/src/conversions/refused-shapes.test.ts b/packages/spec/src/conversions/refused-shapes.test.ts index 267f0791c27..d056e96c4e9 100644 --- a/packages/spec/src/conversions/refused-shapes.test.ts +++ b/packages/spec/src/conversions/refused-shapes.test.ts @@ -74,6 +74,7 @@ describe('the refused-shape list — registered against the migration chain', () 'object-duplicate-action-name-refused', 'ui-dashboard-global-filter-duplicate-name-refused', 'object-extension-field-patch-key-grammar-refused', + 'object-extension-field-key-grammar-refused', ]); }); @@ -909,3 +910,61 @@ describe('#29554 — a stored object extension whose fieldPatches key cannot be expect(patchKeyIssues(row)).toEqual([]); }); }); + +// ── #29523 (the extension `fields` key grammar) ─────────────────────────── + +const fieldRow = (fields: Record) => ({ extend: 'crm_account', fields }); +/** The `invalid_key` issues the extension parse locates at `fields.KEY`, as `[path, message]`. */ +const fieldKeyIssues = (row: unknown) => { + const parsed = ObjectExtensionSchema.safeParse(row); + return parsed.success + ? [] + : parsed.error.issues + .filter((i) => i.code === 'invalid_key' && i.path[0] === 'fields') + .map((i) => [i.path.join('.'), i.message]); +}; + +describe('#29523 — a stored object extension whose fields key cannot be a field name is listed by extension', () => { + it('one TODO per refused key, at fields.KEY, naming the target; the row is not rewritten', () => { + const row = fieldRow({ + 'Region Code': { type: 'text', label: 'Region' }, + 'region-tier': { type: 'number', label: 'Tier' }, + region_owner: { type: 'text', label: 'Owner' }, + }); + const { item, todos } = storedTodos('object_extension', row); + expect(item).toEqual(row); + expect(todos.map((t) => [t.code, t.conversionId, t.path])).toEqual([ + [CONVERSION_TODO_CODE, 'object-extension-field-key-grammar-refused', 'objectExtensions[0].fields.Region Code'], + [CONVERSION_TODO_CODE, 'object-extension-field-key-grammar-refused', 'objectExtensions[0].fields.region-tier'], + ]); + const todo = todos[0]!; + expect(todo.reason).toMatch(/^The extension of `crm_account` adds a field under `Region Code`\. No lossless rewrite:/); + expect(todo.reason).not.toMatch(/#\d{3,}/); + expect(JSON.parse(todo.from)).toEqual({ 'Region Code': { type: 'text' } }); + }); + + it('agrees with the refusing door: each listed key is one the extension parse refuses as a key, and no other', () => { + const row = fieldRow({ RegionCode: { type: 'text', label: 'R' }, '2nd_phone': { type: 'phone', label: 'P' }, region: { type: 'text', label: 'R' } }); + const { todos } = storedTodos('object_extension', row); + const issues = fieldKeyIssues(row); + expect(issues.map(([path]) => path)).toEqual(['fields.RegionCode', 'fields.2nd_phone']); + expect(todos.map((t) => t.path)).toEqual(issues.map(([path]) => `objectExtensions[0].${path}`)); + // The door's own message closes each reason, so the listing says what the parse says. + todos.forEach((t, i) => expect(t.reason.endsWith(issues[i]![1]!)).toBe(true)); + }); + + it('snake_case keys, `__c` ones included, are not listed, and the parse accepts them', () => { + const row = fieldRow({ region_code: { type: 'text', label: 'Region' }, region_code__c: { type: 'text', label: 'Region' } }); + expect(storedTodos('object_extension', row).todos).toEqual([]); + expect(ObjectExtensionSchema.safeParse(row).success).toBe(true); + }); + + it('each record is listed by its own detector: a refused fieldPatches key is not listed as a field, nor the reverse', () => { + const row = { extend: 'crm_account', fields: { 'Region Code': { type: 'text', label: 'R' } }, fieldPatches: { Industry: { label: 'Sector' } } }; + const { todos } = storedTodos('object_extension', row); + expect(todos.map((t) => [t.conversionId, t.path])).toEqual([ + ['object-extension-field-patch-key-grammar-refused', 'objectExtensions[0].fieldPatches.Industry'], + ['object-extension-field-key-grammar-refused', 'objectExtensions[0].fields.Region Code'], + ]); + }); +}); diff --git a/packages/spec/src/conversions/refused-shapes.ts b/packages/spec/src/conversions/refused-shapes.ts index d205cc7d3b7..4ba1a951dae 100644 --- a/packages/spec/src/conversions/refused-shapes.ts +++ b/packages/spec/src/conversions/refused-shapes.ts @@ -45,9 +45,9 @@ * ({@link externalBindingDatasourceRefusal}), and the one-name-per-action * judge of the object refinement ({@link duplicateActionNames}), and the * one-key-per-global-filter judge of the dashboard refinement - * ({@link duplicateGlobalFilterNames}), and the `fieldPatches` key - * grammar of `ObjectExtensionSchema`'s own parse - * ({@link refusedFieldPatchKeys}). A site is listed here exactly + * ({@link duplicateGlobalFilterNames}), and the `fieldPatches` and + * `fields` key grammar of `ObjectExtensionSchema`'s own parse + * ({@link refusedFieldPatchKeys}, {@link refusedExtensionFieldKeys}). A site is listed here exactly * when that door refuses it. One door lives outside this package: the * page/view `visibleWhen` rule in `@objectstack/lint` judges a `has()` * argument on the engine's AST, which this package cannot parse, so its @@ -597,12 +597,25 @@ const dashboardDuplicateGlobalFilterName: RefusedStoredShape = { * a key is listed here exactly when that parse refuses it as a key. */ function refusedFieldPatchKeys(extension: Dict): Array<{ key: string; message: string }> { - if (!isDict(extension.fieldPatches)) return []; + return refusedExtensionRecordKeys(extension, 'fieldPatches'); +} + +/** + * The keys of one field-name-keyed record of an object extension (`fields` or + * `fieldPatches`) that `ObjectExtensionSchema` refuses as no field name: each + * `invalid_key` issue the extension's own parse locates at `SLOT.KEY`, with + * that issue's message. + */ +function refusedExtensionRecordKeys( + extension: Dict, + slot: 'fields' | 'fieldPatches', +): Array<{ key: string; message: string }> { + if (!isDict(extension[slot])) return []; const parsed = ObjectExtensionSchema.safeParse(extension); if (parsed.success) return []; const refused: Array<{ key: string; message: string }> = []; for (const issue of parsed.error.issues) { - if (issue.code !== 'invalid_key' || issue.path.length !== 2 || issue.path[0] !== 'fieldPatches') continue; + if (issue.code !== 'invalid_key' || issue.path.length !== 2 || issue.path[0] !== slot) continue; const key = issue.path[1]; if (typeof key === 'string') refused.push({ key, message: issue.message }); } @@ -634,6 +647,45 @@ const objectExtensionFieldPatchKeyGrammar: RefusedStoredShape = { }, }; +// ── #29523: an object extension whose `fields` key cannot be a field name ── + +/** + * The `fields` keys `ObjectExtensionSchema` refuses as no field name, read as + * {@link refusedFieldPatchKeys} reads `fieldPatches`: the extension's own + * parse, the one the save door and the sync that registers stored extensions + * run, so a key is listed here exactly when that parse refuses it as a key. + */ +function refusedExtensionFieldKeys(extension: Dict): Array<{ key: string; message: string }> { + return refusedExtensionRecordKeys(extension, 'fields'); +} + +const objectExtensionFieldKeyGrammar: RefusedStoredShape = { + id: 'object-extension-field-key-grammar-refused', + toMajor: 18, + surface: 'objectExtensions[].fields — a key outside the snake_case grammar of an object\'s `fields` keys', + detect(stack, report) { + mapCollection(stack, 'objectExtensions', (extension, path) => { + const target = typeof extension.extend === 'string' ? `\`${extension.extend}\`` : 'its target'; + for (const { key, message } of refusedExtensionFieldKeys(extension)) { + report({ + path: `${path}.fields.${key}`, + // The key and the field's type only: the body is the author's, the key is what is refused. + from: JSON.stringify({ [key]: { type: ((extension.fields as Dict)[key] as Dict | undefined)?.type } }), + reason: `The extension of ${target} adds a field under \`${key}\`. ` + + 'No lossless rewrite: the key is the name the field is stored and read under, and re-spelling it ' + + '(lower-casing it, turning `-` or a space into `_`) names a different column than the one rows, ' + + 'views and formulas may already read, or lands on a field the object already declares, so the ' + + 'name is the author\'s to choose. Left as stored, the row no longer parses: the sync that registers ' + + 'stored extensions names it (INVALID_METADATA) and registers none of it, its `fieldPatches` ' + + 'included, and its next save is refused until the field is renamed. ' + + message, + }); + } + return extension; + }); + }, +}; + /** * Every refused stored shape, in report order. A new entry is a reviewed edit: * `refused-shapes.test.ts` pins each one. @@ -648,6 +700,7 @@ export const REFUSED_STORED_SHAPES: readonly RefusedStoredShape[] = [ objectDuplicateActionName, dashboardDuplicateGlobalFilterName, objectExtensionFieldPatchKeyGrammar, + objectExtensionFieldKeyGrammar, ]; /** diff --git a/packages/spec/src/data/field-customization.test.ts b/packages/spec/src/data/field-customization.test.ts index 85f47c40d0f..0b0aec656cb 100644 --- a/packages/spec/src/data/field-customization.test.ts +++ b/packages/spec/src/data/field-customization.test.ts @@ -411,3 +411,67 @@ describe('`fieldPatches` keys — the field map\'s own key grammar', () => { } }); }); + +/** Parse an extension whose `fields` is `fields`; return the issues. */ +function extensionFieldsIssues(fields: Record): Issue[] { + const r = ObjectExtensionSchema.safeParse({ extend: 'crm_account', fields }); + return r.success ? [] : (r.error.issues as unknown as Issue[]); +} + +describe('extension `fields` keys — the field map\'s own key grammar', () => { + it.each([['Region Code'], ['RegionCode'], ['region-code'], ['2nd_phone'], ['Region Code__c']])( + 'refuses `%s`, which no field name can be: one `invalid_key` at its key, naming the grammar and an example', + (key) => { + const issues = extensionFieldsIssues({ [key]: { type: 'text', label: 'Region' } }); + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('invalid_key'); + expect(issues[0]!.path).toEqual(['fields', key]); + expect(issues[0]!.message.startsWith(`\`${key}\` cannot be a field name`)).toBe(true); + expect(issues[0]!.message).toContain('lowercase snake_case such as `region_code`'); + expect(issues[0]!.message).not.toMatch(/#\d{3,}/); + }, + ); + + it('control: a snake_case key parses, and so does one spelled `__c` (the suffix is judged at the doors)', () => { + expect(extensionFieldsIssues({ region_code: { type: 'text', label: 'Region' } })).toEqual([]); + expect(extensionFieldsIssues({ region_code__c: { type: 'text', label: 'Region' } })).toEqual([]); + }); + + it('control: the authored examples of the object-extension guide still parse whole', () => { + // `content/docs/data-modeling/object-extensions.mdx`, the declaration and the worked example. + const declared = ObjectExtensionSchema.parse({ + extend: 'showcase_account', + fields: { + loyalty_tier: { type: 'select', label: 'Loyalty Tier', options: [{ label: 'Gold', value: 'gold' }] }, + csat_score: { type: 'number', label: 'CSAT Score', min: 0, max: 100 }, + }, + priority: 210, + }); + expect(Object.keys(declared.fields ?? {})).toEqual(['loyalty_tier', 'csat_score']); + const worked = ObjectExtensionSchema.parse({ + extend: 'crm_account', + fields: { + cs_health_score: { type: 'number', label: 'Health Score', min: 0, max: 100 }, + cs_renewal_date: { type: 'date', label: 'Renewal Date' }, + cs_owner: { type: 'text', label: 'Success Manager' }, + }, + indexes: [{ name: 'idx_crm_account_cs_renewal', fields: ['cs_renewal_date'] }], + priority: 300, + }); + expect(Object.keys(worked.fields ?? {})).toEqual(['cs_health_score', 'cs_renewal_date', 'cs_owner']); + }); + + it('the key schema IS the one `ObjectSchema.fields` reads: both admit exactly the same grammar', () => { + // `constructor` / `prototype` are left out: `ObjectSchema.fields` refuses them by record refines, outside the key schema. + const keys = ['region', 'region_code', '_internal', 'region__c', 'a1', 'Region', 'Region Code', 'region-code', '2nd_phone', 'regionCode', '']; + 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 = extensionFieldsIssues({ [key]: { type: 'text', label: 'L' } }) + .some((i) => i.code === 'invalid_key' && i.path.join('.') === `fields.${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 26fb52b2c70..06e35bf52d0 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -2777,10 +2777,12 @@ 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. + * object — `ObjectSchema.fields`, `ObjectExtensionSchema.fieldPatches` + * (#29366) and `ObjectExtensionSchema.fields` (#29523) — 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 or as a field the target's own map would + * refuse. 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")', @@ -5035,11 +5037,33 @@ export const ObjectExtensionSchema = lazySchema(() => strictObject({ extend: z.string().describe('Target object name (FQN) to extend'), /** - * Fields to merge into the target object (additive). `__proto__` is refused - * on the raw input, as on `ObjectSchema.fields`: zod's record parser skips - * it, so the save door would store a key the registered extension lacks. + * Fields to merge into the target object (additive). Each record key is a + * field name, judged by the field map's own key grammar + * ({@link FieldNameKeySchema}, as `ObjectSchema.fields`): the fields land in + * the target's field map, so a key that map would refuse is refused here, + * where it is written. `__proto__` is refused on the raw input, as on + * `ObjectSchema.fields`: zod's record parser skips it, so the save door + * would store a key the registered extension lacks. */ - fields: refuseRecordProtoKey(z.record(z.string(), FieldSchema), 'fields').optional().describe('Fields to add/override'), + fields: refuseRecordProtoKey(z.record(FieldNameKeySchema, FieldSchema, { + // [#29523] Keyed by the field map's own key grammar: a key no field name + // can be (`Region Code`, `region-code`) is refused here, where it is + // written, instead of parsing and being merged into the target's map. + error: (issue) => { + if (issue.code === 'invalid_key') { + return `\`${String(issue.input)}\` cannot be a field name: an extension's \`fields\` is keyed by the ` + + 'name of each field it adds to the extended object, lowercase snake_case such as `region_code` ' + + '(`region_code__c` in a customization package), the key grammar of an object\'s own `fields`, ' + + 'never by its label.'; + } + return undefined; + }, + }), 'fields') + .optional() + .describe( + 'Fields to add/override, keyed by field name (lowercase snake_case, the key grammar of an object\'s ' + + '`fields`).', + ), /** * [ADR-0146 D10, second rule] Per-field display patch of the target's own diff --git a/packages/spec/src/migrations/entries/semantic/18.object-extension-field-key-grammar-refused.ts b/packages/spec/src/migrations/entries/semantic/18.object-extension-field-key-grammar-refused.ts new file mode 100644 index 00000000000..a4fac621126 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.object-extension-field-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'; + +// #29523: an object extension's `fields` is keyed by the key schema +// `ObjectSchema.fields` reads, as its `fieldPatches` is (#29366). A semantic +// entry and not a conversion: the key is the name the added field is stored +// and queried under, and a rewrite of the spelling (lower-casing, a space or +// `-` to `_`) names a different column than the one rows, views and formulas +// may already read, or lands on a field the target already declares, so the +// name is the author's to choose. +// +// A stored row in this shape is listed, not rewritten: the stored pass wraps an +// `object_extension` row as an `objectExtensions` entry (the stored-only map in +// `conversions/stored.ts`), and the `REFUSED_STORED_SHAPES` detector of this id +// (`conversions/refused-shapes.ts`) lists each key the extension's own parse +// refuses as a key, under its row, through `os migrate meta --stored`. +export const entry: SemanticMigration = { + id: 'object-extension-field-key-grammar-refused', + surface: + '`objectExtensions[].fields` keys (`defineObjectExtension({ fields })`) that are not a field name: any ' + + 'key outside the lowercase snake_case grammar of an object\'s `fields` keys, such as a label ' + + '(`Region Code`, `RegionCode`) or a kebab spelling (`region-code`).', + replacement: + 'A lowercase snake_case field name, the key grammar of the target object\'s own `fields`: ' + + '`fields: { region_code: { type: \'text\', label: \'Region Code\' } }` (`region_code__c` in a ' + + 'customization package), never `fields: { \'Region Code\': { … } }`.', + reason: + 'An extension\'s `fields` are merged into the target object\'s field map, and every key of that map is ' + + 'lowercase snake_case: `ObjectSchema.fields` refuses any other key. The extension schema used to take ' + + 'any string, so such a field parsed in `defineStack` and in `defineObjectExtension`, whether or not ' + + 'the target was in the same stack, and was merged into an object whose own declaration could never ' + + 'have held it. The key is now judged where it is written, by the same key schema the field map uses.', + acceptanceCriteria: + 'Every `fields` key of every object extension in the stack is lowercase snake_case, and `os validate` ' + + 'passes. A key outside the grammar is refused at parse with one `invalid_key` issue at `fields.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 `fieldPatches` included, and its next save is ' + + 'refused until the field is renamed. `os migrate meta --stored` lists such a row before the upgrade, ' + + 'one TODO for each refused key at `fields.KEY` naming the extended object, and leaves it as stored: ' + + 'rename each listed field to a snake_case name, along with every view, formula and caller that reads ' + + 'it, and save the extension.', +}; From b09c16404f548d47e835a96b2722ca928d412e38 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 02:14:29 +0000 Subject: [PATCH 2/5] feat(spec)!: changeset, and the measured door readings in the D3 entry Claude-Session: https://claude.ai/code/session_01WGufzf5kvTFdbWYDbe8bHE Co-Authored-By: Claude --- ...9523-object-extension-field-key-grammar.md | 24 +++++++++++++++++++ .../spec/src/conversions/refused-shapes.ts | 4 ++-- ...ect-extension-field-key-grammar-refused.ts | 12 ++++++---- 3 files changed, 33 insertions(+), 7 deletions(-) create mode 100644 .changeset/29523-object-extension-field-key-grammar.md diff --git a/.changeset/29523-object-extension-field-key-grammar.md b/.changeset/29523-object-extension-field-key-grammar.md new file mode 100644 index 00000000000..cc9fe537fc7 --- /dev/null +++ b/.changeset/29523-object-extension-field-key-grammar.md @@ -0,0 +1,24 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: an object extension's `fields` is keyed by the field map's own key grammar, so a field no object could declare is refused where it is written + +`ObjectExtensionSchema.fields` was keyed by any string. The fields an extension adds are merged into the target object's field map, and every key of that map is lowercase snake_case: `ObjectSchema.fields` refuses any other. So an extension could add a field under a key (`Region Code`, `region-code`) that the target's own declaration could never hold. + +Clause-②: no (narrowing) — `ObjectExtensionSchema` refuses a `fields` 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 add a field under anything but a snake_case field name) + +Skills-docs: updated here — `content/docs/data-modeling/object-extensions.mdx` ("Naming the fields you add") gains the key grammar. `skills/objectstack-data/SKILL.md` and its evals teach extension fields only as `region__c`, 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.** `fields: { 'Region Code': { type: 'text', label: 'Region' } }` parsed in `defineStack`, whether or not the target was in the same stack, and `os validate` raised nothing on it. In a `.custom` package, `fields: { 'Region Code__c': … }` was saved and published by the `object_extension` doors, served on every object read, re-registered at boot, and stored values in a column of that name. +- **Now.** One `invalid_key` issue at `fields.Region Code`, naming the grammar and an example. The key schema is the one `ObjectSchema.fields` and `fieldPatches` read, one constant, so the three cannot drift. +- **Unchanged.** A snake_case key parses as before, `__c` included (`region_code__c`); where the `__c` suffix is required or refused is still judged by the doors' `__c` rule. +- **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 that registers stored extensions counts it as an error and registers none of it, its `fieldPatches` included, and its next save is refused (422 `INVALID_METADATA`) until the field is renamed. `os migrate meta --stored` lists such a row beforehand, one TODO for each refused key at `fields.KEY`, and leaves it as stored for its owner to rename. + +**FROM → TO.** `fields: { 'Region Code': { type: 'text', label: 'Region Code' } }` → `fields: { region_code: { type: 'text', label: 'Region Code' } }` (`region_code__c` in a customization package): key each added field by a lowercase snake_case name, and keep the display text in its `label`. Renaming a field that already stores values also means renaming every view, formula and caller that reads it. The upgrade guide entry is `object-extension-field-key-grammar-refused`. diff --git a/packages/spec/src/conversions/refused-shapes.ts b/packages/spec/src/conversions/refused-shapes.ts index 4ba1a951dae..4209246b5ec 100644 --- a/packages/spec/src/conversions/refused-shapes.ts +++ b/packages/spec/src/conversions/refused-shapes.ts @@ -676,8 +676,8 @@ const objectExtensionFieldKeyGrammar: RefusedStoredShape = { + '(lower-casing it, turning `-` or a space into `_`) names a different column than the one rows, ' + 'views and formulas may already read, or lands on a field the object already declares, so the ' + 'name is the author\'s to choose. Left as stored, the row no longer parses: the sync that registers ' - + 'stored extensions names it (INVALID_METADATA) and registers none of it, its `fieldPatches` ' - + 'included, and its next save is refused until the field is renamed. ' + + 'stored extensions counts it as an error and registers none of it, its `fieldPatches` included, ' + + 'and its next save is refused with 422 INVALID_METADATA until the field is renamed. ' + message, }); } diff --git a/packages/spec/src/migrations/entries/semantic/18.object-extension-field-key-grammar-refused.ts b/packages/spec/src/migrations/entries/semantic/18.object-extension-field-key-grammar-refused.ts index a4fac621126..3167e3733e0 100644 --- a/packages/spec/src/migrations/entries/semantic/18.object-extension-field-key-grammar-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.object-extension-field-key-grammar-refused.ts @@ -29,15 +29,17 @@ export const entry: SemanticMigration = { 'An extension\'s `fields` are merged into the target object\'s field map, and every key of that map is ' + 'lowercase snake_case: `ObjectSchema.fields` refuses any other key. The extension schema used to take ' + 'any string, so such a field parsed in `defineStack` and in `defineObjectExtension`, whether or not ' - + 'the target was in the same stack, and was merged into an object whose own declaration could never ' - + 'have held it. The key is now judged where it is written, by the same key schema the field map uses.', + + 'the target was in the same stack, and `os validate` raised nothing on it. In a customization package, ' + + 'an `object_extension` row keyed `Region Code__c` was saved and published, served on every read of ' + + 'the target, and stored values under that name: a field the target\'s own declaration could never ' + + 'have held. The key is now judged where it is written, by the same key schema the field map uses.', acceptanceCriteria: 'Every `fields` key of every object extension in the stack is lowercase snake_case, and `os validate` ' + 'passes. A key outside the grammar is refused at parse with one `invalid_key` issue at `fields.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 `fieldPatches` included, and its next save is ' - + 'refused until the field is renamed. `os migrate meta --stored` lists such a row before the upgrade, ' + + 'a key no longer parses, so the sync that registers stored extensions counts the row as an error and ' + + 'registers none of it, its `fieldPatches` included, and its next save is refused (422 ' + + '`INVALID_METADATA`) until the field is renamed. `os migrate meta --stored` lists such a row before the upgrade, ' + 'one TODO for each refused key at `fields.KEY` naming the extended object, and leaves it as stored: ' + 'rename each listed field to a snake_case name, along with every view, formula and caller that reads ' + 'it, and save the extension.', From 61876fc4fe8d02a0e00cb8c197b3d46ef1f2c0dd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 02:17:50 +0000 Subject: [PATCH 3/5] docs(spec): regenerate the object reference for the extension fields describe Claude-Session: https://claude.ai/code/session_01WGufzf5kvTFdbWYDbe8bHE Co-Authored-By: Claude --- content/docs/references/data/object.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index ba9b37ba982..42f3c63fa0a 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -577,7 +577,7 @@ const result = ApiMethod.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **extend** | `string` | ✅ | Target object name (FQN) to extend | -| **fields** | `Record; description?: string; … }>` | optional | Fields to add/override | +| **fields** | `Record; description?: string; … }>` | optional | Fields to add/override, keyed by field name (lowercase snake_case, the key grammar of an object's `fields`). | | **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 | From bd50ef25315a838988c2086cd5f2d6733bbc7b08 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 03:02:49 +0000 Subject: [PATCH 4/5] docs(spec): regenerate the object reference from the merged tree Claude-Session: https://claude.ai/code/session_01WGufzf5kvTFdbWYDbe8bHE Co-Authored-By: Claude --- content/docs/references/data/object.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 42f3c63fa0a..b14bc6312ba 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -247,7 +247,7 @@ const result = ApiMethod.parse(data); | **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. Never a stored-metadata table (`sys_metadata`, `sys_metadata_history`): their rows are served only through the metadata API, so the engine never hydrates a reference into one, and `os validate`, `os build` and the object save door refuse it. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target, and writes the own name when it is left out. | | **referenceVia** | `string` | optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. `record_id` with `referenceVia: 'object_name'`. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with `reference` (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. | | **referenceDeletedWhen** | `{ field: string; value: string }` | optional | Marks the record a `referenceVia` pointer names as PLATFORM-DELETED on rows where a sibling `select` field holds one declared option — e.g. `record_id` with `{ field: 'cancel_reason', value: 'record_deleted' }`. A renderer then draws a tombstone from that option's own label, reads nothing and links nothing. Valid only beside `referenceVia`; `field` must be a single-value `select` on the same object declaring inline `options`, and `value` one of its option values — any other shape is refused at parse. | -| **referenceNameVisible** | `boolean` | optional | Declared name exposure (`lookup` and `user` fields only). TRUE declares that a principal who can read the record carrying this field may see the referenced record's display name (the target object's `nameField` value) and id even when that principal cannot read the referenced record. Served on `$expand`: a target the reader is refused (the object's read gate or its row scope) answers `{ id, name, readRefused: true }`, per element on a multi-value field; an unexpanded read answers the stored id, as for every reader. Nothing else of the target: `$expand` on this field returns the same name and id only, the field's `displayField` does not widen it, and the name renders as text with no link to the target. A field-level rule on the name field still holds: when the reader may not read that field, it is masked, or it is a formula reading through another reference, the name is the `Record #` floor with the id. A principal who can read the target is served exactly what it is served without the key. Absent or false keeps the restricted-reference posture for a target the reader cannot read. Declared on the referencing field only; there is no target-object switch. Refused on any other type. | +| **referenceNameVisible** | `boolean` | optional | Declared name exposure (`lookup` and `user` fields only). TRUE declares that a principal who can read the record carrying this field may see the referenced record's display name (the target object's `nameField`, else its `displayNameField`, else the derived title field) and id even when that principal cannot read the referenced record. Served on `$expand`: a target the reader is refused (the object's read gate or its row scope) answers `{ id, name, readRefused: true }` (`ReferenceNameOnlyValueSchema`), per element on a multi-value field; an unexpanded read answers the stored id, as for every reader. Nothing else of the target: `$expand` on this field returns the same name and id only, the field's `displayField` does not widen it, and the name renders as text with no link to the target. A field-level rule on the name field still holds, and the name field is read only where nothing the security layer owns must be computed: the name is the `Record #` floor with the id when the reader may not read that field, it declares a `maskingRule`, it is a formula reading through another reference, an explicit `nameField` (or its `displayNameField` alias) points at a column that is neither title-typed nor autonumber, or no field-level answer can be had for the reader. A target with no resolvable or no non-empty name reads as the same floor. A principal who can read the target is served exactly what it is served without the key. Absent or false keeps the restricted-reference posture for a target the reader cannot read. Declared on the referencing field only; there is no target-object switch. Refused on any other type. | | **mailAssociation** | `boolean` | optional | Inbound mail association (`email` fields only): TRUE marks this field as identifying a correspondent. When a mailbox service ingests a message, it associates the message with every record of this object whose value in this field equals one of the message's participant addresses (From, Sender, Reply-To, To and Cc, plus those of a forwarded message it carries; ASCII case-insensitive; the receiving mailbox's own addresses excluded; only within the mailbox's organization). A message no record matches waits in the mailbox's unassociated pool. Refused on any other type. Takes effect only where a mailbox service is composed. | | **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted | | **inlineEdit** | `boolean \| Enum<'grid' \| 'form'>` | optional | Edit these child records inline within the parent's form (atomic master-detail). true = auto-pick grid/form by child shape; 'grid' = editable line-item grid; 'form' = list + per-row full form. | @@ -618,7 +618,7 @@ const result = ApiMethod.parse(data); | **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. Never a stored-metadata table (`sys_metadata`, `sys_metadata_history`): their rows are served only through the metadata API, so the engine never hydrates a reference into one, and `os validate`, `os build` and the object save door refuse it. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target, and writes the own name when it is left out. | | **referenceVia** | `string` | optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. `record_id` with `referenceVia: 'object_name'`. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with `reference` (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. | | **referenceDeletedWhen** | `{ field: string; value: string }` | optional | Marks the record a `referenceVia` pointer names as PLATFORM-DELETED on rows where a sibling `select` field holds one declared option — e.g. `record_id` with `{ field: 'cancel_reason', value: 'record_deleted' }`. A renderer then draws a tombstone from that option's own label, reads nothing and links nothing. Valid only beside `referenceVia`; `field` must be a single-value `select` on the same object declaring inline `options`, and `value` one of its option values — any other shape is refused at parse. | -| **referenceNameVisible** | `boolean` | optional | Declared name exposure (`lookup` and `user` fields only). TRUE declares that a principal who can read the record carrying this field may see the referenced record's display name (the target object's `nameField` value) and id even when that principal cannot read the referenced record. Served on `$expand`: a target the reader is refused (the object's read gate or its row scope) answers `{ id, name, readRefused: true }`, per element on a multi-value field; an unexpanded read answers the stored id, as for every reader. Nothing else of the target: `$expand` on this field returns the same name and id only, the field's `displayField` does not widen it, and the name renders as text with no link to the target. A field-level rule on the name field still holds: when the reader may not read that field, it is masked, or it is a formula reading through another reference, the name is the `Record #` floor with the id. A principal who can read the target is served exactly what it is served without the key. Absent or false keeps the restricted-reference posture for a target the reader cannot read. Declared on the referencing field only; there is no target-object switch. Refused on any other type. | +| **referenceNameVisible** | `boolean` | optional | Declared name exposure (`lookup` and `user` fields only). TRUE declares that a principal who can read the record carrying this field may see the referenced record's display name (the target object's `nameField`, else its `displayNameField`, else the derived title field) and id even when that principal cannot read the referenced record. Served on `$expand`: a target the reader is refused (the object's read gate or its row scope) answers `{ id, name, readRefused: true }` (`ReferenceNameOnlyValueSchema`), per element on a multi-value field; an unexpanded read answers the stored id, as for every reader. Nothing else of the target: `$expand` on this field returns the same name and id only, the field's `displayField` does not widen it, and the name renders as text with no link to the target. A field-level rule on the name field still holds, and the name field is read only where nothing the security layer owns must be computed: the name is the `Record #` floor with the id when the reader may not read that field, it declares a `maskingRule`, it is a formula reading through another reference, an explicit `nameField` (or its `displayNameField` alias) points at a column that is neither title-typed nor autonumber, or no field-level answer can be had for the reader. A target with no resolvable or no non-empty name reads as the same floor. A principal who can read the target is served exactly what it is served without the key. Absent or false keeps the restricted-reference posture for a target the reader cannot read. Declared on the referencing field only; there is no target-object switch. Refused on any other type. | | **mailAssociation** | `boolean` | optional | Inbound mail association (`email` fields only): TRUE marks this field as identifying a correspondent. When a mailbox service ingests a message, it associates the message with every record of this object whose value in this field equals one of the message's participant addresses (From, Sender, Reply-To, To and Cc, plus those of a forwarded message it carries; ASCII case-insensitive; the receiving mailbox's own addresses excluded; only within the mailbox's organization). A message no record matches waits in the mailbox's unassociated pool. Refused on any other type. Takes effect only where a mailbox service is composed. | | **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted | | **inlineEdit** | `boolean \| Enum<'grid' \| 'form'>` | optional | Edit these child records inline within the parent's form (atomic master-detail). true = auto-pick grid/form by child shape; 'grid' = editable line-item grid; 'form' = list + per-row full form. | From 649d47eabbc85188b8c8f9576dc7758266ed402e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 04:27:55 +0000 Subject: [PATCH 5/5] docs(changeset): the marketplace install is not a door that parses an object extension Claude-Session: https://claude.ai/code/session_01WGufzf5kvTFdbWYDbe8bHE Co-Authored-By: Claude --- .changeset/29523-object-extension-field-key-grammar.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/29523-object-extension-field-key-grammar.md b/.changeset/29523-object-extension-field-key-grammar.md index cc9fe537fc7..4ef51be85a4 100644 --- a/.changeset/29523-object-extension-field-key-grammar.md +++ b/.changeset/29523-object-extension-field-key-grammar.md @@ -14,7 +14,7 @@ Skills-docs: updated here — `content/docs/data-modeling/object-extensions.mdx` -**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. +**BREAKING** accept-set narrowing on `ObjectExtensionSchema` (`@objectstack/spec`), at every door that parses an object extension: `defineObjectExtension`, `defineStack({ objectExtensions })`, `os validate` and `os build`, and the `/api/v1/meta` `object_extension` save door. The marketplace install does not parse an extension with the schema (it judges a built artifact's `__c` suffixes and patch targets), so a package built after this change carries the refusal from `os build`. - **Before.** `fields: { 'Region Code': { type: 'text', label: 'Region' } }` parsed in `defineStack`, whether or not the target was in the same stack, and `os validate` raised nothing on it. In a `.custom` package, `fields: { 'Region Code__c': … }` was saved and published by the `object_extension` doors, served on every object read, re-registered at boot, and stored values in a column of that name. - **Now.** One `invalid_key` issue at `fields.Region Code`, naming the grammar and an example. The key schema is the one `ObjectSchema.fields` and `fieldPatches` read, one constant, so the three cannot drift.