Skip to content
24 changes: 24 additions & 0 deletions .changeset/29523-object-extension-field-key-grammar.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- adr-0087: registered object-extension-field-key-grammar-refused -->

**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.
- **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`.
5 changes: 5 additions & 0 deletions content/docs/data-modeling/object-extensions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.<key>`.

Field names in an extension are **not** namespace-prefixed by the platform. The
namespace rule (`<namespace>_<name>`) governs the names of objects a package *defines*;
it does not walk `objectExtensions`, and it could not — the target's name belongs to
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/data/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -577,7 +577,7 @@ const result = ApiMethod.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **extend** | `string` | ✅ | Target object name (FQN) to extend |
| **fields** | `Record<string, { name?: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; description?: string; … }>` | optional | Fields to add/override |
| **fields** | `Record<string, { name?: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; description?: string; … }>` | optional | Fields to add/override, keyed by field name (lowercase snake_case, the key grammar of an object's `fields`). |
| **fieldPatches** | `Record<string, { label?: string; description?: string; inlineHelpText?: string; placeholder?: string; … }>` | 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 |
Expand Down
59 changes: 59 additions & 0 deletions packages/spec/src/conversions/refused-shapes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ describe('the refused-shape list — registered against the migration chain', ()
'ui-dashboard-global-filter-duplicate-name-refused',
'ui-dashboard-global-filter-reserved-date-range-refused',
'object-extension-field-patch-key-grammar-refused',
'object-extension-field-key-grammar-refused',
]);
});

Expand Down Expand Up @@ -993,3 +994,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<string, unknown>) => ({ 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'],
]);
});
});
63 changes: 58 additions & 5 deletions packages/spec/src/conversions/refused-shapes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@
* judge of the object refinement ({@link duplicateActionNames}), and the
* one-key-per-global-filter judge of the dashboard refinement
* ({@link duplicateGlobalFilterNames}), and its reserved-`dateRange` judge
* ({@link reservedDateRangeGlobalFilters}), and the `fieldPatches` key
* grammar of `ObjectExtensionSchema`'s own parse
* ({@link refusedFieldPatchKeys}). A site is listed here exactly
* ({@link reservedDateRangeGlobalFilters}), 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
Expand Down Expand Up @@ -628,12 +628,25 @@ const dashboardReservedDateRangeGlobalFilter: 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 });
}
Expand Down Expand Up @@ -665,6 +678,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 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,
});
}
return extension;
});
},
};

/**
* Every refused stored shape, in report order. A new entry is a reviewed edit:
* `refused-shapes.test.ts` pins each one.
Expand All @@ -680,6 +732,7 @@ export const REFUSED_STORED_SHAPES: readonly RefusedStoredShape[] = [
dashboardDuplicateGlobalFilterName,
dashboardReservedDateRangeGlobalFilter,
objectExtensionFieldPatchKeyGrammar,
objectExtensionFieldKeyGrammar,
];

/**
Expand Down
64 changes: 64 additions & 0 deletions packages/spec/src/data/field-customization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>): 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));
}
});
});
Loading
Loading