Skip to content

Commit d0a7e74

Browse files
docs(adr-0087): amend D8 predicate 4 and D9 predicate 1 for ui packages (#26455)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9Ex6pB8edHJLnCCc1tgdXz5)_ Part of #26220 (PR 1 of 2 for parts 3 and 4: the ADR wording. PR 2, the gate code, follows once this lands). Clause-②: no Metadata-protocol: none **Governed (Tier H): `docs/adr/**`.** This PR stays a draft and is not queued or armed until a GOVERNED_APPROVERS account approves it. ## 维护者速读(草稿) **改了什么:** 只改了 ADR-0087 一个文件,加了两条修订说明,状态行也记了一笔。 - D8 谓词 4 加一个窄分支:接口除了 `any` 索引签名变成非 `any`(`unknown` 也算)以外,继承和所有成员逐字不变,就算收窄。 - D9 谓词 1 的入口映射:除了 `src/index.ts`,也认 `src/index.tsx`。两个都有就判为歧义拒绝,都没有照旧拒绝。 **为什么改:** 有两个 ui PR 走真实处置被门禁拒。#26293 把 `[key: string]: any` 改成 `unknown`,原文只认“整体是 any”。#26365(plugin-ai 退役)卡在入口映射上:38 个发布的 ui 包里有 21 个入口是 `src/index.tsx`。两处都只影响 TS 消费者,账本不是正确渠道。这是你在决策卡上选的方案 A。 **风险与代价(含回滚):** 谓词 4 是 `type-surface-only` 豁免的唯一保险。新分支要求除索引签名外逐字相同,所以 #6048(删成员)的形状照样被拒,自测里的回归钉继续断言这一点。入口映射只按源文件名判断,不读构建配置。本 PR 只改文字;门禁代码在 PR 2,带电池行和消融。回滚:revert 本 PR;PR 2 落地后要一起 revert。 **席位意见:** 同意合入(ui 席 1)。两条修订都只放宽到实测挡住的那一类,没有顺手扩大。D8 的新分支只放行「声明成员完全不变、只有 `any` 索引签名收窄」这一种情况;#6048 那种丢成员的情况照旧被拒,回归钉还在。D9 只多认 `src/index.tsx` 一个入口,两个入口同时存在时按有歧义拒绝。被挡的是 #26293(#25918)和 #26365(#25921 plugin-ai 下线);PR 2 把检查脚本和测试补上后,会拿这两个 PR 证明 exit 0。 **你要做的:** 读两条修订说明(D8 谓词 4 下、D9 不可映射段落下),同意就批准本 PR。批准后由席位入队,然后开 PR 2。 ## What changes `docs/adr/0087-metadata-protocol-upgrade-contract.md` only (+63 / −1): - **Status line:** one `**Amended** (2026-10-27, #26220 …)` entry pointing at the two notes. - **D8, under predicate 4: amendment note "predicate 4 reads an `any` INDEX SIGNATURE".** For a bare reference naming an exported `interface`, or a `type` alias whose right-hand side is an object type literal, predicate 4 also holds when (1) at base the member surface has at least one index signature typed `any`, (2) at HEAD at least one of them carries a non-`any` value type, with `unknown` counted as narrowed there only, and (3) heritage, type parameters and every member are otherwise identical, compared member by member on the masked, normalised surface. The note states why this is not a weakening: the #6048 shape (a member removed) fails condition 3 and keeps the founding-case refusal, and dotted member paths are untouched. - **D9, under the unmappable-entry paragraph: amendment note "the source entry may be `src/index.tsx`".** The declaration entry (`dist/index.d.ts` or its twins) maps to the package's one source entry, `src/index.ts` or `src/index.tsx`. Both present is refused as ambiguous, neither present is refused as before, and the build tool is never consulted. ## Measured (main `83e9d718`) - 38 published `ui/` packages (46 manifests, 8 private). 21 have `src/index.tsx`, and all 21 name `./dist/index.d.ts` as their types entry; their builds are vite-plugin-dts with `insertTypesEntry: true`. - No workspace package has both `src/index.ts` and `src/index.tsx`. - `@objectstack/client-react` (one of D9's four measured unmappables) builds with `tsup src/index.tsx`, the same shape. - #26293's operand at its merge base: `{ schema: TSchema; [key: string]: any; }` → HEAD `{ schema: TSchema; [key: string]: unknown; }` (gate output at `e349c832`, predicate 4 refusal). ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 24 commands at `2494b54b` (base `83e9d718`). All 24 exit 0, including `node scripts/check-adr-0087-registration.mjs --base origin/main` and `--self-test`, `node scripts/check-adr-links.mjs`, `node scripts/check-adr-symbol-anchors.mjs`, `pnpm check:adr-anchors`, `pnpm check:doc-authoring` and `pnpm check:pm-governed-merges`. `pnpm --filter @objectstack/lint run check:doc-formula-expressions` first exited 3 (prerequisite: `@objectstack/formula` and `@objectstack/lint` dist not built); after `pnpm exec turbo run build --filter=@objectstack/formula --filter=@objectstack/lint` it exits 0. `--ran`: 24 derived, 24 run, 0 NOT-MEASURED, 0 UNRUN. No package or script is touched, so there is no build closure and no changeset (`skip-changeset`). ## Acceptance notes - PR 2 (after this lands) carries the code in `scripts/check-adr-0087-registration.mjs`: the predicate-4 limb with red/green battery rows (#26293's shape green; a member removed beside the index signature, the #6048 pin, a non-`any` base index signature, and a dotted path all red), and the `src/index.tsx` mapping (green on a vite-plugin-dts-shaped fixture; both entries present red as ambiguous), each with an ablation. Its proof: `--base origin/main` exits 0 on #26293 and #26365. - #26324 (predicate 2 cannot certify an operand named `default`) is a separate card and is not addressed here. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Am8eYNReiNP66D2JihPS1q Co-authored-by: Claude <noreply@anthropic.com>
1 parent eeaa827 commit d0a7e74

1 file changed

Lines changed: 63 additions & 1 deletion

File tree

‎docs/adr/0087-metadata-protocol-upgrade-contract.md‎

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ADR-0087: Metadata protocol upgrades for AI consumers — conversion over notification, executable migrations, machine-verifiable upgrades
22

3-
**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) · **Amended** (2026-10-09, #22449 ruling B′ — the two D4 projections, the per-major section of `spec-changes.json` and the protocol upgrade guide, are generated at publish rather than committed; see the amendment note under D4, which says what is generated where and where each part landed, and the struck clauses in the P2 phase-status bullet) · **Amended** (2026-10-13, #23038 — the artifact-ingestion window's third disjunct, landed as `58f5c90999`: a floor that is the first release of a major replays that major's conversion step, verdict `'converted-major-step'`; see the 2026-10-13 amendment note under the 2026-09-13 addendum's window bullet)
3+
**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) · **Amended** (2026-10-09, #22449 ruling B′ — the two D4 projections, the per-major section of `spec-changes.json` and the protocol upgrade guide, are generated at publish rather than committed; see the amendment note under D4, which says what is generated where and where each part landed, and the struck clauses in the P2 phase-status bullet) · **Amended** (2026-10-13, #23038 — the artifact-ingestion window's third disjunct, landed as `58f5c90999`: a floor that is the first release of a major replays that major's conversion step, verdict `'converted-major-step'`; see the 2026-10-13 amendment note under the 2026-09-13 addendum's window bullet) · **Amended** (2026-10-27, #26220, the maintainer's decision A: D8's predicate 4 gains an index-signature limb, and D9's predicate 1 maps a `src/index.tsx` source entry; see the 2026-10-27 amendment notes under D8's predicate 4 and under D9's unmappable-entry paragraph)
44
**Deciders**: ObjectStack Protocol Architects
55
**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)
66
**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**
@@ -927,6 +927,45 @@ checks all four by name (`published`, `no-spec-diff`, `no-metadata-surface-diff`
927927
never guessed at: a reference that silently landed on the wrong same-named member
928928
would be writable but wrong, which is worse than the refusal it replaces.
929929

930+
> **Amended 2026-10-27 (#26220) — predicate 4 reads an `any` INDEX SIGNATURE.**
931+
> Provenance: the maintainer's decision A on the combined D8 + D9 question of
932+
> #26220, recorded on the card as claim amendment `6433464805` (2026-10-27T04:03Z).
933+
> The class it admits: a published interface whose declared members stay as they
934+
> were while its index signature moves off `any`. Measured on #26293:
935+
> `ui/packages/types/src/base.ts#ComponentRendererProps` is
936+
> `{ schema: TSchema; [key: string]: any; }` at the merge base and
937+
> `{ schema: TSchema; [key: string]: unknown; }` at HEAD. Every forwarded key was
938+
> readable as anything and now has to be narrowed first, so a consumer's code can stop
939+
> compiling, and nothing reaches `objectstack migrate meta`. Read as written above,
940+
> predicate 4 refuses it twice over: the interface is a named structural declaration,
941+
> concrete at base by construction, and `unknown` at HEAD counts as erased.
942+
>
943+
> For a bare `<path>#<Symbol>` naming an exported `interface`, or an exported `type`
944+
> alias whose right-hand side is an object type literal, predicate 4 **also** holds
945+
> when all three of these hold:
946+
>
947+
> 1. at the merge base the declared member surface carries at least one index
948+
> signature whose value type is `any`;
949+
> 2. at HEAD at least one of those index signatures carries a value type other than
950+
> `any`. Here `unknown` **counts as narrowed**: through an index signature `any`
951+
> admits every read and `unknown` admits none, which is exactly the break this
952+
> category exists for. The whole-symbol reading above is unchanged, and there
953+
> `unknown` stays erased;
954+
> 3. everything else is identical at both revs: the declaration's heritage (its
955+
> `extends` clause, its type parameters) and every member, compared member by
956+
> member on the comment-masked, whitespace-normalised surface, with only the value
957+
> types of the index signatures set aside. A member added, removed, renamed,
958+
> re-typed or made optional fails this limb.
959+
>
960+
> **Why this is not a weakening.** The #6048 shape, a concretely typed interface that
961+
> LOST a member, fails condition 3 and is refused by predicate 4 exactly as before,
962+
> with the same founding-case refusal. The regression pin below keeps asserting it.
963+
> The limb admits only a surface whose sole change is that an index signature moved
964+
> off `any`. That is the same base-side reading predicate 4 always made, applied to the
965+
> one member that can be erased while the declaration around it is concrete. A dotted
966+
> member path keeps reading a member's return annotation alone; the limb does not
967+
> apply to it.
968+
930969
### ⭐ Predicate 4 is what makes this a narrowing rather than a hole
931970

932971
This is the **only** category exempt from the `no-migration-prescription` refusal.
@@ -1062,6 +1101,29 @@ claim at HEAD (a name could sit behind it), and so does a type whose member set
10621101
HEAD depends on something the reader cannot resolve. A re-export of another workspace
10631102
package's root entry is followed through the same mapping.
10641103

1104+
> **Amended 2026-10-27 (#26220) — the source entry may be `src/index.tsx`.**
1105+
> Provenance: the maintainer's decision A on the combined D8 + D9 question of #26220,
1106+
> recorded on the card as claim amendment `6433464805` (2026-10-27T04:03Z). The mapping
1107+
> pinned above reads only `src/index.ts`. The `ui/` packages build their declaration
1108+
> entry with vite-plugin-dts (`insertTypesEntry`, `include: ['src']`) from
1109+
> `src/index.tsx`. Measured at `83e9d718`, 21 of the 38 published `ui/` packages have that
1110+
> entry, and all 21 name `./dist/index.d.ts` as their types entry, so a
1111+
> `runtime-export-retired` operand on any of them was refused at predicate 1 as
1112+
> unmappable. The plugin-ai retirement of #25921 (PR #26365) is the first such case.
1113+
> `@objectstack/client-react`, named above as unmappable, is the same shape under tsup
1114+
> (`tsup src/index.tsx`).
1115+
>
1116+
> The mapping is now: the declaration entry the `exports` map names (`dist/index.d.ts`, or
1117+
> its `.d.mts` / `.d.cts` twin) is generated from the package's **one** source entry,
1118+
> `src/index.ts` **or** `src/index.tsx`. A package carrying both is refused as ambiguous
1119+
> (none does at `83e9d718`), and a package carrying neither is refused as unmappable,
1120+
> loudly and by name as before. Nothing else about predicate 1 changes. The compiler
1121+
> reads the `.tsx` entry and every `.tsx` module behind it with JSX enabled. #26319 made
1122+
> the reader resolve `.tsx` modules for predicate 2, and an entry that is itself `.tsx` is
1123+
> read the same way. The build tool is never asked: the source file's name is the
1124+
> fact the gate reads, and a build that generates `dist/index.d.ts` from some other file
1125+
> stays unmappable.
1126+
10651127
**The deviation accepted with the ruling.** A merge base has no build output (`dist/`
10661128
is gitignored), so predicate 1 compiles the **source** entry that generates the
10671129
public `.d.ts`, not the `.d.ts` itself. For the question "which names does the

0 commit comments

Comments
 (0)