Commit b3615f1
> ⚠️ **这是一次重建。** 原 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
- .changeset
- packages/spec
- src
- data
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
3 | 3 | | |
4 | 4 | | |
5 | 5 | | |
6 | | - | |
7 | | - | |
| 6 | + | |
| 7 | + | |
8 | 8 | | |
9 | 9 | | |
10 | 10 | | |
| |||
66 | 66 | | |
67 | 67 | | |
68 | 68 | | |
69 | | - | |
70 | 69 | | |
71 | 70 | | |
72 | 71 | | |
| |||
158 | 157 | | |
159 | 158 | | |
160 | 159 | | |
161 | | - | |
162 | 160 | | |
163 | 161 | | |
164 | 162 | | |
| |||
240 | 238 | | |
241 | 239 | | |
242 | 240 | | |
243 | | - | |
244 | 241 | | |
245 | 242 | | |
246 | 243 | | |
| |||
283 | 280 | | |
284 | 281 | | |
285 | 282 | | |
286 | | - | |
287 | 283 | | |
288 | 284 | | |
289 | 285 | | |
| |||
323 | 319 | | |
324 | 320 | | |
325 | 321 | | |
326 | | - | |
327 | 322 | | |
328 | 323 | | |
329 | 324 | | |
| |||
683 | 678 | | |
684 | 679 | | |
685 | 680 | | |
686 | | - | |
687 | 681 | | |
688 | 682 | | |
689 | 683 | | |
| |||
954 | 948 | | |
955 | 949 | | |
956 | 950 | | |
957 | | - | |
958 | 951 | | |
959 | 952 | | |
960 | 953 | | |
| |||
972 | 965 | | |
973 | 966 | | |
974 | 967 | | |
975 | | - | |
976 | 968 | | |
977 | 969 | | |
978 | 970 | | |
| |||
1015 | 1007 | | |
1016 | 1008 | | |
1017 | 1009 | | |
1018 | | - | |
1019 | 1010 | | |
1020 | 1011 | | |
1021 | 1012 | | |
| |||
0 commit comments