Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/29366-field-patches-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 `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.

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

**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`.
8 changes: 5 additions & 3 deletions content/docs/data-modeling/object-extensions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 @@ -578,7 +578,7 @@ const result = ApiMethod.parse(data);
| :--- | :--- | :--- | :--- |
| **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 |
| **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: 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<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 |
| **description** | `string` | optional | Override description for the extended object |
Expand Down
64 changes: 63 additions & 1 deletion packages/spec/src/data/field-customization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, z.ZodTypeAny> {
Expand Down Expand Up @@ -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<string, unknown>): 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));
}
});
});
41 changes: 31 additions & 10 deletions packages/spec/src/data/object.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.'),
Expand Down Expand Up @@ -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 ' +
Expand Down Expand Up @@ -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
Expand All @@ -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()),

Expand Down
Original file line number Diff line number Diff line change
@@ -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.',
};
Loading