Repository navigation
Commit 447a2a6
feat(metadata-protocol,cli,cloud-connection)!:
<!-- claude-projects-attribution: {"github_login":"os-project-manager"}
-->
_Requested by **Leehom** · [project
thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XB8y3FqD5csjtFmNMzvKsg5)_
Fixes #28564
Clause-②: yes (narrowing)
New refusals at the save, restore and publish doors, `duplicatePackage`,
`os validate` and the marketplace install narrow the accepted set: a
field spelled `__c` outside a base's paired `.custom` package, and a
`.custom` extension field without the suffix. No `packages/spec/**` file
is touched; no schema, type, error code or runtime interface moves.
Metadata-protocol: narrowed
Skills-docs: none — no file under `skills/**` teaches a field spelled
`__c`, an `objectExtensions` field name or a `BASE.custom` package; the
measured count below found none.
## 维护者速读
- 改了什么:ADR-0146 D10 的第一条规则落地——`__c` 后缀只给基础包配对的 `.custom`
包用。一个判官(`judgeExtensionFieldSuffix`),保存/恢复/发布三道门、复制包、`os
validate`、市场安装都问它。非 `.custom` 包里出现 `xxx__c` 字段被拒(让改名);`.custom`
包的对象扩展字段不带 `__c` 被拒(让加后缀,或用 `fieldPatches` 改基础字段的显示属性)。
- 为什么改:客户字段叫
`region__c`,厂商下个版本永远不会占用这个名字,也没人会把客户字段当成厂商字段——前提是每道门都拒。之前后缀只在 spec
里声明,没有一道门读它。
- 风险与回滚:收窄只拦一个今天没人用的拼法:在 `2678600d` 上实测
`examples/**`、`packages/*/src`、`skills/**`(不含测试)里 `[a-z0-9]__c` 字段名 0
个(7 处文本命中全在文档注释里)。每道门沿用自己的信封,不新增错误码。回滚即 revert,无数据迁移。
-
先看:`packages/metadata-protocol/src/protocol-extension-field-suffix.ts`(判官与两句话);`protocol.ts`
的保存门、`promoteDraftForPublish`、`duplicatePackage` 三处接线;`cloud-connection`
安装门的步骤 1c''。
- 两分钟验证:`pnpm --filter @objectstack/metadata-protocol exec vitest run
src/protocol.extension-field-suffix.test.ts`,预期 `Tests 20 passed (20)`。
- 需要你做的:无,席位落地(合同审查后)。
## What changes
- **One judge** (`@objectstack/metadata-protocol`, new
`protocol-extension-field-suffix.ts`, exported from the index).
`judgeExtensionFieldSuffix({ packageId, objects, objectExtensions, at
})` is pure and answers every refused field, located. It reads the
spec's predicates, `isExtensionFieldName` (`@objectstack/spec/data`) and
`isCustomizationPackage` (`@objectstack/spec/kernel`), and spells
neither the suffix nor the pair rule.
- (a) `reserved_outside_pair`: in a package that is not a `.custom`
pair, a field spelled `__c` on its own objects or in its
`objectExtensions`: "Field 'F' on object 'O' in package 'P': the `__c`
suffix is reserved to a base's paired `.custom` package (ADR-0146 D10);
rename the field."
- (b) `unsuffixed_in_pair`: in a `.custom` pair, an
`objectExtensions[].fields` key without the suffix: "Extension field 'F'
on object 'O' in package 'P': name it `F__c`, or change a base field's
display properties in the extension's patch (`fieldPatches`)." The patch
slot is named as it stands on `main` (#28560's
`ObjectExtensionSchema.fieldPatches`).
- Not judged: a `.custom` pair's own new objects, an extension of one of
them, and a write bound to no package (the package-unbound row is the
environment's own, as `objectNamespacePrefixViolation` already skips
it).
- Beside it: `extensionFieldSuffixRefusalError` (the active doors'
`VALIDATION_ERROR` / 400 envelope, `issues[]` locating each field) and
`objectBodyExtensionFieldSuffixRefusal(type, item, name, binding)` (the
one-item question the save, restore and promotion doors ask, on the
RESOLVED binding, as `permissionSetOwnerBindingRefusal` reads it).
- **The three `active` doors**, each beside the
`permissionSetOwnerBindingRefusal` call: the save door (every writer,
both modes, binding from `saveWriteBinding`), the restore
(`restoredBodyWriter`, so `rollbackMetaItem` and `revertCommit`'s
restore limb), and the draft promotion `publishMetaItem` and
`publishPackageDrafts` share (`promoteDraftForPublish`, against
`promoteInto`, before `repo.promoteDraft` writes; a batch publish
reports it in `failed` and aborts).
- **`duplicatePackage`**, judged against the target id after the rows
are read and before the copy's first mutation (the package record): a
copy of a `.custom` pair's `__c` objects into a plain id is refused
whole, `400 VALIDATION_ERROR`, nothing minted or copied.
- **`os validate`** (`@objectstack/cli`, new
`utils/extension-field-suffix.ts`, step 3a-iv beside N-09's 3a-iii):
each `packages[]` entry under its own id, or the stack under
`manifest.id`; exit 1, the rows on `errors` with `path`, `code:
'VALIDATION_ERROR'`, `rule`, `packageId`, `message`, and printed with
the path without `--json`. The build/validate parity ledger records the
call under `VALIDATE_ONLY_GATES` with its reason (ADR-0146 decided item
7 names publish, validate and install, not `os build`).
- **Marketplace install** (`@objectstack/cloud-connection`, step 1c''
right after the dependency check): every delivered package (each
`packages[]` entry under its own id, the body otherwise) is judged
before the manifest service sees the artifact and before the
install-mode branch, so the managed and the unmanaged install are both
covered. The answer is this door's existing body refusal, `422
VALIDATION_ERROR` (the unrunnable-code refusal's code and status), with
`details.refusals`; nothing is registered and no ledger entry is
written.
- `scripts/engine-double-contract.pinned.json`: the new test file's
three engine doubles, written by `check-engine-double-contract.mjs
--write`.
## Acceptance notes
- **`installPackage` gets no call: its closed manifest covers it** (seat
1 accepted this reading before the first edit).
`PackageInstallManifestSchema` is a `strictObject` whose `objects` are
glob strings and which has no `objectExtensions` key, so an `x__c` field
body never reaches the door: it is refused `400` by that parse before
any write. A judge call there would judge an empty set. N-09 (#28410, PR
#28554) met the same fact and moved its install gate to the marketplace
install. Pinned in `protocol.extension-field-suffix.test.ts` section 5.
- **Case (b) is pinned where an extension can arrive** (seat 1
accepted). Only `os validate` and the marketplace install receive
`objectExtensions`; the save, restore and promotion doors and
`duplicatePackage` only see `object` bodies, where a `.custom` pair's
own object is not judged. Those doors pin case (a) and the pair's admit;
the judge's own section pins (b), and the validate and install files pin
it at those doors. #28574 (the runtime `object_extension` write door)
asks this judge from its own card.
- **handleInstall's position.** The call sits at the dependency check's
early position (step 1c''), as the card asks, so it also covers the
unmanaged branch, which leaves before N-09's step 2d. It judges the body
as delivered (before the ADR-0087 forward conversion), which does not
touch field names. #28565's patch-target judge adds its own separate
line; the two are not folded.
- A draft save that names no package is judged at its promotion, not at
the save: `saveWriteBinding` resolves the store-read binding for
permission sets only, and an object draft is not live until a door
promotes it.
- `protocol-packaged-base-refusal.ts` is not edited (#28412 holds it).
## Tests
-
`packages/metadata-protocol/src/protocol.extension-field-suffix.test.ts`
(20): the judge's two cases, its admits and what it does not judge (6);
the save door in publish and draft mode, every field located, and the
control (pair admit, plain field, no package) (4); `rollbackMetaItem`,
`revertCommit`'s restore limb and the control (3); `publishMetaItem`,
`publishPackageDrafts` and the pair's batch control (3);
`duplicatePackage` from `app.dupsrc.custom` into a plain id refused
whole, and its control (2); `installPackage` refusing an object body and
an `objectExtensions` entry at its parse (2).
-
`packages/cloud-connection/src/marketplace-install-local-extension-field-suffix.test.ts`
(6): (a) on an extension and on an own object, (b), a `packages[]`
artifact judged per entry, the refusal ahead of the install-mode
judgement (`installMode: 'unmanaged'`), and the control (the pair's
`region__c` and a vendor extension's plain field install). Each refusal
asserts nothing registered and no ledger entry.
- `packages/cli/src/utils/extension-field-suffix.test.ts` (4, unit) and
`packages/cli/test/validate-extension-field-suffix.test.ts` (3, the
command, integration tier): (a) exits 1 with the path on `errors`, (b)
exits 1 with the path printed, the pair's `region__c` validates clean.
- **Red first on base**: the four door files restored to `2678600d57`
(judge module and tests kept): metadata-protocol 8 failed / 12 passed
(every door pin red; the judge, the controls and the `installPackage`
coverage pins green), cloud-connection 5 failed / 1 passed (the control
green), cli command 2 failed / 5 passed. Restore proved by `git diff
HEAD` empty.
- **Ablations, one per door**, each through
`scripts/ablation-replace.mjs` (anchor hit once, blob changed, restore
blob equal to HEAD, `git diff HEAD` empty after): save door (call handed
another type) 3 failed; promotion 2 failed; restore 2 failed;
`duplicatePackage` (throw condition inverted) 1 failed; `os validate`
(judged `{}`) 2 failed; marketplace install (package id dropped) 5
failed. No `dist/` is on any of these resolution paths
(metadata-protocol and cloud-connection tests import their own `src`,
the cli command runs its `src` through tsx).
- Existing install and validate fixtures stay green: the full suites
below.
## Verification
Run on `40dfde1a` (`737815d4` + the engine-double ledger commit
`6923eb9f` + one merge of `origin/main` at `da6f0ce3`), each test
command under `os-verify-lock` (`VERDICT command-exit 0`).
- Merge: one conflict,
`packages/cli/test/validate-build-gate-parity.test.ts`. #28681 (card
#28643) moved `findOverrideDeclarationRefusals` out of
`VALIDATE_ONLY_GATES` by wiring `os build`; this branch keeps that and
records `findExtensionFieldSuffixRefusals` there (the card names no
build door), with the docblock updated. `pnpm install --frozen-lockfile`
exit 0; `turbo run build` (docs and `ui/` excluded) exit 0.
- `@objectstack/metadata-protocol` full suite: Test Files 292 passed, 4
skipped (296); Tests 21970 passed, 26 skipped.
- `@objectstack/cloud-connection` full suite: Test Files 66 passed (66);
Tests 802 passed (802).
- `@objectstack/cli` unit tier: Test Files 365 passed (365); Tests 5171
passed (5171). Integration tier, the two validate gate files
(`validate-extension-field-suffix`, `validate-override-declarations`): 2
files, 5 tests passed. The rest of the integration tier is CI's.
- Typecheck metadata-protocol, cli, cloud-connection: exit 0. `eslint
--no-inline-config packages/metadata-protocol packages/cli
packages/cloud-connection`: exit 0.
- Dogfood battery (`pnpm --filter @objectstack/dogfood test`, the
narrowing arm): Test Files 369 passed, 3 skipped (372); Tests 2981
passed, 28 skipped.
- Derived gates (`dispatch-gates.mjs --commands`, reconciled with
`--ran`): `✓ dispatch-gates --ran: 74 derived famil(ies) accounted for —
74 run, 0 NOT-MEASURED`. `check:engine-double-contract` first read 1
(the new file's three doubles unrecorded) and is green after `--write`;
`check:type-check-debt` first read 3 (lock wait behind this branch's own
dogfood run) and is green on its re-run.
- Count (migration note): `git grep -nE "[a-z0-9]__c\b"` over
`examples/**`, `packages/*/src/**`, `skills/**`, tests excluded, at
`2678600d57`: 7 hits, all in doc comments (`field-customization.ts`,
`object.zod.ts`, `validation.zod.ts`); 0 field names.
<details><summary>Live probe: the stock showcase, through the save
door</summary>
`pnpm --filter @objectstack/example-showcase dev -- --fresh -p 4310`
(the `ui/` dists were not built, so `pnpm dev`'s prerequisite check was
skipped; REST only), signed in as the seeded dev admin with `Origin:
http://localhost:4310`.
1. `POST /api/v1/packages` with
`{"manifest":{"id":"app.probevendor","name":"Probe
vendor","version":"1.0.0","type":"app"},"enableOnInstall":true}`: 201.
2. `PUT /api/v1/meta/object/probevendor_ticket?package=app.probevendor`
with fields `title` and `x__c`: **400**, `code: VALIDATION_ERROR`,
`issues: [{ path: 'fields.x__c', code: 'custom' }]`, message: "Field
'x__c' on object 'probevendor_ticket' in package 'app.probevendor': the
`__c` suffix is reserved to a base's paired `.custom` package (ADR-0146
D10); rename the field."
3. The same object without `x__c`: 200, saved active in
`app.probevendor`.
4. `POST /api/v1/packages/com.example.showcase/ensure-customization`:
201, pair `com.example.showcase.custom`, namespace `showcase_custom`.
(The ensure door answers 409 for the writable `app.probevendor`: a pair
is for a read-only base.)
5. `PUT
/api/v1/meta/object/showcase_custom_ticket?package=com.example.showcase.custom`
with fields `title` and `x__c`: **200**, saved active in the pair. The
save door takes no `objectExtensions` entry (#28574's door), so the
pair's own object stands in for "the pair's extension"; the extension
admit is pinned at `os validate` and the install door.
</details>
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01FEkQ5kk6y5435oc5AmphgU
---------
Co-authored-by: Claude <noreply@anthropic.com>__c extension fields only in a base's paired .custom package, judged at every door (ADR-0146 D10) (#28724)1 parent 8bceb6a commit 447a2a6
14 files changed
Lines changed: 1435 additions & 5 deletions
File tree
- .changeset
- packages
- cli
- src
- commands
- utils
- test
- cloud-connection/src
- metadata-protocol/src
- scripts
| 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 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
77 | 77 | | |
78 | 78 | | |
79 | 79 | | |
| 80 | + | |
80 | 81 | | |
81 | 82 | | |
82 | 83 | | |
| |||
690 | 691 | | |
691 | 692 | | |
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 | + | |
| 720 | + | |
| 721 | + | |
693 | 722 | | |
694 | 723 | | |
695 | 724 | | |
| |||
| 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 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 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 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
235 | 235 | | |
236 | 236 | | |
237 | 237 | | |
238 | | - | |
| 238 | + | |
239 | 239 | | |
240 | | - | |
241 | | - | |
242 | | - | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
243 | 243 | | |
244 | | - | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
245 | 255 | | |
246 | 256 | | |
247 | 257 | | |
| |||
| 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 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
0 commit comments