You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 3fb74b6
Browse filesBrowse the repository at this point in the historyBrowse files
feat(spec)!: object extension fieldPatches refuse a field the extension replaces, key option patches by value, refuse __proto__, carry display renames (#29331)
<!-- claude-projects-attribution: {"github_login":"os-steve"} -->
_Requested by **Steve** · [project
thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9LXi8jnV2NzztvoFy8gqAqx)_Fixes#28655
Clause-②: no (narrowing)
Metadata-protocol: narrowed
Skills-docs: updated here —
`content/docs/data-modeling/object-extensions.mdx` gains the "Patch it
or take it over, not both" bullet (the replaced-field refusal and the
option-key grammar). `skills/objectstack-data/SKILL.md` already teaches
"Relabel a field the owner declares with `fieldPatches` — never by
redeclaring it in `fields`" and its example keys the option by
`technology`, so every taught line stays true and no skill line changes.
## 维护者速读
- 改了什么:对象扩展的 `fieldPatches` 多拒三种输入——同一扩展既在 `fields` 里整字段替换又在
`fieldPatches` 里打补丁的字段、不可能是选项值的选项补丁键(如 `Tech Sector`)、以及 `__proto__`
键;补丁上写 `helpText`/`title` 这类别名时给出改名提示;描述里的白名单成员改为从常量生成。
- 为什么改:`fieldPatches`
现在已被合并真实应用,原来这些写法"能保存、不生效":补丁落在扩展自己的替换字段上,或永远匹配不到选项,`__proto__`
键在保存门被存下却在注册时丢失。
- 风险与回滚:收窄(BREAKING,spec minor)。仓内 examples/skills/docs 无一处命中被拒写法;回滚即
revert 本 PR。HotCRM 仓不在本容器,未测。
-
先看:`packages/spec/src/data/object.zod.ts`(`refuseFieldPatchOfReplacedField`、`options`
的键
schema、`whitelistedAliases`)、`packages/spec/src/data/field-key-aliases.ts`(别名表搬出的唯一副本)、`packages/spec/src/data/field-customization.test.ts`
新增一节。
- 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run
src/data/field-customization.test.ts
src/data/field-patches-derived.test.ts`,预期 2 个文件全绿(91 条)。
- 需要你做的:无,席位落地。
## What changes
All in `ObjectExtensionSchema` (`packages/spec/src/data/object.zod.ts`),
the four should-fixes A–D of the contract review on PR #28650:
- **A.** The root `superRefine` refuses a field name present in both
`fields` and `fieldPatches` of one extension
(`refuseFieldPatchOfReplacedField`): `custom` at `fieldPatches.NAME`,
prescribing "patch the vendor's field or declare your own, not both". A
`__c` name in both is left to the existing `__c` rule, so it is refused
once.
- **B.** A field patch's `options` map is keyed by
`SelectOptionSchema.shape.value` (read off the shape, not restated), so
`Tech Sector` is refused with `invalid_key` at
`fieldPatches.industry.options.Tech Sector`. `refuseRecordProtoKey` now
guards `fields`, `fieldPatches` and the `options` map: the measurement
holds at the door (below), not only at schema level.
- **C.** The field patch and the option patch carry the rows of their
source shape's rename table whose target the whitelist admits, by filter
(`whitelistedAliases`). Field: `title`, `displayName` onto `label`;
`help`, `helpText`, `hint`, `tooltip` onto `inlineHelpText`. Option:
`text`, `name`, `title` onto `label`. Carriage follows the
`ui/action-target-aliases.ts` precedent: the two tables moved out of
`field.zod.ts` into a relative-import-only leaf,
`packages/spec/src/data/field-key-aliases.ts`, which `FieldSchema`,
`SelectOptionSchema` and the patches all read. No second mechanism and
no new public export (`check:api-surface` unchanged).
- **D.** The `fieldPatches` `.describe()` lists its members from
`BASE_FIELD_DISPLAY_WHITELIST` (`fieldPatchesDescription`).
Generated and ledger files that ride along:
- `content/docs/references/data/object.mdx` (regenerated description);
- `packages/spec/dropped-refinements.baseline.json`: the five
`data/ObjectExtension` `fields.valueType…` sites moved to
`fields.out.valueType…`, because the `__proto__` guard wraps the record
in a preprocess. The same sites, one level deeper, and no new site;
- `content/docs/data-modeling/object-extensions.mdx` gains one bullet;
- `.changeset/28655-field-patches-refuse-replaced-field.md`.
Forced test edits: none. The new pins are added to
`field-customization.test.ts` and `field-patches-derived.test.ts`. No
existing assertion changed.
## Why
The card's "reach: none yet" premise has expired:
`mergeObjectDefinitions` (`packages/objectql/src/registry.ts`) applies
`fieldPatches` on main (#28565). Each input A and B refuse parsed and
then did nothing visible. A patch landed on the extension's own
whole-field replacement, and an option key no value can be never matched
an option.
`__proto__` reach, measured at the door on main `47ee57fe` before the
change. This is the `/api/v1/meta` `object_extension` save door's own
schema (`OBJECT_EXTENSION_ITEM_SCHEMA`) and its registration seam
(`registrableObjectExtension`), over a JSON-parsed body:
- the door schema answered `success: true`, with `fields: {}` and
`fieldPatches` missing the key;
- the registered body was missing it too;
- the stored (verbatim) body kept `fieldPatches.__proto__` and
`fields.__proto__`. The stored row and the served extension disagree.
## Tests
The new pins, each giving zod code, path and named subject beside a
control through the same parse:
- A: refusal at `fieldPatches.industry`, with a control (a patch beside
a `fields` entry of another name parses), and the `__c` case refused
once.
- B: `invalid_key` at the option key, with a control (the key verdict
equals `SelectOptionSchema.shape.value.safeParse` for four keys). There
are three `__proto__` cases (`fields`, `fieldPatches`, `options`) over
JSON documents, plus a control.
- C: four field renames and three option renames, with a control (a
rename whose target the patch refuses carries no arrow, and `colour`
carries none under the shipped whitelist). The derived proof is in
`field-patches-derived.test.ts`: once the mocked whitelist admits
`color`, the option's `colour` rename reaches the patch.
- D: every whitelist member appears in the description, and the widened
members appear under the mock.
Ablation, in a side worktree at `5f8d7494`, through
`scripts/ablation-replace.mjs` (each anchor 1 → 0, every leg restored to
blob `67228dde3fe9`, `git diff HEAD` empty). Spec tests import `src`, so
no build is in the path:
| leg | mutation | red |
|:--|:--|:--|
| A | drop the `refuseFieldPatchOfReplacedField` call | 1 (the A
refusal) |
| B key | `z.string()` key instead of `optionShape.value` | 2 (refusal +
grammar control) |
| B proto | drop the `fieldPatches` guard | 1 (the `fieldPatches`
`__proto__` case) |
| C field | drop the field patch `aliases` | 4 (the four field renames)
|
| C option | drop the option patch `aliases` | 3 (the three option
renames) |
| D | constant description | 3 (both description pins + the existing
liveness pin) |
## Acceptance notes
- The card listed the field renames as `title`, `help`, `helpText`,
`hint`, `tooltip`. The filter also yields `displayName` onto `label`,
which `FieldSchema`'s table carries. It is pinned.
- The `options` map's `__proto__` guard goes beyond the card's
"`fieldPatches` and `fields`". It is the same defect class and the same
mechanism, in the same schema, inside the same gate family. Without it
B's key grammar could not see that key, because zod skips `__proto__`
before the key schema.
- The claim's file surface names `object.zod.ts`. C also touches
`field.zod.ts` (the two tables now read from the leaf, byte-identical
rows) and adds `field-key-aliases.ts`. This is declared as a deviation
for the seat to amend.
- HotCRM fixtures: that repository is not reachable from this container,
so its count of A/B hits is NOT MEASURED. In this repository, examples/,
skills/ and content/docs/ carry zero refused forms. The only authored
`fieldPatches` (the docs page and the skill) key options by
`technology`.
- `refuseUnlessPlatformWritten` and `mergeObjectDefinitions` are
untouched (#28205 follows in the same file).
## Verification
<details><summary>Consumer packages (downstream of spec), at
5f8d749</summary>
- `@objectstack/spec` test: Test Files 790 passed, Tests 23390 passed, 1
todo. typecheck: exit 0.
- `@objectstack/objectql` test: 527 files, 10140 tests passed.
- `@objectstack/metadata-protocol` test: 300 passed, 4 skipped files;
22100 tests passed.
- `@objectstack/metadata-core` test: 22 files, 885 tests passed.
- `@objectstack/lint` test: 189 files, 7830 tests passed.
- `@objectstack/cloud-connection` test: 67 files, 813 tests passed.
- `@objectstack/rest` test: 367 files, 6963 passed, 332 skipped.
- `@objectstack/cli` `--project unit`: 370 files passed. Two files
reported "cli is not built" (`published-subpath-*.pin.test.ts`), a
prerequisite, rerun after the full build: 2 files, 29 tests passed.
`--project integration test/validate-field-patch-targets.test.ts`: 2/2
passed.
</details>
<details><summary>Gates and dogfood</summary>
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `5f8d7494`: 103 derived commands, each
run with stdin closed. 102 exited 0 on the first pass.
`check-plugin-teardown-shape --self-test` exited 3 (PREREQUISITE NOT
MET: its pinned fixture commit is outside the shallow clone); after `git
fetch --depth=1 origin 621a487…` it exited 0 (48 cases). `--ran`
reconciliation: "103 derived, 103 run, 0 NOT-MEASURED, 0 UNRUN" (a
DERIVED zero). Offline before the PR: `check-changeset-no-major --event`
on this body's two lines: LEVEL AXIS accepted (`no (narrowing)` +
`narrowed`, spec `minor`); `check:adr-0087-registration`: 1
declared-breaking changeset with a disposition. eslint on the five
changed `.ts` files: exit 0. `pnpm --filter @objectstack/spec
check:generated`: 1 stale (`check:docs`), fixed with `--fix` and
re-checked green; the other 13 were up to date.
Dogfood (title carries `!` and the `(narrowing)` arm) at `5f8d7494`,
after `turbo run build --filter=./packages/**` (78/78): `os-verify-lock:
VERDICT command-exit 0` · held 1829 s · `pnpm --filter
@objectstack/dogfood test`: Test Files 389 passed, 3 skipped (392);
Tests 3215 passed, 28 skipped (3243).
</details>
## 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.
- [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 by [Claude
Code](https://claude.ai/code/session_01ADHkmT6ynacYWXqz9Yredm)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
feat(spec)!: an object extension's `fieldPatches` refuses a field the same extension replaces in `fields`, keys option patches by the option-value grammar, refuses `__proto__`, and carries the field's and option's display renames
6
+
7
+
`ObjectExtensionSchema.fieldPatches` (ADR-0146 D10) is applied by the registry merge, so what it accepts now changes what every read serves. Three things it accepted are refused, each located at its key:
8
+
9
+
- a field named in both `fields` and `fieldPatches` of one extension (`fieldPatches.industry`): `fields` takes the vendor's field over whole, so the patch landed on the extension's own replacement and the author got neither answer;
10
+
- an option-patch key no option `value` can be (`fieldPatches.industry.options.Tech Sector`): the map is now keyed by `SelectOptionSchema.shape.value` itself, so the patch can address only an option that can exist;
11
+
- a `__proto__` own key in `fields`, `fieldPatches` or an option-patch map, which zod's record parser dropped while reporting success. Measured at the `/api/v1/meta` save door: the body parsed, the stored row kept the key, and the registered extension did not.
12
+
13
+
A field patch that spells a key the way the field's own rename table knows (`helpText`, `help`, `hint` or `tooltip` for `inlineHelpText`; `title` or `displayName` for `label`), and an option patch that spells one the option's table knows (`text`, `name` or `title` for `label`), now gets the rename hint instead of a bare unknown-key refusal. The rows are taken by filter from the one table each source shape reads, so a rename onto a whitelisted key reaches the patch the day it lands. The `fieldPatches` description lists its members from `BASE_FIELD_DISPLAY_WHITELIST`.
14
+
15
+
Clause-②: no (narrowing) — `ObjectExtensionSchema` refuses three inputs it accepted: a field both replaced and patched, an option-patch key outside the option-value grammar, and a `__proto__` own key in its three record slots. Nothing is widened: the renames are hints on keys already refused.
16
+
17
+
Metadata-protocol: narrowed (an object extension may no longer patch a field it replaces in `fields`, nor key an option patch by anything but a valid option value)
18
+
19
+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed or renamed. A field both replaced and patched has two opposite intents and no rewrite keeps both; an option-patch key outside the value grammar never addressed an option; a __proto__ key never survived the parse. The author decides, and each refusal names the key. -->
20
+
21
+
**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.
22
+
23
+
-**Before.**`{ extend: 'crm_account', fields: { industry: {…} }, fieldPatches: { industry: { label: 'Sector' } } }` parsed, and the patch relabelled the extension's replacement. `fieldPatches: { industry: { options: { 'Tech Sector': { label: 'Tech' } } } }` parsed and could never match an option. A JSON body carrying `"__proto__"` under `fields` or `fieldPatches` parsed with that key silently missing.
24
+
-**Now.** Each is refused: `custom` at `fieldPatches.industry`, `invalid_key` at `fieldPatches.industry.options.Tech Sector`, `custom` at `fields.__proto__` / `fieldPatches.__proto__`.
25
+
-**Unchanged.** A patch of a field the extension does not replace, keyed by a valid option value, parses as before.
26
+
-**Who sees it.** No extension in this repository's examples, skills or docs writes any of the refused forms.
27
+
28
+
**The remedy.** Choose one answer per field: keep the patch to change only its display (the vendor's type, values and upgrades still apply), or keep the `fields` entry and set the display properties there. Key an option patch by the option's stored `value` (`technology`), not its label. Rename a `__proto__` key.
|**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, 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). 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`. Example: `fieldPatches: { industry: { label: 'Sector', inlineHelpText: 'The market the account sells into', options: { technology: { label: 'Tech' } } } }`. |
580
+
| **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
581
|**label**|`string`| optional | Override label for the extended object |
582
582
|**pluralLabel**|`string`| optional | Override plural label for the extended object |
583
583
|**description**|`string`| optional | Override description for the extended object |
0 commit comments