Skip to content

Commit 7adc841

Browse files
baozhoutaoclaude
andauthored
fix(spec): 退休登记按确切 key 判定,叶名匹配不再让无关簇替 tombstone 背书 (#4659) (#5902)
`build-schemas.ts` 检查 (b)(`check:authorable-surface`)此前判定「这次退休 已登记」的方式是:取 key 的叶名,和全部 major 的所有 conversion / migration `surface` 子句做 `endsWith('.' + name)`,完全不看 key 属于哪个 def。任何无关 登记只要 surface 以同名叶子结尾,就替这个墓碑背书。#4658 实测: `automation/Event:type` 零 conversion 静默通过,命中的是 protocol 11 的 `flow.node.type`;#5509 之后 `.description` 也进了这个免检名单。 - 新增导出 `RETIRED_KEYS_BY_MAJOR`(`src/migrations/registry.ts` 末尾追加, 未改动该文件任何既有行),值是确切的 `${defKey}:${name}`。 - 检查 (b) 改为对该表精确集合判定;失败信息直接打印要粘贴的那一行和 major。 - 新增检查 (b2):登记了一个仍然 live 的 key 直接失败;登记了一个本次构建已不 再产出的 key 不是错误(墓碑老化后的预期稳态)。 - conversion 的 `surface` 散文一字未动;检查 (c) 的叶名匹配保留,原因与后续 处置记在 #5898。 - 退休 playbook(`.claude/skills/spec-property-retirement/SKILL.md`)同步: 原先那条「surface 必须以裸 key 结尾」正是本单的缺陷,已改写。 Claude-Session: https://claude.ai/code/session_01559M8FVm6W6vDLABL3jvdW Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7f1a635 commit 7adc841

7 files changed

Lines changed: 514 additions & 36 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): 退休登记按**确切 key** 判定 —— 无关簇的同名叶子不再让 tombstone 冒充「已登记」(#4659)
6+
7+
`scripts/build-schemas.ts` 的检查 (b)(`check:authorable-surface`)保证一件事:
8+
一个 authorable key 从 live 翻成 retired(`retiredKey()` 墓碑)时,这次退休必须
9+
被登记下来,否则 `spec-changes.json`(ADR-0087 D4)、生成的升级指南、`spec_changes`
10+
MCP 工具全是空的,消费者只能靠失败才知道。
11+
12+
它此前是这样判定「已登记」的:取 key 的**叶名**,拿去和**全部 major** 的所有
13+
conversion / migration `surface` 子句做 `endsWith('.' + name)` —— 完全不看 key
14+
属于哪个 def。于是任何一条无关登记,只要 surface 以同名叶子结尾,就把这个墓碑判为
15+
「已登记」:
16+
17+
- #4658 实测:`automation/Event:type` 被 tombstone、零 conversion,门禁全绿 ——
18+
命中的是 protocol 11 的 `flow-node-http-callout-rename`(`flow.node.type`),
19+
一个 flow 节点的 `type`,和状态机事件毫无关系。
20+
- #5509(ADR-0087 D2 `page-header-subtitle-alias`)登记
21+
`page.component.page-header.description` 之后,任何叶名为 `description` 的 key
22+
也进了同一个免检名单。`type` / `name` / `config` / `filter` / `schema` /
23+
`description` 都是 authorable 形状上最常见的叶子,这条保证对它们整体失效。
24+
25+
现在登记有了自己的表:
26+
27+
- **新增导出 `RETIRED_KEYS_BY_MAJOR`**(`@objectstack/spec`,
28+
`src/migrations/registry.ts`),值就是确切的 `` `${defKey}:${name}` `` 字符串 ——
29+
`authorable-surface.json` 怎么写它就怎么写,去掉 `[RETIRED]` 标记。
30+
- 检查 (b) 改为对这张表做**精确集合判定**:不再有 `endsWith`,不再取叶名,不再从
31+
相邻的 key 辐射过来。门禁失败时直接打印要粘贴的那一行和它该进哪个 major。
32+
- 新增检查 (b2):表里登记了一个**当前仍然 live** 的 key —— 一次没有任何东西消费的
33+
登记 —— 直接失败;它会替一次尚未发生的退休提前放行。登记了一个本次构建**已不再
34+
产出**的 key 则不是错误:墓碑满 ~2 个 major 之后由检查 (c) 放行其基线行,登记条目
35+
留下,这是预期稳态。
36+
37+
conversion 的 `surface` 保持散文形态、一个字没动:它面向作者、按作者书写元数据的
38+
形状表达(`flow.nodes[].outputSchema`),本来就无法可判定地映射回 def key。所以
39+
搬走的是机器事实,不是散文。一次退休仍然两样都要写:登记条目是**声明的凭据**,
40+
conversion 是**消费者照着做的处方**
41+
42+
不回填历史:检查 (b) 只在相对已提交基线的 live → retired ****跃迁上触发,而更早的
43+
墓碑在基线里已经是 `[RETIRED]`,不会再触发它。所以这张表读作「在确切-key 门禁下登记的
44+
退休」,不是「历史上的全部退休」—— 空表在 `main` 上实测全绿。

.claude/skills/spec-property-retirement/SKILL.md

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -171,10 +171,15 @@ jumping several majors at once, whom the load-path conversion no longer covers.
171171

172172
## 3. Register the surface (ADR-0087 D2/D3) — or the gate stops you
173173

174-
`scripts/build-schemas.ts` gate (b) fails any newly-tombstoned key with no
175-
registered migration surface: the tombstone is audible only to whoever *hits*
176-
it, while `spec-changes.json`, the generated upgrade guide and the
177-
`spec_changes` MCP tool are the primary channel and would stay empty.
174+
`scripts/build-schemas.ts` gate (b) fails any newly-tombstoned key that is not
175+
registered by its **exact** `${defKey}:${name}` in `RETIRED_KEYS_BY_MAJOR`: the
176+
tombstone is audible only to whoever *hits* it, while `spec-changes.json`, the
177+
generated upgrade guide and the `spec_changes` MCP tool are the primary channel
178+
and would stay empty.
179+
180+
**This is two separate obligations, and only one of them is a string match.**
181+
The registry entry is what the gate reads; the conversion is what a consumer
182+
follows. Write both.
178183

179184
- [ ] **A `MetadataConversion`** in `packages/spec/src/conversions/registry.ts`:
180185
kebab-case `id` ending `-removed`, `toMajor`, one
@@ -183,13 +188,27 @@ it, while `spec-changes.json`, the generated upgrade guide and the
183188
not the item count. Walkers (`mapCollection`, `mapFlowNodes`, `renameKey`)
184189
live in `conversions/walk.ts` and are copy-on-write — return the input
185190
reference untouched when nothing matched.
186-
- [ ] **`surface` must end with the bare key.** The matcher is
187-
`surfaces.some((s) => s.endsWith('.' + key))` after
188-
`.flatMap((s) => s.split(' / '))`. Multi-key conversions join clauses with
189-
exactly `' / '` (house style since the tool sweep) and **each clause must
190-
end with its own key**. Caveat: only the last dotted segment is compared,
191-
so the schema name is never checked — `dashboard.aria` would satisfy
192-
`ui/FormView:aria`. Don't lean on the gate for attribution.
191+
- [ ] **A `RETIRED_KEYS_BY_MAJOR` entry** in
192+
`packages/spec/src/migrations/registry.ts` — the literal
193+
`'<defKey>:<name>'` as `authorable-surface.json` spells it, minus the
194+
`[RETIRED]` mark, under this major. This is the string gate (b) reads, by
195+
exact set membership; nothing is inferred and nothing radiates from a
196+
neighbouring key. The gate's failure prints the line to paste. ⚠ Do **not**
197+
add the entry before the tombstone lands: an entry naming a key that is
198+
still live fails gate (b2) as a registration nothing consumed.
199+
*Why a second table:* until #4659 gate (b) matched the key's **leaf** against
200+
every registered `surface` (`endsWith('.' + name)`, all majors, def
201+
ignored), so `dashboard.aria` registered `ui/FormView:aria` and protocol
202+
11's `flow.node.type` registered any `.type` at all (#4658). The guarantee
203+
had lapsed for every common leaf.
204+
- [ ] **`surface` stays prose — it is no longer matched.** Write it the way an
205+
author writes metadata (`flow.nodes[].outputSchema`), which is what the
206+
upgrade guide prints. Multi-key conversions still join clauses with exactly
207+
`' / '` (house style since the tool sweep). Nothing downstream parses it
208+
for attribution any more — that job moved to the entry above. ⚠ One
209+
consumer still does read the clauses by leaf: gate (c)'s *aged-out
210+
tombstone* proof, which adjudicates retirements older than
211+
`RETIRED_KEYS_BY_MAJOR` and could not be moved with it (#5898).
193212
- [ ] **`retiredFromLoadPath: true`** — for a retirement, always. Two distinct
194213
justifications, and they are not interchangeable: for a *rename* it means
195214
"no alias window, deliberately" (the tombstone owns the refusal; the entry
@@ -243,7 +262,7 @@ Work top to bottom; each line has a gate behind it.
243262
keys from `defineAction`'s input and the snapshot did not change). Its gate
244263
also lives in a different workflow (`TypeScript Type Check`, not
245264
`Check Generated Artifacts`) and reads the built `dist/*.d.ts`.
246-
- [ ] **Conversion + chain step** (§3).
265+
- [ ] **Conversion + chain step + the exact-key `RETIRED_KEYS_BY_MAJOR` entry** (§3).
247266
- [ ] **Liveness ledger** — per §2's route table, with `verifiedAt`. Update the
248267
README's per-type row **and its counts** (that table has drifted badly
249268
once; regenerate the counts with the python snippet in the README rather

packages/spec/api-surface.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@
9191
"PredicateInput (type)",
9292
"PredicateInputSchema (const)",
9393
"PredicateSchema (const)",
94+
"RETIRED_KEYS_BY_MAJOR (const)",
9495
"STACK_KEY_GUIDANCE (const)",
9596
"STACK_RUNTIME_MEMBERS (const)",
9697
"SemanticMigration (interface)",

0 commit comments

Comments
 (0)