Skip to content

Commit b3615f1

Browse files
Altmanclaude
andauthored
fix(spec)!: publish the ObjectSchema.fields constructor/prototype refusal through the bannedKeys arm (#19346) (#19538)
> ⚠️ **这是一次重建。** 原 PR #19524 在 `os-sam` 账号停用后**不可见**;分支与提交幸存,因为它们属于仓库。 > ⚠️ **本行已更正。** 它原本写的是原 PR「随账号停用一同**被销毁**(404)」。那是**错的**:实测同时为真的三条读数 —— `GET /pulls/19524` 回 **404**、按 head 过滤列 PR 可见 **0** 条、而在**同一条 head** 上 `POST /pulls` 回 **422「A pull request already exists」** ⇒ 对象仍在,仍占着「一条 head 只能有一个 open PR」的唯一性,只是对其他身份**不可见**。⛔ 卡与评论是否也只是隐藏,本席**未实测**,不作推断。本 PR 指向**救援分支**(与原分支同一个 sha `94291658`),内容是同一份工作**外加更正轮 R2**。正文主体取自原 PR 创建时的原文,逐字保留;R2 的增量另起一节写在末尾,⛔ 未混进原文。 Fixes #19346 Clause-②: yes (narrowing) `ObjectSchema.fields` refuses `constructor` and `prototype` as field names. Until this PR that rule was a `.refine()` on the record's **key schema** — a `custom` check, which `z.toJSONSchema()` has no arm for — so it reached the runtime and never `packages/spec/json-schema/**`. Nine `fields.out.keyType` rows in `dropped-refinements.baseline.json` recorded exactly that, one per embedding schema. This PR rewrites the rule as a record-level `bannedKeys(['constructor', 'prototype'])` inside the existing `refuseRecordProtoKey(...)` wrapper. No arm joins the closed projection list: the ban is over a finite list of two names, which is what the existing `banned-keys` arm (#19137) already expresses. ## The measurement — the card's lead, confirmed The card filed this as a lead, not a result, so the first act of the round was to measure it. Both readings are from `pnpm --filter @objectstack/spec gen:schema` in this worktree, each against a named tree. | reading | BEFORE — worktree at `48c39e00` (the branch point, pre-change) | AFTER — worktree at `90321e34` (this head) | |:---|---:|---:| | ledger entries (`publishedSchemasWithDroppedRefinements`) | 204 | **204** | | ledger sites (`droppedRefinementSites`) | 569 | **560** | | `refinementSitesThatDidProject` | 357 | **366** | | ...of which arm `banned-keys` | 2 | **11** | | ...arms `non-blank-string` / `required-one-of` / `dependent-required` | 224 / 129 / 2 | 224 / 129 / 2 | | `refinementSitesWithNoJsonFormToCompare` | 9 | 9 | Nine sites moved from `dropped` to `projected`, **zero sites were added anywhere**, and the entry count is unchanged because every one of the nine schemas keeps other rows. The generator's own diagnostic listed exactly nine `-` lines and no `+` line. The `measured` header block in the ledger was updated to match the body, which `scripts/dropped-refinements.test.ts` pins. The nine rows, by ledger entry: | entry | row deleted | |:---|:---| | `api/AssembledInstalledPackage` | `manifest.objects.element.fields.out.keyType` | | `api/GetInstalledPackageResponse` | `data.options[1].manifest.objects.element.fields.out.keyType` | | `api/InstalledPackageAtEitherStage` | `options[1].manifest.objects.element.fields.out.keyType` | | `api/ListInstalledPackagesResponse` | `data.packages.element.options[1].manifest.objects.element.fields.out.keyType` | | `api/ObjectDefinitionResponse` | `data.fields.out.keyType` | | `data/Object` | `fields.out.keyType` | | `system/ChangeSet` | `operations.element.options[3].object.fields.out.keyType` | | `system/CreateObjectOperation` | `object.fields.out.keyType` | | `system/MigrationOperation` | `options[3].object.fields.out.keyType` | ## The published file, read first-hand A ledger that reads `projected` while the published file carries nothing is the failure this card exists to prevent, so the artifact was read rather than inferred. `packages/spec/json-schema/data/Object.json`, at `.properties.fields`: ```json "propertyNames": { "type": "string", "pattern": "^[a-z_][a-z0-9_]*$" }, "allOf": [ { "propertyNames": { "not": { "enum": ["constructor", "prototype"] } } } ] ``` The record's own key-TYPE rule survives — the ban is conjoined through `allOf`, never substituted — and the matching `fields.out.keyType` entry is gone from that file's `x-dropped-refinements` list. All nine carriers hold the node (`system/ChangeSet` holds two, one per union arm that embeds an object definition); a negative control, `data/Field.json`, holds none. **Validated with ajv 8 (draft 2020-12) on the generated `data/Object.json` itself, both sides.** BEFORE is the file regenerated from the branch point in this same worktree, not a reconstruction: | document at `.properties.fields` | BEFORE | AFTER | |:---|:---|:---| | `{"title":{"type":"text","label":"T"}}` | PASS | PASS | | `{"constructor":{...}}` | **PASS** — the defect | **FAIL** | | `{"prototype":{...}}` | **PASS** — the defect | **FAIL** | | CONTROL `{"constructors":{...}}` | PASS | PASS | | CONTROL `{"to_string":{...}}` | PASS | PASS | Across the published tree, **1524 of 1535 files are byte-identical** (measured by regenerating both sides in this worktree and `diff -rq`): the nine carriers above, plus the bundle `objectstack.json` and the build-input hash. ## What moves, and what does not **The runtime accept set does not move.** `bannedKeys` reads OWN properties and never `key in value`, which is what a record's key loop visits too; it is presence and never value. Every document the runtime accepted before it accepts now, and the two names it refused it still refuses. **The refusal's LOCATION moves, and a consumer reading issues by path will see it.** This is the cost of the projection and it is stated in the changeset as a FROM/TO mapping: | | before | after | |:---|:---|:---| | issue `path` | `['fields', 'the offending key']` | `['fields']` | | issue `code` | `invalid_key` | `custom` | | the reason text | nested under zod's fixed "Invalid key in record" | the issue's own `message` | The message text is unchanged and names both reserved words in full. The closed list can only publish a record-level predicate, and `.refine()` carries no per-key path, so a located-per-key refusal and a published refusal cannot both come from one rule. With a closed two-name ban, the slot is still named and both candidate keys are named in the message. **`__proto__` is untouched.** Its guard is `refuseRecordProtoKey`'s `z.preprocess` on the raw input, because zod's record parser skips that one name with an unconditional `continue` above the key schema. It holds no ledger row and gains no keyword here. This round reaches two of the three names, never three — exactly as the card measured. ## Tests `packages/spec/src/data/object.test.ts` gains three cases pinning the half that had none, and its existing behaviour pin moves with the mechanism. The projection goes through the shared `projectPublishedJsonSchema` helper on the generator's own io ladder (`data/Object` publishes as the input shape), never a local `z.toJSONSchema()`, so a pin cannot stay green while the published file goes wide. The corpus assertion is an EQUALITY between the runtime verdict and the published keywords, with near-miss controls (`constructors`, `to_string`), and the evaluator throws rather than passing vacuously when the node states no ban. **Reverse verification.** With the pre-change key-schema `.refine()` restored on disk (mutation proven by content hash `817d2dfd` to `ef293623` and by grep counts: `bannedKeys` 4 to 0, the old predicate 0 to 1), **5 of the 200 cases turn red** — the two behaviour pins and all three published-half pins. Restored from `HEAD` afterwards and the restoration proven by hash equality with the `HEAD` blob, not by an exit code. ## Verification Anchored at head `90321e34` (a merge of `origin/main` `f34dda62` into this branch; nothing upstream has touched this PR's carriers since). - `node scripts/pm/dispatch-gates.mjs --changed`: **83 derived, 83 run, 0 NOT-MEASURED, 0 UNRUN**, every family exit 0, each code captured before any pipe and reconciled through `--ran`. - `pnpm --filter @objectstack/spec typecheck` — exit 0. - `pnpm --filter @objectstack/spec test` — exit 0, **508 files / 14874 tests passed**. - `pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'` — exit 0, 72/72 tasks. Four gates (`check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:type-check-debt`, `check:doc-formula-expressions`) first answered exit 3 PREREQUISITE NOT MET against an unbuilt closure; they were re-run green after the build rather than recorded as passes. - A changeset is included, `minor`, carrying the `Clause-②` declaration, the FROM/TO migration sentence and its ADR-0087 disposition. ## Acceptance notes Noted, not filed: - `packages/spec/dropped-refinements.baseline.json` carries a hand-maintained `measured` header block, of which `scripts/dropped-refinements.test.ts` pins two fields (`publishedSchemasWithDroppedRefinements`, `droppedRefinementSites`) against the body. The other two (`refinementSitesThatDidProject`, `refinementSitesWithNoJsonFormToCompare`) are pinned by nothing, so they can drift from the generator's own census without any gate noticing. Both were updated by hand here from this run's output. Not a defect in this PR's sense — no contract is violated and nothing is dropped — and closing it would add a ratchet row, which tonight's standing ruling forbids without the maintainer's sentence. --- ## ⚠️ R2 增量 —— 原 PR 正文写于 `90321e34`,本分支现为 `94291658` 原 PR 描述的是上一个提交。此后多了一个提交,内容如下。 ### 1. CI 红已修,而根因不是我们的引用 `Lint & Repo Gates` 在 `90321e34` 上 exit 2,红在 `check-issue-citations`:`[allocated-but-absent] objectstack#17852`。 ⭐ **本地 exit 0 / CI exit 2 并不是同一个问题的两个答案**:`package.json` 里的 `check:issue-citations` 只是 `--self-test`,而 `lint.yml` 另外还跑一条**裸的**判决命令;派发令的门禁族清单只吐前者 ⇒ 那一轮**从未跑过真正的判决**。 ⛔ **没有按门禁的处方写假话。**它建议「保留号码并在散文里说明它不再解析」—— 而 #17852 当时实测 **HTTP 200,解析得了**。采用的是门禁自己文档里的约定:`objectstack#17852` → `#17852`(裸 `#N` 即本仓)。改后该命令 exit 0。 ⚠️ 底下还压着一个**真实的门禁缺陷**(`buildBoard` 把所有带限定符的引用踢出探测集,而 `classifyCitation` 又拿本仓限定符去查那块板子),它另有卡承接。 ### 2. 一处被本 PR 弄假的散文已修 `packages/spec/src/shared/record-proto-key-guard.ts` 原写着两个名字「在自己的 key grammar 里」被拒 —— 而本 PR 正是把那条拒绝从 key grammar 移到了 record 上。该段**改写而非删除**:它关于「⛔ 不要扩大本守卫自身名单」的论点仍然成立且承重。PR 文件数 4 → 5。 ### 3. changeset 补上已发布信封的迁移 实测(两侧各跑一次真 `ObjectSchema` + 真 `zodIssuesToFields`):`field` 由 `fields.constructor` → `fields`,条目数 **2 → 1**,且 `invalid_shape` 这个**已发布枚举值**在此拒绝上**不再出现**。 ### 4. 钉子的 `catch {}` 已收紧 原来裸吞异常 ⇒ 输出投影若因别的真实原因失败,**构建会红而钉子会绿**。现在捕获后除非消息含 `cannot be represented in JSON Schema` 否则重抛,并明写第三级 rung 未建模。 ### CI 状态 —— ⚠️ 本节已重写 原文在此处写的是「**在 `94291658` 上 NOT MEASURED** —— 该提交刚推上,尚无任何 check-run」。那句话在写下时是真的,**现在过期了**,原样记在这里,免得读者以为它被悄悄换掉。 本席于 **2026-09-21T08:33Z** 在 `94291658` 上第一手重取花名册: ``` 39 条 check-run,全部 completed —— success 33 · skipped 6 · failure 0 · cancelled 0 七条必需上下文逐条点名,全部 success: Lint & Repo Gates 08:19:38Z ← 引用修复绿的就是这一条 TypeScript Type Check 08:22:06Z Test Core 08:23:59Z Dogfood Regression Gate 08:16:34Z Build Core 08:13:57Z Temporal Conformance (live PG + MySQL) 08:16:17Z Governed Surface Queue Guard 08:05:59Z 6 条 skipped:Console Pin Gate · Build Docs · Packed-tarball smoke (opt-in) · Check PR Size · Auto Label mergeable_state: clean ``` ⛔ `skipped` 是路径过滤器的结果,不是失败;本 head 上 **没有一条 `cancelled`**。 ### Docs Drift 回执(评论 `5757326519`)—— 已核,⛔ 不欠文档修改 正文刷新于 2026-09-21T08:35Z,以下是对该机器人评论的逐条回执。⚠️ 这次是在**当前 main `ecf56e79`** 上重取的,⛔ 不是沿用原轮次的旧读数。 它点名 `content/docs/concepts/metadata-driven.mdx`。实测该页**唯一**一处 `ObjectSchemaBase` 在第 372 行,说的是 `z.input<>` 的**编译期类型**;`constructor` / `prototype` / `__proto__` 在该页 **0 命中**(亮控:`fields` 在同页出现 9 次 ⇒ grep 够得着这一页)。本 PR 把 `.refine()` 从 key schema 搬到 record 上,**两侧的 `z.input` 都不变** ⇒ 该句不因本 PR 变假。 ⚠️ 一条缺口如实带上:该 drift 自己声明**有 2 个改动文件产不出锚点**(其中包括 R2 改的 `packages/spec/src/shared/record-proto-key-guard.ts`),并写明「这不是一份干净健康证明」。⇒ 那个文件的散文正确性**不在该仪器覆盖内**,靠的是 R2 自己的修正与其后的在档复核。 --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3e8e2b0 commit b3615f1

5 files changed

Lines changed: 225 additions & 35 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
**BREAKING (published artifact narrows)** — `packages/spec/json-schema/**` now states the `constructor` / `prototype` field-name ban that `ObjectSchema.fields` has always enforced, so a validator reading the published files stops answering PASS on `{"constructor":{"type":"text","label":"R"}}` at `data/Object.properties.fields` — a document the runtime refuses by name (#19346; #18670 item 2, through the `banned-keys` arm).
6+
7+
Clause-②: yes (narrowing)
8+
9+
**No arm joins the closed list.** The ban is over a FINITE list of two names, which is exactly what the existing `banned-keys` arm expresses, so this is a call site moving onto a declared pattern rather than a new public-contract decision. What moved is WHERE the refusal is written: from a `.refine()` on the record's KEY schema to a record-level `bannedKeys(['constructor', 'prototype'])` inside the existing `refuseRecordProtoKey(...)` wrapper. A key-schema `.refine()` is a `custom` check, and `z.toJSONSchema()` has no arm for one, so that rule reached the runtime and never the file.
10+
11+
**The rows retired, by name.** `packages/spec/dropped-refinements.baseline.json` goes from 204 entries / 569 sites to **204 entries / 560 sites** — nine site deletions, no entry deletions (every one of the nine schemas keeps other rows), and **0 sites added anywhere**:
12+
13+
| ledger entry | row deleted |
14+
|:---|:---|
15+
| `api/AssembledInstalledPackage` | `manifest.objects.element.fields.out.keyType` |
16+
| `api/GetInstalledPackageResponse` | `data.options[1].manifest.objects.element.fields.out.keyType` |
17+
| `api/InstalledPackageAtEitherStage` | `options[1].manifest.objects.element.fields.out.keyType` |
18+
| `api/ListInstalledPackagesResponse` | `data.packages.element.options[1].manifest.objects.element.fields.out.keyType` |
19+
| `api/ObjectDefinitionResponse` | `data.fields.out.keyType` |
20+
| `data/Object` | `fields.out.keyType` |
21+
| `system/ChangeSet` | `operations.element.options[3].object.fields.out.keyType` |
22+
| `system/CreateObjectOperation` | `object.fields.out.keyType` |
23+
| `system/MigrationOperation` | `options[3].object.fields.out.keyType` |
24+
25+
Generator census after: **560 dropped across 204 published schemas, 366 projected** — 224 `non-blank-string`, 129 `required-one-of`, **11 `banned-keys`** (2 before), 2 `dependent-required` — 9 undecidable. Across the published tree, **1524 of 1535 files are byte-identical**: the nine carriers above each gain the ban and lose their matching `x-dropped-refinements` row, and the remaining two are the bundle (`objectstack.json`) and the build-input hash.
26+
27+
**⛔ The set of documents the runtime accepts does not move.** The arm is EXACT rather than approximate: a JSON object's properties are exactly its own enumerable string-keyed ones and `propertyNames` judges exactly those names, and `bannedKeys` reads OWN properties and never `key in value` — which is what the key schema judged too, since a record's key loop only ever visits own keys. It is presence and never value: a banned key present with a `null` value is present on both sides. Measured with ajv 8 (draft 2020-12) on the generated `data/Object.json`, before and after, the verdict vector moves in one direction only — `{"constructor": …}` and `{"prototype": …}` go `true` to `false`, while an ordinary document and the near-miss controls `{"constructors": …}` and `{"to_string": …}` are accepted on both sides.
28+
29+
**⚠️ What DOES move is the refusal's location, and a consumer will see it at BOTH layers** — the raw zod issue, and the published `{field, code, message}` envelope every REST / data-API client reads (ADR-0114, built by `api/zod-issues-to-fields.ts`). Measured on this tree by parsing `{"name":"lead","label":"Lead","fields":{"title":{…},"constructor":{…}}}` with the schema before and after:
30+
31+
| layer | | before | after |
32+
|:---|:---|:---|:---|
33+
| raw zod issue | `path` | `['fields', '<the offending key>']` | `['fields']` |
34+
| raw zod issue | `code` | `invalid_key` | `custom` |
35+
| raw zod issue | where the reason text sits | nested one level down, under zod's fixed "Invalid key in record" | the issue's own `message` |
36+
| published envelope | entries | **2** | **1** |
37+
| published envelope | `field` | `fields.constructor` on both entries | `fields` |
38+
| published envelope | `code` | `invalid_shape` (zod's "Invalid key in record") **and** `invalid_value` (the reason) | `invalid_value` alone |
39+
40+
⚠️ `invalid_shape` is a member of the published `FieldErrorCode` vocabulary and it no longer appears for this refusal at all. A client that branched on `invalid_shape` to detect a rejected field NAME must branch on `invalid_value` at `field: "fields"` instead, and must stop expecting two entries where it now receives one.
41+
42+
The message text is unchanged and still names both reserved words in full. The fix for a consumer that keyed on the old shape: match the issue at path `fields` with code `custom` — `field: "fields"`, `code: "invalid_value"` in the envelope — and read its `message` directly, instead of descending into an `invalid_key` issue's nested `issues[0]`. This is the cost of the projection: the closed list can only publish a RECORD-level predicate, and `.refine()` carries no per-key path, so a located-per-key refusal and a published refusal cannot both be had from one rule. The ban list is closed and two names long, so the slot is still named and the two candidate keys are both named in the message.
43+
44+
**⛔ `__proto__` is untouched, and it is a third name rather than a third case.** Its guard is `refuseRecordProtoKey`'s `z.preprocess` on the raw input, because zod's record parser skips that one name with an unconditional `continue` ABOVE the key schema — no schema, and therefore no projection, can ever see it. It holds no ledger row and gains no published keyword here. This change reaches two of the three names, never three.
45+
46+
<!-- adr-0087: not-required (no-migration-prescription) Nothing an author can write is removed, renamed or re-spelled: no spec key, no export and no config field changes, and the set of metadata documents the runtime accepts is exactly what it was. What changed is a machine-readable DECLARATION catching up with the runtime it always described, plus the shape of the issue the refusal raises -- so there is nothing for `objectstack migrate meta` to rewrite and no stored representation to convert. -->

‎packages/spec/dropped-refinements.baseline.json‎

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
"measured": {
44
"zod": "4.4.3",
55
"publishedSchemasWithDroppedRefinements": 204,
6-
"droppedRefinementSites": 569,
7-
"refinementSitesThatDidProject": 357,
6+
"droppedRefinementSites": 560,
7+
"refinementSitesThatDidProject": 366,
88
"refinementSitesWithNoJsonFormToCompare": 9
99
},
1010
"entries": {
@@ -66,7 +66,6 @@
6666
"manifest.objectExtensions.element",
6767
"manifest.objects.element",
6868
"manifest.objects.element.fieldGroups",
69-
"manifest.objects.element.fields.out.keyType",
7069
"manifest.objects.element.fields.out.valueType",
7170
"manifest.objects.element.fields.out.valueType.currencyConfig",
7271
"manifest.objects.element.lifecycle",
@@ -158,7 +157,6 @@
158157
"data.options[1].manifest.objectExtensions.element",
159158
"data.options[1].manifest.objects.element",
160159
"data.options[1].manifest.objects.element.fieldGroups",
161-
"data.options[1].manifest.objects.element.fields.out.keyType",
162160
"data.options[1].manifest.objects.element.fields.out.valueType",
163161
"data.options[1].manifest.objects.element.fields.out.valueType.currencyConfig",
164162
"data.options[1].manifest.objects.element.lifecycle",
@@ -240,7 +238,6 @@
240238
"options[1].manifest.objectExtensions.element",
241239
"options[1].manifest.objects.element",
242240
"options[1].manifest.objects.element.fieldGroups",
243-
"options[1].manifest.objects.element.fields.out.keyType",
244241
"options[1].manifest.objects.element.fields.out.valueType",
245242
"options[1].manifest.objects.element.fields.out.valueType.currencyConfig",
246243
"options[1].manifest.objects.element.lifecycle",
@@ -283,7 +280,6 @@
283280
"data.packages.element.options[1].manifest.objectExtensions.element",
284281
"data.packages.element.options[1].manifest.objects.element",
285282
"data.packages.element.options[1].manifest.objects.element.fieldGroups",
286-
"data.packages.element.options[1].manifest.objects.element.fields.out.keyType",
287283
"data.packages.element.options[1].manifest.objects.element.fields.out.valueType",
288284
"data.packages.element.options[1].manifest.objects.element.fields.out.valueType.currencyConfig",
289285
"data.packages.element.options[1].manifest.objects.element.lifecycle",
@@ -323,7 +319,6 @@
323319
"data.actions.element.in",
324320
"data.actions.element.in.params.element.in",
325321
"data.fieldGroups",
326-
"data.fields.out.keyType",
327322
"data.fields.out.valueType",
328323
"data.fields.out.valueType.currencyConfig",
329324
"data.fields.out.valueType.relatedListFilter.lazy",
@@ -683,7 +678,6 @@
683678
"actions.element.in",
684679
"actions.element.in.params.element.in",
685680
"fieldGroups",
686-
"fields.out.keyType",
687681
"fields.out.valueType",
688682
"fields.out.valueType.currencyConfig",
689683
"fields.out.valueType.relatedListFilter.lazy",
@@ -954,7 +948,6 @@
954948
"operations.element.options[3].object.actions.element.in",
955949
"operations.element.options[3].object.actions.element.in.params.element.in",
956950
"operations.element.options[3].object.fieldGroups",
957-
"operations.element.options[3].object.fields.out.keyType",
958951
"operations.element.options[3].object.fields.out.valueType",
959952
"operations.element.options[3].object.lifecycle",
960953
"operations.element.options[3].object.listViews.valueType",
@@ -972,7 +965,6 @@
972965
"object.actions.element.in",
973966
"object.actions.element.in.params.element.in",
974967
"object.fieldGroups",
975-
"object.fields.out.keyType",
976968
"object.fields.out.valueType",
977969
"object.fields.out.valueType.currencyConfig",
978970
"object.fields.out.valueType.relatedListFilter.lazy",
@@ -1015,7 +1007,6 @@
10151007
"options[3].object.actions.element.in",
10161008
"options[3].object.actions.element.in.params.element.in",
10171009
"options[3].object.fieldGroups",
1018-
"options[3].object.fields.out.keyType",
10191010
"options[3].object.fields.out.valueType",
10201011
"options[3].object.lifecycle",
10211012
"options[3].object.listViews.valueType",

0 commit comments

Comments
 (0)