Skip to content

Commit da9c2bf

Browse files
feat(adr-0087): the seventh disposition category, runtime-export-retired — retired exports and members, proven by the compiler at both revs (#22940)
Fixes #22918 Clause-②: no ## What this does ADR-0087's closed disposition vocabulary gains a **seventh category, `runtime-export-retired`**, for a declared-breaking changeset that retires published runtime exports (and members of an exported type) and carries the FROM → TO line each retired name owes. Built on the maintainer's ruling `6116982351` on #22918 (Q1 → B narrowed · Q2 → A) and the unrevised clauses of ruling A `6116351873` on #22915. The marker, written here without its comment delimiters (this body cannot carry them): `adr-0087: not-required (runtime-export-retired PACKAGE#EXPORT, PACKAGE#TYPE.MEMBER) WHY`. The two operand kinds mix in one marker; the package is named, never a file. Three predicates, re-verified on every run and pinned by name (`RUNTIME_EXPORT_PREDICATES`): 1. **`retired-from-entry`** — an export is reachable from the package's published entry at the merge base and absent at HEAD; for a member, its type is reachable at the base, the member is present on it there (`getPropertiesOfType`) and absent at HEAD while the type is still exported. Read by the TypeScript compiler: a Program over the package's **source** entry at each rev, served from that rev's blobs, and `getExportsOfModule` on the entry's symbol (so `export *` resolves). The entry is mapped through the `exports` map (`resolveTypesEntry`, imported read-only from the readme-exports gate) and tsup's `dist/index.d.ts` → `src/index.ts`; anything else is **refused loudly, never guessed**. An `export *` the reader cannot follow refuses an absence claim at HEAD. 2. **`no-metadata-surface`** — at the merge base, no operand is declared in a `*.zod.ts`, under `packages/spec/src/contracts/` or in an object definition, none is a Zod projection, and no metadata surface references it in code (the `runtime-interface-only` primitives; its step-4 reference scan is extracted unchanged as `metadataSurfaceReference` and now shared). 3. **`from-operands-named`** — every FROM operand is a named operand spelled as in the marker (`TYPE.MEMBER` for a member), and every named operand has a FROM side. A call `name(...)` / `new Name(...)` reads as that name; no constructor argument or object literal is parsed, so `new EmailServicePlugin({ seedTemplates: false })` names `EmailServicePlugin` and is refused as out of range (the ruling's own example). The category is **not** exempt from the `no-migration-prescription` refusal: it sets aside exactly the FROM lines it read and runs the same detector over what is left. `typescript` is loaded **lazily** (`loadExportReader`, through `import-prerequisite.mjs`), only when a changeset names the category: the entry point scans, and if a scan reports `needsExportReader` it loads the compiler and scans again. The header line "Zero third-party IMPORTS" is retracted, as ruled. Files: `scripts/check-adr-0087-registration.mjs` and `docs/adr/0087-metadata-protocol-upgrade-contract.md` (the vocabulary block, and a new addendum beside the sixth category: D9, both operand kinds, the predicates, the mapping refusal, the accepted deviation, the worked example, what was not taken). ## Two readings taken where the ruling's words leave a choice (please confirm on review) - **"exactly the named operands" is read as set equality, both directions.** A FROM operand outside the named set is refused (the ruled negative case), and so is a named operand with no FROM side (self-test `REX-R7`). This also mechanises the house rule that a removed export's changeset carries its FROM → TO line. If only the first direction was meant, the second check is one `for` loop to delete. - **tsup's `.d.mts` / `.d.cts` twin maps like `dist/index.d.ts`.** Re-measured at `018b077fb`: `resolveTypesEntry` lands `@objectstack/spec`'s `.` on `dist/index.d.mts` (its `import` condition). Mapping only the literal `.d.ts` makes 5 of 70 published packages unmappable; with the twin it is the 4 the ruling cites (`@objectstack/client-react`: no `src/index.ts`; `@objectstack/console`, `@objectstack/skills`, `create-objectstack`: no types entry). ## Evidence (all at HEAD `f06cee413`, which includes `origin/main` `e4ad6776d`) - **The worked example, end to end through the shipping CLI** — two ref-less probe commits on `7de0265fc`, its changeset re-marked under the new category, judged with `--base 9e9e6f9`: - lines 25–28 only (the nine exports): **exit 0**, `verified: @objectstack/plugin-email#createSysEmailTemplateLoader (export, packages/plugins/plugin-email/src/template-loader.ts), …` (the entry point took the load-and-rescan path); - lines 25–30 (the full block): **exit 1**, `the FROM operand new EmailServicePlugin({ seedTemplates: false }) names EmailServicePlugin, which the marker does not name -- out of range`. - **Self-test**: `node scripts/check-adr-0087-registration.mjs --self-test` → `548 assertions` (463 before; +3 from the vocabulary growing, +82 new in four batteries: the category, `REX-N` predicates by name, `REX-U` units, `REX-L` lazy load). The ruled cases: accept the export half (`REX-G1`); refuse a still-present export (`REX-R1`), a `*.zod.ts` symbol (`REX-R2`), a FROM operand outside the named set (`REX-R3a`, `REX-R3b` = lines 29–30 verbatim), a non-canonical member spelling (`REX-R4a`, `REX-R4b`), an unmappable package (`REX-R5a`, `REX-R5b`); the six existing categories' batteries all green, and `REX-C` pins that the worked example's own `no-migration-prescription` verdict is unchanged. - **Ablation** (committed first, mutated through `scripts/ablation-replace.mjs`, trap-guarded): predicate 1's still-present check off → `REX-R1` red; predicate 3's out-of-range check off → `REX-R3a`, `REX-R3b`, `REX-R4a` red; the inherited-detector residual off → `REX-R8a` red. Each restore proven: blob `322b6f872` = HEAD and an empty `git diff HEAD`. - **Six existing categories unchanged on stock**: `--list` before (at `018b077fb`) and after is byte-identical (md5 `862b7498…`): 131 declared-breaking, 131 with a disposition, 83 on `no-migration-prescription`. `--base origin/main` exit 0 before and after. - **Cost on every other PR**: a bare import of the gate loads no `typescript` (`REX-L1`, positive control `REX-L2`). Interleaved wall time on an empty diff, 6 runs each on a shared box: median 3848 ms (base gate) vs 3795 ms (this gate). - **Gates**: `node scripts/pm/dispatch-gates.mjs --ran` → `43 derived famil(ies) accounted for — 43 run, 0 NOT-MEASURED (a DERIVED zero — all 43 recorded an exit code and none of them is 3)`. `check:doc-formula-expressions` first answered exit 3 (PREREQUISITE NOT MET: `@objectstack/formula` / `@objectstack/lint` unbuilt) and was re-run green after building them. `check:pm-dispatch-gates`: `1828 cases pass` (fast tier). - **Lint, narrowed**: the diff's only lintable file is the gate (the ADR is `.md`, matched by no config object's `files`); `eslint --no-inline-config --format json` reports 1 file, 0 errors, 0 warnings. The config resolved for it has no `parserOptions.project` / `projectService`, so type-aware linting is off and this diff cannot move any untouched file's verdict. - Size: +1377 / −56 over 2 files (precedent PR #13596 +956 / −10; the director estimated about 1.3× of ruling A's size). ## Acceptance notes - The gate header's "What keeps `not-required` honest" section still says "four of the five dispositions" — stale since the sixth category, not touched here (prose drift, nothing reads it). - The first dev's noted self-description gap is closed in this diff: the detector's residual-blind-spot paragraph now names the arrowless `FROM … TO` line (6 of 417 stock changesets, 4 declared-breaking, none read by the detector), stating it stays out of the detector's reach and is read by this category. - The worked example (`.changeset/15205-email-templates-resolve-registry.md`) is not re-graded, as ruled. ## 维护者速读(草稿) **改了什么**:ADR-0087 的「破坏性变更怎么处置迁移账本」词表新增第七类 `runtime-export-retired`。一个 changeset 删掉了已发布包的公开函数、常量、类型,或某个导出类型上的配置项时,作者在标记里点名这些名字;门禁用 TypeScript 编译器在合并基点和当前版本各读一次这个包的公开入口,核对「以前确实公开导出、现在确实没了」,确认它们都不是元数据,并确认 changeset 里的 FROM → TO 行恰好只写这些被点名的名字。只改了门禁脚本和 ADR 两个文件。 **为什么改**:这类变更以前只能挂在兜底类 `no-migration-prescription` 上,而且是靠检测器读不到「没有箭头的 FROM … TO 行」才过关(样例 15205),正是 D7 要淘汰的「靠漏检过关」。你 10-12 的裁决(Q1 B 收窄、Q2 A)要求给它一个能复跑、能正面证明的类别,并覆盖导出类型上的成员。 **风险与代价(含回滚)**:门禁多了一个 `typescript` 依赖,但只在 changeset 点名这一类时才加载,其他 PR 的门禁耗时不变(实测中位数 3.85 秒对 3.80 秒)。不碰任何运行时代码,也不重评已有 changeset:存量 131 份破坏性 changeset 的审计输出前后逐字节相同。回滚就是 revert 本 PR。有两处按字面读的地方请你确认:「恰好」按双向执行(被点名的每个名字都必须有自己的 FROM 行);tsup 的 `.d.mts` 入口也算可映射,否则 `@objectstack/spec` 会被拒,与裁决引用的「70 个里 4 个映射不上」对不上。 **席位意见**: **你要做的**:读 ADR 新附录 D9 和上面两处读法;同意就在本 PR 上给 APPROVED review,席位随后落地。 --- _Generated by [Claude Code](https://claude.ai/code/session_012yK1ddAdAqfzwigRHZq3r2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 51b43a8 commit da9c2bf

2 files changed

Lines changed: 1377 additions & 56 deletions

File tree

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

Lines changed: 142 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -730,6 +730,8 @@ gate's log and its `--list` output.
730730
<!-- adr-0087: not-required (runtime-interface-only <path>#<Symbol>[, ...]) <why> -->
731731
<!-- adr-0087: not-required (type-surface-only <path>#<Symbol>[, ...]) <why> -->
732732
<!-- adr-0087: not-required (type-surface-only <path>#<a>.<b>.<member>[, ...]) <why> -->
733+
<!-- adr-0087: not-required (runtime-export-retired <package>#<Export>[, ...]) <why> -->
734+
<!-- adr-0087: not-required (runtime-export-retired <package>#<Type>.<member>[, ...]) <why> -->
733735
```
734736

735737
**The vocabulary is closed, and every exemption is re-verified on every run** — an
@@ -739,8 +741,9 @@ avoid. `registered` must name ids that resolve *and* are new in the diff;
739741
`already-registered` requires the named ids to pre-date the merge base;
740742
`no-migration-prescription` is refused by a body that carries a migration
741743
prescription; `runtime-interface-only` is the subject of the rest of this
742-
addendum; and `type-surface-only` — the sixth, added 2026-08-30 — is the subject of
743-
the addendum after it. The checks live in
744+
addendum; `type-surface-only` — the sixth, added 2026-08-30 — is the subject of
745+
the addendum after it; and `runtime-export-retired` — the seventh, added 2026-10-12 —
746+
is the subject of the addendum after that. The checks live in
744747
[`scripts/check-adr-0087-registration.mjs`](../../scripts/check-adr-0087-registration.mjs),
745748
which is also where each one's measured history is written down.
746749

@@ -970,6 +973,143 @@ lands, per the ruling, and one of them (`declare-search-clone-response-contracts
970973
fails predicates 2 and 3 outright — it bumps `@objectstack/spec` and adds
971974
`packages/spec/src/api/protocol.zod.ts`, so it is not a member of this class.
972975

976+
## Addendum (2026-10-12) — the seventh category: a published runtime EXPORT retirement (#22918)
977+
978+
The vocabulary above gained a seventh answer, with two operand kinds that may be
979+
mixed in one marker:
980+
981+
```text
982+
<!-- adr-0087: not-required (runtime-export-retired <package>#<Export>[, ...]) <why> -->
983+
<!-- adr-0087: not-required (runtime-export-retired <package>#<Type>.<member>[, ...]) <why> -->
984+
```
985+
986+
### The dead end it closes
987+
988+
The worked example is `.changeset/15205-email-templates-resolve-registry.md` (PR
989+
#22875, merged as `7de0265fc7`). It retires nine exports of
990+
`@objectstack/plugin-email` and two members of its exported
991+
`EmailServicePluginOptions`, and gives each retired name the one-line FROM → TO fix a
992+
removal owes, written as six arrowless `- FROM … TO …` list lines. Every disposition
993+
was closed to it on the facts but one:
994+
995+
| disposition | outcome |
996+
|---|---|
997+
| `registered` / `already-registered` | closed on the facts — no ledger id covers these paths and the diff adds none, and `objectstack migrate meta` has nothing to rewrite in host code. |
998+
| `not-required (unpublished)` | refused — every bumped package publishes. |
999+
| `not-required (runtime-interface-only …)` | refused — it reads an exported type declaration at HEAD, and a retired export has none. |
1000+
| `not-required (type-surface-only …)` | refused — nothing narrowed off `any`. |
1001+
| `not-required (no-migration-prescription)` | admitted — but only because the detector reads an arrow or a framed table, and these lines carry neither. |
1002+
1003+
So the exemption was held by a detector **miss**, the shape D7 retires, and the
1004+
changeset's own marker asked for a ruling. The class recurs with ADR-0131's v18
1005+
retirements. Measured on the stock at `018b077fb`: 131 declared-breaking changesets,
1006+
83 of them on `no-migration-prescription`; arrowless `FROM … TO` lines naming code
1007+
appear in 6 changesets, 4 declared-breaking, and the detector reads none of them —
1008+
15205 is the one that holds the catch-all, the other three are `registered`.
1009+
1010+
### D9 — a retired published runtime export is compiler-carried, and the gate proves the retirement with the compiler
1011+
1012+
The channel argument is D7's and D8's: the affected party is a TypeScript consumer,
1013+
the compiler reports the break at their own import (`TS2305 … has no exported
1014+
member`) or at the object literal that still passes a removed option, and the ledger
1015+
has no metadata to rewrite.
1016+
1017+
**Operands.** `<package>#<Export>` names a retired export of the package's published
1018+
entry; `<package>#<Type>.<member>` names a retired member of an exported type — the
1019+
dotted spelling `type-surface-only` already uses. The **package** is named, never a
1020+
file: "reachable from the published entry" is a fact about the package, and a
1021+
declaration can move between files without the export moving. ⛔ A member is addressed
1022+
by that **canonical spelling only**. The gate parses no constructor argument and no
1023+
object literal: a FROM side written `new EmailServicePlugin({ seedTemplates: false })`
1024+
names `EmailServicePlugin`, never `EmailServicePluginOptions.seedTemplates`.
1025+
1026+
An author may claim the category when, for every operand, all three of these hold —
1027+
re-verified on every run and checked by name (`retired-from-entry`,
1028+
`no-metadata-surface`, `from-operands-named`):
1029+
1030+
1. **retired-from-entry** — an export is reachable from the package's published entry
1031+
at the merge base and absent at HEAD; for a member, its type is reachable at the
1032+
base, the member is present on it there (`getPropertiesOfType`), and it is absent
1033+
at HEAD while the type is still exported. The reading is the **TypeScript
1034+
compiler's**: a Program over the package's source entry at each rev, and
1035+
`getExportsOfModule` on the entry's symbol, so `export *` resolves. The entry is
1036+
located through the package's `exports` map (read with `resolveTypesEntry`, the
1037+
readme-exports gate's reader, imported rather than re-written) and tsup's
1038+
convention: the declaration entry `dist/index.d.ts` — or the `.d.mts` / `.d.cts`
1039+
twin tsup writes beside it, which is where `@objectstack/spec`'s `import`
1040+
condition lands — is generated from `src/index.ts`. A private package has no
1041+
published entry (`unpublished` is its disposition).
1042+
2. **no-metadata-surface** — at the merge base, no operand is declared in a
1043+
`*.zod.ts`, under `packages/spec/src/contracts/` or in an object definition, none
1044+
is a Zod projection, and no metadata surface references it in code: the D7
1045+
primitives, read at the **base** because the operand is gone at HEAD.
1046+
3. **from-operands-named** — the FROM → TO block names **exactly** the named
1047+
operands, each spelled as in the marker (`Type.member` for a member): every FROM
1048+
operand is a named one, and every named one has a FROM side.
1049+
1050+
⛔ **A published package whose entry cannot be mapped is refused, loudly and by name —
1051+
never guessed.** Measured at `018b077fb`, 4 of 70 published packages:
1052+
`@objectstack/client-react` (no `src/index.ts`), and `@objectstack/console`,
1053+
`@objectstack/skills` and `create-objectstack` (no types entry). Absence is proven,
1054+
not assumed, the same way: an `export *` the reader cannot follow refuses an absence
1055+
claim at HEAD (a name could sit behind it), and so does a type whose member set at
1056+
HEAD depends on something the reader cannot resolve. A re-export of another workspace
1057+
package's root entry is followed through the same mapping.
1058+
1059+
**The deviation accepted with the ruling.** A merge base has no build output (`dist/`
1060+
is gitignored), so predicate 1 compiles the **source** entry that generates the
1061+
public `.d.ts`, not the `.d.ts` itself. For the question "which names does the
1062+
package export" the two agree.
1063+
1064+
**It inherits the `no-migration-prescription` refusal — more finely, never less.**
1065+
Unlike `type-surface-only` it is not exempt. Predicate 3 reads the FROM side of every
1066+
rewrite the body writes — a `FROM … TO …` line, an arrow `X → Y` (or `X → delete`),
1067+
the FROM column of a `| FROM | TO |` or `| Wrote | Write instead |` table — then sets
1068+
exactly those lines aside and runs the same prescription detector over what is left.
1069+
Anything it still finds is refused: a rewrite table with no FROM column, a vertical
1070+
FROM / TO pair, a FROM → TO label over anything not read. Nothing the detector refuses
1071+
today becomes claimable unless every operand it rests on was proven retired by
1072+
predicates 1 and 2 — and the arrowless block the detector cannot see is read here
1073+
instead of waved through.
1074+
1075+
### The worked example under this category
1076+
1077+
Its lines 25–28 name the nine retired exports, and the gate accepts that half under
1078+
this marker (the self-test carries their text). Its lines 29–30 write the two retired
1079+
options as `new EmailServicePlugin({ … })`, which names the still-exported
1080+
`EmailServicePlugin`: under this marker they are refused as out of range. Those two
1081+
lines are the reason the member limb exists — without it a mixed retirement keeps
1082+
half its claim on a detector miss — and the reason the member is spelled canonically
1083+
(`EmailServicePluginOptions.seedTemplates`). The changeset itself is merged and is
1084+
**not re-graded**: it stays on `no-migration-prescription`. New retirements use this
1085+
category.
1086+
1087+
### Not taken
1088+
1089+
- **Exports only** — a mixed retirement would keep its member half on the catch-all,
1090+
held by a detector miss: the shape D7 retires.
1091+
- **Members resolved from constructor arguments** (`new X({ option })` read back to
1092+
the options type) — the fragile half, and unneeded once the spelling is canonical.
1093+
- **The existing detector alone** — it reads no arrowless line, so the class would be
1094+
admitted by the same miss.
1095+
- **A one-hop text reader of the entry that refuses `export *`** — a second export
1096+
resolver beside the compiler, and its blind spot refuses honest claims.
1097+
- **Per-package export snapshots** — a new family of generated artifacts with its own
1098+
gate; a new gate defaults to no.
1099+
1100+
### What this does not decide
1101+
1102+
**The completeness of the claim.** Like `registered`, `runtime-interface-only` and
1103+
`type-surface-only`, the gate judges the operands named, not every export the diff
1104+
removed.
1105+
1106+
**A subpath entry or a static member.** Only the root entry is read, and a member is
1107+
read on a class's instance type; anything else is refused rather than assumed.
1108+
1109+
**The detector.** `no-migration-prescription`'s refusal and the detector's arrow rule
1110+
are unchanged; the arrowless line stays outside the detector's reach, stated as such
1111+
in the gate's residual-blind-spot paragraph, and is read only by this category.
1112+
9731113
## Addendum (2026-09-13) — the artifact-ingestion door opens a versioned window (#12772)
9741114

9751115
The addenda above reach authored source and metadata at rest in `sys_metadata`.

0 commit comments

Comments
 (0)