Skip to content

Commit 33bc8ae

Browse files
docs(skills,spec): the fieldPatches example patches options only on a field that declares them, so HotCRM accepts it (#30995)
<!-- claude-projects-attribution: {"github_login":"huangyiirene"} --> _Requested by **Irene** · [project thread](https://claude.ai/code/project/chan_01TJXtQ1tVD26ecLDZxFS67x?thread=cmsg_01TJXtQ1tVD26ecLDZxFS67xMHYuqFd4hLYHNf14Fg8wBe)_ Fixes #30944 Clause-②: no Metadata-protocol: none `dispatch-gates` mandates the contract-review model tier for `skills/**` (clause ①). The maintainer's standing order in this project (2026-11-03) waives it; this PR was developed below that tier, and the dispatching seat runs the contract review. ## 维护者速读 - 改了什么:`fieldPatches` 的唯一示例改为只改 `industry` 的 `label`,选项补丁移到自带 inline `options` 的 `type`(`customer` → `Client`);覆盖 `objectstack-data` 技能、spec 的 `fieldPatchesDescription()` 与 TSDoc、`object-extensions.mdx`,`references/data/object.mdx` 由 spec 重新生成。 - 为什么改:HotCRM 的 `crm_account.industry` 绑定共享 picklist `industry`,`fieldPatchTargetMisses` 对其任何 `options` 键返回 `picklist-option`,照抄旧示例会在安装和 `os validate` 时被拒。 - 风险与回滚:只改说明文字和示例,无 schema、键或行为变化;发布 `@objectstack/skills` 与 `@objectstack/spec` 各一个 patch;回滚即 revert。 - Token 账:SKILL.md 示例多 10 字节,删掉同文件 `.custom` 段末重复的 "a base field changes only through `fieldPatches`" 付账;上限行不动(6068/6078)。 - 先看:`skills/objectstack-data/SKILL.md` L364 与 `packages/spec/src/data/object.zod.ts` `fieldPatchesDescription()`。 - 需要你做的:无;席位负责复审与落地。 ## What changes - `skills/objectstack-data/SKILL.md` L364: the example is now `defineObjectExtension({ extend: 'crm_account', fieldPatches: { industry: { label: 'Sector' }, type: { options: { customer: { label: 'Client' } } } } })`. `extend: 'crm_account'` and `label: 'Sector'` stay whole tokens (eval cases 5 and 7 of `evals/objects-fields-relationships.json` require them). The sentence before it still states that "a `picklist`-bound field's options are never patchable". - `packages/spec/src/data/object.zod.ts`, description text only: `fieldPatchesDescription()` now says an option of a field bound to a shared `picklist` is never patchable and only a field declaring its own `options` takes an option patch, and its example sets `industry`'s `label` and `inlineHelpText` and patches `type`'s `customer` option. The `ObjectExtensionSchema` TSDoc example carries the same patch; since that example extends `contact`, its comment is conditional: where `industry` is picklist-bound, as on HotCRM's `crm_account`, its options are not patchable (its display keys are). No schema change. - `content/docs/references/data/object.mdx`: regenerated with `pnpm --filter @objectstack/spec gen:docs` after `pnpm --filter @objectstack/spec build`; not hand-edited. - `content/docs/data-modeling/object-extensions.mdx` L104-118: the `os:check` example drops `industry`'s `options` (a comment says it takes them from a shared picklist) and patches `type: { options: { customer: { label: 'Client' } } }`. Its existing bullet on picklist-bound fields (L152-153) now agrees with the example. - Two changesets: `'@objectstack/skills': patch` and `'@objectstack/spec': patch`, each with `Clause-②: no` and `Metadata-protocol: none`. No `skip-changeset`. Read on HotCRM `main` at `6961ab1`, `src/sales/objects/account.object.ts`: - L311-318: `industry: Field.select({ label: 'Industry', group: 'basic', picklist: 'industry' })`, no inline `options` (the values, `technology` among them, live in `src/sales/picklists/industry.picklist.ts` L23). - L300-309: `type: Field.select({ label: 'Account Type', …, options: [...] })` declares its own inline options; L305 is `{ label: 'Customer', value: 'customer', … }`, so `customer` is a stored value the patch can address. Read on objectstack `main`: `fieldPatchTargetMisses` in `packages/metadata-core/src/extension-field-patch-targets.ts` pushes `reason: 'picklist-option'` for every key under a patch's `options` when the target field has a non-empty `picklist`, before it looks at option values; a field with inline `options` is checked by stored value (`reason: 'option'` when absent). The new examples pass both checks. Out of scope and left as is: the `options` shape error message in `object.zod.ts` (`'options: { technology: { label: \'Tech\' } }'`, near L5034) illustrates the map shape, not a HotCRM patch. **Token ratchet.** `skills/objectstack-data/SKILL.md`: after merging `main` at `acd190b2` the ceiling is 6078 with 1 byte of slack. The new example adds 10 bytes. Payment, by the seat's ruling: the `.custom` paragraph's closing clause "; a base field changes only through `fieldPatches`" (L372 on `acd190b2`) is deleted. Its home is L355-360 of the same file: "**Relabel a field the owner declares with `fieldPatches` — never by redeclaring it in `fields`**" and "the type, values and rules stay the owner's". The file is now 6068 tokens (ceiling 6078, headroom 10). No ratchet row moves, nothing was re-wrapped. Net lines in `skills/**`: 0. ## Why The one concrete `fieldPatches` example the platform taught, in the skill, the spec description and two docs pages, patched an option of HotCRM's picklist-bound `industry`. A verbatim copy was refused at install and by `os validate`, and the skill paragraph contradicted its own rule. ## Verification Head `5a6e8fad`: the two edit commits, a merge of `origin/main` at `acd190b2` (`8920b06e`, no conflict, no rebase), the ratchet-payment commit, and one commit for the contract review's two should-fixes (TSDoc comment made conditional; a skills-changeset claim the skill text does not make, dropped), and one commit for the delta review's must-fix (the comment said only the label was patchable; a picklist binding blocks only option patches). <details><summary>Local gates on 7c661de (head 5a6e8fa changes one TSDoc comment only): 105 derived, 105 run with exit 0, 0 NOT MEASURED (`dispatch-gates --ran`)</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 105 commands for the 6 changed files. All ran after `pnpm install --frozen-lockfile`, a full `pnpm build` of the merged tree and a `@objectstack/spec` rebuild on this head (each exit 0), each under a 900 s timeout with `NODE_OPTIONS=--max-old-space-size=8192` and its exit code captured before any pipe. The larger heap is for this container: at the script's default 4096 MB, `check:test-typecheck` inside `check:generated` had tsc exit with no diagnostics; at 10240 MB it was green. - All 105 exited 0, among them `check-skills-token-ratchet.mjs`, `check:generated`, `check:docs`, `check:skill-examples`, `check:skill-identifier-liveness`, `check:doc-authoring`, `check:dts-closure`, `check:dual-build-cjs-loads` and `check-comment-mask-corpus.mjs`. Also run: `pnpm --filter @objectstack/spec exec tsc --noEmit` (exit 0) and `pnpm exec eslint packages/spec/src/data/object.zod.ts` (exit 0). - `--ran` printed: `105 derived famil(ies) accounted for — 105 run, 0 NOT-MEASURED (a DERIVED zero — all 105 recorded an exit code and none of them is 3)`. - The first head `ad4d96f6` (before the merge) also ran all 105 with exit 0; there `check-plugin-teardown-shape.mjs --self-test` first exited 3 because the shallow clone lacked its pinned fixture commit, and exited 0 after fetching it. - `node scripts/check-commit-card-trailers.mjs --range origin/main..HEAD`: exit 0 (6 commits) before each push. Heads `22db7aee` and `7c661dec` ran all 105 with exit 0; `5a6e8fad` changes only those two TSDoc comment lines, and on it `pnpm --filter @objectstack/spec build` and `check:generated` exit 0 with nothing stale. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (Description text only.) - [x] 2. Every name I added uses the casing for its kind. (No names added.) - [x] 3. No formatter ran. `eslint` on the touched TypeScript file exits 0. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. (`main` merged in, no rebase.) - [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 (`check:generated` exit 0). - [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. (The 维护者速读 section is Chinese, per the lane template.) - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01J8VVAmsHMwmra6cg78RoEp <!-- signed: domain:skills#3 · huangyiirene · session_01J8VVAj7CvjkGXvgXzd6RZ7 --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent dc3adf3 commit 33bc8ae

6 files changed

Lines changed: 34 additions & 7 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/skills': patch
3+
---
4+
5+
docs(skills): the `fieldPatches` example relabels HotCRM's `industry` and patches options only on a field that declares its own
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The `objectstack-data` skill's one `fieldPatches` example patched an option of `crm_account.industry`. HotCRM binds that field to the shared `industry` picklist, so a verbatim copy was refused at install and by `os validate` (`picklist-option`). The example now sets `industry`'s `label` only and patches an option of `type`, which declares its own `options` (`customer` → `Client`). The added bytes are paid by deleting a restatement in the same file: the `.custom` paragraph's closing clause "a base field changes only through `fieldPatches`", whose facts the relabel paragraph above it already states. No token ceiling moves. No schema, key or behaviour changes.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
docs(spec): the `fieldPatches` description's example no longer patches an option of a picklist-bound field
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
`ObjectExtensionSchema.fieldPatches`'s description and the `ObjectExtensionSchema` TSDoc taught `industry: { label: 'Sector', options: { technology: { label: 'Tech' } } }`. HotCRM's `crm_account.industry` takes its options from a shared picklist, so the install door and `os validate` refuse that patch (`picklist-option`). The description now says an option of a picklist-bound field is never patchable and only a field declaring its own `options` takes an option patch, and its example sets `industry`'s label and help text and patches `type`'s `customer` option. The generated `references/data/object.mdx` follows. Description text only: no schema, key or behaviour changes.

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,10 @@ export const AccountWordingExtension = defineObjectExtension({
110110
industry: {
111111
label: 'Sector',
112112
inlineHelpText: 'The market the account sells into.',
113-
options: { technology: { label: 'Tech' } }, // addressed by the stored value
113+
// no `options` here: `industry` takes its options from a shared picklist
114+
},
115+
type: {
116+
options: { customer: { label: 'Client' } }, // addressed by the stored value
114117
},
115118
},
116119
});

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -580,7 +580,7 @@ const result = ApiMethod.parse(data);
580580
| :--- | :--- | :--- | :--- |
581581
| **extend** | `string` | ✅ | Target object name (FQN) to extend |
582582
| **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`). |
583-
| **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' } } } }`. |
583+
| **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. An option of a field bound to a shared `picklist` is never patchable: its labels are the picklist's, so only a field declaring its own `options` takes an option patch. Example: `fieldPatches: { industry: { label: 'Sector', inlineHelpText: 'The market the account sells into' }, type: { options: { customer: { label: 'Client' } } } }`. |
584584
| **label** | `string` | optional | Override label for the extended object |
585585
| **pluralLabel** | `string` | optional | Override plural label for the extended object |
586586
| **description** | `string` | optional | Override description for the extended object |

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5152,8 +5152,10 @@ function fieldPatchesDescription(): string {
51525152
'(`mergeObjectDefinitions`) after the additive merges, on every object read: a tenant\'s own text keeps ' +
51535153
'winning, and between two extensions the higher `priority` wins. A patch naming a field or option value ' +
51545154
'the target does not have is refused at install and by `os validate`; a field the same extension ' +
5155-
'replaces in `fields` is refused here. Example: `fieldPatches: { industry: { label: \'Sector\', ' +
5156-
'inlineHelpText: \'The market the account sells into\', options: { technology: { label: \'Tech\' } } } }`.'
5155+
'replaces in `fields` is refused here. An option of a field bound to a shared `picklist` is never ' +
5156+
'patchable: its labels are the picklist\'s, so only a field declaring its own `options` takes an option ' +
5157+
'patch. Example: `fieldPatches: { industry: { label: \'Sector\', inlineHelpText: \'The market the ' +
5158+
'account sells into\' }, type: { options: { customer: { label: \'Client\' } } } }`.'
51575159
);
51585160
}
51595161

@@ -5168,7 +5170,9 @@ function fieldPatchesDescription(): string {
51685170
* fields: { sales_stage: Field.select([...]) },
51695171
* // A base field's display properties only (ADR-0146 D10), applied by the
51705172
* // registry merge (`mergeObjectDefinitions`) — see `fieldPatches`.
5171-
* fieldPatches: { industry: { label: 'Sector', options: { technology: { label: 'Tech' } } } },
5173+
* // Where `industry` is picklist-bound, as on HotCRM's `crm_account`, its options are not
5174+
* // patchable (its display keys are); an option patch goes on a field declaring its own options.
5175+
* fieldPatches: { industry: { label: 'Sector' }, type: { options: { customer: { label: 'Client' } } } },
51725176
* }]
51735177
* ```
51745178
*/

‎skills/objectstack-data/SKILL.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -361,15 +361,15 @@ values and rules stay the owner's. A key the tenant edited keeps the tenant's
361361
text; between two extensions the higher `priority` wins. A patch naming a field
362362
or option value the target lacks is refused at install and by `os validate`, and
363363
a `picklist`-bound field's options are never patchable. Example:
364-
`defineObjectExtension({ extend: 'crm_account', fieldPatches: { industry: { label: 'Sector', options: { technology: { label: 'Tech' } } } } })`.
364+
`defineObjectExtension({ extend: 'crm_account', fieldPatches: { industry: { label: 'Sector' }, type: { options: { customer: { label: 'Client' } } } } })`.
365365

366366
**An object an installed package ships** (ADR-0146 D10) is extended from the
367367
base's paired `.custom` package (`hotcrm.custom`; **objectstack-platform**
368368
§ Customizing an installed package), never replaced: it takes no `overrides`
369369
declaration. Every field the pair adds ends in `__c` (`region__c`). The suffix is
370370
reserved to the pair, so a `__c` field in any other package, or an unsuffixed
371371
field in a `.custom` extension, is refused at save, publish and install and by
372-
`os validate` / `os build`; a base field changes only through `fieldPatches`.
372+
`os validate` / `os build`.
373373
Studio's **Add field** on a base object writes the same extension, as an
374374
`object_extension` item of the pair.
375375

0 commit comments

Comments
 (0)