From 249b7836566687dda731160117402247cffcfca6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 06:53:50 +0000 Subject: [PATCH 1/2] docs(governed): retirement route is retiredKey() on any shape; retiredAfter stamp; ADR-0087 window per entry The retirement kit's route table and AGENTS.md Post-Task Checklist step 3 now prescribe a retiredKey() tombstone whether or not the schema is strict: on a closed shape a bare deletion is loud but reports only an unrecognized key, losing both the prescription and the tsc channel (retired-key.ts header). The strictObject guidance map is named only for a spelling the shape never declared, such as a retired key's old alias, where a tombstone has no property to replace (data/mapping.zod.ts). The retiredFromLoadPath checklist item gains the required retiredAfter stamp: tsc refuses its absence, a new retirement carries the current packages/spec label, and retired-after.census.test.ts pins each value. ADR-0087's artifact-window bullet said a floor at or above the runtime replays nothing; since e956924e the door replays every retired entry whose retiredAfter the floor does not exceed. Amended in the dated style. Lines paid by deleting narrative with another home: the orphan-leg history (orphans.mts header) and the withdrawn-example sentence. Claude-Session: https://claude.ai/code/session_01KTZmMfzVzjNvyaLyQ8mHvg Co-authored-by: Claude --- .../skills/spec-property-retirement/SKILL.md | 26 +++++++++---------- AGENTS.md | 11 ++++---- ...0087-metadata-protocol-upgrade-contract.md | 23 +++++++++++++--- 3 files changed, 38 insertions(+), 22 deletions(-) diff --git a/.claude/skills/spec-property-retirement/SKILL.md b/.claude/skills/spec-property-retirement/SKILL.md index 2985b9e1ea3..3b19a41a5c9 100644 --- a/.claude/skills/spec-property-retirement/SKILL.md +++ b/.claude/skills/spec-property-retirement/SKILL.md @@ -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 换了 @@ -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`): @@ -204,14 +201,15 @@ 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。⚠ 没有东西直接断言「每个 @@ -239,7 +237,7 @@ conversion 是消费者跟的;D3 条目是升级方的 agent 读的。三个都 从上往下做;每一行背后都有一个门。 -- [ ] **Schema** —— 墓碑或 strict 删除(§2),外加 schema 内注释:删了什么、真正生 +- [ ] **Schema** —— 墓碑或无墓碑删键(§2),外加 schema 内注释:删了什么、真正生 效的机制是什么。 - [ ] **孤儿值 schema** —— 一个键的 `XxxConfigSchema` 没有别的消费者就随它一起走 (`PerformanceConfigSchema`、`AIKnowledgeSchema`、`ToolCategorySchema`)。没有 @@ -254,7 +252,7 @@ conversion 是消费者跟的;D3 条目是升级方的 agent 读的。三个都 要手改)。 - [ ] **生成 baseline** —— `pnpm --filter @objectstack/spec gen:schema` 会动 `authorable-surface/.json`(墓碑 → 一条新的 `… [RETIRED]` 行; - strict 删除 → 该行**消失**,这是门 (a) 的绊线,所以同一个 PR 里有意删掉它)与 + 无墓碑删键 → 该行**消失**,这是门 (a) 的绊线,所以同一个 PR 里有意删掉它)与 `json-schema.manifest/.json`。#5837 起两者都按 category 分片 —— 门 禁把整个目录读成一个集合,退役流程不变;变的只是那一行住在哪个文件。然后 `gen:spec-changes`、`gen:upgrade-guide`、`gen:api-surface`、`gen:docs`。 diff --git a/AGENTS.md b/AGENTS.md index 4c52138f684..c70e4d09e43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/docs/adr/0087-metadata-protocol-upgrade-contract.md b/docs/adr/0087-metadata-protocol-upgrade-contract.md index 4cea680c28c..fe559ee418e 100644 --- a/docs/adr/0087-metadata-protocol-upgrade-contract.md +++ b/docs/adr/0087-metadata-protocol-upgrade-contract.md @@ -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** @@ -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 From 2e3f8aa08b57a0b2e7643e1d5bed66c44b62b89f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 06:54:44 +0000 Subject: [PATCH 2/2] docs(skills): the step-18 D3-chain item follows the keyed registry shape on main For protocol 18 the retirement kit no longer tells an author to append to MIGRATIONS_BY_MAJOR[18].conversionIds and extend a rationale string. On main the conversion goes only into MAJOR_18_CONVERSIONS in conversions/registry.ts, inserted at its identifier's sorted position per that list's header (CONVERSIONS_BY_MAJOR[18] and the step's conversionIds both derive from it), and the rationale gains one STEP18_RATIONALE fragment at its D3 semantic id's sorted position. The merge pins each header names refuse an append at the tail. Earlier steps keep the old wording, and the misspelled-id warning is scoped to them: it cannot happen on step 18, whose ids are derived. Claude-Session: https://claude.ai/code/session_01KTZmMfzVzjNvyaLyQ8mHvg Co-authored-by: Claude --- .claude/skills/spec-property-retirement/SKILL.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.claude/skills/spec-property-retirement/SKILL.md b/.claude/skills/spec-property-retirement/SKILL.md index 3b19a41a5c9..738187f1f74 100644 --- a/.claude/skills/spec-property-retirement/SKILL.md +++ b/.claude/skills/spec-property-retirement/SKILL.md @@ -210,10 +210,12 @@ conversion 是消费者跟的;D3 条目是升级方的 agent 读的。三个都 「没有 alias 窗口,故意的」;对**默认值翻转**,只有确知输入早于翻转的 seam 才可重 放,其余按 id 退订 `excludeConversionIds` —— `app-hidden-to-unpublished` 在 artifact 门即如此。 -- [ ] **一步 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,必须恰好等于