Repository navigation
Commit eeb3117
feat(spec)!: key an object extension's fields by the field map's own key grammar (#29774)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} -->
_Requested by **Yibo Wang** · [project
thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwB9MJTq8giFNFawGE7vvMCTA)_
Fixes #29523
Clause-②: no (narrowing)
Metadata-protocol: narrowed
Skills-docs: updated here —
`content/docs/data-modeling/object-extensions.mdx` ("Naming the fields
you add") gains the key grammar. `skills/objectstack-data/SKILL.md` and
its evals teach extension fields only as `region__c`, so every taught
line stays true and no skill line changes.
## 维护者速读
- 改了什么:对象扩展的 `fields`(扩展新增的字段)的键改用与对象 `fields`、扩展 `fieldPatches` 同一个键
schema(小写 snake_case),`Region Code`、`region-code`
这类对象自己的字段表不可能容纳的键在解析时就被拒,报一个带示例的 `invalid_key`。
- 为什么改:以前任何字符串都能过 schema。实测 main 上 `defineStack`、`os validate` 都放行;在
`.custom` 包里 `Region Code__c` 被保存门保存并发布、出现在每个对象读取里、启动重读照样注册,还按这个名字建列存了值。
- 风险与回滚:收窄(BREAKING,spec minor,ADR-0087 语义迁移条目已登记,`os migrate meta
--stored` 会列出存量坏键行)。仓内
examples/skills/docs/模板无一处写过这种键;已存储的带坏键的扩展行升级后整行不再注册(其 `fieldPatches`
一并),再保存被 422 拒,直到改名。回滚即 revert 本 PR。
-
先看:`packages/spec/src/data/object.zod.ts`(`ObjectExtensionSchema.fields`
的键 schema 与 `invalid_key`
文案)、`packages/spec/src/conversions/refused-shapes.ts`(新检测器)、`packages/spec/src/migrations/entries/semantic/18.object-extension-field-key-grammar-refused.ts`。
- 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run
src/data/field-customization.test.ts
src/conversions/refused-shapes.test.ts`,预期 2 个文件全绿(192 条)。
- 需要你做的:无,席位落地。
## What changes
- `packages/spec/src/data/object.zod.ts`: `ObjectExtensionSchema.fields`
is keyed by `FieldNameKeySchema`, the one key schema
`ObjectSchema.fields` and `ObjectExtensionSchema.fieldPatches` already
read (not exported; its doc comment now lists all three readers). The
record's error map answers `invalid_key` with a prescription: the key is
the name of a field the extension adds, lowercase snake_case such as
`region_code` (`region_code__c` in a customization package), never its
label. The issue sits at `fields.KEY`, as the `fieldPatches` one sits at
`fieldPatches.KEY`. The `fields` describe names the grammar. `__proto__`
stays refused on the raw input by `refuseRecordProtoKey`, unchanged.
- `packages/spec/src/conversions/refused-shapes.ts`: one detector,
`object-extension-field-key-grammar-refused`, appended after the
`fieldPatches` one. Its predicate is the refusing door's own:
`ObjectExtensionSchema.safeParse`, keeping each `invalid_key` issue at
`fields.KEY` (the `fieldPatches` reader and this one now share one
helper, `refusedExtensionRecordKeys(extension, slot)`). It lists, never
rewrites, names the target, shows only the key and the field's `type` in
`from`, and closes each reason with the parse's own message.
-
`packages/spec/src/migrations/entries/semantic/18.object-extension-field-key-grammar-refused.ts`:
the ADR-0087 semantic entry (refused, with a prescription; no
conversion, because the key is the name a field is stored and read
under, and a respelling names a different column or lands on a field the
target already declares).
- `.changeset/29523-object-extension-field-key-grammar.md`: spec
`minor`, BREAKING, `adr-0087: registered
object-extension-field-key-grammar-refused`, with the FROM → TO line.
- `content/docs/data-modeling/object-extensions.mdx`: one paragraph in
"Naming the fields you add". `content/docs/references/data/object.mdx`:
regenerated (the `fields` describe moved).
- No public export is added or removed. No existing test or fixture
needed an edit: every extension `fields` key in `examples/**`,
`skills/**`, `content/docs/**`, templates and test fixtures is already
snake_case.
## Why — the measurement that chose the declaration
Measured on `origin/main` `1c1a5d40` (spec built from main's
`object.zod.ts`), then on this branch:
| Door | `main` | this branch |
|:--|:--|:--|
| `ObjectExtensionSchema` / `defineObjectExtension` (`fields: { 'Region
Code': … }`) | parses | one `invalid_key` at `fields.Region Code` |
| `defineStack({ objectExtensions })`, target outside the stack |
accepted | refused, `objectExtensions.0.fields.Region Code` |
| `defineStack`, target inside the stack | accepted (the merged object
is not re-parsed) | refused |
| `os validate` (CLI on a scratch `defineStack` config) | no issue on
the extension (only an unrelated `security-owd-unset` on the scratch
object) | exit 1, both extension keys named |
| `object_extension` save + publish door, `.custom` pair, key `Region
Code__c` | stored and published; served on the by-name, list, layered
and registry reads; a record insert stored `EMEA` under `Region
Code__c`; a boot re-read (`loadMetaFromDb`) registered it again with 0
errors | save refused 422 `INVALID_METADATA`, issue `invalid_key` at
`fields.Region Code__c`; no row stored; insert refused `INVALID_FIELD` |
| A row stored before the upgrade, re-read at boot | n/a |
`loadMetaFromDb` counts 1 error, the extension is not registered (no
extender), and its re-save is refused 422 |
So every authoring door accepted such a key end to end, which makes this
`Clause-②: no (narrowing)` · `Metadata-protocol: narrowed`, BREAKING,
with the semantic entry and the `REFUSED_STORED_SHAPES` detector the
card asks for (the stored pass reaches `object_extension` rows since
#29589).
## Tests
- `packages/spec/src/data/field-customization.test.ts` gains the
describe "extension `fields` keys — the field map's own key grammar" (8
cases): `Region Code`, `RegionCode`, `region-code`, `2nd_phone`, `Region
Code__c` each refused with exactly one `invalid_key` at `fields.KEY`
(message names the grammar and an example, no tracker id); CONTROL
`region_code` and `region_code__c` parse; CONTROL both authored examples
of the object-extension guide parse whole; and an agreement test that
the extension `fields` record refuses at the key exactly the keys
`ObjectSchema.fields` refuses at the key, over an 11-key sample that
holds keys on both sides (guarded against vacuity).
- `packages/spec/src/conversions/refused-shapes.test.ts`: the registered
id list gains the new id last; one TODO per refused key at
`objectExtensions[0].fields.KEY`, the row unchanged; the listed keys
equal the parse's `invalid_key` issues and each reason ends with the
parse's message; snake_case keys (`__c` included) are not listed and
parse; and a row with both a bad `fields` key and a bad `fieldPatches`
key is listed once by each detector, never crossed.
## Acceptance notes
- Not changed here: the manifest fold (`ObjectQL.registerApp`, the
`objectExtensions` branch in `packages/objectql/src/engine.ts`) copies
`ext.fields` as given and parses nothing; measured on this branch,
`registerApp` with `fields: { 'Region Code': … }` still registers the
field and stores a value. Every authoring path that produces a manifest
(`defineStack`, `os validate`, `os build`, the save door) now refuses
the key first, so only an artifact built before the upgrade reaches it.
That is the fold's trust model for every collection, not this card's;
noted, not filed.
- Not changed here, same family: `ObjectSchema.fields` refuses
`constructor` and `prototype` by record refines that sit outside the key
schema, so `ObjectExtensionSchema.fields: { constructor: … }` still
parses (as `fieldPatches: { constructor: … }` does, noted on #29513).
Noted, not filed.
## Verification
All readings on objectstack-ai/objectstack, head `bd50ef25` (merge of
`origin/main` `87956dcc`; the delta against main is the eight files
listed above).
<details><summary>Door probes (scratch, not committed)</summary>
A throwaway copy of the
`packages/runtime/src/domains/meta-object-extension-item.test.ts` rig
(better-sqlite3 `:memory:`, real `ObjectQL`, real protocol), run once
against a spec dist built from main's `object.zod.ts` and once against
this branch's; and `os validate` on a scratch `defineStack` config in
`packages/cli`. Readings are the table above. The probe files were
deleted before commit.
</details>
<details><summary>Ablation: the new tests can fail</summary>
`node scripts/ablation-replace.mjs` replaced
`z.record(FieldNameKeySchema, FieldSchema, {` with `z.record(z.string(),
FieldSchema, {` in `object.zod.ts` (anchor 1 → 0, blob `06e35bf5` →
`09e91a78`) and ran `src/data/field-customization.test.ts
src/conversions/refused-shapes.test.ts`: 9 failed | 183 passed (192).
The five refusal cases, the agreement test and the three new listing
tests went red; every control stayed 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. Taken on `61876fc4`; the
tested lines did not change in the merge.
</details>
<details><summary>Spec and consumer packages (downstream of the
contract)</summary>
Under `scripts/pm/os-verify-lock.sh`.
On the final head `bd50ef25`, after a full `turbo run build
--concurrency=2` (117/117 tasks):
- `@objectstack/spec` `check:generated`: exit 0. `test`: 803/803 files,
23807 passed | 1 todo.
- `@objectstack/organizations`
`object-extension-managed-target.wall.test.ts`: 1/1, 5 tests.
`@objectstack/plugin-auth` `identity-objects-plugin.test.ts`: 1/1, 15.
`@objectstack/plugin-security` `upgrade-impact-report.wiring.test.ts`:
1/1, 5.
- `@objectstack/downstream-contract` test: 3/3, 31.
`@objectstack/example-showcase` test: 45/45, 541.
On `61876fc4` (before the main merge; spec built from this branch):
- `@objectstack/spec` `test`: 803/803 files, 23798 passed | 1 todo.
`typecheck`: exit 0. `test:repo`: 89/89, 1522.
- `@objectstack/metadata-core` (full): 23/23, 904.
`@objectstack/objectql` (full): 544/544, 10606.
`@objectstack/metadata-protocol` (full): 308 passed | 4 skipped (312),
22262 passed | 26 skipped.
- Every other test file outside `packages/spec` that names
`objectExtensions`, `defineObjectExtension`, `object_extension` or
`ObjectExtensionSchema`: `@objectstack/cli` 9/9 (108),
`@objectstack/cloud-connection` 3/3 (19), `@objectstack/lint` 7/7 (535),
`@objectstack/metadata` 2/2 (17), `@objectstack/platform-objects` 2/2
(73), `@objectstack/rest` 5/5 (81), `@objectstack/runtime` 4/4 (48).
- Not run locally: the three `ui/packages/app-shell` files that name an
extension (`ResourceListPage.objectExtensionName-29569`,
`DataPillar.baseObjectExtension-28575`,
`DataPillar.draftUnreadable-29148`). Their extension bodies are
mock-server fixtures keyed `custom_note__c`, `budget_code__c`,
`region__c`, `tier__c`, and none parses them with the spec; Test UI runs
them. Studio's add-field derives the name through `toFieldNameLoose` and
`extensionFieldName`, so it writes snake_case keys.
</details>
<details><summary>Derived gates</summary>
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` on the final head `bd50ef25`: 104 commands,
all exit 0; `--ran` reconciliation: 104 derived, 104 run, 0
NOT-MEASURED. They ran after the full build, so the built-output gates
(`check:skill-examples`, `check:dual-build-cjs-loads`) measured, where
on `61876fc4` they had exited 3 for want of that build.
`check-adr-0087-registration` (registered, new here) and
`check-changeset-no-major` are among the 104.
</details>
<details><summary>Dogfood (narrowing arm, final head)</summary>
`pnpm --filter @objectstack/dogfood test` under the lock on `bd50ef25`,
after the full build in the same locked run: 402 passed | 3 skipped
(405) files; 3317 passed | 28 skipped (3345) tests; exit 0, lock VERDICT
command-exit 0.
</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 (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_01WGufzf5kvTFdbWYDbe8bHE
---
_Generated by [Claude
Code](https://claude.ai/code/session_01WGufzf5kvTFdbWYDbe8bHE)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent b62463f commit eeb3117
8 files changed
Lines changed: 289 additions & 14 deletions
File tree
- .changeset
- content/docs
- data-modeling
- references/data
- packages/spec/src
- conversions
- data
- migrations/entries/semantic
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
160 | 160 | | |
161 | 161 | | |
162 | 162 | | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
163 | 168 | | |
164 | 169 | | |
165 | 170 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
577 | 577 | | |
578 | 578 | | |
579 | 579 | | |
580 | | - | |
| 580 | + | |
581 | 581 | | |
582 | 582 | | |
583 | 583 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
75 | 75 | | |
76 | 76 | | |
77 | 77 | | |
| 78 | + | |
78 | 79 | | |
79 | 80 | | |
80 | 81 | | |
| |||
993 | 994 | | |
994 | 995 | | |
995 | 996 | | |
| 997 | + | |
| 998 | + | |
| 999 | + | |
| 1000 | + | |
| 1001 | + | |
| 1002 | + | |
| 1003 | + | |
| 1004 | + | |
| 1005 | + | |
| 1006 | + | |
| 1007 | + | |
| 1008 | + | |
| 1009 | + | |
| 1010 | + | |
| 1011 | + | |
| 1012 | + | |
| 1013 | + | |
| 1014 | + | |
| 1015 | + | |
| 1016 | + | |
| 1017 | + | |
| 1018 | + | |
| 1019 | + | |
| 1020 | + | |
| 1021 | + | |
| 1022 | + | |
| 1023 | + | |
| 1024 | + | |
| 1025 | + | |
| 1026 | + | |
| 1027 | + | |
| 1028 | + | |
| 1029 | + | |
| 1030 | + | |
| 1031 | + | |
| 1032 | + | |
| 1033 | + | |
| 1034 | + | |
| 1035 | + | |
| 1036 | + | |
| 1037 | + | |
| 1038 | + | |
| 1039 | + | |
| 1040 | + | |
| 1041 | + | |
| 1042 | + | |
| 1043 | + | |
| 1044 | + | |
| 1045 | + | |
| 1046 | + | |
| 1047 | + | |
| 1048 | + | |
| 1049 | + | |
| 1050 | + | |
| 1051 | + | |
| 1052 | + | |
| 1053 | + | |
| 1054 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
46 | 46 | | |
47 | 47 | | |
48 | 48 | | |
49 | | - | |
50 | | - | |
51 | | - | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
52 | 52 | | |
53 | 53 | | |
54 | 54 | | |
| |||
628 | 628 | | |
629 | 629 | | |
630 | 630 | | |
631 | | - | |
| 631 | + | |
| 632 | + | |
| 633 | + | |
| 634 | + | |
| 635 | + | |
| 636 | + | |
| 637 | + | |
| 638 | + | |
| 639 | + | |
| 640 | + | |
| 641 | + | |
| 642 | + | |
| 643 | + | |
| 644 | + | |
632 | 645 | | |
633 | 646 | | |
634 | 647 | | |
635 | 648 | | |
636 | | - | |
| 649 | + | |
637 | 650 | | |
638 | 651 | | |
639 | 652 | | |
| |||
665 | 678 | | |
666 | 679 | | |
667 | 680 | | |
| 681 | + | |
| 682 | + | |
| 683 | + | |
| 684 | + | |
| 685 | + | |
| 686 | + | |
| 687 | + | |
| 688 | + | |
| 689 | + | |
| 690 | + | |
| 691 | + | |
| 692 | + | |
| 693 | + | |
| 694 | + | |
| 695 | + | |
| 696 | + | |
| 697 | + | |
| 698 | + | |
| 699 | + | |
| 700 | + | |
| 701 | + | |
| 702 | + | |
| 703 | + | |
| 704 | + | |
| 705 | + | |
| 706 | + | |
| 707 | + | |
| 708 | + | |
| 709 | + | |
| 710 | + | |
| 711 | + | |
| 712 | + | |
| 713 | + | |
| 714 | + | |
| 715 | + | |
| 716 | + | |
| 717 | + | |
| 718 | + | |
| 719 | + | |
668 | 720 | | |
669 | 721 | | |
670 | 722 | | |
| |||
680 | 732 | | |
681 | 733 | | |
682 | 734 | | |
| 735 | + | |
683 | 736 | | |
684 | 737 | | |
685 | 738 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
411 | 411 | | |
412 | 412 | | |
413 | 413 | | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
| 441 | + | |
| 442 | + | |
| 443 | + | |
| 444 | + | |
| 445 | + | |
| 446 | + | |
| 447 | + | |
| 448 | + | |
| 449 | + | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
| 455 | + | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
| 460 | + | |
| 461 | + | |
| 462 | + | |
| 463 | + | |
| 464 | + | |
| 465 | + | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
| 476 | + | |
| 477 | + | |
0 commit comments