Skip to content

Commit 0e75081

Browse files
feat(spec)!: key an object extension's fieldPatches by the field map's own key grammar (#29513)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9T3v3ZgJMgqevubSmheyLPf)_ Fixes #29366 Clause-②: no (narrowing) Metadata-protocol: narrowed 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. ## 维护者速读 - 改了什么:对象扩展的 `fieldPatches` 的键改用与对象 `fields` 完全相同的键 schema(小写 snake_case),`Industry`、`Annual Revenue` 这类不可能是字段名的键在解析时就被拒,报一个带示例的 `invalid_key`。 - 为什么改:以前任何字符串都能过 schema,这种补丁在 `defineStack`、保存门、启动时都被接受,启动只在日志里点名、什么也不改;作者以为改了标签,界面却没变。 - 风险与回滚:收窄(BREAKING,spec minor,ADR-0087 语义迁移条目已登记)。仓内 examples/skills/docs/模板无一处写过这种键;已存储的带坏键的扩展行同步时会整行注册失败。回滚即 revert 本 PR。 - 先看:`packages/spec/src/data/object.zod.ts`(`FieldNameKeySchema` 与 `fieldPatches` 的 `invalid_key` 文案)、`packages/spec/src/data/field-customization.test.ts` 末尾新一节、`packages/spec/src/migrations/entries/semantic/18.object-extension-field-patch-key-grammar-refused.ts`。 - 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run src/data/field-customization.test.ts`,预期 1 个文件全绿(93 条)。 - 需要你做的:无,席位落地。 ## What changes - `packages/spec/src/data/object.zod.ts`: the key schema `ObjectSchema.fields` used inline (the snake_case regex and its message) is hoisted, unchanged, to one module constant `FieldNameKeySchema` (not exported), declared above `ObjectSchemaBase`. `ObjectSchema.fields` and `ObjectExtensionSchema.fieldPatches` both read it, so the grammar is written once and the two cannot drift. `fieldPatches`' record error map answers `invalid_key` with a prescription: the key is a field name the extended object declares, lowercase snake_case such as `industry` or `annual_revenue`, never a label. The issue sits at `fieldPatches.KEY`, as the option-key refusal sits at `fieldPatches.FIELD.options.KEY`. The `fieldPatches` describe and its doc comment name the grammar. - `packages/spec/src/migrations/entries/semantic/18.object-extension-field-patch-key-grammar-refused.ts`: the ADR-0087 semantic entry (refused, with a prescription; no conversion, because a respelling can land on another field or on none). - `.changeset/29366-field-patches-key-grammar.md`: spec `minor`, BREAKING, `adr-0087: registered object-extension-field-patch-key-grammar-refused`, with the FROM → TO line. - `content/docs/data-modeling/object-extensions.mdx`: one sentence in "A patch must name something real". - `content/docs/references/data/object.mdx`: regenerated (the describe moved). - No public export is added or removed (`check:api-surface` unchanged). No forced test edit. `ui/packages` has no zod twin of `fieldPatches` (`git grep` hits only the Studio controls that write keys read from the target's own field names). ## Why — the measurement that chose the declaration The target judge (`@objectstack/metadata-core` `fieldPatchTargetMisses`) does NOT reach every door, so a non-grammar key was accepted end to end before: - `defineStack` / `defineObjectExtension`: pure schema, no target judge; the key parsed. - `/api/v1/meta` `object_extension` save door: `OBJECT_EXTENSION_ITEM_SCHEMA` is `ObjectExtensionSchema` (`packages/metadata-protocol/src/protocol-object-extension-item.ts`); the save door's refusal (`objectExtensionItemRefusal`) runs the `__c` suffix judge, not the target judge, so the row was stored. - Boot / registry fold (`packages/objectql/src/registry.ts`, the `fieldPatchTargetMisses` call in the extension fold): never refuses; the patch is not applied and named once in the log. - `os validate`: refuses only when the target is in the composed stack (`packages/cli/src/utils/field-patch-targets.ts`); a target outside it is left to the install door. - Marketplace install door: refuses (the only door that always did). So this is `Clause-②: no (narrowing)` · `Metadata-protocol: narrowed`, BREAKING, with the semantic migration entry the delta prescribes for that branch. Authored forms: every `fieldPatches` key in `examples/**`, `skills/**`, `content/docs/**` (references excluded), `packages/**/templates`, dogfood and test fixtures is snake_case (`industry` ×20+, `name`, `sector`, `notes`, `region`, `state`, `anything`, `no_such_field`, plus the deliberate `region__c` and `__proto__` refusal fixtures), so nothing authored in this repository newly refuses. One consequence to read: a stored `object_extension` row that carries such a key no longer parses, so the sync that registers stored extensions (`registrableObjectExtension` → `INVALID_METADATA`) names the row and registers none of it, its `fields` included. This is the uniform effect of any `ObjectExtensionSchema` narrowing (PR #29331 had the same), stated in the changeset and the migration entry. **No stored-shape detector, measured (round 2).** The contract review asked for a `REFUSED_STORED_SHAPES` detector so `os migrate meta --stored` lists such a row before the upgrade refuses it. None can be reached: `applyConversionsToStoredItem` wraps a row in the stack collection its type maps to, and `object_extension` maps to none (it is in neither `SINGULAR_TO_PLURAL` nor the stored-only map in `conversions/stored.ts`), so the stored pass returns the row untouched. Probe on this branch: `applyConversionsToStoredItem('object_extension', { extend, fieldPatches: { Industry: … }, fields: { shift_at: { type: 'time', defaultValue: '12:00Z' } } }, { onTodo, onNotice })` returns the same reference with 0 TODOs and 0 notices, while the control `applyConversionsToStoredItem('object', …)` rewrites the time default (1 notice). A detector walking `objectExtensions` would therefore be dead code, so none is added; the semantic entry and the changeset say the stored pass does not list such a row, and name the check an operator runs instead. ## Tests `packages/spec/src/data/field-customization.test.ts` gains `fieldPatches keys — the field map's own key grammar` (8 cases): `Industry`, `Annual Revenue`, `annual-revenue`, `2nd_phone` each refused with exactly one `invalid_key` at `fieldPatches.KEY` (message names the grammar and an example, no tracker id); CONTROL a snake_case key parses and a `__c` key stays the `__c` rule's single `custom` issue; CONTROL the guide's authored example (`object-extensions.mdx`, "Relabelling a vendor field") parses whole; and an agreement test that `fieldPatches` refuses at the key exactly the keys `ObjectSchema.fields` refuses, over an 11-key sample that holds keys on both sides (guarded against vacuity). ## Verification Round 2 readings on head `fb1c5bc9` (objectstack-ai/objectstack): `origin/main` merged with `scripts/pm/os-regen-merge.sh` (merge base `399df5bb`), `content/docs/references/data/object.mdx` regenerated from the merged tree; the branch's delta against main is still the six files listed above. The ablation below ran in round 1 on `6a70094d`; the tested lines did not change since. <details><summary>Ablation: the new tests can fail</summary> `node scripts/ablation-replace.mjs` replaced `z.record(FieldNameKeySchema, FieldDisplayPatchSchema, {` with `z.record(z.string(), FieldDisplayPatchSchema, {` in `object.zod.ts` (anchor 1 → 0, blob f24cd61 → c7a8c925), ran `src/data/field-customization.test.ts`: 5 failed | 88 passed (93), the four refusal cases and the agreement test red, every control green. Restored: blob == HEAD, `git diff HEAD` empty. The spec suite imports `./object.zod` from source, so no build sits between the mutation and the run. </details> <details><summary>Consumer packages (downstream of the contract)</summary> - `@objectstack/spec` test: 792/792 files, 23529 passed | 1 todo. `typecheck`: exit 0. - `@objectstack/metadata-core` (the target judge): 23/23 files, 904 tests. - `@objectstack/objectql` (the fold that applies `fieldPatches`): 535/535 files, 10396 tests. - `@objectstack/metadata-protocol` (the `object_extension` save door): 307 passed | 4 skipped (311), 22229 passed | 26 skipped. - `@objectstack/cli` field-patch files (`src/utils/field-patch-targets.test.ts`, `test/validate-build-gate-parity.test.ts`, `test/validate-field-patch-targets.test.ts`): 3/3, 41 tests. - `@objectstack/cloud-connection` `marketplace-install-local-field-patch-targets.test.ts`: 1/1, 8 tests. - `@objectstack/rest` `meta-object-extension-property-classes.test.ts`: 1/1, 19 tests. - `ui` app-shell `DataPillar.baseObjectExtension-28575.test.tsx` + `StudioCustomizeControls.objectExtension-28575.test.ts`: 2/2, 19 tests. - Every file in the repo that names `fieldPatches` in a test outside `packages/spec` is in this list; the full suites ran for the three packages the delta names. </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on `fb1c5bc9`: 105 commands, all exit 0; `--ran` reconciliation: 105 derived, 105 run, 0 NOT-MEASURED. `check:type-check-debt` first returned exit 3 (its closure build queued behind the dogfood run on the verify lock, `queue-timeout`), and was re-run once the lock was free: exit 0. `check:generated`, `check:api-surface` (no export change), `check-adr-0087-registration` (registered, new here) and `check-changeset-no-major` exit 0. </details> <details><summary>Dogfood (narrowing arm, final head)</summary> `pnpm --filter @objectstack/dogfood test` under the lock on `fb1c5bc9`: 397 passed | 3 skipped (400) files; 3277 passed | 28 skipped (3305) tests; VERDICT command-exit 0. (Round 1 on `6a70094d` showed 4 reds in `flow-runas` and `declarative-endpoint-anonymous-guest` that also failed with the merge-base spec; main fixed them before this merge.) </details> ## Acceptance notes - Not fixed here, same family: `ObjectExtensionSchema.fields` is still keyed by a bare `z.string()`. Measured at the schema: `{ extend: 'crm_account', fields: { 'Region Code': { type: 'text', label: 'Region' } } }` PARSES, so an extension can add a field whose name the target's own `fields` would refuse. Reported to the seat for a card. - Not fixed here: `ObjectSchema.fields` refuses `constructor` / `prototype` by record refines, which are not part of the key schema; `fieldPatches: { constructor: … }` still parses at the schema (it is a valid snake_case word) and is refused by the target judge at install / `os validate` like any unknown field. Noted, not filed. - Not fixed here, found in round 2: no ADR-0087 conversion that walks `objectExtensions[]` replays over a stored `object_extension` row either, for the same reason (no stack collection for the type). Reported to the seat. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes (CI). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01ADHkmSCC3J91hA9anBJ81g --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmSCC3J91hA9anBJ81g)_ <!-- signed: domain:spec#2 · os-steve · session_01ADHkmSduNGC2SZXszyrNut --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e9fb529 commit 0e75081

6 files changed

Lines changed: 168 additions & 15 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
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
6+
7+
`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.
8+
9+
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.
10+
11+
Metadata-protocol: narrowed (an object extension may no longer key a field patch by anything but a field name)
12+
13+
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.
14+
15+
<!-- adr-0087: registered object-extension-field-patch-key-grammar-refused -->
16+
17+
**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.
18+
19+
- **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.
20+
- **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.
21+
- **Unchanged.** A snake_case key parses as before, and a `__c` key is still refused by the `__c` rule with its own prescription.
22+
- **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.
23+
24+
**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`.

‎content/docs/data-modeling/object-extensions.mdx‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,9 +142,11 @@ reason.
142142
can be (`Tech Sector`, a label) is refused too.
143143
- **Conflicts resolve like `label`.** Two extensions patching the same key: the higher
144144
`priority` wins. A tenant who has edited that text keeps their own, [below](#extensions-and-tenant-customization).
145-
- **A patch must name something real.** A field the target does not declare, or an option
146-
value its field does not have, is refused when the package installs and by
147-
`os validate` when the target is in the same stack. A field whose options come from a
145+
- **A patch must name something real.** A key that cannot be a field name (`Industry`,
146+
`Annual Revenue`, a label) is refused when it parses: `fieldPatches` is keyed by the
147+
field's name, lowercase snake_case like every key of `fields`. A field the target does
148+
not declare, or an option value its field does not have, is refused when the package
149+
installs and by `os validate` when the target is in the same stack. A field whose options come from a
148150
shared picklist has no option labels of its own to patch. At boot, an unmatched patch
149151
is not applied and is named once in the log; the boot carries on.
150152
- **Options are the owner's too.** A patch is judged against the options the owner

‎content/docs/references/data/object.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -578,7 +578,7 @@ const result = ApiMethod.parse(data);
578578
| :--- | :--- | :--- | :--- |
579579
| **extend** | `string` | ✅ | Target object name (FQN) to extend |
580580
| **fields** | `Record<string, { name?: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; description?: string; … }>` | optional | Fields to add/override |
581-
| **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' } } } }`. |
581+
| **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' } } } }`. |
582582
| **label** | `string` | optional | Override label for the extended object |
583583
| **pluralLabel** | `string` | optional | Override plural label for the extended object |
584584
| **description** | `string` | optional | Override description for the extended object |

‎packages/spec/src/data/field-customization.test.ts‎

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { describe, expect, it } from 'vitest';
1010
import type { z } from 'zod';
1111
import { BASE_FIELD_DISPLAY_WHITELIST, EXTENSION_FIELD_SUFFIX, isExtensionFieldName } from './field-customization';
1212
import { FieldSchema, SelectOptionSchema } from './field.zod';
13-
import { ObjectExtensionSchema } from './object.zod';
13+
import { ObjectExtensionSchema, ObjectSchema } from './object.zod';
1414

1515
/** The object shape behind a (possibly optional) schema. */
1616
function shapeOf(schema: unknown): Record<string, z.ZodTypeAny> {
@@ -349,3 +349,65 @@ describe('`fieldPatches` — what the slot refuses beyond its keys, and the rena
349349
}
350350
});
351351
});
352+
353+
/** Parse an extension whose `fieldPatches` is `fieldPatches`; return the issues. */
354+
function fieldPatchesIssues(fieldPatches: Record<string, unknown>): Issue[] {
355+
const r = ObjectExtensionSchema.safeParse({ extend: 'crm_account', fieldPatches });
356+
return r.success ? [] : (r.error.issues as unknown as Issue[]);
357+
}
358+
359+
/** Does `ObjectSchema.fields` admit `key` as a field name? Read off its own parse, so the grammar is not restated. */
360+
function objectFieldsAdmit(key: string): boolean {
361+
const r = ObjectSchema.safeParse({ name: 'crm_account', label: 'Account', fields: { [key]: { type: 'text', label: 'X' } } });
362+
const issues = r.success ? [] : (r.error.issues as unknown as Issue[]);
363+
return !issues.some((i) => i.code === 'invalid_key' && i.path.join('.') === `fields.${key}`);
364+
}
365+
366+
describe('`fieldPatches` keys — the field map\'s own key grammar', () => {
367+
it.each([['Industry'], ['Annual Revenue'], ['annual-revenue'], ['2nd_phone']])(
368+
'refuses `%s`, which no field name can be: one `invalid_key` at its key, naming the grammar and an example',
369+
(key) => {
370+
const issues = fieldPatchesIssues({ [key]: { label: 'Sector' } });
371+
expect(issues).toHaveLength(1);
372+
expect(issues[0]!.code).toBe('invalid_key');
373+
expect(issues[0]!.path).toEqual(['fieldPatches', key]);
374+
expect(issues[0]!.message).toMatch(new RegExp(`^\`${key}\` cannot be a field name`));
375+
expect(issues[0]!.message).toContain('lowercase snake_case such as `industry`');
376+
expect(issues[0]!.message).not.toMatch(/#\d{3,}/);
377+
},
378+
);
379+
380+
it('control: a snake_case key parses, and so does a `__c` key, which stays the `__c` rule\'s to refuse', () => {
381+
expect(fieldPatchesIssues({ annual_revenue: { label: 'Revenue' } })).toEqual([]);
382+
const issues = fieldPatchesIssues({ region__c: { label: 'Region' } });
383+
expect(issues.map((i) => [i.code, i.path.join('.')])).toEqual([['custom', 'fieldPatches.region__c']]);
384+
});
385+
386+
it('control: the authored example of the object-extension guide still parses whole', () => {
387+
// `content/docs/data-modeling/object-extensions.mdx`, "Relabelling a vendor field".
388+
const parsed = ObjectExtensionSchema.parse({
389+
extend: 'crm_account',
390+
fieldPatches: {
391+
industry: {
392+
label: 'Sector',
393+
inlineHelpText: 'The market the account sells into.',
394+
options: { technology: { label: 'Tech' } },
395+
},
396+
},
397+
});
398+
expect(parsed.fieldPatches?.industry?.label).toBe('Sector');
399+
});
400+
401+
it('the key schema IS the one `ObjectSchema.fields` reads: both admit exactly the same keys', () => {
402+
const keys = ['industry', 'annual_revenue', '_internal', 'region__c', 'a1', 'Industry', 'Annual Revenue', 'annual-revenue', '2nd_phone', 'industryName', ''];
403+
const admitted = keys.filter((key) => objectFieldsAdmit(key));
404+
// Guard against a vacuous agreement: the sample holds keys on both sides of the grammar.
405+
expect(admitted.length).toBeGreaterThan(0);
406+
expect(admitted.length).toBeLessThan(keys.length);
407+
for (const key of keys) {
408+
const refusedAtKey = fieldPatchesIssues({ [key]: { label: 'L' } })
409+
.some((i) => i.code === 'invalid_key' && i.path.join('.') === `fieldPatches.${key}`);
410+
expect(refusedAtKey, key).toBe(!admitted.includes(key));
411+
}
412+
});
413+
});

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

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2774,6 +2774,18 @@ function refuseDuplicateActionNames(objectName: unknown, actions: unknown, ctx:
27742774
}
27752775
}
27762776

2777+
/**
2778+
* The key grammar of an object's field map: a field name is lowercase
2779+
* snake_case. ONE schema, read by every record keyed by a field name of an
2780+
* object — `ObjectSchema.fields` and `ObjectExtensionSchema.fieldPatches`
2781+
* (#29366) — so a key the field map can never hold is refused where it is
2782+
* written, never accepted as a patch that no field can match. Declared above
2783+
* `ObjectSchemaBase` for the order reason the block below gives.
2784+
*/
2785+
const FieldNameKeySchema = z.string().regex(/^[a-z_][a-z0-9_]*$/, {
2786+
message: 'Field names must be lowercase snake_case (e.g., "first_name", "company", "annual_revenue")',
2787+
});
2788+
27772789
// ⚠️ ORDER IS LOAD-BEARING (#5593). This map used to live ~700 lines BELOW
27782790
// `ObjectSchemaBase`, and the error map that reads it was built lazily
27792791
// (`objectUnknownKeyErrorImpl ??= …`) purely to step around the temporal dead
@@ -3195,13 +3207,7 @@ const ObjectSchemaBase = strictObject(
31953207
message: 'Field names must not be "constructor" or "prototype" (reserved JavaScript prototype property names).',
31963208
path: [reserved],
31973209
}),
3198-
z.record(
3199-
z.string()
3200-
.regex(/^[a-z_][a-z0-9_]*$/, {
3201-
message: 'Field names must be lowercase snake_case (e.g., "first_name", "company", "annual_revenue")',
3202-
}),
3203-
FieldSchema,
3204-
),
3210+
z.record(FieldNameKeySchema, FieldSchema),
32053211
),
32063212
'fields',
32073213
).describe('Field definitions map. Keys must be snake_case identifiers; "__proto__", "constructor" and "prototype" are refused.'),
@@ -4939,7 +4945,8 @@ function refuseFieldPatchOfReplacedField(fields: unknown, fieldPatches: unknown,
49394945
/** [ADR-0146 D10] The `fieldPatches` description, its member list read from {@link BASE_FIELD_DISPLAY_WHITELIST}. */
49404946
function fieldPatchesDescription(): string {
49414947
return (
4942-
'Display patches of the target object\'s own fields, keyed by field name: change a vendor field\'s ' +
4948+
'Display patches of the target object\'s own fields, keyed by field name (lowercase snake_case, the ' +
4949+
'key grammar of an object\'s `fields`): change a vendor field\'s ' +
49434950
`display properties (BASE_FIELD_DISPLAY_WHITELIST: ${backtickList(BASE_FIELD_DISPLAY_WHITELIST.field)} ` +
49444951
`on the field, ${backtickList(BASE_FIELD_DISPLAY_WHITELIST.option)} on an option under \`options\`, ` +
49454952
'keyed by its stored value) without taking the field over, so its type, values and rules stay the ' +
@@ -5036,7 +5043,9 @@ export const ObjectExtensionSchema = lazySchema(() => strictObject({
50365043

50375044
/**
50385045
* [ADR-0146 D10, second rule] Per-field display patch of the target's own
5039-
* fields. Keys are DERIVED from `BASE_FIELD_DISPLAY_WHITELIST`; structural
5046+
* fields. Each record key is a field name, judged by the field map's own
5047+
* key grammar ({@link FieldNameKeySchema}, as `ObjectSchema.fields`). A
5048+
* patch's keys are DERIVED from `BASE_FIELD_DISPLAY_WHITELIST`; structural
50405049
* keys are refused by name with the D6 prescription; an `__c` key, and a
50415050
* field the same extension replaces in `fields`, are refused by the root
50425051
* refinement below; `__proto__` is refused on the raw input. Pure shape: whether the target has the field
@@ -5045,7 +5054,19 @@ export const ObjectExtensionSchema = lazySchema(() => strictObject({
50455054
* `os validate`, named in the boot log — and the merge that applies the patch
50465055
* is `@objectstack/objectql`'s `mergeObjectDefinitions`.
50475056
*/
5048-
fieldPatches: refuseRecordProtoKey(z.record(z.string(), FieldDisplayPatchSchema), 'fieldPatches')
5057+
fieldPatches: refuseRecordProtoKey(z.record(FieldNameKeySchema, FieldDisplayPatchSchema, {
5058+
// [#29366] Keyed by the field map's own key grammar: a key no field name
5059+
// can be (`Industry`, `Annual Revenue`) is refused here, where it is
5060+
// written, instead of parsing and reaching only the target judge.
5061+
error: (issue) => {
5062+
if (issue.code === 'invalid_key') {
5063+
return `\`${String(issue.input)}\` cannot be a field name, so no field sits under it: \`fieldPatches\` is ` +
5064+
'keyed by the name of a field the extended object declares, lowercase snake_case such as ' +
5065+
'`industry` or `annual_revenue` (the key of its `fields` entry), never by its label.';
5066+
}
5067+
return undefined;
5068+
},
5069+
}), 'fieldPatches')
50495070
.optional()
50505071
.describe(fieldPatchesDescription()),
50515072

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
// #29366: an object extension's `fieldPatches` is keyed by the key schema
6+
// `ObjectSchema.fields` reads. A semantic entry and not a conversion: the key
7+
// that was meant is the name of a field the target declares, and a rewrite of
8+
// the spelling (lower-casing, `-` to `_`) can land on a different field or on
9+
// none, so which field is the author's to name.
10+
//
11+
// No `REFUSED_STORED_SHAPES` detector lists a stored row in this shape, because
12+
// none could be reached: `applyConversionsToStoredItem` maps a row type onto a
13+
// stack collection, and `object_extension` has none (it is in neither
14+
// `SINGULAR_TO_PLURAL` nor the stored-only map), so the stored pass hands the
15+
// row back untouched and runs no conversion or detector over it. The criteria
16+
// below say so, and name the query that finds such a row instead.
17+
export const entry: SemanticMigration = {
18+
id: 'object-extension-field-patch-key-grammar-refused',
19+
surface:
20+
'`objectExtensions[].fieldPatches` keys (`defineObjectExtension({ fieldPatches })`) that are not a '
21+
+ 'field name: any key outside the lowercase snake_case grammar of an object\'s `fields` keys, such as '
22+
+ 'a label (`Industry`, `Annual Revenue`) or a kebab spelling (`annual-revenue`).',
23+
replacement:
24+
'The name of the field the extended object declares, the key of its `fields` entry: '
25+
+ '`fieldPatches: { industry: { label: \'Sector\' } }`, never `fieldPatches: { Industry: { … } }`.',
26+
reason:
27+
'A field patch addresses one of the target\'s own fields by name, and every field name is lowercase '
28+
+ 'snake_case, so a key outside that grammar can never match a field. The schema used to take any '
29+
+ 'string, so such a patch parsed in `defineStack`, was stored by the `object_extension` save door, '
30+
+ 'and booted: the registry fold only named it in the log and applied nothing, and `os validate` '
31+
+ 'refused it only when the target object was part of the composed stack. The author read "Sector" '
32+
+ 'in their package while the screen kept the vendor\'s label. The key is now judged where it is '
33+
+ 'written, by the same key schema the field map uses.',
34+
acceptanceCriteria:
35+
'Every `fieldPatches` key in the stack is the name of a field its `extend` target declares, and '
36+
+ '`os validate` passes. A key outside the grammar is refused at parse with one `invalid_key` issue '
37+
+ 'at `fieldPatches.KEY` that names the grammar and an example. A stored `object_extension` row whose '
38+
+ 'body still carries such a key no longer parses, so the sync that registers stored extensions names '
39+
+ 'the row (`INVALID_METADATA`) and registers none of it, its `fields` included, and its next save is '
40+
+ 'refused until the key is renamed to the field it meant. `os migrate meta --stored` does not list such '
41+
+ 'a row: the stored pass maps no `object_extension` row onto a stack collection, so it reads the row as '
42+
+ 'already on protocol. Find one before upgrading by reading each stored `object_extension` row\'s '
43+
+ '`fieldPatches` keys against the snake_case grammar of `fields` keys.',
44+
};

0 commit comments

Comments
 (0)