Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 18 additions & 18 deletions .claude/skills/spec-property-retirement/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,8 @@ conversion,钉上 non-warn。十四个键里有一个是这样被证伪的 —

| Schema | 路线 | 机制 |
|---|---|---|
| **非 `.strict()`** | `retiredKey()` 墓碑 | `packages/spec/src/shared/retired-key.ts` 的 `retiredKey(guidance)` —— `z.never({ error: () => guidance }).optional()`。两个通道:`tsc`(输入类型 `never`)与 parse(处方本身,不是 "unrecognized key")。 |
| **`.strict()`** | 删键 + guidance map | 从 shape 里删除;向该 schema 的 `*_RETIRED_KEY_GUIDANCE` 加条目,由 `strictObject()` 的 `guidance:` 槽消费(`shared/strict-object.ts`;整族一条走 `guidanceSets`)。样板 `ai/tool.zod.ts`,审计 `shared/alias-integrity.test.ts`。⛔ 别再手写 `$ZodErrorMap`。 |
| **任何 shape**(strict 与否) | `retiredKey()` 墓碑 | `packages/spec/src/shared/retired-key.ts` 的 `retiredKey(guidance)` —— `z.never({ error: () => guidance }).optional()`。两个通道:`tsc`(输入类型 `never`)与 parse(处方本身)。strict 上裸删也响,但只报 "unrecognized key",两通道都丢。 |
| **从未声明的拼写** | guidance map | 退役键的旧 alias、错层指针,墓碑无属性可换:向 `*_RETIRED_KEY_GUIDANCE` 加条目,由 `strictObject()` 的 `guidance:` 槽消费(整族走 `guidanceSets`)。样板 `data/mapping.zod.ts`,审计 `shared/alias-integrity.test.ts`。⛔ 别手写 `$ZodErrorMap`。 |
| **没人 parse 它** | 都不用 | 没人能收到的处方是噪音。有意删掉 baseline 行并在 changeset 里写明 —— 先例 #3896 与 #4834(PR #4878),都在 kernel plugin-runtime 家族。家族删除后幸存的解释块在 `packages/spec/src/kernel/index.ts`(搜 `plugin-runtime.zod`)。 |

永不从非 strict schema 上裸删一个键:zod 会静默剥掉它,你只是用一个静默 no-op 换了
Expand All @@ -99,18 +99,15 @@ liveness 门禁走的是 **schema 的 shape**,逐个属性去
| 路线 | 键还在被走的 shape 里? | 它的台账条目 |
|---|---|---|
| `retiredKey()` 墓碑 | **在**(`z.never()` 是属性) | **保留** —— `status: "dead"`、一个 `verifiedAt`、一条 `note` 写明 REMOVED + 条目为何还在 |
| strict 删除 | 不在 | **删除**,连同 CLI advisory-lint 的预期 |
| 删键(无墓碑) | 不在 | **删除**,连同 CLI advisory-lint 的预期 |

现在两个方向都会红 CI,搞反了两边都很响:

- 删掉**墓碑**键的行,报 **UNCLASSIFIED**(#3896 清扫一次 14 个 —— 本节就是防它);
- 留着 **strict 删除**键的行,报 **ORPHAN** 行。
- 留着**无墓碑删除**的键的行,报 **ORPHAN** 行。

orphan 这条腿是新的(`packages/spec/scripts/liveness/orphans.mts`)。它落地之前这个方向从不失败
—— 门禁走 schema 再查行,键已离开 shape 的行根本不会被问到,原地腐烂。report 的
`aria`/`performance` 行就这样比它们的键多活了一整个 release,靠有人恰好读到那个文件
才手工删掉。你撞上 orphan 报错而属性确实还可编写时,要修的是 **walk**,不是行:
walk 看不见的属性就是 ratchet 管不到的属性。
orphan 这条腿住 `packages/spec/scripts/liveness/orphans.mts`,来历见其头注。你撞上 orphan 报错而属性
确实还可编写时,要修的是 **walk**,不是行:walk 看不见的属性就是 ratchet 管不到的属性。

墓碑条目的 note 模板(house style 原文,如 `liveness/action.json`):

Expand Down Expand Up @@ -204,18 +201,21 @@ conversion 是消费者跟的;D3 条目是升级方的 agent 读的。三个都
(`flow.nodes[].outputSchema`),那也是 upgrade guide 打印的。多键 conversion
仍用恰好 `' / '` 连接子句(tool 清扫以来的 house style)。下游不再有任何东西
从它解析归属 —— 那个职责移给了上面的条目。
- [ ] **`retiredFromLoadPath: true`** —— 退役恒真,但管辖权只有 authoring 漏斗
- [ ] **`retiredFromLoadPath: true` 与 `retiredAfter: 'x.y.z'`** —— 后者必填(缺则 `tsc` 拒),新退役填
`packages/spec/package.json` 的当前版本标签(`retired-after.census.test.ts` 逐值钉;artifact 门据它
逐条开窗)。前者退役恒真,但管辖权只有 authoring 漏斗
`normalizeStackInput`;三处 data-at-rest seam 以 `includeRetired: true` 故意重放退役
条目,它**一处也拦不住**:`applyConversionsToStoredItem`(钉死)、automation
engine 的 flow rehydration、`applyArtifactForwardConversions`。对*改名*它意味着
「没有 alias 窗口,故意的」;对**默认值翻转**,只有确知输入早于翻转的 seam 才可重
放,其余按 id 退订 `excludeConversionIds` —— `app-hidden-to-unpublished` 在 artifact
门即如此。上一版样例栽在这:它教「只有 migrate meta 能应用翻转」,而 boot 时照样
应用,该 conversion 已撤(`packages/spec/CHANGELOG.md`)。
- [ ] **一步 D3 链**,在 `packages/spec/src/migrations/registry.ts` —— 把 id 加进
`MIGRATIONS_BY_MAJOR[N].conversionIds`,扩写该步的 `rationale`。
`conversion.toMajor` **必须等于**该步的 major。⚠ 没有东西直接断言「每个
conversion 都接进了某一步」,拼错的 id 在 replay 时被**静默跳过**;
门即如此。
- [ ] **一步 D3 链**,在 `packages/spec/src/migrations/registry.ts`;`conversion.toMajor` **必须等于**该步的
major。**18 步**:conversion 只进 `conversions/registry.ts` 的 `MAJOR_18_CONVERSIONS`,照其头注按
标识符排序插入,本步 `conversionIds` 由它派生;`rationale` 只加一个 `STEP18_RATIONALE` 片段,
按其头注插在你 D3 semantic id 的排序位;尾部追加被两处头注点名的 merge 测试拒收。
**更早的步**:id 加进 `MIGRATIONS_BY_MAJOR[N].conversionIds`,扩写该步 `rationale`。⚠ 没有东西
直接断言「每个 conversion 都接进了某一步」,拼错的 id 在 replay 时被**静默跳过**;
chain-replay 测试抓得到它,只因为没接线的 fixture 永远到不了自己的 `after`。
所以把那个测试的失败读作「没接线」,不是「transform 坏了」。
- [ ] **fixture 必须不相交 —— 两重。** 每个 fixture 都被整张表 replay,必须恰好等于
Expand All @@ -239,7 +239,7 @@ conversion 是消费者跟的;D3 条目是升级方的 agent 读的。三个都

从上往下做;每一行背后都有一个门。

- [ ] **Schema** —— 墓碑或 strict 删除(§2),外加 schema 内注释:删了什么、真正生
- [ ] **Schema** —— 墓碑或无墓碑删键(§2),外加 schema 内注释:删了什么、真正生
效的机制是什么。
- [ ] **孤儿值 schema** —— 一个键的 `XxxConfigSchema` 没有别的消费者就随它一起走
(`PerformanceConfigSchema`、`AIKnowledgeSchema`、`ToolCategorySchema`)。没有
Expand All @@ -254,7 +254,7 @@ conversion 是消费者跟的;D3 条目是升级方的 agent 读的。三个都
要手改)。
- [ ] **生成 baseline** —— `pnpm --filter @objectstack/spec gen:schema` 会动
`authorable-surface/<category>.json`(墓碑 → 一条新的 `… [RETIRED]` 行;
strict 删除 → 该行**消失**,这是门 (a) 的绊线,所以同一个 PR 里有意删掉它)与
无墓碑删键 → 该行**消失**,这是门 (a) 的绊线,所以同一个 PR 里有意删掉它)与
`json-schema.manifest/<category>.json`。#5837 起两者都按 category 分片 —— 门
禁把整个目录读成一个集合,退役流程不变;变的只是那一行住在哪个文件。然后
`gen:spec-changes`、`gen:upgrade-guide`、`gen:api-surface`、`gen:docs`。
Expand Down
11 changes: 6 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1077,11 +1077,12 @@ Both non-handshake shapes, and how to classify and probe your own:
spec key, an export, a config field), the changeset body must state the FROM → TO mapping and the one-line fix —
this text ships to consumers as `CHANGELOG.md` inside the npm package and is what an upgrading agent greps after the
tombstone error. Removing an authorable spec key also requires a tombstone so the rejection itself carries the
prescription — `retiredKey()` (`packages/spec/src/shared/retired-key.ts`) on a non-strict schema, or an entry in
the relevant `UNKNOWN_KEY_GUIDANCE` / `*_RETIRED_KEY_GUIDANCE` map (see `object.zod.ts`, `ai/tool.zod.ts`) when the
schema is `.strict()`. The changeset is one of fourteen surfaces a retirement touches — follow the
`spec-property-retirement` skill (`.claude/skills/`) rather than reconstructing the kit, and note the two routes
imply **opposite** liveness-ledger dispositions.
prescription — `retiredKey()` (`packages/spec/src/shared/retired-key.ts`) on the schema whether or not it is
`.strict()`, and an entry in the shape's `*_RETIRED_KEY_GUIDANCE` map (see `data/mapping.zod.ts`) only for a
spelling the shape never declared, such as the retired key's old alias, where a tombstone has no property to
replace. The changeset is one of fourteen surfaces a retirement touches — follow the
`spec-property-retirement` skill (`.claude/skills/`) rather than reconstructing the kit, and note that a tombstone
keeps its liveness-ledger row while a key deleted without one loses it.
**A breaking changeset must also state its ADR-0087 disposition, in writing** — exactly one marker in the changeset
body, which also carries the PR's `Clause-②` line: `pnpm check:adr-0087-registration` reads the arm there. ⛔ The
categories are NOT copied here — the gate prints the full set when it fails.
Expand Down
23 changes: 20 additions & 3 deletions docs/adr/0087-metadata-protocol-upgrade-contract.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ADR-0087: Metadata protocol upgrades for AI consumers — conversion over notification, executable migrations, machine-verifiable upgrades

**Status**: Accepted (2026-07-04, #2582) · trued up to as-built 2026-07-15 (see Addendum)
**Status**: Accepted (2026-07-04, #2582) · trued up to as-built 2026-07-15 (see Addendum) · **Amended** (2026-09-30, #20390 ruling A — the artifact-ingestion window decides per entry by each retired conversion's `retiredAfter`; see the amendment note under the 2026-09-13 addendum's window bullet)
**Deciders**: ObjectStack Protocol Architects
**Builds on**: [ADR-0059](./0059-third-party-backward-compatibility-gates.md) (layered backward-compat gates — this ADR is its consumer-facing sequel), [ADR-0078](./0078-no-silently-inert-metadata.md) (no declarable-but-unenforced metadata — the un-checked `engines.protocol` is exactly this class), [ADR-0025](./0025-plugin-package-distribution.md) (§3.2 `engines.protocol` / `engines.platform` compatibility ranges, §3.10 #3 protocol-first check order), [ADR-0033](./0033-ai-assisted-metadata-authoring.md) (the authoring population this ADR designs for), [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove), [ADR-0054](./0054-runtime-proof-for-authorable-surface.md) (prove-it-runs), AGENTS.md Prime Directive #12 (contract-first, no consumer-side dialect fallbacks — §"Why the conversion layer does not violate PD #12" draws the line)
**Consumers**: `@objectstack/spec` (protocol version constant, conversion layer, deprecation/change registries), `@objectstack/cli` (`validate`, `doctor`, `migrate meta`), the runtime metadata loader (handshake + conversion), `@objectstack/mcp` (the AI-native change/migration surface), `@objectstack/create-objectstack`, the Release workflow, and every third-party consumer — whose maintainer is assumed to be an **AI agent**
Expand Down Expand Up @@ -903,14 +903,31 @@ unconditional strip would start deleting legal metadata the day the keys return.
manifest declares (`engines.protocol`, ADR-0025) and the `@objectstack/spec`
version the process actually runs. `floor < runtime` replays the FULL chain,
retired entries included, before the strict parse — the artifact is the
"consumer arriving late" D3 keeps every conversion forever for. `floor >=
"consumer arriving late" D3 keeps every conversion forever for. ~~`floor >=
runtime` replays nothing: the artifact claims the current or a newer surface,
and the strict parse, tombstones included, stays the authority. That branch is
and the strict parse, tombstones included, stays the authority.~~ — **amended
2026-09-30** — `floor >= runtime` replays only the retired entries whose
`retiredAfter` the floor does not exceed (verdict `'converted-retired-after'`),
and nothing when no entry is that recent; every other entry meets the strict
parse, tombstones included, as its authority. That branch is
what makes the window *versioned rather than a blanket amnesty*, and it is the
branch the M2 return needs. No declared range replays (an artifact of unknown
age is old data at rest, and conversions only rewrite shapes they positively
recognize); an unresolvable runtime version replays nothing, because amnesty
rests on positive version evidence.

> **Amended (2026-09-30) — the window decides per entry.** Provenance: the
> #20390 ruling, comment `5865890672` (director batch #235 item 1, letter A;
> maintainer 「同意 A」), landed as `e956924e` (PR #20435). Every retired
> conversion carries a required `retiredAfter`, the last published
> `@objectstack/spec` whose authoring surface still accepted the old shape,
> and the door replays an entry E when `floor < runtime` OR
> `floor <= E.retiredAfter`. Why: `main` refuses keys the next release retires
> while it still carries the last release's label, so the label-only
> comparison read an artifact built by that last release as current and
> refused it outright. The module docblock of
> `packages/metadata-core/src/artifact-forward-conversion.ts` states the rule
> and is its authority.
- **⚠️ The shipped key is the DECLARED FLOOR, not the authored version.** The
ruling says "authored `specVersion`"; what an artifact manifest actually
carries is a protocol *range*, so the implementation keys off that range's
Expand Down
Loading