Skip to content

Commit 5114b0e

Browse files
feat(spec): a per-field display patch slot on the object extension (fieldPatches), keys derived from BASE_FIELD_DISPLAY_WHITELIST (#28650)
🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V Fixes #28560 Clause-②: yes (widening) Metadata-protocol: added ## 维护者速读 - **改了什么**:对象扩展(`objectExtensions[]`)新增一个可选槽 `fieldPatches`,按字段名给厂商字段打"显示补丁":只能改 `label`、`description`、`inlineHelpText`、`placeholder` 和选项的 `label`;键集合直接从 `BASE_FIELD_DISPLAY_WHITELIST` 读出,白名单放宽槽就跟着放宽。 - **为什么改**:客户想把 HotCRM 的 "Industry" 改叫 "Sector"、补一句更好的帮助文字,却不想接管整个字段;字段的类型、取值和规则仍归厂商,所以 HotCRM 下个版本照样能升级进来。今天扩展只能新增字段或整字段替换。 - **风险与回滚**:纯新增的可选键,不写它的扩展解析结果逐字节不变;槽带 `[EXPERIMENTAL — not enforced]`,引擎注册扩展时还不搬运它,N-23d(#28565)接上合并之前没有任何运行时读它。回滚 = revert 本 PR。 - **先看**:① `packages/spec/src/data/object.zod.ts` 的 `FieldDisplayPatchSchema` / `FieldOptionDisplayPatchSchema`(键从白名单派生、结构键按名拒收);② 同文件 `ObjectExtensionSchema` 根 `superRefine` 里的 `refuseExtensionFieldPatchKeys`(`__c` 拒收);③ `packages/spec/src/data/field-patches-derived.test.ts`(放宽白名单即放宽槽的证明)。 - **两分钟验证**:`pnpm --filter @objectstack/spec exec vitest run src/data/field-customization.test.ts src/data/field-patches-derived.test.ts`,预期 2 个文件、71 个测试全绿。 - **需要你做的**:无,席位落地。 ## What changes ADR-0146 D10, second rule: a base field changes only within the display whitelist, and the patch is a per-field map in the extension body. `ObjectExtensionSchema` gains one optional key, `fieldPatches`: ```ts objectExtensions: [{ extend: 'crm_account', fieldPatches: { industry: { label: 'Sector', inlineHelpText: 'The market the account sells into', options: { tech: { label: 'Technology' } }, }, }, }] ``` - **Shape.** `Record(fieldName, closed patch)`. The patch's keys are `BASE_FIELD_DISPLAY_WHITELIST.field`, each value schema taken by key from `FieldSchema` and made optional, plus `options`: `Record(storedValue, closed option patch)` whose keys are `BASE_FIELD_DISPLAY_WHITELIST.option`, value schemas taken from `SelectOptionSchema`. Nothing restates the list. Construction throws if the whitelist names a key the source shape no longer declares. - **Refusals at parse, each with a prescription.** - Every other `FieldSchema` key on a patch is refused by name (`unrecognized_keys`) through a `guidanceSets` entry whose member set is derived (`FieldSchema` keys minus the whitelist minus `options`). The prescription names the whitelist, lists its members read from the constant, and states that everything else stays the vendor's (ADR-0146 D6). - On an option patch, `value` has its own guidance: the option is addressed by its stored value, and an extension cannot rename it. Every other option key (`color`, `default`, …) gets the D6 prescription, from a derived set. - A field's option list (the `FieldSchema` array form) under `options` is refused as membership, with a prescription showing the map form. - A `fieldPatches` key spelled as an extension field (`isExtensionFieldName`, e.g. `region__c`) is refused at `fieldPatches.region__c` with "declare it in `fields`; it is your own field". This check is in the extension's existing root `superRefine`. The published JSON Schema already lists that root as a dropped-refinement site (`data/ObjectExtension` `''`), so the `dropped-refinements` ledger does not change. - **Pure shape.** Whether the target has the field or the option, and who may patch, belong to the server (#28565). - **Not enforced yet.** The slot's `.describe()` reads `[EXPERIMENTAL — not enforced]` and names rollout card N-23d. `objectql/src/engine.ts`'s extension registration (now at `:8857-8869`; the card measured `:8820-8829` on an older tree) copies the declared keys by name, so the slot is dropped there until N-23d. The engine is not changed here. The guidance comment on `ObjectExtensionSchema` that said "copies EXACTLY the declared keys" now names `fieldPatches` as the one declared key not carried yet. - `field-customization.ts`'s header now points at the slot instead of saying the shape is "not declared here". ### The settled name: `fieldPatches` No existing spelling of this slot exists. A repo-wide search for `fieldPatches`, `fieldOverrides`, `fieldOverride` and `field_patches` finds only unrelated hits: a permission-matrix UI stat and a lint test helper. The name follows ADR-0146's own words: D10 says the extension may **patch** a base field's display properties and calls the result "a per-field map in the extension body". The Salesforce-style "override" is not used because in ADR-0146 an override replaces the whole item (D8/D9's declared override, ADR-0131 D6's O regime), and `fields` on the same extension already does "add/override", which replaces a same-named field whole (`mergeObjectDefinitions`). A patch keeps the vendor's field and changes named display properties of it, so "patch" is the accurate word, and `fieldPatches` (plural, keyed by field) matches the sibling `fields` map. ### Measured assumptions (PM) - **`check:liveness` does not walk `ObjectExtensionSchema`.** Two legs on this branch: with the marker removed from the `fieldPatches` describe (an ablation, restored and checked byte-identical to `HEAD`), `check-liveness.mts --json` exits 0 with `unclassified: []`. The positive control, a marker-less key added to `ObjectSchema`, exits 1 with `unclassified: ['object/probeKeyNoLedger']`. The walk is rooted on the metadata-type registry plus `SPEC_ONLY_SCHEMAS`. `ManifestSchema` has no `objectExtensions`, and the stack schema is not a governed root. So no ledger row is added. - **The metadata-protocol `/meta/types` count pins are not reached.** They count top-level keys of registered metadata types (`object` is `ObjectSchema`). An extension is a stack collection entry, not a metadata type. `@objectstack/metadata-protocol`'s whole suite is green on this head (291 files passed, 4 skipped). - **`object-strictness-batch20.test.ts` and the extension fixtures parse unchanged.** Batch 20 is green, as is the frozen `packages/qa/downstream-contract` `DcObjectExtension` (optional key; nothing narrowed). A test pins that an extension without the slot parses to the same output as before. - **No `ui/packages/types` mirror** of `ObjectExtension` exists (`grep` finds none), so there is nothing to sync. ## Tests `packages/spec/src/data/field-customization.test.ts` gains a `fieldPatches` block next to `STRUCTURAL_FIELD_KEYS`: - every whitelist member is accepted, field and option, iterated from the constant; - every `STRUCTURAL_FIELD_KEYS` key is refused by name (`code` `unrecognized_keys`, `path` `fieldPatches.industry`, `keys`, and the message names `BASE_FIELD_DISPLAY_WHITELIST` and ADR-0146 D6); `options` membership (the list form) is refused as `invalid_type` at `fieldPatches.industry.options`; - every `FieldSchema` key outside the whitelist is refused, and the refused set is derived from the shape; - an option patch takes `label` only: `value` is refused with its own guidance, every other option key with D6; - an `__c` key is refused (`custom` at `fieldPatches.region__c`, naming `fields`); - pure shape: an unknown field or option still parses; - the slot is optional, and output is unchanged without it; - the describe starts with `[EXPERIMENTAL — not enforced]` and names N-23d. `packages/spec/src/data/field-patches-derived.test.ts` widens the constant itself (a file-scoped `vi.mock` adding `rows` and an option's `color`) and parses through the real `ObjectExtensionSchema`. The widened keys are accepted, a widened key keeps its `FieldSchema` value type, and the refusal prose lists the widened members. **Ablation** (one-time, not a permanent test). With the fix committed, `scripts/ablation-replace.mjs` replaced the derived field keys with the restated list `['label', 'description', 'inlineHelpText', 'placeholder']`. Two tests in `field-patches-derived.test.ts` went red (2 failed, 69 passed). The restore was checked against `HEAD`: blob `b44d0197cc1d`, `git diff HEAD` empty. ## Acceptance notes - **Describe spelling vs the order.** The order asked for the describe to name "N-23d (#28565)". The describe names N-23d and ADR-0146 D10 but not the tracker id, because `check:doc-authoring` rule 3 bans `#NNNN` in `.describe()` prose: it projects into `content/docs/references/**`, where a reader cannot resolve it. The id `#28565` is in the code comments beside the slot. - **No liveness row.** Nothing walks the slot, as measured above. If a ledger ever walks it, the describe marker classifies it as `experimental` (the marker grammar has no `planned` spelling that also says "not enforced"). N-23d flips the marker. - **Out of scope, noted, not filed.** `ObjectExtensionSchema.fields` (and now `fieldPatches`) is a bare `z.record` without the `refuseRecordProtoKey` guard that `ObjectSchema.fields` carries. A `__proto__` own key (as `JSON.parse` produces) is dropped silently from the parse output. The new slot keeps parity with its sibling and does not take that on. ## Verification All readings are on head `09f996f719` (after merging `origin/main` once). <details><summary>Local runs</summary> - `pnpm --filter @objectstack/spec build`: exit 0. - `pnpm --filter @objectstack/spec check:generated -- --fix`: regenerated `content/docs/references/**` (`gen:docs`) and the strictness-ledger counts (`gen:strictness-ledger`). The build wrote `authorable-surface/data.json` (`data/ObjectExtension:fieldPatches`). The re-check was green, and `check:generated` was green on the final head. - `pnpm --filter @objectstack/spec test`: 783 files passed, 23173 tests passed, 1 todo. Exit 0. - `pnpm --filter @objectstack/spec typecheck` (includes `check:scripts-typecheck` and `check:test-typecheck`): exit 0. `check:test-typecheck` run alone: exit 0. - `pnpm --filter @objectstack/metadata-protocol test`: 291 files passed, 4 skipped. 21912 tests passed, 26 skipped. Exit 0. </details> <details><summary>Derived gates (dispatch-gates --commands, reconciled with --ran)</summary> - 103 commands were derived. 102 ran green (exit 0). - `check:skill-examples` and `check:lean-entry-closure` first exited 3 (prerequisite: no `client`/`client-react`/`objectql` dist). They were re-run green after building those closures. - `check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET: about 75 packages without `dist/`). That is NOT MEASURED locally, a declared narrowing, and CI's `Lint & Repo Gates` runs it. - `--ran` reconciliation: "103 derived famil(ies) accounted for — 102 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)". Exit 0. </details> Independent review: none run (quota); the PR waits for the seat's contract-review hand-off. --- _Generated by [Claude Code](https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9da2f4b commit 5114b0e

9 files changed

Lines changed: 434 additions & 13 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): an object extension can carry a per-field display patch (`fieldPatches`), its keys derived from `BASE_FIELD_DISPLAY_WHITELIST`; not applied yet
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: added
9+
10+
ADR-0146 D10's second rule: a customer changes a vendor field's display properties without taking the field over, so the field's type, values and rules stay the vendor's and the vendor's next version still applies. `ObjectExtensionSchema` gains one optional key for it:
11+
12+
```ts
13+
objectExtensions: [{
14+
extend: 'crm_account',
15+
fieldPatches: {
16+
industry: {
17+
label: 'Sector',
18+
inlineHelpText: 'The market the account sells into',
19+
options: { tech: { label: 'Technology' } },
20+
},
21+
},
22+
}]
23+
```
24+
25+
- **Keys.** A patch takes the field keys of `BASE_FIELD_DISPLAY_WHITELIST.field` (`label`, `description`, `inlineHelpText`, `placeholder`) plus `options`, a map from an option's stored `value` to that option's patch, which takes the keys of `BASE_FIELD_DISPLAY_WHITELIST.option` (`label`). Both sets are read from the constant: widening the whitelist widens the slot.
26+
- **Refused at parse, each with a prescription.** Every other field key (`type`, `required`, `unique`, `reference`, `defaultValue`, `expression`, …) and every other option key (`color`, `default`, …) is refused by name, naming the whitelist and stating that it stays the vendor's (ADR-0146 D6). An option's `value` is refused: the option is addressed by it, and it cannot be renamed. A field's option list under `options` is refused as membership. A key spelled as an extension field (`region__c`) is refused with "declare it in `fields`; it is your own field".
27+
- **Pure shape.** Whether the target object has the field or the option, and who may patch it, are the server's to judge.
28+
- **Not enforced yet.** The slot's description reads `[EXPERIMENTAL — not enforced]`. The engine's extension registration does not carry the key, so a patch is parsed and then dropped until the registry merge applies it (ADR-0146 rollout card N-23d). Nothing reads it before then.
29+
30+
An extension without `fieldPatches` parses exactly as before.

‎content/docs/references/api/package-api-assembled.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ Installed package row whose manifest is the assembled package body
112112
| **functions** | `Record<string, string \| { handler?: string; effect?: Enum<'pure' \| 'writes'> }> \| { name: string; handler?: string; packageId?: string; effect?: Enum<'pure' \| 'writes'> }[]` | optional | Named handler functions, lowered to the refs a JSON document carries |
113113
| **datasourceMapping** | `{ namespace?: string; package?: string; objectPattern?: string; default?: boolean; … }[]` | optional | Centralized datasource routing rules for packages/namespaces/objects |
114114
| **translations** | `Record<string, { objects?: Record<string, object>; picklists?: Record<string, object>; apps?: Record<string, object>; messages?: Record<string, string>; … }>[]` | optional | I18n Translation Bundles |
115-
| **objectExtensions** | `{ extend: string; fields?: Record<string, object>; label?: string; pluralLabel?: string; … }[]` | optional | Extensions to objects owned by other packages |
115+
| **objectExtensions** | `{ extend: string; fields?: Record<string, object>; fieldPatches?: Record<string, object>; label?: string; … }[]` | optional | Extensions to objects owned by other packages |
116116
| **picklists** | `{ name: string; label: string; description?: string; options: object[]; … }[]` | optional | Shared option lists that select fields reference by name |
117117
| **picklistExtensions** | `{ extend: string; options: object[] }[]` | optional | Options added to picklists owned by other packages (additive only) |
118118
| **apps** | `{ name: string; label: string \| Record<string, string>; description?: string \| Record<string, string>; icon?: string; … }[]` | optional | Applications |
@@ -362,7 +362,7 @@ This schema accepts one of the following structures:
362362
| **functions** | `Record<string, string \| { handler?: string; effect?: Enum<'pure' \| 'writes'> }> \| { name: string; handler?: string; packageId?: string; effect?: Enum<'pure' \| 'writes'> }[]` | optional | Named handler functions, lowered to the refs a JSON document carries |
363363
| **datasourceMapping** | `{ namespace?: string; package?: string; objectPattern?: string; default?: boolean; … }[]` | optional | Centralized datasource routing rules for packages/namespaces/objects |
364364
| **translations** | `Record<string, { objects?: Record<string, object>; picklists?: Record<string, object>; apps?: Record<string, object>; messages?: Record<string, string>; … }>[]` | optional | I18n Translation Bundles |
365-
| **objectExtensions** | `{ extend: string; fields?: Record<string, object>; label?: string; pluralLabel?: string; … }[]` | optional | Extensions to objects owned by other packages |
365+
| **objectExtensions** | `{ extend: string; fields?: Record<string, object>; fieldPatches?: Record<string, object>; label?: string; … }[]` | optional | Extensions to objects owned by other packages |
366366
| **picklists** | `{ name: string; label: string; description?: string; options: object[]; … }[]` | optional | Shared option lists that select fields reference by name |
367367
| **picklistExtensions** | `{ extend: string; options: object[] }[]` | optional | Options added to picklists owned by other packages (additive only) |
368368
| **apps** | `{ name: string; label: string \| Record<string, string>; description?: string \| Record<string, string>; icon?: string; … }[]` | optional | Applications |

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,7 @@ const result = ApiMethod.parse(data);
576576
| :--- | :--- | :--- | :--- |
577577
| **extend** | `string` | ✅ | Target object name (FQN) to extend |
578578
| **fields** | `Record<string, { name?: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; description?: string; … }>` | optional | Fields to add/override |
579+
| **fieldPatches** | `Record<string, { label?: string; description?: string; inlineHelpText?: string; placeholder?: string; … }>` | optional | [EXPERIMENTAL — not enforced] 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, help text, placeholder, option labels) without taking the field over, so its type, values and rules stay the vendor's and its upgrades still apply (ADR-0146 D10). Nothing applies a patch yet: the registry merge that does is rollout card N-23d, and until it lands the engine drops this slot at registration. Example: `fieldPatches: { industry: { label: 'Sector', inlineHelpText: 'The market the account sells into', options: { tech: { label: 'Technology' } } } }`. |
579580
| **label** | `string` | optional | Override label for the extended object |
580581
| **pluralLabel** | `string` | optional | Override plural label for the extended object |
581582
| **description** | `string` | optional | Override description for the extended object |
@@ -669,6 +670,16 @@ const result = ApiMethod.parse(data);
669670
| **_packageVersion** | `string` | optional | Owning package version. |
670671
| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. |
671672

673+
### Nested Shape: `ObjectExtension.fieldPatches[string]`
674+
675+
| Property | Type | Required | Description |
676+
| :--- | :--- | :--- | :--- |
677+
| **label** | `string` | optional | Human readable label |
678+
| **description** | `string` | optional | Tooltip/Help text |
679+
| **inlineHelpText** | `string` | optional | Help text displayed below the field in forms |
680+
| **placeholder** | `string` | optional | Placeholder text rendered inside the empty input (the HTML placeholder attribute); disappears once a value is entered. Distinct from `inlineHelpText` (always-visible help rendered beside/under the input) and `description` (tooltip/developer documentation). |
681+
| **options** | `Record<string, { label?: string }>` | optional | Display patches of the base field's options, keyed by each option's stored `value` (which stays the vendor's); each entry takes the option display keys of BASE_FIELD_DISPLAY_WHITELIST. |
682+
672683
### Nested Shape: `ObjectExtension.indexes[number]`
673684

674685
| Property | Type | Required | Description |

‎docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th
2121

2222
| Dir | Sites | strict | passthrough | catchall | strip |
2323
|---|---|---|---|---|---|
24-
| `data/` | 156 | 90 | 2 | 0 | 64 |
24+
| `data/` | 158 | 92 | 2 | 0 | 64 |
2525

2626
## `data/` — sites
2727

@@ -50,22 +50,22 @@ classify and is not listed (it becomes reportable the day it grows its first sit
5050
| `hook-body.zod.ts` | 2 |
5151
| `hook.zod.ts` | 8 |
5252
| `mapping.zod.ts` | 5 |
53-
| `object.zod.ts` | 23 |
53+
| `object.zod.ts` | 25 |
5454
| `picklist.zod.ts` | 3 |
5555
| `query.zod.ts` | 5 |
5656
| `seed-file.zod.ts` | 2 |
5757
| `seed-loader.zod.ts` | 12 |
5858
| `seed.zod.ts` | 2 |
5959
| `validation.zod.ts` | 6 |
60-
| **total** | **156** |
60+
| **total** | **158** |
6161

6262
## `data/` — open
6363

6464
Per file, how many of its sites still silently discard unknown keys. The `Class`
6565
column that decides the bucket split is hand-written in the ledger; the arithmetic
6666
over it is here.
6767

68-
**64 strip of 156**, in 10 file(s).
68+
**64 strip of 158**, in 10 file(s).
6969

7070
| File | Strip | Sites |
7171
|---|---|---|
@@ -79,7 +79,7 @@ over it is here.
7979
| `hook.zod.ts` | 5 | 8 |
8080
| `query.zod.ts` | 4 | 5 |
8181
| `seed-loader.zod.ts` | 12 | 12 |
82-
| **total** | **64** | **156** |
82+
| **total** | **64** | **158** |
8383

8484
| Bucket | Sites |
8585
|---|---|

‎packages/spec/authorable-surface/data.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -688,6 +688,7 @@
688688
"data/ObjectDependencyNode:references",
689689
"data/ObjectExtension:description",
690690
"data/ObjectExtension:extend",
691+
"data/ObjectExtension:fieldPatches",
691692
"data/ObjectExtension:fields",
692693
"data/ObjectExtension:indexes",
693694
"data/ObjectExtension:label",

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

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22

33
/**
44
* ADR-0146 D10 — the `__c` reservation and the base-field display-property
5-
* whitelist, measured against `FieldSchema`.
5+
* whitelist, measured against `FieldSchema`, and the extension slot that
6+
* carries a patch (`ObjectExtensionSchema.fieldPatches`).
67
*/
78

89
import { describe, expect, it } from 'vitest';
910
import type { z } from 'zod';
1011
import { BASE_FIELD_DISPLAY_WHITELIST, EXTENSION_FIELD_SUFFIX, isExtensionFieldName } from './field-customization';
1112
import { FieldSchema, SelectOptionSchema } from './field.zod';
13+
import { ObjectExtensionSchema } from './object.zod';
1214

1315
/** The object shape behind a (possibly optional) schema. */
1416
function shapeOf(schema: unknown): Record<string, z.ZodTypeAny> {
@@ -100,3 +102,119 @@ describe('the base-field display-property whitelist', () => {
100102
expect(BASE_FIELD_DISPLAY_WHITELIST.option.filter((k) => structuralOption.has(k))).toEqual([]);
101103
});
102104
});
105+
106+
// ---------------------------------------------------------------------------
107+
// The slot that carries a patch: `ObjectExtensionSchema.fieldPatches`
108+
// (ADR-0146 D10, second rule). Its key sets are derived from the whitelist;
109+
// `field-patches-derived.test.ts` proves a widened whitelist widens it.
110+
// ---------------------------------------------------------------------------
111+
112+
type Issue = { code: string; path: PropertyKey[]; message: string; keys?: string[] };
113+
114+
/** Parse an extension carrying `patch` for the base field `industry`; return the issues. */
115+
function patchIssues(patch: unknown): Issue[] {
116+
const r = ObjectExtensionSchema.safeParse({ extend: 'crm_account', fieldPatches: { industry: patch } });
117+
return r.success ? [] : (r.error.issues as unknown as Issue[]);
118+
}
119+
120+
describe('`ObjectExtensionSchema.fieldPatches` — the per-field display patch slot', () => {
121+
it('accepts every field member of the whitelist, read from the constant', () => {
122+
const patch = Object.fromEntries(BASE_FIELD_DISPLAY_WHITELIST.field.map((k) => [k, `Patched ${k}`]));
123+
const parsed = ObjectExtensionSchema.parse({ extend: 'crm_account', fieldPatches: { industry: patch } });
124+
expect(parsed.fieldPatches?.industry).toEqual(patch);
125+
});
126+
127+
it.each(BASE_FIELD_DISPLAY_WHITELIST.field.map((k) => [k]))('accepts the field member `%s` on its own', (key) => {
128+
expect(patchIssues({ [key]: 'Display text' })).toEqual([]);
129+
});
130+
131+
it.each(BASE_FIELD_DISPLAY_WHITELIST.option.map((k) => [k]))('accepts the option member `%s`, the option keyed by its stored value', (key) => {
132+
const parsed = ObjectExtensionSchema.parse({
133+
extend: 'crm_account',
134+
fieldPatches: { industry: { options: { tech: { [key]: 'Technology' } } } },
135+
});
136+
expect(parsed.fieldPatches?.industry?.options?.tech).toEqual({ [key]: 'Technology' });
137+
});
138+
139+
it.each(STRUCTURAL_FIELD_KEYS.filter((k) => k !== 'options').map((k) => [k]))(
140+
'refuses the structural key `%s` by name, with the D6 prescription naming the whitelist',
141+
(key) => {
142+
const issues = patchIssues({ label: 'Sector', [key]: true });
143+
expect(issues).toHaveLength(1);
144+
expect(issues[0]!.code).toBe('unrecognized_keys');
145+
expect(issues[0]!.path).toEqual(['fieldPatches', 'industry']);
146+
expect(issues[0]!.keys).toEqual([key]);
147+
expect(issues[0]!.message).toContain('BASE_FIELD_DISPLAY_WHITELIST');
148+
expect(issues[0]!.message).toContain('ADR-0146 D6');
149+
},
150+
);
151+
152+
it('refuses `options` membership: the field\'s option LIST is refused, the map keyed by stored value is the only form', () => {
153+
const issues = patchIssues({ options: [{ label: 'Technology', value: 'tech' }] });
154+
expect(issues).toHaveLength(1);
155+
expect(issues[0]!.code).toBe('invalid_type');
156+
expect(issues[0]!.path).toEqual(['fieldPatches', 'industry', 'options']);
157+
expect(issues[0]!.message).toContain('ADR-0146 D6');
158+
});
159+
160+
it('refuses every FieldSchema key the whitelist does not admit — the refused set is derived, not listed', () => {
161+
const fieldKeys = Object.keys(shapeOf(FieldSchema));
162+
const admitted = new Set<string>([...BASE_FIELD_DISPLAY_WHITELIST.field, 'options']);
163+
const refused = fieldKeys.filter((k) => !admitted.has(k));
164+
expect(refused).toEqual(expect.arrayContaining(STRUCTURAL_FIELD_KEYS.filter((k) => k !== 'options')));
165+
for (const key of refused) {
166+
const issues = patchIssues({ [key]: true });
167+
expect(issues.map((i) => i.code), key).toEqual(['unrecognized_keys']);
168+
expect(issues[0]!.message, key).toContain('BASE_FIELD_DISPLAY_WHITELIST');
169+
}
170+
});
171+
172+
it('an option patch takes `label` only: the stored `value` is refused with its own prescription', () => {
173+
const issues = patchIssues({ options: { tech: { label: 'Technology', value: 'technology' } } });
174+
expect(issues).toHaveLength(1);
175+
expect(issues[0]!.code).toBe('unrecognized_keys');
176+
expect(issues[0]!.path).toEqual(['fieldPatches', 'industry', 'options', 'tech']);
177+
expect(issues[0]!.keys).toEqual(['value']);
178+
expect(issues[0]!.message).toContain('stored value');
179+
expect(issues[0]!.message).toContain('ADR-0146 D6');
180+
});
181+
182+
it('an option patch refuses every other option key with the D6 prescription — derived from the option shape', () => {
183+
const optionKeys = Object.keys(shapeOf(SelectOptionSchema));
184+
const refused = optionKeys.filter((k) => !(BASE_FIELD_DISPLAY_WHITELIST.option as readonly string[]).includes(k) && k !== 'value');
185+
expect(refused.length).toBeGreaterThan(0);
186+
for (const key of refused) {
187+
const issues = patchIssues({ options: { tech: { [key]: true } } });
188+
expect(issues.map((i) => i.code), key).toEqual(['unrecognized_keys']);
189+
expect(issues[0]!.message, key).toContain('BASE_FIELD_DISPLAY_WHITELIST');
190+
}
191+
});
192+
193+
it('refuses a key spelled as an extension field (`__c`): declare it in `fields`, it is your own field', () => {
194+
const r = ObjectExtensionSchema.safeParse({ extend: 'crm_account', fieldPatches: { region__c: { label: 'Region' } } });
195+
expect(r.success).toBe(false);
196+
const issues = r.success ? [] : (r.error.issues as unknown as Issue[]);
197+
expect(issues).toHaveLength(1);
198+
expect(issues[0]!.code).toBe('custom');
199+
expect(issues[0]!.path).toEqual(['fieldPatches', 'region__c']);
200+
expect(issues[0]!.message).toContain('`fields`');
201+
});
202+
203+
it('is pure shape: a field or option the target may not have still parses (the server judges it)', () => {
204+
expect(patchIssues({ options: { no_such_option: { label: 'Whatever' } } })).toEqual([]);
205+
expect(ObjectExtensionSchema.safeParse({ extend: 'crm_account', fieldPatches: { no_such_field: { label: 'X' } } }).success).toBe(true);
206+
expect(patchIssues({})).toEqual([]);
207+
});
208+
209+
it('is optional: an extension without it parses to the same output as before the slot', () => {
210+
const parsed = ObjectExtensionSchema.parse({ extend: 'contact', fields: {} });
211+
expect(parsed).toEqual({ extend: 'contact', fields: {}, priority: 200 });
212+
expect('fieldPatches' in parsed).toBe(false);
213+
});
214+
215+
it('reads `[EXPERIMENTAL — not enforced]` and names the merge card until the merge applies it', () => {
216+
const description = shapeOf(ObjectExtensionSchema).fieldPatches!.description ?? '';
217+
expect(description.startsWith('[EXPERIMENTAL — not enforced]')).toBe(true);
218+
expect(description).toContain('N-23d');
219+
});
220+
});

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@
1717
* {@link BASE_FIELD_DISPLAY_WHITELIST} is that whitelist: the closed constant
1818
* the write gate reads, never a judgement it makes.
1919
*
20-
* Constants and one pure predicate, imports nothing: the shape that carries a
21-
* per-field patch on an object extension, and the merge that applies it, are
22-
* not declared here.
20+
* Constants and one pure predicate, imports nothing. The shape that carries a
21+
* per-field patch is `ObjectExtensionSchema.fieldPatches`
22+
* (`data/object.zod.ts`), its keys derived from the whitelist below; the merge
23+
* that applies it is not declared in `packages/spec`.
2324
*/
2425

2526
/**

0 commit comments

Comments
 (0)